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.
6.2 KiB
RFC: dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core
Status: implemented
Problem
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 plugin (dsh-hooks-claude, dsh-hooks-codex) owns what genuinely differs.
Shared (here):
- Matcher —
matchesMatcher(pattern, query, mode). The ONE axis the dialects differ on is collapsed to themodeparameter:claudetreats a pure[A-Za-z0-9_|]+pattern as a literal (pipe = exact-match alternation) and anything else as a regex;codexis 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 thectx.bashseam rather than a bespokespawn: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, anddsh-bash'sstdin/envfields (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'stimeoutSec(elseDEFAULT_HOOK_TIMEOUT_MS, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-errorHookOutput). - Decode —
parseHookOutput(exit, stdout, stderr), the exit-code + structured-stdout codec, producing a dialect-neutralHookOutput. Exit0→ lenient JSON parse of stdout; exit2→ blocking error withstderras the reason (surfaced asdecision: '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'ssuppressOutput— hook stdout never enters a transcript here, so there is nothing to suppress; see the tighten-hook-protocol-contract RFC). - Merge —
mergeHookOutputs(outputs), folding multiple matched hooks into one most-restrictiveMergedHookOutcome: permission precedence deny > ask > allow, halt sticky on the firstcontinue:false, block reasons joined\n\n, context/system-messages accumulated in order. hook/*session events —hook/invoked/hook/result, declaration-merged intoSessionEventMap(log-only, likecompact/*— NOTSurfaceEventTypes), withappendHookInvoked/appendHookResulthelpers so the invoked/result pairing and turn-enclosure stay consistent across bridges.appendHookResultalso owns the durable record's semantics — the decision string (the hook's parsed decision, else'stop'oncontinue:false, else'pass') and the 500-characterstderrSummarytruncation derive from theHookOutputhere, not per-bridge.
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).
Alternatives considered
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 bridge plugins 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); 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.