git-agent-verdict
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.
$ 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.
$ my-code-review --json "$(git agent-verdict --reviewer-prompt standards --doc docs/repo-standards.md)
INTENT: the commit-msg hook delegates verdict verification to an external CLI 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"}
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.
$ 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.
$ 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:
| What we asked for | What we got |
|---|---|
| "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
#!/usr/bin/env bash
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: