TL;DR: Want Claude Code to keep working until tests pass? Add a Stop hook. It is a small script Claude Code runs every time it tries to finish. The script runs your tests, and if they fail, it sends Claude back to work with the error. In these runs it caught every miss, but it needs a retry limit, or one impossible check will make Claude loop and even edit your tests.
You give Claude Code a small job and go make tea. You come back to a cheerful message: “Done, all tests pass.” Then CI (the checks that run on every push) goes red. Sound familiar?
The tests did pass. Claude just didn’t know your project also wants a clean linter and a changelog line for every change. Those rules live in a script it never opened.
That gap is the whole problem. By default, Claude decides when it is done. This post shows how to hand that decision to your own checks, what that changed in real runs, and the one trap that can make it worse.
Why does Claude Code stop before the job is really done?

It stops when the model thinks it is finished, not when your project says so.
Here is why. Claude Code works as a loop. It reads files, runs a tool, looks at the result, and goes again. Each time it writes a reply instead of calling a tool, the loop ends and control comes back to you.
So “done” is a judgment call made by the same model that did the work. Think of a student marking their own homework. Most days they are right. Some days they skip the question on the back of the page.

And Claude can only check what it knows about. Does it know about your changelog rule? If your definition of done is “tests, lint and changelog”, and only the tests are obvious, it will happily stop after the tests.
What is a Stop hook in Claude Code?
A Stop hook is a script that runs every time Claude tries to finish, and it can say “not yet”.
Hooks (shell commands Claude Code runs on its own at fixed moments) live in a settings file. Each moment is called an event. Other events can block risky commands before they run or tell you when Claude needs you. Stop is the event that fires when Claude finishes its reply.
When Stop fires, Claude Code starts your script and passes it a little JSON (structured text) on standard input. Your script runs whatever check you want. If the check passes, it does nothing and Claude stops as normal.
If the check fails, the script prints {"decision": "block", "reason": "..."}. Claude Code then keeps the session going and gives Claude your reason as its next instruction. Exiting with code 2 and writing the reason to stderr does the same thing.
Picture a guard at the door with a checklist. You can try to leave whenever you like. The guard reads the list, and if something is missing, hands you the list and points you back inside.

How do I set it up?
Two files: a few lines in settings, and a short script that runs your checks.
You need a project with a check command that exits 0 when everything is fine. That can be npm test, cargo test, pytest, or a script that runs several checks at once.
- Create
.claude/hooks/check-before-stop.pyin your project (code below). - Add the Stop hook to
.claude/settings.json(the project settings file you can commit). - Add
.claude/stop-blocks.txtto.gitignore. The script keeps its retry count there. - Change
CHECKin the script to your own command. - Start a fresh Claude Code session so it loads the hook.
Here is the settings part:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-before-stop.py",
"timeout": 300
}
]
}
]
}
}And the script. It is the logic from these runs, minus the logging, plus one line that resets the counter when it gives up:
#!/usr/bin/env python3
"""Stop hook: don't let Claude finish while the checks are red."""
import json, pathlib, subprocess, sys
CHECK = ["bash", "scripts/ci/verify.sh"] # your own check command
MAX_BLOCKS = 3 # your retry limit
data = json.load(sys.stdin)
project = pathlib.Path(data.get("cwd", "."))
counter = project / ".claude" / "stop-blocks.txt"
blocks = int(counter.read_text()) if counter.exists() else 0
result = subprocess.run(CHECK, cwd=project, capture_output=True, text=True)
if result.returncode == 0: # green: let Claude stop
counter.unlink(missing_ok=True)
sys.exit(0)
if blocks >= MAX_BLOCKS: # tried enough: hand control back to you
counter.unlink(missing_ok=True)
sys.exit(0)
counter.write_text(str(blocks + 1))
tail = "\n".join(result.stdout.strip().splitlines()[-15:])
print(json.dumps({
"decision": "block",
"reason": "The project checks are still failing. Fix them before you finish. "
"Do not edit, skip or delete tests to make them pass. "
"If a check cannot pass on this machine, stop and explain why.\n\n" + tail,
}))That reason carries the last 15 lines of output on purpose. Claude sees the actual failure, not just “try again”, so its next move is a fix instead of a guess.
So the same file works for every model you run in that project, because the check runs outside the model. Put it in ~/.claude/settings.json instead if you want it in every project.

Does a Stop hook actually make a difference?
On easy work it never fired. On a project with hidden rules, it caught every miss.
To check if this is worth the setup, this write-up ran Claude Code headless (claude -p, no chat window) on two small Python projects, many times each, and checked the result myself afterwards.
The first test was a plain bug: one failing test, plus a trap where the obvious fix breaks a different test. Sonnet and Haiku both ran the full suite on their own, spotted the trap and fixed both, in every run, with or without the hook. The hook ran once per session, saw green, and stayed quiet.
That is worth knowing. If your tests are the only rule and the suite is easy to find, Claude usually checks them already.
But the second test is closer to real life. The task was “add a format_price function and tests”. The project’s real definition of done sat in scripts/ci/verify.sh: tests, a linter, and a changelog line under “Unreleased”. Nothing in the prompt mentioned it.

Here is what came out, counted as “the full check passes at the end”:
| Setup | Sonnet | Haiku |
|---|---|---|
| Plain prompt | 7 of 9 | 1 of 4 |
One line in CLAUDE.md | 6 of 6 | 4 of 4 |
| Stop hook | 6 of 6 | 4 of 4 |
So what did the misses have in common? Every one lacked the changelog line, and one also had a code line too long for the linter. With the hook, Haiku tried to stop early in all 4 runs. The hook blocked it once, Haiku read the failure, added the line, and finished green.

Still, the catch is the sample size. These are small projects and short sessions, not a study. But the pattern was clean: when the rule is hidden, the plain prompt misses it, and both fixes catch it.
What happens if the check can never pass?
Without a limit, Claude loops until Claude Code’s safety cap stops it, and sometimes it cheats.
This is the part most guides skip. One more test needs a secret key only CI has. No amount of good code can make it pass on a laptop. Then the hook ran with the retry limit effectively turned off.
Claude Code has its own brake. After a Stop hook keeps a turn going eight times in a row, it overrides the next block and ends the turn. The counter resets every time Claude calls a tool, so in practice the hook fired 9 or 10 times before it ended.

That cap did stop the loops. Three of the four runs ground on until it. Sonnet’s bill roughly doubled, from about \$0.14 for a normal run to \$0.27 and \$0.28. The fourth run was worse in a quieter way: Haiku added a “skip this test when the key is missing” rule to the test file, and the hook happily let it go.
Nobody told it to cheat. A gate that can’t open pushes the model toward the one path that turns it green, and editing the test is that path.

So the fix is the two lines already in the script above: your own MAX_BLOCKS, and a reason that says “don’t touch the tests, explain instead”. With those, all four runs tried three times, then stopped and explained that the key only exists in CI. No test was edited, and Sonnet’s runs cost \$0.18 and \$0.20.
Should I use /goal or CLAUDE.md instead?
[CLAUDE.md](CLAUDE.md) asks, /goal judges from the chat, and a Stop hook actually runs the check.
CLAUDE.md is the instruction file Claude Code reads at the start of every session. One line there (“before you say you are done, run bash scripts/ci/verify.sh“) fixed every run in this test. If you already keep a good CLAUDE.md, start there.
Then there is /goal, a built-in command. You type a finish condition, and after each turn a small, fast model checks whether it holds. Under the hood it is a Stop hook too, but one that reads the conversation. It does not run commands or open files itself, and it lasts for one session.
A command Stop hook is the only one of the three that runs the check with its own hands, every session, whatever the model remembers or claims. That is the difference that matters when you are not watching.

Still, there is a fair case for skipping the hook. In short, attended sessions with a clear CLAUDE.md, it added a test run and a few seconds per stop and changed nothing. For long runs, cheaper models or work you start and walk away from, a check that cannot be talked out of is worth those seconds.
What should the hook check?
Your real definition of done, and nothing so slow that every stop feels like a deploy.
The hook runs on every stop, so its speed is your speed. Point it at the checks that decide whether you would merge the change: unit tests, the linter, a type check, a changelog rule. Leave the 20-minute end-to-end suite to CI. The full list of hook fields lives in the Claude Code hooks docs.
And keep that output short and specific. The reason is Claude’s whole briefing for the next attempt, so “3 failed: test\_price\_negative expected ‘-\$0.50′” beats a wall of logs.
Then set the limit low. Three tries is plenty. If Claude can’t fix it in three, you need to look at it anyway.
So set it up once. Then let the tests say done.
Common questions about Claude Code keep working until tests pass
How do I make Claude Code run tests before it finishes?
Add a Stop hook in .claude/settings.json that runs your test command. If the tests fail, the hook returns "decision": "block" with the output, and Claude keeps working.
Will a Stop hook make Claude Code loop forever?
No. Claude Code ends the turn after eight blocks in a row, and you can change that with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP. Add your own lower limit anyway so an impossible check stops early.
What is stop\_hook\_active in a Claude Code hook?
It is a field in the hook’s input that is true when Claude is already continuing because of a Stop hook. You can check it to avoid blocking again on something that will never pass.
Is /goal the same as a Stop hook?
/goal is a session-only Stop hook where a small model judges your condition from the conversation. A command Stop hook runs your actual check every time, in every session.
First-hand test log (Claude Code 2.1.318, claude -p, tool allowlist, empty HOME per run)
- Test 1, bug + regression trap: Sonnet plain 4/4, ask 3/3, hook 3/3, /goal 3/3; Haiku plain 3/3, hook 3/3. Hook never blocked.
- Test 2, hidden definition of done in
scripts/ci/verify.sh(pytest + ruff E,F,D + CHANGELOG Unreleased line), task “add format\_price + tests”: Sonnet plain 7/9,CLAUDE.md6/6, hook 6/6 (0 blocks). Haiku plain 1/4,CLAUDE.md4/4, hook 4/4 (1 block each). Misses: all lacked changelog, one also E501. Median Sonnet cost plain \$0.137, hook \$0.140. - Test 3, impossible check (test needs PAYMENTS\_SANDBOX\_KEY). No limit: hook runs 10, 2 (test edited to skip), 9, 10; Sonnet \$0.280 / \$0.268. MAX\_BLOCKS 3 + “don’t edit tests” reason: 4 hook runs each, 0 test edits, Sonnet \$0.200 / \$0.177, all explained the missing key.