Verifying agent work with ctxgrd
Wire ctxgrd as an external check on an AI coding agent, so that “I’m done” has to survive something the agent does not control.
An agent that both does the work and judges the work will eventually mark
unfinished work finished. Not from malice – it has no independent view of the
contract it was given. ctxgrd supplies that view: the rules live in
ctxgrd.toml, the agent cannot edit its way past them without the edit itself
showing up, and the verdict arrives as an exit code rather than a paragraph of
self-assessment.
There are three places to put that check. They answer different questions and compose; wiring one does not commit you to the others.
| Surface | Question it answers | Command | Fires when |
|---|---|---|---|
| Turn-end gate | “Can this agent stop talking yet?” | ctxgrd lint --harness <name> | The agent tries to end a turn |
| Commit gate | “Can this change enter history?” | ctxgrd hooks install | Anyone runs git commit |
| Done-signal | “Has this feature actually landed?” | ctxgrd status --lineage <ID> --exit-code | A supervising loop polls |
The turn-end gate is the tightest loop and the one most people have never wired. The commit gate catches whatever the turn-end gate missed, including work done by humans and by agents you did not configure. The done-signal is for an outer loop deciding whether to keep going at all.
Before you start
You need a working ctxgrd.toml – a project where ctxgrd already reports
something meaningful. If ctxgrd prints ok: 0 documents, no gate you wire
will ever fire, because nothing is claimed. Start with
Getting started, and read
How ctxgrd decides what to lint if the
document count is lower than you expected.
Check your baseline before wiring anything:
ctxgrdok: 279 documents · 125 rules · 0 diagnosticsWiring a gate on a tree that is already failing means the gate blocks
immediately and you will not be able to tell a real catch from your own
backlog. Get to zero diagnostics first, or scope the gate with --namespace
until you do.
Surface 1 – The turn-end gate
This is the check that runs when the agent believes it has finished. In Claude
Code the hook is called Stop; it fires as the agent ends a turn, and it can
refuse.
Ask ctxgrd for the wiring:
ctxgrd hooks claudeClaude Code Stop-hook — a turn-end lint gate (ADR-062).
Add this to .claude/settings.json (project) or ~/.claude/settings.json (global):
{
"hooks": {
"Stop": [
{
"hooks": [
{
"command": "command -v ctxgrd >/dev/null 2>&1 || exit 0; exec ctxgrd lint --harness claude",
"type": "command"
}
]
}
]
}
}
It runs `ctxgrd lint --harness claude` when the agent ends a turn;
an error-severity diagnostic blocks the turn until it is fixed. Warnings
never block, and a clean run is silent.
not wired: no ctxgrd `--harness claude` Stop hook found in project or global settings.The command prints the JSON and tells you whether it is already installed. It
does not write settings.json for you. That file is shared, user-global agent
configuration, and a tool that rewrites it can drop hooks belonging to
something else. Paste the block in yourself, or merge it into an existing
"hooks" object if you have one.
The command -v ctxgrd >/dev/null 2>&1 || exit 0 prefix matters: on a machine
where ctxgrd is not installed, the hook exits silently instead of breaking
every turn with a “command not found”.
What the agent sees
On a tree with an error-severity diagnostic:
ctxgrd lint --harness claude{"decision":"block","reason":"Verification failed:\ndocs/adrs/001-example.md:0:0: error: [core.required-metadata] required metadata key 'date' is missing or empty\nFix before completing.\n"}The agent does not get to stop. It receives reason as feedback and keeps
working. Because the diagnostic names the file, the rule, and what is missing,
it is usually enough to act on without further prompting.
On a clean tree, or a tree with only warnings, the command prints nothing at all and the turn ends normally. Warnings are advisory by design – a gate that blocks on style opinions gets switched off within a day.
The gate will not block the same turn forever
A turn-end gate that always blocks on a failing tree is an infinite loop: the agent tries to stop, the gate refuses, the agent tries to stop again. Claude Code prevents this by telling the hook when it is already inside a blocked stop, and ctxgrd honours that signal.
The hook receives a JSON payload on stdin. If it carries
"stop_hook_active": true, ctxgrd does no work and stays silent – the turn
ends even though the tree is still failing:
echo '{"stop_hook_active":true}' | ctxgrd lint --harness claude(no output)The same tree, on a first stop, blocks:
echo '{"stop_hook_active":false}' | ctxgrd lint --harness claude{"decision":"block","reason":"Verification failed:\n…"}So the gate gets the agent one corrective pass, not unlimited passes. If the agent cannot fix the diagnostic in that pass, the turn ends and the failure is yours to look at – which is why the commit gate below is worth having as well.
One consequence for manual testing: ctxgrd only reads stdin when it is not a
terminal. Running the command yourself in an interactive shell is safe, but
invoking it from a script that leaves stdin open will block until that stream
closes. Redirect it (</dev/null) when in doubt.
The exit code is not the signal here
--harness claude always exits 0, including when it blocks:
ctxgrd lint --harness claude; echo "exit=$?"{"decision":"block","reason":"Verification failed:\n…"}
exit=0This is deliberate and it is the one thing that surprises people. Claude Code
reads the decision object on stdout, not the exit status; a non-zero exit
would be read as “the hook itself crashed” rather than “the work is not done”.
If you are testing the gate by hand, check the output, not $?.
Everywhere else in ctxgrd – plain lint, status --exit-code, the commit
gate – the exit code is the contract (0 clean, 1 diagnostics, 2 config
error). --harness is the exception, because the harness on the other end
speaks a different protocol.
Surface 2 – The commit gate
The turn-end gate only constrains agents you have configured. The commit gate constrains everything that reaches your history.
ctxgrd hooks install.githooks/pre-commit.d/10-ctxgrd
Installed ctxgrd as a composable pre-commit fragment under the tracked
.githooks/ directory and set core.hooksPath -> .githooks.
The shared run-parts runner at .githooks/pre-commit dispatches every executable
fragment in pre-commit.d/ (10-ctxgrd before a sibling *grd's 50- gate) and
aborts on the first failure.
Bootstrap a fresh clone with scripts/setup-hooks.sh.
Remove ctxgrd's gate with `rm .githooks/pre-commit.d/10-ctxgrd`.Use --dry-run first if you want to see what it would touch without touching
it. Add --format json for a machine-readable result
({"status":"would-install","path":".githooks/pre-commit.d/10-ctxgrd"}).
Two things are worth understanding about this layout.
It does not claim the single pre-commit slot. ctxgrd installs a fragment
at .githooks/pre-commit.d/10-ctxgrd and a shared runner at
.githooks/pre-commit that executes every executable fragment in lexical order,
aborting on the first failure. A sibling tool dropping in 50-something coexists
instead of overwriting you. Re-installing refreshes only ctxgrd’s own fragment
and never touches a neighbour’s.
The hooks live in the repo, not in .git/. core.hooksPath is set to
.githooks, which is tracked. A fresh clone gets the gate as soon as someone
runs git config core.hooksPath .githooks (or your scripts/setup-hooks.sh).
Hooks under .git/hooks/ would be per-clone and invisible to everyone else.
The generated fragment:
#!/bin/sh
command -v ctxgrd >/dev/null 2>&1 || {
echo 'ctxgrd not found on PATH — install it or remove this fragment' >&2
exit 1
}
export CTXGRD_COMMIT_CONTEXT=1
exec ctxgrd --root "."CTXGRD_COMMIT_CONTEXT=1 tells rules they are running at commit time. The
agents.context-cache rule uses it to warn about edits to CLAUDE.md /
AGENTS.md that would bust every future session’s prompt cache – a thing
worth knowing about at commit time and pure noise during an editor lint.
Note the asymmetry with the turn-end gate: this fragment does exit non-zero to abort the commit, because git reads exit codes. Same binary, same rules, different signalling convention at each end.
To remove the gate, delete the fragment:
rm .githooks/pre-commit.d/10-ctxgrdSurface 3 – The done-signal
The first two gates answer “is anything broken right now”. Neither answers “has this feature finished”, which is the question an outer supervising loop needs.
ctxgrd status --lineage PRD-3 --exit-code--lineage scopes to one feature by walking the depends_on graph in reverse,
so a loop driving one PRD is not blocked by unrelated in-flight work elsewhere
in the repo. Here the exit code is the contract again: 0 nothing stuck, 1
something is blocked on a non-terminal dependency, 2 config error or cycle.
Read 0 carefully – it means “nothing is stuck”, not “everything landed”.
That surface has its own guide, including the poll loop, the JSON fields, and how to fold Definition-of-Done checkboxes into the check: Polling a feature done-signal in an agent loop.
If your harness is not Claude Code
--harness speaks four dialects. Pick the one matching your agent CLI and
wire it into that harness’s turn-end hook – you do not translate anything by
hand.
| Harness | Turn-end hook | Flag | Object it emits |
|---|---|---|---|
| Claude Code | Stop, SubagentStop | --harness claude | {"decision":"block","reason":…} |
| Codex CLI | Stop | --harness codex | {"decision":"block","reason":…} – same shape as Claude Code |
| Gemini CLI | AfterAgent | --harness gemini | {"decision":"deny","reason":…} |
| Antigravity | Stop | --harness antigravity | {"decision":"continue","reason":…} |
| opencode | session.idle | none | None – an event notification, with no channel to refuse |
Every dialect behaves the same way: an error-severity diagnostic emits that object on stdout, a clean or warning-only run emits nothing, and the command always exits 0. Block-versus-allow rides the JSON, not the exit code, because a non-zero exit reads to these harnesses as a crashed hook rather than as unfinished work.
# Codex, Gemini and Antigravity -- substitute your harness name
ctxgrd lint --harness codexHow well-verified each dialect is
Three of the four were derived from the harnesses’ own source. Antigravity’s was not, and you should know which one you are trusting:
| Dialect | Source of its wire shape |
|---|---|
claude | repository – shipped and in use since ADR-062 |
codex | repository – read at the consuming call site in codex-rs/core/src/session/turn.rs |
gemini | repository – docs/hooks/reference.md in google-gemini/gemini-cli |
antigravity | documentation – antigravity.google/docs/hooks, not confirmed against a repository read |
If the antigravity object turns out to be wrong, the symptom is your session
ending on a lint diagnostic instead of the agent being asked to fix it. Report
it and it is a one-line change: the shape lives behind a single named constant
in src/run.rs.
The trap this saves you from
If you wire one of these by hand anyway, do not reach for a “stop” or
continue: false field to express the refusal. Codex is the clearest case: its
Stop hook reads two independent fields. decision: "block" sets
should_block, and the engine responds by feeding your reason back and
continuing the turn – the behaviour you want. continue: false sets
should_stop, and the engine responds by breaking out of the turn loop
entirely. Both are refusals in plain English; only the first asks the agent to
fix anything. Gemini’s AfterAgent splits the same way – decision: "deny"
retries, continue: false ends the session. --harness never emits a
continue field for any dialect.
Note the naming asymmetry across vendors: Antigravity uses
decision: "continue" to mean “prevent the stop”, where Codex and Gemini use
continue: false to mean “end the session”. Same word, opposite intent.
Looping is already handled for you
A hook must not refuse the same turn forever, or a document the agent cannot
fix – one needing a decision only you can make – puts it in a refusal it can
never satisfy. --harness handles this in every dialect; you do not need the
wrapper script this guide used to recommend.
Claude Code, Codex and Gemini each pass a boolean stop_hook_active into the
hook to say “you are already inside a blocked stop”. In all three it is
informational – the engine does not enforce a skip – so ctxgrd reads it and
lets the turn end.
Antigravity is the exception, and it is worth knowing about if you wire
anything else alongside ctxgrd: it documents no stop_hook_active and gives
exit codes no meaning, so there is no field saying “you already refused once”.
--harness antigravity uses executionNum from the payload instead, refusing
only on the first pass through the execution loop. That grants exactly one
retry – the same budget the other three get.
For opencode, there is no turn-end gate to wire. session.idle is a bus
event with no reply channel, so nothing you attach to it can hold the turn
open. This is structural rather than a missing feature. Use the other two
surfaces instead:
The commit gate (Surface 2) catches the work before it enters history, independent of any harness.
The exit-code contract in a wrapper script, if you drive opencode from CI or a task runner:
opencode run "$TASK" ctxgrd || exit 1This checks after the fact rather than blocking mid-turn, so the agent has already stopped. You lose the self-correction loop, but the bad state still cannot escape unnoticed.
Choosing what to wire
Wire the commit gate first. It is one command, it constrains humans and agents alike, and it has no harness dependency.
Add the turn-end gate if you run Claude Code, Codex, Gemini CLI or Antigravity – each has a dialect – and want the agent to fix its own output before you ever see it. This is where the largest behavioural change comes from – the agent’s self-assessment stops being the last word, and the correction happens while it still has full context on what it was doing.
Add the done-signal when you have a supervising loop that needs to decide whether to dispatch more work, rather than a human watching the output.
One thing all three share: they check the documents against the rules, not the
agent’s account of the documents. If a rule is not in ctxgrd.toml, no gate
enforces it. The gates are only as good as the contract you wrote, which is why
ctxgrd.toml deserves review attention that a lint config usually does not get.
Cross-references
- Getting started – first
ctxgrd.toml. - How ctxgrd decides what to lint –
why a file is or is not claimed, and why
ok: 0 documentsis a warning sign. - Polling a feature done-signal in an agent loop – Surface 3 in depth.
- CLI reference – every command and flag.
- ADR-014 – commit-gate and CI design, including the composable fragment layout.
- ADR-062 – the
Claude Code
Stop-hook gate and why it never writessettings.json. - ADR-086 – the
*grd-family output and exit-code contract the gates signal through. - ADR-119 – why a
configuration that claims nothing must not report
ok.