jared hebb ~ %

Claude Code hooks

The Stop hook in Claude Code

Stop fires when Claude finishes responding, and can refuse to let it finish. Fields, exit codes, and the hook that blocks a session ending with uncommitted work.

The natural comparison is PostToolUse: that one reacts after a single tool call, while this is the last checkpoint before Claude hands the whole turn back. A check that belongs at "is this turn actually done" goes here, not scattered across every individual tool call in it.

When it fires
When Claude has finished responding and is about to hand the turn back to you.
Can it block?
Yes. Exit 2, or decision block, sends Claude back to work instead of ending the turn.
Matcher
No. It always fires.

What it receives

The event arrives as JSON on standard input. These are the fields worth reading.

FieldTypeWhat it is
last_assistant_messagestringThe reply Claude is about to finish on.
stop_hook_activebooleanTrue when a Stop hook already blocked this cycle. Check it or you build an infinite loop.
session_idstringStable for the session.
prompt_idstringThe turn being ended.
transcript_pathstringPath to the transcript, if the hook wants to read the whole turn.
cwdstringWorking directory.

Exit codes

The exit code is the decision. Anything the script writes to standard error on a blocking exit is what Claude gets told.

ExitWhat happens
0Let the turn end.
2Do not stop. The conversation continues and stderr tells Claude why.
otherNon-blocking error. The turn ends normally.

What it can return

decision of block with a reason is the same as exit 2 but lets you attach additionalContext, which is where you put the specific commands you want run rather than a vague complaint.

{
  "decision": "block",
  "reason": "string",
  "hookSpecificOutput": {
    "hookEventName": "Stop",
    "additionalContext": "string"
  }
}

Refusing to finish with work sitting uncommitted

An audit of nineteen project folders here found finished, deployed work living only on one machine. This site tracked eight files while the live version had far more: a whole page, an invite, the function behind it, all shipped and none committed. No single session was careless. Nothing ever said to commit, and sessions run from a home directory so no repository feels like the current one. A Stop hook can catch it, because the one moment a session is certain to reach is the end. This one checks the directory the session ran in and blocks the first attempt to finish while anything is uncommitted. It blocks once, never twice, so a session can always end.

In settings.json

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node ~/.claude/hooks/uncommitted-check.mjs",
            "timeout": 30,
            "statusMessage": "Checking for uncommitted work"
          }
        ]
      }
    ]
  }
}

The script

const fs = require('fs');
const { execSync } = require('child_process');
const input = JSON.parse(fs.readFileSync(0, 'utf8')) || {};

// One reminder is a reminder. Two is a trap.
if (input.stop_hook_active) process.exit(0);

const cwd = input.cwd || process.cwd();
let dirty = '';
try {
  dirty = execSync('git status --porcelain', { cwd, encoding: 'utf8' }).trim();
} catch {
  process.exit(0); // not a repository, nothing to say
}
if (!dirty) process.exit(0);

console.error('Uncommitted work in ' + cwd + ':\n' + dirty);
process.exit(2);

This checks the one directory the session ran in, which is enough to be useful. The version running on this machine instead reads a list of repositories that a PostToolUse hook recorded as written to, because a repository can be dirty for reasons the session had nothing to do with, and blocking on those teaches Claude to commit work it did not write.

The catch

stop_hook_active is not optional. Without that check the hook blocks, Claude works, the hook blocks again, and the session never ends. Every blocking Stop hook needs a way to give up.