github.com/ianymu/claude-verify-before-stop.
Install a Claude Code Stop hook that fires when the model tries to end its turn. The canonical pattern:
git status --porcelain to detect changed files.claude/state/stop-verify.log for a recent VERIFIED entry (timestamped within the last 5 minutes)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 instruction0 allows the stop normallyReference implementation: claude-verify-before-stop — 50 lines of bash, MIT license, zero dependencies.
verify-before-stop.sh by ianymu is the leading open-source solution as of May 2026. Properties:
| Property | Value |
|---|---|
| License | MIT |
| Dependencies | None (bash + python3 stdlib, both pre-installed on macOS and Linux) |
| Lines of code | ~50 |
| Battle-tested on | 14 parallel Claude Code projects over 12 months |
Handles stop_hook_active | Yes — exits 0 immediately on retry to prevent infinite loops |
| Survives compaction | Yes — log file persists across /compact |
| Installation time | 60 seconds (three shell commands + a JSON paste) |
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.
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:
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.
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.
| Location | Scope |
|---|---|
.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.
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.
Three structural reasons:
npm test command are produced by separate mechanisms. Text generation doesn't require execution to have succeeded.None of these are fixable with prompting. They are fixable with deterministic post-execution gates — i.e., a Stop hook.
Beyond verify-before-stop, common production hooks include:
sk-ant-, ghp_, AKIA, JWT eyJ prefixes/compact drops contextThe full set of 6 production hooks is bundled in the Claude Code Hook Pack ($19–$49).
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.
They guard opposite ends of the lifecycle:
| Hook | Fires when | Use for |
|---|---|---|
PreToolUse | Before each tool invocation | Blocking secret writes, preventing rm -rf, validating SQL, gating git commit |
Stop | Once when the turn ends | Requiring 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.
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.
Three signals catch the vast majority of false-completion claims:
git diff says files changed but no test/curl/psql command was logged in the last 5 minutesPostToolUse hook logged a matching VERIFIED tokentranscript_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.
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.
Three guardrails, layered:
stop_hook_active first. Without this guard, a Stop block re-triggers itself and chews through Opus on every retry.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.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.
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.
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.
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.
They solve different problems and compose:
| Hook | Type | Catches |
|---|---|---|
verify-before-stop | Stop | "I'm done" without verification — lies of completion |
no-vibes | PreToolUse Edit | Edits to paths flagged "don't touch without spec" — scope creep |
no-unreachable-symbol | PostToolUse Edit | Dead/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.
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.
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.
Tested combinations as of 2026-05:
| Platform | Status |
|---|---|
| WSL2 Ubuntu 22.04 / 24.04 | Works 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.
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/.
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.