Sessions started by hand now live in maestro. The daemon listed only aoe, so it found no owner for their branches and spawned a second session on a worktree that already had an agent in it. Both listings now feed one session set, keyed the same way, and a hint goes back out through whichever orchestrator owns the pane. Only the delivery call branches on source; routing, cooldowns and state read one set of names. Sessions the daemon creates are still aoe sessions -- profiles, yolo clearing and the review sandbox have no maestro equivalent. Assumes `maestro send <id> <message>`, which is landing separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
25 KiB
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, Pi from ~/.agents/skills, Codex from ~/.codex/skills (and only there — ~/.agents/skills is invisible to it), and opencode auto-loads the first two. 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)
git clone https://git.naps.pt/yolo/agent-skills.git ~/tea/agent-skills
~/tea/agent-skills/bin/link.sh
Symlinks each skill into ~/.claude/skills/, ~/.agents/skills/ and ~/.codex/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)
# 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:
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".
~/.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:
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.
It runs on sonnet — reading session logs into a table is not opus work —
overridable with HOURLOG_MODEL, or empty for the harness default.
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:
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 fromskills/hourlog/config.example.json.HOURLOG_APIandHOURLOG_TOKENin~/.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 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
into a live pane, 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. 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.
Both orchestrators are one session set. Sessions are listed from aoe list --json --all and maestro list --json together, and a hint goes back out
through whichever one owns the pane. Only the delivery call branches on it;
routing, cooldowns and state all read one set of names. This is what stops the
daemon spawning a second session on a worktree that already has an agent in it
— it used to see the aoe half only. Sessions it creates itself are still aoe
sessions, because the profile, yolo and sandbox handling below has no maestro
equivalent yet. A maestro that is missing or stopped costs the aoe half
nothing: its sessions just go invisible, logged once.
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.
Hints are rate-limited per PR and role. Every hint costs the receiving
session a full model turn, so after one goes out the next waits
hintCooldownSeconds (default 300) and arrives carrying every reason that
accumulated meanwhile. A hint identical to the last one sent is dropped, and so
is a ci hint to a land session whose own worktree already holds that head
commit — it pushed it. Reasons are banked until they are actually delivered, so
a busy pane or a cooldown delays a hint but never loses one.
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 |
| github, review requested from you | review-pr |
review profile, no yolo, no trusted hooks, sandboxed |
yours on github, review requested, with selfReview |
both | plus a reviewer on a different agent |
A reviewer needs an explicit request. Two conditions, both required: the
forge is github, and one of your logins sits in the PR's requested_reviewers.
Gitea never spawns one, and a merely non-draft PR doesn't either. An audit of 47
closed PRs is where that came from — roughly a third of the findings paid for
themselves and nearly all of those were daemon and core changes, while small
PRs reviewed clean often enough that the reviewing cost bought nothing. Github
won't let you request a review from a PR's own author, so selfReview now only
fires when another of your logins opened the PR.
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.
The agent roster
agents is one roster of harness+model combos for everything in this repo that
spawns a session — the reviewer rotation and blitz's worker sessions — so a
model added once is available to both. Each entry names a harness and whatever
flags pin its model and effort; consumers pass args through --extra-args
and know nothing about what they mean. Two fields say who may pick an entry:
roles—reviewfor the reviewer rotation,blitzfor milestone workers. Absent means both, which is the useful default for a general-purpose combo.tiers— blitz's difficulty routing (execution,design,subtle), and meaningless to the daemon. An entry with notiersis never auto-routed by blitz, though the operator can still name it in an invocation.
Markdown skills query it through scripts/roster.sh (linked to
~/.claude/scripts/roster.sh) rather than parsing the config themselves:
~/.claude/scripts/roster.sh --role blitz --tier execution --format aoe
# --tool claude --extra-args "--model sonnet"
The key was reviewers when only the daemon read it; that name is still
accepted. A machine-wide blitz block (maxSessions, notifyService) lives
here too, overridden per repo by .claude/tracker.json.
Reviewer rotation
selfReview exists so a PR is never reviewed by the agent that wrote it. The
rotation pool is every agents entry whose roles include review. 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):
{"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.
Review sessions used to stop at permission prompts instead, which stalled them on a dialog nobody was there to answer. They now run confined rather than gated — no prompt, no approval, and a boundary the session cannot argue with:
| Claude | Codex | |
|---|---|---|
| no prompts | defaultMode: dontAsk — a denial goes to the agent, not to you |
--ask-for-approval never |
| writes | sandbox allowWrite: the worktree and <main>/.git/worktrees |
--sandbox workspace-write --add-dir <main>/.git/worktrees |
| network | sandbox allowlist: the configured forge API hosts only | full egress (codex has no per-domain list) |
| reads | everything except ~/.ssh, ~/.aws, ~/.gnupg, ~/.env, ~/.env.claude, ~/.config/agent-skills, ~/.config/reviewer and the two agent credential files |
same list, as sandbox denyRead |
| project config | no --trust-hooks |
trust_level = "untrusted", which also answers codex's trust prompt without granting it |
The grants are generated per repo in sandboxArgs — a settings file under
~/.local/state/reviewer/settings/ for Claude, a ~/.codex/review-*.config.toml
profile for Codex. .git/worktrees is in the write set because that is where
pr-<N>-seen and pr-<N>-findings.md live, deliberately outside the branch;
.git itself is not, since that would hand a reviewed branch the repo's hooks.
Claude Code treats .git as a protected path no allow rule opens, so those two
files are written with a shell redirect, which the sandbox permits.
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/agents-config.example.json to ~/.config/agent-skills/config.json
(~/.config/reviewer/config.json still works — the daemon reads whichever
exists, so an old box migrates with a mv). Secrets in env next to it, never
here:
REVIEWER_GITEA_TOKEN=... # read-only
REVIEWER_GITHUB_TOKEN=... # read-only
REVIEWER_GITEA_REVIEW_TOKEN=... # optional, write:issue — handed to review sessions
REVIEWER_GITEA_SECRET=... # webhook HMAC
REVIEWER_GITHUB_SECRET=...
The daemon's own tokens are read-only — it never writes to a forge, which is also why it doesn't mark notifications read.
A review session is a different case: it has to post its findings, and the
sandbox denies it ~/.env.claude, where $GITEA_TOKEN normally comes from.
Name a write-capable variable in a forge's reviewTokenEnv and the daemon
passes its value into the session as $GITEA_TOKEN ($GH_TOKEN on GitHub) —
through the generated Claude settings file (env) or codex profile
(shell_environment_policy.set), both written 0600. Leave reviewTokenEnv
out and nothing is injected; the session falls back to the forge's credential
helper, which is what it did before. Scope it to commenting: on Gitea that is
write:issue, and nothing else.
Claude review sessions need bubblewrap and socat on the box, or the sandbox
cannot start and the session refuses to run (failIfUnavailable). That is
deliberate: without the sandbox the confinement above is gone.
systemd/pr-daemon.service is linked by bin/link.sh but not enabled. On the
one machine that should run it:
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 typeapplication/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 —
namematches the directory, names are unique,descriptionis 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.shandnix/home.nixinstall the same set of files. Expected divergences are listed in the script with the reason. - Entry imports — every
@~/.claude/x.mdin an entry file is something both installers actually create. - Unit paths —
ExecStarttargets insystemd/*.serviceandnix/home.nixexist 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; keeps model feedback in ~/.local/state/agent-skills/models.md |
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 |
Model notes
Blitz reads ~/.local/state/agent-skills/models.md before routing issues to
worker sessions and rewrites it before the run ends. It is a compiled summary —
difficulty tiers, task fit, cost effectiveness, caveats — capped at ~60 lines
and edited in place, never appended to, so the next run reads a current belief
instead of a log. skills/blitz/AOE-WORKERS.md holds the rules.
Machine-local on purpose: the skill dirs are read-only nix store paths here, and
the notes describe runs on this box. The roster it draws models from is the PR
shared agents roster in ~/.config/agent-skills/config.json; changes to the
roster go through week-review, not blitz.
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.
./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 | MIT |
impeccable |
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.