Navy and orange featured image for a post about Claude Code hooks and exit code 2

Why your Claude Code hooks ignore failing checks

I asked Claude Code to add an editorial.ts file to my Express project. My hook ran the quality gate, and the gate reported six functions over my complexity threshold. Claude saw the message and moved on. It wrote no tests. It did no refactoring. My settings.json looked right, with a matcher on Edit and Write that ran my gate command on every change. The problem sits in a detail most tutorials on Claude Code hooks skip. It’s the exit code.

🚀 Complete Claude Code & Coding Agents Course

The gate I wanted Claude to respect

My test project is a small Express app in TypeScript with one health route. The interesting part is package.json. The typecheck script runs tsc --noEmit. Lint runs ESLint with type-aware rules. Vitest runs the tests, with Istanbul as the coverage provider. A CRAP scorer runs last, and a gate script chains all of them.

CRAP stands for Change Risk Anti-Patterns. The score punishes complexity combined with missing tests. A simple function with no tests scores low because it’s straightforward. A function full of branches and no tests scores high. I gate at 15. That’s a bit high for human code, but it should be fine for AI. I’m still experimenting with the number.

Your first instinct may be to put these checks in a skill and hope the agent runs them. It won’t always. Hooks are shell commands bound to points in the agent’s life cycle, and they run whether anyone asks or not.

Why exit code 1 gets ignored

I use PostToolUse for the per-edit check. It fires after a tool call succeeds. The edit already happened, so this hook can’t prevent anything. What it can do is put a message in front of the model while the file it just touched is still what it’s thinking about.

Here is why my first setup failed. When npm run gate fails, it exits with code 1. On PostToolUse, code 1 is a non-blocking error. You get a notice and the model proceeds. Exit code 0 is worse. Your output goes to the debug log and Claude never sees it. Exit code 2 is the one you need, because it puts your stderr in front of the model.

So the hook can’t be a bare npm script. It needs a wrapper that translates. I created .claude/hooks/gate.sh to run the checks, capture everything, and exit 0 on success or print to stderr and exit 2 on failure.

[INFERRED] The video shows the script on screen but the transcript doesn’t include it. This is my reconstruction of the behavior described:

#!/bin/bash
# .claude/hooks/gate.sh
output=$(npm run gate 2>&1)
if [ $? -ne 0 ]; then
  echo "$output" >&2
  exit 2
fi
exit 0

Then I pointed settings.json at the wrapper, still on PostToolUse with the Edit|Write matcher. I referenced it through $CLAUDE_PROJECT_DIR, the variable that holds your project root, so the hook doesn’t break when the agent changes directory.

Same prompt, second run. The gate failed on the same six functions, and this time Claude read the failures and started writing tests for the branches in editorial.ts. When it said it was done, I ran npm run gate myself. Type check, lint, and tests passed. The highest CRAP score in the file was 10, well under 15. Claude fixed it without being asked.

Splitting Claude Code hooks into fast and full tiers

gate.sh runs everything. That means the type check, lint, the full suite with Istanbul instrumentation, and then the CRAP scorer, which shells out to ESLint a second time. Claude makes dozens of tool calls to finish one feature. Running all of that on every edit gets slow.

So I split it. fast.sh runs type check and lint only, which takes about a second. It stays on PostToolUse. full.sh runs the whole gate and moves to the Stop hook. Stop fires when Claude finishes responding, and unlike PostToolUse it can block. If the gate fails, the conversation continues and Claude works on the fix.

The check at the top of full.sh is not optional. When a Stop hook blocks, Claude keeps working, tries to stop again, and your hook fires again. The stop_hook_active field tells you that you’re already inside a continuation. Read it, exit 0, done. Skip it and you’re stuck in a loop until you kill the terminal.

[INFERRED] Reconstructed from the video’s description, not copied from the screen:

#!/bin/bash
# .claude/hooks/full.sh
input=$(cat)
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
output=$(npm run gate 2>&1)
if [ $? -ne 0 ]; then
  echo "$output" >&2
  exit 2
fi
exit 0

Two details in the final settings.json matter. Stop has no matcher, because it always fires. And statusMessage gives each hook a label, which helps when you run more than one hook and one of them hangs.

[INFERRED] The exact settings.json from the video, including the if rule, needs checking against the recording:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "if": "Edit(src/**/*.ts)",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fast.sh",
            "statusMessage": "Fast gate: tsc + eslint"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/full.sh",
            "statusMessage": "Full gate: tests, coverage, CRAP"
          }
        ]
      }
    ]
  }
}

Then I gave Claude a real prompt. I asked for full CRUD on an articles resource, with filtering and pagination on the list endpoint. Storage lived in memory behind a repository module, since there’s no database. I also asked for proper status codes and tests. Partway through, PostToolUse returned a blocking error. Claude fixed it while it was still working on that file. At the end it reported type check, lint, tests, coverage, and complexity all green. My own npm run gate agreed.

What the gate does not catch

I don’t want to oversell this. The gate is a floor. It is not code review. What I get is narrower than “AI writes good code.” Nothing lands with a type error. Nothing lands breaking a rule I wrote down. Nothing lands as a complex function with no test coverage at all. Those are good guardrails. They don’t replace reading the tests and checking that the agent did what you asked.

Hooks cost something too. Every hook is a process spawn, and the fast tier runs on every edit, even on files it has no opinion about. The if field narrows that. It matches the tool call with permission-rule syntax, so the handler only spawns when the edit touches a TypeScript file in src.

The gate also fails silently. Mistype the script path in settings.json and Claude Code reports a non-blocking error, then carries on with no gate at all. That’s the exact failure I opened with.

Here’s what I’d actually do. Don’t build all of it. Start with one PostToolUse hook on Edit|Write that runs only the type check and exits 2 on failure. Then break it on purpose. Ask for something with an obvious type error and watch the failure come back into the model’s context and get fixed. That single hook is most of the value. The Stop tier, the coverage gate, and the CRAP threshold refine a loop that either exists or doesn’t. Get the loop to exist first.

Key takeaways

  • On PostToolUse, only exit code 2 puts your hook’s stderr in front of Claude, while exit 1 shows a notice and exit 0 sends output to the debug log.
  • Wrap your npm gate script in a shell script that prints failures to stderr and exits 2, because a bare npm script exits 1.
  • Run fast checks like type check and lint on PostToolUse, and move the full test, coverage, and CRAP gate to the Stop hook.
  • A Stop hook that blocks must check stop_hook_active and exit 0 when it’s true, or Claude loops until you kill the terminal.
  • The gate is a floor, so you still need to read the tests and confirm the agent built what you asked for.

Conclusion

Running your checks and making Claude listen to them are two different things. The difference is one exit code. Wrap the gate so failures reach the model, keep the per-edit checks cheap, and guard the Stop hook against loops. With that in place, Claude Code hooks turn a failing check into a fix the agent makes on its own. You still read the tests.

Share this article

Similar Posts