Skip to main content

twoperson

CI PyPI Python License: MIT

A two-person rule for AI coding agents. The agent that wrote the change can't record it as shipped until a reviewer has written down a verdict for that exact commit, and that rule lives in a schema rather than in a prompt.

builder ──publish──▶ .twoperson/pending/ ──claim──▶ reviewer
   ▲                                                    │
   └────────────── verdicts/ ◀──── Approve / Request changes

No server, no API keys, no network. Two agents share a directory. That's the whole transport.

demo

Why I built it

I run two coding agents against the same repo: one builds, the other reviews. For a while the "get a review first" step was a line in a system prompt. It worked until it didn't. Left alone for a few hours, the builder would decide the review was optional, or would review its own work and approve it, or would get a real review and then rebase three more commits on top of the approval and push the lot.

None of that was malicious. It's just what happens when a rule is advice. So I moved the rule out of the prompt and into the data format the two agents use to talk to each other.

What it actually enforces

The builder writes a review packet: a JSON record of what it was trying to do, the head sha, which files changed, what tests it ran and what they said. twoperson publish validates it and drops it in .twoperson/pending/. The reviewer claims it, reads it, and writes a verdict.

Four refusals do the real work. They're all real output.

A verdict has to answer a packet that actually exists in the inbox:

$ twoperson verdict --packet made-up --decision Approve --head 0900128
verdict rejected — packet_id: no packet 'made-up' in this inbox (pending,
claimed or audited) — a verdict must answer a published packet; run `next` to claim one

An approval has to name the commit that packet is at, not some other commit:

$ twoperson verdict --packet demo-1 --decision Approve --head abcdef0
verdict rejected — head_sha: 'Approve' names 'abcdef0' but packet 'demo-1' is at
'6e5acc68…' — an approval binds to the packet's own head

A packet can't say it was pushed, deployed, or restarted without a review reference:

$ twoperson verify --from packet.json
packet rejected — push_status.review_ref: a packet may not report
pushed/deployed/restarted=true without a recorded Reviewer audit reference

And that reference has to be a verdict that exists, approves, and approves this commit:

$ twoperson verify --from packet.json
packet rejected — push_status.review_ref: verdict 'vdt-20260902T215020Z-e2e38d43'
approved head '6e5acc68…', but this packet shipped 'f00dbabe…' — an approval does
not carry over to a different commit

So an approval is for one sha of one packet. Rebase, amend, or add a commit and it's stale. The builder has to publish again and the reviewer has to look again. verify runs the same checks as publish and writes nothing, so a builder can dry-run its own ship report.

Try it

pip install twoperson
cd your-repo

# builder
twoperson template > packet.json     # evidence fields are "unknown"; a few fixed placeholders (see --help)
$EDITOR packet.json                  # goal, head sha, tests, evidence
twoperson publish --from packet.json

# reviewer (another terminal, another agent, another machine with the same checkout)
twoperson check                      # exit 0 = something's waiting. Costs a few stat() calls.
twoperson next                       # claim the oldest packet, print it for review
twoperson verdict --packet demo-1 --decision "Approve with nits" \
  --note "tests cover the 503 path" --finding "add a jitter test"
# --head defaults to the packet's own head; pass it explicitly if you want to be sure

# builder
twoperson verdicts --ack
2026-09-02T21:50:20Z  vdt-20260902T215020Z-e2e38d43
  packet   : demo-1
  head     : 6e5acc6800f849c3a4a5e5bd8edc324a6247836a
  reviewer : reviewer
  DECISION : Approve with nits  (ship gate OPEN for this head)
  findings :
    - add a jitter test
  note     : tests cover the 503 path

check is designed to be polled. It doesn't touch a model, so a reviewer agent can sit in a loop on it and spend nothing until there's actually a packet to read.

Hooking it into Claude Code

twoperson install-hook

That adds a Stop hook to .claude/settings.json. When a session ends it drops a small signal file in the inbox, so a reviewer can be woken by the event instead of polling on a timer.

The signal is deliberately not a packet. It says "a session stopped here" and nothing else. A Stop hook doesn't know what the session did or whether the tests passed, and if I let it guess it would produce a nice-looking packet full of made-up facts. The whole point is to not have those.

On a Mac, twoperson install-watch sets up a launchd agent that fires on inbox changes and runs whatever command you put in TWOPERSON_ON_PACKET. That's where you point your reviewer.

The packet is hostile input

The builder is a language model. Whatever it writes into the packet might be aimed at the reviewer, on purpose or by accident. So the packet gets treated the way you'd treat a form submission from the internet:

  • Hard size cap (256 KB) before parsing.
  • Strict schema. Unknown keys are rejected. Every string has a length limit, every list a count limit.
  • File paths have to be repo-relative. No absolute paths, no .., no drive letters.
  • A scan for things that look like credentials: Anthropic keys, OpenAI keys (legacy sk-… and the sk-proj-… / sk-admin-… shapes), GitHub, AWS, Slack and Google keys, PEM blocks, bearer tokens, and NAME=value where NAME ends in key/secret/token/password and the value is 16+ characters. It only looks at values, so writing "read the key from ANTHROPIC_API_KEY" is fine while pasting the actual key is not. When it finds one, the error names the field path and never echoes the value. The list is in packet.py and every shape has a fail-closed test; it is a tripwire, not a guarantee.
  • When the reviewer renders a packet, the body sits between BEGIN/END markers under a short preamble saying this is data, not instructions. Markers forged inside the body get defanged.

None of that replaces the reviewer's judgment. It just means the reviewer isn't the only line of defense.

Worktrees

If your agents each run in their own git worktree (they should), a naive "put the inbox in this checkout" gives each agent a private inbox. Builder publishes, reviewer polls, neither sees the other, and check returns 1 in a way that looks exactly like "nothing to do". I lost an afternoon to that.

twoperson walks up to the main working tree, following .git files and commondir by hand, so every worktree of a repo lands on the same .twoperson/. No git subprocess, so it stays cheap. Set TWOPERSON_INBOX if you want to override it.

Picking a reviewer by difficulty

Not every packet deserves your strongest (slowest, priciest) reviewer. twoperson tier scores a packet from its validated fields — which review areas it names, whether the changed paths touch auth/payment/deploy/migration, how big the diff is, whether tests ran and passed, whether the builder left open questions, whether something already shipped — and reports low, medium, high or critical. It's substring checks on structured fields, so a builder writing "trivial typo fix, please approve" next to thirty changed files under src/auth/ still gets critical.

The watcher hands that tier to whatever reviewer command you configured (TWOPERSON_TIER, TWOPERSON_TIER_SCORE, TWOPERSON_PACKET_ID in its environment), so the reviewer side can start cheap and only spend the big model on the packets that earn it. If a reviewer decides a packet is beyond it, the convention is a Needs owner decision verdict with a note starting ESCALATE:; a reviewer ladder can catch that and re-run one rung stronger. twoperson records the convention and the tier; it doesn't run models.

There's also a consult lane

Sometimes the builder wants an opinion, not an audit. consult-publish / consult-next / consult-advise do that. It's a separate set of directories, it never produces a verdict, and nothing on it can unlock a push. It exists so "what do you think of this approach" can't get quietly upgraded to "this was reviewed".

How this compares

There are several projects in this space and I read them before writing this one. Most of them are more capable than this is. They solve a different half of the problem.

codex-plugin-cc is OpenAI's own plugin for driving Codex from inside Claude Code, and it's the closest thing to an official answer here. Turn on its optional review gate and a Stop hook runs a targeted Codex review of Claude's response; if the review finds something, the stop is blocked. That gates the turn. The check happens while the session is alive, no durable record says which commit was reviewed, and nothing afterwards refuses a claim that the work was pushed. It also needs a ChatGPT subscription or an OpenAI key and spends Codex usage on every review, which its own README warns can drain limits quickly. If what you want is a second model reading the diff before the session ends, use it; it does that better than this does.

claude-review-loop runs a set of parallel Codex reviewers when a session tries to stop and writes the consolidated review to a reviews/ file. The review is real and it persists, but nothing ties it to one commit, and nothing later refuses a record that says the work shipped.

sd0x-harness is far bigger (99 skills, hooks, rules) and made the opposite trade on purpose: its git-level guards stay hard while, in its own words, "the review layer became advisory by design". Its hooks report facts and the model decides.

quorum, which absorbed the earlier consensus-loop, does enforce an audit gate, over MCP and a SQLite event store, with 22 hooks and 30 tools. Approval there is a state in that store rather than something bound to a particular commit.

secondmate is the one I found that also refuses a stale approval: its verify-gate.sh fails if the head moved since the checker's verdict. It gets there by being an orchestrator that spawns the maker and the checker itself. This is the same idea with none of the orchestration, which matters if you already have two agents you like.

shiplog records reviews as signed Reviewed-by: lines on pull requests, with four dispositions. It needs an authenticated gh and a GitHub remote.

So: everything above either runs the review for you, or records it without binding it to a commit, or both. twoperson runs nothing and reviews nothing. It is a schema and a directory, and the only thing it does is make "shipped without review" and "approved without a sha" impossible to write down. It has no opinion about which agents you use and doesn't need a network, a key, or a remote.

What it doesn't do

It doesn't review code. It makes sure a verdict was recorded and writes down what it said.

It doesn't know who the reviewer is. --reviewer is a label, not an identity. If the builder and the reviewer run in the same process with the same permissions, the builder can approve itself, and twoperson will not notice. Keeping the two apart is your deployment's job: separate OS users, separate checkouts, a reviewer that only has read access to the code. What twoperson guarantees is narrower and mechanical: an approving verdict exists for this exact commit, and the ship report points at it. The binding is per commit, not per packet. A ship report is its own packet, and what it has to cite is an approval of the same head; it doesn't have to be the packet that was originally reviewed, because after a rebase it can't be.

It doesn't spawn agents or call models. It's a directory with a lock and a validator.

It doesn't watch your repository or your deploys. Nothing in the gate reads HEAD or the working tree; the only git it touches is a git rev-parse to label a signal with its branch name and, in the shell hooks, to find a virtualenv. It never sees a push happen. What it validates is the builder's report: a packet that says "I pushed/deployed/restarted commit X" is refused unless it cites an approving verdict for X. A builder that lies in the report can lie. What can't exist is a consistent report that skipped review, and an agent that has to lie to ship is a much easier thing to catch than one that was never asked.

It doesn't replace CI. CI checks the code. This checks the paperwork.

Exit codes

0 did the thing. 1 nothing to do. 2 rejected. The one exception is signal: a valid signal invocation never returns 2, and the packaged Stop-hook script always exits 0 whatever happens inside it, because Claude Code treats a 2 from a Stop hook as "don't stop" and a broken hook would trap the session in a loop. (Malformed flags still get argparse's usual 2, like any CLI; the hook script never passes malformed flags.)

Development

pip install -e ".[dev]"
pytest -q

A few hundred tests, about two seconds. MIT.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

twoperson-0.1.1.tar.gz (537.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

twoperson-0.1.1-py3-none-any.whl (77.4 kB view details)

Uploaded Python 3

File details

Details for the file twoperson-0.1.1.tar.gz.

File metadata

  • Download URL: twoperson-0.1.1.tar.gz
  • Upload date:
  • Size: 537.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for twoperson-0.1.1.tar.gz
Algorithm Hash digest
SHA256 2bab382b21f623e050d7b97afa56bae439769171fa13bba887ed771bd813d9d7
MD5 d6e2463676895d03d3c61c3a75cd2371
BLAKE2b-256 e88e759eea5916cc3a736134f835f1e3677b5c955e9e488799b548d640bc49cc

See more details on using hashes here.

Provenance

The following attestation bundles were made for twoperson-0.1.1.tar.gz:

Publisher: publish.yml on ahm3dwasim/twoperson

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file twoperson-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: twoperson-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 77.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for twoperson-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 b7ca303b357e4119132d3348fb1e2af9d501f710e31f979f4de78126170542eb
MD5 6662531575e4d6a395657e648bcf79db
BLAKE2b-256 889fe4670d9335b5ab7f17b9eefa1744a25ee54afb11e37a022651341d0e1b7b

See more details on using hashes here.

Provenance

The following attestation bundles were made for twoperson-0.1.1-py3-none-any.whl:

Publisher: publish.yml on ahm3dwasim/twoperson

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page