docs: audit hook compatibility limits
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -10,9 +10,9 @@ A package README can explain APIs and runtime mechanics without answering the qu
|
||||
|
||||
Every workspace package README ends with the canonical [Model Experience section](../../../cookbook/adding-a-package.md#4-write-the-package-readme), immediately before `## Known Limitations and Deferred Work`; a package on the no-limitations allowlist ends with Model Experience itself. Packages with direct, multi-surface, conditional, capped, or lifetime effects use one H3 block per context surface. Each block says what the relevant model literally receives and when under `**What the model sees**:`, then classifies the token effect under `**Token effect**:`; the structured section grounds at least one surface with inline code, a nested `markdown` block, or an anchored catalog link. Every stable system-prompt paragraph, including a one-liner, follows those fields inside the owning H3 as a titled H4 plus `markdown` fence; the H3 title contains `system prompt`. Other short stable source literals remain inline with named placeholders only for interpolated values; other long non-generated literals use the same nested H4 form. Model Experience subsections do not link to each other because physical nesting owns the literal. Tool-schema surfaces use `schema` in their H3 and link the relevant anchored package section of the generated [tool schema catalog](../../../tool-catalog.md) rather than copying default descriptions or JSON Schema, then state only configuration or composition deltas absent from that catalog. A runtime-only definition outside the catalog's stated scope links that scope and explains the exception before reproducing its stable text. Summaries are reserved for data-dependent payloads and provider-owned text. The default subject is the conversation model; a package that invokes an auxiliary model, such as a summarizer or search provider, names that request separately. Agent-scoped visibility is stated where it changes which agent receives a contribution. Prompt text and tool schemas are described separately whenever configuration or scoping can hide one without the other.
|
||||
|
||||
Every package participates. A package with no model-context effect, or one simple effect rendered entirely by another package, uses the verifier's audited sentence allowlist. It carries exactly one sentence beginning `None, as ` or `Indirectly, through ` instead of expanding a negative fact into a structured block. Pure routing, transport, and keyless test-support packages use the none form when they create no model-bound content. A provider backend whose single context path is formatted and inserted entirely by a named consumer uses the indirect sentence even when it caps or filters data before returning it; wiring bundles do the same when every model effect belongs to named children. The indirect form names that consumer only to locate this package's contribution and does not restate the consumer's implementation. Structured blocks likewise document only package-owned inputs, transformations, and deltas. Packages that own model input, output shaping, multiple context paths, or an auxiliary request keep context-surface blocks even when they add zero direct prompt tokens.
|
||||
Every package participates. A package with no model-context effect, or one simple effect rendered entirely by another package, uses one of the verifier's audited short-form allowlists instead of expanding a negative fact into a structured block. It normally carries exactly one sentence beginning `None, as ` or `Indirectly, through `. A tiny package whose lack of runtime and model behavior is already self-evident may instead use exactly `None.` when `BARE_NONE_MODEL_EXPERIENCE` records the package and its audit reason. Pure routing, transport, and keyless test-support packages otherwise use the explained none form when they create no model-bound content. A provider backend whose single context path is formatted and inserted entirely by a named consumer uses the indirect sentence even when it caps or filters data before returning it; wiring bundles do the same when every model effect belongs to named children. The indirect form names that consumer only to locate this package's contribution and does not restate the consumer's implementation. Structured blocks likewise document only package-owned inputs, transformations, and deltas. Packages that own model input, output shaping, multiple context paths, or an auxiliary request keep context-surface blocks even when they add zero direct prompt tokens.
|
||||
|
||||
`verify-package-readme-model-experience` discovers packages from `packages/*/*/package.json`, requires one sibling README and the canonical final-section order, and validates one of two package-classified bodies. An allowlisted package carries exactly one sentence with its assigned prefix; every other package carries at least one H3 context surface with the two exact, non-empty fields and one blank line between each element plus at least one inline literal, nested block, or catalog link across the section. Optional verbatim literals follow those fields inside that surface, each as an H4 title paired to one non-empty `markdown` fence. Local subsection links are rejected, every system-prompt surface requires at least one nested block, and every tool-schema surface links an existing H2 section in the generated tool catalog. The check runs in `doc-sync` and the parallel gate runner. It owns package classification, structural presence, concrete-literal evidence, nested-block shape, catalog-link shape, and order; implementation review owns coverage, link relevance, and the truth of the prose.
|
||||
`verify-package-readme-model-experience` discovers packages from `packages/*/*/package.json`, requires one sibling README and the canonical final-section order, and validates one of three package-classified bodies. A bare-none package carries exactly `None.`, an explanatory short-form package carries exactly one sentence with its assigned prefix, and every other package carries at least one H3 context surface with the two exact, non-empty fields and one blank line between each element plus at least one inline literal, nested block, or catalog link across the section. Optional verbatim literals follow those fields inside that surface, each as an H4 title paired to one non-empty `markdown` fence. Local subsection links are rejected, every system-prompt surface requires at least one nested block, and every tool-schema surface links an existing H2 section in the generated tool catalog. The check runs in `doc-sync` and the parallel gate runner. It owns package classification, structural presence, concrete-literal evidence, nested-block shape, catalog-link shape, and order; implementation review owns coverage, link relevance, and the truth of the prose.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
Reference in New Issue
Block a user