4.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?
Unsupported vocabulary can return when a real consumer exists. durationMs remains because durable audit timing is useful independently of a current reader. Bridge-specific payload construction stays in each bridge, while shared durable-event normalization belongs in the protocol library.
Verification
HookDialect contains only Claude and Codex, and suppressOutput is absent from source, parsed-field docs, and normalization. durationMs remains in events and fixtures with replay scrubbing. The 600_000 and 500 defaults each live once in the protocol library, per-hook timeout overrides still apply, and both bridge suites exercise the library-owned stderr truncation and decision rules.
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.