Free script

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 --all

How it works

Limits (read these)

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.

Premium · $19

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

Related free guides