git-agent-verdict 0.2.0

Verify that a commit message carries an attested review verdict
git-agent-verdict-0.2.0 is not a library.

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.

Gate names below (my-code-review) are repo-chosen labels, not keywords. A repo declares whatever gates it wants in its hook, one line each.

1. The agent finishes the work and commits.

$ git commit
git-agent-verdict: my-code-review: REVIEW GATE FAILED

MISSING — the message needs this trailer and has none

  Reviewed-my-code-review: reviewer=<id> major=0 moderate=<n> minor=<n>

Earned by a review you run yourself: spawn a reviewer in a fresh context, hand it the
block below, fix every MODERATE it names, then write the counts it REPORTED into the
trailer. Only major=0 passes; there is no re-review. Trailers must be the LAST paragraph.

── FORWARD BELOW THIS LINE ──
NEUTRAL REVIEW — gate: my-code-review

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 about where to look
...

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 and hands it that block with the INTENT filled in.

$ git agent-verdict --reviewer-prompt my-code-review | claude -p \
    "INTENT: the commit-msg hook delegates verdict verification to an external CLI
             and keeps only the gate declarations."
- KISS: none
- SoC: MODERATE — install.sh declares the roster twice and the gate holds neither
...
major=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, what was fixed, or what the author suspects tells the reviewer what counts, and it will find that and stop looking.

3. The agent fixes the MODERATE and writes down what the reviewer reported. Not what is left after the fix: the trailer is a record of the review, and moderate=1 is what this review found. The reviewer is not asked again — one look is all a gate buys, and the counts stand as it left them.

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: my-code-review: attested (1 verdict(s), major=0 moderate=1 minor=3)
git-agent-verdict: annotations: attested (5 verdict(s), major=0 moderate=0 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 pass was configured. claude -p is one runtime; 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-my-code-review: reviewer=claude-opus-5 major=0 moderate=1 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. moderate=1 is not an outstanding defect: it was fixed, and the count records that the review found it.

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] [--simple] [--override-prompt <path>]
                  --doc <path>... --path <pathspec>...
git-agent-verdict --rubric-guard --doc <path>...
git-agent-verdict --reviewer-prompt <gate>

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. The third prints a gate's reviewer block on stdout without failing anything first, and takes the gate name only: everything about how that gate is briefed — its docs, its ladder, its template — is read back out of the hook that declares it.

--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.

--simple makes the gate advisory. It still demands the trailer, so the review still has to happen and its findings still land on the record — but no count blocks, and the reviewer is briefed to say so. For a dimension worth a look and not worth a veto, which is otherwise the case a repo answers by not gating it at all.

--override-prompt <path> replaces the built-in reviewer block with a file of the repo's own, rendered verbatim apart from {{gate}}, {{docs}} and {{ladder}}. The default is what stops the brief becoming a per-repo file that drifts, so reach for this when a repo's review genuinely is not the default one — not to reword it.

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. The only rung that blocks.
  • MODERATE: the outcome is right, the execution is not. The author fixes it and does not go back for a second opinion on the fix.
  • MINOR: the author's discretion — fixed, or consciously left. Recorded either way.

The review runs once. Only major=0 is demanded, so the counts on a passing commit are what the reviewer found, not what survived it. moderate=2 is a commit whose two MODERATEs were fixed, and the trailer says a review happened and what it saw. That is what terminates: there is no round two to converge in.

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 comes back clean. Too loose and nothing is caught: the reviewer rubber-stamps. The loop itself was most of the cost — a full second review to confirm fixes the same reviewer had already specified — and MODERATE is what buys it away: real enough to have to fix, small enough that the fix does not need looking at. MAJOR is the one case where another review is genuinely cheaper than the alternative, and even it re-reviews nothing: the work is re-planned by an agent that did not write it, and comes back new.

MINOR is what keeps the count honest. A finding can be recorded without obliging anyone to act on it, 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.

--simple is the rung below the ladder. A gate whose findings are worth reading and not worth blocking on: the review is still demanded and still recorded, and the reviewer is told plainly that nothing it reports blocks, so it grades rather than negotiates.

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 says what is wrong and what that costs, where a number lets a weak dimension 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
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 --simple --doc docs/annotation-guide.md --path .
git agent-verdict "$1" prose       --simple --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.

Each line is the whole declaration of its gate. --simple and --override-prompt live here beside the docs rather than in a config file, so --reviewer-prompt <gate> can hand back exactly the brief that gate will judge by, read from the one place that states it.

Annotations and prose are --simple above, and that is the usual shape: one gate holds the bar the work has to clear, and the rest report. A repo that blocks on every dimension it cares about spends its review budget on the ones that were never going to sink the change.

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 — --override-prompt is the deliberate exception, and costs a repo that file.

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:

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