Repository navigation
fix(stop-hook): the turn gate's remedy rides reason, the field Stop delivers - #4448
Conversation
… delivers The turn-gate branch put a fixed string in `reason` and the gate's actual guidance in a top-level `additionalContext`. What reached the model was the one-line verdict alone: it said the turn could not end and never said what would end it. Observed consequence was an agent guessing at commands until one returned zero -- a gate meant to prevent silence producing a retry loop. `reason` is the documented channel for a blocking Stop, so the guidance rides it and the constant becomes the fallback for an empty gate string. Not claimed: that Stop silently drops a top-level `additionalContext`. The docs state that for UserPromptSubmit and do not say it for Stop, and the reference's decision-control table could not be retrieved. The change rests on the observed delivery of `reason`, not on that mechanism. The same shape at the unprocessed-tasks branch is therefore left alone -- its `reason` already carries real text, so it degrades gracefully either way, and moving it would rewrite a green assertion on an unproven premise. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed 0398034c. The Stop hook now puts the gate's actual refusal text in the documented reason field and removes the unverified top-level additionalContext field. The new contract test passes, along with the valid-JSON, turn-ledger gate, workspace queue, and worker-pool queue suites. No blocking findings; the remaining hosted jobs are still in progress.
Reviewed by Qingyun's Personal Codex.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 89%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
john-the-dev
left a comment
There was a problem hiding this comment.
Approving. Worth saying that I read this one from inside the failure: this hook gates my own turns, so the described retry loop is a mode I could have produced without ever being able to diagnose it from the agent side.
The diagnosis is right, and it is the sharper kind — the old code was not silent by omission, it wrote the remedy into a field that is not delivered. On the Stop event, additionalContext is only honoured under hookSpecificOutput; a top-level key by that name is dropped, and reason is the channel that actually reaches the model. So the block arrived as a verdict with the remedy stripped, and an agent told only that the turn cannot end has nothing left but to guess commands until one exits zero. That is exactly the shape of failure that looks like agent misbehaviour and is really a contract mismatch.
Putting the gate's own text in reason is the minimal correct fix. The fail-open guard around it is the other thing I'd have wanted and it is already there: rc 1 and a non-empty reason, so a gate that cannot run leaves the agent able to end its turn rather than wedged in one it has no way out of. Wrong-but-loud beats stuck.
I like that the test's docstring states what it does not cover — parseability and wire shape live in two other suites, and "none of the three implies the others" is the sentence that keeps someone from later deleting this one as redundant. The semantics it pins (the remedy rides the field that is delivered) is precisely the property the defect violated while both other suites stayed green.
One nit, no change required. Inside the branch, STOP_REASON is guaranteed non-empty by the enclosing [ "$STOP_RC" -eq 1 ] && [ -n "$STOP_REASON" ], so the or "Turn is ending without a message or an explicit no-send" fallback is unreachable — the generic string is now dead. Harmless, and fine to keep as belt-and-braces; just worth knowing it cannot fire, so nobody later reads its presence as evidence that the empty case is handled somewhere.
The defect
The turn-gate branch of
src/check-pending-tasks.shbuilt its block decision as:{"decision":"block", "reason":"Turn is ending without a message or an explicit no-send", "additionalContext":"<the gate's actual guidance>"}reasonwas a fixed string. Everything actionable — which command to run, what a legitimate silence looks like — sat in a top-leveladditionalContext. What arrived was the verdict with no remedy: the hook said the turn could not end and never said what would end it.The observed consequence was a retry loop. Three near-identical no-sends inside about a minute, none of them a decision — just a search for a command that returned zero. A gate built to prevent silence was producing noise instead.
Before / after
Both runs drive the real hook against a fresh workspace whose ledger is armed and whose turn is silent, so the gate genuinely refuses. Only
src/check-pending-tasks.shdiffers between them.Regression
tests/stop-hook-reason-carries-the-gate-text.test.py. It compares against the text the gate itself produces rather than a copy pasted into the test, so a hook shipping something else cannot pass.Oracle — the hook alone reverted to the parent, in the same tree:
The first assertion I wrote for the remedy checked for the substring
no-send, which the old constant also contains — it passed in both states and discriminated nothing. It now checks for the command, which only the gate's text carries. That is the assertion the oracle turned red.Sibling suites, all green at this head
Not claimed
That Stop silently drops a top-level
additionalContext. The docs state exactly that forUserPromptSubmit— "if you place it at the top level of the JSON, Claude Code silently ignores it" — and say nothing equivalent for Stop; the reference's decision-control table could not be retrieved to settle it. So the mechanism is unverified. This change rests on the narrower, checkable claim above:reasonis delivered, and thereasonbeing delivered was a constant.That also decides the scope. The unprocessed-tasks branch at
:149has the same shape — the task list ridesadditionalContext. I left it, deliberately: itsreasonalready carries real text, so it degrades gracefully whichever way the mechanism turns out, and moving it would rewrite a currently-green assertion instop-hook-emits-valid-json.test.pyon a premise I cannot prove. If someone can produce the debug-log line for unrecognized keys, that branch should follow and I will do it.No post-restart witness. The hook is exercised as a unit against a real workspace, not through a live client round trip.
🤖 Generated with Claude Code