# Claude Code Stop hook: block a fake "done" until your checks pass

> A free, dependency-free Python Stop hook for Claude Code that runs the checks you list (tests, lint, build) when the agent finishes and sends it back to work if any fail. The same checks file runs in CI.

Source: https://agentpromptsatoz.com/guides/done-gate/ · License: free to use and adapt · Publisher: Agent Prompts A to Z

## Install

1. Save as .claude/hooks/done_gate.py (also at https://agentpromptsatoz.com/guides/done-gate/done_gate.py.txt):

````python
#!/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 checks, one shell command per line:

````text
# .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 in .claude/settings.json:

````json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/done_gate.py",
            "timeout": 600
          }
        ]
      }
    ]
  }
}
````

4. CI gate:

````yaml
# .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
````

## Free kit

Get the free Agent Done-Check Kit (Stop hook, rules and CI job) on Payhip, pay what you want from $0: https://payhip.com/b/EAenF

## Limits

- Enforces only the commands you list.
- Cannot verify claims outside the terminal (deploys, emails): use the auditor prompt in the Agent Done-Check pack and require a read-back.
- Stop hooks are a Claude Code feature; checks file and --ci mode work with any agent.
- Not run inside a live Claude Code session by us; verify with /hooks and a deliberately failing check.

## 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.

## Go further (premium)

[App Builder Agent Pack](https://payhip.com/b/EuRgw) ($19): six tested Cursor rules files, CLAUDE.md, AGENTS.md and a plan-then-build workflow. Or [all 5 premium packs](https://payhip.com/b/J8eUb) for $29.
