From 5eb74d2413ac57b438d6301fc749c43db61438cc Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 7 Sep 2026 15:22:52 +0200 Subject: [PATCH 01/20] context: Add Codex mutation-scope integration plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define the empirical research, adapter implementation, hook routing, setup/doctor integration, regression coverage, and durable context needed to wire Codex into mutation-scope tracking. The plan sequences T01 through T07 and records fail-closed, recovery, lifecycle, attribution, and unsupported-execution requirements. Ref: context/plans/codex-mutation-scope-integration.md (T01–T07) --- .../plans/codex-mutation-scope-integration.md | 1400 +++++++++++++++++ 1 file changed, 1400 insertions(+) create mode 100644 context/plans/codex-mutation-scope-integration.md diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md new file mode 100644 index 00000000..81ea08d1 --- /dev/null +++ b/context/plans/codex-mutation-scope-integration.md @@ -0,0 +1,1400 @@ +# Plan: codex-mutation-scope-integration + +## Change summary + +Add the **second** concrete mutation-scope producer for SCE: a Codex adapter +that translates raw Codex hook lifecycle 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`) and proven end-to-end by the +Claude adapter (`context/cli/claude-mutation-scope-integration.md`, PR #263). + +Data flow: + +```text +Codex raw hook event + -> sce hooks codex-mutation-scope (new, hidden — see T02 for the command decision) + -> normalize Codex lifecycle + identity, classify tool + -> hooks::mutation_scope generic ingress (in-process pub(crate) seam) + -> coordinate() / abandon_scope() + -> mutation cursor +``` + +The fundamental mapping is **one independently mutation-capable Codex tool +execution = one SCE mutation `ScopeId`**. A Codex session, turn, or delegated +agent is never a scope; `session_id` / `turn_id` / any delegated-agent identity +are only inputs that distinguish tool executions. + +This extends the mutation-scope stack. The generic ingress, the runtime +(`coordinate()` / `abandon_scope()`), and the `mutation-trace` protocol already +exist and are **unchanged in contract**. `ActorKind::Codex` and the +`"actor_kind":"codex"` wire value are already accepted by +`parse_mutation_scope_payload` today — the Codex adapter is a new caller of an +existing seam, not a protocol change. This change adds the Codex harness adapter +layer the ingress explicitly deferred, its checkout-local bookkeeping, its +hidden command/routing, and its generated `.codex/hooks.json` registrations +through the existing shared Codex hook-config ownership/merge/doctor system. + +**The Claude adapter is a structural reference, not a lifecycle specification +for Codex.** Codex's hook surface differs materially from Claude's: it has no +`PostToolUseFailure`, no `PermissionDenied`, no `StopFailure`, and no +`WorktreeRemove` event (the eleven event names `codex_hook_config.rs` accepts +are `PreToolUse`, `PermissionRequest`, `PostToolUse`, `PreCompact`, +`PostCompact`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`, +`SubagentStart`, `SubagentStop`, `Stop`). Every Codex lifecycle mapping in this +plan is therefore **gated on T01 evidence** rather than copied from Claude. T01 +freezes the real Codex hook contract for the Codex version SCE chooses to +support before any mapping is designed. + +The existing `sce hooks codex` integration (conversation tracing via +`UserPromptSubmit`/`Stop`, `PreToolUse(Bash)` policy, `PostToolUse(apply_patch)` +`diff_traces` evidence, `.codex/hooks.json` ownership/merge, doctor trust +diagnosis) is preserved untouched and additive. Mutation-scope events continue +to write only through the mutation runtime (`mutation_trace_*` tables); the +`apply_patch -> diff_traces -> post-commit intersection` evidence pipeline is a +complementary system and is not folded into mutation-scope storage. + +No mutation protocol, Quint model, mutation-trace SQL migration, +mutation-attribution algorithm, or Agent Trace schema change is in scope. If T01 +reveals Codex fundamentally cannot be represented by the current mutation-scope +contract, T01 stops and records the contradiction in this plan's Open questions +rather than modifying the protocol; any protocol change becomes a separate, +explicitly justified PR. + +## Stack and base + +- **Predecessor:** PR #263 `claude-mutation-scope-integration` + (head branch `claude-mutation-scope-integration`, itself based on + `mutation-scope-ingress` / #261). +- **This branch:** `codex-mutation-scope-integration`, already created at #263's + head (no commits ahead of `claude-mutation-scope-integration` at plan time). +- **Base for the PR while the stack is unmerged:** + `claude-mutation-scope-integration` (#263 head), **not** `main`. +- **Final branch comparison** is against `claude-mutation-scope-integration`, + not `main`, for as long as #263 remains open. If the stack has changed by + execution time (e.g. #263 merged to `main`, or a new predecessor inserted), + re-check `gh pr list` and rebase onto the actual current predecessor, then + update this section and every `origin/claude-mutation-scope-integration` + reference below. + +## Design + +These are the design decisions the task stack and acceptance criteria reference +by number. Decisions whose correctness depends on a Codex lifecycle signal +actually firing (or on a payload field actually being present and stable) are +marked **T01-GATED** and carry no committed mapping until T01 records a +disposition (`PROVEN`, `DOCUMENTED — NON-LOAD-BEARING`, `ASSUMPTION — PROBE`, +or `UNSUPPORTED`). T01 writes each disposition back into this Design section. + +### D1 — Scope = one independently mutation-capable Codex tool execution + +A mutation scope is exactly one independently mutation-capable Codex tool +execution attempt. Not a session, not a turn, not a delegated agent. Sequential +tool calls are sequential scopes. Whether two Codex mutation-capable executions +can genuinely overlap (and therefore whether Codex alone can produce +`AiContended`) is **T01-GATED** — Codex has historically executed tools +serially within a turn. `AiContended` can still arise from a Codex scope +overlapping another harness's scope on the same worktree regardless of T01's +finding; see D14. + +### D2 — Codex tool classification — T01-GATED + +The adapter classifies the raw Codex `tool_name` in Rust into: + +- **Mutation-capable (establishes a scope):** at minimum `apply_patch` and the + Codex shell/`Bash` tool. MCP tools and any **unknown** tool name are treated + conservatively as mutation-capable (an unknown read-only tool only creates + harmless scopes; the opposite default silently misses a new mutation-capable + tool). +- **Read-only (never a scope):** the Codex file-read / list / search / web + tools T01 enumerates. +- **Delegation (never a scope):** whatever tool Codex uses to spawn a delegated + agent, if any — the delegated agent's own mutation-capable tool calls + establish their own scopes. + +T01 must enumerate Codex's actual tool-name vocabulary (`tool_name` values on +`PreToolUse`/`PostToolUse`, MCP tool naming, delegation tool name). The exact +membership of each list is frozen by T01 and recorded here. + +### D3 — Codex execution identity — T01-GATED + +The adapter needs the **smallest stable identity for one Codex tool execution**. +Candidate inputs, to be confirmed by T01 evidence only: + +- `session_id` (present on Codex events today, stored `cx_`-prefixed elsewhere); +- `tool_use_id` (used today by the `apply_patch` `diff_traces` path to derive + synthetic line identities — so present on at least `PostToolUse`); +- `turn_id` (present on Codex events today); +- a delegated-agent identifier **only if T01 proves Codex exposes one** — do not + invent an `agent_id` abstraction if Codex has no equivalent. + +T01 must establish: (a) which of these is present on `PreToolUse`, (b) which is +present on `PostToolUse` (and any terminal/failure event), (c) whether the same +execution identity appears in both the pre and post events for one tool call, +(d) whether a raw Codex tool identifier can recur after that execution is +terminal. T02 then freezes the execution key. + +### D4 — ScopeId / EventId derivation — depends on D3 + +A raw Codex tool identifier that can recur after terminal execution forces a +checkout-local monotonic attempt sequence, exactly as in the Claude adapter: the +adapter keeps `next_attempt_seq` in its bookkeeping store and each new attempt +draws a fresh `attempt_seq`. A terminal SCE `ScopeId` is **never reused**. + +`ScopeId` is a length-prefixed, hash-free encoding (no crypto dependency) in a +Codex-specific versioned namespace. The conceptual shape, **not frozen until D3 +is resolved by T01**: + +```text +cx-tool-v1|n=| +``` + +`EventId`s derive deterministically from the `ScopeId`: `|start` and +`|close`. 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 hook event for the same raw Codex tool +identifier draws a new `attempt_seq` and a new `ScopeId`. + +### D5 — Checkout-local adapter bookkeeping — reasoned, not assumed + +Codex hooks are invoked as **independent OS processes** — the generated +`.codex/hooks.json` command is `... exec bash "$root/.codex/hooks/ +run-sce-or-show-install-guidance.sh" sce hooks codex-mutation-scope`, a fresh +process per hook event. A `PreToolUse` process and the later `PostToolUse` +process for the same tool call share no in-memory state. Therefore the adapter +**requires** durable cross-process bookkeeping to know which Codex-created +scopes may still need a terminal action — the same conclusion the Claude adapter +reached, for the same reason. T02/T03 must confirm this holds for the supported +Codex version (Codex does not, for example, run all hooks for one turn in a +single persistent process); if it does not, T03 records why the store shape +changes. + +The store lives at `/sce/codex-mutation-scope-state.json` with lock +`/sce/codex-mutation-scope-state.lock` (`` via +`checkout::resolve_git_dir(cwd)` — worktree-specific for linked worktrees). It +holds a versioned `{version, next_attempt_seq, recovery_pending, attempts[]}`, +each attempt carrying `attempt_seq`, `scope_id`, the D3 identity fields, +`tool_name`, and `phase` (`pending_start | active`). This is **adapter +bookkeeping, never attribution evidence**: not exported, not synced, not part of +Agent Trace, not authoritative for attribution. A malformed or wrong-version +file is rejected, never fabricated. + +### D6 — Durable persistence and a separate state lock + +State writes follow the `checkout::persist_checkout_id_inner` durability pattern +(lock, temp file, `sync_data`, atomic rename, best-effort parent-dir `sync_all` +on Unix). The adapter-state lock protects bookkeeping only and is **never held +across a `hooks::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`. + +### D7 — Write-ahead Start ordering + +Unless T01 proves a different safe ordering from Codex's hook semantics, the +adapter preserves the write-ahead property for a new tracked mutation-capable +tool: + +```text +parse event -> resolve raw cwd -> resolve git_dir (bookkeeping only) + -> acquire state lock -> allocate attempt_seq -> persist phase=pending_start -> release lock + -> invoke generic ingress seam with { "operation":"start", "scope_id":, + "event_id":|start, "actor_kind":"codex" }, passing the raw cwd as repository_root + -> reacquire state lock -> phase pending_start -> active -> release lock + -> return (Codex-native "continue" — see D8) +``` + +A mutation-capable tool must not execute after SCE has failed to establish its +mutation scope. + +### D8 — Codex-native fail-closed PreToolUse — T01-GATED + +A mutation-capable Codex `PreToolUse` is **fail-closed**: any failure to durably +establish the scope (state-allocation failure, seam `Start` failure, +unresolvable `cwd`, recovery-barrier denial) must **block the tool**, not let it +run un-scoped. + +The exact Codex-native denial response and exit semantics are **T01-GATED**. Do +**not** assume Claude's `{"hookSpecificOutput":{...,"permissionDecision": +"deny",...}}` shape. Candidate shapes to disambiguate in T01 for the supported +version: the Claude-identical `hookSpecificOutput.permissionDecision: "deny"` +(the shape the existing `sce hooks codex` `PreToolUse(Bash)` policy arm returns +today, per `openai/codex` issue #28437), or a `{"decision":"block","reason": +"..."}` shape seen in some Codex versions, or a non-zero exit code. T01 records +which one blocks the tool for the supported version; T04 emits exactly that. + +The detailed error is logged via `Logger::warn` +(`sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed`); the model-visible +denial reason never carries it. The adapter never emits an explicit **allow** +that bypasses Codex's own permission system — success returns Codex's neutral +"continue" (empty stdout, or whatever T01 shows is the no-op response), and only +failure returns the block. + +A read-only or delegation `PreToolUse` returns the neutral response, no scope. + +### D9 — Terminal boundary on success — T01-GATED + +For a successful mutation-capable tool with an `active` tracked attempt, the +terminal Codex hook maps to +`{ "operation":"close", "scope_id":, "event_id":|close, +"actor_kind":"codex" }`. T01 must confirm **which** hook is the reliable +terminal signal for a successful mutation-capable execution (`PostToolUse` for +that tool, carrying the D3 identity that ties it to the `PreToolUse`). The +attempt is removed from adapter state only after durable `Close` success; +duplicate delivery after cleanup is a safe no-op. + +### D10 — Failed-tool terminal observation — T01-GATED, likely no reliable signal + +Prior SCE research (`context/plans/codex-cli-integration.md` Assumptions) found +Codex calls `PostToolUse` **only after a successful tool result**. Codex has +**no `PostToolUseFailure` event**. If T01 confirms this for the supported +version, then a mutation-capable tool that **wrote files then failed** produces +**no terminal hook**, and the adapter must **not** design a Close-on-failure +path. + +In that case the failed-tool partial-mutation interval is bounded conservatively +by the next lifecycle signal (D11/D12): the stale `pending_start`/`active` +attempt is `abandon`ed and, once quiescent, one `flush` re-baselines the +worktree. The partial mutation is then attributed to nothing +(`IneligibleUnscoped`) rather than misattributed. **False-negative attribution +is preferable to false-positive attribution.** + +If — and only if — T01 proves Codex emits a reliable final observation for a +failed mutation-capable tool (a `PostToolUse` with a failure-indicating +`tool_response`, or another event carrying the D3 identity), D10 becomes a +`Close` mapping like D9. T01 records which. + +### D10a — Failed-tool -> successor-tool in the same turn — T01-GATED + +D10's "next lifecycle signal" is **not sufficient on its own** for the case where +a failed mutation-capable tool with no terminal hook is followed by **another +mutation-capable tool in the same turn**, before any `Stop` / `SessionEnd` / +`UserPromptSubmit`: + +```text +PreToolUse(A) -> Start(A) -> A mutates Git-visible state -> A fails -> NO terminal hook +PreToolUse(B) -> ... (adapter state still: A.phase = active, recovery_pending = false) +``` + +At `PreToolUse(B)` the D13 barrier does nothing (nothing armed it), so if B +starts normally SCE has a **zombie live scope A** alongside the real scope B. +This can produce a false `AiContended` for any tree transition observed while +both look live, or attribute B's (or later) mutations to A. + +Two invariants must both hold, and they are in tension: + +```text +(i) a mutation-capable successor must never Start while an older attempt that + may already be terminal remains "live" solely because Codex omitted its + terminal event; +(ii) a still-legitimately-running parallel attempt must never be abandoned just + because another PreToolUse arrived. +``` + +Reconciling them requires a **T01-proven seriality boundary**. T01's new +"failed tool followed by another tool in the same turn" probe (see T01 scope) +records exactly one disposition: + +- **Case A — a reliable intermediate cleanup signal exists.** Codex emits + positive stale/terminal evidence for A between A's failure and `PreToolUse(B)`. + The adapter routes it through D12: signal -> `abandon` A -> `recovery_pending` + -> quiescent `flush` -> then `PreToolUse(B)` proceeds. T01 records which signal + is load-bearing. No successor-barrier logic is needed. +- **Case B — no intermediate signal, but successor `PreToolUse` proves the + predecessor stale.** T01 proves Codex executes mutation-capable tools + **serially within a narrow, identity-defined lane** (candidate lane keys: + session, turn, or a proven delegated-agent identity — **not frozen until T01 + proves the concurrency semantics**). Only then may `PreToolUse(B)` itself count + as positive evidence that an older outstanding attempt **in B's same proven + lane** cannot still be running. The adapter then does, on `PreToolUse(B)`, + before allocating B: + + ```text + inspect existing attempts in B's proven-serial lane + -> a stale predecessor A found (same lane, not terminal in bookkeeping) + -> arm recovery_pending + -> abandon A + -> when quiescent, one flush through the seam + -> only then write-ahead Start(B) + ``` + + This is a **lane-scoped** sweep, never a global sweep. An attempt outside B's + proven lane (a legitimately concurrent execution, if T01 shows any exist) is + left untouched — invariant (ii). +- **Case C — neither is safe.** No reliable intermediate signal **and** the + successor `PreToolUse` does not prove the predecessor stale because parallel + executions in the same lane are possible. The adapter cannot distinguish + `failed-and-dead A` from `still-running A`. T01 **marks this an architectural + contradiction / unsupported lifecycle and stops the plan for re-planning** — + it does not guess. + +T01 must define "same lane" using only identity/concurrency facts it actually +established. T02 freezes the lane key and the successor-barrier design (if +Case B); T04 implements it; T06 proves it. + +### D11 — Uncertain-boundary abandonment rules + +Carried verbatim from the Claude adapter (D11/D12 there), because they are +runtime-contract properties, not Claude lifecycle specifics: + +- **`pending_start` + terminal/cleanup signal -> abandon, not late-Start.** The + adapter cannot prove `Start` committed; a late `Start` after the tool ran + would observe the post-tool tree and misattribute the interval. `abandon` on + a committed `Start` is normal abandonment; `abandon` on a `Start` that never + committed hits the runtime's `MissingScope` / `NeverSeen` recovery path. +- **Failed `Close` -> abandon + `recovery_pending`, not a replayed `Close`.** + The original observation time is lost; a later tree must never be presented as + the tool-completion tree. The two ingress carried-success variants + (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion`) are durable success + and do not enter this path. + +### D12 — Codex lifecycle cleanup signals — T01-GATED + +The adapter retires outstanding attempts on positive staleness evidence only — +**never** on absence of activity, and **never** inferred from `ActorKind`. The +candidate Codex signals, each **T01-GATED** on actually firing with the identity +fields the mapping needs: + +| Candidate Codex event | Would retire | T01 must establish | +| --- | --- | --- | +| `Stop` | outstanding attempts for that `session_id` (main turn) | fires on every turn end; carries `session_id` | +| `SessionEnd` | every outstanding attempt for the session | fires on session/process termination; carries `session_id` | +| `SubagentStop` | outstanding attempts owned by the ending delegated agent | fires; carries a delegated-agent identity distinguishable from the main thread (else it cannot be used for a scoped sweep) | +| `PermissionRequest` (denied) | the one live attempt for that tool call | whether a denied `PermissionRequest` leaves a durably-established `Start` with no terminal event | +| next `UserPromptSubmit` | stale main-turn attempts (interruption fallback) | whether Codex emits `Stop` on user interruption, or only the next prompt | +| `PreCompact` / `PostCompact` | *(diagnostic only unless T01 shows a lifecycle gap)* | whether compaction can strand an attempt | + +Only signals T01 marks `PROVEN` (or `DOCUMENTED — NON-LOAD-BEARING` with a +load-bearing backstop named) become adapter behavior. T01 must identify which +signals provide **positive staleness evidence** suitable for `abandon`, and +which single signal is the load-bearing backstop (the Claude adapter's backstop +is `SessionEnd`). + +The failed-tool -> successor-tool sequence (D10a) is the one case where the +"next lifecycle signal" backstop is too late; its resolution (an intermediate +signal, a proven serial-lane successor barrier, or unsupported) is owned by +D10a, not this table. + +### D13 — recovery_pending barrier and quiescent Flush + +Carried verbatim from the Claude adapter (D19 there). Whenever an abandonment or +an uncertain lifecycle sets `recovery_pending = true`: + +```text +recovery_pending == true AND known attempts still outstanding + -> deny every new mutation-capable PreToolUse (D8 fail-closed shape) + +recovery_pending == true AND attempts.is_empty() + -> run one { "operation":"flush" } through the generic ingress + -> clear recovery_pending ONLY on durable flush success + -> a failed flush stays fail-closed +``` + +A failed abandonment leaves the attempt tracked and `recovery_pending = true` +(never silently allows a successor mutation execution). + +**Successor-Start invariant.** A mutation-capable `PreToolUse` must never reach +its write-ahead `Start` while a known-stale predecessor attempt (D10a) remains +`active`/`pending_start` in bookkeeping. Where D10a Case A or Case B applies, the +predecessor is abandoned and — once quiescent — flushed before the successor's +`Start`. Where D10a Case C applies, the integration is unsupported and no +successor logic ships. This invariant does **not** license abandoning an attempt +that T01 proved can legitimately run concurrently with the successor (D10a +invariant (ii)); the sweep is always lane-scoped, never global. + +### D14 — Concurrency and AiContended + +Two simultaneously-live scopes (whether two Codex executions per D1, or a Codex +execution overlapping a Claude/OpenCode/Pi execution on the same worktree) carry +distinct `ScopeId`s, so the runtime can report `AiContended` for a tree +transition observed while both are live. The adapter never collapses two +executions into one `ScopeId`. If T01 proves Codex cannot overlap its own +mutation executions, the regression for AiContended (T06) exercises a Codex +scope overlapping a second harness's scope instead, and this plan records that +Codex-alone `AiContended` is not reachable. + +### D15 — Raw Codex hook cwd is authoritative — T01-GATED + +The mutation runtime's repository root is the raw Codex hook payload's `cwd` +(Codex runs command hooks with `.current_dir(cwd)` and the event carries `cwd`). +T01 must confirm the raw hook `cwd` is authoritative for the actual checkout +being mutated (Codex has no known Claude-style `isolation: worktree` subagent, +but T01 verifies, and checks whether Codex exposes any separate +worktree-lifecycle event or path). The adapter passes the raw `cwd` to the seam +as `repository_root`; the runtime derives `WorktreeId`. The adapter never +accepts, derives, stores, or constructs a `WorktreeId`, and passes no +`worktree_id` key to the seam. There is no Codex `WorktreeRemove` equivalent; +worktree-scoped cleanup relies on the D12 session/agent signals. + +### D16 — Background / detached shell is a correctness boundary — T01-GATED + +T01 must separate **Codex-managed background execution** (if Codex exposes a +"run in background" tool option or a background-task lifecycle) from a +**foreground shell command that spawns a self-detaching descendant** (already +known to be fundamentally difficult without process supervision — the Claude +adapter's D20 confirmed this is real and Git-observable). + +- If Codex exposes explicit background execution, and T01 shows its lifecycle + does **not** reliably bound the mutations, the adapter **denies** it in + `PreToolUse` (D8 shape) with a Codex-specific unsupported-execution message, + exactly as the Claude adapter denies `run_in_background = true`. +- The self-detaching-descendant boundary is recorded, with **Codex-specific + evidence from T01**, as an explicit unsupported boundary: `PostToolUse` (or + whatever terminal signal exists) does not necessarily prove that every + descendant stopped mutating. The adapter adds **no** PID supervision, + process-group tracking, background-process ownership, shell-command static + analysis, or staleness polling. + +Do not claim support for any execution pattern unless T01 demonstrates its +lifecycle actually bounds the mutations. + +### D17 — Command architecture: separate hidden command (recommended) — decided in T02 + +Codex today funnels **every** registered hook event through the single +`sce hooks codex` dispatcher, which is **fail-open** (errors -> empty stdout), +and `codex_hook_config.rs` decides SCE ownership structurally by matching the +exact trailing command tokens `["sce", "hooks", "codex"]` +(`CODEX_COMMAND_WORDS`). + +The mutation-scope adapter is **non-fail-open** (fail-closed on `PreToolUse`, +never-silently-drop on terminal boundaries). Two viable architectures: + +1. **Separate hidden command `sce hooks codex-mutation-scope`** (recommended + default). Its `.codex/hooks.json` registrations use a distinct command, so + Codex invokes the mutation-scope hook as its own process, independent of the + `sce hooks codex` policy/diff process for the same event — exactly how Claude + runs `sce policy bash` and `sce hooks claude-mutation-scope` side by side on + `PreToolUse`. `codex_hook_config.rs` gains a **second command contract** + (`CODEX_COMMAND_WORDS` becomes a set; `REQUIRED_EVENTS` records which command + owns each registration; ownership/merge/doctor become command-aware). This + keeps each evidence system's failure posture and diagnosability independent, + and no single `sce hooks codex` invocation has to do two jobs with two + failure postures. +2. **Extend the `sce hooks codex` dispatcher** with mutation-scope arms, keyed + by matched tool groups so no event is double-invoked, and restructure the + dispatcher so mutation-scope arms propagate errors while conversation/diff + arms stay fail-open. Smaller `codex_hook_config.rs` change + (`CODEX_COMMAND_WORDS` unchanged, only `REQUIRED_EVENTS` grows), but couples + the two evidence systems' fate inside one process for `PreToolUse(shell)` and + `PostToolUse(apply_patch)`. + +T02 makes the final call against T01 findings and the code, defaulting to (1), +and records the decision here. Every subsequent task's wording assumes (1); if +T02 chooses (2), T02 revises D17, D8's routing, and T04/T05 scope accordingly. + +### D18 — Adapter depends on hooks::mutation_scope only + +Dependency direction is strictly +`codex_mutation_scope -> hooks::mutation_scope -> mutation_trace::runtime`. The +Codex adapter's production code (everything in +`cli/src/services/hooks/codex_mutation_scope/` outside `#[cfg(test)]`) imports +no `crate::services::mutation_trace::{runtime,protocol,store}` and names no +`RepositoryAgentTraceDb`, `WorktreeId`, `GitSnapshotService`, or +`RepositoryAgentTraceDb`. Its only dependency into the mutation stack is the +single `super::mutation_scope::run_mutation_scope_from_payload` seam import +(already `pub(crate)` since #263's T05), reused verbatim by building the generic +wire payload as a JSON string — no second `RuntimeBoundary` path, no spawned +`sce` subprocess, no invocation of `coordinate()` / `abandon_scope()` directly. + +### D19 — Existing Codex integration stays additive + +The `sce hooks codex` pipeline (`UserPromptSubmit`/`Stop` conversation capture, +`PreToolUse(Bash)` policy, `PostToolUse(apply_patch)` `diff_traces` evidence, +`.codex/hooks.json` ownership/merge, doctor trust/policy diagnosis, +`sce setup --codex`, `sce doctor`) is not replaced or redesigned. The +`apply_patch -> diff_traces -> post-commit intersection` pipeline is a +complementary evidence system and is **not** folded into mutation-scope storage. +The new adapter writes only `mutation_trace_*` rows through the runtime. The raw +Agent Trace tables `diff_traces`, `post_commit_patch_intersections`, +`agent_traces`, `messages`, and `parts` are never written by the new adapter. +Generated `.codex/hooks.json` must preserve every existing SCE-owned and +user-owned registration; setup merge stays idempotent. + +### D20 — Existing Codex hook trust identity must survive the upgrade + +Codex hook-trust identity is not just handler bytes. Current SCE/Codex trust +logic (`codex_hook_config.rs` + `codex_hook_policy.rs`, and upstream +`hooks::version_for_toml` / state keying) identifies a hook by +`event key label` + `matcher-group index` + `handler index` + +`normalized handler contents/hash`. So **adding a mutation-scope handler can +invalidate an existing hook's trust even though its JSON bytes are unchanged**: + +```text +before: Stop / group 0 / handler 0 -> sce hooks codex (key stop:0:0, trusted) +after (bad merge): + Stop / group 0 / handler 0 -> sce hooks codex-mutation-scope + Stop / group 0 / handler 1 -> sce hooks codex (key stop:0:1 — re-trust needed) +``` + +T05 must preserve, for every existing SCE Codex registration, the tuple +`(event, matcher, matcher-group index, handler index, handler contents/hash)` +when upgrading a canonical four-registration document to one containing +mutation-scope registrations. Insertion is **additive and position-stable**: + +- an existing handler keeps its index; a new mutation-scope handler is appended + **after** the existing handler in its group; +- an existing matcher group keeps its index; a new matcher group is appended + **after** the existing groups. + +New SCE-owned handlers/groups are never prepended in a way that renumbers an +already-trusted hook. If position preservation is genuinely impossible for some +event/matcher structure (T01/T05 must say which, if any), the plan records it and +`sce doctor` must surface that **re-trust is needed** — trust is never silently +invalidated, and `sce doctor --fix` never writes, grants, or changes Codex trust +or managed policy. + +### D21 — Doctor: three-dimension health for mutation-scope registrations + +A structurally-current mutation-scope hook is useless if Codex never loads it — +and unlike a fail-open diff/conversation hook, an unloaded mutation-scope hook +means SCE **silently cannot fail closed**. So each mutation-scope registration +participates in the full existing Codex health model, exactly as the four +`sce hooks codex` registrations do: + +```text +healthy == structurally current AND trusted/enabled AND effective project-hook policy allows it +``` + +Never healthy: `PresentAndCurrent + Untrusted`, `+ Modified`, `+ Disabled`, +`+ PolicyBlocked` (Error severity, manual-only), `+ PolicyUnknown` (Warning +severity, manual-only). Doctor reports the actual readiness of each registration +rather than flattening the mutation-scope hooks and the `sce hooks codex` hooks +into one generic `.codex/hooks.json` status. The single per-invocation +`configRequirements/read` policy probe (`codex_hook_policy.rs`) is reused, not +re-run per registration. + +### D22 — New event key labels come from upstream, not from lowercasing + +`codex_hook_config::hook_event_key_label` currently maps only the four +SCE-owned events. Every **newly** SCE-owned mutation-scope event (T01 decides the +set — candidates `SessionEnd`, `SubagentStop`, `PermissionRequest`, an unmatched +`PreToolUse` group, etc.) must be added with the **exact upstream Codex key +label**, verified against `openai/codex` source (T01/T05), not by lowercasing the +event name. Each newly registered event gets a `hook_event_key_label` test. + +## 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 codex-mutation-scope` exists, is hidden from `sce --help` + and `sce hooks --help`, and routes through the normal hook command stack + (`HooksSubcommand::CodexMutationScope` -> `convert_hooks_subcommand_request` + -> `HookSubcommand::CodexMutationScope` -> `run_hooks_subcommand_in_repo`, + **unwrapped / non-fail-open** like `mutation-scope`). + - Validate: `sce hooks codex-mutation-scope .` diagnostic, never + fabricating an identity. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::codex_mutation_scope` parser unit tests, + including a fixture per T01 probe. +- [ ] AC3: `classify_tool` classifies every Codex tool name T01 enumerated: + mutation-capable (`apply_patch`, the shell tool, MCP tools, unknown names), + read-only (the enumerated read/search tools — no scope), delegation (no + scope). No `Start` boundary is emitted for `SessionStart`, `UserPromptSubmit`, + `SubagentStart`, or any non-tool lifecycle event. + - Validate: classification unit-test table; adapter mapping unit tests + asserting processed-event keys. +- [ ] AC4: The Codex execution key is exactly the field tuple T02 froze from + T01 evidence (recorded in D3). Duplicate delivery of the same live + `PreToolUse` reuses the same `attempt_seq`, `ScopeId`, and `Start` `EventId`. + - Validate: identity/formatter unit tests; state unit tests; T06 duplicate- + delivery regression. +- [ ] AC5: A later execution attempt of the same raw Codex tool identifier, + after the previous attempt became terminal, receives a new `attempt_seq` and a + new `ScopeId`; a terminal `ScopeId` is never reused; replaying a live + attempt's event is `ScopeId`/`EventId`-stable. + - Validate: state unit tests (terminal attempt then fresh `attempt_seq`); + formatter determinism tests; T06 reused-identifier regression. +- [ ] AC6: A tracked mutation-capable `PreToolUse` reaches durable + generic-ingress `Start` before the hook returns its "continue" response to + Codex (write-ahead `pending_start` -> ingress `Start` -> `active`), and the + seam receives the raw hook `cwd` as `repository_root` (never `git_dir`). + - Validate: adapter ordering unit test with an injected seam asserting the + persisted phase from inside the seam call and the `repository_root` + argument; T06 production-path confirmation. +- [ ] AC7: Any failure to establish adapter state or `Start` during a + mutation-capable `PreToolUse` returns the exact Codex-native block response + T01 froze (D8), never a silent success and never an explicit allow; the + detailed error is logged via + `sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed`. + - Validate: failure-classification unit tests asserting the exact response + JSON/exit; `RecordingLogger` assertion that the detail is logged and not + leaked into the model-visible reason. +- [ ] AC8: A successful mutation-capable tool with an `active` attempt closes + its scope: `PreToolUse` -> real filesystem mutation -> terminal Codex hook + produces exactly one eligible tool interval and one terminal (`Closed`) scope + with attribution `AiExclusive`. + - Validate: T06 real-Git + real-Agent-Trace-DB regression. +- [ ] AC9: A mutation-capable tool that partially mutated the checkout then + failed is handled per D10's T01 disposition: if Codex provides a reliable + final observation, the scope closes and the partial mutation is attributed to + that scope; if it does not, the stale attempt is abandoned and a subsequent + `flush` re-baselines the worktree so the partial mutation is + `IneligibleUnscoped`, never misattributed. + - Validate: T06 failed-tool regression whose assertions match the D10 + disposition recorded by T01. +- [ ] AC9a: A partially-mutating failed tool A with **no terminal event**, + followed by another mutation-capable `PreToolUse(B)` in the same turn, is + handled per D10a's T01 disposition: **either** a proven intermediate lifecycle + signal retires A before B starts (Case A), **or** a proven serial-lane + successor barrier retires A (arm `recovery_pending` -> abandon A -> flush when + quiescent) before B's write-ahead `Start` (Case B), **or** T01 marked the + lifecycle unsupported and implementation did not proceed (Case C). B must never + reach `Start` while a known-stale A is still `active`/`pending_start` in + bookkeeping, and no false `AiContended` event is produced merely because A + lingered in adapter bookkeeping. + - Validate: T06 failed-A-then-B regression (real Git + real Agent Trace DB): + assert A is `Abandoned`, B is the only live scope at its `Start`, the + worktree re-baselined between them, and the mutation-event stream contains + **no** `AiContended` row attributable to the A/B overlap; adapter unit test + for the Case B lane-scoped successor barrier (a same-lane stale predecessor + is swept, an out-of-lane concurrent attempt is not). If Case C, validate by + the recorded T01 disposition and the plan's stop-for-re-planning note. +- [ ] AC10: Two simultaneously-live scopes (per D14 — two Codex executions if + T01 proves overlap is possible, otherwise a Codex scope overlapping a second + harness's scope) produce `AiContended` for a tree transition observed while + both are live; the adapter never assigns them one shared `ScopeId`. + - Validate: T06 concurrency regression; the plan records which D14 form was + exercised. +- [ ] AC11: An outstanding mutation-capable execution with no terminal hook is + retired by exactly the Codex lifecycle signals T01 marked load-bearing (D12), + via `abandon_scope`, leaving the worktree `needs_rebaseline`. A concurrent + execution T01 proved can legitimately still be running is **not** retired by + the same sweep (D10a invariant (ii)) — sweeps are always lane-scoped, never + global. + - Validate: T06 regressions for each proven cleanup signal; adapter cleanup + unit tests including one asserting an out-of-lane attempt survives a sweep. +- [ ] AC12: While `recovery_pending` is armed and known attempts remain + outstanding, every new mutation-capable `PreToolUse` is denied (D8 shape); + once quiescent, exactly one `{"operation":"flush"}` runs through the seam and + `recovery_pending` clears only on durable flush success. The same barrier is + what a D10a Case B successor arms before abandoning a known-stale predecessor, + so a successor B is denied its own `Start` until predecessor A's abandonment + and the quiescent flush have durably completed. + - Validate: adapter recovery-barrier unit tests (deny-while-outstanding, + flush-then-proceed, flush-failure-stays-closed, successor-blocked-until- + predecessor-recovered); T06 recovery-barrier regression. +- [ ] AC13: A failed abandonment leaves the attempt tracked and + `recovery_pending = true` (no successor mutation `Start` is allowed until + recovery succeeds). + - Validate: adapter unit test (failed `abandon` -> attempt retained, barrier + armed, next `PreToolUse` denied, seam not re-driven). +- [ ] AC14: A hook process whose raw payload `cwd` names checkout B drives + mutation state for checkout B only (its `WorktreeId`/cursor advances; another + checkout's cursor is unchanged); the adapter constructs no `WorktreeId` and + passes no `worktree_id` key. + - Validate: T06 worktree-isolation regression (real linked worktree); + dependency-boundary grep for `worktree_id` key construction. +- [ ] AC15: The background/detached execution boundary matches T01's finding + (D16): any Codex-managed background execution T01 shows is unbounded is denied + in `PreToolUse` with the recorded message; the self-detaching-descendant + boundary is documented with Codex-specific T01 evidence and the adapter adds + no detection or supervision. + - Validate: adapter classification unit test (if a deny applies); T06 + documented unsupported-case regression; inspection of the T01 evidence + fixture and the D16 disposition. +- [ ] AC16: Generated `.codex/hooks.json` after `sce setup --codex` still + contains the four existing SCE registrations (`UserPromptSubmit`, `Stop`, + `PreToolUse` matcher `Bash`, `PostToolUse` matcher `apply_patch`) routed to + `sce hooks codex`, byte-for-byte, plus the new mutation-scope registrations; + user-owned Codex handlers and unrelated valid event groups are preserved. + - Validate: `nix run .#pkl-check-generated`; `codex_hook_config.rs` merge + tests (existing + new); inspection of the rendered `config/.codex/hooks.json`. +- [ ] AC16a: Upgrading a realistic already-installed, already-trusted SCE Codex + document (exactly the canonical four registrations) through the same + merge/setup path that adds mutation-scope hooks preserves, for every existing + registration, the tuple `(event, matcher, matcher-group index, handler index, + handler contents/hash)` — so its Codex trust identity stays valid (D20). New + mutation-scope handlers/groups are appended after existing ones, never + prepended in a way that renumbers a trusted hook; each new mutation-scope hook + appears exactly once; user-owned hooks are untouched; a second merge is + byte-identical. Where position preservation is genuinely impossible for some + structure, the plan says which and doctor surfaces that re-trust is needed. + - Validate: `codex_hook_config.rs` "canonical-four -> plus-mutation-hooks" + upgrade regression asserting each existing registration's identity tuple and + computed trust key are unchanged, the new hooks are appended once, user hooks + unchanged, and a second run is idempotent. +- [ ] AC17: `sce setup --codex` merge is idempotent for the mutation-scope + registrations (a second run produces byte-identical output) and a + structurally invalid existing `.codex/hooks.json` fails before the atomic swap + with the existing file untouched. + - Validate: `codex_hook_config.rs` idempotency + malformed-input tests. +- [ ] AC17a: Every newly SCE-owned mutation-scope event has a + `codex_hook_config::hook_event_key_label` entry whose label is the **exact + upstream Codex key label** (verified against `openai/codex` source in T01/T05, + not derived by lowercasing), with a dedicated test per newly registered event + (D22). + - Validate: `services::codex_hook_config` label tests, one per new event; + a comment or `NOTES.md` citation of the upstream source for each label. +- [ ] AC18: `sce doctor` gives each SCE-owned mutation-scope registration the + full three-dimension Codex health model (D21), reported independently of the + four existing `sce hooks codex` registrations, proving all of: + 1. structural diagnosis (`PresentAndCurrent` / `Missing` / `Stale`, + `Malformed` for the whole document); + 2. normal Codex trust diagnosis (`Trusted` / `Untrusted` / `Modified` / + `Disabled`); + 3. effective project-hook policy diagnosis (`ProjectHooksAllowed` / + `PolicyBlocked` / `PolicyUnknown`), reusing the single per-invocation + `configRequirements/read` probe; + 4. `PresentAndCurrent` combined with `Untrusted` / `Modified` / `Disabled` / + `PolicyBlocked` / `PolicyUnknown` is **never** reported healthy; + 5. `sce doctor --fix` changes only SCE-owned `.codex/hooks.json` structure; + 6. no `$CODEX_HOME/config.toml` or any trust-state / managed-policy mutation + occurs on any doctor path. + - Validate: `services::doctor::` tests covering a missing, a stale, an + untrusted, a modified, a disabled, a policy-blocked, and a policy-unknown + mutation-scope registration, plus the `--fix` path; an assertion that the + existing trusted `sce hooks codex` hooks and the new (untrusted) mutation + hooks are reported with distinct readiness rather than one flattened + `.codex/hooks.json` status; a filesystem assertion that no `$CODEX_HOME` + write occurs. +- [ ] AC19: Production Codex-adapter code (everything in + `cli/src/services/hooks/codex_mutation_scope/` outside `#[cfg(test)]`) + contains no `use` or qualified-path reference naming + `crate::services::mutation_trace::{runtime,protocol,store}`, + `RepositoryAgentTraceDb`, `WorktreeId`, or `GitSnapshotService`, and its only + dependency into the mutation stack is the single seam import from + `crate::services::hooks::mutation_scope`. + - Validate: `rg -n --type rust + '^\s*use\s+crate::services::mutation_trace::(runtime|protocol|store)|::(RepositoryAgentTraceDb|WorktreeId|GitSnapshotService)\b' + cli/src/services/hooks/codex_mutation_scope/` returns no match outside a + `#[cfg(test)]` module; manual check confirms exactly one `use` reaching + `crate::services::hooks::mutation_scope`. +- [ ] AC20: Codex mutation-scope-only regressions leave `diff_traces`, + `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` + unchanged (before/after row-count assertions), and adapter state lives only + below `/sce/`. + - Validate: T06 regression with row-count assertions; state-module inspection. +- [ ] AC21: Crash/recovery invariants hold against the real runtime: (a) a + `pending_start` attempt whose `Start` never committed is abandoned then + recovered by the quiescent flush; (b) a `pending_start` attempt whose `Start` + did commit is abandoned as a real runtime abandonment, not a late `Start`; + (c) a `Close` that committed durably before local bookkeeping caught up is + replay-safe on redelivery (no second transition, revision unchanged). + - Validate: T06 regressions Test-crash-a/b/c driving real events after + simulating each crash point via the adapter's own bookkeeping helpers only. +- [ ] AC22: The diff against + `origin/claude-mutation-scope-integration` 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`; no mutation-trace SQL migration and + no Quint/protocol/attribution-algorithm change is introduced. + - Validate: `git diff origin/claude-mutation-scope-integration -- ` is empty. +- [ ] AC23: Durable context clearly separates the generic mutation-scope + ingress, the Codex mutation adapter, and the mutation runtime, and records: + the tool-execution scope model, Codex tool classification, execution identity, + `ScopeId`/`EventId` derivation, the fail-closed `PreToolUse` and its exact + Codex-native response, the terminal-boundary mappings, the failed-tool -> + successor-tool handling and its recorded case + successor-Start invariant, + the Codex lifecycle cleanup signals and the load-bearing backstop, the + recovery barrier, worktree/cwd ownership, the concurrency story, the exact + background/detached execution limitations, and the Codex hook-config coexistence + contract (existing-registration trust-identity preservation, the + three-dimension doctor health model, upstream-verified event key labels) — + each stated as Codex-proven, Codex-documented, or Codex-unsupported, with the + tested Codex version. + - Validate: inspection of `context/cli/codex-mutation-scope-integration.md` + and the updated cross-reference files. +- [ ] AC24: The plan's exact unsupported limitations are enumerated in durable + context: no line-level attribution for mutations from a failed tool with no + terminal hook (if D10 lands that way); the failed-tool -> successor-tool + guarantee only as strong as T01's D10a case (and, if Case C, the integration + is not shipped and durable context records the unsupported lifecycle); no + attribution guarantee for self-detaching descendant processes; no + Codex-managed background execution SCE cannot bound; and whatever else T01 + marks `UNSUPPORTED`. + - Validate: inspection of the "Unsupported" section of + `context/cli/codex-mutation-scope-integration.md`. + +### Full validation + +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex_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::codex` +- `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 services::codex_hook_config` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::doctor::` +- `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/claude-mutation-scope-integration -- 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 `claude-mutation-scope-integration` +(#263 head), not `main`, while this PR remains stacked on #263. + +### Context sync + +- New: `context/cli/codex-mutation-scope-integration.md` (owns the Codex adapter + domain — see AC23/AC24). +- Update: `context/cli/mutation-scope-runtime.md` (a second concrete adapter now + exists; Codex is no longer "unwired"), + `context/cli/mutation-scope-hook-ingress.md` (a second in-process seam + consumer now exists), + `context/sce/agent-trace-hooks-command-routing.md` (new + `codex-mutation-scope` route, or the new `sce hooks codex` mutation-scope + arms if T02 chooses D17 option 2), + `context/sce/codex-integration-runtime.md` (the Codex hook runtime now also + drives mutation-scope; keep the existing conversation/diff pipeline + description intact and additive), + `context/context-map.md`, `context/overview.md`, `context/architecture.md` + (line 135's hook-runtime paragraph names the new adapter). +- Not a target: `context/sce/generated-opencode-plugin-registration.md`, + `context/cli/claude-mutation-scope-integration.md` (Claude-only — unchanged), + and any `context/decisions/2026-08-23-codex-*` ADR unless T02/T05's ownership + extension materially changes the accepted non-destructive-ownership contract + (in which case a **new dated** ADR is written, never an edit to the existing + one). +- `context/sce/doctor-human-text-contract.md` — update only if T05's + three-dimension mutation-scope health rows change the documented `sce doctor` + human text layout. +- ADR: only if T01/T02/T05 reveals a genuinely new system-wide architectural + constraint meeting the repository's ADR threshold (e.g. the Codex hook-config + ownership model must become permanently multi-command, or existing-hook trust + identity must be a first-class merge invariant). A routine extension of + `REQUIRED_EVENTS` / `CODEX_COMMAND_WORDS` / `hook_event_key_label` does not + qualify. + +## 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` (no change expected beyond the + already-`pub(crate)` seam; touch only if T04 finds a genuine gap), + `cli/src/services/hooks/codex_mutation_scope/mod.rs` (new — event model, tool + classification, identity, `ScopeId`/`EventId` derivation, adapter driver), + `cli/src/services/hooks/codex_mutation_scope/state.rs` (new — durable + checkout-local bookkeeping), + `cli/src/services/hooks/codex_mutation_scope/fixtures/` (new — T01 raw Codex + hook-event fixtures + `NOTES.md`), + `cli/src/services/codex_hook_config.rs` (command-aware ownership/merge per + D17; additive position-stable insertion preserving existing trust identity per + D20; `hook_event_key_label` coverage for new events per D22), + `config/pkl/renderers/codex-content.pkl` (mutation-scope `.codex/hooks.json` + registrations, appended after the existing SCE registrations), + `cli/src/services/doctor/` (three-dimension structural + trust + effective- + policy diagnosis for each mutation-scope registration per D21), + and the context files listed under Context sync. +- **Out of scope:** OpenCode/Pi mutation-scope adapters; a generic + adapter-framework extraction; any change to the mutation protocol, Quint + model, `mutation_trace/protocol.rs`, `mutation_trace/store.rs`, or + `mutation_trace/runtime/`; any mutation-trace SQL migration; any Agent Trace + schema migration or `config/schema/agent-trace.schema.json` change; any new + mutation-attribution algorithm; the existing `sce hooks codex` + conversation/diff pipeline behavior (`UserPromptSubmit`/`Stop`/ + `PreToolUse(Bash)`/`PostToolUse(apply_patch)` slices — unchanged); folding + `apply_patch -> diff_traces` into mutation-scope storage; PID/process-group + supervision, background-process ownership, shell-command static analysis, + staleness polling; a Codex App Server / `codex exec --json` integration for + mutation-scope; writing Codex hook-trust or auto-trust state. +- **Constraints:** the adapter depends only on `hooks::mutation_scope`, never on + `mutation_trace::runtime` / `::protocol` / `::store` directly + (`codex_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; `ScopeId` uses a length-prefixed tuple + encoding, no hashing / no crypto dependency; latest deps pinned exactly, + node24 for any new JS work per `context/plans/feedback_deps.md` (no new deps + expected here); reuse the shared `codex_hook_config.rs` ownership/merge and + the shared doctor structural/trust/policy diagnosis — no second Codex + hook-config implementation; reuse `ActorKind::Codex` / + `"actor_kind":"codex"`, already accepted by the generic ingress. +- **Non-goal:** copying Claude's `PostToolUseFailure` / `PermissionDenied` / + `StopFailure` / `WorktreeRemove` mappings into Codex without T01 evidence that + the equivalent Codex signal exists and fires; designing a Close-on-failure + path around an event Codex does not reliably emit; treating any terminal hook + as proof that every descendant process has stopped mutating; turning `abandon` + into a `RuntimeBoundary`; a long-lived Codex "session" or "agent" scope; + inventing a Codex `agent_id` abstraction if Codex exposes no delegated-agent + identity. + +## Assumptions + +- Task numbering is `T01..T07`. T04 and T05 of the change request's suggested + shape (driver, and command/routing) are combined into this plan's T04, because + the D17 command decision is made in T02 and the Claude adapter's own precedent + combined driver + command wiring in one task (#263 T06). +- The generic seam is the existing + `hooks::mutation_scope::run_mutation_scope_from_payload(repository_root, + stdin_payload, logger) -> Result`, already `pub(crate)` since #263's + T05, reused verbatim by constructing the `{"operation":...}` JSON wire string. + No new payload operation and no `sce` subprocess. +- `ActorKind::Codex` and the `"actor_kind":"codex"` wire value are already + accepted by `parse_mutation_scope_payload` (per + `context/cli/mutation-scope-hook-ingress.md`), so the Codex adapter needs no + ingress change to identify itself. +- Adapter state path is `/sce/codex-mutation-scope-state.json` with + lock `/sce/codex-mutation-scope-state.lock`, following the + `checkout::persist_checkout_id_inner` durability pattern. +- Generated Codex mutation-scope hook registrations carry the matchers T01 + proves Codex requires (an unmatched group if Codex supports it, else + per-tool matched groups); the exact set of registered events is the minimum + the adapter actually uses after T01, not Claude's ten. +- The user has explicitly allowed assumptions for ordinary local choices + (module naming, test-helper shape, fixture layout) — these follow the Claude + adapter's precedent and are not blocking. +- The Codex version SCE supports is pinned by T01 (`codex --version` plus the + inspected upstream commit), the same way #263's T01 pinned Claude Code + `2.1.258`. + +## Task stack + +- [ ] T01: `Freeze the real Codex hook and lifecycle contract` (status:todo) + - Task ID: T01 + - Scope: In — capture raw Codex hook-event fixtures from the Codex version SCE + chooses to support, commit them under + `cli/src/services/hooks/codex_mutation_scope/fixtures/` (one file per probe, + named for the probe) with a `NOTES.md` recording the tested `codex --version` + and the inspected `openai/codex` commit; inspect current upstream Codex + source/documentation where a live probe is not possible. For each probe + record, in `NOTES.md` and back into this plan's Design section, a + disposition of `PROVEN` / `DOCUMENTED — NON-LOAD-BEARING` / + `ASSUMPTION — PROBE` / `UNSUPPORTED`. Probes: + - **Normal mutation-capable execution** — `apply_patch` success, shell/`Bash` + command success, and any other checkout-mutating Codex tool: capture + `PreToolUse` and the terminal hook, recording `tool_use_id`, + `session_id` / `turn_id`, `cwd`, `tool_name`, `tool_input`, + `tool_response`, `model`, and any timestamp. Establish whether the same + stable execution identity appears in both the pre and post events (D3/D9). + - **Tool failure** — a tool that writes files then exits non-zero / fails: + determine the exact event sequence, whether `PostToolUse` fires at all on + failure, and whether any event carries a reliable final mutation + observation. Do **not** assume success and failure use the same sequence + (D10). + - **Failed tool followed by another tool in the same turn** (D10a — the + hard case): (1) execute mutation-capable tool A; (2) make A mutate + Git-visible repository state; (3) make A fail; (4) do **not** end the + turn; (5) cause mutation-capable tool B to execute; (6) capture every + hook/lifecycle event between A's failure and `PreToolUse(B)`. Establish + whether Codex emits any **positive** stale/terminal evidence for A before + B, and record exactly one disposition: + - **Case A** — a reliable intermediate cleanup signal exists between A's + failure and `PreToolUse(B)`; record which signal is load-bearing. + - **Case B** — no intermediate signal, but Codex is proven to execute + mutation-capable tools **serially within a narrow, identity-defined + lane** (candidate lane keys: session / turn / a proven delegated-agent + identity), so `PreToolUse(B)` itself proves an older same-lane attempt + cannot still be running. Record the exact concurrency evidence and the + lane key it licenses. Codex tools have historically run serially — this + case needs a positive proof of the seriality boundary, not an + assumption. + - **Case C** — neither: no reliable intermediate signal **and** same-lane + parallel executions are possible, so the adapter cannot distinguish + `failed-and-dead A` from `still-running A`. Mark this an architectural + contradiction / unsupported lifecycle, record it in Open questions, and + **stop the plan for re-planning** — do not guess. + - **Tool denial** — SCE `PreToolUse` policy denies; Codex itself denies; + `PermissionRequest` denied; another hook denies (if applicable): + determine whether a `Start` could have been durably established with no + corresponding terminal event, and the exact Codex-native denial/block + response shape and exit semantics for the supported version (D8/D12). + - **Interrupted execution / turn / session lifecycle** — user interruption, + turn completion, session termination, process termination: which of + `Stop` / `SessionEnd` / `SubagentStop` / `PreCompact` fire, with what + identity fields, and which provide **positive** staleness evidence + suitable for `abandon` (not absence of activity) (D12). + - **Parallel execution** — whether Codex can have two mutation-capable + executions overlapping; if yes, capture evidence of two coexisting + independent tool executions (D1/D14). + - **Subagents / delegated execution** — whether Codex exposes nested/ + delegated agent execution and whether hook payloads carry enough identity + to distinguish a delegated agent from the main thread (D3). Do not invent + an `agent_id` if Codex has none. + - **Worktrees / cwd** — whether the raw hook `cwd` is authoritative for the + actual checkout being mutated, and whether Codex exposes any separate + worktree-lifecycle event or path (D15). + - **Background / detached shell** — separate Codex-managed background + execution (if any) from a foreground command spawning a self-detaching + descendant; capture Git-observable evidence for the descendant case, as + #263's T04 did for Claude, before making any Codex-specific claim (D16). + - **Tool vocabulary** — enumerate every `tool_name` Codex emits on + `PreToolUse` / `PostToolUse`, including MCP tool naming and the delegation + tool name, for the D2 classification table. + Out — any production code, any Rust module, any settings change, any Design + decision that is not backed by a captured fixture or an upstream source + citation. + - Dependencies: none + - Done when: fixtures exist for every probe correctness depends on (or an + upstream-source citation where a live probe is impossible), `NOTES.md` + records the tested Codex version and per-probe dispositions, and every + T01-GATED decision (D1, D2, D3, D8, D9, D10, D10a, D12, D15, D16, + D17-inputs) carries an explicit disposition written back into this plan's + Design section — D10a specifically records Case A / Case B / Case C with its + evidence and, for Case B, the exact lane key. If any probe (D10a Case C + included) shows Codex cannot be represented by the current mutation-scope + contract, that is recorded in Open questions and the plan stops for + re-planning rather than proceeding to T02. + - Verify: fixtures committed and referenced from this plan; `NOTES.md` lists + the manifest and per-probe disposition; `nix run .#pkl-check-generated` and + `nix flake check` still pass (fixtures are inert data — confirm the CLI + build input list includes the new fixtures directory, as #263's T01 needed + for Claude). + - Context synchronization: pending + +- [ ] T02: `Command architecture, Codex event model, classification, and identity` (status:todo) + - Task ID: T02 + - Scope: In — (1) decide the D17 command architecture against T01 + the code, + defaulting to a separate hidden `sce hooks codex-mutation-scope` command, + and write the decision into D17; (2) `cli/src/services/hooks/ + codex_mutation_scope/mod.rs`: the strict raw Codex mutation-scope event + parser (rejecting empty/non-object/missing/blank/wrong-typed with + `Invalid Codex hook event payload from STDIN: .`), the supported + mutation-scope hook-event enum (only the events T01 proved), the D2 + `classify_tool` table, the D3 execution-key type frozen from T01 evidence, + the D4 length-prefixed `cx-tool-v1|n=..|...` `ScopeId` formatter, and the + `|start` / `|close` `EventId` formatters, plus any + background-execution classifier T01 shows is needed (model/classify only — + the denial is T04's); (3) if T01 recorded D10a Case B, freeze the + **serial-lane key** (the exact identity-field subset from T01's evidence + that defines "same lane" for the successor-barrier) as a typed accessor on + the parsed event, with unit tests — no adapter logic yet, just the key. Out + — any durable state, any runtime/ingress call, any CLI wiring, any generated + settings. + - Dependencies: T01 + - Done when: the module compiles behind the existing `hooks` module tree; the + D17 decision is recorded in this plan; the D10a lane key is frozen (Case B) + or explicitly N/A (Case A) or the plan is already stopped (Case C); unit + tests prove AC2, AC3, AC4 (formatter determinism), AC5 (formatter is a + function of `attempt_seq`), and the full classification table against T01's + tool vocabulary. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::codex_mutation_scope`; `clippy` / + `fmt` clean. + - Context synchronization: pending + +- [ ] T03: `Durable checkout-local Codex adapter state and recovery bookkeeping` (status:todo) + - Task ID: T03 + - Scope: In — `cli/src/services/hooks/codex_mutation_scope/state.rs`: the + versioned `{version, next_attempt_seq, recovery_pending, attempts[]}` store + at `/sce/codex-mutation-scope-state.json`, the + `checkout::persist_checkout_id_inner`-style durable write with its own lock + at `/sce/codex-mutation-scope-state.lock` (never held across a seam + call), the `pending_start | active` phase model, `allocate_attempt` / + `mark_active` / `remove_attempt` / `mark_recovery_pending` / + `clear_recovery_pending` helpers (the same set #263's Claude adapter needed + after its follow-up), malformed/wrong-version rejection, and the D5 reasoning + (confirm Codex hook invocations are independent processes for the supported + version and record it). Out — any event parsing, any runtime/ingress call, + any driver logic. + - Dependencies: T01, T02 + - Done when: unit tests prove attempt allocation is monotonic and + checkout-local, a terminal attempt is followed by a fresh `attempt_seq` (AC5 + state half), the store round-trips durably, a malformed/wrong-version file is + rejected not fabricated, and the state lock is provably released before any + external call boundary (helper-level test). + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::codex_mutation_scope`. + - Context synchronization: pending + +- [ ] T04: `Codex mutation-scope driver + hidden command routing` (status:todo) + - Task ID: T04 + - Scope: In — per the D17 decision (default: separate command), + `cli_schema::HooksSubcommand::CodexMutationScope` (hidden via + `#[command(hide = true)]`), the `convert_hooks_subcommand_request` arm, + `services::hooks::HookSubcommand::CodexMutationScope`, the + `run_hooks_subcommand_in_repo` dispatch arm **unwrapped / non-fail-open** + (mirroring the `MutationScope` and `ClaudeMutationScope` arms), + `hook_runtime_invocation_name`, and the adapter driver in + `codex_mutation_scope/mod.rs`: read one raw Codex hook JSON object from + STDIN via `super::read_hook_stdin()`; resolve the raw `cwd` as + `repository_root` and `git_dir` (bookkeeping only) as two independent + parameters, never substituted; map each proven event — + mutation-capable `PreToolUse` -> D7 write-ahead `Start` + D8 fail-closed + Codex-native block on any failure + D16 background-execution deny if + applicable; the proven success terminal hook -> `Close` (D9); the proven + failure disposition from D10; the D10a failed-tool -> successor-tool + handling per T01's recorded case — Case A routes the intermediate signal + through D12; Case B implements the **lane-scoped** successor barrier on + `PreToolUse(B)` (inspect same-lane attempts using T02's frozen lane key -> + if a stale predecessor is found, arm `recovery_pending` -> abandon it -> + flush when quiescent -> only then write-ahead `Start(B)`; an out-of-lane + attempt is never touched); Case C ships nothing; the proven denial + signal(s) -> `abandon` (D12); the proven session/turn/agent cleanup signals + -> scoped `abandon` sweeps (D12, always lane/identity-scoped, never global); + D11 uncertain-boundary rules and the D13 successor-Start invariant; the D13 + recovery barrier + one quiescent `flush`. The driver reaches the runtime + **only** through + `super::mutation_scope::run_mutation_scope_from_payload` by building the + generic wire payload as a string (D18/AC19). Inject the git-dir resolver and + the seam as `&dyn Fn` parameters so every mapping is unit-testable without a + real repo or DB. Fail-closed `PreToolUse` failures log via + `sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed`. Out — generated + `.codex/hooks.json` / `sce setup` wiring (T05), real Git/DB regressions + (T06). If T02 chose D17 option 2, this task instead adds the mutation-scope + `CodexDispatchArm` variants and the non-fail-open handling inside + `run_codex_subcommand`, and the AC1/AC7 assertions target that surface. + - Dependencies: T02, T03 + - Done when: focused unit tests with an injected seam cover every proven + event-to-operation mapping, fail-closed `PreToolUse` (exact Codex-native + response JSON/exit, AC7), write-ahead ordering (AC6), `pending_start` + + terminal -> abandon (D11), failed `Close` -> abandon + `recovery_pending` + (D11/AC13), the recovery barrier's branches including + successor-blocked-until-predecessor-recovered (AC12), the D10a handling for + T01's recorded case — Case B's lane-scoped successor barrier proven to sweep + a same-lane stale predecessor and leave an out-of-lane concurrent attempt + untouched (AC9a, AC11) — and, if T01 proved a deny applies, the + background-execution deny (AC15). AC1 routing test passes; + `sce hooks codex-mutation-scope + plus-mutation-hooks **upgrade regression**: starting from a realistic + already-installed, already-trusted document with exactly + `UserPromptSubmit`/`Stop`/`PreToolUse Bash`/`PostToolUse apply_patch` -> + `sce hooks codex`, the same merge/setup path adds mutation-scope hooks while + every existing registration's identity tuple and trust key are unchanged, + new hooks appear exactly once, user hooks are untouched, and a second merge + is byte-identical), AC17 (idempotency + malformed-input untouched-file), + AC17a (one `hook_event_key_label` test per new event); doctor tests prove + AC18's six points, including that the existing trusted `sce hooks codex` + hooks and the new untrusted mutation-scope hooks are reported with distinct + readiness and no `$CODEX_HOME` write occurs on any path; a fresh + `sce setup --codex` into a repo with a user-owned Codex handler preserves it. + - Verify: `nix run .#pkl-check-generated`; `nix develop -c + ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml + services::codex_hook_config`; `nix develop -c ./scripts/run-cli-cargo.sh + test --manifest-path cli/Cargo.toml services::doctor::`; `nix develop -c + ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml + services::setup::`. + - Context synchronization: pending + +- [ ] T06: `Real Git/DB regressions through the production Codex path` (status:todo) + - Task ID: T06 + - Scope: In — regressions using real temporary Git repositories and real + repository Agent Trace DBs, driven through the production Codex-adapter -> + generic-ingress -> real runtime path (no manual `mutation_trace_*` inserts; + the only permitted injection is the adapter's own `state.rs` bookkeeping + helpers to simulate a crash point, exactly as #263's T08 did). Add a + `#[cfg(test)]` state-root variant of the real adapter entry point mirroring + `mutation_scope::run_mutation_scope_from_payload_at_state_root`. The matrix, + adapted to T01 findings — expect at least: + 1. successful mutation-capable tool -> `AiExclusive` + `Closed` (AC8); + 2. failed tool with partial mutation -> the D10 disposition's assertion + (Close-and-attribute, or abandon-then-`IneligibleUnscoped` after flush) + (AC9); + 2a. failed tool A (partial mutation, no terminal event) followed by + `PreToolUse(B)` in the same turn -> A is `Abandoned` and the worktree + re-baselined before B's `Start`; B is the only live scope at its + `Start`; the mutation-event stream contains **no** `AiContended` row + attributable to the A/B overlap; and an out-of-lane concurrent attempt + (if T01's model allows one) is not swept (AC9a) — asserted per T01's + recorded D10a case; if Case C, this row is replaced by a comment + pointing at the stopped-for-re-planning disposition; + 3. two overlapping mutation executions -> `AiContended` (the D14 form T01 + allows) (AC10); + 4. duplicate `Pre`/terminal delivery -> idempotent, no second transition + (AC4); + 5. denied execution -> no mutation runs under an untracked `Start` + (AC7 production half); + 6. interrupted / stale execution -> conservative abandonment via the proven + D12 signal (AC11); + 7. reused raw Codex tool identifier after terminal -> new `ScopeId` (AC5); + 8. cwd/worktree isolation -> correct `WorktreeId`/cursor, other worktree + unchanged (AC14); + 9. crash before `Start` commit -> conservative recovery (AC21a); + 10. `Start` committed before bookkeeping settlement -> abandonment recovery, + not late-`Start` (AC21b); + 11. terminal transition committed before bookkeeping cleanup -> replay-safe + (AC21c); + 12. `recovery_pending` blocks a successor until recovery succeeds (AC12); + 13. any unsupported background execution is rejected / documented (AC15); + 14. mutation-scope path does not alter `diff_traces`, + `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts` + (before/after row counts) (AC20). + 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 or + detached-child detection; any test that inserts the event it means to prove. + - Dependencies: T04 (T05 only for a test that installs generated settings — + prefer driving the adapter entry point directly). + - Done when: the whole matrix passes and collectively satisfies AC4, AC5, + AC7 (production half), AC8, AC9, AC9a, AC10, AC11, AC12, AC14, AC15, AC20, + AC21. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::codex_mutation_scope`; `nix develop -c + ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml + services::mutation_trace::`. + - Context synchronization: pending + +- [ ] T07: `Author the durable Codex mutation-scope context` (status:todo) + - Task ID: T07 + - Scope: In — create `context/cli/codex-mutation-scope-integration.md` owning + the Codex adapter domain (the tool-execution scope model, the D2 + classification table, D3 execution identity, D4 `ScopeId`/`EventId` + derivation, D5/D6 bookkeeping store, D7 write-ahead, D8 exact Codex-native + fail-closed response, D9/D10 terminal boundaries, D10a the failed-tool -> + successor-tool handling and the recorded case (A/B/C) with the successor-Start + invariant and the lane key, D12 cleanup signals + the load-bearing backstop, + D13 recovery barrier, D15 worktree/cwd ownership, D14 concurrency, D16 + background/detached limitations, D17 command architecture, D18 dependency + direction, and D20/D21/D22 the Codex hook trust-identity preservation, + three-dimension doctor health model, and upstream-verified event key + labels), with an explicit **Unsupported** section (AC24) and the tested Codex + version; and update + `context/cli/mutation-scope-runtime.md`, + `context/cli/mutation-scope-hook-ingress.md`, + `context/sce/agent-trace-hooks-command-routing.md`, + `context/sce/codex-integration-runtime.md`, `context/context-map.md`, + `context/overview.md`, and `context/architecture.md` (line 135) to record + that a second concrete harness adapter now exists — each edit additive, + naming Codex as wired/registered and leaving OpenCode/Pi as still-unwired, + and keeping the existing `sce hooks codex` conversation/diff description + intact. Write a new dated ADR **only if** T01/T02 established a genuinely + new system-wide constraint (e.g. permanently multi-command Codex hook-config + ownership). Out — describing behavior not actually shipped by T02–T06; any + edit to `context/cli/claude-mutation-scope-integration.md`; any edit to an + existing ADR. + - Dependencies: T05, T06 + - Done when: `context/cli/codex-mutation-scope-integration.md` exists and + satisfies AC23/AC24; every cross-reference file names the second adapter; + `context/` files stay within the repository's per-file line budget (split a + file rather than overrun); `nix flake check` and `nix run + .#pkl-check-generated` still pass. + - Verify: inspection of the new file and each updated cross-reference against + AC23/AC24; `git diff origin/claude-mutation-scope-integration -- + spec/mutation_cursor.qnt cli/src/services/mutation_trace/protocol.rs + cli/migrations/agent-trace-repository/ config/schema/agent-trace.schema.json` + is empty (AC22); `nix flake check`. + - Context synchronization: pending + +## Open questions + +The change's value is not in doubt: the mutation-scope stack exists to attribute +mutations per independently-capable execution across every harness, and Codex is +the second of four planned producers. The generic ingress and runtime were +built specifically so this adapter would be additive. There is no smaller +version worth naming — an adapter that does not establish `Start` before the +tool runs, or does not fail closed, is not a correct adapter. + +The real risks are empirical, not architectural, and every one is deliberately +deferred to T01 evidence rather than guessed here: + +- **Failed tool with no terminal event, then another tool in the same turn.** + (D10a — the single highest-risk correctness question.) Codex has no + `PostToolUseFailure` and prior SCE research found `PostToolUse` fires only on + success, so a mutation-capable tool A that mutated then failed can leave no + terminal hook. If `PreToolUse(B)` then arrives before any `Stop`/`SessionEnd`, + the `recovery_pending` barrier is unarmed and B would `Start` alongside a + zombie live A — false `AiContended` or misattribution. T01's dedicated probe + records one of: **Case A** (a reliable intermediate signal retires A), + **Case B** (Codex is proven to run mutation-capable tools serially within a + narrow identity-defined lane, so `PreToolUse(B)` proves same-lane A stale and + the adapter runs a lane-scoped abandon+flush before `Start(B)`), or **Case C** + (neither is safe -> unsupported lifecycle, stop for re-planning). The + invariant: B must never `Start` while a known-stale A is live; and a + legitimately-concurrent out-of-lane attempt must never be swept for it. +- **Is there any reliable terminal signal for a failed mutation-capable tool at + all?** (D10.) Even outside the successor case, the failed-partial-mutation + interval with no `Close` is bounded only by the next lifecycle signal + a + re-baselining `flush` — a deliberate false-negative. +- **Which Codex lifecycle signal is the load-bearing cleanup backstop?** (D12.) + Claude's is `SessionEnd`. Codex's must be one T01 proves fires on process/ + session termination with a usable `session_id`. If none does, outstanding + attempts can only be retired on the next turn's `PreToolUse` via the recovery + barrier — acceptable but weaker. +- **What is the exact Codex-native fail-closed response for the supported + version?** (D8.) `permissionDecision: "deny"`, `{"decision":"block"}`, or a + non-zero exit — version-dependent, and the adapter must emit exactly the one + that blocks the tool. +- **Can Codex overlap its own mutation-capable executions?** (D1/D14.) If not, + Codex-alone `AiContended` is unreachable and the concurrency regression must + cross harnesses. +- **Does Codex expose any delegated-agent identity?** (D3.) If not, there is no + per-agent cleanup sweep and no `agent_id` in the `ScopeId` — and the plan + must not invent one. +- **Command architecture** (D17): separate `sce hooks codex-mutation-scope` + (recommended, decided in T02) versus extending the `sce hooks codex` + dispatcher. Not blocking — T02 decides against T01 + the code with a recorded + rationale. +- **Can the mutation-scope registrations be added without disturbing the + existing hooks' Codex trust identity?** (D20.) Codex trust keys on + `event label + matcher-group index + handler index + handler hash`, so a + non-additive merge would silently un-trust the four working `sce hooks codex` + hooks. T05 must merge additively (append, never prepend/renumber) and prove + every existing identity tuple + trust key is unchanged; if some structure + genuinely cannot preserve position, doctor must say re-trust is needed. Not + blocking — this is a T05 implementation constraint, not an unknown. + +No Quint / mutation-cursor protocol / mutation-trace SQL migration / Agent Trace +schema / attribution-algorithm change is expected: the generic mutation-scope +contract already models `Start`/`Advance`/`Close`/`Flush`/`abandon`, already +accepts `ActorKind::Codex`, and already handles replay idempotency, conservative +recovery, and `AiContended` — the Claude adapter proved the contract is +sufficient for a concrete harness without touching any of those. If T01 proves +Codex genuinely cannot be represented by it, T01 stops and records the +contradiction here for re-planning; it does not quietly modify the protocol. From 812e30d8e9d3b8b1a0adcf760cbbbecdff92c9c1 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 7 Sep 2026 16:15:09 +0200 Subject: [PATCH 02/20] codex: Freeze hook lifecycle contract with probe fixtures Record the codex-cli 0.153.4 hook and lifecycle behavior needed to implement the Codex mutation-scope adapter. Add byte-for-byte event captures and evidence for tool classification, terminal cleanup, interruption, delegation, detached descendants, and linked worktrees, and include the fixture directory in the CLI build inputs. The findings establish the adapter's supported boundaries and allow the plan to proceed to T02 without adding a successor barrier or background-process supervision. Plan: codex-mutation-scope-integration.md (T01) Co-authored-by: SCE --- .../codex_mutation_scope/fixtures/NOTES.md | 249 ++++++++++++++++ ...ell-success.apply_patch.post_tool_use.json | 1 + ...hell-success.apply_patch.pre_tool_use.json | 1 + ...y-patch-and-shell-success.session_end.json | 1 + ...patch-and-shell-success.session_start.json | 1 + ...and-shell-success.shell.post_tool_use.json | 1 + ...-and-shell-success.shell.pre_tool_use.json | 1 + ...01-apply-patch-and-shell-success.stop.json | 1 + ...-and-shell-success.user_prompt_submit.json | 1 + ...write-then-nonzero-exit.post_tool_use.json | 1 + ...-write-then-nonzero-exit.pre_tool_use.json | 1 + ...-nonzero-exit.successor.post_tool_use.json | 1 + ...n-nonzero-exit.successor.pre_tool_use.json | 1 + ...-use-hook-decision-block.pre_tool_use.json | 1 + ...ecision-block.successor.post_tool_use.json | 1 + ...-hookspecificoutput-deny.pre_tool_use.json | 1 + ...-vocabulary.apply_patch.post_tool_use.json | 1 + ...y.shell-read-list-search.pre_tool_use.json | 1 + ...fication-failure-no-post.pre_tool_use.json | 1 + ...ilure-no-post.successor.post_tool_use.json | 1 + ...be07-sigint-during-shell.pre_tool_use.json | 1 + ...obe07-sigint-during-shell.session_end.json | 1 + ...ation.agent-apply-patch.post_tool_use.json | 1 + ...gation.agent-apply-patch.pre_tool_use.json | 1 + ...-delegation.spawn_agent.post_tool_use.json | 1 + ...t-delegation.spawn_agent.pre_tool_use.json | 1 + ...08-subagent-delegation.subagent_start.json | 1 + ...e08-subagent-delegation.subagent_stop.json | 1 + ...nt-delegation.wait_agent.pre_tool_use.json | 1 + ...09-self-detaching-descendant.evidence.json | 24 ++ ...lf-detaching-descendant.post_tool_use.json | 1 + ...elf-detaching-descendant.pre_tool_use.json | 1 + ...be10-linked-worktree-cwd.pre_tool_use.json | 1 + ...e10-linked-worktree-cwd.session_start.json | 1 + ...1-interrupt-event-on-sigint.interrupt.json | 1 + ...nterrupt-event-on-sigint.pre_tool_use.json | 1 + ...interrupt-event-on-sigint.session_end.json | 1 + .../plans/codex-mutation-scope-integration.md | 269 +++++++++++++++++- flake.nix | 1 + 39 files changed, 572 insertions(+), 6 deletions(-) create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.apply_patch.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.apply_patch.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.session_end.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.session_start.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.shell.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.shell.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.stop.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.user_prompt_submit.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.successor.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.successor.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe03-pre-tool-use-hook-decision-block.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe03-pre-tool-use-hook-decision-block.successor.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe05-tool-vocabulary.apply_patch.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe05-tool-vocabulary.shell-read-list-search.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe06-apply-patch-verification-failure-no-post.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe06-apply-patch-verification-failure-no-post.successor.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe07-sigint-during-shell.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe07-sigint-during-shell.session_end.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.agent-apply-patch.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.agent-apply-patch.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.spawn_agent.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.spawn_agent.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.subagent_start.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.subagent_stop.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.wait_agent.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.evidence.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe10-linked-worktree-cwd.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe10-linked-worktree-cwd.session_start.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.interrupt.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.session_end.json diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md b/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md new file mode 100644 index 00000000..67910a0b --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md @@ -0,0 +1,249 @@ +# T01 fixture capture notes — Codex mutation-scope integration + +Raw Codex CLI hook-event payloads captured live by wiring a throwaway dump hook +into a **scratch** git repository's `.codex/hooks.json` (never the SCE repo's) and +driving scenarios with `codex exec`. Every `*.json` file in this directory is an +unmodified byte-for-byte copy of what the real `codex` binary wrote to the hook +script's STDIN, except the two synthesised `*.evidence.json` metadata files, which +are clearly marked and contain only capture metadata (timestamps, tree/ignore +observations), not hook payloads. + +## Tested Codex version + +- **`codex-cli 0.153.4`** (`codex --version`) — the version installed in this + environment; "the version SCE chooses to support" per T01's scope, the same way + #263's T01 pinned Claude Code `2.1.258`. +- Model reported in every payload: `gpt-5.6-sol`. `hooks` feature flag: `stable`, + enabled. `multi_agent` feature flag: `stable`, enabled. +- **Inspected upstream `openai/codex` at tag `rust-v0.153.4`, commit + `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`.** Authoritative source files: + - `codex-rs/hooks/schema/generated/*.command.input.schema.json` / + `*.command.output.schema.json` — the wire schema for every hook event. + - `codex-rs/hooks/src/schema.rs` — `HookEventNameWire` enum (11 input variants; + `SessionEnd` is a separate struct) and the `PreToolUse` output wire type. + - `codex-rs/hooks/src/lib.rs` lines 96–108 — the normalized event-key labels + (used for `$CODEX_HOME/config.toml` `[hooks.state]` keys and for + `codex_hook_config::hook_event_key_label`), verified NOT to be a naive + lowercase in every case (they happen to be snake_case here, but the mapping + is explicit upstream — cite this file, do not lowercase — D22 / AC17a): + + | `hooks.json` key (PascalCase) | normalized label | + | --- | --- | + | `PreToolUse` | `pre_tool_use` | + | `PermissionRequest` | `permission_request` | + | `PostToolUse` | `post_tool_use` | + | `PreCompact` | `pre_compact` | + | `PostCompact` | `post_compact` | + | `SessionStart` | `session_start` | + | `SessionEnd` | `session_end` | + | `UserPromptSubmit` | `user_prompt_submit` | + | `SubagentStart` | `subagent_start` | + | `SubagentStop` | `subagent_stop` | + | `Stop` | `stop` | + | `Interrupt` | `interrupt` | + - `codex-rs/core/src/tools/registry.rs` ~line 674 — `PostToolUse` hooks run + **only when `success_for_logging()` is true** for the tool result; a shell + command that executed then exited non-zero still counts as a successful tool + result (the exit code is data), while an `apply_patch` that fails verification + does not. + - `codex-rs/core/src/hook_runtime.rs` — `PreToolUseHookResult::Blocked` + ("Command blocked by PreToolUse hook: …"), the `SessionEnd` and `Interrupt` + transcript-flush points (both fire on interruption). + +## Capture method + +`cli/src/services/hooks/codex_mutation_scope/fixtures/` did not exist before this +task. In a scratch repo (`$SCRATCH/probe-repo`, throwaway; not the SCE checkout): + +- `dump.sh` — reads raw STDIN, writes it verbatim to a per-event file plus a + sequential `_sequence.log`, then exits 0 with empty stdout (neutral no-op). + Registered as a handler on every candidate event. +- `block.sh` — a second `PreToolUse` handler (appended after `dump.sh` in group 0) + that, only when the payload contains a unique marker, returns either + `{"decision":"block","reason":…}` or + `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":…}}`. +- Driven with `codex exec --dangerously-bypass-hook-trust + --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check -C `. + `--dangerously-bypass-hook-trust` is why no `$CODEX_HOME/config.toml` trust + state was written for the scratch hooks; nothing was added to the SCE repo. + +## Per-probe manifest and disposition + +| # | Probe | Result | Fixtures | +|---|---|---|---| +| 1 | `apply_patch` success + shell success, full lifecycle | captured | `probe01-apply-patch-and-shell-success.*` | +| 2 | shell writes a file then `exit 7` (partial-write failure), then a successor shell tool in the same turn | captured | `probe02-shell-partial-write-then-nonzero-exit.*` | +| 3 | another `PreToolUse` hook returns `{"decision":"block"}` | captured — **blocks the tool** | `probe03-pre-tool-use-hook-decision-block.*` | +| 4 | another `PreToolUse` hook returns `hookSpecificOutput.permissionDecision:"deny"` | captured — **also blocks the tool** | `probe04-pre-tool-use-hook-hookspecificoutput-deny.*` | +| 5 | tool vocabulary — read / list / search / edit | captured | `probe05-tool-vocabulary.*` | +| 6 | `apply_patch` that fails verification, then a successor shell tool | captured — **no `PostToolUse` for the failed patch** | `probe06-apply-patch-verification-failure-no-post.*` | +| 7 | SIGINT during a running shell tool (no `Interrupt` hook registered) | captured | `probe07-sigint-during-shell.*` | +| 8 | subagent delegation (`spawn_agent` / `wait_agent`) | captured | `probe08-subagent-delegation.*` | +| 9 | foreground shell spawns a self-detaching descendant that mutates the repo after `PostToolUse` | captured + Git-observability evidence | `probe09-self-detaching-descendant.*` | +| 10 | linked `git worktree` — hook `cwd` authority | captured | `probe10-linked-worktree-cwd.*` | +| 11 | SIGINT during a running shell tool, **with** an `Interrupt` hook registered | captured | `probe11-interrupt-event-on-sigint.*` | +| — | parallel mutation executions | not reproducible — Codex ran every tool strictly serially in probes 1–11 | see D1/D14 | +| — | `PermissionRequest` denial | not reachable from `codex exec` (non-interactive, bypass mode); documented from the upstream output schema | see D8/D12 | +| — | `PreCompact` / `PostCompact` | not triggered; documented from upstream as diagnostic-only | see D12 | +| — | MCP tool naming | no MCP server configured; documented from upstream (`__` namespaced names) + the conservative "unknown → mutation-capable" default | see D2 | + +## Observed event sequences (from `_sequence.log`) + +``` +probe 1 : session_start → user_prompt_submit → PreToolUse(apply_patch) → PostToolUse(apply_patch) + → PreToolUse(Bash) → PostToolUse(Bash) → Stop → SessionEnd +probe 2 : … → PreToolUse(Bash, exits 7) → PostToolUse(Bash) ← fires on the failed tool + → PreToolUse(Bash successor) → PostToolUse(Bash successor) → Stop → SessionEnd +probe 3/4: … → PreToolUse(blocked) ← NO PostToolUse + → PreToolUse(successor) → PostToolUse(successor) → Stop → SessionEnd +probe 6 : … → PreToolUse(apply_patch, verification fails) ← NO PostToolUse + → PreToolUse(Bash successor) → PostToolUse(Bash successor) → Stop → SessionEnd +probe 7 : … → PreToolUse(Bash) → SessionEnd ← no PostToolUse, no Stop, no Interrupt hook wired +probe 8 : … → PreToolUse(spawn_agent) → PostToolUse(spawn_agent) → SubagentStart + → PreToolUse(wait_agent) → PreToolUse(apply_patch, agent_id=A) → PostToolUse(apply_patch, agent_id=A) + → SubagentStop(agent_id=A) → PostToolUse(wait_agent) → Stop → SessionEnd +probe 11 : … → PreToolUse(Bash) → Interrupt → SessionEnd ← still no PostToolUse, no Stop +``` + +## Payload shape (Codex 0.153.4, cross-checked against the generated schemas) + +- **`PreToolUse` / `PostToolUse`** required: `hook_event_name`, `cwd`, + `session_id`, `turn_id`, `model`, `permission_mode`, `tool_name`, + `tool_use_id`, `tool_input`, `transcript_path` (nullable); `PostToolUse` also + requires `tool_response` (an untyped value — a string in practice: `""` for a + shell command with no stdout, `"Exit code: 0\nWall time: …\nOutput:\n…"` for + `apply_patch`, command stdout when present). `agent_id` + `agent_type` appear + **only for subagent** tool executions (not in the schema's `required` list). +- **`Stop`** required: `+ last_assistant_message` (nullable — `null` under + `codex exec`), `stop_hook_active`. No `tool_use_id`. +- **`SessionStart`**: `session_id`, `transcript_path`, `cwd`, `model`, + `permission_mode`, `source` ("startup"). No `turn_id`. +- **`SessionEnd`**: `session_id`, `transcript_path`, `cwd`, `reason` — `reason` is + `const "other"` upstream (identical for a clean exit and a SIGINT). No output + schema → cannot respond. No `turn_id`, `model`, or `permission_mode`. +- **`Interrupt`**: `session_id`, `turn_id`, `transcript_path`, `cwd`, `model`, + `permission_mode`. Fires on SIGINT before `SessionEnd` (probe 11). +- **`SubagentStart`**: `session_id`, `turn_id` (the agent's), `agent_id`, + `agent_type`, `transcript_path` (the agent's), `model`, `permission_mode`. No + tool fields. +- **`SubagentStop`**: `+ agent_transcript_path`, `last_assistant_message` + (string), `stop_hook_active`; `transcript_path` here is the parent's. +- The delegation tools' own `tool_name` values are `collaborationspawn_agent` and + `collaborationwait_agent`; their `tool_use_id` is `call_` (function-call + style), whereas shell / `apply_patch` executions use `exec-`. + +## Design-decision dispositions (written back into the plan's Design section) + +- **D1 / D14 — concurrency:** `ASSUMPTION — PROBE` (leaning serial). Codex executed + every mutation-capable tool strictly serially in all 11 probes (`Pre → Post → + Pre → Post …`, never interleaved), including when asked to parallelise and + across the parent/subagent boundary. Codex-alone `AiContended` is treated as + not reachable for 0.153.4; the AC10 regression must cross harnesses (a Codex + scope overlapping a second harness's scope on the same worktree). The adapter + still never collapses two executions into one `ScopeId`. +- **D2 — tool classification:** `PROVEN` for the `codex exec` surface. + Mutation-capable (establish a scope): `apply_patch`, `Bash` (the shell tool — + it also performs reads/list/search via shell commands, so it is always treated + mutation-capable and a read-only shell command simply creates a harmless + scope). Delegation (never a scope): `collaborationspawn_agent`, + `collaborationwait_agent`. There are **no dedicated built-in read-only tool + names** in this surface. MCP tools and any unknown `tool_name` → + conservatively mutation-capable (documented from upstream; none live here). +- **D3 — execution identity:** `PROVEN`. Key = `(session_id, agent_id?, + tool_use_id)`. `tool_use_id` is present and identical on the `PreToolUse` and + `PostToolUse` for one call. `session_id` is stable across a whole session + including subagents; `turn_id` differs per turn and per subagent; `agent_id` + (a UUID) is present only on subagent events and distinguishes a delegated + agent from the main thread. Codex **does** expose a delegated-agent identity — + the plan must use `agent_id`, and must not invent one where it is absent + (= main thread). Raw `tool_use_id`s are UUID-based and not observed to recur, + but the D4 checkout-local `attempt_seq` guard is retained anyway. +- **D8 — fail-closed `PreToolUse` response:** `PROVEN`. **Both** shapes block the + tool on 0.153.4 and are both in the generated output schema + (`pre-tool-use.command.output.schema.json`): top-level + `{"decision":"block","reason":…}` (enum `approve|block`) **and** + `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":…}}` + (enum `allow|deny|ask`). Recommend the adapter emit the `hookSpecificOutput` + shape, matching the existing `sce hooks codex` `PreToolUse(Bash)` policy arm. + A blocked tool fires `PreToolUse` only — **no `PostToolUse`** — so a fail-closed + denial leaves no scope needing a terminal action. +- **D9 — terminal boundary on success:** `PROVEN`. `PostToolUse` is the reliable + terminal signal for a successful mutation-capable tool, carrying the same + `tool_use_id` (and `agent_id` for a subagent) as its `PreToolUse`. +- **D10 — failed-tool terminal observation:** `PROVEN`, and **cleaner than the + plan feared**. A shell (`Bash`) tool that wrote a file then exited non-zero + **does** fire `PostToolUse` (probe 2), same `tool_use_id` — it maps to `close` + exactly like D9, so a partial mutation from a failed shell command is bounded + by a terminal hook. An `apply_patch` that fails verification fires **no** + `PostToolUse` (probe 6) — but Codex verifies the patch before touching the + working tree, so a failed `apply_patch` writes nothing; there is no + partial-mutation-without-terminal case for it. Prior SCE research + (`context/plans/codex-cli-integration.md`) said "`PostToolUse` fires only on a + successful tool result" — that is true at the `success_for_logging()` layer, + but an executed shell command with a non-zero exit still qualifies as a + successful tool result. The adapter needs **no Close-on-failure path**. +- **D10a — failed tool → successor tool in the same turn:** `PROVEN — Case A` + (the dangerous scenario does not arise on 0.153.4). A failed shell tool always + emits `PostToolUse` before the next `PreToolUse` (serial execution, probe 2); + a failed `apply_patch` never mutates; a hook-blocked tool never executes + (probes 3/4). The only "partial mutation, no `PostToolUse`" case is whole-turn + interruption (probes 7/11), which emits `Interrupt` and/or `SessionEnd` and + ends the turn — there is no in-turn successor to race. **No serial-lane + successor barrier is required**; T02 records the lane key as N/A. +- **D12 — lifecycle cleanup signals:** `PROVEN`. + - `Stop` — main-turn end, carries `session_id` + `turn_id`. Fires only on a + clean turn end (not on interruption). + - `SessionEnd` — the **load-bearing backstop**. Fires on a clean exit **and** + on SIGINT (probes 7, 11), carries `session_id` + `cwd`. No `turn_id`/ + `agent_id` → a whole-session sweep. + - `Interrupt` — fires on SIGINT **before** `SessionEnd` (probe 11), carries + `session_id` + `turn_id`. A newly-observed signal the plan's D12 table does + not list; usable as an earlier session/turn-scoped sweep, with `SessionEnd` + still the backstop. + - `SubagentStop` — carries `agent_id`; retires the ending delegated agent's + outstanding attempts (probe 8). + - `PermissionRequest` (denied) — `hookSpecificOutput.decision.behavior:"deny"` + per the upstream output schema; not reachable from `codex exec`, so treated + as `DOCUMENTED — NON-LOAD-BEARING`, with `SessionEnd` as the backstop. + - `PreCompact` / `PostCompact` — not observed; `DOCUMENTED — NON-LOAD-BEARING` + (diagnostic only). +- **D15 — raw hook `cwd` is authoritative:** `PROVEN`. Every payload's `cwd` was + the `codex exec -C` directory; running against a linked `git worktree` (probe + 10) reported the worktree path in `cwd`, and the write landed in the worktree, + not the main checkout. `checkout::resolve_git_dir(cwd)` resolves the + worktree-specific `.git/worktrees/` directory. Codex exposes **no** + worktree-lifecycle event (no `WorktreeRemove` equivalent among the 12 event + names); worktree-scoped cleanup relies on the `SessionEnd` / `Interrupt` / + `SubagentStop` sweeps. +- **D16 — background / detached shell:** `PROVEN` (self-detaching descendant). + The default `codex exec` shell tool has **no `run_in_background` parameter** + (params are `command`, `workdir`, `timeout_ms`, `with_escalated_permissions`, + `justification`), so there is no "explicit Codex-managed background execution" + to deny in `PreToolUse` for this surface. A foreground shell command that + `setsid`-detaches a descendant **does** leave a Git-observable mutation landing + ~4s after `PostToolUse` (probe 9 + `probe09-…​.evidence.json`) — same class as + the Claude adapter's D20. Recorded as an explicit unsupported boundary; the + adapter adds no PID supervision, process-group tracking, shell static analysis, + or staleness polling. +- **D17 inputs — command architecture:** the mutation-scope adapter needs + registrations for at least `PreToolUse`, `PostToolUse`, `Stop`, `SessionEnd`, + `SubagentStop` (and optionally `Interrupt`). The existing `sce hooks codex` + dispatcher is fail-open; the mutation-scope adapter is fail-closed on + `PreToolUse`. A **separate hidden `sce hooks codex-mutation-scope` command** + (D17 option 1) remains the recommended default — its registrations use a + distinct command so Codex invokes it as its own process, exactly as the + existing `PreToolUse(Bash)` policy hook and a mutation-scope hook would run + side by side. T02 makes the final call. + +## Newly-discovered facts the plan did not anticipate + +- Codex 0.153.4 has **12** hook events, not 11: the plan's list omits + **`Interrupt`** (`hook_event_name: "Interrupt"`, label `interrupt`). D22 / the + `hook_event_key_label` work must include it if the adapter registers it. +- `apply_patch`'s hook `tool_response` is a **string** + (`"Exit code: 0\nWall time: …\nOutput:\n…"`), not the `{"success": true}` + object some existing `sce hooks codex` tests assume. Not load-bearing for + mutation-scope (the adapter does not parse `tool_response`), flagged for T05. +- The delegation tool names are `collaborationspawn_agent` / + `collaborationwait_agent` (a `collaboration` namespace prefix with no + separator), not a bare `spawn_agent`. diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.apply_patch.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.apply_patch.post_tool_use.json new file mode 100644 index 00000000..1b41742a --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.apply_patch.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c1e-e08e-7172-8032-cb9d62af21d9","turn_id":"01a07c1e-e0cc-75f1-a566-e790c06cb033","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-46-33-01a07c1e-e08e-7172-8032-cb9d62af21d9.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch\n*** Add File: alpha.txt\n+alpha one\n*** End Patch"},"tool_response":"Exit code: 0\nWall time: 0 seconds\nOutput:\nSuccess. Updated the following files:\nA alpha.txt\n","tool_use_id":"exec-1af8025d-ad98-415e-ab60-9eff31a15b73"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.apply_patch.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.apply_patch.pre_tool_use.json new file mode 100644 index 00000000..d5da73e0 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.apply_patch.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c1e-e08e-7172-8032-cb9d62af21d9","turn_id":"01a07c1e-e0cc-75f1-a566-e790c06cb033","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-46-33-01a07c1e-e08e-7172-8032-cb9d62af21d9.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch\n*** Add File: alpha.txt\n+alpha one\n*** End Patch"},"tool_use_id":"exec-1af8025d-ad98-415e-ab60-9eff31a15b73"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.session_end.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.session_end.json new file mode 100644 index 00000000..4d349115 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.session_end.json @@ -0,0 +1 @@ +{"session_id":"01a07c1e-e08e-7172-8032-cb9d62af21d9","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-46-33-01a07c1e-e08e-7172-8032-cb9d62af21d9.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"SessionEnd","reason":"other"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.session_start.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.session_start.json new file mode 100644 index 00000000..896afb5f --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.session_start.json @@ -0,0 +1 @@ +{"session_id":"01a07c1e-e08e-7172-8032-cb9d62af21d9","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-46-33-01a07c1e-e08e-7172-8032-cb9d62af21d9.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"SessionStart","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","source":"startup"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.shell.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.shell.post_tool_use.json new file mode 100644 index 00000000..cc23b70f --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.shell.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c1e-e08e-7172-8032-cb9d62af21d9","turn_id":"01a07c1e-e0cc-75f1-a566-e790c06cb033","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-46-33-01a07c1e-e08e-7172-8032-cb9d62af21d9.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"echo 'beta two' > beta.txt"},"tool_response":"","tool_use_id":"exec-414820f5-555e-457a-92e7-60ddd27d4eec"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.shell.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.shell.pre_tool_use.json new file mode 100644 index 00000000..cd251bd1 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.shell.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c1e-e08e-7172-8032-cb9d62af21d9","turn_id":"01a07c1e-e0cc-75f1-a566-e790c06cb033","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-46-33-01a07c1e-e08e-7172-8032-cb9d62af21d9.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"echo 'beta two' > beta.txt"},"tool_use_id":"exec-414820f5-555e-457a-92e7-60ddd27d4eec"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.stop.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.stop.json new file mode 100644 index 00000000..2035b7d5 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.stop.json @@ -0,0 +1 @@ +{"session_id":"01a07c1e-e08e-7172-8032-cb9d62af21d9","turn_id":"01a07c1e-e0cc-75f1-a566-e790c06cb033","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-46-33-01a07c1e-e08e-7172-8032-cb9d62af21d9.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"Stop","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","stop_hook_active":false,"last_assistant_message":null} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.user_prompt_submit.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.user_prompt_submit.json new file mode 100644 index 00000000..9ddf37e8 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe01-apply-patch-and-shell-success.user_prompt_submit.json @@ -0,0 +1 @@ +{"session_id":"01a07c1e-e08e-7172-8032-cb9d62af21d9","turn_id":"01a07c1e-e0cc-75f1-a566-e790c06cb033","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-46-33-01a07c1e-e08e-7172-8032-cb9d62af21d9.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"UserPromptSubmit","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","prompt":"Probe 1. Do exactly these steps and nothing more: (1) use the apply_patch tool to create a new file named alpha.txt containing the single line 'alpha one'. (2) Then use the shell tool to run: echo 'beta two' > beta.txt . (3) Then stop. Do not run git. Do not explain."} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.post_tool_use.json new file mode 100644 index 00000000..62efa865 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c1f-956f-7551-8ca2-b63637715533","turn_id":"01a07c1f-9628-7343-ac0e-78887b23e94d","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-47-19-01a07c1f-956f-7551-8ca2-b63637715533.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"bash -c \"echo failwrite > gamma.txt; exit 7\""},"tool_response":"","tool_use_id":"exec-52155265-e98d-423f-87f8-76ee56ff33b1"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.pre_tool_use.json new file mode 100644 index 00000000..7d840ef9 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c1f-956f-7551-8ca2-b63637715533","turn_id":"01a07c1f-9628-7343-ac0e-78887b23e94d","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-47-19-01a07c1f-956f-7551-8ca2-b63637715533.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"bash -c \"echo failwrite > gamma.txt; exit 7\""},"tool_use_id":"exec-52155265-e98d-423f-87f8-76ee56ff33b1"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.successor.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.successor.post_tool_use.json new file mode 100644 index 00000000..51cfaa40 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.successor.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c1f-956f-7551-8ca2-b63637715533","turn_id":"01a07c1f-9628-7343-ac0e-78887b23e94d","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-47-19-01a07c1f-956f-7551-8ca2-b63637715533.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"echo 'delta four' > delta.txt"},"tool_response":"","tool_use_id":"exec-453274c0-078d-4a1e-8567-fce19cb6378a"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.successor.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.successor.pre_tool_use.json new file mode 100644 index 00000000..15a6c557 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe02-shell-partial-write-then-nonzero-exit.successor.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c1f-956f-7551-8ca2-b63637715533","turn_id":"01a07c1f-9628-7343-ac0e-78887b23e94d","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-47-19-01a07c1f-956f-7551-8ca2-b63637715533.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"echo 'delta four' > delta.txt"},"tool_use_id":"exec-453274c0-078d-4a1e-8567-fce19cb6378a"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe03-pre-tool-use-hook-decision-block.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe03-pre-tool-use-hook-decision-block.pre_tool_use.json new file mode 100644 index 00000000..c0c5dc42 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe03-pre-tool-use-hook-decision-block.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c20-a177-7fb2-a4e5-b7905b05c7be","turn_id":"01a07c20-a1fd-7b92-ae81-471107b30d88","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-48-28-01a07c20-a177-7fb2-a4e5-b7905b05c7be.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"echo DENY_ME_DECISION > d1.txt"},"tool_use_id":"exec-9c68cde2-4998-4410-a1fe-932509effe07"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe03-pre-tool-use-hook-decision-block.successor.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe03-pre-tool-use-hook-decision-block.successor.post_tool_use.json new file mode 100644 index 00000000..04738b6f --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe03-pre-tool-use-hook-decision-block.successor.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c20-a177-7fb2-a4e5-b7905b05c7be","turn_id":"01a07c20-a1fd-7b92-ae81-471107b30d88","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-48-28-01a07c20-a177-7fb2-a4e5-b7905b05c7be.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"echo ok > d2.txt"},"tool_response":"","tool_use_id":"exec-e7aab629-145d-47f3-b9f6-fa7e8c190ba2"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.pre_tool_use.json new file mode 100644 index 00000000..ca6c0a1e --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c21-6e58-7fb1-abf2-6ea8d8d6968c","turn_id":"01a07c21-6efd-7fe0-b0e1-a079f6f0fda6","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-49-20-01a07c21-6e58-7fb1-abf2-6ea8d8d6968c.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"echo DENY_ME_HSO > h1.txt"},"tool_use_id":"exec-18ef6757-cb7c-41e8-80fd-2c002fbe7a69"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe05-tool-vocabulary.apply_patch.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe05-tool-vocabulary.apply_patch.post_tool_use.json new file mode 100644 index 00000000..4e477b27 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe05-tool-vocabulary.apply_patch.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c21-e3b9-7423-8ce5-46d22b571b0a","turn_id":"01a07c21-e456-7c42-97d3-1d96182b442f","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-49-50-01a07c21-e3b9-7423-8ce5-46d22b571b0a.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch\n*** Update File: /tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo/README.md\n@@\n # probe repo\n+touched\n*** End Patch"},"tool_response":"Exit code: 0\nWall time: 0 seconds\nOutput:\nSuccess. Updated the following files:\nM /tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo/README.md\n","tool_use_id":"exec-a8aa5c26-a7e3-4a31-90dd-2817941ee36b"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe05-tool-vocabulary.shell-read-list-search.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe05-tool-vocabulary.shell-read-list-search.pre_tool_use.json new file mode 100644 index 00000000..7a387532 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe05-tool-vocabulary.shell-read-list-search.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c21-e3b9-7423-8ce5-46d22b571b0a","turn_id":"01a07c21-e456-7c42-97d3-1d96182b442f","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-49-50-01a07c21-e3b9-7423-8ce5-46d22b571b0a.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"pwd && printf '%s\n' 'FIRST_LINE' && sed -n '1p' README.md && printf '%s\n' 'FILES' && find . -maxdepth 1 -mindepth 1 -printf '%f\n' | sort && printf '%s\n' 'ALPHA_MATCHES' && rg -n --hidden --glob '!.git' 'alpha' . || true"},"tool_use_id":"exec-d5bc7c46-b979-441c-bef4-cc79d8123fb2"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe06-apply-patch-verification-failure-no-post.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe06-apply-patch-verification-failure-no-post.pre_tool_use.json new file mode 100644 index 00000000..9bd82fdf --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe06-apply-patch-verification-failure-no-post.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c22-af22-7612-ad6b-8c84aa0227fb","turn_id":"01a07c22-afa1-73c1-9150-0709aec058a6","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-50-42-01a07c22-af22-7612-ad6b-8c84aa0227fb.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch\n*** Update File: alpha.txt\n@@\n-this line does not exist in the file\n+replacement\n*** End Patch"},"tool_use_id":"exec-cb6eb008-03df-4199-b307-fe41fd1f965b"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe06-apply-patch-verification-failure-no-post.successor.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe06-apply-patch-verification-failure-no-post.successor.post_tool_use.json new file mode 100644 index 00000000..9afbb7a2 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe06-apply-patch-verification-failure-no-post.successor.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c22-af22-7612-ad6b-8c84aa0227fb","turn_id":"01a07c22-afa1-73c1-9150-0709aec058a6","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-50-42-01a07c22-af22-7612-ad6b-8c84aa0227fb.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"echo recovered > recovered.txt"},"tool_response":"","tool_use_id":"exec-4defae69-a9c9-4ece-98c4-34a30d924e89"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe07-sigint-during-shell.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe07-sigint-during-shell.pre_tool_use.json new file mode 100644 index 00000000..ecdb6c53 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe07-sigint-during-shell.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c23-5e5d-7ec2-a633-fa91e151ecba","turn_id":"01a07c23-5edc-7562-b169-5210a1514598","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-51-27-01a07c23-5e5d-7ec2-a633-fa91e151ecba.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"bash -c 'echo starting > longmark.txt; sleep 30; echo finished >> longmark.txt'"},"tool_use_id":"exec-a5243dfe-ae03-49b2-b831-d91b2878ab0f"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe07-sigint-during-shell.session_end.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe07-sigint-during-shell.session_end.json new file mode 100644 index 00000000..e2f3d5c1 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe07-sigint-during-shell.session_end.json @@ -0,0 +1 @@ +{"session_id":"01a07c23-5e5d-7ec2-a633-fa91e151ecba","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-51-27-01a07c23-5e5d-7ec2-a633-fa91e151ecba.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"SessionEnd","reason":"other"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.agent-apply-patch.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.agent-apply-patch.post_tool_use.json new file mode 100644 index 00000000..df68cab8 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.agent-apply-patch.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c24-8c12-7531-a077-c6bdc9a1b4a1","turn_id":"01a07c24-bb71-7c20-9cc1-7db59d43caa8","agent_id":"01a07c24-bb59-7ca0-80f7-99cf940a486e","agent_type":"default","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-52-57-01a07c24-bb59-7ca0-80f7-99cf940a486e.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch\n*** Add File: subagent_out.txt\n+hello-from-subagent\n*** End Patch"},"tool_response":"Exit code: 0\nWall time: 0 seconds\nOutput:\nSuccess. Updated the following files:\nA subagent_out.txt\n","tool_use_id":"exec-eecebe4a-7491-41c4-81a0-dd1ea7d0c9d2"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.agent-apply-patch.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.agent-apply-patch.pre_tool_use.json new file mode 100644 index 00000000..6dc3ae66 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.agent-apply-patch.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c24-8c12-7531-a077-c6bdc9a1b4a1","turn_id":"01a07c24-bb71-7c20-9cc1-7db59d43caa8","agent_id":"01a07c24-bb59-7ca0-80f7-99cf940a486e","agent_type":"default","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-52-57-01a07c24-bb59-7ca0-80f7-99cf940a486e.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch\n*** Add File: subagent_out.txt\n+hello-from-subagent\n*** End Patch"},"tool_use_id":"exec-eecebe4a-7491-41c4-81a0-dd1ea7d0c9d2"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.spawn_agent.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.spawn_agent.post_tool_use.json new file mode 100644 index 00000000..6d485f3a --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.spawn_agent.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c24-8c12-7531-a077-c6bdc9a1b4a1","turn_id":"01a07c24-8cd6-7480-afd3-c36f6cc88aa3","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-52-45-01a07c24-8c12-7531-a077-c6bdc9a1b4a1.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"collaborationspawn_agent","tool_input":{"task_name":"create_subagent_out","fork_turns":"all","message":"gAAAAABqnsG5XkYIOVjRjKMDrv2GKajpRTATSzUg2cI7HJK045SvaYe-mSEnw14pxIcdpKxBoZiZmFrymSv6ML1qyrz5mpQLws_gz1E8oryDAcVCPOEJVWxkK02RcwXRpRkj8d8O4fy-pqpJiYR0JNB0RpV21M-wBLrCyL0cDFf_n7db_qsnCqoBGQWjWyYRT8SjMhWyao65suhZtstgrOwVizLDtCOWd9LyEeMp13YHj9ZODytDY_c="},"tool_response":"{\"task_name\":\"/root/create_subagent_out\"}","tool_use_id":"call_NIiwlC3znFlfNsaxR0qe3a8i"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.spawn_agent.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.spawn_agent.pre_tool_use.json new file mode 100644 index 00000000..ea32916a --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.spawn_agent.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c24-8c12-7531-a077-c6bdc9a1b4a1","turn_id":"01a07c24-8cd6-7480-afd3-c36f6cc88aa3","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-52-45-01a07c24-8c12-7531-a077-c6bdc9a1b4a1.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"collaborationspawn_agent","tool_input":{"task_name":"create_subagent_out","fork_turns":"all","message":"gAAAAABqnsG5XkYIOVjRjKMDrv2GKajpRTATSzUg2cI7HJK045SvaYe-mSEnw14pxIcdpKxBoZiZmFrymSv6ML1qyrz5mpQLws_gz1E8oryDAcVCPOEJVWxkK02RcwXRpRkj8d8O4fy-pqpJiYR0JNB0RpV21M-wBLrCyL0cDFf_n7db_qsnCqoBGQWjWyYRT8SjMhWyao65suhZtstgrOwVizLDtCOWd9LyEeMp13YHj9ZODytDY_c="},"tool_use_id":"call_NIiwlC3znFlfNsaxR0qe3a8i"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.subagent_start.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.subagent_start.json new file mode 100644 index 00000000..6c6dee37 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.subagent_start.json @@ -0,0 +1 @@ +{"session_id":"01a07c24-8c12-7531-a077-c6bdc9a1b4a1","turn_id":"01a07c24-bb71-7c20-9cc1-7db59d43caa8","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-52-57-01a07c24-bb59-7ca0-80f7-99cf940a486e.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"SubagentStart","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","agent_id":"01a07c24-bb59-7ca0-80f7-99cf940a486e","agent_type":"default"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.subagent_stop.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.subagent_stop.json new file mode 100644 index 00000000..6d28dd40 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.subagent_stop.json @@ -0,0 +1 @@ +{"session_id":"01a07c24-8c12-7531-a077-c6bdc9a1b4a1","turn_id":"01a07c24-bb71-7c20-9cc1-7db59d43caa8","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-52-45-01a07c24-8c12-7531-a077-c6bdc9a1b4a1.jsonl","agent_transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-52-57-01a07c24-bb59-7ca0-80f7-99cf940a486e.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"SubagentStop","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","stop_hook_active":false,"agent_id":"01a07c24-bb59-7ca0-80f7-99cf940a486e","agent_type":"default","last_assistant_message":"Created `subagent_out.txt` containing `hello-from-subagent`."} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.wait_agent.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.wait_agent.pre_tool_use.json new file mode 100644 index 00000000..1fd88513 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe08-subagent-delegation.wait_agent.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c24-8c12-7531-a077-c6bdc9a1b4a1","turn_id":"01a07c24-8cd6-7480-afd3-c36f6cc88aa3","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T15-52-45-01a07c24-8c12-7531-a077-c6bdc9a1b4a1.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"collaborationwait_agent","tool_input":{"timeout_ms":3600000},"tool_use_id":"call_GIqftHrXTlsC6jNATYlcwsYP"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.evidence.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.evidence.json new file mode 100644 index 00000000..2d86d3df --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.evidence.json @@ -0,0 +1,24 @@ +{ + "probe": "probe09-self-detaching-descendant", + "purpose": "D16 — prove a foreground Codex shell tool call returns PostToolUse while a self-detaching descendant it spawned is still running and later performs a Git-observable mutation outside the tool's closed scope.", + "codex_version": "codex-cli 0.153.4", + "model": "gpt-5.6-sol", + "captured_utc": "2026-09-07", + "session_id": "see probe09-self-detaching-descendant.pre_tool_use.json", + "command_run_by_shell_tool": "bash detach-probe.sh", + "detach_probe_sh": "rm -f detached-descendant.marker; setsid bash -c 'sleep 4; date -u ... > detached-descendant.marker' /dev/null 2>&1 & disown; echo parent-returned ", + "observed_timestamps_utc": { + "t1_pre_tool_use_capture": "2026-09-07T14:01:27.853331Z", + "t2_post_tool_use_capture": "2026-09-07T14:01:27.920478Z", + "t2_parent_returned_stdout": "2026-09-07T14:01:27.888217Z", + "t3_descendant_marker_write": "2026-09-07T14:01:31.894147Z" + }, + "derived_ordering": "t1 < t2 < t3 — the detached descendant wrote to the repository ~4s AFTER PostToolUse fired.", + "git_observability": { + "marker_path": "detached-descendant.marker (repository root)", + "git_check_ignore": "no match (exit 1) — NOT gitignored", + "git_status_short": "?? detached-descendant.marker — an untracked path Git reports", + "conclusion": "The descendant's write changes the tree an SCE GitSnapshotService::capture_tree() would observe, and it lands strictly after the shell tool's scope would close at PostToolUse." + }, + "disposition": "D16 self-detaching-descendant boundary: CONFIRMED for Codex 0.153.4 with setsid-based shell detachment. Same class as the Claude adapter's D20. The adapter adds no PID supervision / process-group tracking / shell static analysis / staleness polling; PostToolUse is not treated as proof every descendant stopped mutating. Does not generalize to nohup / double-fork / daemonize, which this probe did not exercise." +} diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.post_tool_use.json new file mode 100644 index 00000000..5add8c6b --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c2c-6389-7112-8d03-10aa422ff782","turn_id":"01a07c2c-63c1-7b42-a60e-f3568b54ee44","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T16-01-18-01a07c2c-6389-7112-8d03-10aa422ff782.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"bash detach-probe.sh"},"tool_response":"parent-returned 2026-09-07T14:01:27.888217Z\n","tool_use_id":"exec-2e02d66a-fd0e-4212-99c7-46d1dac9ebca"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.pre_tool_use.json new file mode 100644 index 00000000..963043e0 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe09-self-detaching-descendant.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c2c-6389-7112-8d03-10aa422ff782","turn_id":"01a07c2c-63c1-7b42-a60e-f3568b54ee44","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T16-01-18-01a07c2c-6389-7112-8d03-10aa422ff782.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"bash detach-probe.sh"},"tool_use_id":"exec-2e02d66a-fd0e-4212-99c7-46d1dac9ebca"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe10-linked-worktree-cwd.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe10-linked-worktree-cwd.pre_tool_use.json new file mode 100644 index 00000000..57070f67 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe10-linked-worktree-cwd.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c2c-f58a-7831-b4ec-3483deb40048","turn_id":"01a07c2c-f62a-7f53-bad0-30d2a68e1aec","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T16-01-56-01a07c2c-f58a-7831-b4ec-3483deb40048.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-worktree","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"echo worktree-write > wt.txt"},"tool_use_id":"exec-477ec2bd-abfd-44e3-a1fc-1f91c4040a6f"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe10-linked-worktree-cwd.session_start.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe10-linked-worktree-cwd.session_start.json new file mode 100644 index 00000000..cbf6ac3b --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe10-linked-worktree-cwd.session_start.json @@ -0,0 +1 @@ +{"session_id":"01a07c2c-f58a-7831-b4ec-3483deb40048","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T16-01-56-01a07c2c-f58a-7831-b4ec-3483deb40048.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-worktree","hook_event_name":"SessionStart","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","source":"startup"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.interrupt.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.interrupt.json new file mode 100644 index 00000000..085caa20 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.interrupt.json @@ -0,0 +1 @@ +{"session_id":"01a07c2f-ccbf-79f0-afb9-2d2ce919eea7","turn_id":"01a07c2f-cd65-7e71-bae0-a8b32c842b48","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T16-05-02-01a07c2f-ccbf-79f0-afb9-2d2ce919eea7.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"Interrupt","model":"gpt-5.6-sol","permission_mode":"bypassPermissions"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.pre_tool_use.json new file mode 100644 index 00000000..90a75d64 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c2f-ccbf-79f0-afb9-2d2ce919eea7","turn_id":"01a07c2f-cd65-7e71-bae0-a8b32c842b48","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T16-05-02-01a07c2f-ccbf-79f0-afb9-2d2ce919eea7.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"Bash","tool_input":{"command":"bash -c 'echo s > im.txt; sleep 25'"},"tool_use_id":"exec-decd4cf2-3f21-4c2e-98c8-228e7505504c"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.session_end.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.session_end.json new file mode 100644 index 00000000..b0024880 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe11-interrupt-event-on-sigint.session_end.json @@ -0,0 +1 @@ +{"session_id":"01a07c2f-ccbf-79f0-afb9-2d2ce919eea7","transcript_path":"/home/davidabram/.codex/sessions/2026/09/07/rollout-2026-09-07T16-05-02-01a07c2f-ccbf-79f0-afb9-2d2ce919eea7.jsonl","cwd":"/tmp/nix-shell.mT8dci/claude-1000/-home-davidabram-repos-shared-context-engineering/4fae8dde-e17c-4212-8a0e-8fd4f536b6a9/scratchpad/probe-repo","hook_event_name":"SessionEnd","reason":"other"} \ No newline at end of file diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 81ea08d1..2e876fd4 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -97,6 +97,15 @@ serially within a turn. `AiContended` can still arise from a Codex scope overlapping another harness's scope on the same worktree regardless of T01's finding; see D14. +**T01 disposition (codex-cli 0.153.4): ASSUMPTION — PROBE (leaning serial).** +Codex executed every mutation-capable tool strictly serially in all 11 probes +(`PreToolUse → PostToolUse → PreToolUse → …`, never interleaved), including +across the parent/subagent boundary and when asked to parallelise. Codex-alone +`AiContended` is treated as not reachable for 0.153.4; the concurrency +regression must cross harnesses (D14). The adapter still never collapses two +executions into one `ScopeId`. Evidence: `fixtures/probe01…`, `probe02…`, +`probe08-subagent-delegation.*` + `fixtures/NOTES.md`. + ### D2 — Codex tool classification — T01-GATED The adapter classifies the raw Codex `tool_name` in Rust into: @@ -116,6 +125,21 @@ T01 must enumerate Codex's actual tool-name vocabulary (`tool_name` values on `PreToolUse`/`PostToolUse`, MCP tool naming, delegation tool name). The exact membership of each list is frozen by T01 and recorded here. +**T01 disposition (codex-cli 0.153.4): PROVEN for the `codex exec` surface.** +- **Mutation-capable:** `apply_patch`, `Bash` (the shell tool — it also performs + reads / listing / search via shell commands, so it is always treated + mutation-capable; a read-only shell command merely creates a harmless scope). + MCP tools and any unknown `tool_name` → conservatively mutation-capable + (from upstream; no MCP server was configured live). +- **Read-only (never a scope):** none — this Codex surface has **no dedicated + built-in read-only tool names**; reads go through `Bash`. +- **Delegation (never a scope):** `collaborationspawn_agent`, + `collaborationwait_agent` (a `collaboration` namespace prefix, no separator). + The delegated agent's own tool calls carry its `agent_id` and establish their + own scopes. +Evidence: `fixtures/probe05-tool-vocabulary.*`, `probe08-subagent-delegation.*`, +`fixtures/NOTES.md`. + ### D3 — Codex execution identity — T01-GATED The adapter needs the **smallest stable identity for one Codex tool execution**. @@ -134,6 +158,23 @@ execution identity appears in both the pre and post events for one tool call, (d) whether a raw Codex tool identifier can recur after that execution is terminal. T02 then freezes the execution key. +**T01 disposition (codex-cli 0.153.4): PROVEN.** Execution key = +`(session_id, agent_id?, tool_use_id)`. +- `tool_use_id` is present on **both** `PreToolUse` and `PostToolUse` and is + identical for one call (`exec-` for shell / `apply_patch`, + `call_` for the delegation tools). Not observed to recur (UUID-based); the + D4 checkout-local `attempt_seq` guard is kept regardless. +- `session_id` is stable for a whole session including subagents; `turn_id` + differs per turn and per subagent. +- `agent_id` (a UUID) is present **only on subagent tool/lifecycle events** and + distinguishes a delegated agent from the main thread (absent = main thread). + Codex **does** expose a delegated-agent identity — use `agent_id`; do not + invent one where it is absent. `agent_type` ("default") is diagnostic only. +Evidence: `fixtures/probe01…`, `probe08-subagent-delegation.*`, and the +`pre-tool-use` / `post-tool-use` / `subagent-stop` generated schemas at +`openai/codex` `rust-v0.153.4` (`agent_id`/`agent_type` present but not in +`required`). + ### D4 — ScopeId / EventId derivation — depends on D3 A raw Codex tool identifier that can recur after terminal execution forces a @@ -232,6 +273,20 @@ failure returns the block. A read-only or delegation `PreToolUse` returns the neutral response, no scope. +**T01 disposition (codex-cli 0.153.4): PROVEN.** **Both** denial shapes block +the tool on 0.153.4 and both appear in the generated +`pre-tool-use.command.output.schema.json` at `openai/codex` `rust-v0.153.4`: +top-level `{"decision":"block","reason":"…"}` (`decision` enum `approve|block`), +**and** +`{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"…"}}` +(`permissionDecision` enum `allow|deny|ask`). T04 should emit the +`hookSpecificOutput` shape, matching the existing `sce hooks codex` +`PreToolUse(Bash)` policy arm. A blocked tool fires `PreToolUse` only — +**no `PostToolUse`** — so a fail-closed denial leaves no scope needing a +terminal action. Evidence: +`fixtures/probe03-pre-tool-use-hook-decision-block.*`, +`fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.*`. + ### D9 — Terminal boundary on success — T01-GATED For a successful mutation-capable tool with an `active` tracked attempt, the @@ -243,6 +298,12 @@ that tool, carrying the D3 identity that ties it to the `PreToolUse`). The attempt is removed from adapter state only after durable `Close` success; duplicate delivery after cleanup is a safe no-op. +**T01 disposition (codex-cli 0.153.4): PROVEN.** `PostToolUse` is the reliable +terminal signal for a successful mutation-capable tool, carrying the same +`tool_use_id` (and `agent_id`, for a subagent) as its `PreToolUse`. Evidence: +`fixtures/probe01-apply-patch-and-shell-success.*`, +`fixtures/probe08-subagent-delegation.agent-apply-patch.*`. + ### D10 — Failed-tool terminal observation — T01-GATED, likely no reliable signal Prior SCE research (`context/plans/codex-cli-integration.md` Assumptions) found @@ -264,6 +325,24 @@ failed mutation-capable tool (a `PostToolUse` with a failure-indicating `tool_response`, or another event carrying the D3 identity), D10 becomes a `Close` mapping like D9. T01 records which. +**T01 disposition (codex-cli 0.153.4): PROVEN — D10 becomes a `Close` mapping +like D9, and is cleaner than feared.** +- A **shell (`Bash`)** tool that wrote a file then exited non-zero **does** fire + `PostToolUse` (same `tool_use_id`), so a partial mutation from a failed shell + command is bounded by a terminal hook → map to `close` exactly like D9. +- An **`apply_patch`** that fails verification fires **no** `PostToolUse`, but + Codex verifies the patch before touching the working tree, so a failed + `apply_patch` writes nothing — there is no partial-mutation-without-terminal + case for it. +- Prior SCE research's "`PostToolUse` fires only on a successful tool result" is + true at the `success_for_logging()` layer (`codex-rs/core/src/tools/registry.rs` + ~line 674 at `rust-v0.153.4`), but an executed shell command with a non-zero + exit still counts as a successful tool result. +The adapter needs **no Close-on-failure path**. Evidence: +`fixtures/probe02-shell-partial-write-then-nonzero-exit.*` (PostToolUse fires), +`fixtures/probe06-apply-patch-verification-failure-no-post.*` (no PostToolUse, +no write). + ### D10a — Failed-tool -> successor-tool in the same turn — T01-GATED D10's "next lifecycle signal" is **not sufficient on its own** for the case where @@ -332,6 +411,28 @@ T01 must define "same lane" using only identity/concurrency facts it actually established. T02 freezes the lane key and the successor-barrier design (if Case B); T04 implements it; T06 proves it. +**T01 disposition (codex-cli 0.153.4): PROVEN — Case A. The dangerous scenario +does not arise on 0.153.4, and no successor-barrier logic ships.** +- A failed **shell** tool always emits `PostToolUse` (terminal) before the next + `PreToolUse` — Codex runs mutation-capable tools strictly serially (D1), so + predecessor A is already terminal in bookkeeping when successor B's + `PreToolUse` arrives. +- A failed **`apply_patch`** never mutates the working tree (atomic + verification), so there is nothing to strand. +- A **hook-blocked** tool never executes (`PreToolUse` only, no `PostToolUse`) — + no scope was established (D8 fail-closed happens before `start`). +- The only "partial mutation, no `PostToolUse`" case is **whole-turn + interruption** (SIGINT), which emits `Interrupt` and then `SessionEnd` and + ends the turn — there is no in-turn successor `PreToolUse` to race. +T02 records the D10a lane key as **N/A (Case A)**; T04 ships no successor +barrier; the D13 successor-Start invariant is still upheld trivially because a +terminal `PostToolUse` (or `Interrupt`/`SessionEnd`) always precedes the +successor. Evidence: +`fixtures/probe02-shell-partial-write-then-nonzero-exit.*`, +`fixtures/probe06-apply-patch-verification-failure-no-post.*`, +`fixtures/probe07-sigint-during-shell.*`, +`fixtures/probe11-interrupt-event-on-sigint.*`. + ### D11 — Uncertain-boundary abandonment rules Carried verbatim from the Claude adapter (D11/D12 there), because they are @@ -375,6 +476,26 @@ The failed-tool -> successor-tool sequence (D10a) is the one case where the signal, a proven serial-lane successor barrier, or unsupported) is owned by D10a, not this table. +**T01 disposition (codex-cli 0.153.4): PROVEN.** + +| Signal | Fires | Identity | Adapter use | +| --- | --- | --- | --- | +| `SessionEnd` | clean exit **and** SIGINT | `session_id`, `cwd` (no `turn_id`/`agent_id`) | **load-bearing backstop** — whole-session sweep of every outstanding attempt | +| `Stop` | clean main-turn end only (not interruption) | `session_id`, `turn_id` | main-turn sweep (`agent_id` absent) | +| `Interrupt` | SIGINT, **before** `SessionEnd` | `session_id`, `turn_id` | earlier session/turn-scoped sweep — **newly discovered; not in the plan's original list** | +| `SubagentStop` | delegated agent ends | `agent_id`, `agent_type` | sweep attempts owned by that `agent_id` | +| `PermissionRequest` (deny) | interactive approval flows only; not reachable from `codex exec` | — | `DOCUMENTED — NON-LOAD-BEARING`; `SessionEnd` backstop covers it | +| `PreCompact` / `PostCompact` | not observed | — | `DOCUMENTED — NON-LOAD-BEARING` (diagnostic only) | + +`SessionEnd` is the single load-bearing backstop (the Codex analogue of the +Claude adapter's `SessionEnd`). Evidence: +`fixtures/probe01-…​.stop.json` / `.session_end.json`, +`fixtures/probe07-sigint-during-shell.session_end.json`, +`fixtures/probe11-interrupt-event-on-sigint.{interrupt,session_end}.json`, +`fixtures/probe08-subagent-delegation.subagent_stop.json`, and the generated +`session-end` / `stop` / `interrupt` / `subagent-stop` / `permission-request` +schemas at `openai/codex` `rust-v0.153.4`. + ### D13 — recovery_pending barrier and quiescent Flush Carried verbatim from the Claude adapter (D19 there). Whenever an abandonment or @@ -426,6 +547,17 @@ accepts, derives, stores, or constructs a `WorktreeId`, and passes no `worktree_id` key to the seam. There is no Codex `WorktreeRemove` equivalent; worktree-scoped cleanup relies on the D12 session/agent signals. +**T01 disposition (codex-cli 0.153.4): PROVEN.** Every hook payload's `cwd` was +the `codex exec -C` directory. Running against a linked `git worktree` reported +the worktree path in `cwd`, and the write landed inside the worktree, not the +main checkout; `checkout::resolve_git_dir(cwd)` resolves the worktree-specific +`.git/worktrees/` directory. Codex exposes **no** worktree-lifecycle event +(the 12 event names are `PreToolUse`, `PermissionRequest`, `PostToolUse`, +`PreCompact`, `PostCompact`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`, +`SubagentStart`, `SubagentStop`, `Stop`, `Interrupt` — no `WorktreeRemove`). +Evidence: `fixtures/probe10-linked-worktree-cwd.*`, and every other probe's +`cwd`. + ### D16 — Background / detached shell is a correctness boundary — T01-GATED T01 must separate **Codex-managed background execution** (if Codex exposes a @@ -448,6 +580,23 @@ adapter's D20 confirmed this is real and Git-observable). Do not claim support for any execution pattern unless T01 demonstrates its lifecycle actually bounds the mutations. +**T01 disposition (codex-cli 0.153.4): PROVEN (self-detaching descendant); +no Codex-managed background execution in this surface.** +- The default `codex exec` shell tool has **no `run_in_background` parameter** + (params: `command`, `workdir`, `timeout_ms`, `with_escalated_permissions`, + `justification`). There is no explicit Codex-managed background execution to + deny in `PreToolUse` for this surface, so the adapter ships **no + background-execution classifier / deny** (unlike the Claude adapter's + `run_in_background = true` deny). If a future Codex surface adds one, revisit. +- A foreground shell command that `setsid`-detaches a descendant **does** leave a + Git-observable mutation landing ~4s after `PostToolUse` + (`fixtures/probe09-self-detaching-descendant.{pre_tool_use,post_tool_use,evidence}.json`) + — same class as the Claude adapter's D20. Recorded as an explicit unsupported + boundary; the adapter adds no PID supervision, process-group tracking, + shell-command static analysis, or staleness polling, and does not treat + `PostToolUse` as proof every descendant stopped mutating. Not generalised to + `nohup` / double-fork / daemonize, which this probe did not exercise. + ### D17 — Command architecture: separate hidden command (recommended) — decided in T02 Codex today funnels **every** registered hook event through the single @@ -482,6 +631,20 @@ T02 makes the final call against T01 findings and the code, defaulting to (1), and records the decision here. Every subsequent task's wording assumes (1); if T02 chooses (2), T02 revises D17, D8's routing, and T04/T05 scope accordingly. +**T01 inputs (codex-cli 0.153.4):** the mutation-scope adapter needs +registrations for at least `PreToolUse`, `PostToolUse`, `Stop`, `SessionEnd`, +`SubagentStop` (optionally `Interrupt` as an earlier interruption sweep). The +existing `sce hooks codex` dispatcher funnels 4 events (`UserPromptSubmit`, +`Stop`, `PreToolUse` matcher `Bash`, `PostToolUse` matcher `apply_patch`) and is +fail-open; the mutation-scope adapter is fail-closed on `PreToolUse` and +never-silently-drop on terminal boundaries. `PreToolUse` and `Stop` would be +double-registered (once per command). Live probes confirmed Codex runs each +registered handler as its own process and that two `PreToolUse` handlers in one +group both execute (dump + block). A **separate hidden +`sce hooks codex-mutation-scope` command** (option 1) remains the recommended +default; nothing in T01 argues against it. Evidence: `fixtures/NOTES.md`, +`.codex/hooks.json` two-handler `PreToolUse` group used across probes 3–11. + ### D18 — Adapter depends on hooks::mutation_scope only Dependency direction is strictly @@ -572,6 +735,22 @@ set — candidates `SessionEnd`, `SubagentStop`, `PermissionRequest`, an unmatch label**, verified against `openai/codex` source (T01/T05), not by lowercasing the event name. Each newly registered event gets a `hook_event_key_label` test. +**T01 disposition (codex-cli 0.153.4): PROVEN.** The upstream label map is +`codex-rs/hooks/src/lib.rs` lines 96–108 at tag `rust-v0.153.4` +(commit `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`): +`PreToolUse→pre_tool_use`, `PermissionRequest→permission_request`, +`PostToolUse→post_tool_use`, `PreCompact→pre_compact`, +`PostCompact→post_compact`, `SessionStart→session_start`, +`SessionEnd→session_end`, `UserPromptSubmit→user_prompt_submit`, +`SubagentStart→subagent_start`, `SubagentStop→subagent_stop`, `Stop→stop`, +`Interrupt→interrupt`. **Codex 0.153.4 has 12 hook events, not the 11 this plan +lists — it also has `Interrupt`** (fires on SIGINT before `SessionEnd`). T05 +must add a `hook_event_key_label` entry + test for every newly-registered +mutation-scope event, citing this source file. The `$CODEX_HOME/config.toml` +`[hooks.state]` keys observed live use exactly these labels +(`…:pre_tool_use:0:0`, `…:post_tool_use:0:0`, `…:stop:0:0`, +`…:user_prompt_submit:0:0`). + ## Acceptance criteria How this plan is proven complete. Each criterion is observable and names the @@ -965,8 +1144,59 @@ Persist this field in every plan; this is durable plan state, not chat state: ## Task stack -- [ ] T01: `Freeze the real Codex hook and lifecycle contract` (status:todo) +- [x] T01: `Freeze the real Codex hook and lifecycle contract` (status:done) - Task ID: T01 + - Completed: 2026-09-07 + - Files changed: + - `cli/src/services/hooks/codex_mutation_scope/fixtures/` (new — 35 raw + byte-for-byte Codex hook-event captures across 11 probes + one + `probe09-*.evidence.json` capture-metadata file + `NOTES.md`) + - `flake.nix` (add `./cli/src/services/hooks/codex_mutation_scope/fixtures` + to `workspaceSrc` so the fixtures reach the build sandbox, mirroring the + Claude adapter's T01 line) + - `context/plans/codex-mutation-scope-integration.md` (T01 dispositions + written into D1, D2, D3, D8, D9, D10, D10a, D12, D15, D16, D17-inputs, + D22; T01 outcome added to Open questions; this task record) + - Result: Froze the Codex hook/lifecycle contract for **codex-cli 0.153.4** + (model `gpt-5.6-sol`), cross-checked against upstream `openai/codex` tag + `rust-v0.153.4` (commit `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`) — + generated hook JSON schemas, `codex-rs/hooks/src/{schema.rs,lib.rs}`, + `codex-rs/core/src/{tools/registry.rs,hook_runtime.rs}`. All 11 live probes + captured (normal apply_patch + shell success, shell partial-write-then-fail, + two `PreToolUse`-hook denial shapes, tool vocabulary, apply_patch + verification failure, SIGINT with/without an `Interrupt` hook, subagent + delegation, self-detaching descendant with Git-observability evidence, + linked-worktree cwd). Key findings: **D10a is Case A** (a failed shell tool + still fires `PostToolUse`; a failed `apply_patch` writes nothing; + interruption ends the turn — no successor-barrier logic ships); + **`SessionEnd` is the load-bearing cleanup backstop**, `Interrupt` is a + newly-discovered earlier interruption signal (**Codex has 12 hook events, + not 11**); **both** `{"decision":"block"}` and + `hookSpecificOutput.permissionDecision:"deny"` block a tool; Codex runs + mutation-capable tools **strictly serially** (Codex-alone `AiContended` + unreachable); Codex **does** expose a delegated-agent identity (`agent_id`, + subagent events only); raw hook `cwd` is authoritative including for linked + worktrees; the default `codex exec` shell tool has **no `run_in_background` + parameter** (no background-execution deny needed) but a self-detaching + descendant is an unsupported boundary as for Claude. No probe showed Codex + cannot be represented by the current mutation-scope contract — the plan + proceeds to T02. + - Verify: + - `nix run .#pkl-check-generated` — **passed** ("Ephemeral Pkl generation + passed: 141 files, inventory sha256 + dcbd28041c3587156510bdb3a6c76e5a9ec4851c140b4c50785c764d95ebfd5c"). + - `nix flake check` — **passed** ("all checks passed!"; incompatible + non-Linux systems omitted as usual). + - Fixtures committed under + `cli/src/services/hooks/codex_mutation_scope/fixtures/` and referenced + from this plan; `NOTES.md` lists the manifest and per-probe disposition; + all 36 JSON fixture files parse; CLI build input list updated (`flake.nix`). + - Context impact: domain — a new adapter-domain fixture corpus + frozen Codex + hook-contract facts now exist; no code, no user-visible behavior, no public + interface yet. Durable Codex-adapter context (`context/cli/ + codex-mutation-scope-integration.md`) is authored by T07 once behavior + ships; T01's facts live in the plan's Design section and the fixtures + `NOTES.md` until then. - Scope: In — capture raw Codex hook-event fixtures from the Codex version SCE chooses to support, commit them under `cli/src/services/hooks/codex_mutation_scope/fixtures/` (one file per probe, @@ -1050,11 +1280,11 @@ Persist this field in every plan; this is durable plan state, not chat state: included) shows Codex cannot be represented by the current mutation-scope contract, that is recorded in Open questions and the plan stops for re-planning rather than proceeding to T02. - - Verify: fixtures committed and referenced from this plan; `NOTES.md` lists - the manifest and per-probe disposition; `nix run .#pkl-check-generated` and - `nix flake check` still pass (fixtures are inert data — confirm the CLI - build input list includes the new fixtures directory, as #263's T01 needed - for Claude). + - Verify (planned): fixtures committed and referenced from this plan; + `NOTES.md` lists the manifest and per-probe disposition; + `nix run .#pkl-check-generated` and `nix flake check` still pass (fixtures + are inert data — confirm the CLI build input list includes the new fixtures + directory, as #263's T01 needed for Claude). - Context synchronization: pending - [ ] T02: `Command architecture, Codex event model, classification, and identity` (status:todo) @@ -1341,6 +1571,33 @@ built specifically so this adapter would be additive. There is no smaller version worth naming — an adapter that does not establish `Start` before the tool runs, or does not fail closed, is not a correct adapter. +**T01 outcome (codex-cli 0.153.4, upstream `openai/codex` `rust-v0.153.4` / +`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`): Codex is representable by the +current mutation-scope contract — the plan proceeds to T02.** Every empirical +risk below is resolved by a captured fixture or an upstream schema citation +(see the Design section dispositions and +`cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md`). Headline +resolutions: + +- **D10a is Case A / a non-issue on 0.153.4.** A failed shell tool still fires + `PostToolUse`; a failed `apply_patch` writes nothing; interruption ends the + turn. No serial-lane successor barrier ships. +- **`SessionEnd` is the load-bearing cleanup backstop** (fires on clean exit and + on SIGINT); `Interrupt` is an additional earlier interruption signal the plan + did not know about (Codex has **12** hook events, not 11). +- **The fail-closed `PreToolUse` response**: both `{"decision":"block"}` and + `hookSpecificOutput.permissionDecision:"deny"` block the tool; T04 emits the + `hookSpecificOutput` shape. +- **Codex runs mutation-capable tools strictly serially** → Codex-alone + `AiContended` is not reachable; the AC10 regression crosses harnesses. +- **Codex exposes a delegated-agent identity** (`agent_id`, on subagent events + only) — the plan uses it and does not invent one. +- **Command architecture**: T01 found nothing against the recommended separate + hidden `sce hooks codex-mutation-scope` command; T02 still decides. +- **Existing-hook trust identity**: the upstream label map is + `codex-rs/hooks/src/lib.rs` 96–108; a position-stable additive merge is a T05 + implementation constraint, not an unknown. + The real risks are empirical, not architectural, and every one is deliberately deferred to T01 evidence rather than guessed here: diff --git a/flake.nix b/flake.nix index 93de837b..a2c8f46c 100644 --- a/flake.nix +++ b/flake.nix @@ -191,6 +191,7 @@ (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/src/services/hooks/codex_mutation_scope/fixtures) (pkgs.lib.fileset.maybeMissing ./cli/migrations) cliBuildInputFileset (pkgs.lib.fileset.maybeMissing ./cli/assets/hooks) From 0364d580659c2e18d9a0946368762666bf505f68 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 7 Sep 2026 17:21:35 +0200 Subject: [PATCH 03/20] hooks: Record Codex MCP mutation-scope lifecycle findings Codex 0.153.4 MCP probes show successful calls emit PostToolUse, but mutate-then-error calls can leave git-visible changes without terminal hooks, failed calls can be followed directly by successors, and parallel-capable MCP calls can overlap. Capture the raw fixtures and probe harness, and update the plan to stop before T02 for MCP re-planning while preserving built-in Bash/apply_patch findings. Plan: codex-mutation-scope-integration.md, T01 Co-authored-by: SCE --- .../codex_mutation_scope/fixtures/NOTES.md | 312 +++- .../fixtures/mcp_probe/block.sh | 11 + .../fixtures/mcp_probe/config.toml.sample | 24 + .../fixtures/mcp_probe/dump.sh | 39 + .../fixtures/mcp_probe/hooks.json.sample | 128 ++ .../fixtures/mcp_probe/run-probes.sh | 140 ++ .../fixtures/mcp_probe/server.py | 255 +++ ...be12-mcp-mutate-success.post_tool_use.json | 1 + ...obe12-mcp-mutate-success.pre_tool_use.json | 1 + ...robe12-mcp-mutate-success.session_end.json | 1 + .../probe12-mcp-mutate-success.stop.json | 1 + ...robe13-mcp-mutate-then-error.evidence.json | 25 + ...13-mcp-mutate-then-error.pre_tool_use.json | 1 + ...e13-mcp-mutate-then-error.session_end.json | 1 + .../probe13-mcp-mutate-then-error.stop.json | 1 + ...14-mcp-failed-then-successor.evidence.json | 25 + ...ed-then-successor.failed.pre_tool_use.json | 1 + ...robe14-mcp-failed-then-successor.stop.json | 1 + ...hen-successor.successor.post_tool_use.json | 1 + ...then-successor.successor.pre_tool_use.json | 1 + .../probe15-mcp-blocked-call.evidence.json | 21 + ...probe15-mcp-blocked-call.pre_tool_use.json | 1 + .../probe15-mcp-blocked-call.stop.json | 1 + ...parallel-server-optin.a.post_tool_use.json | 1 + ...-parallel-server-optin.a.pre_tool_use.json | 1 + ...parallel-server-optin.b.post_tool_use.json | 1 + ...-parallel-server-optin.b.pre_tool_use.json | 1 + ...16-mcp-parallel-server-optin.evidence.json | 24 + ...arallel-readonly-hint.a.post_tool_use.json | 1 + ...parallel-readonly-hint.a.pre_tool_use.json | 1 + ...arallel-readonly-hint.b.post_tool_use.json | 1 + ...parallel-readonly-hint.b.pre_tool_use.json | 1 + ...7-mcp-parallel-readonly-hint.evidence.json | 23 + .../plans/codex-mutation-scope-integration.md | 1363 ++++++++++++----- 34 files changed, 2026 insertions(+), 385 deletions(-) create mode 100755 cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/block.sh create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/config.toml.sample create mode 100755 cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/dump.sh create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/hooks.json.sample create mode 100755 cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/run-probes.sh create mode 100755 cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/server.py create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.session_end.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.stop.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.evidence.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.session_end.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.stop.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.evidence.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.failed.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.stop.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.successor.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.successor.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.evidence.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.stop.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.a.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.a.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.b.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.b.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.evidence.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.a.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.a.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.b.post_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.b.pre_tool_use.json create mode 100644 cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.evidence.json diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md b/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md index 67910a0b..544bdd94 100644 --- a/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md @@ -4,9 +4,12 @@ Raw Codex CLI hook-event payloads captured live by wiring a throwaway dump hook into a **scratch** git repository's `.codex/hooks.json` (never the SCE repo's) and driving scenarios with `codex exec`. Every `*.json` file in this directory is an unmodified byte-for-byte copy of what the real `codex` binary wrote to the hook -script's STDIN, except the two synthesised `*.evidence.json` metadata files, which -are clearly marked and contain only capture metadata (timestamps, tree/ignore -observations), not hook payloads. +script's STDIN, **except the `*.evidence.json` metadata files** (one for the +built-in probe 9, five for the MCP probes 13–17), which are clearly marked with a +leading `_comment` and contain only capture metadata (timestamps, git-status +observations, event ordering, upstream citations), not hook payloads. The +built-in captures (probes 1–11) predate the MCP lifecycle extension (probes +12–17); see the "T01 MCP lifecycle probe extension" section for that harness. ## Tested Codex version @@ -82,10 +85,17 @@ task. In a scratch repo (`$SCRATCH/probe-repo`, throwaway; not the SCE checkout) | 9 | foreground shell spawns a self-detaching descendant that mutates the repo after `PostToolUse` | captured + Git-observability evidence | `probe09-self-detaching-descendant.*` | | 10 | linked `git worktree` — hook `cwd` authority | captured | `probe10-linked-worktree-cwd.*` | | 11 | SIGINT during a running shell tool, **with** an `Interrupt` hook registered | captured | `probe11-interrupt-event-on-sigint.*` | -| — | parallel mutation executions | not reproducible — Codex ran every tool strictly serially in probes 1–11 | see D1/D14 | +| — | parallel mutation executions (built-in) | not reproduced — Codex ran every **built-in** tool serially in probes 1–11 | see D1/D14 | +| — | parallel mutation executions (MCP) | **REPRODUCED LIVE** — see probes 16/17 | see D1/D14 + MCP extension | | — | `PermissionRequest` denial | not reachable from `codex exec` (non-interactive, bypass mode); documented from the upstream output schema | see D8/D12 | | — | `PreCompact` / `PostCompact` | not triggered; documented from upstream as diagnostic-only | see D12 | -| — | MCP tool naming | no MCP server configured; documented from upstream (`__` namespaced names) + the conservative "unknown → mutation-capable" default | see D2 | +| 12 | MCP `mutate_success` — success lifecycle + `tool_name` shape | captured — `PreToolUse → PostToolUse`, same `tool_use_id` | `probe12-mcp-mutate-success.*` | +| 13 | MCP `mutate_then_error` — mutates git-visible file **then** returns `is_error:true` | captured — **NO `PostToolUse`**; mutation survives | `probe13-mcp-mutate-then-error.*` | +| 14 | failed MCP tool A → successor mutation-capable MCP tool B, same turn (the direct D10a probe) | captured — **no event of any kind between `PreToolUse(A)` and `PreToolUse(B)`** | `probe14-mcp-failed-then-successor.*` | +| 15 | MCP call blocked by a `PreToolUse` hook (`permissionDecision:"deny"`) | captured — `PreToolUse` only, no `PostToolUse`, no mutation | `probe15-mcp-blocked-call.*` | +| 16 | two mutation-capable MCP executions in parallel — server `supports_parallel_tool_calls = true` | captured — **genuinely concurrent**, two scopes live at once | `probe16-mcp-parallel-server-optin.*` | +| 17 | two mutation-capable MCP executions in parallel — via the tool's own `annotations.readOnlyHint` (no server opt-in) | captured — **genuinely concurrent** | `probe17-mcp-parallel-readonly-hint.*` | +| — | MCP tool naming | **PROVEN — `mcp____`** (`mcp__probe__mutate_success`); `tool_use_id` is `exec-` (same shape as shell / `apply_patch`, not `call_`) | `probe12…pre_tool_use.json` | ## Observed event sequences (from `_sequence.log`) @@ -134,13 +144,18 @@ probe 11 : … → PreToolUse(Bash) → Interrupt → SessionEnd ← still no ## Design-decision dispositions (written back into the plan's Design section) -- **D1 / D14 — concurrency:** `ASSUMPTION — PROBE` (leaning serial). Codex executed - every mutation-capable tool strictly serially in all 11 probes (`Pre → Post → - Pre → Post …`, never interleaved), including when asked to parallelise and - across the parent/subagent boundary. Codex-alone `AiContended` is treated as - not reachable for 0.153.4; the AC10 regression must cross harnesses (a Codex - scope overlapping a second harness's scope on the same worktree). The adapter - still never collapses two executions into one `ScopeId`. +- **D1 / D14 — concurrency:** **scope-split by tool type.** + - **Built-in `Bash` / `apply_patch`:** `ASSUMPTION — PROBE` (leaning serial). + Codex executed every built-in mutation-capable tool strictly serially in all + 11 probes (`Pre → Post → Pre → Post …`, never interleaved), including when + asked to parallelise and across the parent/subagent boundary. + - **MCP:** `PROVEN (live)` — two mutation-capable MCP executions **do** overlap + (probes 16/17). **Codex-alone `AiContended` IS reachable via MCP** on 0.153.4. + The "serial / `AiContended` unreachable" conclusion is therefore correct **only + for the built-in tools**. The AC10 regression still crosses harnesses; if MCP + stays supported, T06 must add an MCP-overlap `AiContended` regression. The + adapter never collapses two executions into one `ScopeId`. See the "T01 MCP + lifecycle probe extension" section below. - **D2 — tool classification:** `PROVEN` for the `codex exec` surface. Mutation-capable (establish a scope): `apply_patch`, `Bash` (the shell tool — it also performs reads/list/search via shell commands, so it is always treated @@ -148,7 +163,12 @@ probe 11 : … → PreToolUse(Bash) → Interrupt → SessionEnd ← still no scope). Delegation (never a scope): `collaborationspawn_agent`, `collaborationwait_agent`. There are **no dedicated built-in read-only tool names** in this surface. MCP tools and any unknown `tool_name` → - conservatively mutation-capable (documented from upstream; none live here). + conservatively mutation-capable. **MCP naming is now `PROVEN` live — + `mcp____` (probes 12–17).** Classification conservatism + (`unknown/MCP → mutation-capable`) is **only** for `Start` / fail-closed and + confers **no lifecycle-support guarantee**: an unknown/MCP tool inherits none + of the `Bash` / `apply_patch` terminal guarantees (probes 13/14). See the MCP + extension section below. - **D3 — execution identity:** `PROVEN`. Key = `(session_id, agent_id?, tool_use_id)`. `tool_use_id` is present and identical on the `PreToolUse` and `PostToolUse` for one call. `session_id` is stable across a whole session @@ -170,26 +190,47 @@ probe 11 : … → PreToolUse(Bash) → Interrupt → SessionEnd ← still no - **D9 — terminal boundary on success:** `PROVEN`. `PostToolUse` is the reliable terminal signal for a successful mutation-capable tool, carrying the same `tool_use_id` (and `agent_id` for a subagent) as its `PreToolUse`. -- **D10 — failed-tool terminal observation:** `PROVEN`, and **cleaner than the - plan feared**. A shell (`Bash`) tool that wrote a file then exited non-zero - **does** fire `PostToolUse` (probe 2), same `tool_use_id` — it maps to `close` - exactly like D9, so a partial mutation from a failed shell command is bounded - by a terminal hook. An `apply_patch` that fails verification fires **no** - `PostToolUse` (probe 6) — but Codex verifies the patch before touching the - working tree, so a failed `apply_patch` writes nothing; there is no - partial-mutation-without-terminal case for it. Prior SCE research - (`context/plans/codex-cli-integration.md`) said "`PostToolUse` fires only on a - successful tool result" — that is true at the `success_for_logging()` layer, - but an executed shell command with a non-zero exit still qualifies as a - successful tool result. The adapter needs **no Close-on-failure path**. -- **D10a — failed tool → successor tool in the same turn:** `PROVEN — Case A` - (the dangerous scenario does not arise on 0.153.4). A failed shell tool always - emits `PostToolUse` before the next `PreToolUse` (serial execution, probe 2); - a failed `apply_patch` never mutates; a hook-blocked tool never executes - (probes 3/4). The only "partial mutation, no `PostToolUse`" case is whole-turn - interruption (probes 7/11), which emits `Interrupt` and/or `SessionEnd` and - ends the turn — there is no in-turn successor to race. **No serial-lane - successor barrier is required**; T02 records the lane key as N/A. +- **D10 — failed-tool terminal observation:** **scope-split by tool type.** + - **`Bash`:** `PROVEN` — a shell tool that wrote a file then exited non-zero + **does** fire `PostToolUse` (probe 2), same `tool_use_id` — maps to `close` + like D9; a partial mutation from a failed shell command is bounded by a + terminal hook. + - **`apply_patch`:** `PROVEN` — a verification failure fires **no** + `PostToolUse` (probe 6), but Codex verifies before touching the tree, so + nothing is written; no partial-mutation-without-terminal case. + - **MCP:** `PROVEN` — a mutation-capable MCP tool that **mutates then returns + `is_error:true`** fires **no terminal hook of any kind** (probe 13: + `PreToolUse → Stop → SessionEnd`), and the mutation (`mcp_b.txt`) is + git-visible afterwards. This is a real partial-mutation-without-terminal + case. An external MCP server is not under Codex's atomicity control. + - **unknown:** no tool-specific terminal guarantee; governed only by + tool-generic lifecycle policy. + Prior SCE research's "`PostToolUse` fires only on a successful tool result" is + true at the `success_for_logging()` layer + (`codex-rs/core/src/tools/registry.rs` ~674); a non-zero-exit shell command + still counts as a successful tool result, but a `CallToolResult` with + `is_error:true` does not (`McpToolOutput::success_for_logging` = + `self.result.success()`). The adapter needs no Close-on-failure path for + `Bash` / `apply_patch`; for MCP it has **no terminal signal at all**. +- **D10a — failed tool → successor tool in the same turn:** **scope-split by + tool type.** + - **Built-in `Bash` / `apply_patch`: `PROVEN — Case A`.** A failed shell tool + always emits `PostToolUse` before the next `PreToolUse` (probe 2); a failed + `apply_patch` never mutates; a hook-blocked tool never executes (probes 3/4); + the only "partial mutation, no `PostToolUse`" case is whole-turn interruption + (probes 7/11) which ends the turn. No serial-lane successor barrier is + required for built-ins. + - **MCP: `PROVEN — Case C`.** Probe 13: a mutation-capable MCP tool can mutate + then fail with **no terminal hook**. Probe 14: the failed MCP tool A is + followed **directly** by mutation-capable MCP tool B with **no event of any + kind between them** — no positive stale/terminal evidence for A. Probes + 16/17: MCP executions **can overlap**, so `PreToolUse(B)` does **not** prove + A stale. The adapter cannot distinguish `failed-and-dead A` from + `still-running A`. This is D10a **Case C *if MCP is modeled as a scope***. + **Resolved 2026-09-08 by re-planning direction B** — MCP/unknown are + `Untracked` (no scope, no bookkeeping), so this lifecycle can never strand a + scope. See the "Re-planning resolution" note at the end of this file and the + plan's D23 / Open questions. - **D12 — lifecycle cleanup signals:** `PROVEN`. - `Stop` — main-turn end, carries `session_id` + `turn_id`. Fires only on a clean turn end (not on interruption). @@ -247,3 +288,208 @@ probe 11 : … → PreToolUse(Bash) → Interrupt → SessionEnd ← still no - The delegation tool names are `collaborationspawn_agent` / `collaborationwait_agent` (a `collaboration` namespace prefix with no separator), not a bare `spawn_agent`. + +--- + +# T01 MCP lifecycle probe extension (probes 12–17) + +The original probes 1–11 proved the failed-tool / serial-execution / successor-safety +story for the **built-in** `Bash` and `apply_patch` tools only. The plan then +generalised those conclusions to MCP / unknown mutation-capable tools **without +equivalent evidence**. This extension live-probes MCP against the same supported +version and finds the generalisation is **wrong**. + +## Probe infrastructure + +`cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/` (probe-only, +**not SCE runtime code**): + +- `server.py` — a ~250-line zero-dependency stdio MCP server (MCP 2025-06-18, + JSON-RPC over stdio). Tools, all deliberately mutation-capable (each writes a + git-visible file into the scratch repo): + - `mutate_success` — write a file, return success. + - `mutate_then_error` — write a file **first**, then return `is_error:true`. + - `slow_mutate` — write a file, sleep ~8 s (on its own thread), write a second + file, return success. Long enough for overlap to be observable. + - `read_only_liar` — same as `slow_mutate` but annotated + `annotations.readOnlyHint = true` while still mutating. +- `dump.sh` / `block.sh` — the same neutral dump hook + marker-gated + `permissionDecision:"deny"` hook as probes 1–11, wired onto all 12 events. +- `run-probes.sh` — builds a scratch git repo + a private `CODEX_HOME` (auth + only), writes `config.toml` with two MCP servers (`probe`, and `probe_par` + carrying `supports_parallel_tool_calls = true`), and drives `codex exec + --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust + --skip-git-repo-check` once per probe. Nothing touches the SCE checkout or the + real `$CODEX_HOME`. +- `config.toml.sample` / `hooks.json.sample` — the generated config, path-sanitised. + +## Observed MCP event sequences + +``` +probe 12 : session_start → user_prompt_submit + → PreToolUse(mcp__probe__mutate_success) → PostToolUse(same tool_use_id) + → Stop → SessionEnd +probe 13 : … → PreToolUse(mcp__probe__mutate_then_error) ← MCP writes mcp_b.txt, returns is_error:true + → Stop → SessionEnd ← NO PostToolUse at all +probe 14 : … → PreToolUse(A = mcp__probe__mutate_then_error) ← writes mcp_c1.txt, is_error:true, tool_use_id exec-27777ab1 + → PreToolUse(B = mcp__probe__mutate_success) ← nothing between A and B + → PostToolUse(B) → Stop → SessionEnd ← A's tool_use_id never recurs; A has no terminal hook +probe 15 : … → PreToolUse(mcp__probe__mutate_success) ← blocked by permissionDecision:"deny" + → Stop → SessionEnd ← no PostToolUse, no tools/call, no mutation +probe 16 : … → PreToolUse(A) 14:44:32.4187 → PreToolUse(B) 14:44:32.4199 ← both before any Post + → PostToolUse(A) 14:44:40.4431 → PostToolUse(B) 14:44:40.4502 + server: slow_mutate[d1] begin :32.431, slow_mutate[d2] begin :32.439 (d1 still sleeping), both end :40.43x +probe 17 : same interleaving as 16, reached via annotations.readOnlyHint with NO server opt-in +``` + +## MCP design-decision dispositions + +- **MCP tool naming (D2):** `PROVEN` — `mcp____` + (`mcp__probe__mutate_success`, `mcp__probe_par__slow_mutate`). `tool_use_id` is + `exec-` — the **same shape** as shell / `apply_patch`, *not* the + `call_` form used by the `collaboration*` delegation tools. `tool_name` is + identical on `PreToolUse` and `PostToolUse`; the execution key + `(session_id, agent_id?, tool_use_id)` (D3) holds unchanged for MCP. + Upstream contract: `MCP_TOOL_NAME_DELIMITER` / `join_tool_name` / + `ensure_mcp_prefix` in `codex-rs/core/src/tools/handlers/mcp.rs` at + `rust-v0.153.4`. + +- **Successful MCP call (D9):** `PROVEN` — `PostToolUse` is the reliable terminal + hook, same `tool_use_id` as its `PreToolUse` (probe 12). `tool_response` for MCP + is a **structured object** `{"content":[…],"isError":false}`, not a string as + for `apply_patch` — not load-bearing (the adapter does not parse it). + +- **Blocked MCP call (D8):** `PROVEN` — a hook-blocked MCP `PreToolUse` + (`hookSpecificOutput.permissionDecision:"deny"`) fires `PreToolUse` only, **no + `PostToolUse`**, `tools/call` is never issued, nothing is written (probe 15). + Identical to the built-in blocked-tool case (probes 3/4). A D8 fail-closed deny + on a mutation-capable MCP `PreToolUse` therefore strands no scope. + +- **Failed MCP call — `mutate_then_error` (D10):** `PROVEN` — a mutation-capable + MCP tool that **writes a git-visible file and then returns `is_error:true`** + receives **no terminal hook of any kind** (probe 13): `PreToolUse` → `Stop` → + `SessionEnd`, no `PostToolUse`. `git status` after shows `mcp_b.txt`, mtime + `16:43:33.836` — the mutation landed and survives the failed result. This is + **not** the built-in `apply_patch` story (atomic pre-verification, nothing + written) and **not** the built-in shell story (`PostToolUse` still fires on a + non-zero exit). Upstream mechanism: `codex-rs/core/src/tools/registry.rs` + ~line 674 — `let post_tool_use_payload = if success { … } else { None }` with + `success = result.success_for_logging()`; for MCP, + `McpToolOutput::success_for_logging()` = `self.result.success()` + (`codex-rs/core/src/tools/context.rs:122-124`), which is false when the + `CallToolResult` carries `is_error:true`. An external MCP server is not under + Codex's atomicity control, so the write can precede the failure. + Evidence: `probe13-mcp-mutate-then-error.{pre_tool_use,stop,session_end,evidence}.json`. + +- **Failed MCP tool → successor tool, same turn (D10a):** `PROVEN — Case C for + MCP *if modeled as a scope*` (resolved by direction B — MCP is `Untracked`). + Probe 14: `PreToolUse(A = mutate_then_error)` is followed **directly** by + `PreToolUse(B = mutate_success)` with **no intervening event** — no + `PostToolUse(A)`, no `Interrupt`, no `Stop`, no `SubagentStop`, no + `PermissionRequest`, no compaction event. A's `tool_use_id` (`exec-27777ab1…`) + appears in exactly one hook delivery. Both `mcp_c1.txt` and `mcp_c2.txt` land. + There is **no positive stale/terminal evidence for A** before B starts, and — + because MCP executions *can* overlap (probes 16/17) — `PreToolUse(B)` does + **not** prove A stale. The adapter cannot distinguish `failed-and-dead A` from + `still-running A`. This is exactly D10a **Case C *if MCP is modeled as a + scope***. **Resolved 2026-09-08 by re-planning direction B** — the adapter does + not model MCP as a scope (MCP/unknown = `Untracked`), so no A attempt exists to + strand. See the "Re-planning resolution" note at the end of this file. + Evidence: `probe14-mcp-failed-then-successor.*`. + +- **MCP concurrency (D1 / D14):** `PROVEN (live)` — two mutation-capable MCP tool + executions run **genuinely concurrently** on 0.153.4. Probe 16 (server + `supports_parallel_tool_calls = true`): `PreToolUse(A)` at `14:44:32.418731`, + `PreToolUse(B)` at `14:44:32.419862` — 1.1 ms apart, both before either + `PostToolUse`; the MCP server's own log shows `slow_mutate[d2]` begins while + `slow_mutate[d1]` is still sleeping; both scopes are live between their + `PreToolUse` and `PostToolUse` for ~8 s. Probe 17 reaches the same interleaving + via the tool's own `annotations.readOnlyHint` with **no** server- or + config-side opt-in. Upstream contract: + `McpHandler::supports_parallel_tool_calls()` (`codex-rs/core/src/tools/handlers/mcp.rs:128-139`) + = `tool_info.supports_parallel_tool_calls || annotations.read_only_hint`; + `tool_info.supports_parallel_tool_calls` comes from `McpServerMetadata` + (`codex-rs/codex-mcp/src/server.rs:395-421`) which reads the config.toml key + `RawMcpServerConfig.supports_parallel_tool_calls` + (`codex-rs/config/src/mcp_types.rs:362`). Therefore **Codex-alone `AiContended` + IS reachable** on 0.153.4 whenever a mutation-capable MCP tool is + parallel-eligible. The plan's blanket "Codex mutation-capable tools are serial / + Codex-alone `AiContended` is unreachable" is true **only for the built-in + `Bash` / `apply_patch` tools exercised by probes 1–11**. + Evidence: `probe16-mcp-parallel-server-optin.*`, `probe17-mcp-parallel-readonly-hint.*`. + +## Disposition terminology used above + +- **PROVEN** — observed live in a captured fixture, or a deterministic structural + fact that cannot differ at runtime. +- **DOCUMENTED — NON-LOAD-BEARING** — established from upstream source/schema, not + load-bearing for adapter correctness, with a load-bearing backstop named. +- **ASSUMPTION — PROBE** — a leaning conclusion from limited observation, not + proven. +- **UNSUPPORTED** — the lifecycle cannot be represented safely by the current + mutation-scope contract; the adapter must fail closed / exclude / re-architect, + and the plan stops for re-planning. + +## Answers to the T01-extension questions + +1. Successful MCP calls emit `PostToolUse`: **yes** (probe 12). +2. `mutate-then-error` MCP calls emit `PostToolUse`: **no** (probe 13). +3. An MCP side effect can survive a failed MCP result: **yes** — `mcp_b.txt` is + git-visible after `is_error:true` with no terminal hook (probe 13). +4. A positive cleanup signal appears before a successor tool: **no** — nothing + between `PreToolUse(A)` and `PreToolUse(B)` (probe 14). +5. MCP executions can overlap: **yes** — genuinely concurrent (probes 16, 17). +6. Codex-alone `AiContended` is reachable: **yes, via MCP** (probes 16/17); + still **no** for built-in `Bash` / `apply_patch` (probes 1–11). +7. Final D10a disposition for MCP: **Case C *if MCP is modeled as a mutation + scope***. Built-ins remain **Case A**. Re-planning (2026-09-08) resolved this + by **not** modeling MCP as a scope — see the resolution note at the end of + this file. +8. MCP remains supported operationally but is **outside Codex mutation-scope + attribution coverage** in adapter v1 (re-planning **direction B**, chosen + 2026-09-08). MCP tools and unknown tool names are classified `Untracked`: + allowed to execute, may mutate, no `Start`, no scope, no bookkeeping. The + Case C evidence below is retained as the *reason* for the exclusion. Rejected: + (A) deny MCP fail-closed. Deferred as future work: (C) a richer + lifecycle/runtime mechanism for first-class MCP attribution. +9. Unknown tool names: classification conservatism (`unknown → mutation-capable` + for `Start` / fail-closed) must be **separated** from a lifecycle-support + guarantee. An unknown tool inherits **no** `Bash` / `apply_patch` terminal + guarantee; its terminal/recovery behaviour may rely only on tool-generic + lifecycle signals, else it is unsupported for trustworthy attribution. +10. Is T01 safe to mark done / proceed to T02: **yes, as of 2026-09-08** — the + MCP D10a Case C finding was resolved by re-planning direction B (MCP/unknown + are `Untracked`, outside coverage), which needs no protocol change. T01 is + done; T02 is unblocked (not started). See the resolution note below. + +## Re-planning resolution (2026-09-08) — direction B + +The MCP D10a Case C finding above is **correct and retained**: *if a +mutation-capable MCP tool were represented as an SCE mutation scope, the Codex +0.153.4 hook lifecycle makes that scope's lifecycle unsafe* (probe 13: +mutate-then-error has no terminal hook; probe 14: a successor `PreToolUse` can +follow with no cleanup signal between; probes 16/17: parallel MCP execution is +real, so a successor cannot prove a predecessor stale). + +**Resolution:** the Codex adapter v1 does **not** create mutation scopes for MCP +calls. `PreToolUse(mcp__…)` — and any unknown `tool_name` — is classified +`Untracked`: it executes normally, it may mutate, but the adapter emits no +`Start`, no `ScopeId`, no `EventId`, no attempt, and no `recovery_pending`, so no +`Close` / `Abandon` / `Flush` is ever needed for it. This is a deliberate +**attribution-coverage boundary**, not a lifecycle workaround — the adapter does +not claim MCP is read-only, does not guarantee MCP mutations are detected +immediately, and `AiExclusive` continues to mean "exactly one *tracked* scope was +live", not "sole authorship of the interval". + +Coverage for Codex adapter v1: + +| Class | Tools | Scope? | +| --- | --- | --- | +| `TrackedMutation` | `Bash`, `apply_patch` | yes — one execution → one `ScopeId` | +| `Delegation` | `collaborationspawn_agent`, `collaborationwait_agent` | no (the delegated agent's tracked tools get scopes) | +| `Untracked` | `mcp__*`, unknown `tool_name` | no — allowed, may mutate, outside coverage | + +No `mutation_cursor.qnt` / mutation protocol / runtime-semantic / SQL-migration / +Agent Trace schema change is required. The plan's Design section (D1, D2, D8, D9, +D10, D10a, D12, D13, D14, new D23) and Open questions carry the full disposition. +The probe fixtures in this directory are unchanged evidence. diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/block.sh b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/block.sh new file mode 100755 index 00000000..dc24b9f8 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/block.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# T01 MCP-probe PreToolUse block hook. NOT SCE runtime code. +# Second PreToolUse handler (after dump.sh). Only blocks when the payload +# contains the marker "PLEASE_BLOCK_THIS_MCP_CALL"; otherwise a neutral no-op. +# Used to prove whether a blocked MCP call still emits PostToolUse. +set -u +payload="$(cat)" +if printf '%s' "$payload" | grep -q 'PLEASE_BLOCK_THIS_MCP_CALL'; then + printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"blocked by MCP probe"}}' +fi +exit 0 diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/config.toml.sample b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/config.toml.sample new file mode 100644 index 00000000..885a78c3 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/config.toml.sample @@ -0,0 +1,24 @@ +model = "gpt-5.6-sol" +model_reasoning_effort = "low" + +[projects."/probe-repo"] +trust_level = "trusted" + +[mcp_servers.probe] +command = "" +args = ["/server.py"] +startup_timeout_sec = 90 +tool_timeout_sec = 240 +[mcp_servers.probe.env] +MCP_PROBE_DIR = "/probe-repo" +MCP_PROBE_SLOW_SECONDS = "8" + +[mcp_servers.probe_par] +command = "" +args = ["/server.py"] +supports_parallel_tool_calls = true +startup_timeout_sec = 90 +tool_timeout_sec = 240 +[mcp_servers.probe_par.env] +MCP_PROBE_DIR = "/probe-repo" +MCP_PROBE_SLOW_SECONDS = "8" diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/dump.sh b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/dump.sh new file mode 100755 index 00000000..7eac4154 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/dump.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# T01 MCP-probe dump hook. NOT SCE runtime code. +# Reads the raw Codex hook payload from STDIN, records it verbatim, and exits 0 +# with empty stdout (a neutral no-op) so it never influences Codex behaviour. +# +# The capture directory is passed as $1 (absolute path, baked into the +# .codex/hooks.json command by run-probes.sh) because Codex runs hook commands +# with a cleared environment (see codex-rs/hooks/src/engine/command_runner.rs +# build_command: env_clear()). +set -u +CAP="${1:?capture dir arg missing}" +mkdir -p "$CAP/events" 2>/dev/null || true + +payload="$(cat)" +ts="$(date -u +%Y-%m-%dT%H:%M:%S.%NZ)" + +event="$(printf '%s' "$payload" | sed -n 's/.*"hook_event_name":"\([^"]*\)".*/\1/p')" +[ -z "$event" ] && event="unknown" +tuid="$(printf '%s' "$payload" | sed -n 's/.*"tool_use_id":"\([^"]*\)".*/\1/p')" +tname="$(printf '%s' "$payload" | sed -n 's/.*"tool_name":"\([^"]*\)".*/\1/p')" + +# Concurrent hook deliveries (parallel MCP calls) run this script at the same +# time, so serialise the shared counter/log writes with a lock and give each +# delivery a collision-proof filename (nanosecond timestamp + pid). +uniq="$(date -u +%Y%m%dT%H%M%S.%N)-$$" +( flock 9 + n="$(cat "$CAP/.seq" 2>/dev/null || echo 0)" + n=$((n + 1)) + echo "$n" > "$CAP/.seq" + printf '%s\t%s\tevent=%s\ttool_name=%s\ttool_use_id=%s\n' \ + "$n" "$ts" "$event" "$tname" "$tuid" >> "$CAP/_sequence.log" + printf '%s\n' "$payload" >> "$CAP/_stream.ndjson" +) 9>"$CAP/.lock" + +slot="${uniq}-${event}" +[ -n "$tname" ] && slot="${slot}-${tname}" +printf '%s' "$payload" > "$CAP/events/${slot}.json" + +exit 0 diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/hooks.json.sample b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/hooks.json.sample new file mode 100644 index 00000000..bb329371 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/hooks.json.sample @@ -0,0 +1,128 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + }, + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/block.sh\"" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + } + ] + } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + } + ] + } + ], + "Interrupt": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"/probe-repo/.codex/hooks/dump.sh\" \"/cap/_live\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/run-probes.sh b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/run-probes.sh new file mode 100755 index 00000000..62b78dc3 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/run-probes.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# T01 MCP lifecycle probe driver. NOT SCE runtime code. +# +# Drives `codex exec` (codex-cli 0.153.4) against a tiny local stdio MCP server +# (server.py) wired into a scratch git repo, capturing every raw Codex hook +# payload plus git-observable mutation evidence. See NOTES.md "MCP probe manifest". +# +# Usage: +# MCP_PROBE_WORK=/abs/scratch/dir PY=/abs/python3 bash run-probes.sh [A B C ...] +# +# Requirements: codex on PATH, a working $CODEX_HOME/auth.json, network for the +# model. Nothing is written outside $MCP_PROBE_WORK and a private CODEX_HOME copy. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK="${MCP_PROBE_WORK:?set MCP_PROBE_WORK to a scratch directory}" +PY="${PY:-$(command -v python3 || true)}" +[ -n "$PY" ] || { echo "no python3; set PY=/abs/python3"; exit 1; } +REAL_CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" + +REPO="$WORK/probe-repo" +CH="$WORK/codex-home" +CAPROOT="$WORK/cap" +LIVE="$CAPROOT/_live" +MODEL="${MCP_PROBE_MODEL:-gpt-5.6-sol}" + +rm -rf "$WORK" +mkdir -p "$REPO/.codex/hooks" "$CH" "$LIVE/events" + +cp "$REAL_CODEX_HOME/auth.json" "$CH/auth.json" +cat > "$CH/config.toml" < "$REPO/seed.txt" +git -C "$REPO" add -A +git -C "$REPO" commit -qm seed + +run_probe() { + local name="$1" prompt="$2" + local cap="$CAPROOT/$name" + rm -rf "$cap" "$LIVE"; mkdir -p "$cap" "$LIVE/events" + : > "$LIVE/_sequence.log"; : > "$LIVE/_stream.ndjson"; echo 0 > "$LIVE/.seq" + rm -f "$REPO/.mcp-probe-server.log" + git -C "$REPO" reset -q --hard HEAD + git -C "$REPO" clean -qfd + + echo "=== probe $name ===" + git -C "$REPO" status --porcelain > "$LIVE/git-before.txt" + local t0 t1 + t0="$(date -u +%Y-%m-%dT%H:%M:%S.%NZ)" + CODEX_HOME="$CH" codex exec \ + --dangerously-bypass-approvals-and-sandbox \ + --dangerously-bypass-hook-trust \ + --skip-git-repo-check \ + -C "$REPO" \ + "$prompt" > "$LIVE/codex-stdout.txt" 2> "$LIVE/codex-stderr.txt" || true + t1="$(date -u +%Y-%m-%dT%H:%M:%S.%NZ)" + + git -C "$REPO" status --porcelain > "$LIVE/git-after.txt" + ( cd "$REPO" && find . -maxdepth 2 -type f \( -name '*.txt' -o -name '*.log' \) \ + -newer .git/HEAD -printf '%TY-%Tm-%TdT%TH:%TM:%.9TS\t%p\n' | sort ) \ + > "$LIVE/mutation-mtimes.txt" 2>/dev/null || true + cp "$REPO/.mcp-probe-server.log" "$LIVE/mcp-probe-server.log" 2>/dev/null || true + printf 'probe=%s\ncodex_start=%s\ncodex_end=%s\nmodel=%s\n' "$name" "$t0" "$t1" "$MODEL" > "$LIVE/capture-meta.txt" + + cp -r "$LIVE"/. "$cap"/ + echo "--- $name sequence ---"; cat "$cap/_sequence.log" || true + echo "--- $name git-after ---"; cat "$cap/git-after.txt" || true + echo +} + +PROBES=("$@") +[ ${#PROBES[@]} -eq 0 ] && PROBES=(A B C Cblock D Dliar E) + +for p in "${PROBES[@]}"; do +case "$p" in +A) run_probe probeA-mcp-mutate-success \ + "Use the MCP tool named mutate_success (server 'probe') with argument filename set to \"mcp_a.txt\". Call it exactly once. After it returns, immediately end your turn without doing anything else." ;; +B) run_probe probeB-mcp-mutate-then-error \ + "Use the MCP tool mutate_then_error (server 'probe') with argument filename set to \"mcp_b.txt\". Call it exactly once. It is expected to report an error - that is fine and expected. Do NOT retry it, do NOT call any other tool, do NOT create or edit any file yourself. Immediately end your turn after it returns." ;; +C) run_probe probeC-failed-mcp-then-successor \ + "Step 1: call the MCP tool mutate_then_error (server 'probe') with filename \"mcp_c1.txt\" exactly once. It will return an error; ignore the error and do NOT retry it. Step 2: in the same turn, call the MCP tool mutate_success (server 'probe') with filename \"mcp_c2.txt\" exactly once. Then end your turn. Do not create or edit any files yourself." ;; +Cblock) run_probe probeCblock-blocked-mcp-call \ + "Call the MCP tool mutate_success (server 'probe') with argument content set to the literal string \"PLEASE_BLOCK_THIS_MCP_CALL\" and filename \"mcp_blocked.txt\", exactly once. If the call is blocked or denied, do NOT retry and do NOT do anything else - immediately end your turn." ;; +D) run_probe probeD-mcp-parallel \ + "The MCP server 'probe_par' supports parallel tool calls. In a SINGLE assistant message, emit TWO tool calls together before waiting for either result: slow_mutate with tag \"d1\", and slow_mutate with tag \"d2\", both on server 'probe_par'. Do not call them sequentially - both calls must be in flight at once. After both return, end your turn." ;; +Dliar) run_probe probeD-mcp-parallel-readonly-liar \ + "In a SINGLE assistant message, emit TWO tool calls together before waiting for either result: read_only_liar with tag \"r1\", and read_only_liar with tag \"r2\", both on server 'probe'. Both calls must be in flight at once, not sequential. After both return, end your turn." ;; +E) run_probe probeE-mcp-tool-name \ + "Call mutate_success (server 'probe') once with filename \"mcp_e.txt\", then end your turn." ;; +esac +done + +rm -rf "$LIVE" +echo "All captures under: $CAPROOT" diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/server.py b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/server.py new file mode 100755 index 00000000..cf5a3697 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/server.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Minimal zero-dependency stdio MCP server — T01 MCP lifecycle probe infrastructure. + +NOT SCE runtime code. This server exists only to drive Codex 0.153.4 hook +lifecycle probes for the codex-mutation-scope integration plan (T01 MCP +extension). It implements just enough of the Model Context Protocol +(2025-06-18) over stdio JSON-RPC to let `codex exec` discover and call a +handful of deliberately mutation-capable tools: + + mutate_success write a git-visible file, return a successful result + mutate_then_error write a git-visible file, THEN return is_error:true + slow_mutate write a file, sleep, write a second file, return success + read_only_liar annotated read_only_hint:true but still writes a file + (used to force supports_parallel_tool_calls via annotation) + +Every write goes to $MCP_PROBE_DIR (the scratch git repo). Each call also +appends a line to $MCP_PROBE_DIR/.mcp-probe-server.log with a UTC timestamp so +the fixtures can be correlated with hook events and git state. + +Protocol version is echoed back from the client's initialize request so we do +not have to track rmcp's negotiation rules. +""" + +import json +import os +import sys +import threading +import time +import datetime + +_STDOUT_LOCK = threading.Lock() + +PROBE_DIR = os.environ.get("MCP_PROBE_DIR", os.getcwd()) +LOG_PATH = os.path.join(PROBE_DIR, ".mcp-probe-server.log") +SLOW_SECONDS = float(os.environ.get("MCP_PROBE_SLOW_SECONDS", "6")) +DEFAULT_PROTOCOL = "2025-06-18" + + +def log(msg): + line = f"{datetime.datetime.now(datetime.timezone.utc).isoformat()} {msg}\n" + try: + with open(LOG_PATH, "a", encoding="utf-8") as fh: + fh.write(line) + except OSError: + pass + sys.stderr.write("[mcp-probe] " + line) + sys.stderr.flush() + + +def write_probe_file(name, content): + path = os.path.join(PROBE_DIR, name) + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + return path + + +TOOLS = [ + { + "name": "mutate_success", + "description": "Write a git-visible file, then return a successful result.", + "inputSchema": { + "type": "object", + "properties": { + "filename": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": [], + }, + }, + { + "name": "mutate_then_error", + "description": ( + "Write a git-visible file FIRST, then return an MCP error " + "(is_error:true). The side effect always lands before the error." + ), + "inputSchema": { + "type": "object", + "properties": { + "filename": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": [], + }, + }, + { + "name": "slow_mutate", + "description": ( + "Write a file, stay busy for several seconds, write a second file, " + "then return success. Long enough to observe overlap." + ), + "inputSchema": { + "type": "object", + "properties": { + "tag": {"type": "string"}, + "seconds": {"type": "number"}, + }, + "required": [], + }, + }, + { + "name": "read_only_liar", + "description": ( + "Annotated read_only_hint:true but still writes a git-visible file. " + "Used to exercise the annotation branch of " + "McpHandler::supports_parallel_tool_calls()." + ), + "inputSchema": { + "type": "object", + "properties": { + "tag": {"type": "string"}, + "seconds": {"type": "number"}, + }, + "required": [], + }, + "annotations": {"readOnlyHint": True, "title": "read_only_liar"}, + }, +] + + +def ok_result(text): + return {"content": [{"type": "text", "text": text}], "isError": False} + + +def err_result(text): + return {"content": [{"type": "text", "text": text}], "isError": True} + + +def call_tool(name, args): + args = args or {} + if name == "mutate_success": + fn = args.get("filename", "mcp_success.txt") + path = write_probe_file(fn, args.get("content", "mcp mutate_success\n")) + log(f"mutate_success wrote {path}") + return ok_result(f"wrote {fn}") + + if name == "mutate_then_error": + fn = args.get("filename", "mcp_then_error.txt") + path = write_probe_file(fn, args.get("content", "mcp mutate_then_error\n")) + log(f"mutate_then_error wrote {path} BEFORE returning is_error:true") + return err_result( + f"wrote {fn} but the operation then failed (simulated downstream error)" + ) + + if name == "slow_mutate": + tag = args.get("tag", "x") + secs = float(args.get("seconds", SLOW_SECONDS)) + p1 = write_probe_file(f"slow_{tag}_begin.txt", f"begin {tag}\n") + log(f"slow_mutate[{tag}] begin, sleeping {secs}s (wrote {p1})") + time.sleep(secs) + p2 = write_probe_file(f"slow_{tag}_end.txt", f"end {tag}\n") + log(f"slow_mutate[{tag}] end (wrote {p2})") + return ok_result(f"slow_mutate {tag} done") + + if name == "read_only_liar": + tag = args.get("tag", "r") + secs = float(args.get("seconds", SLOW_SECONDS)) + p1 = write_probe_file(f"liar_{tag}_begin.txt", f"begin {tag}\n") + log(f"read_only_liar[{tag}] begin, sleeping {secs}s (wrote {p1})") + time.sleep(secs) + p2 = write_probe_file(f"liar_{tag}_end.txt", f"end {tag}\n") + log(f"read_only_liar[{tag}] end (wrote {p2})") + return ok_result(f"read_only_liar {tag} done") + + return err_result(f"unknown tool {name}") + + +def handle(msg): + method = msg.get("method") + msg_id = msg.get("id") + params = msg.get("params") or {} + + if method == "initialize": + protocol = params.get("protocolVersion") or DEFAULT_PROTOCOL + log(f"initialize (protocolVersion={protocol})") + return { + "jsonrpc": "2.0", + "id": msg_id, + "result": { + "protocolVersion": protocol, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "sce-codex-mcp-probe", "version": "0.1.0"}, + }, + } + + if method in ("notifications/initialized", "initialized"): + log("notifications/initialized") + return None + + if method == "ping": + return {"jsonrpc": "2.0", "id": msg_id, "result": {}} + + if method == "tools/list": + log("tools/list") + return {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": TOOLS}} + + if method == "tools/call": + name = params.get("name") + args = params.get("arguments") + log(f"tools/call name={name} args={json.dumps(args)}") + result = call_tool(name, args) + log(f"tools/call name={name} -> isError={result.get('isError')}") + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + if method is not None and msg_id is not None: + return { + "jsonrpc": "2.0", + "id": msg_id, + "error": {"code": -32601, "message": f"method not found: {method}"}, + } + return None + + +def emit(reply): + if reply is None: + return + with _STDOUT_LOCK: + sys.stdout.write(json.dumps(reply) + "\n") + sys.stdout.flush() + + +def dispatch(msg): + try: + emit(handle(msg)) + except Exception as exc: # noqa: BLE001 - probe tool, log and continue + log(f"handler error: {exc!r}") + if msg.get("id") is not None: + emit({ + "jsonrpc": "2.0", + "id": msg["id"], + "error": {"code": -32603, "message": str(exc)}, + }) + + +def main(): + log(f"server start pid={os.getpid()} PROBE_DIR={PROBE_DIR}") + for raw in sys.stdin: + raw = raw.strip() + if not raw: + continue + try: + msg = json.loads(raw) + except json.JSONDecodeError as exc: + log(f"bad json: {exc}: {raw!r}") + continue + # tools/call runs on its own thread so slow_mutate calls genuinely + # overlap in wall-clock time when Codex dispatches them in parallel. + if msg.get("method") == "tools/call": + threading.Thread(target=dispatch, args=(msg,), daemon=True).start() + else: + dispatch(msg) + log("server stdin closed, exiting") + + +if __name__ == "__main__": + main() diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.post_tool_use.json new file mode 100644 index 00000000..828e894c --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c52-7acf-7a90-88f2-4be5a7f45ed4","turn_id":"01a07c52-7ae2-7913-9f16-00037dac6886","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-42-55-01a07c52-7acf-7a90-88f2-4be5a7f45ed4.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe__mutate_success","tool_input":{"filename":"mcp_a.txt"},"tool_response":{"content":[{"type":"text","text":"wrote mcp_a.txt"}],"isError":false},"tool_use_id":"exec-00988fad-6707-48ed-81b6-07bb11933886"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.pre_tool_use.json new file mode 100644 index 00000000..f53198d1 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c52-7acf-7a90-88f2-4be5a7f45ed4","turn_id":"01a07c52-7ae2-7913-9f16-00037dac6886","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-42-55-01a07c52-7acf-7a90-88f2-4be5a7f45ed4.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe__mutate_success","tool_input":{"filename":"mcp_a.txt"},"tool_use_id":"exec-00988fad-6707-48ed-81b6-07bb11933886"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.session_end.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.session_end.json new file mode 100644 index 00000000..7740312a --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.session_end.json @@ -0,0 +1 @@ +{"session_id":"01a07c52-7acf-7a90-88f2-4be5a7f45ed4","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-42-55-01a07c52-7acf-7a90-88f2-4be5a7f45ed4.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"SessionEnd","reason":"other"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.stop.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.stop.json new file mode 100644 index 00000000..fa6911b4 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe12-mcp-mutate-success.stop.json @@ -0,0 +1 @@ +{"session_id":"01a07c52-7acf-7a90-88f2-4be5a7f45ed4","turn_id":"01a07c52-7ae2-7913-9f16-00037dac6886","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-42-55-01a07c52-7acf-7a90-88f2-4be5a7f45ed4.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"Stop","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","stop_hook_active":false,"last_assistant_message":null} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.evidence.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.evidence.json new file mode 100644 index 00000000..c3703188 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.evidence.json @@ -0,0 +1,25 @@ +{ + "_comment": "SYNTHESISED capture metadata, NOT a raw hook payload. Probe B (NOTES.md 'MCP probe manifest'). Proves an MCP tool that mutates git-visible state and THEN returns is_error:true receives NO PostToolUse hook, yet the mutation survives.", + "codex_version": "codex-cli 0.153.4", + "model": "gpt-5.6-sol", + "mcp_server": "probe (stdio, server.py), tool mutate_then_error", + "tool_use_id": "exec-a0e0e3a4-7374-466c-884f-d5af9f872706", + "event_ordering": [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse(mcp__probe__mutate_then_error) 14:43:33.817Z", + "Stop 14:43:36.006Z", + "SessionEnd 14:43:36.036Z" + ], + "post_tool_use_fired": false, + "mcp_server_log": [ + "14:43:33.836468Z tools/call name=mutate_then_error args={\"filename\": \"mcp_b.txt\"}", + "14:43:33.836716Z tools/call name=mutate_then_error -> isError=True" + ], + "git_status_before": [], + "git_status_after": ["?? mcp_b.txt", "?? .mcp-probe-server.log"], + "mutation_mtime": "2026-09-07T16:43:33.836652 (local) -> mcp_b.txt written BEFORE the is_error result, and BEFORE Stop (16:43:36)", + "distinguishes": "tool MUTATED then returned failure (mcp_b.txt present in git status) — NOT 'tool did not mutate'. Established from git-observable state, not from the MCP result.", + "upstream_mechanism": "codex-rs/core/src/tools/registry.rs ~line 674 at rust-v0.153.4: `let post_tool_use_payload = if success { ... } else { None };` where success = result.success_for_logging(); for MCP McpToolOutput::success_for_logging() = self.result.success() (codex-rs/core/src/tools/context.rs:122-124), which is false when the CallToolResult carries is_error:true.", + "disposition": "PROVEN (live) - failed MCP call emits NO terminal hook; side effect survives." +} diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.pre_tool_use.json new file mode 100644 index 00000000..cf9c65f7 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c52-dfeb-76e1-a8e1-f53e5834158e","turn_id":"01a07c52-e07d-7da1-856f-365dc6dc3ac2","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-43-21-01a07c52-dfeb-76e1-a8e1-f53e5834158e.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe__mutate_then_error","tool_input":{"filename":"mcp_b.txt"},"tool_use_id":"exec-a0e0e3a4-7374-466c-884f-d5af9f872706"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.session_end.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.session_end.json new file mode 100644 index 00000000..b10ea16d --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.session_end.json @@ -0,0 +1 @@ +{"session_id":"01a07c52-dfeb-76e1-a8e1-f53e5834158e","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-43-21-01a07c52-dfeb-76e1-a8e1-f53e5834158e.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"SessionEnd","reason":"other"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.stop.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.stop.json new file mode 100644 index 00000000..f879bfd9 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe13-mcp-mutate-then-error.stop.json @@ -0,0 +1 @@ +{"session_id":"01a07c52-dfeb-76e1-a8e1-f53e5834158e","turn_id":"01a07c52-e07d-7da1-856f-365dc6dc3ac2","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-43-21-01a07c52-dfeb-76e1-a8e1-f53e5834158e.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"Stop","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","stop_hook_active":false,"last_assistant_message":null} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.evidence.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.evidence.json new file mode 100644 index 00000000..6b54c33c --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.evidence.json @@ -0,0 +1,25 @@ +{ + "_comment": "SYNTHESISED capture metadata, NOT a raw hook payload. Probe C - the direct D10a probe. Failed mutation-capable MCP tool A followed by mutation-capable MCP tool B in the SAME turn, with no turn end between them.", + "codex_version": "codex-cli 0.153.4", + "model": "gpt-5.6-sol", + "A": { "tool": "mcp__probe__mutate_then_error", "tool_use_id": "exec-27777ab1-ea7f-429f-a744-d7f72ee8c294", "result": "is_error:true", "mutation": "mcp_c1.txt (git-visible)" }, + "B": { "tool": "mcp__probe__mutate_success", "tool_use_id": "exec-1643344b-f335-4506-ba06-a2326977cba2", "result": "is_error:false", "mutation": "mcp_c2.txt (git-visible)" }, + "event_ordering": [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse(A mcp__probe__mutate_then_error) 14:43:53.208Z", + "PreToolUse(B mcp__probe__mutate_success) 14:43:53.232Z", + "PostToolUse(B mcp__probe__mutate_success) 14:43:53.254Z", + "Stop 14:43:55.678Z", + "SessionEnd 14:43:55.709Z" + ], + "events_between_A_failure_and_PreToolUse_B": "NONE. No PostToolUse(A), no Interrupt, no Stop, no SubagentStop, no SessionEnd, no PermissionRequest, no compaction event. PreToolUse(B) is the very next hook delivery after PreToolUse(A).", + "A_terminal_hook": "never delivered (A's tool_use_id exec-27777ab1 appears in exactly one hook event, its PreToolUse).", + "git_status_after": ["?? mcp_c1.txt", "?? mcp_c2.txt", "?? .mcp-probe-server.log"], + "mcp_server_log": [ + "14:43:53.220 tools/call mutate_then_error {\"filename\":\"mcp_c1.txt\"} -> isError=True", + "14:43:53.244 tools/call mutate_success {\"filename\":\"mcp_c2.txt\"} -> isError=False" + ], + "d10a_finding": "At PreToolUse(B), adapter bookkeeping would still hold A as active/pending_start with recovery_pending=false and NOTHING has armed the D13 barrier. No positive stale/terminal evidence for A exists. Combined with probe 16/17 (parallel MCP execution IS possible), PreToolUse(B) cannot prove A stale. This is D10a Case C for MCP.", + "disposition": "PROVEN (live) - no intermediate cleanup signal; successor PreToolUse does not prove predecessor stale." +} diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.failed.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.failed.pre_tool_use.json new file mode 100644 index 00000000..40c57f6f --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.failed.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c53-219c-75e3-89dc-4edd726c348e","turn_id":"01a07c53-2236-71c3-b867-d1edd2389dd0","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-43-38-01a07c53-219c-75e3-89dc-4edd726c348e.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe__mutate_then_error","tool_input":{"filename":"mcp_c1.txt"},"tool_use_id":"exec-27777ab1-ea7f-429f-a744-d7f72ee8c294"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.stop.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.stop.json new file mode 100644 index 00000000..05740de8 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.stop.json @@ -0,0 +1 @@ +{"session_id":"01a07c53-219c-75e3-89dc-4edd726c348e","turn_id":"01a07c53-2236-71c3-b867-d1edd2389dd0","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-43-38-01a07c53-219c-75e3-89dc-4edd726c348e.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"Stop","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","stop_hook_active":false,"last_assistant_message":"Completed both MCP calls exactly once, in the requested order."} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.successor.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.successor.post_tool_use.json new file mode 100644 index 00000000..d02c03f3 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.successor.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c53-219c-75e3-89dc-4edd726c348e","turn_id":"01a07c53-2236-71c3-b867-d1edd2389dd0","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-43-38-01a07c53-219c-75e3-89dc-4edd726c348e.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe__mutate_success","tool_input":{"filename":"mcp_c2.txt"},"tool_response":{"content":[{"type":"text","text":"wrote mcp_c2.txt"}],"isError":false},"tool_use_id":"exec-1643344b-f335-4506-ba06-a2326977cba2"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.successor.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.successor.pre_tool_use.json new file mode 100644 index 00000000..15ea806e --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe14-mcp-failed-then-successor.successor.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c53-219c-75e3-89dc-4edd726c348e","turn_id":"01a07c53-2236-71c3-b867-d1edd2389dd0","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-43-38-01a07c53-219c-75e3-89dc-4edd726c348e.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe__mutate_success","tool_input":{"filename":"mcp_c2.txt"},"tool_use_id":"exec-1643344b-f335-4506-ba06-a2326977cba2"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.evidence.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.evidence.json new file mode 100644 index 00000000..6f5e2154 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.evidence.json @@ -0,0 +1,21 @@ +{ + "_comment": "SYNTHESISED capture metadata, NOT a raw hook payload. Probe C-block. A second PreToolUse hook returns hookSpecificOutput.permissionDecision:deny for an MCP tool call.", + "codex_version": "codex-cli 0.153.4", + "model": "gpt-5.6-sol", + "tool": "mcp__probe__mutate_success", + "tool_use_id": "exec-58634f15-7c81-4069-9bda-c036cf871be9", + "block_response": "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"blocked by MCP probe\"}}", + "event_ordering": [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse(mcp__probe__mutate_success) 14:44:11.672Z <- BLOCKED", + "Stop 14:44:16.857Z", + "SessionEnd 14:44:16.889Z" + ], + "post_tool_use_fired": false, + "mcp_server_log": "(empty - tools/call was never issued; the MCP server received only initialize + tools/list)", + "git_status_after": ["?? .mcp-probe-server.log"], + "mutation_occurred": false, + "finding": "A hook-blocked MCP PreToolUse behaves exactly like a hook-blocked built-in tool (probes 3/4): PreToolUse only, NO PostToolUse, NO tool execution, NO mutation. A D8 fail-closed deny on a mutation-capable MCP PreToolUse therefore leaves no scope needing a terminal action - identical to the built-in case.", + "disposition": "PROVEN (live)." +} diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.pre_tool_use.json new file mode 100644 index 00000000..8e0720e0 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c53-6e67-7671-ab97-1f705194579a","turn_id":"01a07c53-6f12-7b51-8e42-3f96fd5c4940","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-43-57-01a07c53-6e67-7671-ab97-1f705194579a.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe__mutate_success","tool_input":{"content":"PLEASE_BLOCK_THIS_MCP_CALL","filename":"mcp_blocked.txt"},"tool_use_id":"exec-58634f15-7c81-4069-9bda-c036cf871be9"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.stop.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.stop.json new file mode 100644 index 00000000..5b0386c9 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe15-mcp-blocked-call.stop.json @@ -0,0 +1 @@ +{"session_id":"01a07c53-6e67-7671-ab97-1f705194579a","turn_id":"01a07c53-6f12-7b51-8e42-3f96fd5c4940","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-43-57-01a07c53-6e67-7671-ab97-1f705194579a.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"Stop","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","stop_hook_active":false,"last_assistant_message":null} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.a.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.a.post_tool_use.json new file mode 100644 index 00000000..a4a6b86b --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.a.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c53-c0cd-7703-944c-717ab3ee5c93","turn_id":"01a07c53-c161-7af2-9ecd-1c9c6701648e","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-44-18-01a07c53-c0cd-7703-944c-717ab3ee5c93.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe_par__slow_mutate","tool_input":{"tag":"d1"},"tool_response":{"content":[{"type":"text","text":"slow_mutate d1 done"}],"isError":false},"tool_use_id":"exec-da35da84-b51a-4b8b-b2f0-1fb8f969782e"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.a.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.a.pre_tool_use.json new file mode 100644 index 00000000..e0fbd8e8 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.a.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c53-c0cd-7703-944c-717ab3ee5c93","turn_id":"01a07c53-c161-7af2-9ecd-1c9c6701648e","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-44-18-01a07c53-c0cd-7703-944c-717ab3ee5c93.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe_par__slow_mutate","tool_input":{"tag":"d1"},"tool_use_id":"exec-da35da84-b51a-4b8b-b2f0-1fb8f969782e"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.b.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.b.post_tool_use.json new file mode 100644 index 00000000..cb4815e4 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.b.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c53-c0cd-7703-944c-717ab3ee5c93","turn_id":"01a07c53-c161-7af2-9ecd-1c9c6701648e","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-44-18-01a07c53-c0cd-7703-944c-717ab3ee5c93.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe_par__slow_mutate","tool_input":{"tag":"d2"},"tool_response":{"content":[{"type":"text","text":"slow_mutate d2 done"}],"isError":false},"tool_use_id":"exec-46673c08-63a3-4eb7-a3c7-dd016edb8212"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.b.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.b.pre_tool_use.json new file mode 100644 index 00000000..f0197ac3 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.b.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c53-c0cd-7703-944c-717ab3ee5c93","turn_id":"01a07c53-c161-7af2-9ecd-1c9c6701648e","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-44-18-01a07c53-c0cd-7703-944c-717ab3ee5c93.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe_par__slow_mutate","tool_input":{"tag":"d2"},"tool_use_id":"exec-46673c08-63a3-4eb7-a3c7-dd016edb8212"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.evidence.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.evidence.json new file mode 100644 index 00000000..d5663956 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe16-mcp-parallel-server-optin.evidence.json @@ -0,0 +1,24 @@ +{ + "_comment": "SYNTHESISED capture metadata, NOT a raw hook payload. Probe D. Two mutation-capable MCP tool executions running genuinely concurrently, enabled by the upstream-supported `[mcp_servers.] supports_parallel_tool_calls = true` config key.", + "codex_version": "codex-cli 0.153.4", + "model": "gpt-5.6-sol", + "mcp_server": "probe_par (server.py, tool slow_mutate); config.toml carried `supports_parallel_tool_calls = true`", + "A_tool_use_id": "exec-da35da84-b51a-4b8b-b2f0-1fb8f969782e", + "B_tool_use_id": "exec-46673c08-63a3-4eb7-a3c7-dd016edb8212", + "hook_event_timeline_utc": [ + "PreToolUse(A) 14:44:32.418731", + "PreToolUse(B) 14:44:32.419862 <- B starts 1.1ms after A, BEFORE either PostToolUse", + "PostToolUse(A) 14:44:40.443056", + "PostToolUse(B) 14:44:40.450226" + ], + "both_scopes_live_window": "14:44:32.419 -> 14:44:40.443 (~8.0s with A and B both between their PreToolUse and PostToolUse)", + "mcp_server_execution_timeline_utc": [ + "14:44:32.431 slow_mutate[d1] begin (wrote slow_d1_begin.txt), sleeping 8s", + "14:44:32.439 slow_mutate[d2] begin (wrote slow_d2_begin.txt), sleeping 8s <- d2 begins while d1 is still sleeping", + "14:44:40.431 slow_mutate[d1] end (wrote slow_d1_end.txt)", + "14:44:40.440 slow_mutate[d2] end (wrote slow_d2_end.txt)" + ], + "git_status_after": ["?? slow_d1_begin.txt", "?? slow_d1_end.txt", "?? slow_d2_begin.txt", "?? slow_d2_end.txt", "?? .mcp-probe-server.log"], + "upstream_source": "codex-rs/core/src/tools/handlers/mcp.rs:128-139 (McpHandler::supports_parallel_tool_calls) at rust-v0.153.4: returns `self.tool_info.supports_parallel_tool_calls || annotations.read_only_hint`. tool_info.supports_parallel_tool_calls is populated from McpServerMetadata (codex-rs/codex-mcp/src/server.rs:395-421) which reads RawMcpServerConfig.supports_parallel_tool_calls (codex-rs/config/src/mcp_types.rs:362) - a config.toml key.", + "disposition": "PROVEN (live) - two mutation-capable MCP executions overlap; Codex-alone AiContended is reachable via MCP on 0.153.4." +} diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.a.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.a.post_tool_use.json new file mode 100644 index 00000000..7af943f0 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.a.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c54-247f-7271-b199-963820f49a4f","turn_id":"01a07c54-250f-7b40-8e96-59b101da778c","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-44-44-01a07c54-247f-7271-b199-963820f49a4f.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe__read_only_liar","tool_input":{"tag":"r1"},"tool_response":{"content":[{"type":"text","text":"read_only_liar r1 done"}],"isError":false},"tool_use_id":"exec-89684f86-307f-473c-b3f5-d05c50041474"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.a.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.a.pre_tool_use.json new file mode 100644 index 00000000..a85d4f58 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.a.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c54-247f-7271-b199-963820f49a4f","turn_id":"01a07c54-250f-7b40-8e96-59b101da778c","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-44-44-01a07c54-247f-7271-b199-963820f49a4f.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe__read_only_liar","tool_input":{"tag":"r1"},"tool_use_id":"exec-89684f86-307f-473c-b3f5-d05c50041474"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.b.post_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.b.post_tool_use.json new file mode 100644 index 00000000..bd7e3978 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.b.post_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c54-247f-7271-b199-963820f49a4f","turn_id":"01a07c54-250f-7b40-8e96-59b101da778c","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-44-44-01a07c54-247f-7271-b199-963820f49a4f.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PostToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe__read_only_liar","tool_input":{"tag":"r2"},"tool_response":{"content":[{"type":"text","text":"read_only_liar r2 done"}],"isError":false},"tool_use_id":"exec-0c61904e-31c6-4715-b87e-40750d353ecb"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.b.pre_tool_use.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.b.pre_tool_use.json new file mode 100644 index 00000000..c0183612 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.b.pre_tool_use.json @@ -0,0 +1 @@ +{"session_id":"01a07c54-247f-7271-b199-963820f49a4f","turn_id":"01a07c54-250f-7b40-8e96-59b101da778c","transcript_path":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/codex-home/sessions/2026/09/07/rollout-2026-09-07T16-44-44-01a07c54-247f-7271-b199-963820f49a4f.jsonl","cwd":"/tmp/nix-shell.mT8dci/nix-shell.5LxIeg/claude-1000/-home-davidabram-repos-shared-context-engineering/1a1a4a51-f191-4d9c-b7e7-525eddaf5e5f/scratchpad/mcp-probe-work/probe-repo","hook_event_name":"PreToolUse","model":"gpt-5.6-sol","permission_mode":"bypassPermissions","tool_name":"mcp__probe__read_only_liar","tool_input":{"tag":"r2"},"tool_use_id":"exec-0c61904e-31c6-4715-b87e-40750d353ecb"} \ No newline at end of file diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.evidence.json b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.evidence.json new file mode 100644 index 00000000..ee48612a --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/probe17-mcp-parallel-readonly-hint.evidence.json @@ -0,0 +1,23 @@ +{ + "_comment": "SYNTHESISED capture metadata, NOT a raw hook payload. Probe D variant. Parallel MCP execution reached via the OTHER branch of McpHandler::supports_parallel_tool_calls() - the tool's own annotations.read_only_hint - with NO server-level opt-in. The tool still performs a git-visible mutation ('read_only_liar').", + "codex_version": "codex-cli 0.153.4", + "model": "gpt-5.6-sol", + "mcp_server": "probe (server.py, tool read_only_liar; annotations.readOnlyHint = true; NO supports_parallel_tool_calls on the server)", + "A_tool_use_id": "exec-89684f86-307f-473c-b3f5-d05c50041474", + "B_tool_use_id": "exec-0c61904e-31c6-4715-b87e-40750d353ecb", + "hook_event_timeline_utc": [ + "PreToolUse(A) 14:44:57.996966", + "PreToolUse(B) 14:44:57.998020 <- B starts ~1ms after A, BEFORE either PostToolUse", + "PostToolUse(A) 14:45:06.019858", + "PostToolUse(B) 14:45:06.029715" + ], + "mcp_server_execution_timeline_utc": [ + "14:44:58.008 read_only_liar[r1] begin (wrote liar_r1_begin.txt), sleeping 8s", + "14:44:58.019 read_only_liar[r2] begin (wrote liar_r2_begin.txt), sleeping 8s <- r2 begins while r1 is still sleeping", + "14:45:06.008 read_only_liar[r1] end (wrote liar_r1_end.txt)", + "14:45:06.019 read_only_liar[r2] end (wrote liar_r2_end.txt)" + ], + "git_status_after": ["?? liar_r1_begin.txt", "?? liar_r1_end.txt", "?? liar_r2_begin.txt", "?? liar_r2_end.txt", "?? .mcp-probe-server.log"], + "finding": "A tool an adapter would classify mutation-capable (unknown/MCP -> mutation-capable) can be marked parallel-eligible purely by the MCP server's own `readOnlyHint` annotation, which SCE cannot trust and does not inspect. Concurrency of a mutation-capable MCP execution is therefore reachable even without a Codex-side or config-side opt-in.", + "disposition": "PROVEN (live)." +} diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 2e876fd4..08956fbd 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -20,10 +20,20 @@ Codex raw hook event -> mutation cursor ``` -The fundamental mapping is **one independently mutation-capable Codex tool -execution = one SCE mutation `ScopeId`**. A Codex session, turn, or delegated -agent is never a scope; `session_id` / `turn_id` / any delegated-agent identity -are only inputs that distinguish tool executions. +The fundamental mapping is **one independently mutation-capable *tracked* Codex +tool execution = one SCE mutation `ScopeId`**. "Tracked" is load-bearing: the +Codex adapter v1 gives a mutation scope only to tool classes whose terminal +lifecycle it can safely observe (`Bash`, `apply_patch`). A Codex session, turn, +or delegated agent is never a scope; `session_id` / `turn_id` / any +delegated-agent identity are only inputs that distinguish tool executions. + +**MCP calls and unknown/future Codex tools are deliberately *outside* Codex +mutation-scope attribution coverage in v1** (re-planning direction B, chosen +2026-09-08 — see D23 and Open questions). They execute normally; they may mutate; +the adapter creates no mutation scope, no bookkeeping, and no `Start` for them. +This is an attribution-coverage boundary, not a claim that MCP is read-only and +not a lifecycle workaround — the T01 evidence that MCP *cannot* be safely modeled +as a scope on Codex 0.153.4 (D10a Case C) is exactly why it is excluded. This extends the mutation-scope stack. The generic ingress, the runtime (`coordinate()` / `abandon_scope()`), and the `mutation-trace` protocol already @@ -61,6 +71,14 @@ contract, T01 stops and records the contradiction in this plan's Open questions rather than modifying the protocol; any protocol change becomes a separate, explicitly justified PR. +**T01 outcome (recorded 2026-09-08):** the built-in `Bash` / `apply_patch` +surface *is* representable by the current contract; the MCP mutation-scope +lifecycle *is not* (D10a Case C). Re-planning chose **direction B** — MCP and +unknown tools remain usable but are outside the Codex adapter's mutation-scope +coverage. This requires **no** protocol / Quint / mutation-trace SQL / Agent +Trace schema change (D23); it narrows the Codex adapter's coverage boundary +only. T01 is done; T02 is unblocked. + ## Stack and base - **Predecessor:** PR #263 `claude-mutation-scope-integration` @@ -86,59 +104,196 @@ marked **T01-GATED** and carry no committed mapping until T01 records a disposition (`PROVEN`, `DOCUMENTED — NON-LOAD-BEARING`, `ASSUMPTION — PROBE`, or `UNSUPPORTED`). T01 writes each disposition back into this Design section. -### D1 — Scope = one independently mutation-capable Codex tool execution - -A mutation scope is exactly one independently mutation-capable Codex tool -execution attempt. Not a session, not a turn, not a delegated agent. Sequential -tool calls are sequential scopes. Whether two Codex mutation-capable executions -can genuinely overlap (and therefore whether Codex alone can produce -`AiContended`) is **T01-GATED** — Codex has historically executed tools -serially within a turn. `AiContended` can still arise from a Codex scope -overlapping another harness's scope on the same worktree regardless of T01's -finding; see D14. - -**T01 disposition (codex-cli 0.153.4): ASSUMPTION — PROBE (leaning serial).** -Codex executed every mutation-capable tool strictly serially in all 11 probes -(`PreToolUse → PostToolUse → PreToolUse → …`, never interleaved), including -across the parent/subagent boundary and when asked to parallelise. Codex-alone -`AiContended` is treated as not reachable for 0.153.4; the concurrency -regression must cross harnesses (D14). The adapter still never collapses two -executions into one `ScopeId`. Evidence: `fixtures/probe01…`, `probe02…`, -`probe08-subagent-delegation.*` + `fixtures/NOTES.md`. - -### D2 — Codex tool classification — T01-GATED - -The adapter classifies the raw Codex `tool_name` in Rust into: - -- **Mutation-capable (establishes a scope):** at minimum `apply_patch` and the - Codex shell/`Bash` tool. MCP tools and any **unknown** tool name are treated - conservatively as mutation-capable (an unknown read-only tool only creates - harmless scopes; the opposite default silently misses a new mutation-capable - tool). -- **Read-only (never a scope):** the Codex file-read / list / search / web - tools T01 enumerates. -- **Delegation (never a scope):** whatever tool Codex uses to spawn a delegated - agent, if any — the delegated agent's own mutation-capable tool calls - establish their own scopes. - -T01 must enumerate Codex's actual tool-name vocabulary (`tool_name` values on -`PreToolUse`/`PostToolUse`, MCP tool naming, delegation tool name). The exact -membership of each list is frozen by T01 and recorded here. +### D1 — Scope = one independently mutation-capable *tracked* Codex tool execution + +**"Capable of mutating" and "covered by SCE mutation-scope attribution" are two +different things for the Codex adapter.** A Codex tool execution can be +independently capable of mutating the checkout and still not be represented as an +SCE mutation scope, if the adapter cannot safely observe that tool class's +terminal lifecycle. + +For the Codex adapter v1: + +```text +one independently mutation-capable SUPPORTED / TRACKED Codex tool execution += +one SCE mutation ScopeId +``` + +Concretely: + +```text +Bash -> scope (TrackedMutation) +apply_patch -> scope (TrackedMutation) +MCP (mcp__*) -> no scope (Untracked — still executes, may mutate) +unknown tool -> no scope (Untracked — still executes, may mutate) +``` + +A scope is exactly one such tracked execution attempt. Not a session, not a +turn, not a delegated agent. Sequential tracked tool calls are sequential +scopes. Whether two Codex mutation-capable executions can genuinely overlap +(and therefore whether Codex alone can produce `AiContended`) is answered by +T01 below. `AiContended` can still arise from a Codex *tracked* scope +overlapping another harness's scope on the same worktree regardless; see D14. + +This is a **Codex adapter coverage policy**. It does **not** broaden or narrow +the generic mutation-scope runtime contract, which already models exclusivity +among the tracked scopes it is told about, not exhaustive filesystem authorship +(D14, D23). + +**T01 disposition (codex-cli 0.153.4): scope-split by tool type.** +- **Built-in `Bash` / `apply_patch`: ASSUMPTION — PROBE (leaning serial).** + Codex executed every built-in mutation-capable tool strictly serially in all + 11 built-in probes (`PreToolUse → PostToolUse → PreToolUse → …`, never + interleaved), including across the parent/subagent boundary and when asked to + parallelise. +- **MCP: PROVEN — parallel-capable, live-reproduced.** The T01 MCP extension + (probes 16/17) shows two mutation-capable MCP tool executions running + **genuinely concurrently** on 0.153.4 — `PreToolUse(A)` and `PreToolUse(B)` + ~1 ms apart, both before either `PostToolUse`, both scopes live for ~8 s, + confirmed by the MCP server's own execution log. Enabled by the + upstream-supported `[mcp_servers.] supports_parallel_tool_calls = true` + config key **or** the tool's own `annotations.readOnlyHint` + (`McpHandler::supports_parallel_tool_calls()`, + `codex-rs/core/src/tools/handlers/mcp.rs:128-139` at `rust-v0.153.4`). +- **Parallel MCP execution is a real operational fact and remains supported** — + the adapter never blocks it. But because MCP tools are **Untracked** in Codex + adapter v1 (D2, D23), two overlapping MCP executions produce **no MCP mutation + scopes**, and therefore **no MCP-derived `AiContended`**. A tracked `Bash` / + `apply_patch` scope overlapping an MCP execution may still yield + `AiExclusive(Bash)` from the runtime — this means "exactly one *tracked* + mutation scope was live", not "that scope authored every filesystem mutation + in the interval" (D14). +- **Codex-alone `AiContended` from built-in tools only:** built-in + `Bash` / `apply_patch` executed strictly serially across all 11 built-in + probes, so built-in Codex-alone overlap was never observed. Cross-harness + `AiContended` (a Codex tracked scope overlapping a Claude/OpenCode/Pi scope) + remains reachable regardless; see D14. +The adapter still never collapses two executions into one `ScopeId`. Evidence: +`fixtures/probe01…`, `probe02…`, `probe08-subagent-delegation.*`, +`probe16-mcp-parallel-server-optin.*`, `probe17-mcp-parallel-readonly-hint.*` + +`fixtures/NOTES.md` ("T01 MCP lifecycle probe extension"). + +**v1 resolution (D23):** MCP is not modeled as a mutation scope, so D1's +"one execution = one `ScopeId`" rule simply does not range over MCP or unknown +tools. No contradiction with the parallel-MCP evidence remains, because the +adapter creates nothing for those executions. + +### D2 — Codex tool classification — three semantic classes + +The adapter classifies the raw Codex `tool_name` in Rust by **semantic intent**, +not by mutability alone. Terminology equivalent to: + +```rust +enum ToolClassification { + TrackedMutation, + Delegation, + Untracked, +} +``` + +(exact Rust naming may still be adjusted in T02). + +#### TrackedMutation + +```text +tool executes ++ adapter can safely observe its terminal lifecycle ++ adapter creates a mutation scope +``` + +v1 members: `Bash`, `apply_patch`. Each independently mutation-capable tracked +execution establishes exactly one `ScopeId` (D1/D4). Fail-closed on `PreToolUse` +(D8), terminal boundary on the proven terminal hook (D9/D10). + +#### Delegation + +```text +the delegation tool itself does not receive a mutation scope; +the delegated agent's own TrackedMutation tools do (carrying its agent_id). +``` + +v1 members: `collaborationspawn_agent`, `collaborationwait_agent` (a +`collaboration` namespace prefix, no separator). `PreToolUse` for a delegation +tool returns the neutral response, no scope. + +#### Untracked + +```text +tool executes normally ++ may mutate the checkout ++ adapter creates NO mutation scope ++ mutations are OUTSIDE SCE mutation-scope coverage (D23) +``` + +v1 members: `mcp__*` (any `mcp____`), and **any unknown / +unrecognised `tool_name`**. + +`Untracked` does **not** mean "read-only". It means exactly: + +```text +allowed to execute + does not participate in SCE mutation-scope accounting +``` + +MCP tools may mutate. Unknown tools may mutate. The adapter neither asserts they +are read-only nor guarantees their mutations are detected immediately — it simply +does not attribute them (D23). The classifier must **not** carry the earlier +language saying MCP/unknown are "mutation-capable therefore `Start`" — that +classification is precisely what produced the D10a Case C contradiction. + +For `PreToolUse(mcp__…)` (and any unknown tool) the adapter conceptually does: + +```text +classify as Untracked + -> return Codex-neutral continue response + -> no ScopeId, no EventId + -> no adapter attempt, no Start, no recovery_pending + -> no bookkeeping entry of any kind +``` + +Therefore successful, failed, interrupted, or parallel MCP/unknown executions +require no `Close`, `Abandon`, or `Flush` from the adapter — there is nothing to +retire. Existing normal Codex/SCE hooks unrelated to mutation-scope +(conversation tracing, `diff_traces`, policy) are unchanged and still run for +MCP calls. **T01 disposition (codex-cli 0.153.4): PROVEN for the `codex exec` surface.** -- **Mutation-capable:** `apply_patch`, `Bash` (the shell tool — it also performs +- **TrackedMutation:** `apply_patch`, `Bash` (the shell tool — it also performs reads / listing / search via shell commands, so it is always treated mutation-capable; a read-only shell command merely creates a harmless scope). - MCP tools and any unknown `tool_name` → conservatively mutation-capable - (from upstream; no MCP server was configured live). -- **Read-only (never a scope):** none — this Codex surface has **no dedicated - built-in read-only tool names**; reads go through `Bash`. -- **Delegation (never a scope):** `collaborationspawn_agent`, - `collaborationwait_agent` (a `collaboration` namespace prefix, no separator). - The delegated agent's own tool calls carry its `agent_id` and establish their - own scopes. +- **Delegation:** `collaborationspawn_agent`, `collaborationwait_agent`. The + delegated agent's own tool calls carry its `agent_id` and establish their own + (tracked) scopes. +- **Untracked:** `mcp____` (T01 MCP extension, probes 12–17: + `mcp__probe__mutate_success`, `mcp__probe_par__slow_mutate`), and every unknown + `tool_name`. MCP `tool_use_id` is `exec-` — the same shape as + shell / `apply_patch` — but the adapter never keys any state on it. Upstream: + `join_tool_name` / `MCP_TOOL_NAME_DELIMITER` / `ensure_mcp_prefix`, + `codex-rs/core/src/tools/handlers/mcp.rs` at `rust-v0.153.4`. +- **Dedicated read-only tool names:** none — this Codex surface routes reads + through `Bash`. There is no separate "read-only, never a scope" class in v1; + the only never-a-scope classes are `Delegation` and `Untracked`. + +**Why MCP and unknown are `Untracked` (not `TrackedMutation`):** T01 proved that +if MCP were modeled as a scope, Codex 0.153.4's lifecycle makes it unsafe — a +mutation-capable MCP tool can mutate a git-visible file then return +`is_error:true` with **no terminal hook** (probe 13), a successor `PreToolUse` +can follow with **no cleanup signal between them** (probe 14), and same-lane MCP +executions **genuinely overlap** (probes 16/17), so a successor cannot prove a +predecessor stale. That is D10a Case C. Rather than ship an unsafe scope +lifecycle, v1 does not create scopes for MCP at all (D10a, D23). Unknown tools +get the same compatibility-oriented default so a future Codex tool never becomes +unusable merely because SCE does not yet know its lifecycle; support is additive: + +```text +new_tool: Untracked --(lifecycle researched / proven)--> TrackedMutation +``` + +without any protocol change. Evidence: `fixtures/probe05-tool-vocabulary.*`, `probe08-subagent-delegation.*`, -`fixtures/NOTES.md`. +`fixtures/probe12-mcp-mutate-success.*`, `probe13-mcp-mutate-then-error.*`, +`probe14-mcp-failed-then-successor.*`, `probe16-mcp-parallel-server-optin.*`, +`probe17-mcp-parallel-readonly-hint.*`, `fixtures/NOTES.md`. ### D3 — Codex execution identity — T01-GATED @@ -159,7 +314,9 @@ execution identity appears in both the pre and post events for one tool call, terminal. T02 then freezes the execution key. **T01 disposition (codex-cli 0.153.4): PROVEN.** Execution key = -`(session_id, agent_id?, tool_use_id)`. +`(session_id, agent_id?, tool_use_id)` — **for `TrackedMutation` tools only**. +`Untracked` (MCP, unknown) and `Delegation` tools get no execution key because +the adapter records no attempt for them (D2/D23). - `tool_use_id` is present on **both** `PreToolUse` and `PostToolUse` and is identical for one call (`exec-` for shell / `apply_patch`, `call_` for the delegation tools). Not observed to recur (UUID-based); the @@ -245,15 +402,35 @@ parse event -> resolve raw cwd -> resolve git_dir (bookkeeping only) -> return (Codex-native "continue" — see D8) ``` -A mutation-capable tool must not execute after SCE has failed to establish its -mutation scope. +A `TrackedMutation` tool must not execute after SCE has failed to establish its +mutation scope. (`Untracked` and `Delegation` tools never reach this path — no +`Start` is attempted for them; D2/D8.) ### D8 — Codex-native fail-closed PreToolUse — T01-GATED -A mutation-capable Codex `PreToolUse` is **fail-closed**: any failure to durably +D8 fail-closed behaviour applies **only** when SCE is trying to establish a +**`TrackedMutation`** scope: + +```text +TrackedMutation PreToolUse + -> failure to establish a durable Start -> deny (block the tool) + +Untracked PreToolUse (mcp__*, unknown) + -> neutral continue + -> never attempts Start, so there is nothing to fail closed on + -> never denied merely because it is untracked + +Delegation PreToolUse + -> neutral continue, no scope +``` + +A `TrackedMutation` Codex `PreToolUse` is **fail-closed**: any failure to durably establish the scope (state-allocation failure, seam `Start` failure, unresolvable `cwd`, recovery-barrier denial) must **block the tool**, not let it -run un-scoped. +run un-scoped. Do **not** deny MCP. Do **not** deny an unknown tool. The adapter +also never emits an explicit **allow** for an `Untracked` tool — it returns the +normal neutral / no-op hook result and lets Codex's own permission handling +proceed unchanged. The exact Codex-native denial response and exit semantics are **T01-GATED**. Do **not** assume Claude's `{"hookSpecificOutput":{...,"permissionDecision": @@ -271,7 +448,8 @@ that bypasses Codex's own permission system — success returns Codex's neutral "continue" (empty stdout, or whatever T01 shows is the no-op response), and only failure returns the block. -A read-only or delegation `PreToolUse` returns the neutral response, no scope. +An `Untracked` or `Delegation` `PreToolUse` returns the neutral response, no +scope, no bookkeeping. **T01 disposition (codex-cli 0.153.4): PROVEN.** **Both** denial shapes block the tool on 0.153.4 and both appear in the generated @@ -283,13 +461,24 @@ top-level `{"decision":"block","reason":"…"}` (`decision` enum `approve|block` `hookSpecificOutput` shape, matching the existing `sce hooks codex` `PreToolUse(Bash)` policy arm. A blocked tool fires `PreToolUse` only — **no `PostToolUse`** — so a fail-closed denial leaves no scope needing a -terminal action. Evidence: +terminal action. This holds for a **blocked MCP call** too (probe 15: +`permissionDecision:"deny"` on `mcp__probe__mutate_success` → `PreToolUse` only, +no `PostToolUse`, `tools/call` never issued, nothing written). **v1 note:** +because re-planning chose direction B (D23), the adapter does **not** deny MCP — +probe 15 stays relevant only as evidence that a blocked `PreToolUse` (from any +source) strands no scope, and as the shape a rejected direction (A) would have +used. Evidence: `fixtures/probe03-pre-tool-use-hook-decision-block.*`, -`fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.*`. +`fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.*`, +`fixtures/probe15-mcp-blocked-call.*`. ### D9 — Terminal boundary on success — T01-GATED -For a successful mutation-capable tool with an `active` tracked attempt, the +Terminal boundary rules apply **only to `TrackedMutation` tools** (`Bash`, +`apply_patch`). MCP and unknown tools have no scope, so they have no terminal +boundary and no `Close`. + +For a successful `TrackedMutation` tool with an `active` tracked attempt, the terminal Codex hook maps to `{ "operation":"close", "scope_id":, "event_id":|close, "actor_kind":"codex" }`. T01 must confirm **which** hook is the reliable @@ -299,10 +488,15 @@ attempt is removed from adapter state only after durable `Close` success; duplicate delivery after cleanup is a safe no-op. **T01 disposition (codex-cli 0.153.4): PROVEN.** `PostToolUse` is the reliable -terminal signal for a successful mutation-capable tool, carrying the same -`tool_use_id` (and `agent_id`, for a subagent) as its `PreToolUse`. Evidence: +terminal signal for a **successful `TrackedMutation`** tool, carrying the same +`tool_use_id` (and `agent_id`, for a subagent) as its `PreToolUse`. A +**successful MCP call** also emits `PostToolUse` (probe 12), but **the adapter +ignores it** — no scope was created, so there is nothing to close. The MCP +lifecycle research (probes 12–17) is retained as the *reason MCP is excluded* +(D2/D10a/D23), not as an MCP terminal-boundary mapping. Evidence: `fixtures/probe01-apply-patch-and-shell-success.*`, -`fixtures/probe08-subagent-delegation.agent-apply-patch.*`. +`fixtures/probe08-subagent-delegation.agent-apply-patch.*`, +`fixtures/probe12-mcp-mutate-success.*`. ### D10 — Failed-tool terminal observation — T01-GATED, likely no reliable signal @@ -325,29 +519,54 @@ failed mutation-capable tool (a `PostToolUse` with a failure-indicating `tool_response`, or another event carrying the D3 identity), D10 becomes a `Close` mapping like D9. T01 records which. -**T01 disposition (codex-cli 0.153.4): PROVEN — D10 becomes a `Close` mapping -like D9, and is cleaner than feared.** -- A **shell (`Bash`)** tool that wrote a file then exited non-zero **does** fire - `PostToolUse` (same `tool_use_id`), so a partial mutation from a failed shell - command is bounded by a terminal hook → map to `close` exactly like D9. -- An **`apply_patch`** that fails verification fires **no** `PostToolUse`, but - Codex verifies the patch before touching the working tree, so a failed - `apply_patch` writes nothing — there is no partial-mutation-without-terminal - case for it. -- Prior SCE research's "`PostToolUse` fires only on a successful tool result" is - true at the `success_for_logging()` layer (`codex-rs/core/src/tools/registry.rs` - ~line 674 at `rust-v0.153.4`), but an executed shell command with a non-zero - exit still counts as a successful tool result. -The adapter needs **no Close-on-failure path**. Evidence: -`fixtures/probe02-shell-partial-write-then-nonzero-exit.*` (PostToolUse fires), -`fixtures/probe06-apply-patch-verification-failure-no-post.*` (no PostToolUse, -no write). +**T01 disposition (codex-cli 0.153.4): scope this conclusion to the tool types +actually proven — it does NOT hold globally.** +- **`Bash`: PROVEN — bounded by a terminal hook.** A shell tool that wrote a file + then exited non-zero **does** fire `PostToolUse` (same `tool_use_id`) → map to + `close` exactly like D9 (probe 2). +- **`apply_patch`: PROVEN — verification failure, no mutation.** Fires **no** + `PostToolUse`, but Codex verifies the patch before touching the working tree, + so nothing is written — there is no partial-mutation-without-terminal case for + it (probe 6). +- **MCP: PROVEN — partial mutation with NO terminal hook.** A mutation-capable + MCP tool that **writes a git-visible file and then returns `is_error:true`** + receives **no terminal hook of any kind** (probe 13: `PreToolUse → Stop → + SessionEnd`); `git status` afterwards shows the file. An external MCP server is + not under Codex's atomicity control, so the side effect precedes the failure. + Upstream mechanism: `codex-rs/core/src/tools/registry.rs` ~line 674 — + `post_tool_use_payload = if success { … } else { None }` with + `success = result.success_for_logging()`; for MCP + `McpToolOutput::success_for_logging()` = `self.result.success()` + (`codex-rs/core/src/tools/context.rs:122-124`), false when + `CallToolResult.is_error == true`. + **v1 consequence (D23):** since MCP is `Untracked`, **no scope exists**, so + whether `PostToolUse` fires or not is **irrelevant to scope cleanup** — there + is no `pending_start`/`active` attempt to strand and nothing to `abandon` or + `flush`. This probe-13 lifecycle is exactly *why* MCP is excluded (D10a), not a + gap the adapter must bound. +- **unknown: no tool-specific terminal guarantee, and no scope** — an unknown + tool is `Untracked` (D2), so like MCP it has no scope and its `PostToolUse` + presence/absence is irrelevant to scope cleanup. +Prior SCE research's "`PostToolUse` fires only on a successful tool result" is +true at the `success_for_logging()` layer; a non-zero-exit shell command still +counts as a successful tool result, an `is_error:true` MCP result does not. The +adapter needs **no Close-on-failure path for `Bash` / `apply_patch`** (the shell +`PostToolUse` still fires; `apply_patch` verification failure writes nothing), +and **no failure path for MCP / unknown** because it creates no scope for them. +Evidence: `fixtures/probe02-shell-partial-write-then-nonzero-exit.*`, +`fixtures/probe06-apply-patch-verification-failure-no-post.*`, +`fixtures/probe13-mcp-mutate-then-error.*`. ### D10a — Failed-tool -> successor-tool in the same turn — T01-GATED +This decision applies only to **`TrackedMutation`** tools. `Untracked` tools +(MCP, unknown) never enter `attempts[]`, so a failed `Untracked` tool followed +by any successor creates no zombie scope — there is nothing in adapter state to +strand (see the MCP disposition below). + D10's "next lifecycle signal" is **not sufficient on its own** for the case where -a failed mutation-capable tool with no terminal hook is followed by **another -mutation-capable tool in the same turn**, before any `Stop` / `SessionEnd` / +a failed `TrackedMutation` tool with no terminal hook is followed by **another +`TrackedMutation` tool in the same turn**, before any `Stop` / `SessionEnd` / `UserPromptSubmit`: ```text @@ -411,27 +630,72 @@ T01 must define "same lane" using only identity/concurrency facts it actually established. T02 freezes the lane key and the successor-barrier design (if Case B); T04 implements it; T06 proves it. -**T01 disposition (codex-cli 0.153.4): PROVEN — Case A. The dangerous scenario -does not arise on 0.153.4, and no successor-barrier logic ships.** +**T01 disposition (codex-cli 0.153.4): scope-split by tool type. Case A for +built-ins; Case C for MCP *if MCP were modeled as a scope* — resolved in v1 by +NOT modeling MCP as a scope (D23, re-planning direction B).** + +**Built-in `Bash` / `apply_patch`: PROVEN — Case A.** The dangerous scenario does +not arise, and no successor-barrier logic ships for built-ins. - A failed **shell** tool always emits `PostToolUse` (terminal) before the next - `PreToolUse` — Codex runs mutation-capable tools strictly serially (D1), so + `PreToolUse` — Codex runs built-in mutation-capable tools serially (D1), so predecessor A is already terminal in bookkeeping when successor B's `PreToolUse` arrives. - A failed **`apply_patch`** never mutates the working tree (atomic verification), so there is nothing to strand. - A **hook-blocked** tool never executes (`PreToolUse` only, no `PostToolUse`) — no scope was established (D8 fail-closed happens before `start`). -- The only "partial mutation, no `PostToolUse`" case is **whole-turn - interruption** (SIGINT), which emits `Interrupt` and then `SessionEnd` and - ends the turn — there is no in-turn successor `PreToolUse` to race. -T02 records the D10a lane key as **N/A (Case A)**; T04 ships no successor -barrier; the D13 successor-Start invariant is still upheld trivially because a -terminal `PostToolUse` (or `Interrupt`/`SessionEnd`) always precedes the -successor. Evidence: -`fixtures/probe02-shell-partial-write-then-nonzero-exit.*`, -`fixtures/probe06-apply-patch-verification-failure-no-post.*`, -`fixtures/probe07-sigint-during-shell.*`, -`fixtures/probe11-interrupt-event-on-sigint.*`. +- The only built-in "partial mutation, no `PostToolUse`" case is **whole-turn + interruption** (SIGINT), which emits `Interrupt` then `SessionEnd` and ends the + turn — there is no in-turn successor `PreToolUse` to race. +Evidence: `fixtures/probe02-*`, `probe06-*`, `probe07-*`, `probe11-*`. + +**MCP: the T01 finding is correct and stands — `MCP is D10a Case C *if modeled +as a scope*`.** The T01 MCP extension establishes all three conditions of Case C +simultaneously, and this evidence is **not** weakened by the v1 resolution: +- **A mutation-capable MCP tool can mutate then fail with no terminal hook** + (probe 13: writes `mcp_b.txt`, returns `is_error:true`, then `PreToolUse → + Stop → SessionEnd` — no `PostToolUse`; the mutation survives). D10 above. +- **No positive cleanup signal appears before a successor.** Probe 14: + `PreToolUse(A = mutate_then_error)` is followed **directly** by + `PreToolUse(B = mutate_success)` with **no event of any kind between them** — + no `PostToolUse(A)`, no `Interrupt`/`Stop`/`SubagentStop`/`SessionEnd`/ + `PermissionRequest`/compaction. A's `tool_use_id` never recurs. +- **Same-lane MCP executions genuinely overlap** (probes 16/17), so + `PreToolUse(B)` does **not** prove A stale — there is no narrower serial lane + than `(session_id, turn_id)` and executions overlap within it. Case B is + unavailable. +If MCP were a scope, the adapter could not distinguish `failed-and-dead A` from +`still-running A`, and no safe successor barrier exists. **That lifecycle did not +become safe — the v1 resolution is to not put MCP in the scope model at all.** + +**v1 resolution (re-planning direction B, D23): the contradiction is resolved by +not modeling MCP executions as scopes.** `PreToolUse(mcp__…)` is classified +`Untracked` (D2): no `Start`, no bookkeeping. So the probe-13/14 sequence +becomes: + +```text +PreToolUse(A MCP) -> Untracked -> no Start, no attempt recorded +A mutates and fails -> no PostToolUse -> nothing in adapter state to strand +PreToolUse(B) -> normal classification + (if B is Bash/apply_patch it Starts on its own merits; + if B is MCP it is also Untracked) +``` + +No successor barrier is needed for MCP because **no MCP attempt exists in adapter +state** — the D10a tension (invariants (i)/(ii)) only arises for tracked scopes, +and there are none for MCP. This is a deliberate attribution-coverage boundary +(D23), not a lifecycle workaround: the adapter does **not** claim the MCP +lifecycle is safe, does **not** silently downgrade MCP/unknown to read-only, and +does **not** pretend an MCP mutation was attributed. + +T02 records the D10a lane key as **N/A** — Case A for built-ins ships no barrier; +MCP/unknown are `Untracked` and ship no barrier and no scope. Evidence: +`fixtures/probe13-mcp-mutate-then-error.*`, +`fixtures/probe14-mcp-failed-then-successor.*`, +`fixtures/probe15-mcp-blocked-call.*`, +`fixtures/probe16-mcp-parallel-server-optin.*`, +`fixtures/probe17-mcp-parallel-readonly-hint.*`, +`fixtures/NOTES.md` ("T01 MCP lifecycle probe extension"). ### D11 — Uncertain-boundary abandonment rules @@ -472,9 +736,10 @@ which single signal is the load-bearing backstop (the Claude adapter's backstop is `SessionEnd`). The failed-tool -> successor-tool sequence (D10a) is the one case where the -"next lifecycle signal" backstop is too late; its resolution (an intermediate -signal, a proven serial-lane successor barrier, or unsupported) is owned by -D10a, not this table. +"next lifecycle signal" backstop is too late **for a tracked tool**; for +built-ins it is Case A (a terminal `PostToolUse` always precedes the successor), +and for MCP/unknown it does not arise because they are `Untracked` (no scope, no +attempt). Owned by D10a, not this table. **T01 disposition (codex-cli 0.153.4): PROVEN.** @@ -496,14 +761,28 @@ Claude adapter's `SessionEnd`). Evidence: `session-end` / `stop` / `interrupt` / `subagent-stop` / `permission-request` schemas at `openai/codex` `rust-v0.153.4`. +**MCP caveat:** `Stop` and `SessionEnd` still fire at turn/session end for an +MCP-only turn (probes 13/14: `PreToolUse → Stop → SessionEnd`). This backstop is +**whole-turn-late** — it does not fire between a failed MCP tool and an in-turn +successor `PreToolUse` (probe 14 shows *nothing* there). That gap is precisely +why MCP cannot be safely modeled as a scope on 0.153.4 (D10a). In v1 there is +**no stranded failed-MCP attempt** for any signal to retire, because MCP is +`Untracked` and the adapter records no attempt for it (D2/D23). The D12 sweeps +operate only over adapter-owned tracked attempts. + ### D13 — recovery_pending barrier and quiescent Flush -Carried verbatim from the Claude adapter (D19 there). Whenever an abandonment or -an uncertain lifecycle sets `recovery_pending = true`: +Carried verbatim from the Claude adapter (D19 there). The recovery barrier +operates **only over adapter-owned tracked attempts** — `Untracked` (MCP, +unknown) executions never enter `attempts[]`, never set `recovery_pending`, and +never participate in the abandon/flush lifecycle. Whenever an abandonment or an +uncertain lifecycle of a **`TrackedMutation`** attempt sets +`recovery_pending = true`: ```text -recovery_pending == true AND known attempts still outstanding - -> deny every new mutation-capable PreToolUse (D8 fail-closed shape) +recovery_pending == true AND known (tracked) attempts still outstanding + -> deny every new TrackedMutation PreToolUse (D8 fail-closed shape) + -> an Untracked PreToolUse is NOT denied by the barrier (it never Starts) recovery_pending == true AND attempts.is_empty() -> run one { "operation":"flush" } through the generic ingress @@ -512,27 +791,61 @@ recovery_pending == true AND attempts.is_empty() ``` A failed abandonment leaves the attempt tracked and `recovery_pending = true` -(never silently allows a successor mutation execution). +(never silently allows a successor tracked-mutation execution). -**Successor-Start invariant.** A mutation-capable `PreToolUse` must never reach -its write-ahead `Start` while a known-stale predecessor attempt (D10a) remains -`active`/`pending_start` in bookkeeping. Where D10a Case A or Case B applies, the -predecessor is abandoned and — once quiescent — flushed before the successor's -`Start`. Where D10a Case C applies, the integration is unsupported and no -successor logic ships. This invariant does **not** license abandoning an attempt -that T01 proved can legitimately run concurrently with the successor (D10a -invariant (ii)); the sweep is always lane-scoped, never global. +**Successor-Start invariant.** A `TrackedMutation` `PreToolUse` must never reach +its write-ahead `Start` while a known-stale predecessor **tracked** attempt +(D10a) remains `active`/`pending_start` in bookkeeping. For built-ins this is +Case A (a terminal `PostToolUse` precedes the successor), so no successor-barrier +logic ships. MCP/unknown are `Untracked`, so they create no predecessor attempt +and no barrier is needed. This invariant does **not** license abandoning an +attempt that can legitimately run concurrently with the successor (D10a +invariant (ii)). ### D14 — Concurrency and AiContended -Two simultaneously-live scopes (whether two Codex executions per D1, or a Codex -execution overlapping a Claude/OpenCode/Pi execution on the same worktree) carry -distinct `ScopeId`s, so the runtime can report `AiContended` for a tree -transition observed while both are live. The adapter never collapses two -executions into one `ScopeId`. If T01 proves Codex cannot overlap its own -mutation executions, the regression for AiContended (T06) exercises a Codex -scope overlapping a second harness's scope instead, and this plan records that -Codex-alone `AiContended` is not reachable. +Two simultaneously-live **tracked** scopes (two tracked Codex executions per D1, +or a tracked Codex execution overlapping a Claude/OpenCode/Pi execution on the +same worktree) carry distinct `ScopeId`s, so the runtime can report `AiContended` +for a tree transition observed while both are live. The adapter never collapses +two executions into one `ScopeId`. + +**The generic mutation-scope semantic is preserved exactly and needs no +protocol or Quint change:** + +```text +AiExclusive(scope) == exactly one tracked mutation scope was live in the interval +``` + +It does **not** mean: + +```text +that scope authored every filesystem mutation in the interval +``` + +An MCP call, a human editor, or any other `Untracked` actor may mutate the +worktree during an `AiExclusive` interval. This is already the generic runtime +contract — the Codex adapter's `Untracked` policy does not change it and does not +require a protocol or Quint change. + +**T01 disposition (codex-cli 0.153.4):** +- **Built-in `Bash` / `apply_patch`: observed serial** (probes 1–11) — no + Codex-alone tracked-scope overlap observed. +- **MCP: parallel MCP execution is real** (probes 16/17: two MCP executions + overlap for ~8 s via `supports_parallel_tool_calls` or the tool's own + `annotations.readOnlyHint`). But MCP is `Untracked` in v1, so: + - `MCP + MCP` overlap -> no MCP mutation scopes -> **no MCP-derived + `AiContended`**; + - `Bash + MCP` overlap -> only `Bash` is a tracked scope -> the runtime may + still report `AiExclusive(Bash)`, which per the semantic above means "the + only *tracked* scope live", **not** "MCP did not mutate". T06 documents this + explicitly (see the tracked-tool-plus-MCP-overlap regression). +- **Cross-harness: `AiContended` remains reachable** — a tracked Codex scope + overlapping a Claude/OpenCode/Pi scope on the same worktree. +The T06 concurrency regression (AC10) crosses harnesses. There is **no** +MCP-overlap `AiContended` regression because MCP produces no tracked scopes; T06 +instead adds a `Bash`-overlapping-MCP regression that asserts the +`AiExclusive` = tracked-scope-exclusivity (not sole-authorship) semantic. ### D15 — Raw Codex hook cwd is authoritative — T01-GATED @@ -751,6 +1064,89 @@ mutation-scope event, citing this source file. The `$CODEX_HOME/config.toml` (`…:pre_tool_use:0:0`, `…:post_tool_use:0:0`, `…:stop:0:0`, `…:user_prompt_submit:0:0`). +### D23 — Codex mutation-scope attribution v1 is partial by tool surface + +**Decision (2026-09-08, re-planning direction B).** Codex mutation-scope +attribution v1 is **deliberately partial**, split by tool surface: + +```text +Covered (TrackedMutation — one execution -> one ScopeId): + Bash + apply_patch + +Delegation (no scope for the tool itself; the delegated agent's tracked tools get scopes): + collaborationspawn_agent + collaborationwait_agent + +Allowed but NOT covered (Untracked — executes, may mutate, no scope, no bookkeeping): + mcp__* (any MCP tool) + unknown / future Codex tool names, until their lifecycle is explicitly researched +``` + +**Coverage meaning.** Codex mutation-scope attribution describes **exclusivity +among the tracked scopes the adapter is told about**, not exhaustive authorship +of every filesystem mutation. `AiExclusive(scope)` means exactly one tracked +scope was live in the interval — an MCP call, a human editor, or another +`Untracked` actor may have mutated the same worktree in that interval (D14). + +**Why MCP is `Untracked`, not a scope.** T01 (probes 12–17, codex-cli 0.153.4) +proved that if MCP were represented as a mutation scope, the Codex 0.153.4 hook +lifecycle makes it unsafe (D10a Case C): + +- **mutate-then-error has no terminal hook** — a mutation-capable MCP tool can + write a git-visible file and return `is_error:true` with no `PostToolUse` + (probe 13); +- **a successor can start immediately** — `PreToolUse(A)` -> `PreToolUse(B)` with + no cleanup signal of any kind between them (probe 14); +- **parallel MCP execution is real** — two mutation-capable MCP executions + genuinely overlap (probes 16/17), so a successor `PreToolUse` cannot prove a + predecessor stale. + +The adapter cannot safely retire a failed-MCP zombie scope, and there is no +narrower serial lane than `(session_id, turn_id)`. **The resolution is to not +model MCP executions as scopes at all.** MCP calls execute normally and are +explicitly outside attribution coverage. This is a first-class coverage +boundary, not a lifecycle workaround — the adapter does not claim the MCP +lifecycle is safe, does not assert MCP is read-only, and does not guarantee MCP +mutations are detected immediately. + +**What v1 must NOT do:** + +- must **not** deny an MCP or unknown `PreToolUse` merely because it is untracked + (D8); +- must **not** silently downgrade MCP/unknown to "read-only"; +- must **not** silently pretend an MCP mutation was attributed; +- must **not** create any `Start`, `attempt`, `recovery_pending`, `Close`, + `Abandon`, or `Flush` for an MCP or unknown execution. + +**No protocol / formal change.** This choice requires **no** +`mutation_cursor.qnt` change, **no** mutation-trace protocol change, **no** +mutation runtime semantic change, **no** SQL migration, and **no** Agent Trace +schema change — the existing runtime already models exclusivity among tracked +scopes, not global filesystem authorship. Direction B changes only the Codex +adapter's coverage boundary. + +**Rejected / deferred alternatives (historical rationale):** + +- **A — deny mutation-capable MCP `PreToolUse` fail-closed.** Rejected: too + disruptive (MCP tools become unusable inside Codex under SCE), and it needs a + rule to tell "mutation-capable MCP" from "read-only MCP" that cannot trust the + server's own `readOnlyHint`. +- **B — allow MCP/unknown untracked.** **Chosen for Codex adapter v1.** +- **C — a richer lifecycle/runtime mechanism** (e.g. a per-`tool_use_id` MCP + scope retired only by `PostToolUse` or an overlap-tolerant turn-boundary + sweep, plus an `AiContended`-aware successor policy). Deferred as possible + **future work** in a separate, explicitly justified PR — "investigate + first-class MCP mutation attribution using a richer lifecycle mechanism". + +**Durable-context requirement.** `context/cli/codex-mutation-scope-integration.md` +(authored by T07) must state, in public/durable language, that **Codex MCP calls +remain usable but their filesystem mutations are not individually attributed by +the Codex mutation-scope adapter**, and must document why (the three T01 +probe findings above), avoiding any wording that implies SCE knows MCP is +read-only, that MCP mutations are necessarily detected immediately, or that +`AiExclusive` proves sole authorship. + ## Acceptance criteria How this plan is proven complete. Each criterion is observable and names the @@ -776,13 +1172,66 @@ performs final validation. - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex_mutation_scope` parser unit tests, including a fixture per T01 probe. -- [ ] AC3: `classify_tool` classifies every Codex tool name T01 enumerated: - mutation-capable (`apply_patch`, the shell tool, MCP tools, unknown names), - read-only (the enumerated read/search tools — no scope), delegation (no - scope). No `Start` boundary is emitted for `SessionStart`, `UserPromptSubmit`, - `SubagentStart`, or any non-tool lifecycle event. - - Validate: classification unit-test table; adapter mapping unit tests - asserting processed-event keys. +- [ ] AC3: `classify_tool` returns exactly one of the three classes (D2) for + every Codex tool name T01 enumerated: `TrackedMutation` (`apply_patch`, the + shell/`Bash` tool), `Delegation` (`collaborationspawn_agent` / + `collaborationwait_agent` — no scope), `Untracked` (any `mcp____` + matching the proven shape, and any unknown/unrecognised `tool_name`). There is + no "read-only" class on the `codex exec` surface. No `Start`, `attempt`, or + bookkeeping entry is created for a `Delegation` or `Untracked` tool, nor for + `SessionStart` / `UserPromptSubmit` / `SubagentStart` / any non-tool lifecycle + event. The classifier must **not** contain language treating MCP/unknown as + "mutation-capable therefore Start". + - Validate: classification unit-test table (each tool name -> class); adapter + mapping unit tests asserting that `Untracked`/`Delegation` events produce no + processed-event keys. +- [ ] AC9b (MCP / unknown pass-through — direction B): A `PreToolUse(mcp__…)` + and a `PreToolUse()` are classified `Untracked` and produce a + **Codex-neutral continue response** — no `ScopeId`, no `EventId`, no + mutation-scope `Start`, no adapter attempt, no `recovery_pending`, no + bookkeeping row of any kind. The adapter never denies them for being untracked, + never emits an explicit allow, never downgrades them to "read-only", and never + records that their mutations were attributed. A successful MCP call produces + **no** mutation-scope rows or events attributable to that MCP execution. + - Validate: adapter unit tests — `PreToolUse(mcp__…)` and `PreToolUse(unknown)` + return the neutral response with the state store untouched; a full + successful-MCP lifecycle (`PreToolUse → PostToolUse`, probe 12 fixture) + leaves zero mutation-scope rows/events; the recorded D23 decision in this + plan. +- [ ] AC9c (MCP mutate-then-error leaves no stale state — direction B): Driving + the probe-13 lifecycle (`PreToolUse(mcp__…)` mutates a git-visible file, tool + returns an error, **no `PostToolUse`**, then `Stop`/`SessionEnd`) through the + adapter leaves **no stale attempt, no `recovery_pending`, no `abandon`, and no + zombie scope** — because no `Start` ever occurred. The subsequent `Stop` / + `SessionEnd` sweeps find nothing to retire. + - Validate: adapter unit test replaying the probe-13 fixture sequence and + asserting the state store is empty throughout and after; T06 row-count + assertion. +- [ ] AC9d (failed MCP A -> successor B — direction B): Using the probe-14 + sequence (`A` = untracked MCP that mutates then errors, immediately followed by + `PreToolUse(B)` where `B` is a tracked `Bash`/`apply_patch` **or** another + MCP), A leaves **no mutation-scope bookkeeping** that can interfere with B: if + B is tracked it `Start`s normally as the only live scope; if B is MCP it is + also `Untracked`. No successor barrier runs because no MCP attempt exists. + - Validate: adapter unit test over the probe-14 fixture asserting B (tracked) + is the only live scope at its `Start` and no false `AiContended`; T06 + regression. +- [ ] AC9e (parallel MCP — direction B): Using probes 16/17 (two MCP executions + genuinely overlap), neither MCP `PreToolUse` creates a mutation scope, there is + **no MCP/MCP `AiContended`**, and the adapter state store shows no leak (empty + before, during, and after). + - Validate: adapter unit test over the probe-16/17 fixtures; T06 regression + asserting zero mutation-scope rows for the overlapping MCP pair. +- [ ] AC9f (tracked tool overlapping MCP — semantics documented): With + `Start(Bash A)` live, an MCP call mutates the worktree, then `Close(Bash A)`. + The runtime may report `AiExclusive(A)` for the interval. The test and the + durable context must state this means **A was the only tracked scope live**, + **not** that MCP did not mutate. The generic protocol is **not** changed to + force this into `AiContended`. + - Validate: T06 regression (`Bash` scope + real MCP mutation via + `fixtures/mcp_probe/`) asserting the `AiExclusive` result and a comment/doc + line recording the tracked-scope-exclusivity (not sole-authorship) reading; + AC22 confirms no protocol/Quint change. - [ ] AC4: The Codex execution key is exactly the field tuple T02 froze from T01 evidence (recorded in D3). Duplicate delivery of the same live `PreToolUse` reuses the same `attempt_seq`, `ScopeId`, and `Start` `EventId`. @@ -794,7 +1243,7 @@ performs final validation. attempt's event is `ScopeId`/`EventId`-stable. - Validate: state unit tests (terminal attempt then fresh `attempt_seq`); formatter determinism tests; T06 reused-identifier regression. -- [ ] AC6: A tracked mutation-capable `PreToolUse` reaches durable +- [ ] AC6: A `TrackedMutation` `PreToolUse` reaches durable generic-ingress `Start` before the hook returns its "continue" response to Codex (write-ahead `pending_start` -> ingress `Start` -> `active`), and the seam receives the raw hook `cwd` as `repository_root` (never `git_dir`). @@ -802,67 +1251,64 @@ performs final validation. persisted phase from inside the seam call and the `repository_root` argument; T06 production-path confirmation. - [ ] AC7: Any failure to establish adapter state or `Start` during a - mutation-capable `PreToolUse` returns the exact Codex-native block response + `TrackedMutation` `PreToolUse` returns the exact Codex-native block response T01 froze (D8), never a silent success and never an explicit allow; the detailed error is logged via - `sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed`. + `sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed`. A `Delegation` or + `Untracked` `PreToolUse` is never subject to this fail-closed path. - Validate: failure-classification unit tests asserting the exact response JSON/exit; `RecordingLogger` assertion that the detail is logged and not leaked into the model-visible reason. -- [ ] AC8: A successful mutation-capable tool with an `active` attempt closes +- [ ] AC8: A successful `TrackedMutation` tool with an `active` attempt closes its scope: `PreToolUse` -> real filesystem mutation -> terminal Codex hook produces exactly one eligible tool interval and one terminal (`Closed`) scope with attribution `AiExclusive`. - Validate: T06 real-Git + real-Agent-Trace-DB regression. -- [ ] AC9: A mutation-capable tool that partially mutated the checkout then - failed is handled per D10's T01 disposition: if Codex provides a reliable - final observation, the scope closes and the partial mutation is attributed to - that scope; if it does not, the stale attempt is abandoned and a subsequent - `flush` re-baselines the worktree so the partial mutation is - `IneligibleUnscoped`, never misattributed. - - Validate: T06 failed-tool regression whose assertions match the D10 - disposition recorded by T01. -- [ ] AC9a: A partially-mutating failed tool A with **no terminal event**, - followed by another mutation-capable `PreToolUse(B)` in the same turn, is - handled per D10a's T01 disposition: **either** a proven intermediate lifecycle - signal retires A before B starts (Case A), **or** a proven serial-lane - successor barrier retires A (arm `recovery_pending` -> abandon A -> flush when - quiescent) before B's write-ahead `Start` (Case B), **or** T01 marked the - lifecycle unsupported and implementation did not proceed (Case C). B must never - reach `Start` while a known-stale A is still `active`/`pending_start` in - bookkeeping, and no false `AiContended` event is produced merely because A - lingered in adapter bookkeeping. - - Validate: T06 failed-A-then-B regression (real Git + real Agent Trace DB): - assert A is `Abandoned`, B is the only live scope at its `Start`, the - worktree re-baselined between them, and the mutation-event stream contains - **no** `AiContended` row attributable to the A/B overlap; adapter unit test - for the Case B lane-scoped successor barrier (a same-lane stale predecessor - is swept, an out-of-lane concurrent attempt is not). If Case C, validate by - the recorded T01 disposition and the plan's stop-for-re-planning note. -- [ ] AC10: Two simultaneously-live scopes (per D14 — two Codex executions if - T01 proves overlap is possible, otherwise a Codex scope overlapping a second - harness's scope) produce `AiContended` for a tree transition observed while - both are live; the adapter never assigns them one shared `ScopeId`. - - Validate: T06 concurrency regression; the plan records which D14 form was - exercised. -- [ ] AC11: An outstanding mutation-capable execution with no terminal hook is +- [ ] AC9: A `TrackedMutation` tool that partially mutated the checkout then + failed is handled per D10's T01 disposition: + - **`Bash`:** the terminal `PostToolUse` closes the scope (partial mutation + attributed to that scope). + - **`apply_patch`:** verification failure writes nothing — no scope to close. + - **MCP / unknown:** not applicable — they are `Untracked` (D2/D23), no scope + exists, so there is nothing to close, abandon, or flush; see AC9c. + - Validate: T06 failed-`Bash` and failed-`apply_patch` regressions whose + assertions match the D10 disposition recorded by T01. +- [ ] AC9a: A partially-mutating failed `TrackedMutation` tool A with **no + terminal event**, followed by another `TrackedMutation` `PreToolUse(B)` in the + same turn (Case A): a terminal `PostToolUse` (or `Interrupt`/`SessionEnd`) + always precedes the successor `PreToolUse` for built-ins, so no + successor-barrier logic ships and B never `Start`s alongside a zombie A. The + MCP form of this scenario is covered by AC9d, not here, because MCP creates no + attempt. + - Validate: T06 built-in failed-A-then-B regression (assert A `Abandoned` or + `Closed` per tool, B the only live scope at its `Start`, no false + `AiContended`). +- [ ] AC10: Two simultaneously-live **tracked** scopes produce `AiContended` for + a tree transition observed while both are live; the adapter never assigns them + one shared `ScopeId`. Per D14 the exercised form is a **tracked Codex scope + overlapping a second harness's scope** on the same worktree. There is **no** + MCP-derived `AiContended` (MCP is `Untracked`); the `Bash`-overlapping-MCP case + is AC9f, and asserts `AiExclusive` = tracked-scope exclusivity, not sole + authorship. + - Validate: T06 cross-harness concurrency regression; the plan records the D14 + form exercised. +- [ ] AC11: An outstanding **tracked** execution with no terminal hook is retired by exactly the Codex lifecycle signals T01 marked load-bearing (D12), - via `abandon_scope`, leaving the worktree `needs_rebaseline`. A concurrent - execution T01 proved can legitimately still be running is **not** retired by - the same sweep (D10a invariant (ii)) — sweeps are always lane-scoped, never - global. + via `abandon_scope`, leaving the worktree `needs_rebaseline`. The D12 sweeps + operate only over adapter-owned tracked attempts; `Untracked` (MCP, unknown) + executions never enter `attempts[]` and are never swept. - Validate: T06 regressions for each proven cleanup signal; adapter cleanup - unit tests including one asserting an out-of-lane attempt survives a sweep. -- [ ] AC12: While `recovery_pending` is armed and known attempts remain - outstanding, every new mutation-capable `PreToolUse` is denied (D8 shape); - once quiescent, exactly one `{"operation":"flush"}` runs through the seam and - `recovery_pending` clears only on durable flush success. The same barrier is - what a D10a Case B successor arms before abandoning a known-stale predecessor, - so a successor B is denied its own `Start` until predecessor A's abandonment - and the quiescent flush have durably completed. + unit tests including one asserting an `Untracked` execution left no attempt + for a sweep to touch. +- [ ] AC12: While `recovery_pending` is armed and known **tracked** attempts + remain outstanding, every new `TrackedMutation` `PreToolUse` is denied (D8 + shape); an `Untracked` `PreToolUse` is not affected by the barrier; once + quiescent, exactly one `{"operation":"flush"}` runs through the seam and + `recovery_pending` clears only on durable flush success. (No D10a successor + barrier ships — built-ins are Case A and MCP/unknown are `Untracked`.) - Validate: adapter recovery-barrier unit tests (deny-while-outstanding, - flush-then-proceed, flush-failure-stays-closed, successor-blocked-until- - predecessor-recovered); T06 recovery-barrier regression. + flush-then-proceed, flush-failure-stays-closed); T06 recovery-barrier + regression. - [ ] AC13: A failed abandonment leaves the attempt tracked and `recovery_pending = true` (no successor mutation `Start` is allowed until recovery succeeds). @@ -972,28 +1418,47 @@ performs final validation. paths>` is empty. - [ ] AC23: Durable context clearly separates the generic mutation-scope ingress, the Codex mutation adapter, and the mutation runtime, and records: - the tool-execution scope model, Codex tool classification, execution identity, - `ScopeId`/`EventId` derivation, the fail-closed `PreToolUse` and its exact - Codex-native response, the terminal-boundary mappings, the failed-tool -> - successor-tool handling and its recorded case + successor-Start invariant, - the Codex lifecycle cleanup signals and the load-bearing backstop, the - recovery barrier, worktree/cwd ownership, the concurrency story, the exact - background/detached execution limitations, and the Codex hook-config coexistence - contract (existing-registration trust-identity preservation, the + the tool-execution scope model, the **three-class** Codex tool classification + (`TrackedMutation` / `Delegation` / `Untracked`) and the **partial-by-tool- + surface coverage boundary** (D23), execution identity, + `ScopeId`/`EventId` derivation, the fail-closed `PreToolUse` (tracked only) and + its exact Codex-native response, the terminal-boundary mappings, the + failed-tool handling, why MCP/unknown are `Untracked` (the three T01 probe + findings), the Codex lifecycle cleanup signals and the load-bearing backstop, + the recovery barrier, worktree/cwd ownership, the concurrency story (including + that `AiExclusive` is tracked-scope exclusivity, not sole authorship), the + exact background/detached execution limitations, and the Codex hook-config + coexistence contract (existing-registration trust-identity preservation, the three-dimension doctor health model, upstream-verified event key labels) — each stated as Codex-proven, Codex-documented, or Codex-unsupported, with the - tested Codex version. + tested Codex version. Durable context must include the coverage table: + `Tracked: Bash, apply_patch` / `Delegation: collaborationspawn_agent, + collaborationwait_agent` / `Allowed but untracked: mcp__*, unknown tools`, and + the sentence that **Codex MCP calls remain usable but their filesystem + mutations are not individually attributed by the Codex mutation-scope + adapter**. - Validate: inspection of `context/cli/codex-mutation-scope-integration.md` and the updated cross-reference files. -- [ ] AC24: The plan's exact unsupported limitations are enumerated in durable - context: no line-level attribution for mutations from a failed tool with no - terminal hook (if D10 lands that way); the failed-tool -> successor-tool - guarantee only as strong as T01's D10a case (and, if Case C, the integration - is not shipped and durable context records the unsupported lifecycle); no - attribution guarantee for self-detaching descendant processes; no - Codex-managed background execution SCE cannot bound; and whatever else T01 - marks `UNSUPPORTED`. - - Validate: inspection of the "Unsupported" section of +- [ ] AC24: The plan's exact unsupported / out-of-coverage limitations are + enumerated in durable context: + - **MCP tools and unknown/future Codex tools are outside Codex mutation-scope + attribution coverage** (D23, direction B). They execute and may mutate; the + adapter creates no scope. Durable context records *why*: on codex-cli + 0.153.4, if MCP were modeled as a scope it would be D10a Case C + (mutate-then-error has no terminal hook; a successor can start with no + cleanup signal between; parallel MCP execution is real). This is a coverage + boundary, not a shipped-then-broken feature. Wording must not imply SCE knows + MCP is read-only, that MCP mutations are detected immediately, or that + `AiExclusive` proves sole authorship. + - No line-level attribution for mutations from a failed tool with no terminal + hook; the failed-tool -> successor-tool guarantee is Case A for built-in + `Bash` / `apply_patch` only. + - No attribution guarantee for self-detaching descendant processes; no + Codex-managed background execution SCE cannot bound; and whatever else T01 + marks `UNSUPPORTED`. + - Future work is explicitly named: investigate first-class MCP mutation + attribution using a richer lifecycle mechanism in a separate PR. + - Validate: inspection of the "Unsupported / Coverage boundary" section of `context/cli/codex-mutation-scope-integration.md`. ### Full validation @@ -1018,7 +1483,24 @@ Final branch comparison is against `claude-mutation-scope-integration` ### Context sync - New: `context/cli/codex-mutation-scope-integration.md` (owns the Codex adapter - domain — see AC23/AC24). + domain — see AC23/AC24). Must document, in public/durable language, the + **partial-by-tool-surface coverage boundary** (D23): + + ```text + Tracked: Bash, apply_patch + Delegation: collaborationspawn_agent, collaborationwait_agent + Allowed but untracked: mcp__*, unknown tools + + Coverage meaning: mutation-scope attribution describes tracked-scope + exclusivity, not exhaustive authorship of every + filesystem mutation. + ``` + + and *why* MCP is untracked, citing the T01 probes: mutate-then-error has no + terminal hook (probe 13); a successor can start immediately with no cleanup + signal (probe 14); parallel MCP execution is real (probes 16/17). Plus the + sentence: "Codex MCP calls remain usable, but their filesystem mutations are + not individually attributed by the Codex mutation-scope adapter." - Update: `context/cli/mutation-scope-runtime.md` (a second concrete adapter now exists; Codex is no longer "unwired"), `context/cli/mutation-scope-hook-ingress.md` (a second in-process seam @@ -1111,7 +1593,11 @@ Persist this field in every plan; this is durable plan state, not chat state: as proof that every descendant process has stopped mutating; turning `abandon` into a `RuntimeBoundary`; a long-lived Codex "session" or "agent" scope; inventing a Codex `agent_id` abstraction if Codex exposes no delegated-agent - identity. + identity; **modeling an MCP call or an unknown Codex tool as a mutation scope** + (D23 — they are `Untracked` in v1: allowed, may mutate, no scope, no + bookkeeping); **denying an MCP or unknown `PreToolUse` for being untracked**; + **claiming MCP is read-only or that its mutations are individually attributed**; + first-class MCP mutation attribution (direction C — deferred to a separate PR). ## Assumptions @@ -1146,17 +1632,46 @@ Persist this field in every plan; this is durable plan state, not chat state: - [x] T01: `Freeze the real Codex hook and lifecycle contract` (status:done) - Task ID: T01 - - Completed: 2026-09-07 + - Built-in probes completed: 2026-09-07 + - MCP lifecycle extension completed: 2026-09-07 (probes 12–17) + - Re-planning resolution recorded: 2026-09-08 + - **Re-planning resolution (2026-09-08) — direction B chosen.** T01 discovered + D10a **Case C for MCP** on codex-cli 0.153.4: a mutation-capable MCP tool can + mutate a git-visible file then return `is_error:true` with **no terminal + hook** (probe 13); no cleanup signal reaches the adapter before a successor + `PreToolUse` (probe 14); same-lane MCP executions **overlap** (probes 16/17). + Re-planning chose **direction B**: MCP tools and unknown/future Codex tools + remain usable but are **outside the Codex adapter's mutation-scope + attribution coverage** (D23) — classified `Untracked`, no scope, no + bookkeeping. The Case C finding is **retained** and is exactly *why* MCP is + excluded: "if MCP were represented as a mutation scope, Codex 0.153.4 makes + the lifecycle unsafe." No protocol / Quint / mutation-trace SQL / Agent Trace + schema change is required (D23). Design decisions updated: D1, D2, D8, D9, + D10, D10a, D12, D13, D14, new D23; ACs updated: AC3, AC9, AC9a, AC9b (now + "MCP/unknown pass-through"), AC9c–AC9f (new), AC10, AC11, AC12, AC23, AC24; + tasks updated: T02 unblocked, T06 matrix. The built-in `Bash` / `apply_patch` + evidence and all non-MCP dispositions (D3, D8, D9-built-in, D10-built-in, + D10a-built-in, D12, D15, D16, D17-inputs, D22) remain valid and are **not** + re-opened. + - **T01 → done; T02 → unblocked** (not started as part of this re-planning). - Files changed: - `cli/src/services/hooks/codex_mutation_scope/fixtures/` (new — 35 raw - byte-for-byte Codex hook-event captures across 11 probes + one - `probe09-*.evidence.json` capture-metadata file + `NOTES.md`) - - `flake.nix` (add `./cli/src/services/hooks/codex_mutation_scope/fixtures` - to `workspaceSrc` so the fixtures reach the build sandbox, mirroring the - Claude adapter's T01 line) + byte-for-byte built-in hook-event captures across 11 probes + one + `probe09-*.evidence.json`; **plus 21 raw MCP hook-event captures + 5 + `probe1[3-7]-*.evidence.json` files across MCP probes 12–17**; `NOTES.md`) + - `cli/src/services/hooks/codex_mutation_scope/fixtures/mcp_probe/` (new — + probe-only, not runtime: `server.py` zero-dep stdio MCP server, `dump.sh`, + `block.sh`, `run-probes.sh` driver, `config.toml.sample`, + `hooks.json.sample`) + - `flake.nix` (`./cli/src/services/hooks/codex_mutation_scope/fixtures` + already in `workspaceSrc`; `mcp_probe/` is under it — no change needed) - `context/plans/codex-mutation-scope-integration.md` (T01 dispositions written into D1, D2, D3, D8, D9, D10, D10a, D12, D15, D16, D17-inputs, - D22; T01 outcome added to Open questions; this task record) + D22; MCP extension dispositions written into D1/D2/D8/D9/D10/D10a/D14; + **2026-09-08 re-planning direction B written into the Change summary, D1, + D2, D8, D9, D10, D10a, D12, D13, D14, new D23, AC3/AC9/AC9a/AC9b/ + AC9c–AC9f/AC10/AC11/AC12/AC23/AC24, T02/T06, Open questions**; this task + record) - Result: Froze the Codex hook/lifecycle contract for **codex-cli 0.153.4** (model `gpt-5.6-sol`), cross-checked against upstream `openai/codex` tag `rust-v0.153.4` (commit `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`) — @@ -1166,31 +1681,54 @@ Persist this field in every plan; this is durable plan state, not chat state: two `PreToolUse`-hook denial shapes, tool vocabulary, apply_patch verification failure, SIGINT with/without an `Interrupt` hook, subagent delegation, self-detaching descendant with Git-observability evidence, - linked-worktree cwd). Key findings: **D10a is Case A** (a failed shell tool - still fires `PostToolUse`; a failed `apply_patch` writes nothing; - interruption ends the turn — no successor-barrier logic ships); + linked-worktree cwd). Built-in key findings: **D10a is Case A for + `Bash` / `apply_patch`** (a failed shell tool still fires `PostToolUse`; a + failed `apply_patch` writes nothing; interruption ends the turn); **`SessionEnd` is the load-bearing cleanup backstop**, `Interrupt` is a newly-discovered earlier interruption signal (**Codex has 12 hook events, not 11**); **both** `{"decision":"block"}` and `hookSpecificOutput.permissionDecision:"deny"` block a tool; Codex runs - mutation-capable tools **strictly serially** (Codex-alone `AiContended` - unreachable); Codex **does** expose a delegated-agent identity (`agent_id`, - subagent events only); raw hook `cwd` is authoritative including for linked - worktrees; the default `codex exec` shell tool has **no `run_in_background` - parameter** (no background-execution deny needed) but a self-detaching - descendant is an unsupported boundary as for Claude. No probe showed Codex - cannot be represented by the current mutation-scope contract — the plan - proceeds to T02. + **built-in** mutation-capable tools serially; Codex **does** expose a + delegated-agent identity (`agent_id`, subagent events only); raw hook `cwd` + is authoritative including for linked worktrees; the default `codex exec` + shell tool has **no `run_in_background` parameter** but a self-detaching + descendant is an unsupported boundary as for Claude. + + **MCP lifecycle extension (probes 12–17, codex-cli 0.153.4, cross-checked + against `codex-rs/core/src/tools/{registry.rs,context.rs,handlers/mcp.rs}` + and `codex-rs/config/src/mcp_types.rs` at `rust-v0.153.4`)** — a tiny local + stdio MCP server (`fixtures/mcp_probe/server.py`) exposing deliberately + mutation-capable tools was wired into a scratch repo and driven with + `codex exec`. Findings: MCP tool naming is `mcp____` with an + `exec-` `tool_use_id` (D2/D3 unchanged for MCP); a **successful** MCP + call emits `PostToolUse` (D9); a **blocked** MCP call emits `PreToolUse` + only (D8); **but a mutation-capable MCP tool that mutates a git-visible file + and then returns `is_error:true` receives NO terminal hook** (probe 13), a + failed MCP tool is followed **directly** by a successor MCP tool with **no + intervening cleanup signal** (probe 14), and **two mutation-capable MCP + executions run genuinely concurrently** (probes 16/17, + `supports_parallel_tool_calls` config key / `annotations.readOnlyHint`). + **This is D10a Case C for MCP if MCP were modeled as a scope.** Re-planning + (2026-09-08) chose **direction B**: MCP and unknown tools are `Untracked` — + they execute and may mutate, but the adapter creates no scope, so the Case C + lifecycle can never produce a zombie scope (D23). Built-in `Bash` / + `apply_patch` evidence is unaffected. Parallel MCP execution remains real + operationally but produces no tracked scopes and therefore no + MCP-derived `AiContended`. - Verify: - - `nix run .#pkl-check-generated` — **passed** ("Ephemeral Pkl generation - passed: 141 files, inventory sha256 - dcbd28041c3587156510bdb3a6c76e5a9ec4851c140b4c50785c764d95ebfd5c"). + - `nix run .#pkl-check-generated` — **passed** (built-in probes; re-run + after the MCP-extension fixtures land). - `nix flake check` — **passed** ("all checks passed!"; incompatible - non-Linux systems omitted as usual). - - Fixtures committed under - `cli/src/services/hooks/codex_mutation_scope/fixtures/` and referenced - from this plan; `NOTES.md` lists the manifest and per-probe disposition; - all 36 JSON fixture files parse; CLI build input list updated (`flake.nix`). + non-Linux systems omitted as usual; re-run after the MCP-extension + fixtures land). + - Built-in fixtures committed under + `cli/src/services/hooks/codex_mutation_scope/fixtures/`; `NOTES.md` lists + the manifest and per-probe disposition; all JSON fixture files parse; CLI + build input list already includes the fixtures directory (`flake.nix`). + - MCP-extension: 21 raw MCP hook payloads + 5 `evidence.json` files + the + `mcp_probe/` probe harness committed under the same fixtures tree; all + parse; the six MCP probes were driven live against `codex exec` + (`codex-cli 0.153.4`). - Context impact: domain — a new adapter-domain fixture corpus + frozen Codex hook-contract facts now exist; no code, no user-visible behavior, no public interface yet. Durable Codex-adapter context (`context/cli/ @@ -1251,7 +1789,21 @@ Persist this field in every plan; this is durable plan state, not chat state: suitable for `abandon` (not absence of activity) (D12). - **Parallel execution** — whether Codex can have two mutation-capable executions overlapping; if yes, capture evidence of two coexisting - independent tool executions (D1/D14). + independent tool executions (D1/D14). **Done for MCP** — probes 16/17 + reproduce genuine overlap; the plan may not conclude seriality from the + built-in probes alone. + - **MCP lifecycle (probes 12–17)** — a local MCP server exposing + mutation-capable tools (`mutate_success`, `mutate_then_error`, + `slow_mutate`, `read_only_liar`): capture the full hook lifecycle for a + successful MCP call, a blocked MCP call, a mutate-then-error MCP call, a + failed MCP call followed by a successor MCP call in the same turn, and two + MCP calls forced to run in parallel; record git-observable mutation + evidence (not just the MCP result) distinguishing "tool did not mutate" + from "tool mutated then returned failure"; determine whether `PostToolUse` + fires and whether any positive cleanup signal precedes a successor; pin the + Codex version and cite the upstream `success_for_logging` / + `supports_parallel_tool_calls` source. Record the D10a MCP disposition + (Case A / B / C) explicitly and, for Case C, stop the plan for re-planning. - **Subagents / delegated execution** — whether Codex exposes nested/ delegated agent execution and whether hook payloads carry enough identity to distinguish a delegated agent from the main thread (D3). Do not invent @@ -1276,19 +1828,34 @@ Persist this field in every plan; this is durable plan state, not chat state: T01-GATED decision (D1, D2, D3, D8, D9, D10, D10a, D12, D15, D16, D17-inputs) carries an explicit disposition written back into this plan's Design section — D10a specifically records Case A / Case B / Case C with its - evidence and, for Case B, the exact lane key. If any probe (D10a Case C - included) shows Codex cannot be represented by the current mutation-scope - contract, that is recorded in Open questions and the plan stops for - re-planning rather than proceeding to T02. + evidence. **Met:** all fixtures committed; `NOTES.md` complete; D10a records + Case A (built-ins) and Case C *if modeled as a scope* (MCP). The Case C + finding triggered the re-planning that chose direction B (D23) — MCP/unknown + are `Untracked` and need no protocol change, so the plan proceeds to T02. - Verify (planned): fixtures committed and referenced from this plan; `NOTES.md` lists the manifest and per-probe disposition; `nix run .#pkl-check-generated` and `nix flake check` still pass (fixtures are inert data — confirm the CLI build input list includes the new fixtures directory, as #263's T01 needed for Claude). - - Context synchronization: pending + - Context synchronization: synced + - T01 ships **no code, no public interface, and no user-visible behaviour** — + only the fixture corpus, the frozen Design-section dispositions, and (as of + 2026-09-08) the recorded re-planning direction B (D23). The durable + cross-reference files (`context/cli/...`, `context/sce/...`) are authored + by **T07** once behaviour ships; there is nothing for T01 to sync into them + now, exactly as recorded under "Context impact". The re-planning facts live + in this plan's Design section, Open questions, and + `cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md`. - [ ] T02: `Command architecture, Codex event model, classification, and identity` (status:todo) - Task ID: T02 + - **Unblocked (2026-09-08).** The T01 D10a Case C blocker is resolved by + re-planning direction B (D23): MCP tools and unknown tool names are + `Untracked` — they execute and may mutate, but the adapter creates no scope, + no `Start`, and no bookkeeping for them, so no zombie-scope lifecycle exists. + `classify_tool` is now a three-way decision (`TrackedMutation` / + `Delegation` / `Untracked`, D2) with a frozen membership; no protocol change + is required. T02 has not been started. - Scope: In — (1) decide the D17 command architecture against T01 + the code, defaulting to a separate hidden `sce hooks codex-mutation-scope` command, and write the decision into D17; (2) `cli/src/services/hooks/ @@ -1296,23 +1863,24 @@ Persist this field in every plan; this is durable plan state, not chat state: parser (rejecting empty/non-object/missing/blank/wrong-typed with `Invalid Codex hook event payload from STDIN: .`), the supported mutation-scope hook-event enum (only the events T01 proved), the D2 - `classify_tool` table, the D3 execution-key type frozen from T01 evidence, - the D4 length-prefixed `cx-tool-v1|n=..|...` `ScopeId` formatter, and the + **three-class** `classify_tool` (`TrackedMutation` = `Bash` / `apply_patch`; + `Delegation` = `collaborationspawn_agent` / `collaborationwait_agent`; + `Untracked` = `mcp__*` and any unknown `tool_name`) with no + "mutation-capable therefore Start" language for MCP/unknown, the D3 + execution-key type frozen from T01 evidence (tracked tools only), the D4 + length-prefixed `cx-tool-v1|n=..|...` `ScopeId` formatter, and the `|start` / `|close` `EventId` formatters, plus any background-execution classifier T01 shows is needed (model/classify only — - the denial is T04's); (3) if T01 recorded D10a Case B, freeze the - **serial-lane key** (the exact identity-field subset from T01's evidence - that defines "same lane" for the successor-barrier) as a typed accessor on - the parsed event, with unit tests — no adapter logic yet, just the key. Out - — any durable state, any runtime/ingress call, any CLI wiring, any generated - settings. + the denial is T04's). Out — any durable state, any runtime/ingress call, any + CLI wiring, any generated settings; **no D10a successor-barrier / lane-key + work** (Case A for built-ins ships none; MCP/unknown are `Untracked`). - Dependencies: T01 - Done when: the module compiles behind the existing `hooks` module tree; the - D17 decision is recorded in this plan; the D10a lane key is frozen (Case B) - or explicitly N/A (Case A) or the plan is already stopped (Case C); unit - tests prove AC2, AC3, AC4 (formatter determinism), AC5 (formatter is a - function of `attempt_seq`), and the full classification table against T01's - tool vocabulary. + D17 decision is recorded in this plan; the D10a lane key is explicitly N/A + (recorded in D10a); unit tests prove AC2, AC3 (the three-class table, + including `Untracked` for `mcp__*` and unknown names, producing no + processed-event keys), AC4 (formatter determinism), AC5 (formatter is a + function of `attempt_seq`), against T01's tool vocabulary. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex_mutation_scope`; `clippy` / `fmt` clean. @@ -1355,20 +1923,20 @@ Persist this field in every plan; this is durable plan state, not chat state: STDIN via `super::read_hook_stdin()`; resolve the raw `cwd` as `repository_root` and `git_dir` (bookkeeping only) as two independent parameters, never substituted; map each proven event — - mutation-capable `PreToolUse` -> D7 write-ahead `Start` + D8 fail-closed + **`TrackedMutation`** `PreToolUse` -> D7 write-ahead `Start` + D8 fail-closed Codex-native block on any failure + D16 background-execution deny if - applicable; the proven success terminal hook -> `Close` (D9); the proven - failure disposition from D10; the D10a failed-tool -> successor-tool - handling per T01's recorded case — Case A routes the intermediate signal - through D12; Case B implements the **lane-scoped** successor barrier on - `PreToolUse(B)` (inspect same-lane attempts using T02's frozen lane key -> - if a stale predecessor is found, arm `recovery_pending` -> abandon it -> - flush when quiescent -> only then write-ahead `Start(B)`; an out-of-lane - attempt is never touched); Case C ships nothing; the proven denial - signal(s) -> `abandon` (D12); the proven session/turn/agent cleanup signals - -> scoped `abandon` sweeps (D12, always lane/identity-scoped, never global); - D11 uncertain-boundary rules and the D13 successor-Start invariant; the D13 - recovery barrier + one quiescent `flush`. The driver reaches the runtime + applicable; **`Untracked`** (`mcp__*`, unknown) and **`Delegation`** + `PreToolUse` -> Codex-neutral continue response, no `Start`, no attempt, no + bookkeeping (D2/D8/D23); the proven success terminal hook for a tracked tool + -> `Close` (D9); the proven tracked failure disposition from D10 (built-in + Case A, no Close-on-failure path); the proven denial signal(s) -> `abandon` + (D12); the proven session/turn/agent cleanup signals -> scoped `abandon` + sweeps over adapter-owned tracked attempts only (D12, never global, never + touching `Untracked` executions — there are none in state); D11 + uncertain-boundary rules and the D13 successor-Start invariant; the D13 + recovery barrier + one quiescent `flush`. **No D10a successor-barrier / + lane-key code ships** (built-ins are Case A; MCP/unknown are `Untracked`). + The driver reaches the runtime **only** through `super::mutation_scope::run_mutation_scope_from_payload` by building the generic wire payload as a string (D18/AC19). Inject the git-dir resolver and @@ -1381,15 +1949,15 @@ Persist this field in every plan; this is durable plan state, not chat state: `run_codex_subcommand`, and the AC1/AC7 assertions target that surface. - Dependencies: T02, T03 - Done when: focused unit tests with an injected seam cover every proven - event-to-operation mapping, fail-closed `PreToolUse` (exact Codex-native - response JSON/exit, AC7), write-ahead ordering (AC6), `pending_start` + - terminal -> abandon (D11), failed `Close` -> abandon + `recovery_pending` - (D11/AC13), the recovery barrier's branches including - successor-blocked-until-predecessor-recovered (AC12), the D10a handling for - T01's recorded case — Case B's lane-scoped successor barrier proven to sweep - a same-lane stale predecessor and leave an out-of-lane concurrent attempt - untouched (AC9a, AC11) — and, if T01 proved a deny applies, the - background-execution deny (AC15). AC1 routing test passes; + event-to-operation mapping, fail-closed `TrackedMutation` `PreToolUse` (exact + Codex-native response JSON/exit, AC7), the `Untracked`/`Delegation` + neutral-pass-through with an untouched state store (AC9b), the probe-13/14 + MCP sequences leaving no stale state / no zombie scope (AC9c/AC9d), + write-ahead ordering (AC6), `pending_start` + terminal -> abandon (D11), + failed `Close` -> abandon + `recovery_pending` (D11/AC13), the recovery + barrier's branches (AC12), and the built-in Case A failed-A-then-B path + (AC9a) — and, if T01 proved a deny applies, the background-execution deny + (AC15). AC1 routing test passes; `sce hooks codex-mutation-scope `AiExclusive` + `Closed` (AC8); - 2. failed tool with partial mutation -> the D10 disposition's assertion - (Close-and-attribute, or abandon-then-`IneligibleUnscoped` after flush) - (AC9); - 2a. failed tool A (partial mutation, no terminal event) followed by - `PreToolUse(B)` in the same turn -> A is `Abandoned` and the worktree - re-baselined before B's `Start`; B is the only live scope at its - `Start`; the mutation-event stream contains **no** `AiContended` row - attributable to the A/B overlap; and an out-of-lane concurrent attempt - (if T01's model allows one) is not swept (AC9a) — asserted per T01's - recorded D10a case; if Case C, this row is replaced by a comment - pointing at the stopped-for-re-planning disposition; - 3. two overlapping mutation executions -> `AiContended` (the D14 form T01 - allows) (AC10); - 4. duplicate `Pre`/terminal delivery -> idempotent, no second transition - (AC4); - 5. denied execution -> no mutation runs under an untracked `Start` - (AC7 production half); - 6. interrupted / stale execution -> conservative abandonment via the proven + adapted to T01 findings and re-planning direction B (D23): + 1. `Bash` successful mutation -> tracked / `AiExclusive` / `Closed` (AC8); + 2. `Bash` partial mutation + non-zero exit -> tracked / `Closed` (partial + mutation attributed to that scope) (AC9); + 3. `apply_patch` success -> tracked / `Closed` (AC8); + 4. `apply_patch` verification failure -> no mutation, safe cleanup, no scope + to close (AC9); + 5. duplicate tracked lifecycle (`Pre`/terminal redelivery) -> idempotent, no + second transition (AC4); + 6. interrupted tracked execution -> abandonment / recovery via the proven D12 signal (AC11); - 7. reused raw Codex tool identifier after terminal -> new `ScopeId` (AC5); - 8. cwd/worktree isolation -> correct `WorktreeId`/cursor, other worktree + 7. subagent tracked tool -> independent scope identity (its `agent_id`); + 8. linked worktree -> correct `WorktreeId` / cursor, other worktree unchanged (AC14); - 9. crash before `Start` commit -> conservative recovery (AC21a); - 10. `Start` committed before bookkeeping settlement -> abandonment recovery, - not late-`Start` (AC21b); - 11. terminal transition committed before bookkeeping cleanup -> replay-safe - (AC21c); - 12. `recovery_pending` blocks a successor until recovery succeeds (AC12); - 13. any unsupported background execution is rejected / documented (AC15); - 14. mutation-scope path does not alter `diff_traces`, - `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts` - (before/after row counts) (AC20). + 9. **MCP success -> allowed, no scope** — full `PreToolUse → PostToolUse` + MCP lifecycle (probe-12 shape) driven live via `fixtures/mcp_probe/` + leaves zero mutation-scope rows/events attributable to the MCP execution + and an untouched adapter state store (AC9b); + 10. **MCP mutate-then-error -> allowed, no scope, no zombie state** — the + probe-13 lifecycle (MCP mutates a git-visible file, returns error, **no + `PostToolUse`**, then `Stop`/`SessionEnd`): no stale attempt, no + `recovery_pending`, no `abandon`, no zombie scope, because no `Start` + occurred (AC9c); + 11. **failed MCP A -> successor tracked B** (probe-14 shape) -> B (`Bash` / + `apply_patch`) `Start`s normally as the only live scope; no stale MCP + state exists to interfere; no false `AiContended` (AC9d); + 12. **parallel MCP executions** (probe-16/17 shape) -> both allowed, neither + creates a scope, **no MCP-derived `AiContended`**, no adapter state leak + (AC9e); + 13. **tracked `Bash`/`apply_patch` overlapping an MCP mutation** -> the + runtime result (`AiExclusive` on the tracked scope) is asserted **and** + documented as *tracked-scope exclusivity, not sole authorship* — MCP did + mutate in the interval; the generic protocol is not changed to force + `AiContended` (AC9f); + 14. **unknown tool -> allowed untracked** — `PreToolUse()` + returns the neutral response, no scope, no bookkeeping (AC9b); + 15. raw Agent Trace tables (`diff_traces`, + `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`) + remain untouched by the mutation-scope adapter (before/after row counts) + (AC20). + Plus the crash/recovery rows: crash before `Start` commit -> conservative + recovery (AC21a); `Start` committed before bookkeeping settlement -> + abandonment recovery, not late-`Start` (AC21b); terminal transition committed + before bookkeeping cleanup -> replay-safe (AC21c); `recovery_pending` blocks + a tracked successor until recovery succeeds (AC12); reused raw Codex tool + identifier after terminal -> new `ScopeId` (AC5); any unsupported background + execution is rejected / documented (AC15); denied tracked execution -> no + mutation under an untracked `Start` (AC7 production half); cross-harness + overlap -> `AiContended` (AC10). + The mutation-scope regressions use **real** temporary Git repos and real + Agent Trace DBs. The live MCP fixtures (`fixtures/mcp_probe/`, probes 12–17) + remain evidence fixtures and are **reused** to drive rows 9–13 rather than + re-running `codex exec` in ordinary unit tests. 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 or - detached-child detection; any test that inserts the event it means to prove. + detached-child detection; any test that inserts the event it means to prove; + any regression asserting an MCP execution itself produces mutation-scope + attribution. - Dependencies: T04 (T05 only for a test that installs generated settings — prefer driving the adapter entry point directly). - Done when: the whole matrix passes and collectively satisfies AC4, AC5, - AC7 (production half), AC8, AC9, AC9a, AC10, AC11, AC12, AC14, AC15, AC20, - AC21. + AC7 (production half), AC8, AC9, AC9a, AC9b, AC9c, AC9d, AC9e, AC9f, AC10, + AC11, AC12, AC14, AC15, AC20, AC21. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex_mutation_scope`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml @@ -1524,18 +2113,24 @@ Persist this field in every plan; this is durable plan state, not chat state: - [ ] T07: `Author the durable Codex mutation-scope context` (status:todo) - Task ID: T07 - Scope: In — create `context/cli/codex-mutation-scope-integration.md` owning - the Codex adapter domain (the tool-execution scope model, the D2 - classification table, D3 execution identity, D4 `ScopeId`/`EventId` - derivation, D5/D6 bookkeeping store, D7 write-ahead, D8 exact Codex-native - fail-closed response, D9/D10 terminal boundaries, D10a the failed-tool -> - successor-tool handling and the recorded case (A/B/C) with the successor-Start - invariant and the lane key, D12 cleanup signals + the load-bearing backstop, - D13 recovery barrier, D15 worktree/cwd ownership, D14 concurrency, D16 - background/detached limitations, D17 command architecture, D18 dependency - direction, and D20/D21/D22 the Codex hook trust-identity preservation, - three-dimension doctor health model, and upstream-verified event key - labels), with an explicit **Unsupported** section (AC24) and the tested Codex - version; and update + the Codex adapter domain (the tool-execution scope model, the D2 **three-class** + classification table (`TrackedMutation` / `Delegation` / `Untracked`), the + **D23 partial-by-tool-surface coverage boundary** — the coverage table and + the "MCP calls remain usable but are not individually attributed" statement + plus *why* (the T01 probe findings), D3 execution identity, D4 + `ScopeId`/`EventId` derivation, D5/D6 bookkeeping store, D7 write-ahead, D8 + exact Codex-native fail-closed response (tracked only), D9/D10 terminal + boundaries, D10a the failed-tool -> successor-tool handling (Case A for + built-ins; Case C *if MCP were modeled as a scope*, resolved by not modeling + it), D12 cleanup signals + the load-bearing backstop, D13 recovery barrier, + D15 worktree/cwd ownership, D14 concurrency (including that `AiExclusive` is + tracked-scope exclusivity, not sole authorship), D16 background/detached + limitations, D17 command architecture, D18 dependency direction, and + D20/D21/D22 the Codex hook trust-identity preservation, three-dimension + doctor health model, and upstream-verified event key labels), with an + explicit **Unsupported / Coverage boundary** section (AC24) naming the future + work (first-class MCP attribution via a richer lifecycle mechanism in a + separate PR) and the tested Codex version; and update `context/cli/mutation-scope-runtime.md`, `context/cli/mutation-scope-hook-ingress.md`, `context/sce/agent-trace-hooks-command-routing.md`, @@ -1571,25 +2166,81 @@ built specifically so this adapter would be additive. There is no smaller version worth naming — an adapter that does not establish `Start` before the tool runs, or does not fail closed, is not a correct adapter. -**T01 outcome (codex-cli 0.153.4, upstream `openai/codex` `rust-v0.153.4` / -`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`): Codex is representable by the -current mutation-scope contract — the plan proceeds to T02.** Every empirical -risk below is resolved by a captured fixture or an upstream schema citation -(see the Design section dispositions and +**T01 outcome + re-planning (codex-cli 0.153.4, upstream `openai/codex` +`rust-v0.153.4` / `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`): built-in +`Bash` / `apply_patch` is representable by the current mutation-scope contract. +The MCP mutation-scope lifecycle is D10a Case C *if MCP is modeled as a scope*. +Re-planning (2026-09-08) chose direction B (D23): MCP and unknown tools are +`Untracked` — usable, may mutate, but outside the Codex adapter's mutation-scope +coverage; no protocol change. T01 is done; T02 is unblocked.** Every built-in +risk is resolved by a captured fixture or an upstream schema citation; the MCP +risk is resolved by exclusion, with the Case C evidence retained as the reason +(see the Design section dispositions, D23, and `cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md`). Headline resolutions: -- **D10a is Case A / a non-issue on 0.153.4.** A failed shell tool still fires - `PostToolUse`; a failed `apply_patch` writes nothing; interruption ends the - turn. No serial-lane successor barrier ships. +- **D10a is Case A for built-in `Bash` / `apply_patch` only.** A failed shell + tool still fires `PostToolUse`; a failed `apply_patch` writes nothing; + interruption ends the turn. No serial-lane successor barrier ships for + built-ins. +- **D10a is Case C for MCP *if MCP were modeled as a scope* — resolved by NOT + modeling MCP as a scope (direction B, D23).** The T01 MCP + extension (probes 12–17) proves, live, all three Case C conditions at once, + and this evidence stands unchanged: + 1. a mutation-capable MCP tool can **mutate a git-visible file and then return + `is_error:true` with NO terminal hook** (probe 13: + `PreToolUse → Stop → SessionEnd`, `mcp_b.txt` present in `git status`). + Upstream: `registry.rs` ~674 gates `PostToolUse` on + `success_for_logging()`; MCP's is `CallToolResult.success()`, false on + `is_error:true`. + 2. **no positive cleanup signal precedes a successor** — probe 14: + `PreToolUse(A = mutate_then_error)` → `PreToolUse(B = mutate_success)` with + **no event of any kind between them**; A's `tool_use_id` never recurs. + 3. **same-lane MCP executions can overlap** — probes 16/17: two + mutation-capable MCP executions run genuinely concurrently (~8 s window, + `PreToolUse`s ~1 ms apart, confirmed by the MCP server's own log). Enabled + by the config key `[mcp_servers.] supports_parallel_tool_calls = true` + **or** the tool's own `annotations.readOnlyHint` + (`McpHandler::supports_parallel_tool_calls()`). So `PreToolUse(B)` cannot + prove A stale, and there is no narrower serial lane than + `(session_id, turn_id)`. + If MCP were a scope, the adapter could not distinguish `failed-and-dead A` from + `still-running A` — exactly the stale-scope problem D10a was added to prevent. + **Re-planning decision (2026-09-08):** + - **A — deny mutation-capable MCP `PreToolUse` fail-closed. REJECTED.** Too + disruptive (MCP tools unusable inside Codex under SCE); also needs a rule to + tell "mutation-capable MCP" from "read-only MCP" that cannot trust the + server's own `readOnlyHint`. + - **B — MCP (and unknown) tools are `Untracked`: allowed, may mutate, no + `Start`, no scope, explicitly outside mutation-scope coverage. CHOSEN for + Codex adapter v1** (D23). The un-attributed-MCP-mutation gap is an explicit, + documented coverage boundary, never a silent gap. No protocol / Quint / + SQL / schema change. + - **C — a richer lifecycle/runtime mechanism** (per-`tool_use_id` MCP scope + retired only by `PostToolUse`, or an overlap-tolerant turn-boundary sweep, + plus an `AiContended`-aware successor policy). DEFERRED as possible future + work in a separate, explicitly justified PR — "investigate first-class MCP + mutation attribution using a richer lifecycle mechanism". + The adapter must **not** silently downgrade MCP/unknown to read-only; direction + B allows them *explicitly* untracked, documented as a coverage boundary. +- **Parallel MCP execution is real** (probes 16/17) but produces **no tracked + scopes** under direction B, so there is **no MCP-derived `AiContended`** and + **no MCP-overlap `AiContended` regression** in T06. `Bash`-overlapping-MCP is + covered by AC9f/T06 row 13, asserting `AiExclusive` = tracked-scope + exclusivity (not sole authorship). Cross-harness `AiContended` remains + reachable and is the AC10/T06 form. - **`SessionEnd` is the load-bearing cleanup backstop** (fires on clean exit and on SIGINT); `Interrupt` is an additional earlier interruption signal the plan - did not know about (Codex has **12** hook events, not 11). + did not know about (Codex has **12** hook events, not 11). For MCP, + `SessionEnd` would be a **whole-turn-late** backstop — which is exactly why MCP + cannot be modeled as a scope; under direction B (D23) no MCP attempt is created + for any signal to retire. - **The fail-closed `PreToolUse` response**: both `{"decision":"block"}` and - `hookSpecificOutput.permissionDecision:"deny"` block the tool; T04 emits the - `hookSpecificOutput` shape. -- **Codex runs mutation-capable tools strictly serially** → Codex-alone - `AiContended` is not reachable; the AC10 regression crosses harnesses. + `hookSpecificOutput.permissionDecision:"deny"` block the tool (probes 3/4/15); + T04 emits the `hookSpecificOutput` shape for a **`TrackedMutation`** failure + only — MCP/unknown are never denied for being untracked (D8/D23). +- **MCP tool naming** is `mcp____` with an `exec-` + `tool_use_id` (D2/D3 unchanged for MCP). - **Codex exposes a delegated-agent identity** (`agent_id`, on subagent events only) — the plan uses it and does not invent one. - **Command architecture**: T01 found nothing against the recommended separate @@ -1598,23 +2249,21 @@ resolutions: `codex-rs/hooks/src/lib.rs` 96–108; a position-stable additive merge is a T05 implementation constraint, not an unknown. -The real risks are empirical, not architectural, and every one is deliberately -deferred to T01 evidence rather than guessed here: - -- **Failed tool with no terminal event, then another tool in the same turn.** - (D10a — the single highest-risk correctness question.) Codex has no - `PostToolUseFailure` and prior SCE research found `PostToolUse` fires only on - success, so a mutation-capable tool A that mutated then failed can leave no - terminal hook. If `PreToolUse(B)` then arrives before any `Stop`/`SessionEnd`, - the `recovery_pending` barrier is unarmed and B would `Start` alongside a - zombie live A — false `AiContended` or misattribution. T01's dedicated probe - records one of: **Case A** (a reliable intermediate signal retires A), - **Case B** (Codex is proven to run mutation-capable tools serially within a - narrow identity-defined lane, so `PreToolUse(B)` proves same-lane A stale and - the adapter runs a lane-scoped abandon+flush before `Start(B)`), or **Case C** - (neither is safe -> unsupported lifecycle, stop for re-planning). The - invariant: B must never `Start` while a known-stale A is live; and a - legitimately-concurrent out-of-lane attempt must never be swept for it. +The real risks were empirical, not architectural. T01 resolved the built-in ones +with fixtures and the MCP one by an explicit coverage boundary: + +- **RESOLVED — Failed tool with no terminal event, then another tool in the same + turn.** (D10a — the single highest-risk correctness question.) For built-in + `Bash` / `apply_patch`: **Case A** — a failed shell tool still fires + `PostToolUse`, a failed `apply_patch` writes nothing, and interruption ends the + turn; no barrier ships. For **MCP: Case C *if modeled as a scope*** — probes + 13/14/16/17 prove a mutation-capable MCP tool can mutate-then-fail with no + terminal hook, no cleanup signal reaches the adapter before the successor + `PreToolUse`, and same-lane MCP executions can overlap. **Resolved by direction + B (D23):** MCP/unknown are `Untracked`, so no MCP attempt or scope exists, the + D10a tension never arises for them, and the un-attributed MCP mutation is a + documented coverage boundary. The MCP lifecycle did **not** become safe — it is + simply out of scope for the v1 adapter. - **Is there any reliable terminal signal for a failed mutation-capable tool at all?** (D10.) Even outside the successor case, the failed-partial-mutation interval with no `Close` is bounded only by the next lifecycle signal + a @@ -1628,9 +2277,14 @@ deferred to T01 evidence rather than guessed here: version?** (D8.) `permissionDecision: "deny"`, `{"decision":"block"}`, or a non-zero exit — version-dependent, and the adapter must emit exactly the one that blocks the tool. -- **Can Codex overlap its own mutation-capable executions?** (D1/D14.) If not, - Codex-alone `AiContended` is unreachable and the concurrency regression must - cross harnesses. +- **RESOLVED — Can Codex overlap its own mutation-capable executions?** (D1/D14.) + **Yes, via MCP** (probes 16/17). Built-in `Bash` / `apply_patch` remained + serial across all 11 built-in probes. Under direction B, MCP executions are + `Untracked` and produce no tracked scopes, so there is **no MCP-derived + `AiContended`**; the T06 concurrency regression exercises the **cross-harness** + form only, and a separate `Bash`-overlapping-MCP regression documents the + `AiExclusive` = tracked-scope-exclusivity (not sole-authorship) semantic + (AC9f). - **Does Codex expose any delegated-agent identity?** (D3.) If not, there is no per-agent cleanup sweep and no `agent_id` in the `ScopeId` — and the plan must not invent one. @@ -1648,10 +2302,15 @@ deferred to T01 evidence rather than guessed here: blocking — this is a T05 implementation constraint, not an unknown. No Quint / mutation-cursor protocol / mutation-trace SQL migration / Agent Trace -schema / attribution-algorithm change is expected: the generic mutation-scope -contract already models `Start`/`Advance`/`Close`/`Flush`/`abandon`, already -accepts `ActorKind::Codex`, and already handles replay idempotency, conservative -recovery, and `AiContended` — the Claude adapter proved the contract is -sufficient for a concrete harness without touching any of those. If T01 proves -Codex genuinely cannot be represented by it, T01 stops and records the -contradiction here for re-planning; it does not quietly modify the protocol. +schema / attribution-algorithm change is expected or required: the generic +mutation-scope contract already models `Start`/`Advance`/`Close`/`Flush`/ +`abandon`, already accepts `ActorKind::Codex`, and already handles replay +idempotency, conservative recovery, and `AiContended` — the Claude adapter proved +the contract is sufficient for a concrete harness without touching any of those. +The T01 MCP D10a Case C finding did **not** force a protocol change: re-planning +direction B (D23) resolves it entirely within the Codex adapter's coverage +boundary — MCP and unknown tools are `Untracked`. The runtime already models +exclusivity among the tracked scopes it is told about, not global filesystem +authorship, so nothing formal changes. Future work (direction C — first-class MCP +attribution via a richer lifecycle mechanism) would be a separate, explicitly +justified PR. From 4589de732c656a299da1112de215a46404104ccc Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 10:27:28 +0200 Subject: [PATCH 04/20] hooks: Implement Codex mutation-scope event foundation Add strict Codex hook event parsing, tool classification, and deterministic attempt scope ID formatters for the mutation-scope adapter. Register the module and record T02's architecture decision and completion evidence in the integration plan. Plan: codex-mutation-scope-integration (T02) Co-authored-by: SCE --- .../hooks/codex_mutation_scope/mod.rs | 706 ++++++++++++++++++ cli/src/services/hooks/mod.rs | 1 + .../plans/codex-mutation-scope-integration.md | 99 ++- 3 files changed, 802 insertions(+), 4 deletions(-) create mode 100644 cli/src/services/hooks/codex_mutation_scope/mod.rs diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs new file mode 100644 index 00000000..ab6c6e54 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -0,0 +1,706 @@ +#![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 TURN_ID_FIELD: &str = "turn_id"; +const CWD_FIELD: &str = "cwd"; +const AGENT_ID_FIELD: &str = "agent_id"; +const AGENT_TYPE_FIELD: &str = "agent_type"; +const TOOL_NAME_FIELD: &str = "tool_name"; +const TOOL_USE_ID_FIELD: &str = "tool_use_id"; + +const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; +const HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; +const HOOK_EVENT_STOP: &str = "Stop"; +const HOOK_EVENT_INTERRUPT: &str = "Interrupt"; +const HOOK_EVENT_SUBAGENT_STOP: &str = "SubagentStop"; +const HOOK_EVENT_SESSION_END: &str = "SessionEnd"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CodexHookEvent { + PreToolUse(CodexToolExecution), + PostToolUse(CodexToolIdentity), + Stop(CodexTurnIdentity), + Interrupt(CodexTurnIdentity), + SubagentStop(CodexAgentIdentity), + SessionEnd(CodexSessionIdentity), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexToolIdentity { + pub session_id: String, + pub turn_id: String, + pub cwd: String, + pub agent_id: Option, + pub tool_name: String, + pub tool_use_id: String, +} + +impl CodexToolIdentity { + 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 CodexToolExecution { + pub identity: CodexToolIdentity, + pub agent_type: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexTurnIdentity { + pub session_id: String, + pub turn_id: String, + pub cwd: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexAgentIdentity { + pub session_id: String, + pub turn_id: String, + pub cwd: String, + pub agent_id: String, + pub agent_type: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexSessionIdentity { + pub session_id: String, + pub cwd: 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 { + TrackedMutation, + Delegation, + Untracked, +} + +const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["Bash", "apply_patch"]; +const DELEGATION_TOOL_NAMES: &[&str] = &["collaborationspawn_agent", "collaborationwait_agent"]; +const MCP_TOOL_NAME_PREFIX: &str = "mcp__"; + +pub(crate) fn is_mcp_tool_name(tool_name: &str) -> bool { + tool_name.starts_with(MCP_TOOL_NAME_PREFIX) +} + +pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { + if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::TrackedMutation + } else if DELEGATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::Delegation + } else { + ToolClassification::Untracked + } +} + +const CODEX_SCOPE_ID_SCHEME: &str = "cx-tool-v1"; + +pub(crate) fn format_codex_scope_id(attempt_seq: u64, key: &AttemptKey) -> String { + let agent_id = key.agent_id.as_deref().unwrap_or(""); + format!( + "{CODEX_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 codex_scope_start_event_id(scope_id: &str) -> String { + format!("{scope_id}|start") +} + +pub(crate) fn codex_scope_close_event_id(scope_id: &str) -> String { + format!("{scope_id}|close") +} + +pub(crate) fn parse_codex_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(CodexHookEvent::PreToolUse), + HOOK_EVENT_POST_TOOL_USE => parse_tool_identity(object).map(CodexHookEvent::PostToolUse), + HOOK_EVENT_STOP => parse_turn_identity(object).map(CodexHookEvent::Stop), + HOOK_EVENT_INTERRUPT => parse_turn_identity(object).map(CodexHookEvent::Interrupt), + HOOK_EVENT_SUBAGENT_STOP => parse_agent_identity(object).map(CodexHookEvent::SubagentStop), + HOOK_EVENT_SESSION_END => parse_session_identity(object).map(CodexHookEvent::SessionEnd), + other => bail!(validation_error(&format!( + "unsupported hook_event_name '{other}'" + ))), + } +} + +fn parse_tool_identity(object: &Map) -> Result { + Ok(CodexToolIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + agent_id: optional_non_blank_str(object, AGENT_ID_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + tool_use_id: required_non_blank_str(object, TOOL_USE_ID_FIELD)?, + }) +} + +fn parse_pre_tool_use(object: &Map) -> Result { + Ok(CodexToolExecution { + identity: parse_tool_identity(object)?, + agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, + }) +} + +fn parse_turn_identity(object: &Map) -> Result { + Ok(CodexTurnIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +fn parse_agent_identity(object: &Map) -> Result { + Ok(CodexAgentIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + agent_id: required_non_blank_str(object, AGENT_ID_FIELD)?, + agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, + }) +} + +fn parse_session_identity(object: &Map) -> Result { + Ok(CodexSessionIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_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 Codex hook event payload from STDIN: {detail}.") +} + +#[cfg(test)] +mod tests { + use super::*; + + const PROBE01_SHELL_PRE: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.shell.pre_tool_use.json"); + const PROBE01_SHELL_POST: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.shell.post_tool_use.json"); + const PROBE01_APPLY_PATCH_PRE: &str = include_str!( + "fixtures/probe01-apply-patch-and-shell-success.apply_patch.pre_tool_use.json" + ); + const PROBE01_STOP: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.stop.json"); + const PROBE01_SESSION_END: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.session_end.json"); + const PROBE02_FAILED_SHELL_POST: &str = + include_str!("fixtures/probe02-shell-partial-write-then-nonzero-exit.post_tool_use.json"); + const PROBE04_BLOCKED_PRE: &str = include_str!( + "fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.pre_tool_use.json" + ); + const PROBE05_SHELL_PRE: &str = + include_str!("fixtures/probe05-tool-vocabulary.shell-read-list-search.pre_tool_use.json"); + const PROBE08_SPAWN_AGENT_PRE: &str = + include_str!("fixtures/probe08-subagent-delegation.spawn_agent.pre_tool_use.json"); + const PROBE08_WAIT_AGENT_PRE: &str = + include_str!("fixtures/probe08-subagent-delegation.wait_agent.pre_tool_use.json"); + const PROBE08_AGENT_APPLY_PATCH_PRE: &str = + include_str!("fixtures/probe08-subagent-delegation.agent-apply-patch.pre_tool_use.json"); + const PROBE08_AGENT_APPLY_PATCH_POST: &str = + include_str!("fixtures/probe08-subagent-delegation.agent-apply-patch.post_tool_use.json"); + const PROBE08_SUBAGENT_STOP: &str = + include_str!("fixtures/probe08-subagent-delegation.subagent_stop.json"); + const PROBE10_WORKTREE_PRE: &str = + include_str!("fixtures/probe10-linked-worktree-cwd.pre_tool_use.json"); + const PROBE11_INTERRUPT: &str = + include_str!("fixtures/probe11-interrupt-event-on-sigint.interrupt.json"); + const PROBE12_MCP_PRE: &str = + include_str!("fixtures/probe12-mcp-mutate-success.pre_tool_use.json"); + const PROBE12_MCP_POST: &str = + include_str!("fixtures/probe12-mcp-mutate-success.post_tool_use.json"); + const PROBE13_MCP_MUTATE_THEN_ERROR_PRE: &str = + include_str!("fixtures/probe13-mcp-mutate-then-error.pre_tool_use.json"); + const PROBE13_MCP_SESSION_END: &str = + include_str!("fixtures/probe13-mcp-mutate-then-error.session_end.json"); + + fn pre_tool_use_json(overrides: &[(&str, Value)]) -> String { + let mut object = 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( + TURN_ID_FIELD.to_string(), + Value::String("turn-1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("/repo/checkout".to_string()), + ); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("Bash".to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String("exec-1".to_string()), + ); + for (field, value) in overrides { + object.insert((*field).to_string(), value.clone()); + } + Value::Object(object).to_string() + } + + 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(), + } + } + + fn pre_tool_use(payload: &str) -> CodexToolExecution { + match parse_codex_hook_event(payload).expect("valid PreToolUse parses") { + CodexHookEvent::PreToolUse(execution) => execution, + other => panic!("expected PreToolUse, got {other:?}"), + } + } + + #[test] + fn ac2_empty_payload_is_rejected() { + let error = parse_codex_hook_event(" ").unwrap_err().to_string(); + assert_eq!( + error, + "Invalid Codex hook event payload from STDIN: expected a JSON object, got an empty payload." + ); + } + + #[test] + fn ac2_non_object_json_is_rejected() { + for payload in ["[]", "\"PreToolUse\"", "42", "null"] { + let error = parse_codex_hook_event(payload).unwrap_err().to_string(); + assert!( + error.contains("expected a JSON object"), + "payload {payload:?} produced {error:?}" + ); + } + } + + #[test] + fn ac2_invalid_json_is_rejected() { + let error = parse_codex_hook_event("{not json").unwrap_err().to_string(); + assert!( + error.contains("Invalid Codex hook event payload from STDIN: expected valid JSON"), + "{error:?}" + ); + } + + #[test] + fn ac2_unsupported_hook_event_name_is_rejected() { + for name in [ + "SessionStart", + "SubagentStart", + "UserPromptSubmit", + "PreCompact", + ] { + let payload = + pre_tool_use_json(&[(HOOK_EVENT_NAME_FIELD, Value::String(name.to_string()))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("unsupported hook_event_name '{name}'")), + "{error:?}" + ); + } + } + + #[test] + fn ac2_missing_required_fields_are_rejected_without_fabricating_identity() { + for field in [ + SESSION_ID_FIELD, + TURN_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + TOOL_USE_ID_FIELD, + ] { + let mut object: Map = + serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); + object.remove(field); + let payload = Value::Object(object).to_string(); + + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("'{field}'")), + "missing {field} produced {error:?}" + ); + } + } + + #[test] + fn ac2_blank_required_fields_are_rejected() { + for field in [SESSION_ID_FIELD, TURN_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { + let payload = pre_tool_use_json(&[(field, Value::String(" ".to_string()))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("field '{field}' must be a non-blank string")), + "blank {field} produced {error:?}" + ); + } + } + + #[test] + fn ac2_wrong_typed_fields_are_rejected() { + let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::Bool(true))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'tool_use_id' must be a string"), + "{error:?}" + ); + } + + #[test] + fn ac2_wrong_typed_optional_agent_id_is_rejected() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Bool(false))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'agent_id' must be null, absent, or a non-blank string"), + "{error:?}" + ); + } + + #[test] + fn ac2_pre_tool_use_fixtures_parse_to_expected_identity() { + let shell = pre_tool_use(PROBE01_SHELL_PRE); + assert_eq!(shell.identity.tool_name, "Bash"); + assert_eq!( + shell.identity.tool_use_id, + "exec-414820f5-555e-457a-92e7-60ddd27d4eec" + ); + assert_eq!( + shell.identity.session_id, + "01a07c1e-e08e-7172-8032-cb9d62af21d9" + ); + assert_eq!( + shell.identity.turn_id, + "01a07c1e-e0cc-75f1-a566-e790c06cb033" + ); + assert_eq!(shell.identity.agent_id, None); + assert!(!shell.identity.is_subagent()); + assert!(shell.identity.cwd.ends_with("/probe-repo")); + + let apply_patch = pre_tool_use(PROBE01_APPLY_PATCH_PRE); + assert_eq!(apply_patch.identity.tool_name, "apply_patch"); + + let vocab = pre_tool_use(PROBE05_SHELL_PRE); + assert_eq!(vocab.identity.tool_name, "Bash"); + + let worktree = pre_tool_use(PROBE10_WORKTREE_PRE); + assert!(worktree.identity.cwd.ends_with("/probe-worktree")); + + let mcp = pre_tool_use(PROBE12_MCP_PRE); + assert_eq!(mcp.identity.tool_name, "mcp__probe__mutate_success"); + + let mcp_err = pre_tool_use(PROBE13_MCP_MUTATE_THEN_ERROR_PRE); + assert!(is_mcp_tool_name(&mcp_err.identity.tool_name)); + } + + #[test] + fn ac2_subagent_pre_tool_use_fixture_carries_agent_identity() { + let execution = pre_tool_use(PROBE08_AGENT_APPLY_PATCH_PRE); + assert_eq!( + execution.identity.agent_id.as_deref(), + Some("01a07c24-bb59-7ca0-80f7-99cf940a486e") + ); + assert!(execution.identity.is_subagent()); + assert_eq!(execution.agent_type.as_deref(), Some("default")); + } + + #[test] + fn ac2_post_tool_use_fixtures_parse() { + for (payload, tool_name, tool_use_id) in [ + ( + PROBE01_SHELL_POST, + "Bash", + "exec-414820f5-555e-457a-92e7-60ddd27d4eec", + ), + ( + PROBE02_FAILED_SHELL_POST, + "Bash", + "exec-52155265-e98d-423f-87f8-76ee56ff33b1", + ), + ( + PROBE12_MCP_POST, + "mcp__probe__mutate_success", + "exec-00988fad-6707-48ed-81b6-07bb11933886", + ), + ] { + match parse_codex_hook_event(payload).expect("PostToolUse fixture parses") { + CodexHookEvent::PostToolUse(identity) => { + assert_eq!(identity.tool_name, tool_name); + assert_eq!(identity.tool_use_id, tool_use_id); + } + other => panic!("expected PostToolUse, got {other:?}"), + } + } + } + + #[test] + fn ac2_subagent_post_tool_use_ties_to_its_pre_tool_use() { + let CodexHookEvent::PostToolUse(post) = + parse_codex_hook_event(PROBE08_AGENT_APPLY_PATCH_POST).unwrap() + else { + panic!("expected PostToolUse"); + }; + let pre = pre_tool_use(PROBE08_AGENT_APPLY_PATCH_PRE); + assert_eq!(post.attempt_key(), pre.identity.attempt_key()); + assert!(post.attempt_key().agent_id.is_some()); + } + + #[test] + fn ac2_terminal_lifecycle_fixtures_parse() { + assert!(matches!( + parse_codex_hook_event(PROBE01_STOP).unwrap(), + CodexHookEvent::Stop(id) if id.turn_id == "01a07c1e-e0cc-75f1-a566-e790c06cb033" + )); + assert!(matches!( + parse_codex_hook_event(PROBE11_INTERRUPT).unwrap(), + CodexHookEvent::Interrupt(id) if id.session_id == "01a07c2f-ccbf-79f0-afb9-2d2ce919eea7" + )); + assert!(matches!( + parse_codex_hook_event(PROBE08_SUBAGENT_STOP).unwrap(), + CodexHookEvent::SubagentStop(id) + if id.agent_id == "01a07c24-bb59-7ca0-80f7-99cf940a486e" + )); + for session_end in [PROBE01_SESSION_END, PROBE13_MCP_SESSION_END] { + assert!(matches!( + parse_codex_hook_event(session_end).unwrap(), + CodexHookEvent::SessionEnd(_) + )); + } + } + + #[test] + fn ac2_session_end_needs_no_turn_id() { + let CodexHookEvent::SessionEnd(identity) = + parse_codex_hook_event(PROBE01_SESSION_END).unwrap() + else { + panic!("expected SessionEnd"); + }; + assert_eq!(identity.session_id, "01a07c1e-e08e-7172-8032-cb9d62af21d9"); + } + + #[test] + fn ac3_classification_table() { + let cases: &[(&str, ToolClassification)] = &[ + ("Bash", ToolClassification::TrackedMutation), + ("apply_patch", ToolClassification::TrackedMutation), + ("collaborationspawn_agent", ToolClassification::Delegation), + ("collaborationwait_agent", ToolClassification::Delegation), + ("mcp__probe__mutate_success", ToolClassification::Untracked), + ("mcp__probe_par__slow_mutate", ToolClassification::Untracked), + ("mcp__", ToolClassification::Untracked), + ("Read", ToolClassification::Untracked), + ("PowerShell", ToolClassification::Untracked), + ("some_future_codex_tool", ToolClassification::Untracked), + ("", ToolClassification::Untracked), + ]; + for (tool_name, expected) in cases { + assert_eq!( + classify_tool(tool_name), + *expected, + "classify_tool({tool_name:?})" + ); + } + } + + #[test] + fn ac3_classification_is_total_and_single_valued() { + for tool_name in [ + "Bash", + "apply_patch", + "collaborationspawn_agent", + "collaborationwait_agent", + "mcp__x__y", + "unknown", + ] { + let _: ToolClassification = classify_tool(tool_name); + } + } + + #[test] + fn ac3_delegation_and_untracked_tool_fixtures_do_not_yield_a_tracked_scope() { + for payload in [ + PROBE08_SPAWN_AGENT_PRE, + PROBE08_WAIT_AGENT_PRE, + PROBE12_MCP_PRE, + PROBE13_MCP_MUTATE_THEN_ERROR_PRE, + ] { + let execution = pre_tool_use(payload); + let classification = classify_tool(&execution.identity.tool_name); + assert_ne!( + classification, + ToolClassification::TrackedMutation, + "tool {:?} must not be TrackedMutation", + execution.identity.tool_name + ); + } + + assert_eq!( + classify_tool(&pre_tool_use(PROBE04_BLOCKED_PRE).identity.tool_name), + ToolClassification::TrackedMutation + ); + } + + #[test] + fn ac3_is_mcp_tool_name() { + assert!(is_mcp_tool_name("mcp__probe__mutate_success")); + assert!(is_mcp_tool_name("mcp__")); + assert!(!is_mcp_tool_name("Bash")); + assert!(!is_mcp_tool_name("apply_patch")); + assert!(!is_mcp_tool_name("collaborationspawn_agent")); + } + + #[test] + fn ac4_scope_id_is_deterministic_for_the_same_attempt_seq_and_key() { + let k = key("session-1", None, "exec-1"); + assert_eq!(format_codex_scope_id(7, &k), format_codex_scope_id(7, &k)); + + let scope_id = format_codex_scope_id(7, &k); + assert_eq!(scope_id, "cx-tool-v1|n=7|s=9:session-1|a=0:|t=6:exec-1"); + assert_eq!( + codex_scope_start_event_id(&scope_id), + format!("{scope_id}|start") + ); + assert_eq!( + codex_scope_close_event_id(&scope_id), + format!("{scope_id}|close") + ); + } + + #[test] + fn ac4_length_prefix_disambiguates_delimiter_collisions() { + let a = key("a:b", None, "c"); + let b = key("a", None, "b:c"); + assert_ne!(format_codex_scope_id(1, &a), format_codex_scope_id(1, &b)); + } + + #[test] + fn ac4_subagent_key_encodes_the_agent_id() { + let main = key("session-1", None, "exec-1"); + let sub = key("session-1", Some("agent-1"), "exec-1"); + assert_ne!( + format_codex_scope_id(1, &main), + format_codex_scope_id(1, &sub) + ); + assert_eq!( + format_codex_scope_id(1, &sub), + "cx-tool-v1|n=1|s=9:session-1|a=7:agent-1|t=6:exec-1" + ); + } + + #[test] + fn ac5_a_fresh_attempt_seq_yields_a_new_scope_id() { + let k = key("session-1", None, "exec-1"); + assert_ne!(format_codex_scope_id(1, &k), format_codex_scope_id(2, &k)); + assert!(format_codex_scope_id(2, &k).contains("|n=2|")); + } + + #[test] + fn ac5_attempt_key_excludes_turn_id() { + let base = pre_tool_use(&pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-9".to_string()), + )])); + let other_turn = pre_tool_use(&pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("exec-9".to_string())), + (TURN_ID_FIELD, Value::String("turn-99".to_string())), + ])); + assert_eq!( + base.identity.attempt_key(), + other_turn.identity.attempt_key() + ); + } +} diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 1af3648d..5746c0fb 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -43,6 +43,7 @@ pub mod claude_model_state; pub mod claude_mutation_scope; pub mod claude_transcript; pub mod codex; +pub mod codex_mutation_scope; pub mod command; pub mod lifecycle; pub mod mutation_scope; diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 08956fbd..7eef43cc 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -688,8 +688,10 @@ and there are none for MCP. This is a deliberate attribution-coverage boundary lifecycle is safe, does **not** silently downgrade MCP/unknown to read-only, and does **not** pretend an MCP mutation was attributed. -T02 records the D10a lane key as **N/A** — Case A for built-ins ships no barrier; -MCP/unknown are `Untracked` and ship no barrier and no scope. Evidence: +T02 records the D10a lane key as **N/A** (confirmed 2026-09-08) — Case A for +built-ins ships no barrier; MCP/unknown are `Untracked` and ship no barrier and +no scope. The T02 event model therefore carries no lane-key field and +`classify_tool` has no successor-sweep hook. Evidence: `fixtures/probe13-mcp-mutate-then-error.*`, `fixtures/probe14-mcp-failed-then-successor.*`, `fixtures/probe15-mcp-blocked-call.*`, @@ -958,6 +960,33 @@ group both execute (dump + block). A **separate hidden default; nothing in T01 argues against it. Evidence: `fixtures/NOTES.md`, `.codex/hooks.json` two-handler `PreToolUse` group used across probes 3–11. +**T02 decision (2026-09-08): option 1 — a separate hidden +`sce hooks codex-mutation-scope` command.** Confirmed against T01 and the code: + +- The mutation-scope adapter is fail-closed on `PreToolUse` and + never-silently-drop on terminal boundaries; the existing `sce hooks codex` + dispatcher (`hooks::codex`) is fail-open (errors -> empty stdout). Folding the + two into one process (option 2) would couple their failure postures for + `PreToolUse(shell)` and `PostToolUse(apply_patch)` — the exact events both + systems care about. +- T01 proved Codex runs each registered handler as its own OS process and that + two `PreToolUse` handlers in one matcher group both execute (dump + block + across probes 3–11), so a distinct command registered alongside + `sce hooks codex` runs independently — the direct analogue of Claude running + `sce policy bash` and `sce hooks claude-mutation-scope` side by side. +- `#263`'s Claude adapter set the precedent: a dedicated non-fail-open + `sce hooks claude-mutation-scope` command, not an arm of the fail-open + `sce hooks claude` dispatcher. + +Consequences for later tasks (unchanged from each task's current wording, which +already assumes option 1): T04 adds the hidden `HooksSubcommand::CodexMutationScope` +routed unwrapped like `mutation-scope`; T05 makes `codex_hook_config.rs` +command-aware (`CODEX_COMMAND_WORDS` becomes a set; `REQUIRED_EVENTS` records +which command owns each registration) and appends the mutation-scope +registrations position-stably after the existing `sce hooks codex` ones (D20). +D8's denial routing is unaffected — the mutation-scope command owns its own +`PreToolUse` registration. + ### D18 — Adapter depends on hooks::mutation_scope only Dependency direction is strictly @@ -1847,8 +1876,9 @@ Persist this field in every plan; this is durable plan state, not chat state: in this plan's Design section, Open questions, and `cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md`. -- [ ] T02: `Command architecture, Codex event model, classification, and identity` (status:todo) +- [x] T02: `Command architecture, Codex event model, classification, and identity` (status:done) - Task ID: T02 + - Completed: 2026-09-08 - **Unblocked (2026-09-08).** The T01 D10a Case C blocker is resolved by re-planning direction B (D23): MCP tools and unknown tool names are `Untracked` — they execute and may mutate, but the adapter creates no scope, @@ -1884,7 +1914,68 @@ 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::codex_mutation_scope`; `clippy` / `fmt` clean. - - Context synchronization: pending + - Files changed: + - `cli/src/services/hooks/codex_mutation_scope/mod.rs` (new — event model, + strict parser, three-class `classify_tool`, D3 `AttemptKey`, D4 + `cx-tool-v1` `ScopeId` / `EventId` formatters, 23 unit tests over the T01 + fixture corpus) + - `cli/src/services/hooks/mod.rs` (one line — `pub mod codex_mutation_scope;` + module-tree declaration; no CLI wiring) + - `context/plans/codex-mutation-scope-integration.md` (D17 T02 decision = + option 1; D10a lane-key N/A confirmation; this task record) + - Result: Froze the Codex mutation-scope adapter's foundation layer. D17 + decided as **option 1** — a separate hidden `sce hooks codex-mutation-scope` + command — recorded in D17 with the failure-posture, process-isolation, and + `#263`-precedent rationale. `codex_mutation_scope/mod.rs` contains: + `parse_codex_hook_event` (strict — empty / non-object / invalid-JSON / + unsupported `hook_event_name` / missing / blank / wrong-typed all rejected + with `Invalid Codex hook event payload from STDIN: .`, no fabricated + identity); `CodexHookEvent` limited to the six events the adapter registers + (`PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, `SubagentStop`, + `SessionEnd`); identity types carrying Codex's `turn_id` and subagent-only + `agent_id` / `agent_type`; `classify_tool` -> `TrackedMutation` + (`Bash`, `apply_patch`) / `Delegation` (`collaborationspawn_agent`, + `collaborationwait_agent`) / `Untracked` (`mcp__*` + any unknown name), with + no "mutation-capable therefore Start" path; `AttemptKey` + `(session_id, agent_id?, tool_use_id)` (turn_id excluded); `format_codex_scope_id` + (`cx-tool-v1|n=|s=:|a=:|t=:`, length-prefixed, + hash-free) and `codex_scope_{start,close}_event_id`. No durable state, no + ingress call, no CLI wiring, no generated settings, no D10a successor-barrier + (D10a lane key confirmed **N/A**). No background-execution classifier — T01 + D16 proved the `codex exec` shell tool has no `run_in_background` parameter. + - Verify: + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::codex_mutation_scope` — **passed** (23 + tests: AC2 parser/fixtures, AC3 classification table + delegation/untracked + fixtures, AC4 formatter determinism + length-prefix disambiguation, AC5 + fresh-seq / turn_id-excluded). + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::` — **passed** (354 tests; existing + `sce hooks codex` / mutation-scope suites unaffected). + - `clippy --all-targets -- -D warnings` — **clean**. + - `cargo fmt -- --check` — **clean** (after `cargo fmt`). + - Context impact: domain — new adapter-domain Rust module (event model, + classifier, identity/formatter contract) plus the D17 command-architecture + decision and the D10a N/A confirmation now exist in the plan's Design + section. No user-visible behaviour, no public CLI interface, no generated + config yet (all deferred to T04/T05). Durable Codex-adapter context + (`context/cli/codex-mutation-scope-integration.md`) is authored by T07 once + behaviour ships; the frozen contract lives in the plan's Design section + (D2/D3/D4/D17) and this record until then. + - Context synchronization: synced + - T02 ships an internal Rust foundation module with **no non-test caller** + (no CLI wiring, no ingress call, no `sce setup` registration), no + user-visible behaviour, no public interface, and no generated config. The + D17 / D10a decisions are plan-internal design state, not durable context. + The durable Codex-adapter context + (`context/cli/codex-mutation-scope-integration.md` and the cross-reference + edits to `context/overview.md`, `context/context-map.md`, + `context/cli/mutation-scope-hook-ingress.md`, etc.) is authored by **T07** + once behaviour ships — exactly as recorded for T01. Mandatory five-root + pass done: `overview.md`, `architecture.md`, `glossary.md`, `patterns.md`, + `context-map.md` all read and confirmed not contradicted (each still + correctly states Codex has no wired mutation-scope adapter). No + architecture decision qualified for an ADR. - [ ] T03: `Durable checkout-local Codex adapter state and recovery bookkeeping` (status:todo) - Task ID: T03 From a9a53d0b2d9efb20146d4e985284e304bb260ce0 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 10:42:21 +0200 Subject: [PATCH 05/20] hooks: Add durable Codex mutation-scope state Persist checkout-local Codex adapter attempts across short-lived hook processes so duplicate deliveries can reuse live scopes while terminal attempts receive fresh monotonic identifiers. Protect updates with a separate OS lock and durable temp-file replacement, and reject malformed or unsupported state. The module is intentionally unwired; runtime and ingress integration remain follow-up work. Plan: codex-mutation-scope-integration (T03) Co-authored-by: SCE --- .../hooks/codex_mutation_scope/mod.rs | 2 + .../hooks/codex_mutation_scope/state.rs | 879 ++++++++++++++++++ .../plans/codex-mutation-scope-integration.md | 79 +- 3 files changed, 958 insertions(+), 2 deletions(-) create mode 100644 cli/src/services/hooks/codex_mutation_scope/state.rs diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index ab6c6e54..7d56e475 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_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/codex_mutation_scope/state.rs b/cli/src/services/hooks/codex_mutation_scope/state.rs new file mode 100644 index 00000000..113956c4 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/state.rs @@ -0,0 +1,879 @@ +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_codex_scope_id, AttemptKey}; + +const SCE_STATE_DIR: &str = "sce"; +const ADAPTER_STATE_FILE: &str = "codex-mutation-scope-state.json"; +const ADAPTER_STATE_LOCK_FILE: &str = "codex-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_codex_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) +} + +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}; + 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-codex-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, "exec-1"), "Bash") + .expect("first allocation should succeed"); + let second = allocate_attempt(&git_dir, &key("session-1", None, "exec-2"), "apply_patch") + .expect("second allocation should succeed"); + let third = allocate_attempt( + &git_dir, + &key("session-1", Some("agent-1"), "exec-3"), + "Bash", + ) + .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 attempt_state_is_checkout_local_to_its_git_dir() { + let git_dir_a = unique_test_git_dir("checkout-local-a"); + let git_dir_b = unique_test_git_dir("checkout-local-b"); + std::fs::create_dir_all(&git_dir_a).expect("git dir A should be created"); + std::fs::create_dir_all(&git_dir_b).expect("git dir B should be created"); + + allocate_attempt(&git_dir_a, &key("session-1", None, "exec-1"), "Bash") + .expect("checkout A allocation should succeed"); + allocate_attempt(&git_dir_a, &key("session-1", None, "exec-2"), "Bash") + .expect("second checkout A allocation should succeed"); + + let first_b = allocate_attempt(&git_dir_b, &key("session-1", None, "exec-1"), "Bash") + .expect("checkout B allocation should succeed"); + assert_eq!( + first_b.attempt.attempt_seq, 1, + "checkout B's monotonic counter must be independent of checkout A's" + ); + + let state_a = read_state(&git_dir_a).expect("state A readable"); + let state_b = read_state(&git_dir_b).expect("state B readable"); + assert_eq!(state_a.attempts.len(), 2); + assert_eq!(state_b.attempts.len(), 1); + + remove_test_git_dir(&git_dir_a); + remove_test_git_dir(&git_dir_b); + } + + #[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, "exec-1"); + + let first = allocate_attempt(&git_dir, &attempt_key, "Bash") + .expect("first allocation should succeed"); + let second = allocate_attempt(&git_dir, &attempt_key, "Bash") + .expect("duplicate delivery should still succeed"); + + assert!(!first.reused, "the first allocation is not a reuse"); + assert!( + second.reused, + "duplicate live delivery must be reported as reused" + ); + assert_eq!( + first.attempt.attempt_seq, second.attempt.attempt_seq, + "duplicate delivery must reuse the same attempt_seq" + ); + assert_eq!( + first.attempt.scope_id, second.attempt.scope_id, + "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, "exec-1"); + + let first = allocate_attempt(&git_dir, &attempt_key, "Bash") + .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, "Bash") + .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, "exec-1"), "Bash") + .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 mark_active_for_an_unknown_scope_id_is_rejected_without_fabricating_an_attempt() { + let git_dir = unique_test_git_dir("mark-active-unknown"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let error = mark_active(&git_dir, "cx-tool-v1|n=99|s=1:x|a=0:|t=1:y") + .expect_err("marking an unknown scope active must be rejected"); + assert!(error.to_string().contains("No adapter-state attempt found")); + + let state = read_state(&git_dir).expect("state should be readable"); + assert!(state.attempts.is_empty(), "no attempt may be fabricated"); + + 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, "exec-1"), "Bash") + .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 state_round_trips_durably_through_the_canonical_path() { + let git_dir = unique_test_git_dir("round-trip"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let allocated = allocate_attempt( + &git_dir, + &key("session-7", Some("agent-2"), "exec-9"), + "apply_patch", + ) + .expect("allocation should succeed"); + mark_active(&git_dir, &allocated.attempt.scope_id).expect("mark_active should succeed"); + mark_recovery_pending(&git_dir).expect("marking recovery pending should succeed"); + + let reloaded = read_state(&git_dir).expect("state should reload"); + assert_eq!(reloaded.version, ADAPTER_STATE_VERSION); + assert_eq!(reloaded.next_attempt_seq, 2); + assert!(reloaded.recovery_pending); + assert_eq!(reloaded.attempts.len(), 1); + assert_eq!(reloaded.attempts[0].phase, AttemptPhase::Active); + assert_eq!(reloaded.attempts[0].agent_id.as_deref(), Some("agent-2")); + assert_eq!(reloaded.attempts[0].tool_name, "apply_patch"); + assert_eq!(reloaded.attempts[0].scope_id, allocated.attempt.scope_id); + + 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, "exec-1"), "Bash"); + 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!("exec-{index}")); + allocate_attempt(&git_dir, &attempt_key, "Bash") + .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 every_public_helper_releases_the_state_lock_before_returning() { + let git_dir = unique_test_git_dir("lock-released-between-helpers"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let attempt_key = key("session-1", None, "exec-1"); + + let allocated = allocate_attempt(&git_dir, &attempt_key, "Bash") + .expect("allocate_attempt must release the lock before returning"); + mark_active(&git_dir, &allocated.attempt.scope_id) + .expect("mark_active must acquire the lock allocate_attempt released"); + mark_recovery_pending(&git_dir) + .expect("mark_recovery_pending must acquire the lock mark_active released"); + clear_recovery_pending(&git_dir) + .expect("clear_recovery_pending must acquire the lock mark_recovery_pending released"); + remove_attempt(&git_dir, &allocated.attempt.scope_id) + .expect("remove_attempt must acquire the lock clear_recovery_pending released"); + + drop( + AdapterStateLock::acquire(&git_dir, Duration::from_millis(200)) + .expect("the state lock must be free once every helper has returned"), + ); + + 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, "exec-1"), "Bash") + .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, "D13: 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"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + allocate_attempt(&git_dir, &key("session-1", None, "exec-1"), "Bash") + .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/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 7eef43cc..b82c6b68 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -1977,8 +1977,9 @@ Persist this field in every plan; this is durable plan state, not chat state: correctly states Codex has no wired mutation-scope adapter). No architecture decision qualified for an ADR. -- [ ] T03: `Durable checkout-local Codex adapter state and recovery bookkeeping` (status:todo) +- [x] T03: `Durable checkout-local Codex adapter state and recovery bookkeeping` (status:done) - Task ID: T03 + - Completed: 2026-09-08 - Scope: In — `cli/src/services/hooks/codex_mutation_scope/state.rs`: the versioned `{version, next_attempt_seq, recovery_pending, attempts[]}` store at `/sce/codex-mutation-scope-state.json`, the @@ -1999,7 +2000,81 @@ Persist this field in every plan; this is durable plan state, not chat state: external call boundary (helper-level test). - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex_mutation_scope`. - - Context synchronization: pending + - Files changed: + - `cli/src/services/hooks/codex_mutation_scope/state.rs` (new — the versioned + `{version, next_attempt_seq, recovery_pending, attempts[]}` store at + `/sce/codex-mutation-scope-state.json` with its own lock at + `/sce/codex-mutation-scope-state.lock`; `pending_start | active` + phase model; `allocate_attempt` / `mark_active` / `remove_attempt` / + `mark_recovery_pending` / `clear_recovery_pending` helpers; the + `checkout::persist_checkout_id_inner`-style lock → temp file → `sync_data` + → atomic rename → best-effort parent-dir `sync_all` durable write; + malformed / wrong-version rejection; 20 unit tests) + - `cli/src/services/hooks/codex_mutation_scope/mod.rs` (one line — + `pub(crate) mod state;`) + - Result: Added the Codex adapter's durable checkout-local bookkeeping layer, + structurally mirroring #263's `claude_mutation_scope::state` (post-follow-up + helper set). The store is `AdapterState` (`version` = 1, `next_attempt_seq`, + `recovery_pending`, `attempts: Vec`); each `AdapterAttempt` + carries `attempt_seq`, the D4 `scope_id` (from `format_codex_scope_id`), the + D3 identity fields (`session_id`, `agent_id?`, `tool_use_id`), `tool_name`, + and `phase` (`PendingStart | Active`). `allocate_attempt` reuses a live + attempt on a duplicate key (same `attempt_seq` / `ScopeId`, no counter + advance) and otherwise draws a fresh monotonic `attempt_seq`; a removed + (terminal) attempt is never reused — a later same-`tool_use_id` execution + draws a new `attempt_seq` and a new `ScopeId` (AC5 state half). Writes go + through a `try_lock`-based `AdapterStateLock` (separate `.lock` file, D6) and + the durable temp-file/`sync_data`/atomic-rename pattern; the lock is released + before each helper returns and is never held across an external boundary + (D6). Malformed JSON and any `version != 1` file are rejected, never + fabricated (D5). **D5 process-isolation reasoning confirmed and recorded + here:** T01 proved Codex runs every registered hook handler as its own + short-lived OS process (plan line ~972), so a `PreToolUse` process and the + later `PostToolUse` process share no memory — the durable cross-process + store is required, exactly as for Claude; the store shape does not change. + - Verify: + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::codex_mutation_scope` — **passed** (43 + tests: 20 new `state::tests` covering monotonic + checkout-local + allocation, duplicate-key reuse, terminal→fresh non-reuse, phase + transition, unknown-`scope_id` rejection, durable round-trip, + malformed / wrong-version rejection, interrupted-before-rename atomicity, + leftover-lock-file, parallel writers, lock contention, lock-released- + between-helpers, and the `/sce/` path boundary; plus the 23 + pre-existing T02 tests). + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::` — **passed** (374 tests; existing + `sce hooks codex` / mutation-scope / Claude-adapter suites unaffected). + - `clippy --all-targets -- -D warnings` — **clean**. + - `cargo fmt -- --check` — **clean** (after `cargo fmt`). + - Context impact: domain — a new adapter-domain Rust module (durable state + store + helpers) with **no non-test caller** (no event parsing, no + runtime/ingress call, no driver, no CLI wiring — all deferred to T04). No + user-visible behaviour, no public interface, no generated config. The D5/D6 + durability contract lives in the plan's Design section and the module doc + comment; durable Codex-adapter context + (`context/cli/codex-mutation-scope-integration.md`) is authored by T07 once + behaviour ships, as for T01/T02. + - Context synchronization: synced + - T03 ships an internal Rust state module (`codex_mutation_scope::state`) + with **no non-test caller** — no event parsing wiring, no runtime/ingress + call, no driver, no CLI command, no `sce setup` registration — and no + user-visible behaviour, public interface, or generated config. The D5/D6 + durability and process-isolation contract is plan-internal design state, + not durable context. The durable Codex-adapter + context (`context/cli/codex-mutation-scope-integration.md` and the + cross-reference edits to `context/overview.md`, `context/context-map.md`, + `context/cli/mutation-scope-hook-ingress.md`, etc.) is authored by **T07** + once behaviour ships — exactly as recorded for T01/T02. Mandatory + five-root pass done: `overview.md` (its "Codex … still have no adapter" + statement remains accurate — nothing is wired), `architecture.md` (its + `sce hooks codex` description is untouched and additive), `glossary.md`, + `patterns.md`, `context-map.md` all read and confirmed not contradicted. + No architecture decision qualified for an ADR — a durable adapter-state + file mirroring the existing `claude_mutation_scope::state` pattern is a + routine, reversible implementation detail with no boundary, interface, + persistence-contract, or security-posture change (the store is explicitly + not attribution evidence, not exported, not synced). - [ ] T04: `Codex mutation-scope driver + hidden command routing` (status:todo) - Task ID: T04 From 4d24a568eb11f0ba7ff2d26f49a9b2d561d00c8a Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 11:23:36 +0200 Subject: [PATCH 06/20] hooks: Implement Codex mutation-scope adapter routing Add hidden CLI routing for the Codex mutation-scope hook and implement its write-ahead Start, fail-closed PreToolUse handling, cleanup events, and recovery barrier through the existing mutation-scope ingress. Keep untracked and delegation tools neutral while tracking built-in mutations. Plan: context/plans/codex-mutation-scope-integration.md (T04) Co-authored-by: SCE --- cli/src/cli_schema.rs | 6 + .../hooks/codex_mutation_scope/mod.rs | 1385 ++++++++++++++++- cli/src/services/hooks/mod.rs | 5 + cli/src/services/parse/command_runtime.rs | 28 + .../plans/codex-mutation-scope-integration.md | 147 +- 5 files changed, 1568 insertions(+), 3 deletions(-) diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index 4507f133..b5ef9c4e 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -329,6 +329,12 @@ pub enum HooksSubcommand { hide = true )] ClaudeMutationScope, + + #[command( + about = "Run the Codex mutation-scope adapter (reads JSON payload from STDIN)", + hide = true + )] + CodexMutationScope, } #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index 7d56e475..ec312255 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_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"; @@ -260,6 +265,348 @@ fn validation_error(detail: &str) -> String { format!("Invalid Codex 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_CODEX: &str = "codex"; + +const FAIL_CLOSED_DENY_REASON: &str = + "SCE could not establish mutation attribution for this tool execution."; + +const PRE_TOOL_USE_FAIL_CLOSED_EVENT: &str = + "sce.hooks.codex_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_codex_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { + let stdin_payload = super::read_hook_stdin()?; + run_codex_mutation_scope_from_payload(&stdin_payload, logger) +} + +pub(crate) fn run_codex_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_codex_mutation_scope_from_payload_with(stdin_payload, logger, &resolve_git_dir_fn, &seam_fn) +} + +#[cfg(test)] +fn run_codex_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_codex_mutation_scope_from_payload_with(stdin_payload, logger, &resolve_git_dir_fn, &seam_fn) +} + +fn run_codex_mutation_scope_from_payload_with( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let event = parse_codex_hook_event(stdin_payload)?; + dispatch_codex_hook_event(event, logger, resolve_git_dir, seam) +} + +fn dispatch_codex_hook_event( + event: CodexHookEvent, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + match event { + CodexHookEvent::PreToolUse(execution) => Ok(handle_pre_tool_use( + &execution, + logger, + resolve_git_dir, + seam, + )), + CodexHookEvent::PostToolUse(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, + ) + } + CodexHookEvent::Stop(turn) => { + let git_dir = resolve_git_dir(&turn.cwd)?; + let repository_root = Path::new(&turn.cwd); + let session_id = turn.session_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.is_none() + }) + } + CodexHookEvent::Interrupt(turn) => { + let git_dir = resolve_git_dir(&turn.cwd)?; + let repository_root = Path::new(&turn.cwd); + let session_id = turn.session_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + }) + } + CodexHookEvent::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) + }) + } + CodexHookEvent::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 + }) + } + } +} + +fn handle_pre_tool_use( + execution: &CodexToolExecution, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> String { + let identity = &execution.identity; + + if !matches!( + classify_tool(&identity.tool_name), + ToolClassification::TrackedMutation + ) { + return String::new(); + } + + 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: &CodexToolIdentity, + 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, &codex_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, + &codex_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 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_CODEX, + }) + .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::*; @@ -705,4 +1052,1040 @@ mod tests { other_turn.identity.attempt_key() ); } + + mod driver { + use std::cell::RefCell; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Mutex}; + + use anyhow::{anyhow, Result}; + + use super::*; + use crate::services::observability::traits::Logger; + + const CWD: &str = "/repo/checkout"; + + 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-codex-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 tool_event_json(event_name: &str, overrides: &[(&str, Value)]) -> String { + let mut merged: Vec<(&str, Value)> = + vec![(HOOK_EVENT_NAME_FIELD, Value::String(event_name.to_string()))]; + merged.extend( + overrides + .iter() + .map(|(field, value)| (*field, value.clone())), + ); + pre_tool_use_json(&merged) + } + + fn post_tool_use_json(overrides: &[(&str, Value)]) -> String { + tool_event_json(HOOK_EVENT_POST_TOOL_USE, overrides) + } + + fn turn_scoped_payload(event_name: &str, session_id: &str, turn_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: event_name, + SESSION_ID_FIELD: session_id, + TURN_ID_FIELD: turn_id, + CWD_FIELD: CWD, + }) + .to_string() + } + + fn subagent_stop_payload(session_id: &str, turn_id: &str, agent_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SUBAGENT_STOP, + SESSION_ID_FIELD: session_id, + TURN_ID_FIELD: turn_id, + CWD_FIELD: CWD, + AGENT_ID_FIELD: agent_id, + }) + .to_string() + } + + fn session_end_payload(session_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SESSION_END, + SESSION_ID_FIELD: session_id, + CWD_FIELD: CWD, + }) + .to_string() + } + + fn read_state(git_dir: &Path) -> state::AdapterState { + state::read_state(git_dir).expect("adapter state should be readable") + } + + #[test] + fn untracked_mcp_pre_tool_use_creates_no_scope_and_never_touches_seam_or_git_dir() { + let resolver = |_: &str| -> Result { + panic!("an untracked tool must never resolve a git dir") + }; + let payload = pre_tool_use_json(&[( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_success".to_string()), + )]); + + let output = run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("untracked PreToolUse should succeed"); + + assert_eq!(output, ""); + } + + #[test] + fn unknown_tool_pre_tool_use_creates_no_scope_ac3() { + let resolver = |_: &str| -> Result { + panic!("an unknown tool must never resolve a git dir") + }; + let payload = pre_tool_use_json(&[( + TOOL_NAME_FIELD, + Value::String("some_future_codex_tool".to_string()), + )]); + + let output = run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("unknown PreToolUse should succeed"); + + assert_eq!(output, ""); + } + + #[test] + fn delegation_tool_pre_tool_use_creates_no_scope_ac3() { + let resolver = |_: &str| -> Result { + panic!("a delegation tool must never resolve a git dir") + }; + for tool in ["collaborationspawn_agent", "collaborationwait_agent"] { + let payload = + pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + let output = run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("delegation PreToolUse should succeed"); + assert_eq!(output, "", "tool {tool} must produce a neutral continue"); + } + } + + #[test] + fn untracked_pre_tool_use_leaves_the_state_store_untouched_ac9b() { + let git_dir = unique_test_git_dir("untracked-state-untouched"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool in ["mcp__probe__mutate_success", "some_future_codex_tool"] { + let payload = + pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + run_codex_mutation_scope_from_payload_with(&payload, None, &resolver, &ok_seam) + .expect("untracked PreToolUse should succeed"); + } + + assert!( + state::read_state(&git_dir) + .expect("absent state reads as default") + .attempts + .is_empty(), + "AC9b: an untracked PreToolUse must not record an attempt" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn successful_mcp_lifecycle_leaves_no_scope_ac9b() { + let git_dir = unique_test_git_dir("mcp-success-lifecycle"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre = pre_tool_use_json(&[ + ( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_success".to_string()), + ), + (TOOL_USE_ID_FIELD, Value::String("exec-mcp".to_string())), + ]); + let post = post_tool_use_json(&[ + ( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_success".to_string()), + ), + (TOOL_USE_ID_FIELD, Value::String("exec-mcp".to_string())), + ]); + + run_codex_mutation_scope_from_payload_with(&pre, None, &resolver, &unreachable_seam) + .expect("MCP PreToolUse should succeed"); + run_codex_mutation_scope_from_payload_with(&post, None, &resolver, &unreachable_seam) + .expect("MCP PostToolUse should be a no-op"); + + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn tracked_pre_tool_use_writes_ahead_start_then_returns_continue_ac6() { + let git_dir = unique_test_git_dir("tracked-write-ahead"); + let resolver = fixed_resolver(git_dir.clone()); + + let seen: RefCell> = RefCell::new(Vec::new()); + let seam = + |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + let phase_is_pending = state::read_state(&git_dir) + .expect("state readable inside seam") + .attempts + .first() + .is_some_and(|attempt| attempt.phase == state::AttemptPhase::PendingStart); + seen.borrow_mut() + .push((payload.to_string(), root == Path::new(CWD))); + assert!( + phase_is_pending, + "AC6: Start must be driven while the attempt is still PendingStart" + ); + Ok(String::new()) + }; + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + None, + &resolver, + &seam, + ) + .expect("tracked PreToolUse should establish a Start"); + + assert_eq!( + output, "", + "a successful Start returns Codex's neutral continue" + ); + let calls = seen.into_inner(); + assert_eq!(calls.len(), 1); + assert!(calls[0].0.contains(r#""operation":"start""#)); + assert!(calls[0].0.contains(r#""actor_kind":"codex""#)); + assert!( + calls[0].1, + "AC6: the seam receives the raw hook cwd as repository_root" + ); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn duplicate_pre_tool_use_reuses_the_same_scope_id_ac4() { + let git_dir = unique_test_git_dir("duplicate-pre"); + let resolver = fixed_resolver(git_dir.clone()); + + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + None, + &resolver, + &ok_seam, + ) + .expect("first PreToolUse should succeed"); + let scope_id_after_first = read_state(&git_dir).attempts[0].scope_id.clone(); + + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + None, + &resolver, + &ok_seam, + ) + .expect("replayed PreToolUse should succeed"); + + let attempts = read_state(&git_dir).attempts; + assert_eq!( + attempts.len(), + 1, + "AC4: a replayed live PreToolUse must not fork a new attempt" + ); + assert_eq!(attempts[0].scope_id, scope_id_after_first); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn resolver_failure_denies_with_stable_reason_and_logs_the_detail_ac7() { + let logger = RecordingLogger::default(); + let resolver = |_: &str| -> Result { + Err(anyhow!("boom: git rev-parse --git-dir failed")) + }; + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + 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 internal detail must never leak to Codex" + ); + assert!( + !output.contains("allow"), + "a fail-closed PreToolUse must never allow" + ); + + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); + assert!(warnings[0].1.contains("boom")); + } + + #[test] + fn start_seam_failure_denies_with_stable_reason_and_logs_ac7() { + let git_dir = unique_test_git_dir("start-seam-failure"); + let resolver = fixed_resolver(git_dir.clone()); + let logger = RecordingLogger::default(); + let seam = seam_failing_on("start"); + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + 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)); + assert!( + !logger.warnings().is_empty(), + "the Start failure must be logged" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn delegation_and_untracked_pre_tool_use_are_never_fail_closed_ac7() { + let resolver = |_: &str| -> Result { Err(anyhow!("must not be called")) }; + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + ] { + let payload = + pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + let output = run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("non-tracked PreToolUse should succeed"); + assert_eq!( + output, "", + "tool {tool} must not be denied for being untracked" + ); + } + } + + #[test] + fn successful_close_removes_the_attempt_ac8() { + let git_dir = unique_test_git_dir("close-success"); + let resolver = fixed_resolver(git_dir.clone()); + + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + None, + &resolver, + &ok_seam, + ) + .expect("PreToolUse should establish an active attempt"); + + let seen: RefCell> = RefCell::new(Vec::new()); + let seam = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + let output = run_codex_mutation_scope_from_payload_with( + &post_tool_use_json(&[]), + None, + &resolver, + &seam, + ) + .expect("PostToolUse should close the scope"); + + assert_eq!(output, ""); + let calls = seen.into_inner(); + assert_eq!(calls.len(), 1); + assert!(calls[0].contains(r#""operation":"close""#)); + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn pending_start_close_abandons_rather_than_late_starting_d11() { + let git_dir = unique_test_git_dir("pending-start-close"); + let resolver = fixed_resolver(git_dir.clone()); + + state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "exec-1".to_string(), + }, + "Bash", + ) + .expect("seeding a pending_start attempt should succeed"); + + let seen: RefCell> = RefCell::new(Vec::new()); + let seam = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + run_codex_mutation_scope_from_payload_with( + &post_tool_use_json(&[]), + None, + &resolver, + &seam, + ) + .expect("PostToolUse on a pending_start attempt should abandon"); + + let calls = seen.into_inner(); + assert_eq!(calls.len(), 1); + assert!(calls[0].contains(r#""operation":"abandon""#)); + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_close_abandons_and_arms_recovery_ac13() { + let git_dir = unique_test_git_dir("failed-close"); + let resolver = fixed_resolver(git_dir.clone()); + + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + None, + &resolver, + &ok_seam, + ) + .expect("PreToolUse should establish an active attempt"); + + let seam = seam_failing_on("close"); + run_codex_mutation_scope_from_payload_with( + &post_tool_use_json(&[]), + None, + &resolver, + &seam, + ) + .expect("a failed Close should abandon, not propagate"); + + let final_state = read_state(&git_dir); + assert!( + final_state.attempts.is_empty(), + "the abandoned attempt is retired" + ); + assert!( + final_state.recovery_pending, + "D11: a failed Close arms recovery" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_close_and_failed_abandon_keep_the_attempt_tracked_and_recovery_armed_d11() { + let git_dir = unique_test_git_dir("failed-close-and-abandon"); + let resolver = fixed_resolver(git_dir.clone()); + + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + None, + &resolver, + &ok_seam, + ) + .expect("PreToolUse should establish an active attempt"); + + let seam = seam_failing_on_any(vec!["close", "abandon"]); + let error = run_codex_mutation_scope_from_payload_with( + &post_tool_use_json(&[]), + None, + &resolver, + &seam, + ) + .expect_err("a failed Close then failed Abandon must propagate"); + assert!(error.to_string().contains("abandon")); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1, "the attempt stays tracked"); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_with_no_matching_attempt_is_a_noop() { + let git_dir = unique_test_git_dir("close-no-attempt"); + let resolver = fixed_resolver(git_dir.clone()); + + let output = run_codex_mutation_scope_from_payload_with( + &post_tool_use_json(&[( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_success".to_string()), + )]), + None, + &resolver, + &unreachable_seam, + ) + .expect("a PostToolUse with nothing to close is a no-op"); + + assert_eq!(output, ""); + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + fn seed_attempt( + git_dir: &Path, + session_id: &str, + agent_id: Option<&str>, + tool_use_id: &str, + ) { + let allocated = state::allocate_attempt( + git_dir, + &AttemptKey { + session_id: session_id.to_string(), + agent_id: agent_id.map(str::to_string), + tool_use_id: tool_use_id.to_string(), + }, + "Bash", + ) + .expect("seeding an attempt should succeed"); + state::mark_active(git_dir, &allocated.attempt.scope_id) + .expect("marking the seeded attempt active should succeed"); + } + + #[test] + fn stop_sweeps_only_main_thread_attempts_d12() { + let git_dir = unique_test_git_dir("stop-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt(&git_dir, "session-1", None, "exec-main"); + seed_attempt(&git_dir, "session-1", Some("agent-1"), "exec-agent"); + + run_codex_mutation_scope_from_payload_with( + &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), + None, + &resolver, + &ok_seam, + ) + .expect("Stop cleanup should succeed"); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1, "only the subagent attempt survives Stop"); + assert_eq!(attempts[0].tool_use_id, "exec-agent"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn interrupt_sweeps_every_attempt_for_the_session_d12() { + let git_dir = unique_test_git_dir("interrupt-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt(&git_dir, "session-1", None, "exec-main"); + seed_attempt(&git_dir, "session-1", Some("agent-1"), "exec-agent"); + seed_attempt(&git_dir, "session-2", None, "exec-other"); + + run_codex_mutation_scope_from_payload_with( + &turn_scoped_payload(HOOK_EVENT_INTERRUPT, "session-1", "turn-1"), + None, + &resolver, + &ok_seam, + ) + .expect("Interrupt cleanup should succeed"); + + let attempts = read_state(&git_dir).attempts; + assert_eq!( + attempts.len(), + 1, + "SIGINT retires the whole interrupted session" + ); + assert_eq!(attempts[0].session_id, "session-2"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn subagent_stop_sweeps_only_the_matching_agent_d12() { + let git_dir = unique_test_git_dir("subagent-stop-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt(&git_dir, "session-1", Some("agent-a"), "exec-a"); + seed_attempt(&git_dir, "session-1", Some("agent-b"), "exec-b"); + seed_attempt(&git_dir, "session-1", None, "exec-main"); + + run_codex_mutation_scope_from_payload_with( + &subagent_stop_payload("session-1", "turn-1", "agent-a"), + None, + &resolver, + &ok_seam, + ) + .expect("SubagentStop cleanup should succeed"); + + let mut remaining: Vec = read_state(&git_dir) + .attempts + .into_iter() + .map(|attempt| attempt.tool_use_id) + .collect(); + remaining.sort(); + assert_eq!( + remaining, + vec!["exec-b".to_string(), "exec-main".to_string()] + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn session_end_sweeps_every_attempt_for_the_session_d12() { + let git_dir = unique_test_git_dir("session-end-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt(&git_dir, "session-1", None, "exec-main"); + seed_attempt(&git_dir, "session-1", Some("agent-1"), "exec-agent"); + + run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &ok_seam, + ) + .expect("SessionEnd cleanup should succeed"); + + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn lifecycle_cleanup_with_a_failed_abandon_keeps_the_attempt_tracked_d12() { + let git_dir = unique_test_git_dir("sweep-failed-abandon"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt(&git_dir, "session-1", None, "exec-main"); + + let seam = seam_failing_on("abandon"); + let error = run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &seam, + ) + .expect_err("a failed abandonment during cleanup must propagate"); + assert!(error.to_string().contains("abandon")); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_denies_new_tracked_pre_tool_use_while_attempts_remain_ac12() { + let git_dir = unique_test_git_dir("barrier-attempts-remain"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt(&git_dir, "session-1", None, "exec-live"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + None, + &resolver, + &unreachable_seam, + ) + .expect("the barrier denial still returns 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_does_not_affect_untracked_pre_tool_use_ac12() { + let git_dir = unique_test_git_dir("barrier-untracked-unaffected"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt(&git_dir, "session-1", None, "exec-live"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_success".to_string()), + )]), + None, + &resolver, + &unreachable_seam, + ) + .expect("an untracked PreToolUse ignores the barrier"); + + assert_eq!( + output, "", + "AC12: the barrier never denies an untracked tool" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flushes_once_quiescent_then_starts_ac12() { + 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: "exec-seed".to_string(), + }, + "Bash", + ) + .expect("seeding a 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: RefCell> = RefCell::new(Vec::new()); + let seam = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + None, + &resolver, + &seam, + ) + .expect("a quiescent recovery should flush then proceed"); + + assert_eq!(output, ""); + let operations = seen.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 = read_state(&git_dir); + assert!( + !final_state.recovery_pending, + "a successful flush clears the barrier" + ); + assert_eq!(final_state.attempts.len(), 1); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_stays_closed_when_flush_fails_ac12() { + 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: "exec-seed".to_string(), + }, + "Bash", + ) + .expect("seeding a 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 output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + None, + &resolver, + &seam, + ) + .expect("a failed flush still returns Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + let final_state = read_state(&git_dir); + assert!( + final_state.recovery_pending, + "a failed flush keeps the barrier armed" + ); + assert!( + final_state.attempts.is_empty(), + "a denied PreToolUse allocates nothing" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mcp_mutate_then_error_leaves_no_stale_state_ac9c() { + let git_dir = unique_test_git_dir("mcp-mutate-then-error"); + let resolver = fixed_resolver(git_dir.clone()); + + let mcp_pre = pre_tool_use_json(&[ + ( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_then_error".to_string()), + ), + (TOOL_USE_ID_FIELD, Value::String("exec-mcp".to_string())), + ]); + run_codex_mutation_scope_from_payload_with( + &mcp_pre, + None, + &resolver, + &unreachable_seam, + ) + .expect("MCP PreToolUse is a neutral continue"); + assert!(read_state(&git_dir).attempts.is_empty()); + + run_codex_mutation_scope_from_payload_with( + &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), + None, + &resolver, + &ok_seam, + ) + .expect("Stop finds nothing to retire"); + run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &ok_seam, + ) + .expect("SessionEnd finds nothing to retire"); + + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!( + !final_state.recovery_pending, + "AC9c: no Start ⇒ no abandon ⇒ no recovery_pending" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_mcp_then_tracked_successor_starts_clean_ac9d() { + let git_dir = unique_test_git_dir("mcp-then-tracked"); + let resolver = fixed_resolver(git_dir.clone()); + + let mcp_a = pre_tool_use_json(&[ + ( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_then_error".to_string()), + ), + (TOOL_USE_ID_FIELD, Value::String("exec-a".to_string())), + ]); + let bash_b = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("Bash".to_string())), + (TOOL_USE_ID_FIELD, Value::String("exec-b".to_string())), + ]); + + run_codex_mutation_scope_from_payload_with(&mcp_a, None, &resolver, &unreachable_seam) + .expect("MCP A is a neutral continue"); + run_codex_mutation_scope_from_payload_with(&bash_b, None, &resolver, &ok_seam) + .expect("Bash B starts on its own merits"); + + let attempts = read_state(&git_dir).attempts; + assert_eq!( + attempts.len(), + 1, + "AC9d: B is the only live scope, no successor barrier ran" + ); + assert_eq!(attempts[0].tool_use_id, "exec-b"); + assert_eq!(attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn parallel_mcp_executions_create_no_scopes_ac9e() { + let git_dir = unique_test_git_dir("parallel-mcp"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool_use_id in ["exec-par-a", "exec-par-b"] { + let payload = pre_tool_use_json(&[ + ( + TOOL_NAME_FIELD, + Value::String("mcp__probe_par__slow_mutate".to_string()), + ), + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), + ]); + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("each overlapping MCP PreToolUse is a neutral continue"); + assert!(read_state(&git_dir).attempts.is_empty()); + } + + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!(!final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn builtin_failed_a_then_b_never_leaves_a_zombie_scope_ac9a() { + let git_dir = unique_test_git_dir("builtin-failed-a-then-b"); + let resolver = fixed_resolver(git_dir.clone()); + + let predecessor_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]); + let predecessor_post = + post_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]); + let successor_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]); + + run_codex_mutation_scope_from_payload_with(&predecessor_pre, None, &resolver, &ok_seam) + .expect("A starts"); + run_codex_mutation_scope_from_payload_with( + &predecessor_post, + None, + &resolver, + &ok_seam, + ) + .expect("A closes on its terminal PostToolUse"); + run_codex_mutation_scope_from_payload_with(&successor_pre, None, &resolver, &ok_seam) + .expect("B starts"); + + let attempts = read_state(&git_dir).attempts; + assert_eq!( + attempts.len(), + 1, + "AC9a: A is retired before B starts, no zombie" + ); + assert_eq!(attempts[0].tool_use_id, "exec-b"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn malformed_payload_propagates_as_a_real_error_not_fail_open() { + let error = run_codex_mutation_scope_from_payload("not json", None).unwrap_err(); + assert!(error.to_string().contains("valid JSON")); + } + + #[test] + fn unsupported_event_name_propagates_as_a_real_error() { + let payload = json!({ + HOOK_EVENT_NAME_FIELD: "UserPromptSubmit", + SESSION_ID_FIELD: "session-1", + CWD_FIELD: CWD, + }) + .to_string(); + let error = run_codex_mutation_scope_from_payload(&payload, None).unwrap_err(); + assert!(error.to_string().contains("unsupported hook_event_name")); + } + } } diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 5746c0fb..c53b20df 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -105,6 +105,7 @@ pub enum HookSubcommand { ClaudeModelState, MutationScope, ClaudeMutationScope, + CodexMutationScope, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -252,6 +253,9 @@ fn run_hooks_subcommand_in_repo( HookSubcommand::ClaudeMutationScope => { claude_mutation_scope::run_claude_mutation_scope_subcommand(logger) } + HookSubcommand::CodexMutationScope => { + codex_mutation_scope::run_codex_mutation_scope_subcommand(logger) + } } } @@ -1964,6 +1968,7 @@ fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str { HookSubcommand::ClaudeModelState => "Claude model-state runtime invocation", HookSubcommand::MutationScope => "mutation-scope runtime invocation", HookSubcommand::ClaudeMutationScope => "Claude mutation-scope runtime invocation", + HookSubcommand::CodexMutationScope => "Codex mutation-scope runtime invocation", } } diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 92becb5d..1916a2c3 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -510,6 +510,9 @@ fn convert_hooks_subcommand_request( cli_schema::HooksSubcommand::ClaudeMutationScope => { Ok(services::hooks::HookSubcommand::ClaudeMutationScope) } + cli_schema::HooksSubcommand::CodexMutationScope => { + Ok(services::hooks::HookSubcommand::CodexMutationScope) + } } } @@ -633,6 +636,31 @@ mod tests { ); } + #[test] + fn codex_mutation_scope_hook_parses_to_hook_subcommand() { + let command = parse(&["sce", "hooks", "codex-mutation-scope"]); + + let RuntimeCommand::Hooks(command) = command else { + panic!("expected hooks command"); + }; + + assert_eq!( + command.subcommand, + services::hooks::HookSubcommand::CodexMutationScope + ); + } + + #[test] + fn codex_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("codex-mutation-scope"), + "AC1: codex-mutation-scope must not be listed in `sce hooks --help`, got: {help}" + ); + } + #[test] fn sync_json_format_parses_to_sync_request() { let command = parse(&["sce", "sync", "--format", "json"]); diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index b82c6b68..56d1ffb6 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -2076,8 +2076,9 @@ Persist this field in every plan; this is durable plan state, not chat state: persistence-contract, or security-posture change (the store is explicitly not attribution evidence, not exported, not synced). -- [ ] T04: `Codex mutation-scope driver + hidden command routing` (status:todo) +- [x] T04: `Codex mutation-scope driver + hidden command routing` (status:done) - Task ID: T04 + - Completed: 2026-09-08 - Scope: In — per the D17 decision (default: separate command), `cli_schema::HooksSubcommand::CodexMutationScope` (hidden via `#[command(hide = true)]`), the `convert_hooks_subcommand_request` arm, @@ -2131,7 +2132,149 @@ Persist this field in every plan; this is durable plan state, not chat state: ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope`; `services::parse::` routing tests; live binary check of the hidden command; AC19 grep. - - Context synchronization: pending + - Files changed: + - `cli/src/cli_schema.rs` (new hidden `HooksSubcommand::CodexMutationScope` + variant, `#[command(hide = true)]`) + - `cli/src/services/parse/command_runtime.rs` + (`convert_hooks_subcommand_request` arm → + `HookSubcommand::CodexMutationScope`; two routing/hidden-help tests) + - `cli/src/services/hooks/mod.rs` (`HookSubcommand::CodexMutationScope` + variant; unwrapped / non-fail-open `run_hooks_subcommand_in_repo` dispatch + arm mirroring `MutationScope` / `ClaudeMutationScope`; + `hook_runtime_invocation_name` arm) + - `cli/src/services/hooks/codex_mutation_scope/mod.rs` (adapter driver: + `GitDirResolver` / `IngressSeam` injected `&dyn Fn` aliases, `ACTOR_KIND_CODEX`, + `run_codex_mutation_scope_subcommand` / `_from_payload` / `_from_payload_with` + (+ `#[cfg(test)] _at_state_root`), `dispatch_codex_hook_event`, + `handle_pre_tool_use`, `apply_recovery_barrier` / `BarrierOutcome`, + `establish_start`, `handle_close`, `cleanup_attempts_matching`, + `abandon_attempt`, `attempt_matches_key`, `scope_boundary_payload` / + `abandon_payload` / `flush_payload`, `pre_tool_use_deny_json`, + `log_pre_tool_use_fail_closed`; 30 new `mod driver` unit tests) + - Result: Wired the hidden `sce hooks codex-mutation-scope` command through the + normal hook stack unwrapped (non-fail-open), exactly as `mutation-scope` / + `claude-mutation-scope`, and implemented the Codex adapter driver as a close + structural mirror of #263's `claude_mutation_scope` driver, adapted to Codex's + six-event surface (`PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, + `SubagentStop`, `SessionEnd` — no `PermissionDenied` / `StopFailure` / + `UserPromptSubmit` / `WorktreeRemove`). Mappings: `TrackedMutation` + (`Bash` / `apply_patch`) `PreToolUse` → D13 recovery barrier → D7 write-ahead + `Start` (`pending_start` → ingress `start` with `actor_kind:"codex"` and the + raw hook `cwd` as `repository_root` → `active`) → D8 fail-closed + `hookSpecificOutput` `permissionDecision:"deny"` on any failure (resolver, + barrier, allocation, seam), detail logged via + `sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed`, never leaked to the + model; `Untracked` (`mcp__*`, unknown) and `Delegation` + (`collaborationspawn_agent` / `collaborationwait_agent`) `PreToolUse` → + neutral empty continue, no `Start`, no attempt, no bookkeeping, never denied + for being untracked, never affected by the recovery barrier (D2/D8/D23); + `PostToolUse` for a tracked `active` attempt → `close` then `remove_attempt`, + a `pending_start` attempt → `abandon`, a failed `close` → `abandon` + + `recovery_pending` (D9/D11); no `Close`-on-failure path (built-in Case A); + `Stop` → main-turn (`agent_id` none) scoped abandon sweep; `Interrupt` → + whole-session sweep (SIGINT tears down the session, strictly covered by the + `SessionEnd` backstop); `SubagentStop` → `agent_id`-scoped sweep; `SessionEnd` + → whole-session sweep — all sweeps over adapter-owned tracked attempts only + (D12); D13 recovery barrier denies new `TrackedMutation` `PreToolUse` while + tracked attempts remain and runs exactly one quiescent `flush`, clearing + `recovery_pending` only on durable flush success. No D10a successor-barrier / + lane-key code ships (T02 recorded the lane key N/A). The driver's only + mutation-stack dependency in non-test code is + `super::mutation_scope::run_mutation_scope_from_payload`, wire payload built + as a JSON string (D18/AC19). Injected git-dir resolver + seam make every + mapping unit-testable without a real repo or DB. No background-execution + classifier/deny ships (D16 — the `codex exec` shell tool has no + `run_in_background`); AC15's live-check for that path is therefore N/A for + this Codex surface. + - Verify: + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::codex_mutation_scope` — **passed** (73 + tests: 30 new `tests::driver` covering the `Untracked` / `Delegation` + pass-through with an untouched state store (AC9b), a full successful-MCP + lifecycle leaving no scope (AC9b), write-ahead `Start` ordering + `cwd` as + `repository_root` observed from inside the seam (AC6), duplicate-delivery + `ScopeId` reuse (AC4), resolver/`Start`-seam fail-closed with the exact + deny JSON + logged detail + never-allow (AC7), `Delegation`/`Untracked` + never fail-closed (AC7), successful `close` (AC8), `pending_start` → abandon + and failed `close` → abandon + `recovery_pending` (D11/AC13), failed + `close` + failed `abandon` keep the attempt tracked (D11), `PostToolUse` + with no matching attempt is a no-op, `Stop` / `Interrupt` / `SubagentStop` + / `SessionEnd` scoped sweeps + a failed-abandon sweep keeping the attempt + tracked (D12), recovery-barrier deny-while-outstanding / untracked-unaffected + / flush-then-start / flush-failure-stays-closed (AC12), probe-13 + mutate-then-error leaves no stale state (AC9c), probe-14 failed-MCP → + tracked successor starts clean (AC9d), probes-16/17 parallel MCP creates no + scopes (AC9e), built-in failed-A-then-B never leaves a zombie (AC9a), + malformed / unsupported-event payloads propagate as real errors; plus the + 43 pre-existing T02/T03 tests). + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::mutation_scope` — **passed** (36 tests; + generic ingress unaffected). + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::parse::` — **passed** (15 tests, incl. the new + `codex_mutation_scope_hook_parses_to_hook_subcommand` and + `codex_mutation_scope_hook_is_hidden_from_hooks_help`). + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::` — **passed** (404 tests; existing + `sce hooks codex` / Claude-adapter / mutation-scope suites unaffected and + additive). + - Live binary (AC1): `sce hooks codex-mutation-scope Date: Tue, 8 Sep 2026 12:22:34 +0200 Subject: [PATCH 07/20] hooks: Make Codex mutation admission atomic across processes Close inter-process TOCTOU gaps in the Codex mutation-scope recovery barrier by replacing the boolean state with generation-aware Clear/Pending/Flushing transitions and performing recovery checks, duplicate reuse, and PendingStart persistence under one adapter-state lock. Keep the lock out of ingress seam calls, fail closed on uncertain attempts, and add deterministic concurrency regressions for stale flushes, duplicate admissions, and neutral tools. Plan: context/plans/codex-mutation-scope-integration.md (T04) Co-authored-by: SCE --- .../hooks/codex_mutation_scope/mod.rs | 1074 ++++++++++------- .../hooks/codex_mutation_scope/state.rs | 925 ++++++++++---- .../plans/codex-mutation-scope-integration.md | 127 +- 3 files changed, 1452 insertions(+), 674 deletions(-) diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index ec312255..5cf23b20 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -418,64 +418,77 @@ fn handle_pre_tool_use( } }; - if matches!( - apply_recovery_barrier(&git_dir, repository_root, logger, seam), - BarrierOutcome::Deny + let key = identity.attempt_key(); + match admit_or_recover( + &git_dir, + repository_root, + &key, + &identity.tool_name, + logger, + seam, ) { - return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); - } - - match establish_start(&git_dir, repository_root, identity, logger, seam) { - Ok(()) => String::new(), + Ok(Admission::Admitted(allocated)) => { + match establish_start(&git_dir, repository_root, &allocated, 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) + } + } + } + Ok(Admission::Denied) => pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), Err(error) => { - log_pre_tool_use_fail_closed(logger, "establish_start", &error); + log_pre_tool_use_fail_closed(logger, "admit_tracked_attempt", &error); pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) } } } -enum BarrierOutcome { - Proceed, - Deny, +enum Admission { + Admitted(state::AllocatedAttempt), + Denied, } -fn apply_recovery_barrier( +fn admit_or_recover( git_dir: &Path, repository_root: &Path, + key: &AttemptKey, + tool_name: &str, 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; +) -> Result { + match state::admit_tracked_attempt(git_dir, key, tool_name)? { + state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + state::AdmitDecision::RecoveryBlocked | state::AdmitDecision::UncertainAttemptBlocked => { + Ok(Admission::Denied) + } + state::AdmitDecision::FlushClaimed { generation } => { + match seam(repository_root, &flush_payload(), logger) { + Ok(_) => match state::complete_recovery_flush(git_dir, generation)? { + state::RecoveryFlushCompletion::Cleared => { + readmit_after_flush(git_dir, key, tool_name) + } + state::RecoveryFlushCompletion::Superseded => Ok(Admission::Denied), + }, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "recovery_flush", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + Ok(Admission::Denied) + } + } } - }; - - 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 readmit_after_flush(git_dir: &Path, key: &AttemptKey, tool_name: &str) -> Result { + match state::admit_tracked_attempt(git_dir, key, tool_name)? { + state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + state::AdmitDecision::FlushClaimed { generation } => { + state::relinquish_recovery_flush(git_dir, generation)?; + Ok(Admission::Denied) + } + state::AdmitDecision::RecoveryBlocked | state::AdmitDecision::UncertainAttemptBlocked => { + Ok(Admission::Denied) } } } @@ -483,11 +496,10 @@ fn apply_recovery_barrier( fn establish_start( git_dir: &Path, repository_root: &Path, - identity: &CodexToolIdentity, + allocated: &state::AllocatedAttempt, 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, &codex_scope_start_event_id(scope_id)); @@ -567,7 +579,7 @@ fn abandon_attempt( logger: Option<&dyn Logger>, seam: IngressSeam, ) -> Result<()> { - state::mark_recovery_pending(git_dir)?; + state::arm_recovery(git_dir)?; seam(repository_root, &abandon_payload(&attempt.scope_id), logger)?; state::remove_attempt(git_dir, &attempt.scope_id)?; @@ -1056,8 +1068,11 @@ mod tests { mod driver { use std::cell::RefCell; use std::path::{Path, PathBuf}; - use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + use std::sync::mpsc; use std::sync::{Arc, Mutex}; + use std::thread; + use std::time::Duration; use anyhow::{anyhow, Result}; @@ -1116,10 +1131,77 @@ mod tests { } } - fn fixed_resolver(git_dir: PathBuf) -> impl Fn(&str) -> Result { + fn recording_seam( + log: Arc>>, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + move |_root, payload, _logger| { + log.lock() + .expect("recording seam mutex") + .push(payload.to_string()); + Ok(String::new()) + } + } + + struct SeamGate { + entered: mpsc::Receiver<()>, + release: mpsc::Sender<()>, + } + + impl SeamGate { + fn wait_until_entered(&self) { + self.entered + .recv_timeout(Duration::from_secs(5)) + .expect("gated seam should be entered"); + } + + fn release(&self) { + let _ = self.release.send(()); + } + } + + #[allow(clippy::type_complexity)] + fn gated_seam( + operation: &'static str, + calls: Arc>>, + ) -> ( + impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send, + SeamGate, + ) { + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let release_rx = Mutex::new(release_rx); + let seam = move |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { + calls + .lock() + .expect("gated seam mutex") + .push(payload.to_string()); + if payload.contains(&format!(r#""operation":"{operation}""#)) { + entered_tx.send(()).expect("gate entry signal"); + release_rx + .lock() + .expect("gate release mutex") + .recv() + .expect("gate release signal"); + } + Ok(String::new()) + }; + ( + seam, + SeamGate { + entered: entered_rx, + release: release_tx, + }, + ) + } + + fn fixed_resolver(git_dir: PathBuf) -> impl Fn(&str) -> Result + Send + Clone { move |_cwd| Ok(git_dir.clone()) } + fn panicking_resolver(_cwd: &str) -> Result { + panic!("resolve_git_dir must not be called for a non-tracked tool") + } + #[derive(Clone, Default)] struct RecordingLogger { warnings: Arc>>, @@ -1199,64 +1281,54 @@ mod tests { state::read_state(git_dir).expect("adapter state should be readable") } - #[test] - fn untracked_mcp_pre_tool_use_creates_no_scope_and_never_touches_seam_or_git_dir() { - let resolver = |_: &str| -> Result { - panic!("an untracked tool must never resolve a git dir") - }; - let payload = pre_tool_use_json(&[( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_success".to_string()), - )]); - - let output = run_codex_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, + fn seed_attempt( + git_dir: &Path, + session_id: &str, + agent_id: Option<&str>, + tool_use_id: &str, + phase: state::AttemptPhase, + ) -> state::AdapterAttempt { + state::seed_attempt_for_tests( + git_dir, + &AttemptKey { + session_id: session_id.to_string(), + agent_id: agent_id.map(str::to_string), + tool_use_id: tool_use_id.to_string(), + }, + "Bash", + phase, ) - .expect("untracked PreToolUse should succeed"); + } - assert_eq!(output, ""); + fn drive( + payload: &str, + resolver: &(impl Fn(&str) -> Result + ?Sized), + seam: &(impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + ?Sized), + ) -> String { + run_codex_mutation_scope_from_payload_with(payload, None, &resolver, &seam) + .expect("driver should return Ok") } #[test] - fn unknown_tool_pre_tool_use_creates_no_scope_ac3() { - let resolver = |_: &str| -> Result { - panic!("an unknown tool must never resolve a git dir") - }; + fn untracked_mcp_pre_tool_use_creates_no_scope_and_never_touches_seam_or_git_dir() { let payload = pre_tool_use_json(&[( TOOL_NAME_FIELD, - Value::String("some_future_codex_tool".to_string()), + Value::String("mcp__probe__mutate_success".to_string()), )]); - - let output = run_codex_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("unknown PreToolUse should succeed"); - + let output = drive(&payload, &panicking_resolver, &unreachable_seam); assert_eq!(output, ""); } #[test] - fn delegation_tool_pre_tool_use_creates_no_scope_ac3() { - let resolver = |_: &str| -> Result { - panic!("a delegation tool must never resolve a git dir") - }; - for tool in ["collaborationspawn_agent", "collaborationwait_agent"] { + fn unknown_and_delegation_pre_tool_use_create_no_scope_ac3() { + for tool in [ + "some_future_codex_tool", + "collaborationspawn_agent", + "collaborationwait_agent", + ] { let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - let output = run_codex_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("delegation PreToolUse should succeed"); - assert_eq!(output, "", "tool {tool} must produce a neutral continue"); + assert_eq!(drive(&payload, &panicking_resolver, &unreachable_seam), ""); } } @@ -1269,47 +1341,11 @@ mod tests { for tool in ["mcp__probe__mutate_success", "some_future_codex_tool"] { let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - run_codex_mutation_scope_from_payload_with(&payload, None, &resolver, &ok_seam) - .expect("untracked PreToolUse should succeed"); + drive(&payload, &resolver, &ok_seam); } - assert!( - state::read_state(&git_dir) - .expect("absent state reads as default") - .attempts - .is_empty(), - "AC9b: an untracked PreToolUse must not record an attempt" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn successful_mcp_lifecycle_leaves_no_scope_ac9b() { - let git_dir = unique_test_git_dir("mcp-success-lifecycle"); - let resolver = fixed_resolver(git_dir.clone()); - - let pre = pre_tool_use_json(&[ - ( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_success".to_string()), - ), - (TOOL_USE_ID_FIELD, Value::String("exec-mcp".to_string())), - ]); - let post = post_tool_use_json(&[ - ( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_success".to_string()), - ), - (TOOL_USE_ID_FIELD, Value::String("exec-mcp".to_string())), - ]); - - run_codex_mutation_scope_from_payload_with(&pre, None, &resolver, &unreachable_seam) - .expect("MCP PreToolUse should succeed"); - run_codex_mutation_scope_from_payload_with(&post, None, &resolver, &unreachable_seam) - .expect("MCP PostToolUse should be a no-op"); - assert!(read_state(&git_dir).attempts.is_empty()); + assert!(read_state(&git_dir).recovery.is_clear()); remove_test_git_dir(&git_dir); } @@ -1331,23 +1367,14 @@ mod tests { .push((payload.to_string(), root == Path::new(CWD))); assert!( phase_is_pending, - "AC6: Start must be driven while the attempt is still PendingStart" + "AC6: Start driven while attempt is PendingStart" ); Ok(String::new()) }; - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[]), - None, - &resolver, - &seam, - ) - .expect("tracked PreToolUse should establish a Start"); + let output = drive(&pre_tool_use_json(&[]), &resolver, &seam); + assert_eq!(output, ""); - assert_eq!( - output, "", - "a successful Start returns Codex's neutral continue" - ); let calls = seen.into_inner(); assert_eq!(calls.len(), 1); assert!(calls[0].0.contains(r#""operation":"start""#)); @@ -1365,34 +1392,22 @@ mod tests { } #[test] - fn duplicate_pre_tool_use_reuses_the_same_scope_id_ac4() { + fn duplicate_pre_tool_use_reuses_the_same_scope_id_ac4_test_e() { let git_dir = unique_test_git_dir("duplicate-pre"); let resolver = fixed_resolver(git_dir.clone()); - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[]), - None, - &resolver, - &ok_seam, - ) - .expect("first PreToolUse should succeed"); - let scope_id_after_first = read_state(&git_dir).attempts[0].scope_id.clone(); + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[]), - None, - &resolver, - &ok_seam, - ) - .expect("replayed PreToolUse should succeed"); + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); let attempts = read_state(&git_dir).attempts; assert_eq!( attempts.len(), 1, - "AC4: a replayed live PreToolUse must not fork a new attempt" + "AC4/Test E: a replay must not fork a new attempt" ); - assert_eq!(attempts[0].scope_id, scope_id_after_first); + assert_eq!(attempts[0].scope_id, scope_id); remove_test_git_dir(&git_dir); } @@ -1413,14 +1428,8 @@ mod tests { .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 internal detail must never leak to Codex" - ); - assert!( - !output.contains("allow"), - "a fail-closed PreToolUse must never allow" - ); + assert!(!output.contains("boom")); + assert!(!output.contains("allow")); let warnings = logger.warnings(); assert_eq!(warnings.len(), 1); @@ -1429,7 +1438,7 @@ mod tests { } #[test] - fn start_seam_failure_denies_with_stable_reason_and_logs_ac7() { + fn start_seam_failure_denies_and_leaves_the_pending_start_attempt_as_a_barrier_ac7() { let git_dir = unique_test_git_dir("start-seam-failure"); let resolver = fixed_resolver(git_dir.clone()); let logger = RecordingLogger::default(); @@ -1444,9 +1453,29 @@ mod tests { .expect("a Start failure must still return Ok with a deny payload"); assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - assert!( - !logger.warnings().is_empty(), - "the Start failure must be logged" + assert!(!logger.warnings().is_empty()); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!( + final_state.attempts[0].phase, + state::AttemptPhase::PendingStart + ); + + let successor = pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-successor".to_string()), + )]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &successor, + None, + &resolver, + &unreachable_seam, + ) + .expect("successor must return Ok with a deny payload"), + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "I5: an unresolved PendingStart must block a successor Start", ); remove_test_git_dir(&git_dir); @@ -1462,16 +1491,15 @@ mod tests { ] { let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - let output = run_codex_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("non-tracked PreToolUse should succeed"); assert_eq!( - output, "", - "tool {tool} must not be denied for being untracked" + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("non-tracked PreToolUse should succeed"), + "", ); } } @@ -1481,13 +1509,7 @@ mod tests { let git_dir = unique_test_git_dir("close-success"); let resolver = fixed_resolver(git_dir.clone()); - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[]), - None, - &resolver, - &ok_seam, - ) - .expect("PreToolUse should establish an active attempt"); + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); let seen: RefCell> = RefCell::new(Vec::new()); let seam = @@ -1495,15 +1517,8 @@ mod tests { seen.borrow_mut().push(payload.to_string()); Ok(String::new()) }; - let output = run_codex_mutation_scope_from_payload_with( - &post_tool_use_json(&[]), - None, - &resolver, - &seam, - ) - .expect("PostToolUse should close the scope"); + assert_eq!(drive(&post_tool_use_json(&[]), &resolver, &seam), ""); - assert_eq!(output, ""); let calls = seen.into_inner(); assert_eq!(calls.len(), 1); assert!(calls[0].contains(r#""operation":"close""#)); @@ -1516,17 +1531,13 @@ mod tests { fn pending_start_close_abandons_rather_than_late_starting_d11() { let git_dir = unique_test_git_dir("pending-start-close"); let resolver = fixed_resolver(git_dir.clone()); - - state::allocate_attempt( + seed_attempt( &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "exec-1".to_string(), - }, - "Bash", - ) - .expect("seeding a pending_start attempt should succeed"); + "session-1", + None, + "exec-1", + state::AttemptPhase::PendingStart, + ); let seen: RefCell> = RefCell::new(Vec::new()); let seam = @@ -1534,20 +1545,15 @@ mod tests { seen.borrow_mut().push(payload.to_string()); Ok(String::new()) }; - run_codex_mutation_scope_from_payload_with( - &post_tool_use_json(&[]), - None, - &resolver, - &seam, - ) - .expect("PostToolUse on a pending_start attempt should abandon"); + drive(&post_tool_use_json(&[]), &resolver, &seam); let calls = seen.into_inner(); assert_eq!(calls.len(), 1); assert!(calls[0].contains(r#""operation":"abandon""#)); + let final_state = read_state(&git_dir); assert!(final_state.attempts.is_empty()); - assert!(final_state.recovery_pending); + assert!(!final_state.recovery.is_clear()); remove_test_git_dir(&git_dir); } @@ -1557,30 +1563,15 @@ mod tests { let git_dir = unique_test_git_dir("failed-close"); let resolver = fixed_resolver(git_dir.clone()); - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[]), - None, - &resolver, - &ok_seam, - ) - .expect("PreToolUse should establish an active attempt"); + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); let seam = seam_failing_on("close"); - run_codex_mutation_scope_from_payload_with( - &post_tool_use_json(&[]), - None, - &resolver, - &seam, - ) - .expect("a failed Close should abandon, not propagate"); + drive(&post_tool_use_json(&[]), &resolver, &seam); let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); assert!( - final_state.attempts.is_empty(), - "the abandoned attempt is retired" - ); - assert!( - final_state.recovery_pending, + !final_state.recovery.is_clear(), "D11: a failed Close arms recovery" ); @@ -1592,13 +1583,7 @@ mod tests { let git_dir = unique_test_git_dir("failed-close-and-abandon"); let resolver = fixed_resolver(git_dir.clone()); - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[]), - None, - &resolver, - &ok_seam, - ) - .expect("PreToolUse should establish an active attempt"); + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); let seam = seam_failing_on_any(vec!["close", "abandon"]); let error = run_codex_mutation_scope_from_payload_with( @@ -1611,8 +1596,8 @@ mod tests { assert!(error.to_string().contains("abandon")); let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1, "the attempt stays tracked"); - assert!(final_state.recovery_pending); + assert_eq!(final_state.attempts.len(), 1); + assert!(!final_state.recovery.is_clear()); remove_test_git_dir(&git_dir); } @@ -1622,60 +1607,49 @@ mod tests { let git_dir = unique_test_git_dir("close-no-attempt"); let resolver = fixed_resolver(git_dir.clone()); - let output = run_codex_mutation_scope_from_payload_with( - &post_tool_use_json(&[( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_success".to_string()), - )]), - None, - &resolver, - &unreachable_seam, - ) - .expect("a PostToolUse with nothing to close is a no-op"); - - assert_eq!(output, ""); + assert_eq!( + drive( + &post_tool_use_json(&[( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_success".to_string()), + )]), + &resolver, + &unreachable_seam, + ), + "", + ); assert!(read_state(&git_dir).attempts.is_empty()); remove_test_git_dir(&git_dir); } - fn seed_attempt( - git_dir: &Path, - session_id: &str, - agent_id: Option<&str>, - tool_use_id: &str, - ) { - let allocated = state::allocate_attempt( - git_dir, - &AttemptKey { - session_id: session_id.to_string(), - agent_id: agent_id.map(str::to_string), - tool_use_id: tool_use_id.to_string(), - }, - "Bash", - ) - .expect("seeding an attempt should succeed"); - state::mark_active(git_dir, &allocated.attempt.scope_id) - .expect("marking the seeded attempt active should succeed"); - } - #[test] fn stop_sweeps_only_main_thread_attempts_d12() { let git_dir = unique_test_git_dir("stop-sweep"); let resolver = fixed_resolver(git_dir.clone()); - seed_attempt(&git_dir, "session-1", None, "exec-main"); - seed_attempt(&git_dir, "session-1", Some("agent-1"), "exec-agent"); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-1"), + "exec-agent", + state::AttemptPhase::Active, + ); - run_codex_mutation_scope_from_payload_with( + drive( &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), - None, &resolver, &ok_seam, - ) - .expect("Stop cleanup should succeed"); + ); let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1, "only the subagent attempt survives Stop"); + assert_eq!(attempts.len(), 1); assert_eq!(attempts[0].tool_use_id, "exec-agent"); remove_test_git_dir(&git_dir); @@ -1685,24 +1659,36 @@ mod tests { fn interrupt_sweeps_every_attempt_for_the_session_d12() { let git_dir = unique_test_git_dir("interrupt-sweep"); let resolver = fixed_resolver(git_dir.clone()); - seed_attempt(&git_dir, "session-1", None, "exec-main"); - seed_attempt(&git_dir, "session-1", Some("agent-1"), "exec-agent"); - seed_attempt(&git_dir, "session-2", None, "exec-other"); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-1"), + "exec-agent", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-2", + None, + "exec-other", + state::AttemptPhase::Active, + ); - run_codex_mutation_scope_from_payload_with( + drive( &turn_scoped_payload(HOOK_EVENT_INTERRUPT, "session-1", "turn-1"), - None, &resolver, &ok_seam, - ) - .expect("Interrupt cleanup should succeed"); + ); let attempts = read_state(&git_dir).attempts; - assert_eq!( - attempts.len(), - 1, - "SIGINT retires the whole interrupted session" - ); + assert_eq!(attempts.len(), 1); assert_eq!(attempts[0].session_id, "session-2"); remove_test_git_dir(&git_dir); @@ -1712,17 +1698,33 @@ mod tests { fn subagent_stop_sweeps_only_the_matching_agent_d12() { let git_dir = unique_test_git_dir("subagent-stop-sweep"); let resolver = fixed_resolver(git_dir.clone()); - seed_attempt(&git_dir, "session-1", Some("agent-a"), "exec-a"); - seed_attempt(&git_dir, "session-1", Some("agent-b"), "exec-b"); - seed_attempt(&git_dir, "session-1", None, "exec-main"); + seed_attempt( + &git_dir, + "session-1", + Some("agent-a"), + "exec-a", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-b"), + "exec-b", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); - run_codex_mutation_scope_from_payload_with( + drive( &subagent_stop_payload("session-1", "turn-1", "agent-a"), - None, &resolver, &ok_seam, - ) - .expect("SubagentStop cleanup should succeed"); + ); let mut remaining: Vec = read_state(&git_dir) .attempts @@ -1742,16 +1744,22 @@ mod tests { fn session_end_sweeps_every_attempt_for_the_session_d12() { let git_dir = unique_test_git_dir("session-end-sweep"); let resolver = fixed_resolver(git_dir.clone()); - seed_attempt(&git_dir, "session-1", None, "exec-main"); - seed_attempt(&git_dir, "session-1", Some("agent-1"), "exec-agent"); - - run_codex_mutation_scope_from_payload_with( - &session_end_payload("session-1"), + seed_attempt( + &git_dir, + "session-1", None, - &resolver, - &ok_seam, - ) - .expect("SessionEnd cleanup should succeed"); + "exec-main", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-1"), + "exec-agent", + state::AttemptPhase::Active, + ); + + drive(&session_end_payload("session-1"), &resolver, &ok_seam); assert!(read_state(&git_dir).attempts.is_empty()); @@ -1762,7 +1770,13 @@ mod tests { fn lifecycle_cleanup_with_a_failed_abandon_keeps_the_attempt_tracked_d12() { let git_dir = unique_test_git_dir("sweep-failed-abandon"); let resolver = fixed_resolver(git_dir.clone()); - seed_attempt(&git_dir, "session-1", None, "exec-main"); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); let seam = seam_failing_on("abandon"); let error = run_codex_mutation_scope_from_payload_with( @@ -1776,7 +1790,7 @@ mod tests { let final_state = read_state(&git_dir); assert_eq!(final_state.attempts.len(), 1); - assert!(final_state.recovery_pending); + assert!(!final_state.recovery.is_clear()); remove_test_git_dir(&git_dir); } @@ -1785,8 +1799,14 @@ mod tests { fn recovery_barrier_denies_new_tracked_pre_tool_use_while_attempts_remain_ac12() { let git_dir = unique_test_git_dir("barrier-attempts-remain"); let resolver = fixed_resolver(git_dir.clone()); - seed_attempt(&git_dir, "session-1", None, "exec-live"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-live", + state::AttemptPhase::Active, + ); + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); let output = run_codex_mutation_scope_from_payload_with( &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), @@ -1802,27 +1822,37 @@ mod tests { } #[test] - fn recovery_barrier_does_not_affect_untracked_pre_tool_use_ac12() { + fn recovery_barrier_does_not_affect_untracked_pre_tool_use_ac12_test_f() { let git_dir = unique_test_git_dir("barrier-untracked-unaffected"); let resolver = fixed_resolver(git_dir.clone()); - seed_attempt(&git_dir, "session-1", None, "exec-live"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); - - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_success".to_string()), - )]), + seed_attempt( + &git_dir, + "session-1", None, - &resolver, - &unreachable_seam, - ) - .expect("an untracked PreToolUse ignores the barrier"); - - assert_eq!( - output, "", - "AC12: the barrier never denies an untracked tool" + "exec-live", + state::AttemptPhase::Active, ); + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); + + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + ] { + let payload = + pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("an untracked PreToolUse ignores the barrier"), + "", + "Test F: recovery must never deny an untracked tool", + ); + } remove_test_git_dir(&git_dir); } @@ -1832,20 +1862,7 @@ mod tests { 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: "exec-seed".to_string(), - }, - "Bash", - ) - .expect("seeding a 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"); + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); let seen: RefCell> = RefCell::new(Vec::new()); let seam = @@ -1854,15 +1871,13 @@ mod tests { Ok(String::new()) }; - let output = run_codex_mutation_scope_from_payload_with( + let output = drive( &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), - None, &resolver, &seam, - ) - .expect("a quiescent recovery should flush then proceed"); - + ); assert_eq!(output, ""); + let operations = seen.into_inner(); assert_eq!( operations.len(), @@ -1874,10 +1889,11 @@ mod tests { let final_state = read_state(&git_dir); assert!( - !final_state.recovery_pending, + final_state.recovery.is_clear(), "a successful flush clears the barrier" ); assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); remove_test_git_dir(&git_dir); } @@ -1887,20 +1903,8 @@ mod tests { 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: "exec-seed".to_string(), - }, - "Bash", - ) - .expect("seeding a 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 generation = + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); let seam = seam_failing_on("flush"); let output = run_codex_mutation_scope_from_payload_with( @@ -1913,14 +1917,12 @@ mod tests { assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); let final_state = read_state(&git_dir); - assert!( - final_state.recovery_pending, - "a failed flush keeps the barrier armed" - ); - assert!( - final_state.attempts.is_empty(), - "a denied PreToolUse allocates nothing" + assert_eq!( + final_state.recovery, + state::RecoveryState::Pending { generation }, + "a failed flush hands the generation back as Pending so a later PreToolUse retries", ); + assert!(final_state.attempts.is_empty()); remove_test_git_dir(&git_dir); } @@ -1937,35 +1939,21 @@ mod tests { ), (TOOL_USE_ID_FIELD, Value::String("exec-mcp".to_string())), ]); - run_codex_mutation_scope_from_payload_with( - &mcp_pre, - None, - &resolver, - &unreachable_seam, - ) - .expect("MCP PreToolUse is a neutral continue"); + drive(&mcp_pre, &resolver, &unreachable_seam); assert!(read_state(&git_dir).attempts.is_empty()); - run_codex_mutation_scope_from_payload_with( + drive( &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), - None, - &resolver, - &ok_seam, - ) - .expect("Stop finds nothing to retire"); - run_codex_mutation_scope_from_payload_with( - &session_end_payload("session-1"), - None, &resolver, &ok_seam, - ) - .expect("SessionEnd finds nothing to retire"); + ); + drive(&session_end_payload("session-1"), &resolver, &ok_seam); let final_state = read_state(&git_dir); assert!(final_state.attempts.is_empty()); assert!( - !final_state.recovery_pending, - "AC9c: no Start ⇒ no abandon ⇒ no recovery_pending" + final_state.recovery.is_clear(), + "AC9c: no Start => no abandon => recovery stays clear" ); remove_test_git_dir(&git_dir); @@ -1988,17 +1976,11 @@ mod tests { (TOOL_USE_ID_FIELD, Value::String("exec-b".to_string())), ]); - run_codex_mutation_scope_from_payload_with(&mcp_a, None, &resolver, &unreachable_seam) - .expect("MCP A is a neutral continue"); - run_codex_mutation_scope_from_payload_with(&bash_b, None, &resolver, &ok_seam) - .expect("Bash B starts on its own merits"); + drive(&mcp_a, &resolver, &unreachable_seam); + drive(&bash_b, &resolver, &ok_seam); let attempts = read_state(&git_dir).attempts; - assert_eq!( - attempts.len(), - 1, - "AC9d: B is the only live scope, no successor barrier ran" - ); + assert_eq!(attempts.len(), 1); assert_eq!(attempts[0].tool_use_id, "exec-b"); assert_eq!(attempts[0].phase, state::AttemptPhase::Active); @@ -2018,19 +2000,13 @@ mod tests { ), (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), ]); - run_codex_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("each overlapping MCP PreToolUse is a neutral continue"); + drive(&payload, &resolver, &unreachable_seam); assert!(read_state(&git_dir).attempts.is_empty()); } let final_state = read_state(&git_dir); assert!(final_state.attempts.is_empty()); - assert!(!final_state.recovery_pending); + assert!(final_state.recovery.is_clear()); remove_test_git_dir(&git_dir); } @@ -2047,24 +2023,12 @@ mod tests { let successor_pre = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]); - run_codex_mutation_scope_from_payload_with(&predecessor_pre, None, &resolver, &ok_seam) - .expect("A starts"); - run_codex_mutation_scope_from_payload_with( - &predecessor_post, - None, - &resolver, - &ok_seam, - ) - .expect("A closes on its terminal PostToolUse"); - run_codex_mutation_scope_from_payload_with(&successor_pre, None, &resolver, &ok_seam) - .expect("B starts"); + drive(&predecessor_pre, &resolver, &ok_seam); + drive(&predecessor_post, &resolver, &ok_seam); + drive(&successor_pre, &resolver, &ok_seam); let attempts = read_state(&git_dir).attempts; - assert_eq!( - attempts.len(), - 1, - "AC9a: A is retired before B starts, no zombie" - ); + assert_eq!(attempts.len(), 1); assert_eq!(attempts[0].tool_use_id, "exec-b"); remove_test_git_dir(&git_dir); @@ -2087,5 +2051,269 @@ mod tests { let error = run_codex_mutation_scope_from_payload(&payload, None).unwrap_err(); assert!(error.to_string().contains("unsupported hook_event_name")); } + + #[test] + fn test_a_recovery_armed_mid_abandon_blocks_a_concurrent_tracked_pre_tool_use() { + let git_dir = unique_test_git_dir("race-admission-vs-recovery"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (abandon_seam, gate) = gated_seam("abandon", Arc::clone(&calls)); + + let sweeper = { + let git_dir = git_dir.clone(); + let resolver = fixed_resolver(git_dir.clone()); + thread::spawn(move || { + run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &abandon_seam, + ) + .expect("SessionEnd cleanup should succeed") + }) + }; + + gate.wait_until_entered(); + + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Pending { generation: 1 }, + "arm_recovery must run before the abandon seam call", + ); + + let resolver = fixed_resolver(git_dir.clone()); + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]), + None, + &resolver, + &unreachable_seam, + ) + .expect("the concurrent tracked PreToolUse must return a deny payload"); + assert_eq!( + output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "Test A: B must be denied and must never reach the Start seam", + ); + + gate.release(); + sweeper.join().expect("sweeper thread should not panic"); + + let recorded = calls.lock().expect("calls mutex").clone(); + assert!( + recorded + .iter() + .all(|payload| !payload.contains(r#""operation":"start""#)), + "Test A: no start payload for B may reach the seam, got {recorded:?}", + ); + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_b_two_quiescent_recovery_callers_emit_exactly_one_flush() { + let git_dir = unique_test_git_dir("race-two-quiescent-flush"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let generation = state::arm_recovery(&git_dir).expect("arming recovery should succeed"); + + let flush_calls = Arc::new(AtomicUsize::new(0)); + let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (owner_seam, gate) = gated_seam("flush", Arc::clone(&calls)); + + let owner = { + let git_dir = git_dir.clone(); + let resolver = fixed_resolver(git_dir.clone()); + let flush_calls = Arc::clone(&flush_calls); + thread::spawn(move || { + let seam = move |root: &Path, + payload: &str, + logger: Option<&dyn Logger>| + -> Result { + if payload.contains(r#""operation":"flush""#) { + flush_calls.fetch_add(1, Ordering::SeqCst); + } + owner_seam(root, payload, logger) + }; + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-owner".to_string()), + )]), + None, + &resolver, + &seam, + ) + .expect("owner PreToolUse should return Ok") + }) + }; + + gate.wait_until_entered(); + + let resolver = fixed_resolver(git_dir.clone()); + let contender_flushes = Arc::new(AtomicUsize::new(0)); + let contender_flushes_seam = Arc::clone(&contender_flushes); + let contender_seam = move |_root: &Path, + payload: &str, + _logger: Option<&dyn Logger>| + -> Result { + if payload.contains(r#""operation":"flush""#) { + contender_flushes_seam.fetch_add(1, Ordering::SeqCst); + } + Ok(String::new()) + }; + let contender_output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-contender".to_string()), + )]), + None, + &resolver, + &contender_seam, + ) + .expect("contender PreToolUse should return Ok"); + assert_eq!( + contender_output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "Test B: the contender must stay fail-closed while the owner flushes", + ); + assert_eq!( + contender_flushes.load(Ordering::SeqCst), + 0, + "Test B: only the owner may run the flush for generation {generation}", + ); + + gate.release(); + owner.join().expect("owner thread should not panic"); + + assert_eq!( + flush_calls.load(Ordering::SeqCst), + 1, + "Test B: exactly one Flush is emitted for the recovery generation", + ); + assert!(read_state(&git_dir).recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_c_recovery_rearmed_while_flush_in_flight_survives_the_stale_completion() { + let git_dir = unique_test_git_dir("race-rearm-during-flush"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + state::arm_recovery(&git_dir).expect("arm g1"); + + let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (flush_seam, gate) = gated_seam("flush", Arc::clone(&calls)); + + let flusher = { + let git_dir = git_dir.clone(); + let resolver = fixed_resolver(git_dir.clone()); + thread::spawn(move || { + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-flusher".to_string()), + )]), + None, + &resolver, + &flush_seam, + ) + .expect("flusher PreToolUse should return Ok") + }) + }; + + gate.wait_until_entered(); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Flushing { generation: 1 }, + ); + + let second_generation = state::arm_recovery(&git_dir).expect("re-arm to g2"); + assert_eq!(second_generation, 2); + + gate.release(); + let flusher_output = flusher.join().expect("flusher thread should not panic"); + assert_eq!( + flusher_output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "Test C: the flusher denies because recovery was re-armed under it", + ); + + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Pending { generation: 2 }, + "Test C: the stale Flush(g1) completion must not clear Pending(g2)", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_d_start_succeeds_but_mark_active_fails_blocks_a_successor_until_recovery() { + let git_dir = unique_test_git_dir("start-then-mark-active-fails"); + let resolver = fixed_resolver(git_dir.clone()); + + state::arm_mark_active_failure_for_tests(); + let logger = RecordingLogger::default(); + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]), + Some(&logger), + &resolver, + &ok_seam, + ) + .expect("a mark_active failure still returns Ok with a deny payload"); + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let after_start = read_state(&git_dir); + assert_eq!(after_start.attempts.len(), 1); + assert_eq!( + after_start.attempts[0].phase, + state::AttemptPhase::PendingStart + ); + assert!(after_start.recovery.is_clear()); + + let successor = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &successor, + None, + &resolver, + &unreachable_seam, + ) + .expect("successor returns a deny payload"), + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "Test D: an uncertain PendingStart blocks a successor Start", + ); + + drive(&session_end_payload("session-1"), &resolver, &ok_seam); + assert!(read_state(&git_dir).attempts.is_empty()); + assert!(!read_state(&git_dir).recovery.is_clear()); + + let recording: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seam = recording_seam(Arc::clone(&recording)); + let recovered = drive(&successor, &resolver, &seam); + assert_eq!(recovered, ""); + + let ops = recording.lock().expect("recording mutex").clone(); + assert_eq!(ops.len(), 2, "expected flush then start, got {ops:?}"); + assert!(ops[0].contains(r#""operation":"flush""#)); + assert!(ops[1].contains(r#""operation":"start""#)); + + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + + remove_test_git_dir(&git_dir); + } } } diff --git a/cli/src/services/hooks/codex_mutation_scope/state.rs b/cli/src/services/hooks/codex_mutation_scope/state.rs index 113956c4..581422f2 100644 --- a/cli/src/services/hooks/codex_mutation_scope/state.rs +++ b/cli/src/services/hooks/codex_mutation_scope/state.rs @@ -15,7 +15,7 @@ const ADAPTER_STATE_LOCK_FILE: &str = "codex-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; +const ADAPTER_STATE_VERSION: u32 = 2; #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -24,6 +24,25 @@ pub(crate) enum AttemptPhase { Active, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "phase", rename_all = "snake_case")] +pub(crate) enum RecoveryState { + #[default] + Clear, + Pending { + generation: u64, + }, + Flushing { + generation: u64, + }, +} + +impl RecoveryState { + pub(crate) fn is_clear(&self) -> bool { + matches!(self, RecoveryState::Clear) + } +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub(crate) struct AdapterAttempt { pub attempt_seq: u64, @@ -43,11 +62,18 @@ impl AdapterAttempt { } } +fn default_recovery_generation() -> u64 { + 1 +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub(crate) struct AdapterState { pub version: u32, pub next_attempt_seq: u64, - pub recovery_pending: bool, + #[serde(default = "default_recovery_generation")] + pub next_recovery_generation: u64, + #[serde(default)] + pub recovery: RecoveryState, pub attempts: Vec, } @@ -56,7 +82,8 @@ impl Default for AdapterState { AdapterState { version: ADAPTER_STATE_VERSION, next_attempt_seq: 1, - recovery_pending: false, + next_recovery_generation: default_recovery_generation(), + recovery: RecoveryState::Clear, attempts: Vec::new(), } } @@ -68,6 +95,20 @@ pub(crate) struct AllocatedAttempt { pub reused: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AdmitDecision { + Admitted(AllocatedAttempt), + RecoveryBlocked, + UncertainAttemptBlocked, + FlushClaimed { generation: u64 }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RecoveryFlushCompletion { + Cleared, + Superseded, +} + fn state_dir(git_dir: &Path) -> PathBuf { git_dir.join(SCE_STATE_DIR) } @@ -260,52 +301,86 @@ where Ok(()) } -pub(crate) fn allocate_attempt( - git_dir: &Path, +fn acquire_lock(git_dir: &Path) -> Result { + AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) + .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}")) +} + +fn allocate_pending_start( + state: &mut AdapterState, 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}"))?; +) -> AdapterAttempt { + let attempt_seq = state.next_attempt_seq; + let attempt = AdapterAttempt { + attempt_seq, + scope_id: format_codex_scope_id(attempt_seq, key), + 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; + attempt +} +pub(crate) fn admit_tracked_attempt( + git_dir: &Path, + key: &AttemptKey, + tool_name: &str, +) -> Result { + let _lock = acquire_lock(git_dir)?; let mut state = read_state(git_dir)?; + match state.recovery { + RecoveryState::Flushing { .. } => return Ok(AdmitDecision::RecoveryBlocked), + RecoveryState::Pending { generation } => { + if !state.attempts.is_empty() { + return Ok(AdmitDecision::RecoveryBlocked); + } + state.recovery = RecoveryState::Flushing { generation }; + write_state_durably(git_dir, &state)?; + return Ok(AdmitDecision::FlushClaimed { generation }); + } + RecoveryState::Clear => {} + } + if let Some(existing) = state .attempts .iter() .find(|attempt| attempt.matches_key(key)) { - return Ok(AllocatedAttempt { + return Ok(AdmitDecision::Admitted(AllocatedAttempt { attempt: existing.clone(), reused: true, - }); + })); } - let attempt_seq = state.next_attempt_seq; - let scope_id = format_codex_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, - }; + if state + .attempts + .iter() + .any(|attempt| attempt.phase == AttemptPhase::PendingStart) + { + return Ok(AdmitDecision::UncertainAttemptBlocked); + } - state.attempts.push(attempt.clone()); - state.next_attempt_seq += 1; + let attempt = allocate_pending_start(&mut state, key, tool_name); write_state_durably(git_dir, &state)?; - - Ok(AllocatedAttempt { + Ok(AdmitDecision::Admitted(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}"))?; + #[cfg(test)] + if fault::take_mark_active_failure() { + return Err(anyhow!("injected mark_active failure for tests")); + } + + let _lock = acquire_lock(git_dir)?; let mut state = read_state(git_dir)?; let attempt = state @@ -318,8 +393,7 @@ pub(crate) fn mark_active(git_dir: &Path, scope_id: &str) -> Result<()> { } 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 _lock = acquire_lock(git_dir)?; let mut state = read_state(git_dir)?; let before = state.attempts.len(); @@ -332,22 +406,90 @@ 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}"))?; - +pub(crate) fn arm_recovery(git_dir: &Path) -> Result { + let _lock = acquire_lock(git_dir)?; let mut state = read_state(git_dir)?; - state.recovery_pending = true; - write_state_durably(git_dir, &state) + + let generation = match state.recovery { + RecoveryState::Pending { generation } => generation, + RecoveryState::Clear | RecoveryState::Flushing { .. } => { + let generation = state.next_recovery_generation; + state.next_recovery_generation += 1; + generation + } + }; + state.recovery = RecoveryState::Pending { generation }; + write_state_durably(git_dir, &state)?; + Ok(generation) } -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}"))?; +pub(crate) fn complete_recovery_flush( + git_dir: &Path, + generation: u64, +) -> Result { + let _lock = acquire_lock(git_dir)?; + let mut state = read_state(git_dir)?; + match state.recovery { + RecoveryState::Flushing { generation: owned } if owned == generation => { + state.recovery = RecoveryState::Clear; + write_state_durably(git_dir, &state)?; + Ok(RecoveryFlushCompletion::Cleared) + } + _ => Ok(RecoveryFlushCompletion::Superseded), + } +} + +pub(crate) fn relinquish_recovery_flush(git_dir: &Path, generation: u64) -> Result<()> { + let _lock = acquire_lock(git_dir)?; let mut state = read_state(git_dir)?; - state.recovery_pending = false; - write_state_durably(git_dir, &state) + + if let RecoveryState::Flushing { generation: owned } = state.recovery { + if owned == generation { + state.recovery = RecoveryState::Pending { generation: owned }; + write_state_durably(git_dir, &state)?; + } + } + Ok(()) +} + +#[cfg(test)] +pub(crate) fn seed_attempt_for_tests( + git_dir: &Path, + key: &AttemptKey, + tool_name: &str, + phase: AttemptPhase, +) -> AdapterAttempt { + let _lock = acquire_lock(git_dir).expect("test seed lock"); + let mut state = read_state(git_dir).expect("test seed read"); + allocate_pending_start(&mut state, key, tool_name); + let seeded = state.attempts.last_mut().expect("attempt was just pushed"); + seeded.phase = phase; + let attempt = seeded.clone(); + write_state_durably(git_dir, &state).expect("test seed write"); + attempt +} + +#[cfg(test)] +mod fault { + use std::cell::Cell; + + thread_local! { + static FAIL_NEXT_MARK_ACTIVE: Cell = const { Cell::new(false) }; + } + + pub(super) fn arm_mark_active_failure() { + FAIL_NEXT_MARK_ACTIVE.with(|cell| cell.set(true)); + } + + pub(super) fn take_mark_active_failure() -> bool { + FAIL_NEXT_MARK_ACTIVE.with(Cell::take) + } +} + +#[cfg(test)] +pub(crate) fn arm_mark_active_failure_for_tests() { + fault::arm_mark_active_failure(); } #[cfg(test)] @@ -379,38 +521,50 @@ mod tests { } } + fn admit(git_dir: &Path, key: &AttemptKey, tool_name: &str) -> AdmitDecision { + admit_tracked_attempt(git_dir, key, tool_name).expect("admit should not error") + } + + fn admit_and_activate(git_dir: &Path, key: &AttemptKey, tool_name: &str) -> AdapterAttempt { + match admit(git_dir, key, tool_name) { + AdmitDecision::Admitted(allocated) => { + mark_active(git_dir, &allocated.attempt.scope_id) + .expect("mark_active should succeed"); + allocated.attempt + } + other => panic!("expected Admitted, got {other:?}"), + } + } + #[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()); + assert_eq!(state.version, 2); + assert!(state.recovery.is_clear()); + assert_eq!(state.next_recovery_generation, 1); remove_test_git_dir(&git_dir); } #[test] - fn attempt_seq_allocation_is_sequential_across_distinct_keys() { + fn admit_allocates_sequential_attempt_seqs_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, "exec-1"), "Bash") - .expect("first allocation should succeed"); - let second = allocate_attempt(&git_dir, &key("session-1", None, "exec-2"), "apply_patch") - .expect("second allocation should succeed"); - let third = allocate_attempt( + let first = admit_and_activate(&git_dir, &key("session-1", None, "exec-1"), "Bash"); + let second = admit_and_activate(&git_dir, &key("session-1", None, "exec-2"), "apply_patch"); + let third = admit_and_activate( &git_dir, &key("session-1", Some("agent-1"), "exec-3"), "Bash", - ) - .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); + assert_eq!(first.attempt_seq, 1); + assert_eq!(second.attempt_seq, 2); + assert_eq!(third.attempt_seq, 3); let state = read_state(&git_dir).expect("state should be readable"); assert_eq!(state.next_attempt_seq, 4); @@ -420,98 +574,421 @@ mod tests { } #[test] - fn attempt_state_is_checkout_local_to_its_git_dir() { + fn admit_state_is_checkout_local_to_its_git_dir() { let git_dir_a = unique_test_git_dir("checkout-local-a"); let git_dir_b = unique_test_git_dir("checkout-local-b"); std::fs::create_dir_all(&git_dir_a).expect("git dir A should be created"); std::fs::create_dir_all(&git_dir_b).expect("git dir B should be created"); - allocate_attempt(&git_dir_a, &key("session-1", None, "exec-1"), "Bash") - .expect("checkout A allocation should succeed"); - allocate_attempt(&git_dir_a, &key("session-1", None, "exec-2"), "Bash") - .expect("second checkout A allocation should succeed"); + admit_and_activate(&git_dir_a, &key("session-1", None, "exec-1"), "Bash"); + admit_and_activate(&git_dir_a, &key("session-1", None, "exec-2"), "Bash"); - let first_b = allocate_attempt(&git_dir_b, &key("session-1", None, "exec-1"), "Bash") - .expect("checkout B allocation should succeed"); + let first_b = admit_and_activate(&git_dir_b, &key("session-1", None, "exec-1"), "Bash"); assert_eq!( - first_b.attempt.attempt_seq, 1, + first_b.attempt_seq, 1, "checkout B's monotonic counter must be independent of checkout A's" ); - let state_a = read_state(&git_dir_a).expect("state A readable"); - let state_b = read_state(&git_dir_b).expect("state B readable"); - assert_eq!(state_a.attempts.len(), 2); - assert_eq!(state_b.attempts.len(), 1); + assert_eq!( + read_state(&git_dir_a) + .expect("state A readable") + .attempts + .len(), + 2 + ); + assert_eq!( + read_state(&git_dir_b) + .expect("state B readable") + .attempts + .len(), + 1 + ); remove_test_git_dir(&git_dir_a); remove_test_git_dir(&git_dir_b); } #[test] - fn duplicate_live_attempt_reuses_the_same_attempt_seq_and_scope_id() { + fn duplicate_live_delivery_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, "exec-1"); - let first = allocate_attempt(&git_dir, &attempt_key, "Bash") - .expect("first allocation should succeed"); - let second = allocate_attempt(&git_dir, &attempt_key, "Bash") - .expect("duplicate delivery should still succeed"); + let AdmitDecision::Admitted(first) = admit(&git_dir, &attempt_key, "Bash") else { + panic!("first admission should be Admitted"); + }; + assert!(!first.reused); - assert!(!first.reused, "the first allocation is not a reuse"); + let AdmitDecision::Admitted(second) = admit(&git_dir, &attempt_key, "Bash") else { + panic!("duplicate delivery should still be Admitted"); + }; assert!( second.reused, "duplicate live delivery must be reported as reused" ); + assert_eq!(first.attempt.attempt_seq, second.attempt.attempt_seq); + assert_eq!(first.attempt.scope_id, second.attempt.scope_id); + + let state = read_state(&git_dir).expect("state should be readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.next_attempt_seq, 2); + + 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, "exec-1"); + + let first = admit_and_activate(&git_dir, &attempt_key, "Bash"); + remove_attempt(&git_dir, &first.scope_id).expect("terminal attempt should be removable"); + + let AdmitDecision::Admitted(second) = admit(&git_dir, &attempt_key, "Bash") else { + panic!("a later execution should be Admitted with a fresh attempt"); + }; + assert!(!second.reused); + assert_ne!(first.attempt_seq, second.attempt.attempt_seq); + assert_ne!(first.scope_id, second.attempt.scope_id); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn admit_persists_the_pending_start_attempt_before_returning() { + let git_dir = unique_test_git_dir("admit-persists-pending-start"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let AdmitDecision::Admitted(allocated) = + admit(&git_dir, &key("session-1", None, "exec-1"), "Bash") + else { + panic!("expected Admitted"); + }; + + let state = read_state(&git_dir).expect("state should be readable"); assert_eq!( - first.attempt.attempt_seq, second.attempt.attempt_seq, - "duplicate delivery must reuse the same attempt_seq" + state.attempts.len(), + 1, + "I2: PendingStart must be durable before admit returns" ); + assert_eq!(state.attempts[0].scope_id, allocated.attempt.scope_id); + assert_eq!(state.attempts[0].phase, AttemptPhase::PendingStart); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn admit_blocks_a_successor_while_an_unrelated_pending_start_is_unresolved() { + let git_dir = unique_test_git_dir("admit-blocks-on-pending-start"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let AdmitDecision::Admitted(_) = admit(&git_dir, &key("session-1", None, "exec-a"), "Bash") + else { + panic!("first admission should be Admitted"); + }; + assert_eq!( - first.attempt.scope_id, second.attempt.scope_id, - "duplicate delivery must reuse the same ScopeId" + admit(&git_dir, &key("session-1", None, "exec-b"), "Bash"), + AdmitDecision::UncertainAttemptBlocked, + "I5: an unrelated unresolved PendingStart must block a successor Start" ); - let state = read_state(&git_dir).expect("state should be readable"); + remove_test_git_dir(&git_dir); + } + + #[test] + fn admit_allows_a_new_key_alongside_an_active_attempt() { + let git_dir = unique_test_git_dir("admit-alongside-active"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + admit_and_activate(&git_dir, &key("session-1", None, "exec-a"), "Bash"); + + let AdmitDecision::Admitted(second) = + admit(&git_dir, &key("session-1", None, "exec-b"), "Bash") + else { + panic!("a distinct execution may run alongside an Active attempt (D14)"); + }; + assert!(!second.reused); assert_eq!( - state.attempts.len(), - 1, - "reusing a live attempt must not create a second bookkeeping entry" + read_state(&git_dir).expect("state readable").attempts.len(), + 2 + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn admit_refuses_while_recovery_is_pending_with_outstanding_attempts() { + let git_dir = unique_test_git_dir("admit-recovery-blocked"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + seed_attempt_for_tests( + &git_dir, + &key("session-1", None, "exec-live"), + "Bash", + AttemptPhase::Active, ); + arm_recovery(&git_dir).expect("arming recovery should succeed"); + assert_eq!( - state.next_attempt_seq, 2, - "reusing a live attempt must not advance the monotonic counter" + admit(&git_dir, &key("session-1", None, "exec-new"), "Bash"), + AdmitDecision::RecoveryBlocked, + "I1: no new tracked Start while recovery is pending with outstanding attempts" ); 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"); + fn admit_claims_the_flush_when_recovery_is_quiescent() { + let git_dir = unique_test_git_dir("admit-claims-flush"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let attempt_key = key("session-1", None, "exec-1"); - let first = allocate_attempt(&git_dir, &attempt_key, "Bash") - .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 generation = arm_recovery(&git_dir).expect("arming recovery should succeed"); - let second = allocate_attempt(&git_dir, &attempt_key, "Bash") - .expect("a later execution of the same tool_use_id should allocate a fresh attempt"); + assert_eq!( + admit(&git_dir, &key("session-1", None, "exec-new"), "Bash"), + AdmitDecision::FlushClaimed { generation }, + ); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Flushing { generation }, + ); - assert!( - !second.reused, - "the attempt is a fresh allocation, not a reuse" + remove_test_git_dir(&git_dir); + } + + #[test] + fn only_one_concurrent_caller_claims_the_flush_for_a_generation() { + let git_dir = unique_test_git_dir("one-flush-owner"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let generation = arm_recovery(&git_dir).expect("arming recovery should succeed"); + + let handles: Vec<_> = ["a", "b"] + .into_iter() + .map(|suffix| { + let git_dir = git_dir.clone(); + thread::spawn(move || { + admit_tracked_attempt( + &git_dir, + &key("session-1", None, &format!("exec-{suffix}")), + "Bash", + ) + .expect("admit should not error") + }) + }) + .collect(); + + let mut decisions: Vec = handles + .into_iter() + .map(|handle| handle.join().expect("thread should not panic")) + .collect(); + decisions.sort_by_key(|decision| format!("{decision:?}")); + + let flush_claims = decisions + .iter() + .filter(|decision| matches!(decision, AdmitDecision::FlushClaimed { .. })) + .count(); + let blocked = decisions + .iter() + .filter(|decision| matches!(decision, AdmitDecision::RecoveryBlocked)) + .count(); + assert_eq!( + flush_claims, 1, + "I3: exactly one process may claim Flushing(g)" + ); + assert_eq!( + blocked, 1, + "the other concurrent caller must stay fail-closed" + ); + assert_eq!( + decisions.iter().find_map(|decision| match decision { + AdmitDecision::FlushClaimed { generation } => Some(*generation), + _ => None, + }), + Some(generation), + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn arm_recovery_moves_clear_to_pending_with_a_fresh_generation() { + let git_dir = unique_test_git_dir("arm-clear-to-pending"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let generation = arm_recovery(&git_dir).expect("arming should succeed"); + assert_eq!(generation, 1); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.recovery, RecoveryState::Pending { generation: 1 }); + assert_eq!(state.next_recovery_generation, 2); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn arm_recovery_keeps_the_same_generation_when_already_pending() { + let git_dir = unique_test_git_dir("arm-pending-idempotent"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + assert_eq!(arm_recovery(&git_dir).expect("first arm"), 1); + assert_eq!(arm_recovery(&git_dir).expect("second arm"), 1); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.recovery, RecoveryState::Pending { generation: 1 }); + assert_eq!(state.next_recovery_generation, 2); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn arm_recovery_supersedes_a_flushing_generation_with_a_newer_one() { + let git_dir = unique_test_git_dir("arm-supersedes-flushing"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + arm_recovery(&git_dir).expect("arm g1"); + admit(&git_dir, &key("session-1", None, "exec-new"), "Bash"); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Flushing { generation: 1 }, + ); + + let generation = arm_recovery(&git_dir).expect("re-arm during flush"); + assert_eq!(generation, 2); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 2 }, + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn complete_recovery_flush_clears_only_with_the_matching_generation() { + let git_dir = unique_test_git_dir("complete-matching-generation"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + arm_recovery(&git_dir).expect("arm"); + admit(&git_dir, &key("session-1", None, "exec-new"), "Bash"); + + assert_eq!( + complete_recovery_flush(&git_dir, 2).expect("wrong-generation completion"), + RecoveryFlushCompletion::Superseded, + ); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Flushing { generation: 1 }, + ); + + assert_eq!( + complete_recovery_flush(&git_dir, 1).expect("matching completion"), + RecoveryFlushCompletion::Cleared, + ); + assert!(read_state(&git_dir) + .expect("state readable") + .recovery + .is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn complete_recovery_flush_is_a_no_op_when_a_newer_recovery_was_armed() { + let git_dir = unique_test_git_dir("complete-superseded-by-newer"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + arm_recovery(&git_dir).expect("arm g1"); + admit(&git_dir, &key("session-1", None, "exec-new"), "Bash"); + arm_recovery(&git_dir).expect("re-arm to g2 while flushing g1"); + + assert_eq!( + complete_recovery_flush(&git_dir, 1).expect("stale completion"), + RecoveryFlushCompletion::Superseded, + ); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 2 }, + "I4: a stale Flush(g1) completion must not clear recovery armed for g2" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn stale_generation_completion_against_clear_state_is_a_safe_no_op() { + let git_dir = unique_test_git_dir("complete-stale-against-clear"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + assert_eq!( + complete_recovery_flush(&git_dir, 7).expect("stale completion against clear"), + RecoveryFlushCompletion::Superseded, + ); + assert!(read_state(&git_dir) + .expect("state readable") + .recovery + .is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn relinquish_recovery_flush_returns_a_claimed_generation_to_pending() { + let git_dir = unique_test_git_dir("relinquish-to-pending"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + arm_recovery(&git_dir).expect("arm"); + admit(&git_dir, &key("session-1", None, "exec-new"), "Bash"); + + relinquish_recovery_flush(&git_dir, 1).expect("relinquish should succeed"); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 1 }, + ); + + relinquish_recovery_flush(&git_dir, 1).expect("second relinquish is a no-op"); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 1 }, + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn relinquish_recovery_flush_is_a_no_op_for_a_superseded_generation() { + let git_dir = unique_test_git_dir("relinquish-superseded"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + arm_recovery(&git_dir).expect("arm g1"); + admit(&git_dir, &key("session-1", None, "exec-new"), "Bash"); + arm_recovery(&git_dir).expect("re-arm to g2"); + + relinquish_recovery_flush(&git_dir, 1).expect("stale relinquish"); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 2 }, ); - assert_ne!( - first.attempt.attempt_seq, second.attempt.attempt_seq, - "AC5: attempt_seq must never be reused after the prior attempt became terminal" + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_state_survives_serialization_and_reload() { + let git_dir = unique_test_git_dir("recovery-round-trip"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + arm_recovery(&git_dir).expect("arm"); + assert_eq!( + read_state(&git_dir).expect("reload pending").recovery, + RecoveryState::Pending { generation: 1 }, ); - assert_ne!( - first.attempt.scope_id, second.attempt.scope_id, - "AC5: a terminal ScopeId must never be reused" + + admit(&git_dir, &key("session-1", None, "exec-new"), "Bash"); + assert_eq!( + read_state(&git_dir).expect("reload flushing").recovery, + RecoveryState::Flushing { generation: 1 }, ); remove_test_git_dir(&git_dir); @@ -521,19 +998,17 @@ mod tests { 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, "exec-1"), "Bash") - .expect("allocation should succeed"); + let AdmitDecision::Admitted(allocated) = + admit(&git_dir, &key("session-1", None, "exec-1"), "Bash") + else { + panic!("expected Admitted"); + }; 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); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); remove_test_git_dir(&git_dir); } @@ -546,9 +1021,10 @@ mod tests { let error = mark_active(&git_dir, "cx-tool-v1|n=99|s=1:x|a=0:|t=1:y") .expect_err("marking an unknown scope active must be rejected"); assert!(error.to_string().contains("No adapter-state attempt found")); - - let state = read_state(&git_dir).expect("state should be readable"); - assert!(state.attempts.is_empty(), "no attempt may be fabricated"); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); remove_test_git_dir(&git_dir); } @@ -557,16 +1033,16 @@ mod tests { 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, "exec-1"), "Bash") - .expect("allocation should succeed"); + let attempt = admit_and_activate(&git_dir, &key("session-1", None, "exec-1"), "Bash"); - 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"); + remove_attempt(&git_dir, &attempt.scope_id).expect("first removal should succeed"); + remove_attempt(&git_dir, &attempt.scope_id) + .expect("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()); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); remove_test_git_dir(&git_dir); } @@ -576,24 +1052,22 @@ mod tests { let git_dir = unique_test_git_dir("round-trip"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let allocated = allocate_attempt( + let attempt = admit_and_activate( &git_dir, &key("session-7", Some("agent-2"), "exec-9"), "apply_patch", - ) - .expect("allocation should succeed"); - mark_active(&git_dir, &allocated.attempt.scope_id).expect("mark_active should succeed"); - mark_recovery_pending(&git_dir).expect("marking recovery pending should succeed"); + ); + arm_recovery(&git_dir).expect("arming recovery should succeed"); let reloaded = read_state(&git_dir).expect("state should reload"); assert_eq!(reloaded.version, ADAPTER_STATE_VERSION); assert_eq!(reloaded.next_attempt_seq, 2); - assert!(reloaded.recovery_pending); + assert_eq!(reloaded.recovery, RecoveryState::Pending { generation: 1 }); assert_eq!(reloaded.attempts.len(), 1); assert_eq!(reloaded.attempts[0].phase, AttemptPhase::Active); assert_eq!(reloaded.attempts[0].agent_id.as_deref(), Some("agent-2")); assert_eq!(reloaded.attempts[0].tool_name, "apply_patch"); - assert_eq!(reloaded.attempts[0].scope_id, allocated.attempt.scope_id); + assert_eq!(reloaded.attempts[0].scope_id, attempt.scope_id); remove_test_git_dir(&git_dir); } @@ -613,26 +1087,36 @@ mod tests { } #[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), + fn an_unsupported_or_prior_version_state_file_is_rejected() { + for payload in [ serde_json::json!({ "version": 99, "next_attempt_seq": 1, + "next_recovery_generation": 1, + "recovery": { "phase": "clear" }, + "attempts": [] + }), + serde_json::json!({ + "version": 1, + "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")); + }), + ] { + 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), payload.to_string()) + .expect("state file should be written"); + + let error = read_state(&git_dir).expect_err("unsupported version must be rejected"); + assert!( + error.to_string().contains("unsupported version"), + "payload {payload} produced {error}" + ); - remove_test_git_dir(&git_dir); + remove_test_git_dir(&git_dir); + } } #[test] @@ -646,32 +1130,16 @@ mod tests { ..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" - ); + assert!(tmp_path.exists()); + assert!(!canonical_path.exists()); 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!(result.is_err()); + assert!(!state_path(&git_dir).exists()); assert_eq!( - after, + read_state(&git_dir).expect("read should not error on an absent canonical file"), AdapterState::default(), - "an interrupted write must not partially apply" ); remove_test_git_dir(&git_dir); @@ -685,53 +1153,68 @@ mod tests { 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, "exec-1"), "Bash"); + let decision = admit_tracked_attempt(&git_dir, &key("session-1", None, "exec-1"), "Bash"); assert!( - result.is_ok(), + matches!(decision, Ok(AdmitDecision::Admitted(_))), "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; + const PARALLEL_ADMISSION_COUNT: u64 = 6; #[test] - fn parallel_writers_for_distinct_keys_all_converge_without_lost_updates() { - let git_dir = unique_test_git_dir("parallel-writers"); + fn parallel_admissions_serialize_and_converge_without_lost_updates() { + let git_dir = unique_test_git_dir("parallel-admissions"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let handles: Vec<_> = (0..PARALLEL_WRITER_COUNT) + let handles: Vec<_> = (0..PARALLEL_ADMISSION_COUNT) .map(|index| { let git_dir = git_dir.clone(); thread::spawn(move || { let attempt_key = key("session-1", None, &format!("exec-{index}")); - allocate_attempt(&git_dir, &attempt_key, "Bash") - .expect("each concurrent allocation should durably succeed") + loop { + match admit_tracked_attempt(&git_dir, &attempt_key, "Bash") + .expect("admit should not error") + { + AdmitDecision::Admitted(allocated) => { + mark_active(&git_dir, &allocated.attempt.scope_id) + .expect("mark_active should succeed"); + break allocated.attempt.attempt_seq; + } + AdmitDecision::UncertainAttemptBlocked => { + thread::sleep(Duration::from_millis(5)); + } + other => panic!("unexpected admission decision: {other:?}"), + } + } }) }) .collect(); - let allocated: Vec = handles + let mut attempt_seqs: Vec = handles .into_iter() - .map(|handle| handle.join().expect("writer thread should not panic")) + .map(|handle| handle.join().expect("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" + usize::try_from(PARALLEL_ADMISSION_COUNT).unwrap(), + "concurrent admissions must serialize and never collide on attempt_seq" ); - let state = read_state(&git_dir).expect("state should be readable after concurrent writes"); + let state = read_state(&git_dir).expect("state should be readable after concurrent admits"); assert_eq!( state.attempts.len(), - usize::try_from(PARALLEL_WRITER_COUNT).unwrap() + usize::try_from(PARALLEL_ADMISSION_COUNT).unwrap() ); - assert_eq!(state.next_attempt_seq, PARALLEL_WRITER_COUNT + 1); + assert_eq!(state.next_attempt_seq, PARALLEL_ADMISSION_COUNT + 1); + assert!(state + .attempts + .iter() + .all(|a| a.phase == AttemptPhase::Active)); remove_test_git_dir(&git_dir); } @@ -773,21 +1256,23 @@ mod tests { } #[test] - fn every_public_helper_releases_the_state_lock_before_returning() { + fn every_locked_helper_releases_the_state_lock_before_returning() { let git_dir = unique_test_git_dir("lock-released-between-helpers"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); let attempt_key = key("session-1", None, "exec-1"); - let allocated = allocate_attempt(&git_dir, &attempt_key, "Bash") - .expect("allocate_attempt must release the lock before returning"); + let AdmitDecision::Admitted(allocated) = admit(&git_dir, &attempt_key, "Bash") else { + panic!("expected Admitted"); + }; mark_active(&git_dir, &allocated.attempt.scope_id) - .expect("mark_active must acquire the lock allocate_attempt released"); - mark_recovery_pending(&git_dir) - .expect("mark_recovery_pending must acquire the lock mark_active released"); - clear_recovery_pending(&git_dir) - .expect("clear_recovery_pending must acquire the lock mark_recovery_pending released"); + .expect("mark_active must acquire the lock admit released"); + arm_recovery(&git_dir).expect("arm_recovery must acquire the lock mark_active released"); remove_attempt(&git_dir, &allocated.attempt.scope_id) - .expect("remove_attempt must acquire the lock clear_recovery_pending released"); + .expect("remove_attempt must acquire the lock arm_recovery released"); + complete_recovery_flush(&git_dir, 999) + .expect("complete_recovery_flush must acquire the lock remove_attempt released"); + relinquish_recovery_flush(&git_dir, 999) + .expect("relinquish_recovery_flush must acquire the lock"); drop( AdapterStateLock::acquire(&git_dir, Duration::from_millis(200)) @@ -797,62 +1282,12 @@ 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, "exec-1"), "Bash") - .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, "D13: 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"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - allocate_attempt(&git_dir, &key("session-1", None, "exec-1"), "Bash") - .expect("allocation should succeed"); + admit(&git_dir, &key("session-1", None, "exec-1"), "Bash"); let sce_dir = git_dir.join(SCE_STATE_DIR); assert!(state_path(&git_dir).starts_with(&sce_dir)); @@ -861,18 +1296,12 @@ mod tests { 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/" - ); + assert!(entry.path().starts_with(&sce_dir)); if entry.path() == state_path(&git_dir) { found_state_file = true; } } - assert!( - found_state_file, - "state file should exist under /sce/" - ); + assert!(found_state_file); remove_test_git_dir(&git_dir); } diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 56d1ffb6..a423fae3 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -792,7 +792,7 @@ recovery_pending == true AND attempts.is_empty() -> a failed flush stays fail-closed ``` -A failed abandonment leaves the attempt tracked and `recovery_pending = true` +A failed abandonment leaves the attempt tracked and recovery armed (never silently allows a successor tracked-mutation execution). **Successor-Start invariant.** A `TrackedMutation` `PreToolUse` must never reach @@ -804,6 +804,57 @@ and no barrier is needed. This invariant does **not** license abandoning an attempt that can legitimately run concurrently with the successor (D10a invariant (ii)). +**D13a — inter-process concurrency semantics (T04 follow-up, 2026-09-08).** +Codex runs every hook as an independent OS process, so the barrier and +tracked-attempt admission must be atomic across processes, not merely +lock-serialized field writes. The initial T04 driver composed the decision from +an unlocked `read_state` recovery check followed by a separate `allocate_attempt` +(no recovery re-check) plus a plain-boolean `recovery_pending` — a TOCTOU gap +where a second process could arm recovery in between, a duplicate quiescent +`flush`, and a stale `flush` completion clearing a newer recovery. The follow-up +makes recovery **generation-aware** and moves admission into one locked +transition: + +```text +recovery state = Clear | Pending(generation) | Flushing(generation) + + a monotonic next_recovery_generation + +admit_tracked_attempt(key, tool) -- one adapter-state-lock transition: + Flushing(_) -> RecoveryBlocked + Pending(g) AND attempts non-empty -> RecoveryBlocked + Pending(g) AND attempts empty -> persist Flushing(g); return FlushClaimed(g) + Clear AND key already tracked -> reuse that attempt (idempotent) + Clear AND an unrelated PendingStart -> UncertainAttemptBlocked + Clear otherwise -> persist a fresh PendingStart; return Admitted + +arm_recovery() -- one transition: + Clear -> Pending(next_recovery_generation++) + Pending(g) -> Pending(g) (kept; not a downgrade) + Flushing(g) -> Pending(next_recovery_generation++) (supersedes the in-flight flush) + +complete_recovery_flush(g) -- one transition, run AFTER the flush seam: + Flushing(g) -> Clear (only when the generation still matches) + otherwise -> no-op (a newer recovery armed while the flush ran survives) + +relinquish_recovery_flush(g) -- flush seam failed: + Flushing(g) -> Pending(g) so a later PreToolUse re-claims and retries +``` + +The adapter-state lock is still **never** held across a `hooks::mutation_scope` +seam call (I6). The driver drives at most one quiescent `flush` per +`PreToolUse`: `FlushClaimed(g)` -> flush seam -> `complete_recovery_flush(g)` -> +one re-entrant `admit_tracked_attempt`; a re-armed generation observed on +re-entry is relinquished and the tool denied (the next `PreToolUse` owns it). + +**D13b — unresolved `PendingStart` is a conservative barrier (Problem 4).** +`Start` seam success followed by a failed `mark_active` leaves a durable +`PendingStart` attempt whose runtime `Start` may have committed. That attempt now +blocks a successor tracked admission (`UncertainAttemptBlocked`) until a positive +cleanup signal abandons it -> arms recovery -> quiescent `flush`. `PendingStart` +means "the adapter cannot prove whether `Start` committed", never "`Start` +definitely failed". A duplicate delivery for the same `AttemptKey` still reuses +the same attempt and `ScopeId` (no second scope). + ### D14 — Concurrency and AiContended Two simultaneously-live **tracked** scopes (two tracked Codex executions per D1, @@ -2146,11 +2197,16 @@ Persist this field in every plan; this is durable plan state, not chat state: `GitDirResolver` / `IngressSeam` injected `&dyn Fn` aliases, `ACTOR_KIND_CODEX`, `run_codex_mutation_scope_subcommand` / `_from_payload` / `_from_payload_with` (+ `#[cfg(test)] _at_state_root`), `dispatch_codex_hook_event`, - `handle_pre_tool_use`, `apply_recovery_barrier` / `BarrierOutcome`, + `handle_pre_tool_use`, `admit_or_recover` / `readmit_after_flush` / + `Admission` (superseding the initial `apply_recovery_barrier` / + `BarrierOutcome` — see the concurrency follow-up), `establish_start`, `handle_close`, `cleanup_attempts_matching`, `abandon_attempt`, `attempt_matches_key`, `scope_boundary_payload` / `abandon_payload` / `flush_payload`, `pre_tool_use_deny_json`, - `log_pre_tool_use_fail_closed`; 30 new `mod driver` unit tests) + `log_pre_tool_use_fail_closed`; `mod driver` unit tests including the + inter-process concurrency regressions Test A–F) + - `cli/src/services/hooks/codex_mutation_scope/state.rs` (T03's durable store, + materially revised by the concurrency follow-up — see below) - Result: Wired the hidden `sce hooks codex-mutation-scope` command through the normal hook stack unwrapped (non-fail-open), exactly as `mutation-scope` / `claude-mutation-scope`, and implemented the Codex adapter driver as a close @@ -2233,6 +2289,64 @@ Persist this field in every plan; this is durable plan state, not chat state: **clean**. - `cargo fmt --manifest-path cli/Cargo.toml -- --check` — **clean** (after `cargo fmt`). + - Follow-up (2026-09-08 — inter-process concurrency correctness, PR #268): + The initial T04 driver implementation exposed an inter-process TOCTOU gap + between recovery-barrier inspection and tracked-attempt allocation, plus + duplicate quiescent `flush` and stale-`flush`-clear risks. `apply_recovery_barrier` + did an unlocked `read_state` recovery check and then a separate + `allocate_attempt` that never re-checked recovery, and `recovery_pending` was a + bare boolean. The follow-up moved recovery ownership and tracked admission into + **generation-aware atomic adapter-state transitions** (see D13a/D13b): + `state.rs` bumps `ADAPTER_STATE_VERSION` to `2`, replaces `recovery_pending: bool` + with `recovery: RecoveryState` (`Clear | Pending{generation} | Flushing{generation}`) + + a monotonic `next_recovery_generation`, and replaces `allocate_attempt` / + `mark_recovery_pending` / `clear_recovery_pending` with `admit_tracked_attempt` + (one locked transition: recovery check + duplicate-key reuse + unresolved- + `PendingStart` barrier + fresh `PendingStart` persist, returning + `Admitted | RecoveryBlocked | UncertainAttemptBlocked | FlushClaimed{generation}`), + `arm_recovery` (`Clear→Pending(new)`, `Pending(g)` kept, `Flushing(g)→Pending(new)`), + `complete_recovery_flush(g)` (clears only while still `Flushing(g)`), and + `relinquish_recovery_flush(g)` (failed flush → `Pending(g)` for retry). The + driver's `admit_or_recover` runs at most one quiescent `flush` per `PreToolUse` + with the state lock never held across the seam (I6). A `Start`-succeeded-then- + `mark_active`-failed attempt stays `PendingStart` and conservatively blocks a + successor Start until a cleanup signal abandons it → recovery → `flush` + (Problem 4 / D13b). + - New durable state shape: `{ version:2, next_attempt_seq, next_recovery_generation, + recovery: RecoveryState, attempts[] }`; a v1-shaped or unknown-version file is + rejected, never fabricated. + - `state.rs` state-machine tests: `Clear→Pending(g)`, `Pending(g)→Flushing(g)`, + one-claimer-per-generation (concurrent), `Flushing(g)→Clear` only on matching + generation, stale/superseded completion is a safe no-op, `Flushing(g1)` + + newer recovery survives completion(g1), recovery survives serialization/reload, + atomic admission refuses recovery state and persists `PendingStart` before + returning, blocks an unrelated `PendingStart`, allows a new key alongside an + `Active` attempt; retained: parallel admission converges without lost updates, + monotonic `attempt_seq`, checkout-local, durable write, malformed/version + rejection, OS lock behaviour + lock-released-between-helpers. + - `mod.rs` inter-process concurrency regressions (deterministic, channel-gated + seams — no probabilistic sleeps): **Test A** recovery armed mid-abandon blocks + a concurrent tracked `PreToolUse` and no `start` payload for it reaches the + seam; **Test B** two quiescent recovery callers emit exactly one `flush(g)`; + **Test C** recovery re-armed while `flush(g1)` is in flight → final state + `Pending(g2)`, the stale completion cannot clear it; **Test D** `Start` ok + + `mark_active` fails → successor denied, then cleanup → abandon → recovery → + one `flush` then `start`; **Test E** duplicate `AttemptKey` reuses the same + `ScopeId`; **Test F** `mcp__*` / unknown / delegation stay neutral while + recovery is `Pending`/`Flushing`. + - Re-validated: `services::hooks::codex_mutation_scope` — **passed** (87 tests); + `services::hooks::mutation_scope` — **passed** (36); `services::hooks::` — + **passed** (418); `services::hooks::codex` — **passed** (216, existing + dispatcher unaffected); `services::mutation_trace::` — **passed** (323, no + protocol/runtime change); `clippy --all-targets -- -D warnings` — **clean**; + `cargo fmt -- --check` — **clean**. AC19 boundary still clean (only + `super::mutation_scope::run_mutation_scope_from_payload`); no + `spec/mutation_cursor.qnt` / `mutation_trace/protocol.rs` / + `mutation_trace/runtime/` / `mutation_trace/store.rs` / + `cli/migrations/agent-trace-repository/` / `agent-trace.schema.json` change; + MCP Option B classification unchanged; no daemon, PID supervision, polling, or + new DB; the adapter-state lock is never held across the generic ingress seam. + Changes are confined to `codex_mutation_scope/{state,mod}.rs` and this plan. - Context impact: domain — a new adapter-domain driver plus a new hidden CLI route. User-visible surface: one hidden `sce hooks codex-mutation-scope` subcommand (hidden from `sce --help` / `sce hooks --help`); no visible-help @@ -2275,6 +2389,13 @@ Persist this field in every plan; this is durable plan state, not chat state: structurally mirroring `claude_mutation_scope`; the Codex hook-ownership ADR (`2026-08-23-codex-nondestructive-hook-ownership.md`) is untouched (that is T05's `codex_hook_config.rs` scope). + - Concurrency follow-up (2026-09-08, PR #268) re-checked the five roots and + remains `no_context_change`: the generation-aware recovery state machine + (D13a/D13b) is adapter-internal inter-process synchronisation with no + protocol, Quint, runtime-semantic, SQL, or Agent Trace schema change (AC22 + still holds) and no user-visible surface change — the adapter is still + inert and unregistered. Durable adapter context (D13a/b, the coverage + table, the concurrency story) is authored by T07 as already recorded. - [ ] T05: `Generated .codex/hooks.json registrations, setup merge, and doctor` (status:todo) - Task ID: T05 From bb54a73d240f321d6b8c2f48c92a4dceacdec8b1 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 12:58:40 +0200 Subject: [PATCH 08/20] hooks: Add a boundary lock for Codex mutation scopes Serialize each tracked adapter transaction across durable state transitions and runtime ingress with a checkout-local OS advisory lock. Reuse the shared lock primitive, reclaim orphaned Flushing generations, and suppress duplicate Start calls for already-active attempts. Add concurrency, crash-recovery, process-death, and untracked-tool regression coverage. Plan: codex-mutation-scope-integration, T07 (D13c) Co-authored-by: SCE --- .../codex_mutation_scope/boundary_lock.rs | 195 +++++++ .../hooks/codex_mutation_scope/mod.rs | 534 ++++++++++++++---- .../hooks/codex_mutation_scope/os_lock.rs | 95 ++++ .../hooks/codex_mutation_scope/state.rs | 164 +++--- .../plans/codex-mutation-scope-integration.md | 174 +++++- 5 files changed, 970 insertions(+), 192 deletions(-) create mode 100644 cli/src/services/hooks/codex_mutation_scope/boundary_lock.rs create mode 100644 cli/src/services/hooks/codex_mutation_scope/os_lock.rs diff --git a/cli/src/services/hooks/codex_mutation_scope/boundary_lock.rs b/cli/src/services/hooks/codex_mutation_scope/boundary_lock.rs new file mode 100644 index 00000000..eb41286d --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/boundary_lock.rs @@ -0,0 +1,195 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use super::os_lock::{AdvisoryLockError, OsAdvisoryLock}; +use super::state::adapter_state_dir; + +const ADAPTER_BOUNDARY_LOCK_FILE: &str = "codex-mutation-scope-boundary.lock"; +const BOUNDARY_LOCK_WHAT: &str = "adapter-boundary"; + +pub(crate) const DEFAULT_BOUNDARY_LOCK_TIMEOUT: Duration = Duration::from_secs(10); + +pub(crate) fn boundary_lock_path(git_dir: &Path) -> PathBuf { + adapter_state_dir(git_dir).join(ADAPTER_BOUNDARY_LOCK_FILE) +} + +pub(crate) struct AdapterBoundaryLock { + _inner: OsAdvisoryLock, +} + +impl AdapterBoundaryLock { + pub(crate) fn acquire( + git_dir: &Path, + timeout: Duration, + ) -> Result { + let inner = OsAdvisoryLock::acquire( + &adapter_state_dir(git_dir), + boundary_lock_path(git_dir), + timeout, + BOUNDARY_LOCK_WHAT, + )?; + Ok(AdapterBoundaryLock { _inner: inner }) + } +} + +#[cfg(test)] +mod tests { + use std::io::Write as _; + use std::process::Command; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::mpsc; + use std::thread; + + use super::*; + + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + + fn unique_git_dir(label: &str) -> PathBuf { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-codex-mutation-scope-boundary-{label}-{}-{id}", + std::process::id() + )) + } + + const CHILD_ENV_GIT_DIR: &str = "SCE_BOUNDARY_LOCK_CHILD_GIT_DIR"; + const CHILD_ENV_TIMEOUT_MS: &str = "SCE_BOUNDARY_LOCK_CHILD_TIMEOUT_MS"; + const CHILD_HELPER_PATH: &str = + "services::hooks::codex_mutation_scope::boundary_lock::tests::boundary_lock_child_helper"; + + #[test] + #[ignore = "subprocess helper, driven by process_death_releases_the_boundary_lock"] + fn boundary_lock_child_helper() { + let git_dir = PathBuf::from( + std::env::var(CHILD_ENV_GIT_DIR).expect("child helper needs a git dir in the env"), + ); + let timeout = Duration::from_millis( + std::env::var(CHILD_ENV_TIMEOUT_MS) + .expect("child helper needs a timeout in the env") + .parse() + .expect("timeout must parse"), + ); + match AdapterBoundaryLock::acquire(&git_dir, timeout) { + Ok(lock) => { + print!("ACQUIRED"); + std::io::stdout().flush().expect("flush stdout"); + std::mem::forget(lock); + } + Err(AdvisoryLockError::TimedOut { .. }) => { + print!("TIMEOUT"); + std::io::stdout().flush().expect("flush stdout"); + } + Err(other) => panic!("unexpected child lock error: {other}"), + } + } + + fn run_child(git_dir: &Path, timeout: Duration) -> String { + let exe = std::env::current_exe().expect("test executable path should resolve"); + let output = Command::new(exe) + .args(["--exact", "--ignored", "--nocapture", CHILD_HELPER_PATH]) + .env(CHILD_ENV_GIT_DIR, git_dir) + .env(CHILD_ENV_TIMEOUT_MS, timeout.as_millis().to_string()) + .output() + .expect("the child test process should spawn"); + assert!( + output.status.success(), + "child process failed: stdout={:?} stderr={:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains("ACQUIRED") { + "ACQUIRED".to_string() + } else if stdout.contains("TIMEOUT") { + "TIMEOUT".to_string() + } else { + panic!("child produced no lock verdict: {stdout}"); + } + } + + #[test] + fn process_death_releases_the_boundary_lock() { + let git_dir = unique_git_dir("process-death"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let held = AdapterBoundaryLock::acquire(&git_dir, Duration::from_secs(5)) + .expect("the parent should acquire the boundary lock immediately"); + + assert_eq!( + run_child(&git_dir, Duration::from_millis(300)), + "TIMEOUT", + "a second OS process must not acquire the boundary lock while the parent holds it", + ); + + drop(held); + + assert_eq!( + run_child(&git_dir, Duration::from_millis(300)), + "ACQUIRED", + "once the parent releases, a child process acquires it", + ); + + let reacquired = AdapterBoundaryLock::acquire(&git_dir, Duration::from_secs(2)) + .expect("the child's process exit must have released the boundary lock"); + drop(reacquired); + + let _ = std::fs::remove_dir_all(&git_dir); + } + + #[test] + fn a_leftover_boundary_lock_file_alone_does_not_block_acquisition() { + let git_dir = unique_git_dir("leftover-file"); + std::fs::create_dir_all(adapter_state_dir(&git_dir)).expect("state dir should be created"); + std::fs::write(boundary_lock_path(&git_dir), b"leftover") + .expect("leftover lock file should be writable"); + + AdapterBoundaryLock::acquire(&git_dir, Duration::from_millis(200)) + .expect("a lock file with no live OS owner must not block a new acquirer"); + + let _ = std::fs::remove_dir_all(&git_dir); + } + + #[test] + fn a_second_in_process_acquirer_blocks_until_the_first_releases() { + let git_dir = unique_git_dir("in-process-contention"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let holder = AdapterBoundaryLock::acquire(&git_dir, Duration::from_secs(5)) + .expect("first acquirer should succeed immediately"); + + let (tx, rx) = mpsc::channel(); + let git_dir_clone = git_dir.clone(); + let handle = thread::spawn(move || { + let result = AdapterBoundaryLock::acquire(&git_dir_clone, Duration::from_secs(5)); + let _ = tx.send(()); + result.is_ok() + }); + + assert!( + rx.recv_timeout(Duration::from_millis(300)).is_err(), + "the second acquirer must not proceed while the first holds the boundary lock", + ); + + drop(holder); + + rx.recv_timeout(Duration::from_secs(5)) + .expect("the second acquirer should complete once the first releases"); + assert!(handle + .join() + .expect("second acquirer thread should not panic")); + + let _ = std::fs::remove_dir_all(&git_dir); + } + + #[test] + fn the_boundary_lock_path_is_distinct_from_the_state_lock_and_lives_under_sce() { + let git_dir = unique_git_dir("path-shape"); + let path = boundary_lock_path(&git_dir); + assert!(path.starts_with(adapter_state_dir(&git_dir))); + assert!(path.ends_with(ADAPTER_BOUNDARY_LOCK_FILE)); + assert_ne!( + path.file_name(), + Path::new("codex-mutation-scope-state.lock").file_name(), + ); + } +} diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index 5cf23b20..13285d1e 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -1,5 +1,7 @@ #![allow(dead_code)] +mod boundary_lock; +mod os_lock; pub(crate) mod state; use std::path::{Path, PathBuf}; @@ -10,6 +12,8 @@ use serde_json::{json, Map, Value}; use crate::services::checkout; use crate::services::observability::traits::Logger; +use boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; + const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; const SESSION_ID_FIELD: &str = "session_id"; const TURN_ID_FIELD: &str = "turn_id"; @@ -350,28 +354,34 @@ fn dispatch_codex_hook_event( CodexHookEvent::PostToolUse(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, - ) + with_boundary_lock(&git_dir, || { + handle_close( + &git_dir, + repository_root, + &identity.attempt_key(), + logger, + seam, + ) + }) } CodexHookEvent::Stop(turn) => { let git_dir = resolve_git_dir(&turn.cwd)?; let repository_root = Path::new(&turn.cwd); let session_id = turn.session_id.clone(); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id && attempt.agent_id.is_none() + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.is_none() + }) }) } CodexHookEvent::Interrupt(turn) => { let git_dir = resolve_git_dir(&turn.cwd)?; let repository_root = Path::new(&turn.cwd); let session_id = turn.session_id.clone(); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + }) }) } CodexHookEvent::SubagentStop(agent) => { @@ -379,21 +389,32 @@ fn dispatch_codex_hook_event( 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) + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + && attempt.agent_id.as_deref() == Some(&agent_id) + }) }) } CodexHookEvent::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 + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + }) }) } } } +fn with_boundary_lock(git_dir: &Path, operation: impl FnOnce() -> Result) -> Result { + let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) + .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; + operation() +} + fn handle_pre_tool_use( execution: &CodexToolExecution, logger: Option<&dyn Logger>, @@ -419,31 +440,40 @@ fn handle_pre_tool_use( }; let key = identity.attempt_key(); - match admit_or_recover( - &git_dir, - repository_root, - &key, - &identity.tool_name, - logger, - seam, - ) { - Ok(Admission::Admitted(allocated)) => { - match establish_start(&git_dir, repository_root, &allocated, 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) - } + let outcome = with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + + match admit_or_recover( + &git_dir, + repository_root, + &key, + &identity.tool_name, + logger, + seam, + )? { + Admission::Admitted(allocated) => { + establish_start(&git_dir, repository_root, &allocated, logger, seam)?; + Ok(PreToolUseOutcome::Continue) } + Admission::Denied => Ok(PreToolUseOutcome::Deny), } - Ok(Admission::Denied) => pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + }); + + match outcome { + Ok(PreToolUseOutcome::Continue) => String::new(), + Ok(PreToolUseOutcome::Deny) => pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), Err(error) => { - log_pre_tool_use_fail_closed(logger, "admit_tracked_attempt", &error); + log_pre_tool_use_fail_closed(logger, "codex_mutation_scope_pre_tool_use", &error); pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) } } } +enum PreToolUseOutcome { + Continue, + Deny, +} + enum Admission { Admitted(state::AllocatedAttempt), Denied, @@ -501,6 +531,11 @@ fn establish_start( seam: IngressSeam, ) -> Result<()> { let scope_id = &allocated.attempt.scope_id; + + if allocated.reused && allocated.attempt.phase == state::AttemptPhase::Active { + return Ok(()); + } + let start_payload = scope_boundary_payload("start", scope_id, &codex_scope_start_event_id(scope_id)); @@ -2052,9 +2087,46 @@ mod tests { assert!(error.to_string().contains("unsupported hook_event_name")); } + fn spawn_pre_tool_use( + git_dir: &Path, + tool_use_id: &'static str, + seam: impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send + 'static, + ) -> (thread::JoinHandle, mpsc::Receiver<()>) { + let (done_tx, done_rx) = mpsc::channel(); + let resolver = fixed_resolver(git_dir.to_path_buf()); + let handle = thread::spawn(move || { + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String(tool_use_id.to_string()), + )]), + None, + &resolver, + &seam, + ) + .expect("PreToolUse should return Ok"); + let _ = done_tx.send(()); + output + }); + (handle, done_rx) + } + + fn assert_still_blocked(done_rx: &mpsc::Receiver<()>, context: &str) { + assert!( + done_rx.recv_timeout(Duration::from_millis(250)).is_err(), + "{context}: the operation must still be blocked on the boundary lock", + ); + } + + fn first_index_of(recorded: &[String], operation: &str) -> Option { + recorded + .iter() + .position(|payload| payload.contains(&format!(r#""operation":"{operation}""#))) + } + #[test] - fn test_a_recovery_armed_mid_abandon_blocks_a_concurrent_tracked_pre_tool_use() { - let git_dir = unique_test_git_dir("race-admission-vs-recovery"); + fn test_h_cleanup_owning_the_boundary_lock_blocks_admission_until_recovery_is_processed() { + let git_dir = unique_test_git_dir("test-h-cleanup-owns-boundary"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); seed_attempt( &git_dir, @@ -2064,11 +2136,10 @@ mod tests { state::AttemptPhase::Active, ); - let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (abandon_seam, gate) = gated_seam("abandon", Arc::clone(&calls)); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (abandon_seam, gate) = gated_seam("abandon", Arc::clone(&recorded)); let sweeper = { - let git_dir = git_dir.clone(); let resolver = fixed_resolver(git_dir.clone()); thread::spawn(move || { run_codex_mutation_scope_from_payload_with( @@ -2082,65 +2153,147 @@ mod tests { }; gate.wait_until_entered(); - assert_eq!( read_state(&git_dir).recovery, state::RecoveryState::Pending { generation: 1 }, - "arm_recovery must run before the abandon seam call", + "cleanup arms recovery while it owns the boundary lock", ); - let resolver = fixed_resolver(git_dir.clone()); - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]), - None, - &resolver, - &unreachable_seam, - ) - .expect("the concurrent tracked PreToolUse must return a deny payload"); - assert_eq!( - output, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "Test A: B must be denied and must never reach the Start seam", + let (b_handle, b_done) = + spawn_pre_tool_use(&git_dir, "exec-b", recording_seam(Arc::clone(&recorded))); + assert_still_blocked(&b_done, "Test H"); + assert!( + first_index_of(&recorded.lock().unwrap(), "start").is_none(), + "Test H: B must not reach Start while cleanup owns the boundary lock", ); gate.release(); sweeper.join().expect("sweeper thread should not panic"); - let recorded = calls.lock().expect("calls mutex").clone(); + let b_output = b_handle.join().expect("B thread should not panic"); + assert_eq!( + b_output, "", + "Test H: once recovery is processed B proceeds" + ); + + let recorded = recorded.lock().unwrap().clone(); + let abandon_at = + first_index_of(&recorded, "abandon").expect("cleanup abandoned exec-a"); + let flush_at = first_index_of(&recorded, "flush").expect("B drove the quiescent flush"); + let start_at = first_index_of(&recorded, "start").expect("B reached Start"); assert!( - recorded - .iter() - .all(|payload| !payload.contains(r#""operation":"start""#)), - "Test A: no start payload for B may reach the seam, got {recorded:?}", + abandon_at < flush_at && flush_at < start_at, + "Test H: the serialized order must be abandon -> flush -> start, got {recorded:?}", ); - assert!(read_state(&git_dir).attempts.is_empty()); + + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); remove_test_git_dir(&git_dir); } #[test] - fn test_b_two_quiescent_recovery_callers_emit_exactly_one_flush() { - let git_dir = unique_test_git_dir("race-two-quiescent-flush"); + fn test_g_admission_completed_recovery_cannot_arm_before_start() { + let git_dir = unique_test_git_dir("test-g-admit-before-start"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let generation = state::arm_recovery(&git_dir).expect("arming recovery should succeed"); - let flush_calls = Arc::new(AtomicUsize::new(0)); - let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (owner_seam, gate) = gated_seam("flush", Arc::clone(&calls)); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (start_seam, gate) = gated_seam("start", Arc::clone(&recorded)); + + let p1 = { + let resolver = fixed_resolver(git_dir.clone()); + thread::spawn(move || { + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-b".to_string()), + )]), + None, + &resolver, + &start_seam, + ) + .expect("P1 PreToolUse should return Ok") + }) + }; + + gate.wait_until_entered(); + let mid = read_state(&git_dir); + assert_eq!(mid.attempts.len(), 1); + assert_eq!(mid.attempts[0].phase, state::AttemptPhase::PendingStart); + assert!( + mid.recovery.is_clear(), + "recovery must still be Clear while P1 holds the boundary lock pre-Start", + ); + + let (p2_handle, p2_done) = spawn_pre_tool_use( + &git_dir, + "exec-cleanup-trigger", + recording_seam(Arc::clone(&recorded)), + ); + + let sweeper = { + let resolver = fixed_resolver(git_dir.clone()); + let recorded = Arc::clone(&recorded); + thread::spawn(move || { + let seam = recording_seam(recorded); + run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &seam, + ) + .expect("SessionEnd cleanup should return Ok") + }) + }; + + assert_still_blocked(&p2_done, "Test G"); + assert!( + read_state(&git_dir).recovery.is_clear(), + "Test G: no concurrent process may arm recovery between admit(B) and Start(B)", + ); + + gate.release(); + assert_eq!(p1.join().expect("P1 should not panic"), ""); + sweeper.join().expect("sweeper should not panic"); + p2_handle.join().expect("P2 should not panic"); + + let recorded = recorded.lock().unwrap().clone(); + let start_at = first_index_of(&recorded, "start").expect("P1 drove Start(B)"); + if let Some(abandon_at) = first_index_of(&recorded, "abandon") { + assert!( + start_at < abandon_at, + "Test G: Start(B) must be serialized before any later abandon, got {recorded:?}", + ); + } + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_j_a_live_flush_owner_is_never_reclaimed_by_a_blocked_process() { + let git_dir = unique_test_git_dir("test-j-live-flush-owner"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + state::arm_recovery(&git_dir).expect("arm recovery"); + + let flush_count = Arc::new(AtomicUsize::new(0)); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (owner_gated, gate) = gated_seam("flush", Arc::clone(&recorded)); let owner = { - let git_dir = git_dir.clone(); let resolver = fixed_resolver(git_dir.clone()); - let flush_calls = Arc::clone(&flush_calls); + let flush_count = Arc::clone(&flush_count); thread::spawn(move || { let seam = move |root: &Path, payload: &str, logger: Option<&dyn Logger>| -> Result { if payload.contains(r#""operation":"flush""#) { - flush_calls.fetch_add(1, Ordering::SeqCst); + flush_count.fetch_add(1, Ordering::SeqCst); } - owner_seam(root, payload, logger) + owner_gated(root, payload, logger) }; run_codex_mutation_scope_from_payload_with( &pre_tool_use_json(&[( @@ -2156,49 +2309,234 @@ mod tests { }; gate.wait_until_entered(); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Flushing { generation: 1 }, + ); - let resolver = fixed_resolver(git_dir.clone()); - let contender_flushes = Arc::new(AtomicUsize::new(0)); - let contender_flushes_seam = Arc::clone(&contender_flushes); - let contender_seam = move |_root: &Path, - payload: &str, - _logger: Option<&dyn Logger>| - -> Result { - if payload.contains(r#""operation":"flush""#) { - contender_flushes_seam.fetch_add(1, Ordering::SeqCst); + let flush_count_p2 = Arc::clone(&flush_count); + let (p2_handle, p2_done) = + spawn_pre_tool_use(&git_dir, "exec-2", move |_r, payload, _l| { + if payload.contains(r#""operation":"flush""#) { + flush_count_p2.fetch_add(1, Ordering::SeqCst); + } + Ok(String::new()) + }); + + assert_still_blocked(&p2_done, "Test J"); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Flushing { generation: 1 }, + "Test J: a blocked process must not reclaim the live owner's Flushing(g)", + ); + + gate.release(); + assert_eq!(owner.join().expect("owner should not panic"), ""); + assert_eq!(p2_handle.join().expect("P2 should not panic"), ""); + + assert_eq!( + flush_count.load(Ordering::SeqCst), + 1, + "Test J: exactly one Flush ran — the live owner's, never a reclaim", + ); + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .all(|a| a.phase == state::AttemptPhase::Active)); + + remove_test_git_dir(&git_dir); + } + + fn seed_orphaned_flushing(git_dir: &Path) -> u64 { + let generation = state::arm_recovery(git_dir).expect("arm recovery to seed"); + match state::admit_tracked_attempt(git_dir, &key("seed", None, "seed"), "Bash") + .expect("seeding admit should not error") + { + state::AdmitDecision::FlushClaimed { + generation: claimed, + } => { + assert_eq!(claimed, generation); } - Ok(String::new()) - }; - let contender_output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String("exec-contender".to_string()), - )]), - None, + other => panic!("expected FlushClaimed while seeding, got {other:?}"), + } + assert_eq!( + read_state(git_dir).recovery, + state::RecoveryState::Flushing { generation }, + "seed left durable Flushing(g) with no live boundary-lock owner", + ); + generation + } + + #[test] + fn test_i_orphaned_flushing_is_reclaimed_and_flush_is_retried_once() { + let git_dir = unique_test_git_dir("test-i-orphaned-flushing"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let generation = seed_orphaned_flushing(&git_dir); + let next_generation_before = read_state(&git_dir).next_recovery_generation; + + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let resolver = fixed_resolver(git_dir.clone()); + let output = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-x".to_string()))]), &resolver, - &contender_seam, - ) - .expect("contender PreToolUse should return Ok"); + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + let ops = recorded.lock().unwrap().clone(); assert_eq!( - contender_output, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "Test B: the contender must stay fail-closed while the owner flushes", + ops.iter() + .filter(|p| p.contains(r#""operation":"flush""#)) + .count(), + 1, + "Test I: exactly one retry Flush for the reclaimed generation, got {ops:?}", + ); + assert!( + first_index_of(&ops, "flush").unwrap() < first_index_of(&ops, "start").unwrap() + ); + + let final_state = read_state(&git_dir); + assert!( + final_state.recovery.is_clear(), + "Test I: no permanent RecoveryBlocked" ); assert_eq!( - contender_flushes.load(Ordering::SeqCst), - 0, - "Test B: only the owner may run the flush for generation {generation}", + final_state.next_recovery_generation, next_generation_before, + "Test I: reclaiming Flushing(g) preserves the generation, never bumps it", ); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-x"); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + let _ = generation; - gate.release(); - owner.join().expect("owner thread should not panic"); + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_k_crash_after_durable_flush_before_completion_write_converges() { + let git_dir = unique_test_git_dir("test-k-crash-after-flush"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_orphaned_flushing(&git_dir); + + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let resolver = fixed_resolver(git_dir.clone()); + + let first = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-1".to_string()))]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(first, ""); + assert!(read_state(&git_dir).recovery.is_clear()); + let second = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-2".to_string()))]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(second, ""); + + let ops = recorded.lock().unwrap().clone(); assert_eq!( - flush_calls.load(Ordering::SeqCst), + ops.iter() + .filter(|p| p.contains(r#""operation":"flush""#)) + .count(), 1, - "Test B: exactly one Flush is emitted for the recovery generation", + "Test K: the recovery retry Flush runs exactly once across convergence, got {ops:?}", + ); + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .all(|a| a.phase == state::AttemptPhase::Active)); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_l_duplicate_active_delivery_drives_no_second_start() { + let git_dir = unique_test_git_dir("test-l-duplicate-active"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let first = drive( + &pre_tool_use_json(&[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(first, ""); + let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); + assert_eq!( + read_state(&git_dir).attempts[0].phase, + state::AttemptPhase::Active + ); + + let duplicate = drive( + &pre_tool_use_json(&[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(duplicate, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + ops.iter() + .filter(|p| p.contains(r#""operation":"start""#)) + .count(), + 1, + "Test L: duplicate delivery of an Active execution drives no second Start, got {ops:?}", + ); + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].scope_id, scope_id); + assert_eq!(read_state(&git_dir).next_attempt_seq, 2); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_m_untracked_tools_never_touch_the_boundary_lock() { + let git_dir = unique_test_git_dir("test-m-untracked-no-boundary"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + "collaborationwait_agent", + ] { + let payload = + pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("an untracked PreToolUse is neutral"), + "", + ); + assert_eq!(drive(&payload, &resolver, &unreachable_seam), ""); + } + + assert!( + !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( + &git_dir + ) + .exists(), + "Test M: no untracked tool may create the adapter boundary lock", + ); + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "Test M: an untracked tool resolves no git dir and touches no adapter state", ); - assert!(read_state(&git_dir).recovery.is_clear()); remove_test_git_dir(&git_dir); } diff --git a/cli/src/services/hooks/codex_mutation_scope/os_lock.rs b/cli/src/services/hooks/codex_mutation_scope/os_lock.rs new file mode 100644 index 00000000..dc567c1b --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/os_lock.rs @@ -0,0 +1,95 @@ +use std::fs::{File, OpenOptions, TryLockError}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::Context; + +const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(20); + +#[derive(Debug)] +pub(crate) enum AdvisoryLockError { + TimedOut { + path: PathBuf, + timeout: Duration, + what: &'static str, + }, + Io(anyhow::Error), +} + +impl std::fmt::Display for AdvisoryLockError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AdvisoryLockError::TimedOut { + path, + timeout, + what, + } => write!( + f, + "Timed out after {timeout:?} waiting for the {what} lock '{}'", + path.display() + ), + AdvisoryLockError::Io(source) => write!(f, "{source}"), + } + } +} + +impl std::error::Error for AdvisoryLockError {} + +pub(crate) struct OsAdvisoryLock { + file: File, +} + +impl OsAdvisoryLock { + pub(crate) fn acquire( + parent_dir: &Path, + lock_path: PathBuf, + timeout: Duration, + what: &'static str, + ) -> Result { + std::fs::create_dir_all(parent_dir) + .with_context(|| { + format!( + "Failed to create {what} lock directory '{}'", + parent_dir.display() + ) + }) + .map_err(AdvisoryLockError::Io)?; + + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .with_context(|| format!("Failed to open {what} lock file '{}'", lock_path.display())) + .map_err(AdvisoryLockError::Io)?; + + let deadline = Instant::now() + timeout; + loop { + match file.try_lock() { + Ok(()) => return Ok(OsAdvisoryLock { file }), + Err(TryLockError::WouldBlock) => { + let now = Instant::now(); + if now >= deadline { + return Err(AdvisoryLockError::TimedOut { + path: lock_path, + timeout, + what, + }); + } + std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline - now)); + } + Err(TryLockError::Error(source)) => { + return Err(AdvisoryLockError::Io(anyhow::Error::new(source).context( + format!("Failed to acquire {what} lock '{}'", lock_path.display()), + ))); + } + } + } + } +} + +impl Drop for OsAdvisoryLock { + fn drop(&mut self) { + let _ = self.file.unlock(); + } +} diff --git a/cli/src/services/hooks/codex_mutation_scope/state.rs b/cli/src/services/hooks/codex_mutation_scope/state.rs index 581422f2..0e456830 100644 --- a/cli/src/services/hooks/codex_mutation_scope/state.rs +++ b/cli/src/services/hooks/codex_mutation_scope/state.rs @@ -1,18 +1,19 @@ -use std::fs::{File, OpenOptions, TryLockError}; +use std::fs::OpenOptions; use std::io::Write as _; use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; +use std::time::Duration; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; +use super::os_lock::{AdvisoryLockError, OsAdvisoryLock}; use super::{format_codex_scope_id, AttemptKey}; const SCE_STATE_DIR: &str = "sce"; const ADAPTER_STATE_FILE: &str = "codex-mutation-scope-state.json"; const ADAPTER_STATE_LOCK_FILE: &str = "codex-mutation-scope-state.lock"; +const STATE_LOCK_WHAT: &str = "adapter-state"; -const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(20); const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(10); const ADAPTER_STATE_VERSION: u32 = 2; @@ -109,99 +110,31 @@ pub(crate) enum RecoveryFlushCompletion { Superseded, } -fn state_dir(git_dir: &Path) -> PathBuf { +pub(crate) fn adapter_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) + adapter_state_dir(git_dir).join(ADAPTER_STATE_FILE) } fn lock_path(git_dir: &Path) -> PathBuf { - state_dir(git_dir).join(ADAPTER_STATE_LOCK_FILE) + adapter_state_dir(git_dir).join(ADAPTER_STATE_LOCK_FILE) } struct AdapterStateLock { - file: File, + _inner: OsAdvisoryLock, } -#[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(); + fn acquire(git_dir: &Path, timeout: Duration) -> Result { + let inner = OsAdvisoryLock::acquire( + &adapter_state_dir(git_dir), + lock_path(git_dir), + timeout, + STATE_LOCK_WHAT, + )?; + Ok(AdapterStateLock { _inner: inner }) } } @@ -242,7 +175,7 @@ fn write_state_durably_inner( where F: FnOnce(&Path, &Path) -> Result<()>, { - let dir = state_dir(git_dir); + let dir = adapter_state_dir(git_dir); std::fs::create_dir_all(&dir).with_context(|| { format!( "Failed to create adapter state directory '{}'", @@ -406,6 +339,17 @@ pub(crate) fn remove_attempt(git_dir: &Path, scope_id: &str) -> Result<()> { write_state_durably(git_dir, &state) } +pub(crate) fn normalize_recovery_after_boundary_lock_acquired(git_dir: &Path) -> Result<()> { + let _lock = acquire_lock(git_dir)?; + let mut state = read_state(git_dir)?; + + if let RecoveryState::Flushing { generation } = state.recovery { + state.recovery = RecoveryState::Pending { generation }; + write_state_durably(git_dir, &state)?; + } + Ok(()) +} + pub(crate) fn arm_recovery(git_dir: &Path) -> Result { let _lock = acquire_lock(git_dir)?; let mut state = read_state(git_dir)?; @@ -933,6 +877,50 @@ mod tests { remove_test_git_dir(&git_dir); } + #[test] + fn normalize_after_boundary_lock_reclaims_orphaned_flushing_to_pending_same_generation() { + let git_dir = unique_test_git_dir("normalize-orphaned-flushing"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + arm_recovery(&git_dir).expect("arm g1"); + admit(&git_dir, &key("session-1", None, "exec-new"), "Bash"); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Flushing { generation: 1 }, + ); + let next_generation_before = read_state(&git_dir) + .expect("state readable") + .next_recovery_generation; + + normalize_recovery_after_boundary_lock_acquired(&git_dir) + .expect("normalize should succeed"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.recovery, RecoveryState::Pending { generation: 1 }); + assert_eq!(state.next_recovery_generation, next_generation_before); + } + + #[test] + fn normalize_after_boundary_lock_is_a_no_op_for_clear_or_pending() { + let git_dir = unique_test_git_dir("normalize-noop"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + normalize_recovery_after_boundary_lock_acquired(&git_dir).expect("normalize on clear"); + assert!(read_state(&git_dir) + .expect("state readable") + .recovery + .is_clear()); + + arm_recovery(&git_dir).expect("arm"); + normalize_recovery_after_boundary_lock_acquired(&git_dir).expect("normalize on pending"); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 1 }, + ); + + remove_test_git_dir(&git_dir); + } + #[test] fn relinquish_recovery_flush_returns_a_claimed_generation_to_pending() { let git_dir = unique_test_git_dir("relinquish-to-pending"); @@ -1075,7 +1063,7 @@ mod tests { #[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); + let dir = adapter_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"); @@ -1104,7 +1092,7 @@ mod tests { }), ] { let git_dir = unique_test_git_dir("unsupported-version"); - let dir = state_dir(&git_dir); + let dir = adapter_state_dir(&git_dir); std::fs::create_dir_all(&dir).expect("state dir should be created"); std::fs::write(state_path(&git_dir), payload.to_string()) .expect("state file should be written"); @@ -1122,7 +1110,7 @@ mod tests { #[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); + let dir = adapter_state_dir(&git_dir); std::fs::create_dir_all(&dir).expect("state dir should be created"); let state = AdapterState { @@ -1148,7 +1136,7 @@ mod tests { #[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); + let dir = adapter_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"); diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index a423fae3..f9be0caf 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -387,6 +387,30 @@ across a `hooks::mutation_scope` seam invocation**, so no `checkout::resolve_git_dir` but not `read_checkout_id` / `get_or_create_checkout_id`, and never constructs a `WorktreeId`. +**D6 follow-up (2026-09-08, PR #268).** The adapter now holds **two** distinct +checkout-local OS advisory locks (both built on the shared +`os_lock::OsAdvisoryLock` primitive): + +```text +state lock /sce/codex-mutation-scope-state.lock + protects individual reads / writes / transitions of the JSON state + NEVER held across the hooks::mutation_scope seam + +boundary lock /sce/codex-mutation-scope-boundary.lock + serializes one complete adapter boundary transaction + (state -> mutation-scope ingress -> state) + MAY and SHOULD be held across the seam +``` + +They are not interchangeable and the boundary lock does not replace the state +lock. Lock hierarchy is frozen (D13c): **`boundary lock -> state lock`**; the +boundary lock is never acquired while the state lock is held. Both use +`flock`-equivalent OS ownership (not file existence), so a leftover lock file +alone blocks nothing and process death releases ownership automatically. The +`adapter lock -> WorktreeLock` argument is unchanged — neither adapter lock is +held across the seam-driven `coordinate()` / `abandon_scope()` that take the +`WorktreeLock`. + ### D7 — Write-ahead Start ordering Unless T01 proves a different safe ordering from Codex's hook semantics, the @@ -853,7 +877,73 @@ blocks a successor tracked admission (`UncertainAttemptBlocked`) until a positiv cleanup signal abandons it -> arms recovery -> quiescent `flush`. `PendingStart` means "the adapter cannot prove whether `Start` committed", never "`Start` definitely failed". A duplicate delivery for the same `AttemptKey` still reuses -the same attempt and `ScopeId` (no second scope). +the same attempt and `ScopeId` (no second scope). **D13c follow-up:** a duplicate +delivery whose reused attempt is already `Active` drives **no** second runtime +`Start` boundary (`establish_start` early-returns); a duplicate whose reused +attempt is still `PendingStart` re-drives `Start` (runtime-deduplicated on +`(scope_id, |start)`) so an uncertain `Start` is retried. + +**D13c — checkout-local boundary lock for cross-process serializability +(second T04 follow-up, 2026-09-08, PR #268).** The generation-aware state fix +(D13a) removed the state-transition TOCTOU gaps but left two correctness gaps +because the adapter still ran `durable state transition -> release state lock -> +mutation-scope ingress -> durable state transition` across independent Codex +hook processes: + +1. **Admission could race recovery before `Start`.** Between one process's + `admit(B)` / persist `PendingStart(B)` / release state lock and its `Start(B)` + seam call, a second process could `arm_recovery()` for an unrelated uncertain + lifecycle, so `B` reached runtime `Start` after recovery became required. +2. **An orphaned `Flushing(g)` permanently wedged the checkout.** If the process + that persisted `Pending(g) -> Flushing(g)` died before + `complete_recovery_flush(g)` / `relinquish_recovery_flush(g)`, the durable + state stayed `Flushing(g)` and every future tracked admission returned + `RecoveryBlocked` with no owner-death recovery path. + +Fix: a second checkout-local OS advisory lock, +`/sce/codex-mutation-scope-boundary.lock` (`AdapterBoundaryLock`), +serializes the **complete** adapter boundary transaction — everything from the +first durable read through the ingress seam to the final durable write — against +every other adapter boundary transaction on the same checkout. It wraps: tracked +`PreToolUse` / `Start`, `PostToolUse` / `Close`, `Stop` / `Interrupt` / +`SubagentStop` / `SessionEnd` cleanup, `Abandon`, and the quiescent `Flush`. It +is **not** taken for MCP / unknown / delegation tools (Option B `Untracked` / +`Delegation` stay neutral and resolve no git dir). The state lock is unchanged +and still never held across the seam; the boundary lock is held across the seam. + +Frozen lock hierarchy: + +```text +boundary lock + -> state lock (only when an individual JSON transition is needed) +``` + +Never acquire the boundary lock while holding the state lock. + +Because Codex hooks are independent short-lived OS processes and the boundary +lock is released on process exit, holding it is the liveness proof for crash +recovery. Immediately after acquiring the boundary lock, the tracked +`PreToolUse` path calls +`state::normalize_recovery_after_boundary_lock_acquired`, which conservatively +rewrites any persisted `Flushing(g) -> Pending(g)` (generation preserved — it is +neither assumed the crashed `Flush` succeeded nor that it failed). The existing +quiescent-flush claim then re-runs `Flush(g)` exactly once and converges to +`Clear`. Re-running `Flush` is safe under the **existing** runtime contract: +`RuntimeBoundary::Flush` carries no `event_id`, is a pure snapshot-diff +observation, is the runtime's own crash-recovery re-run path, and does not +advance the revision when it observes no change (verified from +`mutation_scope.rs` ingress `test5` and `coordinator.rs` flush tests — no +runtime, protocol, or Quint change). A **live** `Flush` owner is never reclaimed +because any contender blocks on the boundary lock before it can inspect or +normalize state. Generation semantics (D13a) are unchanged and retained: the +boundary lock solves ownership / liveness, the generation solves stale +completion. + +This is checkout-local (`/sce/...`); linked worktrees with independent +git dirs are independent (D15 preserved). No repo-global or machine-global lock; +no daemon, PID probing, lease expiry, or polling. Timeouts are used only for +OS-lock acquisition failure (fail-closed for tracked tools), never to infer +lifecycle completion. ### D14 — Concurrency and AiContended @@ -2347,6 +2437,77 @@ Persist this field in every plan; this is durable plan state, not chat state: MCP Option B classification unchanged; no daemon, PID supervision, polling, or new DB; the adapter-state lock is never held across the generic ingress seam. Changes are confined to `codex_mutation_scope/{state,mod}.rs` and this plan. + - Second concurrency follow-up (2026-09-08 — boundary-lock serialization + orphaned + `Flushing(g)` recovery, PR #268): the generation-aware state fix removed the + state-transition TOCTOU gaps but left the `state -> ingress -> state` operation + non-serializable across independent hook processes (a second process could + `arm_recovery` between one process's `admit(B)` and its `Start(B)`), and left a + durable `Flushing(g)` orphanable if the claiming process died before + `complete_recovery_flush(g)` / `relinquish_recovery_flush(g)` — wedging every + future tracked admission with `RecoveryBlocked`. The follow-up adds a + **checkout-local OS boundary lock** at + `/sce/codex-mutation-scope-boundary.lock` (`AdapterBoundaryLock`, built + on the shared `os_lock::OsAdvisoryLock` primitive that the adapter-state lock now + also uses — `try_lock`/poll/timeout, `flock`-equivalent OS ownership, leftover + file safe, released on process exit). The boundary lock wraps the **complete** + adapter boundary transaction (`normalize` → `admit_or_recover` → quiescent + `flush` → `establish_start`, and every terminal/cleanup path: + `PostToolUse`/`Close`, `Stop`, `Interrupt`, `SubagentStop`, `SessionEnd`, + `Abandon`). It is **not** taken for `Untracked` / `Delegation` / MCP tools — they + still return neutral without resolving the git dir. The adapter-state lock is + unchanged in responsibility (individual JSON transitions) and still **never** + held across the ingress seam; the boundary lock MAY and SHOULD be held across the + seam. Lock hierarchy is frozen: **`boundary lock -> state lock`**, never the + inverse (D13c). Boundary-lock ownership doubles as crash detection: once a + process holds it, no prior adapter boundary transaction — hence no prior `Flush` + owner — is still executing, so `state::normalize_recovery_after_boundary_lock_acquired` + (called immediately after acquiring the boundary lock, in the tracked + `PreToolUse` path) conservatively rewrites a persisted `Flushing(g) -> Pending(g)` + with the generation preserved; the existing quiescent-flush claim then re-runs + `Flush(g)` exactly once and converges to `Clear`. Re-running `Flush` is safe + under the existing runtime contract — `RuntimeBoundary::Flush` carries no + `event_id`, is a pure snapshot-diff observation, is the runtime's own + crash-recovery re-run path (`coordinator.rs`), and does not advance the revision + when it observes no change (verified from `mutation_scope.rs` ingress `test5` and + `coordinator.rs` flush tests — no runtime change needed). A live `Flush` owner is + never reclaimed because a contender blocks on the boundary lock before it can + inspect state. Duplicate `PreToolUse` delivery for an already-`Active` attempt + now drives **no** second logical `Start` (`establish_start` early-returns on + `reused && phase == Active`); a duplicate `PendingStart` still re-drives `Start` + (runtime-deduplicated on `(scope_id, |start)`). + - New files: `codex_mutation_scope/os_lock.rs` (shared OS advisory-lock + primitive), `codex_mutation_scope/boundary_lock.rs` (`AdapterBoundaryLock` + + cross-process tests). `state.rs` adds + `normalize_recovery_after_boundary_lock_acquired` (precondition: caller owns + the boundary lock) and exposes `adapter_state_dir`; `AdapterStateLock` now + wraps the shared primitive. No state-file shape or version change (still `2`). + - Deterministic regressions added (channel-gated seams + one real child-process + test): **Test G** admission completed, another process cannot `arm_recovery` + before `Start`; **Test H** (supersedes the old Test A) cleanup owning the + boundary lock blocks admission until recovery is processed + (`abandon -> flush -> start` serialized order); **Test I** orphaned + `Flushing(g)` reclaimed and `Flush` retried exactly once, generation + preserved; **Test J** a live `Flush` owner is never reclaimed by a blocked + process (exactly one `Flush`); **Test K** crash after durable `Flush` before + the completion write converges with one retry `Flush`; **Test L** duplicate + `Active` delivery drives no second `Start`; **Test M** `mcp__*` / unknown / + delegation never resolve a git dir, take a state lock, take the boundary lock, + or reach the ingress; **`process_death_releases_the_boundary_lock`** spawns a + real child process that acquires the boundary lock and exits without + unlocking, and the parent then reacquires it. The old Test B (two quiescent + callers, one flush) is subsumed by Tests H and J. + - Re-validated: `services::hooks::codex_mutation_scope` — **passed** (98 tests, + 1 ignored subprocess helper); `services::hooks::mutation_scope` — **passed**; + `services::hooks::` — **passed** (429); full `cargo test --manifest-path + cli/Cargo.toml` — **passed** (1247); `clippy --all-targets -- -D warnings` — + **clean**; `cargo fmt -- --check` — **clean**. AC19 boundary still clean; no + `spec/mutation_cursor.qnt` / `mutation_trace/protocol.rs` / + `mutation_trace/runtime/` / `mutation_trace/store.rs` / + `cli/migrations/agent-trace-repository/` / `agent-trace.schema.json` change; + no daemon, PID supervision, lease expiry, or polling — timeouts are used only + for OS-lock acquisition failure, never to infer lifecycle completion. Changes + confined to `codex_mutation_scope/{mod,state,os_lock,boundary_lock}.rs` and + this plan. - Context impact: domain — a new adapter-domain driver plus a new hidden CLI route. User-visible surface: one hidden `sce hooks codex-mutation-scope` subcommand (hidden from `sce --help` / `sce hooks --help`); no visible-help @@ -2391,11 +2552,12 @@ Persist this field in every plan; this is durable plan state, not chat state: (that is T05's `codex_hook_config.rs` scope). - Concurrency follow-up (2026-09-08, PR #268) re-checked the five roots and remains `no_context_change`: the generation-aware recovery state machine - (D13a/D13b) is adapter-internal inter-process synchronisation with no - protocol, Quint, runtime-semantic, SQL, or Agent Trace schema change (AC22 - still holds) and no user-visible surface change — the adapter is still - inert and unregistered. Durable adapter context (D13a/b, the coverage - table, the concurrency story) is authored by T07 as already recorded. + (D13a/D13b) and the second follow-up's boundary lock (D13c) are + adapter-internal inter-process synchronisation with no protocol, Quint, + runtime-semantic, SQL, or Agent Trace schema change (AC22 still holds) and + no user-visible surface change — the adapter is still inert and + unregistered. Durable adapter context (D13a/b/c, the coverage table, the + concurrency story) is authored by T07 as already recorded. - [ ] T05: `Generated .codex/hooks.json registrations, setup merge, and doctor` (status:todo) - Task ID: T05 From 1aaed0443710742500ff63a45887fed4d39599e8 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 13:21:27 +0200 Subject: [PATCH 09/20] hooks: Skip untracked Codex PostToolUse events Return neutral before resolving git state for MCP, unknown, and delegation tools, preventing boundary-lock and adapter-state side effects. Add lifecycle tests covering zero-footprint behavior. Plan: codex-mutation-scope-integration (T04) Co-authored-by: SCE --- .../hooks/codex_mutation_scope/mod.rs | 119 +++++++++++++++++- .../plans/codex-mutation-scope-integration.md | 7 ++ 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index 13285d1e..7ab21de0 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -352,6 +352,13 @@ fn dispatch_codex_hook_event( seam, )), CodexHookEvent::PostToolUse(identity) => { + if !matches!( + classify_tool(&identity.tool_name), + ToolClassification::TrackedMutation + ) { + return Ok(String::new()); + } + let git_dir = resolve_git_dir(&identity.cwd)?; let repository_root = Path::new(&identity.cwd); with_boundary_lock(&git_dir, || { @@ -1644,10 +1651,7 @@ mod tests { assert_eq!( drive( - &post_tool_use_json(&[( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_success".to_string()), - )]), + &post_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Bash".to_string()),)]), &resolver, &unreachable_seam, ), @@ -2541,6 +2545,113 @@ mod tests { remove_test_git_dir(&git_dir); } + #[test] + fn untracked_post_tool_use_never_touches_mutation_scope_machinery() { + let git_dir = unique_test_git_dir("untracked-post-no-footprint"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + "collaborationwait_agent", + ] { + let payload = + post_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("an untracked PostToolUse is neutral"), + "", + "untracked PostToolUse for {tool:?} must return neutral", + ); + assert_eq!( + drive(&payload, &resolver, &unreachable_seam), + "", + "untracked PostToolUse for {tool:?} must not call the ingress seam", + ); + } + + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "an untracked PostToolUse resolves no git dir and creates no adapter state directory", + ); + assert!( + !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( + &git_dir + ) + .exists(), + "an untracked PostToolUse must not create the adapter boundary lock", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn a_complete_successful_mcp_lifecycle_leaves_zero_adapter_footprint() { + let git_dir = unique_test_git_dir("mcp-lifecycle-no-footprint"); + let resolver = fixed_resolver(git_dir.clone()); + + let mcp = &[( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_success".to_string()), + )]; + + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(mcp), + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("MCP PreToolUse is neutral"), + "", + ); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &post_tool_use_json(mcp), + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("MCP PostToolUse is neutral"), + "", + ); + + assert_eq!( + drive(&pre_tool_use_json(mcp), &resolver, &unreachable_seam), + "" + ); + assert_eq!( + drive(&post_tool_use_json(mcp), &resolver, &unreachable_seam), + "" + ); + + let state = read_state(&git_dir); + assert!(state.attempts.is_empty(), "no attempts recorded"); + assert!(state.recovery.is_clear(), "recovery stays Clear"); + + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "a complete successful MCP lifecycle creates no adapter state directory, \ + state lock, or boundary lock", + ); + assert!( + !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( + &git_dir + ) + .exists(), + "a complete successful MCP lifecycle creates no boundary lock", + ); + + remove_test_git_dir(&git_dir); + } + #[test] fn test_c_recovery_rearmed_while_flush_in_flight_survives_the_stale_completion() { let git_dir = unique_test_git_dir("race-rearm-during-flush"); diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index f9be0caf..6e31a2b2 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -402,6 +402,13 @@ boundary lock /sce/codex-mutation-scope-boundary.lock MAY and SHOULD be held across the seam ``` +The second T04 concurrency follow-up exposed one final Option-B routing +asymmetry: Untracked/Delegation `PreToolUse` already short-circuited before +git-dir resolution, but `PostToolUse` still entered the boundary-lock/no-op Close +path. `PostToolUse` now classifies first and returns neutral for every +non-tracked tool, so complete MCP/unknown/delegation lifecycles leave no adapter +footprint. + They are not interchangeable and the boundary lock does not replace the state lock. Lock hierarchy is frozen (D13c): **`boundary lock -> state lock`**; the boundary lock is never acquired while the state lock is held. Both use From 3173c41d0465e857cf2fe41a1a6b1041cb6f7cac Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 14:17:18 +0200 Subject: [PATCH 10/20] hooks: Register Codex mutation-scope lifecycle hooks Add six unmatched Codex lifecycle registrations for `sce hooks codex-mutation-scope` alongside the four conversation/diff registrations. Make setup merging and doctor diagnostics command-aware so each contract is managed independently while preserving existing handler positions, trust identity, and user-owned hooks. Update generated contracts, installation fixtures, regression tests, and durable context for the four-plus-six registration shape. Plan: context/plans/codex-mutation-scope-integration.md (T05) Co-authored-by: SCE --- .codex/hooks.json | 54 ++ cli/src/services/codex_hook_config.rs | 560 +++++++++++++----- cli/src/services/doctor/inspect.rs | 287 +++++++-- .../codex_mutation_scope/fixtures/NOTES.md | 30 + cli/src/services/setup/mod.rs | 15 +- config/pkl/renderers/codex-content.pkl | 63 +- .../renderers/generation-contract-check.pkl | 12 +- context/architecture.md | 6 +- context/cli/mutation-scope-hook-ingress.md | 19 +- context/cli/mutation-scope-runtime.md | 20 +- context/context-map.md | 6 +- context/overview.md | 4 +- .../plans/codex-mutation-scope-integration.md | 159 ++++- .../sce/agent-trace-hooks-command-routing.md | 2 +- context/sce/codex-integration-runtime.md | 55 +- context/sce/doctor-human-text-contract.md | 11 +- scripts/test-codex-hook-command.sh | 31 +- 17 files changed, 1074 insertions(+), 260 deletions(-) diff --git a/.codex/hooks.json b/.codex/hooks.json index 70543a09..29340c77 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -1,5 +1,15 @@ { "hooks": { + "Interrupt": [ + { + "hooks": [ + { + "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope", + "type": "command" + } + ] + } + ], "PostToolUse": [ { "hooks": [ @@ -9,6 +19,14 @@ } ], "matcher": "apply_patch" + }, + { + "hooks": [ + { + "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope", + "type": "command" + } + ] } ], "PreToolUse": [ @@ -20,6 +38,24 @@ } ], "matcher": "Bash" + }, + { + "hooks": [ + { + "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope", + "type": "command" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope", + "type": "command" + } + ] } ], "Stop": [ @@ -30,6 +66,24 @@ "type": "command" } ] + }, + { + "hooks": [ + { + "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope", + "type": "command" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope", + "type": "command" + } + ] } ], "UserPromptSubmit": [ diff --git a/cli/src/services/codex_hook_config.rs b/cli/src/services/codex_hook_config.rs index 9e5df7bf..d231a81f 100644 --- a/cli/src/services/codex_hook_config.rs +++ b/cli/src/services/codex_hook_config.rs @@ -12,25 +12,58 @@ use serde_json::{Map, Value}; const CODEX_HOOKS_ROOT: &str = "hooks"; const CODEX_HELPER_PATH: &str = ".codex/hooks/run-sce-or-show-install-guidance.sh"; const CODEX_ROOTED_HELPER_PATH: &str = "$root/.codex/hooks/run-sce-or-show-install-guidance.sh"; -const CODEX_COMMAND_WORDS: [&str; 3] = ["sce", "hooks", "codex"]; -const REQUIRED_EVENTS: [(&str, Option<&str>); 4] = [ - ("UserPromptSubmit", None), - ("Stop", None), - ("PreToolUse", Some("Bash")), - ("PostToolUse", Some("apply_patch")), + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CodexHookCommand { + Codex, + MutationScope, +} + +impl CodexHookCommand { + const ALL: [Self; 2] = [Self::Codex, Self::MutationScope]; + + const fn command_words(self) -> &'static [&'static str] { + match self { + Self::Codex => &["sce", "hooks", "codex"], + Self::MutationScope => &["sce", "hooks", "codex-mutation-scope"], + } + } + + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Codex => "sce hooks codex", + Self::MutationScope => "sce hooks codex-mutation-scope", + } + } +} + +const REQUIRED_EVENTS: [(CodexHookCommand, &str, Option<&str>); 10] = [ + (CodexHookCommand::Codex, "UserPromptSubmit", None), + (CodexHookCommand::Codex, "Stop", None), + (CodexHookCommand::Codex, "PreToolUse", Some("Bash")), + (CodexHookCommand::Codex, "PostToolUse", Some("apply_patch")), + (CodexHookCommand::MutationScope, "PreToolUse", None), + (CodexHookCommand::MutationScope, "PostToolUse", None), + (CodexHookCommand::MutationScope, "Stop", None), + (CodexHookCommand::MutationScope, "Interrupt", None), + (CodexHookCommand::MutationScope, "SubagentStop", None), + (CodexHookCommand::MutationScope, "SessionEnd", None), ]; -/// The persisted hook-state key label for one of SCE's four required Codex -/// event names, matching upstream `hooks::hook_event_key_label` -/// (`openai/codex` commit `8e649e3afa5cdddfb09a1b85a090b94775045d9b`, -/// `hooks/src/lib.rs`). Only covers the events SCE registers; any other input -/// is a programming error. +pub(crate) fn required_registrations( +) -> [(CodexHookCommand, &'static str, Option<&'static str>); 10] { + REQUIRED_EVENTS +} + pub(crate) fn hook_event_key_label(event: &str) -> &'static str { match event { "UserPromptSubmit" => "user_prompt_submit", "Stop" => "stop", "PreToolUse" => "pre_tool_use", "PostToolUse" => "post_tool_use", + "Interrupt" => "interrupt", + "SubagentStop" => "subagent_stop", + "SessionEnd" => "session_end", other => unreachable!("unexpected Codex hook event name '{other}'"), } } @@ -68,6 +101,7 @@ pub(crate) fn merge_or_create( #[derive(Clone)] struct Registration { + command: CodexHookCommand, event: &'static str, matcher: Option<&'static str>, group: Value, @@ -97,6 +131,7 @@ pub(crate) enum RegistrationStructuralState { /// own trust hash for it without re-parsing the document. #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct RegistrationDiagnosis { + pub(crate) command: CodexHookCommand, pub(crate) event: &'static str, pub(crate) matcher: Option<&'static str>, pub(crate) state: RegistrationStructuralState, @@ -160,9 +195,6 @@ pub(crate) fn diagnose_document( Ok(HooksDocumentDiagnosis::Registrations(diagnoses)) } -/// One SCE-owned handler found while scanning every matcher group for an -/// event, tagged with where it sits and whether that group is the -/// registration's canonical matcher group. struct OwnedHandlerSighting { group_index: usize, handler_index: usize, @@ -182,6 +214,7 @@ fn diagnose_registration( registration: &Registration, ) -> RegistrationDiagnosis { let missing = || RegistrationDiagnosis { + command: registration.command, event: registration.event, matcher: registration.matcher, state: RegistrationStructuralState::Missing, @@ -205,7 +238,7 @@ fn diagnose_registration( continue; }; for (handler_index, handler) in handlers.iter().enumerate() { - if !handler_is_sce_owned(handler) { + if !handler_owned_by(handler, registration.command) { continue; } sightings.push(OwnedHandlerSighting { @@ -220,10 +253,8 @@ fn diagnose_registration( let Some((only, [])) = sightings.split_first() else { return match sightings.first() { None => missing(), - // More than one SCE-owned handler anywhere for this event: - // always stale, whatever their placement. Surface the first as - // diagnostic context; it is not necessarily "the" canonical one. Some(first) => RegistrationDiagnosis { + command: registration.command, event: registration.event, matcher: registration.matcher, state: RegistrationStructuralState::Stale, @@ -235,6 +266,7 @@ fn diagnose_registration( if only.in_canonical_group && only.handler == registration.handler { RegistrationDiagnosis { + command: registration.command, event: registration.event, matcher: registration.matcher, state: RegistrationStructuralState::PresentAndCurrent, @@ -242,9 +274,8 @@ fn diagnose_registration( position: Some((only.group_index, only.handler_index)), } } else { - // Exactly one owned handler, but either in the wrong matcher group - // or not byte-identical to the canonical generated handler. RegistrationDiagnosis { + command: registration.command, event: registration.event, matcher: registration.matcher, state: RegistrationStructuralState::Stale, @@ -287,6 +318,8 @@ struct CodexHookEvents { subagent_stop: Vec, #[serde(rename = "Stop", default)] stop: Vec, + #[serde(rename = "Interrupt", default)] + interrupt: Vec, } #[derive(Debug, Default, Deserialize)] @@ -315,54 +348,70 @@ fn validate_generated_document_value(generated: &Value) -> Result = None; + for group in groups { + let group_object = group.as_object().with_context(|| { + format!("Generated Codex hook config 'hooks.{event}' group must be a JSON object") })?; - if !command_is_current_sce_contract(command) { - bail!("Generated Codex hook config '{event}' handler does not use the current SCE command contract"); + if group_object.get("matcher").and_then(Value::as_str) != matcher { + continue; + } + let handlers = group_object + .get("hooks") + .and_then(Value::as_array) + .with_context(|| { + format!( + "Generated Codex hook config '{event}' group must contain a 'hooks' array" + ) + })?; + for handler in handlers { + if !handler_owned_by(handler, command) { + continue; + } + validate_handler(handler, "generated Codex hook config", event, 0, 0)?; + if found.is_some() { + bail!( + "Generated Codex hook config '{event}' contains more than one '{}' handler", + command.label() + ); + } + found = Some(handler.clone()); + } } + let handler = found.with_context(|| { + format!( + "Generated Codex hook config is missing the '{}' registration for '{event}'", + command.label() + ) + })?; + let canonical_group = match matcher { + Some(matcher) => { + serde_json::json!({ "matcher": matcher, "hooks": [handler.clone()] }) + } + None => serde_json::json!({ "hooks": [handler.clone()] }), + }; + registrations.push(Registration { + command, event, matcher, - group: Value::Object(group.clone()), - handler: Value::Object(handler.clone()), + group: canonical_group, + handler, }); } @@ -387,6 +436,7 @@ fn validate_document(document: &Value, source_path: &str) -> Result<()> { ("SubagentStart", typed.hooks.subagent_start), ("SubagentStop", typed.hooks.subagent_stop), ("Stop", typed.hooks.stop), + ("Interrupt", typed.hooks.interrupt), ]; for (event, groups) in event_groups { for (group_index, group) in groups.iter().enumerate() { @@ -584,17 +634,6 @@ fn toml_json_kind(value: &Value) -> Option { } } -fn validate_matcher(group: &Map, event: &str, expected: Option<&str>) -> Result<()> { - let actual = group.get("matcher").and_then(Value::as_str); - if actual != expected { - if expected.is_some() { - bail!("Generated Codex hook config '{event}' group must have matcher '{expected:?}'"); - } - bail!("Generated Codex hook config '{event}' group must not have a non-null matcher"); - } - Ok(()) -} - fn merge_document( mut existing: Value, registrations: &[Registration], @@ -620,6 +659,7 @@ fn merge_document( Value::Array(merge_event_groups( existing_groups, registration.matcher, + registration.command, ®istration.handler, ®istration.group, )), @@ -630,51 +670,40 @@ fn merge_document( Ok(existing) } -/// Merge one event's matcher groups so the result matches exactly what -/// `diagnose_registration` calls `PresentAndCurrent`: if the existing -/// document already has exactly one SCE-owned handler, it sits in a matcher -/// group that satisfies `matcher`, and it is byte-identical to -/// `current_handler`, the groups are returned completely untouched — -/// wherever that handler already lives, including a non-first matching -/// group. Relocating an already-canonical handler merely because an earlier -/// matcher group happens to exist would make `merge_or_create` rewrite a -/// document `diagnose_document` calls current, breaking the -/// `PresentAndCurrent` ⇒ no-op invariant those two functions must share. -/// -/// Otherwise every SCE-owned handler across every group is removed and -/// exactly one canonical handler is (re)inserted at a deterministic -/// position: preferring the first matcher-matching group that already held -/// an owned handler (replacing it in place), then the first -/// matcher-matching group at all (appending to it), then a freshly appended -/// `canonical_group` when no matcher-matching group exists. No group is -/// ever deleted, and non-owned handlers/groups are never touched. fn merge_event_groups( groups: Vec, matcher: Option<&str>, + command: CodexHookCommand, current_handler: &Value, canonical_group: &Value, ) -> Vec { let mut owned_sightings: Vec<(usize, usize)> = Vec::new(); let mut canonical_group_sightings: Vec<(usize, usize)> = Vec::new(); - let mut first_matching_group_index: Option = None; + let mut first_appendable_group_index: Option = None; for (group_index, group) in groups.iter().enumerate() { let Some(group_object) = group.as_object() else { continue; }; - let is_canonical_group = group_matches(group_object, matcher); - if is_canonical_group && first_matching_group_index.is_none() { - first_matching_group_index = Some(group_index); + let matcher_matches = group_matches(group_object, matcher); + let handlers = group_object.get("hooks").and_then(Value::as_array); + let holds_other_command = handlers.is_some_and(|handlers| { + handlers.iter().any(|handler| { + handler_owning_command(handler).is_some_and(|owner| owner != command) + }) + }); + if matcher_matches && !holds_other_command && first_appendable_group_index.is_none() { + first_appendable_group_index = Some(group_index); } - let Some(handlers) = group_object.get("hooks").and_then(Value::as_array) else { + let Some(handlers) = handlers else { continue; }; for (handler_index, handler) in handlers.iter().enumerate() { - if !handler_is_sce_owned(handler) { + if !handler_owned_by(handler, command) { continue; } owned_sightings.push((group_index, handler_index)); - if is_canonical_group { + if matcher_matches { canonical_group_sightings.push((group_index, handler_index)); } } @@ -694,15 +723,10 @@ fn merge_event_groups( } } - // Repair. Prefer the (first, by document order) group that already held - // a canonical-matcher owned handler, so collapsing duplicates keeps the - // earliest one in place; otherwise the first group whose matcher - // already matches, even if it never held an owned handler; otherwise - // fall back to appending a fresh canonical group below. let target_group_index = canonical_group_sightings .first() .map(|(group_index, _)| *group_index) - .or(first_matching_group_index); + .or(first_appendable_group_index); let mut merged_groups = groups; let mut insert_at_in_target: Option = None; @@ -715,9 +739,11 @@ fn merge_event_groups( continue; }; if target_group_index == Some(group_index) { - insert_at_in_target = handlers.iter().position(handler_is_sce_owned); + insert_at_in_target = handlers + .iter() + .position(|handler| handler_owned_by(handler, command)); } - handlers.retain(|handler| !handler_is_sce_owned(handler)); + handlers.retain(|handler| !handler_owned_by(handler, command)); } match target_group_index { @@ -725,9 +751,6 @@ fn merge_event_groups( let group_object = merged_groups[group_index] .as_object_mut() .expect("validated group object"); - // A defaulted group (upstream's `#[serde(default)] hooks: Vec<...>`) - // may carry no "hooks" key at all; create an empty array so there - // is somewhere to insert the canonical handler. let handlers = group_object .entry("hooks".to_string()) .or_insert_with(|| Value::Array(Vec::new())) @@ -750,22 +773,32 @@ fn group_matches(group: &Map, matcher: Option<&str>) -> bool { group.get("matcher").and_then(Value::as_str) == matcher } -fn handler_is_sce_owned(handler: &Value) -> bool { - handler +fn handler_owned_by(handler: &Value, command: CodexHookCommand) -> bool { + handler_owning_command(handler) == Some(command) +} + +fn handler_owning_command(handler: &Value) -> Option { + let command = handler .as_object() .and_then(|handler| handler.get("command")) - .and_then(Value::as_str) - .is_some_and(command_is_current_sce_contract) + .and_then(Value::as_str)?; + command_owning_contract(command) } -fn command_is_current_sce_contract(command: &str) -> bool { - command.split(';').any(|segment| { +fn command_owning_contract(command: &str) -> Option { + command.split(';').find_map(|segment| { let tokens: Vec<&str> = segment.split_whitespace().collect(); let offset = usize::from(tokens.first() == Some(&"exec")); - tokens.len() == offset + 5 - && tokens.get(offset) == Some(&"bash") - && helper_path_token_is_valid(tokens[offset + 1]) - && tokens[offset + 2..] == CODEX_COMMAND_WORDS + if tokens.len() != offset + 5 || tokens.get(offset) != Some(&"bash") { + return None; + } + if !helper_path_token_is_valid(tokens[offset + 1]) { + return None; + } + let words = &tokens[offset + 2..]; + CodexHookCommand::ALL + .into_iter() + .find(|contract| contract.command_words() == words) }) } @@ -779,20 +812,62 @@ mod tests { use super::*; use serde_json::json; - fn generated() -> Vec { - let mut generated = serde_json::to_string_pretty(&json!({ + const CANONICAL_COMMAND: &str = "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"; + const MUTATION_SCOPE_COMMAND: &str = "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope"; + + fn codex_handler() -> Value { + json!({"type": "command", "command": CANONICAL_COMMAND}) + } + + fn mutation_scope_handler() -> Value { + json!({"type": "command", "command": MUTATION_SCOPE_COMMAND}) + } + + fn canonical_document() -> Value { + json!({ "hooks": { - "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"}]}], - "Stop": [{"hooks": [{"type": "command", "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"}]}], - "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"}]}], - "PostToolUse": [{"matcher": "apply_patch", "hooks": [{"type": "command", "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"}]}] + "UserPromptSubmit": [{"hooks": [codex_handler()]}], + "Stop": [{"hooks": [codex_handler()]}, {"hooks": [mutation_scope_handler()]}], + "PreToolUse": [ + {"matcher": "Bash", "hooks": [codex_handler()]}, + {"hooks": [mutation_scope_handler()]} + ], + "PostToolUse": [ + {"matcher": "apply_patch", "hooks": [codex_handler()]}, + {"hooks": [mutation_scope_handler()]} + ], + "Interrupt": [{"hooks": [mutation_scope_handler()]}], + "SubagentStop": [{"hooks": [mutation_scope_handler()]}], + "SessionEnd": [{"hooks": [mutation_scope_handler()]}] } - })) - .unwrap(); + }) + } + + fn generated() -> Vec { + let mut generated = serde_json::to_string_pretty(&canonical_document()).unwrap(); generated.push('\n'); generated.into_bytes() } + fn with_mutation_scope_registrations(mut doc: Value) -> Value { + let hooks = doc["hooks"].as_object_mut().expect("hooks object"); + for event in ["PreToolUse", "PostToolUse", "Stop"] { + hooks + .entry(event.to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("event array") + .push(json!({"hooks": [mutation_scope_handler()]})); + } + for event in ["Interrupt", "SubagentStop", "SessionEnd"] { + hooks.insert( + event.to_string(), + json!([{"hooks": [mutation_scope_handler()]}]), + ); + } + doc + } + #[test] fn accepts_upstream_defaulted_groups_and_events() { let existing = json!({ @@ -832,13 +907,19 @@ mod tests { .expect("valid handlers should survive"); let value: Value = serde_json::from_slice(&merged).unwrap(); assert_eq!(value["description"], "user hooks"); - assert_eq!(value["hooks"]["PostToolUse"].as_array().unwrap().len(), 2); + let post_tool_use = value["hooks"]["PostToolUse"].as_array().unwrap(); + assert_eq!(post_tool_use.len(), 3); + assert_eq!(post_tool_use[0]["matcher"], "Write"); + assert_eq!(post_tool_use[0]["hooks"].as_array().unwrap().len(), 4); + assert_eq!(post_tool_use[1]["matcher"], "apply_patch"); + assert_eq!(post_tool_use[1]["hooks"][0]["command"], CANONICAL_COMMAND); + assert!(!post_tool_use[2] + .as_object() + .unwrap() + .contains_key("matcher")); assert_eq!( - value["hooks"]["PostToolUse"][0]["hooks"] - .as_array() - .unwrap() - .len(), - 4 + post_tool_use[2]["hooks"][0]["command"], + MUTATION_SCOPE_COMMAND ); } @@ -1014,20 +1095,33 @@ mod tests { #[test] fn ownership_requires_a_bounded_helper_invocation_shape() { - assert!(command_is_current_sce_contract( - "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex" - )); - assert!(command_is_current_sce_contract( - r#"root="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0; exec bash "$root/.codex/hooks/run-sce-or-show-install-guidance.sh" sce hooks codex"# - )); + assert_eq!( + command_owning_contract( + "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex" + ), + Some(CodexHookCommand::Codex) + ); + assert_eq!( + command_owning_contract( + r#"root="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0; exec bash "$root/.codex/hooks/run-sce-or-show-install-guidance.sh" sce hooks codex"# + ), + Some(CodexHookCommand::Codex) + ); + assert_eq!( + command_owning_contract( + r#"root="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0; exec bash "$root/.codex/hooks/run-sce-or-show-install-guidance.sh" sce hooks codex-mutation-scope"# + ), + Some(CodexHookCommand::MutationScope) + ); for command in [ "echo .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex", "printf '%s' '.codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex'", "foo='.codex/hooks/run-sce-or-show-install-guidance.sh'; echo sce hooks codex", "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex && echo user", + "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex-other", ] { assert!( - !command_is_current_sce_contract(command), + command_owning_contract(command).is_none(), "claimed ownership for {command}" ); } @@ -1051,6 +1145,17 @@ mod tests { .unwrap_or_else(|| panic!("no diagnosis for event '{event}'")) } + fn registration_of<'a>( + diagnoses: &'a [RegistrationDiagnosis], + command: CodexHookCommand, + event: &str, + ) -> &'a RegistrationDiagnosis { + diagnoses + .iter() + .find(|diagnosis| diagnosis.command == command && diagnosis.event == event) + .unwrap_or_else(|| panic!("no {} diagnosis for event '{event}'", command.label())) + } + #[test] fn diagnose_document_reports_absent_for_a_missing_file() { assert_eq!( @@ -1089,12 +1194,15 @@ mod tests { let HooksDocumentDiagnosis::Registrations(diagnoses) = document_diagnosis else { panic!("expected a validated document with per-registration diagnoses"); }; - assert_eq!(diagnoses.len(), 4); + assert_eq!(diagnoses.len(), REQUIRED_EVENTS.len()); for diagnosis in &diagnoses { assert_eq!(diagnosis.state, RegistrationStructuralState::Missing); assert!(diagnosis.owned_handler.is_none()); assert!(diagnosis.position.is_none()); } + assert!(diagnoses.iter().any(|diagnosis| diagnosis.command + == CodexHookCommand::MutationScope + && diagnosis.event == "SessionEnd")); } #[test] @@ -1110,8 +1218,23 @@ mod tests { RegistrationStructuralState::PresentAndCurrent ); assert!(diagnosis.owned_handler.is_some()); - assert_eq!(diagnosis.position, Some((0, 0))); } + assert_eq!( + registration_of(&diagnoses, CodexHookCommand::Codex, "Stop").position, + Some((0, 0)) + ); + assert_eq!( + registration_of(&diagnoses, CodexHookCommand::MutationScope, "Stop").position, + Some((1, 0)) + ); + assert_eq!( + registration_of(&diagnoses, CodexHookCommand::MutationScope, "PreToolUse").position, + Some((1, 0)) + ); + assert_eq!( + registration_of(&diagnoses, CodexHookCommand::MutationScope, "Interrupt").position, + Some((0, 0)) + ); } #[test] @@ -1184,8 +1307,6 @@ mod tests { ); } - const CANONICAL_COMMAND: &str = "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"; - #[test] fn diagnose_document_reports_stale_for_a_canonical_handler_duplicated_in_a_second_matcher_group( ) { @@ -1383,6 +1504,7 @@ mod tests { ]; for (label, existing) in cases { + let existing = with_mutation_scope_registrations(existing); let existing_bytes = serde_json::to_vec(&existing).unwrap(); let document_diagnosis = diagnose_document(Some(&existing_bytes), &generated()).unwrap(); @@ -1447,4 +1569,168 @@ mod tests { RegistrationStructuralState::PresentAndCurrent ); } + + fn canonical_four_document() -> Value { + json!({ + "hooks": { + "UserPromptSubmit": [{"hooks": [codex_handler()]}], + "Stop": [{"hooks": [codex_handler()]}], + "PreToolUse": [{"matcher": "Bash", "hooks": [codex_handler()]}], + "PostToolUse": [{"matcher": "apply_patch", "hooks": [codex_handler()]}] + } + }) + } + + #[test] + fn upgrading_the_canonical_four_document_preserves_existing_trust_identity() { + let mut existing = canonical_four_document(); + existing["hooks"]["Stop"][0]["hooks"] + .as_array_mut() + .unwrap() + .insert(0, json!({"type": "command", "command": "echo user stop"})); + existing["hooks"]["SessionEnd"] = + json!([{"hooks": [{"type": "command", "command": "echo user session end"}]}]); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + + let merged = + merge_or_create(Some(&existing_bytes), &generated(), ".codex/hooks.json").unwrap(); + let value: Value = serde_json::from_slice(&merged).unwrap(); + + let HooksDocumentDiagnosis::Registrations(diagnoses) = + diagnose_document(Some(&merged), &generated()).unwrap() + else { + panic!("expected per-registration diagnoses"); + }; + + for (event, expected_position) in [ + ("UserPromptSubmit", (0usize, 0usize)), + ("Stop", (0, 1)), + ("PreToolUse", (0, 0)), + ("PostToolUse", (0, 0)), + ] { + let registration = registration_of(&diagnoses, CodexHookCommand::Codex, event); + assert_eq!( + registration.state, + RegistrationStructuralState::PresentAndCurrent, + "{event} must stay present and current" + ); + assert_eq!( + registration.position, + Some(expected_position), + "{event} identity tuple (group/handler index) must not move" + ); + assert_eq!( + registration.owned_handler.as_ref().unwrap(), + &codex_handler() + ); + } + + for event in [ + "PreToolUse", + "PostToolUse", + "Stop", + "Interrupt", + "SubagentStop", + "SessionEnd", + ] { + let registration = registration_of(&diagnoses, CodexHookCommand::MutationScope, event); + assert_eq!( + registration.state, + RegistrationStructuralState::PresentAndCurrent, + "mutation-scope {event} must be added" + ); + } + + let mutation_scope_count = value["hooks"] + .as_object() + .unwrap() + .values() + .flat_map(|groups| groups.as_array().unwrap()) + .flat_map(|group| group["hooks"].as_array().unwrap()) + .filter(|handler| handler["command"] == MUTATION_SCOPE_COMMAND) + .count(); + assert_eq!( + mutation_scope_count, 6, + "each mutation-scope handler must appear exactly once" + ); + + assert_eq!( + value["hooks"]["Stop"][0]["hooks"][0]["command"], + "echo user stop" + ); + assert!(value["hooks"]["SessionEnd"] + .as_array() + .unwrap() + .iter() + .flat_map(|group| group["hooks"].as_array().unwrap()) + .any(|handler| handler["command"] == "echo user session end")); + + let merged_again = + merge_or_create(Some(&merged), &generated(), ".codex/hooks.json").unwrap(); + assert_eq!( + merged, merged_again, + "a second merge over the upgraded document must be byte-identical" + ); + } + + #[test] + fn merge_appends_the_mutation_scope_pre_tool_use_group_without_touching_the_bash_group() { + let existing = serde_json::to_vec(&canonical_four_document()).unwrap(); + let merged = merge_or_create(Some(&existing), &generated(), ".codex/hooks.json").unwrap(); + let value: Value = serde_json::from_slice(&merged).unwrap(); + + let pre_tool_use = value["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 2); + assert_eq!(pre_tool_use[0]["matcher"], "Bash"); + assert_eq!(pre_tool_use[0]["hooks"][0]["command"], CANONICAL_COMMAND); + assert!(!pre_tool_use[1].as_object().unwrap().contains_key("matcher")); + assert_eq!( + pre_tool_use[1]["hooks"][0]["command"], + MUTATION_SCOPE_COMMAND + ); + } + + #[test] + fn the_two_unmatched_stop_groups_diagnose_independently_by_command() { + let installed = merge_or_create(None, &generated(), ".codex/hooks.json").unwrap(); + let HooksDocumentDiagnosis::Registrations(diagnoses) = + diagnose_document(Some(&installed), &generated()).unwrap() + else { + panic!("expected per-registration diagnoses"); + }; + let codex_stop = registration_of(&diagnoses, CodexHookCommand::Codex, "Stop"); + let mutation_stop = registration_of(&diagnoses, CodexHookCommand::MutationScope, "Stop"); + assert_eq!( + codex_stop.state, + RegistrationStructuralState::PresentAndCurrent + ); + assert_eq!( + mutation_stop.state, + RegistrationStructuralState::PresentAndCurrent + ); + assert_eq!(codex_stop.position, Some((0, 0))); + assert_eq!(mutation_stop.position, Some((1, 0))); + } + + #[test] + fn interrupt_event_key_label_matches_upstream() { + assert_eq!(hook_event_key_label("Interrupt"), "interrupt"); + } + + #[test] + fn subagent_stop_event_key_label_matches_upstream() { + assert_eq!(hook_event_key_label("SubagentStop"), "subagent_stop"); + } + + #[test] + fn session_end_event_key_label_matches_upstream() { + assert_eq!(hook_event_key_label("SessionEnd"), "session_end"); + } + + #[test] + fn every_required_event_has_an_upstream_key_label() { + for (_, event, _) in REQUIRED_EVENTS { + let _ = hook_event_key_label(event); + } + } } diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index 92bb015e..cff63c15 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -2006,15 +2006,6 @@ fn collect_codex_integration_groups( /// `.codex/hooks.json`'s relative path within Codex's embedded-asset set. const CODEX_HOOKS_JSON_RELATIVE_PATH: &str = ".codex/hooks.json"; -/// Build one `IntegrationChildHealth` per required Codex hook registration, -/// combining `codex_hook_config`'s structural diagnosis with Codex's -/// effective hook-discovery policy readiness (`codex_hook_policy`) and its -/// own hook-trust readiness (`codex_hook_trust`) for registrations that are -/// structurally present. A registration only needs a policy/trust check once -/// it is structurally current; a missing or stale registration has no -/// on-disk canonical handler for Codex to ever load, so policy/trust do not -/// apply. `policy_readiness` is probed once per doctor invocation by the -/// caller and reused here for all four registrations. fn codex_hooks_json_registration_children( hooks_json_path: &Path, generated_bytes: &[u8], @@ -2025,14 +2016,10 @@ fn codex_hooks_json_registration_children( Ok(bytes) => Some(bytes), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) => { - return codex_hook_registration_paths() - .into_iter() - .map(|(suffix, _event, _matcher)| IntegrationChildHealth { - relative_path: format!("{CODEX_HOOKS_JSON_RELATIVE_PATH}#{suffix}"), - path: hooks_json_path.to_path_buf(), - content_state: IntegrationContentState::ReadFailed(error.to_string()), - }) - .collect(); + return codex_required_registration_children( + hooks_json_path, + &IntegrationContentState::ReadFailed(error.to_string()), + ); } }; @@ -2043,23 +2030,14 @@ fn codex_hooks_json_registration_children( }; match document_diagnosis { - codex_hook_config::HooksDocumentDiagnosis::Absent => codex_hook_registration_paths() - .into_iter() - .map(|(suffix, _event, _matcher)| IntegrationChildHealth { - relative_path: format!("{CODEX_HOOKS_JSON_RELATIVE_PATH}#{suffix}"), - path: hooks_json_path.to_path_buf(), - content_state: IntegrationContentState::Missing, - }) - .collect(), + codex_hook_config::HooksDocumentDiagnosis::Absent => { + codex_required_registration_children(hooks_json_path, &IntegrationContentState::Missing) + } codex_hook_config::HooksDocumentDiagnosis::Malformed(error) => { - codex_hook_registration_paths() - .into_iter() - .map(|(suffix, _event, _matcher)| IntegrationChildHealth { - relative_path: format!("{CODEX_HOOKS_JSON_RELATIVE_PATH}#{suffix}"), - path: hooks_json_path.to_path_buf(), - content_state: IntegrationContentState::Malformed(error.clone()), - }) - .collect() + codex_required_registration_children( + hooks_json_path, + &IntegrationContentState::Malformed(error), + ) } codex_hook_config::HooksDocumentDiagnosis::Registrations(diagnoses) => diagnoses .iter() @@ -2075,18 +2053,35 @@ fn codex_hooks_json_registration_children( } } -/// The four required registrations' display suffixes, in canonical order. -fn codex_hook_registration_paths() -> [(&'static str, &'static str, Option<&'static str>); 4] { - [ - ("UserPromptSubmit", "UserPromptSubmit", None), - ("Stop", "Stop", None), - ("PreToolUse(Bash)", "PreToolUse", Some("Bash")), - ( - "PostToolUse(apply_patch)", - "PostToolUse", - Some("apply_patch"), - ), - ] +fn codex_registration_suffix( + command: codex_hook_config::CodexHookCommand, + event: &str, + matcher: Option<&str>, +) -> String { + match command { + codex_hook_config::CodexHookCommand::Codex => match matcher { + Some(matcher) => format!("{event}({matcher})"), + None => event.to_string(), + }, + codex_hook_config::CodexHookCommand::MutationScope => format!("{event}(mutation-scope)"), + } +} + +fn codex_required_registration_children( + hooks_json_path: &Path, + content_state: &IntegrationContentState, +) -> Vec { + codex_hook_config::required_registrations() + .into_iter() + .map(|(command, event, matcher)| { + let suffix = codex_registration_suffix(command, event, matcher); + IntegrationChildHealth { + relative_path: format!("{CODEX_HOOKS_JSON_RELATIVE_PATH}#{suffix}"), + path: hooks_json_path.to_path_buf(), + content_state: content_state.clone(), + } + }) + .collect() } /// Human-readable explanation for `IntegrationContentState::PolicyBlocked`, @@ -2102,10 +2097,7 @@ fn codex_hook_registration_child( trust_context: &codex_hook_trust::TrustContext, policy_readiness: &CodexHookPolicyReadiness, ) -> IntegrationChildHealth { - let suffix = codex_hook_registration_paths() - .into_iter() - .find(|(_, event, matcher)| *event == diagnosis.event && *matcher == diagnosis.matcher) - .map_or(diagnosis.event, |(suffix, _, _)| suffix); + let suffix = codex_registration_suffix(diagnosis.command, diagnosis.event, diagnosis.matcher); let relative_path = format!("{CODEX_HOOKS_JSON_RELATIVE_PATH}#{suffix}"); // Decision order (AC28): structural state wins first (a missing or stale @@ -2620,7 +2612,10 @@ mod tests { .flat_map(|group| &group.children) .filter(|child| child.relative_path.starts_with(".codex/hooks.json#")) .collect::>(); - assert_eq!(registration_children.len(), 4); + assert_eq!( + registration_children.len(), + codex_hook_config::required_registrations().len() + ); for child in ®istration_children { assert_eq!( child.content_state, @@ -3296,6 +3291,7 @@ mod tests { handler: serde_json::Value, ) -> codex_hook_config::RegistrationDiagnosis { codex_hook_config::RegistrationDiagnosis { + command: codex_hook_config::CodexHookCommand::Codex, event, matcher, state: codex_hook_config::RegistrationStructuralState::PresentAndCurrent, @@ -3304,6 +3300,22 @@ mod tests { } } + fn mutation_scope_diagnosis( + event: &'static str, + state: codex_hook_config::RegistrationStructuralState, + handler: Option, + position: Option<(usize, usize)>, + ) -> codex_hook_config::RegistrationDiagnosis { + codex_hook_config::RegistrationDiagnosis { + command: codex_hook_config::CodexHookCommand::MutationScope, + event, + matcher: None, + state, + owned_handler: handler, + position, + } + } + #[test] fn registration_child_is_match_when_trusted_and_policy_allows_project_hooks() { let root = unique_temp_repository_root("policy-trusted-match"); @@ -3503,6 +3515,167 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + #[test] + fn a_mutation_scope_registration_gets_the_full_three_dimension_health_model() { + let root = unique_temp_repository_root("codex-mutation-scope-3d"); + let hooks_json_path = root.join("hooks.json"); + std::fs::write(&hooks_json_path, "{}").unwrap(); + let handler = bare_command_handler_json(); + + let missing = mutation_scope_diagnosis( + "SessionEnd", + codex_hook_config::RegistrationStructuralState::Missing, + None, + None, + ); + assert_eq!( + codex_hook_registration_child( + &hooks_json_path, + &missing, + &deterministic_untrusted_context("ms-missing"), + &allowed_policy(), + ) + .content_state, + IntegrationContentState::Missing + ); + + let stale = mutation_scope_diagnosis( + "SessionEnd", + codex_hook_config::RegistrationStructuralState::Stale, + Some(handler.clone()), + Some((0, 1)), + ); + assert_eq!( + codex_hook_registration_child( + &hooks_json_path, + &stale, + &deterministic_untrusted_context("ms-stale"), + &allowed_policy(), + ) + .content_state, + IntegrationContentState::Stale + ); + + let present = mutation_scope_diagnosis( + "SessionEnd", + codex_hook_config::RegistrationStructuralState::PresentAndCurrent, + Some(handler.clone()), + Some((0, 0)), + ); + + let untrusted = codex_hook_registration_child( + &hooks_json_path, + &present, + &deterministic_untrusted_context("ms-untrusted"), + &allowed_policy(), + ); + assert_eq!( + untrusted.content_state, + IntegrationContentState::NotTrusted("untrusted".to_string()) + ); + assert_eq!( + untrusted.relative_path, + ".codex/hooks.json#SessionEnd(mutation-scope)" + ); + + let policy_blocked = codex_hook_registration_child( + &hooks_json_path, + &present, + &deterministic_untrusted_context("ms-blocked"), + &CodexHookPolicyReadiness::PolicyBlocked, + ); + assert!(matches!( + policy_blocked.content_state, + IntegrationContentState::PolicyBlocked(_) + )); + + let policy_unknown = codex_hook_registration_child( + &hooks_json_path, + &present, + &deterministic_untrusted_context("ms-unknown"), + &CodexHookPolicyReadiness::Unknown("codex executable not found".to_string()), + ); + assert!(matches!( + policy_unknown.content_state, + IntegrationContentState::PolicyUnknown(_) + )); + + let hash = codex_hook_trust::hash_command_handler("SessionEnd", None, &handler).unwrap(); + let trust_context = write_trust_state( + &root, + "ms-trusted", + &hooks_json_path, + "SessionEnd", + (0, 0), + &format!("trusted_hash = \"{hash}\""), + ); + assert_eq!( + codex_hook_registration_child( + &hooks_json_path, + &present, + &trust_context, + &allowed_policy(), + ) + .content_state, + IntegrationContentState::Match + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn mutation_scope_and_sce_hooks_codex_registrations_report_distinct_readiness() { + let root = unique_temp_repository_root("codex-mutation-scope-distinct"); + let codex_dir = root.join(".codex"); + std::fs::create_dir_all(&codex_dir).unwrap(); + let hooks_json_path = codex_dir.join("hooks.json"); + std::fs::write( + &hooks_json_path, + embedded_codex_asset_bytes(".codex/hooks.json"), + ) + .unwrap(); + + let generated = embedded_codex_asset_bytes(".codex/hooks.json"); + let codex_stop_handler: serde_json::Value = { + let document: serde_json::Value = serde_json::from_slice(generated).unwrap(); + document["hooks"]["Stop"][0]["hooks"][0].clone() + }; + let hash = + codex_hook_trust::hash_command_handler("Stop", None, &codex_stop_handler).unwrap(); + let trust_context = write_trust_state( + &root, + "codex-stop-trusted", + &hooks_json_path, + "Stop", + (0, 0), + &format!("trusted_hash = \"{hash}\""), + ); + + let groups = + collect_codex_integration_groups(&root, &[], &trust_context, &allowed_policy()); + let child = |suffix: &str| { + groups + .iter() + .flat_map(|group| &group.children) + .find(|child| child.relative_path == format!(".codex/hooks.json#{suffix}")) + .unwrap_or_else(|| panic!("expected a .codex/hooks.json#{suffix} child")) + .content_state + .clone() + }; + + assert_eq!(child("Stop"), IntegrationContentState::Match); + assert_eq!( + child("Stop(mutation-scope)"), + IntegrationContentState::NotTrusted("untrusted".to_string()) + ); + assert_eq!( + child("Interrupt(mutation-scope)"), + IntegrationContentState::NotTrusted("untrusted".to_string()) + ); + + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn structural_missing_state_wins_over_a_blocked_or_unknown_policy() { let root = absent_repository_root(); @@ -3521,7 +3694,10 @@ mod tests { .flat_map(|group| &group.children) .filter(|child| child.relative_path.starts_with(".codex/hooks.json#")) .collect::>(); - assert_eq!(registration_children.len(), 4); + assert_eq!( + registration_children.len(), + codex_hook_config::required_registrations().len() + ); for child in registration_children { assert_eq!( child.content_state, @@ -3587,15 +3763,18 @@ mod tests { .flat_map(|group| &group.children) .filter(|child| child.relative_path.starts_with(".codex/hooks.json#")) .collect::>(); - assert_eq!(registration_children.len(), 4); + assert_eq!( + registration_children.len(), + codex_hook_config::required_registrations().len() + ); for child in registration_children { assert!( matches!( child.content_state, IntegrationContentState::PolicyBlocked(_) ), - "the single probed policy value must apply identically to every one of the \ - four registrations, not be re-probed per registration: '{}' was {:?}", + "the single probed policy value must apply identically to every \ + registration, not be re-probed per registration: '{}' was {:?}", child.relative_path, child.content_state ); diff --git a/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md b/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md index 544bdd94..d7ec26aa 100644 --- a/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md @@ -493,3 +493,33 @@ No `mutation_cursor.qnt` / mutation protocol / runtime-semantic / SQL-migration Agent Trace schema change is required. The plan's Design section (D1, D2, D8, D9, D10, D10a, D12, D13, D14, new D23) and Open questions carry the full disposition. The probe fixtures in this directory are unchanged evidence. + +## T05 — generated `.codex/hooks.json` registrations and event key labels + +The Codex mutation-scope adapter is registered through a **second** command +contract, `sce hooks codex-mutation-scope`, alongside the existing four +`sce hooks codex` registrations (plan D17). `config/pkl/renderers/codex-content.pkl` +appends, after the four unchanged `sce hooks codex` groups, one unmatched +(catch-all, no `matcher`) group per event the T04 driver dispatches on: +`PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, `SubagentStop`, `SessionEnd`. +Unmatched groups are what every T01 probe used +(`fixtures/mcp_probe/hooks.json.sample`) and they deliver `Bash`, `apply_patch`, +and `mcp__*` tool events alike. + +`codex_hook_config::hook_event_key_label` is the SCE-owned copy of upstream +`hooks::hook_event_key_label`. The persisted `$CODEX_HOME/config.toml` +`[hooks.state]` key for every registration uses these labels, so they must match +upstream byte-for-byte and are **not** a naive lowercase (plan D22 / AC17a). +Source of record: `openai/codex` tag `rust-v0.153.4`, commit +`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`, `codex-rs/hooks/src/lib.rs` +lines 96–108 — the same map transcribed in the "Tested Codex version" table near +the top of this file. The three labels T05 newly registers: + +| `.codex/hooks.json` key | `[hooks.state]` key label | +| --- | --- | +| `Interrupt` | `interrupt` | +| `SubagentStop` | `subagent_stop` | +| `SessionEnd` | `session_end` | + +(`PreToolUse` → `pre_tool_use`, `PostToolUse` → `post_tool_use`, `Stop` → `stop` +were already SCE-owned for the `sce hooks codex` contract.) diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index c08effaf..583a721e 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -2320,7 +2320,20 @@ mod tests { merged["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], "echo user" ); - assert_eq!(merged["hooks"].as_object().unwrap().len(), 5); + assert_eq!(merged["hooks"].as_object().unwrap().len(), 8); + for event in ["Interrupt", "SubagentStop", "SessionEnd"] { + assert!( + merged["hooks"][event][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .ends_with("sce hooks codex-mutation-scope"), + "{event} must route to the mutation-scope command" + ); + } + assert!(merged["hooks"]["PreToolUse"][1]["hooks"][0]["command"] + .as_str() + .unwrap() + .ends_with("sce hooks codex-mutation-scope")); let _ = fs::remove_dir_all(&repo); } diff --git a/config/pkl/renderers/codex-content.pkl b/config/pkl/renderers/codex-content.pkl index 48661b3d..b3341175 100644 --- a/config/pkl/renderers/codex-content.pkl +++ b/config/pkl/renderers/codex-content.pkl @@ -55,15 +55,10 @@ local missingSceInstallMessage = "sce CLI not found. Install it from https://sce local codexSceHookScriptPath = ".codex/hooks/run-sce-or-show-install-guidance.sh" -/// Codex invokes hooks with the event cwd, which may be nested below the -/// repository root. Resolve that root at invocation time and fail open when -/// Git cannot resolve it; the quoted expansion keeps spaces in the root safe. local codexSceHookCommand = "root=\\\"$(git rev-parse --show-toplevel 2>/dev/null)\\\" || exit 0; exec bash \\\"$root/\(codexSceHookScriptPath)\\\" sce hooks codex" -/// Every Codex lifecycle event Codex routes to the SCE hook is dispatched -/// through a single command (`sce hooks codex`); the typed dispatcher inside -/// that command (T06) distinguishes event/tool combinations from the JSON -/// payload it receives on stdin, so no per-event command varies here. +local codexMutationScopeHookCommand = "root=\\\"$(git rev-parse --show-toplevel 2>/dev/null)\\\" || exit 0; exec bash \\\"$root/\(codexSceHookScriptPath)\\\" sce hooks codex-mutation-scope" + hooksJson = new common.RenderedTextFile { slug = "hooks" rendered = """ @@ -87,6 +82,14 @@ hooksJson = new common.RenderedTextFile { "command": "\(codexSceHookCommand)" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopeHookCommand)" + } + ] } ], "PreToolUse": [ @@ -98,6 +101,14 @@ hooksJson = new common.RenderedTextFile { "command": "\(codexSceHookCommand)" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopeHookCommand)" + } + ] } ], "PostToolUse": [ @@ -109,6 +120,44 @@ hooksJson = new common.RenderedTextFile { "command": "\(codexSceHookCommand)" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopeHookCommand)" + } + ] + } + ], + "Interrupt": [ + { + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopeHookCommand)" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopeHookCommand)" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopeHookCommand)" + } + ] } ] } diff --git a/config/pkl/renderers/generation-contract-check.pkl b/config/pkl/renderers/generation-contract-check.pkl index 0764c663..c8c0c7b7 100644 --- a/config/pkl/renderers/generation-contract-check.pkl +++ b/config/pkl/renderers/generation-contract-check.pkl @@ -64,19 +64,23 @@ local assertCodexHookInvocationContract = (artifacts: Mapping) -> && text.split("\"Stop\"").length == 2 && text.split("\"PreToolUse\"").length == 2 && text.split("\"PostToolUse\"").length == 2 - && text.split("\"type\": \"command\"").length == 5 + && text.split("\"Interrupt\"").length == 2 + && text.split("\"SubagentStop\"").length == 2 + && text.split("\"SessionEnd\"").length == 2 + && text.split("\"type\": \"command\"").length == 11 && text.split("\"matcher\": \"Bash\"").length == 2 && text.split("\"matcher\": \"apply_patch\"").length == 2 + && text.split("sce hooks codex\"").length == 5 + && text.split("sce hooks codex-mutation-scope\"").length == 7 && !text.contains("\"$schema\"") && text.contains("git rev-parse --show-toplevel") && text.contains("2>/dev/null") && text.contains("|| exit 0") && text.contains("exec bash") && text.contains("$root/.codex/hooks/run-sce-or-show-install-guidance.sh") - && text.contains("sce hooks codex") && !text.contains("eval") - ) "Codex hook invocation: four registrations use safe repository-root resolution" - else throw("Codex hook invocation must resolve the Git root safely and preserve the exact four registrations") + ) "Codex hook invocation: four sce-hooks-codex plus six mutation-scope registrations use safe repository-root resolution" + else throw("Codex hook invocation must resolve the Git root safely and preserve the four sce-hooks-codex plus six mutation-scope registrations") /// Current upstream Codex skill loading provides no Claude/OpenCode-style /// `$ARGUMENTS` substitution, so a literal, unsubstituted token in generated diff --git a/context/architecture.md b/context/architecture.md index 80ae19d3..5a49d3aa 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -49,7 +49,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. - Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs, with `SessionStart` and `PostModelSwitch` lifecycle registrations routed to the local model-state hook. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. Codex also differs from all three other targets in the two arguments it passes `skillDocuments` beyond frontmatter: the arguments-reference token (OpenCode, Claude, and Pi pass the literal `$ARGUMENTS` their harnesses substitute, while Codex passes the plain-prose token `invocation input`, since its skill loading provides no such substitution) and a per-workflow-slug invocation-example function (empty for OpenCode, Claude, and Pi; for Codex, one authored, runnable `$sce-{slug} ...` example per catalog workflow). So Codex's `## Input` prose and its `sce-handover`/`sce-brownfield` `references/output.md` diverge from Pi's by the arguments-reference token, and every Codex skill's `## Input` section additionally carries a trailing "For example: `$sce-{slug} ...`." paragraph that Pi/Claude/OpenCode do not render. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). -- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi; its `skillDocuments` output matches Pi's byte-for-byte for every shared document except where the arguments-reference token appears (`invocation input` in place of Pi's substituted `$ARGUMENTS`, in every skill's `## Input` prose and in `sce-handover`/`sce-brownfield`'s `references/output.md`) and where every skill's `## Input` section carries Codex's own trailing concrete `$sce-{slug}` invocation-example paragraph, which Pi's `skillDocuments` call passes as empty and so never renders. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. Codex alone additionally carries `{skillSlug}/agents/openai.yaml` for each of the six catalog workflow skills (not `sce-decision`), rendered by `codex-metadata.pkl` from the same catalog `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 `/skills` discovery, never from conversational relevance alone; no other target has an implicit-invocation policy concept. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only, every registration routed through the single command `sce hooks codex`) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the generated command resolves the Git root at invocation time and invokes that helper with quoted paths, so it works from nested event directories and spaced repository paths while exiting successfully when Git-root resolution fails. No Codex analog to `$CLAUDE_PROJECT_DIR` is required. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row atomically through the shared `insert_conversation_text_event` transactional primitive (a replayed or concurrent duplicate delivery leaves exactly one row pair); `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves source and move-destination 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 via the existing `insert_diff_trace` when non-empty — invalid cwd/path resolution, invalid/missing sessions, Delete-File operations, and a `Move to` with no changed lines produce no evidence; reported model IDs remain unqualified unless Codex supplied a qualifier (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open silently. Bash-triggered filesystem mutations remain untracked for Codex. +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi; its `skillDocuments` output matches Pi's byte-for-byte for every shared document except where the arguments-reference token appears (`invocation input` in place of Pi's substituted `$ARGUMENTS`, in every skill's `## Input` prose and in `sce-handover`/`sce-brownfield`'s `references/output.md`) and where every skill's `## Input` section carries Codex's own trailing concrete `$sce-{slug}` invocation-example paragraph, which Pi's `skillDocuments` call passes as empty and so never renders. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. Codex alone additionally carries `{skillSlug}/agents/openai.yaml` for each of the six catalog workflow skills (not `sce-decision`), rendered by `codex-metadata.pkl` from the same catalog `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 `/skills` discovery, never from conversational relevance alone; no other target has an implicit-invocation policy concept. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash`, and `PostToolUse` for `apply_patch` routed through `sce hooks codex`, plus six additive unmatched mutation-scope registrations — `PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, `SubagentStop`, `SessionEnd` — appended after the existing groups and routed through the separate `sce hooks codex-mutation-scope` command) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the generated command resolves the Git root at invocation time and invokes that helper with quoted paths, so it works from nested event directories and spaced repository paths while exiting successfully when Git-root resolution fails. No Codex analog to `$CLAUDE_PROJECT_DIR` is required. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row atomically through the shared `insert_conversation_text_event` transactional primitive (a replayed or concurrent duplicate delivery leaves exactly one row pair); `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves source and move-destination 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 via the existing `insert_diff_trace` when non-empty — invalid cwd/path resolution, invalid/missing sessions, Delete-File operations, and a `Move to` with no changed lines produce no evidence; reported model IDs remain unqualified unless Codex supplied a qualifier (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open silently. Bash-triggered filesystem mutations remain untracked for Codex. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` and `skillDocuments` additionally take an `argumentsReference` string naming the invocation input in skill-mode prose — `$ARGUMENTS` for OpenCode, Claude, and Pi, whose harnesses substitute it, or a plain-prose token for a target whose skill loading does not (Codex passes `invocation input`) — and an `invocationExample` function from skill slug to a concrete `$sce-{slug}` example string, appended by `model.invocationExampleParagraph` immediately before each workflow's `## Workflow` heading when non-empty; OpenCode, Claude, and Pi pass `(_) -> ""` so their `## Input` sections are unaffected, while Codex supplies one authored example per catalog workflow. `renderCommand`'s thin wrapper text is unaffected by either parameter and always states the literal `$ARGUMENTS` its harness substitutes. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). @@ -127,10 +127,10 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies retry-backed blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. - `cli/src/services/agent_trace_db/mod.rs` owns the shared Agent Trace insert payloads, SQL constants, and typed row helpers (diff-trace/intersection/Agent Trace/message/part) plus `ensure_schema_ready_for_hooks()` consumed by the repository adapter. `cli/src/services/agent_trace_db/repository.rs` defines the sole `RepositoryAgentTraceDb` adapter over `TursoDb` with one fresh `agent-trace-repository/001_repository_schema.sql` baseline for `diff_traces` (including `payload_type`), `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes, and triggers, `repository_metadata` validation, no trace-table `checkout_id` columns, `agent_traces.agent_trace_id NOT NULL UNIQUE`, and `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` using the inclusive chronological parser without checkout filtering; structured-row reconstruction applies the persisted row `model_id` to every hunk and the persisted canonical `session_id` to every touched line before downstream combination and intersection. Active hook runtime, setup/lifecycle storage, and `sce sync` resolve through `agent_trace_storage` and use `RepositoryAgentTraceDb`. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, the 15-file `cli/migrations/agent-trace/` chain, and the former `sce trace --legacy` surface were removed by the `retire-legacy-agent-trace-db` plan. -- `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination and swaps it into place by renaming the staging file directly over the destination — never unlinking the destination first, since `fs::rename` already replaces an existing file atomically — with deterministic recovery guidance naming the failing asset's path on swap failure (the pre-existing destination content, if any, is untouched) and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run; Codex has no single target directory this way, since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix and `InstallTargetPaths::codex_target_dir()` resolves to the repository root itself, but the same per-asset stage/atomic-swap and never-delete-what-it-did-not-author guarantees still apply file by file. For the three assets that are merge targets — the Claude target's `settings.json`, the OpenCode target's `opencode.json`, and Codex's user-owned `.codex/hooks.json` — the content staged is not always the embedded asset's bytes. Claude and OpenCode use `cli/src/services/setup/config_merge.rs`, while Codex uses the shared `cli/src/services/codex_hook_config.rs` service for structural validation and canonical-registration merging. For the Claude and OpenCode targets, the content staged is the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. Codex's merge validates the document shape, recognizes ownership only when both the generated helper path and the `sce hooks codex` command contract are present, and replaces stale or duplicate SCE handlers with exactly one current handler for each `UserPromptSubmit`, `Stop`, `PreToolUse/Bash`, and `PostToolUse/apply_patch` registration while preserving unrelated valid Codex fields, groups, and handlers. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`, backed by the rename-injectable `install_required_git_hooks_with_rename`) is a third content-computation seam alongside the two JSON merge targets: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook(existing_bytes, canonical_bytes, hook_name)` rather than writing `hook_asset.bytes` verbatim — a foreign hook (no SCE managed block, no legacy guidance-URL marker) is kept as an exact byte prefix with the canonical block appended after it, an SCE-owned hook has only its block spliced in place or left unchanged, and a legacy pre-marker hook is replaced wholesale — then follows the same per-file stage/atomic-swap choreography as config-asset install: the staging file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact, with deterministic recovery guidance on swap failure. `Installed`/`Updated`/`Skipped` are decided against the merged bytes plus the executable bit rather than the canonical asset's raw bytes, so an already-current foreign-plus-block hook reports `Skipped`; `RequiredHookInstallResult.unreachable_block_advisory` is set, and rendered as a named advisory line in setup's hook output, when an appended block follows a foreign hook's zero-indent `exec`/`exit` and so would never run. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. +- `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination and swaps it into place by renaming the staging file directly over the destination — never unlinking the destination first, since `fs::rename` already replaces an existing file atomically — with deterministic recovery guidance naming the failing asset's path on swap failure (the pre-existing destination content, if any, is untouched) and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run; Codex has no single target directory this way, since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix and `InstallTargetPaths::codex_target_dir()` resolves to the repository root itself, but the same per-asset stage/atomic-swap and never-delete-what-it-did-not-author guarantees still apply file by file. For the three assets that are merge targets — the Claude target's `settings.json`, the OpenCode target's `opencode.json`, and Codex's user-owned `.codex/hooks.json` — the content staged is not always the embedded asset's bytes. Claude and OpenCode use `cli/src/services/setup/config_merge.rs`, while Codex uses the shared `cli/src/services/codex_hook_config.rs` service for structural validation and canonical-registration merging. For the Claude and OpenCode targets, the content staged is the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. Codex's merge validates the document shape and recognizes ownership by the generated helper path plus one of two trailing command contracts — `sce hooks codex` or `sce hooks codex-mutation-scope` — attributing each handler to whichever it matches; it replaces stale or duplicate handlers with exactly one current handler per registration (the four `sce hooks codex` registrations plus six additive `sce hooks codex-mutation-scope` registrations, each appended in its own unmatched group after the existing groups so an already-trusted handler keeps its `(event, matcher, group index, handler index)` identity and computed Codex trust key), touching only the matching command's handlers and preserving unrelated valid Codex fields, groups, and handlers. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`, backed by the rename-injectable `install_required_git_hooks_with_rename`) is a third content-computation seam alongside the two JSON merge targets: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook(existing_bytes, canonical_bytes, hook_name)` rather than writing `hook_asset.bytes` verbatim — a foreign hook (no SCE managed block, no legacy guidance-URL marker) is kept as an exact byte prefix with the canonical block appended after it, an SCE-owned hook has only its block spliced in place or left unchanged, and a legacy pre-marker hook is replaced wholesale — then follows the same per-file stage/atomic-swap choreography as config-asset install: the staging file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact, with deterministic recovery guidance on swap failure. `Installed`/`Updated`/`Skipped` are decided against the merged bytes plus the executable bit rather than the canonical asset's raw bytes, so an already-current foreign-plus-block hook reports `Skipped`; `RequiredHookInstallResult.unreachable_block_advisory` is set, and rendered as a named advisory line in setup's hook output, when an appended block follows a foreign hook's zero-indent `exec`/`exit` and so would never run. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. - `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization, staging/swap install behavior, required-hook installation, and filesystem safety guards, while the inline `prompt` module owns interactive target selection and prompt styling. - `cli/src/services/security.rs` provides shared security utilities for deterministic secret redaction (`redact_sensitive_text`) and directory write-permission probes (`ensure_directory_is_writable`) used by app/setup/observability surfaces. -- `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/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 required registration — the four `sce hooks codex` registrations plus the six `sce hooks codex-mutation-scope` ones, the latter reported under distinct `#(mutation-scope)` rows — 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`, and `codex_hook_policy` probes Codex's effective hook-discovery policy once per invocation; only a structurally current registration that is trusted *and* policy-allowed 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 or policy-blocked is never "fixed", since SCE cannot grant Codex hook trust or change managed policy. - `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`), 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. diff --git a/context/cli/mutation-scope-hook-ingress.md b/context/cli/mutation-scope-hook-ingress.md index 092447c0..8709113d 100644 --- a/context/cli/mutation-scope-hook-ingress.md +++ b/context/cli/mutation-scope-hook-ingress.md @@ -218,18 +218,21 @@ 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: +A Codex adapter (`cli/src/services/hooks/codex_mutation_scope/`, hidden +`sce hooks codex-mutation-scope`) has since been built the same way and is +registered by `sce setup --codex`. Still out of scope for this seam itself, +and left as future work for the remaining harnesses: -- Codex hook mapping, OpenCode plugin, Pi extension; +- OpenCode plugin, Pi extension; - `SubagentStart` / `SubagentStop` / `PostToolUse` / tool-call translation for - those harnesses; -- `session → ScopeId` or `tool-call → EventId` derivation for those harnesses; + OpenCode and Pi; +- `session → ScopeId` or `tool-call → EventId` derivation for OpenCode and Pi; - 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 now ships; Codex/OpenCode/Pi remain - unregistered). +- harness settings generation or `sce setup` integration for OpenCode/Pi + (Claude's and Codex's registrations now ship — the Codex adapter lives in + `cli/src/services/hooks/codex_mutation_scope/` and is installed by + `sce setup --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 4624d2f7..3011de49 100644 --- a/context/cli/mutation-scope-runtime.md +++ b/context/cli/mutation-scope-runtime.md @@ -9,9 +9,11 @@ Built by the `mutation-scope-runtime-integration` plan `sce hooks mutation-scope` CLI ingress ([`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. +([`claude-mutation-scope-integration.md`](claude-mutation-scope-integration.md)) +and the Codex adapter (`sce hooks codex-mutation-scope`, now registered by +`sce setup --codex`; OpenCode/Pi: none yet — see Status) all 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) @@ -254,6 +256,12 @@ via the `pub(crate)` in-process seam 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. +A Codex adapter (`cli/src/services/hooks/codex_mutation_scope/`, hidden +`sce hooks codex-mutation-scope`) also maps onto this contract through the same +seam and is now registered by `sce setup --codex`; its full contract (the +tracked/delegation/untracked tool classification, the partial-by-tool-surface +coverage boundary, and the checkout-local recovery bookkeeping) is authored in a +dedicated file by the `codex-mutation-scope-integration` plan's final task. +OpenCode and Pi have no adapter; each remaining harness 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 bea81352..2520f2ed 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -31,8 +31,8 @@ Feature/domain context: - `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 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-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 Claude Code and Codex adapter drivers, both registered by `sce setup` and reachable — 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 Claude Code adapter driver (`cli/src/services/hooks/claude_mutation_scope/`) and a Codex adapter driver (`cli/src/services/hooks/codex_mutation_scope/`, hidden `sce hooks codex-mutation-scope`) now exist and are registered by `sce setup`, both consuming this seam's own `pub(crate)` in-process entrypoint; 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) @@ -90,7 +90,7 @@ Feature/domain context: - `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) - `context/sce/generated-opencode-plugin-registration.md` (canonical Pkl ownership and ephemeral OpenCode payload layout for `opencode.json`, `sce-bash-policy`, and `sce-agent-trace`, plus the Claude generated settings boundary) - `context/sce/pi-extension-runtime.md` (project-local Pi extension runtime: `config/lib/pi-plugin/sce-pi-extension.ts` emitted verbatim to `config/.pi/extensions/sce/index.ts`, Pi auto-discovery registration model with no manifest, implemented bash policy adapter delegating to `sce policy bash` with block-by-return `{ block, reason }` and fail-open behavior, implemented `message_end` conversation text capture piping mixed `message`/`message.part` batches (text + reasoning parts, `responseId`-or-random message IDs) to `sce hooks conversation-trace` fail-open, and implemented edit/write diff capture producing `git diff --no-index` unified diffs emitted as synthetic-message `patch` conversation parts plus normalized `sce hooks diff-trace` payloads with `tool_name: "pi"`, nullable `model_id`/`tool_version`, Rust-side `pi_` stored session-ID prefixing, and asset-pipeline shipping through the validated repository generated-input handoff, embedded install via `sce setup --pi`, and `sce doctor` `Pi extensions` health group) -- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s four dispatch arms plus fail-open `NoOp` fallthrough (including `PreToolUse(apply_patch)`, unregistered), idempotent `cx_` session prefixing with required trimmed non-empty sessions and truthful reported model-ID preservation (blank models are absent), the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row atomically through the shared `insert_conversation_text_event` transactional primitive with a deterministic `cx::user`/`cx::assistant` message ID, the implemented `PreToolUse(Bash)` slice delegating to the existing Bash policy engine with Codex's native `PreToolUse` deny response, and the implemented `PostToolUse(apply_patch)` slice outer-normalizing then parsing, resolving paths from event cwd against the real Git root, normalizing, and persisting a `diff_traces` row for provable Add/Update evidence under deterministic event-scoped synthetic line identities derived from `tool_use_id`; generated hook commands also resolve the Git root at invocation time and safely reach the helper from nested cwd or spaced repository paths; invalid cwd/path mappings, invalid sessions, or identity/range failures fail open before persistence; all non-policy success/fail-open paths are silent while Bash denial retains Codex's structured response) +- `context/sce/codex-integration-runtime.md` (Rust-side `sce hooks codex` dispatcher runtime: typed `CodexHookEvent` parsing, `classify_codex_event`'s four dispatch arms plus fail-open `NoOp` fallthrough (including `PreToolUse(apply_patch)`, which no `sce hooks codex` registration matches), idempotent `cx_` session prefixing with required trimmed non-empty sessions and truthful reported model-ID preservation (blank models are absent), the implemented `UserPromptSubmit`/`Stop` slices each persisting one `messages`/`parts` row atomically through the shared `insert_conversation_text_event` transactional primitive with a deterministic `cx::user`/`cx::assistant` message ID, the implemented `PreToolUse(Bash)` slice delegating to the existing Bash policy engine with Codex's native `PreToolUse` deny response, and the implemented `PostToolUse(apply_patch)` slice outer-normalizing then parsing, resolving paths from event cwd against the real Git root, normalizing, and persisting a `diff_traces` row for provable Add/Update evidence under deterministic event-scoped synthetic line identities derived from `tool_use_id`; generated hook commands also resolve the Git root at invocation time and safely reach the helper from nested cwd or spaced repository paths; invalid cwd/path mappings, invalid sessions, or identity/range failures fail open before persistence; all non-policy success/fail-open paths are silent while Bash denial retains Codex's structured response) - `context/sce/opencode-agent-trace-plugin-runtime.md` (current OpenCode agent-trace plugin runtime behavior, including captured `message.updated` handoff with `summary.diffs` branching: when diffs exist sends one `-patch` mixed batch containing a synthetic parent message plus per-diff `message.part` patch items, when no diffs sends the original `message.updated` payload; in-memory dedup `Set` keyed by `"${sessionID}:${messageID}"`; captured `message.part.updated` handoff to `sce hooks conversation-trace` for `text`/`reasoning` parts with non-empty text plus completed `question` tool parts emitted as `part_type: "question"` with JSON-stringified `{ question, answer }[]`; existing user-message diff extraction for `{ sessionID, diff, time, model_id }`; session-scoped OpenCode client version capture from `session.created`/`session.updated`; and CLI handoff to `sce hooks diff-trace` over STDIN JSON with required `tool_name="opencode"` plus required nullable `tool_version`; Rust hook parsing and AgentTraceDb insertion persist `oc_`-prefixed session IDs plus required payload fields including `model_id`) - `context/sce/cli-first-install-channels-contract.md` (current Nix/Cargo/npm/source-built Flatpak channel contract, release authority and workflow topology, Nix-owned Flatpak manifest/cargo-source generation and validation, reduced Flatpak app surface, and host-git bridge decision) - `context/sce/cli-release-artifact-contract.md` (shared `sce` binary release artifact naming, checksum/manifest outputs, pre-archive staged-binary preparation including macOS `libiconv` install-name sanitization/ad-hoc re-signing, native portability audit app/check for forbidden `/nix/store/` runtime references, GitHub Releases as the canonical artifact publication surface, manual dispatch `prerelease` flag behavior, the current three-target Linux/macOS release workflow topology including pre-upload extracted-archive smoke/audit validation in each native lane, implemented Flatpak source-manifest and source-built `.flatpak` bundle package assets uploaded by `.github/workflows/release-sce.yml`, and Flatpak's explicit source-built non-binary exception) diff --git a/context/overview.md b/context/overview.md index 55ea2ad8..0805473a 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,6 +1,6 @@ # Overview -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. +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`); the four conversation/diff registrations (`UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash`, `PostToolUse` for `apply_patch`) route through `sce hooks codex`, and six additive mutation-scope registrations (`PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, `SubagentStop`, `SessionEnd`, each an unmatched catch-all group appended after the existing groups) route through the separate `sce hooks codex-mutation-scope` command; the generated commands resolve the Git root at invocation time and safely invoke 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, reporting the mutation-scope registrations under distinct `#(mutation-scope)` rows separate from the `sce hooks codex` rows, and separately reports whether Codex has actually marked each structurally current registration trusted and whether its effective hook-discovery policy allows project hooks, by reading (never writing) Codex's own `$CODEX_HOME/config.toml` and policy state; `sce doctor --fix` repairs structurally unhealthy registrations through the same merge service but never touches trust or policy 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, 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`. @@ -112,7 +112,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, Pi, and Codex are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter; its bodies match Pi's except where prose names the skill's invocation input (`invocation input` in place of Pi's substituted `$ARGUMENTS`) and where each skill's `## Input` section carries a trailing, Codex-only `$sce-{slug}` invocation example Pi's empty invocation-example function never renders; its `sce-handover`/`sce-brownfield` `references/output.md` also quote the arguments-reference token back to the user in their invalid-usage example. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash` only, and `PostToolUse` for `apply_patch` only) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern), with every registered event routed through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely reaches the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the four supported arms above or a no-op fallthrough, all four now with real behavior — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence with truthful event-local session/model identity and silent non-policy success. +- OpenCode, Claude, Pi, and Codex are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter; its bodies match Pi's except where prose names the skill's invocation input (`invocation input` in place of Pi's substituted `$ARGUMENTS`) and where each skill's `## Input` section carries a trailing, Codex-only `$sce-{slug}` invocation example Pi's empty invocation-example function never renders; its `sce-handover`/`sce-brownfield` `references/output.md` also quote the arguments-reference token back to the user in their invalid-usage example. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash`, and `PostToolUse` for `apply_patch` routed to `sce hooks codex`, plus six unmatched mutation-scope registrations — `PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, `SubagentStop`, `SessionEnd` — routed to `sce hooks codex-mutation-scope`) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern); the generated commands resolve the Git root at invocation time and safely reach the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the four supported arms above or a no-op fallthrough, all four now with real behavior — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence with truthful event-local session/model identity and silent non-policy success. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 6e31a2b2..8a3fef0f 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -2566,8 +2566,9 @@ Persist this field in every plan; this is durable plan state, not chat state: unregistered. Durable adapter context (D13a/b/c, the coverage table, the concurrency story) is authored by T07 as already recorded. -- [ ] T05: `Generated .codex/hooks.json registrations, setup merge, and doctor` (status:todo) +- [x] T05: `Generated .codex/hooks.json registrations, setup merge, and doctor` (status:done) - Task ID: T05 + - Completed: 2026-09-08 - Scope: In — (a) `config/pkl/renderers/codex-content.pkl`: add the minimum mutation-scope `.codex/hooks.json` registrations the adapter uses (per T01's matcher @@ -2628,7 +2629,161 @@ Persist this field in every plan; this is durable plan state, not chat state: test --manifest-path cli/Cargo.toml services::doctor::`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::setup::`. - - Context synchronization: pending + - Files changed: + - `config/pkl/renderers/codex-content.pkl` — a second command contract + `codexMutationScopeHookCommand` (`sce hooks codex-mutation-scope`); the + four `sce hooks codex` groups are byte-for-byte unchanged and a new + unmatched (catch-all) mutation-scope group is appended after each of + `Stop`, `PreToolUse`, `PostToolUse`, plus new `Interrupt`, `SubagentStop`, + `SessionEnd` events — exactly the six the T04 driver dispatches on (D17/D20). + - `config/pkl/renderers/generation-contract-check.pkl` — the + `codex-hook-invocation` contract now asserts the four `sce hooks codex` + handlers plus the six unmatched `sce hooks codex-mutation-scope` handlers, + with safe root resolution and no `eval`. + - `cli/src/services/codex_hook_config.rs` — command-aware ownership/merge: + `CodexHookCommand { Codex, MutationScope }`, `REQUIRED_EVENTS` is now 10 + `(command, event, matcher)` rows, `required_registrations()` exported for + doctor, `RegistrationDiagnosis` / `Registration` carry `command`, + `command_owning_contract` / `handler_owning_command` / `handler_owned_by` + replace the single-contract predicate, `validate_generated_document_value` + locates each registration's group+handler by `(matcher, owning command)` + (multiple groups per event allowed), `merge_document` folds registrations + in `REQUIRED_EVENTS` order, `merge_event_groups` is command-scoped and + appends a fresh group rather than extending a group already holding the + other command's handler (D20 position stability), `hook_event_key_label` + gains `Interrupt`/`SubagentStop`/`SessionEnd`. Tests: AC16, AC16a + upgrade regression, AC17 idempotency + malformed-untouched, AC17a one label + test per new event, two-unmatched-`Stop`-groups, append-without-touching-Bash. + - `cli/src/services/doctor/inspect.rs` — `codex_registration_suffix` + (`PreToolUse(Bash)` unchanged for `Codex`, `(mutation-scope)` for + `MutationScope`) and `codex_required_registration_children` replace the + hardcoded four-entry `codex_hook_registration_paths`; + `codex_hook_registration_child` derives the suffix from `diagnosis.command`. + Tests: `a_mutation_scope_registration_gets_the_full_three_dimension_health_model` + (structural / trust / policy, AC18 1-4), distinct-readiness vs + `sce hooks codex` (AC18), count assertions generalised to + `required_registrations().len()`. + - `cli/src/services/setup/mod.rs` — `install_merges_codex_hooks_*` test now + asserts 8 event keys and that the mutation-scope groups route to + `sce hooks codex-mutation-scope`. + - `scripts/test-codex-hook-command.sh` — the flake `codex-hook-command` + check now validates the four-plus-six-registration contract and that the + mutation-scope groups are unmatched and root-aware / fail-open. + - `cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md` — a T05 + section recording the registered event set, the unmatched-group choice + (T01 probe evidence), and the upstream `hook_event_key_label` citation + (`openai/codex` `rust-v0.153.4`, `codex-rs/hooks/src/lib.rs` lines 96–108) + for `interrupt` / `subagent_stop` / `session_end` (D22 / AC17a). + - `.codex/hooks.json` — the repo's own installed Codex hook config, + regenerated via `sce setup --codex` so the four `sce hooks codex` + registrations keep their exact `(event, matcher, group, handler)` identity + and the six mutation-scope registrations are appended (dogfood parity with + `.claude/settings.json`). + - Result: `.codex/hooks.json` generation now emits a second command contract. + The Codex mutation-scope adapter is registered as `sce hooks codex-mutation-scope` + on six unmatched groups (`PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, + `SubagentStop`, `SessionEnd`) appended after the four unchanged + `sce hooks codex` groups (D17). `codex_hook_config.rs`'s ownership, merge, and + diagnosis are command-aware: a handler is attributed to whichever trailing + command-token contract it matches, only that contract's stale/duplicate + handlers are repaired, and a new SCE group is appended rather than inserted + into a group already holding the other command's handler — so upgrading a + canonical-four document leaves every existing registration's identity tuple + and computed Codex trust key untouched (D20/AC16a), user handlers survive, + and a second merge is byte-identical (AC16/AC17). `hook_event_key_label` + covers `Interrupt`/`SubagentStop`/`SessionEnd` with the exact upstream labels + (D22/AC17a). `sce doctor` gives each mutation-scope registration the same + three-dimension model as the `sce hooks codex` registrations — structural, + Codex trust, effective project-hook policy — reported under a distinct + `.codex/hooks.json#(mutation-scope)` row, never flattened, and + `--fix` still only rewrites SCE-owned `.codex/hooks.json` structure (the + trust/policy probes are read-only; no `$CODEX_HOME` write on any path) + (D21/AC18). No adapter behaviour, non-Codex renderer, or trust/policy probe + mechanism changed. + - Verify: + - `nix run .#pkl-check-generated` — **passed** (141 files; the + `codex-hook-invocation` and `codex-hook-command` contracts updated for the + four-plus-six registration shape). No committed file-count artifact — the + inventory digest is printed only; the file set is unchanged. + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::codex_hook_config` — **passed** (34 tests, incl. + the AC16a upgrade regression, AC17 idempotency/malformed, one AC17a label + test per new event, two-unmatched-`Stop`-groups, and the + append-without-touching-Bash placement test). + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::doctor::` — **passed** (27 tests, incl. the new + three-dimension mutation-scope health test and the distinct-readiness + test). + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::setup::` — **passed** (66 tests). + - `nix develop -c bash ./scripts/test-codex-hook-command.sh` — **passed**. + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::codex_hook_trust` — **passed** (13; trust module + unchanged and still correct for the new events/positions). + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml` (full suite) — **passed** (1258 tests, 1 ignored). + - `clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` — + **clean**; `cargo fmt -- --check` — **clean**. + - Context impact: domain — a new generated `.codex/hooks.json` registration + set (six mutation-scope groups) and a command-aware Codex hook-config + ownership/merge/doctor model. User-visible surface: `sce setup --codex` now + installs the mutation-scope registrations; `sce doctor` reports them as + distinct `.codex/hooks.json#(mutation-scope)` rows with the full + three-dimension health model. No public API, protocol, Quint, mutation-trace + SQL, or Agent Trace schema change (AC22 territory untouched). The generic + mutation-scope ingress/runtime and the existing `sce hooks codex` + conversation/diff pipeline are unchanged and additive. Durable + Codex-adapter context (`context/cli/codex-mutation-scope-integration.md`) is + authored by **T07** once the full adapter ships and is proven end-to-end + (T06); T05 corrected the now-contradicted cross-reference statements in + place (see Context synchronization). + - Context synchronization: synced + - T05 is the task that made the Codex mutation-scope adapter reachable: + `sce setup --codex` now installs six `sce hooks codex-mutation-scope` + registrations and `sce doctor` reports them. The T01–T04 precedent + deferred all durable-context edits to T07 on the grounds that the adapter + was "inert and unreachable" — that reasoning no longer holds after T05, so + the now-contradicted statements about generated `.codex/hooks.json` + content, `codex_hook_config` ownership, and the doctor per-registration + model were corrected in place: `context/overview.md` and + `context/architecture.md` (the four-registration / single-`sce hooks codex` + absolutes → four-plus-six, two command contracts, the D20 position-stable + append, the three-dimension doctor model with distinct + `#(mutation-scope)` rows), `context/sce/codex-integration-runtime.md` + (dispatcher scope narrowed to the four conversation/diff registrations; + command-aware merge; twelve supported event names; `PreToolUse` no + `sce hooks codex` match), `context/sce/agent-trace-hooks-command-routing.md` + (two-contract ownership), `context/sce/doctor-human-text-contract.md` (the + mutation-scope rows and per-registration policy probe reuse), and the + mutation-scope stack summaries in `context/cli/mutation-scope-runtime.md`, + `context/cli/mutation-scope-hook-ingress.md`, and `context/context-map.md` + ("Codex has no adapter / remains unregistered" → the Codex adapter now + exists and is registered; OpenCode/Pi remain unwired). Mandatory + five-root pass done: `overview.md`, `architecture.md`, `context-map.md` + edited as above; `glossary.md` and `patterns.md` read and not + contradicted (T05 added no user-facing terminology — the three-class + classification and coverage boundary are T07's glossary scope). + - Deliberately **not** authored here (T07's explicit scope, dependent on + T05 **and** T06): the dedicated + `context/cli/codex-mutation-scope-integration.md` domain file (the + three-class tool classification, the D23 partial-by-tool-surface coverage + boundary and its T01 probe rationale, execution identity / `ScopeId` + derivation, the recovery bookkeeping, D8 fail-closed response, D9/D10/D10a + terminal boundaries, D12/D13 cleanup + recovery barrier, the + `AiExclusive`-is-not-sole-authorship statement, background/detached + limitations); `architecture.md` line 135's `hooks/mod.rs` enumeration + naming the new arm (a T04-accepted deferral); and splitting + `codex-integration-runtime.md` (291 lines) / `mutation-scope-runtime.md` + (267 lines) back under the 250-line budget — both were already over + budget before T05, and T07 edits and owns splitting them. + - No new ADR qualified. The "separate hidden command" architecture is + decision **D17**, already decided and recorded in this plan at T02; T05 + is its merge/doctor-side implementation, consistent with the existing + `2026-08-23-codex-nondestructive-hook-ownership.md` ADR (whose core + non-destructive-merge principle is unchanged and, via D20, strengthened). + The plan reserves the "materially changes the accepted + non-destructive-ownership contract → new dated ADR" call for T07's full + re-evaluation once the adapter ships. - [ ] T06: `Real Git/DB regressions through the production Codex path` (status:todo) - Task ID: T06 diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 58134c59..915760df 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -125,7 +125,7 @@ Interactive `sce setup` persists the independent confirmations at `agent_trace.a - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff with `tool_name: "opencode"`: ordinary message/part events produce one-item mixed envelopes, completed question-tool parts produce `message.part` items with `part_type: "question"`, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. The Pi extension uses the same shape with `tool_name: "pi"`. - `sce hooks claude-model-state` is a silent, local-only lifecycle intake for raw Claude `SessionStart` and `PostModelSwitch` events. A model-bearing `SessionStart` writes normalized `claude/` 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 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 the generated helper path plus either the `sce hooks codex` or the `sce hooks codex-mutation-scope` command contract, attributing each handler to whichever it matches and merging the two contracts' registrations independently. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full `sce hooks codex` dispatcher and per-arm contract; the six `sce hooks codex-mutation-scope` registrations feed the generic mutation-scope ingress ([../cli/mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md)). - `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 are in [../cli/claude-mutation-scope-integration.md](../cli/claude-mutation-scope-integration.md). diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index 819a097a..3d0a7b88 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -1,7 +1,12 @@ # Codex hook runtime (SCE) -Rust-side runtime behind `sce hooks codex`, the single dispatcher subcommand -every registered `.codex/hooks.json` event routes to. Source: `cli/src/services/hooks/codex/`. +Rust-side runtime behind `sce hooks codex`, the dispatcher subcommand the four +conversation/diff `.codex/hooks.json` registrations route to. Source: +`cli/src/services/hooks/codex/`. `.codex/hooks.json` also carries six +mutation-scope registrations routed to the separate +`sce hooks codex-mutation-scope` command (the generic mutation-scope ingress, +[../cli/mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md)) — +not part of this dispatcher. See [Codex generated assets](../architecture.md) for the Pkl-authored `.codex/hooks.json`/hook-script side of this integration and [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md) @@ -9,29 +14,38 @@ for how the other three tools intake conversation/diff evidence. ## Generated hook invocation -The generated `.codex/hooks.json` routes all four registrations through the -same command. That command resolves `git rev-parse --show-toplevel` at -invocation time, then invokes the repository-root +The generated `.codex/hooks.json` routes its four conversation/diff +registrations through `sce hooks codex` and its six mutation-scope registrations +through `sce hooks codex-mutation-scope`. Each command resolves +`git rev-parse --show-toplevel` at invocation time, then invokes the +repository-root `.codex/hooks/run-sce-or-show-install-guidance.sh` helper with quoted expansions. It therefore works from the repository root, arbitrary nested Codex working directories, and repository paths containing spaces. Git-root resolution failures exit successfully without stdout; the helper retains its existing missing-`sce` stderr guidance and forwards the hook JSON STDIN -unchanged. The exact four-registration and invocation contract is covered by -the generated contract and `codex-hook-command` flake check. See [the ADR](../decisions/2026-08-23-codex-root-aware-hook-invocation.md). +unchanged. The exact registration set (four `sce hooks codex` plus six +`sce hooks codex-mutation-scope`) and invocation contract is covered by the +generated contract and `codex-hook-command` flake check. See [the ADR](../decisions/2026-08-23-codex-root-aware-hook-invocation.md). ## Non-destructive hook configuration ownership `.codex/hooks.json` is a user-owned document. `sce setup --codex` and `--all` merge the generated SCE fragment instead of replacing the whole file. The shared `cli/src/services/codex_hook_config.rs` service mirrors current -Codex deserialization: top-level `description`/`hooks` only, the eleven +Codex deserialization: top-level `description`/`hooks` only, the twelve supported event names, defaulted matcher groups, and `command`, `mcp_tool`, `prompt`, or `agent` handlers with their typed fields. It preserves unrelated -valid Codex fields, event groups, matcher groups, and handlers, and replaces stale or duplicate SCE-owned handlers with -one current handler for each of the four required registrations. Ownership -requires both `.codex/hooks/run-sce-or-show-install-guidance.sh` and the -`sce hooks codex` command contract; a generic `sce` substring is not enough. +valid Codex fields, event groups, matcher groups, and handlers. Merge is +command-aware over two contracts: it replaces stale or duplicate handlers with +one current handler per required registration — the four `sce hooks codex` +registrations and six additive `sce hooks codex-mutation-scope` registrations, +each appended in its own unmatched group after the existing groups so an +already-trusted handler keeps its `(event, matcher, group index, handler index)` +identity and computed Codex trust key — touching only the matching command's +handlers. Ownership requires the helper path plus one of the `sce hooks codex` / +`sce hooks codex-mutation-scope` command contracts; a generic `sce` substring is +not enough. Malformed or structurally invalid existing documents fail before staging, so the existing file remains untouched. Doctor diagnoses each required registration structurally (present-and-current, missing, or stale, with a @@ -60,7 +74,7 @@ instead asks the installed `codex` binary for its own composed answer over `codex app-server --stdio`'s read-only `configRequirements/read` method, bounded by a strict timeout with the child process always terminated and reaped. Doctor probes this exactly once per invocation and reuses the result -for all four registrations. A structurally current registration is only +for every required registration. A structurally current registration is only `Match`/healthy when policy allows project hooks *and* it is durably trusted; `allow_managed_hooks_only = true` reports it `PolicyBlocked` (an Error-severity, manual-only problem) even when fully trusted, and a probe @@ -76,7 +90,7 @@ managed/enterprise policy and never attempts to. - `classify_codex_event` matches `(hook_event_name, tool_name)` into one of four dispatch arms — `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)` — with every other combination (`apply_patch` - under `PreToolUse` — no such registration exists in `.codex/hooks.json` — + under `PreToolUse` — no `sce hooks codex` registration matches it — unknown tool, `Bash` under `PostToolUse`, unrecognized `hook_event_name`) falling through to a deterministic `NoOp` success with empty stdout. - Malformed/non-JSON STDIN is logged through `sce.hooks.codex.error` and the @@ -253,11 +267,13 @@ redesign in this path; malformed or unsafe inputs fail open silently. ## No remaining stub arms -All four registered dispatch arms (`UserPromptSubmit`, `Stop`, +All four `sce hooks codex` dispatch arms (`UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`) now have real behavior. -`PreToolUse(apply_patch)` is deliberately never registered (see plan +`PreToolUse(apply_patch)` is deliberately not a `sce hooks codex` arm (see plan `context/plans/codex-cli-integration.md`'s no-snapshot design) and falls -open as a `NoOp` like any other unsupported combination. +open as a `NoOp` like any other unsupported combination; the unmatched +`PreToolUse` group routed to `sce hooks codex-mutation-scope` is a separate +concern handled by that command. ## Verification @@ -265,8 +281,9 @@ open as a `NoOp` like any other unsupported combination. (also runnable narrowed per-arm, e.g. `hooks::codex::user_prompt_submit`). This includes the realistic repository-scoped PostToolUse/post-commit regression and the repeated-identical-content ambiguity test. -- `nix run .#pkl-check-generated` verifies the four generated Codex hook - registrations and root-aware invocation contract. +- `nix run .#pkl-check-generated` verifies the generated Codex hook + registrations (four `sce hooks codex` plus six `sce hooks codex-mutation-scope`) + and root-aware invocation contract. - `nix flake check` runs the same tests plus clippy/fmt/generated-asset checks. See also: [agent-trace-db.md](agent-trace-db.md), diff --git a/context/sce/doctor-human-text-contract.md b/context/sce/doctor-human-text-contract.md index 61b24bd4..ae7b7867 100644 --- a/context/sce/doctor-human-text-contract.md +++ b/context/sce/doctor-human-text-contract.md @@ -90,9 +90,12 @@ own the hierarchy. Areas render in deterministic order: - Codex: `Skills`, `Hooks` Codex's `Hooks` area covers `.codex/hooks/run-sce-or-show-install-guidance.sh` -plus one row per required `.codex/hooks.json` registration -(`UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`) -instead of one whole-file row. Doctor classifies each registration +plus one row per required `.codex/hooks.json` registration instead of one +whole-file row: the four `sce hooks codex` registrations (`UserPromptSubmit`, +`Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`) and, under distinct +`(mutation-scope)` rows, the six `sce hooks codex-mutation-scope` registrations +(`PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, `SubagentStop`, +`SessionEnd`). Doctor classifies each registration structurally — `[PASS]` when present and canonical, `[MISS]` when absent, `[FAIL]` when stale (an SCE-owned handler exists but does not match the canonical one) or when the whole document cannot be structurally validated — @@ -105,7 +108,7 @@ hook-discovery *policy* before trust is ever consulted. Doctor probes the installed `codex` binary's own composed `allow_managed_hooks_only` requirement once per invocation (`codex app-server --stdio`'s read-only `configRequirements/read`, bounded by a strict timeout) and reuses that one -result for all four registrations: `[FAIL]` when the effective policy +result for every required registration: `[FAIL]` when the effective policy excludes project hooks (`allow_managed_hooks_only = true` — this is an Error-severity, administrative-only problem, since SCE cannot change Codex's managed/enterprise policy), `[WARN]` when the policy could not be determined diff --git a/scripts/test-codex-hook-command.sh b/scripts/test-codex-hook-command.sh index 513a9e4c..2408ef15 100755 --- a/scripts/test-codex-hook-command.sh +++ b/scripts/test-codex-hook-command.sh @@ -22,22 +22,25 @@ helper="${generated_root}/config/.codex/hooks/run-sce-or-show-install-guidance.s [ -f "${hooks_json}" ] || fail "generated hooks.json is missing" [ -f "${helper}" ] || fail "generated hook helper is missing" -expected_events='["PostToolUse","PreToolUse","Stop","UserPromptSubmit"]' +expected_events='["Interrupt","PostToolUse","PreToolUse","SessionEnd","Stop","SubagentStop","UserPromptSubmit"]' actual_events="$(jq -c '.hooks | keys | sort' "${hooks_json}")" [ "${actual_events}" = "${expected_events}" ] || fail "unexpected Codex hook event registrations: ${actual_events}" jq -e ' - ((.hooks.UserPromptSubmit | length == 1) and (.hooks.UserPromptSubmit[0].hooks | length == 1)) - and ((.hooks.Stop | length == 1) and (.hooks.Stop[0].hooks | length == 1)) - and ((.hooks.PreToolUse | length == 1) and (.hooks.PreToolUse[0].matcher == "Bash") and (.hooks.PreToolUse[0].hooks | length == 1)) - and ((.hooks.PostToolUse | length == 1) and (.hooks.PostToolUse[0].matcher == "apply_patch") and (.hooks.PostToolUse[0].hooks | length == 1)) + ((.hooks.UserPromptSubmit | length == 1) and (.hooks.UserPromptSubmit[0].hooks | length == 1) and (.hooks.UserPromptSubmit[0] | has("matcher") | not)) + and ((.hooks.Stop | length == 2) and (.hooks.Stop[0].hooks | length == 1) and (.hooks.Stop[1].hooks | length == 1) and (.hooks.Stop[1] | has("matcher") | not)) + and ((.hooks.PreToolUse | length == 2) and (.hooks.PreToolUse[0].matcher == "Bash") and (.hooks.PreToolUse[0].hooks | length == 1) and (.hooks.PreToolUse[1] | has("matcher") | not) and (.hooks.PreToolUse[1].hooks | length == 1)) + and ((.hooks.PostToolUse | length == 2) and (.hooks.PostToolUse[0].matcher == "apply_patch") and (.hooks.PostToolUse[0].hooks | length == 1) and (.hooks.PostToolUse[1] | has("matcher") | not) and (.hooks.PostToolUse[1].hooks | length == 1)) + and ((.hooks.Interrupt | length == 1) and (.hooks.Interrupt[0] | has("matcher") | not)) + and ((.hooks.SubagentStop | length == 1) and (.hooks.SubagentStop[0] | has("matcher") | not)) + and ((.hooks.SessionEnd | length == 1) and (.hooks.SessionEnd[0] | has("matcher") | not)) and (has("$schema") | not) -' "${hooks_json}" >/dev/null || fail "Codex hook registrations are not the expected four-entry contract" +' "${hooks_json}" >/dev/null || fail "Codex hook registrations do not match the expected four-plus-mutation-scope contract" hook_command="$(jq -r '.hooks.UserPromptSubmit[0].hooks[0].command' "${hooks_json}")" -for event in UserPromptSubmit Stop PreToolUse PostToolUse; do - event_command="$(jq -r --arg event "${event}" '.hooks[$event][0].hooks[0].command' "${hooks_json}")" - [ "${event_command}" = "${hook_command}" ] || fail "${event} does not use the shared Codex hook command" +for path in '.hooks.UserPromptSubmit[0]' '.hooks.Stop[0]' '.hooks.PreToolUse[0]' '.hooks.PostToolUse[0]'; do + event_command="$(jq -r "${path}.hooks[0].command" "${hooks_json}")" + [ "${event_command}" = "${hook_command}" ] || fail "${path} does not use the shared Codex hook command" done case "${hook_command}" in *'git rev-parse --show-toplevel'*'2>/dev/null'*'|| exit 0; exec bash '*'$root/.codex/hooks/run-sce-or-show-install-guidance.sh'*' sce hooks codex') ;; @@ -47,6 +50,16 @@ case "${hook_command}" in *eval*) fail "Codex hook command uses eval" ;; esac +mutation_scope_command="$(jq -r '.hooks.PreToolUse[1].hooks[0].command' "${hooks_json}")" +for path in '.hooks.PreToolUse[1]' '.hooks.PostToolUse[1]' '.hooks.Stop[1]' '.hooks.Interrupt[0]' '.hooks.SubagentStop[0]' '.hooks.SessionEnd[0]'; do + group_command="$(jq -r "${path}.hooks[0].command" "${hooks_json}")" + [ "${group_command}" = "${mutation_scope_command}" ] || fail "${path} does not route to the shared mutation-scope command" +done +case "${mutation_scope_command}" in + *'git rev-parse --show-toplevel'*'2>/dev/null'*'|| exit 0; exec bash '*'$root/.codex/hooks/run-sce-or-show-install-guidance.sh'*' sce hooks codex-mutation-scope') ;; + *) fail "Codex mutation-scope hook command is not root-aware and fail-open: ${mutation_scope_command}" ;; +esac + repo="${tmp_root}/repo with spaces" mkdir -p "${repo}/a/b/c" git init -q "${repo}" From eb8b98ce206306bc1a5b1b34ab2d540ee52c0af1 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 15:12:08 +0200 Subject: [PATCH 11/20] codex: Make mutation-scope tool bootstrap fail closed Prevent tracked Bash and apply_patch executions from proceeding without mutation attribution when Git-root resolution, helper startup, sce availability, or adapter execution fails. Narrow mutation-scope PreToolUse/PostToolUse registrations to the anchored tracked-tool matcher and add a fail-closed bootstrap that emits the stable deny response while forwarding successful adapter output. Update generated-contract and hook regressions, and migrate legacy unmatched registrations without changing untracked-tool Option B behavior. Plan: codex-mutation-scope-integration (T05) --- .codex/hooks.json | 8 +- .../hooks/run-sce-or-show-install-guidance.sh | 19 ++ cli/src/services/codex_hook_config.rs | 199 ++++++++++++++++-- config/pkl/renderers/codex-content.pkl | 29 ++- .../renderers/generation-contract-check.pkl | 22 +- .../plans/codex-mutation-scope-integration.md | 120 +++++++++++ scripts/test-codex-hook-command.sh | 141 ++++++++++++- 7 files changed, 507 insertions(+), 31 deletions(-) diff --git a/.codex/hooks.json b/.codex/hooks.json index 29340c77..8f94ea06 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -26,7 +26,8 @@ "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope", "type": "command" } - ] + ], + "matcher": "^(Bash|apply_patch)$" } ], "PreToolUse": [ @@ -42,10 +43,11 @@ { "hooks": [ { - "command": "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope", + "command": "sce_deny(){ printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"SCE could not establish mutation attribution for this tool execution.\"}}'; exit 0; }; root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || sce_deny; test -r \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" || sce_deny; SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED=1 exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope", "type": "command" } - ] + ], + "matcher": "^(Bash|apply_patch)$" } ], "SessionEnd": [ diff --git a/.codex/hooks/run-sce-or-show-install-guidance.sh b/.codex/hooks/run-sce-or-show-install-guidance.sh index a01dc897..48f5d214 100644 --- a/.codex/hooks/run-sce-or-show-install-guidance.sh +++ b/.codex/hooks/run-sce-or-show-install-guidance.sh @@ -1,6 +1,25 @@ #!/usr/bin/env bash set -euo pipefail +sce_pre_tool_use_deny() { + printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"SCE could not establish mutation attribution for this tool execution."}}' +} + +if [ "${SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED:-0}" = "1" ]; then + if ! command -v sce >/dev/null 2>&1; then + echo "sce CLI not found. Install it from https://sce.crocoder.dev/docs/getting-started#install-cli" >&2 + sce_pre_tool_use_deny + exit 0 + fi + if ! adapter_output="$("$@")"; then + echo "SCE mutation-scope adapter failed; denying the tracked tool to preserve fail-closed PreToolUse." >&2 + sce_pre_tool_use_deny + exit 0 + fi + printf '%s' "$adapter_output" + exit 0 +fi + if ! command -v sce >/dev/null 2>&1; then echo "sce CLI not found. Install it from https://sce.crocoder.dev/docs/getting-started#install-cli" >&2 exit 0 diff --git a/cli/src/services/codex_hook_config.rs b/cli/src/services/codex_hook_config.rs index d231a81f..9f434ccd 100644 --- a/cli/src/services/codex_hook_config.rs +++ b/cli/src/services/codex_hook_config.rs @@ -13,6 +13,10 @@ const CODEX_HOOKS_ROOT: &str = "hooks"; const CODEX_HELPER_PATH: &str = ".codex/hooks/run-sce-or-show-install-guidance.sh"; const CODEX_ROOTED_HELPER_PATH: &str = "$root/.codex/hooks/run-sce-or-show-install-guidance.sh"; +pub(crate) const CODEX_MUTATION_SCOPE_TOOL_MATCHER: &str = "^(Bash|apply_patch)$"; + +const CODEX_PRE_TOOL_USE_FAIL_CLOSED_ASSIGNMENT: &str = "SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED=1"; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum CodexHookCommand { Codex, @@ -42,8 +46,16 @@ const REQUIRED_EVENTS: [(CodexHookCommand, &str, Option<&str>); 10] = [ (CodexHookCommand::Codex, "Stop", None), (CodexHookCommand::Codex, "PreToolUse", Some("Bash")), (CodexHookCommand::Codex, "PostToolUse", Some("apply_patch")), - (CodexHookCommand::MutationScope, "PreToolUse", None), - (CodexHookCommand::MutationScope, "PostToolUse", None), + ( + CodexHookCommand::MutationScope, + "PreToolUse", + Some(CODEX_MUTATION_SCOPE_TOOL_MATCHER), + ), + ( + CodexHookCommand::MutationScope, + "PostToolUse", + Some(CODEX_MUTATION_SCOPE_TOOL_MATCHER), + ), (CodexHookCommand::MutationScope, "Stop", None), (CodexHookCommand::MutationScope, "Interrupt", None), (CodexHookCommand::MutationScope, "SubagentStop", None), @@ -787,7 +799,11 @@ fn handler_owning_command(handler: &Value) -> Option { fn command_owning_contract(command: &str) -> Option { command.split(';').find_map(|segment| { - let tokens: Vec<&str> = segment.split_whitespace().collect(); + let all_tokens: Vec<&str> = segment.split_whitespace().collect(); + let tokens: &[&str] = match all_tokens.split_first() { + Some((first, rest)) if *first == CODEX_PRE_TOOL_USE_FAIL_CLOSED_ASSIGNMENT => rest, + _ => all_tokens.as_slice(), + }; let offset = usize::from(tokens.first() == Some(&"exec")); if tokens.len() != offset + 5 || tokens.get(offset) != Some(&"bash") { return None; @@ -814,6 +830,7 @@ mod tests { const CANONICAL_COMMAND: &str = "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex"; const MUTATION_SCOPE_COMMAND: &str = "root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0; exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope"; + const MUTATION_SCOPE_PRE_TOOL_USE_COMMAND: &str = "sce_deny(){ printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"SCE could not establish mutation attribution for this tool execution.\"}}'; exit 0; }; root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || sce_deny; test -r \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" || sce_deny; SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED=1 exec bash \"$root/.codex/hooks/run-sce-or-show-install-guidance.sh\" sce hooks codex-mutation-scope"; fn codex_handler() -> Value { json!({"type": "command", "command": CANONICAL_COMMAND}) @@ -823,6 +840,10 @@ mod tests { json!({"type": "command", "command": MUTATION_SCOPE_COMMAND}) } + fn mutation_scope_pre_tool_use_handler() -> Value { + json!({"type": "command", "command": MUTATION_SCOPE_PRE_TOOL_USE_COMMAND}) + } + fn canonical_document() -> Value { json!({ "hooks": { @@ -830,11 +851,11 @@ mod tests { "Stop": [{"hooks": [codex_handler()]}, {"hooks": [mutation_scope_handler()]}], "PreToolUse": [ {"matcher": "Bash", "hooks": [codex_handler()]}, - {"hooks": [mutation_scope_handler()]} + {"matcher": CODEX_MUTATION_SCOPE_TOOL_MATCHER, "hooks": [mutation_scope_pre_tool_use_handler()]} ], "PostToolUse": [ {"matcher": "apply_patch", "hooks": [codex_handler()]}, - {"hooks": [mutation_scope_handler()]} + {"matcher": CODEX_MUTATION_SCOPE_TOOL_MATCHER, "hooks": [mutation_scope_handler()]} ], "Interrupt": [{"hooks": [mutation_scope_handler()]}], "SubagentStop": [{"hooks": [mutation_scope_handler()]}], @@ -851,14 +872,30 @@ mod tests { fn with_mutation_scope_registrations(mut doc: Value) -> Value { let hooks = doc["hooks"].as_object_mut().expect("hooks object"); - for event in ["PreToolUse", "PostToolUse", "Stop"] { - hooks - .entry(event.to_string()) - .or_insert_with(|| Value::Array(Vec::new())) - .as_array_mut() - .expect("event array") - .push(json!({"hooks": [mutation_scope_handler()]})); - } + hooks + .entry("PreToolUse".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("event array") + .push(json!({ + "matcher": CODEX_MUTATION_SCOPE_TOOL_MATCHER, + "hooks": [mutation_scope_pre_tool_use_handler()] + })); + hooks + .entry("PostToolUse".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("event array") + .push(json!({ + "matcher": CODEX_MUTATION_SCOPE_TOOL_MATCHER, + "hooks": [mutation_scope_handler()] + })); + hooks + .entry("Stop".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("event array") + .push(json!({"hooks": [mutation_scope_handler()]})); for event in ["Interrupt", "SubagentStop", "SessionEnd"] { hooks.insert( event.to_string(), @@ -913,10 +950,10 @@ mod tests { assert_eq!(post_tool_use[0]["hooks"].as_array().unwrap().len(), 4); assert_eq!(post_tool_use[1]["matcher"], "apply_patch"); assert_eq!(post_tool_use[1]["hooks"][0]["command"], CANONICAL_COMMAND); - assert!(!post_tool_use[2] - .as_object() - .unwrap() - .contains_key("matcher")); + assert_eq!( + post_tool_use[2]["matcher"], + CODEX_MUTATION_SCOPE_TOOL_MATCHER + ); assert_eq!( post_tool_use[2]["hooks"][0]["command"], MUTATION_SCOPE_COMMAND @@ -1113,12 +1150,27 @@ mod tests { ), Some(CodexHookCommand::MutationScope) ); + assert_eq!( + command_owning_contract(MUTATION_SCOPE_PRE_TOOL_USE_COMMAND), + Some(CodexHookCommand::MutationScope), + "the fail-closed PreToolUse bootstrap is still MutationScope-owned" + ); + assert_eq!( + command_owning_contract( + r#"SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED=1 exec bash ".codex/hooks/run-sce-or-show-install-guidance.sh" sce hooks codex-mutation-scope"# + ), + Some(CodexHookCommand::MutationScope) + ); for command in [ "echo .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex", "printf '%s' '.codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex'", "foo='.codex/hooks/run-sce-or-show-install-guidance.sh'; echo sce hooks codex", "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex && echo user", "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex-other", + "FOO=1 exec bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex-mutation-scope", + "SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED=1 SCE_OTHER=1 exec bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex-mutation-scope", + "SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED=1 bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex-mutation-scope && echo user", + "sce hooks codex-mutation-scope", ] { assert!( command_owning_contract(command).is_none(), @@ -1647,12 +1699,28 @@ mod tests { .values() .flat_map(|groups| groups.as_array().unwrap()) .flat_map(|group| group["hooks"].as_array().unwrap()) - .filter(|handler| handler["command"] == MUTATION_SCOPE_COMMAND) + .filter(|handler| { + handler["command"] + .as_str() + .and_then(command_owning_contract) + == Some(CodexHookCommand::MutationScope) + }) .count(); assert_eq!( mutation_scope_count, 6, "each mutation-scope handler must appear exactly once" ); + let fail_closed_pre_tool_use_count = value["hooks"]["PreToolUse"] + .as_array() + .unwrap() + .iter() + .flat_map(|group| group["hooks"].as_array().unwrap()) + .filter(|handler| handler["command"] == MUTATION_SCOPE_PRE_TOOL_USE_COMMAND) + .count(); + assert_eq!( + fail_closed_pre_tool_use_count, 1, + "the tracked-mutation PreToolUse registration uses the fail-closed bootstrap" + ); assert_eq!( value["hooks"]["Stop"][0]["hooks"][0]["command"], @@ -1683,10 +1751,13 @@ mod tests { assert_eq!(pre_tool_use.len(), 2); assert_eq!(pre_tool_use[0]["matcher"], "Bash"); assert_eq!(pre_tool_use[0]["hooks"][0]["command"], CANONICAL_COMMAND); - assert!(!pre_tool_use[1].as_object().unwrap().contains_key("matcher")); + assert_eq!( + pre_tool_use[1]["matcher"], + CODEX_MUTATION_SCOPE_TOOL_MATCHER + ); assert_eq!( pre_tool_use[1]["hooks"][0]["command"], - MUTATION_SCOPE_COMMAND + MUTATION_SCOPE_PRE_TOOL_USE_COMMAND ); } @@ -1712,6 +1783,94 @@ mod tests { assert_eq!(mutation_stop.position, Some((1, 0))); } + #[test] + fn mutation_scope_tool_hooks_carry_the_tracked_only_matcher() { + for (command, event, matcher) in required_registrations() { + match (command, event) { + (CodexHookCommand::MutationScope, "PreToolUse" | "PostToolUse") => { + assert_eq!( + matcher, + Some("^(Bash|apply_patch)$"), + "{event} mutation-scope hook must gate on exactly the tracked tools" + ); + } + (CodexHookCommand::MutationScope, _) => assert_eq!( + matcher, None, + "{event} mutation-scope lifecycle hook stays unmatched" + ), + (CodexHookCommand::Codex, _) => {} + } + } + } + + #[test] + fn a_legacy_unmatched_mutation_scope_tool_hook_is_stale_and_repairs_to_the_tracked_matcher() { + let existing = json!({ + "hooks": { + "PreToolUse": [{"hooks": [mutation_scope_pre_tool_use_handler()]}], + "PostToolUse": [{"hooks": [mutation_scope_handler()]}] + } + }); + let existing_bytes = serde_json::to_vec(&existing).unwrap(); + + let HooksDocumentDiagnosis::Registrations(diagnoses) = + diagnose_document(Some(&existing_bytes), &generated()).unwrap() + else { + panic!("expected per-registration diagnoses"); + }; + for event in ["PreToolUse", "PostToolUse"] { + assert_eq!( + registration_of(&diagnoses, CodexHookCommand::MutationScope, event).state, + RegistrationStructuralState::Stale, + "an unmatched {event} mutation-scope hook is stale under the tracked-tool matcher" + ); + } + + let merged = + merge_or_create(Some(&existing_bytes), &generated(), ".codex/hooks.json").unwrap(); + let value: Value = serde_json::from_slice(&merged).unwrap(); + for event in ["PreToolUse", "PostToolUse"] { + let groups = value["hooks"][event].as_array().unwrap(); + let scoped: Vec<&Value> = groups + .iter() + .filter(|group| { + group["hooks"] + .as_array() + .into_iter() + .flatten() + .any(|handler| { + handler["command"] + .as_str() + .and_then(command_owning_contract) + == Some(CodexHookCommand::MutationScope) + }) + }) + .collect(); + assert_eq!( + scoped.len(), + 1, + "{event} keeps exactly one mutation-scope group" + ); + assert_eq!(scoped[0]["matcher"], CODEX_MUTATION_SCOPE_TOOL_MATCHER); + } + + let HooksDocumentDiagnosis::Registrations(after) = + diagnose_document(Some(&merged), &generated()).unwrap() + else { + panic!("expected per-registration diagnoses"); + }; + for event in ["PreToolUse", "PostToolUse"] { + assert_eq!( + registration_of(&after, CodexHookCommand::MutationScope, event).state, + RegistrationStructuralState::PresentAndCurrent + ); + } + assert_eq!( + merge_or_create(Some(&merged), &generated(), ".codex/hooks.json").unwrap(), + merged + ); + } + #[test] fn interrupt_event_key_label_matches_upstream() { assert_eq!(hook_event_key_label("Interrupt"), "interrupt"); diff --git a/config/pkl/renderers/codex-content.pkl b/config/pkl/renderers/codex-content.pkl index b3341175..cb4a1498 100644 --- a/config/pkl/renderers/codex-content.pkl +++ b/config/pkl/renderers/codex-content.pkl @@ -59,6 +59,12 @@ local codexSceHookCommand = "root=\\\"$(git rev-parse --show-toplevel 2>/dev/nul local codexMutationScopeHookCommand = "root=\\\"$(git rev-parse --show-toplevel 2>/dev/null)\\\" || exit 0; exec bash \\\"$root/\(codexSceHookScriptPath)\\\" sce hooks codex-mutation-scope" +local codexPreToolUseFailClosedDenyJson = "{\\\"hookSpecificOutput\\\":{\\\"hookEventName\\\":\\\"PreToolUse\\\",\\\"permissionDecision\\\":\\\"deny\\\",\\\"permissionDecisionReason\\\":\\\"SCE could not establish mutation attribution for this tool execution.\\\"}}" + +local codexMutationScopePreToolUseCommand = "sce_deny(){ printf '%s' '\(codexPreToolUseFailClosedDenyJson)'; exit 0; }; root=\\\"$(git rev-parse --show-toplevel 2>/dev/null)\\\" || sce_deny; test -r \\\"$root/\(codexSceHookScriptPath)\\\" || sce_deny; SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED=1 exec bash \\\"$root/\(codexSceHookScriptPath)\\\" sce hooks codex-mutation-scope" + +local codexMutationScopeToolMatcher = "^(Bash|apply_patch)$" + hooksJson = new common.RenderedTextFile { slug = "hooks" rendered = """ @@ -103,10 +109,11 @@ hooksJson = new common.RenderedTextFile { ] }, { + "matcher": "\(codexMutationScopeToolMatcher)", "hooks": [ { "type": "command", - "command": "\(codexMutationScopeHookCommand)" + "command": "\(codexMutationScopePreToolUseCommand)" } ] } @@ -122,6 +129,7 @@ hooksJson = new common.RenderedTextFile { ] }, { + "matcher": "\(codexMutationScopeToolMatcher)", "hooks": [ { "type": "command", @@ -171,6 +179,25 @@ sceHookScript = new common.RenderedTextFile { #!/usr/bin/env bash set -euo pipefail +sce_pre_tool_use_deny() { + printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"SCE could not establish mutation attribution for this tool execution."}}' +} + +if [ "${SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED:-0}" = "1" ]; then + if ! command -v sce >/dev/null 2>&1; then + echo "\(missingSceInstallMessage)" >&2 + sce_pre_tool_use_deny + exit 0 + fi + if ! adapter_output="$("$@")"; then + echo "SCE mutation-scope adapter failed; denying the tracked tool to preserve fail-closed PreToolUse." >&2 + sce_pre_tool_use_deny + exit 0 + fi + printf '%s' "$adapter_output" + exit 0 +fi + if ! command -v sce >/dev/null 2>&1; then echo "\(missingSceInstallMessage)" >&2 exit 0 diff --git a/config/pkl/renderers/generation-contract-check.pkl b/config/pkl/renderers/generation-contract-check.pkl index c8c0c7b7..61469d27 100644 --- a/config/pkl/renderers/generation-contract-check.pkl +++ b/config/pkl/renderers/generation-contract-check.pkl @@ -70,8 +70,14 @@ local assertCodexHookInvocationContract = (artifacts: Mapping) -> && text.split("\"type\": \"command\"").length == 11 && text.split("\"matcher\": \"Bash\"").length == 2 && text.split("\"matcher\": \"apply_patch\"").length == 2 + && text.split("\"matcher\": \"^(Bash|apply_patch)$\"").length == 3 && text.split("sce hooks codex\"").length == 5 && text.split("sce hooks codex-mutation-scope\"").length == 7 + && text.split("SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED=1").length == 2 + && text.contains("\\\"permissionDecision\\\":\\\"deny\\\"") + && text.contains("\\\"permissionDecisionReason\\\":\\\"SCE could not establish mutation attribution for this tool execution.\\\"") + && !text.contains("mcp__") + && !text.contains("collaborationspawn_agent") && !text.contains("\"$schema\"") && text.contains("git rev-parse --show-toplevel") && text.contains("2>/dev/null") @@ -79,8 +85,19 @@ local assertCodexHookInvocationContract = (artifacts: Mapping) -> && text.contains("exec bash") && text.contains("$root/.codex/hooks/run-sce-or-show-install-guidance.sh") && !text.contains("eval") - ) "Codex hook invocation: four sce-hooks-codex plus six mutation-scope registrations use safe repository-root resolution" - else throw("Codex hook invocation must resolve the Git root safely and preserve the four sce-hooks-codex plus six mutation-scope registrations") + ) "Codex hook invocation: four sce-hooks-codex plus six mutation-scope registrations use safe repository-root resolution, with a tracked-tool matcher and fail-closed PreToolUse bootstrap" + else throw("Codex hook invocation must resolve the Git root safely, gate mutation-scope tool hooks on the ^(Bash|apply_patch)$ matcher, fail closed on the tracked PreToolUse bootstrap, and preserve the four sce-hooks-codex plus six mutation-scope registrations") + +local assertCodexHelperFailClosedMode = (artifacts: Mapping) -> + let (text = artifacts["config/.codex/hooks/run-sce-or-show-install-guidance.sh"]) + if ( + text.contains("SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED") + && text.contains("sce_pre_tool_use_deny") + && text.contains("\"permissionDecision\":\"deny\"") + && text.contains("exec \"$@\"") + && text.split("command -v sce").length == 3 + ) "Codex hook helper: fail-closed PreToolUse mode plus the retained fail-open exec path" + else throw("Codex hook helper must carry a fail-closed PreToolUse mode alongside the retained fail-open exec path") /// Current upstream Codex skill loading provides no Claude/OpenCode-style /// `$ARGUMENTS` substitution, so a literal, unsubstituted token in generated @@ -1248,6 +1265,7 @@ contractChecks { ["compact-execution-contract-negative-fixtures"] = assertCompactExecutionContractFixtures ["artifact-paths"] = assertExactArtifactPaths.apply(generatedArtifacts) ["codex-hook-invocation"] = assertCodexHookInvocationContract.apply(generatedArtifacts) + ["codex-hook-helper-fail-closed"] = assertCodexHelperFailClosedMode.apply(generatedArtifacts) ["codex-skills-exclude-arguments"] = assertCodexSkillsExcludeArguments.apply(generatedArtifacts) ["codex-skill-invocation-examples"] = assertCodexSkillInvocationExamples.apply(generatedArtifacts) ["codex-skill-metadata"] = assertCodexSkillMetadataContract.apply(generatedArtifacts) diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 8a3fef0f..9827d9b6 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -503,6 +503,49 @@ used. Evidence: `fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.*`, `fixtures/probe15-mcp-blocked-call.*`. +### D8a — The generated bootstrap fail-closed boundary sits ahead of the Rust adapter (PR #268, 2026-09-08) + +D8's "failure to establish a durable Start denies the tool" is a property of the +**T04 Rust adapter**. The generated `.codex/hooks.json` bootstrap can fail +*before* the adapter runs — Git-root resolution, helper execution, or `sce` +availability — and the original T05 registration used the fail-open +conversation/diff bootstrap there, so a tracked `Bash` / `apply_patch` +`PreToolUse` could `exit 0` neutrally with no scope. That is a D8 violation at +the bootstrap layer. + +The fix has two parts, neither of which changes the adapter or Option B: + +1. **Matcher narrowing.** The generated mutation-scope `PreToolUse` and + `PostToolUse` hooks carry `matcher = "^(Bash|apply_patch)$"` (codex-cli + 0.153.4 `hooks/src/events/common.rs` `matches_matcher`: regex-metacharacter + matchers compile with the `regex` crate and match with `is_match`, so this is + an anchored full-tool-name alternation). Codex therefore never dispatches the + generated mutation-scope tool hooks for `mcp__*`, + `collaborationspawn_agent`, `collaborationwait_agent`, or unknown tools — + they stay allowed / untracked under D23. This establishes the invariant: *if + the fail-closed `PreToolUse` bootstrap executes, the tool is a + TrackedMutation.* Narrowing (not a blanket bootstrap deny) is required — + a blanket deny would reject `PreToolUse(mcp__…)` and break Option B. + +2. **A fail-closed bootstrap mode.** The mutation-scope `PreToolUse` hook is a + separate generated command that emits the exact D8 deny contract (the same + `FAIL_CLOSED_DENY_REASON` string T04 uses) on stdout with `exit 0` when + Git-root resolution or the helper check fails, and sets + `SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED=1` so the shared helper converts a + missing `sce` or any non-zero adapter exit into that same deny — while + forwarding a successful adapter stdout (neutral, or a recovery-barrier deny) + unchanged. The blocking semantic comes from the Codex `PreToolUse` decision, + not from a non-zero process exit. The other five mutation-scope commands and + all four `sce hooks codex` commands keep the fail-open bootstrap — they are + observational / cleanup boundaries, not the point a mutation-capable tool + begins. + +Resulting invariant for `Bash` / `apply_patch` `PreToolUse`: either the +bootstrap and adapter both succeed and a durable `Start` exists, or any +bootstrap/adapter failure yields the exact Codex-native deny and the tool does +not execute. There is no path where a tracked mutation tool runs while the +mutation-scope `PreToolUse` never established `Start`. + ### D9 — Terminal boundary on success — T01-GATED Terminal boundary rules apply **only to `TrackedMutation` tools** (`Bash`, @@ -1196,6 +1239,19 @@ event/matcher structure (T01/T05 must say which, if any), the plan records it an invalidated, and `sce doctor --fix` never writes, grants, or changes Codex trust or managed policy. +**D20a — the PR #268 matcher change is inside the appended mutation-scope +groups only (2026-09-08).** Adding `matcher = "^(Bash|apply_patch)$"` to the +mutation-scope `PreToolUse` / `PostToolUse` groups changes those groups' Codex +trust keys, but they are SCE-owned groups appended *after* the four +`sce hooks codex` groups, so no `sce hooks codex` trust key moves — the four +existing trusted registrations keep `(event, matcher, group index, handler +index, handler contents/hash)` exactly. A mutation-scope group installed by the +first T05 shape (unmatched) now diagnoses `Stale` and is migrated in place by +`merge_or_create` (strip the owned handler from every group for the event, +append one canonical `^(Bash|apply_patch)$` group), never by renumbering a +`sce hooks codex` group. The canonical-four upgrade regression asserts this +post-change. + ### D21 — Doctor: three-dimension health for mutation-scope registrations A structurally-current mutation-scope hook is useless if Codex never loads it — @@ -2784,6 +2840,70 @@ Persist this field in every plan; this is durable plan state, not chat state: The plan reserves the "materially changes the accepted non-destructive-ownership contract → new dated ADR" call for T07's full re-evaluation once the adapter ships. + - Follow-up (2026-09-08 — bootstrap fail-closed for tracked mutation + `PreToolUse`, PR #268): the initial T05 registration reused the fail-open + conversation/diff bootstrap for the mutation-scope `PreToolUse` hook and + registered every mutation-scope group unmatched. That let a tracked + `Bash` / `apply_patch` execution proceed unscoped when repository-root + resolution, helper execution, or `sce` availability failed before the Rust + adapter ran (D8 violation), because the neutral `exit 0` bootstrap returned + before any durable `Start`. + The mutation-scope `PreToolUse` and `PostToolUse` registrations are now + constrained at the Codex matcher boundary to the tracked tool set — + `matcher = "^(Bash|apply_patch)$"`, verified against codex-cli 0.153.4 + `hooks/src/events/common.rs` `matches_matcher` (a matcher carrying regex + metacharacters is compiled with the `regex` crate and tested with + `is_match`, so this is an anchored full-tool-name alternation). `Stop`, + `Interrupt`, `SubagentStop`, and `SessionEnd` stay unmatched. `mcp__*`, + `collaborationspawn_agent`, `collaborationwait_agent`, and unknown tools do + not match, so Codex never dispatches the generated mutation-scope tool hooks + for them and they remain allowed / untracked under D23 (Option B). The Rust + classifier is unchanged and stays defensive: a manual invocation of the + hidden command with an untracked payload still returns neutral. + The mutation-scope `PreToolUse` command is now a separate generated command + (`codexMutationScopePreToolUseCommand`) that fails **closed**: a Git-root + resolution failure or a missing/unreadable helper emits the exact D8 deny + contract (`{"hookSpecificOutput":{"hookEventName":"PreToolUse", + "permissionDecision":"deny","permissionDecisionReason":"SCE could not + establish mutation attribution for this tool execution."}}` — the same + `FAIL_CLOSED_DENY_REASON` string T04 emits) on stdout with `exit 0`, and it + sets `SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED=1` so the shared helper converts a + missing `sce` or any non-zero adapter exit into the same deny contract while + forwarding a successful adapter stdout (neutral or a recovery-barrier deny) + unchanged. `command_owning_contract` recognises the leading env-assignment + form as `CodexHookCommand::MutationScope` without widening ownership. The + other five mutation-scope commands and the four `sce hooks codex` commands + are byte-identical to before, so the existing four trusted registrations + keep their `(event, matcher, group index, handler index, handler + contents/hash)` identity (D20); a previously installed unmatched + mutation-scope `PreToolUse` / `PostToolUse` registration now diagnoses + `Stale` and `sce setup --codex` / `sce doctor --fix` migrates it into the + tracked-tool matcher group. Doctor's per-registration model is unchanged — + command identity already disambiguates the `#(mutation-scope)` rows, + so the regex is not surfaced. + Files changed: `config/pkl/renderers/codex-content.pkl` (split + `codexMutationScopePreToolUseCommand` out, matcher on the Pre/Post groups, + fail-closed helper mode in `sceHookScript`), + `config/pkl/renderers/generation-contract-check.pkl` (matcher + fail-closed + bootstrap + helper assertions, `codex-hook-helper-fail-closed` check), + `cli/src/services/codex_hook_config.rs` (`CODEX_MUTATION_SCOPE_TOOL_MATCHER` + on the two `REQUIRED_EVENTS` rows, env-assignment-tolerant + `command_owning_contract`, matcher/fail-closed test coverage incl. the + canonical-four upgrade regression and the legacy-unmatched-hook repair + test), `scripts/test-codex-hook-command.sh` (the matcher shape and the six + fail-closed / forward regressions replace the former fail-open contract), + `context/plans/codex-mutation-scope-integration.md` (this note plus the D8a + subsection). No mutation protocol, Quint model, mutation runtime, SQL, Agent + Trace schema, Option B MCP semantics, T04 driver behaviour, or attribution + semantics changed. + Verify: `nix run .#pkl-check-generated` — **passed**; `nix develop -c + ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml + services::codex_hook_config` — **passed** (36); `... services::setup::` — + **passed** (66); `... services::doctor::` — **passed** (27); `... + services::hooks::codex_mutation_scope` — **passed** (100, 1 ignored); `... + services::hooks::codex::` — **passed** (129); `nix develop -c bash + ./scripts/test-codex-hook-command.sh` — **passed**; `clippy ... --all-targets + -- -D warnings` — **clean**; `cargo fmt -- --check` — **clean**. - [ ] T06: `Real Git/DB regressions through the production Codex path` (status:todo) - Task ID: T06 diff --git a/scripts/test-codex-hook-command.sh b/scripts/test-codex-hook-command.sh index 2408ef15..8e348682 100755 --- a/scripts/test-codex-hook-command.sh +++ b/scripts/test-codex-hook-command.sh @@ -26,11 +26,13 @@ expected_events='["Interrupt","PostToolUse","PreToolUse","SessionEnd","Stop","Su actual_events="$(jq -c '.hooks | keys | sort' "${hooks_json}")" [ "${actual_events}" = "${expected_events}" ] || fail "unexpected Codex hook event registrations: ${actual_events}" -jq -e ' +tracked_matcher='^(Bash|apply_patch)$' + +jq -e --arg m "${tracked_matcher}" ' ((.hooks.UserPromptSubmit | length == 1) and (.hooks.UserPromptSubmit[0].hooks | length == 1) and (.hooks.UserPromptSubmit[0] | has("matcher") | not)) and ((.hooks.Stop | length == 2) and (.hooks.Stop[0].hooks | length == 1) and (.hooks.Stop[1].hooks | length == 1) and (.hooks.Stop[1] | has("matcher") | not)) - and ((.hooks.PreToolUse | length == 2) and (.hooks.PreToolUse[0].matcher == "Bash") and (.hooks.PreToolUse[0].hooks | length == 1) and (.hooks.PreToolUse[1] | has("matcher") | not) and (.hooks.PreToolUse[1].hooks | length == 1)) - and ((.hooks.PostToolUse | length == 2) and (.hooks.PostToolUse[0].matcher == "apply_patch") and (.hooks.PostToolUse[0].hooks | length == 1) and (.hooks.PostToolUse[1] | has("matcher") | not) and (.hooks.PostToolUse[1].hooks | length == 1)) + and ((.hooks.PreToolUse | length == 2) and (.hooks.PreToolUse[0].matcher == "Bash") and (.hooks.PreToolUse[0].hooks | length == 1) and (.hooks.PreToolUse[1].matcher == $m) and (.hooks.PreToolUse[1].hooks | length == 1)) + and ((.hooks.PostToolUse | length == 2) and (.hooks.PostToolUse[0].matcher == "apply_patch") and (.hooks.PostToolUse[0].hooks | length == 1) and (.hooks.PostToolUse[1].matcher == $m) and (.hooks.PostToolUse[1].hooks | length == 1)) and ((.hooks.Interrupt | length == 1) and (.hooks.Interrupt[0] | has("matcher") | not)) and ((.hooks.SubagentStop | length == 1) and (.hooks.SubagentStop[0] | has("matcher") | not)) and ((.hooks.SessionEnd | length == 1) and (.hooks.SessionEnd[0] | has("matcher") | not)) @@ -50,8 +52,8 @@ case "${hook_command}" in *eval*) fail "Codex hook command uses eval" ;; esac -mutation_scope_command="$(jq -r '.hooks.PreToolUse[1].hooks[0].command' "${hooks_json}")" -for path in '.hooks.PreToolUse[1]' '.hooks.PostToolUse[1]' '.hooks.Stop[1]' '.hooks.Interrupt[0]' '.hooks.SubagentStop[0]' '.hooks.SessionEnd[0]'; do +mutation_scope_command="$(jq -r '.hooks.PostToolUse[1].hooks[0].command' "${hooks_json}")" +for path in '.hooks.PostToolUse[1]' '.hooks.Stop[1]' '.hooks.Interrupt[0]' '.hooks.SubagentStop[0]' '.hooks.SessionEnd[0]'; do group_command="$(jq -r "${path}.hooks[0].command" "${hooks_json}")" [ "${group_command}" = "${mutation_scope_command}" ] || fail "${path} does not route to the shared mutation-scope command" done @@ -60,6 +62,20 @@ case "${mutation_scope_command}" in *) fail "Codex mutation-scope hook command is not root-aware and fail-open: ${mutation_scope_command}" ;; esac +pre_tool_use_command="$(jq -r '.hooks.PreToolUse[1].hooks[0].command' "${hooks_json}")" +[ "${pre_tool_use_command}" != "${mutation_scope_command}" ] || fail "mutation-scope PreToolUse must use the dedicated fail-closed bootstrap" +case "${pre_tool_use_command}" in + *'SCE_CODEX_PRE_TOOL_USE_FAIL_CLOSED=1 exec bash '*'$root/.codex/hooks/run-sce-or-show-install-guidance.sh'*' sce hooks codex-mutation-scope') ;; + *) fail "mutation-scope PreToolUse bootstrap is not the fail-closed helper form: ${pre_tool_use_command}" ;; +esac +case "${pre_tool_use_command}" in + *'"permissionDecision":"deny"'*) ;; + *) fail "mutation-scope PreToolUse bootstrap does not carry the D8 deny contract" ;; +esac +case "${pre_tool_use_command}" in + *eval*) fail "mutation-scope PreToolUse bootstrap uses eval" ;; +esac + repo="${tmp_root}/repo with spaces" mkdir -p "${repo}/a/b/c" git init -q "${repo}" @@ -120,4 +136,119 @@ printf '%s' "${sentinel}" | [ ! -s "${tmp_root}/missing-sce-output" ] || fail "missing-sce path emitted stdout" grep -F 'sce CLI not found.' "${tmp_root}/missing-sce-error" >/dev/null || fail "missing-sce guidance was not emitted on stderr" +deny_json='{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"SCE could not establish mutation attribution for this tool execution."}}' +tracked_stdin='{"hook_event_name":"PreToolUse","tool_name":"Bash","session_id":"s","tool_use_id":"exec-1"}' + +assert_deny() { + local label="$1" + local out_path="$2" + local exit_code="$3" + [ "${exit_code}" = "0" ] || fail "${label}: expected exit 0, got ${exit_code}" + [ "$(cat "${out_path}")" = "${deny_json}" ] || fail "${label}: stdout is not the stable D8 deny JSON: $(cat "${out_path}")" + jq -e '.hookSpecificOutput.hookEventName == "PreToolUse" and .hookSpecificOutput.permissionDecision == "deny" and (.hookSpecificOutput.permissionDecisionReason | length > 0)' \ + "${out_path}" >/dev/null || fail "${label}: deny JSON does not parse as the PreToolUse deny contract" +} + +set +e +printf '%s' "${tracked_stdin}" | + ( + cd "${repo}" + PATH="${minimal_path}" bash -c "${pre_tool_use_command}" + ) > "${tmp_root}/pre-missing-sce-out" 2> "${tmp_root}/pre-missing-sce-err" +pre_missing_sce_exit=$? +set -e +assert_deny "sce missing" "${tmp_root}/pre-missing-sce-out" "${pre_missing_sce_exit}" +grep -F 'sce CLI not found.' "${tmp_root}/pre-missing-sce-err" >/dev/null || fail "sce missing: install guidance was not emitted on stderr" + +set +e +printf '%s' "${tracked_stdin}" | + ( + cd "${outside}" + PATH="${fake_bin}:${PATH}" bash -c "${pre_tool_use_command}" + ) > "${tmp_root}/pre-no-git-out" 2>/dev/null +pre_no_git_exit=$? +set -e +assert_deny "git root failure" "${tmp_root}/pre-no-git-out" "${pre_no_git_exit}" + +helperless_repo="${tmp_root}/helperless" +mkdir -p "${helperless_repo}" +git init -q "${helperless_repo}" +set +e +printf '%s' "${tracked_stdin}" | + ( + cd "${helperless_repo}" + PATH="${fake_bin}:${PATH}" bash -c "${pre_tool_use_command}" + ) > "${tmp_root}/pre-no-helper-out" 2>/dev/null +pre_no_helper_exit=$? +set -e +assert_deny "helper missing" "${tmp_root}/pre-no-helper-out" "${pre_no_helper_exit}" + +mutation_bin="${tmp_root}/mutation-bin" +mkdir -p "${mutation_bin}" +{ + printf '#!%s\n' "$(command -v bash)" + cat <<'EOF' +set -euo pipefail +[ "$1" = hooks ] && [ "$2" = codex-mutation-scope ] || exit 2 +cat >/dev/null +exit 9 +EOF +} > "${mutation_bin}/sce" +chmod +x "${mutation_bin}/sce" +set +e +printf '%s' "${tracked_stdin}" | + ( + cd "${repo}" + PATH="${mutation_bin}:${minimal_path}" bash -c "${pre_tool_use_command}" + ) > "${tmp_root}/pre-adapter-fail-out" 2>/dev/null +pre_adapter_fail_exit=$? +set -e +assert_deny "adapter non-zero exit" "${tmp_root}/pre-adapter-fail-out" "${pre_adapter_fail_exit}" + +neutral_bin="${tmp_root}/neutral-bin" +mkdir -p "${neutral_bin}" +seen_stdin="${tmp_root}/adapter-stdin-seen" +{ + printf '#!%s\n' "$(command -v bash)" + printf 'set -euo pipefail\n' + printf '[ "$1" = hooks ] && [ "$2" = codex-mutation-scope ] || exit 2\n' + printf 'cat > %q\n' "${seen_stdin}" + printf 'exit 0\n' +} > "${neutral_bin}/sce" +chmod +x "${neutral_bin}/sce" +set +e +printf '%s' "${tracked_stdin}" | + ( + cd "${repo}" + PATH="${neutral_bin}:${minimal_path}" bash -c "${pre_tool_use_command}" + ) > "${tmp_root}/pre-neutral-out" 2>/dev/null +pre_neutral_exit=$? +set -e +[ "${pre_neutral_exit}" = "0" ] || fail "neutral adapter: expected exit 0, got ${pre_neutral_exit}" +[ ! -s "${tmp_root}/pre-neutral-out" ] || fail "neutral adapter: a neutral response must be forwarded as empty stdout" +[ "$(cat "${seen_stdin}")" = "${tracked_stdin}" ] || fail "neutral adapter: stdin was not forwarded byte-for-byte" + +forward_bin="${tmp_root}/forward-bin" +mkdir -p "${forward_bin}" +recovery_json='{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"recovery barrier"}}' +{ + printf '#!%s\n' "$(command -v bash)" + printf 'set -euo pipefail\n' + printf '[ "$1" = hooks ] && [ "$2" = codex-mutation-scope ] || exit 2\n' + printf 'cat >/dev/null\n' + printf 'printf %%s %q\n' "${recovery_json}" + printf 'exit 0\n' +} > "${forward_bin}/sce" +chmod +x "${forward_bin}/sce" +set +e +printf '%s' "${tracked_stdin}" | + ( + cd "${repo}" + PATH="${forward_bin}:${minimal_path}" bash -c "${pre_tool_use_command}" + ) > "${tmp_root}/pre-forward-out" 2>/dev/null +pre_forward_exit=$? +set -e +[ "${pre_forward_exit}" = "0" ] || fail "adapter forward: expected exit 0, got ${pre_forward_exit}" +[ "$(cat "${tmp_root}/pre-forward-out")" = "${recovery_json}" ] || fail "adapter forward: a successful adapter response was not forwarded unchanged" + printf 'Codex hook command tests passed.\n' From 7260f09f2016cca860b13aa74c7379c02959fc41 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 15:40:54 +0200 Subject: [PATCH 12/20] hooks: Preflight Codex Bash policy before mutation scopes Codex runs matching PreToolUse handlers concurrently, so the mutation-scope adapter could persist `Start` before SCE's Bash-policy hook denied execution. Share policy evaluation and command extraction with the Codex Bash handler, and preflight Bash policy before Git resolution, locking, or mutation-scope state. Return the native policy denial, while failing closed on malformed input or evaluation errors. Keep `apply_patch` unchanged and document the remaining race with arbitrary user-owned blocking hooks. Add parity and lifecycle regressions for blocked, allowed, malformed, and failed-policy paths. Plan: context/plans/codex-mutation-scope-integration.md (T04 follow-up) --- cli/src/services/hooks/codex/bash_policy.rs | 144 ++++--- cli/src/services/hooks/codex/mod.rs | 2 +- .../hooks/codex_mutation_scope/mod.rs | 350 +++++++++++++++++- .../plans/codex-mutation-scope-integration.md | 89 ++++- 4 files changed, 538 insertions(+), 47 deletions(-) diff --git a/cli/src/services/hooks/codex/bash_policy.rs b/cli/src/services/hooks/codex/bash_policy.rs index e282f924..f668fd21 100644 --- a/cli/src/services/hooks/codex/bash_policy.rs +++ b/cli/src/services/hooks/codex/bash_policy.rs @@ -7,62 +7,53 @@ use crate::services::bash_policy::{ evaluate_bash_command_policy, format_policy_block_message, PolicyEvaluation, }; use crate::services::config; +#[cfg(test)] use crate::services::config::policy::BashPolicyConfig; use super::CodexHookEvent; -/// Routes a Codex `PreToolUse(Bash)` event through the existing SCE Bash -/// policy engine (`evaluate_bash_command_policy` in -/// `cli/src/services/bash_policy.rs`) unchanged — no reimplemented matching. -/// -/// An allowed command produces silent hook success (empty stdout, no -/// model-visible output). A blocked command produces Codex's own native -/// `PreToolUse` deny response: `{"hookSpecificOutput": {"hookEventName": -/// "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": -/// ...}}`, confirmed against Codex's real hook contract (`openai/codex` -/// issue #28437) — identical in shape to `render_claude_hook_result` in -/// `bash_policy.rs`. Neither branch reads or writes `diff_traces`, a -/// snapshot, or any pending-state file; `apply_patch` handling is a -/// different dispatch arm (T10/T11). -pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { - let command = bash_command_from_event(event)?; +pub(crate) enum CodexBashPolicyDecision { + Allowed, + Blocked(String), +} +pub(crate) fn evaluate_codex_bash_policy( + repository_root: &Path, + command: &str, +) -> Result { let policy_config = config::resolve_bash_policy_runtime_config(repository_root) .context("Failed to resolve bash policy configuration for Codex PreToolUse Bash.")?; - render_bash_policy_response(command, policy_config.as_ref()) + decision_from_evaluation(evaluate_bash_command_policy( + command, + policy_config.as_ref(), + )) } -fn render_bash_policy_response( - command: &str, - policy_config: Option<&BashPolicyConfig>, -) -> Result { - match evaluate_bash_command_policy(command, policy_config) { - PolicyEvaluation::Allowed { .. } => Ok(String::new()), - PolicyEvaluation::Blocked { policy, .. } => serde_json::to_string(&json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": format_policy_block_message(&policy) - } - })) - .context("Failed to serialize Codex PreToolUse Bash deny response."), +fn decision_from_evaluation(evaluation: PolicyEvaluation) -> Result { + match evaluation { + PolicyEvaluation::Allowed { .. } => Ok(CodexBashPolicyDecision::Allowed), + PolicyEvaluation::Blocked { policy, .. } => Ok(CodexBashPolicyDecision::Blocked( + codex_bash_policy_deny_response(&format_policy_block_message(&policy))?, + )), } } -/// Codex's `PreToolUse` `tool_input` for the `Bash` tool carries the shell -/// command string under `command`, mirroring Claude's own `Bash` `tool_input` -/// shape (`ClaudeBashToolInput` in `bash_policy.rs`). This is a working -/// assumption pending direct confirmation against a live Codex CLI payload -/// (see plan `context/plans/codex-cli-integration.md` Assumptions and T06's -/// precedent for adjusting only field extraction, not architecture, if -/// reality differs). -fn bash_command_from_event(event: &CodexHookEvent) -> Result<&str> { - event - .tool_input - .as_ref() +fn codex_bash_policy_deny_response(reason: &str) -> Result { + serde_json::to_string(&json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason + } + })) + .context("Failed to serialize Codex PreToolUse Bash deny response.") +} + +pub(crate) fn bash_command_from_tool_input(tool_input: Option<&serde_json::Value>) -> Result<&str> { + tool_input .and_then(|value| value.get("command")) - .and_then(|value| value.as_str()) + .and_then(serde_json::Value::as_str) .filter(|command| !command.trim().is_empty()) .ok_or_else(|| { anyhow::anyhow!( @@ -71,6 +62,34 @@ fn bash_command_from_event(event: &CodexHookEvent) -> Result<&str> { }) } +pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { + let command = bash_command_from_event(event)?; + + Ok( + match evaluate_codex_bash_policy(repository_root, command)? { + CodexBashPolicyDecision::Allowed => String::new(), + CodexBashPolicyDecision::Blocked(response) => response, + }, + ) +} + +#[cfg(test)] +fn render_bash_policy_response( + command: &str, + policy_config: Option<&BashPolicyConfig>, +) -> Result { + Ok( + match decision_from_evaluation(evaluate_bash_command_policy(command, policy_config))? { + CodexBashPolicyDecision::Allowed => String::new(), + CodexBashPolicyDecision::Blocked(response) => response, + }, + ) +} + +fn bash_command_from_event(event: &CodexHookEvent) -> Result<&str> { + bash_command_from_tool_input(event.tool_input.as_ref()) +} + #[cfg(test)] mod tests { use std::{ @@ -135,6 +154,47 @@ mod tests { assert!(bash_command_from_event(&event).is_err()); } + #[test] + fn shared_policy_evaluation_is_identical_for_the_handler_and_the_mutation_scope_preflight() { + let repo = unique_temp_dir("shared-policy-parity"); + std::fs::create_dir_all(repo.join(".sce")).expect("create .sce dir"); + std::fs::write( + repo.join(".sce").join("config.json"), + concat!( + r#"{"policies":{"bash":{"custom":[{"id":"no-rm","#, + r#""match":{"argv_prefix":["rm"]},"#, + r#""message":"rm is blocked in this repository"}]}}}"#, + ), + ) + .expect("write repo bash policy config"); + + let blocked_event = event_with_tool_input(Some(json!({"command": "rm -rf build"}))); + let handler_output = handle(&repo, &blocked_event).expect("handler evaluates policy"); + match evaluate_codex_bash_policy(&repo, "rm -rf build").expect("shared evaluator") { + CodexBashPolicyDecision::Blocked(response) => assert_eq!( + handler_output, response, + "the handler's rendered deny must match the shared evaluator's Blocked response", + ), + CodexBashPolicyDecision::Allowed => panic!("expected a policy block for `rm`"), + } + + let allowed_event = event_with_tool_input(Some(json!({"command": "echo ok > ok.txt"}))); + assert_eq!( + handle(&repo, &allowed_event).expect("handler evaluates policy"), + "", + "an allowed command is silent through the handler", + ); + assert!( + matches!( + evaluate_codex_bash_policy(&repo, "echo ok > ok.txt").expect("shared evaluator"), + CodexBashPolicyDecision::Allowed + ), + "the shared evaluator agrees the command is allowed", + ); + + std::fs::remove_dir_all(&repo).ok(); + } + #[test] fn render_bash_policy_response_is_silent_for_an_allowed_command() { let output = render_bash_policy_response("echo generated > generated.txt", None) diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index 9b36fab8..7947de19 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -9,7 +9,7 @@ use crate::services::observability::traits::Logger; use super::read_hook_stdin; mod apply_patch; -mod bash_policy; +pub(crate) mod bash_policy; mod stop; mod user_prompt_submit; diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index 7ab21de0..ef0b3c41 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -10,6 +10,9 @@ use anyhow::{anyhow, bail, Context, Result}; use serde_json::{json, Map, Value}; use crate::services::checkout; +use crate::services::hooks::codex::bash_policy::{ + bash_command_from_tool_input, evaluate_codex_bash_policy, CodexBashPolicyDecision, +}; use crate::services::observability::traits::Logger; use boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; @@ -22,6 +25,9 @@ const AGENT_ID_FIELD: &str = "agent_id"; const AGENT_TYPE_FIELD: &str = "agent_type"; const TOOL_NAME_FIELD: &str = "tool_name"; const TOOL_USE_ID_FIELD: &str = "tool_use_id"; +const TOOL_INPUT_FIELD: &str = "tool_input"; + +const CODEX_TRACKED_TOOL_BASH: &str = "Bash"; const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; const HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; @@ -68,6 +74,7 @@ impl CodexToolIdentity { pub(crate) struct CodexToolExecution { pub identity: CodexToolIdentity, pub agent_type: Option, + pub tool_input: Option, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -191,6 +198,7 @@ fn parse_pre_tool_use(object: &Map) -> Result Ok(CodexToolExecution { identity: parse_tool_identity(object)?, agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, + tool_input: object.get(TOOL_INPUT_FIELD).cloned(), }) } @@ -273,6 +281,8 @@ type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; +type BashPolicyEvaluator<'a> = &'a dyn Fn(&Path, &str) -> Result; + const ACTOR_KIND_CODEX: &str = "codex"; const FAIL_CLOSED_DENY_REASON: &str = @@ -305,8 +315,17 @@ pub(crate) fn run_codex_mutation_scope_from_payload( let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { super::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) }; + let bash_policy_fn = |repository_root: &Path, command: &str| { + evaluate_codex_bash_policy(repository_root, command) + }; - run_codex_mutation_scope_from_payload_with(stdin_payload, logger, &resolve_git_dir_fn, &seam_fn) + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + &bash_policy_fn, + ) } #[cfg(test)] @@ -324,18 +343,62 @@ fn run_codex_mutation_scope_from_payload_at_state_root( logger, ) }; + let bash_policy_fn = |repository_root: &Path, command: &str| { + evaluate_codex_bash_policy(repository_root, command) + }; - run_codex_mutation_scope_from_payload_with(stdin_payload, logger, &resolve_git_dir_fn, &seam_fn) + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + &bash_policy_fn, + ) } +#[cfg(test)] fn run_codex_mutation_scope_from_payload_with( stdin_payload: &str, logger: Option<&dyn Logger>, resolve_git_dir: GitDirResolver, seam: IngressSeam, +) -> Result { + let allow_all = |_repository_root: &Path, _command: &str| Ok(CodexBashPolicyDecision::Allowed); + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + resolve_git_dir, + seam, + &allow_all, + ) +} + +#[cfg(test)] +fn run_codex_mutation_scope_from_payload_with_bash_policy( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, +) -> Result { + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + resolve_git_dir, + seam, + evaluate_bash_policy, + ) +} + +fn run_codex_mutation_scope_from_payload_with_seams( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, ) -> Result { let event = parse_codex_hook_event(stdin_payload)?; - dispatch_codex_hook_event(event, logger, resolve_git_dir, seam) + dispatch_codex_hook_event(event, logger, resolve_git_dir, seam, evaluate_bash_policy) } fn dispatch_codex_hook_event( @@ -343,6 +406,7 @@ fn dispatch_codex_hook_event( logger: Option<&dyn Logger>, resolve_git_dir: GitDirResolver, seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, ) -> Result { match event { CodexHookEvent::PreToolUse(execution) => Ok(handle_pre_tool_use( @@ -350,6 +414,7 @@ fn dispatch_codex_hook_event( logger, resolve_git_dir, seam, + evaluate_bash_policy, )), CodexHookEvent::PostToolUse(identity) => { if !matches!( @@ -427,6 +492,7 @@ fn handle_pre_tool_use( logger: Option<&dyn Logger>, resolve_git_dir: GitDirResolver, seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, ) -> String { let identity = &execution.identity; @@ -438,6 +504,18 @@ fn handle_pre_tool_use( } let repository_root = Path::new(&identity.cwd); + + if identity.tool_name == CODEX_TRACKED_TOOL_BASH { + match codex_bash_policy_preflight(repository_root, execution, evaluate_bash_policy) { + BashPolicyPreflight::Allowed => {} + BashPolicyPreflight::Blocked(response) => return response, + BashPolicyPreflight::EvaluationFailed(error) => { + log_pre_tool_use_fail_closed(logger, "bash_policy_preflight", &error); + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + } + } + let git_dir = match resolve_git_dir(&identity.cwd) { Ok(git_dir) => git_dir, Err(error) => { @@ -481,6 +559,29 @@ enum PreToolUseOutcome { Deny, } +enum BashPolicyPreflight { + Allowed, + Blocked(String), + EvaluationFailed(anyhow::Error), +} + +fn codex_bash_policy_preflight( + repository_root: &Path, + execution: &CodexToolExecution, + evaluate_bash_policy: BashPolicyEvaluator, +) -> BashPolicyPreflight { + let command = match bash_command_from_tool_input(execution.tool_input.as_ref()) { + Ok(command) => command, + Err(error) => return BashPolicyPreflight::EvaluationFailed(error), + }; + + match evaluate_bash_policy(repository_root, command) { + Ok(CodexBashPolicyDecision::Allowed) => BashPolicyPreflight::Allowed, + Ok(CodexBashPolicyDecision::Blocked(response)) => BashPolicyPreflight::Blocked(response), + Err(error) => BashPolicyPreflight::EvaluationFailed(error), + } +} + enum Admission { Admitted(state::AllocatedAttempt), Denied, @@ -732,6 +833,7 @@ mod tests { TOOL_USE_ID_FIELD.to_string(), Value::String("exec-1".to_string()), ); + object.insert(TOOL_INPUT_FIELD.to_string(), json!({"command": "true"})); for (field, value) in overrides { object.insert((*field).to_string(), value.clone()); } @@ -1150,6 +1252,34 @@ mod tests { panic!("the ingress seam must not be called for this payload: {payload}"); } + const BLOCKED_BASH_POLICY_RESPONSE: &str = concat!( + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","#, + r#""permissionDecision":"deny","#, + r#""permissionDecisionReason":"Blocked by SCE bash-tool policy 'no-danger': danger is not allowed"}}"#, + ); + + #[allow(clippy::unnecessary_wraps)] + fn blocking_bash_policy(_root: &Path, _command: &str) -> Result { + Ok(CodexBashPolicyDecision::Blocked( + BLOCKED_BASH_POLICY_RESPONSE.to_string(), + )) + } + + #[allow(clippy::unnecessary_wraps)] + fn allow_bash_policy(_root: &Path, _command: &str) -> Result { + Ok(CodexBashPolicyDecision::Allowed) + } + + fn failing_bash_policy(_root: &Path, _command: &str) -> Result { + Err(anyhow!( + "repository Bash policy configuration is invalid and could not be evaluated" + )) + } + + fn unreachable_bash_policy(_root: &Path, command: &str) -> Result { + panic!("the Bash policy preflight must not run for this event (command: {command})"); + } + fn seam_failing_on( operation: &'static str, ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { @@ -2764,5 +2894,219 @@ mod tests { remove_test_git_dir(&git_dir); } + + fn boundary_lock_exists(git_dir: &Path) -> bool { + crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path(git_dir) + .exists() + } + + #[test] + fn policy_blocked_bash_pre_tool_use_creates_no_mutation_scope_state() { + let git_dir = unique_test_git_dir("policy-blocked-no-scope"); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[(TOOL_INPUT_FIELD, json!({"command": "danger --now"}))]), + None, + &panicking_resolver, + &unreachable_seam, + &blocking_bash_policy, + ) + .expect("a policy-blocked Bash PreToolUse still returns Ok"); + + assert_eq!( + output, BLOCKED_BASH_POLICY_RESPONSE, + "a policy block returns the Codex-native policy denial verbatim, \ + never the generic mutation-scope deny", + ); + assert!(!output.contains(FAIL_CLOSED_DENY_REASON)); + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "a policy block must leave no adapter state: no PendingStart, Active, \ + Start, Abandon, Flush, or recovery", + ); + assert!( + !boundary_lock_exists(&git_dir), + "a policy block must not even acquire the boundary lock", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn policy_allowed_bash_pre_tool_use_follows_the_normal_write_ahead_start_path() { + let git_dir = unique_test_git_dir("policy-allowed-start"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[]), + None, + &resolver, + &recording_seam(Arc::clone(&recorded)), + &allow_bash_policy, + ) + .expect("an allowed Bash PreToolUse returns Ok"); + assert_eq!(output, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + ops.iter() + .filter(|payload| payload.contains(r#""operation":"start""#)) + .count(), + 1, + "an allowed Bash still drives exactly one write-ahead Start, got {ops:?}", + ); + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn policy_evaluation_failure_is_fail_closed_with_no_mutation_scope_state() { + let git_dir = unique_test_git_dir("policy-eval-failure"); + let logger = RecordingLogger::default(); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[]), + Some(&logger), + &panicking_resolver, + &unreachable_seam, + &failing_bash_policy, + ) + .expect("a Bash policy evaluation failure still returns Ok"); + + assert_eq!( + output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "a policy evaluation failure fails closed with the generic mutation-scope deny", + ); + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); + assert!(warnings[0].1.contains("could not be evaluated")); + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "a fail-closed policy evaluation must leave no adapter state", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn apply_patch_pre_tool_use_never_evaluates_bash_policy() { + let git_dir = unique_test_git_dir("apply-patch-no-policy"); + let resolver = fixed_resolver(git_dir.clone()); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("apply_patch".to_string())), + (TOOL_INPUT_FIELD, Value::Null), + ]), + None, + &resolver, + &ok_seam, + &unreachable_bash_policy, + ) + .expect("an apply_patch PreToolUse returns Ok"); + assert_eq!(output, ""); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1, "apply_patch still establishes a scope"); + assert_eq!(attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn malformed_bash_tool_input_is_fail_closed_before_the_policy_evaluator_runs() { + let git_dir = unique_test_git_dir("malformed-bash-tool-input"); + let logger = RecordingLogger::default(); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[(TOOL_INPUT_FIELD, json!({"not_command": "x"}))]), + Some(&logger), + &panicking_resolver, + &unreachable_seam, + &unreachable_bash_policy, + ) + .expect("a malformed Bash tool_input still returns Ok"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); + assert!( + warnings[0].1.contains("tool_input.command"), + "extraction reuses the shared bash_command_from_tool_input semantics", + ); + assert!(!state::adapter_state_dir(&git_dir).exists()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn production_bash_policy_evaluator_blocks_a_repo_denied_command_before_any_start() { + let repo = unique_test_git_dir("prod-policy-regression"); + std::fs::create_dir_all(repo.join(".sce")).expect("create .sce dir"); + std::fs::write( + repo.join(".sce").join("config.json"), + concat!( + r#"{"policies":{"bash":{"custom":[{"id":"no-rm","#, + r#""match":{"argv_prefix":["rm"]},"#, + r#""message":"rm is blocked in this repository"}]}}}"#, + ), + ) + .expect("write repo bash policy config"); + + let real_evaluator = + |root: &Path, command: &str| evaluate_codex_bash_policy(root, command); + + let blocked = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[ + ( + CWD_FIELD, + Value::String(repo.to_string_lossy().into_owned()), + ), + (TOOL_INPUT_FIELD, json!({"command": "rm -rf build"})), + ]), + None, + &panicking_resolver, + &unreachable_seam, + &real_evaluator, + ) + .expect("a repo-denied Bash command still returns Ok"); + + assert!(blocked.contains(r#""permissionDecision":"deny""#)); + assert!(blocked.contains("no-rm")); + assert!(blocked.contains("rm is blocked in this repository")); + assert!( + !blocked.contains(FAIL_CLOSED_DENY_REASON), + "a real policy block keeps the policy-specific UX, not the generic deny", + ); + + let allowed = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[ + ( + CWD_FIELD, + Value::String(repo.to_string_lossy().into_owned()), + ), + (TOOL_INPUT_FIELD, json!({"command": "echo ok > ok.txt"})), + (TOOL_USE_ID_FIELD, Value::String("exec-allowed".to_string())), + ]), + None, + &fixed_resolver(repo.join(".git")), + &ok_seam, + &real_evaluator, + ) + .expect("an allowed Bash command still returns Ok"); + assert_eq!( + allowed, "", + "the same repo config lets a non-denied command through to the normal path", + ); + + remove_test_git_dir(&repo); + } } } diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 9827d9b6..6821b222 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -717,12 +717,22 @@ not arise, and no successor-barrier logic ships for built-ins. - A failed **`apply_patch`** never mutates the working tree (atomic verification), so there is nothing to strand. - A **hook-blocked** tool never executes (`PreToolUse` only, no `PostToolUse`) — - no scope was established (D8 fail-closed happens before `start`). + no scope was established when the SCE Bash policy or the SCE mutation-scope + preflight is the source of the block (D8 fail-closed / the PR #268 Bash-policy + preflight both happen before `start`). - The only built-in "partial mutation, no `PostToolUse`" case is **whole-turn interruption** (SIGINT), which emits `Interrupt` then `SessionEnd` and ends the turn — there is no in-turn successor `PreToolUse` to race. Evidence: `fixtures/probe02-*`, `probe06-*`, `probe07-*`, `probe11-*`. +**Known exception (PR #268, tracked as a remaining lifecycle issue in the T04 +follow-up notes):** an *arbitrary user-owned / third-party* concurrent +`PreToolUse(Bash)` hook that denies while the SCE Bash policy allows leaves an +`Active` scope with no `PostToolUse` — the "predecessor already terminal in +bookkeeping" assumption above does not hold for a non-SCE block. Turn-boundary +cleanup (D12) plus the D13 barrier bound the damage to a single turn; the clean +fix is the Case-B lane-scoped successor sweep for built-ins, deferred. + **MCP: the T01 finding is correct and stands — `MCP is D10a Case C *if modeled as a scope*`.** The T01 MCP extension establishes all three conditions of Case C simultaneously, and this evidence is **not** weakened by the v1 resolution: @@ -2571,6 +2581,83 @@ Persist this field in every plan; this is durable plan state, not chat state: for OS-lock acquisition failure, never to infer lifecycle completion. Changes confined to `codex_mutation_scope/{mod,state,os_lock,boundary_lock}.rs` and this plan. + - Third concurrency follow-up (2026-09-08 — SCE-owned Bash-policy race, PR #268): + Codex executes matching `PreToolUse` handlers concurrently. The generated + `PreToolUse(Bash)` therefore runs the existing `sce hooks codex` Bash-policy + handler concurrently with the mutation-scope handler. The initial integration + could establish `Start` before the policy handler denied the `Bash` call, + leaving an `Active` mutation scope for a tool Codex never executed; a + successor tracked execution could then create another scope before + `Stop` / `SessionEnd`, producing false overlap or attribution. Registration + order does not help — matching local handlers run concurrently. + The mutation-scope `Bash` `PreToolUse` path now performs the same SCE + Bash-policy preflight itself before any adapter admission or `Start`. A + blocked policy returns the existing Codex-native policy denial + (`permissionDecision:"deny"` with the policy id + message) with no + mutation-scope state — no `PendingStart`, `Start`, `Active`, recovery, + `Abandon`, or `Flush`, and the git dir is never resolved and the boundary + lock never taken. Policy-evaluation failures and a malformed + `tool_input.command` fail closed with the generic mutation-scope deny and a + `sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed` warning. The + policy is evaluated twice for `Bash` (`sce hooks codex` handler + this + preflight); acceptable because the evaluation is a read-only deterministic + decision over the same repository config and command. `apply_patch` is + unchanged (no Bash `tool_input.command` dependency). The existing + `sce hooks codex` Bash-policy registration and its generated command / Codex + trust identity are unchanged; `.codex/hooks.json` and + `config/pkl/renderers/codex-content.pkl` have no diff. + The shared Bash-policy evaluation lives in + `cli/src/services/hooks/codex/bash_policy.rs` as `evaluate_codex_bash_policy` + (→ `CodexBashPolicyDecision::{Allowed, Blocked(String)}`, `Blocked` carrying + the rendered Codex-native deny) over the unchanged + `crate::services::bash_policy::evaluate_bash_command_policy` + + `config::resolve_bash_policy_runtime_config`; command extraction is the + shared `bash_command_from_tool_input`. Both `sce hooks codex` and the + mutation-scope preflight call these, so they make the same decision for the + same repository + command (frozen by a parity test). + Regressions added (`codex_mutation_scope` driver + `codex::bash_policy`): + policy-blocked `Bash` creates no scope and touches neither resolver nor + seam; policy-allowed `Bash` still follows the write-ahead `Start` path; + policy-evaluation failure is fail-closed with no scope and a diagnostic log; + `apply_patch` never evaluates Bash policy (panicking evaluator); + malformed `tool_input` is fail-closed before the evaluator runs; a + production-shaped regression drives the real `evaluate_codex_bash_policy` + against a repo `.sce/config.json` that denies `rm` and asserts the + Codex-native policy deny with no `Start`; and a handler/preflight parity + test over one repo config + command. + Re-validated: `services::hooks::codex::` — **passed** (236, 1 ignored); + `services::hooks::codex_mutation_scope` — **passed**; `services::hooks::` — + **passed** (438, 1 ignored); `services::codex_hook_config` — **passed** (36); + `services::config::` — **passed** (31); `clippy --all-targets -- -D warnings` + — **clean**; `cargo fmt -- --check` — **clean**. No `spec/mutation_cursor.qnt` + / `mutation_trace/protocol.rs` / `mutation_trace/runtime/` / + `mutation_trace/store.rs` / `cli/migrations/agent-trace-repository/` / + `agent-trace.schema.json` / MCP-semantics / generated-config change. Changes + confined to `cli/src/services/hooks/codex/{mod,bash_policy}.rs`, + `cli/src/services/hooks/codex_mutation_scope/mod.rs`, and this plan. + Arbitrary user-owned concurrent `PreToolUse(Bash)` denial — analysis result: + **not covered by this fix; a separate follow-up is required.** This fix + removes only the SCE-owned race (SCE policy deny vs SCE mutation-scope + `Start`). If a third-party / user-owned `PreToolUse(Bash)` hook denies while + the SCE Bash policy allows, SCE still establishes `Active(A)` and Codex still + blocks the tool with no `PostToolUse(A)`. The mutation-scope hook cannot see + the aggregate Codex decision — matching local handlers run concurrently and + Codex does not report other hooks' verdicts. Existing turn-boundary cleanup + (D12: `Stop` / `Interrupt` / `SessionEnd` → `abandon` A → recovery armed → + quiescent `flush` on the next tracked `PreToolUse`) and the D13 recovery + barrier **bound the exposure to a single turn and prevent cross-turn + contamination** — the zombie `Active` is abandoned and flushed at the turn + boundary. The residual gap: within that turn, a successor tracked + `PreToolUse(B)` starts alongside the zombie `Active(A)` (no successor sweep + ships for built-ins — D10a Case A assumed the predecessor is already terminal + in bookkeeping when the successor arrives, which an arbitrary concurrent + blocker hook violates). A tree transition observed in that window can be + falsely attributed to A or flagged `AiContended`. The clean fix is the D10a + Case-B lane-scoped successor sweep for built-ins (T01 proved built-ins run + serially, so `PreToolUse(B)` is valid evidence A is stale) — deferred here as + out of scope for the SCE-owned race. **This does not block T06 for the + SCE-owned scope**, but T06/T07 must decide whether to ship the built-in + successor sweep. Recorded as a remaining lifecycle issue; not hidden. - Context impact: domain — a new adapter-domain driver plus a new hidden CLI route. User-visible surface: one hidden `sce hooks codex-mutation-scope` subcommand (hidden from `sce --help` / `sce hooks --help`); no visible-help From 1af112fbc3deddf6331e0535ae98c18191852094 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 16:15:13 +0200 Subject: [PATCH 13/20] hooks: Sweep stale Codex mutation attempts by turn lane Prevent an arbitrary concurrent PreToolUse denial from leaving an active mutation scope alongside a later built-in execution in the same Codex session turn. Persist turn metadata, retire differing same-lane attempts with Abandon, flush recovery before Start, and add a fail-closed admission backstop plus regressions for lane scoping, duplicates, and recovery failures. Document the exceptional Case-B lifecycle and its follow-up evidence. Plan: context/plans/codex-mutation-scope-integration.md (T04 follow-up) --- .../hooks/codex_mutation_scope/mod.rs | 481 +++++++++++++++++- .../hooks/codex_mutation_scope/state.rs | 215 +++++++- .../plans/codex-mutation-scope-integration.md | 291 ++++++++--- 3 files changed, 868 insertions(+), 119 deletions(-) diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index ef0b3c41..94828556 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -525,13 +525,17 @@ fn handle_pre_tool_use( }; let key = identity.attempt_key(); + let turn_id = identity.turn_id.as_str(); let outcome = with_boundary_lock(&git_dir, || { state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + sweep_stale_lane_predecessors(&git_dir, repository_root, &key, turn_id, logger, seam)?; + match admit_or_recover( &git_dir, repository_root, &key, + turn_id, &identity.tool_name, logger, seam, @@ -587,24 +591,50 @@ enum Admission { Denied, } +fn sweep_stale_lane_predecessors( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + turn_id: &str, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + loop { + let current = state::read_state(git_dir)?; + let Some(stale) = current + .attempts + .iter() + .find(|attempt| { + attempt.in_builtin_lane(&key.session_id, turn_id) + && !attempt_matches_key(attempt, key) + }) + .cloned() + else { + return Ok(()); + }; + abandon_attempt(git_dir, repository_root, &stale, logger, seam)?; + } +} + fn admit_or_recover( git_dir: &Path, repository_root: &Path, key: &AttemptKey, + turn_id: &str, tool_name: &str, logger: Option<&dyn Logger>, seam: IngressSeam, ) -> Result { - match state::admit_tracked_attempt(git_dir, key, tool_name)? { + match state::admit_tracked_attempt(git_dir, key, turn_id, tool_name)? { state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - state::AdmitDecision::RecoveryBlocked | state::AdmitDecision::UncertainAttemptBlocked => { - Ok(Admission::Denied) - } + state::AdmitDecision::RecoveryBlocked + | state::AdmitDecision::UncertainAttemptBlocked + | state::AdmitDecision::StalePredecessorBlocked => Ok(Admission::Denied), state::AdmitDecision::FlushClaimed { generation } => { match seam(repository_root, &flush_payload(), logger) { Ok(_) => match state::complete_recovery_flush(git_dir, generation)? { state::RecoveryFlushCompletion::Cleared => { - readmit_after_flush(git_dir, key, tool_name) + readmit_after_flush(git_dir, key, turn_id, tool_name) } state::RecoveryFlushCompletion::Superseded => Ok(Admission::Denied), }, @@ -618,16 +648,21 @@ fn admit_or_recover( } } -fn readmit_after_flush(git_dir: &Path, key: &AttemptKey, tool_name: &str) -> Result { - match state::admit_tracked_attempt(git_dir, key, tool_name)? { +fn readmit_after_flush( + git_dir: &Path, + key: &AttemptKey, + turn_id: &str, + tool_name: &str, +) -> Result { + match state::admit_tracked_attempt(git_dir, key, turn_id, tool_name)? { state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), state::AdmitDecision::FlushClaimed { generation } => { state::relinquish_recovery_flush(git_dir, generation)?; Ok(Admission::Denied) } - state::AdmitDecision::RecoveryBlocked | state::AdmitDecision::UncertainAttemptBlocked => { - Ok(Admission::Denied) - } + state::AdmitDecision::RecoveryBlocked + | state::AdmitDecision::UncertainAttemptBlocked + | state::AdmitDecision::StalePredecessorBlocked => Ok(Admission::Denied), } } @@ -1453,12 +1488,32 @@ mod tests { state::read_state(git_dir).expect("adapter state should be readable") } + const DRIVER_TURN: &str = "turn-1"; + fn seed_attempt( git_dir: &Path, session_id: &str, agent_id: Option<&str>, tool_use_id: &str, phase: state::AttemptPhase, + ) -> state::AdapterAttempt { + seed_attempt_in_turn( + git_dir, + session_id, + DRIVER_TURN, + agent_id, + tool_use_id, + phase, + ) + } + + fn seed_attempt_in_turn( + git_dir: &Path, + session_id: &str, + turn_id: &str, + agent_id: Option<&str>, + tool_use_id: &str, + phase: state::AttemptPhase, ) -> state::AdapterAttempt { state::seed_attempt_for_tests( git_dir, @@ -1467,6 +1522,7 @@ mod tests { agent_id: agent_id.map(str::to_string), tool_use_id: tool_use_id.to_string(), }, + turn_id, "Bash", phase, ) @@ -1634,10 +1690,13 @@ mod tests { state::AttemptPhase::PendingStart ); - let successor = pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String("exec-successor".to_string()), - )]); + let successor = pre_tool_use_json(&[ + ( + TOOL_USE_ID_FIELD, + Value::String("exec-successor".to_string()), + ), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ]); assert_eq!( run_codex_mutation_scope_from_payload_with( &successor, @@ -1647,7 +1706,7 @@ mod tests { ) .expect("successor must return Ok with a deny payload"), pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "I5: an unresolved PendingStart must block a successor Start", + "I5: an unresolved PendingStart in another lane must block a successor Start", ); remove_test_git_dir(&git_dir); @@ -1968,9 +2027,10 @@ mod tests { fn recovery_barrier_denies_new_tracked_pre_tool_use_while_attempts_remain_ac12() { let git_dir = unique_test_git_dir("barrier-attempts-remain"); let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( + seed_attempt_in_turn( &git_dir, "session-1", + "turn-other", None, "exec-live", state::AttemptPhase::Active, @@ -2225,15 +2285,24 @@ mod tests { git_dir: &Path, tool_use_id: &'static str, seam: impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send + 'static, + ) -> (thread::JoinHandle, mpsc::Receiver<()>) { + spawn_pre_tool_use_in_turn(git_dir, tool_use_id, DRIVER_TURN, seam) + } + + fn spawn_pre_tool_use_in_turn( + git_dir: &Path, + tool_use_id: &'static str, + turn_id: &'static str, + seam: impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send + 'static, ) -> (thread::JoinHandle, mpsc::Receiver<()>) { let (done_tx, done_rx) = mpsc::channel(); let resolver = fixed_resolver(git_dir.to_path_buf()); let handle = thread::spawn(move || { let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String(tool_use_id.to_string()), - )]), + &pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), + (TURN_ID_FIELD, Value::String(turn_id.to_string())), + ]), None, &resolver, &seam, @@ -2450,7 +2519,7 @@ mod tests { let flush_count_p2 = Arc::clone(&flush_count); let (p2_handle, p2_done) = - spawn_pre_tool_use(&git_dir, "exec-2", move |_r, payload, _l| { + spawn_pre_tool_use_in_turn(&git_dir, "exec-2", "turn-2", move |_r, payload, _l| { if payload.contains(r#""operation":"flush""#) { flush_count_p2.fetch_add(1, Ordering::SeqCst); } @@ -2486,8 +2555,13 @@ mod tests { fn seed_orphaned_flushing(git_dir: &Path) -> u64 { let generation = state::arm_recovery(git_dir).expect("arm recovery to seed"); - match state::admit_tracked_attempt(git_dir, &key("seed", None, "seed"), "Bash") - .expect("seeding admit should not error") + match state::admit_tracked_attempt( + git_dir, + &key("seed", None, "seed"), + "seed-turn", + "Bash", + ) + .expect("seeding admit should not error") { state::AdmitDecision::FlushClaimed { generation: claimed, @@ -2567,7 +2641,10 @@ mod tests { assert!(read_state(&git_dir).recovery.is_clear()); let second = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-2".to_string()))]), + &pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("exec-2".to_string())), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ]), &resolver, &recording_seam(Arc::clone(&recorded)), ); @@ -2858,8 +2935,10 @@ mod tests { ); assert!(after_start.recovery.is_clear()); - let successor = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]); + let successor = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("exec-b".to_string())), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ]); assert_eq!( run_codex_mutation_scope_from_payload_with( &successor, @@ -2869,7 +2948,7 @@ mod tests { ) .expect("successor returns a deny payload"), pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "Test D: an uncertain PendingStart blocks a successor Start", + "Test D: an uncertain PendingStart in another lane blocks a successor Start", ); drive(&session_end_payload("session-1"), &resolver, &ok_seam); @@ -3108,5 +3187,353 @@ mod tests { remove_test_git_dir(&repo); } + + fn operations(recorded: &[String]) -> Vec { + recorded + .iter() + .filter_map(|payload| { + for op in ["start", "close", "abandon", "flush"] { + if payload.contains(&format!(r#""operation":"{op}""#)) { + return Some(op.to_string()); + } + } + None + }) + .collect() + } + + fn pre(tool_use_id: &str, tool_name: &str, overrides: &[(&str, Value)]) -> String { + let mut merged: Vec<(&str, Value)> = vec![ + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), + (TOOL_NAME_FIELD, Value::String(tool_name.to_string())), + ]; + if tool_name != CODEX_TRACKED_TOOL_BASH { + merged.push((TOOL_INPUT_FIELD, Value::Null)); + } + merged.extend( + overrides + .iter() + .map(|(field, value)| (*field, value.clone())), + ); + pre_tool_use_json(&merged) + } + + fn assert_zombie_then_successor_sweep( + a_tool: &str, + b_tool: &str, + b_overrides: &[(&str, Value)], + ) { + let git_dir = unique_test_git_dir(&format!("zombie-successor-{a_tool}-{b_tool}")); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let started = drive( + &pre("exec-a", a_tool, &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(started, ""); + assert_eq!( + read_state(&git_dir).attempts[0].phase, + state::AttemptPhase::Active, + ); + + let successor = drive( + &pre("exec-b", b_tool, b_overrides), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(successor, ""); + + let ops = operations(&recorded.lock().unwrap()); + assert_eq!( + ops, + vec![ + "start".to_string(), + "abandon".to_string(), + "flush".to_string(), + "start".to_string(), + ], + "successor sequence must be Start(A) -> Abandon(A) -> Flush -> Start(B), never Start(A) -> Start(B) -> Abandon(A)", + ); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression1_arbitrary_blocker_zombie_then_tracked_successor() { + assert_zombie_then_successor_sweep("Bash", "Bash", &[]); + } + + #[test] + fn regression2_apply_patch_successor_variants() { + assert_zombie_then_successor_sweep("Bash", "apply_patch", &[]); + assert_zombie_then_successor_sweep("apply_patch", "Bash", &[]); + assert_zombie_then_successor_sweep("apply_patch", "apply_patch", &[]); + } + + #[test] + fn regression3_parent_then_subagent_same_lane_is_swept() { + assert_zombie_then_successor_sweep( + "Bash", + "Bash", + &[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))], + ); + } + + #[test] + fn regression3_subagent_then_parent_same_lane_is_swept() { + let git_dir = unique_test_git_dir("subagent-then-parent"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + drive( + &pre( + "exec-a", + "Bash", + &[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))], + ), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + drive( + &pre("exec-b", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec![ + "start".to_string(), + "abandon".to_string(), + "flush".to_string(), + "start".to_string(), + ], + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert!(final_state.attempts[0].agent_id.is_none()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression4_different_session_is_not_swept() { + let git_dir = unique_test_git_dir("different-session-not-swept"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = drive( + &pre( + "exec-b", + "Bash", + &[(SESSION_ID_FIELD, Value::String("session-2".to_string()))], + ), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec!["start".to_string()] + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .any(|attempt| attempt.tool_use_id == "exec-a")); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression5_different_turn_is_not_swept_by_case_b_inference() { + let git_dir = unique_test_git_dir("different-turn-not-swept"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = drive( + &pre( + "exec-b", + "Bash", + &[(TURN_ID_FIELD, Value::String("turn-2".to_string()))], + ), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec!["start".to_string()] + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .any(|attempt| attempt.tool_use_id == "exec-a")); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression6_duplicate_same_attempt_key_is_not_swept() { + let git_dir = unique_test_git_dir("duplicate-not-swept"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + drive( + &pre("exec-a", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); + let next_seq = read_state(&git_dir).next_attempt_seq; + + drive( + &pre("exec-a", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec!["start".to_string()] + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].scope_id, scope_id); + assert_eq!(final_state.next_attempt_seq, next_seq); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression7_pending_start_predecessor_is_swept() { + let git_dir = unique_test_git_dir("pending-start-predecessor-swept"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::PendingStart, + ); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = drive( + &pre("exec-b", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec![ + "abandon".to_string(), + "flush".to_string(), + "start".to_string(), + ], + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression8_abandon_failure_during_sweep_is_fail_closed() { + let git_dir = unique_test_git_dir("sweep-abandon-failure"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let output = run_codex_mutation_scope_from_payload_with( + &pre("exec-b", "Bash", &[]), + None, + &resolver, + &seam_failing_on("abandon"), + ) + .expect("a failed sweep abandon still returns Ok with a deny payload"); + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-a"); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression9_flush_failure_after_sweep_is_fail_closed() { + let git_dir = unique_test_git_dir("sweep-flush-failure"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let output = run_codex_mutation_scope_from_payload_with( + &pre("exec-b", "Bash", &[]), + None, + &resolver, + &seam_failing_on("flush"), + ) + .expect("a failed post-sweep flush still returns Ok with a deny payload"); + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = read_state(&git_dir); + assert!(final_state + .attempts + .iter() + .all(|attempt| attempt.tool_use_id != "exec-b")); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } } } diff --git a/cli/src/services/hooks/codex_mutation_scope/state.rs b/cli/src/services/hooks/codex_mutation_scope/state.rs index 0e456830..7e0162f7 100644 --- a/cli/src/services/hooks/codex_mutation_scope/state.rs +++ b/cli/src/services/hooks/codex_mutation_scope/state.rs @@ -16,7 +16,7 @@ const STATE_LOCK_WHAT: &str = "adapter-state"; const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(10); -const ADAPTER_STATE_VERSION: u32 = 2; +const ADAPTER_STATE_VERSION: u32 = 3; #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -49,6 +49,7 @@ pub(crate) struct AdapterAttempt { pub attempt_seq: u64, pub scope_id: String, pub session_id: String, + pub turn_id: String, pub agent_id: Option, pub tool_use_id: String, pub tool_name: String, @@ -61,6 +62,10 @@ impl AdapterAttempt { && self.agent_id == key.agent_id && self.tool_use_id == key.tool_use_id } + + pub(crate) fn in_builtin_lane(&self, session_id: &str, turn_id: &str) -> bool { + self.session_id == session_id && self.turn_id == turn_id + } } fn default_recovery_generation() -> u64 { @@ -101,6 +106,7 @@ pub(crate) enum AdmitDecision { Admitted(AllocatedAttempt), RecoveryBlocked, UncertainAttemptBlocked, + StalePredecessorBlocked, FlushClaimed { generation: u64 }, } @@ -242,6 +248,7 @@ fn acquire_lock(git_dir: &Path) -> Result { fn allocate_pending_start( state: &mut AdapterState, key: &AttemptKey, + turn_id: &str, tool_name: &str, ) -> AdapterAttempt { let attempt_seq = state.next_attempt_seq; @@ -249,6 +256,7 @@ fn allocate_pending_start( attempt_seq, scope_id: format_codex_scope_id(attempt_seq, key), session_id: key.session_id.clone(), + turn_id: turn_id.to_string(), agent_id: key.agent_id.clone(), tool_use_id: key.tool_use_id.clone(), tool_name: tool_name.to_string(), @@ -262,6 +270,7 @@ fn allocate_pending_start( pub(crate) fn admit_tracked_attempt( git_dir: &Path, key: &AttemptKey, + turn_id: &str, tool_name: &str, ) -> Result { let _lock = acquire_lock(git_dir)?; @@ -291,6 +300,12 @@ pub(crate) fn admit_tracked_attempt( })); } + if state.attempts.iter().any(|attempt| { + attempt.in_builtin_lane(&key.session_id, turn_id) && !attempt.matches_key(key) + }) { + return Ok(AdmitDecision::StalePredecessorBlocked); + } + if state .attempts .iter() @@ -299,7 +314,7 @@ pub(crate) fn admit_tracked_attempt( return Ok(AdmitDecision::UncertainAttemptBlocked); } - let attempt = allocate_pending_start(&mut state, key, tool_name); + let attempt = allocate_pending_start(&mut state, key, turn_id, tool_name); write_state_durably(git_dir, &state)?; Ok(AdmitDecision::Admitted(AllocatedAttempt { attempt, @@ -401,12 +416,13 @@ pub(crate) fn relinquish_recovery_flush(git_dir: &Path, generation: u64) -> Resu pub(crate) fn seed_attempt_for_tests( git_dir: &Path, key: &AttemptKey, + turn_id: &str, tool_name: &str, phase: AttemptPhase, ) -> AdapterAttempt { let _lock = acquire_lock(git_dir).expect("test seed lock"); let mut state = read_state(git_dir).expect("test seed read"); - allocate_pending_start(&mut state, key, tool_name); + allocate_pending_start(&mut state, key, turn_id, tool_name); let seeded = state.attempts.last_mut().expect("attempt was just pushed"); seeded.phase = phase; let attempt = seeded.clone(); @@ -465,12 +481,32 @@ mod tests { } } + const TEST_TURN: &str = "turn-1"; + fn admit(git_dir: &Path, key: &AttemptKey, tool_name: &str) -> AdmitDecision { - admit_tracked_attempt(git_dir, key, tool_name).expect("admit should not error") + admit_in_turn(git_dir, key, TEST_TURN, tool_name) + } + + fn admit_in_turn( + git_dir: &Path, + key: &AttemptKey, + turn_id: &str, + tool_name: &str, + ) -> AdmitDecision { + admit_tracked_attempt(git_dir, key, turn_id, tool_name).expect("admit should not error") } fn admit_and_activate(git_dir: &Path, key: &AttemptKey, tool_name: &str) -> AdapterAttempt { - match admit(git_dir, key, tool_name) { + admit_and_activate_in_turn(git_dir, key, TEST_TURN, tool_name) + } + + fn admit_and_activate_in_turn( + git_dir: &Path, + key: &AttemptKey, + turn_id: &str, + tool_name: &str, + ) -> AdapterAttempt { + match admit_in_turn(git_dir, key, turn_id, tool_name) { AdmitDecision::Admitted(allocated) => { mark_active(git_dir, &allocated.attempt.scope_id) .expect("mark_active should succeed"); @@ -486,7 +522,7 @@ mod tests { let state = read_state(&git_dir).expect("missing state file should read as default"); assert_eq!(state, AdapterState::default()); - assert_eq!(state.version, 2); + assert_eq!(state.version, 3); assert!(state.recovery.is_clear()); assert_eq!(state.next_recovery_generation, 1); @@ -498,11 +534,22 @@ mod tests { let git_dir = unique_test_git_dir("sequential-allocation"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let first = admit_and_activate(&git_dir, &key("session-1", None, "exec-1"), "Bash"); - let second = admit_and_activate(&git_dir, &key("session-1", None, "exec-2"), "apply_patch"); - let third = admit_and_activate( + let first = admit_and_activate_in_turn( + &git_dir, + &key("session-1", None, "exec-1"), + "turn-1", + "Bash", + ); + let second = admit_and_activate_in_turn( + &git_dir, + &key("session-1", None, "exec-2"), + "turn-2", + "apply_patch", + ); + let third = admit_and_activate_in_turn( &git_dir, &key("session-1", Some("agent-1"), "exec-3"), + "turn-3", "Bash", ); @@ -524,10 +571,25 @@ mod tests { std::fs::create_dir_all(&git_dir_a).expect("git dir A should be created"); std::fs::create_dir_all(&git_dir_b).expect("git dir B should be created"); - admit_and_activate(&git_dir_a, &key("session-1", None, "exec-1"), "Bash"); - admit_and_activate(&git_dir_a, &key("session-1", None, "exec-2"), "Bash"); + admit_and_activate_in_turn( + &git_dir_a, + &key("session-1", None, "exec-1"), + "turn-1", + "Bash", + ); + admit_and_activate_in_turn( + &git_dir_a, + &key("session-1", None, "exec-2"), + "turn-2", + "Bash", + ); - let first_b = admit_and_activate(&git_dir_b, &key("session-1", None, "exec-1"), "Bash"); + let first_b = admit_and_activate_in_turn( + &git_dir_b, + &key("session-1", None, "exec-1"), + "turn-1", + "Bash", + ); assert_eq!( first_b.attempt_seq, 1, "checkout B's monotonic counter must be independent of checkout A's" @@ -623,17 +685,26 @@ mod tests { } #[test] - fn admit_blocks_a_successor_while_an_unrelated_pending_start_is_unresolved() { + fn admit_blocks_a_successor_while_an_unrelated_pending_start_in_another_lane_is_unresolved() { let git_dir = unique_test_git_dir("admit-blocks-on-pending-start"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let AdmitDecision::Admitted(_) = admit(&git_dir, &key("session-1", None, "exec-a"), "Bash") - else { + let AdmitDecision::Admitted(_) = admit_in_turn( + &git_dir, + &key("session-1", None, "exec-a"), + "turn-1", + "Bash", + ) else { panic!("first admission should be Admitted"); }; assert_eq!( - admit(&git_dir, &key("session-1", None, "exec-b"), "Bash"), + admit_in_turn( + &git_dir, + &key("session-1", None, "exec-b"), + "turn-2", + "Bash" + ), AdmitDecision::UncertainAttemptBlocked, "I5: an unrelated unresolved PendingStart must block a successor Start" ); @@ -642,16 +713,96 @@ mod tests { } #[test] - fn admit_allows_a_new_key_alongside_an_active_attempt() { - let git_dir = unique_test_git_dir("admit-alongside-active"); + fn admit_blocks_a_new_key_in_the_same_builtin_lane_as_an_outstanding_attempt() { + let git_dir = unique_test_git_dir("admit-blocks-same-lane"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - admit_and_activate(&git_dir, &key("session-1", None, "exec-a"), "Bash"); + admit_and_activate_in_turn( + &git_dir, + &key("session-1", None, "exec-a"), + "turn-1", + "Bash", + ); + + assert_eq!( + admit_in_turn( + &git_dir, + &key("session-1", None, "exec-b"), + "turn-1", + "Bash" + ), + AdmitDecision::StalePredecessorBlocked, + "a new tracked attempt must not be admitted while an older different \ + AttemptKey in the same (session_id, turn_id) built-in lane is outstanding", + ); + assert_eq!( + read_state(&git_dir).expect("state readable").attempts.len(), + 1, + "the backstop admits nothing while the predecessor remains", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn admit_blocks_a_new_key_when_a_same_lane_predecessor_is_only_pending_start() { + let git_dir = unique_test_git_dir("admit-blocks-same-lane-pending"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let AdmitDecision::Admitted(second) = - admit(&git_dir, &key("session-1", None, "exec-b"), "Bash") + seed_attempt_for_tests( + &git_dir, + &key("session-1", None, "exec-a"), + "turn-1", + "Bash", + AttemptPhase::PendingStart, + ); + + assert_eq!( + admit_in_turn(&git_dir, &key("session-1", None, "exec-b"), "turn-1", "Bash"), + AdmitDecision::StalePredecessorBlocked, + "a same-lane PendingStart predecessor blocks admission before the uncertain-attempt path", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn admit_never_treats_duplicate_delivery_as_a_same_lane_predecessor() { + let git_dir = unique_test_git_dir("admit-duplicate-not-predecessor"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let attempt_key = key("session-1", None, "exec-1"); + + admit_and_activate_in_turn(&git_dir, &attempt_key, "turn-1", "Bash"); + + let AdmitDecision::Admitted(again) = + admit_in_turn(&git_dir, &attempt_key, "turn-1", "Bash") else { - panic!("a distinct execution may run alongside an Active attempt (D14)"); + panic!("duplicate delivery in the same lane must still reuse, never StalePredecessorBlocked"); + }; + assert!(again.reused); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn admit_allows_a_new_key_alongside_an_active_attempt_in_a_different_lane() { + let git_dir = unique_test_git_dir("admit-alongside-active"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + admit_and_activate_in_turn( + &git_dir, + &key("session-1", None, "exec-a"), + "turn-1", + "Bash", + ); + + let AdmitDecision::Admitted(second) = admit_in_turn( + &git_dir, + &key("session-1", None, "exec-b"), + "turn-2", + "Bash", + ) else { + panic!("a distinct-lane execution may run alongside an Active attempt (D14)"); }; assert!(!second.reused); assert_eq!( @@ -670,6 +821,7 @@ mod tests { seed_attempt_for_tests( &git_dir, &key("session-1", None, "exec-live"), + "turn-1", "Bash", AttemptPhase::Active, ); @@ -717,6 +869,7 @@ mod tests { admit_tracked_attempt( &git_dir, &key("session-1", None, &format!("exec-{suffix}")), + &format!("turn-{suffix}"), "Bash", ) .expect("admit should not error") @@ -1053,6 +1206,7 @@ mod tests { assert_eq!(reloaded.recovery, RecoveryState::Pending { generation: 1 }); assert_eq!(reloaded.attempts.len(), 1); assert_eq!(reloaded.attempts[0].phase, AttemptPhase::Active); + assert_eq!(reloaded.attempts[0].turn_id, "turn-1"); assert_eq!(reloaded.attempts[0].agent_id.as_deref(), Some("agent-2")); assert_eq!(reloaded.attempts[0].tool_name, "apply_patch"); assert_eq!(reloaded.attempts[0].scope_id, attempt.scope_id); @@ -1090,6 +1244,13 @@ mod tests { "recovery_pending": false, "attempts": [] }), + serde_json::json!({ + "version": 2, + "next_attempt_seq": 1, + "next_recovery_generation": 1, + "recovery": { "phase": "clear" }, + "attempts": [] + }), ] { let git_dir = unique_test_git_dir("unsupported-version"); let dir = adapter_state_dir(&git_dir); @@ -1141,7 +1302,12 @@ mod tests { std::fs::write(lock_path(&git_dir), b"leftover") .expect("leftover lock file should be writable"); - let decision = admit_tracked_attempt(&git_dir, &key("session-1", None, "exec-1"), "Bash"); + let decision = admit_tracked_attempt( + &git_dir, + &key("session-1", None, "exec-1"), + "turn-1", + "Bash", + ); assert!( matches!(decision, Ok(AdmitDecision::Admitted(_))), "a lock file with no active OS lock held against it must not block a new acquirer" @@ -1162,8 +1328,9 @@ mod tests { let git_dir = git_dir.clone(); thread::spawn(move || { let attempt_key = key("session-1", None, &format!("exec-{index}")); + let turn_id = format!("turn-{index}"); loop { - match admit_tracked_attempt(&git_dir, &attempt_key, "Bash") + match admit_tracked_attempt(&git_dir, &attempt_key, &turn_id, "Bash") .expect("admit should not error") { AdmitDecision::Admitted(allocated) => { diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 6821b222..c951d4f3 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -704,34 +704,66 @@ T01 must define "same lane" using only identity/concurrency facts it actually established. T02 freezes the lane key and the successor-barrier design (if Case B); T04 implements it; T06 proves it. -**T01 disposition (codex-cli 0.153.4): scope-split by tool type. Case A for -built-ins; Case C for MCP *if MCP were modeled as a scope* — resolved in v1 by -NOT modeling MCP as a scope (D23, re-planning direction B).** +**T01 disposition (codex-cli 0.153.4): scope-split by tool type. Built-ins have a +normal Case-A lifecycle plus an exceptional Case-B hook-block lifecycle; MCP is +Case C *if MCP were modeled as a scope* — resolved in v1 by NOT modeling MCP as a +scope (D23, re-planning direction B).** -**Built-in `Bash` / `apply_patch`: PROVEN — Case A.** The dangerous scenario does -not arise, and no successor-barrier logic ships for built-ins. +**Built-in `Bash` / `apply_patch` — normal lifecycle: Case A.** - A failed **shell** tool always emits `PostToolUse` (terminal) before the next `PreToolUse` — Codex runs built-in mutation-capable tools serially (D1), so predecessor A is already terminal in bookkeeping when successor B's `PreToolUse` arrives. - A failed **`apply_patch`** never mutates the working tree (atomic verification), so there is nothing to strand. -- A **hook-blocked** tool never executes (`PreToolUse` only, no `PostToolUse`) — - no scope was established when the SCE Bash policy or the SCE mutation-scope - preflight is the source of the block (D8 fail-closed / the PR #268 Bash-policy - preflight both happen before `start`). +- A tool blocked by the **SCE** Bash policy or the **SCE** mutation-scope + preflight never executes and never established a scope (D8 fail-closed / the + PR #268 Bash-policy preflight both happen before `start`). - The only built-in "partial mutation, no `PostToolUse`" case is **whole-turn interruption** (SIGINT), which emits `Interrupt` then `SessionEnd` and ends the turn — there is no in-turn successor `PreToolUse` to race. Evidence: `fixtures/probe02-*`, `probe06-*`, `probe07-*`, `probe11-*`. -**Known exception (PR #268, tracked as a remaining lifecycle issue in the T04 -follow-up notes):** an *arbitrary user-owned / third-party* concurrent -`PreToolUse(Bash)` hook that denies while the SCE Bash policy allows leaves an -`Active` scope with no `PostToolUse` — the "predecessor already terminal in -bookkeeping" assumption above does not hold for a non-SCE block. Turn-boundary -cleanup (D12) plus the D13 barrier bound the damage to a single turn; the clean -fix is the Case-B lane-scoped successor sweep for built-ins, deferred. +**Built-in `Bash` / `apply_patch` — exceptional hook-block lifecycle: Case B +(PR #268 follow-up, 2026-09-08).** Codex v0.153.4 executes matching `PreToolUse` +handlers concurrently and combines their verdicts afterwards. An *arbitrary +user-owned / third-party* sibling `PreToolUse` hook can therefore DENY the +aggregate execution *after* the SCE mutation-scope handler has already +established `Start(A)`, leaving `Active(A)` with no `PostToolUse(A)`. Codex +exposes no aggregate-denial event to the mutation adapter, so SCE cannot observe +that denial directly. The "predecessor already terminal in bookkeeping" +assumption of Case A does not hold for a non-SCE block. + +For this case, a later tracked built-in `PreToolUse(B)` in the same proven serial +lane is positive evidence that an older outstanding built-in attempt A in that +lane is stale (T01 proved built-in mutation-capable execution is serial within +one lane, including across the parent/subagent boundary — D1). Before the +successor may `Start`: + +```text +stale same-lane predecessor -> arm recovery -> Abandon(A) -> quiescent Flush -> Start(B) +``` + +never `Start(A) -> Start(B) -> Abandon(A)`. `Abandon` (not `Close`) is used +because A's terminal outcome is missing/uncertain. A failed `Abandon` or a failed +`Flush` keeps the successor denied and fail-closed. Multiple stale attempts in +the lane are all retired before B may `Start`. Duplicate delivery of the same +`AttemptKey` is not a predecessor and is never swept (idempotency preserved). A +defensive backstop in the state/admission layer (`AdmitDecision:: +StalePredecessorBlocked`) refuses `Start(B)` if a driver bug ever left an older +different-`AttemptKey` same-lane attempt outstanding. + +**Lane key = `(session_id, turn_id)`.** `agent_id` is **not** part of the lane — +T01 showed built-in mutation-capable execution stays serial across the +parent/subagent boundary within one session+turn, so `parent Bash` then +`subagent apply_patch` in the same session+turn share the built-in serial lane. +`turn_id` is lane metadata persisted on each tracked attempt; it is **not** added +to `AttemptKey` and does not change `ScopeId` / `EventId` formatting. Different +`session_id` or different `turn_id` executions are never swept by this inference +— normal `Stop` / `Interrupt` / `SessionEnd` cleanup (D12) owns turn/session +boundary lifecycle. Evidence: `fixtures/probe08-*` (parent/subagent serial), +`fixtures/probe02-*` / `probe11-*`; regressions in +`codex_mutation_scope::tests::driver::regression1..9`. **MCP: the T01 finding is correct and stands — `MCP is D10a Case C *if modeled as a scope*`.** The T01 MCP extension establishes all three conditions of Case C @@ -772,10 +804,12 @@ and there are none for MCP. This is a deliberate attribution-coverage boundary lifecycle is safe, does **not** silently downgrade MCP/unknown to read-only, and does **not** pretend an MCP mutation was attributed. -T02 records the D10a lane key as **N/A** (confirmed 2026-09-08) — Case A for -built-ins ships no barrier; MCP/unknown are `Untracked` and ship no barrier and -no scope. The T02 event model therefore carries no lane-key field and -`classify_tool` has no successor-sweep hook. Evidence: +T02 originally recorded the D10a lane key as N/A. **Superseded by the PR #268 +follow-up (2026-09-08):** built-ins now ship a Case-B lane-scoped successor sweep +keyed on `CodexBuiltInLane = (session_id, turn_id)`. `AttemptKey` and `ScopeId` +formatting are unchanged; `turn_id` is persisted as adapter-attempt lane metadata +only (adapter-state version bumped 2 -> 3). MCP/unknown remain `Untracked` and +still ship no barrier and no scope. Evidence: `fixtures/probe13-mcp-mutate-then-error.*`, `fixtures/probe14-mcp-failed-then-successor.*`, `fixtures/probe15-mcp-blocked-call.*`, @@ -823,8 +857,10 @@ is `SessionEnd`). The failed-tool -> successor-tool sequence (D10a) is the one case where the "next lifecycle signal" backstop is too late **for a tracked tool**; for -built-ins it is Case A (a terminal `PostToolUse` always precedes the successor), -and for MCP/unknown it does not arise because they are `Untracked` (no scope, no +built-ins the normal path is Case A (a terminal `PostToolUse` always precedes the +successor) and the exceptional arbitrary-hook-block path is Case B (a later +same-lane tracked `PreToolUse` sweeps the stale predecessor before `Start`), and +for MCP/unknown it does not arise because they are `Untracked` (no scope, no attempt). Owned by D10a, not this table. **T01 disposition (codex-cli 0.153.4): PROVEN.** @@ -881,12 +917,18 @@ A failed abandonment leaves the attempt tracked and recovery armed **Successor-Start invariant.** A `TrackedMutation` `PreToolUse` must never reach its write-ahead `Start` while a known-stale predecessor **tracked** attempt -(D10a) remains `active`/`pending_start` in bookkeeping. For built-ins this is -Case A (a terminal `PostToolUse` precedes the successor), so no successor-barrier -logic ships. MCP/unknown are `Untracked`, so they create no predecessor attempt -and no barrier is needed. This invariant does **not** license abandoning an -attempt that can legitimately run concurrently with the successor (D10a -invariant (ii)). +(D10a) remains `active`/`pending_start` in bookkeeping. For built-ins the normal +path is Case A (a terminal `PostToolUse` precedes the successor). The exceptional +arbitrary-hook-block path is Case B (PR #268 follow-up): before `Start(B)` the +`PreToolUse(B)` driver sweeps every tracked built-in attempt that shares B's +`(session_id, turn_id)` lane but not its `AttemptKey` — `arm recovery` -> +`Abandon` -> quiescent `Flush` -> `Start(B)` — and the state/admission layer +backstops this with `AdmitDecision::StalePredecessorBlocked` so a driver bug +denies `Start(B)` rather than silently allowing overlap. MCP/unknown are +`Untracked`, so they create no predecessor attempt and no barrier is needed. This +invariant does **not** license abandoning an attempt that can legitimately run +concurrently with the successor — the sweep is lane-scoped, so a different +`session_id` or `turn_id` attempt is left untouched (D10a invariant (ii)). **D13a — inter-process concurrency semantics (T04 follow-up, 2026-09-08).** Codex runs every hook as an independent OS process, so the barrier and @@ -1518,14 +1560,18 @@ performs final validation. assertions match the D10 disposition recorded by T01. - [ ] AC9a: A partially-mutating failed `TrackedMutation` tool A with **no terminal event**, followed by another `TrackedMutation` `PreToolUse(B)` in the - same turn (Case A): a terminal `PostToolUse` (or `Interrupt`/`SessionEnd`) - always precedes the successor `PreToolUse` for built-ins, so no - successor-barrier logic ships and B never `Start`s alongside a zombie A. The - MCP form of this scenario is covered by AC9d, not here, because MCP creates no - attempt. - - Validate: T06 built-in failed-A-then-B regression (assert A `Abandoned` or - `Closed` per tool, B the only live scope at its `Start`, no false - `AiContended`). + same turn: B never `Start`s alongside a zombie A. Normal path (Case A) — a + terminal `PostToolUse` (or `Interrupt`/`SessionEnd`) precedes the successor. + Exceptional path (Case B, PR #268 arbitrary-hook-block follow-up) — the + `PreToolUse(B)` driver sweeps every same-lane (`session_id`, `turn_id`) + built-in attempt whose `AttemptKey != B` first: `Abandon(A)` -> quiescent + `Flush` -> `Start(B)`, with an `AdmitDecision::StalePredecessorBlocked` + backstop. The MCP form of this scenario is covered by AC9d, not here, because + MCP creates no attempt. + - Validate: `codex_mutation_scope::tests::driver::regression1..9` (Case-B + sweep, seam order, fail-closed, lane scoping); T06 built-in failed-A-then-B + regression (assert A `Abandoned` or `Closed` per tool, B the only live scope + at its `Start`, no false `AiContended`). - [ ] AC10: Two simultaneously-live **tracked** scopes produce `AiContended` for a tree transition observed while both are live; the adapter never assigns them one shared `ScopeId`. Per D14 the exercised form is a **tracked Codex scope @@ -1547,8 +1593,10 @@ performs final validation. remain outstanding, every new `TrackedMutation` `PreToolUse` is denied (D8 shape); an `Untracked` `PreToolUse` is not affected by the barrier; once quiescent, exactly one `{"operation":"flush"}` runs through the seam and - `recovery_pending` clears only on durable flush success. (No D10a successor - barrier ships — built-ins are Case A and MCP/unknown are `Untracked`.) + `recovery_pending` clears only on durable flush success. (The D10a Case-B + built-in successor sweep also runs `Abandon` -> quiescent `flush` -> `Start` + through this same recovery machinery; MCP/unknown are `Untracked` and ship no + barrier.) - Validate: adapter recovery-barrier unit tests (deny-while-outstanding, flush-then-proceed, flush-failure-stays-closed); T06 recovery-barrier regression. @@ -2396,8 +2444,10 @@ Persist this field in every plan; this is durable plan state, not chat state: → whole-session sweep — all sweeps over adapter-owned tracked attempts only (D12); D13 recovery barrier denies new `TrackedMutation` `PreToolUse` while tracked attempts remain and runs exactly one quiescent `flush`, clearing - `recovery_pending` only on durable flush success. No D10a successor-barrier / - lane-key code ships (T02 recorded the lane key N/A). The driver's only + `recovery_pending` only on durable flush success. (T04 shipped no D10a + successor-barrier / lane-key code; **superseded by the fourth concurrency + follow-up below**, which adds the Case-B lane-scoped built-in successor sweep + keyed on `(session_id, turn_id)`.) The driver's only mutation-stack dependency in non-test code is `super::mutation_scope::run_mutation_scope_from_payload`, wire payload built as a JSON string (D18/AC19). Injected git-dir resolver + seam make every @@ -2636,28 +2686,113 @@ Persist this field in every plan; this is durable plan state, not chat state: confined to `cli/src/services/hooks/codex/{mod,bash_policy}.rs`, `cli/src/services/hooks/codex_mutation_scope/mod.rs`, and this plan. Arbitrary user-owned concurrent `PreToolUse(Bash)` denial — analysis result: - **not covered by this fix; a separate follow-up is required.** This fix - removes only the SCE-owned race (SCE policy deny vs SCE mutation-scope - `Start`). If a third-party / user-owned `PreToolUse(Bash)` hook denies while - the SCE Bash policy allows, SCE still establishes `Active(A)` and Codex still - blocks the tool with no `PostToolUse(A)`. The mutation-scope hook cannot see - the aggregate Codex decision — matching local handlers run concurrently and - Codex does not report other hooks' verdicts. Existing turn-boundary cleanup - (D12: `Stop` / `Interrupt` / `SessionEnd` → `abandon` A → recovery armed → - quiescent `flush` on the next tracked `PreToolUse`) and the D13 recovery - barrier **bound the exposure to a single turn and prevent cross-turn - contamination** — the zombie `Active` is abandoned and flushed at the turn - boundary. The residual gap: within that turn, a successor tracked - `PreToolUse(B)` starts alongside the zombie `Active(A)` (no successor sweep - ships for built-ins — D10a Case A assumed the predecessor is already terminal - in bookkeeping when the successor arrives, which an arbitrary concurrent - blocker hook violates). A tree transition observed in that window can be - falsely attributed to A or flagged `AiContended`. The clean fix is the D10a - Case-B lane-scoped successor sweep for built-ins (T01 proved built-ins run - serially, so `PreToolUse(B)` is valid evidence A is stale) — deferred here as - out of scope for the SCE-owned race. **This does not block T06 for the - SCE-owned scope**, but T06/T07 must decide whether to ship the built-in - successor sweep. Recorded as a remaining lifecycle issue; not hidden. + the SCE-owned race (SCE policy deny vs SCE mutation-scope `Start`) is removed + by this third follow-up; the remaining arbitrary-blocker successor-safety gap + is fixed by the **fourth follow-up** below. + - Fourth concurrency follow-up (2026-09-08 — arbitrary-blocker successor sweep, + PR #268): Codex v0.153.4 runs matching `PreToolUse` handlers concurrently and + combines their verdicts afterwards. An arbitrary user-owned / third-party + sibling `PreToolUse` hook can DENY the aggregate execution *after* the SCE + mutation-scope handler has already driven `Start(A)`, leaving `Active(A)` + with no `PostToolUse(A)`. Codex exposes no aggregate-denial event to the + mutation adapter, so SCE cannot learn that verdict directly. Turn-boundary + cleanup (D12) + the D13 barrier already bound the damage to one turn, but + within that turn a successor tracked `PreToolUse(B)` could still `Start` + alongside the zombie `Active(A)`, risking false `AiContended` / attribution. + Fix — the D10a Case-B lane-scoped successor sweep for built-ins: + - `CodexBuiltInLane = (session_id, turn_id)` (T01 proved built-in + mutation-capable execution is serial within one session+turn, including + across the parent/subagent boundary — so `agent_id` is **not** in the + lane). A later tracked built-in `PreToolUse(B)` in that lane is positive + evidence an older outstanding built-in attempt A there is stale. + - `AdapterAttempt` now persists `turn_id` as lane metadata. `AttemptKey` + stays `(session_id, agent_id?, tool_use_id)`; `ScopeId` / `EventId` + formatting unchanged. Adapter-state version bumped **2 -> 3**; a prior + (v2) checkout-local state file is rejected (fail-closed), never silently + swept on a guessed lane — this is checkout-local ephemeral state, no DB + migration. + - `PreToolUse(B)` flow inside the boundary lock: normalize recovery -> sweep + every same-lane attempt whose `AttemptKey != B` (`arm_recovery` -> + `Abandon` -> remove; never `Close`, since A's terminus is missing) -> + drive the existing quiescent recovery (`FlushClaimed` -> `flush` -> + `complete_recovery_flush`) -> re-admit B -> `Start(B)`. Seam order for the + primary regression: `Start(A)` `Abandon(A)` `Flush` `Start(B)`, never + `Start(A)` `Start(B)` `Abandon(A)`. + - Duplicate delivery (`AttemptKey(A) == AttemptKey(B)`) is not a predecessor + and is never swept — same `attempt_seq` / `ScopeId`, no second `Start`. + - `PendingStart` predecessors are swept the same way (conservative + `Abandon` + recovery), because a same-lane successor proves the prior + attempt is no longer legitimately running. + - Fail-closed: a failed `Abandon` keeps A tracked + recovery armed and denies + B; a failed `Flush` after a successful `Abandon` keeps recovery armed and + denies B; state-transition / lock failure denies B via the stable + mutation-scope deny contract. + - Defensive backstop: `AdmitDecision::StalePredecessorBlocked` — the + state/admission layer refuses to admit a new tracked attempt while an older + different-`AttemptKey` same-lane attempt is outstanding, so a driver bug or + incomplete sweep yields no `Start(B)` rather than silent overlap. The + boundary lock means this normally never fires after a successful sweep. + - Different `session_id` / different `turn_id` attempts are never swept by + this inference — D12 `Stop` / `Interrupt` / `SessionEnd` cleanup still owns + turn/session boundary lifecycle. + Regressions added (`codex_mutation_scope::state` + `::tests::driver`): + same-lane predecessor blocks admission (`StalePredecessorBlocked`); + same-lane `PendingStart` predecessor blocks; duplicate delivery is never a + predecessor; different-lane attempt still admits alongside (D14); zombie-A + then same-lane successor B drives `Start`->`Abandon`->`Flush`->`Start` + (`regression1`), `apply_patch` <-> `Bash` variants (`regression2`), + parent<->subagent same lane (`regression3`), different session not swept + (`regression4`), different turn not swept (`regression5`), duplicate key not + swept (`regression6`), `PendingStart` predecessor swept (`regression7`), + `Abandon` failure fail-closed (`regression8`), `Flush` failure fail-closed + (`regression9`); adapter-state v2 file now rejected as an unsupported + version. + Re-validated: `services::hooks::codex_mutation_scope` — **passed** (119, 1 + ignored); `services::hooks::` — **passed** (451, 1 ignored); + `services::hooks::codex` — **passed** (249, 1 ignored); + `services::codex_hook_config` — **passed** (36); full `cargo test + --manifest-path cli/Cargo.toml` — **passed** (1280, 1 ignored); + `clippy --all-targets -- -D warnings` — **clean**; `cargo fmt -- --check` — + **clean**. No `spec/mutation_cursor.qnt` / `mutation_trace/protocol.rs` / + `mutation_trace/runtime/` / `mutation_trace/store.rs` / + `cli/migrations/agent-trace-repository/` / `agent-trace.schema.json` / + MCP-semantics / generated-config / Bash-policy-preflight change. Changes + confined to + `cli/src/services/hooks/codex_mutation_scope/{mod,state}.rs` and this plan. + Untracked-successor / cross-harness analysis (required by this follow-up): + - The generated mutation-scope `PreToolUse` matcher intentionally does not + fire for MCP / unknown / delegation (Option B, D23), so the Case-B sweep + does **not** run at `PreToolUse(mcp__…)` (or an unknown/delegation + `PreToolUse`) after a zombie built-in `Active(A)`. This is not hidden. + - Arbitrary hook denial is fundamentally unobservable at A: SCE never learns + the aggregate verdict. `Active(A)` is kept conservatively live; only a + later **proven same-lane** tracked built-in `PreToolUse(B)` is positive + seriality evidence that A is stale. The fix does **not** claim any later + non-built-in `PreToolUse` proves A stale — no T01 evidence supports that, + and a separate cleanup-only path is **not** designed here. + - If no same-lane tracked built-in successor occurs in the turn, A is + retired by turn-boundary cleanup (`Stop` / `Interrupt` / `SessionEnd` -> + `abandon` -> recovery -> `flush`, D12). `abandon` keeps no + snapshot/attribution semantics for the unobserved interval + (`mutation_scope::tests::real_git_db_ingress::test4`), so an MCP mutation + in the zombie window is folded into `needs_rebaseline`, never falsely + attributed to A — the same un-attributed-MCP coverage boundary already + accepted under D23. + - Residual, single-turn-bounded: a **cross-harness** mutation-scope event + (another harness's scope boundary) landing while zombie A is still `Active` + and before any same-lane tracked successor or turn boundary could produce + a false `AiContended` against A for that transition. This is the same + exposure class the third follow-up recorded; this fix shrinks the window + (an in-turn same-lane tracked successor now retires A immediately instead + of waiting for the turn boundary) but does not eliminate it. It is bounded + to one turn by D12/D13 and does **not** block T06, which proves the + SCE-owned production path. Not claimed as "arbitrary-blocker safety fully + solved"; recorded as a remaining bounded exposure, tracked for T06/T07 to + weigh against a broader cleanup signal only if future evidence justifies + one. + - Matcher and MCP behaviour are unchanged; no MCP tracking, MCP denial, + unknown-tool denial, PID supervision, polling, timeout-as-evidence, or + global/cross-session sweep was added. - Context impact: domain — a new adapter-domain driver plus a new hidden CLI route. User-visible surface: one hidden `sce hooks codex-mutation-scope` subcommand (hidden from `sce --help` / `sce hooks --help`); no visible-help @@ -3041,6 +3176,16 @@ Persist this field in every plan; this is durable plan state, not chat state: `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`) remain untouched by the mutation-scope adapter (before/after row counts) (AC20). + 16. **arbitrary-blocker zombie built-in A -> same-lane successor B** (D10a + Case B): drive `PreToolUse(A = Bash)` to `Active(A)`, omit + `PostToolUse(A)` (models a third-party sibling `PreToolUse` denying the + aggregate), then `PreToolUse(B)` with the same `session_id` / `turn_id` + and a different `tool_use_id`. Through the real runtime, A is + `Abandoned` and flushed before B `Start`s, B is the only live scope at + its `Start`, and no false `AiContended` / attribution to A results. The + adapter-level seam-ordering regression (`Start(A)` -> `Abandon(A)` -> + `Flush` -> `Start(B)`) already ships in + `codex_mutation_scope::tests::driver::regression1..9` (AC9a). Plus the crash/recovery rows: crash before `Start` commit -> conservative recovery (AC21a); `Start` committed before bookkeeping settlement -> abandonment recovery, not late-`Start` (AC21b); terminal transition committed @@ -3140,10 +3285,15 @@ risk is resolved by exclusion, with the Case C evidence retained as the reason `cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md`). Headline resolutions: -- **D10a is Case A for built-in `Bash` / `apply_patch` only.** A failed shell - tool still fires `PostToolUse`; a failed `apply_patch` writes nothing; - interruption ends the turn. No serial-lane successor barrier ships for - built-ins. +- **D10a for built-in `Bash` / `apply_patch`: normal Case A + exceptional + Case B.** Normal path (Case A): a failed shell tool still fires `PostToolUse`; + a failed `apply_patch` writes nothing; interruption ends the turn. Exceptional + path (Case B, PR #268 arbitrary-hook-block follow-up): an arbitrary concurrent + sibling `PreToolUse` hook can deny the aggregate execution after SCE drove + `Start(A)`, so a later same-lane (`session_id`, `turn_id`) tracked built-in + `PreToolUse(B)` sweeps the stale predecessor — `Abandon(A)` -> quiescent + `Flush` -> `Start(B)` — with an `AdmitDecision::StalePredecessorBlocked` + backstop. `agent_id` is not part of the lane. - **D10a is Case C for MCP *if MCP were modeled as a scope* — resolved by NOT modeling MCP as a scope (direction B, D23).** The T01 MCP extension (probes 12–17) proves, live, all three Case C conditions at once, @@ -3215,9 +3365,14 @@ with fixtures and the MCP one by an explicit coverage boundary: - **RESOLVED — Failed tool with no terminal event, then another tool in the same turn.** (D10a — the single highest-risk correctness question.) For built-in - `Bash` / `apply_patch`: **Case A** — a failed shell tool still fires + `Bash` / `apply_patch`: **normal Case A** — a failed shell tool still fires `PostToolUse`, a failed `apply_patch` writes nothing, and interruption ends the - turn; no barrier ships. For **MCP: Case C *if modeled as a scope*** — probes + turn; **exceptional Case B** (PR #268) — an arbitrary concurrent sibling + `PreToolUse` hook can deny after `Start(A)`, so a later same-lane + (`session_id`, `turn_id`) tracked built-in `PreToolUse(B)` sweeps the stale + predecessor (`Abandon` -> `Flush` -> `Start(B)`) with a + `StalePredecessorBlocked` backstop. For **MCP: Case C *if modeled as a + scope*** — probes 13/14/16/17 prove a mutation-capable MCP tool can mutate-then-fail with no terminal hook, no cleanup signal reaches the adapter before the successor `PreToolUse`, and same-lane MCP executions can overlap. **Resolved by direction From 8359626bc1152fe8af8a8bc329a722d98314f445 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 16:58:50 +0200 Subject: [PATCH 14/20] runtime+language: Prevent false mutation attribution for unconfirmed Codex scopes Treat Codex Start as write-ahead admission rather than execution confirmation, so transitions observed before the scope's own Close remain attribution-ineligible. Derive boundary-aware attribution in Rust and Quint, extend MBT scope coverage, and add coordinator/runtime regressions for cross-harness contention and multiple live Codex scopes. At a confirming Codex Close, preserve exclusive or cross-harness contended attribution unless another live Codex scope remains unconfirmed. This intentionally favors false negatives over false-positive AI mutation lineage. Plan: codex-mutation-scope-integration (T06) --- cli/src/services/mutation_trace/mbt/driver.rs | 10 +- cli/src/services/mutation_trace/mbt/model.rs | 2 + cli/src/services/mutation_trace/mod.rs | 1 - cli/src/services/mutation_trace/protocol.rs | 83 +++---- .../mutation_trace/runtime/coordinator.rs | 142 ++++++++++- .../runtime/mutation_attribution/tests.rs | 32 +++ .../services/mutation_trace/runtime/tests.rs | 135 +++++++++- cli/src/services/mutation_trace/tests.rs | 232 +++++++++++++++++- .../plans/codex-mutation-scope-integration.md | 178 +++++++++++--- spec/mutation_cursor.md | 24 +- spec/mutation_cursor.qnt | 222 +++++++++++++++-- 11 files changed, 950 insertions(+), 111 deletions(-) diff --git a/cli/src/services/mutation_trace/mbt/driver.rs b/cli/src/services/mutation_trace/mbt/driver.rs index 8fe3c2d4..3b967d4a 100644 --- a/cli/src/services/mutation_trace/mbt/driver.rs +++ b/cli/src/services/mutation_trace/mbt/driver.rs @@ -57,13 +57,6 @@ pub(super) struct MutationCursorDriver { } impl MutationCursorDriver { - /// Exactly `spec/mutation_cursor.qnt`'s `init`: both worktrees at - /// `Tree0`/revision `0`/healthy/no-rebaseline, all four scopes - /// `NeverSeen` with `scopeActor`'s fixed partition (`Scope0`/`Scope1` - /// Claude Code and `Scope2` Codex on `WT0`, `Scope3` `OpenCode` on `WT1`), - /// and all six attempts `Available` with the same placeholder - /// `Flush(WT0)`/revision `0`/`Tree0`/`Tree0` baseline Quint's `init` - /// assigns every `AttemptId`. fn init() -> Self { let wt0 = worktree("wt0"); let wt1 = worktree("wt1"); @@ -84,11 +77,12 @@ impl MutationCursorDriver { worktree_trees.insert(id.clone(), tree("tree0")); } - let scope_partition: [(&str, &WorktreeId, ActorKind); 4] = [ + let scope_partition: [(&str, &WorktreeId, ActorKind); 5] = [ ("scope0", &wt0, ActorKind::ClaudeCode), ("scope1", &wt0, ActorKind::ClaudeCode), ("scope2", &wt0, ActorKind::Codex), ("scope3", &wt1, ActorKind::OpenCode), + ("scope4", &wt0, ActorKind::Codex), ]; let mut scopes = BTreeMap::new(); for (id, owning_worktree, actor_kind) in scope_partition { diff --git a/cli/src/services/mutation_trace/mbt/model.rs b/cli/src/services/mutation_trace/mbt/model.rs index 1d145e75..6cc48a33 100644 --- a/cli/src/services/mutation_trace/mbt/model.rs +++ b/cli/src/services/mutation_trace/mbt/model.rs @@ -59,6 +59,7 @@ pub(super) enum WireScopeId { Scope1, Scope2, Scope3, + Scope4, } impl From for ScopeId { @@ -69,6 +70,7 @@ impl From for ScopeId { WireScopeId::Scope1 => "scope1", WireScopeId::Scope2 => "scope2", WireScopeId::Scope3 => "scope3", + WireScopeId::Scope4 => "scope4", } .to_string(), ) diff --git a/cli/src/services/mutation_trace/mod.rs b/cli/src/services/mutation_trace/mod.rs index 3deff5a4..dcfc93f1 100644 --- a/cli/src/services/mutation_trace/mod.rs +++ b/cli/src/services/mutation_trace/mod.rs @@ -85,7 +85,6 @@ //! | `MutationEventsMatchCursorHistory` | `ResolvedAttempt::apply`'s `MutationEvent` construction | implemented directly + preserved by transition tests | `apply` derives `before_tree`/`after_tree`/`revision` from the same prepared attempt and the same `advanced_revision` the worktree update itself uses — they cannot diverge by construction; `commit_emits_exactly_one_mutation_event_with_correct_attribution_boundary_and_revision_for_a_real_change`, `attribution_transitions_from_contended_to_exclusive_across_a_close_boundary` (three events at three distinct revisions, each matching its own commit's before/after tree) | //! | `MutationEventsCrossOnlyTrustworthyProtocolStates` | `commit`'s `changed` gate (`observed_change && !needs_rebaseline`) | implemented directly + preserved by transition tests | `changed` — the sole gate for `MutationEvent` construction — is `false` whenever the pre-transition worktree has `needs_rebaseline: true`, independent of whether a real tree change was observed; `needs_rebaseline_suppresses_mutation_event_even_when_commit_observes_a_real_tree_change` proves `commit` reaches `accepted && observes && observed_change` and still emits no event and leaves the cursor unmoved | //! | `NeedsRebaselineSuppressesAttribution` | `attribution_for` | preserved by transition tests | `attribution_for_is_ineligible_unscoped_when_worktree_needs_rebaseline_even_with_an_active_scope` | -//! | `AttributionMatchesObservedScopes` | `attribution_for` | preserved by transition tests | `attribution_for_is_ai_exclusive_for_exactly_one_live_scope`, `attribution_for_is_ai_contended_for_multiple_live_scopes`, `attribution_for_is_ineligible_unscoped_when_no_scope_is_live` | //! | `AiExclusiveRequiresExactlyOneActiveScope` | `Attribution::AiExclusive(ScopeId)` | implemented directly + preserved by transition tests | `Attribution::AiExclusive(ScopeId)` does not itself make an inconsistent scope count unrepresentable (a caller could construct it with any `ScopeId`); the guarantee comes from `attribution_for`'s own algorithm, which only reaches its `AiExclusive` branch when `live.len() == 1`, wrapping that exact scope; `attribution_for_is_ai_exclusive_for_exactly_one_live_scope` | //! | `AiContendedRequiresMultipleActiveScopes` | `Attribution::AiContended` | preserved by transition tests | `attribution_for_is_ai_contended_for_multiple_live_scopes` | //! | `RejectedAttemptsDoNotCommitEvidence` | `commit`'s rejection path | preserved by transition tests | rejection returns before the `changed`/`mutation_events` step is ever reached; `rejected_attempts_do_not_commit_evidence_across_a_mixed_accept_reject_sequence`, `competing_prepared_attempts_the_second_to_commit_is_rejected_by_cas`, `taint_invalidates_a_prepared_attempt_via_stale_revision` | diff --git a/cli/src/services/mutation_trace/protocol.rs b/cli/src/services/mutation_trace/protocol.rs index 0bddc0c4..c94227f0 100644 --- a/cli/src/services/mutation_trace/protocol.rs +++ b/cli/src/services/mutation_trace/protocol.rs @@ -1,23 +1,10 @@ -//! Pure transition logic for the mutation-cursor protocol. -//! -//! Refines `liveScopesOn`/`attributionFor` (`spec/mutation_cursor.qnt:265-301`), -//! `mkMutationEvent` (`spec/mutation_cursor.qnt:303-323`), -//! `prepareAvailable`/`prepare`/`commitAttempt` -//! (`spec/mutation_cursor.qnt:417-661`), and -//! `taintHealthy`/`taint`/`recordDatabaseFailure`/`databaseFailure` -//! (`spec/mutation_cursor.qnt:663-737`), `abandonLiveScope`/`abandon` -//! (`spec/mutation_cursor.qnt:739-805`), and `recoverNeeded`/`recover` -//! (`spec/mutation_cursor.qnt:807-886`). Every function here takes and -//! returns plain [`super::types::ProtocolState`] values; none performs Git, -//! database, filesystem, environment, network, async, or lock I/O. - use std::collections::BTreeSet; use super::types::{ boundary_event_key, boundary_scope, boundary_worktree, is_advance, is_close, is_flush, is_hook, - is_start, AttemptId, AttemptState, AttemptStatus, Attribution, Boundary, FailureKind, - MutationEvent, ProtocolState, ScopeId, ScopeState, ScopeStatus, TreeId, WorktreeId, - WorktreeState, + is_start, ActorKind, AttemptId, AttemptState, AttemptStatus, Attribution, Boundary, + FailureKind, MutationEvent, ProtocolState, ScopeId, ScopeState, ScopeStatus, TreeId, + WorktreeId, WorktreeState, }; /// Advances a worktree revision counter by one, refusing to wrap past @@ -76,6 +63,38 @@ pub fn attribution_for(state: &ProtocolState, worktree: &WorktreeId) -> Attribut } } +pub fn is_codex_scope(state: &ProtocolState, scope: &ScopeId) -> bool { + state + .scopes + .get(scope) + .is_some_and(|scope_state| scope_state.actor_kind == ActorKind::Codex) +} + +pub fn boundary_confirms_scope(boundary: &Boundary, scope: &ScopeId) -> bool { + is_close(boundary) && boundary_scope(boundary).as_ref() == Some(scope) +} + +pub fn has_unconfirmed_codex_scope( + state: &ProtocolState, + live: &BTreeSet, + boundary: &Boundary, +) -> bool { + live.iter() + .any(|scope| is_codex_scope(state, scope) && !boundary_confirms_scope(boundary, scope)) +} + +pub fn attribution_for_boundary( + state: &ProtocolState, + worktree: &WorktreeId, + boundary: &Boundary, +) -> Attribution { + let live = live_scopes_on(state, worktree); + if has_unconfirmed_codex_scope(state, &live, boundary) { + return Attribution::IneligibleUnscoped; + } + attribution_for(state, worktree) +} + /// Prepares `attempt` against `boundary`, snapshotting the worktree's current /// `revision`/`cursor_tree` as the attempt's CAS baseline and `observed_tree` /// as its target `after_tree`. Refines `prepareAvailable`/`prepare` @@ -158,36 +177,6 @@ pub struct CommitOutcome { pub state: ProtocolState, } -/// Evaluates and commits `attempt`, refining `commitAttempt` -/// (`spec/mutation_cursor.qnt:455-661`) for all four boundary kinds -/// (`Start`/`Advance`/`Close`/`Flush`) in one pass. -/// -/// On rejection (`accepted == false`), only the attempt's own status moves to -/// `Rejected` (or stays as-is if it was never `Prepared`); no other durable -/// state changes, so a rejected or stale attempt never advances the -/// revision, moves the cursor, marks its event processed, or emits mutation -/// evidence. -/// -/// On acceptance, applies scope lifecycle transitions -/// (`NeverSeen`→`Active` on an accepted, observing `Start`; →`Closed` on an -/// accepted, observing `Close`), cursor advancement (`after_tree` when -/// `observes and not needs_rebaseline`, otherwise unchanged), revision -/// advancement and the attempt's `Committed` status, processed-event-key -/// recording for hook boundaries, and — when `changed` — materializes exactly -/// one `MutationEvent` (refining `mkMutationEvent`, -/// `spec/mutation_cursor.qnt:303-323`) whose `active_scopes`/`attribution` -/// are computed by [`live_scopes_on`]/[`attribution_for`] against the -/// **pre-transition** state passed into this call, exactly as `commitAttempt` -/// computes `live`/`attribution` before applying `nextScope` -/// (`spec/mutation_cursor.qnt:484-485` precede the `nextScope` `val` at line -/// 530): a `Start` boundary's emitted event never attributes the mutation to -/// the scope it is about to activate, and a `Close` boundary's emitted event -/// still attributes to the scope it is about to close. -/// -/// A no-op (evaluation flags all `false`, state unchanged) when `attempt` has -/// no prepared record or its boundary's worktree cannot be resolved — an -/// attempt only reaches this state via [`prepare`], which already refuses to -/// prepare against an unresolvable worktree. pub fn commit(state: &ProtocolState, attempt: &AttemptId) -> CommitOutcome { let Some(resolved) = ResolvedAttempt::resolve(state, attempt) else { return CommitOutcome { @@ -366,7 +355,7 @@ impl ResolvedAttempt { active_scopes: live_scopes_on(state, &self.worktree), tainted: self.worktree_state.tainted, failure_kind: self.worktree_state.failure_kind, - attribution: attribution_for(state, &self.worktree), + attribution: attribution_for_boundary(state, &self.worktree, &self.boundary), boundary: self.boundary.clone(), }); } diff --git a/cli/src/services/mutation_trace/runtime/coordinator.rs b/cli/src/services/mutation_trace/runtime/coordinator.rs index e5f2a960..7e6816cd 100644 --- a/cli/src/services/mutation_trace/runtime/coordinator.rs +++ b/cli/src/services/mutation_trace/runtime/coordinator.rs @@ -491,6 +491,7 @@ where #[cfg(test)] mod tests { use std::cell::{Cell, RefCell}; + use std::collections::BTreeSet; use std::collections::VecDeque; use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; @@ -950,10 +951,149 @@ mod tests { assert_contended_attribution( "ac5-different-actor", ActorKind::ClaudeCode, - ActorKind::Codex, + ActorKind::OpenCode, ); } + #[test] + fn an_unconfirmed_codex_scope_makes_another_harness_boundary_ineligible() { + let (db, db_path) = test_db("codex-unconfirmed-cross-harness"); + let worktree = WorktreeId("wt-1".to_string()); + let codex = ScopeId("codex-a".to_string()); + let claude = ScopeId("claude-c".to_string()); + let capture = FakeSnapshotCapture::new(TreeId("tree-a".to_string())); + + coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Start { + scope: codex.clone(), + event: EventId("evt-codex-start".to_string()), + actor_kind: ActorKind::Codex, + }, + false, + ) + .expect("starting the Codex scope should succeed"); + coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Start { + scope: claude.clone(), + event: EventId("evt-claude-start".to_string()), + actor_kind: ActorKind::ClaudeCode, + }, + false, + ) + .expect("starting the Claude scope should succeed"); + + capture.push_success(TreeId("tree-b".to_string())); + let outcome = coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Close { + scope: claude.clone(), + event: EventId("evt-claude-close".to_string()), + actor_kind: ActorKind::ClaudeCode, + }, + false, + ) + .expect("closing the Claude scope should succeed"); + + let event = outcome + .mutation_event + .expect("a real tree change with two live scopes should still commit an event"); + assert_eq!( + event.active_scopes, + BTreeSet::from([codex.clone(), claude.clone()]) + ); + assert_eq!(event.attribution, Attribution::IneligibleUnscoped); + assert_ne!(event.attribution, Attribution::AiContended); + + capture.push_success(TreeId("tree-c".to_string())); + let confirmed = coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Close { + scope: codex.clone(), + event: EventId("evt-codex-close".to_string()), + actor_kind: ActorKind::Codex, + }, + false, + ) + .expect("closing the Codex scope should succeed"); + + let confirmed_event = confirmed + .mutation_event + .expect("the Codex Close should commit its own observed change"); + assert_eq!(confirmed_event.active_scopes, BTreeSet::from([codex])); + assert_eq!( + confirmed_event.attribution, + Attribution::AiExclusive(ScopeId("codex-a".to_string())) + ); + + remove_test_db(&db_path); + } + + #[test] + fn a_confirming_codex_close_contends_with_a_live_non_codex_scope() { + let (db, db_path) = test_db("codex-confirmed-cross-harness"); + let worktree = WorktreeId("wt-1".to_string()); + let codex = ScopeId("codex-a".to_string()); + let claude = ScopeId("claude-c".to_string()); + let capture = FakeSnapshotCapture::new(TreeId("tree-a".to_string())); + + coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Start { + scope: codex.clone(), + event: EventId("evt-codex-start".to_string()), + actor_kind: ActorKind::Codex, + }, + false, + ) + .expect("starting the Codex scope should succeed"); + coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Start { + scope: claude.clone(), + event: EventId("evt-claude-start".to_string()), + actor_kind: ActorKind::ClaudeCode, + }, + false, + ) + .expect("starting the Claude scope should succeed"); + + capture.push_success(TreeId("tree-b".to_string())); + let outcome = coordinate_boundary( + &db, + &capture, + &worktree, + &RuntimeBoundary::Close { + scope: codex.clone(), + event: EventId("evt-codex-close".to_string()), + actor_kind: ActorKind::Codex, + }, + false, + ) + .expect("closing the Codex scope should succeed"); + + let event = outcome + .mutation_event + .expect("a real tree change observed at the Codex Close should commit an event"); + assert_eq!(event.active_scopes, BTreeSet::from([codex, claude])); + assert_eq!(event.attribution, Attribution::AiContended); + + remove_test_db(&db_path); + } + #[test] fn cas_conflict_reloads_and_recomputes_without_a_second_snapshot() { const WRITERS: usize = 3; diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs index 0b958de0..6165df15 100644 --- a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs @@ -301,6 +301,38 @@ fn a_surviving_ai_mutation_line_is_attributed() { assert_eq!(attr.barrier, None); } +#[test] +fn an_ineligible_unscoped_transition_never_becomes_ai_mutation_lineage() { + let page_source = FakePageSource::new(vec![page_row( + 1, + "t0", + "t1", + AttributionKind::IneligibleUnscoped, + None, + )]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo")]), + &tree("t1"), + Some(1), + ); + + assert_eq!(attr.reconstructed_events, 1); + assert!(ai_contents(&attr).is_empty()); + assert!(unresolved_contents(&attr).is_empty()); +} + #[test] fn ai_mutation_survives_an_unrelated_later_mutation() { let page_source = FakePageSource::new(vec![ diff --git a/cli/src/services/mutation_trace/runtime/tests.rs b/cli/src/services/mutation_trace/runtime/tests.rs index d193f99e..c55e281f 100644 --- a/cli/src/services/mutation_trace/runtime/tests.rs +++ b/cli/src/services/mutation_trace/runtime/tests.rs @@ -14,8 +14,8 @@ use crate::services::mutation_trace::store::{ encode_revision, CasResult, DurableTransition, MutationTraceStore, }; use crate::services::mutation_trace::types::{ - boundary_event_key, boundary_scope, ActorKind, AttemptId, Boundary, EventId, FailureKind, - ScopeId, ScopeStatus, WorktreeId, + boundary_event_key, boundary_scope, ActorKind, AttemptId, Attribution, Boundary, EventId, + FailureKind, ScopeId, ScopeStatus, WorktreeId, }; use crate::services::patch::{parse_patch, ParsedPatch}; @@ -2393,3 +2393,134 @@ fn a_relevant_event_behind_128_newer_events_is_never_loaded_or_reconstructed() { "the committed line stays unresolved because its only match was never inspected" ); } + +fn patch_is_empty(patch: &ParsedPatch) -> bool { + patch + .files + .iter() + .all(|file| file.hunks.iter().all(|hunk| hunk.lines.is_empty())) +} + +fn drive_codex_overlap_transition( + label: &str, + closing_scope: &ScopeId, + closing_actor: ActorKind, +) -> (Attribution, ParsedPatch, ParsedPatch) { + let repo = TestRepo::new(label); + let git_dir = resolve_git_dir(&repo.repo_root).expect("git dir should resolve"); + let checkout_id = + get_or_create_checkout_id(&git_dir).expect("checkout identity should resolve"); + let snapshot = + GitSnapshotService::new(&repo.repo_root).expect("a snapshot service should build"); + let ok_db = || repo.open_db(); + + std::fs::write(repo.repo_root.join("file.rs"), b"one\n").expect("the baseline write"); + coordinate(&repo.repo_root, &RuntimeBoundary::Flush, ok_db) + .expect("the baseline observation should materialize the worktree"); + + let codex = ScopeId("codex-a".to_string()); + let claude = ScopeId("claude-c".to_string()); + coordinate( + &repo.repo_root, + &RuntimeBoundary::Start { + scope: codex.clone(), + event: EventId("evt-codex-start".to_string()), + actor_kind: ActorKind::Codex, + }, + ok_db, + ) + .expect("starting the Codex scope should succeed"); + coordinate( + &repo.repo_root, + &RuntimeBoundary::Start { + scope: claude.clone(), + event: EventId("evt-claude-start".to_string()), + actor_kind: ActorKind::ClaudeCode, + }, + ok_db, + ) + .expect("starting the Claude scope should succeed"); + + std::fs::write(repo.repo_root.join("file.rs"), b"one\ntwo\n").expect("the mutating write"); + let outcome = coordinate( + &repo.repo_root, + &RuntimeBoundary::Close { + scope: closing_scope.clone(), + event: EventId("evt-close".to_string()), + actor_kind: closing_actor, + }, + ok_db, + ) + .expect("the closing boundary should succeed"); + + let event = outcome + .mutation_event + .expect("the observed tree change should commit a mutation event"); + assert_eq!( + event.active_scopes, + std::collections::BTreeSet::from([codex, claude]), + "the full protocol live set is still recorded on the event" + ); + + let after = snapshot + .capture_tree() + .expect("capturing the committed tree should succeed"); + let db = repo.db(); + let store = MutationTraceStore::new(&db); + let committed = parse_patch( + "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", + None, + ) + .expect("the committed patch should parse"); + let attribution = resolve_bounded_mutation_attribution( + &store, + &snapshot, + &WorktreeId(checkout_id), + &ParsedPatch { files: Vec::new() }, + &committed, + &after, + None, + ); + + ( + event.attribution, + attribution.result.mutation_ai_patch, + attribution.result.resolved_non_ai_patch, + ) +} + +#[test] +fn an_unconfirmed_codex_overlap_never_reaches_the_mutation_ai_patch() { + let (attribution, ai_patch, non_ai_patch) = drive_codex_overlap_transition( + "codex-unconfirmed-lineage", + &ScopeId("claude-c".to_string()), + ActorKind::ClaudeCode, + ); + + assert_eq!(attribution, Attribution::IneligibleUnscoped); + assert_ne!(attribution, Attribution::AiContended); + assert!( + patch_is_empty(&ai_patch), + "an ambiguous transition must never become AI mutation lineage" + ); + assert!( + !patch_is_empty(&non_ai_patch), + "the line is still resolved, just not as AI" + ); +} + +#[test] +fn a_confirmed_codex_close_overlap_is_contended_and_still_not_ai_lineage() { + let (attribution, ai_patch, non_ai_patch) = drive_codex_overlap_transition( + "codex-confirmed-lineage", + &ScopeId("codex-a".to_string()), + ActorKind::Codex, + ); + + assert_eq!(attribution, Attribution::AiContended); + assert!( + patch_is_empty(&ai_patch), + "only AiExclusive becomes AI mutation lineage" + ); + assert!(!patch_is_empty(&non_ai_patch)); +} diff --git a/cli/src/services/mutation_trace/tests.rs b/cli/src/services/mutation_trace/tests.rs index aa246e36..f000ad58 100644 --- a/cli/src/services/mutation_trace/tests.rs +++ b/cli/src/services/mutation_trace/tests.rs @@ -37,9 +37,21 @@ fn healthy_worktree(cursor_tree: TreeId, revision: u64) -> WorktreeState { } fn scope_with_status(status: ScopeStatus, worktree_id: WorktreeId) -> ScopeState { + scope_with_actor(status, ActorKind::ClaudeCode, worktree_id) +} + +fn codex_scope(status: ScopeStatus, worktree_id: WorktreeId) -> ScopeState { + scope_with_actor(status, ActorKind::Codex, worktree_id) +} + +fn scope_with_actor( + status: ScopeStatus, + actor_kind: ActorKind, + worktree_id: WorktreeId, +) -> ScopeState { ScopeState { status, - actor_kind: ActorKind::Codex, + actor_kind, worktree_id, } } @@ -997,6 +1009,220 @@ fn commit_close_on_the_sole_live_scope_that_also_observes_a_change_still_counts_ assert_eq!(event.active_scopes, BTreeSet::from([scope("scope0")])); } +fn state_with_scopes(scopes: &[(&str, ScopeState)]) -> ProtocolState { + let mut state = ProtocolState::default(); + state + .worktrees + .insert(worktree("wt0"), healthy_worktree(tree("tree0"), 0)); + for (id, scope_state) in scopes { + state.scopes.insert(scope(id), scope_state.clone()); + } + state +} + +fn commit_boundary(state: &ProtocolState, boundary: Boundary, observed: TreeId) -> MutationEvent { + let prepared = prepare(state, attempt_id("attempt0"), boundary, observed); + let outcome = commit(&prepared, &attempt_id("attempt0")); + assert!(outcome.evaluation.changed); + assert_eq!(outcome.state.mutation_events.len(), 1); + outcome + .state + .mutation_events + .into_iter() + .next() + .expect("exactly one mutation event") +} + +#[test] +fn an_unconfirmed_codex_scope_makes_another_harness_boundary_ineligible_instead_of_contended() { + let state = state_with_scopes(&[ + ("codex-a", codex_scope(ScopeStatus::Active, worktree("wt0"))), + ( + "claude-c", + scope_with_status(ScopeStatus::Active, worktree("wt0")), + ), + ]); + + let event = commit_boundary( + &state, + Boundary::Advance { + scope: scope("claude-c"), + event: event("event0"), + }, + tree("tree1"), + ); + + assert_eq!( + event.active_scopes, + BTreeSet::from([scope("codex-a"), scope("claude-c")]) + ); + assert_eq!(event.attribution, Attribution::IneligibleUnscoped); + assert_ne!(event.attribution, Attribution::AiContended); +} + +#[test] +fn a_codex_close_confirms_its_own_scope_and_still_attributes_exclusively() { + let state = + state_with_scopes(&[("codex-a", codex_scope(ScopeStatus::Active, worktree("wt0")))]); + + let event = commit_boundary( + &state, + Boundary::Close { + scope: scope("codex-a"), + event: event("event0"), + }, + tree("tree1"), + ); + + assert_eq!(event.active_scopes, BTreeSet::from([scope("codex-a")])); + assert_eq!( + event.attribution, + Attribution::AiExclusive(scope("codex-a")) + ); +} + +#[test] +fn a_codex_close_overlapping_a_live_non_codex_scope_still_attributes_contention() { + let state = state_with_scopes(&[ + ("codex-a", codex_scope(ScopeStatus::Active, worktree("wt0"))), + ( + "claude-c", + scope_with_status(ScopeStatus::Active, worktree("wt0")), + ), + ]); + + let event = commit_boundary( + &state, + Boundary::Close { + scope: scope("codex-a"), + event: event("event0"), + }, + tree("tree1"), + ); + + assert_eq!( + event.active_scopes, + BTreeSet::from([scope("codex-a"), scope("claude-c")]) + ); + assert_eq!(event.attribution, Attribution::AiContended); +} + +#[test] +fn a_second_live_codex_scope_suppresses_attribution_at_a_confirming_codex_close() { + let state = state_with_scopes(&[ + ("codex-a", codex_scope(ScopeStatus::Active, worktree("wt0"))), + ("codex-b", codex_scope(ScopeStatus::Active, worktree("wt0"))), + ]); + + let event = commit_boundary( + &state, + Boundary::Close { + scope: scope("codex-a"), + event: event("event0"), + }, + tree("tree1"), + ); + + assert_eq!( + event.active_scopes, + BTreeSet::from([scope("codex-a"), scope("codex-b")]) + ); + assert_eq!(event.attribution, Attribution::IneligibleUnscoped); +} + +#[test] +fn two_live_non_codex_scopes_still_attribute_contention() { + let state = state_with_scopes(&[ + ( + "claude-a", + scope_with_actor(ScopeStatus::Active, ActorKind::ClaudeCode, worktree("wt0")), + ), + ( + "pi-b", + scope_with_actor(ScopeStatus::Active, ActorKind::Pi, worktree("wt0")), + ), + ]); + + let event = commit_boundary( + &state, + Boundary::Advance { + scope: scope("claude-a"), + event: event("event0"), + }, + tree("tree1"), + ); + + assert_eq!(event.attribution, Attribution::AiContended); +} + +#[test] +fn a_single_live_non_codex_scope_still_attributes_exclusively_at_its_close() { + let state = state_with_scopes(&[( + "claude-a", + scope_with_status(ScopeStatus::Active, worktree("wt0")), + )]); + + let event = commit_boundary( + &state, + Boundary::Close { + scope: scope("claude-a"), + event: event("event0"), + }, + tree("tree1"), + ); + + assert_eq!( + event.attribution, + Attribution::AiExclusive(scope("claude-a")) + ); +} + +#[test] +fn a_flush_never_confirms_a_live_codex_scope() { + let state = + state_with_scopes(&[("codex-a", codex_scope(ScopeStatus::Active, worktree("wt0")))]); + + let event = commit_boundary( + &state, + Boundary::Flush { + worktree: worktree("wt0"), + }, + tree("tree1"), + ); + + assert_eq!(event.active_scopes, BTreeSet::from([scope("codex-a")])); + assert_eq!(event.attribution, Attribution::IneligibleUnscoped); +} + +#[test] +fn a_terminal_codex_scope_does_not_suppress_later_attribution() { + for terminal in [ScopeStatus::Closed, ScopeStatus::Abandoned] { + let state = state_with_scopes(&[ + ("codex-a", codex_scope(terminal, worktree("wt0"))), + ( + "claude-c", + scope_with_status(ScopeStatus::Active, worktree("wt0")), + ), + ]); + + let event = commit_boundary( + &state, + Boundary::Advance { + scope: scope("claude-c"), + event: event("event0"), + }, + tree("tree1"), + ); + + assert_eq!(event.active_scopes, BTreeSet::from([scope("claude-c")])); + assert_eq!( + event.attribution, + Attribution::AiExclusive(scope("claude-c")), + "a {terminal:?} Codex scope is not live and must not suppress attribution" + ); + } +} + #[test] fn taint_changes_exactly_tainted_failure_kind_and_revision() { let mut state = ProtocolState::default(); @@ -1125,7 +1351,7 @@ fn abandon_transitions_a_live_scope_without_moving_the_cursor_or_changing_identi let abandoned = next.scopes.get(&scope("scope0")).unwrap(); assert_eq!(abandoned.status, ScopeStatus::Abandoned); - assert_eq!(abandoned.actor_kind, ActorKind::Codex); + assert_eq!(abandoned.actor_kind, ActorKind::ClaudeCode); assert_eq!(abandoned.worktree_id, worktree("wt0")); let owning_worktree = next.worktrees.get(&worktree("wt0")).unwrap(); @@ -1251,7 +1477,7 @@ fn abandon_succeeds_for_a_live_scope_on_a_snapshot_tainted_worktree() { let abandoned = next.scopes.get(&scope("scope0")).unwrap(); assert_eq!(abandoned.status, ScopeStatus::Abandoned); - assert_eq!(abandoned.actor_kind, ActorKind::Codex); + assert_eq!(abandoned.actor_kind, ActorKind::ClaudeCode); assert_eq!(abandoned.worktree_id, worktree("wt0")); let owning_worktree = next.worktrees.get(&worktree("wt0")).unwrap(); diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index c951d4f3..0b8865f6 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -134,7 +134,8 @@ turn, not a delegated agent. Sequential tracked tool calls are sequential scopes. Whether two Codex mutation-capable executions can genuinely overlap (and therefore whether Codex alone can produce `AiContended`) is answered by T01 below. `AiContended` can still arise from a Codex *tracked* scope -overlapping another harness's scope on the same worktree regardless; see D14. +overlapping another harness's scope on the same worktree, but only at the Codex +scope's own confirming `Close`; see D14. This is a **Codex adapter coverage policy**. It does **not** broaden or narrow the generic mutation-scope runtime contract, which already models exclusivity @@ -168,7 +169,9 @@ among the tracked scopes it is told about, not exhaustive filesystem authorship `Bash` / `apply_patch` executed strictly serially across all 11 built-in probes, so built-in Codex-alone overlap was never observed. Cross-harness `AiContended` (a Codex tracked scope overlapping a Claude/OpenCode/Pi scope) - remains reachable regardless; see D14. + remains reachable, but only at the Codex scope's own confirming `Close`; the + same overlap observed at the other harness's boundary is `IneligibleUnscoped`. + See D14. The adapter still never collapses two executions into one `ScopeId`. Evidence: `fixtures/probe01…`, `probe02…`, `probe08-subagent-delegation.*`, `probe16-mcp-parallel-server-optin.*`, `probe17-mcp-parallel-readonly-hint.*` + @@ -1051,12 +1054,54 @@ lifecycle completion. Two simultaneously-live **tracked** scopes (two tracked Codex executions per D1, or a tracked Codex execution overlapping a Claude/OpenCode/Pi execution on the -same worktree) carry distinct `ScopeId`s, so the runtime can report `AiContended` -for a tree transition observed while both are live. The adapter never collapses -two executions into one `ScopeId`. +same worktree) carry distinct `ScopeId`s. The adapter never collapses two +executions into one `ScopeId`. -**The generic mutation-scope semantic is preserved exactly and needs no -protocol or Quint change:** +**Refined by the fifth follow-up (2026-09-08, PR #268) — Codex `Start` is not +execution confirmation.** The earlier statement that such an overlap can simply +produce `AiContended` was too broad. The refined rule, implemented in +`spec/mutation_cursor.qnt` (`attributionForBoundary`) and +`mutation_trace/protocol.rs` (`attribution_for_boundary`): + +```text +Codex v1 Start is an admission/write-ahead scope boundary, not positive proof +that the tool ultimately executed, because arbitrary sibling PreToolUse hooks +can deny after SCE's Start hook succeeds. + +While a live Codex scope remains unconfirmed, any mutation transition observed +at a boundary that does not positively confirm that Codex scope is attribution- +ineligible. In particular, another harness boundary cannot produce +AiContended merely by overlapping an unconfirmed Codex scope. + +A tracked Codex scope becomes positively confirmed for attribution at its own +proven PostToolUse -> Close boundary. At that boundary, normal AiExclusive / +AiContended semantics apply if no other unconfirmed Codex scope remains. +``` + +An **unconfirmed live Codex scope** is a scope that is live (`Active`), whose +`actor_kind` is `Codex`, and which the *current* boundary does not confirm — the +only confirming boundary being `Close` on that exact scope. `Flush`, a later +`Start` (including `Start(B)` in the same lane), and any other harness's +`Advance`/`Close`/`Start` confirm nothing. Confirmation is derived per boundary +from existing durable state (`ScopeState.status`, `ScopeState.actor_kind`, and +the boundary's own scope); **no `confirmed` bit is persisted**, and after `Close` +the scope is terminal anyway, so no persistent confirmation state is needed. + +Any unconfirmed live Codex scope on the worktree forces `IneligibleUnscoped` for +the whole transition — the uncertain scope is **not** merely dropped from the +live set so the remaining harness can be attributed, because that would still be +a positive claim under incomplete knowledge. `MutationEvent.active_scopes` still +records the complete actual live set (e.g. `{codex-A, claude-C}` with +`attribution = IneligibleUnscoped`); only attribution eligibility changes. + +**This is an intentional false negative.** When Codex A and Claude C are both +genuinely running and a Claude boundary observes a mutation before +`PostToolUse(A)`, SCE now reports `IneligibleUnscoped` rather than the true +`AiContended`, because the same observable protocol state is equally compatible +with "A was denied by an arbitrary sibling hook and never ran". This is the exact +application of `false negatives > false positives` for mutation attribution. + +**The generic mutation-scope semantic is otherwise preserved exactly:** ```text AiExclusive(scope) == exactly one tracked mutation scope was live in the interval @@ -1085,12 +1130,19 @@ require a protocol or Quint change. still report `AiExclusive(Bash)`, which per the semantic above means "the only *tracked* scope live", **not** "MCP did not mutate". T06 documents this explicitly (see the tracked-tool-plus-MCP-overlap regression). -- **Cross-harness: `AiContended` remains reachable** — a tracked Codex scope - overlapping a Claude/OpenCode/Pi scope on the same worktree. -The T06 concurrency regression (AC10) crosses harnesses. There is **no** -MCP-overlap `AiContended` regression because MCP produces no tracked scopes; T06 -instead adds a `Bash`-overlapping-MCP regression that asserts the -`AiExclusive` = tracked-scope-exclusivity (not sole-authorship) semantic. +- **Cross-harness: `AiContended` remains reachable, but only at a confirming + Codex `Close`** — a tracked Codex scope overlapping a Claude/OpenCode/Pi scope + on the same worktree, where the observing boundary is `Close` on that Codex + scope. The same overlap observed at the *other* harness's boundary is + `IneligibleUnscoped`. +The T06 concurrency regression (AC10) crosses harnesses and must exercise **both** +directions. There is **no** MCP-overlap `AiContended` regression because MCP +produces no tracked scopes; T06 instead adds a `Bash`-overlapping-MCP regression +that asserts the `AiExclusive` = tracked-scope-exclusivity (not sole-authorship) +semantic. MCP / unknown / delegation remain Option B (`Untracked`, allowed, no +mutation scope) and neither confirm nor invalidate a live Codex scope; an MCP +mutation may still occur while Codex A is unconfirmed and continues to follow the +existing conservative/untracked attribution path. ### D15 — Raw Codex hook cwd is authoritative — T01-GATED @@ -1572,15 +1624,30 @@ performs final validation. sweep, seam order, fail-closed, lane scoping); T06 built-in failed-A-then-B regression (assert A `Abandoned` or `Closed` per tool, B the only live scope at its `Start`, no false `AiContended`). -- [ ] AC10: Two simultaneously-live **tracked** scopes produce `AiContended` for - a tree transition observed while both are live; the adapter never assigns them - one shared `ScopeId`. Per D14 the exercised form is a **tracked Codex scope - overlapping a second harness's scope** on the same worktree. There is **no** - MCP-derived `AiContended` (MCP is `Untracked`); the `Bash`-overlapping-MCP case - is AC9f, and asserts `AiExclusive` = tracked-scope exclusivity, not sole - authorship. - - Validate: T06 cross-harness concurrency regression; the plan records the D14 - form exercised. +- [ ] AC10: Two simultaneously-live **tracked** scopes never collapse into one + shared `ScopeId`, and a tree transition observed while both are live is + attributed per the refined D14 confirmation rule: + 1. observed at a boundary that does **not** confirm the live Codex scope + (the other harness's `Advance`/`Close`/`Start`, or a `Flush`) -> + `IneligibleUnscoped`, never `AiContended`, with `active_scopes` still + carrying both scopes; + 2. observed at the Codex scope's own `Close` (its proven `PostToolUse` + terminus), with no other unconfirmed live Codex scope -> + `AiContended`. + A second live Codex scope suppresses (2) back to `IneligibleUnscoped`. There is + **no** MCP-derived `AiContended` (MCP is `Untracked`); the + `Bash`-overlapping-MCP case is AC9f, and asserts `AiExclusive` = tracked-scope + exclusivity, not sole authorship. + - Validate: T06 cross-harness concurrency regression exercising **both** + directions above; protocol/runtime regressions already landed with the fifth + follow-up (`mutation_trace::tests` + `an_unconfirmed_codex_scope_makes_another_harness_boundary_ineligible_instead_of_contended`, + `a_codex_close_overlapping_a_live_non_codex_scope_still_attributes_contention`, + `a_second_live_codex_scope_suppresses_attribution_at_a_confirming_codex_close`; + `mutation_trace::runtime::tests` + `an_unconfirmed_codex_overlap_never_reaches_the_mutation_ai_patch`, + `a_confirmed_codex_close_overlap_is_contended_and_still_not_ai_lineage`); the + plan records the D14 form exercised. - [ ] AC11: An outstanding **tracked** execution with no terminal hook is retired by exactly the Codex lifecycle signals T01 marked load-bearing (D12), via `abandon_scope`, leaving the worktree `needs_rebaseline`. The D12 sweeps @@ -2784,15 +2851,70 @@ Persist this field in every plan; this is durable plan state, not chat state: a false `AiContended` against A for that transition. This is the same exposure class the third follow-up recorded; this fix shrinks the window (an in-turn same-lane tracked successor now retires A immediately instead - of waiting for the turn boundary) but does not eliminate it. It is bounded - to one turn by D12/D13 and does **not** block T06, which proves the - SCE-owned production path. Not claimed as "arbitrary-blocker safety fully - solved"; recorded as a remaining bounded exposure, tracked for T06/T07 to - weigh against a broader cleanup signal only if future evidence justifies - one. + of waiting for the turn boundary) but does not eliminate it. + **RESOLVED by the fifth follow-up below (2026-09-08, PR #268)** — the + protocol-level unconfirmed-Codex uncertainty rule now withholds positive + attribution for exactly this window, so no false `AiContended` / + `AiExclusive` can be emitted against a zombie A. The lane-scoped successor + sweep is unchanged and still required: the two solve different problems — + the adapter Case-B sweep *removes* stale A once a proven same-lane Codex + successor arrives; the protocol uncertainty rule *prevents positive + attribution* before such cleanup is possible. - Matcher and MCP behaviour are unchanged; no MCP tracking, MCP denial, unknown-tool denial, PID supervision, polling, timeout-as-evidence, or global/cross-session sweep was added. + - Fifth concurrency follow-up (2026-09-08 — unconfirmed-Codex attribution + uncertainty, PR #268): closes the cross-harness zombie window the fourth + follow-up left open. The Codex adapter **cannot** solve it: at the moment + another harness's boundary arrives, `ScopeState { actor_kind: Codex, status: + Active }` is the identical observable for both "A is genuinely running" and + "A was denied by an arbitrary sibling `PreToolUse` hook after SCE emitted + `Start(A)`", and the aggregate Codex `PreToolUse` decision is never exposed + to SCE — so adapter-local cleanup cannot decide whether A is stale. The fix + is therefore protocol/formal-model level, per the refined D14 rule. + - Model change: `spec/mutation_cursor.qnt` gains `isCodexScope`, + `boundaryConfirmsScope`, `hasUnconfirmedCodexScope` and + `attributionForBoundary`; `commitAttempt` now builds its `MutationEvent` + with `attributionForBoundary(worktree, boundary)`. `Scope4` (Codex on + `WT0`) is added so two simultaneously live Codex scopes are representable. + - Rust refinement: `mutation_trace/protocol.rs` gains `is_codex_scope`, + `boundary_confirms_scope`, `has_unconfirmed_codex_scope` and + `attribution_for_boundary`; `ResolvedAttempt::apply` uses it. `MBT` driver + and wire model gain `scope4`, so Quint Connect keeps replaying the same + semantics through the real `protocol.rs`. + - New formal invariants (all in `SafetyAttribution`): + `NoPositiveAttributionWithUnconfirmedCodexScope`, + `UnconfirmedCodexScopeBlocksCrossHarnessAttribution`, + `MultipleLiveCodexScopesSuppressPositiveAttribution`; + `AttributionMatchesObservedScopes` gained the unconfirmed-Codex branch. + Reachability witnesses `HasCodexConfirmedExclusiveEvidence`, + `HasCodexConfirmedContendedEvidence`, + `HasUnconfirmedCodexSuppressedEvidence` keep Codex attribution from + becoming permanently ineligible. New deterministic runs: + `testUnconfirmedCodexScopeBlocksCrossHarnessContention`, + `testUnconfirmedCodexScopeBlocksExclusiveAttribution`, + `testFlushDoesNotConfirmCodexScope`, + `testCodexCloseConfirmsExclusiveAttribution`, + `testSecondLiveCodexScopeSuppressesConfirmedCodexClose`, + `testTerminalCodexScopeDoesNotSuppressAttribution`; + `testDifferentActorStartKeepsExistingScope` now observes its transition at + the confirming Codex `Close`. No existing invariant was weakened or + skipped. + - Downstream: `mutation_attribution.rs` needed no production change — only + healthy `AiExclusive` becomes `TransitionOrigin::MutationAi`, so an + `IneligibleUnscoped` transition can never enter `mutation_ai_patch`. That + is frozen by + `an_ineligible_unscoped_transition_never_becomes_ai_mutation_lineage` and + by the real-coordinator end-to-end regressions. + - Unchanged: `Start` at `PreToolUse` (still write-ahead, still required for + mutation coverage), `AttemptKey`, `ScopeId`/`EventId` formatting, the + `(session_id, turn_id)` lane and its successor sweep, Bash policy + preflight, boundary lock, recovery state, adapter-state version, MCP / + unknown / delegation Option-B routing. **No** new DB column, `ScopeStatus`, + `ActorKind`, `Attribution` variant, adapter-state field, migration, or + schema field: `ScopeState.actor_kind` / `ScopeState.status` plus the + boundary's own scope already determine confirmation for the current + boundary. The Codex adapter itself has no production change. - Context impact: domain — a new adapter-domain driver plus a new hidden CLI route. User-visible surface: one hidden `sce hooks codex-mutation-scope` subcommand (hidden from `sce --help` / `sce hooks --help`); no visible-help diff --git a/spec/mutation_cursor.md b/spec/mutation_cursor.md index 7def4f11..096bc1b0 100644 --- a/spec/mutation_cursor.md +++ b/spec/mutation_cursor.md @@ -125,14 +125,25 @@ new scope → Active `ScopeId` is the session/scope identity. If a real session is stale, production must establish that through an explicit session/process/generation guarantee and invoke abandonment or recovery; harness type alone is not sufficient. Until then, subsequent work is `AiContended` while two or more scopes are active. -Attribution remains: +Attribution is computed for the transition observed *at a boundary*, and is: -- zero active AI scopes → `IneligibleUnscoped`; -- one active AI scope → `AiExclusive(scope)`; -- two or more active AI scopes → `AiContended`. +- any unconfirmed live Codex scope on the worktree → `IneligibleUnscoped`; +- otherwise zero active AI scopes → `IneligibleUnscoped`; +- otherwise one active AI scope → `AiExclusive(scope)`; +- otherwise two or more active AI scopes → `AiContended`. Failure and external-taint states can only weaken attribution to `IneligibleUnscoped`; they never strengthen it. +## Unconfirmed Codex scopes + +A Codex mutation scope's `Start` is a write-ahead admission boundary. It records that SCE established the scope before the harness's aggregate pre-tool decision was known — not that the tool ultimately executed. An arbitrary third-party sibling pre-tool hook can deny the execution after SCE's own `Start` succeeded, and the harness exposes no aggregate-denial signal, so the resulting scope state is indistinguishable from a genuinely running one. + +A live Codex scope is therefore **unconfirmed** at every boundary except its own `Close`. Its `Close` is driven by the post-tool signal, which a denied tool never reaches, so that boundary is positive evidence the tool actually executed. One boundary closes at most one scope, so any *other* live Codex scope stays unconfirmed even there. + +While a worktree has any unconfirmed live Codex scope, the whole transition is `IneligibleUnscoped` — the uncertain scope is not merely dropped from the live set and the remaining scopes attributed, because that would still be a positive attribution claim made under incomplete knowledge. `MutationEvent.activeScopes` still records the complete actual live set; only attribution eligibility changes. + +This deliberately produces a false negative (real contention reported as ineligible) rather than a false positive (a zombie scope reported as contending or exclusive). + ## Verification properties and scenarios The model includes safety properties covering: @@ -147,9 +158,12 @@ The model includes safety properties covering: - same-actor and different-actor contention; - `AiExclusive` requiring exactly one active scope; - `AiContended` requiring multiple active scopes; +- no positive attribution while an unconfirmed live Codex scope exists; +- a boundary that does not confirm a live Codex scope never contending with it; +- a second live Codex scope suppressing attribution even at a confirming `Close`; - CAS/replay safety and cursor/evidence consistency. -Deterministic runs cover database-unavailable state preservation, external-taint recovery, abandoned-scope non-reactivation, and same-actor and different-actor contention. +Deterministic runs cover database-unavailable state preservation, external-taint recovery, abandoned-scope non-reactivation, same-actor and different-actor contention, an unconfirmed Codex scope blocking cross-harness contention, a `Flush` never confirming a Codex scope, a Codex `Close` confirming both exclusive and contended attribution, a second live Codex scope suppressing a confirming `Close`, and a terminal Codex scope not suppressing later attribution. ## Implementation refinement diff --git a/spec/mutation_cursor.qnt b/spec/mutation_cursor.qnt index 1c4742fd..6a2a5614 100644 --- a/spec/mutation_cursor.qnt +++ b/spec/mutation_cursor.qnt @@ -1,7 +1,7 @@ module mutation_cursor { type WorktreeId = WT0 | WT1 type ActorKind = ClaudeCode | Codex | OpenCode | Pi - type ScopeId = Scope0 | Scope1 | Scope2 | Scope3 + type ScopeId = Scope0 | Scope1 | Scope2 | Scope3 | Scope4 type TreeId = Tree0 | Tree1 | Tree2 | Tree3 type EventId = | Event0 @@ -134,7 +134,7 @@ module mutation_cursor { | MbtStutter val WORKTREES: Set[WorktreeId] = Set(WT0, WT1) - val SCOPES: Set[ScopeId] = Set(Scope0, Scope1, Scope2, Scope3) + val SCOPES: Set[ScopeId] = Set(Scope0, Scope1, Scope2, Scope3, Scope4) val TREES: Set[TreeId] = Set(Tree0, Tree1, Tree2, Tree3) val EVENTS: Set[EventId] = Set( Event0, @@ -171,6 +171,7 @@ module mutation_cursor { | Scope1 => WT0 | Scope2 => WT0 | Scope3 => WT1 + | Scope4 => WT0 } pure def scopeActor(scope: ScopeId): ActorKind = @@ -179,6 +180,7 @@ module mutation_cursor { | Scope1 => ClaudeCode | Scope2 => Codex | Scope3 => OpenCode + | Scope4 => Codex } pure def isLive(status: ScopeStatus): bool = status == Active @@ -257,8 +259,10 @@ module mutation_cursor { Scope1 } else if (scopes.contains(Scope2)) { Scope2 - } else { + } else if (scopes.contains(Scope3)) { Scope3 + } else { + Scope4 } var worktrees: WorktreeId -> WorktreeState @@ -318,6 +322,24 @@ module mutation_cursor { } } + def isCodexScope(scope: ScopeId): bool = + scopes.get(scope).actorKind == Codex + + pure def boundaryConfirmsScope(boundary: Boundary, scope: ScopeId): bool = + isClose(boundary) and boundaryScope(boundary) == scope + + def hasUnconfirmedCodexScope(live: Set[ScopeId], boundary: Boundary): bool = + live.exists(scope => + isCodexScope(scope) and not(boundaryConfirmsScope(boundary, scope)) + ) + + def attributionForBoundary(worktree: WorktreeId, boundary: Boundary): Attribution = + if (hasUnconfirmedCodexScope(liveScopesOn(worktree), boundary)) { + IneligibleUnscoped + } else { + attributionFor(worktree) + } + pure def mkMutationEvent( worktree: WorktreeId, revision: int, @@ -510,7 +532,7 @@ module mutation_cursor { val changed = observedChange and not(state.needsRebaseline) val advancesRevision = accepted and (not(isFlush(boundary)) or observedChange) val live = liveScopesOn(worktree) - val attribution = attributionFor(worktree) + val attribution = attributionForBoundary(worktree, boundary) val emitted: Set[MutationEvent] = if (changed) { Set(mkMutationEvent( @@ -1250,6 +1272,8 @@ module mutation_cursor { event.attribution == IneligibleUnscoped } else if (event.activeScopes.size() == 0) { event.attribution == IneligibleUnscoped + } else if (hasUnconfirmedCodexScope(event.activeScopes, event.boundary)) { + event.attribution == IneligibleUnscoped } else if (event.activeScopes.size() == 1) { match event.attribution { | AiExclusive(scope) => event.activeScopes.contains(scope) @@ -1274,6 +1298,37 @@ module mutation_cursor { event.attribution == AiContended implies event.activeScopes.size() >= 2 }) + pure def isPositiveAttribution(attribution: Attribution): bool = + match attribution { + | IneligibleUnscoped => false + | AiExclusive(_) => true + | AiContended => true + } + + val NoPositiveAttributionWithUnconfirmedCodexScope = + mutationEvents.forall(event => + isPositiveAttribution(event.attribution) implies + not(hasUnconfirmedCodexScope(event.activeScopes, event.boundary)) + ) + + val UnconfirmedCodexScopeBlocksCrossHarnessAttribution = + mutationEvents.forall(event => { + val liveCodex = event.activeScopes.filter(scope => isCodexScope(scope)) + val confirmsSome = liveCodex.exists(scope => + boundaryConfirmsScope(event.boundary, scope) + ) + + (liveCodex.size() > 0 and not(confirmsSome)) implies + event.attribution == IneligibleUnscoped + }) + + val MultipleLiveCodexScopesSuppressPositiveAttribution = + mutationEvents.forall(event => { + val liveCodex = event.activeScopes.filter(scope => isCodexScope(scope)) + + liveCodex.size() >= 2 implies event.attribution == IneligibleUnscoped + }) + val StartDoesNotAbandonExistingScopes = startHistory.forall(start => start.preservedScopes.forall(scope => @@ -1307,6 +1362,24 @@ module mutation_cursor { event.attribution == IneligibleUnscoped }) + val HasCodexConfirmedExclusiveEvidence = mutationEvents.exists(event => + isClose(event.boundary) and + isCodexScope(boundaryScope(event.boundary)) and + event.attribution == AiExclusive(boundaryScope(event.boundary)) + ) + + val HasCodexConfirmedContendedEvidence = mutationEvents.exists(event => + isClose(event.boundary) and + isCodexScope(boundaryScope(event.boundary)) and + event.attribution == AiContended + ) + + val HasUnconfirmedCodexSuppressedEvidence = mutationEvents.exists(event => + hasUnconfirmedCodexScope(event.activeScopes, event.boundary) and + event.activeScopes.size() >= 1 and + event.attribution == IneligibleUnscoped + ) + val HasRejectedAttempt = ATTEMPTS.exists(id => { attempts.get(id).status == Rejected }) @@ -1351,6 +1424,9 @@ module mutation_cursor { AttributionMatchesObservedScopes, AiExclusiveRequiresExactlyOneActiveScope, AiContendedRequiresMultipleActiveScopes, + NoPositiveAttributionWithUnconfirmedCodexScope, + UnconfirmedCodexScopeBlocksCrossHarnessAttribution, + MultipleLiveCodexScopesSuppressPositiveAttribution, StartDoesNotAbandonExistingScopes, } @@ -1395,7 +1471,7 @@ module mutation_cursor { init .then(prepare(Attempt0, Start({ scope: Scope0, event: Event0 }))) .then(commitAttempt(Attempt0)) - .then(prepare(Attempt1, Start({ scope: Scope2, event: Event1 }))) + .then(prepare(Attempt1, Start({ scope: Scope1, event: Event1 }))) .then(commitAttempt(Attempt1)) .then(mutate(WT0, Tree1)) .then(prepare(Attempt2, Flush(WT0))) @@ -1489,12 +1565,12 @@ module mutation_cursor { init .then(prepare(Attempt0, Start({ scope: Scope0, event: Event0 }))) .then(commitAttempt(Attempt0)) - .then(prepare(Attempt1, Start({ scope: Scope2, event: Event1 }))) + .then(prepare(Attempt1, Start({ scope: Scope1, event: Event1 }))) .then(commitAttempt(Attempt1)) .then(mutate(WT0, Tree1)) .then(prepare(Attempt2, Flush(WT0))) .then(commitAttempt(Attempt2)) - .then(prepare(Attempt3, Close({ scope: Scope2, event: Event2 }))) + .then(prepare(Attempt3, Close({ scope: Scope1, event: Event2 }))) .then(commitAttempt(Attempt3)) .then(mutate(WT0, Tree2)) .then(prepare(Attempt4, Close({ scope: Scope0, event: Event3 }))) @@ -1567,18 +1643,18 @@ module mutation_cursor { init .then(prepare(Attempt0, Start({ scope: Scope0, event: Event0 }))) .then(commitAttempt(Attempt0)) - .then(prepare(Attempt1, Start({ scope: Scope2, event: Event1 }))) + .then(prepare(Attempt1, Start({ scope: Scope1, event: Event1 }))) .then(commitAttempt(Attempt1)) .then(mutate(WT0, Tree1)) .then(prepare(Attempt2, Flush(WT0))) .then(commitAttempt(Attempt2)) - .then(prepare(Attempt3, Close({ scope: Scope2, event: Event2 }))) + .then(prepare(Attempt3, Close({ scope: Scope1, event: Event2 }))) .then(commitAttempt(Attempt3)) .then(mutate(WT0, Tree2)) .then(prepare(Attempt4, Flush(WT0))) .then(commitAttempt(Attempt4)) .expect(scopes.get(Scope0).status == Active) - .expect(scopes.get(Scope2).status == Closed) + .expect(scopes.get(Scope1).status == Closed) .expect( mutationEvents.exists(event => event.afterTree == Tree1 and @@ -1650,7 +1726,7 @@ module mutation_cursor { init .then(prepare(Attempt0, Start({ scope: Scope0, event: Event0 }))) .then(commitAttempt(Attempt0)) - .then(prepare(Attempt1, Start({ scope: Scope2, event: Event1 }))) + .then(prepare(Attempt1, Start({ scope: Scope1, event: Event1 }))) .then(commitAttempt(Attempt1)) .then(mutate(WT0, Tree1)) .then(abandon(Scope0)) @@ -1671,7 +1747,7 @@ module mutation_cursor { .then(recover(WT0)) .expect(worktrees.get(WT0).cursorTree == Tree1) .expect(not(worktrees.get(WT0).needsRebaseline)) - .expect(scopes.get(Scope2).status == Active) + .expect(scopes.get(Scope1).status == Active) .then(mutate(WT0, Tree2)) .then(prepare(Attempt3, Flush(WT0))) .then(commitAttempt(Attempt3)) @@ -1679,7 +1755,7 @@ module mutation_cursor { mutationEvents.exists(event => event.beforeTree == Tree1 and event.afterTree == Tree2 and - event.attribution == AiExclusive(Scope2) + event.attribution == AiExclusive(Scope1) ) ) .expect(Safety) @@ -1738,16 +1814,130 @@ module mutation_cursor { .then(commitAttempt(Attempt0)) .then(prepare(Attempt1, Start({ scope: Scope2, event: Event1 }))) .then(commitAttempt(Attempt1)) - .then(mutate(WT0, Tree1)) - .then(prepare(Attempt2, Flush(WT0))) - .then(commitAttempt(Attempt2)) .expect(scopes.get(Scope0).status == Active) .expect(scopes.get(Scope2).status == Active) + .then(mutate(WT0, Tree1)) + .then(prepare(Attempt2, Close({ scope: Scope2, event: Event2 }))) + .then(commitAttempt(Attempt2)) .expect( mutationEvents.exists(event => + event.afterTree == Tree1 and + event.activeScopes == Set(Scope0, Scope2) and event.attribution == AiContended ) ) + .expect(HasCodexConfirmedContendedEvidence) + .expect(Safety) + + run testUnconfirmedCodexScopeBlocksCrossHarnessContention = + init + .then(prepare(Attempt0, Start({ scope: Scope2, event: Event0 }))) + .then(commitAttempt(Attempt0)) + .then(prepare(Attempt1, Start({ scope: Scope0, event: Event1 }))) + .then(commitAttempt(Attempt1)) + .then(mutate(WT0, Tree1)) + .then(prepare(Attempt2, Advance({ scope: Scope0, event: Event2 }))) + .then(commitAttempt(Attempt2)) + .expect(scopes.get(Scope2).status == Active) + .expect(scopes.get(Scope0).status == Active) + .expect( + mutationEvents.exists(event => + event.afterTree == Tree1 and + event.activeScopes == Set(Scope0, Scope2) and + event.attribution == IneligibleUnscoped + ) + ) + .expect(HasUnconfirmedCodexSuppressedEvidence) + .expect(Safety) + + run testUnconfirmedCodexScopeBlocksExclusiveAttribution = + init + .then(prepare(Attempt0, Start({ scope: Scope2, event: Event0 }))) + .then(commitAttempt(Attempt0)) + .then(mutate(WT0, Tree1)) + .then(prepare(Attempt1, Advance({ scope: Scope2, event: Event1 }))) + .then(commitAttempt(Attempt1)) + .expect( + mutationEvents.exists(event => + event.afterTree == Tree1 and + event.activeScopes == Set(Scope2) and + event.attribution == IneligibleUnscoped + ) + ) + .expect(Safety) + + run testFlushDoesNotConfirmCodexScope = + init + .then(prepare(Attempt0, Start({ scope: Scope2, event: Event0 }))) + .then(commitAttempt(Attempt0)) + .then(mutate(WT0, Tree1)) + .then(prepare(Attempt1, Flush(WT0))) + .then(commitAttempt(Attempt1)) + .expect( + mutationEvents.exists(event => + event.afterTree == Tree1 and + event.activeScopes == Set(Scope2) and + event.attribution == IneligibleUnscoped + ) + ) + .expect(Safety) + + run testCodexCloseConfirmsExclusiveAttribution = + init + .then(prepare(Attempt0, Start({ scope: Scope2, event: Event0 }))) + .then(commitAttempt(Attempt0)) + .then(mutate(WT0, Tree1)) + .then(prepare(Attempt1, Close({ scope: Scope2, event: Event1 }))) + .then(commitAttempt(Attempt1)) + .expect(scopes.get(Scope2).status == Closed) + .expect( + mutationEvents.exists(event => + event.afterTree == Tree1 and + event.activeScopes == Set(Scope2) and + event.attribution == AiExclusive(Scope2) + ) + ) + .expect(HasCodexConfirmedExclusiveEvidence) + .expect(Safety) + + run testSecondLiveCodexScopeSuppressesConfirmedCodexClose = + init + .then(prepare(Attempt0, Start({ scope: Scope2, event: Event0 }))) + .then(commitAttempt(Attempt0)) + .then(prepare(Attempt1, Start({ scope: Scope4, event: Event1 }))) + .then(commitAttempt(Attempt1)) + .then(mutate(WT0, Tree1)) + .then(prepare(Attempt2, Close({ scope: Scope2, event: Event2 }))) + .then(commitAttempt(Attempt2)) + .expect(scopes.get(Scope4).status == Active) + .expect( + mutationEvents.exists(event => + event.afterTree == Tree1 and + event.activeScopes == Set(Scope2, Scope4) and + event.attribution == IneligibleUnscoped + ) + ) + .expect(Safety) + + run testTerminalCodexScopeDoesNotSuppressAttribution = + init + .then(prepare(Attempt0, Start({ scope: Scope2, event: Event0 }))) + .then(commitAttempt(Attempt0)) + .then(prepare(Attempt1, Close({ scope: Scope2, event: Event1 }))) + .then(commitAttempt(Attempt1)) + .then(prepare(Attempt2, Start({ scope: Scope0, event: Event2 }))) + .then(commitAttempt(Attempt2)) + .then(mutate(WT0, Tree1)) + .then(prepare(Attempt3, Advance({ scope: Scope0, event: Event3 }))) + .then(commitAttempt(Attempt3)) + .expect(scopes.get(Scope2).status == Closed) + .expect( + mutationEvents.exists(event => + event.afterTree == Tree1 and + event.activeScopes == Set(Scope0) and + event.attribution == AiExclusive(Scope0) + ) + ) .expect(Safety) run testDifferentWorktreesAreIndependent = From 028711515dc953ca9833d6bd9bbbb442f37b4599 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 17:07:34 +0200 Subject: [PATCH 15/20] tests: Exercise hooks with a minimal command path Ensure the hook test validates behavior when only the required bash, cat, and git commands are available. This keeps the test focused on the hook's actual tool dependencies. --- scripts/test-codex-hook-command.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/test-codex-hook-command.sh b/scripts/test-codex-hook-command.sh index 8e348682..501281fd 100755 --- a/scripts/test-codex-hook-command.sh +++ b/scripts/test-codex-hook-command.sh @@ -125,9 +125,11 @@ run_without_git() { run_without_git "${tmp_root}/outside-output" [ ! -s "${tmp_root}/outside-output" ] || fail "Git-root failure was not silent" -git_bin="$(command -v git)" -bash_bin="$(command -v bash)" -minimal_path="$(dirname "${git_bin}"):$(dirname "${bash_bin}")" +minimal_path="${tmp_root}/minimal-bin" +mkdir -p "${minimal_path}" +for minimal_tool in bash cat git; do + ln -s "$(command -v "${minimal_tool}")" "${minimal_path}/${minimal_tool}" +done printf '%s' "${sentinel}" | ( cd "${repo}" From 62d23e9e4a5d5bb088ac026be34f4421ac6869a5 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 17:24:28 +0200 Subject: [PATCH 16/20] context: Update Codex mutation-scope integration plan Record the accepted boundary-aware unconfirmed-Codex attribution semantics from the fifth PR #268 follow-up, correcting the prior no-protocol-change narrative and updating AC22, T06, and T07 constraints. Preserve MCP/unknown untracked behavior while documenting the SQL/schema exclusions and frozen production baseline. Plan: codex-mutation-scope-integration (T06, T07) Co-authored-by: SCE --- .../plans/codex-mutation-scope-integration.md | 350 ++++++++++++++---- 1 file changed, 268 insertions(+), 82 deletions(-) diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 0b8865f6..7d8f27dd 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -37,13 +37,15 @@ as a scope on Codex 0.153.4 (D10a Case C) is exactly why it is excluded. This extends the mutation-scope stack. The generic ingress, the runtime (`coordinate()` / `abandon_scope()`), and the `mutation-trace` protocol already -exist and are **unchanged in contract**. `ActorKind::Codex` and the -`"actor_kind":"codex"` wire value are already accepted by -`parse_mutation_scope_payload` today — the Codex adapter is a new caller of an -existing seam, not a protocol change. This change adds the Codex harness adapter -layer the ingress explicitly deferred, its checkout-local bookkeeping, its -hidden command/routing, and its generated `.codex/hooks.json` registrations -through the existing shared Codex hook-config ownership/merge/doctor system. +exist, and the adapter itself is a new caller of them rather than a rewrite of +them. `ActorKind::Codex` and the `"actor_kind":"codex"` wire value are already +accepted by `parse_mutation_scope_payload` today. The one deliberate exception +is the accepted fifth PR #268 follow-up, which refines the generic attribution +semantics with the boundary-aware unconfirmed-Codex rule (D14) — see "Protocol +scope" below. This change adds the Codex harness adapter layer the ingress +explicitly deferred, its checkout-local bookkeeping, its hidden command/routing, +and its generated `.codex/hooks.json` registrations through the existing shared +Codex hook-config ownership/merge/doctor system. **The Claude adapter is a structural reference, not a lifecycle specification for Codex.** Codex's hook surface differs materially from Claude's: it has no @@ -64,20 +66,44 @@ to write only through the mutation runtime (`mutation_trace_*` tables); the `apply_patch -> diff_traces -> post-commit intersection` evidence pipeline is a complementary system and is not folded into mutation-scope storage. -No mutation protocol, Quint model, mutation-trace SQL migration, -mutation-attribution algorithm, or Agent Trace schema change is in scope. If T01 -reveals Codex fundamentally cannot be represented by the current mutation-scope -contract, T01 stops and records the contradiction in this plan's Open questions -rather than modifying the protocol; any protocol change becomes a separate, -explicitly justified PR. +**Protocol scope (chronological — the plan's scope changed once, deliberately).** + +1. **Original adapter plan.** No mutation protocol, Quint model, mutation-trace + SQL migration, mutation-attribution algorithm, or Agent Trace schema change + was required: the adapter was to be a new caller of an existing seam. If T01 + revealed Codex fundamentally could not be represented by the current + mutation-scope contract, T01 was to stop and record the contradiction rather + than modify the protocol. +2. **The accepted protocol-level safety follow-up.** T04's lifecycle analysis + exposed an *unobservable* arbitrary-hook denial window: an arbitrary sibling + `PreToolUse` hook can deny a Codex tool after SCE has already driven + `Start(A)`, and SCE never learns that verdict — so a cross-harness boundary + observing a mutation during that window could produce **false positive** + attribution. No adapter-local fix exists (the observable state is identical + for "A is running" and "A was denied"). The **fifth PR #268 follow-up** + therefore intentionally refined the generic mutation protocol and the Quint + model with **boundary-aware unconfirmed-Codex attribution semantics** (D14): + `spec/mutation_cursor.qnt`, `spec/mutation_cursor.md`, + `cli/src/services/mutation_trace/protocol.rs`, and the mutation-trace + runtime/MBT tests that refine and validate the rule. +3. **From that point onward (T06, T07).** No *further* protocol, Quint, + runtime-semantic, mutation-attribution-algorithm, mutation-trace SQL, or + Agent Trace schema change is expected or permitted. Any additional such + change becomes a separate, explicitly justified PR. + +**No mutation-trace SQL migration and no Agent Trace schema change is in scope +at any point** — `cli/migrations/agent-trace-repository/` and +`config/schema/agent-trace.schema.json` remain untouched by this PR (AC22). **T01 outcome (recorded 2026-09-08):** the built-in `Bash` / `apply_patch` surface *is* representable by the current contract; the MCP mutation-scope lifecycle *is not* (D10a Case C). Re-planning chose **direction B** — MCP and unknown tools remain usable but are outside the Codex adapter's mutation-scope -coverage. This requires **no** protocol / Quint / mutation-trace SQL / Agent -Trace schema change (D23); it narrows the Codex adapter's coverage boundary -only. T01 is done; T02 is unblocked. +coverage. That decision required **no** protocol / Quint / mutation-trace SQL / +Agent Trace schema change (D23); it narrows the Codex adapter's coverage +boundary only. T01 is done; T02 is unblocked. *(This records what was true at +T01. The later protocol change has a different cause — cross-harness +zombie-attribution, not the MCP coverage decision; see D14 and D23.)* ## Stack and base @@ -1059,9 +1085,24 @@ executions into one `ScopeId`. **Refined by the fifth follow-up (2026-09-08, PR #268) — Codex `Start` is not execution confirmation.** The earlier statement that such an overlap can simply -produce `AiContended` was too broad. The refined rule, implemented in -`spec/mutation_cursor.qnt` (`attributionForBoundary`) and -`mutation_trace/protocol.rs` (`attribution_for_boundary`): +produce `AiContended` was too broad. **D14 is the authoritative record of the +accepted cross-harness attribution semantics** and of the only +protocol/formal-model change this PR introduces (AC22). In short: + +```text +Codex Start is write-ahead admission, not positive execution confirmation. + +An unconfirmed live Codex scope suppresses positive attribution at any +non-confirming boundary. + +The exact Codex scope's own Close confirms that scope for the current boundary. + +If another live Codex scope remains unconfirmed, attribution remains +IneligibleUnscoped. +``` + +The rule as implemented in `spec/mutation_cursor.qnt` (`attributionForBoundary`) +and `mutation_trace/protocol.rs` (`attribution_for_boundary`): ```text Codex v1 Start is an admission/write-ahead scope boundary, not positive proof @@ -1456,12 +1497,20 @@ mutations are detected immediately. - must **not** create any `Start`, `attempt`, `recovery_pending`, `Close`, `Abandon`, or `Flush` for an MCP or unknown execution. -**No protocol / formal change.** This choice requires **no** -`mutation_cursor.qnt` change, **no** mutation-trace protocol change, **no** -mutation runtime semantic change, **no** SQL migration, and **no** Agent Trace -schema change — the existing runtime already models exclusivity among tracked -scopes, not global filesystem authorship. Direction B changes only the Codex -adapter's coverage boundary. +**No protocol / formal change is caused by *this* decision.** Direction B +requires **no** `mutation_cursor.qnt` change, **no** mutation-trace protocol +change, **no** mutation runtime semantic change, **no** SQL migration, and **no** +Agent Trace schema change — the existing runtime already models exclusivity among +tracked scopes, not global filesystem authorship. Direction B changes only the +Codex adapter's coverage boundary. + +*Do not confuse this with the accepted fifth PR #268 follow-up.* That follow-up +**did** intentionally change the protocol and Quint model, but for a distinct +cause: `tracked Codex Start` + `arbitrary sibling hook denial` + a +`cross-harness mutation boundary` could produce **false positive** attribution +(D14). MCP's disposition here — `MCP / unknown -> Untracked -> no scope -> no +adapter bookkeeping` — is unchanged by it, and MCP overlap still never becomes +`AiContended`. **Rejected / deferred alternatives (historical rationale):** @@ -1563,12 +1612,14 @@ performs final validation. `Start(Bash A)` live, an MCP call mutates the worktree, then `Close(Bash A)`. The runtime may report `AiExclusive(A)` for the interval. The test and the durable context must state this means **A was the only tracked scope live**, - **not** that MCP did not mutate. The generic protocol is **not** changed to - force this into `AiContended`. + **not** that MCP did not mutate. MCP remains `Untracked` and the accepted + protocol refinement does **not** convert MCP overlap into `AiContended`. - Validate: T06 regression (`Bash` scope + real MCP mutation via `fixtures/mcp_probe/`) asserting the `AiExclusive` result and a comment/doc line recording the tracked-scope-exclusivity (not sole-authorship) reading; - AC22 confirms no protocol/Quint change. + AC22 confirms there is no SQL/schema change and that the only protocol/Quint + semantic change is the boundary-aware unconfirmed-Codex attribution rule + (D14), which leaves MCP's `Untracked` disposition untouched. - [ ] AC4: The Codex execution key is exactly the field tuple T02 froze from T01 evidence (recorded in D3). Duplicate delivery of the same live `PreToolUse` reuses the same `attempt_seq`, `ScopeId`, and `Start` `EventId`. @@ -1766,14 +1817,38 @@ performs final validation. replay-safe on redelivery (no second transition, revision unchanged). - Validate: T06 regressions Test-crash-a/b/c driving real events after simulating each crash point via the adapter's own bookkeeping helpers only. -- [ ] AC22: The diff against - `origin/claude-mutation-scope-integration` 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`; no mutation-trace SQL migration and - no Quint/protocol/attribution-algorithm change is introduced. - - Validate: `git diff origin/claude-mutation-scope-integration -- ` is empty. +- [ ] AC22: The **only** protocol / formal-model / mutation-attribution semantic + change introduced by the Codex integration is the accepted boundary-aware + unconfirmed-Codex attribution rule recorded in D14 / the fifth PR #268 + follow-up. The diff against `origin/claude-mutation-scope-integration` may + therefore contain the intentional changes to `spec/mutation_cursor.qnt`, + `spec/mutation_cursor.md`, `cli/src/services/mutation_trace/protocol.rs`, and + the mutation-trace runtime, MBT harness, and refinement tests required to + refine and validate that rule — and nothing else of that kind. **No mutation-trace SQL migration is + introduced. No Agent Trace schema change is introduced.** The following must + remain unchanged against the predecessor: `cli/migrations/agent-trace-repository/` + and `config/schema/agent-trace.schema.json`. T06 and T07 introduce **no + further** production protocol, Quint, runtime-semantic, + mutation-attribution-algorithm, SQL, or schema change. + - Validate, in three parts: + 1. Inspect the protocol / Quint diff + (`git diff origin/claude-mutation-scope-integration -- spec/ + cli/src/services/mutation_trace/`) and verify every hunk is part of the + accepted unconfirmed-Codex attribution rule and its formal/refinement + tests (D14: `isCodexScope`, `boundaryConfirmsScope`, + `hasUnconfirmedCodexScope`, `attributionForBoundary` / + `is_codex_scope`, `boundary_confirms_scope`, + `has_unconfirmed_codex_scope`, `attribution_for_boundary`, the + `SafetyAttribution` invariants, the `scope4` MBT wiring, and their + regressions). + 2. Assert empty: `git diff origin/claude-mutation-scope-integration -- + cli/migrations/agent-trace-repository/ + config/schema/agent-trace.schema.json`. + 3. T06 baseline freeze — assert empty against the post-fifth-follow-up head + (`b72f6c2c`, "runtime+language: Prevent false mutation attribution for + unconfirmed Codex scopes"): `git diff b72f6c2c -- spec/mutation_cursor.qnt + spec/mutation_cursor.md cli/src/services/mutation_trace/protocol.rs + cli/src/services/mutation_trace/runtime/`. - [ ] AC23: Durable context clearly separates the generic mutation-scope ingress, the Codex mutation adapter, and the mutation runtime, and records: the tool-execution scope model, the **three-class** Codex tool classification @@ -1784,7 +1859,11 @@ performs final validation. failed-tool handling, why MCP/unknown are `Untracked` (the three T01 probe findings), the Codex lifecycle cleanup signals and the load-bearing backstop, the recovery barrier, worktree/cwd ownership, the concurrency story (including - that `AiExclusive` is tracked-scope exclusivity, not sole authorship), the + that `AiExclusive` is tracked-scope exclusivity, not sole authorship, **and** + the accepted boundary-aware unconfirmed-Codex attribution rule of D14 — Codex + `Start` is write-ahead admission, not execution confirmation; an unconfirmed + live Codex scope forces `IneligibleUnscoped` at any non-confirming boundary; + only that exact scope's own `Close` confirms it), the exact background/detached execution limitations, and the Codex hook-config coexistence contract (existing-registration trust-identity preservation, the three-dimension doctor health model, upstream-verified event key labels) — @@ -1833,7 +1912,10 @@ performs final validation. - `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/claude-mutation-scope-integration -- 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. +- `git diff origin/claude-mutation-scope-integration -- cli/migrations/agent-trace-repository/ config/schema/agent-trace.schema.json` must be empty (AC22 SQL/schema half). +- `git diff origin/claude-mutation-scope-integration -- spec/ cli/src/services/mutation_trace/` is **not** required to be empty; it is inspected and must be limited to the accepted boundary-aware unconfirmed-Codex attribution rule and its formal/refinement tests (D14, AC22). +- `git diff b72f6c2c -- spec/mutation_cursor.qnt spec/mutation_cursor.md cli/src/services/mutation_trace/protocol.rs cli/src/services/mutation_trace/runtime/` must be empty (T06/T07 add no further production semantic change, AC22). +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::` (the accepted attribution rule's refinement/MBT regressions). Final branch comparison is against `claude-mutation-scope-integration` (#263 head), not `main`, while this PR remains stacked on #263. @@ -1919,12 +2001,20 @@ Persist this field in every plan; this is durable plan state, not chat state: `cli/src/services/doctor/` (three-dimension structural + trust + effective- policy diagnosis for each mutation-scope registration per D21), and the context files listed under Context sync. + **Added by the accepted fifth PR #268 follow-up only** (D14; closed, not + reopened by T06/T07): `spec/mutation_cursor.qnt`, `spec/mutation_cursor.md`, + `cli/src/services/mutation_trace/protocol.rs`, and the + `cli/src/services/mutation_trace/runtime/` + MBT tests that refine and + validate the boundary-aware unconfirmed-Codex attribution rule. - **Out of scope:** OpenCode/Pi mutation-scope adapters; a generic - adapter-framework extraction; any change to the mutation protocol, Quint - model, `mutation_trace/protocol.rs`, `mutation_trace/store.rs`, or - `mutation_trace/runtime/`; any mutation-trace SQL migration; any Agent Trace - schema migration or `config/schema/agent-trace.schema.json` change; any new - mutation-attribution algorithm; the existing `sce hooks codex` + adapter-framework extraction; **any protocol / Quint / `mutation_trace` + runtime-semantic / mutation-attribution-algorithm change beyond the one + accepted boundary-aware unconfirmed-Codex attribution rule** (D14, fifth PR + #268 follow-up — `spec/mutation_cursor.qnt`, `spec/mutation_cursor.md`, + `mutation_trace/protocol.rs`, and the runtime/MBT tests that refine and + validate it); any change to `mutation_trace/store.rs`; any mutation-trace SQL + migration; any Agent Trace schema migration or + `config/schema/agent-trace.schema.json` change; the existing `sce hooks codex` conversation/diff pipeline behavior (`UserPromptSubmit`/`Stop`/ `PreToolUse(Bash)`/`PostToolUse(apply_patch)` slices — unchanged); folding `apply_patch -> diff_traces` into mutation-scope storage; PID/process-group @@ -2623,7 +2713,9 @@ Persist this field in every plan; this is durable plan state, not chat state: `super::mutation_scope::run_mutation_scope_from_payload`); no `spec/mutation_cursor.qnt` / `mutation_trace/protocol.rs` / `mutation_trace/runtime/` / `mutation_trace/store.rs` / - `cli/migrations/agent-trace-repository/` / `agent-trace.schema.json` change; + `cli/migrations/agent-trace-repository/` / `agent-trace.schema.json` change + *(this describes **this** follow-up's own diff; the fifth follow-up below + intentionally changes the protocol/Quint attribution semantics)*; MCP Option B classification unchanged; no daemon, PID supervision, polling, or new DB; the adapter-state lock is never held across the generic ingress seam. Changes are confined to `codex_mutation_scope/{state,mod}.rs` and this plan. @@ -2693,7 +2785,9 @@ Persist this field in every plan; this is durable plan state, not chat state: **clean**; `cargo fmt -- --check` — **clean**. AC19 boundary still clean; no `spec/mutation_cursor.qnt` / `mutation_trace/protocol.rs` / `mutation_trace/runtime/` / `mutation_trace/store.rs` / - `cli/migrations/agent-trace-repository/` / `agent-trace.schema.json` change; + `cli/migrations/agent-trace-repository/` / `agent-trace.schema.json` change + *(this follow-up's own diff; the fifth follow-up below intentionally + changes the protocol/Quint attribution semantics)*; no daemon, PID supervision, lease expiry, or polling — timeouts are used only for OS-lock acquisition failure, never to infer lifecycle completion. Changes confined to `codex_mutation_scope/{mod,state,os_lock,boundary_lock}.rs` and @@ -2749,7 +2843,9 @@ Persist this field in every plan; this is durable plan state, not chat state: — **clean**; `cargo fmt -- --check` — **clean**. No `spec/mutation_cursor.qnt` / `mutation_trace/protocol.rs` / `mutation_trace/runtime/` / `mutation_trace/store.rs` / `cli/migrations/agent-trace-repository/` / - `agent-trace.schema.json` / MCP-semantics / generated-config change. Changes + `agent-trace.schema.json` / MCP-semantics / generated-config change *(this + follow-up's own diff; the fifth follow-up below intentionally changes the + protocol/Quint attribution semantics)*. Changes confined to `cli/src/services/hooks/codex/{mod,bash_policy}.rs`, `cli/src/services/hooks/codex_mutation_scope/mod.rs`, and this plan. Arbitrary user-owned concurrent `PreToolUse(Bash)` denial — analysis result: @@ -2823,7 +2919,9 @@ Persist this field in every plan; this is durable plan state, not chat state: **clean**. No `spec/mutation_cursor.qnt` / `mutation_trace/protocol.rs` / `mutation_trace/runtime/` / `mutation_trace/store.rs` / `cli/migrations/agent-trace-repository/` / `agent-trace.schema.json` / - MCP-semantics / generated-config / Bash-policy-preflight change. Changes + MCP-semantics / generated-config / Bash-policy-preflight change *(this + follow-up's own diff; the fifth follow-up below intentionally changes the + protocol/Quint attribution semantics)*. Changes confined to `cli/src/services/hooks/codex_mutation_scope/{mod,state}.rs` and this plan. Untracked-successor / cross-harness analysis (required by this follow-up): @@ -2920,8 +3018,11 @@ Persist this field in every plan; this is durable plan state, not chat state: subcommand (hidden from `sce --help` / `sce hooks --help`); no visible-help or public-API change. No generated config yet (`.codex/hooks.json` / `sce setup` wiring is T05), no real Git/DB path yet (T06). The generic - mutation-scope ingress, runtime, protocol, Quint model, and Agent Trace - schema are unchanged (AC22 territory — no edits to those paths). Durable + mutation-scope ingress and the Agent Trace schema are unchanged, and no + mutation-trace SQL migration exists. The generic protocol, Quint model, and + mutation-trace runtime **were** changed — once, deliberately — by T04's + **fifth** follow-up (the boundary-aware unconfirmed-Codex attribution rule, + D14/AC22); the first four follow-ups left them untouched. Durable Codex-adapter context (`context/cli/codex-mutation-scope-integration.md` and the cross-reference edits to `context/overview.md`, `context/architecture.md`, `context/cli/mutation-scope-runtime.md`, @@ -2957,14 +3058,34 @@ Persist this field in every plan; this is durable plan state, not chat state: structurally mirroring `claude_mutation_scope`; the Codex hook-ownership ADR (`2026-08-23-codex-nondestructive-hook-ownership.md`) is untouched (that is T05's `codex_hook_config.rs` scope). - - Concurrency follow-up (2026-09-08, PR #268) re-checked the five roots and - remains `no_context_change`: the generation-aware recovery state machine - (D13a/D13b) and the second follow-up's boundary lock (D13c) are + - Concurrency follow-ups one through four (2026-09-08, PR #268) re-checked + the five roots and remain `no_context_change`: the generation-aware + recovery state machine (D13a/D13b), the second follow-up's boundary lock + (D13c), the Bash-policy preflight, and the same-lane predecessor sweep are adapter-internal inter-process synchronisation with no protocol, Quint, - runtime-semantic, SQL, or Agent Trace schema change (AC22 still holds) and - no user-visible surface change — the adapter is still inert and - unregistered. Durable adapter context (D13a/b/c, the coverage table, the - concurrency story) is authored by T07 as already recorded. + runtime-semantic, SQL, or Agent Trace schema change and no user-visible + surface change — the adapter is still inert and unregistered. Durable + adapter context (D13a/b/c, the coverage table, the concurrency story) is + authored by T07 as already recorded. + - **Fifth follow-up (2026-09-08, PR #268) — protocol/formal change, recorded + here rather than deferred.** Unlike the first four, this one changes the + *generic* mutation semantics: `spec/mutation_cursor.qnt`, + `spec/mutation_cursor.md`, `cli/src/services/mutation_trace/protocol.rs`, + and the mutation-trace runtime/MBT tests. `spec/mutation_cursor.md` is the + Quint model's own durable prose and was updated in the same commit; D14 in + this plan is the authoritative plan-side record. AC22 was rewritten to + describe this as the **only** protocol/formal/attribution-semantic change + of the Codex integration (SQL and Agent Trace schema remain untouched). The + five roots (`overview.md`, `architecture.md`, `glossary.md`, + `patterns.md`, `context-map.md`) were re-checked and none states an + attribution rule this contradicts. **Two domain files now overstate the + old rule and are added to T07's scope:** + `context/cli/mutation-trace-runtime-coordinator.md` (~line 194, "two live + scopes yield `AiContended` regardless of matching or differing + `ActorKind`") and `context/cli/mutation-scope-runtime.md` (~line 236, + "`AiContended` (more than one live scope)") — both are now conditional on + no unconfirmed live Codex scope being present at the boundary. They are + left to T07, which owns durable-context authorship for this plan. - [x] T05: `Generated .codex/hooks.json registrations, setup merge, and doctor` (status:done) - Task ID: T05 @@ -3130,7 +3251,9 @@ Persist this field in every plan; this is durable plan state, not chat state: installs the mutation-scope registrations; `sce doctor` reports them as distinct `.codex/hooks.json#(mutation-scope)` rows with the full three-dimension health model. No public API, protocol, Quint, mutation-trace - SQL, or Agent Trace schema change (AC22 territory untouched). The generic + SQL, or Agent Trace schema change *in T05's own diff* (the later fifth T04 + follow-up intentionally changes the protocol/Quint attribution semantics — + D14/AC22). The generic mutation-scope ingress/runtime and the existing `sce hooks codex` conversation/diff pipeline are unchanged and additive. Durable Codex-adapter context (`context/cli/codex-mutation-scope-integration.md`) is @@ -3251,6 +3374,22 @@ Persist this field in every plan; this is durable plan state, not chat state: - [ ] T06: `Real Git/DB regressions through the production Codex path` (status:todo) - Task ID: T06 + - **Unblocked (2026-09-08).** T01–T05 are `done` and `synced`, the + protocol/formal safety follow-up is accepted, and the plan/AC22 consistency + cleanup is complete. T06 is ready to begin; it has not been started. + - **T06 production baseline (frozen).** The production semantics under test are + frozen at the completion of the fifth T04 follow-up (head `b72f6c2c`): + Codex tracked-tool classification; fail-closed bootstrap; Bash-policy + preflight; the boundary-lock / recovery protocol; the same-lane predecessor + sweep; and the boundary-aware unconfirmed-Codex attribution rule (D14). T06 + may add tests, fixtures, and test-only helpers (including the `#[cfg(test)]` + state-root entry point below), but must **not** change these production + semantics. Concretely, T06 must leave `spec/mutation_cursor.qnt`, + `spec/mutation_cursor.md`, `cli/src/services/mutation_trace/protocol.rs`, + and `cli/src/services/mutation_trace/runtime/` unchanged **relative to + `b72f6c2c`** (AC22). If a T06 regression exposes another production + correctness issue, **T06 stops**, records the contradiction in this plan, and + a separate follow-up fixes production behaviour before T06 resumes. - Scope: In — regressions using real temporary Git repositories and real repository Agent Trace DBs, driven through the production Codex-adapter -> generic-ingress -> real runtime path (no manual `mutation_trace_*` inserts; @@ -3290,8 +3429,8 @@ Persist this field in every plan; this is durable plan state, not chat state: 13. **tracked `Bash`/`apply_patch` overlapping an MCP mutation** -> the runtime result (`AiExclusive` on the tracked scope) is asserted **and** documented as *tracked-scope exclusivity, not sole authorship* — MCP did - mutate in the interval; the generic protocol is not changed to force - `AiContended` (AC9f); + mutate in the interval; the accepted protocol refinement does not convert + MCP overlap into `AiContended` (AC9f); 14. **unknown tool -> allowed untracked** — `PreToolUse()` returns the neutral response, no scope, no bookkeeping (AC9b); 15. raw Agent Trace tables (`diff_traces`, @@ -3316,17 +3455,25 @@ Persist this field in every plan; this is durable plan state, not chat state: identifier after terminal -> new `ScopeId` (AC5); any unsupported background execution is rejected / documented (AC15); denied tracked execution -> no mutation under an untracked `Start` (AC7 production half); cross-harness - overlap -> `AiContended` (AC10). + overlap attributed per the refined D14 rule — `IneligibleUnscoped` at a + boundary that does not confirm the live Codex scope, `AiContended` only at + that Codex scope's own `Close` with no other unconfirmed live Codex scope + (AC10, **both** directions). The mutation-scope regressions use **real** temporary Git repos and real Agent Trace DBs. The live MCP fixtures (`fixtures/mcp_probe/`, probes 12–17) remain evidence fixtures and are **reused** to drive rows 9–13 rather than re-running `codex exec` in ordinary unit tests. 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 or - detached-child detection; any test that inserts the event it means to prove; - any regression asserting an MCP execution itself produces mutation-scope - attribution. + and adapter state. Out — **new production behaviour beyond the already + accepted T01–T05 plus the lifecycle/attribution follow-ups**; any *further* + change to Codex adapter semantics, the mutation protocol, the Quint model, + mutation runtime semantics, the attribution algorithm, mutation-trace SQL, or + the Agent Trace schema (T06 adds **no further** protocol changes — the + accepted fifth follow-up already landed the only one, D14/AC22); any process + supervision or detached-child detection; any test that inserts the event it + means to prove; any regression asserting an MCP execution itself produces + mutation-scope attribution. - Dependencies: T04 (T05 only for a test that installs generated settings — prefer driving the adapter entry point directly). - Done when: the whole matrix passes and collectively satisfies AC4, AC5, @@ -3335,7 +3482,10 @@ 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::codex_mutation_scope`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml - services::mutation_trace::`. + services::mutation_trace::`; and the frozen-baseline check + `git diff b72f6c2c -- spec/mutation_cursor.qnt spec/mutation_cursor.md + cli/src/services/mutation_trace/protocol.rs + cli/src/services/mutation_trace/runtime/` is empty. - Context synchronization: pending - [ ] T07: `Author the durable Codex mutation-scope context` (status:todo) @@ -3352,7 +3502,12 @@ Persist this field in every plan; this is durable plan state, not chat state: built-ins; Case C *if MCP were modeled as a scope*, resolved by not modeling it), D12 cleanup signals + the load-bearing backstop, D13 recovery barrier, D15 worktree/cwd ownership, D14 concurrency (including that `AiExclusive` is - tracked-scope exclusivity, not sole authorship), D16 background/detached + tracked-scope exclusivity, not sole authorship, **and** the accepted + boundary-aware unconfirmed-Codex attribution rule: Codex `Start` is + write-ahead admission, not execution confirmation; an unconfirmed live Codex + scope forces `IneligibleUnscoped` at any non-confirming boundary; only that + exact scope's own `Close` confirms it; another unconfirmed live Codex scope + keeps attribution ineligible), D16 background/detached limitations, D17 command architecture, D18 dependency direction, and D20/D21/D22 the Codex hook trust-identity preservation, three-dimension doctor health model, and upstream-verified event key labels), with an @@ -3364,7 +3519,14 @@ Persist this field in every plan; this is durable plan state, not chat state: `context/sce/agent-trace-hooks-command-routing.md`, `context/sce/codex-integration-runtime.md`, `context/context-map.md`, `context/overview.md`, and `context/architecture.md` (line 135) to record - that a second concrete harness adapter now exists — each edit additive, + that a second concrete harness adapter now exists; and correct the two + domain statements the accepted fifth follow-up made too broad — + `context/cli/mutation-trace-runtime-coordinator.md` ("two live scopes yield + `AiContended` regardless of matching or differing `ActorKind`") and + `context/cli/mutation-scope-runtime.md` ("`AiContended` (more than one live + scope)") — both now conditional on no unconfirmed live Codex scope at the + boundary (D14). `spec/mutation_cursor.md` already carries the Quint-side + prose and needs no T07 edit. Each edit additive, naming Codex as wired/registered and leaving OpenCode/Pi as still-unwired, and keeping the existing `sce hooks codex` conversation/diff description intact. Write a new dated ADR **only if** T01/T02 established a genuinely @@ -3379,10 +3541,16 @@ Persist this field in every plan; this is durable plan state, not chat state: file rather than overrun); `nix flake check` and `nix run .#pkl-check-generated` still pass. - Verify: inspection of the new file and each updated cross-reference against - AC23/AC24; `git diff origin/claude-mutation-scope-integration -- - spec/mutation_cursor.qnt cli/src/services/mutation_trace/protocol.rs + AC23/AC24; the SQL/schema half of AC22 — `git diff + origin/claude-mutation-scope-integration -- cli/migrations/agent-trace-repository/ config/schema/agent-trace.schema.json` - is empty (AC22); `nix flake check`. + is empty; the frozen-baseline check `git diff b72f6c2c -- + spec/mutation_cursor.qnt spec/mutation_cursor.md + cli/src/services/mutation_trace/protocol.rs + cli/src/services/mutation_trace/runtime/` is empty (T07 introduces no + further protocol/Quint/runtime-semantic change); durable context records the + accepted boundary-aware unconfirmed-Codex attribution refinement (D14); + `nix flake check`. - Context synchronization: pending ## Open questions @@ -3539,16 +3707,34 @@ with fixtures and the MCP one by an explicit coverage boundary: genuinely cannot preserve position, doctor must say re-trust is needed. Not blocking — this is a T05 implementation constraint, not an unknown. -No Quint / mutation-cursor protocol / mutation-trace SQL migration / Agent Trace -schema / attribution-algorithm change is expected or required: the generic -mutation-scope contract already models `Start`/`Advance`/`Close`/`Flush`/ -`abandon`, already accepts `ActorKind::Codex`, and already handles replay -idempotency, conservative recovery, and `AiContended` — the Claude adapter proved -the contract is sufficient for a concrete harness without touching any of those. -The T01 MCP D10a Case C finding did **not** force a protocol change: re-planning -direction B (D23) resolves it entirely within the Codex adapter's coverage -boundary — MCP and unknown tools are `Untracked`. The runtime already models -exclusivity among the tracked scopes it is told about, not global filesystem -authorship, so nothing formal changes. Future work (direction C — first-class MCP -attribution via a richer lifecycle mechanism) would be a separate, explicitly +**Protocol / formal scope — how it actually resolved.** The adapter work needed +no formal change: the generic mutation-scope contract already models +`Start`/`Advance`/`Close`/`Flush`/`abandon`, already accepts `ActorKind::Codex`, +and already handles replay idempotency, conservative recovery, and `AiContended` +— the Claude adapter proved the contract is sufficient for a concrete harness +without touching any of it. The T01 MCP D10a Case C finding did **not** force a +protocol change either: re-planning direction B (D23) resolves it entirely +within the Codex adapter's coverage boundary — MCP and unknown tools are +`Untracked`, and the runtime already models exclusivity among the tracked scopes +it is told about rather than global filesystem authorship. + +**One formal change was nevertheless required, for a different cause, and is +accepted.** T04's lifecycle analysis found that an arbitrary sibling +`PreToolUse` hook can deny a Codex tool *after* SCE drove `Start(A)`, with the +aggregate verdict never exposed to SCE. `ScopeState { actor_kind: Codex, status: +Active }` is then the identical observable for "A is running" and "A was denied", +so no adapter-local fix exists, and a cross-harness boundary observing a mutation +in that window would emit **false positive** attribution. The **fifth PR #268 +follow-up** therefore intentionally refined the generic protocol and the Quint +model with boundary-aware unconfirmed-Codex attribution semantics (D14): +`spec/mutation_cursor.qnt`, `spec/mutation_cursor.md`, +`cli/src/services/mutation_trace/protocol.rs`, and the mutation-trace runtime / +MBT tests that refine and validate the rule. That is the **only** protocol / +Quint / runtime-semantic / attribution-algorithm change in this PR (AC22). + +**No mutation-trace SQL migration and no Agent Trace schema change exists or is +expected**, and no *further* protocol, Quint, runtime-semantic, or +attribution-algorithm change is expected after that follow-up — T06 and T07 are +frozen against the post-follow-up head. Future work (direction C — first-class +MCP attribution via a richer lifecycle mechanism) remains a separate, explicitly justified PR. From d4dab6850d7d675ccda96ee88c2563cb187c444b Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 17:52:55 +0200 Subject: [PATCH 17/20] tests: Add production-path Codex mutation-scope regressions Exercise the real Codex hook adapter, temporary Git repositories, and Agent Trace database across lifecycle, recovery, attribution, policy, worktree, and MCP edge cases. Record completion of T06 in the codex-mutation-scope-integration plan while keeping production behavior unchanged; the new coverage is confined to test code. Ref: context/plans/codex-mutation-scope-integration.md (T06) Co-authored-by: SCE --- .../hooks/codex_mutation_scope/mod.rs | 1622 +++++++++++++++++ .../plans/codex-mutation-scope-integration.md | 87 +- 2 files changed, 1707 insertions(+), 2 deletions(-) diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index 94828556..a80ac388 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -3536,4 +3536,1626 @@ mod tests { remove_test_git_dir(&git_dir); } } + + 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; + + const PROBE01_APPLY_PATCH_POST: &str = include_str!( + "fixtures/probe01-apply-patch-and-shell-success.apply_patch.post_tool_use.json" + ); + const PROBE02_FAILED_SHELL_PRE: &str = include_str!( + "fixtures/probe02-shell-partial-write-then-nonzero-exit.pre_tool_use.json" + ); + const PROBE06_APPLY_PATCH_FAILURE_PRE: &str = include_str!( + "fixtures/probe06-apply-patch-verification-failure-no-post.pre_tool_use.json" + ); + const PROBE09_DETACHED_PRE: &str = + include_str!("fixtures/probe09-self-detaching-descendant.pre_tool_use.json"); + const PROBE09_DETACHED_POST: &str = + include_str!("fixtures/probe09-self-detaching-descendant.post_tool_use.json"); + const PROBE11_INTERRUPT_PRE: &str = + include_str!("fixtures/probe11-interrupt-event-on-sigint.pre_tool_use.json"); + const PROBE13_MCP_STOP: &str = + include_str!("fixtures/probe13-mcp-mutate-then-error.stop.json"); + const PROBE14_MCP_FAILED_PRE: &str = + include_str!("fixtures/probe14-mcp-failed-then-successor.failed.pre_tool_use.json"); + const PROBE14_MCP_SUCCESSOR_PRE: &str = + include_str!("fixtures/probe14-mcp-failed-then-successor.successor.pre_tool_use.json"); + const PROBE14_MCP_SUCCESSOR_POST: &str = + include_str!("fixtures/probe14-mcp-failed-then-successor.successor.post_tool_use.json"); + const PROBE16_MCP_PARALLEL_A_PRE: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.a.pre_tool_use.json"); + const PROBE16_MCP_PARALLEL_A_POST: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.a.post_tool_use.json"); + const PROBE16_MCP_PARALLEL_B_PRE: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.b.pre_tool_use.json"); + const PROBE16_MCP_PARALLEL_B_POST: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.b.post_tool_use.json"); + + const OTHER_HARNESS_ACTOR_KIND: &str = "claude_code"; + + 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 CodexRepo { + temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + } + + impl CodexRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-codex-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_codex_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) + } + + fn drive_generic(&self, payload: &str) -> Result { + self.drive_generic_at(&self.root, payload) + } + + fn drive_generic_at(&self, repository_root: &Path, payload: &str) -> Result { + crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_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, + "codex mutation-scope regression test assertions", + ) + .expect("assertion DB should open") + } + + fn cwd(&self) -> String { + Self::cwd_at(&self.root) + } + + 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 adapter_state_file_exists(&self) -> bool { + state::adapter_state_dir(&self.git_dir()) + .join("codex-mutation-scope-state.json") + .exists() + } + + 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 write(&self, name: &str, contents: &str) { + fs::write(self.root.join(name), contents).expect("regression write should succeed"); + } + + fn live_scope_id(&self) -> String { + let state = self.adapter_state(); + assert_eq!(state.attempts.len(), 1, "exactly one live attempt expected"); + state.attempts[0].scope_id.clone() + } + } + + 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 raw_agent_trace_row_counts(db: &RepositoryAgentTraceDb) -> [i64; 5] { + [ + count(db, "diff_traces"), + count(db, "post_commit_patch_intersections"), + count(db, "agent_traces"), + count(db, "messages"), + count(db, "parts"), + ] + } + + fn assert_raw_agent_trace_tables_untouched(db: &RepositoryAgentTraceDb) { + assert_eq!( + raw_agent_trace_row_counts(db), + [0, 0, 0, 0, 0], + "AC20: the mutation-scope adapter must never write the raw Agent Trace tables" + ); + } + + 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 active_scopes_for(db: &RepositoryAgentTraceDb, worktree_id: &str) -> Vec { + db.query_map( + "SELECT scope_id FROM mutation_trace_event_active_scopes \ + WHERE worktree_id = ?1 ORDER BY revision, scope_id", + (worktree_id,), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("active-scopes query should succeed") + } + + fn fixture_at(fixture: &str, cwd: &str) -> String { + let mut object: Map = + serde_json::from_str(fixture).expect("a fixture payload is a JSON object"); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + Value::Object(object).to_string() + } + + struct ToolEvent<'a> { + event_name: &'a str, + cwd: &'a str, + session_id: &'a str, + turn_id: &'a str, + tool_name: &'a str, + tool_use_id: &'a str, + agent_id: Option<&'a str>, + } + + fn tool_event_json(event: &ToolEvent, tool_input: Option) -> String { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event.event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(event.session_id.to_string()), + ); + object.insert( + TURN_ID_FIELD.to_string(), + Value::String(event.turn_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(event.cwd.to_string())); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String(event.tool_name.to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String(event.tool_use_id.to_string()), + ); + if let Some(agent_id) = event.agent_id { + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String(agent_id.to_string()), + ); + } + if let Some(tool_input) = tool_input { + object.insert(TOOL_INPUT_FIELD.to_string(), tool_input); + } + Value::Object(object).to_string() + } + + struct TrackedCall<'a> { + cwd: &'a str, + session_id: &'a str, + turn_id: &'a str, + tool_name: &'a str, + tool_use_id: &'a str, + agent_id: Option<&'a str>, + } + + impl TrackedCall<'_> { + fn pre(&self) -> String { + tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_PRE_TOOL_USE, + cwd: self.cwd, + session_id: self.session_id, + turn_id: self.turn_id, + tool_name: self.tool_name, + tool_use_id: self.tool_use_id, + agent_id: self.agent_id, + }, + Some(json!({ "command": "echo regression >> file.txt" })), + ) + } + + fn post(&self) -> String { + tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_POST_TOOL_USE, + cwd: self.cwd, + session_id: self.session_id, + turn_id: self.turn_id, + tool_name: self.tool_name, + tool_use_id: self.tool_use_id, + agent_id: self.agent_id, + }, + None, + ) + } + } + + fn bash_call<'a>( + cwd: &'a str, + session_id: &'a str, + tool_use_id: &'a str, + ) -> TrackedCall<'a> { + TrackedCall { + cwd, + session_id, + turn_id: "turn-1", + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id, + agent_id: None, + } + } + + fn turn_event_json(event_name: &str, cwd: &str, session_id: &str, turn_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: event_name, + SESSION_ID_FIELD: session_id, + TURN_ID_FIELD: turn_id, + CWD_FIELD: cwd, + }) + .to_string() + } + + fn session_end_json(cwd: &str, session_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SESSION_END, + SESSION_ID_FIELD: session_id, + CWD_FIELD: cwd, + }) + .to_string() + } + + fn other_harness_payload(operation: &str, scope_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": format!("{scope_id}|{operation}"), + "actor_kind": OTHER_HARNESS_ACTOR_KIND, + }) + .to_string() + } + + #[test] + fn test1_tracked_bash_success_closes_ai_exclusive_ac8() { + let repo = CodexRepo::new("test1-bash-success"); + let cwd = repo.cwd(); + let pre = fixture_at(PROBE01_SHELL_PRE, &cwd); + let post = fixture_at(PROBE01_SHELL_POST, &cwd); + + assert_eq!( + repo.drive(&pre).expect("PreToolUse should succeed"), + "", + "a tracked PreToolUse that established Start returns the neutral response" + ); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ntwo\n"); + + assert_eq!(repo.drive(&post).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(("codex".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(), codex_scope_close_event_id(&scope_id)), + (scope_id.clone(), codex_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_ac9() { + let repo = CodexRepo::new("test2-failed-bash"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE02_FAILED_SHELL_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\npartial\n"); + + assert_eq!( + repo.drive(&fixture_at(PROBE02_FAILED_SHELL_POST, &cwd)) + .expect("a non-zero-exit shell still fires PostToolUse"), + "" + ); + assert!(repo.adapter_state().attempts.is_empty()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "closed".to_string())), + "D10: a Bash tool that partially mutated then exited non-zero closes its scope" + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )], + "the partial mutation is attributed to the failed tool's own scope" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test3_apply_patch_success_closes_ai_exclusive_ac8() { + let repo = CodexRepo::new("test3-apply-patch-success"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE01_APPLY_PATCH_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("alpha.txt", "alpha one\n"); + + repo.drive(&fixture_at(PROBE01_APPLY_PATCH_POST, &cwd)) + .expect("PostToolUse should succeed"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".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), + "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_apply_patch_verification_failure_mutates_nothing_and_is_swept_ac9() { + let repo = CodexRepo::new("test4-apply-patch-failure"); + let cwd = repo.cwd(); + let pre = fixture_at(PROBE06_APPLY_PATCH_FAILURE_PRE, &cwd); + let tree_before = repo.working_tree(); + + repo.drive(&pre).expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + let stop = { + let execution = pre_tool_use(&pre); + turn_event_json( + HOOK_EVENT_STOP, + &cwd, + &execution.identity.session_id, + &execution.identity.turn_id, + ) + }; + repo.drive(&stop) + .expect("Stop should retire the attempt that never received PostToolUse"); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + assert_eq!( + repo.working_tree(), + tree_before, + "D10: apply_patch verification failure never touches the working tree" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "abandoned".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![], + "no mutation happened, so nothing is attributed" + ); + assert!(worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test5_duplicate_tracked_lifecycle_is_idempotent_ac4() { + let repo = CodexRepo::new("test5-duplicate-lifecycle"); + let cwd = repo.cwd(); + let pre = fixture_at(PROBE01_SHELL_PRE, &cwd); + let post = fixture_at(PROBE01_SHELL_POST, &cwd); + + repo.drive(&pre).expect("first PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + assert_eq!( + repo.drive(&pre) + .expect("duplicate PreToolUse should be idempotent"), + "" + ); + assert_eq!( + repo.live_scope_id(), + scope_id, + "AC4: duplicate delivery of a live PreToolUse reuses the same ScopeId" + ); + + repo.write("file.txt", "one\ntwo\n"); + 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 == &codex_scope_close_event_id(&scope_id)) + .count(), + 1 + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test6_interrupted_tracked_execution_is_retired_by_interrupt_ac11() { + let repo = CodexRepo::new("test6-interrupt"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE11_INTERRUPT_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ninterrupted\n"); + + assert_eq!( + repo.drive(&fixture_at(PROBE11_INTERRUPT, &cwd)) + .expect("Interrupt should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "AC11: Interrupt is a proven cleanup signal for the interrupted turn" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".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_eq!(mutation_events_for(&db, &worktree_id), vec![]); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test6b_session_end_is_the_load_bearing_backstop_ac11() { + let repo = CodexRepo::new("test6b-session-end"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\nstranded\n"); + + assert_eq!( + repo.drive(&fixture_at(PROBE01_SESSION_END, &cwd)) + .expect("SessionEnd should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "D12: SessionEnd is the load-bearing whole-session backstop" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "abandoned".to_string())) + ); + assert!(worktree_row(&db, &repo.worktree_id()) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test7_subagent_tracked_tool_gets_its_own_scope_identity() { + let repo = CodexRepo::new("test7-subagent"); + let cwd = repo.cwd(); + let subagent_pre = fixture_at(PROBE08_AGENT_APPLY_PATCH_PRE, &cwd); + let subagent_identity = pre_tool_use(&subagent_pre).identity; + let agent_id = subagent_identity + .agent_id + .clone() + .expect("probe08 carries a delegated-agent identity"); + let main_thread = TrackedCall { + cwd: &cwd, + session_id: &subagent_identity.session_id, + turn_id: "main-turn", + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id: "exec-main-thread", + agent_id: None, + }; + + repo.drive(&main_thread.pre()) + .expect("main-thread PreToolUse should succeed"); + repo.drive(&subagent_pre) + .expect("subagent PreToolUse should succeed"); + + let state = repo.adapter_state(); + assert_eq!(state.attempts.len(), 2); + let subagent_scope_id = state + .attempts + .iter() + .find(|attempt| attempt.agent_id.as_deref() == Some(agent_id.as_str())) + .map(|attempt| attempt.scope_id.clone()) + .expect("the subagent attempt carries its agent_id"); + let main_scope_id = state + .attempts + .iter() + .find(|attempt| attempt.agent_id.is_none()) + .map(|attempt| attempt.scope_id.clone()) + .expect("the main-thread attempt has no agent_id"); + assert_ne!(subagent_scope_id, main_scope_id); + assert!(subagent_scope_id.contains(&agent_id)); + + repo.drive(&fixture_at(PROBE08_SUBAGENT_STOP, &cwd)) + .expect("SubagentStop should succeed"); + + let remaining = repo.adapter_state(); + assert_eq!( + remaining + .attempts + .iter() + .map(|attempt| attempt.scope_id.clone()) + .collect::>(), + vec![main_scope_id.clone()], + "D12: SubagentStop sweeps only the ending agent's attempts" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &subagent_scope_id).map(|(_, status)| status), + Some("abandoned".to_string()) + ); + assert_eq!( + scope_status(&db, &main_scope_id).map(|(_, status)| status), + Some("active".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test8_linked_worktree_advances_only_its_own_cursor_ac14() { + let repo = CodexRepo::new("test8-linked-worktree"); + let worktree_path = repo.add_worktree("codex-worktree"); + let worktree_cwd = CodexRepo::cwd_at(&worktree_path); + + let main_worktree_id = repo.worktree_id(); + let linked_worktree_id = CodexRepo::worktree_id_at(&worktree_path); + assert_ne!(main_worktree_id, linked_worktree_id); + + 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"); + + let pre = fixture_at(PROBE10_WORKTREE_PRE, &worktree_cwd); + let post = { + let identity = pre_tool_use(&pre).identity; + tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_POST_TOOL_USE, + cwd: &worktree_cwd, + session_id: &identity.session_id, + turn_id: &identity.turn_id, + tool_name: &identity.tool_name, + tool_use_id: &identity.tool_use_id, + agent_id: None, + }, + None, + ) + }; + + repo.drive(&pre) + .expect("linked-worktree PreToolUse should succeed"); + let scope_id = CodexRepo::adapter_state_at(&worktree_path).attempts[0] + .scope_id + .clone(); + fs::write(worktree_path.join("wt.txt"), "worktree-write\n") + .expect("the linked worktree's own write should succeed"); + repo.drive(&post) + .expect("linked-worktree PostToolUse should succeed"); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &main_worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(main_cursor_before), + "AC14: the main checkout's cursor must not move" + ); + let linked_row = + worktree_row(&db, &linked_worktree_id).expect("linked worktree row should exist"); + assert_eq!( + linked_row.1, + CodexRepo::working_tree_at(&worktree_path), + "AC14: the linked worktree's own cursor advances" + ); + assert_eq!( + mutation_events_for(&db, &linked_worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )] + ); + assert_eq!(mutation_events_for(&db, &main_worktree_id), vec![]); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test9_successful_mcp_lifecycle_creates_no_mutation_scope_ac9b() { + let repo = CodexRepo::new("test9-mcp-success"); + let cwd = repo.cwd(); + + assert_eq!( + repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) + .expect("an MCP PreToolUse is allowed"), + "", + "AC9b: an Untracked tool gets the Codex-neutral continue response" + ); + repo.write("mcp_a.txt", "written by the MCP server\n"); + assert_eq!( + repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) + .expect("an MCP PostToolUse is ignored"), + "" + ); + + assert!( + !repo.adapter_state_file_exists(), + "AC9b: an Untracked lifecycle writes no adapter bookkeeping at all" + ); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + assert_eq!(count(&db, "mutation_trace_processed_events"), 0); + assert_eq!(count(&db, "mutation_trace_worktrees"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test10_mcp_mutate_then_error_leaves_no_zombie_state_ac9c() { + let repo = CodexRepo::new("test10-mcp-mutate-then-error"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE13_MCP_MUTATE_THEN_ERROR_PRE, &cwd)) + .expect("an MCP PreToolUse is allowed"); + repo.write("mcp_b.txt", "mutated before the MCP error\n"); + + repo.drive(&fixture_at(PROBE13_MCP_STOP, &cwd)) + .expect("Stop should find nothing to retire"); + repo.drive(&fixture_at(PROBE13_MCP_SESSION_END, &cwd)) + .expect("SessionEnd should find nothing to retire"); + + assert!( + !repo.adapter_state_file_exists(), + "AC9c: no Start occurred, so there is no stale attempt and no recovery to arm" + ); + + 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 test11_failed_mcp_then_tracked_successor_starts_clean_ac9d() { + let repo = CodexRepo::new("test11-mcp-then-tracked"); + let cwd = repo.cwd(); + let failed_mcp = fixture_at(PROBE14_MCP_FAILED_PRE, &cwd); + let failed_identity = pre_tool_use(&failed_mcp).identity; + + repo.drive(&failed_mcp) + .expect("the failing MCP PreToolUse is allowed"); + repo.write("mcp_c1.txt", "mutated by the failing MCP tool\n"); + repo.drive(&fixture_at(PROBE14_MCP_SUCCESSOR_PRE, &cwd)) + .expect("the MCP successor is also Untracked"); + repo.drive(&fixture_at(PROBE14_MCP_SUCCESSOR_POST, &cwd)) + .expect("the MCP successor's PostToolUse is ignored"); + + let tracked_successor = TrackedCall { + cwd: &cwd, + session_id: &failed_identity.session_id, + turn_id: &failed_identity.turn_id, + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id: "exec-tracked-successor", + agent_id: None, + }; + repo.drive(&tracked_successor.pre()) + .expect("the tracked successor should Start normally"); + let scope_id = repo.live_scope_id(); + assert!( + repo.adapter_state().recovery.is_clear(), + "AC9d: no MCP attempt existed, so no successor barrier runs" + ); + + repo.write("file.txt", "one\ntracked-successor\n"); + repo.drive(&tracked_successor.post()) + .expect("the tracked successor's PostToolUse should close its scope"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!(count(&db, "mutation_trace_scopes"), 1); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )], + "AC9d: the tracked successor is the only live scope — no false AiContended" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test12_parallel_mcp_executions_create_no_scopes_ac9e() { + let repo = CodexRepo::new("test12-parallel-mcp"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_A_PRE, &cwd)) + .expect("parallel MCP A is allowed"); + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_B_PRE, &cwd)) + .expect("parallel MCP B is allowed"); + assert!( + !repo.adapter_state_file_exists(), + "AC9e: neither overlapping MCP execution creates adapter state" + ); + + repo.write("mcp_parallel_a.txt", "a\n"); + repo.write("mcp_parallel_b.txt", "b\n"); + + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_A_POST, &cwd)) + .expect("parallel MCP A PostToolUse is ignored"); + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_B_POST, &cwd)) + .expect("parallel MCP B PostToolUse is ignored"); + + assert!(!repo.adapter_state_file_exists()); + + let db = repo.db(); + assert_eq!( + count(&db, "mutation_trace_scopes"), + 0, + "AC9e: overlapping MCP executions produce no scopes and therefore no AiContended" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test13_tracked_scope_overlapping_an_mcp_mutation_is_tracked_exclusivity_ac9f() { + let repo = CodexRepo::new("test13-tracked-plus-mcp"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) + .expect("the tracked Bash PreToolUse should Start"); + let scope_id = repo.live_scope_id(); + + repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) + .expect("the overlapping MCP call is allowed and untracked"); + repo.write("mcp_a.txt", "written by the MCP server, not by Bash\n"); + repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) + .expect("the MCP PostToolUse is ignored"); + + repo.drive(&fixture_at(PROBE01_SHELL_POST, &cwd)) + .expect("the tracked Bash PostToolUse should close its scope"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!(count(&db, "mutation_trace_scopes"), 1); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )], + "AC9f: ai_exclusive means exactly one TRACKED scope was live in the interval, \ + not that the tracked scope authored every mutation — the MCP call did mutate \ + mcp_a.txt inside this interval and remains unattributed (D14/D23)" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test14_unknown_tool_is_allowed_untracked_ac9b() { + let repo = CodexRepo::new("test14-unknown-tool"); + let cwd = repo.cwd(); + let unknown = TrackedCall { + cwd: &cwd, + session_id: "session-unknown", + turn_id: "turn-1", + tool_name: "some_future_codex_tool", + tool_use_id: "exec-unknown", + agent_id: None, + }; + + assert_eq!( + repo.drive(&unknown.pre()) + .expect("an unknown tool is never denied for being untracked"), + "" + ); + repo.write("unknown_tool_output.txt", "the unknown tool mutated\n"); + assert_eq!( + repo.drive(&unknown.post()) + .expect("an unknown tool's PostToolUse is ignored"), + "" + ); + + assert!(!repo.adapter_state_file_exists()); + + 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 test15_regression_matrix_leaves_raw_agent_trace_tables_untouched_ac20() { + let repo = CodexRepo::new("test15-raw-tables"); + let cwd = repo.cwd(); + + let before = raw_agent_trace_row_counts(&repo.db()); + assert_eq!(before, [0, 0, 0, 0, 0]); + + repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) + .expect("tracked PreToolUse should succeed"); + repo.write("file.txt", "one\ntwo\n"); + repo.drive(&fixture_at(PROBE01_SHELL_POST, &cwd)) + .expect("tracked PostToolUse should succeed"); + repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) + .expect("MCP PreToolUse should succeed"); + repo.write("mcp_a.txt", "mcp\n"); + repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) + .expect("MCP PostToolUse should succeed"); + repo.drive(&fixture_at(PROBE01_APPLY_PATCH_PRE, &cwd)) + .expect("apply_patch PreToolUse should succeed"); + repo.drive(&fixture_at(PROBE01_STOP, &cwd)) + .expect("Stop should succeed"); + + let db = repo.db(); + assert_eq!( + raw_agent_trace_row_counts(&db), + before, + "AC20: mutation-scope-only regressions leave diff_traces, \ + post_commit_patch_intersections, agent_traces, messages and parts unchanged" + ); + assert!(count(&db, "mutation_trace_scopes") > 0); + assert!( + state::adapter_state_dir(&repo.git_dir()).starts_with(repo.git_dir()), + "AC20: adapter state lives only below /sce/" + ); + } + + #[test] + fn test16_arbitrary_blocker_zombie_then_same_lane_successor_ac9a() { + let repo = CodexRepo::new("test16-zombie-successor"); + let cwd = repo.cwd(); + let zombie = bash_call(&cwd, "session-lane", "exec-zombie"); + let successor = bash_call(&cwd, "session-lane", "exec-successor"); + + repo.drive(&zombie.pre()) + .expect("the first tracked PreToolUse should Start"); + let zombie_scope_id = repo.live_scope_id(); + repo.write("file.txt", "one\nzombie-partial\n"); + + repo.drive(&successor.pre()) + .expect("the same-lane successor should sweep, flush, then Start"); + let successor_scope_id = repo.live_scope_id(); + assert_ne!(zombie_scope_id, successor_scope_id); + assert!( + repo.adapter_state().recovery.is_clear(), + "the quiescent flush must clear the barrier before Start(B)" + ); + + repo.write("file.txt", "one\nzombie-partial\nsuccessor\n"); + repo.drive(&successor.post()) + .expect("the successor's PostToolUse should close its scope"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &zombie_scope_id), + Some(("codex".to_string(), "abandoned".to_string())), + "AC9a: the stale same-lane predecessor is abandoned, never closed" + ); + assert_eq!( + scope_status(&db, &successor_scope_id), + Some(("codex".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + let events = mutation_events_for(&db, &worktree_id); + assert!( + events.iter().all(|(kind, scope, _)| kind != "ai_contended" + && scope.as_deref() != Some(zombie_scope_id.as_str())), + "AC9a: no false AiContended and nothing attributed to the zombie: {events:?}" + ); + assert_eq!( + events.last(), + Some(&( + "ai_exclusive".to_string(), + Some(successor_scope_id), + "close".to_string() + )), + "the successor is the only live scope at its own Close" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test17_crash_before_start_commit_is_recovered_conservatively_ac21a() { + let repo = CodexRepo::new("test17-crash-before-start"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + let crashed = bash_call(&cwd, "session-crash", "exec-crashed"); + let key = key("session-crash", None, "exec-crashed"); + + let attempt = state::seed_attempt_for_tests( + &git_dir, + &key, + "turn-1", + CODEX_TRACKED_TOOL_BASH, + state::AttemptPhase::PendingStart, + ); + + repo.drive(&crashed.post()) + .expect("D11: a pending_start attempt must abandon, not late-Start"); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &attempt.scope_id), + None, + "AC21a: a Start that never committed must never appear as a real scope" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + let fresh = bash_call(&cwd, "session-crash", "exec-fresh"); + repo.drive(&fresh.pre()) + .expect("the next tracked PreToolUse proceeds after the quiescent flush"); + assert!(repo.adapter_state().recovery.is_clear()); + assert_eq!(repo.adapter_state().attempts.len(), 1); + + assert_raw_agent_trace_tables_untouched(&repo.db()); + } + + #[test] + fn test18_start_committed_before_state_settlement_is_abandoned_ac21b() { + let repo = CodexRepo::new("test18-crash-after-start"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + let crashed = bash_call(&cwd, "session-crash", "exec-crashed"); + let key = key("session-crash", None, "exec-crashed"); + + let attempt = state::seed_attempt_for_tests( + &git_dir, + &key, + "turn-1", + CODEX_TRACKED_TOOL_BASH, + state::AttemptPhase::PendingStart, + ); + let scope_id = attempt.scope_id.clone(); + + repo.drive_generic(&scope_boundary_payload( + "start", + &scope_id, + &codex_scope_start_event_id(&scope_id), + )) + .expect("the runtime Start should commit durably"); + assert_eq!( + repo.adapter_state().attempts[0].phase, + state::AttemptPhase::PendingStart + ); + + repo.drive(&crashed.post()) + .expect("D11: a committed Start with unsettled bookkeeping must be abandoned"); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "abandoned".to_string())), + "AC21b: the committed Start settles as a real abandonment, not a late Start" + ); + assert!(worktree_row(&db, &repo.worktree_id()) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test19_close_committed_before_state_cleanup_is_replay_safe_ac21c() { + let repo = CodexRepo::new("test19-crash-after-close"); + let cwd = repo.cwd(); + let call = bash_call(&cwd, "session-close", "exec-close"); + + repo.drive(&call.pre()).expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + repo.write("file.txt", "one\ntwo\n"); + + repo.drive_generic(&scope_boundary_payload( + "close", + &scope_id, + &codex_scope_close_event_id(&scope_id), + )) + .expect("the runtime Close should commit durably"); + assert_eq!(repo.adapter_state().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"), + ); + + repo.drive(&call.post()) + .expect("a replayed Close against an already-durable commit must be safe"); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the stale bookkeeping is finally cleared" + ); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), + Some(revision_before), + "AC21c: a durably completed Close is never 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 test20_recovery_pending_blocks_a_tracked_successor_until_recovery_succeeds_ac12() { + let repo = CodexRepo::new("test20-recovery-barrier"); + let cwd = repo.cwd(); + let first = bash_call(&cwd, "session-a", "exec-a"); + let second = bash_call(&cwd, "session-b", "exec-b"); + let blocked = bash_call(&cwd, "session-c", "exec-c"); + + repo.drive(&first.pre()).expect("session-a Start"); + repo.drive(&second.pre()).expect("session-b Start"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + repo.write("file.txt", "one\nabandoned\n"); + repo.drive(&turn_event_json( + HOOK_EVENT_INTERRUPT, + &cwd, + "session-a", + "turn-1", + )) + .expect("Interrupt should retire session-a's attempt"); + assert!(!repo.adapter_state().recovery.is_clear()); + assert_eq!(repo.adapter_state().attempts.len(), 1); + + assert_eq!( + repo.drive(&blocked.pre()) + .expect("a barred PreToolUse still returns Ok with a deny payload"), + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "AC12: while recovery is armed and attempts remain, a tracked successor is denied" + ); + assert!( + repo.adapter_state() + .attempts + .iter() + .all(|attempt| attempt.tool_use_id != "exec-c"), + "the denied successor must never be admitted" + ); + + repo.drive(&session_end_json(&cwd, "session-b")) + .expect("SessionEnd should retire session-b's attempt"); + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + + repo.drive(&blocked.pre()) + .expect("once quiescent, the flush runs and the successor Starts"); + assert!( + repo.adapter_state().recovery.is_clear(), + "AC12: recovery_pending clears only on durable flush success" + ); + let scope_id = repo.live_scope_id(); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id).map(|(_, status)| status), + Some("active".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test21_reused_tool_use_id_after_terminal_gets_a_fresh_scope_id_ac5() { + let repo = CodexRepo::new("test21-reused-identifier"); + let cwd = repo.cwd(); + let call = bash_call(&cwd, "session-reuse", "exec-reused"); + + repo.drive(&call.pre()).expect("first PreToolUse"); + let first_scope_id = repo.live_scope_id(); + repo.write("file.txt", "one\nfirst\n"); + repo.drive(&call.post()).expect("first PostToolUse"); + assert!(repo.adapter_state().attempts.is_empty()); + + repo.drive(&call.pre()) + .expect("a later attempt reusing the same tool_use_id"); + let second_scope_id = repo.live_scope_id(); + assert_ne!( + first_scope_id, second_scope_id, + "AC5: a terminal ScopeId is never reused" + ); + + repo.write("file.txt", "one\nfirst\nsecond\n"); + repo.drive(&call.post()).expect("second PostToolUse"); + + 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 test22_self_detaching_descendant_write_is_not_folded_into_the_closed_scope_ac15() { + let repo = CodexRepo::new("test22-detached-descendant"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE09_DETACHED_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\nforeground\n"); + let tree_at_close = repo.working_tree(); + repo.drive(&fixture_at(PROBE09_DETACHED_POST, &cwd)) + .expect("PostToolUse should succeed"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(tree_at_close.clone()) + ); + let events_before_flush = mutation_events_for(&db, &worktree_id); + + repo.write("file.txt", "one\nforeground\ndetached-descendant\n"); + let tree_after_descendant = repo.working_tree(); + assert_ne!(tree_after_descendant, tree_at_close); + + repo.drive_flush() + .expect("a later 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); + let (attribution_kind, attribution_scope_id, _) = events_after_flush + .last() + .expect("a flush event should exist"); + assert_eq!( + attribution_kind, "ineligible_unscoped", + "AC15/D16: SCE does not supervise self-detaching descendants; \ + a post-terminal write is never folded into the closed tool scope" + ); + assert_ne!(attribution_scope_id.as_deref(), Some(scope_id.as_str())); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test23_denied_tracked_execution_leaves_no_untracked_start_ac7() { + let repo = CodexRepo::new("test23-policy-denied"); + let cwd = repo.cwd(); + fs::create_dir_all(repo.root.join(".sce")).expect(".sce dir should be created"); + fs::write( + repo.root.join(".sce").join("config.json"), + concat!( + r#"{"policies":{"bash":{"custom":[{"id":"no-rm","#, + r#""match":{"argv_prefix":["rm"]},"#, + r#""message":"rm is blocked in this repository"}]}}}"#, + ), + ) + .expect("repo bash policy config should write"); + + let denied = tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_PRE_TOOL_USE, + cwd: &cwd, + session_id: "session-denied", + turn_id: "turn-1", + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id: "exec-denied", + agent_id: None, + }, + Some(json!({ "command": "rm -rf build" })), + ); + + let response = repo + .drive(&denied) + .expect("a policy-denied Bash command still returns Ok with a deny payload"); + assert!(response.contains(r#""permissionDecision":"deny""#)); + + assert!( + !repo.adapter_state_file_exists(), + "AC7: a denied tracked execution must never leave an untracked Start behind" + ); + + 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 test24_cross_harness_overlap_at_a_non_confirming_boundary_is_ineligible_ac10() { + let repo = CodexRepo::new("test24-cross-harness-ineligible"); + let cwd = repo.cwd(); + let codex_call = bash_call(&cwd, "session-cross", "exec-codex"); + let other_scope_id = "claude-scope-1"; + + repo.drive_generic(&other_harness_payload("start", other_scope_id)) + .expect("the other harness's Start should commit"); + repo.drive(&codex_call.pre()) + .expect("the Codex PreToolUse should Start"); + let codex_scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ncontended\n"); + + repo.drive_generic(&other_harness_payload("close", other_scope_id)) + .expect("the other harness's Close should commit"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ineligible_unscoped".to_string(), None, "close".to_string())], + "AC10/D14: an unconfirmed live Codex scope forces IneligibleUnscoped at a \ + boundary that does not confirm it — never AiContended" + ); + let mut active = active_scopes_for(&db, &worktree_id); + active.sort(); + let mut expected = vec![codex_scope_id, other_scope_id.to_string()]; + expected.sort(); + assert_eq!( + active, expected, + "active_scopes still records the complete live set; only eligibility changes" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test25_cross_harness_overlap_at_the_codex_close_is_contended_ac10() { + let repo = CodexRepo::new("test25-cross-harness-contended"); + let cwd = repo.cwd(); + let codex_call = bash_call(&cwd, "session-cross", "exec-codex"); + let other_scope_id = "claude-scope-1"; + + repo.drive_generic(&other_harness_payload("start", other_scope_id)) + .expect("the other harness's Start should commit"); + repo.drive(&codex_call.pre()) + .expect("the Codex PreToolUse should Start"); + let codex_scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ncontended\n"); + + repo.drive(&codex_call.post()) + .expect("the Codex PostToolUse should close its scope"); + + 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())], + "AC10/D14: the Codex scope's own Close confirms it, so the overlap with the \ + other harness's live scope is attributed AiContended" + ); + assert_eq!( + scope_status(&db, &codex_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test26_a_second_unconfirmed_codex_scope_suppresses_contention_ac10() { + let repo = CodexRepo::new("test26-second-codex-scope"); + let cwd = repo.cwd(); + let confirmed = bash_call(&cwd, "session-one", "exec-one"); + let unconfirmed = bash_call(&cwd, "session-two", "exec-two"); + let other_scope_id = "claude-scope-1"; + + repo.drive_generic(&other_harness_payload("start", other_scope_id)) + .expect("the other harness's Start should commit"); + repo.drive(&confirmed.pre()) + .expect("the first Codex PreToolUse should Start"); + repo.drive(&unconfirmed.pre()) + .expect("a second Codex lane's PreToolUse should Start"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + repo.write("file.txt", "one\ncontended\n"); + + repo.drive(&confirmed.post()) + .expect("the first Codex scope's Close should commit"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ineligible_unscoped".to_string(), None, "close".to_string())], + "AC10/D14: a second unconfirmed live Codex scope suppresses attribution back to \ + IneligibleUnscoped even at a confirming Codex Close" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + } } diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 7d8f27dd..1b5f73df 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -3372,7 +3372,7 @@ Persist this field in every plan; this is durable plan state, not chat state: ./scripts/test-codex-hook-command.sh` — **passed**; `clippy ... --all-targets -- -D warnings` — **clean**; `cargo fmt -- --check` — **clean**. -- [ ] T06: `Real Git/DB regressions through the production Codex path` (status:todo) +- [x] T06: `Real Git/DB regressions through the production Codex path` (status:done) - Task ID: T06 - **Unblocked (2026-09-08).** T01–T05 are `done` and `synced`, the protocol/formal safety follow-up is accepted, and the plan/AC22 consistency @@ -3486,7 +3486,90 @@ Persist this field in every plan; this is durable plan state, not chat state: `git diff b72f6c2c -- spec/mutation_cursor.qnt spec/mutation_cursor.md cli/src/services/mutation_trace/protocol.rs cli/src/services/mutation_trace/runtime/` is empty. - - Context synchronization: pending + - Completed: 2026-09-08 + - Files changed: + - `cli/src/services/hooks/codex_mutation_scope/mod.rs` (+1622, test-only: + new `tests::production_regressions` module) + - Result: Added 27 production-path regressions in + `codex_mutation_scope::tests::production_regressions`, each driving real + temporary Git repositories and real repository Agent Trace DBs through + `run_codex_mutation_scope_from_payload_at_state_root` (the `#[cfg(test)]` + state-root variant of the real adapter entry point, which already existed + from T04) into the generic ingress and the real mutation runtime. No manual + `mutation_trace_*` inserts and no injected seams; the only injection is the + adapter's own `state::seed_attempt_for_tests` bookkeeping helper for the + crash rows. **No production file changed** — the diff is confined to the + adapter module's `#[cfg(test)]` tree. + Matrix coverage (test -> criterion): + `test1`/`test3` tracked `Bash` and `apply_patch` success -> `Closed` / + `AiExclusive` (AC8); `test2` `Bash` partial write then non-zero exit -> + `Closed`, partial mutation attributed to that scope (AC9); `test4` + `apply_patch` verification failure -> no mutation, `Stop` sweep, no scope to + close (AC9); `test5` duplicate `Pre`/`Post` redelivery -> same `ScopeId`, no + second transition (AC4); `test6`/`test6b`/`test7`/`test4` the four proven D12 + cleanup signals `Interrupt` / `SessionEnd` / `SubagentStop` / `Stop` (AC11); + `test7` subagent tracked tool -> its own `agent_id`-bearing scope, swept only + by its own `SubagentStop`; `test8` linked worktree -> only its own cursor + advances (AC14); `test9` full successful MCP lifecycle -> allowed, zero + mutation-scope rows, no adapter state file (AC9b); `test10` probe-13 + mutate-then-error -> no stale attempt, no `recovery_pending`, no zombie + (AC9c); `test11` probe-14 failed MCP then tracked successor -> successor is + the only live scope, no false `AiContended` (AC9d); `test12` probe-16 + parallel MCP -> no scopes, no MCP-derived `AiContended`, no state leak + (AC9e); `test13` tracked `Bash` overlapping a real MCP mutation -> + `AiExclusive` asserted with the tracked-scope-exclusivity (not + sole-authorship) reading recorded in the assertion message (AC9f); `test14` + unknown tool -> neutral response, untracked (AC9b); `test15` raw Agent Trace + tables (`diff_traces`, `post_commit_patch_intersections`, `agent_traces`, + `messages`, `parts`) unchanged before/after, adapter state only below + `/sce/` (AC20, also asserted at the end of every other row); + `test16` arbitrary-blocker zombie built-in A then same-lane successor B -> + `Abandon(A)` -> flush -> `Start(B)`, nothing attributed to A, no false + `AiContended` (AC9a); `test17`/`test18`/`test19` the three crash points + (AC21a/b/c); `test20` `recovery_pending` denies a tracked successor while + attempts remain and clears only on durable flush success (AC12); `test21` + reused `tool_use_id` after terminal -> fresh `ScopeId` (AC5); `test22` + self-detaching descendant write after `PostToolUse` -> `IneligibleUnscoped` + at the later flush, never folded into the closed scope (AC15 documented + half; D16 records that this `codex exec` surface exposes no Codex-managed + background execution to deny); `test23` policy-denied tracked `Bash` -> deny + response, no scope, no bookkeeping (AC7 production half); `test24`/`test25`/ + `test26` the refined D14 rule in both directions plus the suppression case — + `IneligibleUnscoped` at a non-confirming other-harness `Close` with + `active_scopes` still carrying both scopes, `AiContended` at the Codex + scope's own `Close`, and back to `IneligibleUnscoped` when a second + unconfirmed live Codex scope remains (AC10). The other harness's scope is + driven through the same production generic-ingress seam with + `"actor_kind":"claude_code"`, never by SQL. + - Verify: + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::hooks::codex_mutation_scope` — **pass** + (146 passed, 0 failed, 1 ignored; the 27 new regressions included). + - `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path + cli/Cargo.toml services::mutation_trace::` — **pass** (336 passed, + 0 failed, including the MBT refinement suite). + - `git diff b72f6c2c -- spec/mutation_cursor.qnt spec/mutation_cursor.md + cli/src/services/mutation_trace/protocol.rs + cli/src/services/mutation_trace/runtime/` — **empty** (frozen baseline + held; AC22). + - Additional checks run: `services::hooks::` (478 passed, 0 failed); + `clippy --all-targets -- -D warnings` — clean; `fmt --check` — clean; + `git diff origin/claude-mutation-scope-integration -- + cli/migrations/agent-trace-repository/ + config/schema/agent-trace.schema.json` — empty (AC22 SQL/schema half); + the AC19 dependency-boundary grep matches only inside the + `#[cfg(test)]` module. + - Deviation: the `#[cfg(test)]` state-root entry point the scope asked T06 to + add already existed (`run_codex_mutation_scope_from_payload_at_state_root`, + added by T04 and previously unused); T06 uses it rather than adding a second + one. AC9f's "comment/doc line" is carried by the test's assertion message + and test name rather than a code comment, per the repository's + no-comments-in-code convention; T07 owns the durable-context wording. + - Context impact: none for durable context — test-only change, no production + behaviour, interface, or architecture change. The Codex adapter domain file + and the cross-reference updates remain T07's scope; this task adds the + executable evidence those documents will describe. + - Context synchronization: synced - [ ] T07: `Author the durable Codex mutation-scope context` (status:todo) - Task ID: T07 From 4a834f55a486f83ab7665d8c3081c5a0d26e6e9f Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 18:42:46 +0200 Subject: [PATCH 18/20] context: Author Codex mutation-scope integration context Document the completed Codex adapter as the second wired mutation-scope producer and preserve the tested boundaries around tracked, delegation, and untracked tools, lifecycle recovery, attribution, setup, and doctor behavior. Split the existing apply_patch evidence contract into its own context document and update overview, architecture, routing, and context-map references to keep durable context aligned. Complete plan `codex-mutation-scope-integration` task T07 and record its verification evidence and synchronized context state. Co-authored-by: SCE --- context/architecture.md | 10 + .../cli/codex-mutation-scope-integration.md | 250 ++++++++++++++++++ context/cli/mutation-scope-hook-ingress.md | 20 +- context/cli/mutation-scope-runtime.md | 92 +++---- .../cli/mutation-trace-runtime-coordinator.md | 33 +-- context/context-map.md | 5 + context/glossary.md | 2 +- context/overview.md | 12 + .../plans/codex-mutation-scope-integration.md | 49 +++- .../sce/agent-trace-hooks-command-routing.md | 5 +- context/sce/codex-apply-patch-diff-runtime.md | 56 ++++ context/sce/codex-integration-runtime.md | 96 +------ 12 files changed, 452 insertions(+), 178 deletions(-) create mode 100644 context/cli/codex-mutation-scope-integration.md create mode 100644 context/sce/codex-apply-patch-diff-runtime.md diff --git a/context/architecture.md b/context/architecture.md index 5a49d3aa..5e232318 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -1,5 +1,15 @@ # Architecture +## Mutation-scope harness adapters + +The mutation-scope runtime now has two concrete lifecycle adapters: Claude Code +and Codex. Codex is implemented in +`cli/src/services/hooks/codex_mutation_scope/` and reaches the generic ingress +through its in-process seam; its hidden command is registered by the shared +Codex setup/merge/doctor path. OpenCode and Pi remain unwired. The Codex +adapter's tracked-tool coverage and MCP boundary are documented in +[`context/cli/codex-mutation-scope-integration.md`](cli/codex-mutation-scope-integration.md). + ## Config generation boundary (current approved design) The repository keeps no committed OpenCode, Claude, Pi, or Codex generated target trees. `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and `config/.codex` are logical payload layouts emitted only beneath temporary generation roots, Cargo `OUT_DIR`, and packaging-only fallback directories. diff --git a/context/cli/codex-mutation-scope-integration.md b/context/cli/codex-mutation-scope-integration.md new file mode 100644 index 00000000..8c887dd7 --- /dev/null +++ b/context/cli/codex-mutation-scope-integration.md @@ -0,0 +1,250 @@ +# Codex mutation-scope integration + +The Codex mutation-scope adapter is SCE's second concrete harness producer. It +lives in `cli/src/services/hooks/codex_mutation_scope/` behind the hidden +`sce hooks codex-mutation-scope` command. Its only production dependency inside +the mutation stack is the in-process +`hooks::mutation_scope::run_mutation_scope_from_payload` seam; it does not call +runtime, protocol, or database modules directly. + +The adapter was tested against codex-cli **0.153.4**, upstream +`openai/codex` tag `rust-v0.153.4`, commit +`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`; raw lifecycle evidence is in +[`codex_mutation_scope/fixtures`](../../cli/src/services/hooks/codex_mutation_scope/fixtures/) +and `fixtures/NOTES.md`. + +## Scope model and coverage + +The attribution unit is one independently mutation-capable **tracked** Codex +tool execution, never a session, turn, or delegated agent. Each tracked +execution receives one fresh `ScopeId`, even when session or tool identifiers +repeat. + +| Codex tool class | Tool names in v1 | Mutation-scope behavior | +| --- | --- | --- | +| `TrackedMutation` | `Bash`, `apply_patch` | one attempt, one scope, write-ahead `Start`, terminal `Close` | +| `Delegation` | `collaborationspawn_agent`, `collaborationwait_agent` | no scope for the delegation tool; tracked tools of the delegated agent get their own scopes | +| `Untracked` | `mcp__*`, and every unknown/future tool name | neutral pass-through; no scope, `Start`, terminal bookkeeping, or recovery state | + +`Untracked` means outside attribution coverage, not read-only. MCP and unknown +tools remain usable and may mutate the checkout, but their mutations are not +individually attributed; the adapter does not deny them, emit an explicit allow, +or claim to have observed them. + +### Why MCP is outside v1 coverage + +T01's live probes against codex-cli 0.153.4 established that a mutation-capable +MCP tool can write a Git-visible file, return `CallToolResult.is_error == true`, +and emit no terminal hook; a failed call can be followed directly by another +MCP `PreToolUse` with no cleanup signal; and two mutation-capable MCP calls can +genuinely overlap when parallel calls are enabled by server configuration or +the tool's `readOnlyHint` annotation. + +That is D10a Case C if MCP were modeled as a scope: the adapter could not +distinguish a failed-and-dead scope from a still-running one. The v1 resolution +is therefore a deliberate coverage boundary: MCP and unknown tools create no +adapter attempt, so they cannot strand a zombie mutation scope or produce +MCP-derived `AiContended`. First-class MCP attribution using a richer, +overlap-tolerant lifecycle mechanism is deferred to a separate, explicitly +justified change. + +The runtime describes exclusivity only among known tracked scopes: +`AiExclusive(scope)` means exactly one was live, not that it authored every +filesystem mutation. An MCP call, human editor, or other untracked actor may +mutate the same worktree; tracked Codex plus MCP may still yield `AiExclusive` +for the tracked scope. + +## Raw event mapping + +The parser accepts the probed forms `PreToolUse`, `PostToolUse`, `Stop`, +`Interrupt`, `SubagentStop`, and `SessionEnd`, with non-blank identity fields. +Tool identity is `(session_id, agent_id?, tool_use_id)`; `turn_id` is lane +metadata, not part of the execution key. + +| Raw event | Tracked adapter action | +| --- | --- | +| `PreToolUse` for `Bash` or `apply_patch` | classify, run the Bash policy preflight when applicable, admit an attempt, persist `pending_start`, invoke ingress `start`, mark it `active`, then return neutral continue | +| `PostToolUse` for `Bash` or `apply_patch` | find the matching attempt and invoke ingress `close`; remove adapter state only after the close succeeds | +| `Stop` | abandon outstanding main-agent attempts in the session; it does not sweep delegated-agent attempts | +| `Interrupt` | abandon all outstanding attempts in the session | +| `SubagentStop` | abandon attempts for the matching session and `agent_id` | +| `SessionEnd` | abandon all outstanding attempts in the session; this is the load-bearing cleanup backstop | + +Terminal hooks are positive lifecycle evidence; cleanup is never inferred from +inactivity, `ActorKind`, or a state file. `PostToolUse` for an untracked tool is +ignored, and delegation/untracked `PreToolUse` returns empty, Codex-neutral +stdout without resolving Git, acquiring state, or calling the seam. + +T01 proved built-in serial execution in the `(session_id, turn_id)` lane. Before +a successor, the adapter sweeps a different tracked predecessor in that lane +when an arbitrary sibling hook may have denied it after SCE's `Start`; other +sessions/turns are not swept, and MCP is not part of this rule. + +## Identity and ingress contract + +Each new attempt gets a checkout-local monotonic sequence and length-prefixed ID: + +```text +cx-tool-v1|n=|s=:|a=:|t=: +``` + +The agent ID is empty for the main agent; event IDs are `|start` and +`|close`. Live duplicates reuse attempt, scope, and event IDs, while a +later execution never reuses a terminal scope, even if Codex reuses +`tool_use_id`. + +The raw hook `cwd` is passed as `repository_root`; the runtime derives Git +directory, checkout identity, snapshots, and revisions. The adapter never +constructs `worktree_id`, and sends `actor_kind: "codex"` through the existing +seam without spawning `sce`. + +## Write-ahead admission and failure posture + +Tracked `PreToolUse` follows this ordering: + +```text +boundary lock + -> state lock -> allocate sequence -> persist pending_start + -> generic ingress Start(scope, |start, codex) + -> state lock -> mark active + -> empty stdout / Codex continue +``` + +Failure to resolve checkout, acquire a lock, persist state, evaluate Bash +policy, or establish `Start` is fail-closed for tracked tools: + +```json +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"SCE could not establish mutation attribution for this tool execution."}} +``` + +The detailed failure is logged as +`sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed`, not exposed in the +denial reason. Delegation and untracked tools do not use this path. + +The generated `PreToolUse` bootstrap also fails closed for matcher +`^(Bash|apply_patch)$` when Git-root resolution, the helper, `sce`, or adapter +cannot be reached, while preserving MCP/unknown pass-through. If `Start` may +have committed but `mark_active` failed, `pending_start` blocks a tracked +successor until positive cleanup and a quiescent flush complete. + +## Recovery and durable state + +Adapter bookkeeping is checkout-local: + +```text +/sce/codex-mutation-scope-state.json +/sce/codex-mutation-scope-state.lock +``` + +Version-3 JSON stores `next_attempt_seq`, recovery generation, +`clear`/`pending`/`flushing`, and attempts containing scope, execution identity, +tool, lane turn, and `pending_start`/`active` phase. Writes stage, `sync_data`, +rename, and sync the directory where supported. The boundary lock serializes +state-to-ingress-to-state across hook processes (boundary lock then state lock); +file existence alone is never ownership. + +Positive cleanup arms `recovery_pending`, abandons matching tracked scopes via +generic `abandon`, and removes settled attempts. Known attempts keep tracked +`PreToolUse` denied; once empty, one generic `flush` re-baselines and clears +recovery. Flush failure leaves the barrier armed. Untracked events never enter +or are blocked by this barrier. + +The cleanup matrix is identity-scoped: `Stop` covers the session's main agent, +`Interrupt` and `SessionEnd` cover the session, and `SubagentStop` covers one +delegated agent. A linked worktree's `cwd` resolves to its own independent state +and cursor. + +## Concurrency and attribution confirmation + +Codex built-in tracked tools were observed serially, so no Codex-only overlap +was observed. Distinct tracked scopes remain required, and a tracked Codex scope +can overlap a Claude, OpenCode, or Pi scope on the same worktree. + +The accepted boundary-aware rule is important: Codex `Start` is write-ahead +admission, not positive execution confirmation, because an arbitrary sibling +`PreToolUse` hook can deny after SCE's hook succeeds. While an active Codex +scope remains unconfirmed, a mutation observed at any non-confirming boundary +is `IneligibleUnscoped`, including at another harness's boundary. Only that +exact Codex scope's own proven `PostToolUse` → `Close` confirms it for the +current boundary. Then ordinary `AiExclusive`/`AiContended` rules apply if no +other unconfirmed Codex scope remains. A second unconfirmed live Codex scope +keeps the result ineligible. The complete live scope set remains in the +mutation event; no `confirmed` bit is persisted. + +This conservative rule prefers false negatives to false-positive authorship +claims. It is the only accepted protocol/Quint follow-up; T07 adds no further +protocol, runtime-semantic, attribution-algorithm, SQL, or Agent Trace schema +change. + +## Configuration, trust, and ownership + +The hidden command is separate from the existing fail-open `sce hooks codex` +conversation/diff dispatcher. `sce setup --codex` and `--all` install both +contracts through the shared `codex_hook_config` merge service. The generated +`.codex/hooks.json` has four existing `sce hooks codex` registrations and six +mutation-scope registrations: matched `PreToolUse` and `PostToolUse` groups for +`^(Bash|apply_patch)$`, plus unmatched `Stop`, `Interrupt`, `SubagentStop`, and +`SessionEnd` groups. They are appended after existing groups so the event, +matcher, group index, and handler index used by Codex trust remain stable. + +Ownership recognizes the helper path plus the exact trailing command contract +for either `sce hooks codex` or `sce hooks codex-mutation-scope`. Unrelated valid +Codex handlers and fields survive merge; malformed or Codex-invalid documents +fail before staging. Doctor reports mutation-scope rows separately from the +conversation/diff rows and keeps three dimensions distinct: structural +registration, Codex trust, and effective project-hook policy. Doctor reads +Codex trust/policy state and never writes it; `--fix` repairs only SCE-owned +structure. The event-key labels `interrupt`, `subagent_stop`, and `session_end` +are verified against the supported upstream event names. + +## Existing Codex evidence remains separate + +The existing `sce hooks codex` behavior is additive and unchanged: it captures +`UserPromptSubmit`/`Stop` conversation rows, applies the Bash policy, and +captures `PostToolUse(apply_patch)` diff evidence. The mutation-scope adapter +writes only through the generic mutation runtime and only to `mutation_trace_*` +tables. It does not write `diff_traces`, `post_commit_patch_intersections`, +`agent_traces`, `messages`, or `parts`, and it does not fold the complementary +apply-patch evidence pipeline into mutation-scope storage. + +Codex has no Codex-managed background execution surface in the supported +`codex exec` path. A foreground Bash command can still spawn a self-detaching +descendant that writes after `PostToolUse`; the adapter does not supervise +processes, inspect process groups, poll for staleness, or treat `PostToolUse` as +proof that every descendant stopped mutating. Such writes are outside the +closed tracked interval and remain conservatively unscoped. + +## Unsupported / coverage boundary + +The v1 adapter intentionally leaves these cases outside individual Codex +mutation-scope attribution: + +- MCP tools, including mutation-capable and parallel MCP tools; +- unknown and future Codex tool names until their lifecycle is researched; +- filesystem mutations by humans or detached descendants; and +- first-class attribution for MCP, which is future work requiring a richer + lifecycle mechanism and a separate design/implementation change. + +This boundary is tested and documented for codex-cli 0.153.4. It is not a claim +that excluded tools are read-only, harmless, or immediately detectable. + +## Verification evidence + +T06 exercised the adapter through real temporary Git repositories, real +repository Agent Trace databases, and the production entrypoint. The adapter +suite passed with 146 tests; mutation-trace passed with 336. Regressions cover +write-ahead admission, duplicate and reused +identities, tracked success/failure, all cleanup signals, linked worktrees, +MCP pass-through and mutate-then-error behavior, parallel MCP, tracked-tool plus +MCP overlap, arbitrary-hook denial recovery, crash points, and both directions +of the boundary-aware Codex attribution rule. + +The frozen protocol/Quint/runtime baseline and the Agent Trace SQL/schema +boundary remain unchanged after the accepted D14 follow-up: +```text +git diff b72f6c2c -- spec/mutation_cursor.qnt spec/mutation_cursor.md \ + cli/src/services/mutation_trace/protocol.rs \ + cli/src/services/mutation_trace/runtime/ +git diff origin/claude-mutation-scope-integration -- \ + cli/migrations/agent-trace-repository/ config/schema/agent-trace.schema.json +``` diff --git a/context/cli/mutation-scope-hook-ingress.md b/context/cli/mutation-scope-hook-ingress.md index 8709113d..e9552676 100644 --- a/context/cli/mutation-scope-hook-ingress.md +++ b/context/cli/mutation-scope-hook-ingress.md @@ -1,18 +1,17 @@ # Mutation-scope hook ingress: the harness-neutral transport seam -`sce hooks mutation-scope` is the one generic CLI ingress that drives the -mutation-scope runtime (`coordinate()` / `abandon_scope()`, documented in -[`mutation-scope-runtime.md`](mutation-scope-runtime.md)). It reads a single -normalized JSON lifecycle object from STDIN, strictly parses and validates it, -translates it into one `RuntimeBoundary` or one `abandon_scope()` call, and -invokes the existing runtime with a lazy DB provider. +`sce hooks mutation-scope` is the generic CLI ingress for the mutation-scope +runtime (`coordinate()` / `abandon_scope()`, documented in +[`mutation-scope-runtime.md`](mutation-scope-runtime.md)). It strictly parses +one normalized JSON lifecycle object from STDIN, translates it into one runtime +call, and invokes the existing runtime with a lazy DB provider. Built by the `mutation-scope-hook-ingress` plan (`context/plans/mutation-scope-hook-ingress.md`). It lives in `cli/src/services/hooks/mutation_scope.rs` and is the transport/normalization -seam every future Claude Code, Codex, OpenCode, and Pi adapter will target. It -contains **no** concrete harness mapping and **no** lifecycle-event translation — -see [Generic ingress vs harness adapter](#generic-ingress-vs-harness-adapter). +seam used by shipped Claude Code/Codex adapters and intended for future +OpenCode/Pi adapters. It contains **no** concrete mapping or lifecycle +translation — see [Generic ingress vs harness adapter](#generic-ingress-vs-harness-adapter). ## Command routing @@ -240,8 +239,9 @@ 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 +The Codex mapping and partial coverage are in [`codex-mutation-scope-integration.md`](codex-mutation-scope-integration.md). +## 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) diff --git a/context/cli/mutation-scope-runtime.md b/context/cli/mutation-scope-runtime.md index 3011de49..5c907ea6 100644 --- a/context/cli/mutation-scope-runtime.md +++ b/context/cli/mutation-scope-runtime.md @@ -1,29 +1,21 @@ # Mutation-scope runtime: the harness-adapter contract -The crate-visible surface of `cli/src/services/mutation_trace/runtime/`, and the -lifecycle contract every harness adapter (Codex, Claude Code, OpenCode, Pi) must -uphold when it drives that surface. +The crate-visible surface of `cli/src/services/mutation_trace/runtime/` and the +lifecycle contract every Codex, Claude Code, OpenCode, and Pi adapter must uphold. 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 the -shipped Claude Code adapter -([`claude-mutation-scope-integration.md`](claude-mutation-scope-integration.md)) -and the Codex adapter (`sce hooks codex-mutation-scope`, now registered by -`sce setup --codex`; OpenCode/Pi: none yet — see Status) all 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) -(`coordinate()`), -[`mutation-trace-scope-abandonment.md`](mutation-trace-scope-abandonment.md) -(`abandon_scope()`), -[`mutation-trace-protected-worktree.md`](mutation-trace-protected-worktree.md) -(the shared safety prefix), and -[`mutation-trace-protocol.md`](mutation-trace-protocol.md) (the pure protocol). -This file is the layer above them: what an adapter is required to do, and why. +[`sce hooks mutation-scope` ingress](mutation-scope-hook-ingress.md), shipped +Claude Code adapter, and Codex adapter (`sce hooks codex-mutation-scope`, +registered by `sce setup --codex`; OpenCode/Pi: none yet) drive this seam. This +file records the adapter contract; the Codex-specific mapping is in +[`codex-mutation-scope-integration.md`](codex-mutation-scope-integration.md). + +The mechanics live in [`mutation-trace-runtime-coordinator.md`](mutation-trace-runtime-coordinator.md) +(`coordinate()`), [`mutation-trace-scope-abandonment.md`](mutation-trace-scope-abandonment.md) +(`abandon_scope()`), [`mutation-trace-protected-worktree.md`](mutation-trace-protected-worktree.md) +(safety prefix), and [`mutation-trace-protocol.md`](mutation-trace-protocol.md) +(pure protocol). This file records what adapters must do and why. ## The exported seam @@ -43,41 +35,29 @@ This file is the layer above them: what an adapter is required to do, and why. | `AbandonScopeError` | `scope_runtime` | its error surface | `ExternalTaintOperation` crosses the boundary because it is part of -`CoordinateError`'s own public shape — a crate-visible error a caller cannot -match on is not a usable seam. It lives in `protected_worktree.rs` and reaches -the seam through `coordinator.rs`'s `pub use super::protected_worktree:: -ExternalTaintOperation`, so the type becomes crate-visible **without** -`protected_worktree` becoming a public module. - -Every `mod` declaration in `runtime/mod.rs` stays private. Nothing else is -reachable from `git_snapshot`, `external_taint`, `worktree_lock`, -`ref_reconciliation`, or `protected_worktree` — in particular `ProtectedWorktree`, -`ProtectedWorktreeError`, and `WORKTREE_LOCK_TIMEOUT` remain internal to -`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` 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`) that -`clippy --all-targets -- -D warnings` would otherwise flag. +`CoordinateError`'s public shape. It lives in `protected_worktree.rs` and is +re-exported by `coordinator.rs` without making that module public. + +Every runtime `mod` stays private, including `ProtectedWorktree`, its error, +`WORKTREE_LOCK_TIMEOUT`, and `reconcile_worktree`. Adapters drive only the two +entrypoints and never assemble the safety prefix. + +The re-exports are the intentional crate-visible seam consumed by the generic +ingress; runtime internals stay private. The two re-export statements retain +`#[allow(unused_imports)]` because no consumer names the completing types yet. ## What a mutation scope is -**A scope is one independently mutation-capable execution.** Not one session, -not one process, not one harness. +**A scope is one independently mutation-capable execution**, not a session, +process, or harness. -The practical consequence: a main agent and a subagent that can each edit the -worktree concurrently are two scopes and must carry **distinct `ScopeId`s**. If -an adapter gives them one shared `ScopeId`, their intervals collapse into a -single exclusivity claim and the protocol can never report `AiContended` for two -executions that genuinely raced. +A main agent and subagent that can edit concurrently are two scopes with +**distinct `ScopeId`s**; sharing one would collapse their intervals and hide +`AiContended`. -A `ScopeId` is durably bound to one worktree for life. `abandon_scope()` rejects -a target whose durable `worktree_id` differs from the `WorktreeId` the invocation -derived from its own checkout (`AbandonScopeError::WorktreeIdentityMismatch`), -and writes nothing. +A `ScopeId` is durably bound to one worktree. `abandon_scope()` rejects a target +whose durable identity differs from the invoking checkout +(`WorktreeIdentityMismatch`) and writes nothing. ## `Start` / `Advance` / `Close` @@ -233,8 +213,10 @@ likewise means two or more scopes overlapped, not that two humans disagreed. Consumers building human-vs-AI authorship claims need evidence beyond this signal; the protocol deliberately does not supply it. The complementary states -are `AiContended` (more than one live scope) and `IneligibleUnscoped` (no live -scope, or the worktree is unhealthy, externally tainted, or needs rebaseline). +are `AiContended` (more than one live scope when no unconfirmed live Codex scope +remains at the boundary) and `IneligibleUnscoped` (no live scope, an +unconfirmed live Codex scope, or the worktree is unhealthy, externally tainted, +or needs rebaseline). ## Status @@ -260,8 +242,8 @@ A Codex adapter (`cli/src/services/hooks/codex_mutation_scope/`, hidden `sce hooks codex-mutation-scope`) also maps onto this contract through the same seam and is now registered by `sce setup --codex`; its full contract (the tracked/delegation/untracked tool classification, the partial-by-tool-surface -coverage boundary, and the checkout-local recovery bookkeeping) is authored in a -dedicated file by the `codex-mutation-scope-integration` plan's final task. +coverage boundary, and the checkout-local recovery bookkeeping) is in +[`codex-mutation-scope-integration.md`](codex-mutation-scope-integration.md). OpenCode and Pi have no adapter; each remaining harness 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/cli/mutation-trace-runtime-coordinator.md b/context/cli/mutation-trace-runtime-coordinator.md index 5d4a3cb2..4e8a747d 100644 --- a/context/cli/mutation-trace-runtime-coordinator.md +++ b/context/cli/mutation-trace-runtime-coordinator.md @@ -14,8 +14,8 @@ submodule is declared privately in `runtime/mod.rs`, which re-exports in [`mutation-scope-runtime.md`](mutation-scope-runtime.md). `coordinate()` and `abandon_scope()` are now driven by the generic `sce hooks mutation-scope` CLI ingress. `reconcile_worktree` stays `runtime`-internal and unwired, and the -mutation runtime does not itself insert into `diff_traces`. No concrete Claude -Code, Codex, OpenCode, or Pi lifecycle adapter is wired to the seam yet. +mutation runtime does not itself insert into `diff_traces`. Concrete Claude Code +and Codex adapters are wired to the seam; OpenCode and Pi remain future work. `runtime` depends on `protocol`/`store`/`types` and on `services::checkout`, never the reverse — this is a structural module boundary, not merely a @@ -165,23 +165,15 @@ On-disk layout so far: ## Testing boundary -`WorktreeLock`'s inline `#[cfg(test)] mod tests` in `worktree_lock.rs` covers -contention (a second acquirer blocks until the first releases), independence -across distinct worktree paths, timing out with a distinct matchable error -while the lock is still held, and a leftover lock file with no active OS lock -never blocking a fresh acquirer — each test uses a unique -`std::env::temp_dir()` path, following the same filesystem-touching -inline-unit-test precedent as `cli/src/services/checkout/mod.rs` (see -[`../patterns.md`](../patterns.md)). +`WorktreeLock` tests cover contention, independent worktree paths, bounded +timeout errors, and leftover lock files without active OS ownership, using the +filesystem-touching inline-test precedent from `checkout`. -`ProtectedWorktree`'s and `scope_runtime`'s inline tests use RAII -`tempfile::TempDir` fixtures over real `git init` repositories; coverage in -[`mutation-trace-protected-worktree.md`](mutation-trace-protected-worktree.md#testing-boundary) -and [`mutation-trace-scope-abandonment.md`](mutation-trace-scope-abandonment.md#testing-boundary). +`ProtectedWorktree` and `scope_runtime` tests use RAII `TempDir` fixtures over +real `git init` repositories; their detailed contracts remain in their domain +documents. -`GitSnapshotService`'s inline `#[cfg(test)] mod tests` in `git_snapshot.rs` uses -the same precedent, extended to real per-test `git init` repositories; coverage -in [`mutation-trace-snapshot-service.md`](mutation-trace-snapshot-service.md). +`GitSnapshotService` tests use the same real-repository precedent. `coordinator.rs`'s inline `#[cfg(test)] mod tests` exercises the internal pipeline against a real temp-file `RepositoryAgentTraceDb`, using a fake, @@ -191,7 +183,8 @@ observation establishes a baseline with no evidence; an edit observed between `Start` and `Advance` commits exactly one `AiExclusive` event; replaying an identical `(scope, event)` boundary is a no-op, not a duplicate; `Close` attributes to the scope it is about to close; two live scopes yield -`AiContended` regardless of matching or differing `ActorKind`; a CAS conflict +`AiContended` when no unconfirmed live Codex scope remains at the boundary, +regardless of matching or differing `ActorKind`; a CAS conflict reloads and recomputes without a second capture or pin; `needs_rebaseline` recovery preserves live scopes while taint recovery abandons them; and the taint-retry loop taints an existing worktree, survives a losing CAS before @@ -240,8 +233,8 @@ driven together; an inherited external-taint marker is overlaid onto `database_failure` recovery on the next invocation. The generic `sce hooks mutation-scope` CLI ingress now drives both entrypoints (`start`/`advance`/`close`/`flush` → `coordinate()`, `abandon` → `abandon_scope()`); -concrete harness lifecycle adapters (Claude Code, Codex, OpenCode, Pi) remain -future work. +the concrete Claude Code and Codex lifecycle adapters drive that ingress, while +OpenCode and Pi remain future work. See also: [`mutation-trace-ref-reconciliation.md`](mutation-trace-ref-reconciliation.md) (the per-worktree snapshot-ref maintenance pass under the same `WorktreeLock`), diff --git a/context/context-map.md b/context/context-map.md index 2520f2ed..d23fc448 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -102,6 +102,11 @@ Feature/domain context: - Setup behavior selection contract: [local bootstrap](sce/setup-repo-local-config-bootstrap.md) is canonical for the independent interactive confirmations, explicit nested config values, non-interactive existing-config safety, and unchanged runtime gates; see also [config precedence](cli/config-precedence-contract.md), [automatic sync](cli/agent-trace-auto-sync.md), [hook routing](sce/agent-trace-hooks-command-routing.md), and [commit attribution](sce/agent-trace-commit-msg-coauthor-policy.md). +Additional mutation-scope integration context: + +- `context/cli/codex-mutation-scope-integration.md` (the second concrete harness adapter: Codex tracked/delegation/untracked classification, partial-by-tool-surface coverage, identity and checkout-local recovery state, write-ahead fail-closed lifecycle, cleanup signals, boundary-aware attribution confirmation, and Codex setup/doctor ownership) +- `context/sce/codex-apply-patch-diff-runtime.md` (the complementary Codex `PostToolUse(apply_patch)` parsing, path containment, normalization, and `diff_traces` evidence contract) + Working areas: - `context/plans/` (active plan execution artifacts, not durable history) diff --git a/context/glossary.md b/context/glossary.md index 4ee828b3..4cb55f6e 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -6,7 +6,7 @@ - important change (context sync): A completed task change that affects cross-cutting behavior, repository-wide policy/contracts, architecture boundaries, or canonical terminology; these changes require root context edits in `context/overview.md`, `context/architecture.md`, and/or `context/glossary.md` instead of verify-only handling. `setup config handling` is the Git-root-gated setup path that keeps an invalid default-discovered repo-local `.sce/config.json` untouched while allowing normal preflight, bootstrap, lifecycle, hooks, and target installation to continue; absent config remains eligible for create-if-missing bootstrap, Agent Trace storage consumes the same degraded result for invalid discovered layers, explicit `--config` and `SCE_CONFIG_FILE` failures remain fatal, and ordinary startup consumers retain degraded-default behavior. See [the degraded discovered-config boundary decision](decisions/2026-09-04-setup-storage-degrade-invalid-discovered-config.md). - verify-only root context pass: Context-sync mode for localized tasks where root-level behavior, architecture, and terminology are unchanged; root shared files are checked against code truth but are not edited by default. - ephemeral generated payload: Files materialized by `config/pkl/generate.pkl` using payload-relative `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, `config/.agents/**`, `config/.codex/**`, and `config/schema/sce-config.schema.json` paths beneath Cargo `OUT_DIR`, temporary previews, or packaging fallbacks. These layouts are installed by `sce setup` but are never committed as repository target trees; `config/automated/.opencode/**` remains a forbidden generator surface. -- `Codex root-aware hook invocation`: Generated `.codex/hooks.json` command contract that resolves the Git repository root at hook runtime, invokes the installed helper through quoted path expansion from root or nested event cwd, preserves JSON STDIN, and exits silently successfully when Git-root resolution fails. The existing helper remains responsible for missing-`sce` stderr guidance; the contract forbids install-time absolute paths and `eval`. +- `Codex root-aware hook invocation and mutation-scope coverage`: Generated `.codex/hooks.json` command contract that resolves the Git repository root at hook runtime, invokes the installed helper through quoted path expansion from root or nested event cwd, preserves JSON STDIN, and exits silently successfully when Git-root resolution fails. The existing helper remains responsible for missing-`sce` stderr guidance; the contract forbids install-time absolute paths and `eval`. The same Codex integration classifies `Bash` and `apply_patch` as `TrackedMutation`, delegation tools as non-scoped delegation, and MCP or unknown tools as usable `Untracked` executions outside individual mutation attribution; `AiExclusive` therefore means tracked-scope exclusivity rather than sole filesystem authorship. See [Codex mutation-scope integration](cli/codex-mutation-scope-integration.md). - `CLI generated-input handoff`: Repository-build contract rooted at the temporary directory named by `SCE_CLI_GENERATED_INPUT_DIR`. `config/pkl/generator-inputs.txt` declares the canonical `config/pkl` and referenced `config/lib` inputs; `scripts/produce-cli-generated-input.sh` discovers those files, generates Pkl twice, rejects nondeterminism and in-flight input mutation, and atomically places `pkl-generated/`, its exact `SHA256SUMS`, and `INPUTS.SHA256SUMS` there. `scripts/run-cli-cargo.sh` delegates production and removes its temporary handoff after Cargo exits. `cli/build.rs` verifies payload integrity and input freshness before copying `pkl-generated/` into Cargo `OUT_DIR`; missing, incomplete, modified, or stale handoffs fail rather than invoking Pkl or falling back to packaged assets. - `generated-input producer`: Repository-owned `scripts/produce-cli-generated-input.sh` contract driven by `config/pkl/generator-inputs.txt`. It is the canonical owner for expanding repository-relative generator inputs, snapshotting their inventory, two-pass Pkl evaluation, byte-tree determinism comparison, payload and canonical-input SHA-256 inventories, input-mutation rejection, atomic output publication, and private staging cleanup. The repository Cargo wrapper, generated-output check, package-fallback preparation, and Nix `cliGeneratedInput` derivation all consume it. - `Pi workflow package`: Generated Pi workflow surface consisting of one thin prompt in `config/.pi/prompts/` plus the one workflow skill package under `config/.pi/skills/` that the prompt routes to. Phase-based workflows include `SKILL.md`, `references/output.md`, and named phase, persisted-document, or supporting references; phase-free `/brownfield` has the two core files, while `/handover` also has `references/handover-template.md`. Pi currently receives `/change-to-plan`, `/next-task`, `/validate`, `/commit`, `/handover`, and `/brownfield` this way and has no generated agent-role prompts. diff --git a/context/overview.md b/context/overview.md index 0805473a..0662da75 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,5 +1,17 @@ # Overview +## Mutation-scope harness coverage + +Claude Code and Codex are the two wired mutation-scope producers. The Codex +adapter tracks `Bash` and `apply_patch` executions through the hidden +`sce hooks codex-mutation-scope` command; its `PreToolUse`/`PostToolUse` groups +match `^(Bash|apply_patch)$`, while cleanup events are unmatched. Delegation tools +do not create scopes, and MCP/unknown tools remain usable but outside individual +mutation attribution. +OpenCode and Pi remain unwired. See +[`context/cli/codex-mutation-scope-integration.md`](cli/codex-mutation-scope-integration.md) +for the tested Codex 0.153.4 lifecycle, recovery, and attribution boundary. + 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`); the four conversation/diff registrations (`UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash`, `PostToolUse` for `apply_patch`) route through `sce hooks codex`, and six additive mutation-scope registrations (`PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, `SubagentStop`, `SessionEnd`, each an unmatched catch-all group appended after the existing groups) route through the separate `sce hooks codex-mutation-scope` command; the generated commands resolve the Git root at invocation time and safely invoke 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, reporting the mutation-scope registrations under distinct `#(mutation-scope)` rows separate from the `sce hooks codex` rows, and separately reports whether Codex has actually marked each structurally current registration trusted and whether its effective hook-discovery policy allows project hooks, by reading (never writing) Codex's own `$CODEX_HOME/config.toml` and policy state; `sce doctor --fix` repairs structurally unhealthy registrations through the same merge service but never touches trust or policy 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, 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`. diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index 1b5f73df..be27cf02 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -3571,7 +3571,7 @@ Persist this field in every plan; this is durable plan state, not chat state: executable evidence those documents will describe. - Context synchronization: synced -- [ ] T07: `Author the durable Codex mutation-scope context` (status:todo) +- [x] T07: `Author the durable Codex mutation-scope context` (status:done) - Task ID: T07 - Scope: In — create `context/cli/codex-mutation-scope-integration.md` owning the Codex adapter domain (the tool-execution scope model, the D2 **three-class** @@ -3634,7 +3634,52 @@ Persist this field in every plan; this is durable plan state, not chat state: further protocol/Quint/runtime-semantic change); durable context records the accepted boundary-aware unconfirmed-Codex attribution refinement (D14); `nix flake check`. - - Context synchronization: pending + - Completed: 2026-09-08 + - Files changed: + - `context/cli/codex-mutation-scope-integration.md` (new durable Codex + adapter domain contract) + - `context/sce/codex-apply-patch-diff-runtime.md` (new split-out existing + Codex apply-patch evidence contract) + - `context/cli/mutation-scope-runtime.md` + - `context/cli/mutation-scope-hook-ingress.md` + - `context/cli/mutation-trace-runtime-coordinator.md` + - `context/sce/agent-trace-hooks-command-routing.md` + - `context/sce/codex-integration-runtime.md` + - `context/context-map.md` + - `context/overview.md` + - `context/architecture.md` + - Result: Authored the durable Codex mutation-scope domain contract for + codex-cli 0.153.4, including the tracked/delegation/untracked classification, + D23 MCP coverage boundary, write-ahead and fail-closed lifecycle, checkout- + local recovery, cleanup matrix, D14 boundary-aware attribution rule, + command/configuration ownership, doctor health dimensions, unsupported + boundaries, and T06 evidence. Updated all requested cross-references to + name Codex as the second wired adapter while preserving the existing + conversation/diff dispatcher. Split the existing Codex apply-patch detail + into its own context file and kept every affected context file within the + 250-line budget. No new ADR qualified. + - Verify: + - New domain and split-out evidence documents plus every requested + cross-reference inspected against AC23/AC24 — **passed**. + - `git diff --check` and affected context line-budget checks — **passed**; + all affected files are at or below 250 lines. + - `git diff origin/claude-mutation-scope-integration -- + cli/migrations/agent-trace-repository/ config/schema/agent-trace.schema.json` + — **passed** (empty). + - `git diff b72f6c2c -- spec/mutation_cursor.qnt spec/mutation_cursor.md + cli/src/services/mutation_trace/protocol.rs + cli/src/services/mutation_trace/runtime/` — **passed** (empty). + - `nix run .#pkl-check-generated` — **passed** (141 files; inventory sha256 + `3d1e3aa23681e5c544eac660f9489f6ba4f48286efd9dc8de5b59fe8ee902d07`). + - `nix flake check` — **passed** (all checks passed; incompatible systems + omitted as usual). + - Context impact: cross-cutting durable context — added the Codex adapter + domain and complementary apply-patch evidence contract; updated mutation + runtime/ingress, hook routing, architecture, overview, and context-map + references because a second concrete harness adapter is now wired and + registered. No production behavior, protocol, Quint, SQL, or Agent Trace + schema changed in T07. + - Context synchronization: synced ## Open questions diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 915760df..846dc534 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -17,6 +17,7 @@ - `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; registered by `sce setup` — see below) +- `sce hooks codex-mutation-scope` (hidden; reads one raw Codex hook JSON object from STDIN; registered by `sce setup --codex` — see below) ## Setup-controlled gates @@ -126,8 +127,9 @@ Interactive `sce setup` persists the independent confirmations at `agent_trace.a - `sce hooks claude-model-state` is a silent, local-only lifecycle intake for raw Claude `SessionStart` and `PostModelSwitch` events. A model-bearing `SessionStart` writes normalized `claude/` 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 the generated helper path plus either the `sce hooks codex` or the `sce hooks codex-mutation-scope` command contract, attributing each handler to whichever it matches and merging the two contracts' registrations independently. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full `sce hooks codex` dispatcher and per-arm contract; the six `sce hooks codex-mutation-scope` registrations feed the generic mutation-scope ingress ([../cli/mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md)). -- `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 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); the Codex adapter also consumes it in-process, while OpenCode and 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 are in [../cli/claude-mutation-scope-integration.md](../cli/claude-mutation-scope-integration.md). +- `sce hooks codex-mutation-scope` (hidden) is the second concrete harness adapter: it reads raw Codex lifecycle events and drives the same in-process seam with one tracked scope per `Bash`/`apply_patch` execution, write-ahead fail-closed admission, proven terminal close, identity-scoped cleanup, and checkout-local recovery bookkeeping. MCP and unknown tools remain usable but are untracked and outside individual mutation attribution; their lifecycle is not treated as read-only. It is registered by `sce setup --codex` alongside the existing `sce hooks codex` conversation/diff contract. Full mapping and the tested Codex 0.153.4 boundary are in [../cli/codex-mutation-scope-integration.md](../cli/codex-mutation-scope-integration.md). ## Explicit non-goals in the current baseline @@ -148,3 +150,4 @@ Interactive `sce setup` persists the independent confirmations at `agent_trace.a - [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) +- [Codex mutation-scope integration: the second concrete harness adapter](../cli/codex-mutation-scope-integration.md) diff --git a/context/sce/codex-apply-patch-diff-runtime.md b/context/sce/codex-apply-patch-diff-runtime.md new file mode 100644 index 00000000..93384446 --- /dev/null +++ b/context/sce/codex-apply-patch-diff-runtime.md @@ -0,0 +1,56 @@ +# Codex apply-patch diff evidence runtime + +This document owns the existing `sce hooks codex` `PostToolUse(apply_patch)` +evidence path. It is complementary to the Codex mutation-scope adapter and does +not write mutation-scope state. + +`cli/src/services/hooks/codex/apply_patch/` parses Codex's +`*** Begin Patch` ... `*** End Patch` format, including `Add File`, `Delete +File`, `Update File`, and optional `Move to` operations. Its stages are: + +- `parser.rs` builds a typed `CodexPatch` from the raw + `tool_input.command` text; +- `path.rs` resolves source and destination paths from the event `cwd` against + the real Git root; and +- `normalize.rs` converts supported touched-line evidence into the SCE + `Index:`-form unified diff accepted by `crate::services::patch::parse_patch`. + +The outer intake unwraps only the upstream-compatible `<` session prefix, and a reported non-blank model +ID preserved without a fabricated provider prefix. Timestamp failure skips the +insert. Every successful or fail-open path returns empty stdout. + +After commit, the existing `combine_patches` and `intersect_patches` behavior +uses the event-scoped identities and historical `kind`+`content` fallback. It +does not prove which physical occurrence of repeated identical content came +from a particular event. Delete File, pure rename, and Bash-created filesystem +mutations have no line-level evidence in this complementary pipeline. + +This path writes only `diff_traces`; the Codex mutation-scope adapter writes +only `mutation_trace_*` rows. No Agent Trace schema migration, snapshot, or +Codex-specific Agent Trace builder is part of either path. + diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index 3d0a7b88..2952e9e9 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -175,95 +175,13 @@ Codex (see "Explicit non-goals" in ## `PostToolUse(apply_patch)` diff capture -`cli/src/services/hooks/codex/apply_patch/` implements the -`PostToolUse(apply_patch)` arm: `parser.rs` parses Codex's own `apply_patch` -text format (`*** Begin Patch` ... `*** End Patch`, with `Add File`/`Delete -File`/`Update File` operations and an optional `Update File` + `Move to`) -into a typed `CodexPatch`; `path.rs` resolves its paths from the event cwd to -safe repository-relative paths; `normalize.rs` normalizes it into SCE -`Index:`-form unified-diff text `crate::services::patch::parse_patch` already -accepts; `mod.rs`'s `handle` wires the stages together and persists the result: - -- Reads the raw patch text from `tool_input.command` (a working assumption - mirroring `PreToolUse(Bash)`'s own `tool_input.command` shape); a missing or - non-string `command` fails open with no evidence. -- Before canonical parsing, outer intake preserves raw patch input and unwraps - exactly the upstream-compatible `<` after required - trimmed non-empty validation, `model_id = normalize_codex_model_id(event.model)` - when a model is reported, `tool_name = "codex"`, `tool_version = None`, - `payload_type = "patch"` — no new persistence adapter. The event-scoped - synthetic identity scheme is an accepted durable decision; see [the ADR](../decisions/2026-08-23-codex-event-scoped-apply-patch-evidence-identities.md). -- The timestamp comes from `current_unix_time_ms()`; a timestamp-acquisition - failure here skips the insert entirely (fails open) rather than - substituting a fabricated epoch-zero value, matching `UserPromptSubmit` - and `Stop`'s own fail-open timestamp behavior above. -- Every path — success, empty-normalize no-op, and every fail-open branch — - returns exactly empty stdout; Bash denial is the only structured Codex - response. - -Once committed, Codex evidence still attributes correctly through the -existing, unmodified `intersect_patches` historical `kind`+`content` fallback -(`cli/src/services/patch.rs`) even when the real committed lines land at -different real line numbers. Multiple same-content events retain separate -synthetic identities through the existing `combine_patches` behavior and can -match corresponding committed additions. This module does not touch the -fallback or combination semantics, and no `diff_traces`/Agent Trace schema -migration was added to support it. - -## Conservative attribution boundary - -This pipeline proves supplied touched content, not the physical occurrence of -that content in the repository. Codex provides no true source line ranges, and -SCE intentionally takes no filesystem snapshot or maintains pending tool state. -When repeated identical lines occur, `combine_patches` preserves separate -event-scoped evidence identities, but the existing content-based intersection -can only match available occurrences deterministically; it cannot prove which -identical physical occurrence came from which event. The focused regression test -covers this ambiguity and deliberately does not claim that issue 8 is solved. - -The complete supported path is therefore `PostToolUse apply_patch` → -`tool_input.command` outer normalization and parsing → event-cwd/real-Git-root -path resolution → SCE `payload_type = "patch"` `diff_traces` persistence → -existing recent-row parsing, `combine_patches`, and post-commit intersection → -Agent Trace. Delete File, pure rename, and Bash-created filesystem mutations -remain without line-level evidence. There is no snapshot, pending-state, -Codex-specific Agent Trace builder, schema migration, or generic intersection -redesign in this path; malformed or unsafe inputs fail open silently. +The existing Codex `PostToolUse(apply_patch)` evidence path remains separate +from mutation-scope attribution. Its parser, cwd-aware path containment, +event-scoped synthetic line identities, and `diff_traces` persistence contract +are documented in +[`codex-apply-patch-diff-runtime.md`](codex-apply-patch-diff-runtime.md). +Malformed or unsafe input remains fail-open and Bash denial remains the only +structured response from this dispatcher. ## No remaining stub arms From f09627bd43ce7811d690d6fdf42ec00b8e1daec2 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 19:23:57 +0200 Subject: [PATCH 19/20] context: reconcile Codex mutation-scope durable context Co-authored-by: SCE --- context/architecture.md | 4 +- .../cli/codex-mutation-scope-integration.md | 4 +- context/context-map.md | 2 +- context/glossary.md | 2 +- context/overview.md | 6 +- .../plans/codex-mutation-scope-integration.md | 153 ++++++++++++++---- .../sce/agent-trace-hooks-command-routing.md | 2 +- context/sce/codex-integration-runtime.md | 52 +++--- 8 files changed, 158 insertions(+), 67 deletions(-) diff --git a/context/architecture.md b/context/architecture.md index 5e232318..382248ea 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -59,7 +59,7 @@ Renderer modules apply target-specific metadata/frontmatter rules while reusing - All four renderers consume the six canonical workflow packages as behavior sources. OpenCode, Claude, and Pi emit the same six command-routed workflow packages: `sce-change-to-plan`, `sce-next-task`, `sce-validate`, `sce-commit`, `sce-handover`, and `sce-brownfield`; Codex emits the same six as skill packages only, with no command or prompt layer. Each renderer also emits the standalone internal `sce-decision` package with `SKILL.md` plus `references/adr-template.md`; it stays outside workflow composition and has no command or prompt. For the phase-based workflows, `workflow-composite.pkl` renders one `SKILL.md` that owns input parsing, phase order, branching, waits, and same-session resume, plus package-local references for each applicable phase and persisted-document format. The applicable reference must be read before its phase runs. `references/output.md` remains the sole owner of human-visible gates and terminal layouts. Phase statuses remain internal, commands and prompts still invoke exactly one workflow skill, and SCE sibling handoffs remain limited to the successful task-synchronization gate's bounded `sce-decision` invocation; `/validate` reports validation directly without a plan-synchronization handoff. Relevant non-SCE skills may help within the active step and must return control without weakening its invariants. `sce-handover` and `sce-brownfield` are phase-free; handover has a package-local persisted-format template in addition to `SKILL.md` and `references/output.md`, while brownfield retains its two-file shape. OpenCode, Claude, Pi, and Codex render identical package-relative inventories and document bodies for each workflow, apart from supported target frontmatter. - Per-target differences are confined to frontmatter and the surrounding non-workflow outputs. The manual OpenCode renderer adds `agent`, `entry-skill`, and a one-entry `skills` list to command frontmatter, adds `compatibility: opencode` to package entrypoints, and emits two thin routing agents. Each OpenCode agent allows ordinary non-SCE skills by default, denies the `sce-*` wildcard, and then allows only its catalog-derived owned workflow skills; only the Code agent additionally allows `sce-decision` for the synchronization exception. The Claude renderer adds `compatibility: claude` plus command `allowed-tools:` and emits no agents; Claude settings and the hook helper remain separate retained outputs, with `SessionStart` and `PostModelSwitch` lifecycle registrations routed to the local model-state hook. The Pi renderer adds no frontmatter to either prompts or skills. The Codex renderer likewise adds no frontmatter and, unlike Pi, emits no `commands` mapping at all — `config/.agents` has no command/prompt directory. Codex also differs from all three other targets in the two arguments it passes `skillDocuments` beyond frontmatter: the arguments-reference token (OpenCode, Claude, and Pi pass the literal `$ARGUMENTS` their harnesses substitute, while Codex passes the plain-prose token `invocation input`, since its skill loading provides no such substitution) and a per-workflow-slug invocation-example function (empty for OpenCode, Claude, and Pi; for Codex, one authored, runnable `$sce-{slug} ...` example per catalog workflow). So Codex's `## Input` prose and its `sce-handover`/`sce-brownfield` `references/output.md` diverge from Pi's by the arguments-reference token, and every Codex skill's `## Input` section additionally carries a trailing "For example: `$sce-{slug} ...`." paragraph that Pi/Claude/OpenCode do not render. - Pi renderer consumes the same shared workflow composition as OpenCode and Claude. It emits exactly six thin prompts to `config/.pi/prompts/{slug}.md`, each routing to exactly one workflow skill, four phase-based workflow packages with package-local phase and supporting references plus the phase-free handover and brownfield packages under `config/.pi/skills/{slug}/` (handover also has its persisted-format template), and the standalone `sce-decision` package beside them. Pi prompts and skills carry no target-specific frontmatter beyond the shared description and argument hint, so Pi passes the empty extra-frontmatter string to both package render paths. It emits no Pi agent-role prompts. Pi has no settings/plugin manifest; runtime integration remains the project-local extension emitted verbatim from `config/lib/pi-plugin/sce-pi-extension.ts` to `config/.pi/extensions/sce/index.ts` (auto-discovered by Pi, no registration manifest; see `context/sce/pi-extension-runtime.md`). -- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi; its `skillDocuments` output matches Pi's byte-for-byte for every shared document except where the arguments-reference token appears (`invocation input` in place of Pi's substituted `$ARGUMENTS`, in every skill's `## Input` prose and in `sce-handover`/`sce-brownfield`'s `references/output.md`) and where every skill's `## Input` section carries Codex's own trailing concrete `$sce-{slug}` invocation-example paragraph, which Pi's `skillDocuments` call passes as empty and so never renders. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. Codex alone additionally carries `{skillSlug}/agents/openai.yaml` for each of the six catalog workflow skills (not `sce-decision`), rendered by `codex-metadata.pkl` from the same catalog `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 `/skills` discovery, never from conversational relevance alone; no other target has an implicit-invocation policy concept. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json` (registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash`, and `PostToolUse` for `apply_patch` routed through `sce hooks codex`, plus six additive unmatched mutation-scope registrations — `PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, `SubagentStop`, `SessionEnd` — appended after the existing groups and routed through the separate `sce hooks codex-mutation-scope` command) and its fail-open install-guidance hook script at `.codex/hooks/run-sce-or-show-install-guidance.sh`, mirroring Claude's `settings.json`/hook-helper pattern; the generated command resolves the Git root at invocation time and invokes that helper with quoted paths, so it works from nested event directories and spaced repository paths while exiting successfully when Git-root resolution fails. No Codex analog to `$CLAUDE_PROJECT_DIR` is required. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row atomically through the shared `insert_conversation_text_event` transactional primitive (a replayed or concurrent duplicate delivery leaves exactly one row pair); `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves source and move-destination 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 via the existing `insert_diff_trace` when non-empty — invalid cwd/path resolution, invalid/missing sessions, Delete-File operations, and a `Move to` with no changed lines produce no evidence; reported model IDs remain unqualified unless Codex supplied a qualifier (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open silently. Bash-triggered filesystem mutations remain untracked for Codex. +- Codex renderer consumes the same shared workflow composition and the same empty extra-frontmatter string as Pi; its `skillDocuments` output matches Pi's byte-for-byte for every shared document except where the arguments-reference token appears (`invocation input` in place of Pi's substituted `$ARGUMENTS`, in every skill's `## Input` prose and in `sce-handover`/`sce-brownfield`'s `references/output.md`) and where every skill's `## Input` section carries Codex's own trailing concrete `$sce-{slug}` invocation-example paragraph, which Pi's `skillDocuments` call passes as empty and so never renders. It emits the same four phase-based packages, the phase-free handover and brownfield packages, and the standalone `sce-decision` package under `config/.agents/skills/{slug}/`. Codex alone additionally carries `{skillSlug}/agents/openai.yaml` for each of the six catalog workflow skills (not `sce-decision`), rendered by `codex-metadata.pkl` from the same catalog `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 `/skills` discovery, never from conversational relevance alone; no other target has an implicit-invocation policy concept. It has no `commands` map, no agents, and no settings/plugin manifest. It separately emits `.codex/hooks.json`: the four conversation/diff registrations route through `sce hooks codex`, while the mutation-scope registrations route through `sce hooks codex-mutation-scope` with `PreToolUse` and `PostToolUse` matcher `^(Bash|apply_patch)$` and unmatched `Stop`, `Interrupt`, `SubagentStop`, and `SessionEnd` groups. The conversation/diff command resolves the Git root at invocation time and retains fail-open behavior where designed; tracked mutation `PreToolUse` fails closed if root/helper/`sce`/adapter/`Start`/policy bootstrap cannot establish attribution. No Codex analog to `$CLAUDE_PROJECT_DIR` is required. The `sce hooks codex` Rust dispatcher now exists (`cli/src/services/hooks/codex/`): a typed `CodexHookEvent` parser plus a `classify_codex_event` match over `(hook_event_name, tool_name)` routing `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, and `PostToolUse(apply_patch)` to distinct dispatch arms, all four now real behavior. `UserPromptSubmit` and `Stop` each persist one `messages`/`parts` row atomically through the shared `insert_conversation_text_event` transactional primitive (a replayed or concurrent duplicate delivery leaves exactly one row pair); `PreToolUse(Bash)` delegates to the existing Bash policy engine (`evaluate_bash_command_policy`), returning Codex's own native `PreToolUse` deny response or silent allow; and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves source and move-destination 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 via the existing `insert_diff_trace` when non-empty — invalid cwd/path resolution, invalid/missing sessions, Delete-File operations, and a `Move to` with no changed lines produce no evidence; reported model IDs remain unqualified unless Codex supplied a qualifier (see [codex-integration-runtime.md](sce/codex-integration-runtime.md)) — with every other combination, and any malformed STDIN, failing open silently. Bash filesystem effects do not become `diff_traces` in this existing evidence path, but the concrete Codex mutation-scope adapter tracks Bash executions as `TrackedMutation` scopes; `AiExclusive` means tracked-scope exclusivity, not exhaustive authorship. - Workflow composition itself is shared rather than per target. `config/pkl/renderers/workflow-composite.pkl` owns the six composite workflow definitions and assembles their references, while each composite looks up its typed identity in `config/pkl/base/workflow-catalog.pkl` and migrated workflow modules supply canonical phase, persisted-document, and output documents. Every workflow supplies a required `StructuredCompositeSource`, so commands, phase documents, persisted-document formats, and output references render in package or composite mode before Markdown assembly. The renderer has no nullable legacy adapter, frontmatter stripping, or prose-wide replacement chain. Its `renderSkill`, `renderCommand`, and `skillDocuments` entrypoints take a newline-terminated `extraFrontmatterLines` string carrying only the frontmatter a target supports; a target that adds none passes the empty string. `renderSkill` and `skillDocuments` additionally take an `argumentsReference` string naming the invocation input in skill-mode prose — `$ARGUMENTS` for OpenCode, Claude, and Pi, whose harnesses substitute it, or a plain-prose token for a target whose skill loading does not (Codex passes `invocation input`) — and an `invocationExample` function from skill slug to a concrete `$sce-{slug}` example string, appended by `model.invocationExampleParagraph` immediately before each workflow's `## Workflow` heading when non-empty; OpenCode, Claude, and Pi pass `(_) -> ""` so their `## Input` sections are unaffected, while Codex supplies one authored example per catalog workflow. `renderCommand`'s thin wrapper text is unaffected by either parameter and always states the literal `$ARGUMENTS` its harness substitutes. `renderSkill` assembles the document as an ordered section list — preamble (purpose, user-visible output, and the composite control-flow rules, all stated before the workflow's `## Input`), then the workflow body, then the phase appendix and any persisted-document formats, each emitted only when its listing is non-empty. Claude passes `compatibility: claude` for skills and a catalog-derived `allowed-tools` line for commands. The `renderSkill` preamble also carries the no-improvisation rule that every generated workflow `SKILL.md` states on every target: the executing agent follows the canonical workflow's steps, gates, and stops exactly as written and never invents, skips, reorders, or merges a step, and its user-visible output is limited to the `references/output.md` layouts with no invented layout and no added preamble, commentary, summary, or extra section. Its generic control-flow wording says that any workflow-defined user wait resumes the same skill in the same session; workflow-specific wait semantics remain in the workflow that owns them. The rule is prose instruction only; the generation contract checks assert paths and metadata, not agent behavior. - Shared renderer document types and OpenCode plugin-registration helpers live in `config/pkl/renderers/common.pkl`. - The canonical OpenCode plugin-registration source for generated SCE plugins lives in `config/pkl/base/opencode.pkl`; `config/pkl/renderers/common.pkl` re-exports the shared plugin list and JSON-ready paths for OpenCode renderers, and the current generated registration scope is limited to SCE-managed plugins emitted by this repo (`sce-bash-policy` and `sce-agent-trace`). @@ -137,7 +137,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies retry-backed blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. - `cli/src/services/agent_trace_db/mod.rs` owns the shared Agent Trace insert payloads, SQL constants, and typed row helpers (diff-trace/intersection/Agent Trace/message/part) plus `ensure_schema_ready_for_hooks()` consumed by the repository adapter. `cli/src/services/agent_trace_db/repository.rs` defines the sole `RepositoryAgentTraceDb` adapter over `TursoDb` with one fresh `agent-trace-repository/001_repository_schema.sql` baseline for `diff_traces` (including `payload_type`), `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes, and triggers, `repository_metadata` validation, no trace-table `checkout_id` columns, `agent_traces.agent_trace_id NOT NULL UNIQUE`, and `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` using the inclusive chronological parser without checkout filtering; structured-row reconstruction applies the persisted row `model_id` to every hunk and the persisted canonical `session_id` to every touched line before downstream combination and intersection. Active hook runtime, setup/lifecycle storage, and `sce sync` resolve through `agent_trace_storage` and use `RepositoryAgentTraceDb`. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, the 15-file `cli/migrations/agent-trace/` chain, and the former `sce trace --legacy` surface were removed by the `retire-legacy-agent-trace-db` plan. -- `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination and swaps it into place by renaming the staging file directly over the destination — never unlinking the destination first, since `fs::rename` already replaces an existing file atomically — with deterministic recovery guidance naming the failing asset's path on swap failure (the pre-existing destination content, if any, is untouched) and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run; Codex has no single target directory this way, since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix and `InstallTargetPaths::codex_target_dir()` resolves to the repository root itself, but the same per-asset stage/atomic-swap and never-delete-what-it-did-not-author guarantees still apply file by file. For the three assets that are merge targets — the Claude target's `settings.json`, the OpenCode target's `opencode.json`, and Codex's user-owned `.codex/hooks.json` — the content staged is not always the embedded asset's bytes. Claude and OpenCode use `cli/src/services/setup/config_merge.rs`, while Codex uses the shared `cli/src/services/codex_hook_config.rs` service for structural validation and canonical-registration merging. For the Claude and OpenCode targets, the content staged is the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. Codex's merge validates the document shape and recognizes ownership by the generated helper path plus one of two trailing command contracts — `sce hooks codex` or `sce hooks codex-mutation-scope` — attributing each handler to whichever it matches; it replaces stale or duplicate handlers with exactly one current handler per registration (the four `sce hooks codex` registrations plus six additive `sce hooks codex-mutation-scope` registrations, each appended in its own unmatched group after the existing groups so an already-trusted handler keeps its `(event, matcher, group index, handler index)` identity and computed Codex trust key), touching only the matching command's handlers and preserving unrelated valid Codex fields, groups, and handlers. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`, backed by the rename-injectable `install_required_git_hooks_with_rename`) is a third content-computation seam alongside the two JSON merge targets: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook(existing_bytes, canonical_bytes, hook_name)` rather than writing `hook_asset.bytes` verbatim — a foreign hook (no SCE managed block, no legacy guidance-URL marker) is kept as an exact byte prefix with the canonical block appended after it, an SCE-owned hook has only its block spliced in place or left unchanged, and a legacy pre-marker hook is replaced wholesale — then follows the same per-file stage/atomic-swap choreography as config-asset install: the staging file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact, with deterministic recovery guidance on swap failure. `Installed`/`Updated`/`Skipped` are decided against the merged bytes plus the executable bit rather than the canonical asset's raw bytes, so an already-current foreign-plus-block hook reports `Skipped`; `RequiredHookInstallResult.unreachable_block_advisory` is set, and rendered as a named advisory line in setup's hook output, when an appended block follows a foreign hook's zero-indent `exec`/`exit` and so would never run. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. +- `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator (`install_assets_for_concrete_target_with_rename`) installs each embedded asset individually: it stages the asset's content next to its final destination and swaps it into place by renaming the staging file directly over the destination — never unlinking the destination first, since `fs::rename` already replaces an existing file atomically — with deterministic recovery guidance naming the failing asset's path on swap failure (the pre-existing destination content, if any, is untouched) and no backup artifact creation; it never removes an integration target directory as a whole, so files a repository owns inside `.opencode`/`.claude`/`.pi` — including nested inside an SCE-owned subdirectory such as `skills/` or `commands/` — survive a setup run; Codex has no single target directory this way, since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix and `InstallTargetPaths::codex_target_dir()` resolves to the repository root itself, but the same per-asset stage/atomic-swap and never-delete-what-it-did-not-author guarantees still apply file by file. For the three assets that are merge targets — the Claude target's `settings.json`, the OpenCode target's `opencode.json`, and Codex's user-owned `.codex/hooks.json` — the content staged is not always the embedded asset's bytes. Claude and OpenCode use `cli/src/services/setup/config_merge.rs`, while Codex uses the shared `cli/src/services/codex_hook_config.rs` service for structural validation and canonical-registration merging. For the Claude and OpenCode targets, the content staged is the result of `cli/src/services/setup/config_merge.rs::merge_or_create_claude_settings`/`merge_or_create_opencode_config(existing_bytes, generated_bytes, source_path)`: each returns the generated document verbatim when no file exists yet, otherwise parses the existing file as JSON (a parse failure is a hard error naming `source_path`, and nothing is written) and merges it with a pure per-shape function that copies `$schema` from the generated document and preserves every other key from the existing file untouched. `merge_claude_settings` replaces, per hook event key the generated document declares, only the entries whose command contains the ownership marker `run-sce-or-show-install-guidance.sh`, preserving every event key and non-SCE hook entry from the existing file. `merge_opencode_config` merges the `plugin` array as a set: existing entries whose path starts with the ownership marker `./plugins/sce-` are dropped structurally — so a stale plugin path an older or renamed catalog once installed is still recognized and pruned even after the current generated document stops declaring it — and the generated document's `plugin` entries are appended after the surviving entries. Codex's merge validates the document shape and recognizes ownership by the generated helper path plus one of two trailing command contracts — `sce hooks codex` or `sce hooks codex-mutation-scope` — attributing each handler to whichever it matches; it replaces stale or duplicate handlers with exactly one current handler per registration (the four `sce hooks codex` registrations plus six additive `sce hooks codex-mutation-scope` registrations, within that mutation-scope set, `PreToolUse` and `PostToolUse` use matcher `^(Bash|apply_patch)$`, while `Stop`, `Interrupt`, `SubagentStop`, and `SessionEnd` are appended in unmatched groups after the existing groups so an already-trusted handler keeps its `(event, matcher, group index, handler index)` identity and computed Codex trust key), touching only the matching command's handlers and preserving unrelated valid Codex fields, groups, and handlers. After that per-asset install loop, `prune_stale_assets_for_concrete_target` deletes every path the full embedded catalog for the concrete target claims but the resolved selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), and `remove_empty_ancestor_directories` removes any parent directory left empty by that deletion, stopping at the target root or at a directory that still holds something such as a user file. It formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`, backed by the rename-injectable `install_required_git_hooks_with_rename`) is a third content-computation seam alongside the two JSON merge targets: `install_single_required_hook_with_rename` computes the bytes to stage with `cli/src/services/setup/hook_merge.rs::merge_or_create_hook(existing_bytes, canonical_bytes, hook_name)` rather than writing `hook_asset.bytes` verbatim — a foreign hook (no SCE managed block, no legacy guidance-URL marker) is kept as an exact byte prefix with the canonical block appended after it, an SCE-owned hook has only its block spliced in place or left unchanged, and a legacy pre-marker hook is replaced wholesale — then follows the same per-file stage/atomic-swap choreography as config-asset install: the staging file is renamed directly over the existing hook without unlinking it first, so a rename failure leaves the prior hook's bytes and executable bit intact, with deterministic recovery guidance on swap failure. `Installed`/`Updated`/`Skipped` are decided against the merged bytes plus the executable bit rather than the canonical asset's raw bytes, so an already-current foreign-plus-block hook reports `Skipped`; `RequiredHookInstallResult.unreachable_block_advisory` is set, and rendered as a named advisory line in setup's hook output, when an appended block follows a foreign hook's zero-indent `exec`/`exit` and so would never run. After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. - `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization, staging/swap install behavior, required-hook installation, and filesystem safety guards, while the inline `prompt` module owns interactive target selection and prompt styling. - `cli/src/services/security.rs` provides shared security utilities for deterministic secret redaction (`redact_sensitive_text`) and directory write-permission probes (`ensure_directory_is_writable`) used by app/setup/observability surfaces. - `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 required registration — the four `sce hooks codex` registrations plus the six `sce hooks codex-mutation-scope` ones, the latter reported under distinct `#(mutation-scope)` rows — 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`, and `codex_hook_policy` probes Codex's effective hook-discovery policy once per invocation; only a structurally current registration that is trusted *and* policy-allowed 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 or policy-blocked is never "fixed", since SCE cannot grant Codex hook trust or change managed policy. diff --git a/context/cli/codex-mutation-scope-integration.md b/context/cli/codex-mutation-scope-integration.md index 8c887dd7..c1e0e740 100644 --- a/context/cli/codex-mutation-scope-integration.md +++ b/context/cli/codex-mutation-scope-integration.md @@ -157,8 +157,8 @@ and cursor. ## Concurrency and attribution confirmation Codex built-in tracked tools were observed serially, so no Codex-only overlap -was observed. Distinct tracked scopes remain required, and a tracked Codex scope -can overlap a Claude, OpenCode, or Pi scope on the same worktree. +was observed; a tracked Codex scope can currently overlap a Claude Code scope on +the same worktree. Generic runtime and `ActorKind` support future OpenCode/Pi adapters once wired. The accepted boundary-aware rule is important: Codex `Start` is write-ahead admission, not positive execution confirmation, because an arbitrary sibling diff --git a/context/context-map.md b/context/context-map.md index d23fc448..ce66426e 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -31,7 +31,7 @@ Feature/domain context: - `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 Claude Code and Codex adapter drivers, both registered by `sce setup` and reachable — OpenCode/Pi remain unwired) +- `context/cli/mutation-scope-runtime.md` (the crate-visible mutation-trace runtime seam and the lifecycle contract every current or future harness adapter 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 current Claude Code and Codex adapter drivers, both registered by `sce setup` and reachable — 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 Claude Code adapter driver (`cli/src/services/hooks/claude_mutation_scope/`) and a Codex adapter driver (`cli/src/services/hooks/codex_mutation_scope/`, hidden `sce hooks codex-mutation-scope`) now exist and are registered by `sce setup`, both consuming this seam's own `pub(crate)` in-process entrypoint; 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) diff --git a/context/glossary.md b/context/glossary.md index 4cb55f6e..ac9c652b 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -6,7 +6,7 @@ - important change (context sync): A completed task change that affects cross-cutting behavior, repository-wide policy/contracts, architecture boundaries, or canonical terminology; these changes require root context edits in `context/overview.md`, `context/architecture.md`, and/or `context/glossary.md` instead of verify-only handling. `setup config handling` is the Git-root-gated setup path that keeps an invalid default-discovered repo-local `.sce/config.json` untouched while allowing normal preflight, bootstrap, lifecycle, hooks, and target installation to continue; absent config remains eligible for create-if-missing bootstrap, Agent Trace storage consumes the same degraded result for invalid discovered layers, explicit `--config` and `SCE_CONFIG_FILE` failures remain fatal, and ordinary startup consumers retain degraded-default behavior. See [the degraded discovered-config boundary decision](decisions/2026-09-04-setup-storage-degrade-invalid-discovered-config.md). - verify-only root context pass: Context-sync mode for localized tasks where root-level behavior, architecture, and terminology are unchanged; root shared files are checked against code truth but are not edited by default. - ephemeral generated payload: Files materialized by `config/pkl/generate.pkl` using payload-relative `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, `config/.agents/**`, `config/.codex/**`, and `config/schema/sce-config.schema.json` paths beneath Cargo `OUT_DIR`, temporary previews, or packaging fallbacks. These layouts are installed by `sce setup` but are never committed as repository target trees; `config/automated/.opencode/**` remains a forbidden generator surface. -- `Codex root-aware hook invocation and mutation-scope coverage`: Generated `.codex/hooks.json` command contract that resolves the Git repository root at hook runtime, invokes the installed helper through quoted path expansion from root or nested event cwd, preserves JSON STDIN, and exits silently successfully when Git-root resolution fails. The existing helper remains responsible for missing-`sce` stderr guidance; the contract forbids install-time absolute paths and `eval`. The same Codex integration classifies `Bash` and `apply_patch` as `TrackedMutation`, delegation tools as non-scoped delegation, and MCP or unknown tools as usable `Untracked` executions outside individual mutation attribution; `AiExclusive` therefore means tracked-scope exclusivity rather than sole filesystem authorship. See [Codex mutation-scope integration](cli/codex-mutation-scope-integration.md). +- `Codex root-aware hook invocation and mutation-scope coverage`: Generated `.codex/hooks.json` command contract that resolves the Git repository root at hook runtime, invokes the installed helper through quoted path expansion from root or nested event cwd, and preserves JSON STDIN. Existing `sce hooks codex` conversation/diff intake remains fail-open where designed when root resolution fails; tracked mutation `PreToolUse` fails closed when attribution bootstrap cannot be established, while mutation-scope `PostToolUse` and cleanup hooks do not use that bootstrap behavior. The existing helper remains responsible for missing-`sce` guidance; the contract forbids install-time absolute paths and `eval`. The same Codex integration classifies `Bash` and `apply_patch` as `TrackedMutation`, delegation tools as non-scoped delegation, and MCP or unknown tools as usable `Untracked` executions outside individual mutation attribution; `AiExclusive` therefore means tracked-scope exclusivity rather than sole filesystem authorship. See [Codex mutation-scope integration](cli/codex-mutation-scope-integration.md). - `CLI generated-input handoff`: Repository-build contract rooted at the temporary directory named by `SCE_CLI_GENERATED_INPUT_DIR`. `config/pkl/generator-inputs.txt` declares the canonical `config/pkl` and referenced `config/lib` inputs; `scripts/produce-cli-generated-input.sh` discovers those files, generates Pkl twice, rejects nondeterminism and in-flight input mutation, and atomically places `pkl-generated/`, its exact `SHA256SUMS`, and `INPUTS.SHA256SUMS` there. `scripts/run-cli-cargo.sh` delegates production and removes its temporary handoff after Cargo exits. `cli/build.rs` verifies payload integrity and input freshness before copying `pkl-generated/` into Cargo `OUT_DIR`; missing, incomplete, modified, or stale handoffs fail rather than invoking Pkl or falling back to packaged assets. - `generated-input producer`: Repository-owned `scripts/produce-cli-generated-input.sh` contract driven by `config/pkl/generator-inputs.txt`. It is the canonical owner for expanding repository-relative generator inputs, snapshotting their inventory, two-pass Pkl evaluation, byte-tree determinism comparison, payload and canonical-input SHA-256 inventories, input-mutation rejection, atomic output publication, and private staging cleanup. The repository Cargo wrapper, generated-output check, package-fallback preparation, and Nix `cliGeneratedInput` derivation all consume it. - `Pi workflow package`: Generated Pi workflow surface consisting of one thin prompt in `config/.pi/prompts/` plus the one workflow skill package under `config/.pi/skills/` that the prompt routes to. Phase-based workflows include `SKILL.md`, `references/output.md`, and named phase, persisted-document, or supporting references; phase-free `/brownfield` has the two core files, while `/handover` also has `references/handover-template.md`. Pi currently receives `/change-to-plan`, `/next-task`, `/validate`, `/commit`, `/handover`, and `/brownfield` this way and has no generated agent-role prompts. diff --git a/context/overview.md b/context/overview.md index 0662da75..528742f9 100644 --- a/context/overview.md +++ b/context/overview.md @@ -12,9 +12,9 @@ OpenCode and Pi remain unwired. See [`context/cli/codex-mutation-scope-integration.md`](cli/codex-mutation-scope-integration.md) for the tested Codex 0.153.4 lifecycle, recovery, and attribution boundary. -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`); the four conversation/diff registrations (`UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash`, `PostToolUse` for `apply_patch`) route through `sce hooks codex`, and six additive mutation-scope registrations (`PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, `SubagentStop`, `SessionEnd`, each an unmatched catch-all group appended after the existing groups) route through the separate `sce hooks codex-mutation-scope` command; the generated commands resolve the Git root at invocation time and safely invoke 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, reporting the mutation-scope registrations under distinct `#(mutation-scope)` rows separate from the `sce hooks codex` rows, and separately reports whether Codex has actually marked each structurally current registration trusted and whether its effective hook-discovery policy allows project hooks, by reading (never writing) Codex's own `$CODEX_HOME/config.toml` and policy state; `sce doctor --fix` repairs structurally unhealthy registrations through the same merge service but never touches trust or policy 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. +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 install-guidance helper (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`). The existing conversation/diff registrations (`UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash`, `PostToolUse` for `apply_patch`) route through `sce hooks codex` and retain their fail-open behavior where designed. The separate mutation-scope registrations route through `sce hooks codex-mutation-scope`: `PreToolUse` and `PostToolUse` use matcher `^(Bash|apply_patch)$`, while `Stop`, `Interrupt`, `SubagentStop`, and `SessionEnd` omit matcher and are unmatched. The tracked mutation `PreToolUse` bootstrap fails closed if Git-root resolution, helper or `sce` discovery, adapter startup, runtime `Start`, or Bash-policy evaluation cannot establish attribution; mutation-scope `PostToolUse` and cleanup hooks do not use this bootstrap behavior. Delegation, MCP, and unknown tools do not match these generated mutation Pre/Post registrations. `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, reporting the mutation-scope registrations under distinct `#(mutation-scope)` rows separate from the `sce hooks codex` rows, and separately reports whether Codex has actually marked each structurally current registration trusted and whether its effective hook-discovery policy allows project hooks, by reading (never writing) Codex's own `$CODEX_HOME/config.toml` and policy state; `sce doctor --fix` repairs structurally unhealthy registrations through the same merge service but never touches trust or policy 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 filesystem effects do not become `diff_traces` in this existing conversation/diff pipeline, and its apply_patch evidence has separate operation limitations. Separately, the Codex mutation-scope adapter tracks `Bash` and `apply_patch` executions as `TrackedMutation` scopes; `AiExclusive` means tracked-scope exclusivity, not that Bash authored every mutation in the interval. -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`. +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 current or future harness adapter 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. Concrete harness lifecycle adapters for Claude Code and Codex are wired to it in-process via a `pub(crate)` seam rather than by re-invoking the ingress command; they map each harness's lifecycle events onto one mutation `ScopeId` per mutation-capable tool execution with write-ahead fail-closed `Start`, terminal `Close`, cleanup, recovery barriers, and harness-specific unsupported-boundary handling. OpenCode and Pi remain unwired; the generic runtime supports future adapters for them. Codex execution identity and `ScopeId`/`EventId` derivation are owned by `context/cli/codex-mutation-scope-integration.md`. 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. @@ -124,7 +124,7 @@ Lightweight post-task verification baseline (required after each completed task) ## Cross-target parity -- OpenCode, Claude, Pi, and Codex are generated from canonical Pkl content with per-target capability mapping. All three receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter; its bodies match Pi's except where prose names the skill's invocation input (`invocation input` in place of Pi's substituted `$ARGUMENTS`) and where each skill's `## Input` section carries a trailing, Codex-only `$sce-{slug}` invocation example Pi's empty invocation-example function never renders; its `sce-handover`/`sce-brownfield` `references/output.md` also quote the arguments-reference token back to the user in their invalid-usage example. It also receives a hook registration file (`config/.codex/hooks.json`, registering `UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash`, and `PostToolUse` for `apply_patch` routed to `sce hooks codex`, plus six unmatched mutation-scope registrations — `PreToolUse`, `PostToolUse`, `Stop`, `Interrupt`, `SubagentStop`, `SessionEnd` — routed to `sce hooks codex-mutation-scope`) and a fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`, matching Claude's `.claude/hooks/run-sce-or-show-install-guidance.sh` pattern); the generated commands resolve the Git root at invocation time and safely reach the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the four supported arms above or a no-op fallthrough, all four now with real behavior — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence with truthful event-local session/model identity and silent non-policy success. +- OpenCode, Claude, Pi, and Codex are generated from canonical Pkl content with per-target capability mapping. All four receive the same six command-routed workflow packages plus a standalone internal `sce-decision` package. The decision package contains `SKILL.md` and `references/adr-template.md`, defines one qualifying system-wide decision per immutable dated ADR, defaults new records to `Accepted`, reuses only equivalent active ADRs, and returns a written/not-qualified/skipped/blocked internal handoff. It has no user-facing command or prompt and is not part of the workflow catalog. Successful task synchronization applies the system-wide decision gate before current-state context edits, continues normally for nonqualifying or skipped decisions, and invokes `sce-decision` only for qualifying SCE decisions; non-SCE helper skills remain usable inside an active step without becoming workflow handoffs. Pi consumes exactly six thin prompts with no agent-role prompts and no added frontmatter. Manual OpenCode consumes exactly six commands plus two thin routing agents, and its Code agent alone allows internal `sce-decision` invocation. Claude consumes exactly six thin commands with no generated agents; its generated settings and hook helper remain. The four phase-based command-routed packages add package-local phase, persisted-document, and supporting references beside `SKILL.md` and `references/output.md`; `/handover` additionally carries its package-local persisted-format template, while `/brownfield` retains its two-file package. Codex is a fourth generated target: it receives the same six workflow skill packages plus the standalone `sce-decision` package under `config/.agents/skills/`, but no command or prompt layer and no added frontmatter; its bodies match Pi's except where prose names the skill's invocation input (`invocation input` in place of Pi's substituted `$ARGUMENTS`) and where each skill's `## Input` section carries a trailing, Codex-only `$sce-{slug}` invocation example Pi's empty invocation-example function never renders; its `sce-handover`/`sce-brownfield` `references/output.md` also quote the arguments-reference token back to the user in their invalid-usage example. It also receives a hook registration file (`config/.codex/hooks.json`): the four conversation/diff registrations route to `sce hooks codex`, while the separate mutation-scope registrations route to `sce hooks codex-mutation-scope` with `PreToolUse` and `PostToolUse` matcher `^(Bash|apply_patch)$` and unmatched `Stop`, `Interrupt`, `SubagentStop`, and `SessionEnd` groups. The existing conversation/diff command retains fail-open behavior where designed; tracked mutation `PreToolUse` fails closed when attribution bootstrap cannot be established, and delegation, MCP, and unknown tools do not match its generated mutation Pre/Post groups. Codex is a fourth `sce setup` target (`--codex`, and included in `--all`), installing both output roots directly at the repository root and recording `"codex"` in persisted `integrations.target`; `sce hooks codex` now exists as a dispatcher classifying each event into one of the four supported arms above or a no-op fallthrough, all four now with real behavior — `UserPromptSubmit` and `Stop` capture real conversation evidence, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses/normalizes/persists a `diff_traces` row for provable Add/Update evidence with truthful event-local session/model identity and silent non-policy success. - When capabilities differ, parity is implemented by supported target-specific behavior rather than forcing unsupported fields. ## Context navigation diff --git a/context/plans/codex-mutation-scope-integration.md b/context/plans/codex-mutation-scope-integration.md index be27cf02..499dca05 100644 --- a/context/plans/codex-mutation-scope-integration.md +++ b/context/plans/codex-mutation-scope-integration.md @@ -1539,7 +1539,7 @@ 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 codex-mutation-scope` exists, is hidden from `sce --help` +- [x] AC1: `sce hooks codex-mutation-scope` exists, is hidden from `sce --help` and `sce hooks --help`, and routes through the normal hook command stack (`HooksSubcommand::CodexMutationScope` -> `convert_hooks_subcommand_request` -> `HookSubcommand::CodexMutationScope` -> `run_hooks_subcommand_in_repo`, @@ -1550,7 +1550,7 @@ performs final validation. (If T02 chooses D17 option 2, this AC instead asserts the new `CodexDispatchArm` mutation-scope variants and their non-fail-open handling inside `sce hooks codex`.) -- [ ] AC2: The raw Codex mutation-scope event parser strictly validates the +- [x] AC2: The raw Codex mutation-scope event parser strictly validates the fields T01 freezes as required for a tracked `PreToolUse` and rejects an empty payload, non-object JSON, and missing/blank/wrong-typed fields with a `Invalid Codex hook event payload from STDIN: .` diagnostic, never @@ -1558,7 +1558,7 @@ performs final validation. - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex_mutation_scope` parser unit tests, including a fixture per T01 probe. -- [ ] AC3: `classify_tool` returns exactly one of the three classes (D2) for +- [x] AC3: `classify_tool` returns exactly one of the three classes (D2) for every Codex tool name T01 enumerated: `TrackedMutation` (`apply_patch`, the shell/`Bash` tool), `Delegation` (`collaborationspawn_agent` / `collaborationwait_agent` — no scope), `Untracked` (any `mcp____` @@ -1571,7 +1571,7 @@ performs final validation. - Validate: classification unit-test table (each tool name -> class); adapter mapping unit tests asserting that `Untracked`/`Delegation` events produce no processed-event keys. -- [ ] AC9b (MCP / unknown pass-through — direction B): A `PreToolUse(mcp__…)` +- [x] AC9b (MCP / unknown pass-through — direction B): A `PreToolUse(mcp__…)` and a `PreToolUse()` are classified `Untracked` and produce a **Codex-neutral continue response** — no `ScopeId`, no `EventId`, no mutation-scope `Start`, no adapter attempt, no `recovery_pending`, no @@ -1584,7 +1584,7 @@ performs final validation. successful-MCP lifecycle (`PreToolUse → PostToolUse`, probe 12 fixture) leaves zero mutation-scope rows/events; the recorded D23 decision in this plan. -- [ ] AC9c (MCP mutate-then-error leaves no stale state — direction B): Driving +- [x] AC9c (MCP mutate-then-error leaves no stale state — direction B): Driving the probe-13 lifecycle (`PreToolUse(mcp__…)` mutates a git-visible file, tool returns an error, **no `PostToolUse`**, then `Stop`/`SessionEnd`) through the adapter leaves **no stale attempt, no `recovery_pending`, no `abandon`, and no @@ -1593,7 +1593,7 @@ performs final validation. - Validate: adapter unit test replaying the probe-13 fixture sequence and asserting the state store is empty throughout and after; T06 row-count assertion. -- [ ] AC9d (failed MCP A -> successor B — direction B): Using the probe-14 +- [x] AC9d (failed MCP A -> successor B — direction B): Using the probe-14 sequence (`A` = untracked MCP that mutates then errors, immediately followed by `PreToolUse(B)` where `B` is a tracked `Bash`/`apply_patch` **or** another MCP), A leaves **no mutation-scope bookkeeping** that can interfere with B: if @@ -1602,13 +1602,13 @@ performs final validation. - Validate: adapter unit test over the probe-14 fixture asserting B (tracked) is the only live scope at its `Start` and no false `AiContended`; T06 regression. -- [ ] AC9e (parallel MCP — direction B): Using probes 16/17 (two MCP executions +- [x] AC9e (parallel MCP — direction B): Using probes 16/17 (two MCP executions genuinely overlap), neither MCP `PreToolUse` creates a mutation scope, there is **no MCP/MCP `AiContended`**, and the adapter state store shows no leak (empty before, during, and after). - Validate: adapter unit test over the probe-16/17 fixtures; T06 regression asserting zero mutation-scope rows for the overlapping MCP pair. -- [ ] AC9f (tracked tool overlapping MCP — semantics documented): With +- [x] AC9f (tracked tool overlapping MCP — semantics documented): With `Start(Bash A)` live, an MCP call mutates the worktree, then `Close(Bash A)`. The runtime may report `AiExclusive(A)` for the interval. The test and the durable context must state this means **A was the only tracked scope live**, @@ -1620,25 +1620,25 @@ performs final validation. AC22 confirms there is no SQL/schema change and that the only protocol/Quint semantic change is the boundary-aware unconfirmed-Codex attribution rule (D14), which leaves MCP's `Untracked` disposition untouched. -- [ ] AC4: The Codex execution key is exactly the field tuple T02 froze from +- [x] AC4: The Codex execution key is exactly the field tuple T02 froze from T01 evidence (recorded in D3). Duplicate delivery of the same live `PreToolUse` reuses the same `attempt_seq`, `ScopeId`, and `Start` `EventId`. - Validate: identity/formatter unit tests; state unit tests; T06 duplicate- delivery regression. -- [ ] AC5: A later execution attempt of the same raw Codex tool identifier, +- [x] AC5: A later execution attempt of the same raw Codex tool identifier, after the previous attempt became terminal, receives a new `attempt_seq` and a new `ScopeId`; a terminal `ScopeId` is never reused; replaying a live attempt's event is `ScopeId`/`EventId`-stable. - Validate: state unit tests (terminal attempt then fresh `attempt_seq`); formatter determinism tests; T06 reused-identifier regression. -- [ ] AC6: A `TrackedMutation` `PreToolUse` reaches durable +- [x] AC6: A `TrackedMutation` `PreToolUse` reaches durable generic-ingress `Start` before the hook returns its "continue" response to Codex (write-ahead `pending_start` -> ingress `Start` -> `active`), and the seam receives the raw hook `cwd` as `repository_root` (never `git_dir`). - Validate: adapter ordering unit test with an injected seam asserting the persisted phase from inside the seam call and the `repository_root` argument; T06 production-path confirmation. -- [ ] AC7: Any failure to establish adapter state or `Start` during a +- [x] AC7: Any failure to establish adapter state or `Start` during a `TrackedMutation` `PreToolUse` returns the exact Codex-native block response T01 froze (D8), never a silent success and never an explicit allow; the detailed error is logged via @@ -1647,12 +1647,12 @@ performs final validation. - Validate: failure-classification unit tests asserting the exact response JSON/exit; `RecordingLogger` assertion that the detail is logged and not leaked into the model-visible reason. -- [ ] AC8: A successful `TrackedMutation` tool with an `active` attempt closes +- [x] AC8: A successful `TrackedMutation` tool with an `active` attempt closes its scope: `PreToolUse` -> real filesystem mutation -> terminal Codex hook produces exactly one eligible tool interval and one terminal (`Closed`) scope with attribution `AiExclusive`. - Validate: T06 real-Git + real-Agent-Trace-DB regression. -- [ ] AC9: A `TrackedMutation` tool that partially mutated the checkout then +- [x] AC9: A `TrackedMutation` tool that partially mutated the checkout then failed is handled per D10's T01 disposition: - **`Bash`:** the terminal `PostToolUse` closes the scope (partial mutation attributed to that scope). @@ -1661,7 +1661,7 @@ performs final validation. exists, so there is nothing to close, abandon, or flush; see AC9c. - Validate: T06 failed-`Bash` and failed-`apply_patch` regressions whose assertions match the D10 disposition recorded by T01. -- [ ] AC9a: A partially-mutating failed `TrackedMutation` tool A with **no +- [x] AC9a: A partially-mutating failed `TrackedMutation` tool A with **no terminal event**, followed by another `TrackedMutation` `PreToolUse(B)` in the same turn: B never `Start`s alongside a zombie A. Normal path (Case A) — a terminal `PostToolUse` (or `Interrupt`/`SessionEnd`) precedes the successor. @@ -1675,7 +1675,7 @@ performs final validation. sweep, seam order, fail-closed, lane scoping); T06 built-in failed-A-then-B regression (assert A `Abandoned` or `Closed` per tool, B the only live scope at its `Start`, no false `AiContended`). -- [ ] AC10: Two simultaneously-live **tracked** scopes never collapse into one +- [x] AC10: Two simultaneously-live **tracked** scopes never collapse into one shared `ScopeId`, and a tree transition observed while both are live is attributed per the refined D14 confirmation rule: 1. observed at a boundary that does **not** confirm the live Codex scope @@ -1699,7 +1699,7 @@ performs final validation. `an_unconfirmed_codex_overlap_never_reaches_the_mutation_ai_patch`, `a_confirmed_codex_close_overlap_is_contended_and_still_not_ai_lineage`); the plan records the D14 form exercised. -- [ ] AC11: An outstanding **tracked** execution with no terminal hook is +- [x] AC11: An outstanding **tracked** execution with no terminal hook is retired by exactly the Codex lifecycle signals T01 marked load-bearing (D12), via `abandon_scope`, leaving the worktree `needs_rebaseline`. The D12 sweeps operate only over adapter-owned tracked attempts; `Untracked` (MCP, unknown) @@ -1707,7 +1707,7 @@ performs final validation. - Validate: T06 regressions for each proven cleanup signal; adapter cleanup unit tests including one asserting an `Untracked` execution left no attempt for a sweep to touch. -- [ ] AC12: While `recovery_pending` is armed and known **tracked** attempts +- [x] AC12: While `recovery_pending` is armed and known **tracked** attempts remain outstanding, every new `TrackedMutation` `PreToolUse` is denied (D8 shape); an `Untracked` `PreToolUse` is not affected by the barrier; once quiescent, exactly one `{"operation":"flush"}` runs through the seam and @@ -1718,18 +1718,18 @@ performs final validation. - Validate: adapter recovery-barrier unit tests (deny-while-outstanding, flush-then-proceed, flush-failure-stays-closed); T06 recovery-barrier regression. -- [ ] AC13: A failed abandonment leaves the attempt tracked and +- [x] AC13: A failed abandonment leaves the attempt tracked and `recovery_pending = true` (no successor mutation `Start` is allowed until recovery succeeds). - Validate: adapter unit test (failed `abandon` -> attempt retained, barrier armed, next `PreToolUse` denied, seam not re-driven). -- [ ] AC14: A hook process whose raw payload `cwd` names checkout B drives +- [x] AC14: A hook process whose raw payload `cwd` names checkout B drives mutation state for checkout B only (its `WorktreeId`/cursor advances; another checkout's cursor is unchanged); the adapter constructs no `WorktreeId` and passes no `worktree_id` key. - Validate: T06 worktree-isolation regression (real linked worktree); dependency-boundary grep for `worktree_id` key construction. -- [ ] AC15: The background/detached execution boundary matches T01's finding +- [x] AC15: The background/detached execution boundary matches T01's finding (D16): any Codex-managed background execution T01 shows is unbounded is denied in `PreToolUse` with the recorded message; the self-detaching-descendant boundary is documented with Codex-specific T01 evidence and the adapter adds @@ -1737,14 +1737,14 @@ performs final validation. - Validate: adapter classification unit test (if a deny applies); T06 documented unsupported-case regression; inspection of the T01 evidence fixture and the D16 disposition. -- [ ] AC16: Generated `.codex/hooks.json` after `sce setup --codex` still +- [x] AC16: Generated `.codex/hooks.json` after `sce setup --codex` still contains the four existing SCE registrations (`UserPromptSubmit`, `Stop`, `PreToolUse` matcher `Bash`, `PostToolUse` matcher `apply_patch`) routed to `sce hooks codex`, byte-for-byte, plus the new mutation-scope registrations; user-owned Codex handlers and unrelated valid event groups are preserved. - Validate: `nix run .#pkl-check-generated`; `codex_hook_config.rs` merge tests (existing + new); inspection of the rendered `config/.codex/hooks.json`. -- [ ] AC16a: Upgrading a realistic already-installed, already-trusted SCE Codex +- [x] AC16a: Upgrading a realistic already-installed, already-trusted SCE Codex document (exactly the canonical four registrations) through the same merge/setup path that adds mutation-scope hooks preserves, for every existing registration, the tuple `(event, matcher, matcher-group index, handler index, @@ -1758,19 +1758,19 @@ performs final validation. upgrade regression asserting each existing registration's identity tuple and computed trust key are unchanged, the new hooks are appended once, user hooks unchanged, and a second run is idempotent. -- [ ] AC17: `sce setup --codex` merge is idempotent for the mutation-scope +- [x] AC17: `sce setup --codex` merge is idempotent for the mutation-scope registrations (a second run produces byte-identical output) and a structurally invalid existing `.codex/hooks.json` fails before the atomic swap with the existing file untouched. - Validate: `codex_hook_config.rs` idempotency + malformed-input tests. -- [ ] AC17a: Every newly SCE-owned mutation-scope event has a +- [x] AC17a: Every newly SCE-owned mutation-scope event has a `codex_hook_config::hook_event_key_label` entry whose label is the **exact upstream Codex key label** (verified against `openai/codex` source in T01/T05, not derived by lowercasing), with a dedicated test per newly registered event (D22). - Validate: `services::codex_hook_config` label tests, one per new event; a comment or `NOTES.md` citation of the upstream source for each label. -- [ ] AC18: `sce doctor` gives each SCE-owned mutation-scope registration the +- [x] AC18: `sce doctor` gives each SCE-owned mutation-scope registration the full three-dimension Codex health model (D21), reported independently of the four existing `sce hooks codex` registrations, proving all of: 1. structural diagnosis (`PresentAndCurrent` / `Missing` / `Stale`, @@ -1792,7 +1792,7 @@ performs final validation. hooks are reported with distinct readiness rather than one flattened `.codex/hooks.json` status; a filesystem assertion that no `$CODEX_HOME` write occurs. -- [ ] AC19: Production Codex-adapter code (everything in +- [x] AC19: Production Codex-adapter code (everything in `cli/src/services/hooks/codex_mutation_scope/` outside `#[cfg(test)]`) contains no `use` or qualified-path reference naming `crate::services::mutation_trace::{runtime,protocol,store}`, @@ -1804,12 +1804,12 @@ performs final validation. cli/src/services/hooks/codex_mutation_scope/` returns no match outside a `#[cfg(test)]` module; manual check confirms exactly one `use` reaching `crate::services::hooks::mutation_scope`. -- [ ] AC20: Codex mutation-scope-only regressions leave `diff_traces`, +- [x] AC20: Codex mutation-scope-only regressions leave `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` unchanged (before/after row-count assertions), and adapter state lives only below `/sce/`. - Validate: T06 regression with row-count assertions; state-module inspection. -- [ ] AC21: Crash/recovery invariants hold against the real runtime: (a) a +- [x] AC21: Crash/recovery invariants hold against the real runtime: (a) a `pending_start` attempt whose `Start` never committed is abandoned then recovered by the quiescent flush; (b) a `pending_start` attempt whose `Start` did commit is abandoned as a real runtime abandonment, not a late `Start`; @@ -1817,7 +1817,7 @@ performs final validation. replay-safe on redelivery (no second transition, revision unchanged). - Validate: T06 regressions Test-crash-a/b/c driving real events after simulating each crash point via the adapter's own bookkeeping helpers only. -- [ ] AC22: The **only** protocol / formal-model / mutation-attribution semantic +- [x] AC22: The **only** protocol / formal-model / mutation-attribution semantic change introduced by the Codex integration is the accepted boundary-aware unconfirmed-Codex attribution rule recorded in D14 / the fifth PR #268 follow-up. The diff against `origin/claude-mutation-scope-integration` may @@ -1849,7 +1849,7 @@ performs final validation. unconfirmed Codex scopes"): `git diff b72f6c2c -- spec/mutation_cursor.qnt spec/mutation_cursor.md cli/src/services/mutation_trace/protocol.rs cli/src/services/mutation_trace/runtime/`. -- [ ] AC23: Durable context clearly separates the generic mutation-scope +- [x] AC23: Durable context clearly separates the generic mutation-scope ingress, the Codex mutation adapter, and the mutation runtime, and records: the tool-execution scope model, the **three-class** Codex tool classification (`TrackedMutation` / `Delegation` / `Untracked`) and the **partial-by-tool- @@ -1876,7 +1876,7 @@ performs final validation. adapter**. - Validate: inspection of `context/cli/codex-mutation-scope-integration.md` and the updated cross-reference files. -- [ ] AC24: The plan's exact unsupported / out-of-coverage limitations are +- [x] AC24: The plan's exact unsupported / out-of-coverage limitations are enumerated in durable context: - **MCP tools and unknown/future Codex tools are outside Codex mutation-scope attribution coverage** (D23, direction B). They execute and may mutate; the @@ -3374,9 +3374,9 @@ Persist this field in every plan; this is durable plan state, not chat state: - [x] T06: `Real Git/DB regressions through the production Codex path` (status:done) - Task ID: T06 - - **Unblocked (2026-09-08).** T01–T05 are `done` and `synced`, the - protocol/formal safety follow-up is accepted, and the plan/AC22 consistency - cleanup is complete. T06 is ready to begin; it has not been started. + - **Completed (2026-09-08).** T01-T05 were done and synced, the + protocol/formal safety follow-up and AC22 consistency cleanup were complete, + and T06 subsequently completed against the frozen production baseline below. - **T06 production baseline (frozen).** The production semantics under test are frozen at the completion of the fifth T04 follow-up (head `b72f6c2c`): Codex tracked-tool classification; fail-closed bootstrap; Bash-policy @@ -3680,6 +3680,18 @@ Persist this field in every plan; this is durable plan state, not chat state: registered. No production behavior, protocol, Quint, SQL, or Agent Trace schema changed in T07. - Context synchronization: synced + - Follow-up (2026-09-08 — final durable-context consistency pass): + The first T07 context sync added the authoritative Codex mutation-scope + domain file but left several pre-adapter absolute statements in + overview/current-state context. This follow-up removed those contradictions: + Codex is now consistently described as the second wired adapter; OpenCode/Pi + remain future adapters; generated mutation Pre/Post groups use + `^(Bash|apply_patch)$` while cleanup groups remain unmatched; tracked + mutation Pre bootstrap is fail-closed; the existing `sce hooks codex` + evidence pipeline is kept distinct from mutation-scope Bash tracking; and + current cross-harness wording distinguishes wired Claude overlap from future + OpenCode/Pi runtime capability. + No production behavior changed. ## Open questions @@ -3866,3 +3878,74 @@ attribution-algorithm change is expected after that follow-up — T06 and T07 ar frozen against the post-follow-up head. Future work (direction C — first-class MCP attribution via a richer lifecycle mechanism) remains a separate, explicitly justified PR. + +## Validation Report + +**Status:** validated +**Date:** 2026-09-08 + +### Commands run + +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex_mutation_scope` -> exit 0 (146 passed, 1 ignored) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope` -> exit 0 (36 passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::codex` -> exit 0 (276 passed, 1 ignored) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::` -> exit 0 (478 passed, 1 ignored) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::` -> exit 0 (336 passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::codex_hook_config` -> exit 0 (36 passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::doctor::` -> exit 0 (27 passed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` -> exit 0 (1320 passed, 1 ignored) +- `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` -> exit 0 (completed without warnings) +- `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` -> exit 0 (format check passed) +- `nix run .#pkl-check-generated` -> exit 0 (141 generated files; inventory sha256 `b5967aeccf044184f8e6aaab0a863254726e849664f95abdc733c77065dcb34e`) +- `nix flake check` -> exit 0 (all checks passed; incompatible systems omitted) +- `nix run .#sce -- hooks codex-mutation-scope exit 4 (strict empty-payload diagnostic, not unknown subcommand) +- `nix run .#sce -- --help` and `nix run .#sce -- hooks --help` -> exit 0 (hidden command omitted from both help surfaces) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::parse::command_runtime` -> exit 0 (15 passed, including routing and hidden-help tests) +- `nix shell nixpkgs#ripgrep -c rg -n --type rust '^\\s*use\\s+crate::services::mutation_trace::(runtime|protocol|store)|::(RepositoryAgentTraceDb|WorktreeId|GitSnapshotService)\\b' cli/src/services/hooks/codex_mutation_scope/` -> exit 0 (only test-module matches) +- `git diff --name-only origin/claude-mutation-scope-integration -- cli/migrations/agent-trace-repository/ config/schema/agent-trace.schema.json` -> exit 0 (empty) +- `git diff --name-only b72f6c2c -- spec/mutation_cursor.qnt spec/mutation_cursor.md cli/src/services/mutation_trace/protocol.rs cli/src/services/mutation_trace/runtime/` -> exit 0 (empty) +- Durable-context inspection and line-budget check -> passed (Codex domain and coverage boundary present; affected files at or below 250 lines) +- Protocol/Quint diff inspection against `origin/claude-mutation-scope-integration` -> passed (limited to the accepted D14 unconfirmed-Codex attribution rule and its formal/refinement tests) + +### Success-criteria verification + +- [x] AC1: Hidden command exists, routes correctly, and is strict/non-fail-open -> direct invocation returned the strict parser error; both help surfaces omitted it; 15 command-runtime tests passed. +- [x] AC2: Raw event parser validates required fields and rejects malformed payloads -> Codex adapter suite passed parser fixtures and rejection tests. +- [x] AC3: Classification is total across tracked, delegation, and untracked tools -> classification and no-scope tests passed. +- [x] AC9b: MCP and unknown tools pass through neutrally without adapter state -> adapter and production regressions passed. +- [x] AC9c: MCP mutate-then-error leaves no stale state -> adapter and production regressions passed. +- [x] AC9d: Failed MCP followed by a successor cannot interfere with tracked or MCP execution -> successor regression passed. +- [x] AC9e: Parallel MCP executions create no scopes or contention -> parallel-MCP regression passed. +- [x] AC9f: Tracked-tool plus MCP overlap retains tracked-scope exclusivity semantics -> real regression passed and durable context records that `AiExclusive` is not sole authorship. +- [x] AC4: Duplicate live delivery is identity and event stable -> adapter identity/state and production regressions passed. +- [x] AC5: Terminal scopes are never reused -> fresh-attempt and reused-identifier regressions passed. +- [x] AC6: Tracked admission is write-ahead and forwards raw `cwd` -> ordering/seam and production regressions passed. +- [x] AC7: Tracked setup failures fail closed while delegation/untracked tools remain neutral -> failure-classification, logging, and production regressions passed. +- [x] AC8: Successful tracked tools close with eligible exclusive attribution -> real Git/DB regressions passed. +- [x] AC9: Failed Bash and apply_patch behavior matches the frozen lifecycle evidence -> failed-tool regressions passed. +- [x] AC9a: Built-in failed-A/successor-B handling prevents zombie scopes -> driver and production regressions passed. +- [x] AC10: Tracked scopes remain distinct and D14 confirmation rules hold -> Codex cross-harness regressions and mutation-trace confirmation tests passed. +- [x] AC11: Proven cleanup signals retire outstanding tracked attempts -> cleanup driver and production regressions passed. +- [x] AC12: Recovery barrier denies tracked work until quiescent flush succeeds, without affecting untracked tools -> recovery regressions passed. +- [x] AC13: Failed abandonment retains the attempt and arms recovery -> abandonment regression passed. +- [x] AC14: Raw `cwd` isolates linked-worktree state without constructing `worktree_id` -> linked-worktree regression and boundary inspection passed. +- [x] AC15: Background/detached limitations match Codex evidence -> documented unsupported-case regression passed. +- [x] AC16: Codex setup output preserves existing registrations and adds mutation registrations -> generated parity, merge tests, and flake checks passed. +- [x] AC16a: Existing trusted registration identity is preserved during upgrade -> canonical-four upgrade regression passed. +- [x] AC17: Setup merge is idempotent and rejects malformed documents before replacement -> merge tests passed. +- [x] AC17a: New event labels match upstream Codex labels -> dedicated label tests passed. +- [x] AC18: Doctor reports structural, trust, and policy dimensions independently without trust/policy writes -> doctor suite passed. +- [x] AC19: Production adapter dependency boundary is limited to the in-process mutation-scope seam -> prohibited direct-reference inspection found only test-module matches; manual production inspection passed. +- [x] AC20: Mutation-scope regressions leave unrelated trace tables unchanged and state is under `/sce/` -> production row-count and state-location regressions passed. +- [x] AC21: Crash/recovery invariants are replay-safe against the real runtime -> all three production crash-point regressions passed. +- [x] AC22: Only the accepted D14 protocol/formal refinement exists, with SQL/schema and frozen-baseline diffs empty -> both empty-diff checks passed and the predecessor diff inspection passed. +- [x] AC23: Durable context separates ingress, adapter, and runtime and records the complete Codex contract -> new domain and cross-reference inspection passed; all affected files fit the line budget. +- [x] AC24: Unsupported and out-of-coverage limits are explicit, including MCP rationale and future work -> the durable “Unsupported / Coverage boundary” inspection passed. + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- First-class MCP mutation attribution remains explicitly deferred to a separate richer-lifecycle change. diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 846dc534..59ee4f12 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -126,7 +126,7 @@ Interactive `sce setup` persists the independent confirmations at `agent_trace.a - The generated OpenCode agent-trace plugin emits this mixed-batch shape for conversation-trace handoff with `tool_name: "opencode"`: ordinary message/part events produce one-item mixed envelopes, completed question-tool parts produce `message.part` items with `part_type: "question"`, and diff-backed message events produce one envelope containing the synthetic parent `message` item plus patch `message.part` items. The Pi extension uses the same shape with `tool_name: "pi"`. - `sce hooks claude-model-state` is a silent, local-only lifecycle intake for raw Claude `SessionStart` and `PostModelSwitch` events. A model-bearing `SessionStart` writes normalized `claude/` 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 the generated helper path plus either the `sce hooks codex` or the `sce hooks codex-mutation-scope` command contract, attributing each handler to whichever it matches and merging the two contracts' registrations independently. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full `sce hooks codex` dispatcher and per-arm contract; the six `sce hooks codex-mutation-scope` registrations feed the generic mutation-scope ingress ([../cli/mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md)). +- `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. 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`; it is independent of the concrete Codex mutation-scope 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 existing Codex command 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; its root-resolution failure remains silent and fail-open where designed. 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 the generated helper path plus either the `sce hooks codex` or `sce hooks codex-mutation-scope` command contract, attributing each handler to whichever it matches and merging the two contracts' registrations independently. The mutation-scope `PreToolUse`/`PostToolUse` registrations use matcher `^(Bash|apply_patch)$`; `Stop`/`Interrupt`/`SubagentStop`/`SessionEnd` are unmatched and feed the generic mutation-scope ingress ([../cli/mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md)). - `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); the Codex adapter also consumes it in-process, while OpenCode and 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 are in [../cli/claude-mutation-scope-integration.md](../cli/claude-mutation-scope-integration.md). - `sce hooks codex-mutation-scope` (hidden) is the second concrete harness adapter: it reads raw Codex lifecycle events and drives the same in-process seam with one tracked scope per `Bash`/`apply_patch` execution, write-ahead fail-closed admission, proven terminal close, identity-scoped cleanup, and checkout-local recovery bookkeeping. MCP and unknown tools remain usable but are untracked and outside individual mutation attribution; their lifecycle is not treated as read-only. It is registered by `sce setup --codex` alongside the existing `sce hooks codex` conversation/diff contract. Full mapping and the tested Codex 0.153.4 boundary are in [../cli/codex-mutation-scope-integration.md](../cli/codex-mutation-scope-integration.md). diff --git a/context/sce/codex-integration-runtime.md b/context/sce/codex-integration-runtime.md index 2952e9e9..15a833c2 100644 --- a/context/sce/codex-integration-runtime.md +++ b/context/sce/codex-integration-runtime.md @@ -16,17 +16,18 @@ for how the other three tools intake conversation/diff evidence. The generated `.codex/hooks.json` routes its four conversation/diff registrations through `sce hooks codex` and its six mutation-scope registrations -through `sce hooks codex-mutation-scope`. Each command resolves -`git rev-parse --show-toplevel` at invocation time, then invokes the -repository-root -`.codex/hooks/run-sce-or-show-install-guidance.sh` helper with quoted -expansions. It therefore works from the repository root, arbitrary nested -Codex working directories, and repository paths containing spaces. Git-root -resolution failures exit successfully without stdout; the helper retains its -existing missing-`sce` stderr guidance and forwards the hook JSON STDIN -unchanged. The exact registration set (four `sce hooks codex` plus six -`sce hooks codex-mutation-scope`) and invocation contract is covered by the -generated contract and `codex-hook-command` flake check. See [the ADR](../decisions/2026-08-23-codex-root-aware-hook-invocation.md). +through `sce hooks codex-mutation-scope`. The mutation-scope `PreToolUse` and +`PostToolUse` groups use matcher `^(Bash|apply_patch)$`; `Stop`, `Interrupt`, +`SubagentStop`, and `SessionEnd` omit matcher and are unmatched. The existing +conversation/diff command resolves `git rev-parse --show-toplevel` at invocation +time, then invokes the repository-root helper with quoted expansions. It works +from nested Codex working directories and repository paths containing spaces, +and remains fail-open where designed when Git-root resolution fails. The +tracked mutation `PreToolUse` bootstrap is separate and fails closed when SCE +cannot establish attribution; mutation-scope `PostToolUse` and cleanup hooks do +not use that bootstrap behavior. The exact registration set and invocation +contract are covered by the generated contract and `codex-hook-command` flake +check. See [the ADR](../decisions/2026-08-23-codex-root-aware-hook-invocation.md). ## Non-destructive hook configuration ownership @@ -39,11 +40,13 @@ supported event names, defaulted matcher groups, and `command`, `mcp_tool`, valid Codex fields, event groups, matcher groups, and handlers. Merge is command-aware over two contracts: it replaces stale or duplicate handlers with one current handler per required registration — the four `sce hooks codex` -registrations and six additive `sce hooks codex-mutation-scope` registrations, -each appended in its own unmatched group after the existing groups so an -already-trusted handler keeps its `(event, matcher, group index, handler index)` -identity and computed Codex trust key — touching only the matching command's -handlers. Ownership requires the helper path plus one of the `sce hooks codex` / +registrations and six `sce hooks codex-mutation-scope` registrations. Within +the mutation-scope contract, `PreToolUse` and `PostToolUse` use matcher +`^(Bash|apply_patch)$`, while `Stop`, `Interrupt`, `SubagentStop`, and +`SessionEnd` are appended in unmatched groups. Each group is appended after the +existing groups so an already-trusted handler keeps its `(event, matcher, group +index, handler index)` identity and computed Codex trust key — touching only the +matching command's handlers. Ownership requires the helper path plus one of the `sce hooks codex` / `sce hooks codex-mutation-scope` command contracts; a generic `sce` substring is not enough. Malformed or structurally invalid existing documents fail before staging, so @@ -169,9 +172,13 @@ no reimplemented matching and no Codex-specific DB adapter: rather than by calling that Claude-specific function. Neither branch reads or writes `diff_traces`, a snapshot, or any -pending-state file; Bash-triggered filesystem mutations remain untracked for -Codex (see "Explicit non-goals" in -[agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md)). +pending-state file. The existing `sce hooks codex` diff-evidence pipeline does +not convert Bash filesystem effects into `diff_traces`, and its apply-patch +evidence has the operation limitations documented separately. The +`sce hooks codex-mutation-scope` adapter nevertheless tracks Bash executions as +`TrackedMutation` scopes; that scope does not prove Bash authored every +mutation in its interval. See "Explicit non-goals" in +[agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md). ## `PostToolUse(apply_patch)` diff capture @@ -189,9 +196,10 @@ All four `sce hooks codex` dispatch arms (`UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`) now have real behavior. `PreToolUse(apply_patch)` is deliberately not a `sce hooks codex` arm (see plan `context/plans/codex-cli-integration.md`'s no-snapshot design) and falls -open as a `NoOp` like any other unsupported combination; the unmatched -`PreToolUse` group routed to `sce hooks codex-mutation-scope` is a separate -concern handled by that command. +open as a `NoOp` like any other unsupported combination; the separate +mutation-scope `PreToolUse` registration matched by `^(Bash|apply_patch)$` and +routed to `sce hooks codex-mutation-scope` is a separate concern handled by +that command. ## Verification From 143089dd4a232e997a9187952b0cb16f9522227e Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 8 Sep 2026 21:12:52 +0200 Subject: [PATCH 20/20] runtime: Normalize Codex lifecycle hook timeouts Match SessionEnd and Interrupt hook identities to Codex's one-second default and three-second cap. Preserve the existing timeout normalization for other hook events and cover the lifecycle defaults and cap with tests. Co-authored-by: SCE --- cli/src/services/codex_hook_trust.rs | 50 +++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/cli/src/services/codex_hook_trust.rs b/cli/src/services/codex_hook_trust.rs index 2e35627a..9153bc3d 100644 --- a/cli/src/services/codex_hook_trust.rs +++ b/cli/src/services/codex_hook_trust.rs @@ -43,6 +43,12 @@ use sha2::{Digest, Sha256}; /// default is normalized away before hashing, exactly as upstream does. const DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT: u64 = 2_500; +/// Codex gives `SessionEnd` and `Interrupt` command hooks a one-second +/// default timeout and clamps them to three seconds before hashing their +/// normalized identity (`hooks/src/engine/discovery.rs`). +const DEFAULT_LIFECYCLE_HOOK_TIMEOUT: u64 = 1; +const MAX_LIFECYCLE_HOOK_TIMEOUT: u64 = 3; + /// Events whose hooks may carry `additionalContext` /// (`hooks/src/engine/discovery.rs`); `Stop` cannot, so an /// `additionalContextLimit` set on a Stop handler is dropped before hashing, @@ -252,11 +258,14 @@ pub(crate) fn hash_command_handler( let mut handler_fields = serde_json::Map::new(); handler_fields.insert("type".to_string(), Value::String("command".to_string())); handler_fields.insert("command".to_string(), Value::String(command.to_string())); - let timeout = object - .get("timeout") - .and_then(Value::as_u64) - .unwrap_or(600) - .max(1); + let configured_timeout = object.get("timeout").and_then(Value::as_u64); + let timeout = if matches!(event, "SessionEnd" | "Interrupt") { + configured_timeout + .unwrap_or(DEFAULT_LIFECYCLE_HOOK_TIMEOUT) + .clamp(1, MAX_LIFECYCLE_HOOK_TIMEOUT) + } else { + configured_timeout.unwrap_or(600).max(1) + }; handler_fields.insert("timeout".to_string(), Value::from(timeout)); let is_async = object .get("async") @@ -408,6 +417,37 @@ mod tests { ); } + #[test] + fn hashing_uses_codex_lifecycle_timeout_defaults_and_caps() { + let handler = bare_command_handler(); + + let mut timeout_one = handler.as_object().unwrap().clone(); + timeout_one.insert("timeout".to_string(), json!(1)); + let timeout_one = Value::Object(timeout_one); + + let interrupt_default = hash_command_handler("Interrupt", None, &handler).unwrap(); + let session_end_default = hash_command_handler("SessionEnd", None, &handler).unwrap(); + assert_eq!( + interrupt_default, + hash_command_handler("Interrupt", None, &timeout_one).unwrap() + ); + assert_eq!( + session_end_default, + hash_command_handler("SessionEnd", None, &timeout_one).unwrap() + ); + + let mut timeout_six_hundred = handler.as_object().unwrap().clone(); + timeout_six_hundred.insert("timeout".to_string(), json!(600)); + let timeout_six_hundred = Value::Object(timeout_six_hundred); + let mut capped_timeout = handler.as_object().unwrap().clone(); + capped_timeout.insert("timeout".to_string(), json!(3)); + let capped_timeout = Value::Object(capped_timeout); + assert_eq!( + hash_command_handler("Interrupt", None, &timeout_six_hundred).unwrap(), + hash_command_handler("Interrupt", None, &capped_timeout).unwrap() + ); + } + #[test] fn trust_readiness_is_untrusted_when_no_user_config_exists() { let dir = temp_dir("no-config");