mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 00:56:30 +03:00
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:
co-authored by
Claude Fable 5
parent
9b7f7ffbba
commit
47aff2e0be
@@ -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;
|
||||
|
||||
|
||||
@@ -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')),
|
||||
|
||||
Reference in New Issue
Block a user