diff --git a/docs/claude-hooks.md b/docs/claude-hooks.md index 6395b5206a..d157b96604 100644 --- a/docs/claude-hooks.md +++ b/docs/claude-hooks.md @@ -5,7 +5,9 @@ handlers) so repos that already ship Claude Code policy hooks work without a Cursor-format `hooks.json`. Tracks issue #639. -This is the Claude counterpart to [`docs/cursor-hooks.md`](./cursor-hooks.md). +This is the Claude counterpart to [`docs/cursor-hooks.md`](./cursor-hooks.md). For the +dialect-agnostic architecture (registry, canonical events, async/budget/epoch, spine, +sandbox, UI) see [`docs/hooks.md`](./hooks.md). ## On-disk layout diff --git a/docs/copse-hooks.md b/docs/copse-hooks.md index 755a91137a..51ed617d4f 100644 --- a/docs/copse-hooks.md +++ b/docs/copse-hooks.md @@ -10,7 +10,9 @@ vendor translation layer. It also exposes Copse-native knobs the imported dialec Each hook is a process that receives a JSON payload on **stdin**, may print a JSON response on **stdout**, and can observe, block, or annotate the action that triggered it. Dialect is -determined by source path (decision 8): `.copse/hooks.json` → the Copse adapter. +determined by source path (decision 8): `.copse/hooks.json` → the Copse adapter. For the +dialect-agnostic architecture (registry, canonical events, async/budget/epoch, spine, +sandbox, UI) see [`docs/hooks.md`](./hooks.md). The official JSON schema is published at [`schemas/copse-hooks.schema.json`](../schemas/copse-hooks.schema.json) diff --git a/docs/cursor-hooks.md b/docs/cursor-hooks.md index d9e49253e0..cf5c9238dd 100644 --- a/docs/cursor-hooks.md +++ b/docs/cursor-hooks.md @@ -7,7 +7,9 @@ can observe, block, or annotate the action that triggered it. This document records the Cursor hooks contract, what Copse honours today, and what remains for fuller parity. It is the hooks counterpart to -[`docs/cursor-plugins.md`](./cursor-plugins.md). +[`docs/cursor-plugins.md`](./cursor-plugins.md). For the cross-cutting architecture — the +unified registry, canonical events, executors, async/budget/epoch, spine, sandbox, and UI +that are dialect-agnostic — see [`docs/hooks.md`](./hooks.md). ## On-disk layout @@ -327,6 +329,7 @@ UPDATE_HOOK_PAYLOAD_SNAPSHOTS=1 npm test - `src/main/services/hooks/payload-snapshots.test.ts` — dialect wire payload snapshot tests (G4) - `src/main/services/hooks/__snapshots__/wire-payloads.json` — committed golden wire-payload fixture (G4) - `src/main/services/exec/child-process-env.ts` — secret-scrubbed env for hook processes +- `docs/hooks.md` — dialect-agnostic hooks architecture umbrella - `docs/claude-hooks.md` — Claude Code hooks contract - `docs/cursor-plugins.md` — sibling exploration of Cursor plugin support - `docs/supply-chain-security.md` — trust boundaries for executed code diff --git a/docs/hooks.md b/docs/hooks.md new file mode 100644 index 0000000000..625b7ec280 --- /dev/null +++ b/docs/hooks.md @@ -0,0 +1,346 @@ +# Hooks in Copse — architecture + +This is the **architecture umbrella** for Copse's hooks platform: one registry, one +canonical event vocabulary, two executor kinds, and three on-disk dialects. It is the +landed-design counterpart to the phased plan in +[`docs/plans/hooks-and-feature-packs.md`](./plans/hooks-and-feature-packs.md) (the design +source of truth and decisions log) and the entry point to the three dialect references: + +- [`docs/cursor-hooks.md`](./cursor-hooks.md) — the imported Cursor `hooks.json` dialect +- [`docs/claude-hooks.md`](./claude-hooks.md) — the imported Claude Code `settings.json` dialect +- [`docs/copse-hooks.md`](./copse-hooks.md) — Copse's own native `.copse/hooks.json` dialect + +Read a dialect doc for the exact on-disk format, events, and response fields a config +author writes. Read this doc for **how the pieces fit** — where the harness fires events, +how a decision flows back, and the cross-cutting concerns (budget, spine, sandbox, UI) +that are dialect-agnostic. + +> Everything below reflects what has **landed** (phases M0–G). Feature packs (Phase P) +> are the intended end state and are not implemented yet; the packs section of the plan +> doc describes them. Where behavior differs by phase, the phase tag (e.g. B4, F3, C3) is +> noted so it maps back to the plan's issue breakdown. + +## What a hook is + +A hook is a function of `(canonical event) → decision` (decision 1). It can **observe**, +**block**, or **annotate** the action that triggered it. Two things run hooks, and they +share one registry, one event vocabulary, and one Sources UI: + +- **First-party (function) hooks** — in-process functions in `packages/agent` (Electron-free). + They may emit typed stream chunks and read loop state, and they **fail hard**: a throw is + a bug, loud in dev, never silently swallowed (decision 9). These are the migrated harness + behaviors (todo steering, closeout nudges, in-loop nudges — phases M0/E). +- **Command hooks** — user/project scripts spawned as processes, owned by a **dialect + adapter**. They receive a JSON payload on **stdin**, may print a JSON response on + **stdout**, and their failure semantics are the vendor's (fail-open by default, per-hook + fail-closed honoured — decision 9). + +The **harness never knows dialects or executors exist**: it fires canonical events; the +registry dispatches to whatever is registered. + +## Core pieces + +``` +harness ──fires──▶ canonical event ──▶ registry ──▶ { function hooks | command hooks } + │ + dialect adapter ◀── source path ┘ + (marshal stdin / interpret stdout) +``` + +### Canonical events + +A **canonical event** is a named point where the harness calls the registry. Names are +final (a rename is a decisions-log edit, not a refactor). The full v1 enumeration, the +kind of each event, and its fire site live in the plan's +[Canonical events table](./plans/hooks-and-feature-packs.md#canonical-events-v1-enumeration); +they are typed in `packages/agent/src/hooks/canonical-events.ts` +(`HOOK_EVENT_NAMES` / `HOOK_EVENT_SPECS` / `HookEventPayloads`). The landed set spans tool +gates (`toolGate`), lifecycle (`beforeSubmitPrompt`, `afterFileEdit`, `stop`, +`afterToolUse`), subagents (`subagentStart` / `subagentStop`), assembly points +(`turnStart`, `beforeFinalize`, `stepBoundary`), session env (`sessionStart`), and the +Copse-native events (`beforeDiffApply` / `afterDiffApply` / `permissionDecision` / +`postTurnReview`). + +### Canonical decision vocabulary + +Adapters translate each dialect's wire format to and from **one** normalized outcome; the +harness consumes only this shape. Blocking and async outcomes are **separate types** so an +async hook cannot return a `decision`, `updatedInput`, or `injectContext` at the type level +(decisions 4 and 11 are compiler errors, not review comments — +`packages/agent/src/hooks/hook-outcome.ts`, pinned by +`async-outcome-type-excludes-decisions.test.ts`): + +```ts +interface HookOutcome { + decision?: 'allow' | 'deny' | 'ask' + haltRun?: { reason: string } // continue:false — outranks everything + updatedInput?: Record // tool gates only; re-runs policy analysis (H1) + injectContext?: string // blocking hooks only (v1); async → queued message + agentMessage?: string // fed to the model on deny/ask + userMessage?: string // shown to the user (hook card) + queueMessage?: { text: string; sendNow: boolean } // the async channel + sessionEnv?: Record // sessionStart → later hook processes +} +``` + +A hook can only ever **tighten** a gate: a `deny` blocks the action, but an `allow` still +flows through Copse's normal prompting — a hook can never auto-approve something Copse +would otherwise ask about. + +### Executors + +- **Function executor** — runs a first-party hook in-process. Gets the richer + `FunctionHookContext` (`emitChunk` + `loopState`). Fail-hard. +- **Command executor** — spawns a script. Gets the base `HookContext` only; command hooks + can **never** emit feature chunks (`todo_update`, `subagent_*`), which keeps the typed + stream first-party and transcripts trustworthy (decision 15, + `command-hooks-cannot-emit-feature-chunks.test.ts`). The contract lives in + `packages/agent/src/hooks/command-executor.ts`; the host runner that actually spawns is + `src/main/services/hooks/command-hook-runner.ts`. + +### Dialects and adapters (source path = format) + +Dialect is determined by **source path**, not prefixes or content sniffing (decision 8): + +| Source path | Dialect | Adapter | +| ----------------------------------- | ------- | ------------------------------------------- | +| `~/.cursor/hooks.json` + project | Cursor | `src/main/services/hooks/cursor-adapter.ts` | +| `~/.claude/settings.json` + project | Claude | `src/main/services/hooks/claude-adapter.ts` | +| `~/.copse/hooks.json` + project | Copse | `src/main/services/hooks/copse-adapter.ts` | + +Each adapter owns **discovery, parsing, matchers, and wire marshalling both directions**: +a Cursor hook sees Cursor's stdin shape and permission vocabulary; a Claude hook sees +Claude's `tool_name` tokens and exit-code-2 protocol; a Copse hook — being our own format +— speaks the canonical event names and decision vocabulary directly, with no translation +layer, and additionally exposes the native knobs (`async`, `onFailure`, `sandbox`, +`loop_limit`). Adapters register in `dialect-registry.ts`, so the dialect-agnostic runner +routes any dialect with no per-dialect branching. Unknown events in a foreign file are +**warned about, never silently skipped**. The shared process spawn (stdin marshalling, +stdout/stderr capture, timeout, output cap) is `src/main/services/hooks/hook-spawn.ts`. + +## Dispatch: blocking vs async + +Dispatch is chosen **by capability, not by origin** (decision 2): + +- **Blocking** — decision/mutation hooks (tool gates, `beforeSubmitPrompt`, mutating + `afterFileEdit`, `subagentStart`, `beforeDiffApply`). The harness awaits them; a + blocking-hook wait pauses the idle deadline the same way tool execution does. +- **Async (detached)** — observation hooks (`stop`, `afterToolUse`, `subagentStop`, + `afterDiffApply`, `permissionDecision`, `postTurnReview`, `sessionStart`). They dispatch + at the step that emitted them and are **never awaited** — not by the loop, not by other + hooks, not by `stop` (decision 3). "Stop stops agent work": abort/turn-end halts emission + of new events but never kills or waits for in-flight hooks. Async over-cap dispatches go + into a pending-dispatch FIFO (concurrency cap ~8/thread, then a bounded backlog); nothing + ever waits on the FIFO (decision 13). The dispatcher is + `src/main/services/hooks/async-hook-dispatcher.ts`. + +`afterFileEdit` is dual: blocking by default, with a **per-hook async opt-in** — expressible +only by the Copse dialect's `async: true` (Cursor/Claude have no such flag), so the opt-in +landed with F1 + C1. + +### The pending-message queue is the only async output channel + +An async hook cannot inject mid-turn (decision 4). Its `queueMessage` lands as a **queued +message**, consumed at idle drain, or immediately if the hook sets `sendNow` (byte-for-byte +the user's send-now semantics). An async hook's `injectContext` is converted to a queued +message (decision 11); Claude's `asyncRewake` (background hook waking the model mid-turn) is +**unsupported in v1** and reported as such by the adapter. A `haltRun` from an async hook is +allowed and routes through the existing abort path, hook-attributed (decision 12). + +## Auto-continuation budget, turn tree, and epoch + +A **turn tree** is everything descending from one human-originated submission (a typed +message or a human send-now/release). There is **one auto-continuation budget per turn +tree** (decision 5), a single counter, hard cap default 5. It counts **machine-initiated new +model turns** only: hook send-now, `stop`/`subagentStop` follow-ups, post-turn remediation +cycles, pre-review todo attempts, and todo-closeout turns. Existing per-mechanism caps +(closeout 3, pre-review 2, remediation 2) remain as **local tighteners inside** the shared +cap. + +**In-loop nudges do not count.** Truncation-continue, finalize, loop, and reasoning-runaway +nudges are mid-turn message pushes inside one `runAgentLoop` invocation, already bounded by +`maxSteps` / `DEFAULT_MAX_LLM_CALLS` and the run deadline (E1 fires them at `stepBoundary` +**without ever consuming a `ContinuationGrant`**). Conflating the two either starves the +loop or unbounds it. + +The budget is **enforced at dispatch time**: a hook-originated queued message consumes +budget when it _drains_, not when it enqueues. Exhaustion (and any hook message arriving +over-budget) flips the item to a **held** state (`autoDispatch: false`), which the drain +loop skips entirely — only an explicit human action submits it, and that human action starts +a fresh turn tree with a reset budget. A `COPSE_HOOK_DEPTH` env guard prevents +hook→Copse recursion. + +The budget is pure and Electron-free so both enforcement surfaces share it +(`packages/agent/src/hooks/continuation-budget.ts`): the main process keys a +`ContinuationLedger` by branded `TurnTreeId` for the in-run tighteners; the renderer applies +the same pure functions against the per-turn-tree counter it keeps on the thread. The run +folds its in-process spend back onto the thread via a `continuation_budget` chunk, epoch- +guarded and monotonic (E3 / C3), so the shared cap is enforced in both directions. + +### Epoch-scoping async outputs (decision 16) + +Every async hook dispatch carries the id of its **emitting turn tree** (its epoch). When an +output arrives, staleness is checked against the current turn tree: + +- a **stale send-now downgrades to a held queued message** (`autoDispatch: false` — no + abort, and _not_ auto-drained at idle, or a plain enqueue would re-open the back door), +- a **stale `haltRun` is a no-op**, recorded in the spine as suppressed. + +Only outputs from the _current_ turn tree may abort or auto-submit; everything stale waits +for a human. This exists because send-now aborts the active local run — a late async hook +from a completed turn must never abort or inject into a newer, unrelated human turn. + +### `loop_limit` clamp divergence (Cursor unlimited vs Copse clamped) + +Cursor's per-script `loop_limit` bounds how many times _that script_ may auto-continue the +agent, and Cursor allows `loop_limit: null` meaning **unlimited**. **Copse diverges here on +purpose:** human-in-the-loop is the floor, so no script may loop the agent forever. + +Copse treats `loop_limit` as **tighten-only**: it may only ever lower a script's +auto-continuation ceiling below the global budget, never raise it, and `null` is not a way +to escape the human-in-the-loop floor. + +**Enforcement status (honest):** the field is currently **reserved** — parsed and +validated by the Copse adapter, with the intended semantics below, but **per-script +enforcement is not yet wired** (plan row C5 owns it). Today only the **global** +auto-continuation budget (decision 5, cap 5 per turn tree) bounds machine turns; the pure +clamp (`clampLoopLimit` in `packages/agent/src/hooks/continuation-budget.ts`) exists and is +contract-tested, waiting for the drain-path wiring. + +| `loop_limit` value | Intended enforcement (C5) | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| a non-negative integer `n` | `min(n, global remaining)` — the script may contribute at most `n` machine turns, and never more than the shared budget has left | +| `null` (Cursor "unlimited") | **clamped to the global remaining, with a warning** — the "unlimited" intent is refused; the shared cap is the ceiling | +| negative / non-integer | ignored (parse-time warning; no field carried) — Copse dialect only | + +The `null`-is-refused warning already surfaces at parse time in Settings → Sources (the +Copse adapter emits it while parsing `.copse/hooks.json`). Only the **Copse** dialect +exposes `loop_limit` on disk today; the field and its reserved status are documented +per-dialect in [`docs/copse-hooks.md`](./copse-hooks.md#copse-native-fields). + +## Spine recording (always-on) + +Every hook execution writes a `hook_run` line to the thread spine (decision 6): event name, +hook id, emitting step, wall-clock duration, exit code, `parse_ok`, the normalized decision, +plus raw stdout **and stderr** as blobs. stderr matters because hook stdout is the response +channel — a script's stray debug print corrupts its own response into a fail-open `allow`, +and `parse_ok: false` next to the captured bytes is what makes that visible. Recording is +always-on and survives full thread saves (`writeThread` regenerates `events.jsonl` from +messages, so appended non-message lines must round-trip — A3). The spine also records +content-addressed **toolset fingerprints** referenced by hash from assistant lines and +`hook_run` records. The spine format is documented in +[`docs/thread-store-format.md`](./thread-store-format.md). + +## Sandbox (F3, macOS-only) + +Hooks are trusted by declaration (the user/workspace-trust gate is the consent) but +**sandboxed by default anyway** (decision 7). `spawnHookProcess` routes a sandboxed hook +through the same macOS-seatbelt wrapper `run_shell` uses. The only escape is the Copse +dialect's `sandbox: false`, which Sources badges **"outside sandbox"**; Cursor and Claude +hooks cannot express the escape and are always sandboxed-by-default. + +**Enforcement is macOS-only.** `isProjectSandboxEnabled()` is hard-false on Linux / Windows +(and when ASRT init fails), so a "sandboxed" hook still spawns with full user authority +there — treat "sandboxed" as a _default, not a guarantee_. A **sandbox-blocked** hook is +never a silent fail-open: `applySandboxBlock` escalates to a `failed` interpretation keyed +off runner-side violation signals (never the hook's own stdout, so a hook can't forge a fake +`allow` before seatbelt kills it — issue #104), records the block on the spine +(`sandboxBlocked: true`), surfaces it in Sources, and resolves it through the hook's +`onFailure` (`closed` → deny; `open` → no-opinion but still recorded). + +## Enablement, trust, and security + +Hooks are **off by default**, gated behind the `cursorHooksEnabled` security setting +(Settings → Sources → Hooks) — the same gate for all three dialects. When disabled the gate +skips discovery on the hot path; Sources still lists discovered hooks so authoring problems +are visible before enabling. User configs (`~/.cursor` / `~/.claude` / `~/.copse`) are always +honoured; **project configs require workspace trust** (#100) and are skipped for untrusted +clones. Hook processes inherit the scrubbed `envForRendererChildProcess()` env — LLM +provider keys are stripped, but **non-LLM tool tokens (e.g. `GITHUB_TOKEN`) remain** and are +readable by a hook. Enabling hooks + trusting a workspace grants that repo's hook config +arbitrary local code execution on the agent's hot path; this is the same trust boundary as +[`docs/supply-chain-security.md`](./supply-chain-security.md). See each dialect doc's +Security section for the full model. + +## Hook UI: cards, Sources, and the dry-run tester + +- **Hook cards (G1).** Hook executions, deny/ask decisions, and queued hook messages render + as a distinct **tool-call-style card family** — right-aligned, filled with the existing + user-message accent ("same blue", never a new hue), with a zap glyph, a status badge, and + the hook id — clearly **not** a user message. Cards are **derived** from the always-on + spine `hook_run` lines at fold time (`attachHookCards`), never a second source of truth + (decision 17), so an old thread renders its hooks exactly as they ran, even for a + now-unregistered hook. Hook-originated turns carry an `origin` marker (`Hook · +()`); the message role stays `user` for the LLM, and a human edit shows an `edited` + note (decision 10). The card model is `src/shared/hooks/hook-card.ts`; styling is + `src/renderer/styles/global/hook-cards.css`. Conventions are in + [`docs/ui-taste.md`](./ui-taste.md). +- **Sources panel (A4).** Settings → Sources → Hooks lists every discovered hook across all + three dialects, per-entry validation warnings, unsupported-event badges, the + "outside sandbox" badge (F3), and per-hook runtime error state (first failure per session). +- **Dry-run tester (G2).** Each Sources hook row has a **Test** button that runs the hook + **once** against a _synthetic_ payload for its event and shows the raw + `stdin` / `stdout` / `stderr` / exit code / duration plus `parse_ok` and a one-line outcome + summary. It is **side-effect-free by construction** — it reuses only the pure seams + (adapter marshal/interpret + `spawnHookProcess`), so it never records a spine line, never + propagates `sessionStart` env, and **never applies the outcome**; it reproduces the live + spawn boundary faithfully (sandboxed-by-default, macOS-only enforcement). Host module: + `src/main/services/hooks/dry-run.ts`. + +## Payload stability & schema drift tooling + +- **Vendored upstream schemas + drift detector (G3).** Copse pins committed copies of the + two upstream foreign-dialect config schemas under [`schemas/vendor/`](../schemas/vendor/) + (`claude-code-settings.schema.json`, `cursor-hooks.schema.json`). They are **never fetched + over the network** at runtime or in CI, and are **never a load gate** (a config that + violates an upstream schema still loads). They drive (1) a **warn-level authoring lint** + (an event the vendor recognises but Copse doesn't wire yet is distinguished from a typo) + and (2) a **CI drift detector** (`vendor-schema-drift.test.ts`) that fails when a + re-vendored schema adds an unaccounted event until it is wired or listed as intentionally + unsupported (`src/shared/hooks/vendored-hook-schemas.ts`). Provenance and re-vendoring + steps: [`schemas/vendor/README.md`](../schemas/vendor/README.md). +- **Wire payload snapshots (G4).** Every dialect wire **request** payload is snapshot-tested + against a committed golden fixture + [`src/main/services/hooks/__snapshots__/wire-payloads.json`](../src/main/services/hooks/__snapshots__/wire-payloads.json) + by `payload-snapshots.test.ts`. The request direction is the stability contract; pre-v1 + with zero consumers we don't version payloads, so **changing a snapshot is a publish-time + stability audit** (decision 14) — the reviewed JSON diff of the fixture _is_ the stability + declaration. Regenerate with `UPDATE_HOOK_PAYLOAD_SNAPSHOTS=1 npm test` and review the diff. +- **Copse's own schema.** Published at + [`schemas/copse-hooks.schema.json`](../schemas/copse-hooks.schema.json) + (`$id: https://copse.dev/schemas/copse-hooks.schema.json`) — the only schema Copse + authors, enumerating the canonical events and native fields. + +## Module layout + +The boundary is fixed (execution-guidance rule 4): + +- **`packages/agent/src/hooks/`** (Electron-free): canonical events (`canonical-events.ts`), + the registry (`hook-registry.ts`), outcome types (`hook-outcome.ts`), the command-executor + contract (`command-executor.ts`), the pure continuation budget + (`continuation-budget.ts` / `turn-tree.ts`), the async dispatcher policy + (`async-dispatcher.ts`), and the first-party function hooks (`turn-start-hooks.ts`, + `before-finalize-hooks.ts`, `step-boundary-hooks.ts`). Function hooks receive app services + via context; they never import them. +- **`src/main/services/hooks/`** (Electron-adjacent): the dialect adapters + (`cursor-adapter.ts`, `claude-adapter.ts`, `copse-adapter.ts`), the dialect registry + (`dialect-registry.ts`), the process spawn (`hook-spawn.ts`), the host runner + (`command-hook-runner.ts`), each canonical event's host orchestrator (`tool-gate.ts`, + `before-submit-prompt.ts`, `after-file-edit.ts`, `stop.ts`, `after-tool-use.ts`, + `subagent.ts`, `session-start.ts`, `diff-apply.ts`, `permission-decision.ts`, + `post-turn-review.ts`), the dry-run tester (`dry-run.ts`), and the spine/drift/snapshot + tests. +- **`src/renderer/`**: hook cards + held-queue UI. + +## Related + +- [`docs/plans/hooks-and-feature-packs.md`](./plans/hooks-and-feature-packs.md) — the design + source of truth: decisions log, canonical-event table, phased issue breakdown, feature packs +- [`docs/cursor-hooks.md`](./cursor-hooks.md) · [`docs/claude-hooks.md`](./claude-hooks.md) · + [`docs/copse-hooks.md`](./copse-hooks.md) — the three dialect references +- [`docs/thread-store-format.md`](./thread-store-format.md) — spine format the `hook_run` line extends +- [`docs/supply-chain-security.md`](./supply-chain-security.md) — the trust boundary hooks live inside +- [`docs/ui-taste.md`](./ui-taste.md) — hook-card conventions +- [`schemas/vendor/README.md`](../schemas/vendor/README.md) — vendored upstream schemas (G3) +- Cursor hooks reference: · Claude Code hooks reference: + diff --git a/docs/plans/hooks-and-feature-packs.md b/docs/plans/hooks-and-feature-packs.md index 2ce48769ab..5e5b47c7b3 100644 --- a/docs/plans/hooks-and-feature-packs.md +++ b/docs/plans/hooks-and-feature-packs.md @@ -485,7 +485,7 @@ name) and todo compaction pinning. Scope discipline matters more than completene | G2 ✅ | Dry-run hook tester | `hooks:test` IPC + Sources button; synthetic payload per event; show stdin/stdout/stderr/exit/duration. **Landed:** Settings → Sources now gives every discovered hook row a **"Test"** button that runs the hook **once** against a _synthetic_ payload for its event and shows the raw `stdin` / `stdout` / `stderr` / exit code / duration plus the derived `parse_ok` + a one-line outcome summary (`allow` / `deny — ` / `no opinion` / `failed — `). The `hooks:test` IPC (`register-handlers.ts`, zod-validated `zHookTestRequest`) delegates to a new host module `src/main/services/hooks/dry-run.ts` (execution-guidance rule 4 — synthesizing wire payloads + spawning is Electron-adjacent, never `packages/agent`). **Side-effect-free by construction:** the dry run does **not** reuse `createCommandHookRunner().run()` (which records the spine, threads session env, and records Sources runtime failures) — it reuses only the pure seams: the dialect adapter's `marshal*`/`interpret*` (A2) and the shared `spawnHookProcess` (A2/F3). So it never records a `hook_run` spine line, never propagates `sessionStart` env (H4), never records a Sources per-hook `lastError`, and **never applies the outcome** — no decision is enforced, no turn is started, no permission is granted (the outcome is _displayed_). It reproduces the **live spawn boundary** faithfully: sandboxed-by-default per F3 (honouring a Copse `sandbox: false` escape), macOS-only enforcement (a default, not a guarantee). **Synthetic payload per event:** `dryRunPlanFor(family, wireEvent)` maps the Sources wire event to a canonical event + tool flavor (Cursor `beforeShellExecution`/`beforeMCPExecution`/`beforeReadFile` → `toolGate` with `run_shell` / `mcp__…` / `read_file`; `afterShell`/`afterMCP` → `afterToolUse`; the rest 1:1; Claude `PreToolUse` → `toolGate`, `SessionStart` → `sessionStart`; Copse's Sources event is already canonical), and `synthesizeCanonicalPayload` builds a representative payload per canonical event; events with no command-hook fire site (first-party assembly events + `compaction`) or no dialect marshaller report `ran: false` without spawning. A bounded `DRY_RUN_TIMEOUT_MS` (15s) keeps the tester responsive regardless of the vendor per-hook timeout defaults. **Tests:** unit/contract `dry-run.test.ts` (payload synthesis shapes, plan mapping, end-to-end stdin/stdout/exit/duration + parse_ok + outcome summary, unsupported-without-spawn, and the **side-effect-free** guarantee — a failing dry run leaves the Sources per-hook `lastError` unset); component `settings-sources-hooks.test.ts` (per-row Test button, click-through renders summary chips + labeled streams, not-runnable notice); WDIO visual `tests/e2e/settings-sources-hook-test.e2e.ts` with a seeded `cat` hook → screenshot `tests/e2e/screenshots/settings-sources-hook-test.png`. The tester `
` streams are added to the `base.css` text-selection allow-list (hook output is copyable content). **Not G2:** G3 vendored schemas / drift detector, G4 payload snapshots, G5 docs. |
 | G3 ✅ | Vendored schemas + CI drift detector | Pin Claude SchemaStore + Cursor community schemas; **warn-level authoring lint only, never a load gate, never remote-fetched**; CI test diffs published event lists vs adapter-known events. **Landed:** pinned, committed copies of the two upstream hook-config JSON schemas live under `schemas/vendor/` — `claude-code-settings.schema.json` (Claude Code, from SchemaStore, `hooks` object publishes 30 events) and `cursor-hooks.schema.json` (the community `cursor-hooks` npm schema, `cursor-hooks@1.1.5`, publishes 6 events) — with provenance, pins (source URL + version/date + sha256), and re-vendoring steps in `schemas/vendor/README.md`. **Never remote-fetched:** the app and CI only ever read the committed copies from disk; the network is touched exactly once by a human/agent re-vendoring a pin (`.prettierignore` keeps the vendored JSON byte-identical to upstream so the diff is meaningful). **Warn-level lint, never a load gate (decision 8):** the vendored _published event lists_ are mirrored as TS constants in `src/shared/hooks/vendored-hook-schemas.ts` (`CURSOR_PUBLISHED_HOOK_EVENTS` / `CLAUDE_PUBLISHED_HOOK_EVENTS` + explicit `*_INTENTIONALLY_UNSUPPORTED_EVENTS`), and the **Claude adapter** now emits warn-level authoring warnings when a hooks group targets a declared-but-unwired event — distinguishing an event Claude Code recognises-but-Copse-doesn't-support-yet from an outright unknown (typo) — surfaced through `listClaudeHooks` (now returns `HooksListResult`) into the `hooks:list` IPC / Settings → Sources. The valid hooks still load exactly as before (the Cursor + Copse adapters already warned; this extends the same warn-only lint to Claude). **CI drift detector:** `src/main/services/hooks/vendor-schema-drift.test.ts` reads the committed vendored JSON (offline), extracts each schema's published events (`hooks.properties` keys), and asserts (a) the JSON list equals the TS mirror (pin integrity) and (b) `intentionally-unsupported == published \ wired` per dialect (`CURSOR_WIRED_HOOK_EVENTS` / the new exported `CLAUDE_WIRED_HOOK_EVENTS`) — so a re-vendored schema that adds an unaccounted event fails CI until it is wired or explicitly documented as unsupported (the "Long-tail Claude events" row). Copse wires Claude `PreToolUse` + `SessionStart` (28 events intentionally-unsupported) and all 6 Cursor community-schema events (0 unsupported; the adapter deliberately knows a superset). Short pointer added to `docs/cursor-hooks.md`. **Not G3:** G4 payload snapshot tests, G5 docs overhaul. | |
 | G4 ✅ | Payload snapshot tests | Decision 14: snapshot every dialect wire payload now. **Landed:** every dialect wire **request** payload — the stdin JSON a Cursor / Claude / Copse hook actually receives — is now snapshot-tested against a committed golden fixture `src/main/services/hooks/__snapshots__/wire-payloads.json` by `src/main/services/hooks/payload-snapshots.test.ts`. The test marshals a fixed synthetic payload with a fixed `AgentSessionInfo` (so the B4 `model` fields are captured — the maximal wire shape) through each adapter's real `marshal*Request` seam (A2, the same functions the live runner + G2 dry-run use) for **every canonical event each dialect declares a marshaller for**, including the tool-flavor splits (`toolGate` → shell / MCP / read; `afterToolUse` → shell / MCP), and asserts the result is **byte-identical** to the fixture. `getWorkspaceRoot()` is pinned via `setWorkspaceRootForTest` to a fixed POSIX root so `workspace_roots` / `cwd` are deterministic across machines. **Coverage** (a second assertion pins the per-dialect event set so a dropped marshaller is a mechanical failure, never a silent gap): Cursor snapshots 11 shapes (3 `toolGate` flavors, `beforeSubmitPrompt`, `afterFileEdit`, `stop`, 2 `afterToolUse` flavors, `subagentStart` / `subagentStop`, `sessionStart`); Claude 4 (3 `PreToolUse` tool flavors + `SessionStart`); Copse 15 (all supported events incl. the F2 Copse-native `beforeDiffApply` / `afterDiffApply` / `permissionDecision` / `postTurnReview`). The **request direction is the stability contract** (a hook parses what we send); response interpretation stays pinned by the per-adapter contract tests (`cursor-adapter.test.ts` / `claude-adapter.test.ts` / `copse-adapter.test.ts`). **Changing a snapshot is a publish-time stability audit** (decision 14): pre-v1 with zero consumers we do not version payloads, so the reviewed JSON diff of the golden fixture _is_ the stability declaration — an accidental shape change becomes a failing test. The fixture is a generated artifact (regenerate with `UPDATE_HOOK_PAYLOAD_SNAPSHOTS=1 npm test`, then review the diff), written byte-exact by the test and `.prettierignore`d so prettier never reformats the bytes the test compares against (mirroring the G3 vendored-schema precedent). Short cross-link + regeneration steps added to `docs/cursor-hooks.md` (G5 owns the full `docs/hooks.md` overhaul — not this row). **Not G4:** G5 docs overhaul. |
-| G5 | Docs overhaul | `docs/hooks.md` architecture doc (this design); fix stale paths (now `src/main/services/hooks/cursor-adapter.ts` after A2); document the `loop_limit` clamp divergence |
+| G5 ✅ | Docs overhaul | `docs/hooks.md` architecture doc (this design); fix stale paths (now `src/main/services/hooks/cursor-adapter.ts` after A2); document the `loop_limit` clamp divergence. **Landed:** a new [`docs/hooks.md`](../hooks.md) is the **dialect-agnostic architecture umbrella** describing the landed design — the unified registry + one canonical event vocabulary + two executor kinds (decision 1), the decision vocabulary and its blocking/async type split (decisions 4/11), dialects-by-source-path with adapters owning wire marshalling (decision 8), blocking-vs-async dispatch + detached async + the queue-as-only-async-channel (decisions 2/3/4/13), the per-turn-tree auto-continuation budget + epoch-scoping (decisions 5/16), always-on spine `hook_run` recording (decision 6), sandbox-by-default macOS-only enforcement (decision 7, F3), enablement/trust/security, the hook-card UI (decision 10, G1), Sources + the dry-run tester (A4, G2), vendored-schema drift + wire-payload snapshots (G3/G4), and the fixed `packages/agent` ↔ `src/main/services/hooks` module layout — cross-linking the plan (design source of truth), the three dialect docs, thread-store format, supply-chain security, UI taste, and the vendored-schema README. The three dialect docs (`cursor-hooks.md` / `claude-hooks.md` / `copse-hooks.md`) and this plan's Related section now point at it. **`loop_limit` clamp divergence documented** as its own section (`docs/hooks.md`, cross-referenced from `docs/copse-hooks.md`): Cursor allows `loop_limit: null` = unlimited; **Copse refuses that** — `clampLoopLimit` (`packages/agent/src/hooks/continuation-budget.ts`) is tighten-only, bounding a numeric limit to `min(n, global remaining)` and **clamping `null` to the global remaining with a warning** because human-in-the-loop is the floor (decision 5, C3). **Stale path fixed:** `docs/security-review-ga.md` M3 referenced the pre-A2 `src/main/services/cursor-hooks.ts` — now `src/main/services/hooks/cursor-adapter.ts`. **Pure docs** — no code behavior changed. **Not G5:** P packs. |
 
 ### Phase P — feature packs
 
@@ -573,6 +573,7 @@ exist to prevent that; the dead-code gate enforces them.
 
 ## Related
 
+- [`docs/hooks.md`](../hooks.md) — the landed-design architecture umbrella (registry, dialects, async/budget/epoch, cards, sandbox); this plan is its design source of truth (G5)
 - [`docs/cursor-hooks.md`](../cursor-hooks.md) — current support + security/trust model
 - PR #879 — Claude `PreToolUse` hooks (Phase 0)
 - PR #840 — permission-decision audit trail (feeds F2)