347 lines
19 KiB
Markdown
347 lines
19 KiB
Markdown
# agent-skills
|
|
|
|
Single source of truth for custom agent skills + commands. Shared across **Claude Code**, **Codex**, **Pi** and **opencode**, every machine.
|
|
|
|
## Layout
|
|
|
|
```
|
|
skills/ # SKILL.md dirs — all four tools read these (open Agent Skills standard)
|
|
commands/ # slash commands — Claude Code and opencode (Codex ignores)
|
|
hooks/ # Claude Code hooks — see hooks/README.md, wiring is manual
|
|
claude-md/ # shared instruction fragments — imported by entry files, concatenated for Pi/opencode
|
|
entry/ # entry files: ~/.claude/CLAUDE.md and ~/.codex/AGENTS.md
|
|
systemd/ # user timers: weekly review + hour log — one machine only, see below
|
|
bin/link.sh # bootstrap symlinks + generated AGENTS.md for non-Nix machines
|
|
bin/lint.sh # every check CI runs — see below
|
|
nix/home.nix # home-manager module for NixOS machines
|
|
flake.nix # exposes homeModules.default
|
|
```
|
|
|
|
Skills are portable: only `name`+`description` frontmatter is required by any of the tools; Claude-only fields (`user-invocable`, `args`) are ignored elsewhere. Claude Code reads them from `~/.claude/skills`, Codex and Pi from `~/.agents/skills`, and opencode auto-loads both — so the two links cover all four. Cross-skill refs use root-relative paths (`tracker-common/COMMON.md`), so they resolve under either root.
|
|
|
|
Context files differ: Claude Code and Codex support `@file` imports, so their entry files import the shared fragments by path. Pi and opencode do not, so each gets a single `AGENTS.md` generated by concatenating the same fragments — on NixOS the home-manager module builds it in the store, elsewhere `bin/link.sh` writes it (idempotent; set `MACHINE=name` to pick a `claude-md/machines/` profile, default is `default`).
|
|
|
|
## Install
|
|
|
|
### Non-Nix machine (e.g. dev VM)
|
|
|
|
```sh
|
|
git clone https://git.naps.pt/yolo/agent-skills.git ~/tea/yolo/agent-skills
|
|
~/tea/yolo/agent-skills/bin/link.sh
|
|
```
|
|
|
|
Symlinks each skill into `~/.claude/skills/` and `~/.agents/skills/`, commands into `~/.claude/commands/` and `~/.config/opencode/commands/`, hooks into `~/.claude/hooks/`, `claude-md/` fragments into `~/.claude/`, and generates `~/.pi/agent/AGENTS.md` and `~/.config/opencode/AGENTS.md` from the fragments. Idempotent; any pre-existing real dir (or non-generated AGENTS.md) is moved to `~/.agent-skills-backup/` (outside the discovery path, so it isn't picked up as a duplicate skill). Re-run after adding a skill.
|
|
|
|
Hooks still need one manual step: the `settings.json` snippet in `hooks/README.md`. Entry files are linked automatically — `entry/CLAUDE.md` and `entry/codex-AGENTS.md` hold the machine-local sections and `@import` the shared fragments, so both tools read the same rules with no copy and no drift.
|
|
|
|
### NixOS machine (home-manager)
|
|
|
|
```nix
|
|
# flake inputs
|
|
inputs.agent-skills.url = "git+https://git.naps.pt/yolo/agent-skills.git";
|
|
|
|
# home config imports
|
|
imports = [ inputs.agent-skills.homeModules.default ];
|
|
```
|
|
|
|
`recursive = true` links files individually, so machine-local skills can coexist in the same dir. `nixos-rebuild switch` to apply/update.
|
|
|
|
The module also carries the user units — `pr-daemon`, `hourlog`, `week-review` — so each lives next to the script it runs. All three are off by default, because every one of them starts an agent session and a second machine enabling them would run the same job twice:
|
|
|
|
```nix
|
|
programs.agentSkills = {
|
|
machine = "yolo";
|
|
prDaemon.enable = true;
|
|
hourlog.enable = true;
|
|
weekReview.enable = true;
|
|
};
|
|
```
|
|
|
|
`repoPath` (default `%h/tea/agent-skills`) is what the units execute from. Deliberately a checkout rather than a store path: the daemon and the scripts change far more often than the flake input is bumped, so a restart is enough to pick up an edit. The `systemd/` unit files stay for non-Nix machines, where `link.sh` installs them.
|
|
|
|
## Shared machine, many sessions
|
|
|
|
Several autonomous runs share one box. `skills/tracker-common/scripts/gate.sh` is a machine-wide semaphore for heavy commands (full test suites, whole-project builds): bounded slots, memory + CPU cap via a systemd user scope, pinned build/test parallelism. Skills run scoped checks in the inner loop and put only the once-per-push full suite through the gate; exit 75 means it never ran and CI takes over. Policy lives in `tracker-common/COMMON.md` under "Local verification budget".
|
|
|
|
```sh
|
|
~/.claude/skills/tracker-common/scripts/gate.sh --status
|
|
AGENT_GATE_SLOTS=3 AGENT_GATE_MEM_MAX=4G ~/.claude/skills/tracker-common/scripts/gate.sh -- cargo test
|
|
```
|
|
|
|
Sessions can also talk to each other: `aoe -p <profile> send <id> "<one line>"` types into another session's pane, which works the same for claude, pi, codex and opencode. `claude-md/intercomms.md` puts the capability in every session's context; the `intercomms` skill holds the protocol.
|
|
|
|
No registry, no announcements, no session list kept anywhere — `aoe list --json --all` is queried at the moment it is needed, which is also the only way it stays correct as sessions come and go.
|
|
|
|
## Weekly review timer
|
|
|
|
`systemd/week-review.timer` fires Fridays at 17:00 Europe/Lisbon (the zone is pinned in the unit because the machine clock is UTC). It runs `bin/week-review-session.sh`, which creates an Agent of Empires session in a fresh `week-review/<ISO week>` worktree, sends it `/week-review`, and pushes an ntfy notification to the `homelab` topic.
|
|
|
|
The session is interactive, not `claude -p`. `/week-review` asks which carry-over issues to take and which changes to apply, so an unattended run would either stall or decide on its own. It stops at the first question and waits for `aoe attach`.
|
|
|
|
If a previous `week-review-*` session is still open the run skips and says so, so weeks do not pile up. `Persistent=true` means a Friday spent with the machine off fires on the next boot instead of being lost.
|
|
|
|
`bin/link.sh` symlinks the units into `~/.config/systemd/user/` but does not enable them — enabling on every machine would spawn one session per box for the same week. On the one machine that should run it:
|
|
|
|
```sh
|
|
systemctl --user daemon-reload
|
|
systemctl --user enable --now week-review.timer
|
|
systemctl --user list-timers week-review.timer
|
|
```
|
|
|
|
Needs `loginctl enable-linger` so the timer runs while logged out. Logs are in `~/.local/state/week-review/run.log`. The nix module deliberately omits the timer for the same one-machine reason.
|
|
|
|
## Hour log timer
|
|
|
|
`systemd/hourlog.timer` fires Fridays at 18:00 Europe/Lisbon and runs
|
|
`bin/hourlog-session.sh`, which opens an Agent of Empires session on a scratch
|
|
dir, sends it `/hourlog --week this`, and pushes an ntfy notification.
|
|
|
|
Same shape as the weekly review and interactive for the same reason: the skill
|
|
proposes hours and stops for approval before writing anything to the timesheet.
|
|
An unattended run would be deciding a company record on your behalf. It skips
|
|
if a previous `hourlog-*` session is still open, and `Persistent=true` makes a
|
|
missed Friday fire on the next boot.
|
|
|
|
Enable on one machine only:
|
|
|
|
```sh
|
|
systemctl --user daemon-reload
|
|
systemctl --user enable --now hourlog.timer
|
|
```
|
|
|
|
Setup lives outside this repo, which is public:
|
|
|
|
- `~/.config/hourlog/projects.json` — path prefix to project mapping, copied
|
|
from `skills/hourlog/config.example.json`.
|
|
- `HOURLOG_API` and `HOURLOG_TOKEN` in `~/.env.claude` — API base URL and a
|
|
personal access token (`profile:read`, `schedule:read`, `schedule:write`).
|
|
|
|
No project, client, or host name belongs in a committed file here.
|
|
|
|
## PR daemon
|
|
|
|
`bin/reviewer-poll.ts` watches PRs on GitHub and Gitea and turns them into
|
|
Agent of Empires sessions. It is the only thing in this setup that polls a
|
|
forge: `land` and `review-pr` do no waiting of their own, they react to what
|
|
the daemon sends them.
|
|
|
|
**It reads metadata only** — state, draft, mergeable, head SHA, comment counts
|
|
— and never a comment body. Its output is typed straight into an agent's prompt
|
|
by `aoe send`, so untrusted text must not pass through it. What it sends is one
|
|
inert line naming a PR, a reason, and a skill; the session fetches the actual
|
|
content itself, where it knows to treat it as data. Format and semantics are in
|
|
`skills/pr-common/COMMON.md`.
|
|
|
|
**Routing is derived, not registered.** A PR belongs to the session whose
|
|
worktree sits on its head branch, found through `aoe list --json --all`. No
|
|
claim files, no database, no cooperation from any skill. A session you started
|
|
by hand for your own work gets the hints for its branch, and loads the named
|
|
skill on arrival if it doesn't have it.
|
|
|
|
**Noise is dropped at the source.** A label, an assignee, an edited title all
|
|
bump `updated_at` and move nothing in the snapshot, so no hint is sent at all.
|
|
With webhooks the filter is sharper still, by event action.
|
|
|
|
**An epoch guards the first run.** `~/.local/state/reviewer/epoch` is written
|
|
once; PRs created before it never spawn a session, so switching the daemon on
|
|
doesn't wake every open PR you have. It gates creation only — start a session
|
|
on an old PR's branch yourself and it joins in. Losing the file reads as a first
|
|
run and sets a later epoch, which filters more, never less.
|
|
|
|
### Sessions it creates
|
|
|
|
| PR | skill | session |
|
|
|----|-------|---------|
|
|
| yours | `land` | default profile, `--yolo --trust-hooks` |
|
|
| yours, with `selfReview` | both | plus a reviewer on a different agent |
|
|
| someone else's | `review-pr` | `review` profile, no yolo, no trusted hooks |
|
|
|
|
Both roles can run on one PR because the role is carried by the worktree
|
|
branch: the author side works on the head branch, the reviewer on a local
|
|
`pull/N/head` checkout. Nothing is registered anywhere, and a retitled PR can't
|
|
orphan either of them. The reviewer only hears about new commits and the PR
|
|
closing — replying to threads is the author's job, so comments aren't routed to
|
|
it.
|
|
|
|
### Reviewer rotation
|
|
|
|
`selfReview` exists so a PR is never reviewed by the agent that wrote it. The
|
|
`reviewers` list is the rotation pool, each entry naming a harness and whatever
|
|
flags pin its model and effort; the daemon passes `args` through `--extra-args`
|
|
and knows nothing about what they mean. Effort is per-harness — `--effort` on
|
|
claude, a `:high` suffix on pi's model pattern, and nothing usable on opencode,
|
|
whose `--variant` exists only under `opencode run`.
|
|
|
|
Selection drops entries whose tool isn't installed, drops every entry sharing
|
|
the author's harness, then picks among the **least-used** remaining ones with
|
|
ties broken at random. Uniform random repeats and leaves combinations
|
|
unexercised, which defeats the point; least-used also means a newly added entry
|
|
goes out on the very next PR.
|
|
|
|
Every pick is appended to `ledger` (default
|
|
`~/.local/state/reviewer/reviewers.jsonl`):
|
|
|
|
```json
|
|
{"at":"…","pr":"gitea:yolo/rev#75","title":"rev-75-fix-race","reviewer":"pi/gpt5.6@high","author":"claude"}
|
|
```
|
|
|
|
That's the raw material for rating later — group by harness, by model, or by
|
|
effort, and `pi/gpt5.6@med` against `@high` is the cleanest comparison in
|
|
there. It's append-only analytics, not routing state, so nothing the daemon
|
|
does depends on it surviving.
|
|
|
|
Drafts never get a reviewer, on the grounds that unfinished work doesn't earn
|
|
one; the draft→ready flip arrives as `reason=state` and spawns it then.
|
|
|
|
The split is the security boundary. Your branch runs your code, so yolo is
|
|
fine. Someone else's branch is code you're reading precisely because you don't
|
|
trust it yet, and `--trust-hooks` there would run their hooks and project MCP
|
|
servers on sight. Those sessions stop at permission prompts instead, which is
|
|
the gate: an unattended review that stalls is the correct failure.
|
|
|
|
Turning yolo off takes a detour. This box sets `session.yolo_mode_default =
|
|
true` globally, `aoe add` has no `--no-yolo`, and aoe 1.14.1 resolves that
|
|
setting from the global config only — `aoe -p review settings explain
|
|
session.yolo_mode_default` shows no profile layer, so a per-profile
|
|
`config.toml` does nothing. What works: the flag is read from the session row
|
|
at `session start`, so the daemon adds the session, clears `yolo_mode` in the
|
|
profile's `sessions.json`, verifies the row, and only then starts it. A row it
|
|
cannot clear or read gets destroyed rather than started. Verified by checking
|
|
that the launched agent has no `--dangerously-skip-permissions` in its command
|
|
line.
|
|
|
|
The `review` profile is still worth having — it keeps these sessions out of the
|
|
default list — but it carries no settings of its own.
|
|
|
|
### Setup
|
|
|
|
Config from `bin/reviewer-config.example.json` to `~/.config/reviewer/config.json`.
|
|
Secrets in `~/.config/reviewer/env`, never here:
|
|
|
|
```sh
|
|
REVIEWER_GITEA_TOKEN=... # read-only
|
|
REVIEWER_GITHUB_TOKEN=... # read-only
|
|
REVIEWER_GITEA_SECRET=... # webhook HMAC
|
|
REVIEWER_GITHUB_SECRET=...
|
|
```
|
|
|
|
The daemon's tokens are read-only — it never writes to a forge, which is also
|
|
why it doesn't mark notifications read.
|
|
|
|
`systemd/pr-daemon.service` is linked by `bin/link.sh` but not enabled. On the
|
|
one machine that should run it:
|
|
|
|
```sh
|
|
systemctl --user daemon-reload
|
|
systemctl --user enable --now pr-daemon.service
|
|
journalctl --user -u pr-daemon -f
|
|
```
|
|
|
|
### Webhooks
|
|
|
|
Optional. With `webhookPort` set the daemon listens on `/gitea` and `/github`
|
|
and the poll drops to `reconcileSeconds`, which then exists to catch what
|
|
webhooks lose while the daemon restarts. Deliveries are not retried forever,
|
|
and a repo where you lack admin can't have a webhook at all, so polling stays
|
|
the floor rather than a fallback.
|
|
|
|
**The daemon does not create the hooks.** Registering them needs a write scope
|
|
(`admin:repo_hook`), and a process that types into agent prompts should not
|
|
hold a credential that can reconfigure repositories. Create them yourself, once,
|
|
preferably at org level so repos added later are covered:
|
|
|
|
- Gitea: site admin → Webhooks for every repo on the instance, or org →
|
|
Settings → Webhooks for one org. Target `http://<host>:<port>/gitea`, secret
|
|
= `REVIEWER_GITEA_SECRET`, events: pull request, pull request comment, pull
|
|
request review. (The admin "Default Webhooks" tab is a template for *new*
|
|
repos and does nothing for existing ones.)
|
|
- GitHub: org (or repo) → Settings → Webhooks. Payload URL
|
|
`https://<public-host>/github`, content type `application/json`, secret =
|
|
`REVIEWER_GITHUB_SECRET`, events: pull requests, pull request reviews, pull
|
|
request review comments, issue comments, check suites, statuses.
|
|
|
|
Signatures are verified before the body is parsed, repos outside the config are
|
|
answered `202` and dropped, and the payload only ever selects which PR to
|
|
re-read from the API — nothing in it is acted on directly.
|
|
|
|
## Adding a skill
|
|
|
|
Drop a new `skills/<name>/SKILL.md` (+ optional `scripts/`, `references/`, `assets/`). Commit. Non-Nix: re-run `bin/link.sh`. Nix: rebuild.
|
|
|
|
## Lint
|
|
|
|
`bin/lint.sh` runs what CI runs. Missing tools are skipped with a note; CI sets `LINT_STRICT=1` so a tool absent from the runner fails instead of passing as green.
|
|
|
|
Generic checks: `shellcheck`, `ruff` (config in `ruff.toml`), `python3 -m compileall`, `jq` on every JSON file, `node --check`, `nix flake check`.
|
|
|
|
Repo-specific ones live in `bin/lint-repo.py` (stdlib only, no install needed):
|
|
|
|
- **Skill frontmatter** — `name` matches the directory, names are unique, `description` is non-empty, no unknown keys. A typo'd key is ignored silently by every tool that reads it.
|
|
- **Internal paths** — every `<skills-root>/…` reference, repo-relative path and relative markdown link in a tracked file points at something that exists.
|
|
- **Installer drift** — `bin/link.sh` and `nix/home.nix` install the same set of files. Expected divergences are listed in the script with the reason.
|
|
- **Entry imports** — every `@~/.claude/x.md` in an entry file is something both installers actually create.
|
|
- **Unit paths** — `ExecStart` targets in `systemd/*.service` and `nix/home.nix` exist in the repo.
|
|
|
|
Vendored skills are excluded from all of it.
|
|
|
|
`bin/check-vendored.sh` is not part of this — it needs network and runs weekly in its own workflow.
|
|
|
|
## Skills
|
|
|
|
| skill | what |
|
|
|-------|------|
|
|
| `work` | tracker issue → worktree → PR → hands off to `land` |
|
|
| `yolo` | quick ship; optional `land` handoff |
|
|
| `land` | drive a PR **you authored** to green + ready-to-merge; user clicks merge |
|
|
| `review-pr` | review a PR **someone else authored**; findings only, never pushes, never runs the branch's code |
|
|
| `pr-common` | shared PR-loop mechanics: hint format, seen file, state file, forge resolution (dependency of land/review-pr) |
|
|
| `blitz` | drive a whole milestone to done |
|
|
| `nightshift` | hours-long unattended build; architect delegating to subagents, backs off before the 5h limit |
|
|
| `tracker-common` | shared GitHub/Gitea/Linear tracker config, worktree conventions, and local verification budget (dependency of work/yolo/blitz/nightshift) |
|
|
| `week-review` | review the past week's sessions for recurring friction; reads open issues here as carry-over |
|
|
| `hourlog` | measured active time per project per day from session transcripts, reconciled against the timesheet; submits only what you approve |
|
|
| `intercomms` | find and talk to other agent sessions on this machine via `aoe`; discovery is a query, nothing is tracked |
|
|
| `improve-codebase-architecture` | misc |
|
|
|
|
## Vendored skills
|
|
|
|
`humanizer` and `impeccable` are third-party and copied in, not written here.
|
|
Each carries an `UPSTREAM` file recording repo, pinned tag, subdirectory, and
|
|
license. Do not edit them — a local change becomes a merge conflict on every
|
|
update, and a rule that reads wrong is a bug to report upstream.
|
|
|
|
```sh
|
|
./bin/check-vendored.sh # reports any that have moved upstream
|
|
```
|
|
|
|
To update: re-clone at the newer tag, copy the `subdir` over the skill dir, and
|
|
bump `ref` + `vendored` in `UPSTREAM`.
|
|
|
|
| skill | upstream | license |
|
|
|-------|----------|---------|
|
|
| `humanizer` | [blader/humanizer](https://github.com/blader/humanizer) | MIT |
|
|
| `impeccable` | [pbakaus/impeccable](https://github.com/pbakaus/impeccable) | Apache-2.0 |
|
|
|
|
## Writing contract
|
|
|
|
One contract, `claude-md/writing.md`. Both entry files `@import` it, so Claude
|
|
Code and Codex read the same bytes on every machine.
|
|
|
|
The rule it encodes is **selection, not compression**: keep output short by
|
|
cutting whole ideas that don't change what the reader does next, then write
|
|
what survives as plain sentences. Not by dropping articles, abbreviating, or
|
|
writing fragments — that is shorter and worse. Anthropic's Fable 5 and Opus 5
|
|
prompting guides both say this explicitly.
|
|
|
|
Two enforcers in `hooks/`:
|
|
|
|
| enforcer | scope |
|
|
|----------|-------|
|
|
| `comms-lint.py` | GitHub issues/PRs/review comments — 150 words (hard cap 300), evidence in `<details>`, no reviewer-addressing opener, plain diction |
|
|
| `comment-lint.py` | code comments — 1-3 lines, volume and purpose, not wording. The rule is path-scoped (`~/.claude/rules/`), so it loads only when Claude reads a source file. |
|
|
|
|
Written in the voice it asks for, and leads with a worked example of each.
|
|
Prompt style leaks into output style, and examples steer harder than
|
|
prohibitions, so the file is short and shows rather than forbids. Prose alone
|
|
drifts; the linters make it binding. Details in `hooks/README.md`.
|