Files
deepseek-harness/docs/rfc/proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md
Tianyi Cui 9ebc38badf docs(rfc): revise the batch for the hooks-stack base; add three post-stack RFCs
The branch now bases on the hooks stack (PR #138's head), so every
'in-flight' reference to stack content became a current-state fact.
Audited all nine RFCs + three supplements claim-by-claim against the
merged tree (none invalidated; several strengthened):

- prune-dead-core-spine-surface: describe the landed tools/pre-execute →
  dispatch → tools/post-execute pipeline — listeners return Decisions,
  the registry builds every result and snapshots it to protect callId,
  and a second (mutation-guard) test pins the field; drop the resolved
  wait-for-interception hedge; scope the additionalContext ferry out.
- prune-producerless-vocabulary-variants: the ui-stdio fixture migrated
  off the continuation trigger (llm-replay is the sole writer now); the
  stack's own additions (rejected, prompt/blocked, hook/invoked+result)
  all arrived with producers — the admission policy demonstrated live.
- prune-unimplemented-subagent-vocabulary: enrichment landed as
  lastAssistantMessage only (agentType was dropped in the stack's own
  review — the same judgment this RFC extends); the seam RFC now names
  tools/pre-execute deny, which exists, sharpening the re-add path.
- drop-inert-request-knobs / drop-image-content-block /
  trim-acp-bridge-unreachable-surface: current-state rewordings (shipped
  bridges set no request fields; only compact-basic has explicit image
  arms; 13 hook goldens also pin agentInfo).
- generic-long-running-tool-runtime census: second production seam
  consumer (hook-protocol runHook: resolve+run, stdin/env, foreground
  only — background machinery stays single-consumer); scrub-duplication
  blast radius.
- discover-package-inventory: identical 54-entry tsconfig reference
  sets; the comparesLog scenario knob (fixture-derivable, like recorded).
- unify-agent-and-session-id: third divergence site (in-process subagent
  children mint two UUIDs), the hooks bridge id-lookups, and ui-stdio's
  labelBySession map as a consumer that deletes under unification.

New RFCs from the post-stack survey:
- remove-agent-steering-mirror: the last mirror-of-durable event; zero
  production listeners; both retention RFCs deferred its fate, and the
  'no durable twin' rationale is contradicted by the adjacent append.
- tighten-hook-protocol-contract: producer-less 'native' dialect,
  parsed-and-discarded suppressOutput, and hook/result semantics
  (truncation + decision-string) defined twice in the bridges instead
  of the lib that owns the event.
- single-source-acp-replay-config: cordis.yml/cordis.snapshot.yml differ
  by exactly one plugin entry, with no gate on the forced symmetry.
2026-07-04 11:24:44 +08:00

4.9 KiB

RFC: Tighten the hook-protocol contract — the native dialect, suppressOutput, and lib-owned hook/result semantics

Status: proposed

Problem

Three pieces of the freshly-landed dsh-hook-protocol contract miss the discipline the hooks stack itself applied elsewhere (its review dropped a subagent/end agentType field for lacking a consumer — the same test these fail):

  1. HookDialect's 'native' variant (packages/hooks/hook-protocol/src/types.ts) has zero producers — the bridges stamp 'claude' and 'codex'; the only 'native' constructor anywhere is 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-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 (no hook/* events at all).
  2. HookOutput.suppressOutput (same file) is 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 gets silent nothing with no warn.
  3. The hook/result semantics live in the bridges, twice, not in the lib that owns the event. summarize() — the 500-character stderr truncation rule — is byte-identical in packages/hooks/hooks-claude/src/index.ts and packages/hooks/hooks-codex/src/index.ts, and so is the decision-string rule output.decision ?? (output.continue === false ? 'stop' : 'pass'); yet dsh-hook-protocol declares hook/result, documents stderrSummary as "truncated" without owning the truncation, and documents the decision values without owning the mapping. If one bridge drifts (a different cap, a different fallback), the shared durable event's semantics fork silently.

Proposal

Narrow HookDialect to 'claude' | 'codex' and fix its JSDoc; retarget the lib's one 'native' test. Drop suppressOutput from HookOutput, the codec's parse lines, its codec-test assertions, and the parsed-superset lists in the lib README and hook-protocol-lib RFC (amended per implemented/AGENTS.md). Move the hook/result semantics into the lib: appendHookResult (or a helper it exposes) derives stderrSummary and the decision string from the HookOutput + exit outcome, and both bridges delete their private copies. Rider: un-export BLOCKING_EXIT_CODE (zero importers; even the codec tests spell the literal 2).

Why not keep them?

The hook-protocol-lib RFC deliberately records "parses the full CC superset", and 'native'/suppressOutput are days old — the strongest counterargument is that this re-litigates fresh decisions. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; and a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter — the exact bar the stack's own review enforced when it dropped agentType. Both return trivially with their first real producer (a transcript surface that has hook stdout to suppress; a native-provenance feature that logs hook events). On item 3, 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.

Acceptance criteria

  • HookDialect is two-valued; rg "'native'" in the hooks packages returns only this RFC's amended references.
  • suppressOutput appears nowhere in source, tests, or parsed-field doc lists.
  • One definition each of the truncation rule and the decision-string rule, in dsh-hook-protocol, exercised by both bridges' suites; the hook-matrix snapshot goldens are byte-identical.

Risks

All three changes are invisible on the wire and in the goldens (dialect values emitted today are claude/codex; suppressOutput influences nothing; the folded semantics are the same rules). The cost is touching a week-old package — cheap now, per the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart.