DEV Community

Cover image for Claude Code Stop hook example: check the "done" claim.
Manpreet Singh
Manpreet Singh

Posted on Originally published at singhlabs.dev AI-assisted

Claude Code Stop hook example: check the "done" claim.

Search for a Claude Code Stop hook example and you get two kinds. One runs the tests when the agent stops. The other plays a sound. Both are fine. Neither reads the paragraph the agent just wrote you.

This one does. It takes the agent's last message, holds it against git diff, and won't let the agent stop while a changed file goes unmentioned. 31 lines of Node, no dependencies. Every output below is a real run.

A test gate checks the code. Nothing checks the paragraph you're about to merge on.

What a Stop hook can see

Stop fires when Claude finishes responding. Your command gets a JSON payload on stdin. Here's the one my hook was fed, paths shortened:

$ cat ../stop-payload.json
{
  "session_id": "ed45f42f-…",
  "transcript_path": "~/.claude/projects/…/….jsonl",
  "cwd": "C:/…/stop-hook-demo",
  "permission_mode": "default",
  "hook_event_name": "Stop",
  "stop_hook_active": false,
  "last_assistant_message": "Done. `login()` in auth.js now returns false when `user` is undefined, instead of throwing on `.trim()`. One guard line, no other changes needed.",
  "background_tasks": [],
  "session_crons": []
}
Enter fullscreen mode Exit fullscreen mode

Four fields matter:

  • last_assistant_message is the claim. No need to dig it out of the transcript: Anthropic's docs say hooks that want the final text should use this field instead.

  • transcript_path is the whole session as JSONL, for when the last message isn't enough.

  • cwd is where to run git.

  • stop_hook_active is the one that bites. More on it below.

I built that payload by hand, in the documented shape, from the real transcript of the Claude Code session that made the fix. Piping a saved payload in is also the quickest way to test a hook without waiting for Claude to stop.

The usual example: a test gate, and what it misses

The task was small. login() in auth.js threw when the user was undefined. Claude Code added one guard line and said:

Done. login() in auth.js now returns false when user is undefined, instead of throwing on .trim(). One guard line, no other changes needed.

Here's the working tree:

$ git status --short
 M auth.js
 M config.js
Enter fullscreen mode Exit fullscreen mode

config.js was edited before the task even started, on purpose. I left a timeout set to 0 in it to stand in for the edit from three tasks ago. Now the tests:

$ node --test 2>&1 | grep -E "^# (pass|fail)"
# pass 2
# fail 0
Enter fullscreen mode Exit fullscreen mode

Green. The usual Stop hook runs the suite and exits 2 on a failure, which the docs say "prevents Claude from stopping". Here it has nothing to fail on. Claude stops, you commit, and the zero timeout ships with the bug fix.

Keep the test gate. It answers a real question. It just isn't this one.

A better one: hold the summary against the diff

Same event, different question: did the message name every file that changed? Save this as .claude/hooks/check-done.mjs:

#!/usr/bin/env node
// Stop hook: don't let Claude stop until its last message names every file it changed.
import { readFileSync } from 'node:fs';
import { execSync } from 'node:child_process';

const input = JSON.parse(readFileSync(0, 'utf8'));

// We blocked once already and Claude is answering for it. Let it stop.
if (input.stop_hook_active) process.exit(0);

// The claim: the last thing Claude said, handed over in the payload.
const claim = input.last_assistant_message;
if (!claim) process.exit(0);

// The evidence: tracked files that changed, plus files that are new.
const git = (args) => execSync(`git ${args}`, { cwd: input.cwd, encoding: 'utf8' })
  .split('\n').filter(Boolean);
let changed;
try {
  changed = [...git('diff --name-only HEAD'), ...git('ls-files --others --exclude-standard')];
} catch { process.exit(0); } // not a git repo, or no commits yet

// A literal match on the path or the file name. Crude on purpose.
const unnamed = changed.filter((f) => !claim.includes(f) && !claim.includes(f.split('/').pop()));
if (unnamed.length === 0) process.exit(0);

console.log(JSON.stringify({
  decision: 'block',
  reason: `You said you're done but never mentioned: ${unnamed.join(', ')}. ` +
    'Say what changed in each one, or undo it.',
}));
Enter fullscreen mode Exit fullscreen mode

The ls-files --others half matters. git diff on its own never lists a brand-new file, so without it a whole new file walks straight past.

Register it in .claude/settings.json and commit both, so the whole team gets it:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-done.mjs"]
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details from the docs. Stop takes no matcher. Add one and it's silently ignored. And args switches the hook to exec form, with no shell in between, which the docs recommend whenever a path placeholder is involved. A project path with a space in it can't split the command in two.

Same payload, same repo:

$ node .claude/hooks/check-done.mjs < ../stop-payload.json
{"decision":"block","reason":"You said you're done but never mentioned: config.js. Say what changed in each one, or undo it."}
Enter fullscreen mode Exit fullscreen mode

"decision": "block" keeps Claude working, and reason becomes its next instruction. The tests had nothing to say about config.js. This did.

If a block showing up as a hook error bothers you, the docs offer hookSpecificOutput.additionalContext instead. Same loop protections, but the transcript labels it "Stop hook feedback".

The infinite-loop trap

When the hook blocks, Claude answers and then tries to stop again. That fires the Stop hook again. If the hook asks the same question and gets the same answer, it blocks again.

The way out is stop_hook_active. The docs say it's true "when Claude Code is already continuing as a result of a stop hook". That's the second line of the script. Here it is with the flag set:

$ grep stop_hook_active ../stop-payload-active.json
  "stop_hook_active": true,
$ node .claude/hooks/check-done.mjs < ../stop-payload-active.json; echo "exit $?"
exit 0
Enter fullscreen mode Exit fullscreen mode

No output, exit 0, Claude stops. The hook gets one block per stop. If Claude's reply to it is weak, it still stops, and you read the reply. That's the trade, and it's the right one.

Without that line, the docs describe a safety net: Claude Code overrides the next block after stop hooks have continued the turn eight times in a row. Don't lean on it. The count resets every time Claude calls a tool, and checking a diff means calling one. I didn't crash a session to prove this. The guard costs one line.

Or let a plugin do more of it

trust issues is the free plugin I built on the same event. It reads the claim from transcript_path and runs four checks instead of one: changed but never mentioned, quiet cuts like a deleted assertion, mutes like .skip, and work described that never happened. Same payload, with TI pointing at the plugin's stop-check.mjs:

$ node "$TI" < ../stop-payload.json | node -pe "JSON.parse(require(\"fs\").readFileSync(0)).systemMessage"
trust issues — the diff says otherwise:

plumb — 2 files changed, 1 named in the summary

changed but never mentioned  (read these first)
  · config.js modified

1 thing the summary did not tell you.
Enter fullscreen mode Exit fullscreen mode

By default it tells you and doesn't block. A guardrail that interrupts you on its first false alarm gets uninstalled the same afternoon. When you trust it, set TRUST_ISSUES=strict and it hands Claude the bill instead:

$ TRUST_ISSUES=strict node "$TI" < ../stop-payload.json
{"decision":"block","reason":"Before you call this done — the summary and the diff disagree:\n\nplumb — 2 files changed, 1 named in the summary\n\nchanged but never mentioned  (read these first)\n  · config.js modified\n\n1 thing the summary did not tell you.\n\nEither fix what was quietly changed, or say plainly what you did and why."}
Enter fullscreen mode Exit fullscreen mode

On the stop_hook_active: true payload it exits 0 and says nothing, in both modes.

What this won't do

  • It matches names literally. "auth.js" counts. "The auth module" doesn't. Ask the agent to end every task with a file list and the noise goes away.

  • It checks the working tree, not the turn. That's how it caught config.js. It's also how it will nag you about the last task's files until you commit them. Commit between tasks.

  • A mention isn't the truth. "Updated the test" names the file and hides the .skip. That's what the quiet-cut and mute checks in trust issues are for.

  • It can't tell whether the code works. Run the test gate too. Both can sit on Stop together.

Why the summary leaves things out in the first place is its own post: Claude Code says it's done. Check the diff, not the paragraph.


This is how we build agents for clients. The agent doesn't get the last word on whether it finished. Something that didn't do the work checks what it claimed against what changed.

Sources: every terminal block is a real run on 9 Oct 2026 (Node 22.17.1, trust issues 1.0.1) in a throwaway git repo, with paths shortened. Claude Code's command line wasn't signed in on this machine, so no payload was logged from a live Stop. Both payloads were built in the documented shape from the real transcript of the Claude Code session that made the fix, then piped in by hand. Payload fields, exit codes, exec form, stop_hook_active and the eight-continuation cap are from Anthropic's hooks reference.

Where this fits: testing and verification, layer 5 of the Agent Ops Stack.

Read next: Claude Code says it's done. Check the diff, not the paragraph. · My mailer printed “campaign sent”. It had sent to nobody.


Originally published at singhlabs.dev.

Top comments (0)