Fix Stop-hook loop: honor stop_hook_active per Claude Code contract

The Stop deep pass (runStopHook) never read the stop_hook_active field
from the Claude Code Stop-hook event. When a prior fire kept the turn
alive via hookSpecificOutput.additionalContext and the agent legitimately
declined to act, the hook re-scanned and re-blocked every re-invocation
until Claude Code's consecutive-block cap force-ended the turn (issue #400).

Read stop_hook_active early in runStopHook, right after the event is
parsed and before any scan, and exit 0 with no output when it is true. The
prior fire already surfaced the findings; acting on them is the agent's
call. Only Claude Code sends this field, so the strict === true is a no-op
for other harnesses. runHook (PostToolUse) and hook-before-edit.mjs
(PreToolUse) never receive the field, so they are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-22 17:34:20 -07:00
co-authored by Claude Fable 5
parent 9b7f7ffbba
commit 47aff2e0be
2 changed files with 54 additions and 0 deletions
+14
View File
@@ -1960,6 +1960,20 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
}
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
// hook is being re-invoked only because a prior invocation kept the turn
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
// re-blocking now would loop until Claude Code's consecutive-block cap
// force-ends the turn (issue #400). The prior fire already surfaced the
// findings; whether to act on them is the agent's call. Exit fast with no
// output before any scan. Only Claude Code sends this field; other
// harnesses omit it, so the strict `=== true` is a no-op for them. This
// guard makes the loop impossible regardless of the finding cache key's
// line-number sensitivity (out of scope here; see findingCacheKey).
if (event.stop_hook_active === true) {
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
}
const harness = resolveHarness(env, event);
audit.harness = harness;
+40
View File
@@ -3400,6 +3400,46 @@ describe('runStopHook()', () => {
assert.match(out.hookSpecificOutput.additionalContext, /em-dash-overuse/);
});
it('re-invoked with stop_hook_active:true exits 0 and silent even with pending findings (issue #400)', async () => {
const sid = 'stop-active';
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('marketing-buzzword', 3)]);
// Prime a real touched-file + finding so a plain Stop pass would fire.
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
// Re-invocation after the previous fire kept the turn alive: the contract
// says exit clean, no re-block, before scanning.
const active = { ...stopEvent(sid), stop_hook_active: true };
const stop = await runStopHook({ stdinJson: JSON.stringify(active), env: {}, cwd, detector: det });
assert.equal(stop.exitCode, 0);
assert.equal(stop.stdout, '');
assert.equal(stop.audit.emitted, undefined);
assert.equal(stop.audit.skipped, 'stop-hook-active');
});
it('stop_hook_active:false or absent still runs the deep pass as before', async () => {
const sid = 'stop-inactive';
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('marketing-buzzword', 3)]);
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
// Explicit false (the stopEvent default).
const explicitFalse = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
assert.equal(explicitFalse.audit.emitted, true);
assert.match(explicitFalse.stdout, /marketing-buzzword/);
// Field absent entirely (legacy / non-Claude-Code payloads): same behavior.
const sid2 = 'stop-absent';
const file2 = write('src/Card2.tsx', 'noop');
await runHook({ stdinJson: JSON.stringify(editEvent(file2, sid2)), env: {}, cwd, detector: det });
const ev = stopEvent(sid2);
delete ev.stop_hook_active;
const absent = await runStopHook({ stdinJson: JSON.stringify(ev), env: {}, cwd, detector: det });
assert.equal(absent.audit.emitted, true);
assert.match(absent.stdout, /marketing-buzzword/);
});
it('honors kill switches and the re-entrancy guard', async () => {
const disabled = await runStopHook({
stdinJson: JSON.stringify(stopEvent('stop-killed')),