deepseek-harness/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md

31 lines
5.2 KiB
Markdown
Raw Normal View History

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.
2026-07-01 00:38:06 +08:00
# RFC: dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core
docs(rfc): define and enforce a uniform RFC format; adopt it across the corpus Define the in-file RFC contract in docs/rfc/README.md § The file format: the header block (`# RFC: <title>` plus a dateless Status enum cross-checked against the lifecycle folder), the per-lifecycle body skeleton (a Problem opener everywhere; Proposal/Alternatives considered/ Acceptance criteria/Risks in proposed/; present-tense Decision/ Consequences with proposal-era headings banned in implemented/; the frozen proposal shape in rejected/), and a mandatory Alternatives considered section with a date-fenced grandfather comment for pre-format RFCs whose alternatives are not reconstructible from the record. Enforce it with a new doc-sync gate, scripts/verify-rfc-format.ts, and normalize all 112 RFCs to it: ~15 Status-line spellings collapse to the enum, 29 Context openers become Problem, the 39 legacy-format XXX debt markers are resolved and banned from reappearing, proposal-era sections in implemented RFCs are rewritten to shipped reality (including the web/fs/subagent seam RFCs' migration plans and test checklists, closing the doc-tiers deferred-work item on the web seam), every RFC gains an Alternatives considered section or the grandfather comment, and the bilingual pair is re-mirrored and re-recorded. Move the generated index tables out of README.md into a fully generated docs/rfc/INDEX.md — gen-rfc-index now writes the whole file, and verify-rfc-classification checks its freshness and rejects index-shaped rows in the curated README — which makes room for the format contract to live in the README front door instead of a separate FORMAT.md. The decision record, and the first RFC written in the new format, is docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md.
2026-07-05 22:58:25 +08:00
Status: implemented
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.
2026-07-01 00:38:06 +08:00
docs(rfc): define and enforce a uniform RFC format; adopt it across the corpus Define the in-file RFC contract in docs/rfc/README.md § The file format: the header block (`# RFC: <title>` plus a dateless Status enum cross-checked against the lifecycle folder), the per-lifecycle body skeleton (a Problem opener everywhere; Proposal/Alternatives considered/ Acceptance criteria/Risks in proposed/; present-tense Decision/ Consequences with proposal-era headings banned in implemented/; the frozen proposal shape in rejected/), and a mandatory Alternatives considered section with a date-fenced grandfather comment for pre-format RFCs whose alternatives are not reconstructible from the record. Enforce it with a new doc-sync gate, scripts/verify-rfc-format.ts, and normalize all 112 RFCs to it: ~15 Status-line spellings collapse to the enum, 29 Context openers become Problem, the 39 legacy-format XXX debt markers are resolved and banned from reappearing, proposal-era sections in implemented RFCs are rewritten to shipped reality (including the web/fs/subagent seam RFCs' migration plans and test checklists, closing the doc-tiers deferred-work item on the web seam), every RFC gains an Alternatives considered section or the grandfather comment, and the bilingual pair is re-mirrored and re-recorded. Move the generated index tables out of README.md into a fully generated docs/rfc/INDEX.md — gen-rfc-index now writes the whole file, and verify-rfc-classification checks its freshness and rejects index-shaped rows in the curated README — which makes room for the format contract to live in the README front door instead of a separate FORMAT.md. The decision record, and the first RFC written in the new format, is docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md.
2026-07-05 22:58:25 +08:00
## Problem
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.
2026-07-01 00:38:06 +08:00
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
2026-07-01 01:12:04 +08:00
A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns four primitive families and the `hook/*` session events; each bridge plugin (`dsh-hooks-claude`, `dsh-hooks-codex`) owns what genuinely differs.
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.
2026-07-01 00:38:06 +08:00
**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)`. 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 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` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`).
refactor(hooks): tighten the hook-protocol contract surface Implement the tighten-hook-protocol-contract RFC (moved to implemented/): - HookDialect narrows to 'claude' | 'codex': the 'native' variant had zero producers (native plugins on the seams write no hook/* provenance), and the dialect is defined as the bridge that ran the hook. - HookOutput.suppressOutput is gone: the codec parsed it and every path discarded it with no warn and no deferral — hook stdout never enters a transcript, so there is nothing to suppress. - hook/result.durationMs is gone: durable timing telemetry with no reader that the snapshot normalizer had to scrub as replay noise. With no duration to measure, runHook loses its injected now clock and the single-field RunHookResult wrapper — it returns the HookOutput directly. The committed hook fixtures had the field stripped mechanically (field-only diff); the stdout goldens never carried it. - The bridges' double-defaulted defaultTimeoutMs config knob is replaced by one reference-default constant, DEFAULT_HOOK_TIMEOUT_MS, exported from the lib's runner and applied inside runHook; per-hook timeoutSec stays the override surface. - The hook/result semantics move into the lib that declares the event: HookResultRecord now carries the decoded HookOutput and appendHookResult derives the decision string (decision ?? stop-on-continue:false ?? pass) and the 500-char stderrSummary truncation; both bridges delete their byte-identical private copies. The snapshot suite passes against the existing goldens, proving the derived values are unchanged. - Rider: BLOCKING_EXIT_CODE is codec-internal again (zero importers). Amend the hook-protocol-lib and hook-snapshot-matrix RFCs to the new facts, update the lib/bridge READMEs and the session.md event tables, and retarget the affected unit tests (including new lib-level coverage of the derivation rules).
2026-07-04 15:44:26 +08:00
- **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 CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md)).
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.
2026-07-01 00:38:06 +08:00
- **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.
refactor(hooks): tighten the hook-protocol contract surface Implement the tighten-hook-protocol-contract RFC (moved to implemented/): - HookDialect narrows to 'claude' | 'codex': the 'native' variant had zero producers (native plugins on the seams write no hook/* provenance), and the dialect is defined as the bridge that ran the hook. - HookOutput.suppressOutput is gone: the codec parsed it and every path discarded it with no warn and no deferral — hook stdout never enters a transcript, so there is nothing to suppress. - hook/result.durationMs is gone: durable timing telemetry with no reader that the snapshot normalizer had to scrub as replay noise. With no duration to measure, runHook loses its injected now clock and the single-field RunHookResult wrapper — it returns the HookOutput directly. The committed hook fixtures had the field stripped mechanically (field-only diff); the stdout goldens never carried it. - The bridges' double-defaulted defaultTimeoutMs config knob is replaced by one reference-default constant, DEFAULT_HOOK_TIMEOUT_MS, exported from the lib's runner and applied inside runHook; per-hook timeoutSec stays the override surface. - The hook/result semantics move into the lib that declares the event: HookResultRecord now carries the decoded HookOutput and appendHookResult derives the decision string (decision ?? stop-on-continue:false ?? pass) and the 500-char stderrSummary truncation; both bridges delete their byte-identical private copies. The snapshot suite passes against the existing goldens, proving the derived values are unchanged. - Rider: BLOCKING_EXIT_CODE is codec-internal again (zero importers). Amend the hook-protocol-lib and hook-snapshot-matrix RFCs to the new facts, update the lib/bridge READMEs and the session.md event tables, and retarget the affected unit tests (including new lib-level coverage of the derivation rules).
2026-07-04 15:44:26 +08:00
- **`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. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge.
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.
2026-07-01 00:38:06 +08:00
2026-07-01 01:12:04 +08:00
**Per-dialect (the bridge plugins):** 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`).
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.
2026-07-01 00:38:06 +08:00
docs(rfc): define and enforce a uniform RFC format; adopt it across the corpus Define the in-file RFC contract in docs/rfc/README.md § The file format: the header block (`# RFC: <title>` plus a dateless Status enum cross-checked against the lifecycle folder), the per-lifecycle body skeleton (a Problem opener everywhere; Proposal/Alternatives considered/ Acceptance criteria/Risks in proposed/; present-tense Decision/ Consequences with proposal-era headings banned in implemented/; the frozen proposal shape in rejected/), and a mandatory Alternatives considered section with a date-fenced grandfather comment for pre-format RFCs whose alternatives are not reconstructible from the record. Enforce it with a new doc-sync gate, scripts/verify-rfc-format.ts, and normalize all 112 RFCs to it: ~15 Status-line spellings collapse to the enum, 29 Context openers become Problem, the 39 legacy-format XXX debt markers are resolved and banned from reappearing, proposal-era sections in implemented RFCs are rewritten to shipped reality (including the web/fs/subagent seam RFCs' migration plans and test checklists, closing the doc-tiers deferred-work item on the web seam), every RFC gains an Alternatives considered section or the grandfather comment, and the bilingual pair is re-mirrored and re-recorded. Move the generated index tables out of README.md into a fully generated docs/rfc/INDEX.md — gen-rfc-index now writes the whole file, and verify-rfc-classification checks its freshness and rejects index-shaped rows in the curated README — which makes room for the format contract to live in the README front door instead of a separate FORMAT.md. The decision record, and the first RFC written in the new format, is docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md.
2026-07-05 22:58:25 +08:00
## Alternatives considered
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.
2026-07-01 00:38:06 +08:00
**One parameterized engine.** Rejected because payload construction and decision mapping genuinely differ by dialect. Matchers, codecs, execution, merge rules, and events remain shared; each bridge keeps its payload and mapping explicit so its wire behavior is readable in place.
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.
2026-07-01 00:38:06 +08:00
## Consequences
Each bridge parses config, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's real load path. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands.