DSH / Atlas
2026-07-04implementedsimplification

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:

  1. 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 defines dialect as "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 (no hook/* events at all).
  2. 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 → a TODO(hook-continue-false) anchor plus the 'stop' decision record). Structurally there is nothing to suppress: hook stdout never enters any transcript (context flows only via additionalContext; the log records only decision/stderrSummary), so a hook author setting suppressOutput: true got silent nothing with no warn.
  3. defaultTimeoutMs was double-defaulted in both bridge configs with a floating literal — a schema .default(600_000) AND a ?? 600_000 fallback (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 (with stderrSummaryMaxChars beside it); the fix is the literal's home.
  4. The hook/result semantics lived in the bridges, twice, not in the lib that owns the event. summarize() — the stderr truncation rule — was byte-identical in packages/hooks/hooks-claude-code/src/index.ts and packages/hooks/hooks-codex/src/index.ts, and so was the decision-string rule output.decision ?? (output.continue === false ? 'stop' : 'pass'); yet dsh-hook-protocol declared hook/result, documented stderrSummary as "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 生命周期字段,以下各项没有通过同一检验:

  1. 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/* 事件)。
  2. 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 得到的是无声的空操作,且无任何警告。
  3. defaultTimeoutMs 在两个 bridge 配置中都以游离的字面量重复设置了默认值——schema 的 .default(600_000) 加上一个 ?? 600_000 回退(packages/hooks/hooks-claude-code/src/index.tspackages/hooks/hooks-codex/src/index.ts),一个协议级常量在每个 bridge 中有两个归属地,两个 bridge 可能在共享默认值上悄然分歧。按 no-hardcoded-tunables 规则,该旋钮保留为 bridge 拥有的显式配置(旁边有 stderrSummaryMaxChars);要修的是字面量的归属地。
  4. hook/result 的语义存在于两个 bridge 中(各一份),而非拥有该事件的 lib。 summarize()——stderr 截断规则——在 packages/hooks/hooks-claude-code/src/index.tspackages/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 移除了不受支持的 suppressOutputhook/result.durationMs 保留为持久化的审计计时,仅在快照中做归一化。参考默认值各只存在一处:DEFAULT_HOOK_TIMEOUT_MSDEFAULT_STDERR_SUMMARY_MAX_CHARSHookResultRecordappendHookResult 共同负责两个 bridge 的 stderr 摘要化和 decision 推导逻辑。BLOCKING_EXIT_CODE 为 codec 内部常量。

曾考虑的替代方案

为什么不保留它们?

不受支持的词汇可以在真正有消费方时回归。durationMs 保留,因为持久化的审计计时独立于当前是否有读取方而有价值。Bridge 特有的 payload 构造留在各自 bridge 中,而共享持久化事件的归一化属于协议库。

验证

HookDialect 仅包含 Claude 和 Codex,suppressOutput 在源码、已解析字段文档和归一化逻辑中均不存在。durationMs 保留在事件和 fixture(测试前置数据)中,回放时做清洗。600_000500 两个默认值各只在协议库中出现一次;每个钩子的超时覆盖仍然生效;两个 bridge 的测试套件均验证了由库拥有的 stderr 截断和 decision 规则。

后果

dialectsuppressOutput、可调参数和语义变更在协议格式(wire format)和预期输出中均不可见。代价是 dsh-hook-protocol 和两个 bridge 中的改动——在预发布立场下成本很低,也比让一项持久事件语义的两个副本各自老化更便宜。