feat: PR daemon + reviewer/author skill split

One systemd daemon watches GitHub and Gitea and routes each PR to an aoe
session: `land` for PRs you authored, `review-pr` for everyone else's.
It reads metadata only and sends a single inert hint line, so untrusted
PR text never passes through the thing that types into agent prompts.

Routing is derived from `aoe list --json --all` by worktree branch, so
no claim files and no daemon database. Dedupe stays in the session via
`pr-<N>-seen`, which makes hints idempotent and a swallowed send
self-healing.

`land` loses its watcher machinery to the daemon and keeps the policy
and per-event handlers; `pr-common` holds what both skills share.

Review sessions run non-yolo without trusted hooks and never run the
branch's code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-19 18:32:16 +01:00
parent 6f05756870
commit 787f133fdc
7 changed files with 1136 additions and 128 deletions
+111 -1
View File
@@ -100,6 +100,114 @@ Setup lives outside this repo, which is public:
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` |
| someone else's | `review-pr` | `review` profile, no yolo, no trusted hooks |
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.
@@ -110,7 +218,9 @@ Drop a new `skills/<name>/SKILL.md` (+ optional `scripts/`, `references/`, `asse
|-------|------|
| `work` | tracker issue → worktree → PR → hands off to `land` |
| `yolo` | quick ship; optional `land` handoff |
| `land` | drive an open PR to green + ready-to-merge; user clicks merge (canonical CI/review loop) |
| `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) |