In Lesson 3 the gate was a CLAUDE.md rule — words the model follows. Now we replace words with a script: a check that physically blocks the turn from ending until it passes.
Everything you've used so far asks the model to verify. A CLAUDE.md rule, a rubric, a delegation habit — the model reads them and almost always complies. But "almost always" is a probability, and the docs draw the line crisply: hooks are for "actions that must happen every time with zero exceptions" — deterministic, not advisory. (Hooks)
The Stop hook is where your verifier stops being a request and becomes a law of physics for the session.
When Claude tries to end its turn, the harness fires the Stop event and runs your script. The script gets a JSON payload on stdin (session id, transcript path, cwd…). Then a single contract:
{"decision":"block","reason":"…"} and exit 0.)
Read that middle line again, because it's the loop: the stderr of your check is the agent's next clue. "Error messages are themselves feedback" (Writing tools for agents) — a Stop hook that prints "FAILED" gives the model nothing; one that prints the failing test output hands it the fix.
Runaway protection: a hook that always fails could trap the session in work-forever. Two safety rails: Claude Code overrides the gate after 8 consecutive blocks (best practices), and the stdin payload includes stop_hook_active — true when the turn is already being continued by a Stop hook — so your script can implement its own escape policy.
Two files, both versioned in the repo. Note what's absent: no CLAUDE.md rule about tests anywhere. That's the experiment.
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/scripts/check.sh",
"timeout": 120 }
]
}
]
}
}
Stop hooks fire on every turn end — they take no matcher. Project-level .claude/settings.json is committed and shared; use .claude/settings.local.json for a personal-only gate.
#!/usr/bin/env bash # Stop hook: the turn may not end while the test suite is red. input=$(cat) # consume the hook's JSON payload cd "$(dirname "$0")/.." || exit 0 if npm test >/tmp/stop-hook-test.log 2>&1; then exit 0 # green — the turn may end fi { echo "BLOCKED: npm test is failing. The turn may not end until the suite is green." # the failing-tests summary, minus stack-frame noise sed -n '/failing tests:/,$p' /tmp/stop-hook-test.log | grep -vE "^ +at " | head -25 grep -E "^ℹ (tests|pass|fail)" /tmp/stop-hook-test.log } >&2 exit 2 # red — block, stderr becomes Claude's feedback
Note the filtering instead of a raw tail: a blind tail catches stack-trace noise and can cut off the expected-vs-actual values — the most diagnostic lines. This script went through two revisions during testing before its stderr satisfied the lesson's own "actionable on failure" rule. Engineering the verifier is the work.
The demo repo has a seeded bug (two failing tests) that nobody mentions. You ask for something unrelated:
BLOCKED: npm test is failing. The turn may not end until the suite is green. ✖ failing tests: ✖ parses a combination AssertionError: Expected values to be strictly equal: + actual - expected + 3780000 - 5400000 ✖ parses a full combination AssertionError: Expected values to be strictly equal: + 3795000 - 5415000 ℹ tests 8 ℹ pass 6 ℹ fail 2
Fast. It runs on every turn end. A 90-second suite makes every turn cost 90 seconds — Ronacher's "speed is a feature" applies doubly here. Gate on the fast tier (unit tests, typecheck), not the slow one (e2e).
Deterministic. A flaky test in a Stop hook is a randomly locking door. Quarantine flakes before gating.
Actionable on failure. Print the failing output, not a verdict word.
Scoped. A Stop hook is wrong for exploratory or Q&A sessions — it'll demand green tests when you asked a question. Put aggressive gates in settings.local.json, or have the script exit 0 when nothing relevant changed (e.g. git diff --quiet src/ test/ && exit 0 — only gate when code actually moved).
cd ~/projects/stop-hook-demo && claude, approve the hook when prompted, and ask only for the README Usage section — then watch the gate refuse to let the turn end. Debrief here afterward.