Define the in-file RFC contract in docs/rfc/README.md § The file format: the header block (`# RFC: <title>` plus a dateless Status enum cross-checked against the lifecycle folder), the per-lifecycle body skeleton (a Problem opener everywhere; Proposal/Alternatives considered/ Acceptance criteria/Risks in proposed/; present-tense Decision/ Consequences with proposal-era headings banned in implemented/; the frozen proposal shape in rejected/), and a mandatory Alternatives considered section with a date-fenced grandfather comment for pre-format RFCs whose alternatives are not reconstructible from the record. Enforce it with a new doc-sync gate, scripts/verify-rfc-format.ts, and normalize all 112 RFCs to it: ~15 Status-line spellings collapse to the enum, 29 Context openers become Problem, the 39 legacy-format XXX debt markers are resolved and banned from reappearing, proposal-era sections in implemented RFCs are rewritten to shipped reality (including the web/fs/subagent seam RFCs' migration plans and test checklists, closing the doc-tiers deferred-work item on the web seam), every RFC gains an Alternatives considered section or the grandfather comment, and the bilingual pair is re-mirrored and re-recorded. Move the generated index tables out of README.md into a fully generated docs/rfc/INDEX.md — gen-rfc-index now writes the whole file, and verify-rfc-classification checks its freshness and rejects index-shaped rows in the curated README — which makes room for the format contract to live in the README front door instead of a separate FORMAT.md. The decision record, and the first RFC written in the new format, is docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md.
7.1 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 'claude' | 'codex', its JSDoc names the two bridges, and the lib's unit test constructs a 'codex' invocation. suppressOutput is gone from HookOutput, the codec's parse, the codec tests, and the parsed-superset lists in the lib README and the hook-protocol-lib RFC (amended per implemented/AGENTS.md). hook/result.durationMs stays: review judged wall-clock hook runtime worth its bytes as durable audit timing (which hook made a turn slow), so runHook keeps its injected now clock and RunHookResult wrapper, the bridges keep passing the measured duration through HookResultRecord, and the snapshot normalizer keeps scrubbing the one nondeterministic field to 0 for replay. On the tunables, the no-hardcoded-tunables audit set the shape this change keeps: defaultTimeoutMs and stderrSummaryMaxChars stay explicit bridge configs, and RunHookOptions.defaultTimeoutMs stays a required parameter the bridge passes in. What this change adds is one home per literal: the reference defaults live in the lib as DEFAULT_HOOK_TIMEOUT_MS (600 000 ms, exported from the runner) and DEFAULT_STDERR_SUMMARY_MAX_CHARS (500, exported from the events module), and both bridges' schema defaults and ?? fallbacks read those constants instead of restating the numbers. The hook/result semantics live in the lib: HookResultRecord carries the decoded HookOutput plus the bridge's stderrSummaryMaxChars, and appendHookResult derives stderrSummary (via the exported summarizeStderr(stderr, maxChars)) and the decision string from them; both bridges deleted their private copies, and the derived values are byte-identical to what the bridges wrote (the goldens prove it — their only diff is the dropped durationMs). Rider: BLOCKING_EXIT_CODE is a codec-internal const, no longer exported (it had zero importers; even the codec tests spell the literal 2).
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.