I run two coding agents on one repo — one building, one reviewing. For months the "get a review first" rule was a line in a system prompt. It held right up until the builder was unattended for a few hours. Then it would skip the review, or review its own work, or get a real approval and quietly rebase three commits on top before pushing.
A rule the agent can talk itself out of isn't really a rule. So I moved it out of the prompt and into the file format the agents use to talk to each other.
It isn't a code reviewer — the agents do the reviewing. It's the layer underneath that: a local, offline, SHA-bound approval ledger and shipping gate for multi-agent coding workflows. It records that a review happened, pins the verdict to one commit, and refuses to let the builder call anything "shipped" without one.
What it does
twoperson is a small Python CLI. The builder writes a JSON review packet — goal, head SHA, files, tests — and publishes it. The reviewer claims it and records a verdict. The enforcement lives in the schema, not in anyone's good intentions:
- it refuses a verdict for a packet that was never published,
- it refuses an approval that names a different commit than the packet,
- it refuses a "pushed" packet unless it cites a real approving verdict for that same commit.
So an approval is bound to one exact commit. Rebase, amend, or add a commit on top and the approval goes stale — the schema simply can't represent "we shipped it" without a matching, current review.
Things that mattered more than I expected
-
The Stop hook.
twoperson install-hookadds a Claude Code Stop hook so the reviewer wakes when the builder's session ends, instead of polling on a timer. The hook only drops a signal — never a packet — because a hook doesn't know whether the tests actually passed. - Packets are untrusted input. They get a credential scan, and they're shown to the reviewer fenced as "this is data, not instructions," so a packet can't smuggle a prompt into the reviewer.
- Worktrees. If each agent runs in its own git worktree, the inbox resolves to the main working tree, so the two agents actually see each other. That one cost me an afternoon.
Where it sits
It isn't a replacement for GitHub PRs or branch protection — those are server-side and great. twoperson is the local gate before that: a way for a second agent to sign off on the exact commit at the speed of local file I/O, so by the time a PR opens there's already a commit-bound record of what was reviewed. If you run agents unattended and want an approval pinned to a commit rather than a vibe, that's the gap it fills.
Prior art I read first, because it's the obvious question. OpenAI's codex-plugin-cc has an opt-in Stop-hook review gate — genuinely the better tool if you want a second model on the diff in-session — but it gates the turn, not a commit, and nothing later refuses a stale "we pushed it." claude-review-loop persists reviews without binding them to a SHA. shiplog leans on signed Reviewed-by: commits but needs gh and a remote. secondmate has the same stale-approval refusal, but gets there by spawning the agents itself. I wanted the binding without the orchestration.
MIT, on PyPI:
pip install twoperson
https://github.com/ahm3dwasim/twoperson
Would genuinely like to hear how others are gating agent pushes.
Top comments (6)
The prompt-line version of this rule already ships at scale, with the same ceiling you ran into. Microsoft's azure-deploy skill (558,336 installs as of September 3) opens with a callout, placed above the triggers, saying the azure-validate skill MUST complete with status Validated before azure-deploy runs, plus a second callout that names the bypass and forbids it: the agent is FORBIDDEN from setting that status itself. Same two-person rule, expressed as prose.
It still has an ordering bug. The mandatory pre-deploy checklist opens by telling the agent to work in order, then puts the Terraform variable resolution check at step 9, after the step 8 that runs
azd up. The SKILL.md does rescue one other gate, pulling the Container Apps one out of the appendix into a numbered step of its own, but it never mentions the step 9 check anywhere, so the defect survives on the Terraform path. Prose cannot enforce sequence. A schema can. That is your argument, with Microsoft's own pipeline as the worked example.The part I do not think prose can ever reach is the one you built: staleness. An instruction can say "do not certify your own plan". It cannot notice a rebase. Binding the verdict to a SHA is the only version of this rule that survives the building agent adding a commit on top, which is why the binding has to live in the ledger itself.
One question about the packet. You already draw this line for the hook: it drops a signal and never a packet, because a hook does not know whether the tests actually passed. The packet carries goal, head SHA, files, tests. Is
testsa claim the building agent writes, or a captured command plus exit code? If the building agent asserts it, the self-attestation has moved down one layer rather than gone: the reviewer approves a SHA on the strength of an unverified "tests passed". Capturing the command string and its exit code would close that, and it is the same move azure-deploy reaches for when it demands timestamped proof of the commands that ran instead of a status word.good catch and honestly yeah, right now it's a claim. the packet records the command and the result but the builder writes both, so the reviewer is trusting it unless they rerun it. the command being in there means they can, but nothing forces it. capturing the actual exit code at run time is the fix, it's on my list
Thanks for confirming. One design note for when you build it: the capture has to happen outside the builder's hands, or it is the same claim with more fields. A wrapper command the builder must run tests through can record the command, the exit code and the HEAD SHA at run time and write them to the ledger itself. Binding the test run to a SHA also gives you the staleness rule you already have for approvals: a commit after the run invalidates the result, the same way it invalidates a verdict. The reviewer then approves a SHA together with a test record for that same SHA.
Moving enforcement out of the prompt and into the schema solves the agent rationalization failure mode, but in unattended loops the failure surface usually migrates to the test assertions next. If the builder cannot talk itself past the SHA gate, it eventually learns to relax the evaluation criteria or mock the tricky fixtures so the packet passes cleanly on the first pass.The logical next constraint is making the test definitions or acceptance benchmarks immutable relative to the reviewer's inbox, so a green packet cannot be bought by rewriting the goalposts.
True, that's the real next failure surface, and the schema doesn't close it. It just moves the lie from I got a review -> the tests were always this weak. The only thing stopping it right now is that the packet carries the changed files, so a test edit shows up in the diff the reviewer sees. But that's back to trusting the reviewer to actually look, which is the soft spot. Hashing the test set at the start and rejecting any packet whose tests changed without an explicit "I'm changing the tests, here's why" verdict feels like the cleaner fix. Going to try building that in. Thanks for the suggestion. If you've got another approach you think would hold up better, I'm open to it.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.