Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics
收紧 hook-protocol 约定——dialect、被丢弃的字段、双重默认值与 lib 拥有的 `hook/result` 语义
Four pieces of the `dsh-hook-protocol`/bridge contract missed the discipline the [subagent-observe-enrich Agent Note](../../archived/feature/2026-06-30-subagent-observe-enrich.md) records — it dropped an `agentType` lifecycle field for lacking a consumer, and these failed the same test: 1. **`HookDialect`'s `'native'` variant** (`packages/hooks/hook-protocol/src/types.ts`) had zero producers — the bridges stamp `'cla
English
Problem
Four pieces of the dsh-hook-protocol/bridge contract missed the discipline the subagent-observe-enrich Agent Note 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 extension-points Agent Note 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-code/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 knob stays as explicit bridge-owned config per the no-hardcoded-tunables rule (withstderrSummaryMaxCharsbeside it); the fix is 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-code/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 expected outputs. 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.
中文
问题
dsh-hook-protocol/bridge 约定中有四部分没有遵守 subagent observe/enrich Agent Note 记下的准则——后者因缺少消费方而删除 agentType 生命周期字段,以下各项没有通过同一检验:
HookDialect的'native'变体(packages/hooks/hook-protocol/src/types.ts)没有生产者——bridge 会标记'claude'和'codex';所有位置中唯一构造'native'的是该库自己的单元测试。字段自身的 JSDoc 将dialect定义为「运行它的 bridge」,而 native 不是 bridge:拦截扩展点 Agent Note 记载 native 钩子不是一个包,并且「native 插件无需持久钩子日志即可使用类型化 Decision」;旗舰 native 插件实践示例恰好断言了这一点(完全没有hook/*事件)。HookOutput.suppressOutput(同一文件)被 codec 解析后在所有路径上均被丢弃:没有 bridge 分支处理它、没有合并 fold、没有 warn、没有 deferred-list 行——在所有「被解析但未兑现」的同类字段中它是唯一没有明确延期声明的(updatedInput→ 一条 warn 日志加 pre-tool-input-rewrite 提案;systemMessage→ 一条 warn 日志加 README deferred 行;continue/stopReason→ 一个TODO(hook-continue-false)锚点加'stop'decision 记录)。从结构上看根本无物可抑制:钩子 stdout 从不进入任何 transcript(文本记录);上下文仅通过additionalContext流入,日志也只记录decision/stderrSummary。因此,钩子作者设置suppressOutput: true得到的是无声的空操作,且无任何警告。defaultTimeoutMs在两个 bridge 配置中都以游离的字面量重复设置了默认值——schema 的.default(600_000)加上一个?? 600_000回退(packages/hooks/hooks-claude-code/src/index.ts、packages/hooks/hooks-codex/src/index.ts),一个协议级常量在每个 bridge 中有两个归属地,两个 bridge 可能在共享默认值上悄然分歧。按 no-hardcoded-tunables 规则,该旋钮保留为 bridge 拥有的显式配置(旁边有stderrSummaryMaxChars);要修的是字面量的归属地。hook/result的语义存在于两个 bridge 中(各一份),而非拥有该事件的 lib。summarize()——stderr 截断规则——在packages/hooks/hooks-claude-code/src/index.ts与packages/hooks/hooks-codex/src/index.ts中逐字节相同;decision 字符串规则output.decision ?? (output.continue === false ? 'stop' : 'pass')同样如此。然而dsh-hook-protocol声明了hook/result、在文档中将stderrSummary描述为「已截断」却不拥有截断逻辑,记录了 decision 值却不拥有映射逻辑。如果某个 bridge 漂移(不同的上限、不同的回退),共享持久化事件的语义就会悄然分叉。
决策
HookDialect 是封闭的 bridge 集合:'claude' | 'codex';HookOutput 移除了不受支持的 suppressOutput。hook/result.durationMs 保留为持久化的审计计时,仅在快照中做归一化。参考默认值各只存在一处:DEFAULT_HOOK_TIMEOUT_MS 与 DEFAULT_STDERR_SUMMARY_MAX_CHARS。HookResultRecord 与 appendHookResult 共同负责两个 bridge 的 stderr 摘要化和 decision 推导逻辑。BLOCKING_EXIT_CODE 为 codec 内部常量。
曾考虑的替代方案
为什么不保留它们?
不受支持的词汇可以在真正有消费方时回归。durationMs 保留,因为持久化的审计计时独立于当前是否有读取方而有价值。Bridge 特有的 payload 构造留在各自 bridge 中,而共享持久化事件的归一化属于协议库。
验证
HookDialect 仅包含 Claude 和 Codex,suppressOutput 在源码、已解析字段文档和归一化逻辑中均不存在。durationMs 保留在事件和 fixture(测试前置数据)中,回放时做清洗。600_000 和 500 两个默认值各只在协议库中出现一次;每个钩子的超时覆盖仍然生效;两个 bridge 的测试套件均验证了由库拥有的 stderr 截断和 decision 规则。
后果
dialect、suppressOutput、可调参数和语义变更在协议格式(wire format)和预期输出中均不可见。代价是 dsh-hook-protocol 和两个 bridge 中的改动——在预发布立场下成本很低,也比让一项持久事件语义的两个副本各自老化更便宜。