Claude Code Stop hook: block a fake "done" until your checks pass
A free Python script (about 100 lines) that runs your tests, lint and build when Claude Code says it is finished, and sends the agent back to work if any fail. Same checks file also runs in CI. No dependencies, no account, no API calls.
Pairs with the free Agent Done-Check pack (the rule and the auditor prompt). Prefer a ready-to-download bundle? Get the free Agent Done-Check Kit (Stop hook, rules and CI job) on Payhip (pay what you want, from $0). Want a full project setup with rules files too? App Builder Agent Pack ($19) or all 5 premium packs for $29.
The problem
Agents write their final summary from what they meant to do, not from re-checking. "All tests pass" often means "I ran one file", or a test was skipped in the same diff. A rule in CLAUDE.md helps, but it still relies on the agent's own word. A Stop hook moves the check out of the agent's hands.
Install in three steps
1. Save the script as .claude/hooks/done_gate.py
Download: curl -o .claude/hooks/done_gate.py https://agentpromptsatoz.com/guides/done-gate/done_gate.py.txt (read it first; it is short) or copy it below.
#!/usr/bin/env python3
"""done_gate.py - make "done" a check that exits 0.
Two modes, one script, Python 3 standard library only:
Hook mode (default): register as a Claude Code Stop hook. It reads the hook
JSON from stdin, runs every command listed in .claude/done-checks.txt, and
if any fails it exits 2 with the failing output on stderr, which stops Claude
from finishing and sends it back to work.
CI mode (--ci): run the same checks and exit 1 on the first failure
(or on any failure with --all). Use it as the independent final gate.
Check file format (.claude/done-checks.txt): one shell command per line.
Blank lines and lines starting with # are ignored. Example:
npm test
npm run lint
npm run build
Environment:
DONE_GATE_FILE path to the checks file (default .claude/done-checks.txt
under CLAUDE_PROJECT_DIR or the current directory)
DONE_GATE_TIMEOUT per-check timeout in seconds (default 300)
"""
import json, os, subprocess, sys
def project_dir():
return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
def load_checks(path):
if not os.path.isfile(path):
return None
out = []
for line in open(path, encoding="utf-8"):
s = line.strip()
if s and not s.startswith("#"):
out.append(s)
return out
def run_check(cmd, cwd, timeout):
try:
p = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=timeout)
text = ((p.stdout or "") + (p.stderr or "")).strip().splitlines()
return p.returncode, "\n".join(text[-25:])
except subprocess.TimeoutExpired:
return 124, f"TIMED OUT after {timeout}s"
def main(argv):
ci = "--ci" in argv
run_all = "--all" in argv
cwd = project_dir()
path = os.environ.get("DONE_GATE_FILE") or os.path.join(cwd, ".claude", "done-checks.txt")
timeout = int(os.environ.get("DONE_GATE_TIMEOUT", "300"))
if not ci:
try:
data = json.load(sys.stdin)
except Exception:
data = {}
# stop_hook_active is true when Claude is already continuing because of
# an earlier block. We still re-run the checks (otherwise one retry
# would be enough to escape the gate). Claude Code itself ends the turn
# after 8 consecutive stop-hook blocks, so this cannot loop forever.
cwd = data.get("cwd") or cwd
checks = load_checks(path)
if checks is None:
if ci:
print(f"done_gate: no checks file at {path}", file=sys.stderr)
return 1
return 0 # no checks configured: do nothing
if not checks:
return 0
failures = []
for cmd in checks:
code, tail = run_check(cmd, cwd, timeout)
status = "PASS" if code == 0 else f"FAIL (exit {code})"
if ci:
print(f"[{status}] {cmd}")
if code != 0:
failures.append((cmd, code, tail))
if ci and not run_all:
break
if not failures:
return 0
if ci:
for cmd, code, tail in failures:
print(f"\n--- {cmd} (exit {code}) ---\n{tail}", file=sys.stderr)
return 1
msg = ["NOT DONE. These checks from .claude/done-checks.txt failed. Fix the cause, re-run them, and only then report done. Do not edit, skip or weaken a check to make it pass.\n"]
for cmd, code, tail in failures:
msg.append(f"$ {cmd}\n(exit {code})\n{tail}\n")
print("\n".join(msg), file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
2. List your checks in .claude/done-checks.txt
# .claude/done-checks.txt (one shell command per line; edit to match your project) npm test npm run lint npm run typecheck npm run build
3. Register the hook in .claude/settings.json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/done_gate.py",
"timeout": 600
}
]
}
]
}
}
Run /hooks in Claude Code to confirm it is registered. To test it, add a check that fails (false) and ask Claude to say hello: it should be sent back with the "NOT DONE" message. Remove the test line afterwards.
What the agent sees when a check fails
NOT DONE. These checks from .claude/done-checks.txt failed. Fix the cause, re-run them, and only then report done. Do not edit, skip or weaken a check to make it pass. $ npm test (exit 1) FAIL tests/auth.test.ts Tests: 1 failed, 11 passed, 12 total
Add the independent gate in CI
The hook stops a lazy "done" locally. CI is the final gate because it reads the checks from the branch being merged, not from the agent's session.
# .github/workflows/done-gate.yml
name: done-gate
on: [pull_request]
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# add your own setup step here (setup-node, setup-python, install deps)
- run: python3 .claude/hooks/done_gate.py --ci --allHow it works
- Claude Code runs
Stophooks when the agent finishes responding. Exit code 2 prevents it from stopping and returns your stderr as feedback; any other non-zero code is a non-blocking error (hooks reference, code.claude.com/docs/en/hooks). - Claude Code ends the turn after 8 consecutive stop-hook continuations, so a check that can never pass cannot trap the agent forever.
- Each check runs through your shell with a 300-second timeout (
DONE_GATE_TIMEOUT) and only the last 25 lines of output go back to the agent, so failures stay readable. - No
.claude/done-checks.txt: the hook does nothing. Missing file in--ci: it fails, so a deleted checks file cannot pass a PR.
Limits (read these)
- It only enforces the commands you list. Weak or missing tests mean a weak gate.
- It cannot see claims the checks do not cover, such as "I deployed it" or "I sent the email". For those, use the auditor prompt in Agent Done-Check: evidence means a read-back from the real service.
- It is a Claude Code hook. The checks file and CI mode work with any agent.
FAQ
Why a hook and not just a rule in CLAUDE.md?
A rule asks the agent to check its work; a Stop hook checks it for you. Claude Code runs the Stop hook when the agent finishes responding, and if the hook exits with code 2 the agent is not allowed to stop and gets your stderr as feedback. The agent's own report is never the evidence. Use both: the Definition-of-Done rule makes it try, the hook makes it prove.
Does the agent loop forever if a check can never pass?
No. Claude Code ends the turn after 8 consecutive stop-hook continuations in a row (the count resets each time the agent calls a tool), and you can change that with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP. Per the Claude Code hooks reference, a Stop hook also receives stop_hook_active=true while it is already continuing because of a stop hook. This script deliberately re-runs the checks in that case, so one retry is not enough to slip past it.
What does exit code 2 do on the Stop event?
Per the hooks reference, exit code 2 on a Stop hook prevents Claude from stopping and continues the conversation, and your stderr is shown to Claude as the reason. Any other non-zero code is a non-blocking error, which is why the script uses 2 and not 1.
Can the agent just edit .claude/done-checks.txt to make it pass?
It could, if you let it. Deny edits to the checks file and the script in your permission rules (for example Edit(.claude/done-checks.txt) and Edit(.claude/hooks/**) under permissions.deny; confirm the syntax with /permissions in your version), and keep the CI job as the independent final gate, because CI reads the checks from the merged branch.
Will it slow every turn down?
It runs your checks every time the agent finishes responding, including turns where it only answered a question. Keep the list fast (unit tests, lint, typecheck), put slow suites in CI, and consider a faster subset in done-checks.txt. If a project has no done-checks.txt the script does nothing.
Does it work with Cursor or Codex?
The Stop hook is a Claude Code feature. The checks file and the --ci mode are tool-agnostic, so you can run the same file in CI or as a pre-push step for any agent. For Cursor and Codex, pair the Definition-of-Done rule from the Done-Check pack with the CI job.
Is this tested?
The script was run against its own behavior cases: no checks file, passing checks, a failing check, a check that times out, malformed hook input, stop_hook_active true with a failing and a passing check, and CI mode pass/fail/no-file. It follows the hook input and exit-code behavior documented in the Claude Code hooks reference. It has not been run inside a live Claude Code session by us, so confirm with /hooks and one deliberately failing check on your machine before you rely on it.
Want the whole setup? App Builder Agent Pack
Six finished Cursor rules files, a CLAUDE.md for Claude Code and an AGENTS.md for Codex and other agents, with a verify-before-done working agreement and a plan-then-build workflow. Plain text you own.
Get the App Builder pack — $19 Or all 5 premium packs for $29