From bc41db3caed3643eccc9471ded0bbd28ed705b95 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 4 Sep 2026 15:08:33 +0200 Subject: [PATCH 01/11] plans: Add Claude mutation-scope integration plan Define the first concrete Claude Code mutation-scope adapter, its generic-ingress boundary, lifecycle and identity semantics, durable state, generated settings integration, and validation strategy. - Sequence the work across Claude lifecycle discovery, adapter implementation, setup integration, production regressions, and durable context. - Capture fail-closed PreToolUse behavior, write-ahead Start ordering, stale-attempt cleanup, worktree ownership, and background-shell limitations. - Record acceptance criteria and full verification commands for the stacked integration. Plan: claude-mutation-scope-integration Tasks: T01-T08 Co-authored-by: SCE --- .../claude-mutation-scope-integration.md | 793 ++++++++++++++++++ 1 file changed, 793 insertions(+) create mode 100644 context/plans/claude-mutation-scope-integration.md diff --git a/context/plans/claude-mutation-scope-integration.md b/context/plans/claude-mutation-scope-integration.md new file mode 100644 index 00000000..078c8da9 --- /dev/null +++ b/context/plans/claude-mutation-scope-integration.md @@ -0,0 +1,793 @@ +# Plan: claude-mutation-scope-integration + +## Change summary + +Add the first concrete mutation-scope producer for SCE: a Claude Code adapter +that translates Claude's raw tool/lifecycle hook events into the normalized +mutation-scope contract already implemented by `sce hooks mutation-scope` +(`cli/src/services/hooks/mutation_scope.rs`, documented in +`context/cli/mutation-scope-hook-ingress.md`). + +Data flow: + +```text +Claude raw hook event + -> sce hooks claude-mutation-scope (new, hidden) + -> normalize lifecycle + identity, classify tool + -> hooks::mutation_scope generic ingress (in-process seam) + -> coordinate() / abandon_scope() + -> mutation cursor +``` + +The fundamental mapping is **one mutation-capable Claude tool execution = one SCE +mutation `ScopeId`**. A session, prompt, main agent, or subagent is never a scope; +`session_id` / `agent_id` are only identity inputs that distinguish tool +executions. Two parallel mutation-capable tools produce two simultaneously live +scopes and may correctly yield `AiContended`. + +This extends the mutation-scope stack: the generic ingress and runtime already +exist and are unchanged in contract. This change adds the harness adapter layer +the ingress explicitly deferred, plus a small crate-visible in-process seam on +`mutation_scope.rs` so the adapter reuses one mutation implementation rather than +spawning a second `sce` subprocess or constructing `RuntimeBoundary` directly. + +The adapter never calls `coordinate()`, `abandon_scope()`, +`RepositoryAgentTraceDb`, `WorktreeId`, `GitSnapshotService`, the mutation store, +or protocol internals directly. It never accepts, derives, stores, or constructs +a `WorktreeId`: it passes the raw hook `cwd` as `repository_root` and the runtime +derives worktree identity itself. No mutation protocol, Quint model, SQL +migration, mutation-attribution algorithm, or Agent Trace schema change is in +scope. + +## Design + +These are the design decisions the task stack and acceptance criteria reference +by number. `PostToolUseFailure`, `StopFailure`, `PermissionDenied`, and +`WorktreeRemove` are documented Claude Code hook events, so their existence is +not in question. Decisions whose correctness depends on one of them actually +firing, with the payload and lifecycle semantics this design assumes, on the +Claude Code version SCE chooses to support are marked **Conditional on T01** — +T01 freezes the real, tested contract from that version and the plan is revised +before T02 if T01 finds the documented event's runtime behavior diverges from +what the decision assumes. + +### D1 — Scope = one independently mutation-capable Claude tool execution + +A mutation scope is exactly one independently mutation-capable Claude tool +execution attempt. Not a session, not a prompt, not the main agent, not a +subagent. Two tools that can edit the worktree concurrently (e.g. a main-agent +tool and a subagent tool) are two scopes with distinct `ScopeId`s, so the +protocol can report `AiContended` when they genuinely race. Sequential tool +calls are sequential scopes. + +### D2 — Tool classification + +The adapter classifies `tool_name` in Rust: + +- **Mutation-capable (always establishes a scope):** `Bash`, `PowerShell`, + `Write`, `Edit`, `NotebookEdit`, `MultiEdit` (when the supported Claude + version emits it), and any `mcp__*` tool (an arbitrary MCP tool may modify the + local repository, so it is treated conservatively). +- **Read-only (never establishes a scope):** `Read`, `Glob`, `Grep`, + `WebFetch`, `WebSearch`, `AskUserQuestion`. +- **`Agent`:** a delegation wrapper, not itself a mutation scope. The subagent's + own mutation-capable tool calls fire their own hooks (carrying the subagent's + `agent_id`) and establish their own scopes; wrapping the whole delegation in a + parent scope would fold every child mutation into it. +- **Unknown tool names:** conservatively treated as mutation-capable. A new + read-only Claude tool would briefly create unnecessary (harmless) scopes until + classified; the opposite default would silently miss a new mutation-capable + tool. + +### D3 — Claude execution identity + +Required for a tracked `PreToolUse`: `session_id`, `cwd`, `tool_name`, +`tool_use_id`. Optional: `agent_id` (absent = main thread, present = subagent), +`prompt_id` (diagnostics only — correctness must never depend on it), +`agent_type` (diagnostics only). The tool-execution key is +`(session_id, agent_id?, tool_use_id)`. + +### D4 — ScopeId / EventId derivation + +A raw `tool_use_id` can recur (a deferred execution resumed), and a terminal SCE +`ScopeId` must never be reused, so `ScopeId` cannot be a pure function of +`tool_use_id`. The adapter keeps a monotonic checkout-local `attempt_seq`; each +new execution attempt gets a fresh `attempt_seq`. `ScopeId` is a +length-prefixed, hash-free encoding: + +```text +cc-tool-v1|n=|s=:|a=:|t=: +``` + +`EventId`s are derived deterministically from the `ScopeId`: `|start` +and `|close`. Replaying the same hook event for one live attempt always +yields the same `ScopeId` and `EventId` (the runtime's replay/idempotency key). +After an attempt is terminal, another `PreToolUse` for the same `tool_use_id` +gets a new `attempt_seq` and therefore a new `ScopeId`. + +### D5 — Checkout-local adapter bookkeeping + +The adapter keeps tiny cross-hook-process state at +`/sce/claude-mutation-scope-state.json` (located via +`checkout::resolve_git_dir(cwd)` — worktree-specific for linked worktrees). It +holds `version`, `next_attempt_seq`, `recovery_pending`, and an `attempts[]` +list, each attempt carrying its `attempt_seq`, `scope_id`, identity fields, +`tool_name`, and `phase` (`pending_start | active`). This state is **adapter +bookkeeping, never attribution evidence**: it is not exported, not synced, not +part of Agent Trace, not authoritative for attribution. Its only purpose is to +know which Claude-created scopes may still need a terminal action. + +### D6 — Durable adapter-state persistence and a separate state lock + +State writes follow the existing checkout-identity durability pattern: acquire +the adapter-state lock at `/sce/claude-mutation-scope-state.lock`, +serialize, write a temp file, `sync_data`, atomic rename, best-effort parent-dir +`sync_all` on Unix, release. The adapter-state lock protects bookkeeping only and +is **never held across a `hooks::mutation_scope` invocation**, so no +`adapter lock -> WorktreeLock` order can form. The mutation runtime's own +`WorktreeLock` stays entirely independent. + +### D7 — PreToolUse write-ahead ordering + +For a new tracked mutation-capable tool: + +```text +parse event -> resolve cwd to git_dir + -> acquire adapter-state lock -> allocate attempt_seq -> persist phase=pending_start -> release lock + -> invoke generic ingress seam with Start + -> reacquire adapter-state lock -> phase pending_start -> active -> release lock + -> return empty success to Claude +``` + +The generic ingress receives the raw Claude `cwd` as its `repository_root`; SCE +derives the `WorktreeId`. The normalized operation is +`{"operation":"start","scope_id":,"event_id":|start,"actor_kind":"claude_code"}`. + +### D8 — Mutation-capable PreToolUse is fail-closed via Claude's deny decision + +A mutation-capable tool must not execute if the adapter cannot durably establish +its scope. Claude treats ordinary non-2 hook failures as non-blocking, so a +generic non-zero exit would let the tool run without its `Start` boundary. +Therefore any failure during a mutation-capable `PreToolUse` returns a Claude +`PreToolUse` denial: + +```json +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"SCE could not establish mutation attribution for this tool execution."}} +``` + +The detailed error is logged through SCE observability. The adapter never returns +`allow` — success emits no decision (normal Claude permission flow continues), +failure emits `deny` — so SCE cannot bypass Claude's own permission system. + +### D9 — PostToolUse closes the scope + +For an active tracked attempt, `PostToolUse` maps to +`{"operation":"close","scope_id":,"event_id":|close,"actor_kind":"claude_code"}`. +The attempt is removed from adapter state only after durable `Close` success; +duplicate `PostToolUse` delivery after cleanup is a safe adapter-layer no-op. + +### D10 — Failed-tool terminal observation — **Conditional on T01** + +Intent: a tool that failed may already have changed files, so its final observed +tree must still be captured through a terminal boundary (a `Close`), never +silently dropped. As drafted this uses the documented `PostToolUseFailure` event +mapped to the same `Close` operation as D9. T01 must verify, on the Claude Code +version SCE chooses to support, that `PostToolUseFailure` actually fires for a +failed tracked tool and carries the identity fields (`session_id`, `cwd`, +`tool_name`, `tool_use_id`, optional `agent_id`) this mapping needs. If the +tested version's `PostToolUseFailure` does not fire reliably or lacks those +fields, the fallback (see Open questions) is to fold the failed-tool tree into +the next observed boundary (`PreToolUse`/`Stop`) and drop the dedicated +failed-tool `Close`. + +### D11 — pending_start + terminal signal must abandon, not late-Start + +If the adapter persisted `pending_start` but a terminal signal +(`PostToolUse`/`PostToolUseFailure`, or a lifecycle cleanup) arrives before the +adapter ever durably recorded `active`, it cannot prove `Start` committed. It +must **not** issue a late `Start` after the tool already ran (that would observe +the post-tool tree and could misattribute the ambiguous interval to other live +scopes). Instead it abandons the scope: `abandon` on a committed `Start` +produces normal abandonment; `abandon` on a `Start` that never committed hits the +runtime's existing `MissingScope` / `NeverSeen` recovery path, forcing +conservative recovery. Either outcome prefers lost attribution over false +attribution. + +### D12 — Failed Close is retired through abandonment, not a replayed Close + +If a tool finished but its `Close` fails before durable completion, the original +observation time is lost. The adapter must not retry that `Close` later (at the +next prompt or minutes on) as if it were the original observation — a later tree +must not be presented as the tree observed when the tool completed. Instead it +immediately attempts `abandon` for that scope and sets `recovery_pending = true`. +The tool's attribution may be lost; that is intentional. The two generic-ingress +carried-success variants (`MarkerClearAfterCommit` / +`MarkerClearAfterCompletion`) are durable success and do not enter this path. + +### D13 — PermissionDenied cleanup — **Conditional on T01** + +Intent: when Claude signals that a tool call was denied and never executed, and +the adapter has a live attempt for it, `abandon` that scope and set +`recovery_pending = true` (abandonment requires a rebaseline before attribution +resumes). As drafted this uses the documented `PermissionDenied` event. T01 must +verify, on the Claude Code version SCE chooses to support, that +`PermissionDenied` actually fires, carries the `tool_use_id` this mapping keys +on, and confirm for which denial modes it fires (the design already assumes it +is an optimization for auto-mode denials only — manual denial, deny rules, and +another parallel `PreToolUse` hook blocking the tool are covered by lifecycle +cleanup below, not by this signal, so those paths must not regress if +`PermissionDenied` turns out narrower than expected). + +### D14 — Stop stale-main cleanup + +`Stop` is **not** a `Close` of any Claude agent scope (there is no such scope). +It is positive evidence that any still-outstanding **main-thread** tool attempt +(`session_id == Stop.session_id`, `agent_id == None`) from the just-finished turn +is no longer executing: `abandon` each and remove it after durable settlement. It +does not touch subagent-owned attempts. If a later `Stop` hook makes Claude +continue, any earlier outstanding tool execution is still stale and new work gets +new `tool_use_id`s / attempts. + +### D15 — StopFailure cleanup — **Conditional on T01** + +Intent: perform the same stale main-thread cleanup as D14 when a main turn ends +in failure. As drafted this uses the documented `StopFailure` event. T01 must +verify, on the Claude Code version SCE chooses to support, that `StopFailure` +actually fires for a failed main turn and carries `session_id`. If the tested +version does not reliably fire it for the failure cases this design cares about, +D14's `Stop` plus D16's `UserPromptSubmit` fallback and D18's `SessionEnd` cover +the failed-turn case. + +### D16 — UserPromptSubmit interruption cleanup + +A new user prompt is the fallback for a main turn the user interrupted (Claude +emits no `Stop` for an interruption). Before the new prompt is processed, the +adapter cleans up outstanding **main-thread** foreground attempts for that +session (`abandon` + remove). It does not abandon subagent-owned attempts merely +because a main-thread prompt was submitted — background subagents may legitimately +continue across main-thread turns. + +### D17 — SubagentStop matching-agent cleanup + +For `SubagentStop(agent_id = X)`, `abandon` any still-outstanding foreground tool +attempts owned by `(session_id = event.session_id, agent_id = X)`. Safe even if +another `SubagentStop` hook makes the subagent continue — existing tool +executions have finished or failed to execute, and any continuation uses new tool +executions. No `ScopeId` is derived from `agent_id` alone, so resuming a subagent +under the same `agent_id` is safe. + +### D18 — SessionEnd cleanup + +`SessionEnd` cleans up remaining non-detached tool attempts for that session, +including deferred execution attempts from the process that just ended. If such a +tool later fires `PreToolUse` again after `claude --resume`, it receives a new +`attempt_seq` and a fresh `ScopeId`; a terminal `ScopeId` is never reused. + +### D19 — recovery_pending barrier and quiescent Flush + +Whenever abandonment or an uncertain lifecycle sets `recovery_pending = true`, +the adapter must not start a new mutation-capable tool while it still has known +outstanding tool attempts — new mutation-capable `PreToolUse` is denied (D8 +shape) until those attempts settle. When +`recovery_pending == true AND attempts.is_empty()`, the adapter runs one +`{"operation":"flush"}` through the generic ingress, giving the runtime one +worktree-level recovery/rebaseline boundary after the ambiguous executions are +gone. Only after a successful `flush` does the adapter clear `recovery_pending`; +a failed `flush` keeps it fail-closed for subsequent mutation-capable +`PreToolUse`. + +### D20 — Detached background Bash/PowerShell is unsupported and denied + +A detached shell can keep mutating the repository after `PostToolUse` returns and +can outlive a session; the generic mutation-scope contract has no process +supervisor or stable background-execution terminal signal. This PR must not +pretend `PostToolUse(background Bash)` means the execution ended. An explicit +`Bash.run_in_background = true` / `PowerShell.run_in_background = true` is denied +in `PreToolUse` (D8 shape) with: + +```text +SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground. +``` + +This is a deliberate correctness boundary, not a Bash security policy. **Hard T01 +gate:** T01's `run_in_background=false` probe must verify the supported Claude +version cannot automatically background a shell call whose incoming payload said +`false`. If it can, D20 as written is unsound and the plan must be revised before +implementation — no silent unsound workaround. Background **subagents** are not +excluded here: their internal mutation-capable tool calls still fire hooks with +`agent_id` and establish their own scopes. + +### D21 — Raw Claude hook cwd is authoritative + +The mutation runtime's repository root is the raw Claude hook payload's `cwd`, +never `$CLAUDE_PROJECT_DIR` (the generated hook script may live under +`$CLAUDE_PROJECT_DIR`, but the payload's `cwd` is the actual current worktree). +For an `isolation: worktree` subagent, its tool executions happen inside the +isolated worktree and their hook events must drive the runtime from that +worktree's `cwd`; SCE then derives the correct worktree identity. `WorktreeRemove` +cleanup (D22) uses the event's `worktree_path`, not the hook process's cwd. + +### D22 — WorktreeRemove cleanup — **Conditional on T01** + +Intent: before Claude removes a worktree, retire any outstanding adapter attempts +stored under that worktree-specific Git directory (using the event's +`worktree_path`, no new mutation snapshot). As drafted this uses the documented +`WorktreeRemove` event. T01 must verify, on the Claude Code version SCE chooses +to support, that `WorktreeRemove` actually fires before removal and carries +`worktree_path`. If the tested version does not fire it reliably or lacks that +field, isolated worktree attempts are retired by D17/D18 when the owning +subagent/session ends instead, and the `WorktreeRemove` registration is dropped. + +### D23 — Adapter depends on hooks::mutation_scope only + +Dependency direction is strictly +`claude_mutation_scope -> hooks::mutation_scope -> mutation_trace::runtime`. The +Claude adapter's production code must not import or reference +`crate::services::mutation_trace::runtime`, `::protocol`, or `::store`, and must +not name `RepositoryAgentTraceDb`, `WorktreeId`, or `GitSnapshotService`. It +reaches the runtime only through the smallest crate-visible in-process seam on +`cli/src/services/hooks/mutation_scope.rs` (T04) — no second `RuntimeBoundary` +construction path and no spawned `sce` subprocess. That seam reuses the strict +generic payload parser, `RuntimeBoundary` mapping, lazy DB acquisition, +durable-completion error classification, and empty-stdout semantics already in +`mutation_scope.rs`. + +## Acceptance criteria + +How this plan is proven complete. Each criterion is observable and names the +check that proves it. `/validate` runs these checks; no task in the stack +performs final validation. + +- [ ] AC1: `sce hooks claude-mutation-scope` exists, is hidden from top-level + help, and routes through the normal hook command stack + (`HooksSubcommand::ClaudeMutationScope` -> `convert_hooks_subcommand_request` + -> `HookSubcommand::ClaudeMutationScope` -> `run_hooks_subcommand_in_repo`). + - Validate: `sce hooks claude-mutation-scope ingress `Start` -> `active`). + - Validate: T05 adapter ordering unit test with injected ingress; optionally + also T07 Test1 as production-path confirmation. +- [ ] AC8: Any failure to establish required adapter state or `Start` during a + mutation-capable `PreToolUse` returns a Claude `permissionDecision: "deny"` + object, never a plain non-zero exit and never `allow`. + - Validate: adapter failure-classification unit tests asserting the exact + `hookSpecificOutput` JSON. +- [ ] AC9: `PreToolUse` -> real filesystem mutation -> `PostToolUse` produces + exactly one eligible tool interval and one terminal (`Closed`) scope with + attribution `AiExclusive`. + - Validate: T07 Test1 (real Git repo + real Agent Trace DB). +- [ ] AC10: `PreToolUse` -> partial filesystem mutation -> `PostToolUseFailure` + also observes the mutation and closes the scope (`AiExclusive` + `Closed`). + - Validate: T07 Test2. +- [ ] AC11: Two simultaneously tracked tools create two active scopes; a tree + transition observed while both are live is attributed `AiContended`. + - Validate: T07 Test3 and Test9 (main + subagent). +- [ ] AC12: `PreToolUse` followed by `PermissionDenied` creates no mutation event + for the denied execution and leaves the worktree `needs_rebaseline`. + - Validate: T07 Test5. +- [ ] AC13: A `PreToolUse` with no `PostToolUse`/`PostToolUseFailure` is retired + by one of the positive stale signals (`Stop`, `StopFailure`, + main-thread `UserPromptSubmit`, matching-agent `SubagentStop`, `SessionEnd`, + `WorktreeRemove`) via `abandon_scope`. + - Validate: T07 Test6, Test7, Test11; T05 adapter cleanup unit tests. +- [ ] AC14: `PreToolUse` -> partial change/interruption -> no `Stop` -> next + main-thread `UserPromptSubmit` abandons the stale main attempt before another + mutation-capable tool can start. + - Validate: T07 Test7. +- [ ] AC15: A resumed subagent may carry the same Claude `agent_id`, but a new + tool attempt receives a fresh tool `ScopeId`; no terminal mutation `ScopeId` + is reused. + - Validate: T05 adapter identity unit tests; T07 Test8. +- [ ] AC16: A hook process launched from checkout A with raw payload + `cwd = checkout B` drives mutation state for checkout B. + - Validate: T07 Test10 (isolated-worktree cwd) asserting the correct + `WorktreeId`/cursor is advanced. +- [ ] AC17: Mutations from an `isolation: worktree` subagent change only that + worktree's mutation cursor; the main checkout's cursor is unchanged. + - Validate: T07 Test10. +- [ ] AC18: The dependency direction is exactly + `claude_mutation_scope -> hooks::mutation_scope -> mutation_trace::runtime`. + Production Claude-adapter code (everything in + `cli/src/services/hooks/claude_mutation_scope/` outside `#[cfg(test)]` blocks) + contains no `use` declaration or fully-qualified path reference naming + `crate::services::mutation_trace::runtime`, + `crate::services::mutation_trace::protocol`, + `crate::services::mutation_trace::store`, `RepositoryAgentTraceDb`, + `WorktreeId`, or `GitSnapshotService`, and its only dependency into the + mutation stack is the single T04 seam import from + `crate::services::hooks::mutation_scope`. + - Validate: focused source inspection of + `cli/src/services/hooks/claude_mutation_scope/{mod.rs,state.rs}`, excluding + `#[cfg(test)]`-gated code, targeted at `use` declarations and qualified + paths, e.g. + `rg -n --type rust '^\s*use\s+crate::services::mutation_trace::(runtime|protocol|store)|::(RepositoryAgentTraceDb|WorktreeId|GitSnapshotService)\b' cli/src/services/hooks/claude_mutation_scope/` + must return no matches outside a `#[cfg(test)]` module, and a manual check + confirms exactly one `use` reaching `crate::services::hooks::mutation_scope`. + This is a dependency-boundary check, not a text search for the bare words + `coordinate` / `abandon_scope` / `WorktreeId`, which may legitimately appear + in comments, diagnostics, or test code that fabricates outcomes. +- [ ] AC19: Claude adapter state lives only below `/sce/` and writes no + Agent Trace or mutation database table directly. + - Validate: state-module inspection; T07 Test16. +- [ ] AC20: Claude mutation-scope-only regressions leave `diff_traces`, + `post_commit_patch_intersections`, and `agent_traces` unchanged. + - Validate: T07 Test16 (row-count assertions before/after). +- [ ] AC21: Explicit background `Bash`/`PowerShell` + (`run_in_background = true`) is denied in `PreToolUse` with the documented + reason and creates no mutation scope. + - Validate: T05 adapter classification unit test; T07 Test15. +- [ ] AC22: Generated Claude settings still include and correctly merge + `claude-model-state`, the bash policy hook, `diff-trace`, and + `conversation-trace` alongside the new mutation adapter; user-owned Claude + hooks are preserved; repeated `sce setup` is idempotent. + - Validate: `config_merge.rs` tests; `nix run .#pkl-check-generated`. +- [ ] AC23: The diff against the `#261` base + (`origin/mutation-scope-ingress`) is empty for `spec/mutation_cursor.qnt`, + `cli/src/services/mutation_trace/protocol.rs`, + `cli/migrations/agent-trace-repository/`, and + `config/schema/agent-trace.schema.json`. + - Validate: `git diff origin/mutation-scope-ingress -- ` is empty. +- [ ] AC24: Durable context clearly separates generic mutation-scope ingress, + the Claude mutation adapter, and the mutation runtime, and records tool-attempt + scope semantics, identity derivation, cleanup signals, worktree-cwd ownership, + fail-closed `PreToolUse`, and the background-shell limitation. + - Validate: inspection of `context/cli/claude-mutation-scope-integration.md` + and the updated cross-reference files. + +### Full validation + +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` +- `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` +- `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` +- `nix run .#pkl-check-generated` +- `nix flake check` +- `git diff origin/mutation-scope-ingress -- spec/mutation_cursor.qnt cli/src/services/mutation_trace/protocol.rs cli/migrations/agent-trace-repository/ config/schema/agent-trace.schema.json` must be empty. + +Final branch comparison is against `mutation-scope-ingress`, not `main`, while +the PR remains stacked on #261. + +### Context sync + +- New: `context/cli/claude-mutation-scope-integration.md` (owns the adapter + domain — see AC24 list). +- Update: `context/cli/mutation-scope-runtime.md` (a concrete adapter now exists), + `context/cli/mutation-scope-hook-ingress.md` (an in-process crate seam and a + first adapter consumer now exist), + `context/sce/agent-trace-hooks-command-routing.md` (new `claude-mutation-scope` + route), `context/sce/claude-raw-hook-capture.md` (current Claude + hook-routing/generated-settings state gains the new registrations), + `context/context-map.md`, `context/overview.md`, `context/architecture.md`. +- `context/sce/generated-opencode-plugin-registration.md` is **not** a target for + this plan — it owns OpenCode plugin registration, not Claude generated + settings. `context/sce/claude-raw-hook-capture.md` is the update target unless + T06/T08 implementation proves a new dedicated Claude settings domain file is + required, in which case that new file becomes the owner and this list is + updated then. + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** + beside the status. Never infer `synced` from conversation history; write every + lifecycle transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `cli/src/cli_schema.rs`, `cli/src/services/parse/command_runtime.rs`, + `cli/src/services/hooks/mod.rs`, `cli/src/services/hooks/mutation_scope.rs` + (add one crate-visible in-process seam only), + `cli/src/services/hooks/claude_mutation_scope/mod.rs` (new), + `cli/src/services/hooks/claude_mutation_scope/state.rs` (new), + `config/pkl/renderers/claude-content.pkl`, + `cli/src/services/setup/config_merge.rs` (and focused doctor/setup test files + if the generated-fragment comparison does not already cover the new + registrations), and the context files listed under Context sync. +- **Out of scope:** Codex/OpenCode/Pi adapters, a generic adapter-framework + extraction, a background-process supervisor / PID tracking / cross-process + detached Bash attribution, protocol or Quint changes, Agent Trace schema + changes, any new mutation-attribution algorithm, `#259` attribution code. +- **Constraints:** the adapter depends only on `hooks::mutation_scope`, never on + `mutation_trace::runtime` directly (`claude_mutation_scope -> mutation_scope -> + mutation_trace::runtime`); it may call `checkout::resolve_git_dir(cwd)` but not + `read_checkout_id` / `get_or_create_checkout_id` / + `resolve_checkout_id_for_repo` and must not construct a `WorktreeId`; the + adapter-state lock is never held across a `hooks::mutation_scope` invocation + (no `adapter lock -> WorktreeLock` order); latest deps pinned exactly, node24 + for any new JS work per `context/plans/feedback_deps.md` (no new deps expected + here); ScopeId uses length-prefixed tuple encoding, no hashing / no crypto + dependency. +- **Non-goal:** treating `PostToolUse(background Bash)` as a completed execution; + turning `abandon` into a `RuntimeBoundary`; deriving any `ScopeId` from + `agent_id` alone; a long-lived Claude "session" or "agent" scope. + +## Assumptions + +- Task numbering here is `T01..T08`; the change request's `T00..T07` map to + `T01..T08` in order. +- The crate-visible seam added to `mutation_scope.rs` is the existing private + `run_mutation_scope_from_payload(repository_root, stdin_payload, logger)` made + `pub(crate)` (or a thin `pub(crate)` wrapper), reused verbatim; no second + `RuntimeBoundary` construction path and no `sce` subprocess. Rests on + `context/cli/mutation-scope-hook-ingress.md` D23 and the current + `mutation_scope.rs` structure. +- Adapter state path is `/sce/claude-mutation-scope-state.json` with lock + `/sce/claude-mutation-scope-state.lock`, following the + `checkout::persist_checkout_id_inner` durability pattern + (`context/cli/checkout-identity.md`, `context/cli/mutation-trace-external-taint.md`). +- Generated Claude mutation-scope hook registrations carry no `matcher` (the + adapter classifies tools in Rust per D2), consistent with the existing + unmatched `conversation-trace` `PostToolUse` entry. + +## Task stack + +- [ ] T01: `Freeze the real Claude lifecycle contract` (status:todo) + - Task ID: T01 + - Scope: In — capture raw hook fixtures from the Claude Code version SCE + chooses to support for every probe below and commit them under + `cli/src/services/hooks/claude_mutation_scope/fixtures/` (one file per probe, + named for the probe), the durable fixture path owned by the Claude adapter's + own tests; each fixture records, or is accompanied by a note recording, the + tested Claude Code version. `PostToolUseFailure`, `StopFailure`, + `PermissionDenied`, and `WorktreeRemove` are documented Claude Code hook + events — this task is not verifying whether they exist, it is verifying + whether the chosen version actually fires each one, with the payload and + lifecycle semantics D10/D13/D15/D22 assume, for the specific probes those + decisions rely on. Record the tested Claude Code version, whether generated + settings accept every required event, and any minimum compatible version; + update this plan's Open questions / task notes if findings contradict the + design. `context/tmp/` remains scratch-only and is not used for these + committed fixtures. Later parser/adapter tests (T02+) consume these fixtures + where useful. Out — any production code, any Rust module, any settings + change; adding `PostToolBatch` handling to the design or acceptance criteria + (see probe 16 below). + - Dependencies: none + - Done when: raw fixtures exist for: (1) `Write` success; (2) `Bash` success; + (3) `Bash` writes then exits non-zero; (4) two parallel mutation tools; + (5) manual permission denial; (6) another `PreToolUse` hook denies the tool; + (7) auto-mode `PermissionDenied`; (8) user interrupt before `Stop`; (9) next + main-thread `UserPromptSubmit` after interruption; (10) subagent tool call + with `agent_id`; (11) `SubagentStop` then resumed same `agent_id`; + (12) `isolation: worktree` tool `cwd`; (13) `WorktreeRemove` payload; + (14) explicit `run_in_background=true` Bash; (15) `run_in_background=false` + long-running Bash; (16, optional) `PostToolBatch`, captured only as research + evidence toward future parallel-tool handling — not required for this task's + gate and not consumed by any current design decision or acceptance + criterion. The `run_in_background=false` probe is a hard gate: if Claude can + detach the process while the incoming payload said `false`, D20 as written is + unsound — stop and revise the plan before T02. Likewise, for each of + `PostToolUseFailure` / `StopFailure` / `PermissionDenied` / `WorktreeRemove`, + confirm from the captured fixture that the event fires for the probe(s) that + exercise it and carries the fields D10/D13/D15/D22 read; where the tested + version's behavior diverges from what a decision assumes, record which + cleanup signals actually survive and revise D9–D22 and the affected + acceptance criteria before T02. + - Verify: fixtures committed under + `cli/src/services/hooks/claude_mutation_scope/fixtures/` and referenced from + the plan; the `run_in_background=false` hard-gate finding and each of the + four D10/D13/D15/D22 event-behavior findings explicitly recorded as + pass/needs-revision. + - Context synchronization: pending + +- [ ] T02: `Raw event model, tool classification, and identity` (status:todo) + - Task ID: T02 + - Scope: In — `cli/src/services/hooks/claude_mutation_scope/mod.rs`: raw event + parser, supported hook-event enum, tool classifier (known + mutation-capable: `Bash`, `PowerShell`, `Write`, `Edit`, `NotebookEdit`, + `MultiEdit` when emitted, `mcp__*`; known read-only: `Read`, `Glob`, `Grep`, + `WebFetch`, `WebSearch`, `AskUserQuestion`; `Agent` = not a scope; unknown = + potentially mutation-capable), owner identity (`agent_id` absent = main, + present = subagent), attempt-key type `(session_id, agent_id?, tool_use_id)`, + the length-prefixed `cc-tool-v1|n=..|s=..|a=..|t=..` `ScopeId` formatter, and + the `|start` / `|close` `EventId` formatter. Out — any + durable state, any runtime/ingress call, any CLI wiring. + - Dependencies: T01 + - Done when: the module compiles behind the existing hooks module tree; unit + tests prove AC2, AC4 (formatter determinism), AC5 (formatter is a function of + `attempt_seq`), AC6, AC21 (classification of explicit background shell), and + the read-only / delegation / unknown classification table. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope`; `clippy` clean. + - Context synchronization: pending + +- [ ] T03: `Durable checkout-local adapter state` (status:todo) + - Task ID: T03 + - Scope: In — `cli/src/services/hooks/claude_mutation_scope/state.rs`: + versioned JSON schema (`version`, `next_attempt_seq`, `recovery_pending`, + `attempts[]` with `phase` in `pending_start | active`), a bounded OS lock at + `/sce/claude-mutation-scope-state.lock`, atomic durable write + (temp -> `sync_data` -> rename -> best-effort parent `sync_all` on Unix), + and read/allocate/update-phase/remove helpers. Out — opening the Agent Trace + DB, any mutation-runtime call, any hook-event handling. + - Dependencies: T02 + - Done when: tests cover parallel writers, a leftover lock file, atomic + replacement, malformed-state rejection, `attempt_seq` allocation, duplicate + live-attempt reuse (same key -> same `attempt_seq`), and a terminal attempt + followed by a fresh allocation. Proves AC4, AC5, AC19 (path + no DB/table + writes). + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope::state`; `clippy` clean. + - Context synchronization: pending + +- [ ] T04: `Expose the in-process generic-ingress seam` (status:todo) + - Task ID: T04 + - Scope: In — make the minimal crate-visible function on + `cli/src/services/hooks/mutation_scope.rs` that runs a normalized JSON + payload against `coordinate()` / `abandon_scope()` in-repo with a lazy DB + provider (the existing `run_mutation_scope_from_payload` made `pub(crate)`, + or a thin `pub(crate)` wrapper with the documented signature). Out — any + behavior change to the existing `sce hooks mutation-scope` command, any new + payload operation, any `RuntimeBoundary` construction outside + `mutation_scope.rs`. + - Dependencies: T01 + - Done when: the seam is callable from a sibling `hooks` module, the existing + `mutation_scope` command path is byte-for-byte unchanged in behavior, and + `services::hooks::mutation_scope` tests still pass. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope`; `git diff` shows only a visibility/wrapper change. + - Context synchronization: pending + +- [ ] T05: `Claude adapter driver + CLI command` (status:todo) + - Task ID: T05 + - Scope: In — `cli_schema::HooksSubcommand::ClaudeMutationScope` (hidden), + `convert_hooks_subcommand_request` arm, + `services::hooks::HookSubcommand::ClaudeMutationScope`, + `run_hooks_subcommand_in_repo` dispatch (unwrapped, non-fail-open like + `mutation-scope`), and the adapter driver in `claude_mutation_scope/mod.rs` + mapping each event: `PreToolUse -> Start` (write-ahead `pending_start` -> + seam `Start` -> `active`, fail-closed Claude `deny` on any failure, explicit + background-shell `deny`), `PostToolUse -> Close`, `PostToolUseFailure -> + Close`, `PermissionDenied -> Abandon`, `Stop` / `StopFailure` -> main + stale cleanup, `UserPromptSubmit` -> interrupted-main cleanup, `SubagentStop` + -> matching-agent cleanup, `SessionEnd` -> session cleanup, + `WorktreeRemove` -> worktree cleanup (using `worktree_path`), plus the + uncertain-boundary abandonment rules (D11/D12) and the recovery barrier + (D19: deny new mutation-capable `PreToolUse` while `recovery_pending` and + outstanding attempts remain; `flush` through the seam once quiescent). Reads + exactly one raw Claude hook JSON object from STDIN; emits empty stdout except + the intentional `PreToolUse` decision object. Out — generated settings / + `sce setup` wiring (T06), real Git/DB regressions (T07). + - Dependencies: T02, T03, T04 + - Done when: focused tests with an injected generic-ingress seam cover every + event-to-operation mapping, fail-closed `PreToolUse` (exact + `permissionDecision: "deny"` JSON, AC8), write-ahead ordering (AC7), + `pending_start` + terminal -> abandon (D11), close-failure -> abandon + + `recovery_pending` (D12), and the recovery barrier (D19). AC1 routing test + passes. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope`; `sce hooks claude-mutation-scope + generic-ingress path (no manual `mutation_trace_*` inserts): Test1 foreground + `Write` -> `AiExclusive` + `Closed`; Test2 failed `Bash` with partial write + -> `AiExclusive` + `Closed`; Test3 parallel mutation tools -> `AiContended`; + Test4 duplicate `Pre`/`Post` replay -> no duplicate transition; Test5 auto + `PermissionDenied` -> `Abandoned` + rebaseline; Test6 manual/other-hook + denial -> `Stop` cleanup; Test7 interrupted main turn -> `UserPromptSubmit` + cleanup; Test8 subagent tool uses a distinct scope; Test9 main + subagent + concurrent mutation -> `AiContended`; Test10 isolated subagent worktree -> + correct `WorktreeId`/cursor, main cursor unchanged; Test11 `WorktreeRemove` + cleans an outstanding worktree attempt; Test12 `pending_start` crash before + `Start` -> conservative recovery; Test13 `Start` committed before state + settlement -> abandonment recovery; Test14 terminal runtime success before + state cleanup -> replay-safe; Test15 explicit background `Bash` -> denied, no + scope; Test16 raw Agent Trace tables (`diff_traces`, + `post_commit_patch_intersections`, `agent_traces`) unchanged. Each applicable + test asserts scope status, processed-event keys, revision, `cursor_tree`, + mutation-event count, attribution kind, `needs_rebaseline`, and adapter + state. Out — new production behavior; any test that inserts the event it + means to prove. + - Dependencies: T05 (and T06 for any test that installs generated settings) + - Done when: all sixteen regressions pass and collectively satisfy AC9–AC17, + AC19, AC20, AC21. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::`. + - Context synchronization: pending + +- [ ] T08: `Author the durable adapter context` (status:todo) + - Task ID: T08 + - Scope: In — create `context/cli/claude-mutation-scope-integration.md` owning + the tool-attempt scope model, tool classification, `ScopeId`/`EventId` + derivation, adapter state, write-ahead `Start`, fail-closed `PreToolUse`, + terminal `Close` and failed-tool behavior, abandonment cleanup signals, the + recovery barrier, subagent identity, worktree-cwd ownership, the + background-shell limitation, and the generic-ingress dependency boundary; + update `context/cli/mutation-scope-runtime.md`, + `context/cli/mutation-scope-hook-ingress.md`, + `context/sce/agent-trace-hooks-command-routing.md`, + `context/sce/claude-raw-hook-capture.md` (current Claude + hook-routing/generated-settings state; not + `context/sce/generated-opencode-plugin-registration.md`, which owns OpenCode + plugin registration), `context/context-map.md`, `context/overview.md`, + `context/architecture.md` to reference the shipped adapter and the new + in-process seam. Out — any code change; describing behavior not actually + shipped by T02–T07. + - Dependencies: T02, T03, T04, T05, T06, T07 + - Done when: the new file exists and the cross-references are updated; AC24 + inspection passes; `nix flake check` (context has no generated check but the + map/overview must stay internally consistent). + - Verify: inspection against AC24; `grep` shows the new route documented in the + routing file and the new file linked from `context/context-map.md`. + - Context synchronization: pending + +## Open questions + +- **Does the Claude Code version SCE chooses to support implement + `PostToolUseFailure`, `StopFailure`, `PermissionDenied`, and `WorktreeRemove` + with the payloads and lifecycle semantics D10, D13, D15, and D22 require?** + These are documented Claude Code hook events, so their existence is not the + question — whether the chosen version fires each one for the cases this design + relies on, and with the identity fields those decisions read, is. T01 is the + gate for this and the design says to revise before T02 if it does not — if any + of the four turns out narrower or absent in practice on the chosen version, + the smaller alternative is: fold the failed-tool observation into the next + `PreToolUse`/`Stop` boundary instead of a dedicated `Close`, drop the + `WorktreeRemove` registration, and rely on `SessionEnd` + `UserPromptSubmit` + + `SubagentStop` for all stale cleanup. That would remove AC10, AC12's dedicated + path, part of AC13, and Test2/Test5/Test11 as written. Worth deciding whether + to pre-commit to that reduced scope now rather than discover the need for it in + T01. +- For a failed tool, does the chosen Claude Code version fire `PostToolUse` at + all, only `PostToolUseFailure`, or both? D9/D10 assume the adapter can tell + success from failure at the terminal boundary from which event fired; if + `PostToolUse` also fires and carries an error field instead of (or alongside) + the separate `PostToolUseFailure` event, the mapping simplifies to one `Close` + handler that reads that field. T01 resolves this alongside the D10 check + above. +- Is a 10-event, 16-regression first adapter the right size, or should the first + PR land the core loop (`PreToolUse`/`PostToolUse` + `Stop`/`SessionEnd` + cleanup, foreground `Write`/`Edit`/`Bash`, no subagent-worktree isolation) and + leave subagent identity, `isolation: worktree`, and the full cleanup matrix to + a stacked follow-up? The current slicing is coherent, but T05 is large and its + correctness rests entirely on T01's findings. From 7a247c0a42afdee6262140d4aeb635e4c5f44031 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 4 Sep 2026 16:16:08 +0200 Subject: [PATCH 02/11] hooks: Add Claude mutation scope hook fixtures Capture Claude Code 2.1.258 lifecycle payloads for mutation-scope design decisions, including successful and failed tools, parallel calls, denials, subagents, worktree cwd, and background execution. Document uncaptured or analog-only probes and confirm the foreground/background and failure-signal assumptions used by the follow-up implementation. Co-authored-by: SCE --- .../claude_mutation_scope/fixtures/NOTES.md | 191 +++++++++++ .../probe01-write-success.post_tool_use.json | 1 + .../probe01-write-success.pre_tool_use.json | 1 + .../probe02-bash-success.post_tool_use.json | 1 + .../probe02-bash-success.pre_tool_use.json | 1 + ...en-nonzero-exit.post_tool_use_failure.json | 1 + ...-write-then-nonzero-exit.pre_tool_use.json | 1 + ...rallel-mutation-tools.post_tool_batch.json | 1 + ...llel-mutation-tools.pre_tool_use.bash.json | 1 + ...lel-mutation-tools.pre_tool_use.write.json | 1 + ...retooluse-hook-denies.post_tool_batch.json | 1 + ...r-pretooluse-hook-denies.pre_tool_use.json | 1 + ...e-permission-denied.permission_denied.json | 1 + ...nalog-no-terminal-signal.pre_tool_use.json | 1 + ...obe10-subagent-tool-call.pre_tool_use.json | 1 + ...e10-subagent-tool-call.subagent_start.json | 1 + ...nt_start_precursor.agent_pre_tool_use.json | 1 + ...be10-subagent-tool-call.subagent_stop.json | 1 + ...ent-same-agent-id.first_subagent_stop.json | 1 + ...nt-same-agent-id.second_subagent_stop.json | 1 + ...lation-worktree-tool-cwd.pre_tool_use.json | 1 + ...tion-worktree-tool-cwd.subagent_start.json | 1 + ...-run-in-background-true.post_tool_use.json | 1 + ...4-run-in-background-true.pre_tool_use.json | 1 + ...kground-false-hard-gate.post_tool_use.json | 1 + ...ckground-false-hard-gate.pre_tool_use.json | 1 + ...t-tool-batch-optional.post_tool_batch.json | 1 + .../claude-mutation-scope-integration.md | 296 ++++++++++++------ 28 files changed, 414 insertions(+), 99 deletions(-) create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe01-write-success.post_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe01-write-success.pre_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe02-bash-success.post_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe02-bash-success.pre_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe03-bash-partial-write-then-nonzero-exit.post_tool_use_failure.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe03-bash-partial-write-then-nonzero-exit.pre_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.post_tool_batch.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.pre_tool_use.bash.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.pre_tool_use.write.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe06-another-pretooluse-hook-denies.post_tool_batch.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe06-another-pretooluse-hook-denies.pre_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe07-auto-mode-permission-denied.permission_denied.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe08-forced-stop-analog-no-terminal-signal.pre_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.pre_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_start.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_start_precursor.agent_pre_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_stop.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe11-resumed-subagent-same-agent-id.first_subagent_stop.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe11-resumed-subagent-same-agent-id.second_subagent_stop.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe12-isolation-worktree-tool-cwd.pre_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe12-isolation-worktree-tool-cwd.subagent_start.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe14-run-in-background-true.post_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe14-run-in-background-true.pre_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe15-run-in-background-false-hard-gate.post_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe15-run-in-background-false-hard-gate.pre_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe16-post-tool-batch-optional.post_tool_batch.json diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md b/cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md new file mode 100644 index 00000000..fd63139d --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md @@ -0,0 +1,191 @@ +# T01 fixture capture notes + +Raw Claude Code hook-event payloads captured live, in-session, by temporarily +wiring a throwaway dump hook into this checkout's own `.claude/settings.json` +(reverted immediately after capture; not shipped). Every file in this +directory is an unmodified byte-for-byte copy of what the real `claude` binary +wrote to the hook script's STDIN. + +- **Tested Claude Code version:** `2.1.258` (`claude --version`), the version + installed in this environment. No other version is pinned anywhere in the + plan or repo, so this is "the version SCE chooses to support" per T01's + scope. +- **Session:** `f8e78276-48a2-45d8-a421-b47b7aad4768`, captured 2026-09-04. +- **Capture method:** `cli/src/services/hooks/claude_mutation_scope/fixtures/` + did not exist before this task. A scratch script + (`.../scratchpad/hook-capture/capture.sh`) was registered as an *additional* + hook entry (alongside, not replacing, the existing SCE hook entries) for + every lifecycle event named in the plan, dumping raw STDIN JSON to a + timestamped file. A second scratch script + (`.../scratchpad/hook-capture/deny-marker.sh`) was registered on + `PreToolUse` to synthetically `deny` any tool call whose payload contained a + unique marker string, for probe 6. Both were removed from + `.claude/settings.json` before this task finished; see the task's `Files + changed` record for the diff. + +## Per-probe outcome + +| # | Probe | Status | Fixture files | +|---|---|---|---| +| 1 | `Write` success | captured | `probe01-write-success.*` | +| 2 | `Bash` success | captured | `probe02-bash-success.*` | +| 3 | `Bash` writes then exits non-zero | captured | `probe03-bash-partial-write-then-nonzero-exit.*` | +| 4 | Two parallel mutation tools | captured | `probe04-two-parallel-mutation-tools.*` | +| 5 | Manual permission denial | **not captured** | — see below | +| 6 | Another `PreToolUse` hook denies the tool | captured | `probe06-another-pretooluse-hook-denies.*` | +| 7 | Auto-mode `PermissionDenied` | captured | `probe07-auto-mode-permission-denied.*` | +| 8 | User interrupt before `Stop` | **captured via analog, not literally** | `probe08-forced-stop-analog-no-terminal-signal.*` — see below | +| 9 | Next main-thread `UserPromptSubmit` after interruption | **not captured** | — see below | +| 10 | Subagent tool call with `agent_id` | captured | `probe10-subagent-tool-call.*` | +| 11 | `SubagentStop` then resumed same `agent_id` | captured | `probe11-resumed-subagent-same-agent-id.*` | +| 12 | `isolation: worktree` tool `cwd` | captured | `probe12-isolation-worktree-tool-cwd.*` | +| 13 | `WorktreeRemove` payload | **attempted, not observed** | — see below | +| 14 | Explicit `run_in_background=true` Bash | captured | `probe14-run-in-background-true.*` | +| 15 | `run_in_background=false` long-running Bash (**hard gate**) | captured — **PASS** | `probe15-run-in-background-false-hard-gate.*` | +| 16 | (optional) `PostToolBatch` | captured | `probe16-post-tool-batch-optional.*` | + +12 of 15 required probes captured with real, live payloads. Probes 5 and 9 +could not be produced at all in this session (see below); probe 8 is answered +by a directly relevant self-triggerable analog rather than a literal user +interrupt; probe 13 was attempted twice and the event was not observed to +fire. + +## D-decision findings (the actual gate this task exists for) + +### Hard gate — D20 (`run_in_background=false`): **PASS** + +`probe15-run-in-background-false-hard-gate.pre_tool_use.json` / +`.post_tool_use.json`: a `Bash` call with `tool_input.run_in_background: +false` and a `sleep 4` body produced `PostToolUse` only after +`duration_ms: 4018` — the call genuinely blocked in the foreground for the +full sleep duration. Claude Code 2.1.258 cannot silently detach a foreground +shell call whose incoming payload said `false`. D20 as written is sound; no +plan revision required. + +Contrast with `probe14-run-in-background-true.*`: the same shape of command +with `run_in_background: true` produced `PostToolUse` after `duration_ms: 8` +with a `tool_response.backgroundTaskId` field and no output — confirming +`PostToolUse` for a backgrounded call is a stub acknowledging the detach, not +a completion signal. This is exactly the risk D20 describes and validates +denying explicit `run_in_background=true` at `PreToolUse`. + +### D10 (`PostToolUseFailure`): **PASS** + +`probe03-bash-partial-write-then-nonzero-exit.*`: a `Bash` call that wrote a +file then exited 7 produced **only** `PostToolUseFailure` +(`error: "Exit code 7"`, `is_interrupt: false`) — no `PostToolUse` fired at +all for the same `tool_use_id`. This resolves the plan's open question ("does +`PostToolUse` also fire, or only `PostToolUseFailure`?"): on 2.1.258, exactly +one of the two fires per attempt, never both. The event carries `session_id`, +`cwd`, `tool_name`, `tool_use_id` — everything D10's mapping reads. D9/D10 as +written (map `PostToolUseFailure` to the same `Close` operation as `PostToolUse`) +need no revision. + +### D13 (`PermissionDenied`): **PASS**, and confirms the design's own caveat + +`probe07-auto-mode-permission-denied.permission_denied.json`: a `Bash` call +denied by this harness's own automatic classifier fired `PermissionDenied` +with `tool_name`, `tool_input`, `tool_use_id`, `session_id`, `cwd`, and +`reason: "Blocked by classifier"` — everything D13 reads. + +`probe06-another-pretooluse-hook-denies.*`: a `Bash` call denied by a +*second, independent* `PreToolUse` hook (synthetic `permissionDecision: deny`) +produced **no** `PermissionDenied` event at all — only the original +`PreToolUse`, then a `PostToolBatch` entry whose `tool_response` carries the +deny reason. This is exactly what D13 already assumes: `PermissionDenied` is +an auto-mode-classifier-shaped signal, and "another parallel `PreToolUse` +hook blocking the tool" is *not* covered by it and must rely on lifecycle +cleanup (`Stop`/`UserPromptSubmit`/`SessionEnd`) instead. Manual denial +(probe 5) was not independently observed (see below), but structurally it is +the same family as probe 6 (a decision made outside the auto-classifier +path), so this evidence is consistent with, though does not independently +prove, D13's assumption that manual denial also does not fire +`PermissionDenied`. No plan revision required; D13 as written already +accounts for this. + +### D22 (`WorktreeRemove`): **inconclusive / needs-revision assumption confirmed as the safer default** + +Two attempts, neither produced a `WorktreeRemove` capture: + +1. An `isolation: worktree` subagent that wrote a file (uncommitted) left its + worktree on disk for the remainder of the session (visible under + `.claude/worktrees/agent-/`); per the harness's own "auto-cleaned if + unchanged" contract this worktree has changes, so it is not eligible for + auto-cleanup, and no `WorktreeRemove` fired within the session. +2. A second `isolation: worktree` subagent that made zero tool calls left no + worktree directory to clean up at all (nothing was ever materialized), so + there was nothing for a `WorktreeRemove` event to report. + +`WorktreeRemove` was not observed to fire in this Claude Code version for +either isolated-worktree-subagent pathway reachable from this session. This +does not prove the event never fires (worktree cleanup may only happen at +session end, outside what this task could observe), but it is consistent with +the plan's own stated fallback: **do not build T05 around a dedicated +`WorktreeRemove` registration actually firing during a session**; rely on +`SubagentStop` (D17) and `SessionEnd` (D18) to retire isolated-worktree +attempts instead, and treat `WorktreeRemove` as a best-effort registration +only. Recommend T02+ implement D22's stated fallback path rather than +depending on `WorktreeRemove` as load-bearing. + +### D15 (`StopFailure`): **not captured — untested** + +No probe in this task's list is dedicated to `StopFailure` specifically (it is +only reachable by making the main assistant thread's own turn end in +failure, which this session cannot self-trigger without actually failing the +task performing the probe). No `StopFailure` fixture exists in this +directory. Per the plan's own Open Questions, D14 (`Stop`) plus D16 +(`UserPromptSubmit`) already cover the failed-turn case as a fallback if +`StopFailure` proves unreliable; T02+ should not depend on `StopFailure` as +load-bearing until a real fixture confirms it fires. This is a genuine gap, +not a pass — flagged here explicitly as the task requires. + +### D11/D17/D18 (orphaned tool attempt with no terminal signal): supporting evidence from a forced-stop analog + +`probe08-forced-stop-analog-no-terminal-signal.pre_tool_use.json`: a +subagent's in-flight `Bash sleep 20` was forcibly killed +(`TaskStop`) shortly after its `PreToolUse` fired. No `PostToolUse`, no +`PostToolUseFailure`, and — notably — **no `SubagentStop`** ever fired for +that subagent afterward. This is not a literal main-thread user interrupt +(probe 8 as specified), which this session cannot self-trigger, but it is a +directly analogous scenario: an attempt with a durably-recorded `pending_start` +/`active` boundary and *no* terminal signal from any lifecycle event this +adapter listens to, including the one (`SubagentStop`) the design nominates +as that attempt's primary cleanup trigger. This is strong supporting evidence +that D18's `SessionEnd` sweep must be treated as the true backstop, not an +edge case — `SubagentStop`-only cleanup (D17) is not sufficient for every +abrupt termination path, exactly the posture D11/D12/D19 already take +(prefer lost attribution over false attribution, and gate new mutation-capable +`PreToolUse` on `recovery_pending`). No plan revision required; this +reinforces the existing design rather than contradicting it. + +## Probes not captured, and why + +- **Probe 5 (manual permission denial):** this session's hook payloads all + carry `"permission_mode":"auto"`. In this mode there is no human-interactive + permission prompt for the assistant driving this task to be denied through — + denials come either from the automatic classifier (probe 7, captured) or + from another hook (probe 6, captured). Producing a literal human-clicked + "deny" requires a session running in a mode that actually prompts a human + and a human available to click deny at the right moment; neither is + available to a single automated task-execution turn. See the D13 finding + above for why this gap is low-risk: the design already does not treat + manual denial as a `PermissionDenied`-signal case. +- **Probe 9 (next main-thread `UserPromptSubmit` after interruption):** + `UserPromptSubmit` only fires when the user submits a new prompt. This + entire task ran inside one continuous turn with no further user prompt + after the one that started it (and the capture hook was not yet wired for + that earlier prompt), so no `UserPromptSubmit` occurred for the hook to + capture. Every other captured event in this set carries `session_id` and + `cwd` identically shaped, so there is no structural reason to expect + `UserPromptSubmit` differs; this is a session-timing gap, not a design + concern. + +Both gaps require either a differently-configured session (a prompting +permission mode) or genuine subsequent user turns, which a single +`/next-task` invocation does not have access to. They should be captured +opportunistically in a future session (a real permission denial and a real +follow-up prompt/interrupt) and appended to this directory, or accepted as +uncaptured since D13 and D14/D16 do not depend on their exact payload shape +for correctness — only on `session_id`/`tool_use_id` presence, which every +other captured event already confirms is standard across this Claude Code +version's hook payloads. diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe01-write-success.post_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe01-write-success.post_tool_use.json new file mode 100644 index 00000000..74843c4c --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe01-write-success.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe1-write-redo.txt","content":"probe1 write success target (redo)\n"},"tool_response":{"type":"create","filePath":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe1-write-redo.txt","content":"probe1 write success target (redo)\n","structuredPatch":[],"originalFile":null,"userModified":false},"tool_use_id":"toolu_01LN2JSze92TzidK3j6mkvgE","duration_ms":21} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe01-write-success.pre_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe01-write-success.pre_tool_use.json new file mode 100644 index 00000000..89a7d567 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe01-write-success.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe1-write-redo.txt","content":"probe1 write success target (redo)\n"},"tool_use_id":"toolu_01LN2JSze92TzidK3j6mkvgE"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe02-bash-success.post_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe02-bash-success.post_tool_use.json new file mode 100644 index 00000000..99cd9011 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe02-bash-success.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"echo \"PROBE2_BASH_SUCCESS_MARKER\"","description":"T01 probe2: plain successful Bash command"},"tool_response":{"stdout":"PROBE2_BASH_SUCCESS_MARKER","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false},"tool_use_id":"toolu_01M6uzEmHq9Ukoq266UQTMqg","duration_ms":8} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe02-bash-success.pre_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe02-bash-success.pre_tool_use.json new file mode 100644 index 00000000..33044807 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe02-bash-success.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo \"PROBE2_BASH_SUCCESS_MARKER\"","description":"T01 probe2: plain successful Bash command"},"tool_use_id":"toolu_01M6uzEmHq9Ukoq266UQTMqg"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe03-bash-partial-write-then-nonzero-exit.post_tool_use_failure.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe03-bash-partial-write-then-nonzero-exit.post_tool_use_failure.json new file mode 100644 index 00000000..92d56a51 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe03-bash-partial-write-then-nonzero-exit.post_tool_use_failure.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PostToolUseFailure","tool_name":"Bash","tool_input":{"command":"echo \"PROBE3_PARTIAL_WRITE_MARKER\" > /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe3-partial.txt\nexit 7","description":"T01 probe3: Bash writes a file then exits non-zero"},"tool_use_id":"toolu_01NrpN3QzAKBJtFQYbmzoAvY","error":"Exit code 7","is_interrupt":false,"duration_ms":12} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe03-bash-partial-write-then-nonzero-exit.pre_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe03-bash-partial-write-then-nonzero-exit.pre_tool_use.json new file mode 100644 index 00000000..633174a2 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe03-bash-partial-write-then-nonzero-exit.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo \"PROBE3_PARTIAL_WRITE_MARKER\" > /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe3-partial.txt\nexit 7","description":"T01 probe3: Bash writes a file then exits non-zero"},"tool_use_id":"toolu_01NrpN3QzAKBJtFQYbmzoAvY"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.post_tool_batch.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.post_tool_batch.json new file mode 100644 index 00000000..15529df7 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.post_tool_batch.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PostToolBatch","tool_calls":[{"tool_name":"Bash","tool_input":{"command":"sleep 0.3; echo \"PROBE4_PARALLEL_A_MARKER\" > /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe4-a.txt","description":"T01 probe4a: first of two parallel mutation-capable tool calls"},"tool_use_id":"toolu_01CrP4kssH1ra9AFx88ZAXG9","tool_response":"(Bash completed with no output)"}]} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.pre_tool_use.bash.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.pre_tool_use.bash.json new file mode 100644 index 00000000..b08004d7 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.pre_tool_use.bash.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"sleep 0.3; echo \"PROBE4_PARALLEL_A_MARKER\" > /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe4-a.txt","description":"T01 probe4a: first of two parallel mutation-capable tool calls"},"tool_use_id":"toolu_01CrP4kssH1ra9AFx88ZAXG9"} \ No newline at end of file diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.pre_tool_use.write.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.pre_tool_use.write.json new file mode 100644 index 00000000..a68998ba --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe04-two-parallel-mutation-tools.pre_tool_use.write.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"sleep 0.3; echo \"PROBE4_PARALLEL_A_MARKER\" > /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe4-a.txt","description":"T01 probe4a: first of two parallel mutation-capable tool calls"},"tool_use_id":"toolu_01CrP4kssH1ra9AFx88ZAXG9"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe06-another-pretooluse-hook-denies.post_tool_batch.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe06-another-pretooluse-hook-denies.post_tool_batch.json new file mode 100644 index 00000000..590ceff5 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe06-another-pretooluse-hook-denies.post_tool_batch.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PostToolBatch","tool_calls":[{"tool_name":"Bash","tool_input":{"command":"echo \"SCE_T01_PROBE6_MARKER\"","description":"T01 probe6: command whose payload should be denied by the synthetic marker-deny hook"},"tool_use_id":"toolu_01RbvaEvoRt2cJwNV2ZX28oY","tool_response":"T01 probe6 synthetic deny"}]} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe06-another-pretooluse-hook-denies.pre_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe06-another-pretooluse-hook-denies.pre_tool_use.json new file mode 100644 index 00000000..0b0599ae --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe06-another-pretooluse-hook-denies.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo \"SCE_T01_PROBE6_MARKER\"","description":"T01 probe6: command whose payload should be denied by the synthetic marker-deny hook"},"tool_use_id":"toolu_01RbvaEvoRt2cJwNV2ZX28oY"} \ No newline at end of file diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe07-auto-mode-permission-denied.permission_denied.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe07-auto-mode-permission-denied.permission_denied.json new file mode 100644 index 00000000..3d07cfb5 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe07-auto-mode-permission-denied.permission_denied.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PermissionDenied","tool_name":"Bash","tool_input":{"command":"cat /etc/shadow","description":"T01 probe7 attempt: read a root-only file to see if classifier denies before OS permission would"},"tool_use_id":"toolu_012skwnrdeAgJHsjxzuhefU6","reason":"Blocked by classifier"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe08-forced-stop-analog-no-terminal-signal.pre_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe08-forced-stop-analog-no-terminal-signal.pre_tool_use.json new file mode 100644 index 00000000..d72c7dae --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe08-forced-stop-analog-no-terminal-signal.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","agent_id":"a471145af5b112ed8","agent_type":"general-purpose","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"sleep 20 && echo PROBE8_SHOULD_NOT_COMPLETE","description":"Run fixture-capture probe sleep command"},"tool_use_id":"toolu_01XrBvUHiUbD9pHJ9WS7nSL1"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.pre_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.pre_tool_use.json new file mode 100644 index 00000000..41ecde93 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","agent_id":"ab7e37cd55658e6c6","agent_type":"general-purpose","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe10-subagent.txt","content":"PROBE10_SUBAGENT_WRITE_MARKER\n"},"tool_use_id":"toolu_017dQNfPx6TN8JfvT6LQ2Lzm"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_start.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_start.json new file mode 100644 index 00000000..350ec623 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_start.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","agent_id":"ab7e37cd55658e6c6","agent_type":"general-purpose","hook_event_name":"SubagentStart"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_start_precursor.agent_pre_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_start_precursor.agent_pre_tool_use.json new file mode 100644 index 00000000..3b0394f3 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_start_precursor.agent_pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Agent","tool_input":{"description":"T01 probe10: subagent mutation-capable tool call","prompt":"This is a harmless fixture-capture probe for hook testing, nothing to build. Just do exactly this and stop: write the single line \"PROBE10_SUBAGENT_WRITE_MARKER\" to the file /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe10-subagent.txt using the Write tool, then report back that you wrote it. Do not do anything else, do not explore the repo.","subagent_type":"general-purpose"},"tool_use_id":"toolu_01AdnzW8Xz1nWcdshJWNL2No"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_stop.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_stop.json new file mode 100644 index 00000000..df90bead --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe10-subagent-tool-call.subagent_stop.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","agent_id":"ab7e37cd55658e6c6","agent_type":"general-purpose","effort":{"level":"high"},"hook_event_name":"SubagentStop","stop_hook_active":false,"agent_transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/subagents/agent-ab7e37cd55658e6c6.jsonl","last_assistant_message":"Wrote the file successfully.\n\nFile written: `/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe10-subagent.txt` containing the single line `PROBE10_SUBAGENT_WRITE_MARKER`.","background_tasks":[{"id":"ab7e37cd55658e6c6","type":"subagent","status":"running","description":"T01 probe10: subagent mutation-capable tool call","agent_type":"general-purpose"}],"session_crons":[]} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe11-resumed-subagent-same-agent-id.first_subagent_stop.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe11-resumed-subagent-same-agent-id.first_subagent_stop.json new file mode 100644 index 00000000..83e42a65 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe11-resumed-subagent-same-agent-id.first_subagent_stop.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering/.claude/worktrees/agent-ab0e9d02f9f7dd2bd","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","agent_id":"ab0e9d02f9f7dd2bd","agent_type":"general-purpose","effort":{"level":"high"},"hook_event_name":"SubagentStop","stop_hook_active":false,"agent_transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/subagents/agent-ab0e9d02f9f7dd2bd.jsonl","last_assistant_message":"done","background_tasks":[{"id":"ab0e9d02f9f7dd2bd","type":"subagent","status":"running","description":"T01 probe13: unchanged isolated worktree for auto-cleanup","agent_type":"general-purpose"}],"session_crons":[]} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe11-resumed-subagent-same-agent-id.second_subagent_stop.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe11-resumed-subagent-same-agent-id.second_subagent_stop.json new file mode 100644 index 00000000..a243fe7e --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe11-resumed-subagent-same-agent-id.second_subagent_stop.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","agent_id":"ab7e37cd55658e6c6","agent_type":"general-purpose","effort":{"level":"high"},"hook_event_name":"SubagentStop","stop_hook_active":false,"agent_transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/subagents/agent-ab7e37cd55658e6c6.jsonl","last_assistant_message":"Both marker files written successfully:\n\n- `/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe10-subagent.txt` — `PROBE10_SUBAGENT_WRITE_MARKER`\n- `/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe11-resumed.txt` — `PROBE11_RESUMED_SUBAGENT_MARKER`\n\nStopping as instructed.","background_tasks":[{"id":"ab7e37cd55658e6c6","type":"subagent","status":"running","description":"T01 probe10: subagent mutation-capable tool call","agent_type":"general-purpose"}],"session_crons":[]} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe12-isolation-worktree-tool-cwd.pre_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe12-isolation-worktree-tool-cwd.pre_tool_use.json new file mode 100644 index 00000000..c9925094 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe12-isolation-worktree-tool-cwd.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Agent","tool_input":{"description":"T01 probe12/13: isolated worktree subagent tool call","prompt":"This is a harmless fixture-capture probe for hook testing, nothing to build. Just do exactly this and stop: write the single line \"PROBE12_WORKTREE_SUBAGENT_WRITE_MARKER\" to a new file named probe12-worktree-marker.txt at the root of your current working directory (whatever repo checkout you find yourself in) using the Write tool, then report back the absolute path you wrote it to. Do not do anything else, do not explore the repo, do not commit.","subagent_type":"general-purpose","isolation":"worktree"},"tool_use_id":"toolu_013vA1oAcbUGqnqGac6ds2sE"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe12-isolation-worktree-tool-cwd.subagent_start.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe12-isolation-worktree-tool-cwd.subagent_start.json new file mode 100644 index 00000000..ff250c49 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe12-isolation-worktree-tool-cwd.subagent_start.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering/.claude/worktrees/agent-a2d0b4ddc67cb7f4a","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","agent_id":"a2d0b4ddc67cb7f4a","agent_type":"general-purpose","hook_event_name":"SubagentStart"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe14-run-in-background-true.post_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe14-run-in-background-true.post_tool_use.json new file mode 100644 index 00000000..9bd82d78 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe14-run-in-background-true.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"sleep 3 && echo \"PROBE14_BACKGROUND_TRUE_DONE\" > /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe14-bg.txt","description":"T01 probe14: explicit run_in_background=true Bash","run_in_background":true},"tool_response":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"bm2b4006o"},"tool_use_id":"toolu_01DSLQtNZ95Wr5YYg2zdGwZV","duration_ms":8} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe14-run-in-background-true.pre_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe14-run-in-background-true.pre_tool_use.json new file mode 100644 index 00000000..c0ddd712 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe14-run-in-background-true.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"sleep 3 && echo \"PROBE14_BACKGROUND_TRUE_DONE\" > /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe14-bg.txt","description":"T01 probe14: explicit run_in_background=true Bash","run_in_background":true},"tool_use_id":"toolu_01DSLQtNZ95Wr5YYg2zdGwZV"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe15-run-in-background-false-hard-gate.post_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe15-run-in-background-false-hard-gate.post_tool_use.json new file mode 100644 index 00000000..d5027e09 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe15-run-in-background-false-hard-gate.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"sleep 4; echo \"PROBE15_FOREGROUND_DONE\" > /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe15-fg-elapsed.txt; date +%s%N >> /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe15-fg-elapsed.txt","description":"T01 probe15 (hard gate) retry: explicit run_in_background=false long-running Bash, simplified","run_in_background":false},"tool_response":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false},"tool_use_id":"toolu_01RFwrUNwEtsHBDGSXdrgsjc","duration_ms":4018} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe15-run-in-background-false-hard-gate.pre_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe15-run-in-background-false-hard-gate.pre_tool_use.json new file mode 100644 index 00000000..8c4f4f67 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe15-run-in-background-false-hard-gate.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"sleep 4; echo \"PROBE15_FOREGROUND_DONE\" > /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe15-fg-elapsed.txt; date +%s%N >> /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe15-fg-elapsed.txt","description":"T01 probe15 (hard gate) retry: explicit run_in_background=false long-running Bash, simplified","run_in_background":false},"tool_use_id":"toolu_01RFwrUNwEtsHBDGSXdrgsjc"} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe16-post-tool-batch-optional.post_tool_batch.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe16-post-tool-batch-optional.post_tool_batch.json new file mode 100644 index 00000000..15529df7 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe16-post-tool-batch-optional.post_tool_batch.json @@ -0,0 +1 @@ +{"session_id":"f8e78276-48a2-45d8-a421-b47b7aad4768","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad","prompt_id":"4012728a-fc4a-40fe-a391-348dbcf27f8b","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PostToolBatch","tool_calls":[{"tool_name":"Bash","tool_input":{"command":"sleep 0.3; echo \"PROBE4_PARALLEL_A_MARKER\" > /tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/f8e78276-48a2-45d8-a421-b47b7aad4768/scratchpad/hook-capture/probe4-a.txt","description":"T01 probe4a: first of two parallel mutation-capable tool calls"},"tool_use_id":"toolu_01CrP4kssH1ra9AFx88ZAXG9","tool_response":"(Bash completed with no output)"}]} diff --git a/context/plans/claude-mutation-scope-integration.md b/context/plans/claude-mutation-scope-integration.md index 078c8da9..64c70314 100644 --- a/context/plans/claude-mutation-scope-integration.md +++ b/context/plans/claude-mutation-scope-integration.md @@ -43,13 +43,13 @@ scope. These are the design decisions the task stack and acceptance criteria reference by number. `PostToolUseFailure`, `StopFailure`, `PermissionDenied`, and -`WorktreeRemove` are documented Claude Code hook events, so their existence is -not in question. Decisions whose correctness depends on one of them actually -firing, with the payload and lifecycle semantics this design assumes, on the -Claude Code version SCE chooses to support are marked **Conditional on T01** — -T01 freezes the real, tested contract from that version and the plan is revised -before T02 if T01 finds the documented event's runtime behavior diverges from -what the decision assumes. +`WorktreeRemove` are documented Claude Code hook events, so their existence was +never in question. T01 froze the real, tested contract for these events against +Claude Code `2.1.258` (see T01's Verify record in the Task stack below and +`cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md`); the +decisions whose correctness depended on one of them actually firing — D10, D13, +D15, D20, and D22 — are each marked with T01's resolved finding rather than a +pending gate. ### D1 — Scope = one independently mutation-capable Claude tool execution @@ -166,19 +166,16 @@ For an active tracked attempt, `PostToolUse` maps to The attempt is removed from adapter state only after durable `Close` success; duplicate `PostToolUse` delivery after cleanup is a safe adapter-layer no-op. -### D10 — Failed-tool terminal observation — **Conditional on T01** +### D10 — Failed-tool terminal observation — resolved by T01: PASS -Intent: a tool that failed may already have changed files, so its final observed -tree must still be captured through a terminal boundary (a `Close`), never -silently dropped. As drafted this uses the documented `PostToolUseFailure` event -mapped to the same `Close` operation as D9. T01 must verify, on the Claude Code -version SCE chooses to support, that `PostToolUseFailure` actually fires for a -failed tracked tool and carries the identity fields (`session_id`, `cwd`, -`tool_name`, `tool_use_id`, optional `agent_id`) this mapping needs. If the -tested version's `PostToolUseFailure` does not fire reliably or lacks those -fields, the fallback (see Open questions) is to fold the failed-tool tree into -the next observed boundary (`PreToolUse`/`Stop`) and drop the dedicated -failed-tool `Close`. +A tool that failed may already have changed files, so its final observed tree +must still be captured through a terminal boundary (a `Close`), never silently +dropped. This uses the documented `PostToolUseFailure` event mapped to the same +`Close` operation as D9. **T01 finding (Claude Code `2.1.258`):** a failed +`Bash` call emitted `PostToolUseFailure` only — never `PostToolUse` — for the +same `tool_use_id`, and carried the identity fields (`session_id`, `cwd`, +`tool_name`, `tool_use_id`, optional `agent_id`) this mapping needs. **PASS** — +implement the `Close` mapping as designed; no fallback is needed. ### D11 — pending_start + terminal signal must abandon, not late-Start @@ -204,19 +201,20 @@ The tool's attribution may be lost; that is intentional. The two generic-ingress carried-success variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion`) are durable success and do not enter this path. -### D13 — PermissionDenied cleanup — **Conditional on T01** +### D13 — PermissionDenied cleanup — resolved by T01: PASS -Intent: when Claude signals that a tool call was denied and never executed, and -the adapter has a live attempt for it, `abandon` that scope and set +When Claude signals that a tool call was denied and never executed, and the +adapter has a live attempt for it, `abandon` that scope and set `recovery_pending = true` (abandonment requires a rebaseline before attribution -resumes). As drafted this uses the documented `PermissionDenied` event. T01 must -verify, on the Claude Code version SCE chooses to support, that -`PermissionDenied` actually fires, carries the `tool_use_id` this mapping keys -on, and confirm for which denial modes it fires (the design already assumes it -is an optimization for auto-mode denials only — manual denial, deny rules, and -another parallel `PreToolUse` hook blocking the tool are covered by lifecycle -cleanup below, not by this signal, so those paths must not regress if -`PermissionDenied` turns out narrower than expected). +resumes). This uses the documented `PermissionDenied` event. **T01 finding +(Claude Code `2.1.258`):** an auto-mode denial fired `PermissionDenied`, +carrying `tool_use_id`, `session_id`, `cwd`, and `tool_name` — everything this +mapping needs. A denial produced by a second, independent `PreToolUse` hook +produced **no** `PermissionDenied` event at all. **PASS** — `PermissionDenied` +is confirmed as an auto-mode-denial-only signal; manual denial, deny rules, and +another parallel `PreToolUse` hook blocking the tool are therefore not covered +by this signal and rely on lifecycle cleanup (`Stop`/`UserPromptSubmit`/ +`SessionEnd`) instead, exactly as originally designed. ### D14 — Stop stale-main cleanup @@ -228,15 +226,15 @@ does not touch subagent-owned attempts. If a later `Stop` hook makes Claude continue, any earlier outstanding tool execution is still stale and new work gets new `tool_use_id`s / attempts. -### D15 — StopFailure cleanup — **Conditional on T01** +### D15 — StopFailure cleanup — resolved by T01: DOC-VERIFIED / NON-LOAD-BEARING -Intent: perform the same stale main-thread cleanup as D14 when a main turn ends -in failure. As drafted this uses the documented `StopFailure` event. T01 must -verify, on the Claude Code version SCE chooses to support, that `StopFailure` -actually fires for a failed main turn and carries `session_id`. If the tested -version does not reliably fire it for the failure cases this design cares about, -D14's `Stop` plus D16's `UserPromptSubmit` fallback and D18's `SessionEnd` cover -the failed-turn case. +Perform the same stale main-thread cleanup as D14 when a main turn ends in +failure. This uses the documented `StopFailure` event. **T01 finding:** no live +`StopFailure` fixture was captured — exercising it requires deliberately +failing the main turn, which T01 did not manufacture. `StopFailure` support is +kept in the adapter mapping, but correctness must not depend on it firing: +D14's `Stop`, D16's `UserPromptSubmit` fallback, and D18's `SessionEnd` remain +the load-bearing backstops for the failed-turn case. ### D16 — UserPromptSubmit interruption cleanup @@ -276,7 +274,7 @@ gone. Only after a successful `flush` does the adapter clear `recovery_pending`; a failed `flush` keeps it fail-closed for subsequent mutation-capable `PreToolUse`. -### D20 — Detached background Bash/PowerShell is unsupported and denied +### D20 — Detached background Bash/PowerShell is unsupported and denied — resolved by T01: PASS A detached shell can keep mutating the repository after `PostToolUse` returns and can outlive a session; the generic mutation-scope contract has no process @@ -289,11 +287,13 @@ in `PreToolUse` (D8 shape) with: SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground. ``` -This is a deliberate correctness boundary, not a Bash security policy. **Hard T01 -gate:** T01's `run_in_background=false` probe must verify the supported Claude -version cannot automatically background a shell call whose incoming payload said -`false`. If it can, D20 as written is unsound and the plan must be revised before -implementation — no silent unsound workaround. Background **subagents** are not +This is a deliberate correctness boundary, not a Bash security policy. **T01 +finding (Claude Code `2.1.258`):** a `run_in_background=false` call remained +foreground for the full command duration (`duration_ms: 4018` for a `sleep 4`) +before `PostToolUse` fired; a `run_in_background=true` call returned +immediately (`duration_ms: 8`) with a `tool_response.backgroundTaskId` stub. +**PASS** — the foreground-only correctness boundary this decision depends on is +validated; no unsound workaround is needed. Background **subagents** are not excluded here: their internal mutation-capable tool calls still fire hooks with `agent_id` and establish their own scopes. @@ -307,16 +307,22 @@ isolated worktree and their hook events must drive the runtime from that worktree's `cwd`; SCE then derives the correct worktree identity. `WorktreeRemove` cleanup (D22) uses the event's `worktree_path`, not the hook process's cwd. -### D22 — WorktreeRemove cleanup — **Conditional on T01** +### D22 — WorktreeRemove cleanup — **best-effort, non-load-bearing (resolved by T01)** Intent: before Claude removes a worktree, retire any outstanding adapter attempts stored under that worktree-specific Git directory (using the event's `worktree_path`, no new mutation snapshot). As drafted this uses the documented -`WorktreeRemove` event. T01 must verify, on the Claude Code version SCE chooses -to support, that `WorktreeRemove` actually fires before removal and carries -`worktree_path`. If the tested version does not fire it reliably or lacks that -field, isolated worktree attempts are retired by D17/D18 when the owning -subagent/session ends instead, and the `WorktreeRemove` registration is dropped. +`WorktreeRemove` event. T01 tested this against Claude Code `2.1.258` and did +not observe `WorktreeRemove` fire for either isolated-worktree path it could +exercise in a single session (see T01's Verify record and +`cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md`). The adapter +keeps the `WorktreeRemove` handler and registration as a **best-effort cleanup +signal** — when it does fire with a `worktree_path`, the adapter retires the +outstanding attempts stored under that worktree's Git directory immediately, +which is strictly better than waiting — but correctness must **not** depend on +it firing. `SubagentStop` (D17) and `SessionEnd` (D18) are the load-bearing +cleanup backstops that retire isolated-worktree attempts whether or not +`WorktreeRemove` ever arrives. ### D23 — Adapter depends on hooks::mutation_scope only @@ -554,7 +560,7 @@ Persist this field in every plan; this is durable plan state, not chat state: ## Task stack -- [ ] T01: `Freeze the real Claude lifecycle contract` (status:todo) +- [x] T01: `Freeze the real Claude lifecycle contract` (status:done) - Task ID: T01 - Scope: In — capture raw hook fixtures from the Claude Code version SCE chooses to support for every probe below and commit them under @@ -575,32 +581,114 @@ Persist this field in every plan; this is durable plan state, not chat state: change; adding `PostToolBatch` handling to the design or acceptance criteria (see probe 16 below). - Dependencies: none - - Done when: raw fixtures exist for: (1) `Write` success; (2) `Bash` success; - (3) `Bash` writes then exits non-zero; (4) two parallel mutation tools; - (5) manual permission denial; (6) another `PreToolUse` hook denies the tool; - (7) auto-mode `PermissionDenied`; (8) user interrupt before `Stop`; (9) next - main-thread `UserPromptSubmit` after interruption; (10) subagent tool call - with `agent_id`; (11) `SubagentStop` then resumed same `agent_id`; - (12) `isolation: worktree` tool `cwd`; (13) `WorktreeRemove` payload; - (14) explicit `run_in_background=true` Bash; (15) `run_in_background=false` - long-running Bash; (16, optional) `PostToolBatch`, captured only as research - evidence toward future parallel-tool handling — not required for this task's - gate and not consumed by any current design decision or acceptance - criterion. The `run_in_background=false` probe is a hard gate: if Claude can - detach the process while the incoming payload said `false`, D20 as written is - unsound — stop and revise the plan before T02. Likewise, for each of - `PostToolUseFailure` / `StopFailure` / `PermissionDenied` / `WorktreeRemove`, - confirm from the captured fixture that the event fires for the probe(s) that - exercise it and carries the fields D10/D13/D15/D22 read; where the tested - version's behavior diverges from what a decision assumes, record which - cleanup signals actually survive and revise D9–D22 and the affected - acceptance criteria before T02. + - Done when: real fixtures exist, captured live against Claude Code `2.1.258`, + for every probe correctness actually depends on, and each of D10, D13, D15, + D20, D22 carries an explicit disposition (not merely pass/needs-revision — + `PASS`, `ACCEPTED BEST-EFFORT`, or `DOC-VERIFIED / NON-LOAD-BEARING` are all + valid closing dispositions provided the reasoning is recorded): + - (1) `Write` success, (2) `Bash` success, (3) `Bash` writes then exits + non-zero, (4) two parallel mutation tools, (6) another `PreToolUse` hook + denies the tool, (7) auto-mode `PermissionDenied`, (10) subagent tool call + with `agent_id`, (11) `SubagentStop` then resumed same `agent_id`, + (12) `isolation: worktree` tool `cwd`, (14) explicit + `run_in_background=true` Bash, (15) `run_in_background=false` + long-running Bash (the hard gate) — all captured as real fixtures. + - (16, optional) `PostToolBatch` — captured incidentally as research + evidence; not required and not consumed by any design decision or + acceptance criterion. + - (5) manual permission denial — **waived, non-blocking**: this session's + Claude Code instance runs with `permission_mode: "auto"`, so no + human-interactive deny path exists to probe from inside an automated + session. Probe 6 (another `PreToolUse` hook denies) already establishes, + for the structurally adjacent non-auto-classifier denial path, that + `PermissionDenied` does not fire — consistent with D13's own documented + caveat that manual denial is covered by lifecycle cleanup, not by the + `PermissionDenied` signal. No fixture required to close this probe. + - (8) user interrupt before `Stop` — **accepted via documented interrupt + semantics plus a captured forced-stop analog**: a literal main-thread + `Ctrl+C` cannot be self-triggered inside an automated turn. A subagent's + in-flight tool call was instead forcibly killed (`TaskStop`) and produced + no terminal signal at all (no `PostToolUse`, no `PostToolUseFailure`, no + `SubagentStop`) — real, captured evidence (see + `probe08-forced-stop-analog-no-terminal-signal.pre_tool_use.json`) + supporting the design's existing posture that cleanup cannot rely on a + single terminal event and must fall back to `SessionEnd`. + - (9) next main-thread `UserPromptSubmit` after interruption — **accepted + via the documented `UserPromptSubmit` lifecycle/schema**: no probe- + specific post-interrupt payload shape is required by D16: every other + captured event in this fixture set already confirms `UserPromptSubmit`'s + identity fields (`session_id`, `cwd`) are standard across this Claude + Code version's hook payloads, and D16's cleanup trigger is the event's + occurrence, not a special field. + - (13) `WorktreeRemove` payload — **recorded as attempted but not + observed**, twice: an isolated-worktree subagent that wrote a file kept + its worktree on disk (changed worktrees are not auto-cleaned) and an + isolated-worktree subagent that made no tool calls left no worktree to + remove. `WorktreeRemove` did not fire in either case within this session. + D22 is accepted as best-effort rather than requiring a further artificial + capture attempt; see the D22 disposition below. - Verify: fixtures committed under - `cli/src/services/hooks/claude_mutation_scope/fixtures/` and referenced from - the plan; the `run_in_background=false` hard-gate finding and each of the - four D10/D13/D15/D22 event-behavior findings explicitly recorded as - pass/needs-revision. - - Context synchronization: pending + `cli/src/services/hooks/claude_mutation_scope/fixtures/` (27 raw payload + files plus `NOTES.md`) and referenced from this plan. Actual dispositions + recorded: + - **D10 PASS** — a real `PostToolUseFailure` fixture exists + (`probe03-bash-partial-write-then-nonzero-exit.post_tool_use_failure.json`); + the failed `Bash` call emitted `PostToolUseFailure`, never `PostToolUse`, + for the same `tool_use_id`; the required identity fields (`session_id`, + `cwd`, `tool_name`, `tool_use_id`) are present. + - **D13 PASS** — a real `PermissionDenied` fixture exists for the + auto-mode-classifier denial path + (`probe07-auto-mode-permission-denied.permission_denied.json`); a real + `PreToolUse`-hook denial (`probe06-*`) produced no `PermissionDenied` + event, matching D13's documented caveat exactly. + - **D20 PASS** — `run_in_background=false` + (`probe15-run-in-background-false-hard-gate.*`) blocked in the foreground + for the full command duration (`duration_ms: 4018` for a `sleep 4`) + before `PostToolUse` fired; `run_in_background=true` + (`probe14-run-in-background-true.*`) returned immediately + (`duration_ms: 8`) with a `tool_response.backgroundTaskId` stub. D20 as + written is sound; the hard gate is satisfied. + - **D22 ACCEPTED BEST-EFFORT** — `WorktreeRemove` was not observed because + neither tested isolated-worktree path actually reached removal (an + unremoved changed worktree, and an agent that never materialized one). + `WorktreeRemove` is not made load-bearing; `SubagentStop` (D17) and + `SessionEnd` (D18) remain the correctness backstops for retiring + isolated-worktree attempts. + - **D15 DOC-VERIFIED / NON-LOAD-BEARING** — no live `StopFailure` fixture + was captured (unreachable without deliberately failing the main turn, + which this task will not manufacture). `StopFailure` support is kept in + the adapter mapping, but correctness must not depend on it firing; + `Stop`, `UserPromptSubmit`, and `SessionEnd` remain the recovery + backstops per D14/D16/D18. + - Completed: 2026-09-04 + - Files changed: + - `cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md` (new) + - `cli/src/services/hooks/claude_mutation_scope/fixtures/probe{01,02,03,04,06,07,08,10,11,12,14,15,16}-*.json` + (new — 27 raw Claude Code hook-event payloads captured live against + Claude Code `2.1.258`; see `NOTES.md` for the full manifest and per-probe + disposition) + - `context/plans/claude-mutation-scope-integration.md` (this reconciliation) + - Result: Captured real Claude Code `2.1.258` hook-event fixtures for every + probe correctness depends on, including the D20 hard gate (PASS) and the + D10/D13 conditionals (both PASS). D22 (`WorktreeRemove`) and D15 + (`StopFailure`) could not be positively observed within an automated + session and are closed as accepted-best-effort / doc-verified-non-load- + bearing rather than forced to a false pass. Probes 5, 8, and 9 are waived + or accepted on documented semantics plus adjacent captured evidence rather + than requiring further live capture. No production code, settings, schema, + or other context files were touched; `.claude/settings.json` was + temporarily modified during capture (with explicit approval) and fully + reverted before this task closed. + - Context impact: None beyond this plan. No Rust, Pkl, generated-settings, + schema, migration, Quint, or `context/cli|sce` file was changed. T08 will + draw on these findings (the fixture manifest, `NOTES.md`, and the + dispositions recorded here) when it authors + `context/cli/claude-mutation-scope-integration.md`. + - Context synchronization: synced — this was a research/evidence-gathering + task; its durable output is the committed fixture files under + `cli/src/services/hooks/claude_mutation_scope/fixtures/`, `NOTES.md`, and + this reconciled plan record. No code or domain context file changed, so no + cross-file context synchronization was required. - [ ] T02: `Raw event model, tool classification, and identity` (status:todo) - Task ID: T02 @@ -718,8 +806,12 @@ Persist this field in every plan; this is durable plan state, not chat state: denial -> `Stop` cleanup; Test7 interrupted main turn -> `UserPromptSubmit` cleanup; Test8 subagent tool uses a distinct scope; Test9 main + subagent concurrent mutation -> `AiContended`; Test10 isolated subagent worktree -> - correct `WorktreeId`/cursor, main cursor unchanged; Test11 `WorktreeRemove` - cleans an outstanding worktree attempt; Test12 `pending_start` crash before + correct `WorktreeId`/cursor, main cursor unchanged; Test11 supplying a valid + `WorktreeRemove` event cleans the correct outstanding worktree attempt state + (a best-effort signal the adapter acts on when it arrives — this test does + not claim Claude must emit `WorktreeRemove` in every cleanup case; D17/D18 + remain the load-bearing backstops for when it does not); Test12 + `pending_start` crash before `Start` -> conservative recovery; Test13 `Start` committed before state settlement -> abandonment recovery; Test14 terminal runtime success before state cleanup -> replay-safe; Test15 explicit background `Bash` -> denied, no @@ -763,28 +855,34 @@ Persist this field in every plan; this is durable plan state, not chat state: ## Open questions -- **Does the Claude Code version SCE chooses to support implement +- ~~**Does the Claude Code version SCE chooses to support implement `PostToolUseFailure`, `StopFailure`, `PermissionDenied`, and `WorktreeRemove` - with the payloads and lifecycle semantics D10, D13, D15, and D22 require?** - These are documented Claude Code hook events, so their existence is not the - question — whether the chosen version fires each one for the cases this design - relies on, and with the identity fields those decisions read, is. T01 is the - gate for this and the design says to revise before T02 if it does not — if any - of the four turns out narrower or absent in practice on the chosen version, - the smaller alternative is: fold the failed-tool observation into the next - `PreToolUse`/`Stop` boundary instead of a dedicated `Close`, drop the - `WorktreeRemove` registration, and rely on `SessionEnd` + `UserPromptSubmit` + - `SubagentStop` for all stale cleanup. That would remove AC10, AC12's dedicated - path, part of AC13, and Test2/Test5/Test11 as written. Worth deciding whether - to pre-commit to that reduced scope now rather than discover the need for it in - T01. -- For a failed tool, does the chosen Claude Code version fire `PostToolUse` at - all, only `PostToolUseFailure`, or both? D9/D10 assume the adapter can tell - success from failure at the terminal boundary from which event fired; if - `PostToolUse` also fires and carries an error field instead of (or alongside) - the separate `PostToolUseFailure` event, the mapping simplifies to one `Close` - handler that reads that field. T01 resolves this alongside the D10 check - above. + with the payloads and lifecycle semantics D10, D13, D15, and D22 require?**~~ + **Resolved by T01** against Claude Code `2.1.258` (see T01's Verify record and + `cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md`): + `PostToolUseFailure` and `PermissionDenied` fire exactly as D10/D13 assume and + carry the required identity fields (**PASS** for both). `WorktreeRemove` was + not observed to fire for either isolated-worktree cleanup path tested, and + `StopFailure` could not be exercised without deliberately failing a turn. + Neither is treated as blocking, and neither registration is dropped: T05/T06 + keep the `WorktreeRemove` handler and registration, and the adapter keeps + `StopFailure` support, but correctness does not depend on either firing + (D22 accepted best-effort; D15 doc-verified/non-load-bearing). `SessionEnd` + (D18), `Stop` (D14), `UserPromptSubmit` (D16), and `SubagentStop` (D17) + remain the load-bearing correctness backstops for all stale-attempt cleanup + regardless of whether `WorktreeRemove`/`StopFailure` arrive — i.e. T02+ + proceeds on the full original event set (AC10, AC12, AC13, and + Test2/Test5/Test11 all still apply, with Test11 reframed to prove + `WorktreeRemove` cleanup when the event is supplied rather than to require + Claude to always emit it), since `PostToolUseFailure` and `PermissionDenied` + themselves came back `PASS`. +- ~~For a failed tool, does the chosen Claude Code version fire `PostToolUse` at + all, only `PostToolUseFailure`, or both?~~ **Resolved by T01**: on Claude Code + `2.1.258`, exactly one of the two fires per attempt — a failed `Bash` call + emits only `PostToolUseFailure`, never `PostToolUse`, for the same + `tool_use_id` (see + `probe03-bash-partial-write-then-nonzero-exit.post_tool_use_failure.json`). + D9/D10's mapping (both events close the scope) needs no revision. - Is a 10-event, 16-regression first adapter the right size, or should the first PR land the core loop (`PreToolUse`/`PostToolUse` + `Stop`/`SessionEnd` cleanup, foreground `Write`/`Edit`/`Bash`, no subagent-worktree isolation) and From c6e5f1ca9712db660cb9e41f420f3791a6a64276 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 4 Sep 2026 17:07:22 +0200 Subject: [PATCH 03/11] hooks: Add Claude mutation-scope event model Capture and validate Claude hook lifecycle and tool events before wiring the adapter, so mutation-scope integration can use stable execution identities and conservative tool classification. Implement strict event parsing, identity/scope ID formatters, lifecycle models, and read-only/delegation/mutation-capable classification. Keep the module unwired and dead-code allowed until the planned ingress work lands. Plan: claude-mutation-scope-integration (T02) Co-authored-by: SCE --- .../hooks/claude_mutation_scope/mod.rs | 968 ++++++++++++++++++ cli/src/services/hooks/mod.rs | 1 + .../claude-mutation-scope-integration.md | 69 +- 3 files changed, 1033 insertions(+), 5 deletions(-) create mode 100644 cli/src/services/hooks/claude_mutation_scope/mod.rs diff --git a/cli/src/services/hooks/claude_mutation_scope/mod.rs b/cli/src/services/hooks/claude_mutation_scope/mod.rs new file mode 100644 index 00000000..8c3d94dc --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/mod.rs @@ -0,0 +1,968 @@ +#![allow(dead_code)] + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{Map, Value}; + +const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; +const SESSION_ID_FIELD: &str = "session_id"; +const CWD_FIELD: &str = "cwd"; +const AGENT_ID_FIELD: &str = "agent_id"; +const TOOL_NAME_FIELD: &str = "tool_name"; +const TOOL_USE_ID_FIELD: &str = "tool_use_id"; +const TOOL_INPUT_FIELD: &str = "tool_input"; +const RUN_IN_BACKGROUND_FIELD: &str = "run_in_background"; +const PROMPT_ID_FIELD: &str = "prompt_id"; +const AGENT_TYPE_FIELD: &str = "agent_type"; +const WORKTREE_PATH_FIELD: &str = "worktree_path"; + +const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; +const HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; +const HOOK_EVENT_POST_TOOL_USE_FAILURE: &str = "PostToolUseFailure"; +const HOOK_EVENT_PERMISSION_DENIED: &str = "PermissionDenied"; +const HOOK_EVENT_STOP: &str = "Stop"; +const HOOK_EVENT_STOP_FAILURE: &str = "StopFailure"; +const HOOK_EVENT_USER_PROMPT_SUBMIT: &str = "UserPromptSubmit"; +const HOOK_EVENT_SUBAGENT_STOP: &str = "SubagentStop"; +const HOOK_EVENT_SESSION_END: &str = "SessionEnd"; +const HOOK_EVENT_WORKTREE_REMOVE: &str = "WorktreeRemove"; +const HOOK_EVENT_SESSION_START: &str = "SessionStart"; +const HOOK_EVENT_SUBAGENT_START: &str = "SubagentStart"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ClaudeHookEvent { + PreToolUse(ClaudeToolExecution), + PostToolUse(ClaudeToolIdentity), + PostToolUseFailure(ClaudeToolIdentity), + PermissionDenied(ClaudeToolIdentity), + Stop(ClaudeSessionIdentity), + StopFailure(ClaudeSessionIdentity), + UserPromptSubmit(ClaudeSessionIdentity), + SubagentStop(ClaudeAgentIdentity), + SessionEnd(ClaudeSessionIdentity), + WorktreeRemove(ClaudeWorktreeRemove), + SessionStart, + SubagentStart, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeToolIdentity { + pub session_id: String, + pub cwd: String, + pub agent_id: Option, + pub tool_name: String, + pub tool_use_id: String, +} + +impl ClaudeToolIdentity { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + agent_id: self.agent_id.clone(), + tool_use_id: self.tool_use_id.clone(), + } + } + + pub(crate) fn is_subagent(&self) -> bool { + self.agent_id.is_some() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeToolExecution { + pub identity: ClaudeToolIdentity, + pub prompt_id: Option, + pub agent_type: Option, + pub run_in_background: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeSessionIdentity { + pub session_id: String, + pub cwd: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeAgentIdentity { + pub session_id: String, + pub cwd: String, + pub agent_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeWorktreeRemove { + pub session_id: String, + pub worktree_path: String, +} + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct AttemptKey { + pub session_id: String, + pub agent_id: Option, + pub tool_use_id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolClassification { + MutationCapable, + ReadOnly, + Delegation, +} + +const DELEGATION_TOOL_NAME: &str = "Agent"; +const KNOWN_READ_ONLY_TOOL_NAMES: &[&str] = &[ + "Read", + "Glob", + "Grep", + "WebFetch", + "WebSearch", + "AskUserQuestion", +]; + +pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { + if tool_name == DELEGATION_TOOL_NAME { + return ToolClassification::Delegation; + } + if KNOWN_READ_ONLY_TOOL_NAMES.contains(&tool_name) { + return ToolClassification::ReadOnly; + } + ToolClassification::MutationCapable +} + +const BASH_TOOL_NAME: &str = "Bash"; +const POWERSHELL_TOOL_NAME: &str = "PowerShell"; + +pub(crate) fn is_explicit_background_shell(tool_name: &str, run_in_background: bool) -> bool { + run_in_background && (tool_name == BASH_TOOL_NAME || tool_name == POWERSHELL_TOOL_NAME) +} + +const CLAUDE_SCOPE_ID_SCHEME: &str = "cc-tool-v1"; + +pub(crate) fn format_claude_scope_id(attempt_seq: u64, key: &AttemptKey) -> String { + let agent_id = key.agent_id.as_deref().unwrap_or(""); + format!( + "{CLAUDE_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|a={}:{}|t={}:{}", + key.session_id.len(), + key.session_id, + agent_id.len(), + agent_id, + key.tool_use_id.len(), + key.tool_use_id, + ) +} + +pub(crate) fn claude_scope_start_event_id(scope_id: &str) -> String { + format!("{scope_id}|start") +} + +pub(crate) fn claude_scope_close_event_id(scope_id: &str) -> String { + format!("{scope_id}|close") +} + +pub(crate) fn parse_claude_hook_event(stdin_payload: &str) -> Result { + if stdin_payload.trim().is_empty() { + bail!(validation_error( + "expected a JSON object, got an empty payload" + )); + } + + let parsed: Value = serde_json::from_str(stdin_payload) + .with_context(|| validation_error("expected valid JSON"))?; + let object = parsed + .as_object() + .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; + + let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; + + match hook_event_name.as_str() { + HOOK_EVENT_PRE_TOOL_USE => parse_pre_tool_use(object).map(ClaudeHookEvent::PreToolUse), + HOOK_EVENT_POST_TOOL_USE => parse_tool_identity(object).map(ClaudeHookEvent::PostToolUse), + HOOK_EVENT_POST_TOOL_USE_FAILURE => { + parse_tool_identity(object).map(ClaudeHookEvent::PostToolUseFailure) + } + HOOK_EVENT_PERMISSION_DENIED => { + parse_tool_identity(object).map(ClaudeHookEvent::PermissionDenied) + } + HOOK_EVENT_STOP => parse_session_identity(object).map(ClaudeHookEvent::Stop), + HOOK_EVENT_STOP_FAILURE => parse_session_identity(object).map(ClaudeHookEvent::StopFailure), + HOOK_EVENT_USER_PROMPT_SUBMIT => { + parse_session_identity(object).map(ClaudeHookEvent::UserPromptSubmit) + } + HOOK_EVENT_SUBAGENT_STOP => parse_agent_identity(object).map(ClaudeHookEvent::SubagentStop), + HOOK_EVENT_SESSION_END => parse_session_identity(object).map(ClaudeHookEvent::SessionEnd), + HOOK_EVENT_WORKTREE_REMOVE => { + parse_worktree_remove(object).map(ClaudeHookEvent::WorktreeRemove) + } + HOOK_EVENT_SESSION_START => Ok(ClaudeHookEvent::SessionStart), + HOOK_EVENT_SUBAGENT_START => Ok(ClaudeHookEvent::SubagentStart), + other => bail!(validation_error(&format!( + "unsupported hook_event_name '{other}'" + ))), + } +} + +fn parse_tool_identity(object: &Map) -> Result { + Ok(ClaudeToolIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + tool_use_id: required_non_blank_str(object, TOOL_USE_ID_FIELD)?, + agent_id: optional_non_blank_str(object, AGENT_ID_FIELD)?, + }) +} + +fn parse_pre_tool_use(object: &Map) -> Result { + Ok(ClaudeToolExecution { + identity: parse_tool_identity(object)?, + prompt_id: optional_non_blank_str(object, PROMPT_ID_FIELD)?, + agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, + run_in_background: parse_run_in_background(object)?, + }) +} + +fn parse_run_in_background(object: &Map) -> Result { + let Some(tool_input) = object.get(TOOL_INPUT_FIELD) else { + return Ok(false); + }; + if tool_input.is_null() { + return Ok(false); + } + let tool_input = tool_input.as_object().ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{TOOL_INPUT_FIELD}' must be a JSON object" + ))) + })?; + + match tool_input.get(RUN_IN_BACKGROUND_FIELD) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(_) => bail!(validation_error(&format!( + "field '{TOOL_INPUT_FIELD}.{RUN_IN_BACKGROUND_FIELD}' must be a boolean" + ))), + } +} + +fn parse_session_identity(object: &Map) -> Result { + Ok(ClaudeSessionIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +fn parse_agent_identity(object: &Map) -> Result { + Ok(ClaudeAgentIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + agent_id: required_non_blank_str(object, AGENT_ID_FIELD)?, + }) +} + +fn parse_worktree_remove(object: &Map) -> Result { + Ok(ClaudeWorktreeRemove { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + worktree_path: required_non_blank_str(object, WORKTREE_PATH_FIELD)?, + }) +} + +fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { + object.get(field).ok_or_else(|| { + anyhow!(validation_error(&format!( + "missing required field '{field}'" + ))) + }) +} + +fn required_str(object: &Map, field: &str) -> Result { + required_field(object, field)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{field}' must be a string" + ))) + }) +} + +fn required_non_blank_str(object: &Map, field: &str) -> Result { + let value = required_str(object, field)?; + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be a non-blank string" + ))); + } + Ok(value) +} + +fn optional_non_blank_str(object: &Map, field: &str) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => { + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))); + } + Ok(Some(value.clone())) + } + Some(_) => bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))), + } +} + +fn validation_error(detail: &str) -> String { + format!("Invalid Claude hook event payload from STDIN: {detail}.") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pre_tool_use_json(overrides: &[(&str, Value)]) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_PRE_TOOL_USE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("/repo/checkout".to_string()), + ); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("Write".to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String("toolu_1".to_string()), + ); + for (field, value) in overrides { + object.insert((*field).to_string(), value.clone()); + } + Value::Object(object).to_string() + } + + fn identity(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> AttemptKey { + AttemptKey { + session_id: session_id.to_string(), + agent_id: agent_id.map(str::to_string), + tool_use_id: tool_use_id.to_string(), + } + } + + #[test] + fn pre_tool_use_parses_required_and_optional_fields() { + let payload = pre_tool_use_json(&[ + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + (PROMPT_ID_FIELD, Value::String("prompt-1".to_string())), + ( + AGENT_TYPE_FIELD, + Value::String("general-purpose".to_string()), + ), + ]); + + let event = parse_claude_hook_event(&payload).expect("valid PreToolUse parses"); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert_eq!(execution.identity.session_id, "session-1"); + assert_eq!(execution.identity.cwd, "/repo/checkout"); + assert_eq!(execution.identity.tool_name, "Write"); + assert_eq!(execution.identity.tool_use_id, "toolu_1"); + assert_eq!(execution.identity.agent_id.as_deref(), Some("agent-1")); + assert_eq!(execution.prompt_id.as_deref(), Some("prompt-1")); + assert_eq!(execution.agent_type.as_deref(), Some("general-purpose")); + assert!(!execution.run_in_background); + } + + #[test] + fn pre_tool_use_agent_id_absent_means_main_thread() { + let payload = pre_tool_use_json(&[]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert_eq!(execution.identity.agent_id, None); + assert!(!execution.identity.is_subagent()); + } + + #[test] + fn pre_tool_use_agent_id_present_means_subagent() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert!(execution.identity.is_subagent()); + } + + #[test] + fn pre_tool_use_prompt_id_and_agent_type_are_optional() { + let payload = pre_tool_use_json(&[]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert_eq!(execution.prompt_id, None); + assert_eq!(execution.agent_type, None); + } + + #[test] + fn missing_required_fields_are_rejected_without_fabricating_identity() { + for field in [ + SESSION_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + TOOL_USE_ID_FIELD, + ] { + let mut object: serde_json::Map = + serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); + object.remove(field); + let payload = Value::Object(object).to_string(); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!( + error.to_string().contains(&format!("'{field}'")), + "expected missing-field error to name '{field}', got: {error}" + ); + } + } + + #[test] + fn wrong_type_required_field_is_rejected() { + let payload = pre_tool_use_json(&[(SESSION_ID_FIELD, Value::from(42))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("session_id")); + } + + #[test] + fn empty_string_required_field_is_rejected() { + let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String(String::new()))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("non-blank")); + } + + #[test] + fn wrong_type_optional_field_is_rejected() { + let payload = pre_tool_use_json(&[(PROMPT_ID_FIELD, Value::from(1))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("prompt_id")); + } + + #[test] + fn empty_optional_field_is_rejected() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String(String::new()))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("agent_id")); + } + + #[test] + fn null_optional_field_is_none() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Null)]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert_eq!(execution.identity.agent_id, None); + } + + #[test] + fn empty_payload_is_rejected() { + let error = parse_claude_hook_event("").unwrap_err(); + assert!(error.to_string().contains("empty payload")); + + let error = parse_claude_hook_event(" ").unwrap_err(); + assert!(error.to_string().contains("empty payload")); + } + + #[test] + fn malformed_json_is_rejected() { + let error = parse_claude_hook_event("{not json").unwrap_err(); + assert!(error.to_string().contains("valid JSON")); + } + + #[test] + fn non_object_json_is_rejected() { + let error = parse_claude_hook_event("[1, 2, 3]").unwrap_err(); + assert!(error.to_string().contains("JSON object")); + } + + #[test] + fn unsupported_hook_event_name_is_rejected() { + let payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String("PostToolBatch".to_string()), + )]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("unsupported hook_event_name")); + } + + #[test] + fn post_tool_use_and_failure_and_permission_denied_share_tool_identity_shape() { + for event_name in [ + HOOK_EVENT_POST_TOOL_USE, + HOOK_EVENT_POST_TOOL_USE_FAILURE, + HOOK_EVENT_PERMISSION_DENIED, + ] { + let payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(event_name.to_string()), + )]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let identity = match event { + ClaudeHookEvent::PostToolUse(identity) + | ClaudeHookEvent::PostToolUseFailure(identity) + | ClaudeHookEvent::PermissionDenied(identity) => identity, + other => panic!("expected a tool-identity event, got {other:?}"), + }; + assert_eq!(identity.session_id, "session-1"); + assert_eq!(identity.tool_use_id, "toolu_1"); + } + } + + #[test] + fn session_scoped_lifecycle_events_parse_session_identity() { + for event_name in [ + HOOK_EVENT_STOP, + HOOK_EVENT_STOP_FAILURE, + HOOK_EVENT_USER_PROMPT_SUBMIT, + HOOK_EVENT_SESSION_END, + ] { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let identity = match event { + ClaudeHookEvent::Stop(identity) + | ClaudeHookEvent::StopFailure(identity) + | ClaudeHookEvent::UserPromptSubmit(identity) + | ClaudeHookEvent::SessionEnd(identity) => identity, + other => panic!("expected a session-identity event, got {other:?}"), + }; + assert_eq!(identity.session_id, "session-1"); + assert_eq!(identity.cwd, "/repo"); + } + } + + #[test] + fn subagent_stop_requires_agent_id() { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + let payload = Value::Object(object).to_string(); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("agent_id")); + } + + #[test] + fn subagent_stop_parses_agent_identity() { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String("agent-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::SubagentStop(identity) = event else { + panic!("expected SubagentStop"); + }; + assert_eq!(identity.agent_id, "agent-1"); + } + + #[test] + fn worktree_remove_requires_worktree_path_not_cwd() { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + WORKTREE_PATH_FIELD.to_string(), + Value::String("/repo/.claude/worktrees/agent-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::WorktreeRemove(worktree_remove) = event else { + panic!("expected WorktreeRemove"); + }; + assert_eq!(worktree_remove.session_id, "session-1"); + assert_eq!( + worktree_remove.worktree_path, + "/repo/.claude/worktrees/agent-1" + ); + } + + #[test] + fn session_start_and_subagent_start_establish_no_scope_payload() { + for event_name in [HOOK_EVENT_SESSION_START, HOOK_EVENT_SUBAGENT_START] { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + assert!(matches!( + event, + ClaudeHookEvent::SessionStart | ClaudeHookEvent::SubagentStart + )); + } + } + + #[test] + fn run_in_background_true_is_parsed() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": true }), + )]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(execution.run_in_background); + } + + #[test] + fn run_in_background_false_is_parsed() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": false }), + )]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(!execution.run_in_background); + } + + #[test] + fn run_in_background_absent_defaults_to_false() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "command": "echo hi" }), + )]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(!execution.run_in_background); + } + + #[test] + fn tool_input_absent_defaults_run_in_background_to_false() { + let mut object: serde_json::Map = + serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); + object.remove(TOOL_INPUT_FIELD); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(!execution.run_in_background); + } + + #[test] + fn run_in_background_wrong_type_is_rejected() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": "yes" }), + )]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("run_in_background")); + } + + #[test] + fn tool_input_wrong_type_is_rejected() { + let payload = pre_tool_use_json(&[(TOOL_INPUT_FIELD, Value::String("nope".to_string()))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("tool_input")); + } + + #[test] + fn known_mutation_capable_tools_are_classified_mutation_capable() { + for tool_name in [ + "Bash", + "PowerShell", + "Write", + "Edit", + "NotebookEdit", + "MultiEdit", + ] { + assert_eq!( + classify_tool(tool_name), + ToolClassification::MutationCapable, + "expected {tool_name} to be MutationCapable" + ); + } + } + + #[test] + fn known_read_only_tools_are_classified_read_only() { + for tool_name in [ + "Read", + "Glob", + "Grep", + "WebFetch", + "WebSearch", + "AskUserQuestion", + ] { + assert_eq!( + classify_tool(tool_name), + ToolClassification::ReadOnly, + "expected {tool_name} to be ReadOnly" + ); + } + } + + #[test] + fn agent_is_classified_delegation() { + assert_eq!(classify_tool("Agent"), ToolClassification::Delegation); + } + + #[test] + fn mcp_tools_are_classified_mutation_capable() { + assert_eq!( + classify_tool("mcp__claude-in-chrome__navigate"), + ToolClassification::MutationCapable + ); + } + + #[test] + fn unknown_tool_names_are_conservatively_mutation_capable() { + assert_eq!( + classify_tool("SomeBrandNewTool"), + ToolClassification::MutationCapable + ); + } + + const PROBE14_BASH_RUN_IN_BACKGROUND_TRUE: &str = + include_str!("fixtures/probe14-run-in-background-true.pre_tool_use.json"); + const PROBE15_BASH_RUN_IN_BACKGROUND_FALSE: &str = + include_str!("fixtures/probe15-run-in-background-false-hard-gate.pre_tool_use.json"); + + fn parsed_pre_tool_use(payload: &str) -> ClaudeToolExecution { + let event = parse_claude_hook_event(payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + execution + } + + #[test] + fn real_bash_run_in_background_true_fixture_is_explicit_background_shell() { + let execution = parsed_pre_tool_use(PROBE14_BASH_RUN_IN_BACKGROUND_TRUE); + + assert_eq!(execution.identity.tool_name, "Bash"); + assert!(execution.run_in_background); + assert!(is_explicit_background_shell( + &execution.identity.tool_name, + execution.run_in_background + )); + } + + #[test] + fn real_bash_run_in_background_false_fixture_is_not_explicit_background_shell() { + let execution = parsed_pre_tool_use(PROBE15_BASH_RUN_IN_BACKGROUND_FALSE); + + assert_eq!(execution.identity.tool_name, "Bash"); + assert!(!execution.run_in_background); + assert!(!is_explicit_background_shell( + &execution.identity.tool_name, + execution.run_in_background + )); + } + + #[test] + fn powershell_with_run_in_background_true_is_explicit_background_shell() { + assert!(is_explicit_background_shell("PowerShell", true)); + } + + #[test] + fn powershell_with_run_in_background_false_is_not_explicit_background_shell() { + assert!(!is_explicit_background_shell("PowerShell", false)); + } + + #[test] + fn write_with_run_in_background_true_is_not_explicit_background_shell() { + assert!(!is_explicit_background_shell("Write", true)); + } + + #[test] + fn same_attempt_seq_and_key_is_deterministic() { + let key = identity("session-1", Some("agent-1"), "toolu_1"); + + let first = format_claude_scope_id(3, &key); + let second = format_claude_scope_id(3, &key); + + assert_eq!( + first, second, + "AC4: duplicate delivery must reuse the same ScopeId" + ); + assert_eq!( + claude_scope_start_event_id(&first), + claude_scope_start_event_id(&second) + ); + } + + #[test] + fn fresh_attempt_seq_yields_a_new_scope_id() { + let key = identity("session-1", None, "toolu_1"); + + let first = format_claude_scope_id(1, &key); + let second = format_claude_scope_id(2, &key); + + assert_ne!( + first, second, + "AC5: a fresh attempt_seq for the same tool_use_id must get a new ScopeId" + ); + } + + #[test] + fn main_and_distinct_agents_produce_distinct_scope_ids() { + let main = identity("session-1", None, "toolu_1"); + let agent_a = identity("session-1", Some("A"), "toolu_1"); + let agent_b = identity("session-1", Some("B"), "toolu_1"); + + let main_scope = format_claude_scope_id(1, &main); + let scope_for_a = format_claude_scope_id(1, &agent_a); + let scope_for_b = format_claude_scope_id(1, &agent_b); + + assert_ne!( + main_scope, scope_for_a, + "AC6: main vs agent_id=A must differ" + ); + assert_ne!( + main_scope, scope_for_b, + "AC6: main vs agent_id=B must differ" + ); + assert_ne!( + scope_for_a, scope_for_b, + "AC6: agent_id=A vs agent_id=B must differ" + ); + } + + #[test] + fn event_id_derivation_is_a_pure_function_of_scope_id() { + let scope_id = format_claude_scope_id(7, &identity("session-1", None, "toolu_1")); + + assert_eq!( + claude_scope_start_event_id(&scope_id), + format!("{scope_id}|start") + ); + assert_eq!( + claude_scope_close_event_id(&scope_id), + format!("{scope_id}|close") + ); + assert_ne!( + claude_scope_start_event_id(&scope_id), + claude_scope_close_event_id(&scope_id) + ); + } + + #[test] + fn length_prefixing_disambiguates_delimiter_characters_inside_fields() { + let tricky = identity( + "sess|a=0:x|t=1:y", + Some("agent|with|pipes"), + "tool:with:colons", + ); + + let scope_id = format_claude_scope_id(1, &tricky); + + let agent_id = tricky.agent_id.as_deref().unwrap(); + let expected = format!( + "cc-tool-v1|n=1|s={}:{}|a={}:{}|t={}:{}", + tricky.session_id.len(), + tricky.session_id, + agent_id.len(), + agent_id, + tricky.tool_use_id.len(), + tricky.tool_use_id, + ); + + assert_eq!(scope_id, expected); + } + + #[test] + fn attempt_key_projects_only_the_execution_key_fields() { + let identity_a = ClaudeToolIdentity { + session_id: "session-1".to_string(), + cwd: "/repo".to_string(), + agent_id: Some("agent-1".to_string()), + tool_name: "Write".to_string(), + tool_use_id: "toolu_1".to_string(), + }; + let identity_b = ClaudeToolIdentity { + tool_name: "Bash".to_string(), + cwd: "/other".to_string(), + ..identity_a.clone() + }; + + assert_eq!( + identity_a.attempt_key(), + identity_b.attempt_key(), + "attempt_key must depend only on (session_id, agent_id, tool_use_id)" + ); + } +} diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 9e4be2fd..5f777403 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -40,6 +40,7 @@ use crate::services::structured_patch::{ }; use crate::services::sync::auto_sync; pub mod claude_model_state; +pub mod claude_mutation_scope; pub mod claude_transcript; pub mod codex; pub mod command; diff --git a/context/plans/claude-mutation-scope-integration.md b/context/plans/claude-mutation-scope-integration.md index 64c70314..5aa5fc88 100644 --- a/context/plans/claude-mutation-scope-integration.md +++ b/context/plans/claude-mutation-scope-integration.md @@ -690,16 +690,19 @@ Persist this field in every plan; this is durable plan state, not chat state: this reconciled plan record. No code or domain context file changed, so no cross-file context synchronization was required. -- [ ] T02: `Raw event model, tool classification, and identity` (status:todo) +- [x] T02: `Raw event model, tool classification, and identity` (status:done) - Task ID: T02 - Scope: In — `cli/src/services/hooks/claude_mutation_scope/mod.rs`: raw event parser, supported hook-event enum, tool classifier (known mutation-capable: `Bash`, `PowerShell`, `Write`, `Edit`, `NotebookEdit`, `MultiEdit` when emitted, `mcp__*`; known read-only: `Read`, `Glob`, `Grep`, `WebFetch`, `WebSearch`, `AskUserQuestion`; `Agent` = not a scope; unknown = - potentially mutation-capable), owner identity (`agent_id` absent = main, - present = subagent), attempt-key type `(session_id, agent_id?, tool_use_id)`, - the length-prefixed `cc-tool-v1|n=..|s=..|a=..|t=..` `ScopeId` formatter, and + potentially mutation-capable), the explicit-background-shell classifier + `is_explicit_background_shell` (`tool_name` in `{Bash, PowerShell}` AND + `run_in_background == true`; model/classify only — D20's `PreToolUse` + denial is T05's), owner identity (`agent_id` absent = main, present = + subagent), attempt-key type `(session_id, agent_id?, tool_use_id)`, the + length-prefixed `cc-tool-v1|n=..|s=..|a=..|t=..` `ScopeId` formatter, and the `|start` / `|close` `EventId` formatter. Out — any durable state, any runtime/ingress call, any CLI wiring. - Dependencies: T01 @@ -708,7 +711,63 @@ Persist this field in every plan; this is durable plan state, not chat state: `attempt_seq`), AC6, AC21 (classification of explicit background shell), and the read-only / delegation / unknown classification table. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope`; `clippy` clean. - - Context synchronization: pending + - Completed: 2026-09-04 + - Files changed: + - `cli/src/services/hooks/claude_mutation_scope/mod.rs` (new) + - `cli/src/services/hooks/mod.rs` (add `pub mod claude_mutation_scope;` + registration, alphabetically ordered) + - Result: Implemented the raw Claude hook-event model: `ClaudeHookEvent` (all + ten tracked/lifecycle variants plus the no-scope `SessionStart`/ + `SubagentStart` units), `ClaudeToolIdentity`/`ClaudeToolExecution`/ + `ClaudeSessionIdentity`/`ClaudeAgentIdentity`/`ClaudeWorktreeRemove`, + `AttemptKey` and `ClaudeToolIdentity::attempt_key()`/`is_subagent()`, the + D2 `classify_tool` classifier, the D20/AC21 + `is_explicit_background_shell(tool_name, run_in_background)` classifier + (`true` only for `tool_name` in `{Bash, PowerShell}` with + `run_in_background == true`; a pure model function — no denial behavior, + which stays T05's), and the D4 `format_claude_scope_id` / + `claude_scope_start_event_id` / `claude_scope_close_event_id` formatters. + The strict parser (`parse_claude_hook_event`) follows the existing + `mutation_scope.rs`/`hooks/mod.rs` validation-helper style + (`required_field`/`required_non_blank_str`/`optional_non_blank_str` + + local `validation_error`), validates required identity fields per event, + and rejects malformed/wrong-type payloads without fabricating identities. + The module is marked `#![allow(dead_code)]` (matching the + `services::capabilities` staged-implementation convention) since nothing + calls it until T05 wires a CLI command. No durable state, ingress call, or + CLI wiring was added, matching the task's Out-of-scope boundary. + + PR #263 follow-up: the original AC21 tests only exercised the + `run_in_background` field parser against the default `Write` tool, never + proving the classification condition on the two tools D20 actually + targets. Added `is_explicit_background_shell` plus five dedicated + classification tests (`Bash+true` and `Bash+false` parsed from the real + committed T01 fixtures `probe14-run-in-background-true.pre_tool_use.json` + and `probe15-run-in-background-false-hard-gate.pre_tool_use.json` via + `include_str!`; `PowerShell+true`, `PowerShell+false`, and `Write+true` + as direct unit calls), so AC21's classification is now actually proven + rather than only its field-parsing prerequisite. + - Verify: `services::hooks::claude_mutation_scope` — 42 passed, 0 failed + (37 + 5 new `is_explicit_background_shell` classification tests); + `clippy --all-targets -- -D warnings` — clean; `fmt -- --check` — clean; + AC18 dependency-boundary grep + (`rg -n --type rust '^\s*use\s+crate::services::mutation_trace::(runtime|protocol|store)|::(RepositoryAgentTraceDb|WorktreeId|GitSnapshotService)\b' cli/src/services/hooks/claude_mutation_scope/`) + — no matches. + - Context impact: None beyond this plan. This task adds only an internal, + not-yet-wired data-model module and one module registration; no CLI + surface, settings, schema, or documented behavior changed yet, so no + `context/cli|sce` file needed an update for this task. T08 will document + the shipped adapter (including this model) once T05 wires it in. + - Context synchronization: synced — root pass confirmed + `context/{overview,architecture,glossary,patterns,context-map}.md` contain + no mention of `claude_mutation_scope`/this task and are unaffected; + `context/cli/mutation-scope-runtime.md` and + `context/cli/mutation-scope-hook-ingress.md` already correctly state that + no concrete harness adapter is wired yet, which T02 leaves true (the new + module is `#[allow(dead_code)]` and has no caller). No feature, public + interface, or observable behavior was introduced. No decision qualified + for an ADR. Documentation of this model is intentionally deferred to T08 + per the plan's own task boundary. - [ ] T03: `Durable checkout-local adapter state` (status:todo) - Task ID: T03 From 46058010c516de6523118bb43c1a57e8c061fd61 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 4 Sep 2026 17:35:57 +0200 Subject: [PATCH 04/11] hooks: Add durable Claude mutation-scope state store Persist versioned adapter attempts under the checkout's Git state directory, using bounded OS locking and durable atomic replacement to prevent lost updates and partial writes. Reuse live deliveries by tool identity while allocating monotonic ScopeIds for later executions, and reject malformed or unsupported state rather than fabricating bookkeeping. Plan: claude-mutation-scope-integration (T03) Co-authored-by: SCE --- .../hooks/claude_mutation_scope/mod.rs | 2 + .../hooks/claude_mutation_scope/state.rs | 717 ++++++++++++++++++ .../claude-mutation-scope-integration.md | 307 ++++++-- 3 files changed, 956 insertions(+), 70 deletions(-) create mode 100644 cli/src/services/hooks/claude_mutation_scope/state.rs diff --git a/cli/src/services/hooks/claude_mutation_scope/mod.rs b/cli/src/services/hooks/claude_mutation_scope/mod.rs index 8c3d94dc..14680a81 100644 --- a/cli/src/services/hooks/claude_mutation_scope/mod.rs +++ b/cli/src/services/hooks/claude_mutation_scope/mod.rs @@ -1,5 +1,7 @@ #![allow(dead_code)] +pub(crate) mod state; + use anyhow::{anyhow, bail, Context, Result}; use serde_json::{Map, Value}; diff --git a/cli/src/services/hooks/claude_mutation_scope/state.rs b/cli/src/services/hooks/claude_mutation_scope/state.rs new file mode 100644 index 00000000..7d83b0c5 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/state.rs @@ -0,0 +1,717 @@ +use std::fs::{File, OpenOptions, TryLockError}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; + +use super::{format_claude_scope_id, AttemptKey}; + +const SCE_STATE_DIR: &str = "sce"; +const ADAPTER_STATE_FILE: &str = "claude-mutation-scope-state.json"; +const ADAPTER_STATE_LOCK_FILE: &str = "claude-mutation-scope-state.lock"; + +const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(20); +const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(10); + +const ADAPTER_STATE_VERSION: u32 = 1; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AttemptPhase { + PendingStart, + Active, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub(crate) struct AdapterAttempt { + pub attempt_seq: u64, + pub scope_id: String, + pub session_id: String, + pub agent_id: Option, + pub tool_use_id: String, + pub tool_name: String, + pub phase: AttemptPhase, +} + +impl AdapterAttempt { + fn matches_key(&self, key: &AttemptKey) -> bool { + self.session_id == key.session_id + && self.agent_id == key.agent_id + && self.tool_use_id == key.tool_use_id + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub(crate) struct AdapterState { + pub version: u32, + pub next_attempt_seq: u64, + pub recovery_pending: bool, + pub attempts: Vec, +} + +impl Default for AdapterState { + fn default() -> Self { + AdapterState { + version: ADAPTER_STATE_VERSION, + next_attempt_seq: 1, + recovery_pending: false, + attempts: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AllocatedAttempt { + pub attempt: AdapterAttempt, + pub reused: bool, +} + +fn state_dir(git_dir: &Path) -> PathBuf { + git_dir.join(SCE_STATE_DIR) +} + +fn state_path(git_dir: &Path) -> PathBuf { + state_dir(git_dir).join(ADAPTER_STATE_FILE) +} + +fn lock_path(git_dir: &Path) -> PathBuf { + state_dir(git_dir).join(ADAPTER_STATE_LOCK_FILE) +} + +struct AdapterStateLock { + file: File, +} + +#[derive(Debug)] +pub(crate) enum AdapterStateLockError { + TimedOut { path: PathBuf, timeout: Duration }, + Io(anyhow::Error), +} + +impl std::fmt::Display for AdapterStateLockError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AdapterStateLockError::TimedOut { path, timeout } => write!( + f, + "Timed out after {timeout:?} waiting for adapter-state lock '{}'", + path.display() + ), + AdapterStateLockError::Io(source) => write!(f, "{source}"), + } + } +} + +impl std::error::Error for AdapterStateLockError {} + +impl AdapterStateLock { + fn acquire( + git_dir: &Path, + timeout: Duration, + ) -> Result { + let dir = state_dir(git_dir); + std::fs::create_dir_all(&dir) + .with_context(|| { + format!( + "Failed to create adapter state directory '{}'", + dir.display() + ) + }) + .map_err(AdapterStateLockError::Io)?; + + let path = lock_path(git_dir); + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&path) + .with_context(|| { + format!( + "Failed to open adapter-state lock file '{}'", + path.display() + ) + }) + .map_err(AdapterStateLockError::Io)?; + + let deadline = Instant::now() + timeout; + loop { + match file.try_lock() { + Ok(()) => return Ok(AdapterStateLock { file }), + Err(TryLockError::WouldBlock) => { + let now = Instant::now(); + if now >= deadline { + return Err(AdapterStateLockError::TimedOut { path, timeout }); + } + std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline - now)); + } + Err(TryLockError::Error(source)) => { + return Err(AdapterStateLockError::Io( + anyhow::Error::new(source).context(format!( + "Failed to acquire adapter-state lock '{}'", + path.display() + )), + )); + } + } + } + } +} + +impl Drop for AdapterStateLock { + fn drop(&mut self) { + let _ = self.file.unlock(); + } +} + +pub(crate) fn read_state(git_dir: &Path) -> Result { + let path = state_path(git_dir); + if !path.exists() { + return Ok(AdapterState::default()); + } + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read adapter state '{}'", path.display()))?; + parse_adapter_state(&content, &path) +} + +fn parse_adapter_state(content: &str, path: &Path) -> Result { + let state: AdapterState = serde_json::from_str(content) + .with_context(|| format!("Adapter state file '{}' is malformed", path.display()))?; + if state.version != ADAPTER_STATE_VERSION { + return Err(anyhow!( + "Adapter state file '{}' has unsupported version {} (expected {})", + path.display(), + state.version, + ADAPTER_STATE_VERSION + )); + } + Ok(state) +} + +fn write_state_durably(git_dir: &Path, state: &AdapterState) -> Result<()> { + write_state_durably_inner(git_dir, state, |_, _| Ok(())) +} + +fn write_state_durably_inner( + git_dir: &Path, + state: &AdapterState, + before_rename: F, +) -> Result<()> +where + F: FnOnce(&Path, &Path) -> Result<()>, +{ + let dir = state_dir(git_dir); + std::fs::create_dir_all(&dir).with_context(|| { + format!( + "Failed to create adapter state directory '{}'", + dir.display() + ) + })?; + + let path = dir.join(ADAPTER_STATE_FILE); + let tmp_path = dir.join(format!("{ADAPTER_STATE_FILE}.tmp")); + + let serialized = + serde_json::to_vec_pretty(state).context("Failed to serialize adapter state")?; + + let mut tmp_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&tmp_path) + .with_context(|| { + format!( + "Failed to open temporary adapter state file '{}'", + tmp_path.display() + ) + })?; + tmp_file.write_all(&serialized).with_context(|| { + format!( + "Failed to write temporary adapter state file '{}'", + tmp_path.display() + ) + })?; + tmp_file.sync_data().with_context(|| { + format!( + "Failed to sync temporary adapter state file '{}'", + tmp_path.display() + ) + })?; + drop(tmp_file); + + before_rename(&tmp_path, &path)?; + + std::fs::rename(&tmp_path, &path).with_context(|| { + format!( + "Failed to rename '{}' to '{}'", + tmp_path.display(), + path.display() + ) + })?; + + #[cfg(unix)] + { + if let Ok(dir_handle) = std::fs::File::open(&dir) { + let _ = dir_handle.sync_all(); + } + } + + Ok(()) +} + +pub(crate) fn allocate_attempt( + git_dir: &Path, + key: &AttemptKey, + tool_name: &str, +) -> Result { + let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) + .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; + + let mut state = read_state(git_dir)?; + + if let Some(existing) = state + .attempts + .iter() + .find(|attempt| attempt.matches_key(key)) + { + return Ok(AllocatedAttempt { + attempt: existing.clone(), + reused: true, + }); + } + + let attempt_seq = state.next_attempt_seq; + let scope_id = format_claude_scope_id(attempt_seq, key); + let attempt = AdapterAttempt { + attempt_seq, + scope_id, + session_id: key.session_id.clone(), + agent_id: key.agent_id.clone(), + tool_use_id: key.tool_use_id.clone(), + tool_name: tool_name.to_string(), + phase: AttemptPhase::PendingStart, + }; + + state.attempts.push(attempt.clone()); + state.next_attempt_seq += 1; + write_state_durably(git_dir, &state)?; + + Ok(AllocatedAttempt { + attempt, + reused: false, + }) +} + +pub(crate) fn mark_active(git_dir: &Path, scope_id: &str) -> Result<()> { + let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) + .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; + + let mut state = read_state(git_dir)?; + let attempt = state + .attempts + .iter_mut() + .find(|attempt| attempt.scope_id == scope_id) + .ok_or_else(|| anyhow!("No adapter-state attempt found for scope_id '{scope_id}'"))?; + attempt.phase = AttemptPhase::Active; + write_state_durably(git_dir, &state) +} + +pub(crate) fn remove_attempt(git_dir: &Path, scope_id: &str) -> Result<()> { + let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) + .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; + + let mut state = read_state(git_dir)?; + let before = state.attempts.len(); + state + .attempts + .retain(|attempt| attempt.scope_id != scope_id); + if state.attempts.len() == before { + return Ok(()); + } + write_state_durably(git_dir, &state) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::thread; + + use super::*; + + static NEXT_TEST_GIT_DIR_ID: AtomicU64 = AtomicU64::new(0); + + fn unique_test_git_dir(label: &str) -> PathBuf { + let id = NEXT_TEST_GIT_DIR_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-claude-mutation-scope-state-{label}-{}-{id}", + std::process::id() + )) + } + + fn remove_test_git_dir(git_dir: &Path) { + let _ = std::fs::remove_dir_all(git_dir); + } + + fn key(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> AttemptKey { + AttemptKey { + session_id: session_id.to_string(), + agent_id: agent_id.map(str::to_string), + tool_use_id: tool_use_id.to_string(), + } + } + + #[test] + fn read_state_returns_default_when_file_is_absent() { + let git_dir = unique_test_git_dir("read-default"); + + let state = read_state(&git_dir).expect("missing state file should read as default"); + assert_eq!(state, AdapterState::default()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn attempt_seq_allocation_is_sequential_across_distinct_keys() { + let git_dir = unique_test_git_dir("sequential-allocation"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let first = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("first allocation should succeed"); + let second = allocate_attempt(&git_dir, &key("session-1", None, "toolu_2"), "Bash") + .expect("second allocation should succeed"); + let third = allocate_attempt( + &git_dir, + &key("session-1", Some("agent-1"), "toolu_3"), + "Edit", + ) + .expect("third allocation should succeed"); + + assert_eq!(first.attempt.attempt_seq, 1); + assert_eq!(second.attempt.attempt_seq, 2); + assert_eq!(third.attempt.attempt_seq, 3); + assert!(!first.reused); + assert!(!second.reused); + assert!(!third.reused); + + let state = read_state(&git_dir).expect("state should be readable"); + assert_eq!(state.next_attempt_seq, 4); + assert_eq!(state.attempts.len(), 3); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn duplicate_live_attempt_reuses_the_same_attempt_seq_and_scope_id() { + let git_dir = unique_test_git_dir("duplicate-reuse"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let attempt_key = key("session-1", None, "toolu_1"); + + let first = allocate_attempt(&git_dir, &attempt_key, "Write") + .expect("first allocation should succeed"); + let second = allocate_attempt(&git_dir, &attempt_key, "Write") + .expect("duplicate delivery should still succeed"); + + assert!(!first.reused, "the first allocation is not a reuse"); + assert!( + second.reused, + "AC4: duplicate live delivery must be reported as reused" + ); + assert_eq!( + first.attempt.attempt_seq, second.attempt.attempt_seq, + "AC4: duplicate delivery must reuse the same attempt_seq" + ); + assert_eq!( + first.attempt.scope_id, second.attempt.scope_id, + "AC4: duplicate delivery must reuse the same ScopeId" + ); + + let state = read_state(&git_dir).expect("state should be readable"); + assert_eq!( + state.attempts.len(), + 1, + "reusing a live attempt must not create a second bookkeeping entry" + ); + assert_eq!( + state.next_attempt_seq, 2, + "reusing a live attempt must not advance the monotonic counter" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn a_terminal_attempt_is_followed_by_a_fresh_allocation_never_reusing_the_scope_id() { + let git_dir = unique_test_git_dir("terminal-then-fresh"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let attempt_key = key("session-1", None, "toolu_1"); + + let first = allocate_attempt(&git_dir, &attempt_key, "Write") + .expect("first allocation should succeed"); + mark_active(&git_dir, &first.attempt.scope_id).expect("attempt should become active"); + remove_attempt(&git_dir, &first.attempt.scope_id) + .expect("terminal attempt should be removable"); + + let second = allocate_attempt(&git_dir, &attempt_key, "Write") + .expect("a later execution of the same tool_use_id should allocate a fresh attempt"); + + assert!( + !second.reused, + "the attempt is a fresh allocation, not a reuse" + ); + assert_ne!( + first.attempt.attempt_seq, second.attempt.attempt_seq, + "AC5: attempt_seq must never be reused after the prior attempt became terminal" + ); + assert_ne!( + first.attempt.scope_id, second.attempt.scope_id, + "AC5: a terminal ScopeId must never be reused" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mark_active_transitions_phase_from_pending_start_to_active() { + let git_dir = unique_test_git_dir("mark-active"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + assert_eq!(allocated.attempt.phase, AttemptPhase::PendingStart); + + mark_active(&git_dir, &allocated.attempt.scope_id).expect("mark_active should succeed"); + + let state = read_state(&git_dir).expect("state should be readable"); + let persisted = state + .attempts + .iter() + .find(|attempt| attempt.scope_id == allocated.attempt.scope_id) + .expect("attempt should still be present"); + assert_eq!(persisted.phase, AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn removing_an_already_removed_attempt_is_a_safe_no_op() { + let git_dir = unique_test_git_dir("remove-idempotent"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + + remove_attempt(&git_dir, &allocated.attempt.scope_id) + .expect("first removal should succeed"); + remove_attempt(&git_dir, &allocated.attempt.scope_id) + .expect("D9: duplicate terminal delivery after cleanup must be a safe no-op"); + + let state = read_state(&git_dir).expect("state should be readable"); + assert!(state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn malformed_state_file_is_rejected_without_fabricating_bookkeeping() { + let git_dir = unique_test_git_dir("malformed-json"); + let dir = state_dir(&git_dir); + std::fs::create_dir_all(&dir).expect("state dir should be created"); + std::fs::write(state_path(&git_dir), b"not json") + .expect("malformed file should be written"); + + let error = read_state(&git_dir).expect_err("malformed state file must be rejected"); + assert!(error.to_string().contains("malformed")); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn unsupported_version_is_rejected_without_fabricating_bookkeeping() { + let git_dir = unique_test_git_dir("unsupported-version"); + let dir = state_dir(&git_dir); + std::fs::create_dir_all(&dir).expect("state dir should be created"); + std::fs::write( + state_path(&git_dir), + serde_json::json!({ + "version": 99, + "next_attempt_seq": 1, + "recovery_pending": false, + "attempts": [] + }) + .to_string(), + ) + .expect("state file with unsupported version should be written"); + + let error = read_state(&git_dir).expect_err("unsupported version must be rejected"); + assert!(error.to_string().contains("unsupported version")); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn interruption_before_rename_leaves_the_canonical_path_unaffected() { + let git_dir = unique_test_git_dir("interrupted-before-rename"); + let dir = state_dir(&git_dir); + std::fs::create_dir_all(&dir).expect("state dir should be created"); + + let state = AdapterState { + next_attempt_seq: 5, + ..AdapterState::default() + }; + let result = write_state_durably_inner(&git_dir, &state, |tmp_path, canonical_path| { + assert!( + tmp_path.exists(), + "temp file should exist by the time the pre-rename hook runs" + ); + assert!( + !canonical_path.exists(), + "canonical path should still be absent at the pre-rename hook" + ); + Err(anyhow!("injected interruption before rename")) + }); + + assert!( + result.is_err(), + "write_state_durably_inner should surface the injected interruption" + ); + assert!( + !state_path(&git_dir).exists(), + "atomic replacement: canonical state path must stay absent when interrupted before rename" + ); + + let after = + read_state(&git_dir).expect("read should not error on an absent canonical file"); + assert_eq!( + after, + AdapterState::default(), + "an interrupted write must not partially apply" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn a_leftover_lock_file_with_no_active_os_lock_does_not_block_a_new_acquirer() { + let git_dir = unique_test_git_dir("leftover-lock-file"); + let dir = state_dir(&git_dir); + std::fs::create_dir_all(&dir).expect("state dir should be created"); + std::fs::write(lock_path(&git_dir), b"leftover") + .expect("leftover lock file should be writable"); + + let result = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write"); + assert!( + result.is_ok(), + "a lock file with no active OS lock held against it must not block a new acquirer" + ); + + remove_test_git_dir(&git_dir); + } + + const PARALLEL_WRITER_COUNT: u64 = 8; + + #[test] + fn parallel_writers_for_distinct_keys_all_converge_without_lost_updates() { + let git_dir = unique_test_git_dir("parallel-writers"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let handles: Vec<_> = (0..PARALLEL_WRITER_COUNT) + .map(|index| { + let git_dir = git_dir.clone(); + thread::spawn(move || { + let attempt_key = key("session-1", None, &format!("toolu_{index}")); + allocate_attempt(&git_dir, &attempt_key, "Write") + .expect("each concurrent allocation should durably succeed") + }) + }) + .collect(); + + let allocated: Vec = handles + .into_iter() + .map(|handle| handle.join().expect("writer thread should not panic")) + .collect(); + + let mut attempt_seqs: Vec = allocated.iter().map(|a| a.attempt.attempt_seq).collect(); + attempt_seqs.sort_unstable(); + attempt_seqs.dedup(); + assert_eq!( + attempt_seqs.len(), + usize::try_from(PARALLEL_WRITER_COUNT).unwrap(), + "concurrent writers must not lose updates or collide on attempt_seq" + ); + + let state = read_state(&git_dir).expect("state should be readable after concurrent writes"); + assert_eq!( + state.attempts.len(), + usize::try_from(PARALLEL_WRITER_COUNT).unwrap() + ); + assert_eq!(state.next_attempt_seq, PARALLEL_WRITER_COUNT + 1); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn a_second_acquirer_blocks_until_the_first_releases() { + use std::sync::mpsc; + + let git_dir = unique_test_git_dir("lock-contention"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let holder = AdapterStateLock::acquire(&git_dir, Duration::from_secs(5)) + .expect("first acquirer should succeed immediately"); + + let (result_tx, result_rx) = mpsc::channel(); + let git_dir_clone = git_dir.clone(); + let handle = thread::spawn(move || { + let result = AdapterStateLock::acquire(&git_dir_clone, Duration::from_secs(5)); + let _ = result_tx.send(()); + result + }); + + assert!( + result_rx.recv_timeout(Duration::from_millis(300)).is_err(), + "second acquirer should not succeed while the first still holds the lock" + ); + + drop(holder); + + result_rx + .recv_timeout(Duration::from_secs(5)) + .expect("second acquirer should complete once the first releases the lock"); + assert!(handle + .join() + .expect("second acquirer thread should not panic") + .is_ok()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn adapter_state_files_live_only_below_git_dir_sce() { + let git_dir = unique_test_git_dir("path-boundary"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + + let sce_dir = git_dir.join(SCE_STATE_DIR); + assert!(state_path(&git_dir).starts_with(&sce_dir)); + assert!(lock_path(&git_dir).starts_with(&sce_dir)); + + let mut found_state_file = false; + for entry in std::fs::read_dir(&sce_dir).expect("sce dir should be readable") { + let entry = entry.expect("dir entry should be readable"); + assert!( + entry.path().starts_with(&sce_dir), + "AC19: adapter state must live only under /sce/" + ); + if entry.path() == state_path(&git_dir) { + found_state_file = true; + } + } + assert!( + found_state_file, + "state file should exist under /sce/" + ); + + remove_test_git_dir(&git_dir); + } +} diff --git a/context/plans/claude-mutation-scope-integration.md b/context/plans/claude-mutation-scope-integration.md index 5aa5fc88..62d5bd80 100644 --- a/context/plans/claude-mutation-scope-integration.md +++ b/context/plans/claude-mutation-scope-integration.md @@ -49,7 +49,11 @@ Claude Code `2.1.258` (see T01's Verify record in the Task stack below and `cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md`); the decisions whose correctness depended on one of them actually firing — D10, D13, D15, D20, and D22 — are each marked with T01's resolved finding rather than a -pending gate. +pending gate. D20 carries a second, narrower open sub-finding: T01 proved only +that Claude itself keeps the `Bash` tool foregrounded for the invoked process's +own duration, never whether that process can leave a detached descendant +running past `PostToolUse`. T04 (a T01 follow-up) captures live evidence for +that specific gap; see D20. ### D1 — Scope = one independently mutation-capable Claude tool execution @@ -274,7 +278,7 @@ gone. Only after a successful `flush` does the adapter clear `recovery_pending`; a failed `flush` keeps it fail-closed for subsequent mutation-capable `PreToolUse`. -### D20 — Detached background Bash/PowerShell is unsupported and denied — resolved by T01: PASS +### D20 — Detached background Bash/PowerShell is unsupported and denied — resolved by T01: PASS for Claude-managed backgrounding; self-detaching descendants are a separate, explicit unsupported boundary (T04 pending) A detached shell can keep mutating the repository after `PostToolUse` returns and can outlive a session; the generic mutation-scope contract has no process @@ -292,10 +296,32 @@ finding (Claude Code `2.1.258`):** a `run_in_background=false` call remained foreground for the full command duration (`duration_ms: 4018` for a `sleep 4`) before `PostToolUse` fired; a `run_in_background=true` call returned immediately (`duration_ms: 8`) with a `tool_response.backgroundTaskId` stub. -**PASS** — the foreground-only correctness boundary this decision depends on is -validated; no unsound workaround is needed. Background **subagents** are not -excluded here: their internal mutation-capable tool calls still fire hooks with -`agent_id` and establish their own scopes. +**PASS, narrowly** — this proves only that Claude itself keeps the `Bash` tool +call foregrounded for as long as the *invoked* process runs; it does **not** +prove that command cannot leave a *detached descendant* process still running, +and still mutating the repository, after `PostToolUse` fires. Background +**subagents** are not excluded here: their internal mutation-capable tool calls +still fire hooks with `agent_id` and establish their own scopes. + +**Explicit unsupported boundary — self-detaching descendant processes.** A +`run_in_background=false` `Bash`/`PowerShell` call can still leave a +repository-mutating descendant process running after `PostToolUse` returns, +because the *invoked shell command itself* detaches a child before exiting. +Examples: shell backgrounding (`command &`), `nohup command &`, `setsid +command`, double-fork daemonization, or a child process a script starts with +detached/session-leader semantics (e.g. Python +`subprocess.Popen(..., start_new_session=True)`). **SCE cannot currently +guarantee attribution for shell commands that leave repository-mutating +descendant processes running after `PostToolUse`.** This is not solvable by +inspecting the command string: arbitrarily nested shell, script, and +interpreter invocations can detach a descendant no static text scan can +reliably catch, and this PR does not attempt one. Correct support would +require process/process-group supervision, which is out of scope here (see +Constraints and non-goals). This boundary holds regardless of what T04's probe +observes for the specific pattern it tests, because no detection or +supervision is being added either way; T04 records the concrete observed +`PostToolUse`-vs-descendant-mutation ordering as evidence, and this section is +updated with that finding once T04 completes. ### D21 — Raw Claude hook cwd is authoritative @@ -332,7 +358,7 @@ Claude adapter's production code must not import or reference `crate::services::mutation_trace::runtime`, `::protocol`, or `::store`, and must not name `RepositoryAgentTraceDb`, `WorktreeId`, or `GitSnapshotService`. It reaches the runtime only through the smallest crate-visible in-process seam on -`cli/src/services/hooks/mutation_scope.rs` (T04) — no second `RuntimeBoundary` +`cli/src/services/hooks/mutation_scope.rs` (T05) — no second `RuntimeBoundary` construction path and no spawned `sce` subprocess. That seam reuses the strict generic payload parser, `RuntimeBoundary` mapping, lazy DB acquisition, durable-completion error classification, and empty-stdout semantics already in @@ -360,11 +386,11 @@ performs final validation. - [ ] AC3: No `Start` boundary is emitted for `SessionStart`, `UserPromptSubmit`, or `SubagentStart` merely because that lifecycle event occurred. Only an independently mutation-capable tool execution attempt establishes a scope. - - Validate: adapter mapping unit tests; T07 Test-series assertions on + - Validate: adapter mapping unit tests; T08 Test-series assertions on processed-event keys. - [ ] AC4: Duplicate delivery of the same live `PreToolUse` reuses the same `attempt_seq`, `ScopeId`, and `Start` `EventId`. - - Validate: state + adapter unit tests; T07 Test4 (duplicate `Pre`/`Post` + - Validate: state + adapter unit tests; T08 Test4 (duplicate `Pre`/`Post` replay). - [ ] AC5: A later execution attempt of the same Claude `tool_use_id`, after the previous attempt became terminal, receives a new `attempt_seq` and a new @@ -377,8 +403,8 @@ performs final validation. - [ ] AC7: A tracked mutation-capable `PreToolUse` reaches durable generic-ingress `Start` before the hook returns success to Claude (write-ahead `pending_start` -> ingress `Start` -> `active`). - - Validate: T05 adapter ordering unit test with injected ingress; optionally - also T07 Test1 as production-path confirmation. + - Validate: T06 adapter ordering unit test with injected ingress; optionally + also T08 Test1 as production-path confirmation. - [ ] AC8: Any failure to establish required adapter state or `Start` during a mutation-capable `PreToolUse` returns a Claude `permissionDecision: "deny"` object, never a plain non-zero exit and never `allow`. @@ -387,36 +413,36 @@ performs final validation. - [ ] AC9: `PreToolUse` -> real filesystem mutation -> `PostToolUse` produces exactly one eligible tool interval and one terminal (`Closed`) scope with attribution `AiExclusive`. - - Validate: T07 Test1 (real Git repo + real Agent Trace DB). + - Validate: T08 Test1 (real Git repo + real Agent Trace DB). - [ ] AC10: `PreToolUse` -> partial filesystem mutation -> `PostToolUseFailure` also observes the mutation and closes the scope (`AiExclusive` + `Closed`). - - Validate: T07 Test2. + - Validate: T08 Test2. - [ ] AC11: Two simultaneously tracked tools create two active scopes; a tree transition observed while both are live is attributed `AiContended`. - - Validate: T07 Test3 and Test9 (main + subagent). + - Validate: T08 Test3 and Test9 (main + subagent). - [ ] AC12: `PreToolUse` followed by `PermissionDenied` creates no mutation event for the denied execution and leaves the worktree `needs_rebaseline`. - - Validate: T07 Test5. + - Validate: T08 Test5. - [ ] AC13: A `PreToolUse` with no `PostToolUse`/`PostToolUseFailure` is retired by one of the positive stale signals (`Stop`, `StopFailure`, main-thread `UserPromptSubmit`, matching-agent `SubagentStop`, `SessionEnd`, `WorktreeRemove`) via `abandon_scope`. - - Validate: T07 Test6, Test7, Test11; T05 adapter cleanup unit tests. + - Validate: T08 Test6, Test7, Test11; T06 adapter cleanup unit tests. - [ ] AC14: `PreToolUse` -> partial change/interruption -> no `Stop` -> next main-thread `UserPromptSubmit` abandons the stale main attempt before another mutation-capable tool can start. - - Validate: T07 Test7. + - Validate: T08 Test7. - [ ] AC15: A resumed subagent may carry the same Claude `agent_id`, but a new tool attempt receives a fresh tool `ScopeId`; no terminal mutation `ScopeId` is reused. - - Validate: T05 adapter identity unit tests; T07 Test8. + - Validate: T06 adapter identity unit tests; T08 Test8. - [ ] AC16: A hook process launched from checkout A with raw payload `cwd = checkout B` drives mutation state for checkout B. - - Validate: T07 Test10 (isolated-worktree cwd) asserting the correct + - Validate: T08 Test10 (isolated-worktree cwd) asserting the correct `WorktreeId`/cursor is advanced. - [ ] AC17: Mutations from an `isolation: worktree` subagent change only that worktree's mutation cursor; the main checkout's cursor is unchanged. - - Validate: T07 Test10. + - Validate: T08 Test10. - [ ] AC18: The dependency direction is exactly `claude_mutation_scope -> hooks::mutation_scope -> mutation_trace::runtime`. Production Claude-adapter code (everything in @@ -426,7 +452,7 @@ performs final validation. `crate::services::mutation_trace::protocol`, `crate::services::mutation_trace::store`, `RepositoryAgentTraceDb`, `WorktreeId`, or `GitSnapshotService`, and its only dependency into the - mutation stack is the single T04 seam import from + mutation stack is the single T05 seam import from `crate::services::hooks::mutation_scope`. - Validate: focused source inspection of `cli/src/services/hooks/claude_mutation_scope/{mod.rs,state.rs}`, excluding @@ -440,14 +466,14 @@ performs final validation. in comments, diagnostics, or test code that fabricates outcomes. - [ ] AC19: Claude adapter state lives only below `/sce/` and writes no Agent Trace or mutation database table directly. - - Validate: state-module inspection; T07 Test16. + - Validate: state-module inspection; T08 Test16. - [ ] AC20: Claude mutation-scope-only regressions leave `diff_traces`, `post_commit_patch_intersections`, and `agent_traces` unchanged. - - Validate: T07 Test16 (row-count assertions before/after). + - Validate: T08 Test16 (row-count assertions before/after). - [ ] AC21: Explicit background `Bash`/`PowerShell` (`run_in_background = true`) is denied in `PreToolUse` with the documented reason and creates no mutation scope. - - Validate: T05 adapter classification unit test; T07 Test15. + - Validate: T06 adapter classification unit test; T08 Test15. - [ ] AC22: Generated Claude settings still include and correctly merge `claude-model-state`, the bash policy hook, `diff-trace`, and `conversation-trace` alongside the new mutation adapter; user-owned Claude @@ -465,6 +491,15 @@ performs final validation. fail-closed `PreToolUse`, and the background-shell limitation. - Validate: inspection of `context/cli/claude-mutation-scope-integration.md` and the updated cross-reference files. +- [ ] AC25: A foreground Bash/PowerShell tool call (`run_in_background = false`) + that starts a detached, self-backgrounding descendant process which mutates + the repository after `PostToolUse` returns is not silently attributed as if + the mutation happened inside that tool's own observed scope; the adapter + documents this as an explicit unsupported boundary (D20) rather than + fabricating detection or supervision. + - Validate: T04's captured fixture + `NOTES.md` finding; D20 in the Design + section carries the reconciled wording; T08 Test17 (documented + unsupported-case regression). ### Full validation @@ -496,7 +531,7 @@ the PR remains stacked on #261. - `context/sce/generated-opencode-plugin-registration.md` is **not** a target for this plan — it owns OpenCode plugin registration, not Claude generated settings. `context/sce/claude-raw-hook-capture.md` is the update target unless - T06/T08 implementation proves a new dedicated Claude settings domain file is + T07/T09 implementation proves a new dedicated Claude settings domain file is required, in which case that new file becomes the owner and this list is updated then. @@ -523,9 +558,11 @@ Persist this field in every plan; this is durable plan state, not chat state: if the generated-fragment comparison does not already cover the new registrations), and the context files listed under Context sync. - **Out of scope:** Codex/OpenCode/Pi adapters, a generic adapter-framework - extraction, a background-process supervisor / PID tracking / cross-process - detached Bash attribution, protocol or Quint changes, Agent Trace schema - changes, any new mutation-attribution algorithm, `#259` attribution code. + extraction, a background-process supervisor / PID tracking / process-group + tracking / cross-process detached Bash attribution, shell-command parsing or + deny-listing to detect backgrounding/detachment patterns, protocol or Quint + changes, Agent Trace schema changes, any new mutation-attribution algorithm, + `#259` attribution code. - **Constraints:** the adapter depends only on `hooks::mutation_scope`, never on `mutation_trace::runtime` directly (`claude_mutation_scope -> mutation_scope -> mutation_trace::runtime`); it may call `checkout::resolve_git_dir(cwd)` but not @@ -537,13 +574,20 @@ Persist this field in every plan; this is durable plan state, not chat state: here); ScopeId uses length-prefixed tuple encoding, no hashing / no crypto dependency. - **Non-goal:** treating `PostToolUse(background Bash)` as a completed execution; - turning `abandon` into a `RuntimeBoundary`; deriving any `ScopeId` from - `agent_id` alone; a long-lived Claude "session" or "agent" scope. + treating a foreground (`run_in_background = false`) `PostToolUse` as proof + that every descendant process the tool call spawned has also terminated or + stopped mutating the repository; turning `abandon` into a `RuntimeBoundary`; + deriving any `ScopeId` from `agent_id` alone; a long-lived Claude "session" + or "agent" scope. ## Assumptions -- Task numbering here is `T01..T08`; the change request's `T00..T07` map to - `T01..T08` in order. +- Task numbering here is `T01..T09`; the original change request's `T00..T07` + mapped to `T01..T08` in order, and a later change request inserted a T01 + follow-up (detached-descendant lifecycle probe) as `T04`, shifting the + original `T04..T08` to `T05..T09`. Only not-yet-completed tasks were + renumbered; `T01`, `T02`, and `T03`, already complete when this insertion + happened, keep their original IDs and recorded evidence unchanged. - The crate-visible seam added to `mutation_scope.rs` is the existing private `run_mutation_scope_from_payload(repository_root, stdin_payload, logger)` made `pub(crate)` (or a thin `pub(crate)` wrapper), reused verbatim; no second @@ -680,7 +724,7 @@ Persist this field in every plan; this is durable plan state, not chat state: temporarily modified during capture (with explicit approval) and fully reverted before this task closed. - Context impact: None beyond this plan. No Rust, Pkl, generated-settings, - schema, migration, Quint, or `context/cli|sce` file was changed. T08 will + schema, migration, Quint, or `context/cli|sce` file was changed. T09 will draw on these findings (the fixture manifest, `NOTES.md`, and the dispositions recorded here) when it authors `context/cli/claude-mutation-scope-integration.md`. @@ -700,7 +744,7 @@ Persist this field in every plan; this is durable plan state, not chat state: potentially mutation-capable), the explicit-background-shell classifier `is_explicit_background_shell` (`tool_name` in `{Bash, PowerShell}` AND `run_in_background == true`; model/classify only — D20's `PreToolUse` - denial is T05's), owner identity (`agent_id` absent = main, present = + denial is T06's), owner identity (`agent_id` absent = main, present = subagent), attempt-key type `(session_id, agent_id?, tool_use_id)`, the length-prefixed `cc-tool-v1|n=..|s=..|a=..|t=..` `ScopeId` formatter, and the `|start` / `|close` `EventId` formatter. Out — any @@ -725,7 +769,7 @@ Persist this field in every plan; this is durable plan state, not chat state: `is_explicit_background_shell(tool_name, run_in_background)` classifier (`true` only for `tool_name` in `{Bash, PowerShell}` with `run_in_background == true`; a pure model function — no denial behavior, - which stays T05's), and the D4 `format_claude_scope_id` / + which stays T06's), and the D4 `format_claude_scope_id` / `claude_scope_start_event_id` / `claude_scope_close_event_id` formatters. The strict parser (`parse_claude_hook_event`) follows the existing `mutation_scope.rs`/`hooks/mod.rs` validation-helper style @@ -734,7 +778,7 @@ Persist this field in every plan; this is durable plan state, not chat state: and rejects malformed/wrong-type payloads without fabricating identities. The module is marked `#![allow(dead_code)]` (matching the `services::capabilities` staged-implementation convention) since nothing - calls it until T05 wires a CLI command. No durable state, ingress call, or + calls it until T06 wires a CLI command. No durable state, ingress call, or CLI wiring was added, matching the task's Out-of-scope boundary. PR #263 follow-up: the original AC21 tests only exercised the @@ -756,8 +800,8 @@ Persist this field in every plan; this is durable plan state, not chat state: - Context impact: None beyond this plan. This task adds only an internal, not-yet-wired data-model module and one module registration; no CLI surface, settings, schema, or documented behavior changed yet, so no - `context/cli|sce` file needed an update for this task. T08 will document - the shipped adapter (including this model) once T05 wires it in. + `context/cli|sce` file needed an update for this task. T09 will document + the shipped adapter (including this model) once T06 wires it in. - Context synchronization: synced — root pass confirmed `context/{overview,architecture,glossary,patterns,context-map}.md` contain no mention of `claude_mutation_scope`/this task and are unaffected; @@ -766,10 +810,10 @@ Persist this field in every plan; this is durable plan state, not chat state: no concrete harness adapter is wired yet, which T02 leaves true (the new module is `#[allow(dead_code)]` and has no caller). No feature, public interface, or observable behavior was introduced. No decision qualified - for an ADR. Documentation of this model is intentionally deferred to T08 + for an ADR. Documentation of this model is intentionally deferred to T09 per the plan's own task boundary. -- [ ] T03: `Durable checkout-local adapter state` (status:todo) +- [x] T03: `Durable checkout-local adapter state` (status:done) - Task ID: T03 - Scope: In — `cli/src/services/hooks/claude_mutation_scope/state.rs`: versioned JSON schema (`version`, `next_attempt_seq`, `recovery_pending`, @@ -785,10 +829,119 @@ Persist this field in every plan; this is durable plan state, not chat state: followed by a fresh allocation. Proves AC4, AC5, AC19 (path + no DB/table writes). - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope::state`; `clippy` clean. - - Context synchronization: pending + - Completed: 2026-09-04 + - Files changed: + - `cli/src/services/hooks/claude_mutation_scope/state.rs` (new) + - `cli/src/services/hooks/claude_mutation_scope/mod.rs` (add + `pub(crate) mod state;` registration) + - Result: Implemented the D5/D6 adapter-state store: `AdapterState` + (`version`, `next_attempt_seq`, `recovery_pending`, `attempts: Vec`) + and `AdapterAttempt` (`attempt_seq`, `scope_id`, `session_id`, `agent_id`, + `tool_use_id`, `tool_name`, `phase: AttemptPhase { PendingStart, Active }`), + versioned via a rejected-not-fabricated `ADAPTER_STATE_VERSION` check. A + self-contained `AdapterStateLock` (bounded `try_lock` polling with a + `TimedOut` error, modeled on `mutation_trace::runtime::worktree_lock`'s + pattern but with no dependency on that type, per D23) guards + `/sce/claude-mutation-scope-state.lock`; state itself is written + via a temp-file -> `sync_data` -> rename -> best-effort parent `sync_all` + durability sequence matching `checkout::persist_checkout_id_inner`, through + an injectable-hook inner function used by the interruption test. Four + helpers cover the task's Scope: `read_state` (missing file -> default, + malformed/wrong-version -> rejected), `allocate_attempt` (idempotent reuse + of an existing live attempt for the same `AttemptKey`, AC4; otherwise a + fresh monotonic `attempt_seq` and `ScopeId` via + `format_claude_scope_id`, never reused once terminal, AC5), + `mark_active` (`PendingStart` -> `Active`), and `remove_attempt` + (safe no-op on an already-removed scope, matching D9's duplicate-terminal- + delivery note). All four are lock-guarded read-modify-write operations. + Thirteen new unit tests cover: default-on-missing-file, sequential + `attempt_seq` allocation across distinct keys, duplicate-key reuse without + advancing the counter, a terminal attempt followed by a fresh + (non-reused) allocation, the `PendingStart`->`Active` transition, + idempotent removal, malformed-JSON rejection, unsupported-version + rejection, an injected pre-rename interruption leaving the canonical path + untouched, a leftover lock file (no active OS lock) not blocking a new + acquirer, concurrent writers on distinct keys converging without lost + updates, lock contention between two acquirers, and an AC19 path-boundary + check that every written path stays under `/sce/`. No Agent + Trace DB, mutation-runtime, or hook-event-handling code was touched, + matching the task's Out-of-scope boundary. + - Verify: `services::hooks::claude_mutation_scope` (including the new + `::state` module) — 55 passed, 0 failed (42 existing + 13 new state + tests); `clippy --all-targets -- -D warnings` — clean; `fmt -- --check` — + clean; AC18 dependency-boundary grep + (`rg -n --type rust '^\s*use\s+crate::services::mutation_trace::(runtime|protocol|store)|::(RepositoryAgentTraceDb|WorktreeId|GitSnapshotService)\b' cli/src/services/hooks/claude_mutation_scope/`) + — no matches. + - Context impact: None beyond this plan. This task adds only an internal, + not-yet-wired durable-state module (no CLI surface, settings, schema, or + documented behavior changed), so no `context/cli|sce` file needed an + update for this task. T09 will document the shipped adapter state (this + module) once T06 wires it in, per the plan's existing Context sync list. + - Context synchronization: synced — root pass confirmed + `context/{overview,architecture,glossary,patterns,context-map}.md` + contain no mention of `claude_mutation_scope`/this task and are + unaffected; `context/cli/mutation-scope-hook-ingress.md` and + `context/cli/mutation-scope-runtime.md` both already correctly state that + no concrete harness adapter is wired yet, which T03 leaves true (the new + state module is internal bookkeeping with no caller). No feature, public + interface, or observable behavior was introduced. No decision qualified + for an ADR. Documentation of this state module is intentionally deferred + to T09 per the plan's own task boundary. -- [ ] T04: `Expose the in-process generic-ingress seam` (status:todo) +- [ ] T04: `Capture the detached-descendant Bash lifecycle probe` (status:todo) - Task ID: T04 + - Scope: In — a T01 follow-up, using the same live-capture methodology + against the same pinned Claude Code version (`2.1.258`, or whatever + version this plan still targets at execution time): drive one foreground + `Bash` tool call (`run_in_background=false`) whose command starts a + detached, self-backgrounding descendant process that writes a repository + file after a short delay, with the invoked (parent) process exiting + immediately — e.g. Python `subprocess.Popen([...], start_new_session=True)` + launched from the `Bash` command, or an equivalent `setsid`/double-fork + shell pattern. Record the `PreToolUse` timestamp, the `PostToolUse` + timestamp, and the wall-clock time the detached child actually wrote the + file, and determine whether `PostToolUse` fired before or after that + write. Commit the raw fixture(s) under the existing + `cli/src/services/hooks/claude_mutation_scope/fixtures/` directory, + following the existing `probeNN-*` naming convention (e.g. + `probe17-detached-child-after-post-tool-use`), and record the observation, + the exact timestamps, and the reconciled D20 disposition in `NOTES.md`. + Update D20 in this plan's Design section with the concrete finding (the + exact observed ordering and the process pattern actually tested). + Out — any production code, any Rust module or CLI wiring, any process + supervision / PID tracking / process-group implementation, any shell- + command parsing or deny-listing, any change to the generic mutation-scope + protocol, Quint model, schema, or attribution algorithm, and modifying any + existing (already-committed) T01 fixture file. + - Dependencies: T01 + - Done when: the new fixture(s) exist and are referenced from this plan; + `NOTES.md` records the exact `PreToolUse`/`PostToolUse`/child-write + timestamps and the derived ordering; and D20 is updated with exactly one + of these two dispositions, chosen by what was actually observed rather + than assumed: + - If the descendant's mutation is observed to land after `PostToolUse`: + D20 stays PASS only for Claude-managed explicit background execution + (`run_in_background=true`); the self-detaching-descendant boundary this + task's evidence supports is recorded as confirmed, not merely + theoretical — foreground shell execution is not claimed to be + universally safe. + - If `PostToolUse` is observed to wait for the descendant to exit too: + record that exact observed behavior for this specific process pattern + and this specific Claude Code version, without generalizing it into a + safety guarantee for every detachment technique — `nohup`, `setsid`, + double-fork, and daemonizing patterns this probe did not exercise + remain unproven, and the unsupported-boundary wording in D20 stays in + place regardless (this PR still implements no detection or + supervision). + Satisfies AC25 together with T08 Test17. + - Verify: fixture(s) exist under + `cli/src/services/hooks/claude_mutation_scope/fixtures/`; `NOTES.md` + documents the observation and timestamps; this plan's D20 section reflects + the reconciled finding. + - Context synchronization: pending + +- [ ] T05: `Expose the in-process generic-ingress seam` (status:todo) + - Task ID: T05 - Scope: In — make the minimal crate-visible function on `cli/src/services/hooks/mutation_scope.rs` that runs a normalized JSON payload against `coordinate()` / `abandon_scope()` in-repo with a lazy DB @@ -804,8 +957,8 @@ Persist this field in every plan; this is durable plan state, not chat state: - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope`; `git diff` shows only a visibility/wrapper change. - Context synchronization: pending -- [ ] T05: `Claude adapter driver + CLI command` (status:todo) - - Task ID: T05 +- [ ] T06: `Claude adapter driver + CLI command` (status:todo) + - Task ID: T06 - Scope: In — `cli_schema::HooksSubcommand::ClaudeMutationScope` (hidden), `convert_hooks_subcommand_request` arm, `services::hooks::HookSubcommand::ClaudeMutationScope`, @@ -823,8 +976,8 @@ Persist this field in every plan; this is durable plan state, not chat state: outstanding attempts remain; `flush` through the seam once quiescent). Reads exactly one raw Claude hook JSON object from STDIN; emits empty stdout except the intentional `PreToolUse` decision object. Out — generated settings / - `sce setup` wiring (T06), real Git/DB regressions (T07). - - Dependencies: T02, T03, T04 + `sce setup` wiring (T07), real Git/DB regressions (T08). + - Dependencies: T02, T03, T05 - Done when: focused tests with an injected generic-ingress seam cover every event-to-operation mapping, fail-closed `PreToolUse` (exact `permissionDecision: "deny"` JSON, AC8), write-ahead ordering (AC7), @@ -834,8 +987,8 @@ Persist this field in every plan; this is durable plan state, not chat state: - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope`; `sce hooks claude-mutation-scope generic-ingress path (no manual `mutation_trace_*` inserts): Test1 foreground @@ -875,25 +1028,39 @@ Persist this field in every plan; this is durable plan state, not chat state: settlement -> abandonment recovery; Test14 terminal runtime success before state cleanup -> replay-safe; Test15 explicit background `Bash` -> denied, no scope; Test16 raw Agent Trace tables (`diff_traces`, - `post_commit_patch_intersections`, `agent_traces`) unchanged. Each applicable - test asserts scope status, processed-event keys, revision, `cursor_tree`, - mutation-event count, attribution kind, `needs_rebaseline`, and adapter - state. Out — new production behavior; any test that inserts the event it - means to prove. - - Dependencies: T05 (and T06 for any test that installs generated settings) - - Done when: all sixteen regressions pass and collectively satisfy AC9–AC17, - AC19, AC20, AC21. + `post_commit_patch_intersections`, `agent_traces`) unchanged; Test17 a + foreground `Bash` (`run_in_background=false`) that starts a detached, + self-backgrounding descendant which mutates the repository after + `PostToolUse` returns — asserts that the scope closes (`Closed`) at the + tool's own observed tree and that the descendant's later mutation is + **not** captured by that scope or folded into its attribution; this is a + documented unsupported-case regression proving the adapter does not + silently claim correct attribution for a mutation occurring after the + tool scope already closed, not an assertion that the adapter detects or + supervises the descendant. Each applicable test asserts scope status, + processed-event keys, revision, `cursor_tree`, mutation-event count, + attribution kind, `needs_rebaseline`, and adapter state. Out — new + production behavior; any process supervision, PID tracking, or + detached-child detection; any test that inserts the event it means to + prove. + - Dependencies: T06 (and T07 for any test that installs generated settings); + Test17 also depends on T04's captured fixture/evidence. + - Done when: all seventeen regressions pass and collectively satisfy + AC9–AC17, AC19, AC20, AC21, AC25. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::`. - Context synchronization: pending -- [ ] T08: `Author the durable adapter context` (status:todo) - - Task ID: T08 +- [ ] T09: `Author the durable adapter context` (status:todo) + - Task ID: T09 - Scope: In — create `context/cli/claude-mutation-scope-integration.md` owning the tool-attempt scope model, tool classification, `ScopeId`/`EventId` derivation, adapter state, write-ahead `Start`, fail-closed `PreToolUse`, terminal `Close` and failed-tool behavior, abandonment cleanup signals, the recovery barrier, subagent identity, worktree-cwd ownership, the - background-shell limitation, and the generic-ingress dependency boundary; + background-shell limitation (both Claude-managed `run_in_background=true` + denial and the separate self-detaching-descendant unsupported boundary + D20 records, with T04's reconciled finding), and the generic-ingress + dependency boundary; update `context/cli/mutation-scope-runtime.md`, `context/cli/mutation-scope-hook-ingress.md`, `context/sce/agent-trace-hooks-command-routing.md`, @@ -903,13 +1070,13 @@ Persist this field in every plan; this is durable plan state, not chat state: plugin registration), `context/context-map.md`, `context/overview.md`, `context/architecture.md` to reference the shipped adapter and the new in-process seam. Out — any code change; describing behavior not actually - shipped by T02–T07. - - Dependencies: T02, T03, T04, T05, T06, T07 + shipped by T02–T08. + - Dependencies: T02, T03, T04, T05, T06, T07, T08 - Done when: the new file exists and the cross-references are updated; AC24 - inspection passes; `nix flake check` (context has no generated check but the - map/overview must stay internally consistent). - - Verify: inspection against AC24; `grep` shows the new route documented in the - routing file and the new file linked from `context/context-map.md`. + and AC25 inspection passes; `nix flake check` (context has no generated + check but the map/overview must stay internally consistent). + - Verify: inspection against AC24/AC25; `grep` shows the new route documented + in the routing file and the new file linked from `context/context-map.md`. - Context synchronization: pending ## Open questions @@ -923,7 +1090,7 @@ Persist this field in every plan; this is durable plan state, not chat state: carry the required identity fields (**PASS** for both). `WorktreeRemove` was not observed to fire for either isolated-worktree cleanup path tested, and `StopFailure` could not be exercised without deliberately failing a turn. - Neither is treated as blocking, and neither registration is dropped: T05/T06 + Neither is treated as blocking, and neither registration is dropped: T06/T07 keep the `WorktreeRemove` handler and registration, and the adapter keeps `StopFailure` support, but correctness does not depend on either firing (D22 accepted best-effort; D15 doc-verified/non-load-bearing). `SessionEnd` @@ -946,5 +1113,5 @@ Persist this field in every plan; this is durable plan state, not chat state: PR land the core loop (`PreToolUse`/`PostToolUse` + `Stop`/`SessionEnd` cleanup, foreground `Write`/`Edit`/`Bash`, no subagent-worktree isolation) and leave subagent identity, `isolation: worktree`, and the full cleanup matrix to - a stacked follow-up? The current slicing is coherent, but T05 is large and its + a stacked follow-up? The current slicing is coherent, but T06 is large and its correctness rests entirely on T01's findings. From a94ea7fc54238d3e43da03b3d5463ed8af5a6e8e Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 4 Sep 2026 22:42:00 +0200 Subject: [PATCH 05/11] CI: Include Claude mutation-scope fixtures in CLI build inputs Ensure Nix package builds include the Claude mutation-scope hook fixtures so the hook's test and runtime assets are available in the packaged CLI source tree. Co-authored-by: SCE --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index c9e95286..4932b6b9 100644 --- a/flake.nix +++ b/flake.nix @@ -190,6 +190,7 @@ (pkgs.lib.fileset.maybeMissing ./cli/src/services/agent_trace/fixtures) (pkgs.lib.fileset.maybeMissing ./cli/src/services/patch/fixtures) (pkgs.lib.fileset.maybeMissing ./cli/src/services/structured_patch/fixtures) + (pkgs.lib.fileset.maybeMissing ./cli/src/services/hooks/claude_mutation_scope/fixtures) (pkgs.lib.fileset.maybeMissing ./cli/migrations) cliBuildInputFileset (pkgs.lib.fileset.maybeMissing ./cli/assets/hooks) From e98b99a4013512508d7f06e9d52e3999f2de7e5e Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 5 Sep 2026 00:10:55 +0200 Subject: [PATCH 06/11] hooks: Record detached-descendant lifecycle evidence Document the observed Claude Code lifecycle boundary for a foreground Bash call that launches a self-detaching descendant whose repository mutation occurs after PostToolUse. Add the captured hook payloads, update fixture notes, and reconcile D20/T04 in the integration plan without changing production behavior or adding process supervision. Plan: claude-mutation-scope-integration (T04) Co-authored-by: SCE --- .../claude_mutation_scope/fixtures/NOTES.md | 131 +++++++++++++- ...ed-child-after-post-tool-use.evidence.json | 24 +++ ...ild-after-post-tool-use.post_tool_use.json | 1 + ...hild-after-post-tool-use.pre_tool_use.json | 1 + .../claude-mutation-scope-integration.md | 166 +++++++++++++++++- 5 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.evidence.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.post_tool_use.json create mode 100644 cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.pre_tool_use.json diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md b/cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md index fd63139d..7f146ee4 100644 --- a/cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md @@ -1,4 +1,4 @@ -# T01 fixture capture notes +# T01/T04 fixture capture notes Raw Claude Code hook-event payloads captured live, in-session, by temporarily wiring a throwaway dump hook into this checkout's own `.claude/settings.json` @@ -43,6 +43,7 @@ wrote to the hook script's STDIN. | 14 | Explicit `run_in_background=true` Bash | captured | `probe14-run-in-background-true.*` | | 15 | `run_in_background=false` long-running Bash (**hard gate**) | captured — **PASS** | `probe15-run-in-background-false-hard-gate.*` | | 16 | (optional) `PostToolBatch` | captured | `probe16-post-tool-batch-optional.*` | +| 17 | T04 follow-up: foreground `Bash` starts a self-detaching descendant | captured | `probe17-detached-child-after-post-tool-use.*` — see T04 addendum below | 12 of 15 required probes captured with real, live payloads. Probes 5 and 9 could not be produced at all in this session (see below); probe 8 is answered @@ -189,3 +190,131 @@ uncaptured since D13 and D14/D16 do not depend on their exact payload shape for correctness — only on `session_id`/`tool_use_id` presence, which every other captured event already confirms is standard across this Claude Code version's hook payloads. + +## T04 addendum: detached-descendant Bash lifecycle probe + +**Revised 2026-09-05.** The original T04 capture wrote its marker to +`context/tmp/`, which `context/tmp/.gitignore` ignores wholesale. SCE's +`GitSnapshotService::capture_tree()` observes repository state through +`git read-tree HEAD` / `git add -A -- .` / `git write-tree`, which never sees +an ignored path. That evidence therefore only proved *a detached descendant +survives `PostToolUse` and writes an ignored file later* — not the stronger, +required claim that the descendant's mutation is one SCE's Git snapshot +would actually observe, and therefore one that falls outside the tool's +closed scope. This section replaces the original addendum with a corrected +capture using a non-ignored marker path and direct proof of Git-observability. +The two raw hook-payload fixtures below are real re-captured payloads from +the corrected rerun, not hand edits of the originals. + +Captured live, in-session, using the exact same methodology as T01: a scratch +dump hook (`.../scratchpad/hook-capture/capture17.sh`) was registered as an +*additional* `PreToolUse`/`PostToolUse` entry for the `Bash` matcher +(alongside, not replacing, the existing SCE hook entries) in +`.claude/settings.json`, dumping raw STDIN JSON plus a wall-clock capture +timestamp to per-event files. The scratch hook was removed from +`.claude/settings.json` before this task finished; see the task's `Files +changed` record. + +### Claude lifecycle evidence + +- **Tested Claude Code version:** `2.1.258` (`claude --version`), same as T01 + — still the version installed in this environment. +- **Session:** `3cd16464-cf03-44f5-825b-27296fd55c34`, captured 2026-09-05 + (UTC timestamps below fall on 2026-09-04, the session's UTC day). +- **Process pattern tested:** a foreground `Bash` call with **explicit** + `"run_in_background":false` present in the real captured `tool_input` + (not merely omitted and defaulted) running: + + ```bash + setsid bash -c 'sleep 3; date -u +%Y-%m-%dT%H:%M:%S.%6NZ > probe17-detached-child-write.marker' < /dev/null > /dev/null 2>&1 & + disown + echo "parent exiting at $(date -u +%Y-%m-%dT%H:%M:%S.%6NZ)" + ``` + + `setsid` starts the inner `bash -c '...'` in a new session, detached from + the invoking shell's session/controlling terminal — the shell-level + equivalent of Python `subprocess.Popen(..., start_new_session=True)` named + as an option in the plan. The backgrounding `&` plus `disown` and the + redirected stdio mean the invoked (parent) shell returns immediately + without waiting for the detached descendant; the descendant itself sleeps + 3 seconds, then writes its own wall-clock write-timestamp to + `probe17-detached-child-write.marker` at the repository root — a + deliberately **non-ignored** test-only path, verified below, not committed + as a file (removed immediately after evidence capture). +- Both the `PreToolUse` and `PostToolUse` fixtures carry the identical + `tool_use_id` `toolu_011DiMMHcxCZr6HzXWZhWzmD`, confirming they describe + the same tool-execution attempt. + +- **Observed timestamps** (all UTC): + - `t1` — `PreToolUse` (hook capture time): `2026-09-04T22:18:09.195Z` + (`probe17-detached-child-after-post-tool-use.pre_tool_use.json`). + - `t2` — `PostToolUse` (hook capture time): `2026-09-04T22:18:21.678Z` + (`probe17-detached-child-after-post-tool-use.post_tool_use.json`); + the payload's own `tool_response.stdout` independently confirms + `"parent exiting at 2026-09-04T22:18:21.668321Z"` with `duration_ms: 13` + — the invoked shell itself returned in 13ms, never waiting on the + detached descendant. + - `t3` — descendant's actual write, timestamped by the descendant process + itself (not the hook capture wrapper): `2026-09-04T22:18:24.674140Z` + (~3.00s after `t2`, matching the child's own `sleep 3`). + +- **Derived ordering:** `t1` (22:18:09.195) < `t2` (22:18:21.678) < `t3` + (22:18:24.674). **`PostToolUse` fired roughly three seconds before the + detached descendant actually wrote to the repository.** + +### Git-observable mutation evidence + +- **Marker path:** `probe17-detached-child-write.marker` (repository root). +- **Not gitignored:** `git check-ignore -v probe17-detached-child-write.marker` + exited `1` (no match) both before the probe ran and again after the + descendant's write; `git status --short -- probe17-detached-child-write.marker` + reported `?? probe17-detached-child-write.marker` — an untracked path Git + actually reports, not one silently swallowed by `.gitignore`. +- **Tree-hash proof, reproducing `GitSnapshotService::capture_tree()` + exactly:** using a `GIT_INDEX_FILE`-scoped temporary index (never the real + `.git/index`), `git read-tree HEAD` → `git add -A -- .` → `git write-tree` + was run twice — once with the marker present (post-child-write state) and + once with it removed (pre-child-write state), restoring the marker + immediately after: + - Tree **before** the child's write: `596fcceafa2ebf70a087f606d7e16645f18ee17e` + - Tree **after** the child's write: `b24d653632e478298b625e86c99f51f4016f9f57` + - The two tree hashes differ: **T1 ≠ T2**. The descendant's mutation is + Git-observable and would change the tree an SCE snapshot captures. +- The temporary marker file was deleted immediately after capture and does + not appear in the committed fixture set; see + `probe17-detached-child-after-post-tool-use.evidence.json` for the full + machine-readable capture metadata (versions, timestamps, tree hashes, + ignore-check result). + +### Derived SCE attribution consequence + +Chaining the two evidence sections: the adapter's mutation scope for this +`Bash` tool execution closes at `PostToolUse` (`t2`), observing the tree as +it stood at that moment. The detached descendant then mutates a +non-ignored, Git-tracked-by-`add -A` repository path at `t3`, changing the +tree an SCE snapshot would capture (`T1 ≠ T2` above) — strictly after the +scope already closed. **A foreground Claude `Bash` tool call can return +`PostToolUse` while a descendant it spawned remains alive and later performs +a mutation that changes SCE's observable Git tree; `PostToolUse` is +therefore not proof that all descendant mutation activity has ended.** + +### D20 disposition (T04) + +This matches the plan's first anticipated disposition exactly: the +descendant's mutation is observed to land *after* `PostToolUse`, and is now +directly proven Git-observable rather than merely surviving past +`PostToolUse`. Per the plan's own instruction, D20 is updated (not left as a +theoretical boundary) to record this as a **confirmed, observed** finding +for this specific process pattern (`setsid`-based shell detachment) and this +specific Claude Code version (`2.1.258`): a foreground +(`run_in_background=false`, explicitly captured as such) `Bash` call's +`PostToolUse` closes the adapter's mutation scope before a self-detaching +descendant it spawned goes on to mutate the repository in a way SCE's own +Git snapshot would observe, so that later mutation is **not** observed +inside the tool's own scope boundary and would be misattributed (or silently +dropped) if the adapter ever treated `PostToolUse` as proof no descendant +process is still running. This does **not** generalize to `nohup`, +double-fork, or daemonizing patterns this probe did not exercise — those +remain unproven, and D20's unsupported-boundary wording stays in place +regardless, since this PR implements no detection or supervision either way, +and no static shell-command inspection is added. diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.evidence.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.evidence.json new file mode 100644 index 00000000..383a6e90 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.evidence.json @@ -0,0 +1,24 @@ +{ + "claude_version": "2.1.258", + "session_id": "3cd16464-cf03-44f5-825b-27296fd55c34", + "tool_use_id": "toolu_011DiMMHcxCZr6HzXWZhWzmD", + "tool_name": "Bash", + "run_in_background": false, + "command": "setsid bash -c 'sleep 3; date -u +%Y-%m-%dT%H:%M:%S.%6NZ > probe17-detached-child-write.marker' < /dev/null > /dev/null 2>&1 &\ndisown\necho \"parent exiting at $(date -u +%Y-%m-%dT%H:%M:%S.%6NZ)\"", + "process_pattern": "setsid-detached-shell-child", + "pre_tool_use_captured_at": "2026-09-04T22:18:09.195Z", + "post_tool_use_captured_at": "2026-09-04T22:18:21.678Z", + "post_tool_use_tool_response_stdout_timestamp": "2026-09-04T22:18:21.668321Z", + "post_tool_use_duration_ms": 13, + "child_write_at": "2026-09-04T22:18:24.674140Z", + "marker_path": "probe17-detached-child-write.marker", + "marker_was_git_ignored": false, + "git_check_ignore_exit_code": 1, + "git_status_short_output": "?? probe17-detached-child-write.marker", + "tree_hash_before_child_write": "596fcceafa2ebf70a087f606d7e16645f18ee17e", + "tree_hash_after_child_write": "b24d653632e478298b625e86c99f51f4016f9f57", + "trees_differ": true, + "tree_computation_method": "GIT_INDEX_FILE-scoped git read-tree HEAD / git add -A -- . / git write-tree against a temporary index file, never the real .git/index", + "derived_ordering": "pre_tool_use_captured_at < post_tool_use_captured_at < child_write_at", + "marker_removed_after_capture": true +} diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.post_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.post_tool_use.json new file mode 100644 index 00000000..4b2422cc --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"3cd16464-cf03-44f5-825b-27296fd55c34","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/3cd16464-cf03-44f5-825b-27296fd55c34.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/3cd16464-cf03-44f5-825b-27296fd55c34/scratchpad","prompt_id":"2035cd2a-03b7-4628-ab72-3e700f490184","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"setsid bash -c 'sleep 3; date -u +%Y-%m-%dT%H:%M:%S.%6NZ > probe17-detached-child-write.marker' < /dev/null > /dev/null 2>&1 &\ndisown\necho \"parent exiting at $(date -u +%Y-%m-%dT%H:%M:%S.%6NZ)\"","description":"Launch a self-detaching descendant (foreground Bash, explicit run_in_background=false) writing a non-ignored repo file","run_in_background":false},"tool_response":{"stdout":"parent exiting at 2026-09-04T22:18:21.668321Z","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false},"tool_use_id":"toolu_011DiMMHcxCZr6HzXWZhWzmD","duration_ms":13} \ No newline at end of file diff --git a/cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.pre_tool_use.json b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.pre_tool_use.json new file mode 100644 index 00000000..13f6123e --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"3cd16464-cf03-44f5-825b-27296fd55c34","transcript_path":"/home/davidabram/.claude/projects/-home-davidabram-repos-shared-context-engineering/3cd16464-cf03-44f5-825b-27296fd55c34.jsonl","cwd":"/home/davidabram/repos/shared-context-engineering","scratchpad_dir":"/tmp/claude-1000/-home-davidabram-repos-shared-context-engineering/3cd16464-cf03-44f5-825b-27296fd55c34/scratchpad","prompt_id":"2035cd2a-03b7-4628-ab72-3e700f490184","permission_mode":"auto","effort":{"level":"high"},"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"setsid bash -c 'sleep 3; date -u +%Y-%m-%dT%H:%M:%S.%6NZ > probe17-detached-child-write.marker' < /dev/null > /dev/null 2>&1 &\ndisown\necho \"parent exiting at $(date -u +%Y-%m-%dT%H:%M:%S.%6NZ)\"","description":"Launch a self-detaching descendant (foreground Bash, explicit run_in_background=false) writing a non-ignored repo file","run_in_background":false},"tool_use_id":"toolu_011DiMMHcxCZr6HzXWZhWzmD"} \ No newline at end of file diff --git a/context/plans/claude-mutation-scope-integration.md b/context/plans/claude-mutation-scope-integration.md index 62d5bd80..96e44de1 100644 --- a/context/plans/claude-mutation-scope-integration.md +++ b/context/plans/claude-mutation-scope-integration.md @@ -278,7 +278,7 @@ gone. Only after a successful `flush` does the adapter clear `recovery_pending`; a failed `flush` keeps it fail-closed for subsequent mutation-capable `PreToolUse`. -### D20 — Detached background Bash/PowerShell is unsupported and denied — resolved by T01: PASS for Claude-managed backgrounding; self-detaching descendants are a separate, explicit unsupported boundary (T04 pending) +### D20 — Detached background Bash/PowerShell is unsupported and denied — resolved by T01: PASS for Claude-managed backgrounding; self-detaching descendants confirmed as a separate, explicit unsupported boundary by T04 A detached shell can keep mutating the repository after `PostToolUse` returns and can outlive a session; the generic mutation-scope contract has no process @@ -319,9 +319,49 @@ reliably catch, and this PR does not attempt one. Correct support would require process/process-group supervision, which is out of scope here (see Constraints and non-goals). This boundary holds regardless of what T04's probe observes for the specific pattern it tests, because no detection or -supervision is being added either way; T04 records the concrete observed -`PostToolUse`-vs-descendant-mutation ordering as evidence, and this section is -updated with that finding once T04 completes. +supervision is being added either way. + +**T04 finding (Claude Code `2.1.258`), corrected 2026-09-05:** the original +capture wrote its marker under `context/tmp/`, which `context/tmp/.gitignore` +ignores wholesale — `GitSnapshotService::capture_tree()`'s +`git read-tree HEAD` / `git add -A -- .` / `git write-tree` sequence never +sees an ignored path, so that capture only proved a detached descendant +*survives* `PostToolUse`, not that its mutation is one SCE's Git snapshot +would actually observe. T04 was rerun with a non-ignored, repository-root +marker path and explicit `"run_in_background":false` present in the real +captured `tool_input` (not merely omitted and defaulted): a foreground `Bash` +call ran `setsid bash -c '... sleep 3; ...' &` (a shell-level +self-detaching descendant, the `setsid`/backgrounding equivalent of Python +`subprocess.Popen(..., start_new_session=True)`) and returned in +`duration_ms: 13` — the invoked shell never waited on the detached child. +`PreToolUse` fired at `2026-09-04T22:18:09.195Z`, `PostToolUse` fired at +`2026-09-04T22:18:21.678Z`, and the detached descendant's own write landed at +`2026-09-04T22:18:24.674140Z` — roughly three seconds *after* `PostToolUse`, +matching the child's own `sleep 3`. `git check-ignore` confirmed the marker +path is not ignored, and a `GIT_INDEX_FILE`-scoped temporary index (never the +real `.git/index`) reproduced `capture_tree()`'s exact sequence before and +after the child's write, yielding two different tree hashes +(`596fcceafa2ebf70a087f606d7e16645f18ee17e` vs +`b24d653632e478298b625e86c99f51f4016f9f57`) — direct proof the descendant's +mutation is one SCE's snapshot model would observe. + +**The self-detaching-descendant boundary is therefore confirmed by direct, +Git-observable evidence, not merely theoretical**, for this process pattern +and this Claude Code version: a foreground Claude `Bash` tool call can return +`PostToolUse` — closing the adapter's mutation scope at the tree observed at +that moment — while a descendant it spawned remains alive and later performs +a mutation that changes SCE's observable Git tree; that later mutation falls +outside the tool's own observed scope boundary and would be misattributed +(or silently dropped) if the adapter ever treated `PostToolUse` as proof no +descendant process is still running or mutating the repository. This does +not generalize to `nohup`, double-fork, daemonization, interpreter, or other +descendant-detachment patterns T04 did not exercise — those remain unproven +— and the unsupported-boundary wording above stays in place regardless, +since this PR implements no detection or supervision either way and adds no +static shell-command inspection. See +`cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.*` +(including the `.evidence.json` capture-metadata artifact) and the T04 +addendum in `NOTES.md` for the full captured evidence. ### D21 — Raw Claude hook cwd is authoritative @@ -888,7 +928,7 @@ Persist this field in every plan; this is durable plan state, not chat state: for an ADR. Documentation of this state module is intentionally deferred to T09 per the plan's own task boundary. -- [ ] T04: `Capture the detached-descendant Bash lifecycle probe` (status:todo) +- [x] T04: `Capture the detached-descendant Bash lifecycle probe` (status:done) - Task ID: T04 - Scope: In — a T01 follow-up, using the same live-capture methodology against the same pinned Claude Code version (`2.1.258`, or whatever @@ -938,7 +978,121 @@ Persist this field in every plan; this is durable plan state, not chat state: `cli/src/services/hooks/claude_mutation_scope/fixtures/`; `NOTES.md` documents the observation and timestamps; this plan's D20 section reflects the reconciled finding. - - Context synchronization: pending + - Completed: 2026-09-04 + - Files changed: + - `cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.pre_tool_use.json` + (new, then replaced with a corrected real recapture — see PR #263 + follow-up below) + - `cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.post_tool_use.json` + (new, then replaced with a corrected real recapture for the same + `tool_use_id` — see PR #263 follow-up below) + - `cli/src/services/hooks/claude_mutation_scope/fixtures/probe17-detached-child-after-post-tool-use.evidence.json` + (new — PR #263 follow-up — machine-readable capture metadata: + versions, timestamps, `git check-ignore`/`git status` results, and the + before/after tree hashes) + - `cli/src/services/hooks/claude_mutation_scope/fixtures/NOTES.md` (update + — new probe-17 table row plus a "T04 addendum" section recording the + exact captured timestamps, the tested process pattern, and the + reconciled D20 disposition; rewritten in the PR #263 follow-up to + separate Claude lifecycle evidence from Git-observable mutation + evidence) + - `context/plans/claude-mutation-scope-integration.md` (this + reconciliation — D20 updated with the concrete T04 finding, then + corrected in the PR #263 follow-up) + - Result: Captured a real, live Claude Code `2.1.258` fixture for the + detached-descendant probe using T01's exact methodology (a scratch + `PreToolUse`/`PostToolUse` dump hook temporarily registered on the `Bash` + matcher in `.claude/settings.json`, alongside the existing SCE entries). + Drove one foreground `Bash` call (`run_in_background` omitted, i.e. + `false`) running `setsid bash -c '... sleep 3; ...' &` — a + shell-level self-detaching descendant, the `setsid`/backgrounding + equivalent of Python `subprocess.Popen(..., start_new_session=True)` + named in the task. The invoked shell returned in `duration_ms: 15` + (`tool_response.stdout` independently confirms `"parent exiting at + 2026-09-04T21:45:07.207863Z"`), never waiting on the detached child. + Observed timestamps: `PreToolUse` `2026-09-04T21:44:58.468Z`, + `PostToolUse` `2026-09-04T21:45:07.219Z`, descendant's own + repository-mutating write `2026-09-04T21:45:10.221976Z` (~3.00s after + `PostToolUse`, matching the child's `sleep 3`). **Ordering: the + descendant's mutation landed after `PostToolUse`** — the first of the + task's two anticipated dispositions. D20 was updated accordingly: the + self-detaching-descendant boundary is now a confirmed, observed finding + for this process pattern and Claude Code version, not merely a + theoretical one, without generalizing to `nohup`/double-fork/daemonizing + patterns this probe did not exercise. No production code, Rust module, + CLI wiring, process supervision, or protocol/schema change was made, + matching the task's Out-of-scope boundary; no already-committed T01 + fixture file was modified. `.claude/settings.json` was temporarily + modified during capture (with explicit user approval, since the + auto-mode classifier initially blocked the edit) and fully reverted + (byte-for-byte, confirmed via diff) before this task closed. + + **PR #263 follow-up (2026-09-05):** review found the original capture's + marker path (`context/tmp/probe17-detached-child-write.marker`) was + wholesale-ignored by `context/tmp/.gitignore`, so + `GitSnapshotService::capture_tree()`'s `git add -A -- .` would never see + it — the original evidence proved only that a detached descendant + survives `PostToolUse`, not that its mutation is Git-observable to SCE's + own snapshot model. T04 was rerun end-to-end with: (1) a non-ignored, + repository-root marker path + (`probe17-detached-child-write.marker`), verified via + `git check-ignore -v` (exit `1`, not ignored) both before the probe and + after the child's write; (2) explicit `"run_in_background":false` + present in the real captured `tool_input` (previously omitted, not + hand-edited in afterward); (3) the same `setsid`-based self-detaching + descendant pattern; and (4) a `GIT_INDEX_FILE`-scoped temporary index + (never the real `.git/index`) that reproduced + `capture_tree()`'s exact `git read-tree HEAD` / `git add -A -- .` / + `git write-tree` sequence before and after the child's write, yielding + two different tree hashes + (`596fcceafa2ebf70a087f606d7e16645f18ee17e` vs + `b24d653632e478298b625e86c99f51f4016f9f57` — T1 ≠ T2). New observed + timestamps: `PreToolUse` `2026-09-04T22:18:09.195Z`, `PostToolUse` + `2026-09-04T22:18:21.678Z` (`duration_ms: 13`), descendant's write + `2026-09-04T22:18:24.674140Z` (~3.00s after `PostToolUse`) — the same + `t1 < t2 < t3` ordering and disposition as before, now with direct proof + the descendant's mutation is Git-observable. The two raw fixture files + were replaced with these real recaptured payloads (same `tool_use_id` + `toolu_011DiMMHcxCZr6HzXWZhWzmD` across both), a new `.evidence.json` + artifact was added, `NOTES.md`'s T04 addendum was rewritten to separate + Claude lifecycle evidence from Git-observable mutation evidence and the + derived SCE attribution consequence, and D20 above was updated + accordingly. The temporary marker file was deleted after capture and is + not part of the committed fixture set. `.claude/settings.json` was again + temporarily modified (with explicit user approval) and fully reverted + (byte-for-byte, confirmed via diff) before this follow-up closed. No + production code, Rust module, CLI wiring, protocol, Quint, or schema + change was made; no already-committed T01 fixture file was touched. + - Verify: fixtures + `probe17-detached-child-after-post-tool-use.{pre_tool_use,post_tool_use,evidence}.json` + committed and referenced from this plan and from `NOTES.md`; `NOTES.md`'s + "T04 addendum" documents the exact observation and timestamps plus the + Git-observability proof; this plan's D20 section reflects the corrected, + reconciled finding — all satisfied. `git diff --stat` against the prior + commit confirms no file outside + `cli/src/services/hooks/claude_mutation_scope/fixtures/` and this plan + changed. + - Context impact: None beyond this plan. No Rust, Pkl, generated-settings, + schema, migration, Quint, or `context/cli|sce` file was changed; only new + fixture files, `NOTES.md`, and this plan's own D20/task record were + touched. T09 will incorporate this finding when it authors + `context/cli/claude-mutation-scope-integration.md` per the plan's + existing task boundary. + - Context synchronization: synced — root pass confirmed + `context/{overview,architecture,glossary,patterns,context-map}.md` contain + no mention of the detached-descendant finding and are unaffected; + `context/cli/mutation-scope-runtime.md` and + `context/cli/mutation-scope-hook-ingress.md` both already correctly state + that no concrete harness adapter is wired yet, which T04 leaves true (only + a research fixture, `NOTES.md`, and this plan's own D20 wording changed — + no adapter, CLI, or production code exists yet for D20's confirmed finding + to attach to). No feature, public interface, or observable behavior was + introduced. The detached-descendant boundary D20 now confirms was already + an established design decision in this plan before T04 ran; T04 supplied + empirical evidence for it rather than establishing a new system-wide + decision, so no ADR qualified. Documentation of this finding in durable + `context/cli` files is intentionally deferred to T09 per the plan's own + task boundary. - [ ] T05: `Expose the in-process generic-ingress seam` (status:todo) - Task ID: T05 From 57cdc1877a4d957f58d48610afa5cb703478ffb9 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 7 Sep 2026 09:43:25 +0200 Subject: [PATCH 07/11] hooks: Expose mutation-scope ingress to sibling adapters Make the existing normalized JSON ingress crate-visible so sibling hook modules can reuse it without an `sce` subprocess or a duplicate `RuntimeBoundary` construction path. Preserve the CLI command path and behavior unchanged. Plan: claude-mutation-scope-integration.md (T05) Co-authored-by: SCE --- cli/src/services/hooks/mutation_scope.rs | 2 +- .../claude-mutation-scope-integration.md | 40 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/cli/src/services/hooks/mutation_scope.rs b/cli/src/services/hooks/mutation_scope.rs index bbf0feb1..5a949f5d 100644 --- a/cli/src/services/hooks/mutation_scope.rs +++ b/cli/src/services/hooks/mutation_scope.rs @@ -191,7 +191,7 @@ pub(crate) fn run_mutation_scope_subcommand( run_mutation_scope_from_payload(repository_root, &stdin_payload, logger) } -fn run_mutation_scope_from_payload( +pub(crate) fn run_mutation_scope_from_payload( repository_root: &Path, stdin_payload: &str, logger: Option<&dyn Logger>, diff --git a/context/plans/claude-mutation-scope-integration.md b/context/plans/claude-mutation-scope-integration.md index 96e44de1..11e33356 100644 --- a/context/plans/claude-mutation-scope-integration.md +++ b/context/plans/claude-mutation-scope-integration.md @@ -1094,7 +1094,7 @@ Persist this field in every plan; this is durable plan state, not chat state: `context/cli` files is intentionally deferred to T09 per the plan's own task boundary. -- [ ] T05: `Expose the in-process generic-ingress seam` (status:todo) +- [x] T05: `Expose the in-process generic-ingress seam` (status:done) - Task ID: T05 - Scope: In — make the minimal crate-visible function on `cli/src/services/hooks/mutation_scope.rs` that runs a normalized JSON @@ -1109,7 +1109,43 @@ Persist this field in every plan; this is durable plan state, not chat state: `mutation_scope` command path is byte-for-byte unchanged in behavior, and `services::hooks::mutation_scope` tests still pass. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope`; `git diff` shows only a visibility/wrapper change. - - Context synchronization: pending + - Completed: 2026-09-07 + - Files changed: + - `cli/src/services/hooks/mutation_scope.rs` (visibility change only: + `fn run_mutation_scope_from_payload` -> `pub(crate) fn + run_mutation_scope_from_payload`, signature and body unchanged) + - Result: Made the existing private `run_mutation_scope_from_payload(repository_root, + stdin_payload, logger)` `pub(crate)`, reused verbatim per the plan's own + Assumptions section — no thin wrapper was needed since the existing + function already has the documented signature. `run_mutation_scope_subcommand` + (the public CLI entry point) still calls it identically, so the existing + `sce hooks mutation-scope` command path is byte-for-byte unchanged in + behavior. The seam is now callable from any sibling `hooks` module + (e.g. `claude_mutation_scope`, both declared `pub mod` under + `services::hooks`) without a second `RuntimeBoundary` construction path or + an `sce` subprocess. No new payload operation, no other behavior change. + - Verify: `services::hooks::mutation_scope` — 36 passed, 0 failed; + `clippy --all-targets -- -D warnings` — clean; `fmt -- --check` — clean; + `git diff` — exactly one file, one line changed (the visibility keyword), + confirming no wrapper or behavior drift. + - Context impact: None beyond this plan. The changed function is + `pub(crate)` (crate-internal visibility only, not a public CLI surface, + settings, or schema change) and is not yet called from anywhere (T06 wires + the first caller), so no `context/cli|sce` file needed an update for this + task. `context/cli/mutation-scope-hook-ingress.md` already documents this + seam as planned/upcoming (D23); T09 will update it to reflect the seam as + shipped, and to name its first consumer, once T06 wires the call. + - Context synchronization: synced — root pass confirmed + `context/{overview,architecture,glossary,patterns,context-map}.md` all + still correctly state that no concrete Claude Code (or other harness) + lifecycle adapter is wired to the mutation-scope ingress yet, which T05 + leaves true (the new `pub(crate)` visibility has no caller). No feature, + public interface, or observable behavior was introduced — the CLI's + `sce hooks mutation-scope` command path is byte-for-byte unchanged. No + decision qualified for an ADR (a private-to-crate visibility widening with + no behavior change is not a system-wide boundary or interface decision). + Documentation of this seam as consumed is intentionally deferred to T09 + per the plan's own task boundary, once T06 adds the first caller. - [ ] T06: `Claude adapter driver + CLI command` (status:todo) - Task ID: T06 From c5d4384d1640dc136314c6c16a6ed40ae745be8e Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 7 Sep 2026 10:20:29 +0200 Subject: [PATCH 08/11] hooks: Implement Claude mutation-scope adapter driver Add the hidden `sce hooks claude-mutation-scope` route and in-process driver that maps Claude lifecycle events to mutation-scope start, close, abandon, and flush operations with worktree-derived resolution and fail-closed PreToolUse behavior. Persist pending attempts before Start, retire failed or stale attempts behind a recovery barrier, and cover duplicate delivery, cleanup, background-shell, and barrier behavior with focused tests. Update the mutation-scope runtime and hook-routing contracts, and record completion of `claude-mutation-scope-integration` T06; setup registration remains pending. Co-authored-by: SCE --- cli/src/cli_schema.rs | 6 + .../hooks/claude_mutation_scope/mod.rs | 1664 ++++++++++++++++- .../hooks/claude_mutation_scope/state.rs | 67 + cli/src/services/hooks/mod.rs | 5 + cli/src/services/parse/command_runtime.rs | 32 + context/cli/mutation-scope-hook-ingress.md | 32 +- context/cli/mutation-scope-runtime.md | 39 +- context/context-map.md | 10 +- .../claude-mutation-scope-integration.md | 235 ++- .../sce/agent-trace-hooks-command-routing.md | 4 +- 10 files changed, 2055 insertions(+), 39 deletions(-) diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index 628ff4c0..4507f133 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -323,6 +323,12 @@ pub enum HooksSubcommand { #[command(about = "Run mutation-scope hook (reads JSON payload from STDIN)")] MutationScope, + + #[command( + about = "Run the Claude mutation-scope adapter (reads JSON payload from STDIN)", + hide = true + )] + ClaudeMutationScope, } #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] diff --git a/cli/src/services/hooks/claude_mutation_scope/mod.rs b/cli/src/services/hooks/claude_mutation_scope/mod.rs index 14680a81..89c9cc0e 100644 --- a/cli/src/services/hooks/claude_mutation_scope/mod.rs +++ b/cli/src/services/hooks/claude_mutation_scope/mod.rs @@ -2,8 +2,13 @@ pub(crate) mod state; +use std::path::{Path, PathBuf}; + use anyhow::{anyhow, bail, Context, Result}; -use serde_json::{Map, Value}; +use serde_json::{json, Map, Value}; + +use crate::services::checkout; +use crate::services::observability::traits::Logger; const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; const SESSION_ID_FIELD: &str = "session_id"; @@ -316,6 +321,378 @@ fn validation_error(detail: &str) -> String { format!("Invalid Claude hook event payload from STDIN: {detail}.") } +type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; + +type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; + +const ACTOR_KIND_CLAUDE_CODE: &str = "claude_code"; + +const FAIL_CLOSED_DENY_REASON: &str = + "SCE could not establish mutation attribution for this tool execution."; +const EXPLICIT_BACKGROUND_SHELL_DENY_REASON: &str = + "SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground."; + +const PRE_TOOL_USE_FAIL_CLOSED_EVENT: &str = + "sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed"; + +fn log_pre_tool_use_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { + if let Some(log) = logger { + log.warn( + PRE_TOOL_USE_FAIL_CLOSED_EVENT, + &error.to_string(), + &[("context", context)], + None, + ); + } +} + +pub(crate) fn run_claude_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { + let stdin_payload = super::read_hook_stdin()?; + run_claude_mutation_scope_from_payload(&stdin_payload, logger) +} + +pub(crate) fn run_claude_mutation_scope_from_payload( + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| checkout::resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + super::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) + }; + + run_claude_mutation_scope_from_payload_with( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + ) +} + +fn run_claude_mutation_scope_from_payload_with( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let event = parse_claude_hook_event(stdin_payload)?; + dispatch_claude_hook_event(event, logger, resolve_git_dir, seam) +} + +fn dispatch_claude_hook_event( + event: ClaudeHookEvent, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + match event { + ClaudeHookEvent::PreToolUse(execution) => Ok(handle_pre_tool_use( + &execution, + logger, + resolve_git_dir, + seam, + )), + ClaudeHookEvent::PostToolUse(identity) | ClaudeHookEvent::PostToolUseFailure(identity) => { + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + handle_close( + &git_dir, + repository_root, + &identity.attempt_key(), + logger, + seam, + ) + } + ClaudeHookEvent::PermissionDenied(identity) => { + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + handle_permission_denied( + &git_dir, + repository_root, + &identity.attempt_key(), + logger, + seam, + ) + } + ClaudeHookEvent::Stop(session) | ClaudeHookEvent::StopFailure(session) => { + let git_dir = resolve_git_dir(&session.cwd)?; + let repository_root = Path::new(&session.cwd); + let session_id = session.session_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.is_none() + }) + } + ClaudeHookEvent::UserPromptSubmit(session) => { + let git_dir = resolve_git_dir(&session.cwd)?; + let repository_root = Path::new(&session.cwd); + let session_id = session.session_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.is_none() + }) + } + ClaudeHookEvent::SubagentStop(agent) => { + let git_dir = resolve_git_dir(&agent.cwd)?; + let repository_root = Path::new(&agent.cwd); + let session_id = agent.session_id.clone(); + let agent_id = agent.agent_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.as_deref() == Some(&agent_id) + }) + } + ClaudeHookEvent::SessionEnd(session) => { + let git_dir = resolve_git_dir(&session.cwd)?; + let repository_root = Path::new(&session.cwd); + let session_id = session.session_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + }) + } + ClaudeHookEvent::WorktreeRemove(worktree_remove) => { + let git_dir = resolve_git_dir(&worktree_remove.worktree_path)?; + let repository_root = Path::new(&worktree_remove.worktree_path); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |_attempt| true) + } + ClaudeHookEvent::SessionStart | ClaudeHookEvent::SubagentStart => Ok(String::new()), + } +} + +fn handle_pre_tool_use( + execution: &ClaudeToolExecution, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> String { + let identity = &execution.identity; + + if matches!( + classify_tool(&identity.tool_name), + ToolClassification::ReadOnly | ToolClassification::Delegation + ) { + return String::new(); + } + + if is_explicit_background_shell(&identity.tool_name, execution.run_in_background) { + return pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON); + } + + let repository_root = Path::new(&identity.cwd); + let git_dir = match resolve_git_dir(&identity.cwd) { + Ok(git_dir) => git_dir, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "resolve_git_dir", &error); + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + }; + + if matches!( + apply_recovery_barrier(&git_dir, repository_root, logger, seam), + BarrierOutcome::Deny + ) { + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + + match establish_start(&git_dir, repository_root, identity, logger, seam) { + Ok(()) => String::new(), + Err(error) => { + log_pre_tool_use_fail_closed(logger, "establish_start", &error); + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) + } + } +} + +enum BarrierOutcome { + Proceed, + Deny, +} + +fn apply_recovery_barrier( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> BarrierOutcome { + let state = match state::read_state(git_dir) { + Ok(state) => state, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "recovery_barrier.read_state", &error); + return BarrierOutcome::Deny; + } + }; + + if !state.recovery_pending { + return BarrierOutcome::Proceed; + } + + if !state.attempts.is_empty() { + return BarrierOutcome::Deny; + } + + match seam(repository_root, &flush_payload(), logger) { + Ok(_) => match state::clear_recovery_pending(git_dir) { + Ok(()) => BarrierOutcome::Proceed, + Err(error) => { + log_pre_tool_use_fail_closed( + logger, + "recovery_barrier.clear_recovery_pending", + &error, + ); + BarrierOutcome::Deny + } + }, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "recovery_barrier.flush", &error); + BarrierOutcome::Deny + } + } +} + +fn establish_start( + git_dir: &Path, + repository_root: &Path, + identity: &ClaudeToolIdentity, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + let allocated = state::allocate_attempt(git_dir, &identity.attempt_key(), &identity.tool_name)?; + let scope_id = &allocated.attempt.scope_id; + let start_payload = + scope_boundary_payload("start", scope_id, &claude_scope_start_event_id(scope_id)); + + seam(repository_root, &start_payload, logger)?; + state::mark_active(git_dir, scope_id)?; + Ok(()) +} + +fn handle_close( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| attempt_matches_key(attempt, key)) + .cloned() + else { + return Ok(String::new()); + }; + + if attempt.phase == state::AttemptPhase::PendingStart { + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + return Ok(String::new()); + } + + let close_payload = scope_boundary_payload( + "close", + &attempt.scope_id, + &claude_scope_close_event_id(&attempt.scope_id), + ); + + if seam(repository_root, &close_payload, logger).is_ok() { + state::remove_attempt(git_dir, &attempt.scope_id)?; + } else { + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + } + Ok(String::new()) +} + +fn handle_permission_denied( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| attempt_matches_key(attempt, key)) + .cloned() + else { + return Ok(String::new()); + }; + + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + Ok(String::new()) +} + +fn cleanup_attempts_matching( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, + predicate: impl Fn(&state::AdapterAttempt) -> bool, +) -> Result { + let current = state::read_state(git_dir)?; + let stale: Vec = current + .attempts + .into_iter() + .filter(|attempt| predicate(attempt)) + .collect(); + + for attempt in &stale { + abandon_attempt(git_dir, repository_root, attempt, logger, seam)?; + } + + Ok(String::new()) +} + +fn attempt_matches_key(attempt: &state::AdapterAttempt, key: &AttemptKey) -> bool { + attempt.session_id == key.session_id + && attempt.agent_id == key.agent_id + && attempt.tool_use_id == key.tool_use_id +} + +fn abandon_attempt( + git_dir: &Path, + repository_root: &Path, + attempt: &state::AdapterAttempt, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + state::mark_recovery_pending(git_dir)?; + + seam(repository_root, &abandon_payload(&attempt.scope_id), logger)?; + state::remove_attempt(git_dir, &attempt.scope_id)?; + Ok(()) +} + +fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_CLAUDE_CODE, + }) + .to_string() +} + +fn abandon_payload(scope_id: &str) -> String { + json!({ + "operation": "abandon", + "scope_id": scope_id, + }) + .to_string() +} + +fn flush_payload() -> String { + json!({ "operation": "flush" }).to_string() +} + +fn pre_tool_use_deny_json(reason: &str) -> String { + json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + }) + .to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -967,4 +1344,1289 @@ mod tests { "attempt_key must depend only on (session_id, agent_id, tool_use_id)" ); } + + mod driver { + use std::cell::RefCell; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Mutex}; + + use anyhow::anyhow; + + use super::*; + + static NEXT_TEST_GIT_DIR_ID: AtomicU64 = AtomicU64::new(0); + + fn unique_test_git_dir(label: &str) -> PathBuf { + let id = NEXT_TEST_GIT_DIR_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-claude-mutation-scope-driver-{label}-{}-{id}", + std::process::id() + )) + } + + fn remove_test_git_dir(git_dir: &Path) { + let _ = std::fs::remove_dir_all(git_dir); + } + + #[allow(clippy::unnecessary_wraps)] + fn ok_seam(_root: &Path, _payload: &str, _logger: Option<&dyn Logger>) -> Result { + Ok(String::new()) + } + + fn unreachable_seam( + _root: &Path, + payload: &str, + _logger: Option<&dyn Logger>, + ) -> Result { + panic!("the ingress seam must not be called for this payload: {payload}"); + } + + fn seam_failing_on( + operation: &'static str, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + seam_failing_on_any(vec![operation]) + } + + fn seam_failing_on_any( + operations: Vec<&'static str>, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + move |_root, payload, _logger| { + if operations + .iter() + .any(|operation| payload.contains(&format!(r#""operation":"{operation}""#))) + { + Err(anyhow!( + "seam failure injected by test for one of {operations:?}" + )) + } else { + Ok(String::new()) + } + } + } + + fn fixed_resolver(git_dir: PathBuf) -> impl Fn(&str) -> Result { + move |_cwd| Ok(git_dir.clone()) + } + + #[derive(Clone, Default)] + struct RecordingLogger { + warnings: Arc>>, + } + + impl RecordingLogger { + fn warnings(&self) -> Vec<(String, String)> { + self.warnings + .lock() + .expect("recording logger mutex must not be poisoned") + .clone() + } + } + + impl Logger for RecordingLogger { + fn info(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + fn debug(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn warn(&self, event_id: &str, message: &str, _: &[(&str, &str)], _: Option<&str>) { + self.warnings + .lock() + .expect("recording logger mutex must not be poisoned") + .push((event_id.to_string(), message.to_string())); + } + + fn error(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn log_cli_error(&self, _: &crate::services::error::CliError, _: Option<&str>) {} + } + + fn session_scoped_payload(event_name: &str, session_id: &str, cwd: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + Value::Object(object).to_string() + } + + #[test] + fn read_only_tool_creates_no_scope_and_never_touches_the_seam_or_git_dir() { + let resolver = |_: &str| -> Result { + panic!("a read-only tool must never resolve a git dir") + }; + let payload = + pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Read".to_string()))]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("read-only PreToolUse should succeed"); + + assert_eq!(output, ""); + } + + #[test] + fn delegation_tool_creates_no_scope_ac3() { + let resolver = |_: &str| -> Result { + panic!("Agent delegation must never resolve a git dir") + }; + let payload = + pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Agent".to_string()))]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("Agent delegation PreToolUse should succeed"); + + assert_eq!(output, ""); + } + + #[test] + fn session_start_and_subagent_start_establish_no_scope_ac3() { + let resolver = |_: &str| -> Result { + panic!("a lifecycle-only event must never resolve a git dir") + }; + + for event_name in [HOOK_EVENT_SESSION_START, HOOK_EVENT_SUBAGENT_START] { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("a lifecycle-only event should succeed with no scope"); + assert_eq!(output, ""); + } + } + + #[test] + fn explicit_background_bash_is_denied_with_the_exact_reason_d20() { + let git_dir = unique_test_git_dir("explicit-background-bash"); + let resolver = fixed_resolver(git_dir.clone()); + let payload = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("Bash".to_string())), + ( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": true }), + ), + ]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("an explicit background shell should still return Ok with a deny payload"); + + assert_eq!( + output, + pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) + ); + assert!( + !git_dir.exists(), + "D20: denial must precede any adapter-state I/O" + ); + } + + #[test] + fn explicit_background_powershell_is_denied_ac21() { + let git_dir = unique_test_git_dir("explicit-background-powershell"); + let resolver = fixed_resolver(git_dir.clone()); + let payload = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("PowerShell".to_string())), + ( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": true }), + ), + ]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("explicit background PowerShell should be denied"); + + assert_eq!( + output, + pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) + ); + } + + #[test] + fn write_ahead_pending_start_persists_before_the_seam_start_call_ac7() { + let git_dir = unique_test_git_dir("write-ahead"); + let resolver = fixed_resolver(git_dir.clone()); + let git_dir_for_seam = git_dir.clone(); + let phase_seen_before_start: RefCell> = RefCell::new(None); + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = + |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"start""#) { + observed_roots.borrow_mut().push(root.to_path_buf()); + let observed = state::read_state(&git_dir_for_seam) + .expect("state should be readable under git_dir inside the seam call"); + *phase_seen_before_start.borrow_mut() = + observed.attempts.first().map(|attempt| attempt.phase); + } + Ok(String::new()) + }; + + let payload = pre_tool_use_json(&[]); + let output = + run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("mutation-capable PreToolUse should succeed"); + + assert_eq!(output, ""); + assert_eq!( + phase_seen_before_start.into_inner(), + Some(state::AttemptPhase::PendingStart), + "AC7: the attempt must be durably pending_start (under git_dir) before the seam Start call" + ); + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from("/repo/checkout")], + "the seam must receive the raw Claude cwd as repository_root, never the resolved git_dir" + ); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!( + final_state.attempts[0].phase, + state::AttemptPhase::Active, + "phase must become active after a successful Start" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn linked_worktree_cwd_and_git_dir_are_never_conflated_for_pre_tool_use_start() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-start"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = + |root: &Path, _payload: &str, _logger: Option<&dyn Logger>| -> Result { + observed_roots.borrow_mut().push(root.to_path_buf()); + Ok(String::new()) + }; + + let payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); + run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("PreToolUse Start should succeed"); + + assert_ne!( + PathBuf::from(raw_cwd), + git_dir, + "test sanity: the raw checkout path and the resolved git_dir must be deliberately distinct" + ); + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "the ingress seam must receive the raw Claude cwd, never git_dir" + ); + + let state = + state::read_state(&git_dir).expect("state should be readable under git_dir"); + assert_eq!( + state.attempts.len(), + 1, + "adapter bookkeeping must be written under the resolved git_dir" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_close_uses_git_dir_for_state_and_raw_cwd_for_the_seam() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-close"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = + |root: &Path, _payload: &str, _logger: Option<&dyn Logger>| -> Result { + observed_roots.borrow_mut().push(root.to_path_buf()); + Ok(String::new()) + }; + let post_payload = pre_tool_use_json(&[ + (CWD_FIELD, Value::String(raw_cwd.to_string())), + ( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + ), + ]); + run_claude_mutation_scope_from_payload_with(&post_payload, None, &resolver, &seam) + .expect("PostToolUse Close should succeed"); + + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "Close must invoke the seam with the raw Claude cwd, never git_dir" + ); + + let state = + state::read_state(&git_dir).expect("state should be readable under git_dir"); + assert!( + state.attempts.is_empty(), + "the closed attempt must be removed from git_dir bookkeeping" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn abandon_via_permission_denied_uses_git_dir_for_state_and_raw_cwd_for_the_seam() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-abandon"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = + |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"abandon""#) { + observed_roots.borrow_mut().push(root.to_path_buf()); + } + Ok(String::new()) + }; + let denied_payload = pre_tool_use_json(&[ + (CWD_FIELD, Value::String(raw_cwd.to_string())), + ( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_PERMISSION_DENIED.to_string()), + ), + ]); + run_claude_mutation_scope_from_payload_with(&denied_payload, None, &resolver, &seam) + .expect("PermissionDenied should succeed"); + + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "Abandon must invoke the seam with the raw Claude cwd, never git_dir" + ); + + let state = + state::read_state(&git_dir).expect("state should be readable under git_dir"); + assert!(state.attempts.is_empty()); + assert!(state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flush_uses_raw_cwd_for_the_seam_and_git_dir_for_state() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-flush"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seed allocation should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seed attempt should succeed"); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = + |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"flush""#) { + observed_roots.borrow_mut().push(root.to_path_buf()); + } + Ok(String::new()) + }; + + let new_pre = pre_tool_use_json(&[ + (CWD_FIELD, Value::String(raw_cwd.to_string())), + (TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) + .expect("quiescent recovery should flush against the raw checkout path"); + + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "flush must run against the raw Claude cwd, not git_dir" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn duplicate_live_pre_tool_use_reuses_the_same_scope_and_start_event_id_ac4() { + let git_dir = unique_test_git_dir("duplicate-delivery"); + let resolver = fixed_resolver(git_dir.clone()); + let start_event_ids: RefCell> = RefCell::new(Vec::new()); + let seam = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"start""#) { + let value: Value = serde_json::from_str(payload).unwrap(); + start_event_ids + .borrow_mut() + .push(value["event_id"].as_str().unwrap().to_string()); + } + Ok(String::new()) + }; + + let payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("first delivery should succeed"); + run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("duplicate delivery should succeed"); + + let ids = start_event_ids.into_inner(); + assert_eq!(ids.len(), 2); + assert_eq!( + ids[0], ids[1], + "AC4: duplicate delivery must reuse the same Start EventId" + ); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "duplicate delivery must not create a second bookkeeping entry" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn seam_start_failure_denies_and_leaves_the_attempt_pending_start_d8_d11() { + let git_dir = unique_test_git_dir("start-failure"); + let resolver = fixed_resolver(git_dir.clone()); + let seam = seam_failing_on("start"); + + let payload = pre_tool_use_json(&[]); + let output = + run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect( + "a Start failure must still return Ok with a deny payload, not propagate", + ); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!( + final_state.attempts[0].phase, + state::AttemptPhase::PendingStart, + "D11: a failed Start must not be marked active nor removed" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn pending_start_attempt_is_abandoned_not_late_started_on_a_terminal_signal_d11() { + let git_dir = unique_test_git_dir("pending-start-then-terminal"); + let resolver = fixed_resolver(git_dir.clone()); + + let start_failing_seam = seam_failing_on("start"); + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with( + &pre_payload, + None, + &resolver, + &start_failing_seam, + ) + .expect("the failed Start must still return Ok with a deny payload"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let recording = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + let post_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let output = run_claude_mutation_scope_from_payload_with( + &post_payload, + None, + &resolver, + &recording, + ) + .expect("PostToolUse for a pending_start attempt should succeed"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!(operations.len(), 1); + assert!( + operations[0].contains(r#""operation":"abandon""#), + "D11: a pending_start attempt must be abandoned, not late-started, got: {operations:?}" + ); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn close_seam_failure_is_retired_through_abandonment_not_a_replayed_close_d12() { + let git_dir = unique_test_git_dir("close-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let close_failing_seam = seam_failing_on("close"); + let post_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let output = run_claude_mutation_scope_from_payload_with( + &post_payload, + None, + &resolver, + &close_failing_seam, + ) + .expect("a Close failure must still succeed via abandonment"); + + assert_eq!(output, ""); + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!( + final_state.attempts.is_empty(), + "D12: after abandonment the attempt must be retired" + ); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_failure_also_closes_the_scope_d10() { + let git_dir = unique_test_git_dir("post-tool-use-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let recording = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + let failure_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE_FAILURE.to_string()), + )]); + let output = run_claude_mutation_scope_from_payload_with( + &failure_payload, + None, + &resolver, + &recording, + ) + .expect("PostToolUseFailure should close the scope"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!(operations.len(), 1); + assert!(operations[0].contains(r#""operation":"close""#)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_with_no_live_attempt_is_a_safe_no_op_d9() { + let payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let resolver = + fixed_resolver(std::env::temp_dir().join("sce-unused-nonexistent-git-dir")); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("a PostToolUse with no live attempt must be a safe no-op"); + + assert_eq!(output, ""); + } + + #[test] + fn permission_denied_abandons_a_live_attempt_d13() { + let git_dir = unique_test_git_dir("permission-denied"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let recording = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + let denied_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_PERMISSION_DENIED.to_string()), + )]); + let output = run_claude_mutation_scope_from_payload_with( + &denied_payload, + None, + &resolver, + &recording, + ) + .expect("PermissionDenied should succeed"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!(operations.len(), 1); + assert!(operations[0].contains(r#""operation":"abandon""#)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn stop_abandons_only_stale_main_thread_attempts_d14() { + let git_dir = unique_test_git_dir("stop-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + let subagent_pre = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("subagent PreToolUse should succeed"); + + let stop_payload = + session_scoped_payload(HOOK_EVENT_STOP, "session-1", "/repo/checkout"); + let output = run_claude_mutation_scope_from_payload_with( + &stop_payload, + None, + &resolver, + &ok_seam, + ) + .expect("Stop cleanup should succeed"); + assert_eq!(output, ""); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "only the subagent attempt should remain" + ); + assert_eq!(final_state.attempts[0].tool_use_id, "toolu_agent"); + assert!( + final_state.recovery_pending, + "abandoning the stale main attempt must arm the barrier" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn stop_failure_abandons_stale_main_thread_attempts_the_same_way_d15() { + let git_dir = unique_test_git_dir("stop-failure-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + + let stop_failure_payload = + session_scoped_payload(HOOK_EVENT_STOP_FAILURE, "session-1", "/repo/checkout"); + run_claude_mutation_scope_from_payload_with( + &stop_failure_payload, + None, + &resolver, + &ok_seam, + ) + .expect("StopFailure cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn user_prompt_submit_abandons_only_stale_main_thread_attempts_d16() { + let git_dir = unique_test_git_dir("user-prompt-submit-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + let subagent_pre = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("subagent PreToolUse should succeed"); + + let prompt_payload = session_scoped_payload( + HOOK_EVENT_USER_PROMPT_SUBMIT, + "session-1", + "/repo/checkout", + ); + run_claude_mutation_scope_from_payload_with(&prompt_payload, None, &resolver, &ok_seam) + .expect("UserPromptSubmit cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "the subagent attempt must survive" + ); + assert_eq!(final_state.attempts[0].tool_use_id, "toolu_agent"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn subagent_stop_abandons_only_the_matching_agent_id_attempts_d17() { + let git_dir = unique_test_git_dir("subagent-stop-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let first_agent_payload = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_a".to_string())), + (AGENT_ID_FIELD, Value::String("agent-a".to_string())), + ]); + let second_agent_payload = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_b".to_string())), + (AGENT_ID_FIELD, Value::String("agent-b".to_string())), + ]); + run_claude_mutation_scope_from_payload_with( + &first_agent_payload, + None, + &resolver, + &ok_seam, + ) + .expect("agent-a PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with( + &second_agent_payload, + None, + &resolver, + &ok_seam, + ) + .expect("agent-b PreToolUse should succeed"); + + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("/repo/checkout".to_string()), + ); + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String("agent-a".to_string()), + ); + let subagent_stop_payload = Value::Object(object).to_string(); + + run_claude_mutation_scope_from_payload_with( + &subagent_stop_payload, + None, + &resolver, + &ok_seam, + ) + .expect("SubagentStop cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "toolu_b"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn session_end_abandons_every_attempt_regardless_of_agent_id_d18() { + let git_dir = unique_test_git_dir("session-end-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + let subagent_pre = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("subagent PreToolUse should succeed"); + + let session_end_payload = + session_scoped_payload(HOOK_EVENT_SESSION_END, "session-1", "/repo/checkout"); + run_claude_mutation_scope_from_payload_with( + &session_end_payload, + None, + &resolver, + &ok_seam, + ) + .expect("SessionEnd cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn worktree_remove_resolves_git_dir_from_worktree_path_not_cwd_d22() { + let main_git_dir = unique_test_git_dir("worktree-remove-main"); + let worktree_git_dir = unique_test_git_dir("worktree-remove-isolated"); + let main_git_dir_for_resolver = main_git_dir.clone(); + let worktree_git_dir_for_resolver = worktree_git_dir.clone(); + + let resolver = move |cwd: &str| -> Result { + if cwd == "/repo/.claude/worktrees/agent-1" { + Ok(worktree_git_dir_for_resolver.clone()) + } else { + Ok(main_git_dir_for_resolver.clone()) + } + }; + + let subagent_pre = pre_tool_use_json(&[ + ( + CWD_FIELD, + Value::String("/repo/.claude/worktrees/agent-1".to_string()), + ), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("isolated-worktree PreToolUse should succeed"); + + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + WORKTREE_PATH_FIELD.to_string(), + Value::String("/repo/.claude/worktrees/agent-1".to_string()), + ); + let worktree_remove_payload = Value::Object(object).to_string(); + + run_claude_mutation_scope_from_payload_with( + &worktree_remove_payload, + None, + &resolver, + &ok_seam, + ) + .expect("WorktreeRemove cleanup should succeed"); + + let worktree_state = + state::read_state(&worktree_git_dir).expect("worktree state should be readable"); + assert!( + worktree_state.attempts.is_empty(), + "D22: WorktreeRemove must retire attempts under the worktree_path's git dir" + ); + + remove_test_git_dir(&main_git_dir); + remove_test_git_dir(&worktree_git_dir); + } + + #[test] + fn recovery_barrier_denies_new_mutation_capable_pre_tool_use_while_attempts_remain_d19() { + let git_dir = unique_test_git_dir("barrier-attempts-remain"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + let surviving = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_surviving".to_string(), + }, + "Write", + ) + .expect("seeding a surviving attempt should succeed"); + let retiring = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_retiring".to_string(), + }, + "Write", + ) + .expect("seeding a retiring attempt should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &retiring.attempt.scope_id) + .expect("removing the retiring attempt should succeed"); + let _ = surviving; + + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = run_claude_mutation_scope_from_payload_with( + &new_pre, + None, + &resolver, + &unreachable_seam, + ) + .expect("D19: barrier denial must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flushes_once_quiescent_and_clears_before_starting_d19() { + let git_dir = unique_test_git_dir("barrier-flush-success"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seeding the retired attempt should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seeded attempt should succeed"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let seam = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = + run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) + .expect("D19: a quiescent recovery should flush then proceed"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!( + operations.len(), + 2, + "expected flush then start, got: {operations:?}" + ); + assert!(operations[0].contains(r#""operation":"flush""#)); + assert!(operations[1].contains(r#""operation":"start""#)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!( + !final_state.recovery_pending, + "a successful flush must clear the barrier" + ); + assert_eq!(final_state.attempts.len(), 1); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_stays_fail_closed_when_flush_fails_d19() { + let git_dir = unique_test_git_dir("barrier-flush-failure"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seeding the retired attempt should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seeded attempt should succeed"); + + let seam = seam_failing_on("flush"); + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = + run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) + .expect("D19: a failed flush must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!( + final_state.recovery_pending, + "a failed flush must keep the barrier armed" + ); + assert!( + final_state.attempts.is_empty(), + "a denied PreToolUse must not allocate a new attempt" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_close_and_failed_abandon_keep_recovery_armed_and_the_attempt_tracked_d12_d19() { + let git_dir = unique_test_git_dir("close-and-abandon-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let failing_seam = seam_failing_on_any(vec!["close", "abandon"]); + let post_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let error = run_claude_mutation_scope_from_payload_with( + &post_payload, + None, + &resolver, + &failing_seam, + ) + .expect_err( + "a failed Close followed by a failed Abandon must propagate, not silently succeed", + ); + assert!(error.to_string().contains("abandon")); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "D12: an attempt whose abandonment failed must remain tracked" + ); + assert!( + final_state.recovery_pending, + "D19: recovery must be armed even though abandonment itself failed" + ); + + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = run_claude_mutation_scope_from_payload_with( + &new_pre, + None, + &resolver, + &unreachable_seam, + ) + .expect("the barrier denial must still return Ok with a deny payload"); + assert_eq!( + output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "the next mutation-capable PreToolUse must be denied, and no new Start may occur" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn lifecycle_cleanup_with_a_failed_abandon_keeps_recovery_armed_and_the_attempt_tracked() { + let git_dir = unique_test_git_dir("lifecycle-cleanup-abandon-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + + let abandon_failing_seam = seam_failing_on("abandon"); + let stop_payload = + session_scoped_payload(HOOK_EVENT_STOP, "session-1", "/repo/checkout"); + let error = run_claude_mutation_scope_from_payload_with( + &stop_payload, + None, + &resolver, + &abandon_failing_seam, + ) + .expect_err("a failed abandonment during Stop cleanup must propagate"); + assert!(error.to_string().contains("abandon")); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "the attempt whose abandonment failed must remain tracked" + ); + assert!( + final_state.recovery_pending, + "the barrier must remain armed even though cleanup abandonment failed" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn resolver_failure_logs_the_detailed_error_and_denies_with_the_stable_reason_ac8_d8() { + let logger = RecordingLogger::default(); + let resolver = |_: &str| -> Result { + Err(anyhow!("boom: git rev-parse --git-dir failed")) + }; + + let payload = pre_tool_use_json(&[]); + let output = run_claude_mutation_scope_from_payload_with( + &payload, + Some(&logger), + &resolver, + &unreachable_seam, + ) + .expect("a resolver failure must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + assert!( + !output.contains("boom"), + "the detailed internal error must never leak into Claude's deny reason" + ); + assert!( + !output.contains("allow"), + "a fail-closed PreToolUse must never emit an allow decision" + ); + + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert!( + warnings[0].1.contains("boom"), + "the detailed error must be logged for operators, got: {warnings:?}" + ); + } + + #[test] + fn start_seam_failure_logs_the_detailed_error_and_denies_with_the_stable_reason() { + let git_dir = unique_test_git_dir("start-failure-logged"); + let resolver = fixed_resolver(git_dir.clone()); + let logger = RecordingLogger::default(); + let seam = seam_failing_on("start"); + + let payload = pre_tool_use_json(&[]); + let output = run_claude_mutation_scope_from_payload_with( + &payload, + Some(&logger), + &resolver, + &seam, + ) + .expect("a Start failure must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let warnings = logger.warnings(); + assert!(!warnings.is_empty(), "the Start failure must be logged"); + assert!(warnings + .iter() + .any(|(_, message)| message.to_lowercase().contains("start"))); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flush_failure_logs_the_detailed_error() { + let git_dir = unique_test_git_dir("barrier-flush-failure-logged"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + let logger = RecordingLogger::default(); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seed allocation should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seeded attempt should succeed"); + + let seam = seam_failing_on("flush"); + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = run_claude_mutation_scope_from_payload_with( + &new_pre, + Some(&logger), + &resolver, + &seam, + ) + .expect("a failed flush must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let warnings = logger.warnings(); + assert!(!warnings.is_empty(), "the flush failure must be logged"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn malformed_payload_propagates_as_a_real_error_not_fail_open() { + let error = run_claude_mutation_scope_from_payload("not json", None).unwrap_err(); + assert!(error.to_string().contains("valid JSON")); + } + } } diff --git a/cli/src/services/hooks/claude_mutation_scope/state.rs b/cli/src/services/hooks/claude_mutation_scope/state.rs index 7d83b0c5..aa920de5 100644 --- a/cli/src/services/hooks/claude_mutation_scope/state.rs +++ b/cli/src/services/hooks/claude_mutation_scope/state.rs @@ -332,6 +332,24 @@ pub(crate) fn remove_attempt(git_dir: &Path, scope_id: &str) -> Result<()> { write_state_durably(git_dir, &state) } +pub(crate) fn mark_recovery_pending(git_dir: &Path) -> Result<()> { + let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) + .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; + + let mut state = read_state(git_dir)?; + state.recovery_pending = true; + write_state_durably(git_dir, &state) +} + +pub(crate) fn clear_recovery_pending(git_dir: &Path) -> Result<()> { + let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) + .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; + + let mut state = read_state(git_dir)?; + state.recovery_pending = false; + write_state_durably(git_dir, &state) +} + #[cfg(test)] mod tests { use std::sync::atomic::{AtomicU64, Ordering}; @@ -684,6 +702,55 @@ mod tests { remove_test_git_dir(&git_dir); } + #[test] + fn mark_recovery_pending_arms_the_barrier_without_touching_attempts() { + let git_dir = unique_test_git_dir("mark-recovery-pending"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + + mark_recovery_pending(&git_dir).expect("marking recovery pending should succeed"); + + let state = read_state(&git_dir).expect("state should be readable"); + assert!(state.recovery_pending, "D19: the barrier must be armed"); + assert_eq!( + state.attempts.len(), + 1, + "marking recovery pending must not remove or otherwise touch tracked attempts" + ); + assert_eq!(state.attempts[0].scope_id, allocated.attempt.scope_id); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mark_recovery_pending_is_idempotent() { + let git_dir = unique_test_git_dir("mark-recovery-pending-idempotent"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + mark_recovery_pending(&git_dir).expect("first marking should succeed"); + mark_recovery_pending(&git_dir).expect("second marking should succeed"); + + let state = read_state(&git_dir).expect("state should be readable"); + assert!(state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn clear_recovery_pending_resets_the_barrier() { + let git_dir = unique_test_git_dir("clear-recovery-pending"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + mark_recovery_pending(&git_dir).expect("marking recovery pending should succeed"); + + clear_recovery_pending(&git_dir).expect("clearing the barrier should succeed"); + + let state = read_state(&git_dir).expect("state should be readable"); + assert!(!state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + #[test] fn adapter_state_files_live_only_below_git_dir_sce() { let git_dir = unique_test_git_dir("path-boundary"); diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 5f777403..1af3648d 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -103,6 +103,7 @@ pub enum HookSubcommand { Codex, ClaudeModelState, MutationScope, + ClaudeMutationScope, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -247,6 +248,9 @@ fn run_hooks_subcommand_in_repo( HookSubcommand::MutationScope => { mutation_scope::run_mutation_scope_subcommand(repository_root, logger) } + HookSubcommand::ClaudeMutationScope => { + claude_mutation_scope::run_claude_mutation_scope_subcommand(logger) + } } } @@ -1958,6 +1962,7 @@ fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str { HookSubcommand::Codex => "codex runtime invocation", HookSubcommand::ClaudeModelState => "Claude model-state runtime invocation", HookSubcommand::MutationScope => "mutation-scope runtime invocation", + HookSubcommand::ClaudeMutationScope => "Claude mutation-scope runtime invocation", } } diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 410f6b22..92becb5d 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -507,6 +507,9 @@ fn convert_hooks_subcommand_request( cli_schema::HooksSubcommand::MutationScope => { Ok(services::hooks::HookSubcommand::MutationScope) } + cli_schema::HooksSubcommand::ClaudeMutationScope => { + Ok(services::hooks::HookSubcommand::ClaudeMutationScope) + } } } @@ -601,6 +604,35 @@ mod tests { ); } + #[test] + fn claude_mutation_scope_hook_parses_to_hook_subcommand() { + let command = parse(&["sce", "hooks", "claude-mutation-scope"]); + + let RuntimeCommand::Hooks(command) = command else { + panic!("expected hooks command"); + }; + + assert_eq!( + command.subcommand, + services::hooks::HookSubcommand::ClaudeMutationScope + ); + } + + #[test] + fn claude_mutation_scope_hook_is_hidden_from_hooks_help() { + let help = + cli_schema::render_help_for_path(&["hooks"]).expect("hooks help should be renderable"); + + assert!( + !help.contains("claude-mutation-scope"), + "AC1: claude-mutation-scope must not be listed in `sce hooks --help`, got: {help}" + ); + assert!( + help.contains("mutation-scope"), + "sanity check: the still-visible mutation-scope command should remain listed, got: {help}" + ); + } + #[test] fn sync_json_format_parses_to_sync_request() { let command = parse(&["sce", "sync", "--format", "json"]); diff --git a/context/cli/mutation-scope-hook-ingress.md b/context/cli/mutation-scope-hook-ingress.md index 80629258..d5f94ead 100644 --- a/context/cli/mutation-scope-hook-ingress.md +++ b/context/cli/mutation-scope-hook-ingress.md @@ -210,20 +210,28 @@ serde derives for this command; the hook transport enum ## Generic ingress vs harness adapter A generic SCE ingress existing is **not** concrete harness integration existing. -Out of scope for this seam, and left as future work: - -- any concrete harness mapping — Claude Code hooks, Codex hook mapping, OpenCode - plugin, Pi extension; -- `SubagentStart` / `SubagentStop` / `PostToolUse` / tool-call translation; -- `session → ScopeId` or `tool-call → EventId` derivation; +A first Claude Code adapter driver now exists +(`cli/src/services/hooks/claude_mutation_scope/`), but it reaches the runtime +through its own `pub(crate)` in-process seam on `mutation_scope.rs` +(`run_mutation_scope_from_payload`) rather than by re-invoking this CLI +command, and it is not yet reachable by a real Claude Code session (`sce +setup` does not register its hooks yet — future work). Still out of scope for +this seam itself, and left as future work for every non-Claude harness: + +- Codex hook mapping, OpenCode plugin, Pi extension; +- `SubagentStart` / `SubagentStop` / `PostToolUse` / tool-call translation for + those harnesses; +- `session → ScopeId` or `tool-call → EventId` derivation for those harnesses; - PID tracking, process supervisors, staleness detection, automatic scope abandonment; -- harness settings generation or `sce setup` integration for the new hook. - -Each future adapter still owns its own `ScopeId` / `EventId` / `actor_kind` -derivation and its own stale-process detection, and targets this ingress as its -transport. See [`mutation-scope-runtime.md`](mutation-scope-runtime.md) for the -lifecycle obligations every such adapter must uphold. +- harness settings generation or `sce setup` integration for any of these + hooks (Claude's own registration is also still pending). + +Each adapter still owns its own `ScopeId` / `EventId` / `actor_kind` +derivation and its own stale-process detection, and targets this ingress (or, +for an in-process consumer like the Claude driver, the same seam directly) as +its transport. See [`mutation-scope-runtime.md`](mutation-scope-runtime.md) +for the lifecycle obligations every such adapter must uphold. ## Related context diff --git a/context/cli/mutation-scope-runtime.md b/context/cli/mutation-scope-runtime.md index a5a714bc..70e834e5 100644 --- a/context/cli/mutation-scope-runtime.md +++ b/context/cli/mutation-scope-runtime.md @@ -7,10 +7,10 @@ uphold when it drives that surface. Built by the `mutation-scope-runtime-integration` plan (`context/plans/mutation-scope-runtime-integration.md`). The generic `sce hooks mutation-scope` CLI ingress -([`mutation-scope-hook-ingress.md`](mutation-scope-hook-ingress.md)) now drives -this seam, but **no concrete harness adapter (Codex, Claude Code, OpenCode, Pi) -is wired to it yet.** This file is the contract a future harness adapter is -written against, not shipped adapter behavior. See the Status section below. +([`mutation-scope-hook-ingress.md`](mutation-scope-hook-ingress.md)) and a +first, not-yet-user-reachable Claude Code adapter driver (Codex/OpenCode/Pi: +none yet — see Status) both drive this seam. This file is the contract every +harness adapter is written against, not shipped-adapter behavior. The mechanics behind each entrypoint live in their own domain files: [`mutation-trace-runtime-coordinator.md`](mutation-trace-runtime-coordinator.md) @@ -53,14 +53,13 @@ reachable from `git_snapshot`, `external_taint`, `worktree_lock`, `runtime`, as does `reconcile_worktree`. An adapter drives the runtime only through the two entrypoints; it never assembles the safety prefix itself. -The re-exports remain the intentional crate-visible runtime seam. The generic -`sce hooks mutation-scope` hook ingress now consumes that seam, while the runtime -submodules and the safety-prefix implementation stay private and no concrete -harness adapter calls a runtime internal directly. Both seam re-export statements -still carry `#[allow(unused_imports)]` in `runtime/mod.rs`: the ingress matches -most of the surface but not the two names that only complete it -(`ExternalTaintOperation`, `AbandonRecoveryReason`), which -`clippy --all-targets -- -D warnings` would otherwise flag. +The re-exports remain the intentional crate-visible runtime seam; the generic +`sce hooks mutation-scope` ingress consumes it while the runtime submodules and +safety-prefix implementation stay private. Both re-export statements still +carry `#[allow(unused_imports)]` in `runtime/mod.rs`, since no consumer yet +names the two completing types (`ExternalTaintOperation`, +`AbandonRecoveryReason`), which `clippy --all-targets -- -D warnings` would +otherwise flag. ## What a mutation scope is @@ -247,9 +246,13 @@ rather than failing open. Full transport/normalization contract in [`mutation-scope-hook-ingress.md`](mutation-scope-hook-ingress.md); routing in [agent-trace hooks command routing](../sce/agent-trace-hooks-command-routing.md). -A generic SCE ingress existing is not concrete harness integration existing. No -Claude Code, Codex, OpenCode, or Pi lifecycle adapter is wired yet — none of -those harnesses emits mutation-scope events, and each still owns its own -`ScopeId` / `EventId` derivation and stale-process detection as this contract -requires. Repository-scoped cleanup of unowned checkout identities is likewise -still open. +A generic ingress existing is not full harness integration existing. A first +Claude Code adapter driver (`cli/src/services/hooks/claude_mutation_scope/`, +hidden CLI command `sce hooks claude-mutation-scope`) now maps Claude's hook +events onto this contract via the `pub(crate)` in-process seam +`mutation_scope::run_mutation_scope_from_payload`, but `sce setup` does not +yet register its hooks, so no real Claude Code session reaches it. Codex, +OpenCode, and Pi have no adapter at all. Each still owns its own `ScopeId` / +`EventId` derivation and stale-process detection this contract requires; +repository-scoped unowned-checkout cleanup is likewise still open. The Claude +adapter's dedicated contract file lands once the full adapter ships. diff --git a/context/context-map.md b/context/context-map.md index 5e215196..3f305379 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -23,16 +23,16 @@ Feature/domain context: - `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys including config-file/default `log_to_file`, `log_dir` / `SCE_LOG_DIR` with `/sce/logs` defaulting plus config-file/default-only positive `log_file_retention_limit`, config-file-only `agent_trace.repository_id`/`agent_trace.repository_remote` repository-identity keys with default remote `origin`, default-enabled `agent_trace.auto_sync` boolean resolution with explicit-false opt-out for the post-commit trigger boundary, the catalog-derived `integrations.optional_workflows` optional-workflow selection key, JSON-pointer-prefixed schema-validation errors, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract including `policies.attribution_hooks.enabled` default-true/explicit-false opt-out metadata, config-file selection order, `show` provenance output, and trimmed `validate` output contract) - `context/cli/capability-traits.md` (current broad CLI capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, compile-time-typed borrowed AppContext wiring with associated-type narrow capability accessors plus `ContextWithRepoRoot` repo-root-scoped context derivation, generic command execution bounds, and test-only unimplemented stubs; current service internals do not consume fs/git traits until later lifecycle migration tasks) - `context/cli/service-lifecycle.md` (current compile-safe lifecycle seam in `cli/src/services/lifecycle.rs`, including default no-op `ServiceLifecycle` diagnose/fix/setup methods against narrow `HasRepoRoot`, lifecycle-owned health/fix/setup result types with generic setup messages, doctor/setup adapter boundaries, the static `LifecycleProvider` enum catalog/dispatcher, hook/config/local_db/auth_db/agent_trace_db lifecycle providers including setup-time repository-scoped Agent Trace DB initialization plus checkout identity diagnostics, implemented doctor aggregation over diagnose/fix providers, and implemented setup aggregation over `setup` providers in order config → local_db → auth_db → agent_trace_db → hooks when requested) -- `context/cli/mutation-trace-protocol.md` (pure, dependency-free `cli/src/services/mutation_trace/` refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol: the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — plus cross-action sequence/invariant tests and a module-level Quint refinement matrix are all complete (`types.rs`, `protocol.rs`, `mod.rs`, `tests.rs`, registered with `#[allow(dead_code)]`); opaque-newtype identity refinement decisions vs. the Quint model's bounded enums, and the `coordinator.rs` target end-state seam this layout leaves room for but does not create — `store.rs`, `runtime/git_snapshot.rs`, and `runtime/coordinator.rs` (including its public `coordinate()` entrypoint) now exist, built out by the `mutation-cursor-store-persistence` and `mutation-cursor-runtime-coordinator` plans respectively; `protocol.rs` is invoked only through the runtime, and the runtime is now driven by the generic `sce hooks mutation-scope` CLI ingress, with no concrete harness lifecycle adapter wired) +- `context/cli/mutation-trace-protocol.md` (pure, dependency-free `cli/src/services/mutation_trace/` refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol: the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — plus cross-action sequence/invariant tests and a module-level Quint refinement matrix are all complete (`types.rs`, `protocol.rs`, `mod.rs`, `tests.rs`, registered with `#[allow(dead_code)]`); opaque-newtype identity refinement decisions vs. the Quint model's bounded enums, and the `coordinator.rs` target end-state seam this layout leaves room for but does not create — `store.rs`, `runtime/git_snapshot.rs`, and `runtime/coordinator.rs` (including its public `coordinate()` entrypoint) now exist, built out by the `mutation-cursor-store-persistence` and `mutation-cursor-runtime-coordinator` plans respectively; `protocol.rs` is invoked only through the runtime, and the runtime is now driven by the generic `sce hooks mutation-scope` CLI ingress and a first, not-yet-user-reachable Claude Code adapter driver) - `context/cli/mutation-trace-agent-attribution.md` (causal mutation-lineage attribution: direct target-shaped coverage is resolved first, then the newest-128 events for the invoking worktree (bounded also by a commit attribution cut — `revision <= latest_mutation_event_revision` captured under the worktree lock) are replayed oldest-to-newest as one ordered sequence of tree transitions. `lineage.rs` is a pure module propagating per-line `LineProvenance` (`Unknown` / `MutationAi{scope}` / `MutationNonAi`) forward through structurally-applied hunks: context carries, removed is permanently deleted, added takes only the introducing transition's origin, replacement never transfers by text. A committed line is AI only if an AI event's line survives every later transition — including a history-gap reload and the unobserved latest-tree→commit-tree tail — into the committed tree; anything unproven stays `Unknown`. Historical patches are never matched against the committed patch. `attribution.rs` keeps only `exclude_direct_coverage` + `patch_for_locations`. The `runtime/mutation_attribution.rs` consumer drives it over `MutationEventPageSource` + `TreeReadSource` (`diff_trees` + `file_at_tree`) seams with conservative fail-closed reloads; wired into post-commit via the read-only `runtime::resolve_post_commit_mutation_ai_patch` entrypoint — existing checkout identity only, direct-only fallback on absent identity/history/unavailable cut, no identity creation, no mutation-cursor write, `diff_traces` / `post_commit_patch_intersections` unchanged; see `agent-trace-hooks-command-routing.md` and `agent-trace-minimal-generator.md` — with real Git/DB post-commit regressions in `cli/src/services/hooks/mod.rs` and a real 128/129-horizon regression in `runtime/tests.rs`) - `context/cli/mutation-trace-revision-refinement.md` (the Quint `revision: int` → Rust `WorktreeState::revision: u64` bounded-integer refinement: the private `next_revision` checked-arithmetic helper `commit`/`taint`/`abandon`/`recover` all route through instead of a raw `+ 1`, so a worktree at `revision: u64::MAX` is a guarded no-op/rejection rather than a silent wrap to `0`) - `context/cli/mutation-trace-quint-connect.md` (`#[cfg(test)]`-only Quint Connect model-based-testing harness in `cli/src/services/mutation_trace/mbt/` continuously checking `protocol.rs` against `spec/mutation_cursor.qnt`: the verification-only `mbtAction`/`MbtAction` record-payload transport excluded from comparison, the operation-identity-vs-`MbtStutter` distinction on guarded/no-op branches with its two deterministic regressions, finite ID mapping, the AC5 comparable-state field list, `randomPrepare` staying a single `step` branch, deterministic/generated (500×30, seed-reproducible) test coverage, and the two Nix checks — generic `checks.cli-tests` and dedicated `checks.mutation-trace-quint-connect` — that both require the pinned Quint binary plus the top-level `spec/` directory in `workspaceSrc`'s Nix fileset) - `context/cli/mutation-trace-store.md` (durable persistence for the mutation-cursor protocol in `cli/src/services/mutation_trace/store.rs`, built by the `mutation-cursor-store-persistence` plan: the one-directional `protocol.rs` -> `DurableTransition::between` (pure structural diff) -> `store.rs` (SQL translation) -> `RepositoryAgentTraceDb` boundary; migration `004_mutation_trace_protocol.sql`'s five tables; `AttemptState`/`external_taint` non-persistence; the 8-byte big-endian `BLOB` revision encoding plus explicit non-`Debug` enum codecs; the bounded hot-path `load_worktree` read vs. the cold-path `load_mutation_event` and descending, exact-worktree, cursor-paged `load_mutation_event_page` reader capped at 32 rows; the public cold-path single-row `load_scope` scope seam that returns one `mutation_trace_scopes` row as `Option` without widening into a projection and, unlike `load_worktree`, never adjudicates worktree identity (a cross-worktree scope is returned as-is, and the mismatch is the caller's decision); the cold-path read-only `load_tree_roots` (one worktree) / `load_all_tree_roots` (repository-wide) durable-tree-SHA queries for ref reconciliation, each a single-statement `UNION` of `cursor_tree`/`before_tree`/`after_tree` read from one DB snapshot; `commit`'s single-`BEGIN IMMEDIATE` CAS batch via `TursoDb::execute_transactional_cas_batch`, distinguishing `Conflict`/retryable-transient/deterministic-`Err` outcomes; and the store's non-goals — no Git/filesystem I/O, no attribution/boundary-kind decisions, no retry-after-`Conflict` loop, no row deletion) -- `context/cli/mutation-trace-runtime-coordinator.md` (imperative-shell runtime layer in `cli/src/services/mutation_trace/runtime/`, built by the `mutation-cursor-runtime-coordinator` plan: the per-worktree OS advisory lock, `runtime::worktree_lock::WorktreeLock::acquire(git_dir, timeout)` at `/sce/mutation-cursor.lock`, bounded `try_lock()` polling with a distinct matchable timeout error and RAII release, and its distinction from the separate checkout-identity-creation lock; the isolated Git snapshot service `runtime::git_snapshot::GitSnapshotService` (documented in `mutation-trace-snapshot-service.md`); and `runtime::coordinator`'s protocol-integration pipeline (`RuntimeBoundary`/`CoordinateOutcome`/`CoordinateError`, the load → recover-if-needed → prepare/commit CAS-retry loop, and its own bounded snapshot-failure taint-retry loop) plus the public `coordinate(repository_root, boundary, open_db)` entrypoint that runs the shared `runtime::protected_worktree` prefix (`WorktreeLock` -> external-taint write-ahead fence -> `WorktreeId`, extracted so a second entrypoint cannot drift from it and documented in `mutation-trace-protected-worktree.md`), invokes the caller-supplied `open_db` provider so DB acquisition falls inside that fence, drives the pipeline under one held lock, and clears the marker through `ProtectedWorktree::complete()` only on success; `runtime/tests.rs` covers the public `coordinate()` API end to end against real linked worktrees and a real Agent Trace DB; an inherited external-taint marker is overlaid onto `protocol::database_failure` recovery on the next invocation, against the single captured snapshot and re-injected across a losing recovery CAS; the private `runtime::ref_reconciliation` per-worktree snapshot-ref maintenance pass (documented in `mutation-trace-ref-reconciliation.md`) shares the same `WorktreeLock`; `coordinate()` and `abandon_scope()` are now `pub(crate)` re-exported from `runtime/mod.rs` (documented in `mutation-scope-runtime.md`) while the runtime submodules themselves stay private; a generic `sce hooks mutation-scope` CLI ingress now drives both entrypoints, but no concrete harness lifecycle adapter is wired) +- `context/cli/mutation-trace-runtime-coordinator.md` (imperative-shell runtime layer in `cli/src/services/mutation_trace/runtime/`, built by the `mutation-cursor-runtime-coordinator` plan: the per-worktree OS advisory lock, `runtime::worktree_lock::WorktreeLock::acquire(git_dir, timeout)` at `/sce/mutation-cursor.lock`, bounded `try_lock()` polling with a distinct matchable timeout error and RAII release, and its distinction from the separate checkout-identity-creation lock; the isolated Git snapshot service `runtime::git_snapshot::GitSnapshotService` (documented in `mutation-trace-snapshot-service.md`); and `runtime::coordinator`'s protocol-integration pipeline (`RuntimeBoundary`/`CoordinateOutcome`/`CoordinateError`, the load → recover-if-needed → prepare/commit CAS-retry loop, and its own bounded snapshot-failure taint-retry loop) plus the public `coordinate(repository_root, boundary, open_db)` entrypoint that runs the shared `runtime::protected_worktree` prefix (`WorktreeLock` -> external-taint write-ahead fence -> `WorktreeId`, extracted so a second entrypoint cannot drift from it and documented in `mutation-trace-protected-worktree.md`), invokes the caller-supplied `open_db` provider so DB acquisition falls inside that fence, drives the pipeline under one held lock, and clears the marker through `ProtectedWorktree::complete()` only on success; `runtime/tests.rs` covers the public `coordinate()` API end to end against real linked worktrees and a real Agent Trace DB; an inherited external-taint marker is overlaid onto `protocol::database_failure` recovery on the next invocation, against the single captured snapshot and re-injected across a losing recovery CAS; the private `runtime::ref_reconciliation` per-worktree snapshot-ref maintenance pass (documented in `mutation-trace-ref-reconciliation.md`) shares the same `WorktreeLock`; `coordinate()` and `abandon_scope()` are now `pub(crate)` re-exported from `runtime/mod.rs` (documented in `mutation-scope-runtime.md`) while the runtime submodules themselves stay private; a generic `sce hooks mutation-scope` CLI ingress now drives both entrypoints, joined by a first, not-yet-user-reachable Claude Code adapter driver documented in `mutation-scope-runtime.md`) - `context/cli/mutation-trace-protected-worktree.md` (the shared safety prefix every mutation-cursor runtime entrypoint runs behind, in `cli/src/services/mutation_trace/runtime/protected_worktree.rs`, extracted from `coordinate()` by the `mutation-scope-runtime-integration` plan so a second entrypoint cannot drift from it: `ProtectedWorktree::acquire(repository_root)` running the safety-critical fixed order resolve `git_dir` → `WorktreeLock` (module-owned 10s `WORKTREE_LOCK_TIMEOUT`) → `ExternalTaintMarker::exists()` → `persist()` (fence armed write-ahead of every fallible step that follows, including DB acquisition) → `get_or_create_checkout_id` as `WorktreeId`; the `worktree_id()` / `inherited_external_taint()` / consuming `complete()` surface, where `complete()` clears the marker under the still-held lock and is the only thing that ever clears it while `Drop` releases only the lock; and the one-variant-per-prefix-step `ProtectedWorktreeError` (`GitDirResolution` | `LockAcquisition` | `ExternalTaintMarker { operation, source }` | `CheckoutIdentity`) each entrypoint maps onto its own error surface — `coordinate()` onto exactly the `CoordinateError` variants that step produced before the extraction) - `context/cli/mutation-trace-scope-abandonment.md` (the mutation-cursor runtime's second protected entrypoint in `cli/src/services/mutation_trace/runtime/scope_runtime.rs`, built by the `mutation-scope-runtime-integration` plan and the first production call site for `protocol::abandon`: `abandon_scope(repository_root, scope, open_db) -> Result` retires a scope whose final worktree boundary was never observed, sharing `coordinate()`'s `ProtectedWorktree` prefix but deliberately capturing **no** Git snapshot, pin, diff, reconciliation, scope registration, or worktree initialization — so abandonment is not a `RuntimeBoundary` and needs no Quint change; the classify-before-transition order that recovers what `protocol::abandon`'s uniform guarded no-op cannot report (`load_scope` first for the missing-row and cross-worktree cases the projection seam treats as errors, then `load_worktree` for the `NeverSeen` / terminal / `Active` split); the `Abandoned` / `AlreadyTerminal` / `RecoveryRequired` outcomes and the `InheritedExternalTaint` | `MissingScope` | `NeverSeenScope` | `MissingWorktreeState` recovery reasons; the inherited-marker short-circuit that returns before the DB provider is ever invoked; the fence-completion rule that clears the marker only for a settled abandonment or proven-terminal no-op and leaves it armed for every recovery-required outcome and every error, with `MarkerClearAfterCompletion` carrying the already-settled outcome; the CAS retry bounded by the coordinator's shared `MAX_CAS_RETRY_ATTEMPTS`, settling on a competitor's terminal status rather than overwriting it; and the deliberate false-negative tradeoff whereby a missing or `NeverSeen` target forces conservative strong recovery that may abandon unrelated live scopes, because attribution safety outranks preserving potentially valid evidence) -- `context/cli/mutation-scope-runtime.md` (the crate-visible mutation-trace runtime seam and the lifecycle contract every future harness adapter — Codex, Claude Code, OpenCode, Pi — must uphold, recorded by the `mutation-scope-runtime-integration` plan: the nine `pub(crate)` re-exports in `runtime/mod.rs` (`coordinate`, `RuntimeBoundary`, `CoordinateOutcome`, `CoordinateError`, `ExternalTaintOperation`, `abandon_scope`, `AbandonScopeOutcome`, `AbandonRecoveryReason`, `AbandonScopeError`), with `ExternalTaintOperation` riding through `coordinator.rs`'s own `pub use` because `CoordinateError::ExternalTaintMarker` carries it — so the type becomes crate-visible without `protected_worktree` becoming a public module — while every `mod` declaration stays private and `ProtectedWorktree`/`ProtectedWorktreeError`/`WORKTREE_LOCK_TIMEOUT`/`reconcile_worktree` stay internal, kept clippy-clean by `#[allow(unused_imports)]` on the re-exports per the `services/style.rs` precedent rather than a placeholder consumer; and the adapter obligations themselves — a scope is one independently mutation-capable execution so a concurrent main agent and subagent need distinct `ScopeId`s, `Start`/`Advance`/`Close` semantics including that a failed tool still requires `Advance` (the boundary is an observation, not a successful edit) and that a `ScopeId` is never reused after a terminal status (the `NeverSeen` guard silently refuses to reactivate it), `abandon_scope()` requiring positive staleness evidence and never inferring it from `ActorKind`, the `abandon` → `coordinate(Start(successor))` sequence with what each outcome implies for it including that a failed abandonment must never be treated as a safely started successor, that abandonment is not a `RuntimeBoundary` and needs no Quint change, the D1 tradeoff whereby a missing or `NeverSeen` target deliberately forces conservative strong recovery that may invalidate unrelated live scopes because attribution safety outranks preserving potentially valid evidence, and the attribution boundary that `AiExclusive(scope)` means scope exclusivity only and is not standalone proof that no human edited the worktree; a generic `sce hooks mutation-scope` ingress now drives the seam, but no concrete harness lifecycle adapter is wired yet) -- `context/cli/mutation-scope-hook-ingress.md` (the one harness-neutral CLI ingress that drives the mutation-scope runtime, in `cli/src/services/hooks/mutation_scope.rs`, built by the `mutation-scope-hook-ingress` plan: the hidden `sce hooks mutation-scope` command routing through the normal `cli_schema::HooksSubcommand::MutationScope` → `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` → `run_hooks_subcommand_in_repo` stack, reading one normalized JSON lifecycle object from STDIN; the strict `parse_mutation_scope_payload` contract supporting exactly `start`/`advance`/`close`/`flush`/`abandon` with a local `MutationScopePayload` transport enum, `claude_code`/`codex`/`opencode`/`pi` actor mapping, non-blank `scope_id`/`event_id`, exact per-operation key sets, and a dedicated hard rejection for any `worktree_id` key; the operation mapping to `RuntimeBoundary::Start`/`Advance`/`Close`/`Flush` through `coordinate()` or a direct `abandon_scope()` call, forwarding `ScopeId`/`EventId`/`ActorKind` verbatim because `EventId` equality is the runtime replay/idempotency key; identity ownership — the adapter owns `scope_id`/`event_id`/`actor_kind`, SCE owns `worktree_id`/Git tree identities/revisions/attempt IDs, and worktree identity is derived only by the runtime from the invoking checkout; the lazy `FnOnce` DB provider reusing `open_agent_trace_db_for_hook_runtime` so DB acquisition stays inside the runtime's protected-worktree ordering; the non-fail-open error classification by durable completion — malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` → `CliError`/non-zero, while `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are treated as durable success with empty stdout, the marker-cleanup failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion`, and the transition not retried; the empty-stdout success contract; abandonment ownership keeping no-snapshot semantics; and the generic-ingress vs harness-adapter boundary — no concrete Claude Code/Codex/OpenCode/Pi lifecycle adapter, no session→`ScopeId` / tool-call→`EventId` derivation, no PID/staleness detection) +- `context/cli/mutation-scope-runtime.md` (the crate-visible mutation-trace runtime seam and the lifecycle contract every future harness adapter — Codex, Claude Code, OpenCode, Pi — must uphold, recorded by the `mutation-scope-runtime-integration` plan: the nine `pub(crate)` re-exports in `runtime/mod.rs` (`coordinate`, `RuntimeBoundary`, `CoordinateOutcome`, `CoordinateError`, `ExternalTaintOperation`, `abandon_scope`, `AbandonScopeOutcome`, `AbandonRecoveryReason`, `AbandonScopeError`), with `ExternalTaintOperation` riding through `coordinator.rs`'s own `pub use` because `CoordinateError::ExternalTaintMarker` carries it — so the type becomes crate-visible without `protected_worktree` becoming a public module — while every `mod` declaration stays private and `ProtectedWorktree`/`ProtectedWorktreeError`/`WORKTREE_LOCK_TIMEOUT`/`reconcile_worktree` stay internal, kept clippy-clean by `#[allow(unused_imports)]` on the re-exports per the `services/style.rs` precedent rather than a placeholder consumer; and the adapter obligations themselves — a scope is one independently mutation-capable execution so a concurrent main agent and subagent need distinct `ScopeId`s, `Start`/`Advance`/`Close` semantics including that a failed tool still requires `Advance` (the boundary is an observation, not a successful edit) and that a `ScopeId` is never reused after a terminal status (the `NeverSeen` guard silently refuses to reactivate it), `abandon_scope()` requiring positive staleness evidence and never inferring it from `ActorKind`, the `abandon` → `coordinate(Start(successor))` sequence with what each outcome implies for it including that a failed abandonment must never be treated as a safely started successor, that abandonment is not a `RuntimeBoundary` and needs no Quint change, the D1 tradeoff whereby a missing or `NeverSeen` target deliberately forces conservative strong recovery that may invalidate unrelated live scopes because attribution safety outranks preserving potentially valid evidence, and the attribution boundary that `AiExclusive(scope)` means scope exclusivity only and is not standalone proof that no human edited the worktree; a generic `sce hooks mutation-scope` ingress now drives the seam, joined by a first, not-yet-user-reachable Claude Code adapter driver — Codex/OpenCode/Pi remain unwired) +- `context/cli/mutation-scope-hook-ingress.md` (the one harness-neutral CLI ingress that drives the mutation-scope runtime, in `cli/src/services/hooks/mutation_scope.rs`, built by the `mutation-scope-hook-ingress` plan: the hidden `sce hooks mutation-scope` command routing through the normal `cli_schema::HooksSubcommand::MutationScope` → `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` → `run_hooks_subcommand_in_repo` stack, reading one normalized JSON lifecycle object from STDIN; the strict `parse_mutation_scope_payload` contract supporting exactly `start`/`advance`/`close`/`flush`/`abandon` with a local `MutationScopePayload` transport enum, `claude_code`/`codex`/`opencode`/`pi` actor mapping, non-blank `scope_id`/`event_id`, exact per-operation key sets, and a dedicated hard rejection for any `worktree_id` key; the operation mapping to `RuntimeBoundary::Start`/`Advance`/`Close`/`Flush` through `coordinate()` or a direct `abandon_scope()` call, forwarding `ScopeId`/`EventId`/`ActorKind` verbatim because `EventId` equality is the runtime replay/idempotency key; identity ownership — the adapter owns `scope_id`/`event_id`/`actor_kind`, SCE owns `worktree_id`/Git tree identities/revisions/attempt IDs, and worktree identity is derived only by the runtime from the invoking checkout; the lazy `FnOnce` DB provider reusing `open_agent_trace_db_for_hook_runtime` so DB acquisition stays inside the runtime's protected-worktree ordering; the non-fail-open error classification by durable completion — malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` → `CliError`/non-zero, while `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are treated as durable success with empty stdout, the marker-cleanup failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion`, and the transition not retried; the empty-stdout success contract; abandonment ownership keeping no-snapshot semantics; and the generic-ingress vs harness-adapter boundary — a first, not-yet-user-reachable Claude Code adapter driver now exists (`cli/src/services/hooks/claude_mutation_scope/`, consuming this seam's own `pub(crate)` in-process entrypoint); Codex/OpenCode/Pi still have no lifecycle adapter, no session→`ScopeId` / tool-call→`EventId` derivation, no PID/staleness detection) - `context/cli/mutation-trace-ref-reconciliation.md` (the conservative per-worktree snapshot-ref reconciliation pass in `cli/src/services/mutation_trace/runtime/ref_reconciliation.rs`, built by the `mutation-cursor-ref-reconciliation` plan: `reconcile_worktree(repository_root, open_db)` → `pub(super) reconcile_worktree_inner(.., on_lock_contention)`, both returning `ReconciliationOutcome` (`Reconciled(ReconciliationReport { local_required, retained, deleted })` for a pass that ran | `SkippedNoCheckoutIdentity` — an `Ok`, not an `Err` — when no current checkout identity could be derived), a variant-per-fallible-step `ReconcileError` with no `Other`, and the module-owned `RECONCILIATION_LOCK_TIMEOUT`; the two-invariant model — a strictly per-worktree fail-closed local-consistency check via `load_tree_roots(W)` vs. a repository-wide deletion-safety set via `load_all_tree_roots()` so an `A`-owned ref is retained whenever any worktree still durably needs its tree — run entirely under the same `/sce/mutation-cursor.lock` `WorktreeLock` `coordinate()` holds, with the repository-wide read kept coherent by being one SQL statement / one DB snapshot rather than a repository-global lock; deletes only SCE-owned refs via one atomic `git update-ref --no-deref --stdin`, writes no `mutation_trace_*` row, never arms `ExternalTaintMarker`, runs no `git gc`; imperative durability maintenance below the verified Quint protocol; reclaims orphan/unreferenced refs only for the namespace of a checkout id a current worktree still derives — a namespace no current worktree owns (identity-based: a deleted linked worktree, or checkout-id metadata loss followed by `get_or_create_checkout_id` minting a fresh id on a still-present worktree) is beyond every per-worktree pass and left to a recorded future repository-scoped unowned-namespace operation, so the current pass does not bound all orphan-ref growth; `reconcile_worktree` has no `pub(crate)` re-export and no harness/command wiring yet) - `context/cli/mutation-trace-snapshot-service.md` (the isolated Git snapshot and ref-pinning service `runtime::git_snapshot::GitSnapshotService` in `cli/src/services/mutation_trace/runtime/git_snapshot.rs`: `new` resolving an absolute `git_dir`, `capture_tree` snapshotting staged/unstaged/untracked/deleted worktree state into the repository's normal object database via a throwaway temp index, `pin_tree` protecting a durable tree with a create-only idempotent **direct** `refs/sce/mutation-cursor//` ref, `diff_trees` emitting `patch.rs`-parseable raw diff text; plus the callerless reconciliation substrate — worktree-scoped `list_pins` inventory returning `Result, PinInventoryError>` that rejects a symbolic ref inside the namespace (mutation-cursor pins are direct refs) as `MalformedRef`, matchable separately from a `git for-each-ref` execution failure, and conditional-atomic `delete_pins` running one `git update-ref --no-deref --stdin` transaction of SHA-conditioned deletes — no-dereference so an inventory→delete direct-ref→symref race cannot escape the inventoried namespace, plus a fail-closed pre-check — that aborts whole if any ref changed since inventory; `REF_NAMESPACE` + `pin_ref_prefix` as the single source of truth for the pin path) - `context/cli/mutation-trace-external-taint.md` (the worktree-local mutation-cursor durability boundary in `cli/src/services/mutation_trace/runtime/external_taint.rs`, built by the `mutation-cursor-external-taint` plan: the `ExternalTaintMarker` primitive — `new(git_dir)`/`exists()`/`persist()`/`clear()` over an empty file at `/sce/mutation-cursor-tainted` whose existence is its entire state, `checkout::persist_checkout_id_inner`-style durability (`sync_data` plus best-effort `#[cfg(unix)]` parent-dir `sync_all`), idempotent persist/clear, `NotFound`-on-clear as success, no `Drop` deletion — as the concrete runtime refinement of the abstract `ProtocolState.external_taint`; armed by the reshaped `coordinate()` entrypoint write-ahead after the `WorktreeLock` and before Agent Trace DB acquisition (a caller-supplied DB provider closure), cleared only on a successful `CoordinateOutcome`, with dedicated fail-closed pre-commit `CoordinateError::ExternalTaintMarker` (`Inspect`/`Persist` only)/`AgentTraceDbUnavailable` variants plus a post-commit `MarkerClearAfterCommit { source, committed }` that carries the durable outcome so a failed trailing clear never hides a committed `MutationEvent`; an inherited marker seeds an invocation-local `external_taint_pending` flag that overlays `protocol::database_failure` onto each freshly loaded projection so `recover` runs once against the captured snapshot, held across a losing recovery CAS and cleared once it lands) @@ -82,7 +82,7 @@ Feature/domain context: - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) - `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,mixed,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact touched-line attribution counts from canonical `post_commit_patch` hunks reusing the same per-hunk classification, plus per-file trace data from patch inputs via `intersect_patches(constructed_patch, post_commit_patch)` then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation with a required lookup `url` derived from top-level `AgentTrace.id`, nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, optional canonical session links derived from matched touched-line provenance, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) -- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence, DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — deferring the full contract to `context/cli/mutation-scope-hook-ingress.md`.) +- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence, DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — plus the hidden non-fail-open `sce hooks claude-mutation-scope` first-concrete-adapter route (not yet registered by `sce setup`) — deferring the full contracts to `context/cli/mutation-scope-hook-ingress.md`.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus current Rust evaluator seam and OpenCode/Claude delegation references, including config schema, argv-prefix matching, shell/nix unwrapping, custom-policy `satisfied_by` wrapper exemption, fixed preset catalog/messages, and precedence rules) - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) diff --git a/context/plans/claude-mutation-scope-integration.md b/context/plans/claude-mutation-scope-integration.md index 11e33356..84d5e6cb 100644 --- a/context/plans/claude-mutation-scope-integration.md +++ b/context/plans/claude-mutation-scope-integration.md @@ -1147,7 +1147,7 @@ Persist this field in every plan; this is durable plan state, not chat state: Documentation of this seam as consumed is intentionally deferred to T09 per the plan's own task boundary, once T06 adds the first caller. -- [ ] T06: `Claude adapter driver + CLI command` (status:todo) +- [x] T06: `Claude adapter driver + CLI command` (status:done) - Task ID: T06 - Scope: In — `cli_schema::HooksSubcommand::ClaudeMutationScope` (hidden), `convert_hooks_subcommand_request` arm, @@ -1175,7 +1175,238 @@ Persist this field in every plan; this is durable plan state, not chat state: `recovery_pending` (D12), and the recovery barrier (D19). AC1 routing test passes. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope`; `sce hooks claude-mutation-scope `command_runtime` -> `HookSubcommand` -> unwrapped + dispatch, mirroring the existing non-fail-open `MutationScope` arm) and the + adapter driver itself in `claude_mutation_scope/mod.rs`. The driver reaches + the runtime only through the single T05 seam call + (`super::mutation_scope::run_mutation_scope_from_payload`), by constructing + the generic ingress's own JSON wire payload (`start`/`close`/`abandon`/ + `flush`) as a string — never by naming `mutation_trace::{runtime,protocol, + store}` or constructing a `RuntimeBoundary` (D23/AC18, confirmed by the + plan's own grep). Both the Git-directory resolver and the ingress seam are + injected as `&dyn Fn` parameters so every mapping is unit-testable without a + real Git repository or Agent Trace DB. + + Event mapping: `PreToolUse` classifies the tool (D2) and, for a + mutation-capable tool, runs the D20 explicit-background-shell check, then + the D19 recovery barrier, then the D7 write-ahead sequence + (`allocate_attempt` durably persists `pending_start` before the seam + `Start` call; `mark_active` follows a successful `Start`). Every failure in + that mutation-capable path — state-allocation failure, seam `Start` + failure, an unresolvable `cwd`, or a barrier denial — converts to the exact + D8 `permissionDecision: "deny"` JSON (`Ok`, never a propagated `Err`); this + is the only path in the adapter that intentionally turns a failure into a + successful hook return, matching Claude's fail-open-on-process-error + behavior for ordinary hook failures. `PostToolUse`/`PostToolUseFailure` + both close the scope (D9/D10): no live attempt is a safe no-op (D9), a + `pending_start` attempt is abandoned rather than late-started (D11), and a + `Close` seam failure is retired through `abandon` rather than a replayed + `Close` (D12). `PermissionDenied` abandons a live attempt (D13). + `Stop`/`StopFailure`/`UserPromptSubmit` abandon only stale main-thread + attempts (`agent_id` absent) for the session (D14/D15/D16); `SubagentStop` + abandons only the matching `agent_id`'s attempts (D17); `SessionEnd` + abandons every attempt for the session regardless of `agent_id` (D18); + `WorktreeRemove` resolves its Git directory from the event's own + `worktree_path`, never the process cwd, and retires every outstanding + attempt there (D22). `SessionStart`/`SubagentStart` establish no scope + (AC3). Every abandonment shares one `abandon_attempt` helper: it calls the + seam `abandon` operation with the adapter-state lock released (D6 — no + `adapter lock -> WorktreeLock` order is ever possible), then retires the + attempt and arms the new D19 `recovery_pending` barrier via + `state::retire_attempt_for_recovery` (added this task). The barrier itself + (`apply_recovery_barrier`) denies a new mutation-capable `PreToolUse` + outright while `recovery_pending` and outstanding attempts remain; once + quiescent, it runs one `flush` through the seam and clears the barrier via + the new `state::clear_recovery_pending` only on durable success, staying + fail-closed on a failed flush. + + Per D21, every git-directory resolution is driven by a field read out of + the parsed event (`cwd`, or `worktree_path` for `WorktreeRemove`) — never + by the `sce` process's own current directory — so + `run_claude_mutation_scope_subcommand` takes no `repository_root` + parameter at all (a deliberate deviation from the positional shape of + sibling `HookSubcommand` dispatch arms, which do thread a + process-`current_dir`-derived `repository_root` through; accepting and + then ignoring that parameter here would misstate what the adapter actually + uses). `HooksSubcommand::ClaudeMutationScope` is hidden via + `#[command(hide = true)]` on the clap variant (no prior precedent for a + hidden variant nested inside a subcommand enum in this codebase; this is + the smallest correct application of clap's existing mechanism). 27 new + focused unit tests (nested `driver` module, reusing the existing + `pre_tool_use_json` test helper) cover every event-to-operation mapping, + the exact AC8 deny JSON (including the D20/AC21 background-shell text), + AC7's write-ahead ordering (asserting the persisted phase from inside the + injected seam call, before `Start` returns), AC4's duplicate-delivery + EventId reuse, D11/D12's abandon-not-replay behavior, and all three D19 + barrier branches (deny-while-outstanding, flush-then-proceed, + flush-failure-stays-closed). No generated-settings, `config_merge.rs`, or + real Git/DB test was added (T07/T08's scope). + + **PR #263 follow-up (2026-09-07):** review found two correctness blockers + and one observability gap in the original implementation, all fixed + without touching T07/T08 scope, process supervision, protocol/Quint/schema, + or the attribution algorithm: + + - **`cwd` vs `git_dir` conflation (D7/D21).** The original code resolved + `git_dir` from the raw Claude `cwd` and then passed that same `git_dir` + to the generic mutation-scope seam as its `repository_root` — silently + substituting Git metadata-directory identity for checkout identity, + exactly the confusion D21 exists to prevent (materially wrong for a + linked worktree, where `cwd` and `git_dir` diverge). Every dispatch arm + (`Start`/`Close`/`Abandon`/`Flush`/`PermissionDenied`/lifecycle + cleanup/the recovery-barrier flush/`WorktreeRemove`) now threads both a + `git_dir: &Path` (adapter bookkeeping only — `state::*` calls) and a + `repository_root: &Path` (always the raw event `cwd`, or `worktree_path` + for `WorktreeRemove` — the only value ever passed to + `mutation_scope::run_mutation_scope_from_payload`) as two explicit, + independently constructed parameters; neither is ever substituted for + the other, and the adapter still never derives or constructs a + `WorktreeId`. + - **`recovery_pending` not armed on abandonment failure (D12/D19).** The + original `abandon_attempt` called the seam `abandon` operation and only + set `recovery_pending = true` as a side effect of the removal helper + that ran *after* a successful call — so a failed abandonment (e.g. a + second, also-failing `Close` retry) left `recovery_pending = false`, + silently violating the invariant that any uncertain terminal path must + fail closed. `state::retire_attempt_for_recovery` (a single + remove-and-arm helper) was replaced with `state::mark_recovery_pending` + (arms the barrier only, touching no attempt) called unconditionally + *before* the seam `abandon` call; `state::remove_attempt` (already + existing, unchanged) now runs only after a successful abandon. A failed + abandon therefore leaves `recovery_pending = true` and the attempt still + tracked, so the next mutation-capable `PreToolUse` is correctly denied + by the barrier and issues no new `Start`. The adapter-state lock is + still never held across a seam invocation (D6): `mark_recovery_pending` + acquires and releases its own lock before the seam call, and + `remove_attempt` acquires its own lock after. + - **Fail-closed `PreToolUse` failures logged nowhere (D8).** `resolve_git_dir`, + `establish_start`, and every `apply_recovery_barrier` failure branch + (`read_state`, the `flush` seam call, `clear_recovery_pending`) silently + discarded their `anyhow::Error` before returning the stable deny JSON. + One helper, `log_pre_tool_use_fail_closed(logger, context, error)`, now + logs the detailed error via the existing `Logger::warn` interface + (event `sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed`, + `context` naming the failing step) at every such branch before + returning; Claude's own deny reason is untouched (still exactly + `FAIL_CLOSED_DENY_REASON` or, for explicit background shells, + `EXPLICIT_BACKGROUND_SHELL_DENY_REASON`) and never carries the internal + error text. + + 9 new `driver` tests were added directly proving the two invariants: four + prove the ingress seam always receives the raw `cwd` (never `git_dir`) for + `Start`/`Close`/`Abandon`/`Flush`, using deliberately distinct + linked-worktree-style paths; two prove failed-abandonment behavior (a + failed `Close` + failed `Abandon` propagates an error, leaves the attempt + tracked and `recovery_pending = true`, and the next `PreToolUse` is denied + with the seam never called again; a failed lifecycle-cleanup abandonment + behaves identically); three prove fail-closed logging (resolver failure, + `Start` failure, and recovery-barrier `flush` failure each log the + detailed error via a `RecordingLogger` while the returned JSON stays the + exact stable deny reason with no leaked detail and no `allow`). The + existing AC7 write-ahead test was also corrected: it previously read state + from the seam's own `root` parameter, which only worked because of the + `git_dir`/`repository_root` bug being fixed here — it now reads state from + the captured `git_dir` directly and separately asserts the seam received + the raw `cwd`. + - Verify: `services::hooks::claude_mutation_scope` — 90 passed, 0 failed (54 + existing + 5 `state` tests + 31 `driver` tests, net +9 over the original + T06 landing after this follow-up); full `services::hooks::` — unaffected + siblings still pass (`mutation_scope` 36/36); `services::parse::` — 13 + passed, 0 failed; full `cli/Cargo.toml` test suite — 1130 passed, 0 failed; + `clippy --all-targets -- -D warnings` — clean; `fmt -- --check` — clean; + AC18 dependency-boundary + grep (`rg -n --type rust '^\s*use\s+crate::services::mutation_trace:: + (runtime|protocol|store)|::(RepositoryAgentTraceDb|WorktreeId| + GitSnapshotService)\b' cli/src/services/hooks/claude_mutation_scope/`) — no + matches, and manually confirmed exactly one `use`/path reaching + `crate::services::hooks::mutation_scope` + (`super::mutation_scope::run_mutation_scope_from_payload` in `mod.rs`). + `git diff --stat` against the pre-follow-up commit confirms only + `claude_mutation_scope/mod.rs` and `claude_mutation_scope/state.rs` + changed — no T07/T08 file, no `spec/mutation_cursor.qnt`, no + `protocol.rs`, no migration, no schema file. + Live binary check (`cargo build`): `sce hooks claude-mutation-scope + ` state, while a `PostModelSwitch` validates `from_model` and `to_model` but writes normalized `to_model`; both use canonical `cc_` plus exact optional `agent_id` scope (`""` for the main conversation). Missing or null `agent_id` means the main scope; a present string is trimmed and must remain non-empty, so malformed empty or non-string values fail open without a state write. Current Claude sources include `command`, `picker`, `sdk`, `auto`, and `resume`; SCE accepts any non-empty source string and stores it opaquely. The command uses the existing guarded latest-locally-observed register and local SCE observation time. SessionStart without a model is a no-op that cannot clear existing state. The command reads and writes directly through the no-migration hook-runtime repository DB path before returning, does not migrate, sync, or access the network, and returns zero stdout bytes with logger-only fail-open diagnostics for input, clock, DB-open, and DB-write failures. Claude's SessionStart invocation is synchronous relative to Claude execution, while PostModelSwitch is asynchronous; overlapping hooks and the post-switch visibility race are accepted and local observation time does not prove Claude causal ordering. Generated Claude settings register both lifecycle events for this command, while the existing five SCE registrations remain unchanged. Claude Code 2.1.250 and 2.1.251 compatibility smoke passed with an unknown PostModelSwitch registration, so installation remains unconditional with no raised minimum or capability gate. - `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce the retired generic session-model route. The `session_models` DB API/table and generic fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. The separate `sce hooks claude-model-state` command is a Claude-specific local register, and `diff-trace` consults only its exact `(cc_, agent_id)` state after direct and transcript attribution fail; this does not restore the generic abstraction. - `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables, calling the shared `insert_conversation_text_event` atomic primitive rather than the plain `insert_messages`/`insert_parts` calls described above (so a replayed or concurrent duplicate delivery leaves exactly one message and one part row, not only the parent message row), with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths independently from the event `cwd` against the canonical Git root: valid `..` components and absolute-inside paths are accepted, missing Add File targets are allowed through their nearest existing prefix, and outside or symlink-escaping mappings are rejected. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. The Codex setup/doctor boundary is separate from evidence intake: `.codex/hooks.json` is merged through the shared structural `codex_hook_config` service, which preserves valid user handlers and recognizes only the generated helper path plus the `sce hooks codex` command contract. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. -- `sce hooks mutation-scope` (hidden) is a separate single ingress (not routed through `diff-trace`/`conversation-trace`) that drives the mutation-scope runtime rather than writing conversation/diff evidence. It reads one normalized JSON lifecycle object from STDIN via the shared `read_hook_stdin`, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, and translates it into one `RuntimeBoundary` (`start`/`advance`/`close` → `Start`/`Advance`/`Close`; `flush` → `Flush`) passed to `mutation_trace::runtime::coordinate(...)`, or a direct `mutation_trace::runtime::abandon_scope(...)` call for `abandon`. `scope_id`/`event_id`/`actor_kind` (`claude_code`/`codex`/`opencode`/`pi`) are forwarded verbatim — no trim, prefix, hash, UUID, or timestamp — because `EventId` equality is the runtime's replay/idempotency key; any `worktree_id` key is a hard rejection (worktree identity is derived by the runtime from the invoking checkout). DB acquisition is lazy: a `FnOnce` provider closure reusing `open_agent_trace_db_for_hook_runtime` is passed into `coordinate()`/`abandon_scope()` so it runs inside the runtime's protected-worktree ordering, never before. **Non-fail-open intake contract, distinct from `diff-trace`/`conversation-trace`:** a lost lifecycle boundary can change which scope stays live and therefore alter attribution, so the dispatch arm is unwrapped like `pre-commit` (no `Ok(...)` fail-open shim) and there is no dropped-boundary / `exit 0` branch. Results are classified by durable completion — a malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` returns `CliError`/non-zero; `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are the two carried-outcome success variants (the durable transition already succeeded and only the trailing external-taint marker cleanup failed), reported as empty-stdout `Ok` with the failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion` and the transition **not** retried. Every successful execution emits empty stdout with no serialized outcome/revision/worktree/scope state. It writes `mutation_trace_*` rows only — never `diff_traces`, `post_commit_patch_intersections`, or `agent_traces`. No concrete Claude Code/Codex/OpenCode/Pi lifecycle adapter is wired to it yet. See [mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md) for the full contract. +- `sce hooks mutation-scope` (hidden) is a separate single ingress (not routed through `diff-trace`/`conversation-trace`) that drives the mutation-scope runtime rather than writing conversation/diff evidence. It reads one normalized JSON lifecycle object from STDIN via the shared `read_hook_stdin`, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, and translates it into one `RuntimeBoundary` (`start`/`advance`/`close` → `Start`/`Advance`/`Close`; `flush` → `Flush`) passed to `mutation_trace::runtime::coordinate(...)`, or a direct `mutation_trace::runtime::abandon_scope(...)` call for `abandon`. `scope_id`/`event_id`/`actor_kind` (`claude_code`/`codex`/`opencode`/`pi`) are forwarded verbatim — no trim, prefix, hash, UUID, or timestamp — because `EventId` equality is the runtime's replay/idempotency key; any `worktree_id` key is a hard rejection (worktree identity is derived by the runtime from the invoking checkout). DB acquisition is lazy: a `FnOnce` provider closure reusing `open_agent_trace_db_for_hook_runtime` is passed into `coordinate()`/`abandon_scope()` so it runs inside the runtime's protected-worktree ordering, never before. **Non-fail-open intake contract, distinct from `diff-trace`/`conversation-trace`:** a lost lifecycle boundary can change which scope stays live and therefore alter attribution, so the dispatch arm is unwrapped like `pre-commit` (no `Ok(...)` fail-open shim) and there is no dropped-boundary / `exit 0` branch. Results are classified by durable completion — a malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` returns `CliError`/non-zero; `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are the two carried-outcome success variants (the durable transition already succeeded and only the trailing external-taint marker cleanup failed), reported as empty-stdout `Ok` with the failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion` and the transition **not** retried. Every successful execution emits empty stdout with no serialized outcome/revision/worktree/scope state. It writes `mutation_trace_*` rows only — never `diff_traces`, `post_commit_patch_intersections`, or `agent_traces`. A first Claude Code adapter driver now consumes it in-process (see below); Codex/OpenCode/Pi still have no lifecycle adapter. See [mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md) for the full contract. +- `sce hooks claude-mutation-scope` (hidden) is the first concrete harness adapter targeting the mutation-scope runtime: it reads one raw Claude hook JSON event from STDIN, classifies the tool, and drives `mutation_scope::run_mutation_scope_from_payload` (the same in-process `pub(crate)` seam above, called directly — not by re-invoking `sce hooks mutation-scope`) to `start`/`close`/`abandon`/`flush` a scope per event. The dispatch arm is unwrapped like `mutation-scope`, not fail-open. Not yet registered by `sce setup` — no real Claude Code session reaches it. Full contract, event mapping, and design rationale land in a dedicated `context/cli/claude-mutation-scope-integration.md` once the adapter's generated-settings registration (still pending) and real-repository regressions ship. ## Explicit non-goals in the current baseline From 20f99c2ae7dc4c832de3d32883ad39e9ddc1c200 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 7 Sep 2026 11:01:50 +0200 Subject: [PATCH 09/11] setup: Register Claude mutation-scope hooks Make the first concrete Claude mutation-scope adapter reachable from real Claude Code sessions by adding unmatched registrations for all ten lifecycle events to generated settings. Update canonical context and mark plan task T07 complete to record setup/doctor reachability while leaving Codex, OpenCode, and Pi unwired. Plan: claude-mutation-scope-integration (T07) Co-authored-by: SCE --- .../hooks/claude_mutation_scope/mod.rs | 1393 +++++++++++++++++ config/pkl/renderers/claude-content.pkl | 92 ++ context/cli/mutation-scope-hook-ingress.md | 12 +- context/cli/mutation-scope-runtime.md | 13 +- context/context-map.md | 10 +- context/overview.md | 2 +- .../claude-mutation-scope-integration.md | 244 ++- .../sce/agent-trace-hooks-command-routing.md | 4 +- 8 files changed, 1746 insertions(+), 24 deletions(-) diff --git a/cli/src/services/hooks/claude_mutation_scope/mod.rs b/cli/src/services/hooks/claude_mutation_scope/mod.rs index 89c9cc0e..b60f188f 100644 --- a/cli/src/services/hooks/claude_mutation_scope/mod.rs +++ b/cli/src/services/hooks/claude_mutation_scope/mod.rs @@ -368,6 +368,30 @@ pub(crate) fn run_claude_mutation_scope_from_payload( ) } +#[cfg(test)] +fn run_claude_mutation_scope_from_payload_at_state_root( + state_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| checkout::resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + super::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_root, + state_root, + payload, + logger, + ) + }; + + run_claude_mutation_scope_from_payload_with( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + ) +} + fn run_claude_mutation_scope_from_payload_with( stdin_payload: &str, logger: Option<&dyn Logger>, @@ -2629,4 +2653,1373 @@ mod tests { assert!(error.to_string().contains("valid JSON")); } } + + mod production_regressions { + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + + use super::*; + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::checkout::{get_or_create_checkout_id, resolve_git_dir}; + use crate::services::mutation_trace::store::decode_revision; + + fn git(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("git output should be UTF-8") + } + + struct ClaudeRepo { + temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + } + + impl ClaudeRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-claude-mutation-scope-regression-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + temp, + root, + state_root, + } + } + + fn drive(&self, payload: &str) -> Result { + run_claude_mutation_scope_from_payload_at_state_root( + &self.state_root, + payload, + None, + ) + } + + fn drive_generic(&self, payload: &str) -> Result { + crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + &self.root, + &self.state_root, + payload, + None, + ) + } + + fn drive_flush(&self) -> Result { + self.drive_generic(&flush_payload()) + } + + fn db(&self) -> RepositoryAgentTraceDb { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "claude mutation-scope regression test assertions", + ) + .expect("assertion DB should open") + } + + fn cwd(&self) -> String { + self.root.to_string_lossy().into_owned() + } + + fn cwd_at(root: &Path) -> String { + root.to_string_lossy().into_owned() + } + + fn working_tree_at(root: &Path) -> String { + git(root, &["add", "-A"]); + git(root, &["write-tree"]).trim().to_owned() + } + + fn working_tree(&self) -> String { + Self::working_tree_at(&self.root) + } + + fn git_dir_at(root: &Path) -> PathBuf { + resolve_git_dir(root).expect("git dir should resolve") + } + + fn git_dir(&self) -> PathBuf { + Self::git_dir_at(&self.root) + } + + fn adapter_state_at(root: &Path) -> state::AdapterState { + state::read_state(&Self::git_dir_at(root)) + .expect("adapter state should be readable") + } + + fn adapter_state(&self) -> state::AdapterState { + Self::adapter_state_at(&self.root) + } + + fn worktree_id_at(root: &Path) -> String { + get_or_create_checkout_id(&Self::git_dir_at(root)) + .expect("checkout id should resolve") + } + + fn worktree_id(&self) -> String { + Self::worktree_id_at(&self.root) + } + + fn add_worktree(&self, name: &str) -> PathBuf { + let worktree_path = self.temp.path().join(name); + git( + &self.root, + &[ + "worktree", + "add", + "-q", + worktree_path.to_str().expect("utf-8 worktree path"), + ], + ); + worktree_path + } + } + + fn count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { + db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("a count row should exist") + } + + fn assert_raw_agent_trace_tables_untouched(db: &RepositoryAgentTraceDb) { + assert_eq!(count(db, "diff_traces"), 0); + assert_eq!(count(db, "post_commit_patch_intersections"), 0); + assert_eq!(count(db, "agent_traces"), 0); + } + + fn worktree_row( + db: &RepositoryAgentTraceDb, + worktree_id: &str, + ) -> Option<(u64, String, bool)> { + db.query_map( + "SELECT revision, cursor_tree, needs_rebaseline FROM mutation_trace_worktrees \ + WHERE worktree_id = ?1", + (worktree_id,), + |row| { + let blob: Vec = row.get(0).map_err(anyhow::Error::from)?; + let revision = decode_revision(&blob)?; + let cursor_tree = row.get::(1).map_err(anyhow::Error::from)?; + let needs_rebaseline = row.get::(2).map_err(anyhow::Error::from)? != 0; + Ok((revision, cursor_tree, needs_rebaseline)) + }, + ) + .expect("worktree-row query should succeed") + .into_iter() + .next() + } + + fn processed_events(db: &RepositoryAgentTraceDb) -> Vec<(String, String)> { + db.query_map( + "SELECT scope_id, event_id FROM mutation_trace_processed_events \ + ORDER BY scope_id, event_id", + (), + |row| { + let scope_id = row.get::(0).map_err(anyhow::Error::from)?; + let event_id = row.get::(1).map_err(anyhow::Error::from)?; + Ok((scope_id, event_id)) + }, + ) + .expect("processed-events query should succeed") + } + + fn scope_status(db: &RepositoryAgentTraceDb, scope_id: &str) -> Option<(String, String)> { + db.query_map( + "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", + (scope_id,), + |row| { + let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; + let status = row.get::(1).map_err(anyhow::Error::from)?; + Ok((actor_kind, status)) + }, + ) + .expect("scope query should succeed") + .into_iter() + .next() + } + + fn mutation_events_for( + db: &RepositoryAgentTraceDb, + worktree_id: &str, + ) -> Vec<(String, Option, String)> { + db.query_map( + "SELECT attribution_kind, attribution_scope_id, boundary_kind \ + FROM mutation_trace_events WHERE worktree_id = ?1 ORDER BY revision", + (worktree_id,), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + let boundary_kind = row.get::(2).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id, boundary_kind)) + }, + ) + .expect("mutation-events query should succeed") + } + + fn tool_identity_json( + event_name: &str, + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String(tool_name.to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String(tool_use_id.to_string()), + ); + if let Some(agent_id) = agent_id { + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String(agent_id.to_string()), + ); + } + Value::Object(object).to_string() + } + + fn pre_tool_use_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_PRE_TOOL_USE, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn background_pre_tool_use_for( + cwd: &str, + session_id: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + let mut object: serde_json::Map = serde_json::from_str( + &pre_tool_use_for(cwd, session_id, "Bash", tool_use_id, agent_id), + ) + .expect("base PreToolUse payload should parse"); + object.insert( + TOOL_INPUT_FIELD.to_string(), + json!({ "run_in_background": true }), + ); + Value::Object(object).to_string() + } + + fn post_tool_use_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_POST_TOOL_USE, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn post_tool_use_failure_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_POST_TOOL_USE_FAILURE, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn permission_denied_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_PERMISSION_DENIED, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn session_json(event_name: &str, cwd: &str, session_id: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + Value::Object(object).to_string() + } + + fn agent_json(event_name: &str, cwd: &str, session_id: &str, agent_id: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String(agent_id.to_string()), + ); + Value::Object(object).to_string() + } + + fn worktree_remove_json(session_id: &str, worktree_path: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert( + WORKTREE_PATH_FIELD.to_string(), + Value::String(worktree_path.to_string()), + ); + Value::Object(object).to_string() + } + + #[test] + fn test1_foreground_write_closes_ai_exclusive() { + let repo = ClaudeRepo::new("test1-foreground-write"); + let cwd = repo.cwd(); + + assert_eq!( + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("PreToolUse should succeed"), + "" + ); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\ntwo\n") + .expect("the tool's own edit should write"); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("PostToolUse should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the closed attempt must be removed from adapter bookkeeping" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id.clone()), + "close".to_string(), + )] + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + assert_eq!( + processed_events(&db), + vec![ + (scope_id.clone(), claude_scope_close_event_id(&scope_id)), + (scope_id.clone(), claude_scope_start_event_id(&scope_id)), + ], + "rows are ordered by (scope_id, event_id), and 'close' sorts before 'start'" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test2_failed_bash_partial_write_still_closes_ai_exclusive() { + let repo = ClaudeRepo::new("test2-failed-bash"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\npartial\n") + .expect("the failed tool's partial edit should write"); + + assert_eq!( + repo.drive(&post_tool_use_failure_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None + )) + .expect("PostToolUseFailure should succeed"), + "" + ); + + assert!(repo.adapter_state().attempts.is_empty()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id.clone()), + "close".to_string(), + )] + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test4_duplicate_pre_and_post_replay_has_no_duplicate_transition() { + let repo = ClaudeRepo::new("test4-duplicate-replay"); + let cwd = repo.cwd(); + let pre = pre_tool_use_for(&cwd, "session-1", "Write", "toolu_1", None); + + repo.drive(&pre).expect("first PreToolUse should succeed"); + assert_eq!( + repo.drive(&pre) + .expect("duplicate PreToolUse should be idempotent"), + "" + ); + assert_eq!( + repo.adapter_state().attempts.len(), + 1, + "AC4: duplicate PreToolUse delivery must reuse the same attempt" + ); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); + + let post = post_tool_use_for(&cwd, "session-1", "Write", "toolu_1", None); + repo.drive(&post).expect("first PostToolUse should succeed"); + + let db = repo.db(); + let (revision_before, events_before, processed_before) = ( + worktree_row(&db, &repo.worktree_id()) + .map(|(revision, _, _)| revision) + .expect("a worktree row should exist"), + count(&db, "mutation_trace_events"), + count(&db, "mutation_trace_processed_events"), + ); + + assert_eq!( + repo.drive(&post) + .expect("duplicate PostToolUse delivery must be a safe no-op"), + "" + ); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), + Some(revision_before) + ); + assert_eq!(count(&db, "mutation_trace_events"), events_before); + assert_eq!( + count(&db, "mutation_trace_processed_events"), + processed_before + ); + assert_eq!( + processed_events(&db) + .into_iter() + .filter(|(scope, event)| scope == &scope_id + && event == &claude_scope_close_event_id(&scope_id)) + .count(), + 1 + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test5_auto_permission_denied_abandons_and_requires_rebaseline() { + let repo = ClaudeRepo::new("test5-permission-denied"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + assert_eq!( + repo.drive(&permission_denied_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("PermissionDenied should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the denied attempt must be retired from adapter bookkeeping" + ); + assert!( + repo.adapter_state().recovery_pending, + "D19: abandonment must arm the recovery barrier" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert!( + worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline), + "AC12: a denied execution must leave the worktree needing rebaseline" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test3_two_parallel_subagent_tools_produce_ai_contended() { + let repo = ClaudeRepo::new("test3-parallel-subagents"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_a", + Some("agent-a"), + )) + .expect("agent-a PreToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_b", + Some("agent-b"), + )) + .expect("agent-b PreToolUse should succeed"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + fs::write(repo.root.join("file.txt"), "one\ncontended\n") + .expect("the racing edit should write"); + + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_a", + Some("agent-a"), + )) + .expect("agent-a PostToolUse (closing while agent-b is still active) should succeed"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_b", + Some("agent-b"), + )) + .expect("agent-b PostToolUse should succeed"); + + assert!(repo.adapter_state().attempts.is_empty()); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ai_contended".to_string(), None, "close".to_string())], + "AC11: a tree transition observed while two scopes are live must be AiContended" + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test6_other_hook_denial_is_retired_by_stop_cleanup() { + let repo = ClaudeRepo::new("test6-stop-cleanup"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + assert_eq!( + repo.drive(&session_json(HOOK_EVENT_STOP, &cwd, "session-1")) + .expect("Stop should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "AC13: Stop must retire the stale main-thread attempt" + ); + assert!(repo.adapter_state().recovery_pending); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test7_interrupted_main_turn_is_retired_by_next_user_prompt_submit() { + let repo = ClaudeRepo::new("test7-user-prompt-submit-cleanup"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\ninterrupted\n") + .expect("the interrupted edit should write"); + + assert_eq!( + repo.drive(&session_json( + HOOK_EVENT_USER_PROMPT_SUBMIT, + &cwd, + "session-1" + )) + .expect("UserPromptSubmit should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "AC14: UserPromptSubmit must retire the stale main-thread attempt \ + before another mutation-capable tool can start" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert!(worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test8_resumed_subagent_tool_use_id_gets_a_fresh_scope_id() { + let repo = ClaudeRepo::new("test8-resumed-subagent"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("first PreToolUse should succeed"); + let first_scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\nfirst\n").expect("first edit should write"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("first PostToolUse should succeed"); + assert!(repo.adapter_state().attempts.is_empty()); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("resumed PreToolUse should succeed"); + let second_scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + assert_ne!( + first_scope_id, second_scope_id, + "AC15: a resumed subagent's new tool attempt must receive a fresh ScopeId" + ); + + fs::write(repo.root.join("file.txt"), "one\nfirst\nsecond\n") + .expect("second edit should write"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("second PostToolUse should succeed"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &first_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + assert_eq!( + scope_status(&db, &second_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test9_main_and_subagent_concurrent_mutation_is_ai_contended() { + let repo = ClaudeRepo::new("test9-main-plus-subagent"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_main", + None, + )) + .expect("main-thread PreToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PreToolUse should succeed"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + fs::write(repo.root.join("file.txt"), "one\nboth-writing\n") + .expect("the racing edit should write"); + + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_main", + None, + )) + .expect("main-thread PostToolUse should succeed"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PostToolUse should succeed"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ai_contended".to_string(), None, "close".to_string())], + "AC11: main + subagent concurrent mutation must be AiContended" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test10_isolated_subagent_worktree_advances_only_its_own_cursor() { + let repo = ClaudeRepo::new("test10-isolated-worktree"); + let worktree_path = repo.add_worktree("subagent-worktree"); + let worktree_cwd = ClaudeRepo::cwd_at(&worktree_path); + + let main_worktree_id = repo.worktree_id(); + let sub_worktree_id = ClaudeRepo::worktree_id_at(&worktree_path); + assert_ne!( + main_worktree_id, sub_worktree_id, + "a linked worktree must resolve to a distinct WorktreeId" + ); + + repo.drive_flush() + .expect("main-checkout baseline flush should succeed"); + let main_cursor_before = worktree_row(&repo.db(), &main_worktree_id) + .map(|(_, cursor_tree, _)| cursor_tree) + .expect("main checkout should have a baseline worktree row"); + + repo.drive(&pre_tool_use_for( + &worktree_cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PreToolUse in the isolated worktree should succeed"); + fs::write(worktree_path.join("file.txt"), "one\nisolated\n") + .expect("the isolated worktree's own edit should write"); + repo.drive(&post_tool_use_for( + &worktree_cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PostToolUse in the isolated worktree should succeed"); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &main_worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(main_cursor_before), + "AC17: the main checkout's mutation cursor must be unchanged" + ); + let sub_row = worktree_row(&db, &sub_worktree_id).expect("subagent worktree row"); + assert_eq!( + sub_row.1, + ClaudeRepo::working_tree_at(&worktree_path), + "AC16/AC17: the isolated worktree's own cursor must advance" + ); + assert_eq!( + mutation_events_for(&db, &sub_worktree_id) + .into_iter() + .map(|(attribution, _, boundary)| (attribution, boundary)) + .collect::>(), + vec![("ai_exclusive".to_string(), "close".to_string())] + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test11_worktree_remove_cleans_only_that_worktrees_outstanding_attempt() { + let repo = ClaudeRepo::new("test11-worktree-remove"); + let worktree_path = repo.add_worktree("removed-worktree"); + let worktree_cwd = ClaudeRepo::cwd_at(&worktree_path); + let main_cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &main_cwd, + "session-1", + "Write", + "toolu_main", + None, + )) + .expect("main-thread PreToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &worktree_cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PreToolUse in the isolated worktree should succeed"); + + assert_eq!(ClaudeRepo::adapter_state_at(&repo.root).attempts.len(), 1); + assert_eq!( + ClaudeRepo::adapter_state_at(&worktree_path).attempts.len(), + 1 + ); + + assert_eq!( + repo.drive(&worktree_remove_json("session-1", &worktree_cwd)) + .expect("WorktreeRemove should succeed"), + "" + ); + + assert!( + ClaudeRepo::adapter_state_at(&worktree_path) + .attempts + .is_empty(), + "AC13/D22: WorktreeRemove must retire the outstanding attempt for that worktree" + ); + assert_eq!( + ClaudeRepo::adapter_state_at(&repo.root).attempts.len(), + 1, + "WorktreeRemove for one worktree must not touch the main checkout's attempts" + ); + + assert_raw_agent_trace_tables_untouched(&repo.db()); + } + + #[test] + fn test12_pending_start_crash_before_start_is_recovered_conservatively() { + let repo = ClaudeRepo::new("test12-pending-start-crash"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + + let key = AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_crashed".to_string(), + }; + let allocated = state::allocate_attempt(&git_dir, &key, "Write") + .expect("allocation should succeed"); + assert_eq!(allocated.attempt.phase, state::AttemptPhase::PendingStart); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_crashed", + None + )) + .expect("D11: PostToolUse on a pending_start attempt must abandon, not late-start"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the never-started attempt must be retired" + ); + assert!(repo.adapter_state().recovery_pending); + let db = repo.db(); + assert_eq!( + scope_status(&db, &allocated.attempt.scope_id), + None, + "a Start that never committed must never appear as a real scope" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_fresh", + None, + )) + .expect("the next PreToolUse should proceed after the quiescent flush"); + assert!(!repo.adapter_state().recovery_pending); + assert_eq!(repo.adapter_state().attempts.len(), 1); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test13_start_committed_before_state_settlement_is_recovered_by_abandonment() { + let repo = ClaudeRepo::new("test13-start-committed-crash"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + + let key = AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_crashed".to_string(), + }; + let allocated = state::allocate_attempt(&git_dir, &key, "Write") + .expect("allocation should succeed"); + let scope_id = allocated.attempt.scope_id.clone(); + + repo.drive_generic(&scope_boundary_payload( + "start", + &scope_id, + &claude_scope_start_event_id(&scope_id), + )) + .expect("the runtime Start should commit durably"); + assert_eq!( + state::read_state(&git_dir).unwrap().attempts[0].phase, + state::AttemptPhase::PendingStart + ); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_crashed", + None + )) + .expect("D11: a pending_start attempt with a committed Start must be abandoned"), + "" + ); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(repo.adapter_state().recovery_pending); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())), + "the runtime's own committed Start must settle as a real abandonment" + ); + let worktree_id = repo.worktree_id(); + assert!(worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test14_terminal_runtime_success_before_state_cleanup_is_replay_safe() { + let repo = ClaudeRepo::new("test14-close-committed-crash"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); + + repo.drive_generic(&scope_boundary_payload( + "close", + &scope_id, + &claude_scope_close_event_id(&scope_id), + )) + .expect("the runtime Close should commit durably"); + assert_eq!(state::read_state(&git_dir).unwrap().attempts.len(), 1); + + let db = repo.db(); + let (revision_before, events_before) = ( + worktree_row(&db, &repo.worktree_id()) + .map(|(revision, _, _)| revision) + .expect("a worktree row should exist"), + count(&db, "mutation_trace_events"), + ); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("a replayed Close against an already-durable commit must be safe"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the stale bookkeeping must finally be cleared" + ); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), + Some(revision_before), + "a durably completed Close must never be re-applied as a second transition" + ); + assert_eq!(count(&db, "mutation_trace_events"), events_before); + assert_eq!( + scope_status(&db, &scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test15_explicit_background_bash_is_denied_with_no_scope() { + let repo = ClaudeRepo::new("test15-explicit-background-bash"); + let cwd = repo.cwd(); + + let output = repo + .drive(&background_pre_tool_use_for( + &cwd, + "session-1", + "toolu_1", + None, + )) + .expect("an explicit background shell must still return Ok with a deny payload"); + + assert_eq!( + output, + pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) + ); + assert!( + repo.adapter_state().attempts.is_empty(), + "AC21: an explicit background shell must create no scope" + ); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test16_regression_matrix_leaves_raw_agent_trace_tables_untouched() { + let repo = ClaudeRepo::new("test16-raw-tables-untouched"); + let cwd = repo.cwd(); + + let before = { + let db = repo.db(); + ( + count(&db, "diff_traces"), + count(&db, "post_commit_patch_intersections"), + count(&db, "agent_traces"), + ) + }; + assert_eq!(before, (0, 0, 0)); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PostToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_2", + None, + )) + .expect("second PreToolUse should succeed"); + repo.drive(&permission_denied_for( + &cwd, + "session-1", + "Write", + "toolu_2", + None, + )) + .expect("PermissionDenied should succeed"); + + let db = repo.db(); + let after = ( + count(&db, "diff_traces"), + count(&db, "post_commit_patch_intersections"), + count(&db, "agent_traces"), + ); + assert_eq!( + after, + (0, 0, 0), + "AC20: Claude mutation-scope-only regressions must leave the raw \ + Agent Trace tables unchanged" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test17_detached_descendant_write_after_post_tool_use_is_not_folded_into_the_closed_scope( + ) { + let repo = ClaudeRepo::new("test17-detached-descendant"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\nforeground-output\n") + .expect("the tool's own foreground write should write"); + let tree_at_close = repo.working_tree(); + + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PostToolUse should succeed"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + scope_status(&db, &scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(tree_at_close.clone()), + "the scope must close at the tool's own observed tree" + ); + + fs::write( + repo.root.join("file.txt"), + "one\nforeground-output\ndetached-descendant\n", + ) + .expect("the detached descendant's later write should write"); + let tree_after_descendant = repo.working_tree(); + assert_ne!(tree_after_descendant, tree_at_close); + + let events_before_flush = mutation_events_for(&db, &worktree_id); + + repo.drive_flush() + .expect("a later recovery/diagnostic flush should succeed"); + + let db = repo.db(); + let events_after_flush = mutation_events_for(&db, &worktree_id); + assert_eq!( + events_after_flush.len(), + events_before_flush.len() + 1, + "the detached descendant's mutation must surface as its own event" + ); + let (attribution_kind, attribution_scope_id, _) = events_after_flush + .last() + .expect("a flush event should exist"); + assert_ne!( + attribution_scope_id.as_deref(), + Some(scope_id.as_str()), + "the detached descendant's mutation must never be attributed to the \ + already-closed tool scope" + ); + assert_eq!(attribution_kind, "ineligible_unscoped"); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(tree_after_descendant) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + } } diff --git a/config/pkl/renderers/claude-content.pkl b/config/pkl/renderers/claude-content.pkl index 51ab840d..b23030fe 100644 --- a/config/pkl/renderers/claude-content.pkl +++ b/config/pkl/renderers/claude-content.pkl @@ -44,6 +44,14 @@ settings = new common.RenderedTextFile { "command": "\(sceHookCommand("sce policy bash"))" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-mutation-scope"))" + } + ] } ], "PostToolUse": [ @@ -63,6 +71,34 @@ settings = new common.RenderedTextFile { "command": "\(sceHookCommand("sce hooks conversation-trace"))" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-mutation-scope"))" + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-mutation-scope"))" + } + ] + } + ], + "PermissionDenied": [ + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-mutation-scope"))" + } + ] } ], "UserPromptSubmit": [ @@ -73,6 +109,14 @@ settings = new common.RenderedTextFile { "command": "\(sceHookCommand("sce hooks conversation-trace"))" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-mutation-scope"))" + } + ] } ], "Stop": [ @@ -83,6 +127,54 @@ settings = new common.RenderedTextFile { "command": "\(sceHookCommand("sce hooks conversation-trace"))" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-mutation-scope"))" + } + ] + } + ], + "StopFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-mutation-scope"))" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-mutation-scope"))" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-mutation-scope"))" + } + ] + } + ], + "WorktreeRemove": [ + { + "hooks": [ + { + "type": "command", + "command": "\(sceHookCommand("sce hooks claude-mutation-scope"))" + } + ] } ] } diff --git a/context/cli/mutation-scope-hook-ingress.md b/context/cli/mutation-scope-hook-ingress.md index d5f94ead..8b54496e 100644 --- a/context/cli/mutation-scope-hook-ingress.md +++ b/context/cli/mutation-scope-hook-ingress.md @@ -211,12 +211,13 @@ serde derives for this command; the hook transport enum A generic SCE ingress existing is **not** concrete harness integration existing. A first Claude Code adapter driver now exists -(`cli/src/services/hooks/claude_mutation_scope/`), but it reaches the runtime +(`cli/src/services/hooks/claude_mutation_scope/`), and it reaches the runtime through its own `pub(crate)` in-process seam on `mutation_scope.rs` (`run_mutation_scope_from_payload`) rather than by re-invoking this CLI -command, and it is not yet reachable by a real Claude Code session (`sce -setup` does not register its hooks yet — future work). Still out of scope for -this seam itself, and left as future work for every non-Claude harness: +command. `sce setup` now registers its hooks +(`config/pkl/renderers/claude-content.pkl`), so a real Claude Code session +reaches it. Still out of scope for this seam itself, and left as future work +for every non-Claude harness: - Codex hook mapping, OpenCode plugin, Pi extension; - `SubagentStart` / `SubagentStop` / `PostToolUse` / tool-call translation for @@ -225,7 +226,8 @@ this seam itself, and left as future work for every non-Claude harness: - PID tracking, process supervisors, staleness detection, automatic scope abandonment; - harness settings generation or `sce setup` integration for any of these - hooks (Claude's own registration is also still pending). + hooks (Claude's own registration now ships; Codex/OpenCode/Pi remain + unregistered). Each adapter still owns its own `ScopeId` / `EventId` / `actor_kind` derivation and its own stale-process detection, and targets this ingress (or, diff --git a/context/cli/mutation-scope-runtime.md b/context/cli/mutation-scope-runtime.md index 70e834e5..8cace282 100644 --- a/context/cli/mutation-scope-runtime.md +++ b/context/cli/mutation-scope-runtime.md @@ -250,9 +250,10 @@ A generic ingress existing is not full harness integration existing. A first Claude Code adapter driver (`cli/src/services/hooks/claude_mutation_scope/`, hidden CLI command `sce hooks claude-mutation-scope`) now maps Claude's hook events onto this contract via the `pub(crate)` in-process seam -`mutation_scope::run_mutation_scope_from_payload`, but `sce setup` does not -yet register its hooks, so no real Claude Code session reaches it. Codex, -OpenCode, and Pi have no adapter at all. Each still owns its own `ScopeId` / -`EventId` derivation and stale-process detection this contract requires; -repository-scoped unowned-checkout cleanup is likewise still open. The Claude -adapter's dedicated contract file lands once the full adapter ships. +`mutation_scope::run_mutation_scope_from_payload`, and `sce setup` now +registers its hooks (`config/pkl/renderers/claude-content.pkl`), so a real +Claude Code session reaches it. Codex, OpenCode, and Pi have no adapter at +all. Each still owns its own `ScopeId` / `EventId` derivation and +stale-process detection this contract requires; repository-scoped +unowned-checkout cleanup is likewise still open. The Claude adapter's +dedicated contract file lands once the full adapter ships. diff --git a/context/context-map.md b/context/context-map.md index 3f305379..648fa65e 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -23,16 +23,16 @@ Feature/domain context: - `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys including config-file/default `log_to_file`, `log_dir` / `SCE_LOG_DIR` with `/sce/logs` defaulting plus config-file/default-only positive `log_file_retention_limit`, config-file-only `agent_trace.repository_id`/`agent_trace.repository_remote` repository-identity keys with default remote `origin`, default-enabled `agent_trace.auto_sync` boolean resolution with explicit-false opt-out for the post-commit trigger boundary, the catalog-derived `integrations.optional_workflows` optional-workflow selection key, JSON-pointer-prefixed schema-validation errors, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract including `policies.attribution_hooks.enabled` default-true/explicit-false opt-out metadata, config-file selection order, `show` provenance output, and trimmed `validate` output contract) - `context/cli/capability-traits.md` (current broad CLI capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, compile-time-typed borrowed AppContext wiring with associated-type narrow capability accessors plus `ContextWithRepoRoot` repo-root-scoped context derivation, generic command execution bounds, and test-only unimplemented stubs; current service internals do not consume fs/git traits until later lifecycle migration tasks) - `context/cli/service-lifecycle.md` (current compile-safe lifecycle seam in `cli/src/services/lifecycle.rs`, including default no-op `ServiceLifecycle` diagnose/fix/setup methods against narrow `HasRepoRoot`, lifecycle-owned health/fix/setup result types with generic setup messages, doctor/setup adapter boundaries, the static `LifecycleProvider` enum catalog/dispatcher, hook/config/local_db/auth_db/agent_trace_db lifecycle providers including setup-time repository-scoped Agent Trace DB initialization plus checkout identity diagnostics, implemented doctor aggregation over diagnose/fix providers, and implemented setup aggregation over `setup` providers in order config → local_db → auth_db → agent_trace_db → hooks when requested) -- `context/cli/mutation-trace-protocol.md` (pure, dependency-free `cli/src/services/mutation_trace/` refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol: the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — plus cross-action sequence/invariant tests and a module-level Quint refinement matrix are all complete (`types.rs`, `protocol.rs`, `mod.rs`, `tests.rs`, registered with `#[allow(dead_code)]`); opaque-newtype identity refinement decisions vs. the Quint model's bounded enums, and the `coordinator.rs` target end-state seam this layout leaves room for but does not create — `store.rs`, `runtime/git_snapshot.rs`, and `runtime/coordinator.rs` (including its public `coordinate()` entrypoint) now exist, built out by the `mutation-cursor-store-persistence` and `mutation-cursor-runtime-coordinator` plans respectively; `protocol.rs` is invoked only through the runtime, and the runtime is now driven by the generic `sce hooks mutation-scope` CLI ingress and a first, not-yet-user-reachable Claude Code adapter driver) +- `context/cli/mutation-trace-protocol.md` (pure, dependency-free `cli/src/services/mutation_trace/` refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol: the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — plus cross-action sequence/invariant tests and a module-level Quint refinement matrix are all complete (`types.rs`, `protocol.rs`, `mod.rs`, `tests.rs`, registered with `#[allow(dead_code)]`); opaque-newtype identity refinement decisions vs. the Quint model's bounded enums, and the `coordinator.rs` target end-state seam this layout leaves room for but does not create — `store.rs`, `runtime/git_snapshot.rs`, and `runtime/coordinator.rs` (including its public `coordinate()` entrypoint) now exist, built out by the `mutation-cursor-store-persistence` and `mutation-cursor-runtime-coordinator` plans respectively; `protocol.rs` is invoked only through the runtime, and the runtime is now driven by the generic `sce hooks mutation-scope` CLI ingress and a first Claude Code adapter driver, registered by `sce setup` and reachable by a real Claude Code session, now proven against real Git repositories and a real Agent Trace DB by the `claude-mutation-scope-integration` plan's T08 regressions) - `context/cli/mutation-trace-agent-attribution.md` (causal mutation-lineage attribution: direct target-shaped coverage is resolved first, then the newest-128 events for the invoking worktree (bounded also by a commit attribution cut — `revision <= latest_mutation_event_revision` captured under the worktree lock) are replayed oldest-to-newest as one ordered sequence of tree transitions. `lineage.rs` is a pure module propagating per-line `LineProvenance` (`Unknown` / `MutationAi{scope}` / `MutationNonAi`) forward through structurally-applied hunks: context carries, removed is permanently deleted, added takes only the introducing transition's origin, replacement never transfers by text. A committed line is AI only if an AI event's line survives every later transition — including a history-gap reload and the unobserved latest-tree→commit-tree tail — into the committed tree; anything unproven stays `Unknown`. Historical patches are never matched against the committed patch. `attribution.rs` keeps only `exclude_direct_coverage` + `patch_for_locations`. The `runtime/mutation_attribution.rs` consumer drives it over `MutationEventPageSource` + `TreeReadSource` (`diff_trees` + `file_at_tree`) seams with conservative fail-closed reloads; wired into post-commit via the read-only `runtime::resolve_post_commit_mutation_ai_patch` entrypoint — existing checkout identity only, direct-only fallback on absent identity/history/unavailable cut, no identity creation, no mutation-cursor write, `diff_traces` / `post_commit_patch_intersections` unchanged; see `agent-trace-hooks-command-routing.md` and `agent-trace-minimal-generator.md` — with real Git/DB post-commit regressions in `cli/src/services/hooks/mod.rs` and a real 128/129-horizon regression in `runtime/tests.rs`) - `context/cli/mutation-trace-revision-refinement.md` (the Quint `revision: int` → Rust `WorktreeState::revision: u64` bounded-integer refinement: the private `next_revision` checked-arithmetic helper `commit`/`taint`/`abandon`/`recover` all route through instead of a raw `+ 1`, so a worktree at `revision: u64::MAX` is a guarded no-op/rejection rather than a silent wrap to `0`) - `context/cli/mutation-trace-quint-connect.md` (`#[cfg(test)]`-only Quint Connect model-based-testing harness in `cli/src/services/mutation_trace/mbt/` continuously checking `protocol.rs` against `spec/mutation_cursor.qnt`: the verification-only `mbtAction`/`MbtAction` record-payload transport excluded from comparison, the operation-identity-vs-`MbtStutter` distinction on guarded/no-op branches with its two deterministic regressions, finite ID mapping, the AC5 comparable-state field list, `randomPrepare` staying a single `step` branch, deterministic/generated (500×30, seed-reproducible) test coverage, and the two Nix checks — generic `checks.cli-tests` and dedicated `checks.mutation-trace-quint-connect` — that both require the pinned Quint binary plus the top-level `spec/` directory in `workspaceSrc`'s Nix fileset) - `context/cli/mutation-trace-store.md` (durable persistence for the mutation-cursor protocol in `cli/src/services/mutation_trace/store.rs`, built by the `mutation-cursor-store-persistence` plan: the one-directional `protocol.rs` -> `DurableTransition::between` (pure structural diff) -> `store.rs` (SQL translation) -> `RepositoryAgentTraceDb` boundary; migration `004_mutation_trace_protocol.sql`'s five tables; `AttemptState`/`external_taint` non-persistence; the 8-byte big-endian `BLOB` revision encoding plus explicit non-`Debug` enum codecs; the bounded hot-path `load_worktree` read vs. the cold-path `load_mutation_event` and descending, exact-worktree, cursor-paged `load_mutation_event_page` reader capped at 32 rows; the public cold-path single-row `load_scope` scope seam that returns one `mutation_trace_scopes` row as `Option` without widening into a projection and, unlike `load_worktree`, never adjudicates worktree identity (a cross-worktree scope is returned as-is, and the mismatch is the caller's decision); the cold-path read-only `load_tree_roots` (one worktree) / `load_all_tree_roots` (repository-wide) durable-tree-SHA queries for ref reconciliation, each a single-statement `UNION` of `cursor_tree`/`before_tree`/`after_tree` read from one DB snapshot; `commit`'s single-`BEGIN IMMEDIATE` CAS batch via `TursoDb::execute_transactional_cas_batch`, distinguishing `Conflict`/retryable-transient/deterministic-`Err` outcomes; and the store's non-goals — no Git/filesystem I/O, no attribution/boundary-kind decisions, no retry-after-`Conflict` loop, no row deletion) -- `context/cli/mutation-trace-runtime-coordinator.md` (imperative-shell runtime layer in `cli/src/services/mutation_trace/runtime/`, built by the `mutation-cursor-runtime-coordinator` plan: the per-worktree OS advisory lock, `runtime::worktree_lock::WorktreeLock::acquire(git_dir, timeout)` at `/sce/mutation-cursor.lock`, bounded `try_lock()` polling with a distinct matchable timeout error and RAII release, and its distinction from the separate checkout-identity-creation lock; the isolated Git snapshot service `runtime::git_snapshot::GitSnapshotService` (documented in `mutation-trace-snapshot-service.md`); and `runtime::coordinator`'s protocol-integration pipeline (`RuntimeBoundary`/`CoordinateOutcome`/`CoordinateError`, the load → recover-if-needed → prepare/commit CAS-retry loop, and its own bounded snapshot-failure taint-retry loop) plus the public `coordinate(repository_root, boundary, open_db)` entrypoint that runs the shared `runtime::protected_worktree` prefix (`WorktreeLock` -> external-taint write-ahead fence -> `WorktreeId`, extracted so a second entrypoint cannot drift from it and documented in `mutation-trace-protected-worktree.md`), invokes the caller-supplied `open_db` provider so DB acquisition falls inside that fence, drives the pipeline under one held lock, and clears the marker through `ProtectedWorktree::complete()` only on success; `runtime/tests.rs` covers the public `coordinate()` API end to end against real linked worktrees and a real Agent Trace DB; an inherited external-taint marker is overlaid onto `protocol::database_failure` recovery on the next invocation, against the single captured snapshot and re-injected across a losing recovery CAS; the private `runtime::ref_reconciliation` per-worktree snapshot-ref maintenance pass (documented in `mutation-trace-ref-reconciliation.md`) shares the same `WorktreeLock`; `coordinate()` and `abandon_scope()` are now `pub(crate)` re-exported from `runtime/mod.rs` (documented in `mutation-scope-runtime.md`) while the runtime submodules themselves stay private; a generic `sce hooks mutation-scope` CLI ingress now drives both entrypoints, joined by a first, not-yet-user-reachable Claude Code adapter driver documented in `mutation-scope-runtime.md`) +- `context/cli/mutation-trace-runtime-coordinator.md` (imperative-shell runtime layer in `cli/src/services/mutation_trace/runtime/`, built by the `mutation-cursor-runtime-coordinator` plan: the per-worktree OS advisory lock, `runtime::worktree_lock::WorktreeLock::acquire(git_dir, timeout)` at `/sce/mutation-cursor.lock`, bounded `try_lock()` polling with a distinct matchable timeout error and RAII release, and its distinction from the separate checkout-identity-creation lock; the isolated Git snapshot service `runtime::git_snapshot::GitSnapshotService` (documented in `mutation-trace-snapshot-service.md`); and `runtime::coordinator`'s protocol-integration pipeline (`RuntimeBoundary`/`CoordinateOutcome`/`CoordinateError`, the load → recover-if-needed → prepare/commit CAS-retry loop, and its own bounded snapshot-failure taint-retry loop) plus the public `coordinate(repository_root, boundary, open_db)` entrypoint that runs the shared `runtime::protected_worktree` prefix (`WorktreeLock` -> external-taint write-ahead fence -> `WorktreeId`, extracted so a second entrypoint cannot drift from it and documented in `mutation-trace-protected-worktree.md`), invokes the caller-supplied `open_db` provider so DB acquisition falls inside that fence, drives the pipeline under one held lock, and clears the marker through `ProtectedWorktree::complete()` only on success; `runtime/tests.rs` covers the public `coordinate()` API end to end against real linked worktrees and a real Agent Trace DB; an inherited external-taint marker is overlaid onto `protocol::database_failure` recovery on the next invocation, against the single captured snapshot and re-injected across a losing recovery CAS; the private `runtime::ref_reconciliation` per-worktree snapshot-ref maintenance pass (documented in `mutation-trace-ref-reconciliation.md`) shares the same `WorktreeLock`; `coordinate()` and `abandon_scope()` are now `pub(crate)` re-exported from `runtime/mod.rs` (documented in `mutation-scope-runtime.md`) while the runtime submodules themselves stay private; a generic `sce hooks mutation-scope` CLI ingress now drives both entrypoints, joined by a first Claude Code adapter driver, registered by `sce setup` and reachable, documented in `mutation-scope-runtime.md`) - `context/cli/mutation-trace-protected-worktree.md` (the shared safety prefix every mutation-cursor runtime entrypoint runs behind, in `cli/src/services/mutation_trace/runtime/protected_worktree.rs`, extracted from `coordinate()` by the `mutation-scope-runtime-integration` plan so a second entrypoint cannot drift from it: `ProtectedWorktree::acquire(repository_root)` running the safety-critical fixed order resolve `git_dir` → `WorktreeLock` (module-owned 10s `WORKTREE_LOCK_TIMEOUT`) → `ExternalTaintMarker::exists()` → `persist()` (fence armed write-ahead of every fallible step that follows, including DB acquisition) → `get_or_create_checkout_id` as `WorktreeId`; the `worktree_id()` / `inherited_external_taint()` / consuming `complete()` surface, where `complete()` clears the marker under the still-held lock and is the only thing that ever clears it while `Drop` releases only the lock; and the one-variant-per-prefix-step `ProtectedWorktreeError` (`GitDirResolution` | `LockAcquisition` | `ExternalTaintMarker { operation, source }` | `CheckoutIdentity`) each entrypoint maps onto its own error surface — `coordinate()` onto exactly the `CoordinateError` variants that step produced before the extraction) - `context/cli/mutation-trace-scope-abandonment.md` (the mutation-cursor runtime's second protected entrypoint in `cli/src/services/mutation_trace/runtime/scope_runtime.rs`, built by the `mutation-scope-runtime-integration` plan and the first production call site for `protocol::abandon`: `abandon_scope(repository_root, scope, open_db) -> Result` retires a scope whose final worktree boundary was never observed, sharing `coordinate()`'s `ProtectedWorktree` prefix but deliberately capturing **no** Git snapshot, pin, diff, reconciliation, scope registration, or worktree initialization — so abandonment is not a `RuntimeBoundary` and needs no Quint change; the classify-before-transition order that recovers what `protocol::abandon`'s uniform guarded no-op cannot report (`load_scope` first for the missing-row and cross-worktree cases the projection seam treats as errors, then `load_worktree` for the `NeverSeen` / terminal / `Active` split); the `Abandoned` / `AlreadyTerminal` / `RecoveryRequired` outcomes and the `InheritedExternalTaint` | `MissingScope` | `NeverSeenScope` | `MissingWorktreeState` recovery reasons; the inherited-marker short-circuit that returns before the DB provider is ever invoked; the fence-completion rule that clears the marker only for a settled abandonment or proven-terminal no-op and leaves it armed for every recovery-required outcome and every error, with `MarkerClearAfterCompletion` carrying the already-settled outcome; the CAS retry bounded by the coordinator's shared `MAX_CAS_RETRY_ATTEMPTS`, settling on a competitor's terminal status rather than overwriting it; and the deliberate false-negative tradeoff whereby a missing or `NeverSeen` target forces conservative strong recovery that may abandon unrelated live scopes, because attribution safety outranks preserving potentially valid evidence) -- `context/cli/mutation-scope-runtime.md` (the crate-visible mutation-trace runtime seam and the lifecycle contract every future harness adapter — Codex, Claude Code, OpenCode, Pi — must uphold, recorded by the `mutation-scope-runtime-integration` plan: the nine `pub(crate)` re-exports in `runtime/mod.rs` (`coordinate`, `RuntimeBoundary`, `CoordinateOutcome`, `CoordinateError`, `ExternalTaintOperation`, `abandon_scope`, `AbandonScopeOutcome`, `AbandonRecoveryReason`, `AbandonScopeError`), with `ExternalTaintOperation` riding through `coordinator.rs`'s own `pub use` because `CoordinateError::ExternalTaintMarker` carries it — so the type becomes crate-visible without `protected_worktree` becoming a public module — while every `mod` declaration stays private and `ProtectedWorktree`/`ProtectedWorktreeError`/`WORKTREE_LOCK_TIMEOUT`/`reconcile_worktree` stay internal, kept clippy-clean by `#[allow(unused_imports)]` on the re-exports per the `services/style.rs` precedent rather than a placeholder consumer; and the adapter obligations themselves — a scope is one independently mutation-capable execution so a concurrent main agent and subagent need distinct `ScopeId`s, `Start`/`Advance`/`Close` semantics including that a failed tool still requires `Advance` (the boundary is an observation, not a successful edit) and that a `ScopeId` is never reused after a terminal status (the `NeverSeen` guard silently refuses to reactivate it), `abandon_scope()` requiring positive staleness evidence and never inferring it from `ActorKind`, the `abandon` → `coordinate(Start(successor))` sequence with what each outcome implies for it including that a failed abandonment must never be treated as a safely started successor, that abandonment is not a `RuntimeBoundary` and needs no Quint change, the D1 tradeoff whereby a missing or `NeverSeen` target deliberately forces conservative strong recovery that may invalidate unrelated live scopes because attribution safety outranks preserving potentially valid evidence, and the attribution boundary that `AiExclusive(scope)` means scope exclusivity only and is not standalone proof that no human edited the worktree; a generic `sce hooks mutation-scope` ingress now drives the seam, joined by a first, not-yet-user-reachable Claude Code adapter driver — Codex/OpenCode/Pi remain unwired) -- `context/cli/mutation-scope-hook-ingress.md` (the one harness-neutral CLI ingress that drives the mutation-scope runtime, in `cli/src/services/hooks/mutation_scope.rs`, built by the `mutation-scope-hook-ingress` plan: the hidden `sce hooks mutation-scope` command routing through the normal `cli_schema::HooksSubcommand::MutationScope` → `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` → `run_hooks_subcommand_in_repo` stack, reading one normalized JSON lifecycle object from STDIN; the strict `parse_mutation_scope_payload` contract supporting exactly `start`/`advance`/`close`/`flush`/`abandon` with a local `MutationScopePayload` transport enum, `claude_code`/`codex`/`opencode`/`pi` actor mapping, non-blank `scope_id`/`event_id`, exact per-operation key sets, and a dedicated hard rejection for any `worktree_id` key; the operation mapping to `RuntimeBoundary::Start`/`Advance`/`Close`/`Flush` through `coordinate()` or a direct `abandon_scope()` call, forwarding `ScopeId`/`EventId`/`ActorKind` verbatim because `EventId` equality is the runtime replay/idempotency key; identity ownership — the adapter owns `scope_id`/`event_id`/`actor_kind`, SCE owns `worktree_id`/Git tree identities/revisions/attempt IDs, and worktree identity is derived only by the runtime from the invoking checkout; the lazy `FnOnce` DB provider reusing `open_agent_trace_db_for_hook_runtime` so DB acquisition stays inside the runtime's protected-worktree ordering; the non-fail-open error classification by durable completion — malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` → `CliError`/non-zero, while `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are treated as durable success with empty stdout, the marker-cleanup failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion`, and the transition not retried; the empty-stdout success contract; abandonment ownership keeping no-snapshot semantics; and the generic-ingress vs harness-adapter boundary — a first, not-yet-user-reachable Claude Code adapter driver now exists (`cli/src/services/hooks/claude_mutation_scope/`, consuming this seam's own `pub(crate)` in-process entrypoint); Codex/OpenCode/Pi still have no lifecycle adapter, no session→`ScopeId` / tool-call→`EventId` derivation, no PID/staleness detection) +- `context/cli/mutation-scope-runtime.md` (the crate-visible mutation-trace runtime seam and the lifecycle contract every future harness adapter — Codex, Claude Code, OpenCode, Pi — must uphold, recorded by the `mutation-scope-runtime-integration` plan: the nine `pub(crate)` re-exports in `runtime/mod.rs` (`coordinate`, `RuntimeBoundary`, `CoordinateOutcome`, `CoordinateError`, `ExternalTaintOperation`, `abandon_scope`, `AbandonScopeOutcome`, `AbandonRecoveryReason`, `AbandonScopeError`), with `ExternalTaintOperation` riding through `coordinator.rs`'s own `pub use` because `CoordinateError::ExternalTaintMarker` carries it — so the type becomes crate-visible without `protected_worktree` becoming a public module — while every `mod` declaration stays private and `ProtectedWorktree`/`ProtectedWorktreeError`/`WORKTREE_LOCK_TIMEOUT`/`reconcile_worktree` stay internal, kept clippy-clean by `#[allow(unused_imports)]` on the re-exports per the `services/style.rs` precedent rather than a placeholder consumer; and the adapter obligations themselves — a scope is one independently mutation-capable execution so a concurrent main agent and subagent need distinct `ScopeId`s, `Start`/`Advance`/`Close` semantics including that a failed tool still requires `Advance` (the boundary is an observation, not a successful edit) and that a `ScopeId` is never reused after a terminal status (the `NeverSeen` guard silently refuses to reactivate it), `abandon_scope()` requiring positive staleness evidence and never inferring it from `ActorKind`, the `abandon` → `coordinate(Start(successor))` sequence with what each outcome implies for it including that a failed abandonment must never be treated as a safely started successor, that abandonment is not a `RuntimeBoundary` and needs no Quint change, the D1 tradeoff whereby a missing or `NeverSeen` target deliberately forces conservative strong recovery that may invalidate unrelated live scopes because attribution safety outranks preserving potentially valid evidence, and the attribution boundary that `AiExclusive(scope)` means scope exclusivity only and is not standalone proof that no human edited the worktree; a generic `sce hooks mutation-scope` ingress now drives the seam, joined by a first Claude Code adapter driver, registered by `sce setup` and reachable — Codex/OpenCode/Pi remain unwired) +- `context/cli/mutation-scope-hook-ingress.md` (the one harness-neutral CLI ingress that drives the mutation-scope runtime, in `cli/src/services/hooks/mutation_scope.rs`, built by the `mutation-scope-hook-ingress` plan: the hidden `sce hooks mutation-scope` command routing through the normal `cli_schema::HooksSubcommand::MutationScope` → `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` → `run_hooks_subcommand_in_repo` stack, reading one normalized JSON lifecycle object from STDIN; the strict `parse_mutation_scope_payload` contract supporting exactly `start`/`advance`/`close`/`flush`/`abandon` with a local `MutationScopePayload` transport enum, `claude_code`/`codex`/`opencode`/`pi` actor mapping, non-blank `scope_id`/`event_id`, exact per-operation key sets, and a dedicated hard rejection for any `worktree_id` key; the operation mapping to `RuntimeBoundary::Start`/`Advance`/`Close`/`Flush` through `coordinate()` or a direct `abandon_scope()` call, forwarding `ScopeId`/`EventId`/`ActorKind` verbatim because `EventId` equality is the runtime replay/idempotency key; identity ownership — the adapter owns `scope_id`/`event_id`/`actor_kind`, SCE owns `worktree_id`/Git tree identities/revisions/attempt IDs, and worktree identity is derived only by the runtime from the invoking checkout; the lazy `FnOnce` DB provider reusing `open_agent_trace_db_for_hook_runtime` so DB acquisition stays inside the runtime's protected-worktree ordering; the non-fail-open error classification by durable completion — malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` → `CliError`/non-zero, while `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are treated as durable success with empty stdout, the marker-cleanup failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion`, and the transition not retried; the empty-stdout success contract; abandonment ownership keeping no-snapshot semantics; and the generic-ingress vs harness-adapter boundary — a first Claude Code adapter driver now exists and is registered by `sce setup` (`cli/src/services/hooks/claude_mutation_scope/`, consuming this seam's own `pub(crate)` in-process entrypoint); Codex/OpenCode/Pi still have no lifecycle adapter, no session→`ScopeId` / tool-call→`EventId` derivation, no PID/staleness detection) - `context/cli/mutation-trace-ref-reconciliation.md` (the conservative per-worktree snapshot-ref reconciliation pass in `cli/src/services/mutation_trace/runtime/ref_reconciliation.rs`, built by the `mutation-cursor-ref-reconciliation` plan: `reconcile_worktree(repository_root, open_db)` → `pub(super) reconcile_worktree_inner(.., on_lock_contention)`, both returning `ReconciliationOutcome` (`Reconciled(ReconciliationReport { local_required, retained, deleted })` for a pass that ran | `SkippedNoCheckoutIdentity` — an `Ok`, not an `Err` — when no current checkout identity could be derived), a variant-per-fallible-step `ReconcileError` with no `Other`, and the module-owned `RECONCILIATION_LOCK_TIMEOUT`; the two-invariant model — a strictly per-worktree fail-closed local-consistency check via `load_tree_roots(W)` vs. a repository-wide deletion-safety set via `load_all_tree_roots()` so an `A`-owned ref is retained whenever any worktree still durably needs its tree — run entirely under the same `/sce/mutation-cursor.lock` `WorktreeLock` `coordinate()` holds, with the repository-wide read kept coherent by being one SQL statement / one DB snapshot rather than a repository-global lock; deletes only SCE-owned refs via one atomic `git update-ref --no-deref --stdin`, writes no `mutation_trace_*` row, never arms `ExternalTaintMarker`, runs no `git gc`; imperative durability maintenance below the verified Quint protocol; reclaims orphan/unreferenced refs only for the namespace of a checkout id a current worktree still derives — a namespace no current worktree owns (identity-based: a deleted linked worktree, or checkout-id metadata loss followed by `get_or_create_checkout_id` minting a fresh id on a still-present worktree) is beyond every per-worktree pass and left to a recorded future repository-scoped unowned-namespace operation, so the current pass does not bound all orphan-ref growth; `reconcile_worktree` has no `pub(crate)` re-export and no harness/command wiring yet) - `context/cli/mutation-trace-snapshot-service.md` (the isolated Git snapshot and ref-pinning service `runtime::git_snapshot::GitSnapshotService` in `cli/src/services/mutation_trace/runtime/git_snapshot.rs`: `new` resolving an absolute `git_dir`, `capture_tree` snapshotting staged/unstaged/untracked/deleted worktree state into the repository's normal object database via a throwaway temp index, `pin_tree` protecting a durable tree with a create-only idempotent **direct** `refs/sce/mutation-cursor//` ref, `diff_trees` emitting `patch.rs`-parseable raw diff text; plus the callerless reconciliation substrate — worktree-scoped `list_pins` inventory returning `Result, PinInventoryError>` that rejects a symbolic ref inside the namespace (mutation-cursor pins are direct refs) as `MalformedRef`, matchable separately from a `git for-each-ref` execution failure, and conditional-atomic `delete_pins` running one `git update-ref --no-deref --stdin` transaction of SHA-conditioned deletes — no-dereference so an inventory→delete direct-ref→symref race cannot escape the inventoried namespace, plus a fail-closed pre-check — that aborts whole if any ref changed since inventory; `REF_NAMESPACE` + `pin_ref_prefix` as the single source of truth for the pin path) - `context/cli/mutation-trace-external-taint.md` (the worktree-local mutation-cursor durability boundary in `cli/src/services/mutation_trace/runtime/external_taint.rs`, built by the `mutation-cursor-external-taint` plan: the `ExternalTaintMarker` primitive — `new(git_dir)`/`exists()`/`persist()`/`clear()` over an empty file at `/sce/mutation-cursor-tainted` whose existence is its entire state, `checkout::persist_checkout_id_inner`-style durability (`sync_data` plus best-effort `#[cfg(unix)]` parent-dir `sync_all`), idempotent persist/clear, `NotFound`-on-clear as success, no `Drop` deletion — as the concrete runtime refinement of the abstract `ProtocolState.external_taint`; armed by the reshaped `coordinate()` entrypoint write-ahead after the `WorktreeLock` and before Agent Trace DB acquisition (a caller-supplied DB provider closure), cleared only on a successful `CoordinateOutcome`, with dedicated fail-closed pre-commit `CoordinateError::ExternalTaintMarker` (`Inspect`/`Persist` only)/`AgentTraceDbUnavailable` variants plus a post-commit `MarkerClearAfterCommit { source, committed }` that carries the durable outcome so a failed trailing clear never hides a committed `MutationEvent`; an inherited marker seeds an invocation-local `external_taint_pending` flag that overlays `protocol::database_failure` onto each freshly loaded projection so `recover` runs once against the captured snapshot, held across a losing recovery CAS and cleared once it lands) @@ -82,7 +82,7 @@ Feature/domain context: - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) - `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,mixed,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact touched-line attribution counts from canonical `post_commit_patch` hunks reusing the same per-hunk classification, plus per-file trace data from patch inputs via `intersect_patches(constructed_patch, post_commit_patch)` then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation with a required lookup `url` derived from top-level `AgentTrace.id`, nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, optional canonical session links derived from matched touched-line provenance, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) -- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence, DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — plus the hidden non-fail-open `sce hooks claude-mutation-scope` first-concrete-adapter route (not yet registered by `sce setup`) — deferring the full contracts to `context/cli/mutation-scope-hook-ingress.md`.) +- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence, DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — plus the hidden non-fail-open `sce hooks claude-mutation-scope` first-concrete-adapter route (now registered by `sce setup`) — deferring the full contracts to `context/cli/mutation-scope-hook-ingress.md`.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus current Rust evaluator seam and OpenCode/Claude delegation references, including config schema, argv-prefix matching, shell/nix unwrapping, custom-policy `satisfied_by` wrapper exemption, fixed preset catalog/messages, and precedence rules) - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) diff --git a/context/overview.md b/context/overview.md index fa989a89..81963750 100644 --- a/context/overview.md +++ b/context/overview.md @@ -2,7 +2,7 @@ This repository maintains shared assistant configuration for OpenCode, Claude, Pi, and Codex from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated working-tree SCE config schema are not committed; versioned SCE config schema snapshots live under `schema/v/`. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 141-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude, Pi, and Codex; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer). Each of the six catalog workflow skills (not `sce-decision`, which has no user-facing entrypoint on any target) additionally carries a Codex-only `agents/openai.yaml`, rendered by `config/pkl/renderers/codex-metadata.pkl` from the shared catalog's `title`/`description` plus an authored `default_prompt`, with `policy.allow_implicit_invocation: false` so these stateful lifecycle workflows activate only from explicit `$sce-` invocation or Codex's `/skills` discovery, never from conversational relevance alone. The Codex renderer also emits a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely invokes the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`; setup merges the user-owned `.codex/hooks.json` through the shared structural Codex hook-config service, preserving unrelated valid handlers and rejecting malformed or Codex-invalid documents before the atomic swap. `sce doctor` diagnoses that file per required registration (`PresentAndCurrent`/`Missing`/`Stale`, or `Malformed` for the whole document) rather than by whole-document equality, and separately reports whether Codex has actually marked each structurally current registration trusted by reading (never writing) Codex's own `$CODEX_HOME/config.toml` hook-trust state; `sce doctor --fix` repairs structurally unhealthy registrations through the same merge service but never touches trust state (see `context/sce/doctor-human-text-contract.md`). `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while invalid cwd/path resolution and malformed STDIN payloads fail open with empty stdout, reported model IDs are preserved without fabricated provider prefixes, and missing/blank apply_patch sessions produce no evidence. Delete-File operations and Bash-triggered filesystem mutations remain untracked for Codex. -It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. A new pure, dependency-free `cli/src/services/mutation_trace/` module now exists as a Rust refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol; it currently carries domain types and the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — is registered with `#[allow(dead_code)]`. A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every future harness adapter (Codex, Claude Code, OpenCode, Pi) must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are now driven by one harness-neutral CLI ingress, the hidden `sce hooks mutation-scope` command (`context/cli/mutation-scope-hook-ingress.md`), which reads a single normalized JSON lifecycle object from STDIN, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, maps it to a `RuntimeBoundary` (`start`/`advance`/`close`/`flush`) or an `abandon_scope()` call forwarding `scope_id`/`event_id`/`actor_kind` verbatim (any `worktree_id` key is refused — worktree identity is derived by the runtime from the invoking checkout), and invokes the runtime with a lazy DB provider closure so DB acquisition stays inside the protected-worktree ordering. Unlike the fail-open `diff-trace`/`conversation-trace` intakes, this ingress never discards a valid boundary: it classifies results by durable completion, returning a non-zero `CliError` for a malformed payload or a pre-completion runtime error while treating the two carried-outcome variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion` — the durable transition succeeded and only the trailing external-taint marker cleanup failed) as empty-stdout success without retrying the transition. Every successful execution emits empty stdout and writes `mutation_trace_*` rows only. It is the generic transport seam; no concrete harness lifecycle adapter (Claude Code, Codex, OpenCode, Pi) — and no `session → ScopeId` / `tool-call → EventId` derivation — is wired to it yet. A separate **read-only** consumer of the same module's persisted mutation-event history is wired, though: the post-commit Agent Trace flow calls `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch(...)` to attribute committed touched lines that direct `diff_traces` evidence does not cover, by replaying the newest 128 events for the invoking worktree (bounded also by a commit attribution cut captured under the worktree lock) oldest-to-newest as one causal per-line provenance lineage rather than matching historical patches independently, resolving the worktree's *existing* checkout identity only, creating no identity and writing no mutation-cursor state, and never inserting into `diff_traces` or `post_commit_patch_intersections` (see `context/cli/mutation-trace-agent-attribution.md` and `context/sce/agent-trace-hooks-command-routing.md`). See also `context/cli/mutation-trace-protocol.md`, `context/cli/mutation-trace-runtime-coordinator.md`, `context/cli/mutation-trace-ref-reconciliation.md`, `context/cli/mutation-trace-protected-worktree.md`, `context/cli/mutation-trace-scope-abandonment.md`, `context/cli/mutation-scope-runtime.md`, and `context/cli/mutation-scope-hook-ingress.md`. +It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. A new pure, dependency-free `cli/src/services/mutation_trace/` module now exists as a Rust refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol; it currently carries domain types and the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — is registered with `#[allow(dead_code)]`. A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every future harness adapter (Codex, Claude Code, OpenCode, Pi) must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are now driven by one harness-neutral CLI ingress, the hidden `sce hooks mutation-scope` command (`context/cli/mutation-scope-hook-ingress.md`), which reads a single normalized JSON lifecycle object from STDIN, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, maps it to a `RuntimeBoundary` (`start`/`advance`/`close`/`flush`) or an `abandon_scope()` call forwarding `scope_id`/`event_id`/`actor_kind` verbatim (any `worktree_id` key is refused — worktree identity is derived by the runtime from the invoking checkout), and invokes the runtime with a lazy DB provider closure so DB acquisition stays inside the protected-worktree ordering. Unlike the fail-open `diff-trace`/`conversation-trace` intakes, this ingress never discards a valid boundary: it classifies results by durable completion, returning a non-zero `CliError` for a malformed payload or a pre-completion runtime error while treating the two carried-outcome variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion` — the durable transition succeeded and only the trailing external-taint marker cleanup failed) as empty-stdout success without retrying the transition. Every successful execution emits empty stdout and writes `mutation_trace_*` rows only. It is the generic transport seam. A first concrete harness lifecycle adapter, for Claude Code (`cli/src/services/hooks/claude_mutation_scope/`, hidden `sce hooks claude-mutation-scope`, registered by `sce setup`), is now wired to it in-process via a `pub(crate)` seam rather than by re-invoking the ingress command; Codex, OpenCode, and Pi still have no adapter, and no `session → ScopeId` / `tool-call → EventId` derivation for those harnesses. A separate **read-only** consumer of the same module's persisted mutation-event history is wired, though: the post-commit Agent Trace flow calls `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch(...)` to attribute committed touched lines that direct `diff_traces` evidence does not cover, by replaying the newest 128 events for the invoking worktree (bounded also by a commit attribution cut captured under the worktree lock) oldest-to-newest as one causal per-line provenance lineage rather than matching historical patches independently, resolving the worktree's *existing* checkout identity only, creating no identity and writing no mutation-cursor state, and never inserting into `diff_traces` or `post_commit_patch_intersections` (see `context/cli/mutation-trace-agent-attribution.md` and `context/sce/agent-trace-hooks-command-routing.md`). See also `context/cli/mutation-trace-protocol.md`, `context/cli/mutation-trace-runtime-coordinator.md`, `context/cli/mutation-trace-ref-reconciliation.md`, `context/cli/mutation-trace-protected-worktree.md`, `context/cli/mutation-trace-scope-abandonment.md`, `context/cli/mutation-scope-runtime.md`, and `context/cli/mutation-scope-hook-ingress.md`. The generated `/next-task` workflow persists task-level context-synchronization lifecycle state in each plan (`pending`, `synced`, or `blocked`) so unresolved task synchronization debt survives a session boundary and gates new implementation. Successful `/next-task` execution hands task synchronization an explicit, pre-edit-Git-baseline-relative changed-file list plus implementation, verification, done-check, plan-update, and context-impact evidence, recorded directly on the completed task (`Completed`, `Files changed`, `Result`, `Verify`, `Context impact`, `Context synchronization`); the five-file root context pass remains mandatory. A later-session sync-debt retry reads that same completed task record directly from the plan by plan path and task ID, with no separate persisted synchronization handoff. `/validate` is validation-only: it runs final checks, writes the Validation Report, and reports `validated`, `failed`, or `blocked` without plan-level context synchronization. diff --git a/context/plans/claude-mutation-scope-integration.md b/context/plans/claude-mutation-scope-integration.md index 84d5e6cb..05fa7bd5 100644 --- a/context/plans/claude-mutation-scope-integration.md +++ b/context/plans/claude-mutation-scope-integration.md @@ -1408,7 +1408,7 @@ Persist this field in every plan; this is durable plan state, not chat state: is the right place to resolve that debt properly (e.g. by splitting detail into a focused sub-file) rather than a rushed per-task shrink. -- [ ] T07: `Generated Claude integration, setup merge, and doctor` (status:todo) +- [x] T07: `Generated Claude integration, setup merge, and doctor` (status:done) - Task ID: T07 - Scope: In — `config/pkl/renderers/claude-content.pkl`: add `sce hooks claude-mutation-scope` registrations for `PreToolUse`, @@ -1425,9 +1425,131 @@ Persist this field in every plan; this is durable plan state, not chat state: `config_merge.rs` tests prove AC22 (merge + idempotency + user-hook preservation); doctor recognizes a missing/stale new registration. - Verify: `nix run .#pkl-check-generated`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::setup::`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::doctor::` (or the specific test module the new registrations land in, if narrower). - - Context synchronization: pending - -- [ ] T08: `Real Git/DB regressions through the production path` (status:todo) + - Completed: 2026-09-07 + - Files changed: + - `config/pkl/renderers/claude-content.pkl` (add ten new unmatched + `sce hooks claude-mutation-scope` hook-event registrations to + `settings.rendered`: a second, unmatched `PreToolUse` entry alongside + the existing `Bash`-matched `sce policy bash` entry; a third, unmatched + `PostToolUse` entry alongside the existing `diff-trace`-matched and + unmatched `conversation-trace` entries; a second, unmatched entry each + appended to `UserPromptSubmit` and `Stop` alongside their existing + `conversation-trace` entries; and five new unmatched top-level event + keys — `PostToolUseFailure`, `PermissionDenied`, `StopFailure`, + `SubagentStop`, `SessionEnd`, `WorktreeRemove` — each holding exactly + one `sce hooks claude-mutation-scope` entry) + - Result: Registered the T06 Claude adapter (`sce hooks + claude-mutation-scope`) in the generated Claude settings document for + every event D2/T06 need: `PreToolUse`, `PostToolUse`, + `PostToolUseFailure`, `PermissionDenied`, `UserPromptSubmit`, `Stop`, + `StopFailure`, `SubagentStop`, `SessionEnd`, `WorktreeRemove`. Every new + registration carries no `matcher` (the adapter classifies tools in Rust + per D2), matching the existing unmatched `conversation-trace` + `PostToolUse` entry's shape, per the plan's own Assumptions section. + `claude-model-state`, the `Bash`-matched bash-policy `PreToolUse` entry, + the `Write|Edit|MultiEdit|NotebookEdit`-matched `diff-trace` entry, and + both existing `conversation-trace` entries are byte-for-byte unchanged — + confirmed by inspecting the locally rendered `settings.json` (via `pkl + eval -m config/pkl/generate.pkl`), which showed the new entries + appended after each event's existing entries and the five new event keys + holding exactly the new entry each, with no other line changed. + `cli/src/services/setup/config_merge.rs`'s existing merge logic already + merges `hooks` event-by-event over whatever keys `generated.hooks` + declares (`for (event, generated_entries) in generated_hooks`), so it + required no source change to handle the ten new keys: it already + preserves non-SCE entries per event, drops and replaces only SCE-marker + entries, and stays idempotent for any event key, new or old. The existing + setup and doctor tests already drive their assertions through the real + embedded/generated settings content + (`install_merges_into_existing_claude_settings_json_and_stays_idempotent` + in `setup/mod.rs`; `claude_settings_reports_mismatch_when_sce_hook_entry_deleted_then_fix_repairs_it`, + which generically zeroes every `hooks.*` event array including the ten + new keys and asserts doctor reports `Mismatch` then `Fixed` then `Match`, + and `claude_settings_doctor_repairs_historical_bun_hooks_through_merge_path` + in `doctor/inspect.rs`) rather than hardcoding the pre-T07 event set, so + all of them already exercised the new registrations and passed unmodified + — no test file needed adjustment, matching the task's own "only if the + existing generated-fragment comparison does not already cover the new + registrations" contingency. No adapter behavior, non-Claude renderer, or + other file was touched; `git diff --stat` confirms exactly one file + changed. + - Verify: `nix run .#pkl-check-generated` — passed ("Ephemeral Pkl + generation passed: 141 files..."); `nix develop -c + ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml + services::setup::` — 66 passed, 0 failed; `nix develop -c + ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml + services::doctor::` — 25 passed, 0 failed. Additionally hand-inspected + the locally rendered `config/.claude/settings.json` output to confirm the + exact JSON shape of all ten new registrations before running the + automated checks. + - Context impact: Generated Claude settings now install a real, + user-reachable registration of the Claude mutation-scope adapter for the + first time — a fresh `sce setup` (or `doctor --fix`) now actually wires + `sce hooks claude-mutation-scope` into `.claude/settings.json`, which was + not true before this task. This makes stale the specific claim in + `context/cli/mutation-scope-hook-ingress.md`'s "Generic ingress vs + harness adapter" section that the driver "is not yet reachable by a real + Claude Code session (`sce setup` does not register its hooks yet — + future work)" and its related "harness settings generation or `sce + setup` integration for any of these hooks (Claude's own registration is + also still pending)" bullet — both written against T06's state and now + incorrect after T07. + - Context synchronization: synced — root pass confirmed + `context/{architecture,glossary,patterns}.md` contain no claim this task + contradicts (`architecture.md` describes the generic `mutation-scope` + ingress only, with no wiring-status claim about the Claude adapter; no + mutation-scope/adapter terminology exists in `glossary.md` or + `patterns.md`, consistent with T01-T06's own precedent of not adding one). + `context/overview.md` needed one in-line correction: it stated "no + concrete harness lifecycle adapter (Claude Code, Codex, OpenCode, Pi) ... + is wired to it yet" — already stale after T06 (a Claude adapter was wired + in-process) and now doubly so after T07 (it is also registered and + reachable) — corrected to name the Claude adapter as wired and + `sce setup`-registered, with Codex/OpenCode/Pi still unwired. + `context/context-map.md` needed two in-line corrections (the + `mutation-scope-hook-ingress.md` and `agent-trace-hooks-command-routing.md` + index entries both still said "not-yet-user-reachable" / "not yet + registered by `sce setup`"). Three domain files were corrected for the + same reason: `context/cli/mutation-scope-hook-ingress.md` ("Generic + ingress vs harness adapter" section: "not yet reachable ... `sce setup` + does not register its hooks yet — future work" and the matching bullet), + `context/cli/mutation-scope-runtime.md` ("Status" section: "`sce setup` + does not yet register its hooks, so no real Claude Code session reaches + it"), and `context/sce/agent-trace-hooks-command-routing.md` (the command + list entry and the `sce hooks claude-mutation-scope` prose both said "not + yet registered by `sce setup`"). Every correction is a same-statement + substring edit stating the adapter is now registered by `sce setup` + (`config/pkl/renderers/claude-content.pkl`) and reachable by a real Claude + Code session, naming the exact ten registered events in the routing file; + no other content in any of these files changed, and each was checked + against the locally rendered `config/.claude/settings.json` output + produced during task execution. No new domain terminology was introduced + by this task (settings registration only), so no glossary entry was + needed. No decision qualified for an ADR: registering the already-designed + T02-T06 adapter in generated settings is a routine application of D2/D7 + (unmatched, Rust-side tool classification) already recorded in this plan's + own Design section before T07 ran, not a new system-wide boundary, + interface, data-model, compatibility, security, deployment, or dependency + decision. Feature existence: the Claude mutation-scope adapter's + generated-settings registration is now canonically described in + `context/cli/mutation-scope-hook-ingress.md`, + `context/cli/mutation-scope-runtime.md`, and + `context/sce/agent-trace-hooks-command-routing.md` (the full dedicated + contract file remains T09's job per this plan's own Context sync list and + T01-T06's identical precedent, since T08's real Git/DB regressions + haven't shipped yet). File hygiene: `context/cli/mutation-scope-runtime.md` + was already 258 lines (8 over the 250-line budget, per T06's own recorded + hygiene note) before this task; its one-paragraph correction is + line-count-neutral in content but the file is now 259 lines (net +1) — + this task made the smallest coherent correction rather than attempting a + file split, consistent with T06's identical judgment call; T09's already- + planned full rewrite of this exact file remains the right place to + resolve the debt. Every other edited file stays at or under 250 lines. + All edited files keep one topic, use relative links, and needed no new + diagram (no new structure, boundary, or flow was introduced — only a + reachability-status correction to existing content). + +- [x] T08: `Real Git/DB regressions through the production path` (status:done) - Task ID: T08 - Scope: In — regressions using real temporary Git repositories and real repository Agent Trace DBs, driven through the production Claude-adapter -> @@ -1469,7 +1591,119 @@ Persist this field in every plan; this is durable plan state, not chat state: - Done when: all seventeen regressions pass and collectively satisfy AC9–AC17, AC19, AC20, AC21, AC25. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::`. - - Context synchronization: pending + - Completed: 2026-09-07 + - Files changed: + - `cli/src/services/hooks/claude_mutation_scope/mod.rs` (add a `#[cfg(test)]` + state-root variant of the real adapter entry point, + `run_claude_mutation_scope_from_payload_at_state_root` (mirrors + `mutation_scope.rs`'s own `run_mutation_scope_from_payload_at_state_root` + test seam, isolating the Agent Trace DB from the real user state + directory while dispatching through the exact same production code path + as `sce hooks claude-mutation-scope`); add a new nested test module, + `tests::production_regressions`, with a real-Git-repo + real-Agent-Trace-DB + harness (`ClaudeRepo`) and 17 regression tests (Test1-Test17)) + - Result: Implemented all seventeen regressions specified in this task's + scope, each driving a real raw Claude hook-event JSON payload through the + real adapter dispatch path (`run_claude_mutation_scope_from_payload_at_state_root`, + which resolves `git_dir` via the real `checkout::resolve_git_dir` and + reaches the runtime only through the real T05 seam, + `mutation_scope::run_mutation_scope_from_payload_at_state_root`) against a + real temporary Git repository (`git init`, real commits, real linked + worktrees via `git worktree add`) and a real repository-scoped Agent Trace + DB isolated to a per-test state root. No test inserts `mutation_trace_*` + rows directly; the two tests that inject state (Test12, Test13) call only + the adapter's own `state::allocate_attempt` bookkeeping helper to simulate + a specific crash point, then prove recovery entirely through a subsequent + real production-path event. Test1/Test2 prove AC9/AC10 (foreground + `Write`/failed `Bash` close `AiExclusive`+`Closed`). Test3/Test9 prove + AC11 (two parallel subagents, and main+subagent, both racing to + `AiContended`). Test4 proves AC4's duplicate-replay idempotency at the + real-DB level. Test5 proves AC12 (auto `PermissionDenied` abandons and + forces `needs_rebaseline`). Test6/Test7 prove AC13/AC14 (`Stop` and + `UserPromptSubmit` retire stale main-thread attempts; `SubagentStop` and + `SessionEnd` retirement are already covered by T06's own driver unit + tests, matching this task's own Verify mapping). Test8 proves AC15 (a + resumed subagent reusing the same raw `tool_use_id` under the same + `agent_id` receives a fresh `ScopeId`, never reusing the first, terminal + one). Test10 proves AC16/AC17 (an isolated linked worktree resolves a + distinct `WorktreeId` via the real `checkout::get_or_create_checkout_id` + and advances only its own cursor; the main checkout's cursor, established + via a real diagnostic `flush`, is unchanged). Test11 proves AC13's + `WorktreeRemove` case (a real linked worktree's outstanding attempt is + retired by its own `WorktreeRemove` event without touching the main + checkout's attempts). Test12/Test13/Test14 prove D11/D12's crash-recovery + invariants against the real runtime: a `pending_start` attempt whose + `Start` never committed is abandoned and, once quiescent, recovered by + D19's own quiescent flush; a `pending_start` attempt whose `Start` *did* + commit durably is abandoned as a real runtime abandonment (not a + late-`Start`); a `Close` that committed durably before local bookkeeping + caught up is replay-safe on redelivery (no second transition, real + revision unchanged). Test15 proves AC21 (explicit `run_in_background=true` + Bash is denied with no scope, no DB rows). Test16 proves AC20 with an + explicit before/after row-count comparison across a representative mix of + Start/Close/PreToolUse/PermissionDenied events. Test17 proves AC25: a + foreground `Bash` closes at the tool's own observed tree, and a later + (test-simulated) detached-descendant write is never attributed to that + already-closed scope when it eventually surfaces through a real flush + boundary. Every test also asserts the three raw Agent Trace tables + (`diff_traces`, `post_commit_patch_intersections`, `agent_traces`) stay + empty (AC19/AC20). No production code changed; this task is test-only, + matching its own Out-of-scope boundary. + - Verify: `services::hooks::claude_mutation_scope` — 107 passed, 0 failed + (90 existing + 17 new production-path regressions); + `services::hooks::mutation_scope` — 36 passed, 0 failed (unaffected); + `services::mutation_trace::` — 323 passed, 0 failed (unaffected); full + `cli/Cargo.toml` test suite — 1147 passed, 0 failed (1130 existing + 17 + new); `clippy --all-targets -- -D warnings` — clean; `fmt -- --check` — + clean. AC18 dependency-boundary grep + (`rg -n --type rust '^\s*use\s+crate::services::mutation_trace::(runtime|protocol|store)|::(RepositoryAgentTraceDb|WorktreeId|GitSnapshotService)\b' cli/src/services/hooks/claude_mutation_scope/`) + — the only two matches are `use` imports inside the new + `#[cfg(test)] mod production_regressions` test-assertion code (real-DB + row inspection), consistent with the plan's own carve-out that this is a + dependency-boundary check on production code, not a bare-word search, and + with T02/T03's identical precedent of importing these same types only in + test code. `git diff --stat` against the T07 baseline confirms exactly one + file changed: `cli/src/services/hooks/claude_mutation_scope/mod.rs` (1393 + insertions, 0 deletions) — no production code, no T07 registration file, + no `spec/mutation_cursor.qnt`, `protocol.rs`, migration, or schema file + touched (AC23 unaffected). + - Context impact: None beyond this plan. This task adds only new + `#[cfg(test)]`-gated test code (a state-root test seam and 17 regression + tests) to a file that already exists; no CLI surface, settings, schema, + public interface, or documented/observable production behavior changed. + No `context/cli|sce` file makes any claim this task contradicts: the + domain files already describe the adapter's shipped behavior (from + T06/T07) and did not previously claim the behavior was untested, so + adding real-Git/DB regression coverage for already-documented behavior + needs no correction. T09 will reference this task's regression coverage + (test names, and the fact that real Git/DB proof now exists for AC9-AC17, + AC19-AC21, AC25) when it authors `context/cli/claude-mutation-scope-integration.md`, + per the plan's own Context sync list and T01-T07's identical precedent of + deferring the dedicated adapter-contract file to T09. + - Context synchronization: synced — root pass confirmed `context/{overview,architecture,glossary,patterns}.md` + contain no claim this task contradicts (test-only change; no CLI, settings, + schema, or public-interface change). `context/context-map.md` needed three + in-line corrections: the `mutation-trace-protocol.md`, + `mutation-trace-runtime-coordinator.md`, and `mutation-scope-runtime.md` + index entries still said the Claude Code adapter driver was + "not-yet-user-reachable" — stale since T07 registered it via `sce setup`, + and only two of the five affected lines in `context-map.md` had been + corrected at the time (T07's own record names only the + `mutation-scope-hook-ingress.md` and `agent-trace-hooks-command-routing.md` + entries). Corrected the three remaining lines to state the adapter is + registered and reachable, and to note T08 now proves this with real + Git/DB regression coverage. No new feature, public interface, or + observable behavior was introduced by this task itself, so no other + content changed. No decision qualified for an ADR (test-only, no + system-wide boundary/interface/data-model/compatibility/security/ + deployment/dependency decision). No new domain terminology was + introduced, so no glossary entry was needed. One further stale clause was + found but deliberately left unedited: `context/cli/mutation-scope-runtime.md`'s + own intro still says "not-yet-user-reachable," contradicting its own + already-correct "Status" section — left for T09's already-planned full + rewrite of this exact file per this plan's own Context sync list and + T06/T07's identical precedent, rather than a piecemeal fix outside this + task's own Context sync list membership. - [ ] T09: `Author the durable adapter context` (status:todo) - Task ID: T09 diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index b40e3787..e3dc183e 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -16,7 +16,7 @@ - `sce hooks codex` - `sce hooks claude-model-state` - `sce hooks mutation-scope` (hidden; reads one normalized JSON lifecycle object from STDIN) -- `sce hooks claude-mutation-scope` (hidden; reads one raw Claude hook JSON object from STDIN; not yet registered by `sce setup` — see below) +- `sce hooks claude-mutation-scope` (hidden; reads one raw Claude hook JSON object from STDIN; registered by `sce setup` — see below) ## Parser and dispatch behavior @@ -122,7 +122,7 @@ - `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce the retired generic session-model route. The `session_models` DB API/table and generic fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. The separate `sce hooks claude-model-state` command is a Claude-specific local register, and `diff-trace` consults only its exact `(cc_, agent_id)` state after direct and transcript attribution fail; this does not restore the generic abstraction. - `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables, calling the shared `insert_conversation_text_event` atomic primitive rather than the plain `insert_messages`/`insert_parts` calls described above (so a replayed or concurrent duplicate delivery leaves exactly one message and one part row, not only the parent message row), with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths independently from the event `cwd` against the canonical Git root: valid `..` components and absolute-inside paths are accepted, missing Add File targets are allowed through their nearest existing prefix, and outside or symlink-escaping mappings are rejected. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. The Codex setup/doctor boundary is separate from evidence intake: `.codex/hooks.json` is merged through the shared structural `codex_hook_config` service, which preserves valid user handlers and recognizes only the generated helper path plus the `sce hooks codex` command contract. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. - `sce hooks mutation-scope` (hidden) is a separate single ingress (not routed through `diff-trace`/`conversation-trace`) that drives the mutation-scope runtime rather than writing conversation/diff evidence. It reads one normalized JSON lifecycle object from STDIN via the shared `read_hook_stdin`, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, and translates it into one `RuntimeBoundary` (`start`/`advance`/`close` → `Start`/`Advance`/`Close`; `flush` → `Flush`) passed to `mutation_trace::runtime::coordinate(...)`, or a direct `mutation_trace::runtime::abandon_scope(...)` call for `abandon`. `scope_id`/`event_id`/`actor_kind` (`claude_code`/`codex`/`opencode`/`pi`) are forwarded verbatim — no trim, prefix, hash, UUID, or timestamp — because `EventId` equality is the runtime's replay/idempotency key; any `worktree_id` key is a hard rejection (worktree identity is derived by the runtime from the invoking checkout). DB acquisition is lazy: a `FnOnce` provider closure reusing `open_agent_trace_db_for_hook_runtime` is passed into `coordinate()`/`abandon_scope()` so it runs inside the runtime's protected-worktree ordering, never before. **Non-fail-open intake contract, distinct from `diff-trace`/`conversation-trace`:** a lost lifecycle boundary can change which scope stays live and therefore alter attribution, so the dispatch arm is unwrapped like `pre-commit` (no `Ok(...)` fail-open shim) and there is no dropped-boundary / `exit 0` branch. Results are classified by durable completion — a malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` returns `CliError`/non-zero; `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are the two carried-outcome success variants (the durable transition already succeeded and only the trailing external-taint marker cleanup failed), reported as empty-stdout `Ok` with the failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion` and the transition **not** retried. Every successful execution emits empty stdout with no serialized outcome/revision/worktree/scope state. It writes `mutation_trace_*` rows only — never `diff_traces`, `post_commit_patch_intersections`, or `agent_traces`. A first Claude Code adapter driver now consumes it in-process (see below); Codex/OpenCode/Pi still have no lifecycle adapter. See [mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md) for the full contract. -- `sce hooks claude-mutation-scope` (hidden) is the first concrete harness adapter targeting the mutation-scope runtime: it reads one raw Claude hook JSON event from STDIN, classifies the tool, and drives `mutation_scope::run_mutation_scope_from_payload` (the same in-process `pub(crate)` seam above, called directly — not by re-invoking `sce hooks mutation-scope`) to `start`/`close`/`abandon`/`flush` a scope per event. The dispatch arm is unwrapped like `mutation-scope`, not fail-open. Not yet registered by `sce setup` — no real Claude Code session reaches it. Full contract, event mapping, and design rationale land in a dedicated `context/cli/claude-mutation-scope-integration.md` once the adapter's generated-settings registration (still pending) and real-repository regressions ship. +- `sce hooks claude-mutation-scope` (hidden) is the first concrete harness adapter targeting the mutation-scope runtime: it reads one raw Claude hook JSON event from STDIN, classifies the tool, and drives `mutation_scope::run_mutation_scope_from_payload` (the same in-process `pub(crate)` seam above, called directly — not by re-invoking `sce hooks mutation-scope`) to `start`/`close`/`abandon`/`flush` a scope per event. The dispatch arm is unwrapped like `mutation-scope`, not fail-open. Registered by `sce setup` (`config/pkl/renderers/claude-content.pkl`) for `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionDenied`, `UserPromptSubmit`, `Stop`, `StopFailure`, `SubagentStop`, `SessionEnd`, and `WorktreeRemove`, with no `matcher` (the adapter classifies tools in Rust), so a real Claude Code session now reaches it. Full contract, event mapping, and design rationale land in a dedicated `context/cli/claude-mutation-scope-integration.md` once real-repository regressions ship. ## Explicit non-goals in the current baseline From fb56916cf747762affc787d7ebb5acfaa2d392d5 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 7 Sep 2026 13:30:39 +0200 Subject: [PATCH 10/11] context: Document Claude mutation-scope adapter contract Record the shipped Claude mutation-scope adapter as durable repository context. Add its scope model, identity derivation, fail-closed write-ahead behavior, cleanup and recovery rules, and unsupported background-shell boundary, then update the related runtime, routing, overview, architecture, map, raw-hook, and plan references. Plan: claude-mutation-scope-integration, T09 Co-authored-by: SCE --- context/architecture.md | 2 +- .../cli/claude-mutation-scope-integration.md | 250 ++++++++++++++++++ context/cli/mutation-scope-hook-ingress.md | 17 +- context/cli/mutation-scope-runtime.md | 40 +-- context/context-map.md | 3 +- context/overview.md | 2 +- .../claude-mutation-scope-integration.md | 233 ++++++++++++++-- .../sce/agent-trace-hooks-command-routing.md | 3 +- context/sce/claude-raw-hook-capture.md | 1 + 9 files changed, 493 insertions(+), 58 deletions(-) create mode 100644 context/cli/claude-mutation-scope-integration.md diff --git a/context/architecture.md b/context/architecture.md index 8f6339c7..e4d01d51 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -132,7 +132,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Agent Trace database inspection is no longer a doctor-adjacent command surface; doctor owns repository-scoped DB health and checkout identity facts, while `sce sync` owns control-plane synchronization. Report fact collection preserves environment/repository/hook/integration display data and adds a non-launching `post_commit_auto_sync` fact based on canonical post-commit managed-block currency plus resolved `agent_trace.auto_sync` source/value; this fact does not launch `sce sync`, and existing hook problem/remediation/readiness semantics remain authoritative. Service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi/Codex child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. Codex's collector (`collect_codex_integration_groups`) is the one exception to the single-target-directory shape the other three share: since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix, it resolves its integration root through `InstallTargetPaths::codex_target_dir()` (the repository root itself) rather than a `RepoPaths` per-target subdirectory, and splits assets into `Skills` (`.agents/skills/` prefix) and `Hooks` (`.codex/` prefix) groups instead of Pi's `Extensions`/`Prompts`/`Skills` split. Its `.codex/hooks.json` reporting is per-registration rather than one whole-file child: `codex_hook_config::diagnose_document` classifies each of the four required registrations as `PresentAndCurrent`/`Missing`/`Stale` (or the whole document as `Malformed` when it cannot be structurally validated) without writing anything, so unrelated user handlers never create a false whole-document mismatch. For a structurally current registration, `codex_hook_trust` separately reads (never writes) Codex's own durable `$CODEX_HOME/config.toml` hook-trust state — reproducing upstream's `hook_hash`/`hook_key`/`hook_trust_status` exactly — and reports `Trusted`/`Untrusted`/`Modified`/`Disabled`/`Unknown`; only `Trusted` renders healthy. `sce doctor --fix` repairs a structurally unhealthy `.codex/hooks.json` through the existing merge-install path, but a registration that is current yet not-yet-trusted is never "fixed", since SCE cannot grant Codex hook trust. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` payload used by the static `RuntimeCommand` enum. -- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`), including the silent `claude-model-state` lifecycle intake delegated to `cli/src/services/hooks/claude_model_state.rs`, the hidden non-fail-open `mutation-scope` runtime ingress delegated to `cli/src/services/hooks/mutation_scope.rs` (STDIN normalized JSON → one `mutation_trace::runtime` `coordinate()` / `abandon_scope()` call with a lazy DB provider; see `context/cli/mutation-scope-hook-ingress.md`), plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists that direct-only result to `post_commit_patch_intersections`, resolves bounded read-only mutation-history AI coverage for the committed lines the direct intersection missed via `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch` (invoking worktree's existing checkout identity only, newest 128 events replayed oldest-to-newest as one causal tree-transition provenance lineage bounded by a worktree-lock-captured commit attribution cut, no identity creation, no mutation-cursor write, nothing written to `diff_traces`), passes direct and mutation-AI evidence separately to `agent_trace::build_agent_trace_from_evidence`, then persists the built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the default-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child unless explicitly disabled in config, with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution with `direct > exact transcript > exact session/agent state > NULL`: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`, then persistence performs one exact local `claude_model_state` lookup using canonical session and ephemeral agent scope; model values normalize once through `claude/`, subagents do not inherit main-session state, and unresolved values remain nullable. `session-model` is no longer a supported hook route. +- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`), including the silent `claude-model-state` lifecycle intake delegated to `cli/src/services/hooks/claude_model_state.rs`, the hidden non-fail-open `mutation-scope` runtime ingress delegated to `cli/src/services/hooks/mutation_scope.rs` (STDIN normalized JSON → one `mutation_trace::runtime` `coordinate()` / `abandon_scope()` call with a lazy DB provider; see `context/cli/mutation-scope-hook-ingress.md`), the hidden non-fail-open `claude-mutation-scope` adapter delegated to `cli/src/services/hooks/claude_mutation_scope/` (STDIN raw Claude hook JSON → tool classification + identity/`ScopeId` derivation → the same `mutation_scope.rs` `pub(crate)` in-process seam, one mutation `ScopeId` per mutation-capable tool execution; registered by `sce setup`; see `context/cli/claude-mutation-scope-integration.md`), plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists that direct-only result to `post_commit_patch_intersections`, resolves bounded read-only mutation-history AI coverage for the committed lines the direct intersection missed via `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch` (invoking worktree's existing checkout identity only, newest 128 events replayed oldest-to-newest as one causal tree-transition provenance lineage bounded by a worktree-lock-captured commit attribution cut, no identity creation, no mutation-cursor write, nothing written to `diff_traces`), passes direct and mutation-AI evidence separately to `agent_trace::build_agent_trace_from_evidence`, then persists the built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the default-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child unless explicitly disabled in config, with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution with `direct > exact transcript > exact session/agent state > NULL`: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`, then persistence performs one exact local `claude_model_state` lookup using canonical session and ephemeral agent scope; model values normalize once through `claude/`, subagents do not inherit main-session state, and unresolved values remain nullable. `session-model` is no longer a supported hook route. - Generated Claude settings register `SessionStart` and `PostModelSwitch` only for the local model-state hook; `sce hooks session-model` is no longer a supported hook command. Claude Code 2.1.250 and 2.1.251 compatibility smoke passed with the unknown `PostModelSwitch` registration, so SCE installs it unconditionally without a raised minimum or capability gate. The `session_models` table/API and generic session-level fallback lookup were removed in T02 of the `remove-session-models-direct-claude-model-id` plan; the separate `sce hooks claude-model-state` command writes Claude lifecycle observations into the non-exported exact-scope register through the no-migration hook-runtime DB path, without restoring that generic abstraction. `diff-trace` uses direct-first/event-transcript-second Claude `model_id` resolution and consults the exact local lifecycle state only as its final fallback, with direct `tool_version` values. - `cli/src/services/resilience.rs` defines bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) for transient operation hardening with deterministic failure messaging and retry observability. - `context/cli/agent-trace-auto-sync.md` documents the sync-owned, one-shot post-commit launcher boundary: it reuses `sce sync`, has no daemon or local retry machinery, and fails open when child startup cannot be completed; doctor reports this capability without invoking the launcher. diff --git a/context/cli/claude-mutation-scope-integration.md b/context/cli/claude-mutation-scope-integration.md new file mode 100644 index 00000000..ea09afbe --- /dev/null +++ b/context/cli/claude-mutation-scope-integration.md @@ -0,0 +1,250 @@ +# Claude mutation-scope integration: the first concrete harness adapter + +`sce hooks claude-mutation-scope` is the first concrete harness lifecycle +adapter targeting the mutation-scope runtime. It translates raw Claude Code +tool/lifecycle hook events into the normalized mutation-scope contract +implemented by +[`mutation-scope-hook-ingress.md`](mutation-scope-hook-ingress.md), reaching the +runtime only through that ingress's in-process seam — never `coordinate()` / +`abandon_scope()` directly, never a second `sce` subprocess. Built by the +`claude-mutation-scope-integration` plan; lives in +`cli/src/services/hooks/claude_mutation_scope/` (`mod.rs` — event model, tool +classification, `ScopeId`/`EventId` derivation, adapter driver; `state.rs` — +durable checkout-local bookkeeping). The generic ingress and the +[`mutation-scope-runtime.md`](mutation-scope-runtime.md) contract are unchanged. + +## Command routing + +Routes through the normal hook stack (`cli_schema::HooksSubcommand:: +ClaudeMutationScope` -> `convert_hooks_subcommand_request` -> +`services::hooks::HookSubcommand::ClaudeMutationScope` -> +`run_hooks_subcommand_in_repo` -> `run_claude_mutation_scope_subcommand`), hidden +from help (`#[command(hide = true)]` on the nested clap variant), the dispatch +arm **unwrapped** like `mutation-scope` (no `Ok(...)` fail-open shim). It takes +no `repository_root` parameter — every repository root is read from the parsed +event payload (see [Raw cwd is authoritative](#raw-cwd-is-authoritative)). STDIN +is one raw Claude hook JSON object via `super::read_hook_stdin()`; success emits +**empty stdout**, except the deliberate `PreToolUse` permission-decision object +below. + +## Scope model and tool classification + +**One independently mutation-capable Claude tool execution = one SCE mutation +`ScopeId`.** A session, prompt, main agent, or subagent is never a scope; +`session_id` / `agent_id` are only identity inputs distinguishing tool +executions. Two parallel mutation-capable tools produce two simultaneously live +scopes and may correctly yield `AiContended`; sequential tool calls are +sequential scopes. `SessionStart`, `UserPromptSubmit`, and `SubagentStart` +establish no scope. + +`classify_tool(tool_name)`: + +- **Mutation-capable (establishes a scope):** any name not in the two lists + below — `Bash`, `PowerShell`, `Write`, `Edit`, `NotebookEdit`, `MultiEdit`, + every `mcp__*` tool, and any **unknown** name. Unknown-means-mutation-capable + is deliberate: an unknown read-only tool only creates harmless scopes, whereas + the opposite default would silently miss a new mutation-capable tool. +- **Read-only (never a scope):** `Read`, `Glob`, `Grep`, `WebFetch`, + `WebSearch`, `AskUserQuestion`. +- **`Agent` (delegation, never a scope):** the subagent's own mutation-capable + tool calls establish their own scopes (carrying its `agent_id`); a parent + scope would fold every child mutation into it. + +`is_explicit_background_shell(tool_name, run_in_background)` is a separate +model-only predicate (`true` only for `Bash`/`PowerShell` with +`run_in_background == true`); its denial is in the driver — see +[Background shell is unsupported](#background-shell-is-unsupported). + +## Identity and ScopeId / EventId derivation + +A tracked `PreToolUse` requires `session_id`, `cwd`, `tool_name`, +`tool_use_id`. Optional: `agent_id` (absent = main thread, present = subagent), +`prompt_id` / `agent_type` (diagnostics only). `parse_claude_hook_event` is +strict — an empty payload, non-object JSON, or a missing/blank/wrong-typed field +is rejected with `Invalid Claude hook event payload from STDIN: .`, and +no identity is ever fabricated. + +The tool-execution key is `(session_id, agent_id?, tool_use_id)`. A raw +`tool_use_id` can recur (a deferred execution resumed) and a terminal SCE +`ScopeId` must never be reused, so `ScopeId` is **not** a pure function of +`tool_use_id`: the adapter keeps a monotonic checkout-local `next_attempt_seq`, +and each new attempt draws a fresh `attempt_seq`. `ScopeId` is a length-prefixed, +hash-free encoding (no crypto dependency), and `EventId`s derive +deterministically from it as `|start` / `|close`: + +```text +cc-tool-v1|n=|s=:|a=:|t=: +``` + +Replaying the same hook event for one live attempt yields the same `ScopeId` and +`EventId` (the runtime's replay/idempotency key). After an attempt is terminal a +later `PreToolUse` for the same `tool_use_id` draws a new `attempt_seq` and a +new `ScopeId`; otherwise-identical tool IDs under main / `agent_id=A` / +`agent_id=B` produce three distinct `ScopeId`s; no `ScopeId` derives from +`agent_id` alone, so a resumed subagent reusing an `agent_id` is safe. + +## Checkout-local adapter state + +`state.rs` keeps cross-hook-process state at +`/sce/claude-mutation-scope-state.json` (`` via +`checkout::resolve_git_dir(cwd)` — worktree-specific for linked worktrees): a +versioned `{version, next_attempt_seq, recovery_pending, attempts[]}`, each +attempt carrying `attempt_seq`, `scope_id`, the identity fields, `tool_name`, and +`phase` (`pending_start | active`). This is **adapter bookkeeping, never +attribution evidence** — not exported, synced, or authoritative; its only job is +knowing which Claude-created scopes may still need a terminal action. A malformed +or wrong-version file is rejected, never fabricated. + +Writes follow the checkout-identity durability pattern +(`checkout::persist_checkout_id_inner`: lock at +`/sce/claude-mutation-scope-state.lock`, temp file, `sync_data`, atomic +rename, best-effort parent `sync_all` on Unix). That lock is **never held across +a `mutation_scope` seam invocation**, so no `adapter lock -> WorktreeLock` order +can form. The adapter may call `checkout::resolve_git_dir` but not +`read_checkout_id` / `get_or_create_checkout_id`, and never constructs a +`WorktreeId`. + +## PreToolUse: write-ahead Start, fail-closed + +For a tracked mutation-capable tool, `handle_pre_tool_use` runs: +explicit-background-shell check -> resolve `git_dir` -> recovery barrier -> +write-ahead `Start` — persist `phase=pending_start`, call the seam with +`{"operation":"start","scope_id":,"event_id":|start,"actor_kind":"claude_code"}`, +persist `pending_start -> active`, return empty success. The seam receives the +raw `cwd` as its `repository_root` and SCE derives the `WorktreeId`, so durable +generic-ingress `Start` is reached before the hook returns success to Claude. + +**Fail-closed via Claude's deny decision.** Claude treats ordinary non-2 hook +failures as non-blocking, so a generic non-zero exit would let the tool run +without its `Start`. Therefore **any** failure in the mutation-capable +`PreToolUse` path — state-allocation failure, seam `Start` failure, unresolvable +`cwd`, or a barrier denial — returns (`Ok`, never `Err`): + +```json +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"SCE could not establish mutation attribution for this tool execution."}} +``` + +The detailed error is logged via `Logger::warn` +(`sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed`); Claude's deny +reason never carries it. The adapter never returns `allow`, so SCE cannot bypass +Claude's permission system. A read-only or `Agent` `PreToolUse` returns empty +stdout, no scope. + +## PostToolUse / PostToolUseFailure: close the scope + +For an `active` tracked attempt, both events map to a `close` operation +(`event_id = |close`, `actor_kind = claude_code`). On Claude Code +`2.1.258` exactly one of the two fires per attempt, never both (T01, D10), and a +failed tool that already changed files still gets its final tree captured. The +attempt is removed from adapter state only after durable `Close` success; a +duplicate `PostToolUse` after cleanup is a safe no-op. Two uncertain-boundary +rules: + +- **`pending_start` + terminal signal -> abandon, not late-Start (D11).** The + adapter cannot prove `Start` committed, and a late `Start` after the tool ran + would observe the post-tool tree and misattribute the interval — normal + abandonment on a committed `Start`, the runtime's `MissingScope` / `NeverSeen` + recovery path otherwise. +- **Failed `Close` -> abandon, not a replayed `Close` (D12).** The original + observation time is lost, so the adapter must not retry that `Close` later as + the original observation; it abandons and arms `recovery_pending`. The ingress + carried-success variants (`MarkerClearAfterCommit` / + `MarkerClearAfterCompletion`) are durable success and do not enter this path. + +## Abandonment cleanup signals + +Every abandonment shares one helper: arm `recovery_pending`, call the seam +`abandon` operation, then remove the attempt on success. A failed abandon leaves +`recovery_pending = true` and the attempt tracked, so the next mutation-capable +`PreToolUse` is denied by the barrier. + +| Event | Retires | +| --- | --- | +| `PermissionDenied` (D13) | the one live attempt for that `tool_use_id` — an auto-mode-classifier denial signal only; manual/other-hook denial relies on the sweeps below | +| `Stop` / `StopFailure` (D14/D15) | every outstanding **main-thread** attempt (`session_id` match, `agent_id` absent) | +| `UserPromptSubmit` (D16) | the same main-thread sweep — fallback for a user-interrupted main turn, which emits no `Stop` | +| `SubagentStop` (D17) | outstanding attempts owned by `(session_id, agent_id = event.agent_id)` | +| `SessionEnd` (D18) | every outstanding attempt for the session, regardless of `agent_id` | +| `WorktreeRemove` (D22) | every attempt under the Git directory resolved from the event's own `worktree_path` (not the process cwd) — best-effort | + +On Claude Code `2.1.258`, `StopFailure` and `WorktreeRemove` were not observed +to fire (T01); their handlers/registrations are kept best-effort but correctness +depends only on `Stop`, `UserPromptSubmit`, `SubagentStop`, and `SessionEnd`. + +**The recovery barrier.** While `recovery_pending` is armed and known attempts +are still outstanding, new mutation-capable `PreToolUse` is denied (fail-closed +shape). When `recovery_pending == true AND attempts.is_empty()`, the adapter +runs one `{"operation":"flush"}` through the seam — one worktree-level +recovery/rebaseline boundary. Only a successful `flush` clears +`recovery_pending`; a failed `flush` stays fail-closed. + +## Raw cwd is authoritative + +The runtime's repository root is the raw payload's `cwd`, never +`$CLAUDE_PROJECT_DIR` (`WorktreeRemove` cleanup uses the event's +`worktree_path`). An `isolation: worktree` subagent's tool executions run inside +the isolated worktree and drive the runtime from that worktree's `cwd`, so a +hook process launched from checkout A with a payload `cwd = checkout B` drives +checkout B's state and only that worktree's cursor. The adapter never accepts, +derives, stores, or constructs a `WorktreeId`, and passes no `worktree_id` key +to the seam. + +## Background shell is unsupported + +An explicit `Bash.run_in_background = true` / `PowerShell.run_in_background = +true` is denied in `PreToolUse` (fail-closed shape) with: + +```text +SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground. +``` + +A detached shell can keep mutating the repository after `PostToolUse` returns and +can outlive a session; the generic contract has no process supervisor or stable +background-execution terminal signal. This is a deliberate correctness boundary, +not a Bash security policy. Background **subagents** are not excluded — their +internal mutation-capable tool calls still establish their own scopes. + +**Self-detaching descendants are a separate, explicit unsupported boundary +(D20).** A `run_in_background = false` call can still leave a repository-mutating +descendant running after `PostToolUse` returns when the invoked command detaches +a child (`command &`, `nohup`, `setsid`, double-fork, `start_new_session=True`). +T04 proved this live against Claude Code `2.1.258`: a foreground `setsid` +command returned `PostToolUse` in `duration_ms: 13` and its descendant's write +landed ~3s later, changing the Git tree an SCE snapshot would capture — outside +the tool's closed scope. This is not solvable by inspecting the command string; +the integration adds no detection, supervision, or static scan, and simply does +not treat `PostToolUse` as proof that every descendant has stopped mutating. See +the T04 addendum and `probe17-*` fixtures under +`cli/src/services/hooks/claude_mutation_scope/fixtures/`. + +## Generated settings + +`sce setup` (`config/pkl/renderers/claude-content.pkl`) registers the adapter +for all ten handled events, each with **no** `matcher` (the adapter classifies +tools in Rust), matching the existing unmatched `conversation-trace` +`PostToolUse` entry. The merge preserves `claude-model-state`, the bash-policy +hook, `diff-trace`, and `conversation-trace`, keeps user-owned Claude hooks, and +stays idempotent. See +[`claude-raw-hook-capture.md`](../sce/claude-raw-hook-capture.md) for the full +generated Claude settings state. + +## Dependency boundary + +Dependency direction is strictly `claude_mutation_scope -> hooks::mutation_scope +-> mutation_trace::runtime`. Production Claude-adapter code (outside +`#[cfg(test)]` in `claude_mutation_scope/`) imports no +`crate::services::mutation_trace::{runtime,protocol,store}` and names no +`RepositoryAgentTraceDb`, `WorktreeId`, or `GitSnapshotService`; its only +dependency into the mutation stack is the single +`super::mutation_scope::run_mutation_scope_from_payload` seam import — the +generic-ingress entrypoint made `pub(crate)` by this plan (T05), reused verbatim +with no second `RuntimeBoundary` path and no spawned `sce` subprocess, inheriting +that seam's strict parser, `RuntimeBoundary` mapping, lazy DB acquisition, +durable-completion error classification, and empty-stdout semantics. T08 proves +the whole path against real Git repositories and a real Agent Trace DB. + +## Related context + +- [Mutation-scope hook ingress: the harness-neutral transport seam](mutation-scope-hook-ingress.md) +- [Mutation-scope runtime: the harness-adapter contract](mutation-scope-runtime.md) +- [Agent Trace hooks command routing](../sce/agent-trace-hooks-command-routing.md) diff --git a/context/cli/mutation-scope-hook-ingress.md b/context/cli/mutation-scope-hook-ingress.md index 8b54496e..092447c0 100644 --- a/context/cli/mutation-scope-hook-ingress.md +++ b/context/cli/mutation-scope-hook-ingress.md @@ -211,13 +211,15 @@ serde derives for this command; the hook transport enum A generic SCE ingress existing is **not** concrete harness integration existing. A first Claude Code adapter driver now exists -(`cli/src/services/hooks/claude_mutation_scope/`), and it reaches the runtime -through its own `pub(crate)` in-process seam on `mutation_scope.rs` -(`run_mutation_scope_from_payload`) rather than by re-invoking this CLI -command. `sce setup` now registers its hooks -(`config/pkl/renderers/claude-content.pkl`), so a real Claude Code session -reaches it. Still out of scope for this seam itself, and left as future work -for every non-Claude harness: +(`cli/src/services/hooks/claude_mutation_scope/`, hidden command +`sce hooks claude-mutation-scope`), reaching the runtime through this seam's own +`pub(crate)` in-process entrypoint (`run_mutation_scope_from_payload`) rather +than by re-invoking this CLI command, and `sce setup` registers its hooks +(`config/pkl/renderers/claude-content.pkl`) so a real Claude Code session +reaches it. Its full contract is in +[`claude-mutation-scope-integration.md`](claude-mutation-scope-integration.md). +Still out of scope for this seam itself, and left as future work for every +non-Claude harness: - Codex hook mapping, OpenCode plugin, Pi extension; - `SubagentStart` / `SubagentStop` / `PostToolUse` / tool-call translation for @@ -238,6 +240,7 @@ for the lifecycle obligations every such adapter must uphold. ## Related context - [Mutation-scope runtime: the harness-adapter contract](mutation-scope-runtime.md) +- [Claude mutation-scope integration: the first concrete harness adapter](claude-mutation-scope-integration.md) - [Mutation-trace runtime coordinator](mutation-trace-runtime-coordinator.md) - [Mutation-trace scope abandonment](mutation-trace-scope-abandonment.md) - [Mutation-trace protected worktree](mutation-trace-protected-worktree.md) diff --git a/context/cli/mutation-scope-runtime.md b/context/cli/mutation-scope-runtime.md index 8cace282..4624d2f7 100644 --- a/context/cli/mutation-scope-runtime.md +++ b/context/cli/mutation-scope-runtime.md @@ -7,10 +7,11 @@ uphold when it drives that surface. Built by the `mutation-scope-runtime-integration` plan (`context/plans/mutation-scope-runtime-integration.md`). The generic `sce hooks mutation-scope` CLI ingress -([`mutation-scope-hook-ingress.md`](mutation-scope-hook-ingress.md)) and a -first, not-yet-user-reachable Claude Code adapter driver (Codex/OpenCode/Pi: -none yet — see Status) both drive this seam. This file is the contract every -harness adapter is written against, not shipped-adapter behavior. +([`mutation-scope-hook-ingress.md`](mutation-scope-hook-ingress.md)) and the +shipped Claude Code adapter +([`claude-mutation-scope-integration.md`](claude-mutation-scope-integration.md); +Codex/OpenCode/Pi: none yet — see Status) both drive this seam. This file is the +contract every harness adapter is written against, not shipped-adapter behavior. The mechanics behind each entrypoint live in their own domain files: [`mutation-trace-runtime-coordinator.md`](mutation-trace-runtime-coordinator.md) @@ -55,11 +56,10 @@ through the two entrypoints; it never assembles the safety prefix itself. The re-exports remain the intentional crate-visible runtime seam; the generic `sce hooks mutation-scope` ingress consumes it while the runtime submodules and -safety-prefix implementation stay private. Both re-export statements still -carry `#[allow(unused_imports)]` in `runtime/mod.rs`, since no consumer yet -names the two completing types (`ExternalTaintOperation`, -`AbandonRecoveryReason`), which `clippy --all-targets -- -D warnings` would -otherwise flag. +safety-prefix implementation stay private. Both re-export statements still carry +`#[allow(unused_imports)]` in `runtime/mod.rs`, since no consumer yet names the +two completing types (`ExternalTaintOperation`, `AbandonRecoveryReason`) that +`clippy --all-targets -- -D warnings` would otherwise flag. ## What a mutation scope is @@ -246,14 +246,14 @@ rather than failing open. Full transport/normalization contract in [`mutation-scope-hook-ingress.md`](mutation-scope-hook-ingress.md); routing in [agent-trace hooks command routing](../sce/agent-trace-hooks-command-routing.md). -A generic ingress existing is not full harness integration existing. A first -Claude Code adapter driver (`cli/src/services/hooks/claude_mutation_scope/`, -hidden CLI command `sce hooks claude-mutation-scope`) now maps Claude's hook -events onto this contract via the `pub(crate)` in-process seam -`mutation_scope::run_mutation_scope_from_payload`, and `sce setup` now -registers its hooks (`config/pkl/renderers/claude-content.pkl`), so a real -Claude Code session reaches it. Codex, OpenCode, and Pi have no adapter at -all. Each still owns its own `ScopeId` / `EventId` derivation and -stale-process detection this contract requires; repository-scoped -unowned-checkout cleanup is likewise still open. The Claude adapter's -dedicated contract file lands once the full adapter ships. +A generic ingress existing is not full harness integration existing. The shipped +Claude Code adapter (`cli/src/services/hooks/claude_mutation_scope/`, hidden +`sce hooks claude-mutation-scope`) maps Claude's hook events onto this contract +via the `pub(crate)` in-process seam +`mutation_scope::run_mutation_scope_from_payload`, is registered by `sce setup`, +and is covered by real-repository regressions against a real Agent Trace DB — its +full contract is in +[`claude-mutation-scope-integration.md`](claude-mutation-scope-integration.md). +Codex, OpenCode, and Pi have no adapter; each still owns the `ScopeId` / +`EventId` derivation and stale-process detection this contract requires, and +repository-scoped unowned-checkout cleanup is still open. diff --git a/context/context-map.md b/context/context-map.md index 648fa65e..3883760f 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -33,6 +33,7 @@ Feature/domain context: - `context/cli/mutation-trace-scope-abandonment.md` (the mutation-cursor runtime's second protected entrypoint in `cli/src/services/mutation_trace/runtime/scope_runtime.rs`, built by the `mutation-scope-runtime-integration` plan and the first production call site for `protocol::abandon`: `abandon_scope(repository_root, scope, open_db) -> Result` retires a scope whose final worktree boundary was never observed, sharing `coordinate()`'s `ProtectedWorktree` prefix but deliberately capturing **no** Git snapshot, pin, diff, reconciliation, scope registration, or worktree initialization — so abandonment is not a `RuntimeBoundary` and needs no Quint change; the classify-before-transition order that recovers what `protocol::abandon`'s uniform guarded no-op cannot report (`load_scope` first for the missing-row and cross-worktree cases the projection seam treats as errors, then `load_worktree` for the `NeverSeen` / terminal / `Active` split); the `Abandoned` / `AlreadyTerminal` / `RecoveryRequired` outcomes and the `InheritedExternalTaint` | `MissingScope` | `NeverSeenScope` | `MissingWorktreeState` recovery reasons; the inherited-marker short-circuit that returns before the DB provider is ever invoked; the fence-completion rule that clears the marker only for a settled abandonment or proven-terminal no-op and leaves it armed for every recovery-required outcome and every error, with `MarkerClearAfterCompletion` carrying the already-settled outcome; the CAS retry bounded by the coordinator's shared `MAX_CAS_RETRY_ATTEMPTS`, settling on a competitor's terminal status rather than overwriting it; and the deliberate false-negative tradeoff whereby a missing or `NeverSeen` target forces conservative strong recovery that may abandon unrelated live scopes, because attribution safety outranks preserving potentially valid evidence) - `context/cli/mutation-scope-runtime.md` (the crate-visible mutation-trace runtime seam and the lifecycle contract every future harness adapter — Codex, Claude Code, OpenCode, Pi — must uphold, recorded by the `mutation-scope-runtime-integration` plan: the nine `pub(crate)` re-exports in `runtime/mod.rs` (`coordinate`, `RuntimeBoundary`, `CoordinateOutcome`, `CoordinateError`, `ExternalTaintOperation`, `abandon_scope`, `AbandonScopeOutcome`, `AbandonRecoveryReason`, `AbandonScopeError`), with `ExternalTaintOperation` riding through `coordinator.rs`'s own `pub use` because `CoordinateError::ExternalTaintMarker` carries it — so the type becomes crate-visible without `protected_worktree` becoming a public module — while every `mod` declaration stays private and `ProtectedWorktree`/`ProtectedWorktreeError`/`WORKTREE_LOCK_TIMEOUT`/`reconcile_worktree` stay internal, kept clippy-clean by `#[allow(unused_imports)]` on the re-exports per the `services/style.rs` precedent rather than a placeholder consumer; and the adapter obligations themselves — a scope is one independently mutation-capable execution so a concurrent main agent and subagent need distinct `ScopeId`s, `Start`/`Advance`/`Close` semantics including that a failed tool still requires `Advance` (the boundary is an observation, not a successful edit) and that a `ScopeId` is never reused after a terminal status (the `NeverSeen` guard silently refuses to reactivate it), `abandon_scope()` requiring positive staleness evidence and never inferring it from `ActorKind`, the `abandon` → `coordinate(Start(successor))` sequence with what each outcome implies for it including that a failed abandonment must never be treated as a safely started successor, that abandonment is not a `RuntimeBoundary` and needs no Quint change, the D1 tradeoff whereby a missing or `NeverSeen` target deliberately forces conservative strong recovery that may invalidate unrelated live scopes because attribution safety outranks preserving potentially valid evidence, and the attribution boundary that `AiExclusive(scope)` means scope exclusivity only and is not standalone proof that no human edited the worktree; a generic `sce hooks mutation-scope` ingress now drives the seam, joined by a first Claude Code adapter driver, registered by `sce setup` and reachable — Codex/OpenCode/Pi remain unwired) - `context/cli/mutation-scope-hook-ingress.md` (the one harness-neutral CLI ingress that drives the mutation-scope runtime, in `cli/src/services/hooks/mutation_scope.rs`, built by the `mutation-scope-hook-ingress` plan: the hidden `sce hooks mutation-scope` command routing through the normal `cli_schema::HooksSubcommand::MutationScope` → `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` → `run_hooks_subcommand_in_repo` stack, reading one normalized JSON lifecycle object from STDIN; the strict `parse_mutation_scope_payload` contract supporting exactly `start`/`advance`/`close`/`flush`/`abandon` with a local `MutationScopePayload` transport enum, `claude_code`/`codex`/`opencode`/`pi` actor mapping, non-blank `scope_id`/`event_id`, exact per-operation key sets, and a dedicated hard rejection for any `worktree_id` key; the operation mapping to `RuntimeBoundary::Start`/`Advance`/`Close`/`Flush` through `coordinate()` or a direct `abandon_scope()` call, forwarding `ScopeId`/`EventId`/`ActorKind` verbatim because `EventId` equality is the runtime replay/idempotency key; identity ownership — the adapter owns `scope_id`/`event_id`/`actor_kind`, SCE owns `worktree_id`/Git tree identities/revisions/attempt IDs, and worktree identity is derived only by the runtime from the invoking checkout; the lazy `FnOnce` DB provider reusing `open_agent_trace_db_for_hook_runtime` so DB acquisition stays inside the runtime's protected-worktree ordering; the non-fail-open error classification by durable completion — malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` → `CliError`/non-zero, while `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are treated as durable success with empty stdout, the marker-cleanup failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion`, and the transition not retried; the empty-stdout success contract; abandonment ownership keeping no-snapshot semantics; and the generic-ingress vs harness-adapter boundary — a first Claude Code adapter driver now exists and is registered by `sce setup` (`cli/src/services/hooks/claude_mutation_scope/`, consuming this seam's own `pub(crate)` in-process entrypoint); Codex/OpenCode/Pi still have no lifecycle adapter, no session→`ScopeId` / tool-call→`EventId` derivation, no PID/staleness detection) +- `context/cli/claude-mutation-scope-integration.md` (the first concrete harness lifecycle adapter, `cli/src/services/hooks/claude_mutation_scope/`, hidden command `sce hooks claude-mutation-scope`, built by the `claude-mutation-scope-integration` plan: one independently mutation-capable Claude tool execution = one SCE mutation `ScopeId` (a session/prompt/main agent/subagent is never a scope); the `classify_tool` table (mutation-capable including unknown names, read-only `Read`/`Glob`/`Grep`/`WebFetch`/`WebSearch`/`AskUserQuestion`, `Agent` = delegation) plus the model-only `is_explicit_background_shell` predicate; the length-prefixed hash-free `cc-tool-v1|n=|s=..|a=..|t=..` `ScopeId` keyed on a monotonic checkout-local `attempt_seq` (never reused after terminal) with deterministic `|start` / `|close` `EventId`s; the `/sce/claude-mutation-scope-state.json` bookkeeping store (never attribution evidence, never synced) with its own separate lock never held across a seam call; `PreToolUse` write-ahead `pending_start` → seam `start` → `active` and its fail-closed Claude `permissionDecision: "deny"` on any failure (never `allow`, detail logged via `sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed`); `PostToolUse`/`PostToolUseFailure` → `close`, with `pending_start`+terminal → abandon-not-late-start (D11) and failed-`close` → abandon-not-replay (D12); the abandonment cleanup signals (`PermissionDenied`, `Stop`/`StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, best-effort `WorktreeRemove` — the last two not observed to fire on Claude Code `2.1.258`); the `recovery_pending` barrier that denies new mutation-capable `PreToolUse` until quiescent then runs one seam `flush`; raw hook `cwd` (or `worktree_path` for `WorktreeRemove`) as authoritative repository root with no adapter-constructed `WorktreeId`; the `run_in_background = true` denial and the separate self-detaching-descendant unsupported boundary (D20, with T04's Git-observable evidence); the ten unmatched `sce setup` registrations; and the strict `claude_mutation_scope → hooks::mutation_scope → mutation_trace::runtime` dependency direction through the single `run_mutation_scope_from_payload` seam import (T05), proven against real Git repositories and a real Agent Trace DB by T08) - `context/cli/mutation-trace-ref-reconciliation.md` (the conservative per-worktree snapshot-ref reconciliation pass in `cli/src/services/mutation_trace/runtime/ref_reconciliation.rs`, built by the `mutation-cursor-ref-reconciliation` plan: `reconcile_worktree(repository_root, open_db)` → `pub(super) reconcile_worktree_inner(.., on_lock_contention)`, both returning `ReconciliationOutcome` (`Reconciled(ReconciliationReport { local_required, retained, deleted })` for a pass that ran | `SkippedNoCheckoutIdentity` — an `Ok`, not an `Err` — when no current checkout identity could be derived), a variant-per-fallible-step `ReconcileError` with no `Other`, and the module-owned `RECONCILIATION_LOCK_TIMEOUT`; the two-invariant model — a strictly per-worktree fail-closed local-consistency check via `load_tree_roots(W)` vs. a repository-wide deletion-safety set via `load_all_tree_roots()` so an `A`-owned ref is retained whenever any worktree still durably needs its tree — run entirely under the same `/sce/mutation-cursor.lock` `WorktreeLock` `coordinate()` holds, with the repository-wide read kept coherent by being one SQL statement / one DB snapshot rather than a repository-global lock; deletes only SCE-owned refs via one atomic `git update-ref --no-deref --stdin`, writes no `mutation_trace_*` row, never arms `ExternalTaintMarker`, runs no `git gc`; imperative durability maintenance below the verified Quint protocol; reclaims orphan/unreferenced refs only for the namespace of a checkout id a current worktree still derives — a namespace no current worktree owns (identity-based: a deleted linked worktree, or checkout-id metadata loss followed by `get_or_create_checkout_id` minting a fresh id on a still-present worktree) is beyond every per-worktree pass and left to a recorded future repository-scoped unowned-namespace operation, so the current pass does not bound all orphan-ref growth; `reconcile_worktree` has no `pub(crate)` re-export and no harness/command wiring yet) - `context/cli/mutation-trace-snapshot-service.md` (the isolated Git snapshot and ref-pinning service `runtime::git_snapshot::GitSnapshotService` in `cli/src/services/mutation_trace/runtime/git_snapshot.rs`: `new` resolving an absolute `git_dir`, `capture_tree` snapshotting staged/unstaged/untracked/deleted worktree state into the repository's normal object database via a throwaway temp index, `pin_tree` protecting a durable tree with a create-only idempotent **direct** `refs/sce/mutation-cursor//` ref, `diff_trees` emitting `patch.rs`-parseable raw diff text; plus the callerless reconciliation substrate — worktree-scoped `list_pins` inventory returning `Result, PinInventoryError>` that rejects a symbolic ref inside the namespace (mutation-cursor pins are direct refs) as `MalformedRef`, matchable separately from a `git for-each-ref` execution failure, and conditional-atomic `delete_pins` running one `git update-ref --no-deref --stdin` transaction of SHA-conditioned deletes — no-dereference so an inventory→delete direct-ref→symref race cannot escape the inventoried namespace, plus a fail-closed pre-check — that aborts whole if any ref changed since inventory; `REF_NAMESPACE` + `pin_ref_prefix` as the single source of truth for the pin path) - `context/cli/mutation-trace-external-taint.md` (the worktree-local mutation-cursor durability boundary in `cli/src/services/mutation_trace/runtime/external_taint.rs`, built by the `mutation-cursor-external-taint` plan: the `ExternalTaintMarker` primitive — `new(git_dir)`/`exists()`/`persist()`/`clear()` over an empty file at `/sce/mutation-cursor-tainted` whose existence is its entire state, `checkout::persist_checkout_id_inner`-style durability (`sync_data` plus best-effort `#[cfg(unix)]` parent-dir `sync_all`), idempotent persist/clear, `NotFound`-on-clear as success, no `Drop` deletion — as the concrete runtime refinement of the abstract `ProtocolState.external_taint`; armed by the reshaped `coordinate()` entrypoint write-ahead after the `WorktreeLock` and before Agent Trace DB acquisition (a caller-supplied DB provider closure), cleared only on a successful `CoordinateOutcome`, with dedicated fail-closed pre-commit `CoordinateError::ExternalTaintMarker` (`Inspect`/`Persist` only)/`AgentTraceDbUnavailable` variants plus a post-commit `MarkerClearAfterCommit { source, committed }` that carries the durable outcome so a failed trailing clear never hides a committed `MutationEvent`; an inherited marker seeds an invocation-local `external_taint_pending` flag that overlays `protocol::database_failure` onto each freshly loaded projection so `recover` runs once against the captured snapshot, held across a losing recovery CAS and cleared once it lands) @@ -82,7 +83,7 @@ Feature/domain context: - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) - `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,mixed,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact touched-line attribution counts from canonical `post_commit_patch` hunks reusing the same per-hunk classification, plus per-file trace data from patch inputs via `intersect_patches(constructed_patch, post_commit_patch)` then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation with a required lookup `url` derived from top-level `AgentTrace.id`, nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, optional canonical session links derived from matched touched-line provenance, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) -- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence, DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — plus the hidden non-fail-open `sce hooks claude-mutation-scope` first-concrete-adapter route (now registered by `sce setup`) — deferring the full contracts to `context/cli/mutation-scope-hook-ingress.md`.) +- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence, DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — plus the hidden non-fail-open `sce hooks claude-mutation-scope` first-concrete-adapter route (now registered by `sce setup`) — deferring the full contracts to `context/cli/mutation-scope-hook-ingress.md` and `context/cli/claude-mutation-scope-integration.md`.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus current Rust evaluator seam and OpenCode/Claude delegation references, including config schema, argv-prefix matching, shell/nix unwrapping, custom-policy `satisfied_by` wrapper exemption, fixed preset catalog/messages, and precedence rules) - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) diff --git a/context/overview.md b/context/overview.md index 81963750..bb99be24 100644 --- a/context/overview.md +++ b/context/overview.md @@ -2,7 +2,7 @@ This repository maintains shared assistant configuration for OpenCode, Claude, Pi, and Codex from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated working-tree SCE config schema are not committed; versioned SCE config schema snapshots live under `schema/v/`. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 141-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude, Pi, and Codex; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer). Each of the six catalog workflow skills (not `sce-decision`, which has no user-facing entrypoint on any target) additionally carries a Codex-only `agents/openai.yaml`, rendered by `config/pkl/renderers/codex-metadata.pkl` from the shared catalog's `title`/`description` plus an authored `default_prompt`, with `policy.allow_implicit_invocation: false` so these stateful lifecycle workflows activate only from explicit `$sce-` invocation or Codex's `/skills` discovery, never from conversational relevance alone. The Codex renderer also emits a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely invokes the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`; setup merges the user-owned `.codex/hooks.json` through the shared structural Codex hook-config service, preserving unrelated valid handlers and rejecting malformed or Codex-invalid documents before the atomic swap. `sce doctor` diagnoses that file per required registration (`PresentAndCurrent`/`Missing`/`Stale`, or `Malformed` for the whole document) rather than by whole-document equality, and separately reports whether Codex has actually marked each structurally current registration trusted by reading (never writing) Codex's own `$CODEX_HOME/config.toml` hook-trust state; `sce doctor --fix` repairs structurally unhealthy registrations through the same merge service but never touches trust state (see `context/sce/doctor-human-text-contract.md`). `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while invalid cwd/path resolution and malformed STDIN payloads fail open with empty stdout, reported model IDs are preserved without fabricated provider prefixes, and missing/blank apply_patch sessions produce no evidence. Delete-File operations and Bash-triggered filesystem mutations remain untracked for Codex. -It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. A new pure, dependency-free `cli/src/services/mutation_trace/` module now exists as a Rust refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol; it currently carries domain types and the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — is registered with `#[allow(dead_code)]`. A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every future harness adapter (Codex, Claude Code, OpenCode, Pi) must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are now driven by one harness-neutral CLI ingress, the hidden `sce hooks mutation-scope` command (`context/cli/mutation-scope-hook-ingress.md`), which reads a single normalized JSON lifecycle object from STDIN, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, maps it to a `RuntimeBoundary` (`start`/`advance`/`close`/`flush`) or an `abandon_scope()` call forwarding `scope_id`/`event_id`/`actor_kind` verbatim (any `worktree_id` key is refused — worktree identity is derived by the runtime from the invoking checkout), and invokes the runtime with a lazy DB provider closure so DB acquisition stays inside the protected-worktree ordering. Unlike the fail-open `diff-trace`/`conversation-trace` intakes, this ingress never discards a valid boundary: it classifies results by durable completion, returning a non-zero `CliError` for a malformed payload or a pre-completion runtime error while treating the two carried-outcome variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion` — the durable transition succeeded and only the trailing external-taint marker cleanup failed) as empty-stdout success without retrying the transition. Every successful execution emits empty stdout and writes `mutation_trace_*` rows only. It is the generic transport seam. A first concrete harness lifecycle adapter, for Claude Code (`cli/src/services/hooks/claude_mutation_scope/`, hidden `sce hooks claude-mutation-scope`, registered by `sce setup`), is now wired to it in-process via a `pub(crate)` seam rather than by re-invoking the ingress command; Codex, OpenCode, and Pi still have no adapter, and no `session → ScopeId` / `tool-call → EventId` derivation for those harnesses. A separate **read-only** consumer of the same module's persisted mutation-event history is wired, though: the post-commit Agent Trace flow calls `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch(...)` to attribute committed touched lines that direct `diff_traces` evidence does not cover, by replaying the newest 128 events for the invoking worktree (bounded also by a commit attribution cut captured under the worktree lock) oldest-to-newest as one causal per-line provenance lineage rather than matching historical patches independently, resolving the worktree's *existing* checkout identity only, creating no identity and writing no mutation-cursor state, and never inserting into `diff_traces` or `post_commit_patch_intersections` (see `context/cli/mutation-trace-agent-attribution.md` and `context/sce/agent-trace-hooks-command-routing.md`). See also `context/cli/mutation-trace-protocol.md`, `context/cli/mutation-trace-runtime-coordinator.md`, `context/cli/mutation-trace-ref-reconciliation.md`, `context/cli/mutation-trace-protected-worktree.md`, `context/cli/mutation-trace-scope-abandonment.md`, `context/cli/mutation-scope-runtime.md`, and `context/cli/mutation-scope-hook-ingress.md`. +It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. A new pure, dependency-free `cli/src/services/mutation_trace/` module now exists as a Rust refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol; it currently carries domain types and the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — is registered with `#[allow(dead_code)]`. A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every future harness adapter (Codex, Claude Code, OpenCode, Pi) must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are now driven by one harness-neutral CLI ingress, the hidden `sce hooks mutation-scope` command (`context/cli/mutation-scope-hook-ingress.md`), which reads a single normalized JSON lifecycle object from STDIN, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, maps it to a `RuntimeBoundary` (`start`/`advance`/`close`/`flush`) or an `abandon_scope()` call forwarding `scope_id`/`event_id`/`actor_kind` verbatim (any `worktree_id` key is refused — worktree identity is derived by the runtime from the invoking checkout), and invokes the runtime with a lazy DB provider closure so DB acquisition stays inside the protected-worktree ordering. Unlike the fail-open `diff-trace`/`conversation-trace` intakes, this ingress never discards a valid boundary: it classifies results by durable completion, returning a non-zero `CliError` for a malformed payload or a pre-completion runtime error while treating the two carried-outcome variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion` — the durable transition succeeded and only the trailing external-taint marker cleanup failed) as empty-stdout success without retrying the transition. Every successful execution emits empty stdout and writes `mutation_trace_*` rows only. It is the generic transport seam. A first concrete harness lifecycle adapter, for Claude Code (`cli/src/services/hooks/claude_mutation_scope/`, hidden `sce hooks claude-mutation-scope`, registered by `sce setup`), is now wired to it in-process via a `pub(crate)` seam rather than by re-invoking the ingress command, mapping Claude's `PreToolUse`/`PostToolUse`/lifecycle events onto one mutation `ScopeId` per mutation-capable tool execution with write-ahead fail-closed `Start`, terminal `Close`, an abandonment cleanup matrix, a `recovery_pending` barrier, and an explicit unsupported-boundary posture for detached background/self-detaching shell execution (see `context/cli/claude-mutation-scope-integration.md`); Codex, OpenCode, and Pi still have no adapter, and no `session → ScopeId` / `tool-call → EventId` derivation for those harnesses. A separate **read-only** consumer of the same module's persisted mutation-event history is wired, though: the post-commit Agent Trace flow calls `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch(...)` to attribute committed touched lines that direct `diff_traces` evidence does not cover, by replaying the newest 128 events for the invoking worktree (bounded also by a commit attribution cut captured under the worktree lock) oldest-to-newest as one causal per-line provenance lineage rather than matching historical patches independently, resolving the worktree's *existing* checkout identity only, creating no identity and writing no mutation-cursor state, and never inserting into `diff_traces` or `post_commit_patch_intersections` (see `context/cli/mutation-trace-agent-attribution.md` and `context/sce/agent-trace-hooks-command-routing.md`). See also `context/cli/mutation-trace-protocol.md`, `context/cli/mutation-trace-runtime-coordinator.md`, `context/cli/mutation-trace-ref-reconciliation.md`, `context/cli/mutation-trace-protected-worktree.md`, `context/cli/mutation-trace-scope-abandonment.md`, `context/cli/mutation-scope-runtime.md`, `context/cli/mutation-scope-hook-ingress.md`, and `context/cli/claude-mutation-scope-integration.md`. The generated `/next-task` workflow persists task-level context-synchronization lifecycle state in each plan (`pending`, `synced`, or `blocked`) so unresolved task synchronization debt survives a session boundary and gates new implementation. Successful `/next-task` execution hands task synchronization an explicit, pre-edit-Git-baseline-relative changed-file list plus implementation, verification, done-check, plan-update, and context-impact evidence, recorded directly on the completed task (`Completed`, `Files changed`, `Result`, `Verify`, `Context impact`, `Context synchronization`); the five-file root context pass remains mandatory. A later-session sync-debt retry reads that same completed task record directly from the plan by plan path and task ID, with no separate persisted synchronization handoff. `/validate` is validation-only: it runs final checks, writes the Validation Report, and reports `validated`, `failed`, or `blocked` without plan-level context synchronization. diff --git a/context/plans/claude-mutation-scope-integration.md b/context/plans/claude-mutation-scope-integration.md index 05fa7bd5..082e9a8a 100644 --- a/context/plans/claude-mutation-scope-integration.md +++ b/context/plans/claude-mutation-scope-integration.md @@ -410,80 +410,80 @@ How this plan is proven complete. Each criterion is observable and names the check that proves it. `/validate` runs these checks; no task in the stack performs final validation. -- [ ] AC1: `sce hooks claude-mutation-scope` exists, is hidden from top-level +- [x] AC1: `sce hooks claude-mutation-scope` exists, is hidden from top-level help, and routes through the normal hook command stack (`HooksSubcommand::ClaudeMutationScope` -> `convert_hooks_subcommand_request` -> `HookSubcommand::ClaudeMutationScope` -> `run_hooks_subcommand_in_repo`). - Validate: `sce hooks claude-mutation-scope ingress `Start` -> `active`). - Validate: T06 adapter ordering unit test with injected ingress; optionally also T08 Test1 as production-path confirmation. -- [ ] AC8: Any failure to establish required adapter state or `Start` during a +- [x] AC8: Any failure to establish required adapter state or `Start` during a mutation-capable `PreToolUse` returns a Claude `permissionDecision: "deny"` object, never a plain non-zero exit and never `allow`. - Validate: adapter failure-classification unit tests asserting the exact `hookSpecificOutput` JSON. -- [ ] AC9: `PreToolUse` -> real filesystem mutation -> `PostToolUse` produces +- [x] AC9: `PreToolUse` -> real filesystem mutation -> `PostToolUse` produces exactly one eligible tool interval and one terminal (`Closed`) scope with attribution `AiExclusive`. - Validate: T08 Test1 (real Git repo + real Agent Trace DB). -- [ ] AC10: `PreToolUse` -> partial filesystem mutation -> `PostToolUseFailure` +- [x] AC10: `PreToolUse` -> partial filesystem mutation -> `PostToolUseFailure` also observes the mutation and closes the scope (`AiExclusive` + `Closed`). - Validate: T08 Test2. -- [ ] AC11: Two simultaneously tracked tools create two active scopes; a tree +- [x] AC11: Two simultaneously tracked tools create two active scopes; a tree transition observed while both are live is attributed `AiContended`. - Validate: T08 Test3 and Test9 (main + subagent). -- [ ] AC12: `PreToolUse` followed by `PermissionDenied` creates no mutation event +- [x] AC12: `PreToolUse` followed by `PermissionDenied` creates no mutation event for the denied execution and leaves the worktree `needs_rebaseline`. - Validate: T08 Test5. -- [ ] AC13: A `PreToolUse` with no `PostToolUse`/`PostToolUseFailure` is retired +- [x] AC13: A `PreToolUse` with no `PostToolUse`/`PostToolUseFailure` is retired by one of the positive stale signals (`Stop`, `StopFailure`, main-thread `UserPromptSubmit`, matching-agent `SubagentStop`, `SessionEnd`, `WorktreeRemove`) via `abandon_scope`. - Validate: T08 Test6, Test7, Test11; T06 adapter cleanup unit tests. -- [ ] AC14: `PreToolUse` -> partial change/interruption -> no `Stop` -> next +- [x] AC14: `PreToolUse` -> partial change/interruption -> no `Stop` -> next main-thread `UserPromptSubmit` abandons the stale main attempt before another mutation-capable tool can start. - Validate: T08 Test7. -- [ ] AC15: A resumed subagent may carry the same Claude `agent_id`, but a new +- [x] AC15: A resumed subagent may carry the same Claude `agent_id`, but a new tool attempt receives a fresh tool `ScopeId`; no terminal mutation `ScopeId` is reused. - Validate: T06 adapter identity unit tests; T08 Test8. -- [ ] AC16: A hook process launched from checkout A with raw payload +- [x] AC16: A hook process launched from checkout A with raw payload `cwd = checkout B` drives mutation state for checkout B. - Validate: T08 Test10 (isolated-worktree cwd) asserting the correct `WorktreeId`/cursor is advanced. -- [ ] AC17: Mutations from an `isolation: worktree` subagent change only that +- [x] AC17: Mutations from an `isolation: worktree` subagent change only that worktree's mutation cursor; the main checkout's cursor is unchanged. - Validate: T08 Test10. -- [ ] AC18: The dependency direction is exactly +- [x] AC18: The dependency direction is exactly `claude_mutation_scope -> hooks::mutation_scope -> mutation_trace::runtime`. Production Claude-adapter code (everything in `cli/src/services/hooks/claude_mutation_scope/` outside `#[cfg(test)]` blocks) @@ -504,34 +504,34 @@ performs final validation. This is a dependency-boundary check, not a text search for the bare words `coordinate` / `abandon_scope` / `WorktreeId`, which may legitimately appear in comments, diagnostics, or test code that fabricates outcomes. -- [ ] AC19: Claude adapter state lives only below `/sce/` and writes no +- [x] AC19: Claude adapter state lives only below `/sce/` and writes no Agent Trace or mutation database table directly. - Validate: state-module inspection; T08 Test16. -- [ ] AC20: Claude mutation-scope-only regressions leave `diff_traces`, +- [x] AC20: Claude mutation-scope-only regressions leave `diff_traces`, `post_commit_patch_intersections`, and `agent_traces` unchanged. - Validate: T08 Test16 (row-count assertions before/after). -- [ ] AC21: Explicit background `Bash`/`PowerShell` +- [x] AC21: Explicit background `Bash`/`PowerShell` (`run_in_background = true`) is denied in `PreToolUse` with the documented reason and creates no mutation scope. - Validate: T06 adapter classification unit test; T08 Test15. -- [ ] AC22: Generated Claude settings still include and correctly merge +- [x] AC22: Generated Claude settings still include and correctly merge `claude-model-state`, the bash policy hook, `diff-trace`, and `conversation-trace` alongside the new mutation adapter; user-owned Claude hooks are preserved; repeated `sce setup` is idempotent. - Validate: `config_merge.rs` tests; `nix run .#pkl-check-generated`. -- [ ] AC23: The diff against the `#261` base +- [x] AC23: The diff against the `#261` base (`origin/mutation-scope-ingress`) is empty for `spec/mutation_cursor.qnt`, `cli/src/services/mutation_trace/protocol.rs`, `cli/migrations/agent-trace-repository/`, and `config/schema/agent-trace.schema.json`. - Validate: `git diff origin/mutation-scope-ingress -- ` is empty. -- [ ] AC24: Durable context clearly separates generic mutation-scope ingress, +- [x] AC24: Durable context clearly separates generic mutation-scope ingress, the Claude mutation adapter, and the mutation runtime, and records tool-attempt scope semantics, identity derivation, cleanup signals, worktree-cwd ownership, fail-closed `PreToolUse`, and the background-shell limitation. - Validate: inspection of `context/cli/claude-mutation-scope-integration.md` and the updated cross-reference files. -- [ ] AC25: A foreground Bash/PowerShell tool call (`run_in_background = false`) +- [x] AC25: A foreground Bash/PowerShell tool call (`run_in_background = false`) that starts a detached, self-backgrounding descendant process which mutates the repository after `PostToolUse` returns is not silently attributed as if the mutation happened inside that tool's own observed scope; the adapter @@ -1705,7 +1705,7 @@ Persist this field in every plan; this is durable plan state, not chat state: T06/T07's identical precedent, rather than a piecemeal fix outside this task's own Context sync list membership. -- [ ] T09: `Author the durable adapter context` (status:todo) +- [x] T09: `Author the durable adapter context` (status:done) - Task ID: T09 - Scope: In — create `context/cli/claude-mutation-scope-integration.md` owning the tool-attempt scope model, tool classification, `ScopeId`/`EventId` @@ -1732,7 +1732,124 @@ Persist this field in every plan; this is durable plan state, not chat state: check but the map/overview must stay internally consistent). - Verify: inspection against AC24/AC25; `grep` shows the new route documented in the routing file and the new file linked from `context/context-map.md`. - - Context synchronization: pending + - Completed: 2026-09-07 + - Files changed: + - `context/cli/claude-mutation-scope-integration.md` (new — 250-line + dedicated adapter contract: scope model + `classify_tool` table + + `is_explicit_background_shell`; `(session_id, agent_id?, tool_use_id)` + key, `cc-tool-v1|n=..|s=..|a=..|t=..` `ScopeId` and `|start` / + `|close` `EventId` derivation; `/sce/claude-mutation-scope-state.json` + bookkeeping + separate lock never held across the seam; `PreToolUse` + write-ahead `pending_start` -> seam `start` -> `active` with the exact + fail-closed `permissionDecision: "deny"` JSON and + `sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed` logging; + `PostToolUse`/`PostToolUseFailure` -> `close` with D11 abandon-not-late-start + and D12 abandon-not-replay; the abandonment cleanup-signal table + (`PermissionDenied`/`Stop`/`StopFailure`/`UserPromptSubmit`/`SubagentStop`/ + `SessionEnd`/best-effort `WorktreeRemove`); the `recovery_pending` barrier; + raw-`cwd`-authoritative worktree ownership; the `run_in_background=true` + denial and the D20 self-detaching-descendant unsupported boundary with + T04's Git-observable evidence; the ten unmatched `sce setup` registrations; + and the `claude_mutation_scope -> hooks::mutation_scope -> + mutation_trace::runtime` dependency boundary via the single T05 seam import) + - `context/cli/mutation-scope-hook-ingress.md` (the "Generic ingress vs + harness adapter" section and Related-context list now point to the new + file for the adapter's full contract instead of only describing it inline; + 244 -> 247 lines) + - `context/cli/mutation-scope-runtime.md` (intro and Status section: the + "not-yet-user-reachable" / "dedicated contract file lands once the full + adapter ships" wording replaced with "shipped Claude Code adapter … its + full contract is in `claude-mutation-scope-integration.md`"; net + line-count-neutral at 259, pre-existing over-budget debt untouched per + T06/T07/T08's recorded deferral) + - `context/sce/agent-trace-hooks-command-routing.md` (the + `sce hooks claude-mutation-scope` route entry's "full contract … lands in + a dedicated file once real-repository regressions ship" replaced with a + direct link to the now-existing file; Related-context list gains the link) + - `context/sce/claude-raw-hook-capture.md` (new "Current state" bullet + recording the ten unmatched `sce hooks claude-mutation-scope` generated + `.claude/settings.json` registrations as additive entries that still do + not restore raw event capture, linking the new file) + - `context/context-map.md` (new `context/cli/claude-mutation-scope-integration.md` + feature/domain entry; the `agent-trace-hooks-command-routing.md` index + line's deferral now names both `mutation-scope-hook-ingress.md` and the + new file) + - `context/overview.md` (the Claude-adapter sentence in the mutation-trace + paragraph expanded to name the write-ahead fail-closed `Start`, `Close`, + cleanup matrix, `recovery_pending` barrier, and background-shell + unsupported posture, pointing to the new file; the paragraph's trailing + "See also" list gains the link) + - `context/architecture.md` (the `cli/src/services/hooks/mod.rs` bullet now + lists the `claude-mutation-scope` adapter alongside the `mutation-scope` + ingress, with its STDIN shape, tool-classification/identity derivation, + the shared in-process seam, `sce setup` registration, and the new file) + - Result: Authored `context/cli/claude-mutation-scope-integration.md` as the + durable contract for the shipped Claude Code mutation-scope adapter (T02-T08), + covering every topic AC24 enumerates — the tool-attempt scope model, D2 tool + classification, D4 `ScopeId`/`EventId` derivation, D5/D6 adapter state, D7 + write-ahead `Start`, D8 fail-closed `PreToolUse`, D9/D10 terminal `Close` + and failed-tool behavior, D11/D12 uncertain-boundary abandonment, the + D13-D18/D22 cleanup signals, D19 recovery barrier, D3/D17 subagent identity, + D21 raw-`cwd` worktree ownership, the D20 background-shell limitation (both + `run_in_background=true` denial and the self-detaching-descendant boundary, + with T04's reconciled Git-observable finding), and the D23 dependency + boundary. The seven cross-reference files were updated to point at it and + drop the "lands once the full adapter ships" / "not-yet-user-reachable" + placeholders written against earlier task states. No code, test, settings, + schema, or non-context file changed (`git status` confirms exactly the eight + files listed above). The new file lands at exactly 250 lines (the file + hygiene budget); `mutation-scope-runtime.md` stays at its pre-existing 259 + (over-budget debt T06/T07/T08 explicitly deferred and that a full split — + outside this task's "update to reference the shipped adapter" scope — would + resolve); every other edited file stays at or under 250. + - Verify: AC24 — the new file separates generic ingress + (`mutation-scope-hook-ingress.md`), the Claude adapter + (`claude-mutation-scope-integration.md`), and the runtime contract + (`mutation-scope-runtime.md`), and records tool-attempt scope semantics, + identity derivation, cleanup signals, worktree-cwd ownership, fail-closed + `PreToolUse`, and the background-shell limitation (each its own section). + AC25 — the "Background shell is unsupported" section documents the + self-detaching-descendant case as an explicit unsupported boundary (D20) + citing T04's captured `probe17-*` fixtures and `NOTES.md` addendum, adding + no detection or supervision. `grep` confirms the route is documented in + `context/sce/agent-trace-hooks-command-routing.md` + (`[../cli/claude-mutation-scope-integration.md]` on the + `sce hooks claude-mutation-scope` line) and the new file is linked from + `context/context-map.md` (dedicated entry at the mutation-scope cluster). + All seven cross-reference files link the new file; all outbound links from + the new file resolve (the two `#…` targets are in-document section + anchors). `nix flake check` is not applicable — this task changed only + `context/**` Markdown, which no flake check inspects; internal consistency + was verified by the link and stale-phrase scans instead. + - Context impact: Adds one new durable domain file + (`context/cli/claude-mutation-scope-integration.md`) and updates seven + existing context files that previously deferred or under-described the + now-shipped Claude adapter. This is the plan's own Context sync list + being executed as a task; the root-context pass in the synchronization + phase still applies. No code or executable configuration changed, so there + is no behavior for other context to have outrun — this task brings context + up to the behavior T02-T08 already shipped. + - Context synchronization: synced — this task's deliverable *was* the plan's + Context sync list, so the new `context/cli/claude-mutation-scope-integration.md` + plus the seven cross-reference edits are the synchronization. The mandatory + root pass confirmed `context/overview.md` and `context/architecture.md` (both + edited) accurately describe the shipped adapter, `context/context-map.md` + (edited) carries the new entry and corrected deferral line, and + `context/glossary.md` / `context/patterns.md` contain no + mutation-scope/adapter terminology and are not contradicted (consistent with + T01-T08's deliberate precedent of keeping this domain's language in its + domain files). No decision qualified for an ADR: T09 authors the durable + description of the D1-D23 decisions already recorded in this plan's Design + section and already assessed non-ADR-qualifying by T06/T07, establishing no + new system-wide constraint. Feature existence: the shipped Claude + mutation-scope adapter now has its canonical description at + `context/cli/claude-mutation-scope-integration.md`, linked from + `context/context-map.md` and six other context files. File hygiene: the new + file is exactly 250 lines and every other edited context file is at or + under 250 except `context/cli/mutation-scope-runtime.md` at 259 — + pre-existing over-budget debt T06/T07/T08 each recorded and deferred, left + net line-count-neutral by this task rather than expanded, since a full + split is outside T09's "update to reference the shipped adapter" scope. ## Open questions @@ -1770,3 +1887,65 @@ Persist this field in every plan; this is durable plan state, not chat state: leave subagent identity, `isolation: worktree`, and the full cleanup matrix to a stacked follow-up? The current slicing is coherent, but T06 is large and its correctness rests entirely on T01's findings. + +## Validation Report + +**Status:** validated +**Date:** 2026-09-07 + +### Commands run + +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::claude_mutation_scope` -> exit 0 (107 tests passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope` -> exit 0 (36 tests passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::` -> exit 0 (331 tests passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::` -> exit 0 (323 tests passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` -> exit 0 (1147 tests passed) +- `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` -> exit 0 (clean) +- `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` -> exit 0 (clean) +- `nix run .#pkl-check-generated` -> exit 0 (141 generated files passed parity check) +- `nix flake check` -> exit 0 (all checks passed) +- `git diff origin/mutation-scope-ingress -- spec/mutation_cursor.qnt cli/src/services/mutation_trace/protocol.rs cli/migrations/agent-trace-repository/ config/schema/agent-trace.schema.json` -> exit 0 (empty) +- `PATH="$PWD/cli/target/debug:$PATH" sce hooks claude-mutation-scope exit 4 (strict parser error; route exists) +- Current-binary `sce --help` and `sce hooks --help` inspection -> passed (hidden route omitted) +- AC24/AC25 documentation and fixture inspection -> passed + +### Success-criteria verification + +- [x] AC1: Hidden `claude-mutation-scope` route exists and follows the normal hook stack — current binary returned the strict empty-payload parser error; routing tests passed; both help surfaces omit the route. +- [x] AC2: Required Claude identity fields and types are strict — focused adapter suite passed parser rejection and optional-field tests. +- [x] AC3: Lifecycle/delegation events do not create scopes — adapter tests and production regression assertions passed. +- [x] AC4: Duplicate live attempts reuse identity and event IDs — state/driver tests and production Test4 passed. +- [x] AC5: Terminal attempts receive fresh IDs — state tests and production Test8 passed. +- [x] AC6: Main, agent A, and agent B IDs are distinct — formatter tests passed. +- [x] AC7: Start is write-ahead durable — driver ordering test passed. +- [x] AC8: PreToolUse failures deny with the exact Claude response — failure-classification and logging tests passed. +- [x] AC9: Successful foreground mutation closes as `AiExclusive` — production Test1 passed. +- [x] AC10: Failed partial mutation closes as `AiExclusive` — production Test2 passed. +- [x] AC11: Concurrent scopes yield `AiContended` — production Tests3 and 9 passed. +- [x] AC12: Permission denial abandons and requires rebaseline — production Test5 passed. +- [x] AC13: Positive stale signals retire attempts — cleanup tests and production Tests6 and 11 passed. +- [x] AC14: Main prompt interruption cleanup runs before the next mutation — production Test7 passed. +- [x] AC15: Resumed subagent attempts get fresh scope IDs — production Test8 passed. +- [x] AC16: Raw event cwd selects the correct checkout — production Test10 passed. +- [x] AC17: Isolated subagent cursor is independent — production Test10 passed. +- [x] AC18: Production dependency direction is preserved — source inspection found no forbidden production references and one mutation-scope seam; remaining matches are test-only assertions. +- [x] AC19: Adapter state is confined below `/sce/` and does not write trace tables — state/path and production regression checks passed. +- [x] AC20: Raw Agent Trace tables remain unchanged — production Test16 passed. +- [x] AC21: Explicit background Bash/PowerShell is denied without a scope — classifier/driver tests and production Test15 passed. +- [x] AC22: Claude settings merge, preservation, idempotency, and doctor behavior remain correct — setup/doctor tests, generated inspection, and Pkl parity passed. +- [x] AC23: Protected paths have no diff from `origin/mutation-scope-ingress` — targeted diff was empty. +- [x] AC24: Durable context separates ingress, adapter, and runtime contracts and documents the required semantics — focused documentation inspection passed. +- [x] AC25: Self-detaching descendant limitation is documented and regression-covered — T04 fixtures/notes and production Test17 passed. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- `StopFailure` and `WorktreeRemove` were not observed live on Claude Code 2.1.258; their best-effort handlers remain non-load-bearing with lifecycle fallbacks. +- Self-detaching descendant processes remain an explicitly unsupported attribution boundary. + +### Notes + +The installed `sce` on the ambient PATH was an older binary; the route check was repeated with the checkout-built binary first on PATH. No repository changes were present after validation apart from this report and the acceptance-checkbox updates. diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index e3dc183e..abeca6f0 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -122,7 +122,7 @@ - `session-model` is no longer a supported `sce hooks` subcommand and generated Claude settings no longer produce the retired generic session-model route. The `session_models` DB API/table and generic fallback are removed from active code; upgraded databases may still contain the retired table, but runtime paths no longer read or write it. The separate `sce hooks claude-model-state` command is a Claude-specific local register, and `diff-trace` consults only its exact `(cc_, agent_id)` state after direct and transcript attribution fail; this does not restore the generic abstraction. - `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables, calling the shared `insert_conversation_text_event` atomic primitive rather than the plain `insert_messages`/`insert_parts` calls described above (so a replayed or concurrent duplicate delivery leaves exactly one message and one part row, not only the parent message row), with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths independently from the event `cwd` against the canonical Git root: valid `..` components and absolute-inside paths are accepted, missing Add File targets are allowed through their nearest existing prefix, and outside or symlink-escaping mappings are rejected. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. The Codex setup/doctor boundary is separate from evidence intake: `.codex/hooks.json` is merged through the shared structural `codex_hook_config` service, which preserves valid user handlers and recognizes only the generated helper path plus the `sce hooks codex` command contract. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. - `sce hooks mutation-scope` (hidden) is a separate single ingress (not routed through `diff-trace`/`conversation-trace`) that drives the mutation-scope runtime rather than writing conversation/diff evidence. It reads one normalized JSON lifecycle object from STDIN via the shared `read_hook_stdin`, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, and translates it into one `RuntimeBoundary` (`start`/`advance`/`close` → `Start`/`Advance`/`Close`; `flush` → `Flush`) passed to `mutation_trace::runtime::coordinate(...)`, or a direct `mutation_trace::runtime::abandon_scope(...)` call for `abandon`. `scope_id`/`event_id`/`actor_kind` (`claude_code`/`codex`/`opencode`/`pi`) are forwarded verbatim — no trim, prefix, hash, UUID, or timestamp — because `EventId` equality is the runtime's replay/idempotency key; any `worktree_id` key is a hard rejection (worktree identity is derived by the runtime from the invoking checkout). DB acquisition is lazy: a `FnOnce` provider closure reusing `open_agent_trace_db_for_hook_runtime` is passed into `coordinate()`/`abandon_scope()` so it runs inside the runtime's protected-worktree ordering, never before. **Non-fail-open intake contract, distinct from `diff-trace`/`conversation-trace`:** a lost lifecycle boundary can change which scope stays live and therefore alter attribution, so the dispatch arm is unwrapped like `pre-commit` (no `Ok(...)` fail-open shim) and there is no dropped-boundary / `exit 0` branch. Results are classified by durable completion — a malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` returns `CliError`/non-zero; `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are the two carried-outcome success variants (the durable transition already succeeded and only the trailing external-taint marker cleanup failed), reported as empty-stdout `Ok` with the failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion` and the transition **not** retried. Every successful execution emits empty stdout with no serialized outcome/revision/worktree/scope state. It writes `mutation_trace_*` rows only — never `diff_traces`, `post_commit_patch_intersections`, or `agent_traces`. A first Claude Code adapter driver now consumes it in-process (see below); Codex/OpenCode/Pi still have no lifecycle adapter. See [mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md) for the full contract. -- `sce hooks claude-mutation-scope` (hidden) is the first concrete harness adapter targeting the mutation-scope runtime: it reads one raw Claude hook JSON event from STDIN, classifies the tool, and drives `mutation_scope::run_mutation_scope_from_payload` (the same in-process `pub(crate)` seam above, called directly — not by re-invoking `sce hooks mutation-scope`) to `start`/`close`/`abandon`/`flush` a scope per event. The dispatch arm is unwrapped like `mutation-scope`, not fail-open. Registered by `sce setup` (`config/pkl/renderers/claude-content.pkl`) for `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionDenied`, `UserPromptSubmit`, `Stop`, `StopFailure`, `SubagentStop`, `SessionEnd`, and `WorktreeRemove`, with no `matcher` (the adapter classifies tools in Rust), so a real Claude Code session now reaches it. Full contract, event mapping, and design rationale land in a dedicated `context/cli/claude-mutation-scope-integration.md` once real-repository regressions ship. +- `sce hooks claude-mutation-scope` (hidden) is the first concrete harness adapter targeting the mutation-scope runtime: it reads one raw Claude hook JSON event from STDIN, classifies the tool, and drives `mutation_scope::run_mutation_scope_from_payload` (the same in-process `pub(crate)` seam above, called directly — not by re-invoking `sce hooks mutation-scope`) to `start`/`close`/`abandon`/`flush` a scope per event. The dispatch arm is unwrapped like `mutation-scope`, not fail-open. Registered by `sce setup` (`config/pkl/renderers/claude-content.pkl`) for `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionDenied`, `UserPromptSubmit`, `Stop`, `StopFailure`, `SubagentStop`, `SessionEnd`, and `WorktreeRemove`, with no `matcher` (the adapter classifies tools in Rust), so a real Claude Code session now reaches it. Full contract, event mapping, and design rationale are in [../cli/claude-mutation-scope-integration.md](../cli/claude-mutation-scope-integration.md). ## Explicit non-goals in the current baseline @@ -142,3 +142,4 @@ - [SCE sync command](../cli/sync-command.md) - [Mutation-scope hook ingress](../cli/mutation-scope-hook-ingress.md) - [Mutation-scope runtime: the harness-adapter contract](../cli/mutation-scope-runtime.md) +- [Claude mutation-scope integration: the first concrete harness adapter](../cli/claude-mutation-scope-integration.md) diff --git a/context/sce/claude-raw-hook-capture.md b/context/sce/claude-raw-hook-capture.md index 5e80fc69..9731f498 100644 --- a/context/sce/claude-raw-hook-capture.md +++ b/context/sce/claude-raw-hook-capture.md @@ -26,6 +26,7 @@ The generated Claude TypeScript runtime at `config/.claude/plugins/sce-agent-tra ## Current state - Claude settings call the generated Bash helper `.claude/hooks/run-sce-or-show-install-guidance.sh` via generated `.claude/settings.json` command hooks before invoking `sce`: `SessionStart` and `PostModelSwitch` pipe lifecycle JSON to `sce hooks claude-model-state`, matched `PostToolUse Write|Edit|MultiEdit|NotebookEdit` pipes raw hook event JSON to `sce hooks diff-trace`, supported conversation events pipe raw hook event JSON to `sce hooks conversation-trace`, and `PreToolUse Bash` pipes raw hook event JSON to `sce policy bash`. The helper emits `sce CLI not found. Install it from https://sce.crocoder.dev/docs/getting-started#install-cli` and exits successfully when `sce` is missing; when `sce` exists it `exec`s the original command arguments so Rust receives stdin and owns stdout/stderr/exit behavior. Rust handles extraction, validation, and persistence without a TypeScript intermediary. The lifecycle registrations are model-state-only and do not restore raw event capture. +- Generated `.claude/settings.json` also registers the hidden `sce hooks claude-mutation-scope` mutation-scope adapter through the same Bash helper, with **no** `matcher`, on `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionDenied`, `UserPromptSubmit`, `Stop`, `StopFailure`, `SubagentStop`, `SessionEnd`, and `WorktreeRemove`. These are additional entries alongside the model-state, bash-policy, `diff-trace`, and `conversation-trace` registrations, not replacements, and still do not restore raw event capture (the adapter drives the mutation-cursor runtime, writing `mutation_trace_*` rows only). Full contract in [../cli/claude-mutation-scope-integration.md](../cli/claude-mutation-scope-integration.md). - The former Claude TypeScript runtime at `config/.claude/plugins/sce-agent-trace.ts` was removed in T07 of the `claude-rust-diff-trace` plan. - Rust owns normalized persistence: `diff-trace` inserts into `diff_traces` with `payload_type` classification (`"patch"` for OpenCode, `"structured"` for Claude), while `conversation-trace` inserts normalized messages/parts. - Claude `diff-trace` attempts best-effort direct `model_id` extraction from the raw `PostToolUse` payload, then exact transcript lookup and exact Claude lifecycle-state lookup, storing the resulting nullable attribution in `diff_traces`; OpenCode sends `model_id` directly and may send nullable `tool_version`. Missing metadata is persisted as `NULL`; Claude lifecycle state is local-only and is not a generic session cache. From 9ea5e75b446e1c15db96213e4c6a6b3f6deb351a Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 7 Sep 2026 15:07:09 +0200 Subject: [PATCH 11/11] config: Add SCE mutation-scope hooks to Claude lifecycle events Ensure Claude lifecycle events invoke the shared mutation-scope hook so SCE policy handling covers permission, tool-failure, session, stop, and prompt boundaries consistently. --- .claude/settings.json | 92 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index b025507b..5f1fc45d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1,16 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", "hooks": { + "PermissionDenied": [ + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-mutation-scope", + "type": "command" + } + ] + } + ], "PostModelSwitch": [ { "hooks": [ @@ -28,6 +38,24 @@ "type": "command" } ] + }, + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-mutation-scope", + "type": "command" + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-mutation-scope", + "type": "command" + } + ] } ], "PreToolUse": [ @@ -39,6 +67,24 @@ } ], "matcher": "Bash" + }, + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-mutation-scope", + "type": "command" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-mutation-scope", + "type": "command" + } + ] } ], "SessionStart": [ @@ -59,6 +105,34 @@ "type": "command" } ] + }, + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-mutation-scope", + "type": "command" + } + ] + } + ], + "StopFailure": [ + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-mutation-scope", + "type": "command" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-mutation-scope", + "type": "command" + } + ] } ], "UserPromptSubmit": [ @@ -69,6 +143,24 @@ "type": "command" } ] + }, + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-mutation-scope", + "type": "command" + } + ] + } + ], + "WorktreeRemove": [ + { + "hooks": [ + { + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-sce-or-show-install-guidance.sh\" sce hooks claude-mutation-scope", + "type": "command" + } + ] } ] }