Sfoglia il codice sorgente

feat(hooks): dsh-hook-protocol — shared Claude Code / Codex hook wire-protocol core

The two hook bridges (dsh-hooks-claude, dsh-hooks-codex) would otherwise duplicate
the bulk of the protocol — Codex deliberately reimplements a SUBSET of the Claude
Code protocol (same hooks.json shape, exit-code/stdout contract, command-hook
model). This library holds the genuinely-identical primitives; each bridge owns
only what differs (per-event stdin payload, env/substitution, decision mapping).

New packages/hooks/ group; hook-protocol is a LIBRARY (no plugin, registers/injects
nothing):
- matcher: matchesMatcher(pattern, query, mode) — the one dialect axis collapsed to
  a mode param (claude = literal-or-regex with pipe alternation; codex = always
  unanchored regex). Match-all on absent/''/'*'; invalid regex matches nothing.
- codec: parseHookOutput(exit, stdout, stderr) → dialect-neutral HookOutput. Exit 0
  → lenient JSON; exit 2 → blocking error (stderr = reason, surfaced as
  decision:'block'); other → non-blocking. Parses the CC superset
  (continue/stopReason/decision/hookSpecificOutput.{permissionDecision,
  additionalContext,updatedInput}/systemMessage); permissionDecision overrides the
  legacy top-level decision.
- runner: runHook(bash, hook, opts, now) — runs a command hook via ctx.bash (stdin
  payload + trusted-plugin env), honors timeoutSec, never throws (executor reject →
  non-blocking-error HookOutput). Injected clock for testable durations.
- merge: mergeHookOutputs — most-restrictive fold (deny>ask>allow, sticky stop,
  block reasons joined, context/system-messages accumulated).
- hook/* session events (declaration-merged into SessionEventMap, log-only like
  compact/*) + appendHookInvoked/appendHookResult helpers.

updatedInput is parsed but NOT honored (deferred pre-tool-input-rewrite RFC); a
bridge logs+warns. 47 unit tests at per-file 100% (matcher per-mode, codec per
exit-code/field, runner plumbing w/ stub executor, merge precedence, hook/*
helpers). RFC: implemented/feature/2026-06-30-hook-protocol-lib.md.
Tianyi Cui 2 mesi fa
parent
commit
65165b5d54

+ 4 - 0
AGENTS.md

@@ -77,6 +77,10 @@ packages/    Harness packages, grouped by role at packages/<group>/<pkg>/.
     tool-todo/      model-facing todo_write tool: writes the whole task list to
                     the session log (todo/write), rendered as a stdio checklist /
                     ACP plan
+  hooks/          hook bridges + shared wire protocol
+    hook-protocol/  shared Claude Code / Codex hook wire-protocol core (library,
+                    not a plugin): matcher primitive, exit-code/stdout codec,
+                    runHook (via ctx.bash), most-restrictive merge, hook/* events
   session-persistence/   persistence capability family
     session-persistence/         durable persistence seam + write coordinator
     session-persistence-jsonl/    JSONL-sidecar backend

+ 1 - 1
docs/core-data-structures/session.md

@@ -6,7 +6,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t
 
 ## `SessionEventMap` — the event vocabulary
 
-The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`.
+The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`).
 
 ```ts type-equiv
 interface SessionEventMap {

+ 3 - 0
docs/module-graph.md

@@ -20,6 +20,8 @@ graph TD
   agent --> session
   compact --> llm
   compact --> session
+  hook-protocol --> bash
+  hook-protocol --> session
   llm-replay --> llm
   llm-replay --> session
   session-persistence --> session
@@ -107,6 +109,7 @@ graph TD
 | `system-prompt` | `llm` |
 | `agent` | `brand`, `llm`, `session` |
 | `compact` | `llm`, `session` |
+| `hook-protocol` | `bash`, `session` |
 | `llm-replay` | `llm`, `session` |
 | `session-persistence` | `session` |
 | `invariants` | `agent`, `llm`, `session` |

+ 1 - 0
docs/rfc/README.md

@@ -89,6 +89,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 | [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
 | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
 | [Subagent lifecycle enrichment — agentType + lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
+| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
 
 ### Simplification
 

+ 32 - 0
docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md

@@ -0,0 +1,32 @@
+# RFC: dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core
+
+Status: implemented (accepted 2026-06-30)
+
+<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. -->
+
+## Context
+
+The hooks subsystem ships two bridge plugins: one that runs a user's existing Claude Code (CC) hooks, one for Codex hooks. Studying the reference implementations (`~/repos/refs/claude-code`, `~/repos/refs/codex`) surfaced a decisive fact: **Codex deliberately reimplements a SUBSET of the CC hook protocol.** Its engine reads the same `hooks.json`, uses the same matcher-group shape, the same exit-code/structured-stdout output contract, and the same command-hook execution model — Codex's source even names the engine after Claude's and comments where it "intentionally diverges." So the two bridges would otherwise duplicate the bulk of the protocol.
+
+This RFC introduces `@deepseek-ai/dsh-hook-protocol`, a **library** (not a plugin — it registers and injects nothing) holding the genuinely-identical primitives both bridges build on. The split between shared and per-dialect is the design's center of gravity.
+
+## Decision
+
+A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns four primitive families and the `hook/*` session events; each bridge (PR-F) owns what genuinely differs.
+
+**Shared (here):**
+- **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop).
+- **Execution** — `runHook(bash, hook, options, now)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added in the bash-seam PR for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec`, and never throws (an executor rejection becomes a non-blocking-error `HookOutput`).
+- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the full CC superset (`continue`/`stopReason`/`suppressOutput`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect.
+- **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order.
+- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges.
+
+**Per-dialect (the bridges, PR-F):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PromptDecision`, `ContinuationDecision`, `PostToolDecision`).
+
+### Why "shared core + per-dialect adapters", not "one parameterized engine"
+
+A single engine parameterized by a full `dialect` descriptor was considered and rejected. The payload construction and decision mapping are where the dialects genuinely diverge (different field names, different supported outputs, CC's env/substitution); folding those into a data-driven descriptor would make the *bridge* logic indirect — a reader of `dsh-hooks-claude` would have to chase a descriptor to see what payload it sends. Keeping the truly-identical primitives shared (matcher, codec, runner, merge, events) and letting each bridge write its own straightforward payload+mapping keeps each bridge readable standalone, at the cost of a little duplication in the payload shape. The primitives are the part where duplication would actually be dangerous (a divergent matcher or exit-code rule is a correctness bug); the payload is the part where explicitness beats sharing.
+
+## Consequences
+
+The two bridges (PR-F) become thin: parse the config file, pick a matcher mode, build the per-event payload+env, call `runHook` + `mergeHookOutputs`, map the outcome to a Decision, and append `hook/*`. The protocol's correctness-critical halves (matcher semantics, exit-code contract, merge precedence) live in one tested place — `hook-protocol` ships with heavy unit tests (matcher per-mode, codec per exit-code/field, runner plumbing with a stub executor, merge precedence, the `hook/*` helpers) at per-file 100%. Input rewrite (`updatedInput`) is parsed but not honored (the deferred [pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs+warns on it. The package is a library, so it has no `cordis.yml` load path of its own — its real-load-path coverage comes through the bridge plugins that consume it (PR-F).

+ 2 - 0
packages/README.md

@@ -14,6 +14,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
 | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (backend + tool deferred) | Product — stable surface |
 | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
 | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
+| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
 | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
 | [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface |
 | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
@@ -88,6 +89,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
 | `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) |
 | `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
 | `tool-todo/` | `todo` | Model-facing `todo_write` tool; writes the whole task list to the session log (`todo/write`) | (registers on `ctx.tools`) |
+| `hook-protocol/` | `hooks` | Shared Claude Code / Codex hook wire-protocol library: matcher, codec, `runHook`, merge, `hook/*` events | (none — library, no service) |
 | `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) |
 
 Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).

+ 11 - 0
packages/hooks/README.md

@@ -0,0 +1,11 @@
+# hooks/ — hook bridges + shared protocol
+
+The hooks subsystem lets users extend the agent at lifecycle points the way Claude Code and Codex do — by pointing a bridge plugin at an existing `hooks.json` (or settings) so those external shell hooks run faithfully. The canonical extension surface itself is the harness's typed interception seams ([the interception-seams RFC](../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)); a "native hook" is just an ordinary cordis plugin on those seams. These packages are the **bridges** that translate the external shell-hook protocol onto that same surface, plus the shared wire-protocol library they build on.
+
+| Package | Role | Shape |
+|---|---|---|
+| `hook-protocol/` | Shared wire-protocol core: matcher primitive, exit-code/stdout codec, `runHook` (via `ctx.bash`), most-restrictive merge, `hook/*` session events | library (no plugin) |
+| `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin |
+| `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin |
+
+Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md).

+ 35 - 0
packages/hooks/hook-protocol/README.md

@@ -0,0 +1,35 @@
+# @deepseek-ai/dsh-hook-protocol
+
+The **shared core** of the Claude Code / Codex hook wire protocol. NOT a cordis plugin — it registers nothing and injects nothing. It is a **library** of dialect-neutral primitives the two bridge plugins (`@deepseek-ai/dsh-hooks-claude`, `@deepseek-ai/dsh-hooks-codex`) import so neither re-implements the identical halves of the protocol.
+
+Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claude Code hook protocol — the same `hooks.json` matcher-group shape, the same exit-code/stdout output contract, the same command-hook execution model. The genuinely-shared parts live here; each bridge owns only what differs.
+
+## What's shared (here) vs. per-dialect (the bridges)
+
+| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) |
+|---|---|---|
+| Matcher test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) |
+| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
+| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
+| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
+| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events) | calls them around each invocation |
+
+## Primitives
+
+- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws).
+- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `defaultTimeoutMs`), and decode the result. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
+- **`parseHookOutput(exitCode, stdout, stderr)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason`/`suppressOutput` are parsed too. Pure and total.
+- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
+
+## `hook/*` session events
+
+Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`):
+
+- `hook/invoked` — `{ turn, point, dialect, matcher?, handlerId }`: a hook command ran.
+- `hook/result` — `{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }`: its outcome, paired by `handlerId`.
+
+Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC.
+
+## Input rewrite is parsed but not honored
+
+`HookOutput.updatedInput` carries a hook's requested tool-input rewrite (CC `updatedInput`), but the harness does not honor it yet — 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)). A bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts.

+ 34 - 0
packages/hooks/hook-protocol/package.json

@@ -0,0 +1,34 @@
+{
+  "name": "@deepseek-ai/dsh-hook-protocol",
+  "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-bash": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-bash": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 129 - 0
packages/hooks/hook-protocol/src/codec.ts

@@ -0,0 +1,129 @@
+/**
+ * Parse a finished hook command's process outcome (exit code + stdout + stderr)
+ * into the dialect-neutral {@link HookOutput} both bridges map from.
+ *
+ * The exit-code contract is shared by Claude Code and Codex:
+ * - exit 0  → success; if stdout is structured JSON, parse it; else the plain
+ *   stdout is available to the bridge (some events treat it as `additionalContext`).
+ * - exit 2  → BLOCKING error; stderr is the block reason fed back to the model.
+ *   We surface this as `decision: 'block'` with `reason = stderr` so a bridge
+ *   needs no separate exit-code branch — the neutral output already says "block".
+ * - other   → non-blocking error; recorded (exitCode + stderr) but no decision.
+ *
+ * Structured-stdout fields are a SUPERSET across dialects (CC is richest); we
+ * parse every field we recognize and leave it to the bridge to honor only the
+ * subset meaningful for its dialect/hook point (Codex, e.g., ignores
+ * `allow`/`ask`/`updatedInput`).
+ *
+ * @module @deepseek-ai/dsh-hook-protocol/codec
+ */
+
+import type { HookOutput } from './types.ts'
+
+/** The exit code a hook uses to signal a blocking error (stderr → model). */
+export const BLOCKING_EXIT_CODE = 2
+
+/** Read a string field from a parsed object, or `undefined` if absent/wrong type. */
+function str(obj: Record<string, unknown>, key: string): string | undefined {
+  const v = obj[key]
+  return typeof v === 'string' ? v : undefined
+}
+
+/** Read a boolean field, or `undefined` if absent/wrong type. */
+function bool(obj: Record<string, unknown>, key: string): boolean | undefined {
+  const v = obj[key]
+  return typeof v === 'boolean' ? v : undefined
+}
+
+/** A plain (non-null, non-array) object, or `undefined`. */
+function obj(value: unknown): Record<string, unknown> | undefined {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+    ? value as Record<string, unknown>
+    : undefined
+}
+
+/** Normalize a raw `decision`/`permissionDecision` string to the neutral enum. */
+function decisionOf(value: string | undefined): HookOutput['decision'] {
+  switch (value) {
+    case 'approve': case 'allow': case 'block': case 'deny': case 'ask':
+      return value
+    default:
+      return undefined
+  }
+}
+
+/**
+ * Parse one finished hook command into a {@link HookOutput}. `stdout`/`stderr`
+ * are the captured streams; `exitCode` is the process exit (`undefined` when the
+ * hook could not be spawned at all). Pure and total — never throws; malformed
+ * JSON on a 0 exit is treated as "no structured output" (the plain stdout is
+ * still on the bridge to use), matching both reference engines' lenient parse of
+ * non-JSON stdout.
+ */
+export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string): HookOutput {
+  const trimmedErr = stderr.trim()
+  const output: HookOutput = { exitCode, stderr: trimmedErr }
+
+  // Exit 2 is a blocking error in both dialects: stderr is the reason. Surface
+  // it as a `block` decision so the bridge maps it uniformly with a structured
+  // `decision:'block'` — the exit code and the JSON channel converge here.
+  if (exitCode === BLOCKING_EXIT_CODE) {
+    output.decision = 'block'
+    if (trimmedErr.length > 0) output.reason = trimmedErr
+  }
+
+  // Structured stdout is only consulted on a clean (0) exit; on a blocking exit
+  // the stderr channel is authoritative. A non-zero/undefined exit other than 2
+  // carries no decision (the bridge records it as a non-blocking error).
+  if (exitCode === 0) {
+    const trimmedOut = stdout.trim()
+    // Only attempt JSON when stdout looks like a JSON object — matches the
+    // reference engines, which treat other stdout as plain text, not an error.
+    if (trimmedOut.startsWith('{')) {
+      let parsed: Record<string, unknown> | undefined
+      try {
+        parsed = obj(JSON.parse(trimmedOut))
+      } catch {
+        // Malformed JSON on a clean exit = no structured output (lenient, as the
+        // reference engines are). The plain stdout remains the bridge's to use.
+        parsed = undefined
+      }
+      if (parsed) applyStructured(output, parsed)
+    }
+  }
+
+  return output
+}
+
+/** Fold a parsed structured-stdout object into `output` (mutates in place). */
+function applyStructured(output: HookOutput, parsed: Record<string, unknown>): void {
+  const cont = bool(parsed, 'continue')
+  if (cont !== undefined) output.continue = cont
+  const stopReason = str(parsed, 'stopReason')
+  if (stopReason !== undefined) output.stopReason = stopReason
+  const suppress = bool(parsed, 'suppressOutput')
+  if (suppress !== undefined) output.suppressOutput = suppress
+  const sysMsg = str(parsed, 'systemMessage')
+  if (sysMsg !== undefined) output.systemMessage = sysMsg
+
+  // Top-level legacy `decision` + `reason` (CC approve/block; Codex block).
+  const topDecision = decisionOf(str(parsed, 'decision'))
+  if (topDecision !== undefined) output.decision = topDecision
+  const topReason = str(parsed, 'reason')
+  if (topReason !== undefined) output.reason = topReason
+
+  // hookSpecificOutput: the per-event channel. permissionDecision (allow/deny/
+  // ask) OVERRIDES the legacy top-level decision when present; additionalContext
+  // and updatedInput live here too.
+  const hso = obj(parsed.hookSpecificOutput)
+  if (hso) {
+    const permission = decisionOf(str(hso, 'permissionDecision'))
+    if (permission !== undefined) output.decision = permission
+    const permissionReason = str(hso, 'permissionDecisionReason')
+    if (permissionReason !== undefined) output.reason = permissionReason
+    const addCtx = str(hso, 'additionalContext')
+    if (addCtx !== undefined) output.additionalContext = addCtx
+    const updated = obj(hso.updatedInput)
+    if (updated !== undefined) output.updatedInput = updated
+  }
+}

+ 72 - 0
packages/hooks/hook-protocol/src/events.ts

@@ -0,0 +1,72 @@
+/**
+ * Append helpers for the log-only `hook/*` session events — the durable record
+ * that a hook ran and what it decided. Thin wrappers over `session.append` so a
+ * bridge does not hand-build the payloads (and so the `turn`-enclosure +
+ * invoked/result pairing stay consistent across both bridges).
+ *
+ * `hook/*` events are log-only (not {@link SurfaceEventType}), so they carry no
+ * `surfaceOp` and append with no surface intent — but, like every event, they
+ * must sit inside an OPEN turn (the invariants oracle rejects an un-enclosed
+ * event). The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/
+ * `Stop`) fire inside the loop's open turn by construction; `SessionStart` is the
+ * exception (its injected `context/message` is the durable evidence instead), so
+ * a bridge does NOT write `hook/*` for session-start — see the hooks RFC.
+ *
+ * @module @deepseek-ai/dsh-hook-protocol/events
+ */
+
+import type { Session } from '@deepseek-ai/dsh-session'
+import type { HookDialect } from './types.ts'
+
+/** What identifies a hook invocation across its invoked/result pair. */
+export interface HookInvocation {
+  /** The open turn the invocation lives inside. */
+  turn: number
+  /** The hook point (`PreToolUse`, `Stop`, …). */
+  point: string
+  /** The bridge dialect that ran it. */
+  dialect: HookDialect
+  /** A stable id correlating the invoked event with its result. */
+  handlerId: string
+  /** The matcher-group pattern that selected it (absent for match-all). */
+  matcher?: string
+}
+
+/** The decided outcome half of the pair. */
+export interface HookResultRecord {
+  turn: number
+  point: string
+  handlerId: string
+  /** The dialect-neutral decision the bridge resolved (`deny`/`allow`/`block`/…). */
+  decision: string
+  /** The process exit code (absent when the hook could not run). */
+  exitCode?: number
+  /** A truncated stderr summary (the block-reason source on exit 2). */
+  stderrSummary?: string
+  /** Wall-clock duration of the run. */
+  durationMs: number
+}
+
+/** Append a `hook/invoked` provenance event to `session`. */
+export function appendHookInvoked(session: Session, invocation: HookInvocation): void {
+  session.append('hook/invoked', {
+    turn: invocation.turn,
+    point: invocation.point,
+    dialect: invocation.dialect,
+    handlerId: invocation.handlerId,
+    ...invocation.matcher !== undefined ? { matcher: invocation.matcher } : {},
+  })
+}
+
+/** Append a `hook/result` outcome event to `session` (pairs with a prior `hook/invoked`). */
+export function appendHookResult(session: Session, record: HookResultRecord): void {
+  session.append('hook/result', {
+    turn: record.turn,
+    point: record.point,
+    handlerId: record.handlerId,
+    decision: record.decision,
+    ...record.exitCode !== undefined ? { exitCode: record.exitCode } : {},
+    ...record.stderrSummary !== undefined ? { stderrSummary: record.stderrSummary } : {},
+    durationMs: record.durationMs,
+  })
+}

+ 38 - 0
packages/hooks/hook-protocol/src/index.ts

@@ -0,0 +1,38 @@
+/**
+ * `@deepseek-ai/dsh-hook-protocol` — the shared core of the Claude Code / Codex
+ * hook wire protocol. NOT a cordis plugin: it registers nothing and injects
+ * nothing. It is a LIBRARY of dialect-neutral primitives the two bridge plugins
+ * (`dsh-hooks-claude`, `dsh-hooks-codex`) import to avoid re-implementing the
+ * identical halves of the protocol:
+ *
+ * - {@link matchesMatcher} — the matcher primitive (literal-or-regex by dialect).
+ * - {@link runHook} + {@link parseHookOutput} — run a command hook via `ctx.bash`
+ *   (stdin payload + env) and decode its exit-code/stdout/stderr into a neutral
+ *   {@link HookOutput}.
+ * - {@link mergeHookOutputs} — fold multiple matched hooks into one
+ *   most-restrictive {@link MergedHookOutcome} (deny > ask > allow).
+ * - {@link appendHookInvoked} / {@link appendHookResult} — the log-only `hook/*`
+ *   session-event helpers (declaration-merged into `SessionEventMap`).
+ *
+ * Each bridge owns what genuinely DIFFERS: building the per-event stdin payload
+ * (CC vs Codex field sets), the dialect's env/substitution, and mapping the
+ * neutral outcome onto the harness's seam-specific typed Decisions.
+ *
+ * @module @deepseek-ai/dsh-hook-protocol
+ */
+
+export type {
+  CommandHook,
+  HookDialect,
+  HookOutput,
+  MatcherGroup,
+  MatcherMode,
+} from './types.ts'
+export { matchesMatcher } from './matcher.ts'
+export { BLOCKING_EXIT_CODE, parseHookOutput } from './codec.ts'
+export { runHook } from './runner.ts'
+export type { RunHookOptions, RunHookResult } from './runner.ts'
+export { mergeHookOutputs } from './merge.ts'
+export type { MergedDecision, MergedHookOutcome } from './merge.ts'
+export { appendHookInvoked, appendHookResult } from './events.ts'
+export type { HookInvocation, HookResultRecord } from './events.ts'

+ 49 - 0
packages/hooks/hook-protocol/src/matcher.ts

@@ -0,0 +1,49 @@
+/**
+ * The matcher primitive shared by both hook dialects: decide whether a matcher
+ * pattern selects a given query (a tool name, a session source, …).
+ *
+ * The two dialects differ ONLY in how a non-empty pattern is interpreted, so
+ * that single axis is the {@link MatcherMode} parameter:
+ * - `claude`: a pattern of purely `[A-Za-z0-9_|]+` is a LITERAL (pipe =
+ *   exact-match alternation, e.g. `Edit|Write`); anything else is a regex.
+ * - `codex`: every pattern is an unanchored regex (no literal fast path).
+ *
+ * Both treat an absent / empty / `'*'` pattern as match-all, and both treat an
+ * invalid regex as a non-match (the bridge logs it; a broken matcher must not
+ * throw into the loop).
+ *
+ * @module @deepseek-ai/dsh-hook-protocol/matcher
+ */
+
+import type { MatcherMode } from './types.ts'
+
+/** True for an absent / empty / `'*'` pattern — the match-all sentinels. */
+function isMatchAll(matcher: string | undefined): boolean {
+  return matcher === undefined || matcher === '' || matcher === '*'
+}
+
+/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */
+const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
+
+/**
+ * Whether `matcher` selects `query` under the given dialect {@link MatcherMode}.
+ * Match-all sentinels (absent/`''`/`'*'`) always match. A `claude` literal
+ * pattern exact-matches the query (splitting `|` into alternatives); every other
+ * `claude` pattern and ALL `codex` patterns are tested as an unanchored regex.
+ * An invalid regex matches nothing (never throws).
+ */
+export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean {
+  if (isMatchAll(matcher)) return true
+  // matcher is a non-empty string past the match-all guard.
+  const pattern = matcher as string
+  if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) {
+    return pattern.split('|').includes(query)
+  }
+  try {
+    return new RegExp(pattern).test(query)
+  } catch {
+    // Invalid regex: a broken matcher selects nothing rather than throwing into
+    // the agent loop. The bridge is responsible for surfacing the bad config.
+    return false
+  }
+}

+ 109 - 0
packages/hooks/hook-protocol/src/merge.ts

@@ -0,0 +1,109 @@
+/**
+ * Merge the outcomes of MULTIPLE hooks that matched one hook point into a single
+ * most-restrictive {@link MergedHookOutcome}. Both reference engines run matched
+ * hooks concurrently and fold their results; the precedence rules here are the
+ * intersection both dialects agree on (and the strictest interpretation where
+ * they differ), so a bridge gets one decision to map onto its seam:
+ *
+ * - **permission precedence `deny > ask > allow`**: any `deny`/`block` wins; an
+ *   `ask` overrides `allow`; `allow`/`approve` only stands if nothing stricter
+ *   appeared. (Claude Code's explicit precedence; Codex only ever blocks, so the
+ *   rule degenerates correctly for it.)
+ * - **halt is sticky**: the first hook with `continue:false` sets `stop` and its
+ *   `stopReason`.
+ * - **reasons accumulate**: block/deny reasons are joined with `\n\n` (Codex's
+ *   `join_text_chunks`), so the model sees every objection, not just the first.
+ * - **context accumulates**: `additionalContext` from every hook is collected in
+ *   order (CC concatenates; Codex keeps them as separate developer messages —
+ *   either way the bridge gets the ordered list).
+ * - **systemMessages accumulate** likewise.
+ *
+ * @module @deepseek-ai/dsh-hook-protocol/merge
+ */
+
+import type { HookOutput } from './types.ts'
+
+/** The single decision a hook point resolves to after merging all matched hooks. */
+export type MergedDecision = 'allow' | 'ask' | 'deny' | 'none'
+
+/** The folded outcome of every hook that matched one point. */
+export interface MergedHookOutcome {
+  /**
+   * The most-restrictive permission decision across all hooks (`deny` > `ask` >
+   * `allow`), or `none` when no hook expressed one. `block`/`deny` both fold to
+   * `deny`; `approve`/`allow` both fold to `allow`.
+   */
+  decision: MergedDecision
+  /** Joined (`\n\n`) reasons from every blocking/denying hook, or `undefined`. */
+  reason?: string
+  /** `true` when any hook asked to halt (`continue:false`). */
+  stop: boolean
+  /** The first halting hook's `stopReason`, when one halted. */
+  stopReason?: string
+  /** Every hook's `additionalContext`, in hook order (no joining — the bridge decides). */
+  additionalContext: string[]
+  /** Every hook's `systemMessage`, in hook order. */
+  systemMessages: string[]
+}
+
+/** Rank a single hook's decision for the deny>ask>allow precedence (higher = stricter). */
+function rank(decision: HookOutput['decision']): number {
+  switch (decision) {
+    case 'deny': case 'block': return 3
+    case 'ask': return 2
+    case 'approve': case 'allow': return 1
+    default: return 0 // no decision
+  }
+}
+
+/** Collapse a ranked decision back to the merged enum. */
+function decisionForRank(maxRank: number): MergedDecision {
+  switch (maxRank) {
+    case 3: return 'deny'
+    case 2: return 'ask'
+    case 1: return 'allow'
+    default: return 'none'
+  }
+}
+
+/**
+ * Fold `outputs` (the results of every hook that matched a point, in hook order)
+ * into one {@link MergedHookOutcome} by the precedence rules above. An empty list
+ * yields a neutral outcome (`decision: 'none'`, no stop, empty context) — the
+ * caller treats that as "no hook had anything to say".
+ */
+export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome {
+  let maxRank = 0
+  const reasons: string[] = []
+  let stop = false
+  let stopReason: string | undefined
+  const additionalContext: string[] = []
+  const systemMessages: string[] = []
+
+  for (const out of outputs) {
+    const r = rank(out.decision)
+    if (r > maxRank) maxRank = r
+    // Collect a reason only from a blocking/denying hook (rank 3) — an allow's
+    // "reason" is not an objection the model needs to see.
+    if (r === 3 && out.reason !== undefined && out.reason.length > 0) reasons.push(out.reason)
+    if (out.continue === false && !stop) {
+      stop = true
+      if (out.stopReason !== undefined) stopReason = out.stopReason
+    }
+    if (out.additionalContext !== undefined && out.additionalContext.length > 0) {
+      additionalContext.push(out.additionalContext)
+    }
+    if (out.systemMessage !== undefined && out.systemMessage.length > 0) {
+      systemMessages.push(out.systemMessage)
+    }
+  }
+
+  return {
+    decision: decisionForRank(maxRank),
+    ...reasons.length > 0 ? { reason: reasons.join('\n\n') } : {},
+    stop,
+    ...stopReason !== undefined ? { stopReason } : {},
+    additionalContext,
+    systemMessages,
+  }
+}

+ 91 - 0
packages/hooks/hook-protocol/src/runner.ts

@@ -0,0 +1,91 @@
+/**
+ * Run one configured command hook through the `ctx.bash` executor seam and parse
+ * its outcome into a {@link HookOutput}. This is where the wire protocol's
+ * EXECUTION half lives: feed the hook its JSON payload on stdin, hand it the
+ * dialect's env vars, honor its timeout, capture stdout/stderr/exit, and decode.
+ *
+ * It runs hooks through `ctx.bash` (not a bespoke `spawn`) deliberately — the
+ * bash seam already provides the scrubbed-but-overridable env, process-group
+ * kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields
+ * are the trusted-plugin surface (added for exactly this) that a hook bridge —
+ * an in-process plugin, not model output — is allowed to use.
+ *
+ * @module @deepseek-ai/dsh-hook-protocol/runner
+ */
+
+import type { BashExecutor } from '@deepseek-ai/dsh-bash'
+import { parseHookOutput } from './codec.ts'
+import type { CommandHook, HookOutput } from './types.ts'
+
+/** Everything a single hook invocation needs beyond its command line. */
+export interface RunHookOptions {
+  /** The JSON payload object written to the hook's stdin (the bridge builds it). */
+  payload: unknown
+  /** Extra env vars for the hook process (`CLAUDE_PROJECT_DIR`, …); the bridge builds these. */
+  env?: Record<string, string>
+  /** Working directory for the hook (defaults to the executor's own default when omitted). */
+  cwd?: string
+  /** Abort signal — cancels the hook run when fired (the parent step aborts). */
+  signal?: AbortSignal
+  /** Default timeout (ms) when the hook config sets none. */
+  defaultTimeoutMs: number
+  /** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */
+  trailingNewline: boolean
+}
+
+/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */
+export interface RunHookResult {
+  output: HookOutput
+  durationMs: number
+}
+
+/**
+ * Run `hook` via `bash` with `options.payload` serialized to its stdin, then
+ * decode the result. `now` is injected (a monotonic-ms source) so the duration
+ * is testable without a real clock. The hook's configured `timeoutSec` (wire
+ * unit: seconds) overrides `defaultTimeoutMs`. The command runs with the
+ * dialect's `env` merged after the executor's credential scrub (the trusted-
+ * plugin path). NEVER throws: an infrastructure failure (the executor rejecting)
+ * is surfaced as a {@link HookOutput} with `exitCode: undefined`, so the caller's
+ * merge logic treats it as a non-blocking error rather than crashing the turn.
+ */
+export async function runHook(
+  bash: BashExecutor,
+  hook: CommandHook,
+  options: RunHookOptions,
+  now: () => number,
+): Promise<RunHookResult> {
+  const started = now()
+  const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : options.defaultTimeoutMs
+  const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '')
+
+  const request = {
+    command: hook.command,
+    timeoutMs,
+    stdin,
+    ...options.cwd !== undefined ? { workdir: options.cwd } : {},
+    ...options.env !== undefined ? { env: options.env } : {},
+    ...options.signal ? { signal: options.signal } : {},
+  }
+
+  try {
+    const result = await bash.run(bash.resolve(request))
+    // BashRunResult.exitCode is `number | null` (null = died by signal); the
+    // protocol's exit-code contract is numeric, so a signal death maps to
+    // `undefined` (a non-blocking error — no clean exit code to act on).
+    const exitCode = result.exitCode ?? undefined
+    return {
+      output: parseHookOutput(exitCode, result.stdout.text, result.stderr.text),
+      durationMs: now() - started,
+    }
+  } catch (error: unknown) {
+    // The executor rejects only on infrastructure faults (unusable workdir,
+    // missing shell). A hook that cannot run is a non-blocking error: no exit
+    // code, the failure on stderr for the record. The turn proceeds.
+    const message = error instanceof Error ? error.message : String(error)
+    return {
+      output: parseHookOutput(undefined, '', message),
+      durationMs: now() - started,
+    }
+  }
+}

+ 131 - 0
packages/hooks/hook-protocol/src/types.ts

@@ -0,0 +1,131 @@
+/**
+ * Dialect-neutral vocabulary for the Claude Code / Codex hook wire protocol,
+ * plus the log-only `hook/*` session events. Types only — runtime helpers live
+ * in the sibling modules (`matcher`, `codec`, `runner`, `merge`, `events`).
+ *
+ * This package is the SHARED CORE: the truly-identical primitives both the
+ * `dsh-hooks-claude` and `dsh-hooks-codex` bridges build on. Each bridge owns
+ * its own per-dialect stdin-payload construction and decision mapping on top of
+ * these primitives — the divergences (which events exist, literal-vs-regex
+ * matching, env/substitution, snake_case extras, allow/ask support) are the
+ * BRIDGE's concern, not this lib's.
+ *
+ * @module @deepseek-ai/dsh-hook-protocol/types
+ */
+
+declare module '@deepseek-ai/dsh-session' {
+  interface SessionEventMap {
+    /**
+     * A hook command was invoked at a hook point — log-only provenance (like
+     * `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
+     * `dialect` is the bridge that ran it (`claude`/`codex`/`native`), `point`
+     * the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
+     * pattern that selected it (absent for match-all), `handlerId` a stable id
+     * for the command (so an invoked/result pair correlates). `turn` is the open
+     * turn the invocation lives inside.
+     * @mode emit
+     */
+    'hook/invoked': {
+      turn: number
+      point: string
+      dialect: HookDialect
+      matcher?: string
+      handlerId: string
+    }
+    /**
+     * A hook command's outcome — log-only, paired with a prior `hook/invoked`
+     * (same `handlerId`). `decision` is the resolved dialect-neutral outcome the
+     * bridge mapped it to (`allow`/`deny`/`ask`/`block`/`continue`/`stop`/`pass`),
+     * `exitCode` the process exit (absent if it never ran), `stderrSummary` a
+     * truncated stderr (the block reason source on exit 2), `durationMs` the wall
+     * time. `turn` matches the `hook/invoked`.
+     * @mode emit
+     */
+    'hook/result': {
+      turn: number
+      point: string
+      handlerId: string
+      decision: string
+      exitCode?: number
+      stderrSummary?: string
+      durationMs: number
+    }
+  }
+}
+
+/** Which protocol dialect a hook config / invocation belongs to. */
+export type HookDialect = 'claude' | 'codex' | 'native'
+
+/**
+ * One configured command hook (the `{ type: 'command', command, timeout? }`
+ * shape shared by both dialects). Non-command hook types (CC's `prompt`/`agent`/
+ * `http`) are parsed-and-skipped by a bridge, so only this shape reaches the
+ * runner.
+ */
+export interface CommandHook {
+  /** The shell command line to run. */
+  command: string
+  /** Per-hook timeout in SECONDS (the wire unit); the runner converts to ms. */
+  timeoutSec?: number
+}
+
+/**
+ * One matcher group: a `matcher` pattern (absent / `''` / `'*'` = match-all)
+ * plus the command hooks that run when it matches. Both dialects share this
+ * shape (CC's `hooks.json` and Codex's `hooks.json`).
+ */
+export interface MatcherGroup {
+  matcher?: string
+  hooks: CommandHook[]
+}
+
+/**
+ * How a matcher pattern is interpreted. Claude Code uses {@link literal} when the
+ * pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and
+ * {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the
+ * mode for its dialect.
+ */
+export type MatcherMode = 'claude' | 'codex'
+
+/**
+ * The dialect-neutral OUTCOME a hook produced, parsed from its exit code +
+ * stdout JSON + stderr by {@link parseHookOutput}. A bridge maps this onto a
+ * seam-specific typed Decision (PreToolDecision, PromptDecision, …). Every field
+ * is OPTIONAL because a hook may exercise any subset; the bridge decides which
+ * fields are meaningful for its hook point and which it ignores (faithful-but-
+ * degraded — e.g. Codex ignores `allow`/`ask`).
+ */
+export interface HookOutput {
+  /** The raw process exit code (`undefined` if the hook could not be run). */
+  exitCode: number | undefined
+  /** Trimmed stderr — the block-reason source on a blocking (exit 2) hook. */
+  stderr: string
+  /**
+   * `false` ⇒ the hook asked to halt (CC/Codex `continue:false`); pairs with
+   * {@link stopReason}. `true`/absent ⇒ proceed.
+   */
+  continue?: boolean
+  /** Human-readable reason shown when {@link continue} is `false`. */
+  stopReason?: string
+  /** Hide the hook's stdout from the transcript (CC `suppressOutput`). */
+  suppressOutput?: boolean
+  /**
+   * The blocking decision a hook expressed via structured stdout (CC's
+   * `decision` / `hookSpecificOutput.permissionDecision`): `'block'`/`'deny'`
+   * forbid the action, `'approve'`/`'allow'` permit it, `'ask'` requests
+   * confirmation. Absent ⇒ no explicit decision (exit code governs).
+   */
+  decision?: 'approve' | 'allow' | 'block' | 'deny' | 'ask'
+  /** The reason/explanation accompanying {@link decision}. */
+  reason?: string
+  /** Extra context to inject for the next model request (CC `additionalContext`). */
+  additionalContext?: string
+  /** A warning surfaced to the user (CC `systemMessage`). */
+  systemMessage?: string
+  /**
+   * A tool-input rewrite a hook requested (CC `updatedInput`). PARSED but NOT
+   * honored — input rewrite is deferred (see the interception-seams RFC); a
+   * bridge logs + warns when this is present.
+   */
+  updatedInput?: Record<string, unknown>
+}

+ 106 - 0
packages/hooks/hook-protocol/tests/codec.spec.ts

@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest'
+import { parseHookOutput } from '@deepseek-ai/dsh-hook-protocol'
+
+describe('parseHookOutput — exit code semantics', () => {
+  it('exit 0 with no stdout is a neutral success', () => {
+    const out = parseHookOutput(0, '', '')
+    expect(out.exitCode).toBe(0)
+    expect(out.decision).toBeUndefined()
+    expect(out.continue).toBeUndefined()
+  })
+
+  it('exit 2 is a blocking error: stderr becomes the block decision + reason', () => {
+    const out = parseHookOutput(2, '', 'this command is not allowed')
+    expect(out.decision).toBe('block')
+    expect(out.reason).toBe('this command is not allowed')
+    expect(out.stderr).toBe('this command is not allowed')
+  })
+
+  it('exit 2 with empty stderr still blocks, with no reason', () => {
+    const out = parseHookOutput(2, '', '   ')
+    expect(out.decision).toBe('block')
+    expect(out.reason).toBeUndefined()
+  })
+
+  it('other non-zero exit is a non-blocking error (no decision, stderr recorded)', () => {
+    const out = parseHookOutput(1, '', 'some warning')
+    expect(out.decision).toBeUndefined()
+    expect(out.exitCode).toBe(1)
+    expect(out.stderr).toBe('some warning')
+  })
+
+  it('undefined exit (could not run) carries no decision', () => {
+    const out = parseHookOutput(undefined, '', 'spawn failed: ENOENT')
+    expect(out.exitCode).toBeUndefined()
+    expect(out.decision).toBeUndefined()
+    expect(out.stderr).toBe('spawn failed: ENOENT')
+  })
+})
+
+describe('parseHookOutput — structured stdout (exit 0 only)', () => {
+  it('parses top-level continue/stopReason/suppressOutput/systemMessage', () => {
+    const out = parseHookOutput(0, JSON.stringify({
+      continue: false, stopReason: 'budget exceeded', suppressOutput: true, systemMessage: 'heads up',
+    }), '')
+    expect(out.continue).toBe(false)
+    expect(out.stopReason).toBe('budget exceeded')
+    expect(out.suppressOutput).toBe(true)
+    expect(out.systemMessage).toBe('heads up')
+  })
+
+  it('parses legacy top-level decision + reason (approve/block)', () => {
+    expect(parseHookOutput(0, JSON.stringify({ decision: 'block', reason: 'nope' }), '').decision).toBe('block')
+    expect(parseHookOutput(0, JSON.stringify({ decision: 'approve' }), '').decision).toBe('approve')
+  })
+
+  it('hookSpecificOutput.permissionDecision OVERRIDES the legacy top-level decision', () => {
+    const out = parseHookOutput(0, JSON.stringify({
+      decision: 'approve',
+      hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'denied by policy' },
+    }), '')
+    expect(out.decision).toBe('deny')
+    expect(out.reason).toBe('denied by policy')
+  })
+
+  it('parses allow/ask permissionDecision (the bridge decides whether to honor)', () => {
+    expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'allow' } }), '').decision).toBe('allow')
+    expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' } }), '').decision).toBe('ask')
+  })
+
+  it('parses additionalContext and updatedInput from hookSpecificOutput', () => {
+    const out = parseHookOutput(0, JSON.stringify({
+      hookSpecificOutput: { additionalContext: 'remember X', updatedInput: { command: 'safe' } },
+    }), '')
+    expect(out.additionalContext).toBe('remember X')
+    expect(out.updatedInput).toEqual({ command: 'safe' })
+  })
+
+  it('an unknown decision string is ignored (not coerced)', () => {
+    expect(parseHookOutput(0, JSON.stringify({ decision: 'maybe' }), '').decision).toBeUndefined()
+  })
+
+  it('malformed JSON on a clean exit is lenient (no structured output, no throw)', () => {
+    const out = parseHookOutput(0, '{ not valid json', '')
+    expect(out.decision).toBeUndefined()
+    expect(out.continue).toBeUndefined()
+  })
+
+  it('non-object stdout (plain text) on exit 0 is left for the bridge (no JSON attempt)', () => {
+    const out = parseHookOutput(0, 'just some text output', '')
+    expect(out.decision).toBeUndefined()
+    expect(out.continue).toBeUndefined()
+  })
+
+  it('a JSON array stdout parses but yields no fields (not an object)', () => {
+    // Starts with '{'? No — '[' — so it is not even attempted. Neutral.
+    const out = parseHookOutput(0, '[1,2,3]', '')
+    expect(out.decision).toBeUndefined()
+  })
+
+  it('structured stdout is IGNORED on a blocking (exit 2) run — stderr is authoritative', () => {
+    const out = parseHookOutput(2, JSON.stringify({ decision: 'approve' }), 'blocked')
+    // exit 2 forces block regardless of what stdout claims
+    expect(out.decision).toBe('block')
+    expect(out.reason).toBe('blocked')
+  })
+})

+ 61 - 0
packages/hooks/hook-protocol/tests/events.spec.ts

@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import { appendHookInvoked, appendHookResult } from '@deepseek-ai/dsh-hook-protocol'
+
+describe('hook/* session events', () => {
+  it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
+    const session = new Session(SessionId('s'))
+    appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' })
+
+    const ev = [...session.events].find(e => e.type === 'hook/invoked')
+    expect(ev?.type).toBe('hook/invoked')
+    if (ev?.type === 'hook/invoked') {
+      expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' })
+    }
+    // Log-only: no surfaceOp on the event.
+    expect((ev as unknown as { surfaceOp?: unknown }).surfaceOp).toBeUndefined()
+  })
+
+  it('omits matcher when absent (match-all hook)', () => {
+    const session = new Session(SessionId('s'))
+    appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'native', handlerId: 'h2' })
+
+    const ev = [...session.events].find(e => e.type === 'hook/invoked')
+    if (ev?.type === 'hook/invoked') {
+      expect('matcher' in ev.data).toBe(false)
+    }
+  })
+
+  it('appendHookResult records the decided outcome, omitting absent optionals', () => {
+    const session = new Session(SessionId('s'))
+    appendHookResult(session, {
+      turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny',
+      exitCode: 2, stderrSummary: 'blocked', durationMs: 12,
+    })
+    const full = [...session.events].find(e => e.type === 'hook/result')
+    if (full?.type === 'hook/result') {
+      expect(full.data).toMatchObject({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 12 })
+    }
+
+    // A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys.
+    const session2 = new Session(SessionId('s2'))
+    appendHookResult(session2, { turn: 1, point: 'Stop', handlerId: 'h3', decision: 'allow', durationMs: 3 })
+    const sparse = [...session2.events].find(e => e.type === 'hook/result')
+    if (sparse?.type === 'hook/result') {
+      expect('exitCode' in sparse.data).toBe(false)
+      expect('stderrSummary' in sparse.data).toBe(false)
+      expect(sparse.data.durationMs).toBe(3)
+    }
+  })
+
+  it('an invoked/result pair correlates by handlerId', () => {
+    const session = new Session(SessionId('s'))
+    appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' })
+    appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', decision: 'allow', exitCode: 0, durationMs: 7 })
+
+    const invoked = [...session.events].find(e => e.type === 'hook/invoked')
+    const result = [...session.events].find(e => e.type === 'hook/result')
+    expect(invoked?.type === 'hook/invoked' && invoked.data.handlerId).toBe('pair-1')
+    expect(result?.type === 'hook/result' && result.data.handlerId).toBe('pair-1')
+  })
+})

+ 58 - 0
packages/hooks/hook-protocol/tests/matcher.spec.ts

@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest'
+import { matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
+
+describe('matchesMatcher — match-all sentinels (both dialects)', () => {
+  for (const mode of ['claude', 'codex'] as const) {
+    it(`${mode}: absent / empty / '*' match everything`, () => {
+      expect(matchesMatcher(undefined, 'Bash', mode)).toBe(true)
+      expect(matchesMatcher('', 'anything', mode)).toBe(true)
+      expect(matchesMatcher('*', 'whatever', mode)).toBe(true)
+    })
+  }
+})
+
+describe('matchesMatcher — claude dialect (literal-or-regex)', () => {
+  it('a pure word-char pattern is a LITERAL exact match (not substring)', () => {
+    expect(matchesMatcher('Bash', 'Bash', 'claude')).toBe(true)
+    // literal exact: "Bash" must NOT match "BashOutput" (a regex would, substring)
+    expect(matchesMatcher('Bash', 'BashOutput', 'claude')).toBe(false)
+  })
+
+  it('a pipe pattern is literal ALTERNATION (exact match any alternative)', () => {
+    expect(matchesMatcher('Edit|Write', 'Edit', 'claude')).toBe(true)
+    expect(matchesMatcher('Edit|Write', 'Write', 'claude')).toBe(true)
+    expect(matchesMatcher('Edit|Write', 'Read', 'claude')).toBe(false)
+    // still exact per-alternative, not substring
+    expect(matchesMatcher('Edit|Write', 'EditFile', 'claude')).toBe(false)
+  })
+
+  it('a non-word pattern falls through to regex (unanchored)', () => {
+    expect(matchesMatcher('^Bash$', 'Bash', 'claude')).toBe(true)
+    expect(matchesMatcher('Bash.*', 'BashOutput', 'claude')).toBe(true)
+    expect(matchesMatcher('.*\\.ts$', 'foo.ts', 'claude')).toBe(true)
+    expect(matchesMatcher('.*\\.ts$', 'foo.js', 'claude')).toBe(false)
+  })
+})
+
+describe('matchesMatcher — codex dialect (always regex)', () => {
+  it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => {
+    expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true)
+    // codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring
+    expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true)
+  })
+
+  it('regex alternation and anchors work', () => {
+    expect(matchesMatcher('Edit|Write', 'Edit', 'codex')).toBe(true)
+    expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true)
+    expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false)
+  })
+})
+
+describe('matchesMatcher — invalid regex is a non-match (never throws)', () => {
+  it('an unbalanced pattern matches nothing rather than throwing', () => {
+    // '(' is not the claude-literal charset, so it goes to the regex path and is invalid.
+    expect(() => matchesMatcher('(', 'x', 'claude')).not.toThrow()
+    expect(matchesMatcher('(', 'x', 'claude')).toBe(false)
+    expect(matchesMatcher('[', 'x', 'codex')).toBe(false)
+  })
+})

+ 82 - 0
packages/hooks/hook-protocol/tests/merge.spec.ts

@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import { mergeHookOutputs } from '@deepseek-ai/dsh-hook-protocol'
+import type { HookOutput } from '@deepseek-ai/dsh-hook-protocol'
+
+function out(over: Partial<HookOutput> = {}): HookOutput {
+  return { exitCode: 0, stderr: '', ...over }
+}
+
+describe('mergeHookOutputs — permission precedence deny > ask > allow', () => {
+  it('empty list yields a neutral outcome', () => {
+    const m = mergeHookOutputs([])
+    expect(m.decision).toBe('none')
+    expect(m.stop).toBe(false)
+    expect(m.additionalContext).toEqual([])
+    expect(m.systemMessages).toEqual([])
+  })
+
+  it('a single allow yields allow', () => {
+    expect(mergeHookOutputs([out({ decision: 'allow' })]).decision).toBe('allow')
+    expect(mergeHookOutputs([out({ decision: 'approve' })]).decision).toBe('allow')
+  })
+
+  it('deny beats ask beats allow regardless of order', () => {
+    expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'ask' })]).decision).toBe('ask')
+    expect(mergeHookOutputs([out({ decision: 'ask' }), out({ decision: 'deny' })]).decision).toBe('deny')
+    expect(mergeHookOutputs([out({ decision: 'deny' }), out({ decision: 'allow' })]).decision).toBe('deny')
+    // block folds to deny
+    expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'block' })]).decision).toBe('deny')
+  })
+
+  it('no decision anywhere yields none', () => {
+    expect(mergeHookOutputs([out(), out()]).decision).toBe('none')
+  })
+})
+
+describe('mergeHookOutputs — reasons, stop, context, systemMessages accumulate', () => {
+  it('joins block/deny reasons with a blank line (only from blocking hooks)', () => {
+    const m = mergeHookOutputs([
+      out({ decision: 'deny', reason: 'first objection' }),
+      out({ decision: 'allow', reason: 'this allow reason is NOT collected' }),
+      out({ decision: 'block', reason: 'second objection' }),
+    ])
+    expect(m.reason).toBe('first objection\n\nsecond objection')
+  })
+
+  it('no reason when nothing blocked', () => {
+    expect(mergeHookOutputs([out({ decision: 'allow' })]).reason).toBeUndefined()
+  })
+
+  it('stop is sticky on the first continue:false, capturing its stopReason', () => {
+    const m = mergeHookOutputs([
+      out({ continue: true }),
+      out({ continue: false, stopReason: 'halt now' }),
+      out({ continue: false, stopReason: 'second halt — ignored' }),
+    ])
+    expect(m.stop).toBe(true)
+    expect(m.stopReason).toBe('halt now')
+  })
+
+  it('no stop when every hook continues', () => {
+    const m = mergeHookOutputs([out({ continue: true }), out()])
+    expect(m.stop).toBe(false)
+    expect(m.stopReason).toBeUndefined()
+  })
+
+  it('a continue:false with no stopReason stops with an undefined reason', () => {
+    const m = mergeHookOutputs([out({ continue: false })])
+    expect(m.stop).toBe(true)
+    expect(m.stopReason).toBeUndefined()
+  })
+
+  it('collects additionalContext and systemMessages in hook order, skipping empties', () => {
+    const m = mergeHookOutputs([
+      out({ additionalContext: 'ctx-A', systemMessage: 'warn-A' }),
+      out({ additionalContext: '', systemMessage: '' }), // empties skipped
+      out({ additionalContext: 'ctx-B' }),
+      out({ systemMessage: 'warn-B' }),
+    ])
+    expect(m.additionalContext).toEqual(['ctx-A', 'ctx-B'])
+    expect(m.systemMessages).toEqual(['warn-A', 'warn-B'])
+  })
+})

+ 134 - 0
packages/hooks/hook-protocol/tests/runner.spec.ts

@@ -0,0 +1,134 @@
+import { describe, expect, it } from 'vitest'
+import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
+import { runHook } from '@deepseek-ai/dsh-hook-protocol'
+
+/**
+ * A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook}
+ * actually calls (`resolve` then `run`). `runHook` is pure plumbing over those
+ * two methods, so a duck-typed recorder is the right test seam — the REAL
+ * executor (dsh-bash-local) is exercised end-to-end by the bridge e2e tests in
+ * PR-F, not here.
+ */
+function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
+  bash: BashExecutor
+  specs: BashExecSpec[]
+} {
+  const specs: BashExecSpec[] = []
+  const bash = {
+    resolve(request: BashExecRequest): BashExecSpec {
+      // Carry the request through verbatim, defaulting the required spec fields —
+      // exactly what dsh-bash-local's resolve does for the fields runHook sets.
+      return {
+        command: request.command,
+        workdir: request.workdir ?? '/stub',
+        timeoutMs: request.timeoutMs ?? 0,
+        ...request.signal ? { signal: request.signal } : {},
+        ...request.stdin !== undefined ? { stdin: request.stdin } : {},
+        ...request.env !== undefined ? { env: request.env } : {},
+        owner: request.owner,
+      }
+    },
+    async run(spec: BashExecSpec): Promise<BashRunResult> {
+      specs.push(spec)
+      return run(spec)
+    },
+  } as unknown as BashExecutor
+  return { bash, specs }
+}
+
+function result(over: Partial<BashRunResult> = {}): BashRunResult {
+  return {
+    exitCode: 0,
+    signal: null,
+    timedOut: false,
+    aborted: false,
+    timeoutMs: 1000,
+    stdout: { text: '', truncated: false },
+    stderr: { text: '', truncated: false },
+    ...over,
+  }
+}
+
+const clock = () => { let t = 0; return () => (t += 5) } // +5ms per call → duration 5
+
+describe('runHook — payload + env + stdin plumbing', () => {
+  it('serializes the payload to stdin (with trailing newline when requested)', async () => {
+    const { bash, specs } = recordingBash(async () => result({ stdout: { text: '', truncated: false } }))
+    await runHook(bash, { command: 'my-hook.sh' }, {
+      payload: { hook_event_name: 'PreToolUse', tool_name: 'Bash' },
+      defaultTimeoutMs: 60000,
+      trailingNewline: true,
+    }, clock())
+    expect(specs[0]!.stdin).toBe(JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + '\n')
+    expect(specs[0]!.command).toBe('my-hook.sh')
+  })
+
+  it('omits the trailing newline when trailingNewline is false (Codex)', async () => {
+    const { bash, specs } = recordingBash(async () => result())
+    await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: 1000, trailingNewline: false }, clock())
+    expect(specs[0]!.stdin).toBe('{"a":1}')
+  })
+
+  it('threads env and cwd into the request', async () => {
+    const { bash, specs } = recordingBash(async () => result())
+    await runHook(bash, { command: 'h' }, {
+      payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work',
+      defaultTimeoutMs: 1000, trailingNewline: true,
+    }, clock())
+    expect(specs[0]!.env).toEqual({ CLAUDE_PROJECT_DIR: '/proj' })
+    expect(specs[0]!.workdir).toBe('/work')
+  })
+
+  it('a per-hook timeoutSec (seconds) overrides the default (ms)', async () => {
+    const { bash, specs } = recordingBash(async () => result())
+    await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
+    expect(specs[0]!.timeoutMs).toBe(3000)
+  })
+
+  it('falls back to the default timeout when the hook sets none', async () => {
+    const { bash, specs } = recordingBash(async () => result())
+    await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
+    expect(specs[0]!.timeoutMs).toBe(60000)
+  })
+
+  it('passes the abort signal through', async () => {
+    const controller = new AbortController()
+    const { bash, specs } = recordingBash(async () => result())
+    await runHook(bash, { command: 'h' }, { payload: {}, signal: controller.signal, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
+    expect(specs[0]!.signal).toBe(controller.signal)
+  })
+})
+
+describe('runHook — outcome decoding + duration', () => {
+  it('decodes a clean exit with structured stdout and reports a duration', async () => {
+    const { bash } = recordingBash(async () => result({
+      exitCode: 0, stdout: { text: JSON.stringify({ decision: 'block', reason: 'no' }), truncated: false },
+    }))
+    const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
+    expect(output.decision).toBe('block')
+    expect(output.reason).toBe('no')
+    expect(durationMs).toBe(5)
+  })
+
+  it('a signal death (exitCode null) decodes as undefined exit (non-blocking error)', async () => {
+    const { bash } = recordingBash(async () => result({ exitCode: null, signal: 'SIGKILL', stderr: { text: 'killed', truncated: false } }))
+    const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
+    expect(output.exitCode).toBeUndefined()
+    expect(output.decision).toBeUndefined()
+    expect(output.stderr).toBe('killed')
+  })
+
+  it('an executor rejection (infra fault) becomes a non-blocking error, never throws', async () => {
+    const { bash } = recordingBash(async () => { throw new Error('bad workdir: ENOENT') })
+    const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
+    expect(output.exitCode).toBeUndefined()
+    expect(output.stderr).toBe('bad workdir: ENOENT')
+    expect(output.decision).toBeUndefined()
+  })
+
+  it('a non-Error rejection is stringified onto stderr', async () => {
+    const { bash } = recordingBash(async () => { throw 'plain string fault' })
+    const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
+    expect(output.stderr).toBe('plain string fault')
+  })
+})

+ 24 - 0
packages/hooks/hook-protocol/tsconfig.json

@@ -0,0 +1,24 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../bash/bash"
+    },
+    {
+      "path": "../../core/session"
+    }
+  ]
+}

+ 12 - 0
pnpm-lock.yaml

@@ -248,6 +248,18 @@ importers:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
 
+  packages/hooks/hook-protocol:
+    devDependencies:
+      '@deepseek-ai/dsh-bash':
+        specifier: workspace:^
+        version: link:../../bash/bash
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
   packages/llm/llm:
     devDependencies:
       '@deepseek-ai/dsh-brand':

+ 1 - 0
tsconfig.base.json

@@ -46,6 +46,7 @@
         "./packages/compact/*/src",
         "./packages/subagent/*/src",
         "./packages/todo/*/src",
+        "./packages/hooks/*/src",
         "./packages/session-persistence/*/src",
         "./packages/ui/*/src",
         "./packages/util/*/src",

+ 2 - 1
tsconfig.build.json

@@ -40,6 +40,7 @@
     { "path": "./packages/subagent/subagent-spawn" },
     { "path": "./packages/subagent/subagent-fork" },
     { "path": "./packages/subagent/subagent-acp" },
-    { "path": "./packages/todo/tool-todo" }
+    { "path": "./packages/todo/tool-todo" },
+    { "path": "./packages/hooks/hook-protocol" }
   ]
 }

+ 2 - 1
tsconfig.json

@@ -51,6 +51,7 @@
     { "path": "./packages/subagent/subagent-spawn" },
     { "path": "./packages/subagent/subagent-fork" },
     { "path": "./packages/subagent/subagent-acp" },
-    { "path": "./packages/todo/tool-todo" }
+    { "path": "./packages/todo/tool-todo" },
+    { "path": "./packages/hooks/hook-protocol" }
   ]
 }