Browse Source

docs: audit hook compatibility limits

Tianyi Cui 2 months ago
parent
commit
c74f17a143

+ 2 - 2
docs/config-catalog.md

@@ -301,7 +301,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/hooks/hooks-claude/src/index.ts:56`](../packages/hooks/hooks-claude/src/index.ts)
+Source: [`packages/hooks/hooks-claude/src/index.ts:55`](../packages/hooks/hooks-claude/src/index.ts)
 
 ## `@deepseek-ai/dsh-hooks-codex`
 
@@ -326,7 +326,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/hooks/hooks-codex/src/index.ts:47`](../packages/hooks/hooks-codex/src/index.ts)
+Source: [`packages/hooks/hooks-codex/src/index.ts:46`](../packages/hooks/hooks-codex/src/index.ts)
 
 ## `@deepseek-ai/dsh-jsonrpc`
 

+ 1 - 1
docs/cookbook/adding-a-package.md

@@ -66,7 +66,7 @@ Stable system-prompt prose of any length, or another long non-generated literal,
 
 Fill Model Experience from the implementation. Direct, multi-surface, conditional, capped, or lifetime effects use one H3 block per context surface; each block has the exact bold-led `What the model sees` and `Token effect` fields shown above. A structured section grounds at least one surface in concrete model-visible text through inline code, a nested `markdown` block, or an anchored tool-catalog link. Put every stable system-prompt paragraph, including a one-liner, in a titled H4 plus `markdown` fence immediately after those fields inside the owning H3 whose title contains `system prompt`; never leave prompt prose in inline code. Quote other short stable model-visible source literals inline, using named placeholders such as `<mode>` only for interpolated values, and attach other long non-generated literals to their owning H3 in the same H4-plus-fence form. Describe an attached literal as the text "below" instead of linking between Model Experience subsections; the physical nesting already records ownership. A tool-schema surface uses `schema` in its H3 and links the relevant anchored package section in the generated [tool schema catalog](../tool-catalog.md) instead of copying its default descriptions or JSON Schema; describe only configuration or composition deltas the catalog does not contain. A runtime-only definition outside the catalog's stated scope links that scope and explains the exception before reproducing its stable text. Summarize data-dependent payloads or provider-owned text by identifying their exact shape and renderer. Do not infer prompt visibility from tool-schema visibility because independently registered guidance can remain after a scoped tool restriction.
 
-An audited package with no context effect or one simple consumer-owned path belongs in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) and uses one line beginning `None, as ` or `Indirectly, through `. Pure routing, transport, and keyless test-support packages use `None, as ` when they create no model-bound content even if consumers use them during composition. A provider backend whose single context path is formatted and inserted entirely by a named consumer uses `Indirectly, through ` even when it caps or filters data before returning it; a wiring bundle whose model effects all belong to named children uses the same form. Do not give these packages a structured block describing another package's work. Packages that own model input, output shaping, multiple context paths, or an auxiliary request keep context-surface blocks; the verifier gates their H3 headings, field labels, spacing, concrete literal evidence, nested H4-plus-`markdown` blocks, absence of local subsection links, system-prompt literals, and schema-surface-to-catalog links. A package with genuinely no limitations joins the separate allowlist in [`verify-package-readme-limitations.ts`](../../scripts/verify-package-readme-limitations.ts) and ends after Model Experience. The [Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale.
+An audited package with no context effect or one simple consumer-owned path belongs in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) and uses one line beginning `None, as ` or `Indirectly, through `. If a tiny package's lack of runtime and model behavior is already self-evident from its package contract, it may instead join the narrow `BARE_NONE_MODEL_EXPERIENCE` allowlist and use exactly `None.`; the allowlist retains the audit reason so the README need not repeat it. Pure routing, transport, and keyless test-support packages otherwise use `None, as ` when they create no model-bound content even if consumers use them during composition. A provider backend whose single context path is formatted and inserted entirely by a named consumer uses `Indirectly, through ` even when it caps or filters data before returning it; a wiring bundle whose model effects all belong to named children uses the same form. Do not give these packages a structured block describing another package's work. Packages that own model input, output shaping, multiple context paths, or an auxiliary request keep context-surface blocks; the verifier gates their H3 headings, field labels, spacing, concrete literal evidence, nested H4-plus-`markdown` blocks, absence of local subsection links, system-prompt literals, and schema-surface-to-catalog links. A package with genuinely no limitations joins the separate allowlist in [`verify-package-readme-limitations.ts`](../../scripts/verify-package-readme-limitations.ts) and ends after Model Experience. The [Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale.
 
 ## 5. Verify
 

+ 8 - 8
docs/rfc/implemented/feature/2026-06-30-hook-bridges.md

@@ -6,14 +6,14 @@ Status: implemented
 
 The harness's extension surface is its typed interception seams ([the interception-seams RFC](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/prompt-submit`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`, `subagent/start`, `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This RFC introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.md)).
 
-The framing that shapes the whole design: **a bridge is a faithfulness adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's only reason to exist is to run an UNMODIFIED external CC/Codex hook with byte-faithful semantics. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, map the neutral outcome onto a seam Decision.
+The framing that shapes the whole design: **a bridge is a compatibility adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's reason to exist is to run the explicitly supported subset of external CC/Codex command hooks. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, and map the neutral outcome onto a seam Decision. The package READMEs own the exact current unsupported-event and partial-field inventory against the official protocols.
 
 ## Decision
 
 Two independent plugins in the `packages/hooks/` group, each a function/namespace plugin (`name`/`inject`/`Config`/`apply`, NO default export — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)) injecting only `bash`:
 
-- **`dsh-hooks-claude`** — the CC dialect. Seven hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`. Owns CC's per-event stdin payloads (a base of `session_id`/`cwd`/`hook_event_name` plus per-event fields), CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. A CC hook's stdin carries a **trailing newline**.
-- **`dsh-hooks-codex`** — the Codex dialect: a deliberate SUBSET. Five hook points (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no subagent/notification/compaction), an always-regex matcher, snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no env and no `${…}` substitution, and a block-only decision model (a Codex hook can never pre-approve, so `allow`/`ask` are not honored). A tool call's payload carries the real `tool_name` (the value the matcher tests, so a config's tool matcher fires) in Codex's `tool_input: { command }` shape.
+- **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. A CC hook's stdin carries a **trailing newline**.
+- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. A tool call's payload carries the real `tool_name` in the bridge's reduced `tool_input: { command }` shape.
 
 ### Outcome → Decision mapping
 
@@ -26,8 +26,8 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se
 | `tools/pre-execute` | `deny`→`deny`; `ask`→`ask` | `block`→`deny` (no allow/ask) |
 | `tools/post-execute` | `deny`→`block`+feedback; context-only→delegate+fold | same |
 | `agent/turn-continuation` | blocking Stop → `continue` (reason = next-step steering) | same |
-| `subagent/start` (emit) | additionalContext → inject into a live in-process child; a remote child has no local injection target | — (not a Codex event) |
-| `subagent/end` (emit) | observe-only | — |
+| `subagent/start` (emit) | additionalContext → inject into a live in-process child; a remote child has no local injection target | unsupported by this bridge |
+| `subagent/end` (emit) | observe-only | unsupported by this bridge |
 
 The CC bridge's `ask` result is a real permission path, not a terminal bridge decision: `dsh-tools` resolves it through the optional [approval seam](2026-07-06-approval-seam.md). A composed ACP answerer prompts the owning editor session and `allowed-once` proceeds; without an ApprovalService or answerer, the call fails closed to `deny`.
 
@@ -45,16 +45,16 @@ Claude Code always exports `CLAUDE_PROJECT_DIR`, and common unmodified hooks ref
 
 ### Containment
 
-The config is parsed ONCE at load; a read/parse failure logs and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run — a `prompt`/`agent`/HTTP hook (CC) or an `async: true` / non-command hook (Codex) is parsed-and-skipped with a warning. The emit-listener paths (`session-start`, `subagent/start`) run detached, with their `inject` contained in a `.catch` that logs (a throwing inject must not break session boot or the loop).
+The config is parsed ONCE at load; a read/parse failure logs and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run for CC; `http`, `mcp_tool`, `prompt`, and `agent` handlers are parsed-and-skipped. Codex runs only synchronous command handlers and skips `async: true` or non-command entries. The emit-listener paths (`session-start`, `subagent/start`) run detached, with their `inject` contained in a `.catch` that logs (a throwing inject must not break session boot or the loop).
 
 ### Where hooks run, and where their config comes from
 
 Two different cwds, kept distinct on purpose. The hooks **themselves** run in the agent's **session workspace**: for the agent-scoped points the bridge threads the session's `cwd` (`session/new.cwd`, on the session header) to `runHook` as the process working directory, so a hook's `pwd` / relative-file read / marker write operates in the user's project tree, not the server's launch directory. The **config path**, by contrast, is **process-level**: `configPath` is resolved and parsed once at load against the process launch cwd, so a single `hooks.json` applies to the whole process — there is no per-session config discovery that reads a project-local `hooks.json` from each `session/new.cwd` (`TODO(per-session-hook-config)`). This is an honest limitation of the current cut: the example `cordis.yml` documents that its `./hooks.json` is process-level, not per-project.
 
-## Deferred (faithful-but-degraded)
+## Deferred compatibility gaps
 
 - **Tool-input rewrite.** A CC/Codex `updatedInput` is logged + warned, not honored — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)), because the pre-execution args are read by `tool/call` audit + `assistant/message` history + ACP/tool-bash presentation, so an honest rewrite is a design unit, not a field.
-- **Stop loop-guard** (`TODO(stop-loop-guard)`). CC/Codex break an infinite force-continue with `stop_hook_active` (true once a Stop hook fired this run) plus a max-consecutive cap; both are deferred. `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands.
+- **Stop loop-guard** (`TODO(stop-loop-guard)`). Claude Code supplies `stop_hook_active` and overrides a hook after eight consecutive blocks; Codex supplies `stop_hook_active` but documents no equivalent cap. Both bridges always report `false`, so a Stop hook that unconditionally blocks force-continues every step — a hook author must self-limit until state tracking lands.
 - **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; the halt request is recorded in the `hook/result` log, and the hook keeps its per-point effect (decision/context) meanwhile.
 - **Config discovery.** The path is explicit in `cordis.yml` and process-level (see above); the full multi-layer CC/Codex precedence walk, per-session project-local discovery, and the trust/hash model are not reimplemented (`TODO(per-session-hook-config)`).
 - **Session-start / subagent-start context is best-effort, not gated (`TODO(session-start-gating)`).** `agent/session-start` is a synchronous emit and the bridge runs its hook on a detached `.then`, so the injected `additionalContext` is not guaranteed to land before the first turn reaches the model — a slow hook can miss the first request (the context then arrives as a later injection). `subagent/start` is emitted only after child publication, so the bridge can capture the live in-process child synchronously, but the result driver may queue the prompt as that same readiness boundary resolves and a short-lived child can finish before the detached hook injects. Making startup context a gated/awaited primitive is a loop-level change deferred to the interception seams; the contract is "injected as soon as the hook resolves", not "before the first request". The bridge tests do NOT wait on the injection where they assert the guaranteed-timing behavior, so they document the real (best-effort) timing rather than masking it.

File diff suppressed because it is too large
+ 0 - 0
docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md


+ 12 - 9
packages/hooks/hooks-claude/README.md

@@ -1,8 +1,8 @@
 # @deepseek-ai/dsh-hooks-claude
 
-A cordis plugin that runs a user's existing **Claude Code** hook config (a `hooks.json`, or a settings file's `hooks` key) on the harness's canonical interception seams. It is the **CC dialect** half of the hooks subsystem: it owns CC's per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the mapping from a hook's neutral outcome onto the harness's typed Decisions. The dialect-agnostic primitives (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md).
+A cordis plugin that runs the supported command-hook subset of a user's existing **Claude Code** hook config (a `hooks.json`, or a settings file's `hooks` key) on the harness's canonical interception seams. It is the **CC dialect** half of the hooks subsystem: it owns the bridge's CC-shaped per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the mapping from a hook's neutral outcome onto the harness's typed Decisions. The dialect-agnostic primitives (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md).
 
-A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only to run UNMODIFIED external CC hooks faithfully**; anything bespoke should be a native plugin on the same seams (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
+A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only as a compatibility path for the mapped CC command-hook subset**; anything bespoke should be a native plugin on the same seams (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
 
 ## Config
 
@@ -26,7 +26,7 @@ In a `cordis.yml`:
     projectDir: .
 ```
 
-The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
+The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
 
 The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
 
@@ -66,9 +66,12 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }
 
 ## Known Limitations and Deferred Work
 
-- **`updatedInput` (tool-input rewrite)** is logged + warned, **not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
-- **`systemMessage`** (a hook's user-facing warning) is logged + warned, **not surfaced** — there is no user-message channel on these seams yet (only model-facing `additionalContext`). The shared merge collects it; the bridge does not yet render it.
-- **Stop loop-guard.** CC breaks an infinite force-continue with `stop_hook_active` (true once a Stop hook has fired this run) plus a max-consecutive cap; both are deferred (`TODO(stop-loop-guard)`). Today `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands.
-- **`{"continue": false}` is recorded, not enforced** — the merge computes the `stop`/`stopReason` outcome and the `hook/result` event records decision `stop`, but the run is not halted (`TODO(hook-continue-false)`).
-- **`SessionStart` cannot gate the first turn** — `agent/session-start` is a synchronous emit, so a hook's injected context lands best-effort before turn 1 (`TODO(session-start-gating)`).
-- **Hook config is process-level** — one `configPath` parsed at load for the whole process; per-session discovery of a project-local config is deferred (`TODO(per-session-hook-config)`).
+- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is parsed but never dispatched. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events).
+- **`SessionStart` is partial:** JSON `additionalContext` is consumed, but plain stdout context, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills`, and `CLAUDE_ENV_FILE` are unsupported. The hook runs detached, so context can miss the first request (`TODO(session-start-gating)`), and the payload omits current optional fields such as `model`, `agent_type`, and `session_title`.
+- **`UserPromptSubmit` is partial:** blocking and JSON `additionalContext` work, but plain stdout context, `sessionTitle`, and `suppressOriginalPrompt` are unsupported. Unless overridden, the bridge also uses its 600-second default instead of Claude Code's event-specific 30-second command timeout.
+- **`PreToolUse` is partial:** `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
+- **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `updatedToolOutput` and `updatedMCPToolOutput` are unsupported and `tool_response` is flattened to text.
+- **`SubagentStart` and `SubagentStop` are partial:** both report a constant `agent_type` of `general-purpose` and use the child session id where Claude Code reports the parent session. Start context is best-effort and can only reach a live in-process child, while stop is observe-only and cannot block the subagent or feed it context. Start omits `transcript_path`; stop also omits `agent_transcript_path`, `last_assistant_message`, `background_tasks`, and `session_crons` and always reports `stop_hook_active: false`.
+- **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message`, `background_tasks`, and `session_crons` are omitted, and the consecutive-block cap is not implemented (`TODO(stop-loop-guard)`). An unconditionally blocking hook therefore force-continues every step unless it self-limits.
+- **Common payload and output fields are partial:** mapped event payloads omit `prompt_id`, `transcript_path`, `permission_mode`, and `effort` where Claude Code would provide them. `systemMessage` is logged + warned but not surfaced; `{"continue": false}` is recorded but does not halt the run; `suppressOutput`, `stopReason`, and `terminalSequence` are not applied (`TODO(hook-continue-false)`).
+- **Handler and config support is partial:** only shell-form command handlers run. `http`, `mcp_tool`, `prompt`, and `agent` handlers are skipped; command-handler options such as `args`, `async`, `asyncRewake`, `shell`, `if`, `once`, and `statusMessage` are not honored. Matching handlers run serially and are not deduplicated, whereas Claude Code runs them in parallel and deduplicates identical handlers. One process-level `configPath` is parsed once at load; Claude Code's layered project, user, plugin, and policy discovery and live reload are not implemented (`TODO(per-session-hook-config)`).

+ 4 - 4
packages/hooks/hooks-claude/src/config.ts

@@ -1,11 +1,11 @@
 /**
- * Parse a Claude Code hook config file into the shared {@link MatcherGroup}
- * shape, faithfully to CC's `hooks.json` / settings `hooks` key format.
+ * Parse the bridge-supported subset of a Claude Code hook config file into the
+ * shared {@link MatcherGroup} shape.
  *
  * A CC config maps each event name to an array of matcher groups, each holding
  * an array of typed hooks. Only `type: 'command'` hooks run here; other types
- * (`prompt`/`agent`/`http`) are PARSED but skipped with a warning (faithful-but-
- * degraded — the same stance Codex takes). The `command` string undergoes
+ * (`http`/`mcp_tool`/`prompt`/`agent`) are parsed but skipped with a warning.
+ * The `command` string undergoes
  * `${CLAUDE_PLUGIN_ROOT}` substitution at parse time so the runner sees a literal.
  *
  * @module @deepseek-ai/dsh-hooks-claude/config

+ 4 - 5
packages/hooks/hooks-claude/src/index.ts

@@ -9,14 +9,13 @@
  * merge, the `hook/*` events) come from `@deepseek-ai/dsh-hook-protocol`.
  *
  * A native cordis plugin could do everything this bridge does — more powerfully,
- * with typed returns and no serialization boundary. The bridge exists only to
- * run UNMODIFIED external CC hooks faithfully; anything bespoke should be a
- * native plugin on the same seams.
+ * with typed returns and no serialization boundary. This bridge is a
+ * compatibility path for the mapped CC command-hook subset; anything bespoke
+ * should be a native plugin on the same seams.
  *
  * Scope: the seven in-scope hook points (`SessionStart`, `UserPromptSubmit`,
  * `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`). Only
- * `type: 'command'` hooks run; the matcher group config + exit-code/stdout
- * protocol are byte-faithful to CC. `updatedInput` (tool-input rewrite) is
+ * shell-form `type: 'command'` hooks run. `updatedInput` (tool-input rewrite) is
  * logged + warned, not honored (deferred — see the interception-seams RFC).
  *
  * @module @deepseek-ai/dsh-hooks-claude

+ 15 - 12
packages/hooks/hooks-codex/README.md

@@ -1,16 +1,16 @@
 # @deepseek-ai/dsh-hooks-codex
 
-A cordis plugin that runs a user's existing **Codex** `hooks.json` on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-specific payloads, matcher mode, and decision mapping.
+A cordis plugin that runs the supported subset of a user's existing **Codex** hook config on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-shaped payloads, matcher mode, and decision mapping.
 
-Codex's hook protocol is a deliberate **subset** of Claude Code's (same `hooks.json` shape):
+This bridge implements a deliberate subset of Codex's current hook protocol:
 
-- **Five hook points only:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no subagent / notification / compaction hooks.
+- **Five of ten hook points:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`.
 - **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex).
 - **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline.
-- **No env vars and no command substitution** (a literal `${…}` in a command survives verbatim).
-- **A block-only decision model** — `allow`/`ask` are not honored; a hook can only block, never pre-approve.
+- **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell).
+- **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input.
 
-A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only to run UNMODIFIED external Codex hooks faithfully (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
+A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only as a compatibility path for the mapped Codex subset (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
 
 ## Config
 
@@ -32,7 +32,7 @@ In a `cordis.yml`:
     model: deepseek-v4
 ```
 
-The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five Codex points are dropped at parse.
+The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
 
 The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
 
@@ -70,8 +70,11 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }`
 
 ## Known Limitations and Deferred Work
 
-- **Stop loop-guard** (`TODO(stop-loop-guard)`) — as in CC, a Stop hook that unconditionally blocks would force-continue every step (`stop_hook_active` is always `false` here); the loop-guard is deferred, and a hook author must self-limit until it lands.
-- **`systemMessage`** — a hook's user-facing warning is logged + warned, not surfaced; there is no user-message channel on these seams yet (only model-facing `additionalContext`).
-- **`{"continue": false}` is recorded, not enforced** — the `hook/result` event records decision `stop`, but the run is not halted (`TODO(hook-continue-false)`).
-- **`SessionStart` cannot gate the first turn** — `agent/session-start` is a synchronous emit with a detached continuation, so a hook's injected context lands best-effort before turn 1 (`TODO(session-start-gating)`).
-- **Hook config is process-level** — one `configPath` parsed at load for the whole process; per-session discovery of a project-local config is deferred (`TODO(per-session-hook-config)`).
+- **Unsupported hook events (5 of Codex's current 10):** `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStart`, and `SubagentStop`. Config for these events is silently dropped during parsing. The comparison baseline is Codex's [official hook reference](https://learn.chatgpt.com/docs/hooks).
+- **`SessionStart` is partial:** plain stdout and JSON `additionalContext` work, but the hook runs detached, so context can miss the first request (`TODO(session-start-gating)`).
+- **`UserPromptSubmit` is partial:** blocking plus plain-stdout or JSON context work, but the common `systemMessage` and `{"continue": false}` controls are not enforced.
+- **`PreToolUse` is partial:** blocking works, but `additionalContext`, `permissionDecision: "allow"`, and `updatedInput` are ignored. Every tool is represented as `tool_input: { command }`, so non-shell tool arguments are not faithfully exposed to the hook.
+- **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `{"continue": false}` is not enforced, non-shell tool arguments are reduced to `{ command }`, and structured tool output is flattened to text in `tool_response`.
+- **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message` is always `null`, and `{"continue": false}` is not enforced. An unconditionally blocking hook therefore force-continues every step unless it self-limits (`TODO(stop-loop-guard)`).
+- **Common payload and output fields are partial:** every mapped event reports `transcript_path: null`, the statically configured `model`, and `permission_mode: "default"` instead of current Codex runtime values. `systemMessage` is logged + warned but not surfaced, and `{"continue": false}` is recorded but does not apply Codex's event-specific stop behavior (`TODO(hook-continue-false)`).
+- **Config loading and execution are partial:** one process-level `configPath` is parsed at load; Codex's active user, project, session, system/managed, and plugin layers, trust controls, and inline `config.toml` hook form are not implemented (`TODO(per-session-hook-config)`). Only synchronous `command` handlers run, current metadata such as `statusMessage` and `commandWindows` is ignored, and matching handlers run serially rather than with Codex's concurrent launch semantics.

+ 8 - 9
packages/hooks/hooks-codex/src/config.ts

@@ -1,17 +1,16 @@
 /**
- * Parse a Codex `hooks.json` into the shared {@link MatcherGroup} shape. Codex's
- * config format is a SUBSET of Claude Code's: the same event-name → matcher-group
- * structure and the same `{ type: 'command', command, timeout?/timeoutSec? }`
- * hook shape, but only five events and NO command-string substitution (Codex sets
- * no hook env vars and does not expand `${…}`). Non-command hooks (and Codex's
- * `async: true` commands) are parsed-and-skipped with a warning.
+ * Parse the bridge-supported subset of a Codex `hooks.json` into the shared
+ * {@link MatcherGroup} shape. The bridge accepts five events and the
+ * `{ type: 'command', command, timeout?/timeoutSec? }` hook shape, performs no
+ * config-time placeholder substitution or plugin-env injection, and skips
+ * non-command and `async: true` handlers with a warning.
  *
  * @module @deepseek-ai/dsh-hooks-codex/config
  */
 
 import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
 
-/** The five hook points Codex's engine supports. */
+/** The five current Codex hook points this bridge supports. */
 export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const
 
 /** A parsed Codex config: event name → its matcher groups (command hooks only). */
@@ -37,10 +36,10 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
 
 /**
  * Parse a raw Codex `hooks.json` object into runnable {@link MatcherGroup}s.
- * Only the five {@link CODEX_EVENTS} are honored; an unknown event is dropped.
+ * Only the five bridge-supported {@link CODEX_EVENTS} are honored; another event is dropped.
  * `type !== 'command'` and `async: true` command hooks are skipped (recorded in
  * `skipped`). Malformed entries are ignored rather than thrown — a bad config
- * must not crash boot. No command substitution (Codex does none).
+ * must not crash boot. No config-time placeholder substitution is performed.
  * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
  * @returns the runnable per-event groups plus the skipped hooks with their reasons.
  */

+ 9 - 10
packages/hooks/hooks-codex/src/index.ts

@@ -3,14 +3,13 @@
  * `hooks.json` on the harness's canonical interception seams. The CODEX DIALECT
  * half of the hooks subsystem.
  *
- * Codex's hook protocol is a deliberate SUBSET of Claude Code's: five hook points
- * (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no
- * subagent/notification/compaction), regex-only matchers, snake_case stdin
- * payloads with `turn_id`/`model` extras and NO trailing newline, no env vars and
- * no command substitution, and a block-only decision model (allow/ask are not
- * honored — a hook can only block, never pre-approve). The dialect-agnostic
+ * This bridge supports five of Codex's ten current hook points (`PreToolUse`,
+ * `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`), regex-only
+ * matchers, snake_case stdin payloads with `turn_id`/`model` extras and no
+ * trailing newline, no config-time placeholder substitution or plugin-env
+ * injection, and no pre-tool approval or rewrite path. The dialect-agnostic
  * primitives come from `@deepseek-ai/dsh-hook-protocol`; this bridge owns the
- * Codex-specific payloads + matcher mode + decision mapping.
+ * Codex-shaped payloads, matcher mode, and decision mapping.
  *
  * @module @deepseek-ai/dsh-hooks-codex
  */
@@ -263,9 +262,9 @@ export function apply(ctx: Context, config: Config): void {
   })
 
   // Stop → ContinuationDecision. A blocking Stop hook forces continuation.
-  // TODO(stop-loop-guard): like CC, a Stop hook that unconditionally blocks would
-  // force-continue every step (`stop_hook_active` is always false here); the
-  // loop-guard (stop_hook_active + a max-consecutive cap) is deferred.
+  // TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can
+  // avoid continuing the same turn indefinitely. It is always false here, so an
+  // unconditionally blocking hook force-continues every step until it self-limits.
   ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
     const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
     /* jscpd:ignore-end */

+ 2 - 2
packages/hooks/hooks-codex/tests/bridge.spec.ts

@@ -116,10 +116,10 @@ describe('hooks-codex bridge', () => {
     expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal')
   })
 
-  it('only the five Codex events are honored — a SubagentStop entry is ignored', async () => {
+  it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => {
     const dir = configDir()
     const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n')
-    // SubagentStop is NOT a Codex event; it must be dropped (no crash, no effect).
+    // SubagentStop is a current Codex event that this bridge drops (no crash, no effect).
     writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
 
     const adapter = new MockAdapter([textResponse('fine')])

+ 4 - 4
packages/hooks/hooks-codex/tests/config.spec.ts

@@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest'
 import { parseCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts'
 
 describe('parseCodexConfig', () => {
-  it('honors only the five Codex events, dropping unknown ones', () => {
+  it('honors only the five bridge-supported Codex events, dropping the rest', () => {
     const { config } = parseCodexConfig({
       PreToolUse: [{ hooks: [{ type: 'command', command: 'a.sh' }] }],
-      SubagentStop: [{ hooks: [{ type: 'command', command: 'b.sh' }] }], // not a Codex event
-      Notification: [{ hooks: [{ type: 'command', command: 'c.sh' }] }], // not a Codex event
+      SubagentStop: [{ hooks: [{ type: 'command', command: 'b.sh' }] }], // current Codex event, unsupported by this bridge
+      Notification: [{ hooks: [{ type: 'command', command: 'c.sh' }] }], // unknown to current Codex
     })
     expect(Object.keys(config)).toEqual(['PreToolUse'])
     expect(CODEX_EVENTS).toContain('PreToolUse')
@@ -18,7 +18,7 @@ describe('parseCodexConfig', () => {
       Stop: [{ hooks: [{ type: 'command', command: '${NOT_SUBSTITUTED}/s.sh', timeout: 10 }] }],
       UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'u.sh', timeoutSec: 20 }] }],
     })
-    // Codex does NO substitution — the literal ${…} survives.
+    // The parser performs no config-time substitution; shell expansion happens later.
     expect(config.Stop).toEqual([{ hooks: [{ command: '${NOT_SUBSTITUTED}/s.sh', timeoutSec: 10 }] }])
     expect(config.UserPromptSubmit).toEqual([{ hooks: [{ command: 'u.sh', timeoutSec: 20 }] }])
   })

+ 1 - 1
packages/util/brand/README.md

@@ -27,4 +27,4 @@ This package owns ONLY the primitive — no concrete id, no runtime code beyond
 
 ## Model Experience
 
-None, as `Branded<B>` is erased at compile time and registers no runtime behavior.
+None.

+ 40 - 7
scripts/verify-package-readme-model-experience.ts

@@ -4,7 +4,8 @@
  * canonical context-surface blocks with optional nested verbatim H4 blocks.
  * Direct system-prompt surfaces must contain exact `markdown` blocks,
  * tool-schema surfaces must link generated catalog sections, local subsection
- * links are rejected, and an audited allowlist uses one concise sentence.
+ * links are rejected, and audited allowlists use either one concise sentence
+ * or the exact bare `None.` form.
  *
  * Run: `tsx scripts/verify-package-readme-model-experience.ts`.
  */
@@ -27,6 +28,15 @@ interface SentenceContract {
   reason: string
 }
 
+/**
+ * Packages whose lack of model behavior is already self-evident from their
+ * package contract, so repeating that contract after `None.` adds no value.
+ * The reason stays here as reviewable audit evidence.
+ */
+const BARE_NONE_MODEL_EXPERIENCE: Readonly<Record<string, string>> = {
+  'packages/util/brand': 'The package is a type-only primitive erased at compile time.',
+}
+
 /**
  * Packages whose Model Experience is simple enough for one gated sentence.
  * Every other package must carry canonical context-surface blocks. A package
@@ -57,7 +67,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/ui/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' },
   'packages/ui/jsonrpc-agent': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' },
   'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
-  'packages/util/brand': { kind: 'none', reason: 'The type-only primitive is erased at compile time.' },
   'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
   'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
   'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
@@ -144,12 +153,25 @@ const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).sort()
 const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length)))
 let structuredCount = 0
 let contextSurfaceCount = 0
-let noneCount = 0
+let bareNoneCount = 0
+let explainedNoneCount = 0
 let indirectCount = 0
 let verbatimBlockCount = 0
 let systemPromptSurfaceCount = 0
 let toolSchemaSurfaceCount = 0
 
+for (const [pkg, reason] of Object.entries(BARE_NONE_MODEL_EXPERIENCE)) {
+  if (!scannedPackages.has(pkg)) {
+    failures.push({ path: `${pkg}/README.md`, message: 'bare-none allowlist entry does not name a scanned package' })
+  }
+  if (reason.trim().length === 0) {
+    failures.push({ path: `${pkg}/README.md`, message: 'bare-none allowlist entry must retain its audit justification' })
+  }
+  if (SENTENCE_MODEL_EXPERIENCE[pkg] !== undefined) {
+    failures.push({ path: `${pkg}/README.md`, message: 'package cannot appear in both Model Experience sentence allowlists' })
+  }
+}
+
 for (const [pkg, contract] of Object.entries(SENTENCE_MODEL_EXPERIENCE)) {
   if (!scannedPackages.has(pkg)) {
     failures.push({ path: `${pkg}/README.md`, message: 'sentence allowlist entry does not name a scanned package' })
@@ -203,6 +225,17 @@ for (const packageJson of packageJsons) {
   const nextH2Line = nextH2 < 0 ? rawLines.length + 1 : (body[nextH2] as Line).index
   const rawSection = rawLines.slice(modelHeading.index, nextH2Line - 1)
   const content = section.filter(line => line.raw.trim().length > 0)
+  const bareNoneReason = BARE_NONE_MODEL_EXPERIENCE[pkg]
+  if (bareNoneReason !== undefined) {
+    const rawContent = rawSection.filter(line => line.trim().length > 0)
+    if (content.length !== 1 || rawContent.length !== 1 || content[0]?.raw !== 'None.') {
+      failures.push({ path: readme, message: 'must contain exactly the bare sentence `None.`' })
+      continue
+    }
+    bareNoneCount += 1
+    continue
+  }
+
   const sentenceContract = SENTENCE_MODEL_EXPERIENCE[pkg]
   if (sentenceContract !== undefined) {
     const pattern = sentenceContract.kind === 'none' ? /^None, as .+\.$/ : /^Indirectly, through .+\.$/
@@ -212,14 +245,14 @@ for (const packageJson of packageJsons) {
       failures.push({ path: readme, message: `must contain exactly one sentence beginning ${JSON.stringify(prefix)} and ending with a period` })
       continue
     }
-    if (sentenceContract.kind === 'none') noneCount += 1
+    if (sentenceContract.kind === 'none') explainedNoneCount += 1
     else indirectCount += 1
     continue
   }
 
-  const shortSentence = content.find(line => /^None, as |^Indirectly, through /.test(line.raw))
+  const shortSentence = content.find(line => line.raw === 'None.' || /^None, as |^Indirectly, through /.test(line.raw))
   if (shortSentence !== undefined) {
-    failures.push({ path: readme, message: `line ${shortSentence.index}: short Model Experience form requires an audited entry in SENTENCE_MODEL_EXPERIENCE` })
+    failures.push({ path: readme, message: `line ${shortSentence.index}: short Model Experience form requires an audited entry in BARE_NONE_MODEL_EXPERIENCE or SENTENCE_MODEL_EXPERIENCE` })
     continue
   }
 
@@ -338,7 +371,7 @@ for (const packageJson of packageJsons) {
 }
 
 if (failures.length === 0) {
-  console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${structuredCount} structured, ${contextSurfaceCount} context surfaces, ${systemPromptSurfaceCount} fenced system-prompt surfaces, ${toolSchemaSurfaceCount} catalog-linked tool-schema surfaces, ${noneCount} none, ${indirectCount} indirect, ${verbatimBlockCount} verbatim markdown blocks), all conform.`)
+  console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${structuredCount} structured, ${contextSurfaceCount} context surfaces, ${systemPromptSurfaceCount} fenced system-prompt surfaces, ${toolSchemaSurfaceCount} catalog-linked tool-schema surfaces, ${bareNoneCount} bare none, ${explainedNoneCount} explained none, ${indirectCount} indirect, ${verbatimBlockCount} verbatim markdown blocks), all conform.`)
   process.exit(0)
 }
 

Some files were not shown because too many files changed in this diff