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:
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user