Verifies that a commit message carries an attested review verdict, and blocks on any declared
blocker. It is blind to what the standards say: the reviewer reads the standard, the gate reads a
verdict.
Built for repos where commits are written by agents. No human is expected to write a trailer.
**Why it exists:** so a repo can stay about its own code. Every repo that wants agent-written commits
reviewed ends up growing the same few hundred lines of bash to demand and check an attestation, and
every copy drifts from the others. That machinery is not what any of those repos are for. This is
that machinery, once, outside them.
## How it goes
The agent is never briefed on the review process. It is told to do the work, it does it, and it
tries to commit. The commit fails by design, and the gate prints what it wants.
**1. The agent finishes the work and commits.**
```console
$ git commit
git-agent-verdict: standards: REVIEW GATE FAILED
MISSING — the message needs this trailer and has none
Reviewed-standards: reviewer=<id> major=0 moderate=0 minor=<n>
Earned by a review you run yourself: spawn a reviewer in a fresh context, hand it
the block below, iterate `re-review` until it reports major=0 and moderate=0, then
write its counts into the trailer. Trailers must be the LAST paragraph.
── FORWARD BELOW THIS LINE ──
NEUTRAL REVIEW — gate: standards
Hand a reviewer in a FRESH context exactly this block with the INTENT filled in, plus
where the repo is, and nothing else. Past that the diff is the only signal it gets
...
```
The missing trailer comes first, then the prompt that earns it. Target and remedy in one turn.
**2. The agent spawns a reviewer** in a fresh context, hands it that block verbatim with one slot
filled in, and keeps the session id.
```console
and keeps only the gate declarations."
{"session_id":"a6f63e4b","result":"- KISS: none\n- SoC: MODERATE — install.sh declares\nthe roster twice and the gate holds neither\n...\nmajor=0 moderate=1 minor=3"}
```
`--reviewer-prompt` takes the gate name and nothing else: the docs come from the `commit-msg` hook,
which already declares them, by re-running it in a mode where each gate prints its declaration
instead of validating. The shell expands whatever the hook wrote, so a `--doc \"$KB/standards.md\"`
resolves the same way it does at commit time.
The `INTENT` is flat, and it is the only thing added. Naming what changed, or what the author
suspects, tells the reviewer what counts, and it will find that and stop looking.
**3. The agent fixes the MODERATE, then resumes that same reviewer with one word.**
```console
$ my-code-review --resume a6f63e4b "re-review"
major=0 moderate=0 minor=3
```
Not a new reviewer, which would need re-briefing and would arrive with no memory of its own
findings. Not a summary of what was fixed, which would steer the second look exactly the way the
first was kept from being steered.
**4. Repeat per gate, in the hook's order, never in parallel.** A gate judging annotations must not
run while the gate judging code is still changing them.
**5. Commit again.**
```console
$ git commit
git-agent-verdict: standards: attested (1 verdict(s), minor=3)
git-agent-verdict: annotations: attested (5 verdict(s), minor=2)
git-agent-verdict: prose: skipped (no staged file matches README.md, CONTRIBUTING.md)
[main 4f2a1c8] Delegate the commit-msg review gate to git-agent-verdict
```
Nothing in that loop was configured, and `my-code-review` is a stand-in: the gate never sees the
reviewer, only the trailer it produces. The requirement was printed at the moment it was wanted,
which is what keeps the process out of the agent's briefing and out of the repo's docs.
## The commit message it produces
```
Delegate the commit-msg review gate to git-agent-verdict
The hook implemented verdict-checking itself in 267 lines of bash, duplicated in
two sibling repos with three distinct md5s between them. That mechanism is now an
external CLI, and the hook keeps only the gate declarations.
Reviewed-standards: reviewer=claude-opus-5 major=0 moderate=0 minor=3
Reviewed-annotations: reviewer=claude-opus-5 major=0 moderate=0 minor=1 file=src/gate.rs
Reviewed-annotations: reviewer=claude-opus-5 major=0 moderate=0 minor=0 file=src/trailer.rs
Reviewed-prose: reviewer=claude-opus-5 major=0 moderate=0 minor=0
```
Subject, body, then the trailers as the last paragraph. One per gate, plus one per file where the
gate is `--per-file`. The counts are the verdict, and they are on the record with a name against
them.
## Trust model
The gate verifies that an attestation is PRESENT and WELL-FORMED. It cannot verify that a review
happened. What it buys is attribution, not enforcement. A false attestation passes, but it is then a
claim on the record with a name on it.
## The trailer
```
Reviewed-standards: reviewer=claude-opus-5 major=0 moderate=0 minor=3
Reviewed-annotations: reviewer=claude-opus-5 major=0 moderate=0 minor=1 file=src/trailer.rs
```
Named fields, so a per-file gate adds one instead of changing the arity. `file=` is terminal, so a
path may contain spaces. Trailers must be the message's last paragraph. That is what
`git interpret-trailers --parse` reads, and a trailer written above the body is invisible to it.
The tool detects that case by name rather than reporting the trailer as missing.
The counts are the whole verdict, and asking for three numbers rather than a verdict is deliberate.
A reviewer that reports `major=0` has committed to a number it can be held to, which matters
precisely because the writer is an LLM.
## Usage
```
git-agent-verdict <msg-file> <gate> [--per-file] --doc <path>... --path <pathspec>...
git-agent-verdict --rubric-guard --doc <path>...
```
Installs as `git agent-verdict`. Every list is a repeated singular flag. No variadic can absorb the
token meant for its neighbour, which would otherwise leave a gate silently skipped.
The second form is the rubric preflight, described below. It reads the index alone, so it takes
neither a message file nor a gate.
`--path` goes straight to `git diff --cached`, so git's pathspec syntax comes free. No staged file
matching it means the gate is skipped, and the tool says so: a mis-scoped pathspec otherwise looks
identical to a passing commit. A literal `--path` naming nothing git tracks is a typo, and fails.
`--per-file` demands one trailer per staged file, deletions excluded, with the list taken from git
rather than from the author. An author-supplied list decides what gets looked at, and that is where a missed file hides.
It is the one check the author cannot fake by scoping.
Auto-generated messages (`Merge`, `Revert`, `fixup!`, `squash!`) carry no review and pass. `Merge`
and `Revert` are trusted only when git's own in-progress state confirms them.
Exit status: 0 pass or skip, 1 gate failed, 2 usage or git error.
## The ladder
Three rungs, graded by what is wrong, not by what the fix costs:
- **MAJOR**: the work is wrong, or carries a severe flaw. Not the author's to patch: the fix is
re-planned by an agent that did not write it, then implemented and reviewed afresh.
- **MODERATE**: the outcome is right, the execution is not. The author fixes it, and the review
runs again.
- **MINOR**: fixable without another look. Never blocks; required, so a real nit has a home instead
of being inflated into a blocker.
Iterate fix -> re-review until MAJOR and MODERATE are both zero. That terminates cleanly because
both blocking buckets mean the same thing: there is work the reviewer has not judged yet. When
there is none left, the loop is over.
A review-and-fix loop fails in two directions, and the band between them is narrow. Too tight and
nothing ever settles: every nit blocks, so no round converges. Too loose and nothing is caught: the
reviewer rubber-stamps.
**MINOR is what makes zero-zero reachable.** A finding can be recorded without restarting the review,
so nothing is suppressed and no reviewer is asked to pretend the code is clean. Findings are
declassified, never hidden, which keeps the pressure on the code rather than on the report. The
loop ends when the reviewer has seen everything that matters, not when it runs out of things to
say.
**The brief carries one thing: intent.** What the change set out to do, stated flatly, as a spec
would state it: no reason it is worth doing, no defence of the approach, no account of what it
replaces. A reviewer handed a case for the change grades the case, and that is most of what stands
between a review and a rubber stamp.
**Scope is not a reviewer's question.** It was settled before a plan existed, and the reviewer sees
neither the issue nor the case for the change. A scope observation comes back as one MINOR line,
never as grounds to re-plan.
A severity, not a score. It drives the weak dimension to zero directly, where a number lets it hide
behind strong ones and invites argument that cannot change the outcome. Two shapes tried before
this one, both of which never settled:
| "Report any issues you can find." | It always found one, however minuscule. No round came back empty. |
| "Stop when every criterion scores above 9/10." | Driving DbC past 9 pushed KISS under it, and the next round traded back. |
## Wiring it into a repo
```bash
#!/usr/bin/env bash
set -euo pipefail
git agent-verdict --rubric-guard \
--doc docs/repo-standards.md --doc docs/annotation-guide.md --doc docs/communication-style.md
git agent-verdict "$1" standards --doc docs/repo-standards.md --path .
git agent-verdict "$1" annotations --per-file --doc docs/annotation-guide.md --path .
git agent-verdict "$1" prose --doc docs/communication-style.md --path README.md
```
Line order is review order. `set -e` stops at the first unattested gate on purpose: a later gate
must never be judged against content an earlier one is still changing.
When a trailer is missing the tool prints it first, then the reviewer prompt that earns it, so an
agent has the target and the remedy in one turn. The other failures name what is wrong and stop. The prompt is embedded in the binary, which is what
stops it becoming a per-repo file that drifts.
## The one edit it makes
A `Co-authored-by:` trailer whose address is `@anthropic.com` is removed from the message. Every
commit in a repo gated this way is agent-written, so a fixed attribution line is constant and
carries nothing, while `reviewer=` in each verdict already records who did the work.
The match is on the address, not the name, so a human co-author called Claude keeps their credit,
and no other trailer is touched. `Signed-off-by`, DCO trailers and anyone else's `Co-authored-by`
survive untouched.
This is the only case where the tool writes to the message rather than reading it.
## Circular-rubric guard
A commit that stages one of its own `--doc` files is refused, and told to land alone via
`--no-verify`. Judging a change to the measure against that same measure is circular.
This is the one place the tool refuses rather than verifies. It lives here because the list of
rubrics IS the list of `--doc` paths, and a copy in bash would be free to drift from it. Docs
outside the worktree can never be staged, so the check is a no-op for them.
## The rubric preflight
A gate sees only its own `--doc` paths, so on its own it cannot refuse on behalf of a gate further
down the hook. Staging a later gate's rubric would cost one full review of an earlier gate before
the refusal arrives, and that wasted review is what `--rubric-guard` removes:
```
git agent-verdict --rubric-guard --doc <path>...
```
It holds every gate's rubrics at once and runs first in the hook, so the refusal arrives before any
review is asked for. It reads the index and nothing else: no message file, no gate, no `--path`.
Staged rubrics are named and the commit is refused with exit 1; otherwise it exits 0 in silence.
**It is an optimisation, not the check.** The per-gate guard stays, and stays the correctness
backstop, so a rubric this list has drifted away from costs an early exit and never a missed rubric.
That is what makes stating the paths a second time safe here, where sharing a list between gates
would not be.
`--rubric-guard` with no `--doc` is a usage error (exit 2), as it is for a gate: a preflight
guarding nothing is a hook that has silently stopped guarding. So is a `<msg-file>`, `<gate>`,
`--path` or `--per-file` given alongside it, since the mode has no use for any of them. A `--doc`
that does not resolve on disk is a usage error in both modes, because a rubric that exempted itself
by being mistyped is the drift the guard exists to prevent.
## Developing this repo
The two linters the pre-commit hook calls are pinned at install time, so the hook stays a flat list
of commands with no wrapper script holding a version:
```bash
npm i -g annotated-tree@0.6.0
cargo install --git https://github.com/fredrikolis/cargo-lint-extra \
--rev 7a232179e45414108d28047acd6315d9a2c4946b --locked cargo-lint-extra
git config core.hooksPath .githooks
```