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.
9.1 KiB
RFC: Interception seams — the typed-Decision surface a hook programs against
Status: implemented
Problem
The harness needs a hooks subsystem: users extend or gate the agent at lifecycle points the way Claude Code (CC) and Codex do. The key reframe driving this design is that "native hooks" are not a package — a native hook is just an ordinary Cordis plugin subscribing to the canonical lifecycle events. So the real product is a powerful, well-typed canonical event surface; the CC/Codex bridges (the dsh-hooks-claude / dsh-hooks-codex packages) are merely translators that map an external shell-hook protocol onto that same surface. Anything a bridge can do, a plain plugin can do directly — more powerfully (no serialization boundary, full ctx, typed returns).
Before this change the interception surface was incomplete and inconsistent for that goal: there was no per-prompt seam (CC's UserPromptSubmit), no session-start signal (CC's SessionStart), the single tools/execute waterfall conflated the pre-gate and post-inspect phases (CC splits PreToolUse/PostToolUse), and agent/turn-continuation returned a bare boolean with no room for a force-continue reason. The event-domain-semantics RFC pinned down the three-domain rule and the typed-Decision idiom as the interception convention; this RFC builds the actual seams on top of it.
Decision
Add/reshape the interception seams so every one returns a small, seam-specific typed Decision union, and the set covers the hook points in scope (session-start, prompt-submit, pre-tool, post-tool, stop-via-continuation).
New agent/* events (dsh-agent):
agent/session-start(agent, source)— emit, once before turn 1, carrying aSessionStartSource(startupfor a fresh/forked create,resumefor a reloaded persisted session;clear/compactreserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context viaagent.inject().agent/prompt-submit(agent, content, source, next) → PromptDecision— waterfall, fired per drained queued message inside the open turn, before theuser/messageappend.allow(optionally rewriting the promptcontentor attachingadditionalContext) orblock(dropping the prompt; the loop appends a durableprompt/blockedin its place — see the dispatch note below).
Reshaped agent/turn-continuation from (…, defaultDecision: boolean) → boolean to (…, defaultDecision: ContinuationDecision) → ContinuationDecision. A {action:'continue', reason?} may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the existing /goal step-end-steer pattern.
Split the single tools/execute waterfall into tools/pre-execute (→ PreToolDecision allow/deny/ask gate) and tools/post-execute (→ PostToolDecision accept/block, optionally replacing content or attaching additionalContext). Core dispatch sits between them as plain code inside ToolRegistry.execute's outer try/catch, and the tool body keeps its own inner try/catch so a thrown tool still becomes an isError result that post-execute listeners can inspect.
New TurnEndReason variant rejected (dsh-session): a turn whose entire prompt batch was blocked by prompt-submit.
Three load-bearing loop decisions
-
Always open the turn first; a fully-blocked batch is a zero-step
rejectedturn; every veto is recorded asprompt/blocked.prompt-submitfires AFTERturn/start, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes withrejected. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durableturn/endis appended and the ACP bridge settles normally off it (mappingrejected→cancelled) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends aprompt/blockedsession event (the originalcontent,source, andreason) in place of theuser/messagethe prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT endrejected, so the boundary reason alone would silently lose the blocked prompt on replay. Anallow'sadditionalContextisinject()ed into this now-open turn. -
Post-tool
additionalContextis buffered and appended AFTER alltool/results.content/feedbackshape the resultexecute()returns, butadditionalContextis a SEPARATEcontext/message, and a single step can carry multiple tool calls. Appending context right after each result would interleaveresult(c1) → context → result(c2)and break tool-call/result adjacency. Soexecute()surfacesadditionalContexton itsToolExecutionResult, and the loop buffers every per-call context for the step and appends them ascontext/message(s) only after everytool/resultis appended. -
A forced
continuereasonis enqueued through the steering channel, so the next step's top-of-loop drain records it as steering for the continued turn — next-step steering within the SAME turn, not a next-turn prompt (matching the existinghasSteeringforce-continue override).
Pre-tool INPUT rewrite is DEFERRED (the over-reach signal)
PreToolDecision is allow/deny/ask only — no arguments rewrite. Output replacement (PostToolDecision.accept.content) is safe because tool/result is logged AFTER execution (one source of truth). Input rewrite is NOT safe today: assistant/message (the model-history source) and tool/call (the audit record) are both logged BEFORE execution, and live consumers READ tool/call.arguments for presentation (the ACP bridge remembers them for presentResult; dsh-tool-bash derives the title/cwd/terminal-vs-background from them). A rewrite that changed only execution would make the UI show one command while another RAN. Designing that consistently (rewriting the audit + history + presentation as one unit) is a real consistency-design problem CC itself warns is racy — so it gets its own proposed RFC, and TODO(pre-tool-input-rewrite) anchors it at the loop's pre-execute call site. This does not regress any production consumer (no production tools/execute listener mutated exec.arguments). The low-level capability to mutate exec in a pre-execute listener still exists (unadvertised — a test shim uses it to thread a generated id), but it is not a first-class advertised contract.
What this PR does NOT do
It does not declare hook/* SessionEvents (the durable hook-invocation log) — those belong to the dsh-hook-protocol library, because a native plugin can already use the typed Decisions without a durable hook log. A worked native-plugin example/test in this PR (packages/core/agent-loop/tests/interception.spec.ts) proves all the seams compose end-to-end through the REAL loop with NO hook/* involved — the concrete proof that "native hooks are just a plugin". Compaction (PreCompact/PostCompact), the Notification hook, Codex PermissionRequest, the permission/ask system, and the Stop loop-guard remain deferred (FIXME(permissions) marks the ask→deny degrade).
Alternatives considered
- Shipping pre-tool INPUT rewrite as part of this seam set — deferred as the over-reach signal; the section above carries the consistency problem (audit, history, and presentation all read
tool/call.argumentslogged before execution), and the pre-tool input-rewrite proposal owns the design. - Declaring the durable
hook/*SessionEvents alongside the seams — rejected: a native plugin uses the typed Decisions with no hook log at all (the worked example proves it), so the durable log belongs to the hook-protocol library, not the seam surface.
Consequences
The canonical interception surface is now complete and uniformly typed: a native plugin returns typed decisions directly, and a CC/Codex bridge maps its protocol fields onto the same unions. The loop gained four firing points (session-start emit, prompt-submit waterfall, the post-tool context buffer, the continuation reshape) and the dsh-tools registry runs a two-waterfall pipeline; both are documented in architecture.md and the package READMEs, and the decision types in core-data-structures + tools.md. All existing tools/execute and turn-continuation listeners (tests, docs) migrated to the new seams. The ACP bridge maps the new rejected reason to cancelled (its codec). A pure internal change with no editor-visible transcript shift for the existing scenarios — the new behavior only fires when a hook is registered — so the snapshot goldens are unchanged; a hook-driven snapshot scenario lands with the dsh-hooks-claude bridge, which is what makes a hook observable end-to-end through ACP.