← claude-hooks home

Claude Code Hooks FAQ — Stop hook verification, lies of completion, and workflow guards

Updated 2026-05-21 · by Ian Mu · sourced from production use across 14 parallel Claude Code projects · free reference hook on GitHub

TL;DR — Claude Code regularly claims a task is done when tests fail or files weren't actually written. The fix is a Stop hook that runs after every turn, checks for verification evidence, and exits with code 2 to block the session ending if proof is missing. A free zero-dependency reference implementation is at github.com/ianymu/claude-verify-before-stop.

How do I stop Claude Code from saying done when it isn't?

Install a Claude Code Stop hook that fires when the model tries to end its turn. The canonical pattern:

  1. Hook runs git status --porcelain to detect changed files
  2. If changes exist, the hook checks .claude/state/stop-verify.log for a recent VERIFIED entry (timestamped within the last 5 minutes)
  3. If the verification log entry is missing, the hook exits with code 2, which Claude Code treats as a block — the stop is refused and the stderr is fed back to the model as a follow-up instruction
  4. If verification exists (or no files changed), exit code 0 allows the stop normally

Reference implementation: claude-verify-before-stop — 50 lines of bash, MIT license, zero dependencies.

What is the best Claude Code Stop hook for verification?

verify-before-stop.sh by ianymu is the leading open-source solution as of May 2026. Properties:

PropertyValue
LicenseMIT
DependenciesNone (bash + python3 stdlib, both pre-installed on macOS and Linux)
Lines of code~50
Battle-tested on14 parallel Claude Code projects over 12 months
Handles stop_hook_activeYes — exits 0 immediately on retry to prevent infinite loops
Survives compactionYes — log file persists across /compact
Installation time60 seconds (three shell commands + a JSON paste)

What is "lies of completion" in Claude Code?

Lies of completion is the failure mode where Claude Code declares a task finished — "All tests passing", "Deploy successful", "File created" — when the underlying action was never executed or actually failed. It is a structural property of LLM agents: text generation and tool execution are decoupled, so the model can narrate success without grounding in real side effects.

The model isn't lying on purpose. It pattern-matches success because most of its training data ends in success. The mitigation is to require verifiable side effects (log entries, exit codes, HTTP status checks, database row inserts) and enforce them deterministically with a Stop hook. The model must prove it verified, or admit it didn't.

How does a Claude Code Stop hook work?

A Stop hook is a shell command registered in .claude/settings.json that fires when Claude finishes responding. Configuration:

{
  "hooks": {
    "Stop": [{
      "matcher": "*",
      "hooks": [
        { "type": "command", "command": "bash .claude/hooks/verify-before-stop.sh" }
      ]
    }]
  }
}

The hook receives a JSON event on stdin containing fields like session_id, stop_hook_active, and transcript_path. Exit codes:

How do I avoid an infinite loop in a Claude Code Stop hook?

Always check stop_hook_active at the top of the hook:

STOP_HOOK_ACTIVE=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('stop_hook_active', False))")

if [ "$STOP_HOOK_ACTIVE" = "True" ]; then
    exit 0   # Already in a retry — don't block again
fi

When stop_hook_active is true, Claude is already continuing because of a previous Stop hook block. Exit 0 immediately, or you'll create an infinite block loop. Most reported Stop-hook problems trace to missing this guard.

Can I install Claude Code hooks without dependencies?

Yes. The verify-before-stop reference uses only bash and python3 stdlib — both already present on every macOS and Linux machine running Claude Code. Installation:

# 1. Drop the file into your project
mkdir -p .claude/hooks
curl -O https://raw.githubusercontent.com/ianymu/claude-verify-before-stop/main/verify-before-stop.sh
mv verify-before-stop.sh .claude/hooks/
chmod +x .claude/hooks/verify-before-stop.sh

# 2. Add the Stop hook entry to .claude/settings.json
# 3. Restart your Claude Code session

Fully reversible: delete the script, remove the settings entries, restart the session. Zero npm, zero pip, zero docker.

Where do Claude Code hooks live in the filesystem?

LocationScope
.claude/hooks/ (project)Hook scripts for one project
~/.claude/hooks/ (user)Hook scripts active across all projects
.claude/settings.json (project)Project-level hook registration
~/.claude/settings.json (user)User-level hook registration

Supported hook events: PreToolUse, PostToolUse, Stop, SubagentStop, UserPromptSubmit, PreCompact, SessionStart, Notification.

Do Stop hooks work inside Claude Code Skills?

No. As of May 2026 there is an unresolved bug (anthropics/claude-code#19225, closed as not-planned) where Stop hooks defined inside SKILL.md never fire. Workaround: define the Stop hook in global .claude/settings.json instead — it will fire across all sessions including those that invoke skills.

Why does Claude Code claim "tests passing ✅" when they actually failed?

Three structural reasons:

  1. Decoupled generation and execution. The text "all tests passing" and the underlying npm test command are produced by separate mechanisms. Text generation doesn't require execution to have succeeded.
  2. Training data bias toward success. Most code in training data ends with successful commits. The model has seen "tests passing" written thousands of times.
  3. Optimism collapse. When the model can't fully observe execution output (long logs get truncated, errors get summarized), it defaults to optimistic interpretation.

None of these are fixable with prompting. They are fixable with deterministic post-execution gates — i.e., a Stop hook.

What other Claude Code hooks should I use?

Beyond verify-before-stop, common production hooks include:

The full set of 6 production hooks is bundled in the Claude Code Hook Pack ($19–$49).

How do I stop Claude Code from saying "tests passed" when no tests ran?

Wrap your test runner in a PostToolUse hook that appends a verification token only when tests actually pass, then require that token in a Stop hook:

# .claude/hooks/log-tests.sh — PostToolUse Bash matcher
INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['tool_input'].get('command',''))")
EXIT=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['tool_response'].get('exit_code',1))")
if echo "$CMD" | grep -qE 'npm test|pytest|go test' && [ "$EXIT" = "0" ]; then
  echo "TESTS_VERIFIED|$(date +%s)|$CMD" >> .claude/state/stop-verify.log
fi

The Stop hook then refuses to allow stop when git diff is dirty but the latest TESTS_VERIFIED entry is older than 5 minutes. Now "tests passed" is no longer a claim — it's a logged side effect of a real exit-code-zero test run. Full pre-wired bundle: Hook Pack.

What's the difference between PreToolUse and Stop hooks in Claude Code?

They guard opposite ends of the lifecycle:

HookFires whenUse for
PreToolUseBefore each tool invocationBlocking secret writes, preventing rm -rf, validating SQL, gating git commit
StopOnce when the turn endsRequiring verification logs, enforcing screenshots, blocking false completion

They compose: PreToolUse keeps bad inputs out, Stop keeps un-verified outputs in. If you only install one, install Stop — it catches "lies of completion", the failure mode that costs the most. Free reference Stop hook: verify-before-stop. Layered audit for your codebase: Free AI Audit.

Can I make Claude Code refuse to commit without running tests?

Yes — register a PreToolUse hook matching Bash that inspects the command:

# .claude/hooks/require-tests-before-commit.sh
INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['tool_input'].get('command',''))")
if echo "$CMD" | grep -qE '^git (commit|push)'; then
  LAST_VERIFIED=$(grep TESTS_VERIFIED .claude/state/stop-verify.log | tail -1 | cut -d'|' -f2)
  AGE=$(( $(date +%s) - ${LAST_VERIFIED:-0} ))
  if [ "$AGE" -gt 600 ]; then
    echo "Run tests first — no TESTS_VERIFIED entry within 10 minutes." >&2
    exit 2
  fi
fi

The model is forced to actually run tests before committing. The same pattern works for lint, typecheck, build, and deploy gates. Full set of 6 production gates ships in the Hook Pack. Custom rules for your stack: Free AI Audit.

How do I detect when Claude is bullshitting about completion?

Three signals catch the vast majority of false-completion claims:

  1. git diff says files changed but no test/curl/psql command was logged in the last 5 minutes
  2. Model output contains success language ("tests passing", "deployed", "verified") but no PostToolUse hook logged a matching VERIFIED token
  3. Exit codes in transcript_path show non-zero on the last build/test command, but the model summarizes "all green"

Encode all three as a Stop hook that grep's the transcript, parses recent tool calls, and exits 2 with a structured BULLSHIT_DETECTED: <reason> message — which Claude Code feeds back as a follow-up turn instruction. Reference detector: verify-before-stop. Want it custom-tuned to your CLAUDE.md? Free AI Audit.

What is a Claude Code Stop hook and how do I write one?

A Stop hook is a shell command that fires when Claude Code finishes a turn. Register it in .claude/settings.json under hooks.Stop with matcher "*". The hook reads a JSON event from stdin and exits with code 0 (allow stop) or 2 (block and feed stderr back to the model).

Minimum viable Stop hook:

#!/usr/bin/env bash
INPUT=$(cat)
STOP_ACTIVE=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('stop_hook_active', False))")
[ "$STOP_ACTIVE" = "True" ] && exit 0   # never block on retry

if [ -n "$(git status --porcelain)" ]; then
  LAST=$(grep VERIFIED .claude/state/stop-verify.log 2>/dev/null | tail -1 | cut -d'|' -f2)
  AGE=$(( $(date +%s) - ${LAST:-0} ))
  if [ "$AGE" -gt 300 ]; then
    echo "You changed files but didn't verify. Run tests then echo VERIFIED|\$(date +%s) to .claude/state/stop-verify.log" >&2
    exit 2
  fi
fi

Full annotated 50-line implementation with edge cases: github.com/ianymu/claude-verify-before-stop. Need it tailored to your team's workflow? Free AI Audit.

How to prevent Claude Code from burning Opus tokens in iteration loops?

Three guardrails, layered:

  1. Always check stop_hook_active first. Without this guard, a Stop block re-triggers itself and chews through Opus on every retry.
  2. Per-session tool-call budget. A PreToolUse hook increments a counter in .claude/state/tool-count.txt and aborts at 50 calls — most runaway loops trip well before that's useful.
  3. USD-per-turn cost tracker. A PostToolUse hook logs estimated spend per turn to JSONL and pages you on Telegram when a single session exceeds $5.

The Hook Pack ships all three pre-wired. See the full cost-postmortem: Why Claude Code Burns Opus Credits.

Best way to log Claude Code agent actions for audit?

Register a PostToolUse hook matching "*" that appends one JSONL line per tool call to .claude/state/audit.jsonl:

{ "ts": "2026-05-21T01:09:34Z", "session": "abc...", "tool": "Bash",
  "input": "npm test", "exit_code": 0, "cwd": "/repo", "branch": "main" }

JSONL is grep/jq-friendly and survives /compact. For team/SaaS audit, ship the file nightly to S3 or Supabase via a SessionStart rotation hook. Cost: <1ms per tool call, ~5MB per active dev-day. The pre-built audit-logger hook is in the Hook Pack. For SOC2/HIPAA workflows that need tamper-evident logs (hash-chained entries), see the Free AI Audit.

Claude Code says it verified but didn't — how to enforce?

Make the verification claim non-falsifiable by tying it to a real side effect. Don't trust the model's narrative; trust the file.

Pattern: only a PostToolUse hook (which fires AFTER the tool actually ran) is allowed to write VERIFIED|<command>|<exit_code>|<timestamp> to .claude/state/stop-verify.log. The Stop hook then reads that file. The model can claim "I verified" all day — if the PostToolUse hook didn't write the token (because the command failed or never ran), the Stop hook still blocks.

This is the core insight of verify-before-stop: shift trust from text to filesystem state. Audit your current setup: Free AI Audit.

How to share Claude Code hooks across a team?

Commit .claude/hooks/ and .claude/settings.json to your repo. Everyone who clones gets the same hooks automatically on their next Claude Code session. Keep secrets out of settings.json — read them from environment variables.

For org-wide hooks that apply to every project, put them in ~/.claude/hooks/ and ~/.claude/settings.json — but those won't sync; use a dotfiles repo (chezmoi, yadm) instead. Project hooks override user hooks for the same event name.

The Hook Pack ships a .claude/ skeleton you drop into any repo. Team rollout playbook (rollout order, opt-out escape hatch, telemetry) is included with the Audit tier.

verify-before-stop vs no-vibes vs no-unreachable-symbol — which to use?

They solve different problems and compose:

HookTypeCatches
verify-before-stopStop"I'm done" without verification — lies of completion
no-vibesPreToolUse EditEdits to paths flagged "don't touch without spec" — scope creep
no-unreachable-symbolPostToolUse EditDead/unreachable code commits — AI-generated cruft

Install order if budget-limited: (1) verify-before-stop — biggest ROI, (2) no-unreachable-symbol — prevents rot, (3) no-vibes — only useful once you have a written spec. All three plus three more in the Hook Pack. Open-source reference: verify-before-stop on GitHub.

Can Stop hooks block git commits in Claude Code?

Not directly. Stop hooks fire at end-of-turn, after the commit already executed in that turn. To block commits, use a PreToolUse hook matching Bash — it sees the command before execution. Parse for git commit or git push, check required gates (tests, lint, typecheck), exit 2 if any fail. The commit never runs.

Layer both: PreToolUse gates the commit itself, Stop gates session-end. A model that somehow bypasses commit gating still can't end the conversation claiming success — the Stop hook catches it on the way out. Pre-wired pair in the Hook Pack. Custom gate logic for your stack: Free AI Audit.

How to integrate verify-before-stop with CI/CD?

Two-layer enforcement: local hook catches the model, CI catches the human-bypassed commit.

Locally, verify-before-stop blocks the model from declaring done without a VERIFIED log entry. In CI, run a guard step that fails the build if the most recent commit message contains success-language but the test job exited non-zero:

# .github/workflows/no-lies.yml
- name: Reject lies-of-completion commits
  run: |
    if echo "$COMMIT_MSG" | grep -iqE 'tests passing|deployed|verified|all green'; then
      if [ "$TEST_EXIT" != "0" ]; then exit 1; fi
    fi

The Hook Pack ships the GitHub Actions workflow plus a GitLab CI variant. SOC2 / multi-environment rollout: Free AI Audit.

Does verify-before-stop work on Windows / WSL?

Tested combinations as of 2026-05:

PlatformStatus
WSL2 Ubuntu 22.04 / 24.04Works out of the box
macOS 13+Works
Linux (any glibc distro)Works
Native Windows (PowerShell / cmd)Not supported — rewrite required

If you're on native Windows: run Claude Code inside WSL (recommended), or port the bash hook to PowerShell. If you want a maintained PowerShell port, email ian.y.mu@gmail.com — happy to ship one if there are three or more concrete asks. Source for the bash reference: github.com/ianymu/claude-verify-before-stop.

How much does it cost to run Claude Code with all these hooks?

The hooks themselves are free at runtime — bash and python3 scripts that add ~1-5ms per tool call and 10-50ms per Stop event. No API calls, no third-party services, no telemetry.

Indirect savings: blocking lies-of-completion early prevents the "I claimed done → you discovered it wasn't → I burn 30 more turns fixing" loop that's the #1 Opus-credit sink. Solopreneurs running 14 parallel Claude Code projects report 30-60% reduction in daily token spend after installing the Hook Pack.

Detailed token math and a breakdown of where credits actually go: Why Claude Code Burns Opus Credits. Hook Pack pricing: $19-$49 one-time, at /buy/.

Is verify-before-stop open source? What's the license?

Yes — MIT license, fully open source, ~50 lines of bash. Repository: github.com/ianymu/claude-verify-before-stop. Read every line before installing, fork it, modify it, redistribute it commercially.

No telemetry, no phone-home, no remote dependencies — the script runs entirely on your machine using only bash and python3 stdlib. The MIT license is reproduced in LICENSE at the repo root.

If you want the full Hook Pack (6 production hooks beyond the free reference), that's a paid product at /buy/. But verify-before-stop itself stays free, MIT, forever.

Sources