twoperson 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. twoperson-0.1.0/.github/workflows/ci.yml +33 -0
  2. twoperson-0.1.0/.github/workflows/publish.yml +33 -0
  3. twoperson-0.1.0/.gitignore +9 -0
  4. twoperson-0.1.0/CHANGELOG.md +31 -0
  5. twoperson-0.1.0/CONTRIBUTING.md +43 -0
  6. twoperson-0.1.0/LICENSE +21 -0
  7. twoperson-0.1.0/PKG-INFO +268 -0
  8. twoperson-0.1.0/README.md +248 -0
  9. twoperson-0.1.0/docs/PROTOCOL.md +169 -0
  10. twoperson-0.1.0/docs/demo.gif +0 -0
  11. twoperson-0.1.0/pyproject.toml +37 -0
  12. twoperson-0.1.0/src/twoperson/__init__.py +203 -0
  13. twoperson-0.1.0/src/twoperson/__main__.py +609 -0
  14. twoperson-0.1.0/src/twoperson/advice.py +249 -0
  15. twoperson-0.1.0/src/twoperson/consult.py +272 -0
  16. twoperson-0.1.0/src/twoperson/hook.py +223 -0
  17. twoperson-0.1.0/src/twoperson/inbox.py +999 -0
  18. twoperson-0.1.0/src/twoperson/packet.py +529 -0
  19. twoperson-0.1.0/src/twoperson/scripts/twoperson_stop_hook.sh +43 -0
  20. twoperson-0.1.0/src/twoperson/scripts/twoperson_watch.sh +62 -0
  21. twoperson-0.1.0/src/twoperson/signal.py +264 -0
  22. twoperson-0.1.0/src/twoperson/tier.py +166 -0
  23. twoperson-0.1.0/src/twoperson/verdict.py +243 -0
  24. twoperson-0.1.0/src/twoperson/watch.py +560 -0
  25. twoperson-0.1.0/src/twoperson/watchagent.py +196 -0
  26. twoperson-0.1.0/tests/__init__.py +0 -0
  27. twoperson-0.1.0/tests/fixtures.py +86 -0
  28. twoperson-0.1.0/tests/test_cli.py +237 -0
  29. twoperson-0.1.0/tests/test_consult.py +369 -0
  30. twoperson-0.1.0/tests/test_docs_law.py +97 -0
  31. twoperson-0.1.0/tests/test_gate_binding.py +153 -0
  32. twoperson-0.1.0/tests/test_hook_install.py +296 -0
  33. twoperson-0.1.0/tests/test_inbox.py +360 -0
  34. twoperson-0.1.0/tests/test_inbox_root.py +181 -0
  35. twoperson-0.1.0/tests/test_packet_schema.py +380 -0
  36. twoperson-0.1.0/tests/test_signal.py +354 -0
  37. twoperson-0.1.0/tests/test_tier.py +140 -0
  38. twoperson-0.1.0/tests/test_verdict.py +260 -0
  39. twoperson-0.1.0/tests/test_watch.py +577 -0
@@ -0,0 +1,33 @@
1
+ name: ci
2
+ on:
3
+ push: { branches: [main] }
4
+ pull_request:
5
+
6
+ jobs:
7
+ test:
8
+ strategy:
9
+ fail-fast: false
10
+ matrix:
11
+ os: [ubuntu-latest, macos-latest]
12
+ python: ["3.10", "3.11", "3.12", "3.13"]
13
+ runs-on: ${{ matrix.os }}
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: pytest -q
21
+
22
+ wheel:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: actions/setup-python@v5
27
+ with: { python-version: "3.12" }
28
+ - run: pip install build && python -m build --wheel
29
+ # The hook scripts must survive packaging: install-hook writes their installed path into
30
+ # settings.json, so a wheel that drops them fails only at Stop time, silently.
31
+ - run: |
32
+ pip install dist/*.whl
33
+ python -c "from twoperson.hook import hook_script_path; assert hook_script_path().exists()"
@@ -0,0 +1,33 @@
1
+ name: publish
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+
6
+ jobs:
7
+ build:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with: { python-version: "3.12" }
13
+ - run: pip install build && python -m build
14
+ - uses: actions/upload-artifact@v4
15
+ with:
16
+ name: dist
17
+ path: dist/
18
+
19
+ pypi:
20
+ needs: build
21
+ runs-on: ubuntu-latest
22
+ # Trusted publishing: PyPI trusts this repo+workflow+environment via OIDC.
23
+ # No API token exists anywhere. Configure the publisher at
24
+ # https://pypi.org/manage/account/publishing/ before the first tag.
25
+ environment: pypi
26
+ permissions:
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/download-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .DS_Store
9
+ .twoperson/
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-09-03
10
+
11
+ First public release.
12
+
13
+ ### Added
14
+
15
+ - Review packets with a strict, allow-listed schema: size cap, repo-relative path checks, a
16
+ credential scan that reports field paths and never values, and a rendering that fences packet
17
+ text as untrusted data.
18
+ - The four refusals that make up the gate: a verdict must answer a packet that exists in the
19
+ inbox; an approval must name that packet's own commit; a packet can't report a push, deploy or
20
+ restart without a `review_ref`; and that `review_ref` must resolve to an existing approving
21
+ verdict for the same commit (`verify` runs the same checks as `publish` and writes nothing).
22
+ - One inbox per repository, shared across `git worktree`s, resolved without spawning `git`.
23
+ - `install-hook`: a Claude Code `Stop` hook that drops a completion signal so a reviewer can be
24
+ woken by the event instead of polling. `install-watch`: a launchd agent for macOS.
25
+ - A consult lane (`consult-*`) that is explicitly non-gating.
26
+ - `tier`: a deterministic difficulty score for a packet, handed to the reviewer command as
27
+ `TWOPERSON_TIER` / `TWOPERSON_TIER_SCORE` / `TWOPERSON_PACKET_ID`, plus the `ESCALATE:`
28
+ convention for asking for a stronger reviewer.
29
+
30
+ [Unreleased]: https://github.com/ahm3dwasim/twoperson/compare/v0.1.0...HEAD
31
+ [0.1.0]: https://github.com/ahm3dwasim/twoperson/releases/tag/v0.1.0
@@ -0,0 +1,43 @@
1
+ # Contributing
2
+
3
+ Thanks for taking a look. This is a small project with a narrow job, so the bar for changes is
4
+ "does it keep the gate honest", not "does it add a feature".
5
+
6
+ ## Setup
7
+
8
+ ```bash
9
+ git clone https://github.com/ahm3dwasim/twoperson
10
+ cd twoperson
11
+ python -m venv .venv && . .venv/bin/activate
12
+ pip install -e ".[dev]"
13
+ pytest -q
14
+ ```
15
+
16
+ The suite runs in about two seconds. Please keep it that way; a test that needs a network or a
17
+ real reviewer belongs in a manual checklist, not in `tests/`.
18
+
19
+ ## What a good change looks like
20
+
21
+ - **Every public claim has a test behind it.** `tests/test_docs_law.py` reads the README and
22
+ `docs/PROTOCOL.md` and fails if they promise something the code doesn't do. If you change what
23
+ the tool enforces, change the docs and the drift test in the same commit.
24
+ - **Packet text is data.** Anything that reads a packet, verdict, consult or advice treats it as
25
+ hostile input. If you add a field, add its length cap, its allowed values, and a test that a
26
+ hostile value is refused.
27
+ - **Don't widen the gate to be helpful.** A refusal that exists is there because someone got burned.
28
+ Loosening one needs a written reason in the PR.
29
+ - **No git subprocesses in the gate.** The inbox resolution deliberately reads `.git` files by hand
30
+ so a poll stays a few `stat` calls. The only `git rev-parse` calls are for a signal's branch label
31
+ and, in the shell hooks, for finding a virtualenv.
32
+
33
+ ## How changes get reviewed here
34
+
35
+ We use `twoperson` on itself. A change is published as a packet, a reviewer records a verdict
36
+ for that exact commit, and the ship report cites it. If you open a PR, expect the review comment to
37
+ look like a verdict: a decision, findings with file and line, and one-line note.
38
+
39
+ ## Reporting a security issue
40
+
41
+ If you find a way to make a packet talk a reviewer into something, or to get a ship report past
42
+ the `review_ref` check, please open an issue with the reproduction. There's no bounty; there is
43
+ a fast fix and a test named after you if you want one.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 twoperson contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,268 @@
1
+ Metadata-Version: 2.5
2
+ Name: twoperson
3
+ Version: 0.1.0
4
+ Summary: A two-person rule for AI coding agents: a ship report cannot be recorded without an approving verdict for that exact commit.
5
+ Project-URL: Homepage, https://github.com/ahm3dwasim/twoperson
6
+ Project-URL: Issues, https://github.com/ahm3dwasim/twoperson/issues
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: agents,ai,claude-code,code-review,codex,guardrails,llm,multi-agent
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Quality Assurance
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: structlog>=23.1
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7.4; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # twoperson
22
+
23
+ [![CI](https://github.com/ahm3dwasim/twoperson/actions/workflows/ci.yml/badge.svg)](https://github.com/ahm3dwasim/twoperson/actions/workflows/ci.yml)
24
+ [![PyPI](https://img.shields.io/pypi/v/twoperson.svg)](https://pypi.org/project/twoperson/)
25
+ [![Python](https://img.shields.io/pypi/pyversions/twoperson.svg)](https://pypi.org/project/twoperson/)
26
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
27
+
28
+ A two-person rule for AI coding agents. The agent that wrote the change can't record it as
29
+ shipped until a reviewer has written down a verdict for that exact commit, and that rule lives
30
+ in a schema rather than in a prompt.
31
+
32
+ ```
33
+ builder ──publish──▶ .twoperson/pending/ ──claim──▶ reviewer
34
+ ▲ │
35
+ └────────────── verdicts/ ◀──── Approve / Request changes
36
+ ```
37
+
38
+ No server, no API keys, no network. Two agents share a directory. That's the whole transport.
39
+
40
+ ![demo](docs/demo.gif)
41
+
42
+ ## Why I built it
43
+
44
+ I run two coding agents against the same repo: one builds, the other reviews. For a while the
45
+ "get a review first" step was a line in a system prompt. It worked until it didn't. Left alone
46
+ for a few hours, the builder would decide the review was optional, or would review its own work
47
+ and approve it, or would get a real review and then rebase three more commits on top of the
48
+ approval and push the lot.
49
+
50
+ None of that was malicious. It's just what happens when a rule is advice. So I moved the rule
51
+ out of the prompt and into the data format the two agents use to talk to each other.
52
+
53
+ ## What it actually enforces
54
+
55
+ The builder writes a **review packet**: a JSON record of what it was trying to do, the head sha,
56
+ which files changed, what tests it ran and what they said. `twoperson publish` validates it and
57
+ drops it in `.twoperson/pending/`. The reviewer claims it, reads it, and writes a **verdict**.
58
+
59
+ Four refusals do the real work. They're all real output.
60
+
61
+ A verdict has to answer a packet that actually exists in the inbox:
62
+
63
+ ```
64
+ $ twoperson verdict --packet made-up --decision Approve --head 0900128
65
+ verdict rejected — packet_id: no packet 'made-up' in this inbox (pending,
66
+ claimed or audited) — a verdict must answer a published packet; run `next` to claim one
67
+ ```
68
+
69
+ An approval has to name the commit that packet is at, not some other commit:
70
+
71
+ ```
72
+ $ twoperson verdict --packet demo-1 --decision Approve --head abcdef0
73
+ verdict rejected — head_sha: 'Approve' names 'abcdef0' but packet 'demo-1' is at
74
+ '6e5acc68…' — an approval binds to the packet's own head
75
+ ```
76
+
77
+ A packet can't say it was pushed, deployed, or restarted without a review reference:
78
+
79
+ ```
80
+ $ twoperson verify --from packet.json
81
+ packet rejected — push_status.review_ref: a packet may not report
82
+ pushed/deployed/restarted=true without a recorded Reviewer audit reference
83
+ ```
84
+
85
+ And that reference has to be a verdict that exists, approves, and approves *this* commit:
86
+
87
+ ```
88
+ $ twoperson verify --from packet.json
89
+ packet rejected — push_status.review_ref: verdict 'vdt-20260902T215020Z-e2e38d43'
90
+ approved head '6e5acc68…', but this packet shipped 'f00dbabe…' — an approval does
91
+ not carry over to a different commit
92
+ ```
93
+
94
+ So an approval is for one sha of one packet. Rebase, amend, or add a commit and it's stale. The
95
+ builder has to publish again and the reviewer has to look again. `verify` runs the same checks as
96
+ `publish` and writes nothing, so a builder can dry-run its own ship report.
97
+
98
+ ## Try it
99
+
100
+ ```bash
101
+ pip install twoperson
102
+ cd your-repo
103
+
104
+ # builder
105
+ twoperson template > packet.json # evidence fields are "unknown"; a few fixed placeholders (see --help)
106
+ $EDITOR packet.json # goal, head sha, tests, evidence
107
+ twoperson publish --from packet.json
108
+
109
+ # reviewer (another terminal, another agent, another machine with the same checkout)
110
+ twoperson check # exit 0 = something's waiting. Costs a few stat() calls.
111
+ twoperson next # claim the oldest packet, print it for review
112
+ twoperson verdict --packet demo-1 --decision "Approve with nits" \
113
+ --note "tests cover the 503 path" --finding "add a jitter test"
114
+ # --head defaults to the packet's own head; pass it explicitly if you want to be sure
115
+
116
+ # builder
117
+ twoperson verdicts --ack
118
+ ```
119
+
120
+ ```
121
+ 2026-09-02T21:50:20Z vdt-20260902T215020Z-e2e38d43
122
+ packet : demo-1
123
+ head : 6e5acc6800f849c3a4a5e5bd8edc324a6247836a
124
+ reviewer : reviewer
125
+ DECISION : Approve with nits (ship gate OPEN for this head)
126
+ findings :
127
+ - add a jitter test
128
+ note : tests cover the 503 path
129
+ ```
130
+
131
+ `check` is designed to be polled. It doesn't touch a model, so a reviewer agent can sit in a
132
+ loop on it and spend nothing until there's actually a packet to read.
133
+
134
+ ## Hooking it into Claude Code
135
+
136
+ ```bash
137
+ twoperson install-hook
138
+ ```
139
+
140
+ That adds a `Stop` hook to `.claude/settings.json`. When a session ends it drops a small
141
+ signal file in the inbox, so a reviewer can be woken by the event instead of polling on a timer.
142
+
143
+ The signal is deliberately not a packet. It says "a session stopped here" and nothing else. A
144
+ Stop hook doesn't know what the session did or whether the tests passed, and if I let it guess
145
+ it would produce a nice-looking packet full of made-up facts. The whole point is to not have
146
+ those.
147
+
148
+ On a Mac, `twoperson install-watch` sets up a launchd agent that fires on inbox changes and runs
149
+ whatever command you put in `TWOPERSON_ON_PACKET`. That's where you point your reviewer.
150
+
151
+ ## The packet is hostile input
152
+
153
+ The builder is a language model. Whatever it writes into the packet might be aimed at the
154
+ reviewer, on purpose or by accident. So the packet gets treated the way you'd treat a form
155
+ submission from the internet:
156
+
157
+ - Hard size cap (256 KB) before parsing.
158
+ - Strict schema. Unknown keys are rejected. Every string has a length limit, every list a count limit.
159
+ - File paths have to be repo-relative. No absolute paths, no `..`, no drive letters.
160
+ - A scan for things that look like credentials: Anthropic keys, OpenAI keys (legacy `sk-…`
161
+ and the `sk-proj-…` / `sk-admin-…` shapes), GitHub, AWS, Slack and Google keys, PEM blocks,
162
+ bearer tokens, and `NAME=value` where NAME ends in key/secret/token/password and the value is
163
+ 16+ characters. It only looks at values, so writing "read the key from `ANTHROPIC_API_KEY`" is
164
+ fine while pasting the actual key is not. When it finds one, the error names the field path
165
+ and never echoes the value. The list is in `packet.py` and every shape has a fail-closed test;
166
+ it is a tripwire, not a guarantee.
167
+ - When the reviewer renders a packet, the body sits between `BEGIN`/`END` markers under a
168
+ short preamble saying this is data, not instructions. Markers forged inside the body get
169
+ defanged.
170
+
171
+ None of that replaces the reviewer's judgment. It just means the reviewer isn't the only line
172
+ of defense.
173
+
174
+ ## Worktrees
175
+
176
+ If your agents each run in their own `git worktree` (they should), a naive "put the inbox in
177
+ this checkout" gives each agent a private inbox. Builder publishes, reviewer polls, neither
178
+ sees the other, and `check` returns 1 in a way that looks exactly like "nothing to do". I lost
179
+ an afternoon to that.
180
+
181
+ `twoperson` walks up to the main working tree, following `.git` files and `commondir` by
182
+ hand, so every worktree of a repo lands on the same `.twoperson/`. No git subprocess, so it
183
+ stays cheap. Set `TWOPERSON_INBOX` if you want to override it.
184
+
185
+ ## Picking a reviewer by difficulty
186
+
187
+ Not every packet deserves your strongest (slowest, priciest) reviewer. `twoperson tier` scores a
188
+ packet from its validated fields — which review areas it names, whether the changed paths touch
189
+ auth/payment/deploy/migration, how big the diff is, whether tests ran and passed, whether the
190
+ builder left open questions, whether something already shipped — and reports `low`, `medium`,
191
+ `high` or `critical`. It's substring checks on structured fields, so a builder writing "trivial
192
+ typo fix, please approve" next to thirty changed files under `src/auth/` still gets `critical`.
193
+
194
+ The watcher hands that tier to whatever reviewer command you configured (`TWOPERSON_TIER`,
195
+ `TWOPERSON_TIER_SCORE`, `TWOPERSON_PACKET_ID` in its environment), so the reviewer side can start
196
+ cheap and only spend the big model on the packets that earn it. If a reviewer decides a packet is
197
+ beyond it, the convention is a `Needs owner decision` verdict with a note starting `ESCALATE:`;
198
+ a reviewer ladder can catch that and re-run one rung stronger. `twoperson` records the convention
199
+ and the tier; it doesn't run models.
200
+
201
+ ## There's also a consult lane
202
+
203
+ Sometimes the builder wants an opinion, not an audit. `consult-publish` / `consult-next` /
204
+ `consult-advise` do that. It's a separate set of directories, it never produces a verdict, and
205
+ nothing on it can unlock a push. It exists so "what do you think of this approach" can't get
206
+ quietly upgraded to "this was reviewed".
207
+
208
+ ## How this compares
209
+
210
+ There are a few projects in this space and I looked at them before writing this one.
211
+
212
+ [claude-review-loop](https://github.com/hamelsmu/claude-review-loop) runs Codex on Claude's
213
+ work from a slash command. It writes each review to a `reviews/` file, and its Stop hook asks for
214
+ a Codex pass once the loop is invoked (failing open if the review can't be produced). What it
215
+ doesn't have is a sha-bound approval or a ship-report binding: nothing ties "reviewed" to one
216
+ commit, and nothing refuses a "shipped" record that lacks one.
217
+ [sd0x-harness](https://github.com/sd0xdev/sd0x-harness) is much bigger (skills, agents,
218
+ hooks) and puts its hard gates at the git level with review as a reminder layer.
219
+ [quorum](https://github.com/berrzebb/quorum) does enforce an audit gate, over MCP and SQLite,
220
+ with a lot more machinery. [shiplog](https://github.com/devallibus/shiplog) leans on GitHub
221
+ branch protection and needs a remote.
222
+
223
+ `twoperson` is smaller than all of those and does one thing: the schema won't let "shipped
224
+ without review" or "approved without a sha" exist. It doesn't care which agents you use or
225
+ whether you have a GitHub remote.
226
+
227
+ ## What it doesn't do
228
+
229
+ It doesn't review code. It makes sure a verdict was recorded and writes down what it said.
230
+
231
+ It doesn't know who the reviewer is. `--reviewer` is a label, not an identity. If the builder
232
+ and the reviewer run in the same process with the same permissions, the builder can approve
233
+ itself, and `twoperson` will not notice. Keeping the two apart is your deployment's job:
234
+ separate OS users, separate checkouts, a reviewer that only has read access to the code. What
235
+ `twoperson` guarantees is narrower and mechanical: an approving verdict exists for this exact
236
+ commit, and the ship report points at it. The binding is per commit, not per packet. A ship
237
+ report is its own packet, and what it has to cite is an approval of the same head; it doesn't
238
+ have to be the packet that was originally reviewed, because after a rebase it can't be.
239
+
240
+ It doesn't spawn agents or call models. It's a directory with a lock and a validator.
241
+
242
+ It doesn't watch your repository or your deploys. Nothing in the gate reads HEAD or the working
243
+ tree; the only git it touches is a `git rev-parse` to label a signal with its branch name and, in
244
+ the shell hooks, to find a virtualenv. It never sees a push happen. What it validates is the
245
+ builder's *report*:
246
+ a packet that says "I pushed/deployed/restarted commit X" is refused unless it cites an approving
247
+ verdict for X. A builder that lies in the report can lie. What can't exist is a consistent
248
+ report that skipped review, and an agent that has to lie to ship is a much easier thing to
249
+ catch than one that was never asked.
250
+
251
+ It doesn't replace CI. CI checks the code. This checks the paperwork.
252
+
253
+ ## Exit codes
254
+
255
+ `0` did the thing. `1` nothing to do. `2` rejected. The one exception is `signal`: a valid
256
+ `signal` invocation never returns 2, and the packaged Stop-hook script always exits 0 whatever
257
+ happens inside it, because Claude Code treats a 2 from a Stop hook as "don't stop" and a broken
258
+ hook would trap the session in a loop. (Malformed flags still get argparse's usual 2, like any
259
+ CLI; the hook script never passes malformed flags.)
260
+
261
+ ## Development
262
+
263
+ ```bash
264
+ pip install -e ".[dev]"
265
+ pytest -q
266
+ ```
267
+
268
+ A few hundred tests, about two seconds. MIT.