PostToolUseFailure: the event name a shared helper stamped wrong

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

The three-file chain

No single file looks wrong. The defect only exists as a relationship between them.

1 · Registration

src/hooks/hooks.json:632
Registers posttool/failure-handler under the PostToolUseFailure event, matcher Bash|Write|Edit|Agent. This is what CC will validate the reply against.

2 · Handler

src/hooks/src/posttool/failure-handler.ts
Four exits. Two return advice via outputWithContext() (:141, :163). Two return outputSilentSuccess() (:113, :155). The handler never names an event. It delegates that.

3 · Shared helper

src/hooks/src/lib/output.ts:84
Hardcoded 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.

Trace a failure through all four exits

Pick an exit to see what the handler returned before the fix, and what CC did with it.

Hook returns


    

      

Why it read as intermittent

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.

ExitReturnsStamps an event?Before fixAfter fix
:113 no erroroutputSilentSuccess() noacceptedaccepted
:141 RFC 9457outputWithContext() yesREJECTEDaccepted
:155 no patternoutputSilentSuccess() noacceptedaccepted
:163 regex hitoutputWithContext() yesREJECTEDaccepted

The fix

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.

src/hooks/src/lib/output.ts
-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,
src/hooks/src/posttool/failure-handler.ts
-    return outputWithContext(parts.join('\n'));
+    return outputWithContext(parts.join('\n'), 'PostToolUseFailure');
   ...
-  return outputWithContext(context);
+  return outputWithContext(context, 'PostToolUseFailure');

What made this hard to catch

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.