- An AI agent says "done" the way it says everything else: confident, fluent, and often without having checked. The word is cheap to produce and expensive to trust.
- A line in the prompt asking it to verify is a request. A hook on
Stopis enforcement. The hook runs in the harness and the model gets no vote. - The Stop hook can't read the model's intent, so it reads evidence instead: it checks the session transcript, or a small proof file the agent has to write, before it's allowed to end its turn.
stop_hook_activeis the escape hatch. Without it, a gate that can never be satisfied loops forever. The hook stands down after forcing one continuation.- A second gate on
PostToolUsegrades the code the agent just wrote and feeds the verdict back. That one only injects an opinion; the edit already ran. Only the Stop hook guards the exit.
"Done." "It works." "Perfect, all tests pass." Those are the most expensive words an AI agent will ever say to you, because most of the time it hasn't checked. It pattern-matched its way to a confident sentence and stopped. The code might be fine. It might also be broken in a way that costs you an hour to find, precisely because you trusted the word "done" and moved on to the next thing.
This is part 3 of my series on Claude Code hooks. Part 1 was about context rot, the slow decay of a session that's been running too long. Part 2 was guardrails, catching one dangerous command before it runs. This one is about the gap between an agent saying it finished and the work actually being finished. The hook that closes that gap fires on Stop.
A prompt asks. A hook enforces.
I used to handle this with a line in the system prompt. Something like "always run the tests before you report success, and if you can't test, say so." It helps. It's also the first thing to evaporate when the context gets long or the task gets hard. A prompt is a request. The model is free to forget it, reinterpret it, or quietly decide this particular case is the exception. Under pressure it usually does exactly that.
A hook works at a different layer. It runs in the harness, not inside the model, so the model doesn't get a vote. When the agent tries to end its turn, my code runs first and decides whether that's allowed. That's the whole reason this lives in a hook instead of a hopeful paragraph of good intentions.
What the Stop hook sees
The Stop hook fires the moment the agent decides it's done talking. Claude Code hands it a small JSON blob on stdin:
{
"hook_event_name": "Stop",
"transcript_path": "/path/to/session.jsonl",
"stop_hook_active": false
}
Two fields carry the weight. transcript_path points at the full session log, so the hook can look back at what actually happened in the turn. stop_hook_active tells me whether I've already forced a continuation once. I'll come back to that one, because it's what keeps this whole thing from turning into an infinite loop.
The decision is top-level. To keep the agent working, the hook prints a block:
{
"decision": "block",
"reason": "No test run found this session. Run the suite and report the result before you stop."
}
This is one of the spots where it's easy to invent a field that doesn't exist. The Stop decision sits at the top level of the JSON. Other hooks tuck their output inside a hookSpecificOutput object; this one does not. That reason string goes straight back to the agent as its next instruction. If you'd rather not assemble JSON, exit 2 with a single line on stderr does the same job. Either way the session doesn't end. The agent reads the reason and keeps going.
How the hook knows the work was verified
The interesting question is how a shell script decides whether anything was actually checked. It can't see the model's intent. So it goes looking for evidence instead. Two mechanisms, and you only need one:
- Scan the transcript.
transcript_pathis right there in the input. The hook reads back through the session for a tool call that ran the test suite, or the linter, or the build, and checks whether it passed. No such call anywhere in the turn, no exit. - Demand a marker. Simpler and more honest: the agent has to write a small proof file before it's allowed to stop. It writes down the command it ran and the result it got. The hook checks that the file exists and is fresh. If the agent never ran anything, there's nothing to write, and the gate stays shut.
I lean on the marker. Grepping a transcript for "did a test really pass here" gets fuzzy fast, and fuzzy is the enemy of a gate. A file that says "ran X, got Y" is a thing I can check in one line and trust.
The infinite loop problem
Here's the failure mode that bites everyone who builds one of these. The agent says done. The hook blocks: no proof. The agent has no way to produce proof, because there are no tests, or the task genuinely had nothing to run. It says done again. The hook blocks again. You've built a machine that can never stop, burning tokens in a circle.
That's what stop_hook_active is for. The first time my hook blocks, the harness flips that flag on the next Stop. When I see it's already true, I know I forced a continuation once and the agent still came back empty-handed. So the hook stands down and lets the session end, usually after logging a quiet note that the gate was bypassed this time. A gate that can never be satisfied protects nothing and just hangs the session. The escape hatch has to be in the design from the first line. You learn that the hard way once, when your terminal locks up grinding out the same refusal over and over.
Any hook that blocks an exit needs an answer to "what if the block is impossible to clear?" Without one, the first un-runnable task turns your quality gate into an infinite loop. stop_hook_active is the harness handing you that answer for free.
Grading the code it just wrote
The Stop hook guards the exit. A second gate works further upstream, on PostToolUse, firing right after the agent edits a file. This one can't block, because the edit already happened by the time the hook runs, but it can talk back. After a write, the hook runs the changed file through a handful of cheap, mechanical checks: how deeply nested the functions are, whether there are bare magic numbers with no name, whether the type hints quietly disappeared, whether a 200-line function is doing six jobs while pretending to do one. Each principle gets a rough score.
Then it does the one thing that makes this worth running. It feeds the verdict back to the agent as additionalContext, parked right next to the tool result:
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Quality check: nesting 4 deep in handle(); 2 unnamed constants. Tidy before moving on."
}
}))
Note where additionalContext lives here: inside hookSpecificOutput. That's the opposite of the Stop hook's top-level decision, and mixing them up is the quickest way to write a hook that silently does nothing. The agent sees "nesting is four deep, the constant on line 12 has no name" while the change is still warm, and it tends to clean things up on the next pass without being asked. The same checks lean on old, boring ideas like keeping it simple, which a tired model forgets the second the task gets interesting.
Worth being precise about the limit. This gate has no veto. The file is already on disk by the time the hook runs. All it can do is hand back an opinion. But an opinion delivered at the right moment, before the agent has moved on, lands far better than a code review three days later when nobody remembers why the function looks like that.
Forcing the recon before the writing
The gates so far catch trouble after it exists. The cheapest bug is the one the agent never writes, and that means getting it to look before it leaps. A UserPromptSubmit hook fires when I send a message, before the model sees it, and can staple a short instruction onto the front of my prompt through additionalContext.
For anything that smells like an implementation task, it injects a tiny checklist: read the files you're about to change first, find the pattern that's already there and match it, state your plan in one line before you touch anything. It's the same advice I'd give a junior in their first week, except it arrives every single time instead of whenever I remember to say it. An agent that reads the surrounding code before writing produces code that looks like it belongs. One that skips that step writes a lonely island of a function in its own private style, and you pay for it at review.
Closing the loop on todos
The last gate is small and a little dumb and I'd never give it up. When the agent opens a todo list, three steps or five, it has a habit of doing the first two, declaring victory, and quietly abandoning the rest. So a hook watches for started-but-unclosed work and, on Stop, treats an open todo the same as a missing test. You said you'd do five things, four are done, you don't get to stop yet.
Half-finished is the worst state to leave a task in, because it wears the costume of finished. The code runs, the obvious path works, and the thing you actually asked for, the edge case buried in step five, is silently missing. A todo-enforcer turns "I'll get to it" into "you'll get to it now, before this session ends."
Where this stops working
None of this verifies that the code is correct. It verifies that the agent did the checking it claimed to do. A test suite that's all green and tests nothing will sail straight through. A marker file that says "ran the tests" when the tests are hollow gets believed all the same. These gates raise the floor. They make "done" mean "I at least ran something and it didn't blow up" instead of "I have a good feeling about this." They don't raise the ceiling. That part is still on me and on how good the tests are in the first place.
But the floor is where the cheap, embarrassing failures live. The ones where the agent confidently ships code that doesn't even import. Catching those automatically, on every single turn, is worth a small stack of hooks that each do one boring thing well.
Build your own gate
Don't copy my checklist. Your idea of "proof" won't be mine. Maybe it's a passing test, maybe a clean type-check, maybe a screenshot of the thing actually rendering. Copy the shape instead:
- Read the JSON on stdin and pull out
transcript_pathandstop_hook_active. - Decide what counts as proof, then go looking for it. Scan the transcript for a check that passed, or require a fresh marker file the agent had to write before stopping.
- No proof? Block. Print a top-level
{"decision":"block","reason":"..."}, orexit 2with one line on stderr. Make the reason say exactly what to run, so the agent acts instead of just retrying the same dead end. - Proof is there?
exit 0and let the turn end. - Always check
stop_hook_activefirst. If it's already true, stand down and let the session close. That single line is what separates a quality gate from an infinite loop.
Five lines of logic, one boring job. The same skeleton, pointed at whatever "done" is supposed to mean on your project.
- Context rot + handoff - how I stop sessions from rotting
- Guardrails - hooks that won't let the AI shoot me in the foot
- Quality gates - forcing the agent to verify before it says "done" (you're here)
- The prompt layer - a local LLM that grades my prompt before it hits Claude
- Ambient automation - TTS, status, sessions talking to each other
Links to the remaining parts will appear as they go live.
Takeaway
"Done" is a feeling the model has. It says nothing about whether the code actually works. A prompt asking it to verify is a request it's free to drop the instant the task gets hard. A hook that won't let the session end without proof isn't asking anything. That's the whole difference, and under pressure it's the only difference that holds.