Files
Miguel Palhas 6b3cabe76a
ci / nix (push) Successful in 14s
ci / lint (push) Failing after 19s
feat(pr-daemon): see maestro sessions, not just aoe
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>
2026-09-01 15:04:44 +01:00

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 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 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:

  • rolesreview for the reviewer rotation, blitz for 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 no tiers is 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 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 frontmattername 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 driftbin/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 pathsExecStart 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; 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.