{"slug": "claude-code-said-done-my-tests-said-otherwise-here-s-the-20-line-fix", "title": "Claude Code said \"Done.\" My tests said otherwise. Here's the 20-line fix.", "summary": "A developer published a 20-line Python Stop hook for Claude Code that blocks the agent from ending its turn until a configured verification command (such as `npm test && npx tsc --noEmit`) passes, feeding the failure output back to Claude via stderr and exit code 2. The hook reads its check from `.claude/verify.txt`, caps consecutive blocks at four to avoid loops, and is registered in `.claude/settings.json`; the author cites Anthropic's best-practices guidance and Claude Code creator Boris Cherny's claim that such feedback loops improve result quality 2–3×.", "body_md": "If you use Claude Code, you've seen this:\n\n```\nclaude: Done! Partial refunds are implemented. ✅\n```\n\nThen you run the tests yourself and four of them fail.\n\nClaude isn't lying. It stops when the work *looks* done, because it has no other signal. If nothing tells it the tests are red, \"looks done\" is all it has. So you become the test runner: you check, paste the errors back, wait, and check again.\n\nThis post shows how to make Claude run that check itself, every time, before it's allowed to say it's finished.\n\nAnthropic's [best practices for Claude Code](https://code.claude.com/docs/en/best-practices) put this first: give Claude a way to verify its work, such as tests, a build, a linter or a script, anything that returns pass or fail. Boris Cherny, who created Claude Code, [has said](https://x.com/bcherny/status/2007179832300581177) that this feedback loop improves the quality of the result 2–3×.\n\nThere are three places you can put that check:\n\n| Where | How reliable | \n|---|---|\n| In your prompt (\"run the tests after\") | Works when you remember to type it | \n| In `CLAUDE.md` | Advice. Claude usually follows it, sometimes not | \n| In a **hook** | Runs every time. Claude can't skip it | \n\nHooks are scripts Claude Code runs at fixed points in its loop. The one we want is the **Stop** hook, which runs whenever Claude tries to end its turn. If a Stop hook exits with code **2**, Claude Code doesn't let the turn end. It sends whatever the hook printed to stderr back to Claude, and Claude keeps working.\n\nThat's the whole trick.\n\nSave this as `.claude/hooks/verify-gate.py` in your project:\n\n``` python\n#!/usr/bin/env python3\nimport json, os, subprocess, sys\n\ndata = json.load(sys.stdin)\nproject = os.environ.get(\"CLAUDE_PROJECT_DIR\", os.getcwd())\nconfig = os.path.join(project, \".claude\", \"verify.txt\")\nif not os.path.exists(config):\n    sys.exit(0)  # no check configured: let Claude stop\n\nif data.get(\"consecutive_blocks\", 0) >= 4:\n    sys.exit(0)  # blocked 4 times in a row: let Claude stop instead of looping\n\ncommand = open(config).read().strip()\nresult = subprocess.run(command, shell=True, cwd=project,\n                        capture_output=True, text=True, timeout=300)\n\nif result.returncode != 0:\n    tail = (result.stdout + result.stderr).strip().splitlines()[-40:]\n    print(\"Verification failed:\\n\" + \"\\n\".join(tail) +\n          \"\\nFix the failures above, then finish. Do not weaken or skip tests.\",\n          file=sys.stderr)\n    sys.exit(2)  # exit 2 = block the stop; stderr is sent back to Claude\n```\n\nRegister it in `.claude/settings.json`:\n\n```\n{\n  \"hooks\": {\n    \"Stop\": [\n      {\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"python3 \\\"${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-gate.py\\\"\",\n            \"timeout\": 330\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\n(On Windows, `python` instead of `python3`.)\n\nThen put your check in `.claude/verify.txt`:\n\n```\nnpm test && npx tsc --noEmit\n```\n\nRestart Claude Code. The next time it says \"Done\" with red tests, this is what happens:\n\n```\nclaude: Done. Partial refunds are implemented.\nStop hook: Verification failed:\n  ✗ refund.test.ts › partial refund rounds to cents\n  Expected 12.35, received 12.349999\n  Fix the failures above, then finish. Do not weaken or skip tests.\nclaude: The rounding is off. Fixing src/orders/refund.ts:42…\nclaude: All 48 tests pass and types are clean. Done.\n```\n\nYou didn't paste anything. Claude found out it wasn't done, and fixed it.\n\nThe hook is only as good as the command in `verify.txt`. A good one:\n\n`vitest` needs `vitest run`, and `jest` needs `--watchAll=false` in some setups.`pytest tests/billing -q`.\nExamples:\n\n```\nnpm test && npx tsc --noEmit\npytest -q && ruff check .\ngo test ./... && go vet ./...\ncargo test && cargo clippy -- -D warnings\n```\n\n**Windows gotcha:** the command runs through `cmd.exe`, which doesn't treat single quotes as quotes. `python -c 'exit(1)'` silently *passes* there, because Python evaluates a string literal and exits 0. Use double quotes inside `verify.txt`. I found this out when my own README example \"passed\" a test that should have failed.\n\nIt works, but a real session exposes some edge cases:\n\n`git status` is clean, or when the tree hasn't changed since the last passing run.`timeout=300` raises an exception but can leave child processes running (a dev server, a test watcher). Better: kill the whole process tree, and report the timeout as a failure with advice.`consecutive_blocks` may not always be there.\nI handled all four in a stdlib-only version that works on Windows, macOS and Linux. It's free and MIT licensed: **[verify-gate-hook on GitHub](https://github.com/elijahmanlockedin112/verify-gate-hook)**. Setup is the same three steps.\n\n**Write the check before the code.** If you write `verify.txt` first and run it once, it should *fail*, because the feature doesn't exist yet. If it passes, it isn't testing the feature. Tighten it before you start.\n\n**Watch for weakened tests.** The hook's message tells Claude not to weaken or skip tests, and it usually listens. Still skim the diff for edited assertions. A gate is only as honest as the test it runs.\n\nThe gate answers \"is it done?\" The harder part is deciding what \"done\" means before Claude starts. I ended up building a full Claude Code setup around this loop:\n\n`/lock-in` has Claude ask you the hard questions, then write a spec with a single verification command. It writes that command to `verify.txt`, checks that it fails first, builds step by step against it, and finally has a fresh subagent review the diff against the spec.`rm -rf ~`, force-pushes to `main`, and reading `.env` before those commands ever run.`/handoff` → `/clear` → `/pickup` saves your progress to a file and resumes from it in a fresh session, so your context stays small and your usage limit lasts longer.\nThat's the [Locked In Kit](https://lockedin.clarionproductlabs.com/): 18 skills, 6 subagents, 5 hooks, 8 CLAUDE.md templates and a playbook. The first 20 people get 30% off with code `LAUNCH`.\n\nThe hook above is free and works fine on its own, though. Add it to one project today and see how often Claude \"finishes\" with failing tests.\n\n*Independent project, not affiliated with Anthropic. Hook behavior is from the [Claude Code hooks reference](https://code.claude.com/docs/en/hooks). If something behaves differently in your version, trust the docs.*", "url": "https://wpnews.pro/news/claude-code-said-done-my-tests-said-otherwise-here-s-the-20-line-fix", "canonical_source": "https://dev.to/elijahmanlockedin112/claude-code-said-done-my-tests-said-otherwise-heres-the-20-line-fix-jc", "published_at": "2026-09-26 15:08:37+00:00", "updated_at": "2026-09-26 15:30:07.335121+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "mlops"], "entities": ["Claude Code", "Anthropic", "Boris Cherny", "Python", "vitest", "pytest", "npm"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/claude-code-said-done-my-tests-said-otherwise-here-s-the-20-line-fix", "markdown": "https://wpnews.pro/news/claude-code-said-done-my-tests-said-otherwise-here-s-the-20-line-fix.md", "text": "https://wpnews.pro/news/claude-code-said-done-my-tests-said-otherwise-here-s-the-20-line-fix.txt", "jsonld": "https://wpnews.pro/news/claude-code-said-done-my-tests-said-otherwise-here-s-the-20-line-fix.jsonld"}}