5.6 KiB
RFC: Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned hook/result semantics
Status: implemented
Problem
Four pieces of the dsh-hook-protocol/bridge contract missed the discipline the subagent-observe-enrich RFC records — it dropped an agentType lifecycle field for lacking a consumer, and these failed the same test:
HookDialect's'native'variant (packages/hooks/hook-protocol/src/types.ts) had zero producers — the bridges stamp'claude'and'codex'; the only'native'constructor anywhere was the lib's own unit test. The field's own JSDoc definesdialectas "the bridge that ran it", and native is not a bridge: the interception-seams RFC records that native hooks are not a package and that "a native plugin can already use the typed Decisions" without the durable hook log, and the flagship native-plugin worked example asserts exactly that (nohook/*events at all).HookOutput.suppressOutput(same file) was parsed by the codec and discarded on every path: no bridge branch, no merge fold, no warn, no deferred-list row — uniquely among its parsed-but-unhonored siblings, each of which carries a stated deferral (updatedInput→ a logged warn plus the pre-tool-input-rewrite proposal;systemMessage→ a logged warn plus a README deferred row;continue/stopReason→ aTODO(hook-continue-false)anchor plus the'stop'decision record). Structurally there is nothing to suppress: hook stdout never enters any transcript (context flows only viaadditionalContext; the log records onlydecision/stderrSummary), so a hook author settingsuppressOutput: truegot silent nothing with no warn.defaultTimeoutMswas double-defaulted in both bridge configs with a floating literal — a schema.default(600_000)AND a?? 600_000fallback (packages/hooks/hooks-claude/src/index.ts,packages/hooks/hooks-codex/src/index.ts), two homes per bridge for one protocol-level constant, so the bridges could silently drift apart on the shared default. The proposal's original remedy — delete the knob outright — was overtaken by the no-hardcoded-tunables audit, which kept the knob as the explicit bridge-owned config (and addedstderrSummaryMaxCharsbeside it); what remained to fix was the literal's home.- The
hook/resultsemantics lived in the bridges, twice, not in the lib that owns the event.summarize()— the stderr truncation rule — was byte-identical inpackages/hooks/hooks-claude/src/index.tsandpackages/hooks/hooks-codex/src/index.ts, and so was the decision-string ruleoutput.decision ?? (output.continue === false ? 'stop' : 'pass'); yetdsh-hook-protocoldeclaredhook/result, documentedstderrSummaryas "truncated" without owning the truncation, and documented the decision values without owning the mapping. If one bridge drifted (a different cap, a different fallback), the shared durable event's semantics would fork silently.
Decision
HookDialect is the closed bridge set, 'claude' | 'codex'; HookOutput omits unsupported suppressOutput. hook/result.durationMs remains durable audit timing and is normalized only in snapshots. Reference defaults live once in DEFAULT_HOOK_TIMEOUT_MS and DEFAULT_STDERR_SUMMARY_MAX_CHARS. HookResultRecord and appendHookResult own stderr summarization and decision derivation for both bridges. BLOCKING_EXIT_CODE is codec-internal.
Alternatives considered
Why not keep them?
The hook-protocol-lib RFC deliberately recorded "parses the full CC superset" — the strongest counterargument was that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events). On durationMs the review reached the opposite verdict: a persistence log is written for future readers, and wall-clock hook timing is audit signal worth carrying before a reader exists — so it stays, with replay normalization as the accepted cost. On item 4, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib.
Verification
HookDialect is two-valued (rg "'native'" in the hooks packages returns nothing); suppressOutput appears nowhere in source, parsed-field doc lists, or the normalizer, while durationMs stays on hook/result and in the fixtures with the replay scrub intact; the literals 600_000 and 500 each live once, in the lib's DEFAULT_HOOK_TIMEOUT_MS/DEFAULT_STDERR_SUMMARY_MAX_CHARS, with per-hook timeoutSec still overriding; and the truncation rule and decision-string rule are defined once, in dsh-hook-protocol's appendHookResult, exercised by both bridges' suites.
Consequences
The dialect, suppressOutput, tunables, and semantics changes are invisible on the wire and in the goldens. The cost was churn in dsh-hook-protocol and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart.