The gitea posting snippet used the issue-comment endpoint, which has no path or line, so findings named `path:line` in prose instead of landing on the code. Both forges now post one COMMENT review carrying anchored findings in comments[] and loose ones in the body. land's gitea baseline missed review-comment ids, which would replay every code comment on the first hint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
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 (linear-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/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)
# 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/linear-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 linear-common/COMMON.md under "Local verification budget".
~/.claude/skills/linear-common/scripts/gate.sh --status
AGENT_GATE_SLOTS=3 AGENT_GATE_MEM_MAX=4G ~/.claude/skills/linear-common/scripts/gate.sh -- cargo test
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.
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 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):
{"at":"…","pr":"gitea:yolo/rev#75","title":"rev-75-fix-race","reviewer":"pi/kimi-k3@high","author":"claude"}
That's the raw material for rating later — group by harness, by model, or by
effort, and pi/kimi-k3@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:
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:
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.
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 |
linear-common |
shared config/setup/worktree conventions + 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 |
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.
./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.