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.

SurfaceQuestion it answersCommandFires 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 installAnyone runs git commit
Done-signal“Has this feature actually landed?”ctxgrd status --lineage <ID> --exit-codeA 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:

ctxgrd
ok: 279 documents · 125 rules · 0 diagnostics

Wiring 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 claude
Claude 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=0

This 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-ctxgrd

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

HarnessTurn-end hookFlagObject it emits
Claude CodeStop, SubagentStop--harness claude{"decision":"block","reason":…}
Codex CLIStop--harness codex{"decision":"block","reason":…} – same shape as Claude Code
Gemini CLIAfterAgent--harness gemini{"decision":"deny","reason":…}
AntigravityStop--harness antigravity{"decision":"continue","reason":…}
opencodesession.idlenoneNone – 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 codex

How 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:

DialectSource of its wire shape
clauderepository – shipped and in use since ADR-062
codexrepository – read at the consuming call site in codex-rs/core/src/session/turn.rs
geminirepository – docs/hooks/reference.md in google-gemini/gemini-cli
antigravitydocumentationantigravity.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 1

    This 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