My blog’s deploy gate is a hook. When I run git push, a PreToolUse hook checks for an approval marker and exits 2 to block the push if it’s missing. I trust it because the exit code decides, not me.
Claude Code’s /goal loop doesn’t work that way. You hand it a completion condition and it runs turns on its own until the condition is “met,” but the thing deciding “met” is a small evaluator model that only reads the conversation. It never runs your tests. So a worker can write “17 tests pass,” or delete the tests and pass, and the loop happily stops. On its own, /goal trusts the worker’s self-report one layer removed.
This guide adds the missing piece: a Stop hook that verifies completion with an exit code, so “done” becomes a process result instead of a model’s claim.
Two scope notes up front. First, this is a best-effort guard against a cooperative-but-lazy model, not a security boundary against an adversary; a worker with unrestricted shell access can reach around it, and I’ll show where. Second, I built the two hooks below and a test harness that drives them through the documented inputs (a passing turn, a failing turn, a re-entry, a deleted test directory, a tampered config, a missing dependency or baseline, a corrupt counter, an edit aimed at the verifier), and everything here is what that harness actually does. I have not run a full overnight /goal loop, so treat the runtime timing as documented rather than lived, and test the scripts on a small project before trusting them unattended.
Targets (from the official docs, as of July 2026):
Claude Code v2.1.139+ /goal and Stop hooks
jq any parse the hook's stdin JSON
A test command that exits non-zero on failure (npm test, pytest, …)
A trusted workspace: /goal is disabled until you accept the trust dialog
The blind spot: who actually decides “done”?
/goal gives you a place to declare a finish line. It does not check whether you crossed it.
Per the /goal docs, after each turn “a small fast model checks whether the condition holds,” and that evaluator “doesn’t run commands or read files independently,” so it can only judge what Claude has already surfaced in the conversation. The judge is grading the worker’s report of its own work, and self-grading is generous. Anthropic’s harness writeup puts it plainly: “Out of the box, Claude is a poor QA agent.” Asking the model that did the work to certify the work is the wrong loop.
The fix is not a better prompt. Split the verdict: let the model keep judging meaning (does this text describe the goal being met?), and let a process judge fact (did the tests actually pass?). The model keeps its half; the exit code takes the other. That division is the whole idea. It is generator-vs-evaluator applied to loop termination, with the evaluator swapped from a second model to a shell script.
1. Write the completion condition as a contract
The condition string is the spec. If it’s vague, the verdict is vague. Write it in four parts.
| Part | What it is | If you skip it |
|---|---|---|
| Goal | One end state | Mixing several lets it stop half-done |
| Proof | Commands whose exit code decides pass/fail | The model declares “done” on vibes |
| Constraints | What must not change | The shortest path routes around the goal |
| Limit | A turn or time cap | A spinning loop burns your budget |
Claude Code has no separate turn-limit flag, so the cap goes inside the condition text. Keep the proof deterministic, and separate it from semantic acceptance: mechanical facts (tests, build, types) go in the proof, while “the README reads clearly” or “the API names are consistent” are for the model or you to judge, not the exit code. If you want a doc check in the proof, turn it into a command (npm run docs:check) that exits non-zero.
A worked example instead of “refactor the billing module”:
/goal Remove the duplicate validation in the billing module. Keep the public
API behavior unchanged. Proof: run `npm test -- billing` in the session each
turn; it must exit 0 with the real tool output in the transcript. Constraints:
do not edit files under test/, do not change the test/build scripts in
package.json. Commit the finished change and leave the working tree clean.
Stop after 20 turns and report what's left.
“Run it in the session” matters: the evaluator only sees what the conversation surfaces, so require an actual tool result, not a prose claim like “all tests pass.” The Stop hook below re-runs the commands anyway, so a fabricated summary just gets caught.
2. Add the verifier (a Stop hook)
The /goal docs describe it as “a wrapper around a session-scoped prompt-based Stop hook.” Add a second Stop hook, a script, to the same event, and it runs real commands at the end of every turn and decides by exit code. A Stop-hook block prevents the stop, so if your script blocks, the turn continues no matter what the evaluator concluded.
One thing to get right before anything else: a Stop hook in settings.json fires on every session in its scope, not just /goal runs. Left ungated it would run your tests at the end of every ordinary turn and block normal work. So the script is inert unless the session opts in with an environment flag, and you launch goal sessions with the flags that match your contract:
GOAL_GATE_ACTIVE=1 GOAL_FREEZE_TESTS=1 \
GOAL_TEST_CMD='npm test -- billing' GOAL_BUILD_CMD='npm run build' \
GOAL_MAX_ATTEMPTS=20 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP=20 \
claude -p "/goal <the billing condition from step 1>"
GOAL_TEST_CMD matches the proof command in the condition (otherwise the gate runs the default npm test), GOAL_FREEZE_TESTS=1 enforces the “don’t edit tests” constraint, and the two cap variables match the 20-attempt budget (both explained below).
Two more rules make it a gate rather than a suggestion. It fails closed: if it can’t run its own checks (no jq, no git, no baseline) it blocks instead of passing, because a broken gate that opens is worse than no gate. And it blocks on repeat with a cap: every turn re-runs the checks, blocking a failing completion up to a bound, then halts the run with continue: false (a real stop plus a failure reason), never a silent pass.
One honest caveat on “fails closed”: the hook runs under an outer timeout (600 seconds in the settings below). If Claude Code kills the process before it prints a verdict, a killed hook is non-blocking, so keep that timeout comfortably above your test-plus-build time, or enforce a shorter one inside the script and turn it into a normal failure.
Here’s the load-bearing part of .claude/hooks/verify-goal.sh (the full script is a download):
[ "${GOAL_GATE_ACTIVE:-0}" = "1" ] || exit 0 # inert unless this session opted in
MAX_ATTEMPTS="${GOAL_MAX_ATTEMPTS:-8}"; [[ "$MAX_ATTEMPTS" =~ ^[1-9][0-9]*$ ]] || MAX_ATTEMPTS=8
command -v jq >/dev/null || emit_block_raw "jq not found; blocking rather than pass unverified."
[ -f .claude/goal-base ] || emit_block "baseline missing; run the setup step."
# ... run tests/build, clean-tree, protected-config diff, and the test-integrity diff → $FAILURES ...
if [ ${#FAILURES[@]} -eq 0 ]; then rm -f "$COUNTER_FILE"; exit 0; fi # pass: allow the stop
N=0; [ -f "$COUNTER_FILE" ] && { N=$(cat "$COUNTER_FILE"); [[ "$N" =~ ^[0-9]+$ ]] || N=0; } # validate: never exit 1
N=$((N + 1))
printf '%s\n' "$N" > "$COUNTER_FILE" || halt "cannot persist the retry counter; halting as incomplete."
[ "$N" -ge "$MAX_ATTEMPTS" ] && halt "not verified after $N attempts; halting as incomplete." # continue:false
jq -nc --arg r "Not met (attempt $N). Fix and re-run; do not edit tests to pass." '{decision:"block", reason:$r}'
exit 0
emit_block, emit_block_raw, and halt are one-line JSON helpers; the download has them and the full check bodies.
chmod +x .claude/hooks/verify-goal.sh
If verification needs judgment rather than an exit code (open a file, weigh a design choice), Claude Code also has experimental type: "agent" Stop hooks that spawn a subagent with tool access. This guide uses a command hook because an exit code is cheaper, auditable, and deterministic once the command is defined.
3. Protect the verifier (best effort)
A gate is only as strong as its own immutability, and the worker can reach the gate. Nothing above stops it from editing verify-goal.sh, deleting the hook from settings.json (or settings.local.json), resetting .claude/goal-base to the current commit, or rewriting the test script in package.json to exit 0. The clean-tree check doesn’t help, because a worker that commits its tampering leaves a clean tree.
So put a gate in front of the gate. A PreToolUse hook denies edits and commands that target the verifier, its settings, or the baseline. Its deny is worth using because, per the hooks guide, “PreToolUse hooks fire before any permission-mode check … A hook that returns permissionDecision: "deny" blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions.”
The core of .claude/hooks/protect-gate.sh (the full script is a download):
PROTECTED='\.claude/hooks/(verify-goal|protect-gate)\.sh$|\.claude/settings(\.local)?\.json$|\.claude/goal-base$'
case "$tool" in
Edit|Write|MultiEdit|NotebookEdit) [[ "$fp" =~ $PROTECTED ]] && deny "Editing the completion gate is not allowed." ;;
Bash) grep -qE "$PROTECTED" <<<"$cmd" && deny "Command touches the gate's files."
grep -qE '\b(rm|rmdir|mv)\b[^|;&]*\.claude(/|\b)' <<<"$cmd" && deny "Command removes or moves .claude." ;;
esac
chmod +x .claude/hooks/protect-gate.sh
Be honest about what this is: a guard, not a sandbox. It denies the obvious edits, but a worker with a shell can still reach around it with a symlink, a wrapper, or a here-doc, and the retry counter lives in a temp file the same OS user can overwrite. The PreToolUse layer raises the bar; it is not a security boundary. If you need one, run the loop in a sandbox or under an external supervisor. The more robust half of the defense is at Stop time, below, where the verifier re-checks the protected config and the test files against the baseline no matter how the change was committed.
4. Register both hooks
Add the Stop and PreToolUse hooks to .claude/settings.json. The Stop event ignores matchers, so don’t add one there. Registering does not turn the gate on; it stays inert until you launch with GOAL_GATE_ACTIVE=1.
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-goal.sh", "args": [], "timeout": 600 } ] }
],
"PreToolUse": [
{ "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash",
"hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-gate.sh", "args": [] } ] }
]
}
}
5. Commit the gate, then record a baseline
Two things have to happen in order. First, commit the hooks and settings. They live under .claude/, and if they’re untracked when the goal starts, the clean-tree check blocks on your very first turn, before any goal work, because the gate’s own files leave the tree dirty.
git add .claude/hooks/verify-goal.sh .claude/hooks/protect-gate.sh .claude/settings.json
git commit -m "Add the /goal completion gate"
Then record the baseline the gaming checks diff against, and keep the marker out of the tree. Don’t reach for .gitignore: appending to it modifies (or creates) a tracked file, which dirties the tree again. Use .git/info/exclude, which ignores the marker locally without touching a tracked file:
grep -qxF ".claude/goal-base" .git/info/exclude || echo ".claude/goal-base" >> .git/info/exclude
git rev-parse HEAD > .claude/goal-base
6. Test the gate before you trust it
Meet the hooks by hand before you meet them inside a session. I drove verify-goal.sh with the documented Stop inputs and a scratch git repo, asserting the contract for each case: inactive without the flag is inert; a passing turn allows the stop; a failing turn blocks; a re-entry still blocks; deleting the whole test directory blocks; a committed package.json tamper blocks; a corrupt counter blocks instead of erroring; and the cap halts with continue: false. A minimal slice:
export CLAUDE_PROJECT_DIR="$PWD" GOAL_GATE_ACTIVE=1
echo '{"session_id":"t"}' | GOAL_TEST_CMD=true .claude/hooks/verify-goal.sh; echo "exit=$?" # pass → allow
echo '{"session_id":"t"}' | GOAL_TEST_CMD=false .claude/hooks/verify-goal.sh | jq . # fail → block JSON
echo '{}' | PATH="" "$(command -v bash)" .claude/hooks/verify-goal.sh # no jq → still blocks
Confirm both are wired with /hooks, which lists each hook and the settings file it came from.
The exit-code contract (the trap that gets everyone)
Here’s the Stop hook’s contract, confirmed against the hooks reference, and the one row that will waste an afternoon:
| Hook returns | Result |
|---|---|
| exit 0, no output | no verdict, stop normally |
exit 0 + stdout {"decision":"block","reason":"…"} | blocks, reason goes to the worker |
| exit 2 + stderr message | blocks, stderr goes to the worker |
| exit 1 | non-blocking error, blocks nothing |
Every instinct says exit 1 means failure, so a script that exits 1 on a failed test should block the stop. It doesn’t. In a Stop hook, exit 1 is a non-blocking error and the gate opens silently, the exact opposite of the Unix convention. I’ve built enough enforcement hooks to distrust exit-code assumptions (my deploy gate blocks on exit 2, not 1), and this is the nastiest version, because the failure mode is a gate that looks wired and quietly passes everything. It is the same silent-pass shape that shows up as a pipefail false pass in the Ralph loop. The verifier uses exit 0 plus block JSON so no verdict path returns 1 by design, and it normalizes a garbled counter to zero (and halts if the counter can’t be written) so a bad value can’t crash it into an exit 1 either.
Block on repeat without looping forever
There’s a real tension. When the hook blocks, the worker gets another turn, and that turn ends with a Stop too, so the hook fires again. Enforce naively and you loop forever; guard naively and you stop enforcing.
The common example short-circuits on the re-entry flag: if stop_hook_active then exit 0. That kills the infinite loop, but look at the cost. The first stop runs the checks, and every stop after a block is waved through unchecked, so the gate blocks exactly once. One corrective turn, then it opens no matter what. For something labeled “deterministic gate,” that is a hole big enough to walk through, and it is why the harness asserts that a re-entry still blocks.
The fix is a bounded counter instead of a one-shot guard. Re-run the checks every turn and count consecutive failed verifications per session, resetting the moment a turn passes. Block until the checks pass or the count hits GOAL_MAX_ATTEMPTS, and at the cap return continue: false, which the docs describe as stopping Claude “entirely after the hook runs” and taking “precedence over any event-specific decision fields.” That halts the run with a stated failure reason rather than declaring success. (This counter bounds how often the gate rejects completion; the or stop after 20 turns clause in the condition bounds the overall goal run.) Coming from backend work, this is a bounded retry: keep trying, but give up loudly in a failed state instead of spinning forever or quietly passing.
Line the counter up with Claude Code’s own limit. It stops honoring a Stop hook after eight consecutive blocks, so a GOAL_MAX_ATTEMPTS above that never fires your explicit halt — Claude Code gives up first with a generic warning. Keep the two equal: raise both GOAL_MAX_ATTEMPTS and CLAUDE_CODE_STOP_HOOK_BLOCK_CAP together when a goal legitimately needs more attempts.
Close the gaming routes
If the finish line is “tests pass,” the fastest path is to delete the tests. That is not malice, it is the shape of the objective, so the constraints have to close the routes the goal opens. The verifier fails if any protected build or test config (package.json, lockfiles, jest.config.js, and so on) changed or was newly created against the baseline — the robust answer to the “rewrite the test script to exit 0” attack, since it catches the change at Stop time even after the worker commits it, and it still holds if you opt out of the clean-tree check. And it diffs the test paths against the baseline (git shows deletions even when the directory is gone, so removing the whole test/ folder is caught), counting deleted it/test/describe declarations and added .skip/.only/.todo.
Be precise about what that last part does and doesn’t catch. It flags deletions and skips, but not a quiet weakening that keeps the declaration — turning expect(total).toBe(42) into expect(true).toBe(true) sails through, because the it(...) line is untouched. If your goal shouldn’t edit tests at all (the example above says exactly that), set GOAL_FREEZE_TESTS=1 and any change under the test paths is a block. For a stack the default patterns don’t fit — pytest, JUnit, Go, Rust — point GOAL_INTEGRITY_CMD at your own integrity script.
One limit stands regardless: this catches an early “done,” not a wrong one. It can tell you a test was deleted or disabled; it cannot tell you the test was weak to begin with. The quality of the acceptance criteria is still a human’s job.
What this stops, and what it doesn’t
Stops: the model’s path-of-least-resistance shortcuts. Declaring “done” without running anything, summarizing a test run it didn’t do, deleting or skipping tests, rewriting the test script to pass, stopping while the build is red or the tree is dirty. These are the realistic failure modes of a capable model taking the cheap route, and the gate turns each one into a block with a reason.
Doesn’t stop: a worker that treats the gate as an adversary. Same OS user, unrestricted shell, and it can overwrite the retry counter, symlink around the PreToolUse protection, or delete .claude outright (after which the hook command fails, and a failed hook command is non-blocking). The counter is operational state, not a security boundary. If your threat model includes an adversarial agent, the enforcement has to live outside its reach: a sandbox, managed settings it can’t edit, or an external supervisor process.
The gate is not the whole safety story
A completion gate decides done. It does not make a long autonomous run safe. Anything with outside effect (sending, paying, deploying, deleting, mutating source data) belongs behind a human approval gate, enforced with a PreToolUse hook: the same mechanism as step 3, on a different set of commands. Add a turn or time cap, keep the baseline commit so git reset --hard "$(cat .claude/goal-base)" is your undo, and don’t hand a production credential to a loop that runs while you sleep.
What you get, within that honest boundary, is bounded deterministic enforcement of completion. The loop can’t stop just because the worker said it was done: every stop re-runs the tests, the build, and the tree, config, and gaming checks, and an unsupported completion is blocked, up to a cap, then halted in a stated failure rather than a pass. The model still reads the meaning of your condition. It no longer gets the last word on whether the work is finished.