The drift check in derive-cc-output-keys.mjs failed when the CC binary named
an event the allow-list lacked. It said nothing when the allow-list asserted an event the binary
never named — 6 of 9 additionalContext events were exactly that, one of them
(PostCompact) independently known to be wrong. This PR makes the gate bidirectional, with a
reviewed-exception mechanism for the events since settled by live observation, and fixes the second bug
the issue surfaced along the way: PostToolUseFailure's handler was reading a field CC never sends.
allow-list: 9 asserted / 3 corroborated by the binary / 6 unverified WARN: no binary string names these events as additionalContext consumers; the missing-only check above cannot see them (#3418): PostCompact PostToolUse PostToolUseFailure PreToolUse SessionStart UserPromptSubmit OK: every event the binary names is present in the generated allow-list. exit=0 ← green, despite 6 unproven assertions
allow-list: 9 asserted / 3 corroborated by the binary / 4 reviewed exception / 2 unreviewed CONFIRMED by trace-and-observe, not binary prose (reviewed exception, see spec/cc-output-keys.spec.yml additionalContext.reviewed_exceptions): PostToolUse PreToolUse SessionStart UserPromptSubmit DRIFT: no binary string names these events as additionalContext consumers, and they carry no reviewed exception (#3418): PostCompact PostToolUseFailure exit=3 ← red on real, still-unproven entries
Both panels are the script's actual stdout,
captured 2026-08-12 running node scripts/derive-cc-output-keys.mjs --check against the real
CC 2.1.228 binary on this machine — not a mockup.
A blanket exception for all 6 uncorroborated events would
just move the false-signal from "gate is silent" to "gate is silenced" — the issue explicitly warned
against pruning or excepting on documentation-prose absence alone. Only events settled by
trace-and-observe (emitting hook → output builder → hookSpecificOutput.additionalContext
→ observed arriving in the model) qualify. Four did, during the issue's own comment thread:
Builder outputAllowWithContext (lib/output.ts:304-311) ·
emitting hook pretool/bash/gh-milestone-enforcer.ts:49 · observed: the
"No --milestone set" hint arriving live.
Builder outputPromptContext (lib/output.ts:195-196) ·
emitting hooks prompt/visual-style-nudge.ts, prompt/multi-step-task-nudge.ts ·
observed: the nudge lines arriving live.
Builder outputWithContext (lib/output.ts:257-258) ·
emitting hook lifecycle/sync-session-dispatcher.ts · observed: the
CLAUDE_PLUGIN_ROOT= line arriving live.
Emitting hook posttool/write/debt-marker-tracker.ts:
a file with a real debt marker was written as an active experiment; the advisory came back attached to
that exact write.
Registered, but #3321 has independent evidence CC ignores
additionalContext on it — a live no-op: because it sits in the allow-list, the #1794
output guard doesn't strip it, so the hook emits into a void.
Its own emitting hook (failure-handler.ts) was
reading a field CC never sends — see below — so the event was unobservable regardless of CC
support. Fixing the field read makes it observable; it hasn't been traced-and-observed yet, so it stays
out of the reviewed set on purpose.
failure-handler.ts built its entire suggestion
logic on input.tool_error, which is always undefined — so every real tool
failure fell through to outputSilentSuccess() with no error message to match against. Decompiling
the shipped CC 2.1.228 binary's PostToolUseFailure payload builder:
let p = {
...zh(s, void 0, o),
hook_event_name: "PostToolUseFailure",
tool_name: e, tool_input: r, tool_use_id: t,
error: n, // ← the real field
is_interrupt: i,
duration_ms: c
};
| field | failure-handler.ts read | CC actually sends |
|---|---|---|
| error message | input.tool_error | input.error |
subagent-stop/retry-handler.ts
already read input.error || input.tool_error — the correct field was one hook away the
whole time. types.ts now documents tool_error as never sent and error
as the shared, verified field across SubagentStop/StopFailure/PostToolUseFailure.
src/hooks vitest (full suite) 7566 passed, 2 skipped, 314 files failure-handler.test.ts 16 passed (fixtures updated: tool_error → error) output-guard-cc-contract.test.ts 7 passed (unaffected — different Sets) typecheck (src/hooks) 0 errors npm run build (root) clean, plugins/ regenerated, no count drift test:manifests passed derive-cc-output-keys.mjs --check exit=3 (real DRIFT now visible, was exit=0)
The DRIFT is intentional, not a regression. cc-contract-probe.yml is a
scheduled, non-blocking workflow (never wired into ci.yml) that now fails loud on
PostCompact and PostToolUseFailure until each is settled by the same
trace-and-observe method as the four above — exactly the "make the asymmetry visible rather than
silent" the issue asked for, now enforced instead of merely printed.