Claude Code validates the hookEventName a hook returns against the event it
actually dispatched. posttool/failure-handler is registered on
PostToolUseFailure, but its two context-bearing exits went through a shared
output helper that hardcoded 'PostToolUse'. Every suggestion it produced was
rejected before Claude ever saw it.
hooksPostToolUseFailure shared-helpersilent-failure
No single file looks wrong. The defect only exists as a relationship between them.
posttool/failure-handler under the
PostToolUseFailure event, matcher Bash|Write|Edit|Agent.
This is what CC will validate the reply against.outputWithContext() (:141, :163). Two return
outputSilentSuccess() (:113, :155). The handler never names an
event. It delegates that.hookEventName: 'PostToolUse'. Correct for the
~40 other callers, wrong for this one. The string is 100 lines and one module away
from the registration that contradicts it.Pick an exit to see what the handler returned before the fix, and what CC did with it.
Two of the four exits carry no hookSpecificOutput at all, so there is no event
name to mismatch and nothing for CC to reject. Those are exactly the paths that had nothing
useful to say. The failures that produced no advice appeared to work perfectly; only the ones
carrying a real suggestion were thrown away. The hook looked flaky when it was
deterministically broken.
| Exit | Returns | Stamps an event? | Before fix | After fix |
|---|---|---|---|---|
| :113 no error | outputSilentSuccess() | no | accepted | accepted |
| :141 RFC 9457 | outputWithContext() | yes | REJECTED | accepted |
| :155 no pattern | outputSilentSuccess() | no | accepted | accepted |
| :163 regex hit | outputWithContext() | yes | REJECTED | accepted |
An optional second parameter, defaulted to the existing value so none of the ~40 other callers change behaviour, and passed explicitly at the two failure-path call sites.
-export function outputWithContext(ctx: string): HookResult { +export function outputWithContext( + ctx: string, + hookEventName: 'PostToolUse' | 'PostToolUseFailure' = 'PostToolUse', +): HookResult { if (!ctx?.trim()) return outputSilentSuccess(); return { ... hookSpecificOutput: { - hookEventName: 'PostToolUse', + hookEventName,
- return outputWithContext(parts.join('\n')); + return outputWithContext(parts.join('\n'), 'PostToolUseFailure'); ... - return outputWithContext(context); + return outputWithContext(context, 'PostToolUseFailure');
The existing unit test for this handler mocks the shared helper with a fixture that also
hardcodes 'PostToolUse'. A regression test written there would assert the
fixture, not the shipped stamp, and would stay red even with the fix applied. The new test
uses the real output helpers with only side effects stubbed, so it fails without the source
change and passes with it. The fixture was updated to honour the new argument too, so the
test double stops diverging from the function it stands in for.
lifecycle/webhook-forwarder, the other hook on this event, was checked and left
alone: it returns outputSilentSuccess() with no
hookSpecificOutput, so it has no event name to get wrong.