diff --git a/.codex/hooks.json b/.codex/hooks.json index 70543a090..8f94ea06c 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,15 @@ } ], "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" + } + ], + "matcher": "^(Bash|apply_patch)$" } ], "PreToolUse": [ @@ -20,6 +39,25 @@ } ], "matcher": "Bash" + }, + { + "hooks": [ + { + "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": [ + { + "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 +68,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/.codex/hooks/run-sce-or-show-install-guidance.sh b/.codex/hooks/run-sce-or-show-install-guidance.sh index a01dc8979..48f5d214f 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/cli_schema.rs b/cli/src/cli_schema.rs index 4507f1332..b5ef9c4e5 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/codex_hook_config.rs b/cli/src/services/codex_hook_config.rs index 9e5df7bf8..9f434ccd6 100644 --- a/cli/src/services/codex_hook_config.rs +++ b/cli/src/services/codex_hook_config.rs @@ -12,25 +12,70 @@ 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")), + +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, + 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", + 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), + (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 +113,7 @@ pub(crate) fn merge_or_create( #[derive(Clone)] struct Registration { + command: CodexHookCommand, event: &'static str, matcher: Option<&'static str>, group: Value, @@ -97,6 +143,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 +207,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 +226,7 @@ fn diagnose_registration( registration: &Registration, ) -> RegistrationDiagnosis { let missing = || RegistrationDiagnosis { + command: registration.command, event: registration.event, matcher: registration.matcher, state: RegistrationStructuralState::Missing, @@ -205,7 +250,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 +265,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 +278,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 +286,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 +330,8 @@ struct CodexHookEvents { subagent_stop: Vec, #[serde(rename = "Stop", default)] stop: Vec, + #[serde(rename = "Interrupt", default)] + interrupt: Vec, } #[derive(Debug, Default, Deserialize)] @@ -315,54 +360,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 +448,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 +646,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 +671,7 @@ fn merge_document( Value::Array(merge_event_groups( existing_groups, registration.matcher, + registration.command, ®istration.handler, ®istration.group, )), @@ -630,51 +682,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 +735,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 +751,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 +763,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 +785,36 @@ 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| { - let tokens: Vec<&str> = segment.split_whitespace().collect(); +fn command_owning_contract(command: &str) -> Option { + command.split(';').find_map(|segment| { + 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")); - 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 +828,83 @@ 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"; + 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}) + } + + fn mutation_scope_handler() -> Value { + 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": { - "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()]}, + {"matcher": CODEX_MUTATION_SCOPE_TOOL_MATCHER, "hooks": [mutation_scope_pre_tool_use_handler()]} + ], + "PostToolUse": [ + {"matcher": "apply_patch", "hooks": [codex_handler()]}, + {"matcher": CODEX_MUTATION_SCOPE_TOOL_MATCHER, "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"); + 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(), + json!([{"hooks": [mutation_scope_handler()]}]), + ); + } + doc + } + #[test] fn accepts_upstream_defaulted_groups_and_events() { let existing = json!({ @@ -832,13 +944,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_eq!( + post_tool_use[2]["matcher"], + CODEX_MUTATION_SCOPE_TOOL_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 +1132,48 @@ 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) + ); + 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_is_current_sce_contract(command), + command_owning_contract(command).is_none(), "claimed ownership for {command}" ); } @@ -1051,6 +1197,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 +1246,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 +1270,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 +1359,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 +1556,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 +1621,275 @@ 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"] + .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"], + "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_eq!( + pre_tool_use[1]["matcher"], + CODEX_MUTATION_SCOPE_TOOL_MATCHER + ); + assert_eq!( + pre_tool_use[1]["hooks"][0]["command"], + MUTATION_SCOPE_PRE_TOOL_USE_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 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"); + } + + #[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/codex_hook_trust.rs b/cli/src/services/codex_hook_trust.rs index 2e35627a8..9153bc3d5 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"); diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index 92bb015e8..cff63c15c 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/bash_policy.rs b/cli/src/services/hooks/codex/bash_policy.rs index e282f9246..f668fd218 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 9b36fab8c..7947de198 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/boundary_lock.rs b/cli/src/services/hooks/codex_mutation_scope/boundary_lock.rs new file mode 100644 index 000000000..eb41286d3 --- /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/fixtures/NOTES.md b/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md new file mode 100644 index 000000000..d7ec26aaf --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/fixtures/NOTES.md @@ -0,0 +1,525 @@ +# 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 `*.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 + +- **`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 (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 | +| 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`) + +``` +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:** **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 + 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. **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 + 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:** **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). + - `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`. + +--- + +# 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. + +## 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/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 000000000..dc24b9f88 --- /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 000000000..885a78c3f --- /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 000000000..7eac41549 --- /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 000000000..bb3293710 --- /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 000000000..62b78dc36 --- /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 000000000..cf5a36972 --- /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/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 000000000..1b41742ad --- /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 000000000..d5da73e0e --- /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 000000000..4d349115e --- /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 000000000..896afb5f5 --- /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 000000000..cc23b70fe --- /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 000000000..cd251bd1d --- /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 000000000..2035b7d58 --- /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 000000000..9ddf37e86 --- /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 000000000..62efa8653 --- /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 000000000..7d840ef9f --- /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 000000000..51cfaa400 --- /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 000000000..15a6c5579 --- /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 000000000..c0c5dc421 --- /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 000000000..04738b6f8 --- /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 000000000..ca6c0a1ef --- /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 000000000..4e477b278 --- /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 000000000..7a387532c --- /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 000000000..9bd82fdfc --- /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 000000000..9afbb7a2e --- /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 000000000..ecdb6c534 --- /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 000000000..e2f3d5c1d --- /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 000000000..df68cab83 --- /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 000000000..6dc3ae66f --- /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 000000000..6d485f3a2 --- /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 000000000..ea32916a6 --- /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 000000000..6c6dee379 --- /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 000000000..6d28dd408 --- /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 000000000..1fd88513d --- /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 000000000..2d86d3dff --- /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 000000000..5add8c6bb --- /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 000000000..963043e0d --- /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 000000000..57070f674 --- /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 000000000..cbf6ac3bf --- /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 000000000..085caa20b --- /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 000000000..90a75d643 --- /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 000000000..b00248805 --- /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/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 000000000..828e894c8 --- /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 000000000..f53198d1d --- /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 000000000..7740312a7 --- /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 000000000..fa6911b42 --- /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 000000000..c3703188b --- /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 000000000..cf9c65f73 --- /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 000000000..b10ea16db --- /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 000000000..f879bfd91 --- /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 000000000..6b54c33c8 --- /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 000000000..40c57f6fa --- /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 000000000..05740de84 --- /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 000000000..d02c03f3a --- /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 000000000..15ea806e0 --- /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 000000000..6f5e21540 --- /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 000000000..8e0720e00 --- /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 000000000..5b0386c9b --- /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 000000000..a4a6b86b7 --- /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 000000000..e0fbd8e85 --- /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 000000000..cb4815e4a --- /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 000000000..f0197ac31 --- /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 000000000..d5663956b --- /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 000000000..7af943f02 --- /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 000000000..a85d4f584 --- /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 000000000..bd7e39782 --- /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 000000000..c01836125 --- /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 000000000..ee48612a3 --- /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/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs new file mode 100644 index 000000000..a80ac3881 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -0,0 +1,5161 @@ +#![allow(dead_code)] + +mod boundary_lock; +mod os_lock; +pub(crate) mod state; + +use std::path::{Path, PathBuf}; + +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}; + +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 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"; +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, + pub tool_input: 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)?, + tool_input: object.get(TOOL_INPUT_FIELD).cloned(), + }) +} + +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}.") +} + +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 = + "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) + }; + let bash_policy_fn = |repository_root: &Path, command: &str| { + evaluate_codex_bash_policy(repository_root, command) + }; + + 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_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, + ) + }; + let bash_policy_fn = |repository_root: &Path, command: &str| { + evaluate_codex_bash_policy(repository_root, command) + }; + + 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, evaluate_bash_policy) +} + +fn dispatch_codex_hook_event( + event: CodexHookEvent, + 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( + &execution, + logger, + resolve_git_dir, + seam, + evaluate_bash_policy, + )), + 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, || { + 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(); + 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(); + with_boundary_lock(&git_dir, || { + 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(); + 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(); + 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>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, +) -> String { + let identity = &execution.identity; + + if !matches!( + classify_tool(&identity.tool_name), + ToolClassification::TrackedMutation + ) { + return String::new(); + } + + 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) => { + log_pre_tool_use_fail_closed(logger, "resolve_git_dir", &error); + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + }; + + 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, + )? { + Admission::Admitted(allocated) => { + establish_start(&git_dir, repository_root, &allocated, logger, seam)?; + Ok(PreToolUseOutcome::Continue) + } + Admission::Denied => Ok(PreToolUseOutcome::Deny), + } + }); + + 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, "codex_mutation_scope_pre_tool_use", &error); + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) + } + } +} + +enum PreToolUseOutcome { + Continue, + 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, +} + +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, turn_id, tool_name)? { + state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + 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, turn_id, 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) + } + } + } + } +} + +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 + | state::AdmitDecision::StalePredecessorBlocked => Ok(Admission::Denied), + } +} + +fn establish_start( + git_dir: &Path, + repository_root: &Path, + allocated: &state::AllocatedAttempt, + logger: Option<&dyn Logger>, + 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)); + + 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::arm_recovery(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::*; + + 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()), + ); + object.insert(TOOL_INPUT_FIELD.to_string(), json!({"command": "true"})); + 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() + ); + } + + mod driver { + use std::cell::RefCell; + use std::path::{Path, PathBuf}; + 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}; + + 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}"); + } + + 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 { + 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 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>>, + } + + 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") + } + + 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, + &AttemptKey { + session_id: session_id.to_string(), + agent_id: agent_id.map(str::to_string), + tool_use_id: tool_use_id.to_string(), + }, + turn_id, + "Bash", + phase, + ) + } + + 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 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("mcp__probe__mutate_success".to_string()), + )]); + let output = drive(&payload, &panicking_resolver, &unreachable_seam); + assert_eq!(output, ""); + } + + #[test] + 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()))]); + assert_eq!(drive(&payload, &panicking_resolver, &unreachable_seam), ""); + } + } + + #[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()))]); + drive(&payload, &resolver, &ok_seam); + } + + assert!(read_state(&git_dir).attempts.is_empty()); + assert!(read_state(&git_dir).recovery.is_clear()); + + 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 driven while attempt is PendingStart" + ); + Ok(String::new()) + }; + + let output = drive(&pre_tool_use_json(&[]), &resolver, &seam); + assert_eq!(output, ""); + + 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_test_e() { + let git_dir = unique_test_git_dir("duplicate-pre"); + let resolver = fixed_resolver(git_dir.clone()); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + + let attempts = read_state(&git_dir).attempts; + assert_eq!( + attempts.len(), + 1, + "AC4/Test E: a replay must not fork a new attempt" + ); + assert_eq!(attempts[0].scope_id, scope_id); + + 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")); + assert!(!output.contains("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_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(); + 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()); + + 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()), + ), + (TURN_ID_FIELD, Value::String("turn-2".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 in another lane must block a successor Start", + ); + + 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()))]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("non-tracked PreToolUse should succeed"), + "", + ); + } + } + + #[test] + fn successful_close_removes_the_attempt_ac8() { + let git_dir = unique_test_git_dir("close-success"); + let resolver = fixed_resolver(git_dir.clone()); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + + 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()) + }; + assert_eq!(drive(&post_tool_use_json(&[]), &resolver, &seam), ""); + + 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()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-1", + state::AttemptPhase::PendingStart, + ); + + 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()) + }; + 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.is_clear()); + + 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()); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + + let seam = seam_failing_on("close"); + drive(&post_tool_use_json(&[]), &resolver, &seam); + + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!( + !final_state.recovery.is_clear(), + "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()); + + 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( + &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); + assert!(!final_state.recovery.is_clear()); + + 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()); + + assert_eq!( + drive( + &post_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Bash".to_string()),)]), + &resolver, + &unreachable_seam, + ), + "", + ); + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[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", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-1"), + "exec-agent", + state::AttemptPhase::Active, + ); + + drive( + &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), + &resolver, + &ok_seam, + ); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + 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", + 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, + ); + + drive( + &turn_scoped_payload(HOOK_EVENT_INTERRUPT, "session-1", "turn-1"), + &resolver, + &ok_seam, + ); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + 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", + 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, + ); + + drive( + &subagent_stop_payload("session-1", "turn-1", "agent-a"), + &resolver, + &ok_seam, + ); + + 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", + 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()); + + 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", + state::AttemptPhase::Active, + ); + + 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.is_clear()); + + 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_in_turn( + &git_dir, + "session-1", + "turn-other", + 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()))]), + 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_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::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); + } + + #[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()); + state::arm_recovery(&git_dir).expect("arming the barrier 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 = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + &resolver, + &seam, + ); + 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.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); + } + + #[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 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( + &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_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); + } + + #[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())), + ]); + drive(&mcp_pre, &resolver, &unreachable_seam); + assert!(read_state(&git_dir).attempts.is_empty()); + + drive( + &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), + &resolver, + &ok_seam, + ); + 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.is_clear(), + "AC9c: no Start => no abandon => recovery stays clear" + ); + + 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())), + ]); + + drive(&mcp_a, &resolver, &unreachable_seam); + drive(&bash_b, &resolver, &ok_seam); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + 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())), + ]); + 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.is_clear()); + + 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()))]); + + 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); + 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")); + } + + 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<()>) { + 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())), + (TURN_ID_FIELD, Value::String(turn_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_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, + "session-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (abandon_seam, gate) = gated_seam("abandon", Arc::clone(&recorded)); + + let sweeper = { + 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 }, + "cleanup arms recovery while it owns the boundary lock", + ); + + 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 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!( + abandon_at < flush_at && flush_at < start_at, + "Test H: the serialized order must be abandon -> flush -> start, got {recorded:?}", + ); + + 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_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 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 resolver = fixed_resolver(git_dir.clone()); + 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_count.fetch_add(1, Ordering::SeqCst); + } + owner_gated(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(); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Flushing { generation: 1 }, + ); + + let flush_count_p2 = Arc::clone(&flush_count); + let (p2_handle, p2_done) = + 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); + } + 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"), + "seed-turn", + "Bash", + ) + .expect("seeding admit should not error") + { + state::AdmitDecision::FlushClaimed { + generation: claimed, + } => { + assert_eq!(claimed, generation); + } + 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, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + 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!( + 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; + + 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())), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(second, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + ops.iter() + .filter(|p| p.contains(r#""operation":"flush""#)) + .count(), + 1, + "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", + ); + + 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"); + 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())), + (TURN_ID_FIELD, Value::String("turn-2".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 in another lane 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); + } + + 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); + } + + 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); + } + } + + 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/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 000000000..dc567c1b7 --- /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 new file mode 100644 index 000000000..7e0162f7f --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/state.rs @@ -0,0 +1,1463 @@ +use std::fs::OpenOptions; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +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 DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(10); + +const ADAPTER_STATE_VERSION: u32 = 3; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AttemptPhase { + PendingStart, + 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, + 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, + 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 + } + + 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 { + 1 +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub(crate) struct AdapterState { + pub version: u32, + pub next_attempt_seq: u64, + #[serde(default = "default_recovery_generation")] + pub next_recovery_generation: u64, + #[serde(default)] + pub recovery: RecoveryState, + pub attempts: Vec, +} + +impl Default for AdapterState { + fn default() -> Self { + AdapterState { + version: ADAPTER_STATE_VERSION, + next_attempt_seq: 1, + next_recovery_generation: default_recovery_generation(), + recovery: RecoveryState::Clear, + attempts: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AllocatedAttempt { + pub attempt: AdapterAttempt, + pub reused: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AdmitDecision { + Admitted(AllocatedAttempt), + RecoveryBlocked, + UncertainAttemptBlocked, + StalePredecessorBlocked, + FlushClaimed { generation: u64 }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RecoveryFlushCompletion { + Cleared, + Superseded, +} + +pub(crate) fn adapter_state_dir(git_dir: &Path) -> PathBuf { + git_dir.join(SCE_STATE_DIR) +} + +fn state_path(git_dir: &Path) -> PathBuf { + adapter_state_dir(git_dir).join(ADAPTER_STATE_FILE) +} + +fn lock_path(git_dir: &Path) -> PathBuf { + adapter_state_dir(git_dir).join(ADAPTER_STATE_LOCK_FILE) +} + +struct AdapterStateLock { + _inner: OsAdvisoryLock, +} + +impl AdapterStateLock { + 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 }) + } +} + +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 = adapter_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(()) +} + +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, + turn_id: &str, + tool_name: &str, +) -> 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(), + 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(), + 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, + turn_id: &str, + 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(AdmitDecision::Admitted(AllocatedAttempt { + attempt: existing.clone(), + reused: true, + })); + } + + 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() + .any(|attempt| attempt.phase == AttemptPhase::PendingStart) + { + return Ok(AdmitDecision::UncertainAttemptBlocked); + } + + let attempt = allocate_pending_start(&mut state, key, turn_id, tool_name); + write_state_durably(git_dir, &state)?; + Ok(AdmitDecision::Admitted(AllocatedAttempt { + attempt, + reused: false, + })) +} + +pub(crate) fn mark_active(git_dir: &Path, scope_id: &str) -> Result<()> { + #[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 + .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 = acquire_lock(git_dir)?; + + 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 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)?; + + 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 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)?; + + 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, + 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, turn_id, 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)] +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(), + } + } + + const TEST_TURN: &str = "turn-1"; + + fn admit(git_dir: &Path, key: &AttemptKey, tool_name: &str) -> AdmitDecision { + 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 { + 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"); + 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, 3); + assert!(state.recovery.is_clear()); + assert_eq!(state.next_recovery_generation, 1); + + remove_test_git_dir(&git_dir); + } + + #[test] + 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 = 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", + ); + + 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); + assert_eq!(state.attempts.len(), 3); + + remove_test_git_dir(&git_dir); + } + + #[test] + 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"); + + 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_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" + ); + + 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_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 AdmitDecision::Admitted(first) = admit(&git_dir, &attempt_key, "Bash") else { + panic!("first admission should be Admitted"); + }; + assert!(!first.reused); + + 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!( + 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_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_in_turn( + &git_dir, + &key("session-1", None, "exec-a"), + "turn-1", + "Bash", + ) else { + panic!("first admission should be Admitted"); + }; + + assert_eq!( + 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" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + 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_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"); + + 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!("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!( + 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"), + "turn-1", + "Bash", + AttemptPhase::Active, + ); + arm_recovery(&git_dir).expect("arming recovery should succeed"); + + assert_eq!( + 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 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 generation = arm_recovery(&git_dir).expect("arming recovery should succeed"); + + 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 }, + ); + + 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}")), + &format!("turn-{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 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"); + 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 }, + ); + + 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 }, + ); + + 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); + } + + #[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 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"); + assert_eq!(state.attempts[0].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")); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + 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 attempt = admit_and_activate(&git_dir, &key("session-1", None, "exec-1"), "Bash"); + + 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"); + + assert!(read_state(&git_dir) + .expect("state readable") + .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 attempt = admit_and_activate( + &git_dir, + &key("session-7", Some("agent-2"), "exec-9"), + "apply_patch", + ); + 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_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); + + 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 = 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"); + + 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 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": [] + }), + 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); + 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); + } + } + + #[test] + fn interruption_before_rename_leaves_the_canonical_path_unaffected() { + let git_dir = unique_test_git_dir("interrupted-before-rename"); + let dir = adapter_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()); + assert!(!canonical_path.exists()); + Err(anyhow!("injected interruption before rename")) + }); + + assert!(result.is_err()); + assert!(!state_path(&git_dir).exists()); + assert_eq!( + read_state(&git_dir).expect("read should not error on an absent canonical file"), + AdapterState::default(), + ); + + 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 = 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"); + + 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" + ); + + remove_test_git_dir(&git_dir); + } + + const PARALLEL_ADMISSION_COUNT: u64 = 6; + + #[test] + 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_ADMISSION_COUNT) + .map(|index| { + 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, &turn_id, "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 mut attempt_seqs: Vec = handles + .into_iter() + .map(|handle| handle.join().expect("thread should not panic")) + .collect(); + attempt_seqs.sort_unstable(); + attempt_seqs.dedup(); + assert_eq!( + attempt_seqs.len(), + 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 admits"); + assert_eq!( + state.attempts.len(), + usize::try_from(PARALLEL_ADMISSION_COUNT).unwrap() + ); + 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); + } + + #[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_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 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 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 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)) + .expect("the state lock must be free once every helper has returned"), + ); + + 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"); + + 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)); + 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)); + if entry.path() == state_path(&git_dir) { + found_state_file = true; + } + } + assert!(found_state_file); + + remove_test_git_dir(&git_dir); + } +} diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 949718d0e..5c2de6382 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -44,6 +44,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; @@ -105,6 +106,7 @@ pub enum HookSubcommand { ClaudeModelState, MutationScope, ClaudeMutationScope, + CodexMutationScope, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -254,6 +256,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) + } } } @@ -2003,6 +2008,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/mutation_trace/mbt/driver.rs b/cli/src/services/mutation_trace/mbt/driver.rs index 8fe3c2d45..3b967d4a9 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 1d145e758..6cc48a335 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 3deff5a48..dcfc93f19 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 0bddc0c40..c94227f06 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 e5f2a9604..7e6816cd7 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 0b958de0a..6165df15d 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 d193f99ef..c55e281f6 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 aa246e367..f000ad581 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/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 92becb5d0..1916a2c3b 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/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index c08effaf6..583a721ea 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 48661b3df..cb4a14984 100644 --- a/config/pkl/renderers/codex-content.pkl +++ b/config/pkl/renderers/codex-content.pkl @@ -55,15 +55,16 @@ 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" + +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 = """ @@ -87,6 +88,14 @@ hooksJson = new common.RenderedTextFile { "command": "\(codexSceHookCommand)" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopeHookCommand)" + } + ] } ], "PreToolUse": [ @@ -98,6 +107,15 @@ hooksJson = new common.RenderedTextFile { "command": "\(codexSceHookCommand)" } ] + }, + { + "matcher": "\(codexMutationScopeToolMatcher)", + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopePreToolUseCommand)" + } + ] } ], "PostToolUse": [ @@ -109,6 +127,45 @@ hooksJson = new common.RenderedTextFile { "command": "\(codexSceHookCommand)" } ] + }, + { + "matcher": "\(codexMutationScopeToolMatcher)", + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopeHookCommand)" + } + ] + } + ], + "Interrupt": [ + { + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopeHookCommand)" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopeHookCommand)" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "\(codexMutationScopeHookCommand)" + } + ] } ] } @@ -122,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 0764c6631..61469d27c 100644 --- a/config/pkl/renderers/generation-contract-check.pkl +++ b/config/pkl/renderers/generation-contract-check.pkl @@ -64,19 +64,40 @@ 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("\"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") && 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, 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 @@ -1244,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/architecture.md b/context/architecture.md index cae099205..758dedfe6 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. @@ -49,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` 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`: 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`). @@ -126,10 +136,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, 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 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 the direct-only intersection metadata to `post_commit_patch_intersections`, then — for committed touched lines that direct intersection did not cover — resolves bounded, read-only mutation-history AI coverage scoped to the invoking worktree's existing identity (newest 128 events replayed oldest-to-newest as one causal tree-transition provenance lineage bounded by a worktree-lock-captured commit attribution cut — `revision <= latest_mutation_event_revision` read under the worktree lock — with a committed line attributed AI only if an AI event's line survives every later transition into the committed tree; no provenance, no `diff_traces` or mutation-cursor write) and passes direct and mutation-AI evidence separately into the Agent Trace builder, and 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 setup-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child when its resolved value is true (a newly created setup config supplies the explicit `true`; omitted configuration resolves to `false`), 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 > bridge-derived chain 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. Only for a raw structured payload in the exact main-session scope, a state miss then resolves the newest `claude_model_state` observation across the transcript's bridge-linked chain members (bounded leading-record reads, one exact-scope read per member, winner by `observed_at_ms`) and, on a hit, writes a `claude_model_state` row for the current session (`source="bridge_inherited"`) before using that model — the only diff-trace resolution path that writes state. 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/codex-mutation-scope-integration.md b/context/cli/codex-mutation-scope-integration.md new file mode 100644 index 000000000..c1e0e7404 --- /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; 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 +`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 092447c05..e95526760 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 @@ -218,18 +217,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, @@ -237,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 4624d2f79..5c907ea65 100644 --- a/context/cli/mutation-scope-runtime.md +++ b/context/cli/mutation-scope-runtime.md @@ -1,27 +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); -Codex/OpenCode/Pi: none yet — see Status) both drive this seam. This file is the -contract every harness adapter is written against, not shipped-adapter behavior. - -The mechanics behind each entrypoint live in their own domain files: -[`mutation-trace-runtime-coordinator.md`](mutation-trace-runtime-coordinator.md) -(`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 @@ -41,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` @@ -231,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 @@ -254,6 +238,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 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 5d4a3cb28..4e8a747d9 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 c2db5be12..b6b9b7360 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 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) - `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) @@ -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 0cb546518..168c13388 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, 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 d905f7723..1ee9e3566 100644 --- a/context/overview.md +++ b/context/overview.md @@ -1,8 +1,20 @@ # 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. +## Mutation-scope harness coverage -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`. +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 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 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. @@ -112,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` 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 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 new file mode 100644 index 000000000..499dca050 --- /dev/null +++ b/context/plans/codex-mutation-scope-integration.md @@ -0,0 +1,3951 @@ +# 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 *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 +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 +`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. + +**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. 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 + +- **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 *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, 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 +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, 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.*` + +`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.** +- **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). +- **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/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 + +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. + +**T01 disposition (codex-cli 0.153.4): PROVEN.** Execution key = +`(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 + 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 +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`. + +**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 +``` + +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 +`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 +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 `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 + +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. 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": +"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. + +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 +`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. 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/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`, +`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 +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. + +**T01 disposition (codex-cli 0.153.4): PROVEN.** `PostToolUse` is the reliable +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/probe12-mcp-mutate-success.*`. + +### 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. + +**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 `TrackedMutation` tool with no terminal hook is followed by **another +`TrackedMutation` 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. + +**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` — 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 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-*`. + +**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 +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 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.*`, +`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 + +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 **for a tracked tool**; for +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.** + +| 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`. + +**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). 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 (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 + -> clear recovery_pending ONLY on durable flush success + -> a failed flush stays fail-closed +``` + +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 +its write-ahead `Start` while a known-stale predecessor **tracked** attempt +(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 +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). **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 + +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. The adapter never collapses two +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. **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 +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 +``` + +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, 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 + +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. + +**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 +"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. + +**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 +`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. + +**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. + +**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 +`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. + +**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 — +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. + +**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`). + +### 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 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):** + +- **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 +check that proves it. `/validate` runs these checks; no task in the stack +performs final validation. + +- [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`, + **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. +- [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____` + 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. +- [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 + 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. +- [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 + 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. +- [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 + 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. +- [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. +- [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**, + **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 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. +- [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. +- [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. +- [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. +- [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 + `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. +- [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. +- [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). + - **`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. +- [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. + 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`). +- [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 + (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. +- [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) + executions never enter `attempts[]` and are never swept. + - 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. +- [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 + `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. +- [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). +- [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. +- [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 + 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. +- [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`. +- [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, + 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. +- [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. +- [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. +- [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`, + `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. +- [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}`, + `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`. +- [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. +- [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`; + (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. +- [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 + 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/`. +- [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- + 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, **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) — + each stated as Codex-proven, Codex-documented, or Codex-unsupported, with the + 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. +- [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 + 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 + +- `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 -- 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. + +### Context sync + +- New: `context/cli/codex-mutation-scope-integration.md` (owns the Codex adapter + 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 + 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. + **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 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 + 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; **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 + +- 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 + +- [x] T01: `Freeze the real Codex hook and lifecycle contract` (status:done) + - Task ID: T01 + - 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 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; 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`) — + 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). 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 + **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** (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; 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/ + 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, + 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). **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 + 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. **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: 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`. + +- [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, + 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/ + 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 + **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). 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 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. + - 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. + +- [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 + `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`. + - 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). + +- [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, + `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 — + **`TrackedMutation`** `PreToolUse` -> D7 write-ahead `Start` + D8 fail-closed + Codex-native block on any failure + D16 background-execution deny if + 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 + 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 `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 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 + *(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 + 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 *(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: + 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 *(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): + - 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. + **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 + 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 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`, + `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`) is + authored by **T07** once the full adapter ships, exactly as recorded for + T01/T02/T03. + - Context synchronization: synced + - T04 wires an internal Rust adapter driver plus one hidden, **unregistered** + CLI route (`sce hooks codex-mutation-scope`). No `sce setup` registration + (T05), no `.codex/hooks.json` generation (T05), and no real Git/DB path + (T06) — so no real Codex session can reach the adapter yet. Consistent with + the plan's "Context sync" section and the T01/T02/T03 precedent, the durable + Codex-adapter context (`context/cli/codex-mutation-scope-integration.md`) + and the root/domain cross-reference edits (`context/overview.md`, + `context/architecture.md` line ~135, + `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`) are + authored by **T07** once the adapter is registered and proven. Mandatory + five-root pass done: `overview.md` (its "Codex, OpenCode, and Pi still have + no adapter" sentence stays accurate — the driver is inert until T05), + `architecture.md` (its `hooks/mod.rs` line-135 enumeration will name the + new arm at T07, matching how the `mutation-scope` / `claude-mutation-scope` + arms were documented at their integration task), `glossary.md`, + `patterns.md`, `context-map.md` all read and confirmed not contradicted by + an inert, unreachable adapter. No architecture decision qualified for an + ADR — T04 is a new caller of the existing + `hooks::mutation_scope::run_mutation_scope_from_payload` seam under the + already-recorded D17 decision (separate hidden command, decided at T02), + 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-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 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 + - 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 + findings), routed to the D17 command, leaving the four existing + `sce hooks codex` registrations byte-for-byte unchanged **and positionally + stable** (D20 — appended after, never prepended). + (b) `cli/src/services/codex_hook_config.rs`: extend `REQUIRED_EVENTS` and + the ownership predicate to be **command-aware** (recognize both the existing + `["sce","hooks","codex"]` contract and the new + `["sce","hooks","codex-mutation-scope"]` contract), so the merge preserves + every existing SCE-owned and user-owned handler, replaces only the matching + command's stale/duplicate handlers, and stays idempotent; the merge inserts + new SCE-owned handlers/groups **additively after** existing ones so an + already-trusted registration keeps its + `(event, matcher, matcher-group index, handler index, handler + contents/hash)` identity and its computed Codex trust key (D20). If some + event/matcher structure genuinely cannot preserve position, the code records + which and doctor surfaces that re-trust is needed — trust is never silently + invalidated. + (c) Extend `codex_hook_config::hook_event_key_label` for **every** newly + SCE-owned mutation-scope event with the exact upstream Codex key label + (verified against `openai/codex` source, cited in a comment / `NOTES.md`, + never lowercased), with a dedicated test per new event (D22). + (d) Extend the doctor Codex-hook diagnosis so each mutation-scope + registration gets the full **three-dimension** health model (D21): structural + (`PresentAndCurrent` / `Missing` / `Stale` / `Malformed`), normal Codex trust + (`Trusted` / `Untrusted` / `Modified` / `Disabled`), and effective + project-hook policy (`ProjectHooksAllowed` / `PolicyBlocked` / + `PolicyUnknown`) reusing the single per-invocation `configRequirements/read` + probe; healthy requires all three; `PresentAndCurrent` + any of + `Untrusted` / `Modified` / `Disabled` / `PolicyBlocked` / `PolicyUnknown` is + never healthy; mutation-scope registrations are reported with readiness + distinct from the `sce hooks codex` registrations, not flattened into one + `.codex/hooks.json` status. `sce doctor --fix` repairs only SCE-owned + `.codex/hooks.json` structure and **never** writes `$CODEX_HOME/config.toml`, + grants trust, or changes managed policy. + Out — any adapter behavior change; any non-Codex renderer; any change to the + trust/policy probe mechanism itself (`codex_hook_policy.rs` — reused as-is). + - Dependencies: T04 + - Done when: `nix run .#pkl-check-generated` passes with the new registrations + (its exact file-count artifact updated if the count legitimately changes); + `codex_hook_config.rs` tests prove AC16, AC16a (the canonical-four -> + 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::`. + - 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 *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 + 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. + - 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**. + +- [x] T06: `Real Git/DB regressions through the production Codex path` (status:done) + - Task ID: T06 + - **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 + 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; + 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 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. subagent tracked tool -> independent scope identity (its `agent_id`); + 8. linked worktree -> correct `WorktreeId` / cursor, other worktree + unchanged (AC14); + 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 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`, + `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 + 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 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 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, + 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 + 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. + - 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 + +- [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** + 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, **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 + 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`, + `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; 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 + 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; 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; 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`. + - 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 + - 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 + +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. + +**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 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, + 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). 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 (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 + 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 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`: **normal Case A** — a failed shell tool still fires + `PostToolUse`, a failed `apply_patch` writes nothing, and interruption ends the + 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 + 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 + 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. +- **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. +- **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. + +**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. + +## 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 65a80141c..8ebfcf41a 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 @@ -125,9 +126,10 @@ 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. A model-less `SessionStart` first attempts bounded, local-only bridge-session correlation through the event's `transcript_path`: it reads only leading records for the `bridgeSessionId`, then resolves the model through the same single newest-chain-observation selection rule the `diff-trace` state-miss path uses — bounded leading-record reads of sibling `.jsonl` transcripts sharing that `bridgeSessionId`, one exact-scope `(cc_, "")` read per member, winner by greatest `observed_at_ms` with a deterministic session-ID tie-break — and seeds the current session with that model as `source="bridge_inherited"` when a chain observation exists. There is no separate SessionStart selection rule and no most-recently-modified-sibling pick. Missing or malformed discovery inputs, no chain observation, filesystem races, and DB reads fail open to the existing no-op. In practice this `SessionStart` attempt fires before Claude has created the session's transcript file, so it reads nothing and always no-ops; bridge inheritance for a cleared session is actually carried by the `diff-trace` state-miss path above, and this attempt is kept only for the case Claude ever creates transcripts eagerly. The command uses the existing guarded latest-locally-observed register and local SCE observation time. 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, DB-read, and DB-write failures. Claude's SessionStart invocation is synchronous relative to Claude execution, while PostModelSwitch is asynchronous; overlapping hooks and bridge/model-switch races 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 its exact `(cc_, agent_id)` state after direct and transcript attribution fail, then — for a main-session-scope raw structured payload only — the newest observation across the transcript's bridge-linked chain, seeding a `source="bridge_inherited"` row for the current session; this remains Claude-specific and does not restore the generic abstraction. -- `sce hooks codex` is a separate single dispatcher subcommand (not routed through `diff-trace`/`conversation-trace`), classifying raw Codex hook JSON internally instead. Its `UserPromptSubmit` and `Stop` arms are each a second writer into the same `messages`/`parts` tables, calling the shared `insert_conversation_text_event` atomic primitive rather than the plain `insert_messages`/`insert_parts` calls described above (so a replayed or concurrent duplicate delivery leaves exactly one message and one part row, not only the parent message row), with idempotent `cx_` session prefixing and a deterministic `cx::user`/`cx::assistant` message ID in place of a generated UUID. Its `PostToolUse(apply_patch)` arm is a second, independent writer into `diff_traces` via the existing `insert_diff_trace`, carrying `tool_name = "codex"` and the same `cx_`-prefixed `session_id` — no new adapter. Apply_patch persistence requires a trimmed non-empty session, preserves reported model IDs without fabricating a provider prefix, and returns empty stdout on every non-policy success or fail-open path. Before persistence it resolves source and move-destination paths independently from the event `cwd` against the canonical Git root: valid `..` components and absolute-inside paths are accepted, missing Add File targets are allowed through their nearest existing prefix, and outside or symlink-escaping mappings are rejected. The generated Codex command itself resolves that Git root at invocation time and invokes the installed helper with quoted paths, so root and nested cwd (including spaced repository paths) share one entrypoint; root-resolution failure is silent and fail-open. The Codex setup/doctor boundary is separate from evidence intake: `.codex/hooks.json` is merged through the shared structural `codex_hook_config` service, which preserves valid user handlers and recognizes only the generated helper path plus the `sce hooks codex` command contract. See [codex-integration-runtime.md](codex-integration-runtime.md) for the full dispatcher and per-arm contract. -- `sce hooks mutation-scope` (hidden) is a separate single ingress (not routed through `diff-trace`/`conversation-trace`) that drives the mutation-scope runtime rather than writing conversation/diff evidence. It reads one normalized JSON lifecycle object from STDIN via the shared `read_hook_stdin`, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, and translates it into one `RuntimeBoundary` (`start`/`advance`/`close` → `Start`/`Advance`/`Close`; `flush` → `Flush`) passed to `mutation_trace::runtime::coordinate(...)`, or a direct `mutation_trace::runtime::abandon_scope(...)` call for `abandon`. `scope_id`/`event_id`/`actor_kind` (`claude_code`/`codex`/`opencode`/`pi`) are forwarded verbatim — no trim, prefix, hash, UUID, or timestamp — because `EventId` equality is the runtime's replay/idempotency key; any `worktree_id` key is a hard rejection (worktree identity is derived by the runtime from the invoking checkout). DB acquisition is lazy: a `FnOnce` provider closure reusing `open_agent_trace_db_for_hook_runtime` is passed into `coordinate()`/`abandon_scope()` so it runs inside the runtime's protected-worktree ordering, never before. **Non-fail-open intake contract, distinct from `diff-trace`/`conversation-trace`:** a lost lifecycle boundary can change which scope stays live and therefore alter attribution, so the dispatch arm is unwrapped like `pre-commit` (no `Ok(...)` fail-open shim) and there is no dropped-boundary / `exit 0` branch. Results are classified by durable completion — a malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` returns `CliError`/non-zero; `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are the two carried-outcome success variants (the durable transition already succeeded and only the trailing external-taint marker cleanup failed), reported as empty-stdout `Ok` with the failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion` and the transition **not** retried. Every successful execution emits empty stdout with no serialized outcome/revision/worktree/scope state. It writes `mutation_trace_*` rows only — never `diff_traces`, `post_commit_patch_intersections`, or `agent_traces`. A first Claude Code adapter driver now consumes it in-process (see below); Codex/OpenCode/Pi still have no lifecycle adapter. See [mutation-scope-hook-ingress.md](../cli/mutation-scope-hook-ingress.md) for the full contract. +- `sce hooks 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). ## 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 000000000..933844464 --- /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 819a097a1..15a833c28 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,41 @@ 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 -`.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). +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`. 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 `.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 `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 the existing file remains untouched. Doctor diagnoses each required registration structurally (present-and-current, missing, or stale, with a @@ -60,7 +77,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 +93,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 @@ -155,109 +172,34 @@ 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 -`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 -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 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 @@ -265,8 +207,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 61b24bd4c..ae7b78672 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/flake.nix b/flake.nix index 93de837bb..a2c8f46ce 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) diff --git a/scripts/test-codex-hook-command.sh b/scripts/test-codex-hook-command.sh index 513a9e4cc..501281fda 100755 --- a/scripts/test-codex-hook-command.sh +++ b/scripts/test-codex-hook-command.sh @@ -22,22 +22,27 @@ 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)) +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].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)) 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 +52,30 @@ case "${hook_command}" in *eval*) fail "Codex hook command uses eval" ;; esac +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 +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 + +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}" @@ -96,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}" @@ -107,4 +138,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' diff --git a/spec/mutation_cursor.md b/spec/mutation_cursor.md index 7def4f11e..096bc1b0a 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 1c4742fde..6a2a5614f 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 =