diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index 9cf65d2c..628ff4c0 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -320,6 +320,9 @@ pub enum HooksSubcommand { #[command(about = "Run Claude model-state hook (reads JSON payload from STDIN)")] ClaudeModelState, + + #[command(about = "Run mutation-scope hook (reads JSON payload from STDIN)")] + MutationScope, } #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index abff0c34..9e4be2fd 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -44,6 +44,7 @@ pub mod claude_transcript; pub mod codex; pub mod command; pub mod lifecycle; +pub mod mutation_scope; pub const NAME: &str = "hooks"; pub const CANONICAL_SCE_COAUTHOR_TRAILER: &str = "Co-authored-by: SCE "; @@ -100,6 +101,7 @@ pub enum HookSubcommand { ConversationTrace, Codex, ClaudeModelState, + MutationScope, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -241,6 +243,9 @@ fn run_hooks_subcommand_in_repo( HookSubcommand::ClaudeModelState => Ok( claude_model_state::run_claude_model_state_subcommand(repository_root, logger), ), + HookSubcommand::MutationScope => { + mutation_scope::run_mutation_scope_subcommand(repository_root, logger) + } } } @@ -1951,6 +1956,7 @@ fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str { HookSubcommand::ConversationTrace => "conversation-trace runtime invocation", HookSubcommand::Codex => "codex runtime invocation", HookSubcommand::ClaudeModelState => "Claude model-state runtime invocation", + HookSubcommand::MutationScope => "mutation-scope runtime invocation", } } diff --git a/cli/src/services/hooks/mutation_scope.rs b/cli/src/services/hooks/mutation_scope.rs new file mode 100644 index 00000000..bbf0feb1 --- /dev/null +++ b/cli/src/services/hooks/mutation_scope.rs @@ -0,0 +1,1300 @@ +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{Map, Value}; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::mutation_trace::runtime::{ + abandon_scope, coordinate, AbandonScopeError, AbandonScopeOutcome, CoordinateError, + CoordinateOutcome, RuntimeBoundary, +}; +use crate::services::mutation_trace::types::{ActorKind, EventId, ScopeId}; +use crate::services::observability::traits::Logger; + +const MUTATION_SCOPE_DB_CONTEXT: &str = "Failed to open Agent Trace DB for mutation-scope runtime."; + +const OPERATION_FIELD: &str = "operation"; +const SCOPE_ID_FIELD: &str = "scope_id"; +const EVENT_ID_FIELD: &str = "event_id"; +const ACTOR_KIND_FIELD: &str = "actor_kind"; +const WORKTREE_ID_FIELD: &str = "worktree_id"; + +const ACTOR_KIND_CLAUDE_CODE: &str = "claude_code"; +const ACTOR_KIND_CODEX: &str = "codex"; +const ACTOR_KIND_OPENCODE: &str = "opencode"; +const ACTOR_KIND_PI: &str = "pi"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum MutationScopePayload { + Start { + scope_id: String, + event_id: String, + actor_kind: ActorKind, + }, + Advance { + scope_id: String, + event_id: String, + actor_kind: ActorKind, + }, + Close { + scope_id: String, + event_id: String, + actor_kind: ActorKind, + }, + Flush, + Abandon { + scope_id: String, + }, +} + +pub(crate) fn parse_mutation_scope_payload(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 operation = required_str(object, OPERATION_FIELD)?; + + match operation.as_str() { + "start" => parse_scope_boundary(object, |scope_id, event_id, actor_kind| { + MutationScopePayload::Start { + scope_id, + event_id, + actor_kind, + } + }), + "advance" => parse_scope_boundary(object, |scope_id, event_id, actor_kind| { + MutationScopePayload::Advance { + scope_id, + event_id, + actor_kind, + } + }), + "close" => parse_scope_boundary(object, |scope_id, event_id, actor_kind| { + MutationScopePayload::Close { + scope_id, + event_id, + actor_kind, + } + }), + "flush" => parse_flush(object), + "abandon" => parse_abandon(object), + other => bail!(validation_error(&format!( + "field 'operation' must be one of 'start', 'advance', 'close', 'flush' or 'abandon', got '{other}'" + ))), + } +} + +fn parse_scope_boundary( + object: &Map, + build: impl FnOnce(String, String, ActorKind) -> MutationScopePayload, +) -> Result { + reject_unexpected_keys( + object, + &[ + OPERATION_FIELD, + SCOPE_ID_FIELD, + EVENT_ID_FIELD, + ACTOR_KIND_FIELD, + ], + )?; + + let scope_id = required_non_blank_str(object, SCOPE_ID_FIELD)?; + let event_id = required_non_blank_str(object, EVENT_ID_FIELD)?; + let actor_kind = parse_actor_kind(&required_str(object, ACTOR_KIND_FIELD)?)?; + + Ok(build(scope_id, event_id, actor_kind)) +} + +fn parse_flush(object: &Map) -> Result { + reject_unexpected_keys(object, &[OPERATION_FIELD])?; + Ok(MutationScopePayload::Flush) +} + +fn parse_abandon(object: &Map) -> Result { + reject_unexpected_keys(object, &[OPERATION_FIELD, SCOPE_ID_FIELD])?; + let scope_id = required_non_blank_str(object, SCOPE_ID_FIELD)?; + Ok(MutationScopePayload::Abandon { scope_id }) +} + +fn parse_actor_kind(wire: &str) -> Result { + match wire { + ACTOR_KIND_CLAUDE_CODE => Ok(ActorKind::ClaudeCode), + ACTOR_KIND_CODEX => Ok(ActorKind::Codex), + ACTOR_KIND_OPENCODE => Ok(ActorKind::OpenCode), + ACTOR_KIND_PI => Ok(ActorKind::Pi), + other => bail!(validation_error(&format!( + "field 'actor_kind' must be one of 'claude_code', 'codex', 'opencode' or 'pi', got '{other}'" + ))), + } +} + +fn reject_unexpected_keys(object: &Map, allowed: &[&str]) -> Result<()> { + for key in object.keys() { + if key == WORKTREE_ID_FIELD { + bail!(validation_error( + "field 'worktree_id' is not accepted; worktree identity is derived from the invoking checkout" + )); + } + if !allowed.contains(&key.as_str()) { + bail!(validation_error(&format!("unexpected field '{key}'"))); + } + } + Ok(()) +} + +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 validation_error(detail: &str) -> String { + format!("Invalid mutation-scope payload from STDIN: {detail}.") +} + +pub(crate) fn run_mutation_scope_subcommand( + repository_root: &Path, + logger: Option<&dyn Logger>, +) -> Result { + let stdin_payload = super::read_hook_stdin()?; + run_mutation_scope_from_payload(repository_root, &stdin_payload, logger) +} + +fn run_mutation_scope_from_payload( + repository_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + run_mutation_scope_from_payload_with( + repository_root, + stdin_payload, + logger, + super::open_agent_trace_db_for_hook_runtime, + ) +} + +#[cfg(test)] +pub(super) fn run_mutation_scope_from_payload_at_state_root( + repository_root: &Path, + state_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + run_mutation_scope_from_payload_with( + repository_root, + stdin_payload, + logger, + |root, context_message| { + super::open_agent_trace_db_for_hook_runtime_at_state_root( + root, + state_root, + context_message, + ) + }, + ) +} + +fn run_mutation_scope_from_payload_with( + repository_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, + open_db: O, +) -> Result +where + O: Fn(&Path, &'static str) -> Result + Copy, +{ + let payload = parse_mutation_scope_payload(stdin_payload)?; + + drive_mutation_scope( + repository_root, + payload, + logger, + |root, boundary| coordinate(root, boundary, || open_db(root, MUTATION_SCOPE_DB_CONTEXT)), + |root, scope| abandon_scope(root, scope, || open_db(root, MUTATION_SCOPE_DB_CONTEXT)), + ) +} + +fn drive_mutation_scope( + repository_root: &Path, + payload: MutationScopePayload, + logger: Option<&dyn Logger>, + coordinate_boundary: C, + abandon: A, +) -> Result +where + C: FnOnce(&Path, &RuntimeBoundary) -> std::result::Result, + A: FnOnce(&Path, &ScopeId) -> std::result::Result, +{ + let boundary = match payload { + MutationScopePayload::Start { + scope_id, + event_id, + actor_kind, + } => RuntimeBoundary::Start { + scope: ScopeId(scope_id), + event: EventId(event_id), + actor_kind, + }, + MutationScopePayload::Advance { + scope_id, + event_id, + actor_kind, + } => RuntimeBoundary::Advance { + scope: ScopeId(scope_id), + event: EventId(event_id), + actor_kind, + }, + MutationScopePayload::Close { + scope_id, + event_id, + actor_kind, + } => RuntimeBoundary::Close { + scope: ScopeId(scope_id), + event: EventId(event_id), + actor_kind, + }, + MutationScopePayload::Flush => RuntimeBoundary::Flush, + MutationScopePayload::Abandon { scope_id } => { + return classify_abandon(abandon(repository_root, &ScopeId(scope_id)), logger); + } + }; + + classify_coordinate(coordinate_boundary(repository_root, &boundary), logger) +} + +fn classify_coordinate( + result: std::result::Result, + logger: Option<&dyn Logger>, +) -> Result { + match result { + Ok(_) => Ok(String::new()), + Err(CoordinateError::MarkerClearAfterCommit { source, .. }) => { + log_marker_clear_after_durable_completion(logger, "coordinate", &source); + Ok(String::new()) + } + Err(error) => Err(anyhow!( + "mutation-scope runtime boundary failed before durable completion: {error}" + )), + } +} + +fn classify_abandon( + result: std::result::Result, + logger: Option<&dyn Logger>, +) -> Result { + match result { + Ok(_) => Ok(String::new()), + Err(AbandonScopeError::MarkerClearAfterCompletion { source, .. }) => { + log_marker_clear_after_durable_completion(logger, "abandon_scope", &source); + Ok(String::new()) + } + Err(error) => Err(anyhow!( + "mutation-scope runtime abandonment failed before durable completion: {error}" + )), + } +} + +fn log_marker_clear_after_durable_completion( + logger: Option<&dyn Logger>, + entrypoint: &str, + source: &anyhow::Error, +) { + if let Some(log) = logger { + log.warn( + "sce.hooks.mutation_scope.marker_clear_after_durable_completion", + &source.to_string(), + &[("entrypoint", entrypoint)], + None, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(payload: &str) -> Result { + parse_mutation_scope_payload(payload) + } + + fn error_of(payload: &str) -> String { + parse(payload) + .expect_err("expected the payload to be rejected") + .to_string() + } + + #[test] + fn start_maps_all_fields_verbatim() { + let payload = parse( + r#"{"operation":"start","scope_id":" scope-A ","event_id":"e1","actor_kind":"claude_code"}"#, + ) + .expect("valid start payload"); + + assert_eq!( + payload, + MutationScopePayload::Start { + scope_id: " scope-A ".to_string(), + event_id: "e1".to_string(), + actor_kind: ActorKind::ClaudeCode, + } + ); + } + + #[test] + fn advance_and_close_parse_to_their_variants() { + assert_eq!( + parse(r#"{"operation":"advance","scope_id":"A","event_id":"e2","actor_kind":"codex"}"#) + .expect("valid advance payload"), + MutationScopePayload::Advance { + scope_id: "A".to_string(), + event_id: "e2".to_string(), + actor_kind: ActorKind::Codex, + } + ); + + assert_eq!( + parse( + r#"{"operation":"close","scope_id":"A","event_id":"e3","actor_kind":"opencode"}"# + ) + .expect("valid close payload"), + MutationScopePayload::Close { + scope_id: "A".to_string(), + event_id: "e3".to_string(), + actor_kind: ActorKind::OpenCode, + } + ); + } + + #[test] + fn every_actor_kind_wire_string_maps() { + for (wire, expected) in [ + ("claude_code", ActorKind::ClaudeCode), + ("codex", ActorKind::Codex), + ("opencode", ActorKind::OpenCode), + ("pi", ActorKind::Pi), + ] { + let payload = parse(&format!( + r#"{{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"{wire}"}}"# + )) + .expect("valid start payload"); + match payload { + MutationScopePayload::Start { actor_kind, .. } => assert_eq!(actor_kind, expected), + other => panic!("expected Start, got {other:?}"), + } + } + } + + #[test] + fn flush_takes_no_identity_fields() { + assert_eq!( + parse(r#"{"operation":"flush"}"#).expect("valid flush payload"), + MutationScopePayload::Flush + ); + } + + #[test] + fn abandon_takes_only_scope_id() { + assert_eq!( + parse(r#"{"operation":"abandon","scope_id":"A"}"#).expect("valid abandon payload"), + MutationScopePayload::Abandon { + scope_id: "A".to_string(), + } + ); + } + + #[test] + fn empty_or_blank_payload_is_rejected() { + assert!(parse("").is_err()); + assert!(parse(" \n\t ").is_err()); + } + + #[test] + fn malformed_json_is_rejected() { + assert!(parse("{").is_err()); + assert!(parse(r#"{"operation":"start""#).is_err()); + assert!(parse("not json at all").is_err()); + } + + #[test] + fn non_object_json_is_rejected() { + assert!(parse("123").is_err()); + assert!(parse(r#""start""#).is_err()); + assert!(parse(r#"["start"]"#).is_err()); + assert!(parse("null").is_err()); + } + + #[test] + fn missing_operation_is_rejected() { + assert!(parse(r#"{"scope_id":"A","event_id":"e1","actor_kind":"pi"}"#).is_err()); + } + + #[test] + fn unknown_operation_is_rejected() { + let error = + error_of(r#"{"operation":"reopen","scope_id":"A","event_id":"e1","actor_kind":"pi"}"#); + assert!(error.contains("'operation'"), "unexpected error: {error}"); + } + + #[test] + fn operation_wrong_type_is_rejected() { + assert!(parse(r#"{"operation":5}"#).is_err()); + } + + #[test] + fn unknown_actor_kind_is_rejected() { + let error = error_of( + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"cursor"}"#, + ); + assert!(error.contains("'actor_kind'"), "unexpected error: {error}"); + } + + #[test] + fn missing_scope_or_event_or_actor_is_rejected() { + assert!(parse(r#"{"operation":"start","event_id":"e1","actor_kind":"pi"}"#).is_err()); + assert!(parse(r#"{"operation":"start","scope_id":"A","actor_kind":"pi"}"#).is_err()); + assert!(parse(r#"{"operation":"start","scope_id":"A","event_id":"e1"}"#).is_err()); + } + + #[test] + fn empty_or_blank_scope_id_or_event_id_is_rejected() { + assert!( + parse(r#"{"operation":"start","scope_id":"","event_id":"e1","actor_kind":"pi"}"#) + .is_err() + ); + assert!(parse( + r#"{"operation":"start","scope_id":" ","event_id":"e1","actor_kind":"pi"}"# + ) + .is_err()); + assert!( + parse(r#"{"operation":"start","scope_id":"A","event_id":"","actor_kind":"pi"}"#) + .is_err() + ); + assert!( + parse(r#"{"operation":"start","scope_id":"A","event_id":"\t","actor_kind":"pi"}"#) + .is_err() + ); + assert!(parse(r#"{"operation":"abandon","scope_id":" "}"#).is_err()); + } + + #[test] + fn wrong_field_json_type_is_rejected() { + assert!( + parse(r#"{"operation":"start","scope_id":123,"event_id":"e1","actor_kind":"pi"}"#) + .is_err() + ); + assert!( + parse(r#"{"operation":"start","scope_id":"A","event_id":true,"actor_kind":"pi"}"#) + .is_err() + ); + assert!(parse( + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":["pi"]}"# + ) + .is_err()); + } + + #[test] + fn unexpected_field_is_rejected() { + let error = error_of( + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"pi","attempt_id":"x"}"#, + ); + assert!( + error.contains("unexpected field 'attempt_id'"), + "unexpected error: {error}" + ); + } + + #[test] + fn any_worktree_id_key_is_rejected_with_a_dedicated_diagnostic() { + for payload in [ + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"pi","worktree_id":"wt"}"#, + r#"{"operation":"flush","worktree_id":"wt"}"#, + r#"{"operation":"abandon","scope_id":"A","worktree_id":"wt"}"#, + ] { + let error = error_of(payload); + assert!( + error.contains("'worktree_id'"), + "unexpected error for {payload}: {error}" + ); + } + } + + #[test] + fn flush_rejects_any_scope_event_or_actor_field() { + assert!(parse(r#"{"operation":"flush","scope_id":"A"}"#).is_err()); + assert!(parse(r#"{"operation":"flush","event_id":"e1"}"#).is_err()); + assert!(parse(r#"{"operation":"flush","actor_kind":"pi"}"#).is_err()); + } + + #[test] + fn abandon_rejects_event_and_actor_fields() { + assert!(parse(r#"{"operation":"abandon","scope_id":"A","event_id":"e1"}"#).is_err()); + assert!(parse(r#"{"operation":"abandon","scope_id":"A","actor_kind":"pi"}"#).is_err()); + } + + #[test] + fn abandon_missing_scope_id_is_rejected() { + assert!(parse(r#"{"operation":"abandon"}"#).is_err()); + } + + mod runtime_dispatch { + use std::cell::Cell; + + use super::*; + use crate::services::mutation_trace::protocol; + use crate::services::mutation_trace::types::{TreeId, WorktreeId}; + + fn committed_outcome() -> CoordinateOutcome { + CoordinateOutcome { + worktree_id: WorktreeId("wt-1".to_string()), + observed_tree: TreeId("tree-1".to_string()), + revision: 1, + evaluation: protocol::CommitEvaluation::default(), + mutation_event: None, + } + } + + fn abandoned_outcome() -> AbandonScopeOutcome { + AbandonScopeOutcome::Abandoned { + worktree_id: WorktreeId("wt-1".to_string()), + scope: ScopeId("A".to_string()), + revision: 1, + } + } + + fn unreachable_coordinate( + _root: &Path, + _boundary: &RuntimeBoundary, + ) -> std::result::Result { + panic!("coordinate must not be invoked for this payload"); + } + + fn unreachable_abandon( + _root: &Path, + _scope: &ScopeId, + ) -> std::result::Result { + panic!("abandon_scope must not be invoked for this payload"); + } + + #[test] + fn start_forwards_identities_verbatim_to_coordinate() { + let result = drive_mutation_scope( + Path::new("/unused"), + MutationScopePayload::Start { + scope_id: "A".to_string(), + event_id: "e1".to_string(), + actor_kind: ActorKind::ClaudeCode, + }, + None, + |_root, boundary| { + match boundary { + RuntimeBoundary::Start { + scope, + event, + actor_kind, + } => { + assert_eq!(scope.0, "A"); + assert_eq!(event.0, "e1"); + assert_eq!(*actor_kind, ActorKind::ClaudeCode); + } + other => panic!("expected RuntimeBoundary::Start, got {other:?}"), + } + Ok(committed_outcome()) + }, + unreachable_abandon, + ); + + assert_eq!(result.expect("start should succeed"), ""); + } + + #[test] + fn flush_maps_to_flush_boundary_without_identity() { + let result = drive_mutation_scope( + Path::new("/unused"), + MutationScopePayload::Flush, + None, + |_root, boundary| { + assert!(matches!(boundary, RuntimeBoundary::Flush)); + Ok(committed_outcome()) + }, + unreachable_abandon, + ); + + assert_eq!(result.expect("flush should succeed"), ""); + } + + #[test] + fn abandon_calls_abandon_scope_only() { + let result = drive_mutation_scope( + Path::new("/unused"), + MutationScopePayload::Abandon { + scope_id: "A".to_string(), + }, + None, + unreachable_coordinate, + |_root, scope| { + assert_eq!(scope.0, "A"); + Ok(abandoned_outcome()) + }, + ); + + assert_eq!(result.expect("abandon should succeed"), ""); + } + + #[test] + fn successful_boundary_produces_empty_stdout() { + let result = drive_mutation_scope( + Path::new("/unused"), + MutationScopePayload::Advance { + scope_id: "A".to_string(), + event_id: "e2".to_string(), + actor_kind: ActorKind::Codex, + }, + None, + |_root, _boundary| Ok(committed_outcome()), + unreachable_abandon, + ); + + assert_eq!(result.expect("advance should succeed"), ""); + } + + #[test] + fn marker_clear_after_commit_is_durable_success_without_reexecution() { + let calls = Cell::new(0_u32); + let result = drive_mutation_scope( + Path::new("/unused"), + MutationScopePayload::Advance { + scope_id: "A".to_string(), + event_id: "e2".to_string(), + actor_kind: ActorKind::ClaudeCode, + }, + None, + |_root, _boundary| { + calls.set(calls.get() + 1); + Err(CoordinateError::MarkerClearAfterCommit { + source: anyhow!("external-taint marker cleanup failed"), + committed: Box::new(committed_outcome()), + }) + }, + unreachable_abandon, + ); + + assert_eq!(result.expect("carried outcome is durable success"), ""); + assert_eq!(calls.get(), 1); + } + + #[test] + fn marker_clear_after_completion_is_durable_success_without_reexecution() { + let calls = Cell::new(0_u32); + let result = drive_mutation_scope( + Path::new("/unused"), + MutationScopePayload::Abandon { + scope_id: "A".to_string(), + }, + None, + unreachable_coordinate, + |_root, _scope| { + calls.set(calls.get() + 1); + Err(AbandonScopeError::MarkerClearAfterCompletion { + source: anyhow!("external-taint marker cleanup failed"), + completed: Box::new(abandoned_outcome()), + }) + }, + ); + + assert_eq!(result.expect("carried outcome is durable success"), ""); + assert_eq!(calls.get(), 1); + } + + #[test] + fn pre_completion_coordinate_error_propagates() { + let result = drive_mutation_scope( + Path::new("/unused"), + MutationScopePayload::Close { + scope_id: "A".to_string(), + event_id: "e3".to_string(), + actor_kind: ActorKind::ClaudeCode, + }, + None, + |_root, _boundary| Err(CoordinateError::Other(anyhow!("snapshot capture failed"))), + unreachable_abandon, + ); + + assert!(result.is_err()); + } + + #[test] + fn pre_completion_abandon_error_propagates() { + let result = drive_mutation_scope( + Path::new("/unused"), + MutationScopePayload::Abandon { + scope_id: "A".to_string(), + }, + None, + unreachable_coordinate, + |_root, _scope| Err(AbandonScopeError::Other(anyhow!("lock acquisition failed"))), + ); + + assert!(result.is_err()); + } + + #[test] + fn malformed_payload_returns_err() { + let result = run_mutation_scope_from_payload(Path::new("/unused"), "{", None); + assert!(result.is_err()); + } + } + + mod real_git_db_ingress { + use std::cell::Cell; + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + + use super::*; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::checkout::resolve_git_dir; + use crate::services::mutation_trace::store::decode_revision; + + fn git(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("git output should be UTF-8") + } + + struct IngressRepo { + _temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + } + + impl IngressRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-mutation-scope-ingress-{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: temp, + root, + state_root, + } + } + + fn drive(&self, payload: &str) -> Result { + run_mutation_scope_from_payload_at_state_root( + &self.root, + &self.state_root, + payload, + None, + ) + } + + fn db(&self) -> RepositoryAgentTraceDb { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "mutation-scope ingress test assertions", + ) + .expect("assertion DB should open") + } + + fn working_tree(&self) -> String { + git(&self.root, &["add", "-A"]); + git(&self.root, &["write-tree"]).trim().to_owned() + } + + fn marker_path(&self) -> PathBuf { + resolve_git_dir(&self.root) + .expect("git dir should resolve") + .join("sce") + .join("mutation-cursor-tainted") + } + } + + fn assert_raw_agent_trace_tables_untouched(db: &RepositoryAgentTraceDb) { + assert_eq!(count(db, "diff_traces"), 0); + assert_eq!(count(db, "post_commit_patch_intersections"), 0); + assert_eq!(count(db, "agent_traces"), 0); + } + + fn 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 worktree_revision(db: &RepositoryAgentTraceDb) -> u64 { + db.query_map("SELECT revision FROM mutation_trace_worktrees", (), |row| { + let blob: Vec = row.get(0).map_err(anyhow::Error::from)?; + decode_revision(&blob) + }) + .expect("worktree revision query should succeed") + .into_iter() + .next() + .expect("a worktree row should exist") + } + + fn cursor_tree(db: &RepositoryAgentTraceDb) -> String { + db.query_map( + "SELECT cursor_tree FROM mutation_trace_worktrees", + (), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("cursor_tree query should succeed") + .into_iter() + .next() + .expect("a worktree row should exist") + } + + fn needs_rebaseline(db: &RepositoryAgentTraceDb) -> bool { + db.query_map( + "SELECT needs_rebaseline FROM mutation_trace_worktrees", + (), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("needs_rebaseline query should succeed") + .into_iter() + .next() + .expect("a worktree row should exist") + != 0 + } + + 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(db: &RepositoryAgentTraceDb) -> Vec<(String, Option, String)> { + db.query_map( + "SELECT attribution_kind, attribution_scope_id, boundary_kind \ + FROM mutation_trace_events ORDER BY revision", + (), + |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") + } + + const START_A_E1: &str = + r#"{"operation":"start","scope_id":"A","event_id":"e1","actor_kind":"claude_code"}"#; + const ADVANCE_A_E2: &str = + r#"{"operation":"advance","scope_id":"A","event_id":"e2","actor_kind":"claude_code"}"#; + const CLOSE_A_E3: &str = + r#"{"operation":"close","scope_id":"A","event_id":"e3","actor_kind":"claude_code"}"#; + const FLUSH: &str = r#"{"operation":"flush"}"#; + const ABANDON_A: &str = r#"{"operation":"abandon","scope_id":"A"}"#; + + #[test] + #[allow(clippy::too_many_lines)] + fn test1_observed_start_advance_close_lifecycle_persists_durable_rows() { + let repo = IngressRepo::new("observed-lifecycle"); + + assert_eq!(repo.drive(START_A_E1).expect("start should succeed"), ""); + fs::write(repo.root.join("file.txt"), "one\ntwo\n") + .expect("the scoped edit should write"); + assert_eq!( + repo.drive(ADVANCE_A_E2).expect("advance should succeed"), + "" + ); + assert_eq!(repo.drive(CLOSE_A_E3).expect("close should succeed"), ""); + + let db = repo.db(); + assert_eq!( + scope_status(&db, "A").map(|(_, status)| status), + Some("closed".to_string()) + ); + assert_eq!( + processed_events(&db), + vec![ + ("A".to_string(), "e1".to_string()), + ("A".to_string(), "e2".to_string()), + ("A".to_string(), "e3".to_string()), + ] + ); + assert_eq!( + mutation_events(&db), + vec![( + "ai_exclusive".to_string(), + Some("A".to_string()), + "advance".to_string(), + )] + ); + assert_eq!(cursor_tree(&db), repo.working_tree()); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test2_replayed_advance_is_fully_idempotent() { + let repo = IngressRepo::new("replay-idempotent"); + + repo.drive(START_A_E1).expect("start should succeed"); + fs::write(repo.root.join("file.txt"), "one\ntwo\n") + .expect("the scoped edit should write"); + repo.drive(ADVANCE_A_E2) + .expect("the first advance should succeed"); + + let (revision_before, events_before, processed_before) = { + let db = repo.db(); + ( + worktree_revision(&db), + count(&db, "mutation_trace_events"), + count(&db, "mutation_trace_processed_events"), + ) + }; + + assert_eq!( + repo.drive(ADVANCE_A_E2) + .expect("the replayed advance should succeed"), + "" + ); + + let db = repo.db(); + assert_eq!(worktree_revision(&db), 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_id, event_id)| scope_id == "A" && event_id == "e2") + .count(), + 1 + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn test3_conflicting_actor_kind_commits_no_second_boundary() { + let repo = IngressRepo::new("actor-conflict"); + + repo.drive(START_A_E1).expect("start should succeed"); + + let (revision_before, processed_before, scope_before, events_before) = { + let db = repo.db(); + ( + worktree_revision(&db), + processed_events(&db), + scope_status(&db, "A"), + count(&db, "mutation_trace_events"), + ) + }; + + let error = repo + .drive( + r#"{"operation":"advance","scope_id":"A","event_id":"e2","actor_kind":"codex"}"#, + ) + .expect_err( + "a conflicting actor_kind must fail the ingress, not commit a boundary", + ); + + let rendered = format!("{error:#}"); + assert!( + rendered.contains("is already registered to actor"), + "the ingress error must carry the scope/actor identity mismatch diagnostic, \ + got: {rendered}" + ); + + let db = repo.db(); + assert_eq!(worktree_revision(&db), revision_before); + assert_eq!(processed_events(&db), processed_before); + assert!(!processed_events(&db) + .into_iter() + .any(|(scope_id, event_id)| scope_id == "A" && event_id == "e2")); + assert_eq!(scope_status(&db, "A"), scope_before); + assert_eq!( + scope_status(&db, "A").map(|(actor_kind, _)| actor_kind), + Some("claude_code".to_string()) + ); + assert_eq!(count(&db, "mutation_trace_events"), events_before); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn test4_abandonment_keeps_no_snapshot_semantics_for_an_unobserved_edit() { + let repo = IngressRepo::new("abandon-unobserved-edit"); + + repo.drive(START_A_E1).expect("start should succeed"); + + let (revision_after_start, cursor_after_start) = { + let db = repo.db(); + (worktree_revision(&db), cursor_tree(&db)) + }; + + fs::write(repo.root.join("file.txt"), "one\nunobserved\n") + .expect("the unobserved edit should write"); + let edited_tree = repo.working_tree(); + assert_ne!( + edited_tree, cursor_after_start, + "the unobserved edit must move the Git tree" + ); + + assert_eq!(repo.drive(ABANDON_A).expect("abandon should succeed"), ""); + + let db = repo.db(); + assert_eq!( + scope_status(&db, "A").map(|(_, status)| status), + Some("abandoned".to_string()) + ); + assert_eq!(worktree_revision(&db), revision_after_start + 1); + assert!(needs_rebaseline(&db)); + assert_eq!(cursor_tree(&db), cursor_after_start); + assert_ne!(cursor_tree(&db), edited_tree); + assert_eq!(count(&db, "mutation_trace_events"), 0); + assert_eq!( + processed_events(&db), + vec![("A".to_string(), "e1".to_string())] + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn test5_adversarial_flush_drives_real_observed_flush_behavior() { + let repo = IngressRepo::new("adversarial-flush"); + + assert_eq!( + repo.drive(FLUSH) + .expect("the baseline flush should succeed"), + "" + ); + let revision_after_baseline = { + let db = repo.db(); + worktree_revision(&db) + }; + + fs::write(repo.root.join("file.txt"), "one\nunscoped\n") + .expect("the unscoped edit should write"); + let edited_tree = repo.working_tree(); + + assert_eq!(repo.drive(FLUSH).expect("the flush should succeed"), ""); + + let db = repo.db(); + assert_eq!(cursor_tree(&db), edited_tree); + assert_eq!(worktree_revision(&db), revision_after_baseline + 1); + assert_eq!( + mutation_events(&db), + vec![("ineligible_unscoped".to_string(), None, "flush".to_string())] + ); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_processed_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn test6_marker_clear_after_commit_is_durable_success_through_the_ingress() { + let repo = IngressRepo::new("marker-clear-after-commit"); + + repo.drive(START_A_E1).expect("start should succeed"); + fs::write(repo.root.join("file.txt"), "one\nattributable\n") + .expect("the scoped edit should write"); + + let marker = repo.marker_path(); + let calls = Cell::new(0_u32); + let resolver = + |root: &Path, context_message: &'static str| -> Result { + calls.set(calls.get() + 1); + fs::remove_file(&marker) + .expect("the armed marker file should be present mid-invocation"); + fs::create_dir_all(marker.join("nested")) + .expect("planting a non-empty directory at the marker path should succeed"); + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + root, + &repo.state_root, + context_message, + ) + }; + + let result = + run_mutation_scope_from_payload_with(&repo.root, ADVANCE_A_E2, None, resolver); + + assert_eq!( + result.expect("a post-commit marker-clear failure is durable success"), + "" + ); + assert_eq!( + calls.get(), + 1, + "the runtime entrypoint must run exactly once, with no retried transition" + ); + + let db = repo.db(); + assert_eq!( + mutation_events(&db), + vec![( + "ai_exclusive".to_string(), + Some("A".to_string()), + "advance".to_string(), + )] + ); + assert_eq!( + processed_events(&db) + .into_iter() + .filter(|(scope_id, event_id)| scope_id == "A" && event_id == "e2") + .count(), + 1 + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn test7_marker_clear_after_abandon_is_durable_success_through_the_ingress() { + let repo = IngressRepo::new("marker-clear-after-abandon"); + + repo.drive(START_A_E1).expect("start should succeed"); + let revision_after_start = { + let db = repo.db(); + worktree_revision(&db) + }; + + fs::write(repo.root.join("file.txt"), "one\nunobserved\n") + .expect("the unobserved edit should write"); + + let marker = repo.marker_path(); + let calls = Cell::new(0_u32); + let resolver = + |root: &Path, context_message: &'static str| -> Result { + calls.set(calls.get() + 1); + fs::remove_file(&marker) + .expect("the armed marker file should be present mid-invocation"); + fs::create_dir_all(marker.join("nested")) + .expect("planting a non-empty directory at the marker path should succeed"); + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + root, + &repo.state_root, + context_message, + ) + }; + + let result = + run_mutation_scope_from_payload_with(&repo.root, ABANDON_A, None, resolver); + + assert_eq!( + result.expect("a post-completion marker-clear failure is durable success"), + "" + ); + assert_eq!(calls.get(), 1, "abandon_scope must run exactly once"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, "A").map(|(_, status)| status), + Some("abandoned".to_string()) + ); + assert_eq!(worktree_revision(&db), revision_after_start + 1); + assert!(needs_rebaseline(&db)); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + } +} diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 036afa32..410f6b22 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -504,6 +504,9 @@ fn convert_hooks_subcommand_request( cli_schema::HooksSubcommand::ClaudeModelState => { Ok(services::hooks::HookSubcommand::ClaudeModelState) } + cli_schema::HooksSubcommand::MutationScope => { + Ok(services::hooks::HookSubcommand::MutationScope) + } } } @@ -584,6 +587,20 @@ mod tests { ); } + #[test] + fn mutation_scope_hook_parses_to_hook_subcommand() { + let command = parse(&["sce", "hooks", "mutation-scope"]); + + let RuntimeCommand::Hooks(command) = command else { + panic!("expected hooks command"); + }; + + assert_eq!( + command.subcommand, + services::hooks::HookSubcommand::MutationScope + ); + } + #[test] fn sync_json_format_parses_to_sync_request() { let command = parse(&["sce", "sync", "--format", "json"]); diff --git a/context/architecture.md b/context/architecture.md index 4c6ca08c..8f6339c7 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -132,7 +132,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/doctor/mod.rs` owns the current doctor request/report surface while focused submodules (`doctor/inspect.rs`, `doctor/render.rs`, `doctor/fixes.rs`, `doctor/types.rs`) split report fact collection, rendering, manual fix reporting, and doctor-owned domain types into smaller seams; `cli/src/services/doctor/command.rs` owns the `DoctorCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Runtime doctor execution resolves a repository root, derives a scoped context, requests the shared static lifecycle provider catalog with hooks included for service-owned `diagnose` and `fix` behavior, adapts lifecycle-owned health/fix records into doctor-owned problem/fix records, and then renders stable text/JSON problem records with category/severity/fixability/remediation fields plus deterministic fix-result reporting in fix mode. Agent Trace database inspection is no longer a doctor-adjacent command surface; doctor owns repository-scoped DB health and checkout identity facts, while `sce sync` owns control-plane synchronization. Report fact collection preserves environment/repository/hook/integration display data and adds a non-launching `post_commit_auto_sync` fact based on canonical post-commit managed-block currency plus resolved `agent_trace.auto_sync` source/value; this fact does not launch `sce sync`, and existing hook problem/remediation/readiness semantics remain authoritative. Service-owned lifecycle providers own config validation, local DB and repository-scoped Agent Trace DB readiness/bootstrap, and hook rollout diagnosis/repair. Integration inspection in `doctor/inspect.rs` is scoped twice over: `resolve_doctor_integration_targets` picks which targets to inspect, and `persisted_optional_workflows` (reused from setup) resolves which optional workflows the repository selected, which the OpenCode/Claude/Pi/Codex child collectors apply through `iter_embedded_assets_for_setup_target_with_selection`. An unselected optional workflow therefore contributes no expected children at all, so no row and no missing/mismatch problem can be produced for it, while a selected one keeps the unchanged presence and content-hash checks. Codex's collector (`collect_codex_integration_groups`) is the one exception to the single-target-directory shape the other three share: since `CODEX_EMBEDDED_ASSETS` relative paths already carry their own `.agents/`/`.codex/` prefix, it resolves its integration root through `InstallTargetPaths::codex_target_dir()` (the repository root itself) rather than a `RepoPaths` per-target subdirectory, and splits assets into `Skills` (`.agents/skills/` prefix) and `Hooks` (`.codex/` prefix) groups instead of Pi's `Extensions`/`Prompts`/`Skills` split. Its `.codex/hooks.json` reporting is per-registration rather than one whole-file child: `codex_hook_config::diagnose_document` classifies each of the four required registrations as `PresentAndCurrent`/`Missing`/`Stale` (or the whole document as `Malformed` when it cannot be structurally validated) without writing anything, so unrelated user handlers never create a false whole-document mismatch. For a structurally current registration, `codex_hook_trust` separately reads (never writes) Codex's own durable `$CODEX_HOME/config.toml` hook-trust state — reproducing upstream's `hook_hash`/`hook_key`/`hook_trust_status` exactly — and reports `Trusted`/`Untrusted`/`Modified`/`Disabled`/`Unknown`; only `Trusted` renders healthy. `sce doctor --fix` repairs a structurally unhealthy `.codex/hooks.json` through the existing merge-install path, but a registration that is current yet not-yet-trusted is never "fixed", since SCE cannot grant Codex hook trust. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` payload used by the static `RuntimeCommand` enum. -- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`), including the silent `claude-model-state` lifecycle intake delegated to `cli/src/services/hooks/claude_model_state.rs`, plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists that direct-only result to `post_commit_patch_intersections`, resolves bounded read-only mutation-history AI coverage for the committed lines the direct intersection missed via `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch` (invoking worktree's existing checkout identity only, newest 128 events replayed oldest-to-newest as one causal tree-transition provenance lineage bounded by a worktree-lock-captured commit attribution cut, no identity creation, no mutation-cursor write, nothing written to `diff_traces`), passes direct and mutation-AI evidence separately to `agent_trace::build_agent_trace_from_evidence`, then persists the built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the default-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child unless explicitly disabled in config, with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution with `direct > exact transcript > exact session/agent state > NULL`: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`, then persistence performs one exact local `claude_model_state` lookup using canonical session and ephemeral agent scope; model values normalize once through `claude/`, subagents do not inherit main-session state, and unresolved values remain nullable. `session-model` is no longer a supported hook route. +- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`), including the silent `claude-model-state` lifecycle intake delegated to `cli/src/services/hooks/claude_model_state.rs`, the hidden non-fail-open `mutation-scope` runtime ingress delegated to `cli/src/services/hooks/mutation_scope.rs` (STDIN normalized JSON → one `mutation_trace::runtime` `coordinate()` / `abandon_scope()` call with a lazy DB provider; see `context/cli/mutation-scope-hook-ingress.md`), plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists that direct-only result to `post_commit_patch_intersections`, resolves bounded read-only mutation-history AI coverage for the committed lines the direct intersection missed via `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch` (invoking worktree's existing checkout identity only, newest 128 events replayed oldest-to-newest as one causal tree-transition provenance lineage bounded by a worktree-lock-captured commit attribution cut, no identity creation, no mutation-cursor write, nothing written to `diff_traces`), passes direct and mutation-AI evidence separately to `agent_trace::build_agent_trace_from_evidence`, then persists the built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the default-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child unless explicitly disabled in config, with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution with `direct > exact transcript > exact session/agent state > NULL`: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`, then persistence performs one exact local `claude_model_state` lookup using canonical session and ephemeral agent scope; model values normalize once through `claude/`, subagents do not inherit main-session state, and unresolved values remain nullable. `session-model` is no longer a supported hook route. - Generated Claude settings register `SessionStart` and `PostModelSwitch` only for the local model-state hook; `sce hooks session-model` is no longer a supported hook command. Claude Code 2.1.250 and 2.1.251 compatibility smoke passed with the unknown `PostModelSwitch` registration, so SCE installs it unconditionally without a raised minimum or capability gate. The `session_models` table/API and generic session-level fallback lookup were removed in T02 of the `remove-session-models-direct-claude-model-id` plan; the separate `sce hooks claude-model-state` command writes Claude lifecycle observations into the non-exported exact-scope register through the no-migration hook-runtime DB path, without restoring that generic abstraction. `diff-trace` uses direct-first/event-transcript-second Claude `model_id` resolution and consults the exact local lifecycle state only as its final fallback, with direct `tool_version` values. - `cli/src/services/resilience.rs` defines bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) for transient operation hardening with deterministic failure messaging and retry observability. - `context/cli/agent-trace-auto-sync.md` documents the sync-owned, one-shot post-commit launcher boundary: it reuses `sce sync`, has no daemon or local retry machinery, and fails open when child startup cannot be completed; doctor reports this capability without invoking the launcher. diff --git a/context/cli/mutation-scope-hook-ingress.md b/context/cli/mutation-scope-hook-ingress.md new file mode 100644 index 00000000..80629258 --- /dev/null +++ b/context/cli/mutation-scope-hook-ingress.md @@ -0,0 +1,234 @@ +# 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. + +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). + +## Command routing + +`sce hooks mutation-scope` routes through the normal CLI/hook command stack and +is hidden with the rest of the `hooks` surface (`HOOKS_SHOW_IN_TOP_LEVEL_HELP` +is already `false`; no new visibility flag): + +``` +cli_schema::HooksSubcommand::MutationScope + -> parse::command_runtime::convert_hooks_subcommand_request + -> services::hooks::HookSubcommand::MutationScope + -> run_hooks_subcommand_in_repo + -> mutation_scope::run_mutation_scope_subcommand(repository_root, logger) +``` + +`hook_runtime_invocation_name` reports `"mutation-scope runtime invocation"`. The +dispatch arm is unwrapped like `PreCommit` — its `Result` is *not* wrapped in an +`Ok(...)` fail-open shim the way `diff-trace` / `conversation-trace` / `codex` / +`claude-model-state` are (see [Non-fail-open error semantics](#non-fail-open-error-semantics)). + +The ingress reads the invoking checkout through the same `repository_root` +(`std::env::current_dir()`) that `run_hooks_subcommand` resolves for every hook; +the runtime derives `git_dir` and `WorktreeId` from it. STDIN is read once +through the shared `super::read_hook_stdin()`. + +## The normalized JSON contract + +One JSON object on STDIN. A required `operation` string selects the shape; +exactly five operations are supported. + +| `operation` | Other accepted keys | Maps to | +| --- | --- | --- | +| `start` | `scope_id`, `event_id`, `actor_kind` (all required, non-blank) | `RuntimeBoundary::Start` | +| `advance` | `scope_id`, `event_id`, `actor_kind` (all required, non-blank) | `RuntimeBoundary::Advance` | +| `close` | `scope_id`, `event_id`, `actor_kind` (all required, non-blank) | `RuntimeBoundary::Close` | +| `flush` | *(none)* | `RuntimeBoundary::Flush` | +| `abandon` | `scope_id` only (required, non-blank) | `abandon_scope()` | + +`actor_kind` is one of exactly `claude_code`, `codex`, `opencode`, `pi`, mapped +to `ActorKind::ClaudeCode` / `Codex` / `OpenCode` / `Pi`. + +The parser (`parse_mutation_scope_payload`) is strict and rejects, each with a +`Invalid mutation-scope payload from STDIN: .` diagnostic: + +- an empty or whitespace-only payload; +- malformed JSON, or JSON that is not an object; +- a missing `operation`, a non-string `operation`, or an unknown operation; +- an unknown `actor_kind`; +- a missing, non-string, empty, or blank `scope_id` / `event_id`; +- any unexpected field for the operation (each operation validates its exact + allowed key set via `reject_unexpected_keys`); +- **any `worktree_id` key**, with a dedicated diagnostic + (`field 'worktree_id' is not accepted; worktree identity is derived from the + invoking checkout`); +- `flush` carrying any `scope_id` / `event_id` / `actor_kind` field; +- `abandon` carrying anything but `scope_id`. + +Unexpected fields and `worktree_id` are validated explicitly against each +operation's allowed key set. The hook transport remains local to +`mutation_scope.rs`; no serde representation is added to the mutation-domain +types. + +## Operation mapping + +`start` / `advance` / `close` build the matching `RuntimeBoundary` variant, +forwarding `ScopeId(scope_id)`, `EventId(event_id)`, and the mapped `ActorKind` +**verbatim** — no trimming, prefixing, hashing, normalization, UUID generation, +or timestamping. A `scope_id` of `" scope-A "` reaches the runtime as +`ScopeId(" scope-A ")` unchanged. + +`flush` builds `RuntimeBoundary::Flush`, which carries no scope, event, or actor +identity. It drives the runtime's real observed-flush behavior: against a +healthy, non-rebaseline worktree, an unscoped edit followed by `flush` advances +`cursor_tree` to the edited Git tree, advances `revision` by one, writes exactly +one `mutation_trace_events` row with `attribution = IneligibleUnscoped`, and +invents no `mutation_trace_scopes` or `mutation_trace_processed_events` row. + +`abandon` calls `abandon_scope(repository_root, &ScopeId(scope_id), provider)` +directly — see [Abandonment ownership](#abandonment-ownership). + +## Identity ownership + +The ingress owns nothing durable. + +- **The external adapter owns** `scope_id`, `event_id`, and `actor_kind`. +- **SCE owns** `worktree_id`, Git tree identities, mutation revisions, and + attempt IDs. + +### No `worktree_id` + +The payload never accepts `worktree_id` (any such key is a hard rejection). +Worktree identity is derived exclusively by the mutation runtime from the +invoking checkout. The production ingress does not accept, read, derive, or +construct a `WorktreeId`, and never passes one into `coordinate()` / +`abandon_scope()`. (`#[cfg(test)]` code constructs `WorktreeId` values only to +fabricate injected `CoordinateOutcome` / `AbandonScopeOutcome` runtime results.) + +### `ScopeId` / `EventId` are translated, never generated + +`scope_id` and `event_id` become `ScopeId(..)` / `EventId(..)` with no +transformation, because **`EventId` equality is the runtime's existing +replay/idempotency key**. A replayed `(ScopeId, EventId)` boundary is fully +idempotent at the runtime: replaying `advance(A, e2)` leaves `revision`, the +`mutation_trace_events` count, and the `mutation_trace_processed_events` count +unchanged, with exactly one `(A, e2)` processed key. If the ingress regenerated, +prefixed, or hashed `EventId`, that idempotency would break. + +The scope's durable `(worktree_id, actor_kind)` identity is registered by the +runtime on every scope-carrying boundary, not only `Start`; a mismatched +`actor_kind` for an existing scope reaches `CoordinateError::ScopeIdentityConflict` +and commits no second boundary. + +## Non-fail-open error semantics + +Unlike `diff-trace` / `conversation-trace`, a lost mutation-scope lifecycle +boundary can change which scope stays live and therefore alter attribution, so a +valid boundary must **never** be silently discarded. There is no +`"failed open" / exit 0` branch for a dropped or malformed boundary, and no +branch that returns success without driving the runtime for a valid payload. + +Results are classified by **durable completion**, matching what the runtime +already models: + +- **A malformed payload** → `Err` → `CliError` / non-zero exit. +- **An ordinary pre-completion runtime error** — any `CoordinateError` / + `AbandonScopeError` variant other than the two below — → `Err` → `CliError` / + non-zero exit. The message is + `mutation-scope runtime boundary failed before durable completion: ` (or + `... abandonment failed before durable completion: ...`). +- **`CoordinateError::MarkerClearAfterCommit { committed, source }`** and + **`AbandonScopeError::MarkerClearAfterCompletion { completed, source }`** → + **durable success**. These mean the durable mutation transition **already + succeeded** and only the trailing external-taint marker cleanup failed. The + ingress treats the carried outcome as the result: it logs the cleanup failure + diagnostically (`sce.hooks.mutation_scope.marker_clear_after_durable_completion`, + `warn`, with an `entrypoint` = `coordinate` / `abandon_scope` field), emits + empty stdout, and exits zero. It does **not** re-run or retry the transition — + the runtime seam is invoked exactly once. The marker stays armed, so the next + runtime invocation recovers conservatively per existing runtime semantics. + +These are the exact carried-outcome variants on the base +(`origin/mutation-trace-agent-attribution`): +`CoordinateError::MarkerClearAfterCommit { source, committed: Box }` +and +`AbandonScopeError::MarkerClearAfterCompletion { source, completed: Box }`. + +The ingress does not interpret otherwise-valid runtime outcomes +(`accepted = false`, `observes = false`, duplicate processed event, no tree +change, `Abandoned` / `AlreadyTerminal` / `RecoveryRequired`) — those stay +existing runtime semantics — beyond this marker-clear-after-durable-completion +classification. + +## Lazy DB provider + +DB acquisition must stay **inside** the runtime's protected-worktree ordering +(`WorktreeLock` → external-taint fence → `WorktreeId` → DB). The ingress +therefore passes a `FnOnce` provider closure to `coordinate()` / +`abandon_scope()`, never an already-open handle. The closure reuses the shared +`open_agent_trace_db_for_hook_runtime` resolver (with context message +`"Failed to open Agent Trace DB for mutation-scope runtime."`) — no second +DB-opening implementation. The resolver is invoked only from within that closure, +so it can only run after the runtime has taken the lock and armed the fence. + +## Empty-stdout contract + +A successful mutation-scope hook execution produces **empty stdout** (zero +bytes). The ingress serializes no `CoordinateOutcome`, `AbandonScopeOutcome`, +`MutationEvent`, revision, worktree ID, or scope state. The two carried-outcome +success variants above also produce empty stdout. + +## Abandonment ownership + +`abandon` calls `abandon_scope()` directly and never acquires `Close` / `Flush` +snapshot semantics. Abandonment captures no Git snapshot and commits no mutation +boundary; it only transitions already-durable state (status → `Abandoned`, +`revision` + 1, `needs_rebaseline = true`). In +`start(A, e1) → unobserved filesystem edit → abandon(A)`, `cursor_tree` stays the +pre-edit tree — it does **not** become the edited Git tree — and no +`mutation_trace_events` row for the edit and no `mutation_trace_processed_events` +row from the abandon are written. + +Turning `abandon` into a `RuntimeBoundary` variant, or capturing a Git snapshot +on behalf of abandonment, is a deliberate non-goal. + +## Durable storage boundary + +The ingress reaches durable storage only through the existing mutation runtime, +writing `mutation_trace_*` rows only. No production ingress path writes +`diff_traces`, `post_commit_patch_intersections`, or `agent_traces`, and none +touches `spec/mutation_cursor.qnt`, `protocol.rs`, the mutation-trace SQL schema, +migrations, or #259 attribution behavior. The pure mutation-domain types gain no +serde derives for this command; the hook transport enum +(`MutationScopePayload`) is local to `mutation_scope.rs`. + +## Generic ingress vs harness adapter + +A generic SCE ingress existing is **not** concrete harness integration existing. +Out of scope for this seam, and left as future work: + +- any concrete harness mapping — Claude Code hooks, Codex hook mapping, OpenCode + plugin, Pi extension; +- `SubagentStart` / `SubagentStop` / `PostToolUse` / tool-call translation; +- `session → ScopeId` or `tool-call → EventId` derivation; +- PID tracking, process supervisors, staleness detection, automatic scope + abandonment; +- harness settings generation or `sce setup` integration for the new hook. + +Each future adapter still owns its own `ScopeId` / `EventId` / `actor_kind` +derivation and its own stale-process detection, and targets this ingress as its +transport. See [`mutation-scope-runtime.md`](mutation-scope-runtime.md) for the +lifecycle obligations every such adapter must uphold. + +## Related context + +- [Mutation-scope runtime: the harness-adapter contract](mutation-scope-runtime.md) +- [Mutation-trace runtime coordinator](mutation-trace-runtime-coordinator.md) +- [Mutation-trace scope abandonment](mutation-trace-scope-abandonment.md) +- [Mutation-trace protected worktree](mutation-trace-protected-worktree.md) +- [Agent Trace hooks command routing](../sce/agent-trace-hooks-command-routing.md) diff --git a/context/cli/mutation-scope-runtime.md b/context/cli/mutation-scope-runtime.md index c34eec2e..a5a714bc 100644 --- a/context/cli/mutation-scope-runtime.md +++ b/context/cli/mutation-scope-runtime.md @@ -5,9 +5,12 @@ lifecycle contract every harness adapter (Codex, Claude Code, OpenCode, Pi) must uphold when it drives that surface. Built by the `mutation-scope-runtime-integration` plan -(`context/plans/mutation-scope-runtime-integration.md`). **No harness is wired to -it yet.** This file is the contract a future adapter is written against, not a -description of shipped adapter behavior. +(`context/plans/mutation-scope-runtime-integration.md`). The generic +`sce hooks mutation-scope` CLI ingress +([`mutation-scope-hook-ingress.md`](mutation-scope-hook-ingress.md)) now drives +this seam, but **no concrete harness adapter (Codex, Claude Code, OpenCode, Pi) +is wired to it yet.** This file is the contract a future harness adapter is +written against, not shipped adapter behavior. See the Status section below. The mechanics behind each entrypoint live in their own domain files: [`mutation-trace-runtime-coordinator.md`](mutation-trace-runtime-coordinator.md) @@ -50,12 +53,14 @@ reachable from `git_snapshot`, `external_taint`, `worktree_lock`, `runtime`, as does `reconcile_worktree`. An adapter drives the runtime only through the two entrypoints; it never assembles the safety prefix itself. -Both re-export statements carry `#[allow(unused_imports)]`, matching the -repository's existing precedent for a seam whose consumers do not exist yet -(`services/style.rs`, `services/hooks/codex/apply_patch/mod.rs`). The -module-level `#[allow(dead_code)] pub mod mutation_trace;` in `services/mod.rs` -covers unused *items*, not unused re-exports. No placeholder consumer was added -to satisfy `clippy --all-targets -- -D warnings`. +The re-exports remain the intentional crate-visible runtime seam. The generic +`sce hooks mutation-scope` hook ingress now consumes that seam, while the runtime +submodules and the safety-prefix implementation stay private and no concrete +harness adapter calls a runtime internal directly. Both seam re-export statements +still carry `#[allow(unused_imports)]` in `runtime/mod.rs`: the ingress matches +most of the surface but not the two names that only complete it +(`ExternalTaintOperation`, `AbandonRecoveryReason`), which +`clippy --all-targets -- -D warnings` would otherwise flag. ## What a mutation scope is @@ -232,7 +237,19 @@ scope, or the worktree is unhealthy, externally tainted, or needs rebaseline). ## Status -The seam is exported and the contract is recorded. No harness hook, plugin, -extension, or command calls either entrypoint yet; each harness's concrete -`ScopeId` / `EventId` format and its stale-process detection are still open, as -is any repository-scoped cleanup of unowned checkout identities. +The seam is exported and the contract is recorded. It is now driven by the +generic `sce hooks mutation-scope` CLI ingress, which reads one normalized JSON +lifecycle object from STDIN and calls `coordinate()` +(`start` / `advance` / `close` / `flush`) or `abandon_scope()` (`abandon`) with a +lazy DB provider, translating `scope_id` / `event_id` / `actor_kind` verbatim, +refusing any `worktree_id` key, and classifying results by durable completion +rather than failing open. Full transport/normalization contract in +[`mutation-scope-hook-ingress.md`](mutation-scope-hook-ingress.md); routing in +[agent-trace hooks command routing](../sce/agent-trace-hooks-command-routing.md). + +A generic SCE ingress existing is not concrete harness integration existing. No +Claude Code, Codex, OpenCode, or Pi lifecycle adapter is wired yet — none of +those harnesses emits mutation-scope events, and each still owns its own +`ScopeId` / `EventId` derivation and stale-process detection as this contract +requires. Repository-scoped cleanup of unowned checkout identities is likewise +still open. diff --git a/context/cli/mutation-trace-protocol.md b/context/cli/mutation-trace-protocol.md index 6fe7ec17..0771cf5b 100644 --- a/context/cli/mutation-trace-protocol.md +++ b/context/cli/mutation-trace-protocol.md @@ -1,8 +1,10 @@ # Mutation-cursor protocol module (`mutation_trace`) Pure Rust refinement of the verified `spec/mutation_cursor.qnt` protocol, living -at `cli/src/services/mutation_trace/`. `protocol.rs`'s transitions are not yet -wired into any hook/command; `store.rs` now provides a real database call site +at `cli/src/services/mutation_trace/`. `protocol.rs` is invoked only through the +`runtime/` layer; that runtime is now driven by the generic +`sce hooks mutation-scope` CLI ingress, with no concrete harness lifecycle +adapter wired. `store.rs` provides the real database call site (see "Target end-state architecture" below). ## Current state @@ -21,9 +23,7 @@ kinds — `Start`/`Advance`/`Close`/`Flush` — in one pass, refining `recoverNeeded`/`recover`). Cross-action sequence/invariant tests and a module-level Quint refinement matrix (`mod.rs`) close out the `mutation-cursor-protocol-kernel` plan's task stack (T01-T07). Registered in -`cli/src/services/mod.rs` with `#[allow(dead_code)]`, matching the existing -precedent for modules not yet consumed by production call sites -(`bash_policy`, `repository_identity`, `agent_trace_export`). +`cli/src/services/mod.rs` with `#[allow(dead_code)]`. `commit` materializes exactly one `MutationEvent` into `mutation_events` when `changed` is true, with `active_scopes`/`attribution` computed by @@ -225,7 +225,11 @@ loads a `ProtocolState`; `protocol.rs` only transitions already-known ones. The plan's file split anticipated three seams beyond `protocol.rs`. `store.rs`, `runtime/git_snapshot.rs`, and `coordinator.rs` (with its public `coordinate()` entrypoint) now all exist as real call sites, covered by cross-module -integration tests; only harness/command wiring remains: +integration tests. The generic command ingress is implemented — the +`sce hooks mutation-scope` command drives `coordinate()` / `abandon_scope()` — +so only concrete Claude Code, Codex, OpenCode, and Pi lifecycle adapters remain +future work; `protocol.rs` itself stays pure and unaware of any CLI or harness +concept: ```mermaid flowchart LR diff --git a/context/cli/mutation-trace-runtime-coordinator.md b/context/cli/mutation-trace-runtime-coordinator.md index 6bcd00bb..5d4a3cb2 100644 --- a/context/cli/mutation-trace-runtime-coordinator.md +++ b/context/cli/mutation-trace-runtime-coordinator.md @@ -11,9 +11,11 @@ Git worktree, built by the `mutation-cursor-runtime-coordinator` plan same `#[allow(dead_code)]` precedent as the rest of `mutation_trace`. Every submodule is declared privately in `runtime/mod.rs`, which re-exports `coordinate` and `abandon_scope` at `pub(crate)` — reachable crate-wide, contract -in [`mutation-scope-runtime.md`](mutation-scope-runtime.md) — while -`reconcile_worktree` stays `runtime`-internal and nothing under `runtime/` is -wired into any hook, command, or `diff_traces` insertion yet. +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. `runtime` depends on `protocol`/`store`/`types` and on `services::checkout`, never the reverse — this is a structural module boundary, not merely a @@ -235,8 +237,11 @@ that prefix are all implemented and both `pub(crate)` re-exported from `runtime/mod.rs` ([`mutation-scope-runtime.md`](mutation-scope-runtime.md)), with `runtime/tests.rs` covering `coordinate()` end to end and both entrypoints driven together; an inherited external-taint marker is overlaid onto -`database_failure` recovery on the next invocation. Harness/command wiring -remains future work. +`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. 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/cli/mutation-trace-scope-abandonment.md b/context/cli/mutation-trace-scope-abandonment.md index 528efb26..247b0c08 100644 --- a/context/cli/mutation-trace-scope-abandonment.md +++ b/context/cli/mutation-trace-scope-abandonment.md @@ -225,8 +225,10 @@ covering what a single-module test with no real Git cannot: The entrypoint, its outcome/error types, its unit coverage, its cross-runtime regressions against real Git repositories, its `pub(crate)` re-export out of -`runtime`, and the harness-adapter contract document all exist; no harness, -hook, or command calls this yet. +`runtime`, and the harness-adapter contract document all exist. The generic +`sce hooks mutation-scope` CLI ingress now calls `abandon_scope()` for an +`abandon` payload; no concrete harness lifecycle adapter (Claude Code, Codex, +OpenCode, Pi) is wired to it yet. See also: [`mutation-trace-runtime-coordinator.md`](mutation-trace-runtime-coordinator.md), [`mutation-trace-protected-worktree.md`](mutation-trace-protected-worktree.md), diff --git a/context/context-map.md b/context/context-map.md index 35108443..5e215196 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -23,15 +23,16 @@ Feature/domain context: - `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, focused `config/resolver.rs` ownership for config discovery/merge/runtime precedence plus default-discovered invalid-file degradation, focused `config/render.rs` ownership for `show`/`validate` text+JSON output construction, canonical `$schema` acceptance for startup-loaded `sce/config.json` files, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, shared runtime resolution for flat logging observability keys including config-file/default `log_to_file`, `log_dir` / `SCE_LOG_DIR` with `/sce/logs` defaulting plus config-file/default-only positive `log_file_retention_limit`, config-file-only `agent_trace.repository_id`/`agent_trace.repository_remote` repository-identity keys with default remote `origin`, default-enabled `agent_trace.auto_sync` boolean resolution with explicit-false opt-out for the post-commit trigger boundary, the catalog-derived `integrations.optional_workflows` optional-workflow selection key, JSON-pointer-prefixed schema-validation errors, canonical Pkl-generated `sce/config.json` schema ownership plus CLI embedding/reuse contract including `policies.attribution_hooks.enabled` default-true/explicit-false opt-out metadata, config-file selection order, `show` provenance output, and trimmed `validate` output contract) - `context/cli/capability-traits.md` (current broad CLI capability seam in `cli/src/services/capabilities.rs`, including `FsOps`/`StdFsOps`, `GitOps`/`ProcessGitOps`, git root/hooks resolution behavior, compile-time-typed borrowed AppContext wiring with associated-type narrow capability accessors plus `ContextWithRepoRoot` repo-root-scoped context derivation, generic command execution bounds, and test-only unimplemented stubs; current service internals do not consume fs/git traits until later lifecycle migration tasks) - `context/cli/service-lifecycle.md` (current compile-safe lifecycle seam in `cli/src/services/lifecycle.rs`, including default no-op `ServiceLifecycle` diagnose/fix/setup methods against narrow `HasRepoRoot`, lifecycle-owned health/fix/setup result types with generic setup messages, doctor/setup adapter boundaries, the static `LifecycleProvider` enum catalog/dispatcher, hook/config/local_db/auth_db/agent_trace_db lifecycle providers including setup-time repository-scoped Agent Trace DB initialization plus checkout identity diagnostics, implemented doctor aggregation over diagnose/fix providers, and implemented setup aggregation over `setup` providers in order config → local_db → auth_db → agent_trace_db → hooks when requested) -- `context/cli/mutation-trace-protocol.md` (pure, dependency-free `cli/src/services/mutation_trace/` refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol: the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — plus cross-action sequence/invariant tests and a module-level Quint refinement matrix are all complete (`types.rs`, `protocol.rs`, `mod.rs`, `tests.rs`, registered with `#[allow(dead_code)]`); opaque-newtype identity refinement decisions vs. the Quint model's bounded enums, and the `coordinator.rs` target end-state seam this layout leaves room for but does not create — `store.rs`, `runtime/git_snapshot.rs`, and `runtime/coordinator.rs` (including its public `coordinate()` entrypoint) now exist, built out by the `mutation-cursor-store-persistence` and `mutation-cursor-runtime-coordinator` plans respectively; `protocol.rs`'s pure transitions are not yet wired into any hook or command) +- `context/cli/mutation-trace-protocol.md` (pure, dependency-free `cli/src/services/mutation_trace/` refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol: the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — plus cross-action sequence/invariant tests and a module-level Quint refinement matrix are all complete (`types.rs`, `protocol.rs`, `mod.rs`, `tests.rs`, registered with `#[allow(dead_code)]`); opaque-newtype identity refinement decisions vs. the Quint model's bounded enums, and the `coordinator.rs` target end-state seam this layout leaves room for but does not create — `store.rs`, `runtime/git_snapshot.rs`, and `runtime/coordinator.rs` (including its public `coordinate()` entrypoint) now exist, built out by the `mutation-cursor-store-persistence` and `mutation-cursor-runtime-coordinator` plans respectively; `protocol.rs` is invoked only through the runtime, and the runtime is now driven by the generic `sce hooks mutation-scope` CLI ingress, with no concrete harness lifecycle adapter wired) - `context/cli/mutation-trace-agent-attribution.md` (causal mutation-lineage attribution: direct target-shaped coverage is resolved first, then the newest-128 events for the invoking worktree (bounded also by a commit attribution cut — `revision <= latest_mutation_event_revision` captured under the worktree lock) are replayed oldest-to-newest as one ordered sequence of tree transitions. `lineage.rs` is a pure module propagating per-line `LineProvenance` (`Unknown` / `MutationAi{scope}` / `MutationNonAi`) forward through structurally-applied hunks: context carries, removed is permanently deleted, added takes only the introducing transition's origin, replacement never transfers by text. A committed line is AI only if an AI event's line survives every later transition — including a history-gap reload and the unobserved latest-tree→commit-tree tail — into the committed tree; anything unproven stays `Unknown`. Historical patches are never matched against the committed patch. `attribution.rs` keeps only `exclude_direct_coverage` + `patch_for_locations`. The `runtime/mutation_attribution.rs` consumer drives it over `MutationEventPageSource` + `TreeReadSource` (`diff_trees` + `file_at_tree`) seams with conservative fail-closed reloads; wired into post-commit via the read-only `runtime::resolve_post_commit_mutation_ai_patch` entrypoint — existing checkout identity only, direct-only fallback on absent identity/history/unavailable cut, no identity creation, no mutation-cursor write, `diff_traces` / `post_commit_patch_intersections` unchanged; see `agent-trace-hooks-command-routing.md` and `agent-trace-minimal-generator.md` — with real Git/DB post-commit regressions in `cli/src/services/hooks/mod.rs` and a real 128/129-horizon regression in `runtime/tests.rs`) - `context/cli/mutation-trace-revision-refinement.md` (the Quint `revision: int` → Rust `WorktreeState::revision: u64` bounded-integer refinement: the private `next_revision` checked-arithmetic helper `commit`/`taint`/`abandon`/`recover` all route through instead of a raw `+ 1`, so a worktree at `revision: u64::MAX` is a guarded no-op/rejection rather than a silent wrap to `0`) - `context/cli/mutation-trace-quint-connect.md` (`#[cfg(test)]`-only Quint Connect model-based-testing harness in `cli/src/services/mutation_trace/mbt/` continuously checking `protocol.rs` against `spec/mutation_cursor.qnt`: the verification-only `mbtAction`/`MbtAction` record-payload transport excluded from comparison, the operation-identity-vs-`MbtStutter` distinction on guarded/no-op branches with its two deterministic regressions, finite ID mapping, the AC5 comparable-state field list, `randomPrepare` staying a single `step` branch, deterministic/generated (500×30, seed-reproducible) test coverage, and the two Nix checks — generic `checks.cli-tests` and dedicated `checks.mutation-trace-quint-connect` — that both require the pinned Quint binary plus the top-level `spec/` directory in `workspaceSrc`'s Nix fileset) - `context/cli/mutation-trace-store.md` (durable persistence for the mutation-cursor protocol in `cli/src/services/mutation_trace/store.rs`, built by the `mutation-cursor-store-persistence` plan: the one-directional `protocol.rs` -> `DurableTransition::between` (pure structural diff) -> `store.rs` (SQL translation) -> `RepositoryAgentTraceDb` boundary; migration `004_mutation_trace_protocol.sql`'s five tables; `AttemptState`/`external_taint` non-persistence; the 8-byte big-endian `BLOB` revision encoding plus explicit non-`Debug` enum codecs; the bounded hot-path `load_worktree` read vs. the cold-path `load_mutation_event` and descending, exact-worktree, cursor-paged `load_mutation_event_page` reader capped at 32 rows; the public cold-path single-row `load_scope` scope seam that returns one `mutation_trace_scopes` row as `Option` without widening into a projection and, unlike `load_worktree`, never adjudicates worktree identity (a cross-worktree scope is returned as-is, and the mismatch is the caller's decision); the cold-path read-only `load_tree_roots` (one worktree) / `load_all_tree_roots` (repository-wide) durable-tree-SHA queries for ref reconciliation, each a single-statement `UNION` of `cursor_tree`/`before_tree`/`after_tree` read from one DB snapshot; `commit`'s single-`BEGIN IMMEDIATE` CAS batch via `TursoDb::execute_transactional_cas_batch`, distinguishing `Conflict`/retryable-transient/deterministic-`Err` outcomes; and the store's non-goals — no Git/filesystem I/O, no attribution/boundary-kind decisions, no retry-after-`Conflict` loop, no row deletion) -- `context/cli/mutation-trace-runtime-coordinator.md` (imperative-shell runtime layer in `cli/src/services/mutation_trace/runtime/`, built by the `mutation-cursor-runtime-coordinator` plan: the per-worktree OS advisory lock, `runtime::worktree_lock::WorktreeLock::acquire(git_dir, timeout)` at `/sce/mutation-cursor.lock`, bounded `try_lock()` polling with a distinct matchable timeout error and RAII release, and its distinction from the separate checkout-identity-creation lock; the isolated Git snapshot service `runtime::git_snapshot::GitSnapshotService` (documented in `mutation-trace-snapshot-service.md`); and `runtime::coordinator`'s protocol-integration pipeline (`RuntimeBoundary`/`CoordinateOutcome`/`CoordinateError`, the load → recover-if-needed → prepare/commit CAS-retry loop, and its own bounded snapshot-failure taint-retry loop) plus the public `coordinate(repository_root, boundary, open_db)` entrypoint that runs the shared `runtime::protected_worktree` prefix (`WorktreeLock` -> external-taint write-ahead fence -> `WorktreeId`, extracted so a second entrypoint cannot drift from it and documented in `mutation-trace-protected-worktree.md`), invokes the caller-supplied `open_db` provider so DB acquisition falls inside that fence, drives the pipeline under one held lock, and clears the marker through `ProtectedWorktree::complete()` only on success; `runtime/tests.rs` covers the public `coordinate()` API end to end against real linked worktrees and a real Agent Trace DB; an inherited external-taint marker is overlaid onto `protocol::database_failure` recovery on the next invocation, against the single captured snapshot and re-injected across a losing recovery CAS; the private `runtime::ref_reconciliation` per-worktree snapshot-ref maintenance pass (documented in `mutation-trace-ref-reconciliation.md`) shares the same `WorktreeLock`; `coordinate()` and `abandon_scope()` are now `pub(crate)` re-exported from `runtime/mod.rs` (documented in `mutation-scope-runtime.md`) while the runtime submodules themselves stay private; harness/command wiring remains future work) +- `context/cli/mutation-trace-runtime-coordinator.md` (imperative-shell runtime layer in `cli/src/services/mutation_trace/runtime/`, built by the `mutation-cursor-runtime-coordinator` plan: the per-worktree OS advisory lock, `runtime::worktree_lock::WorktreeLock::acquire(git_dir, timeout)` at `/sce/mutation-cursor.lock`, bounded `try_lock()` polling with a distinct matchable timeout error and RAII release, and its distinction from the separate checkout-identity-creation lock; the isolated Git snapshot service `runtime::git_snapshot::GitSnapshotService` (documented in `mutation-trace-snapshot-service.md`); and `runtime::coordinator`'s protocol-integration pipeline (`RuntimeBoundary`/`CoordinateOutcome`/`CoordinateError`, the load → recover-if-needed → prepare/commit CAS-retry loop, and its own bounded snapshot-failure taint-retry loop) plus the public `coordinate(repository_root, boundary, open_db)` entrypoint that runs the shared `runtime::protected_worktree` prefix (`WorktreeLock` -> external-taint write-ahead fence -> `WorktreeId`, extracted so a second entrypoint cannot drift from it and documented in `mutation-trace-protected-worktree.md`), invokes the caller-supplied `open_db` provider so DB acquisition falls inside that fence, drives the pipeline under one held lock, and clears the marker through `ProtectedWorktree::complete()` only on success; `runtime/tests.rs` covers the public `coordinate()` API end to end against real linked worktrees and a real Agent Trace DB; an inherited external-taint marker is overlaid onto `protocol::database_failure` recovery on the next invocation, against the single captured snapshot and re-injected across a losing recovery CAS; the private `runtime::ref_reconciliation` per-worktree snapshot-ref maintenance pass (documented in `mutation-trace-ref-reconciliation.md`) shares the same `WorktreeLock`; `coordinate()` and `abandon_scope()` are now `pub(crate)` re-exported from `runtime/mod.rs` (documented in `mutation-scope-runtime.md`) while the runtime submodules themselves stay private; a generic `sce hooks mutation-scope` CLI ingress now drives both entrypoints, but no concrete harness lifecycle adapter is wired) - `context/cli/mutation-trace-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; no harness is wired to the seam yet) +- `context/cli/mutation-scope-runtime.md` (the crate-visible mutation-trace runtime seam and the lifecycle contract every future harness adapter — Codex, Claude Code, OpenCode, Pi — must uphold, recorded by the `mutation-scope-runtime-integration` plan: the nine `pub(crate)` re-exports in `runtime/mod.rs` (`coordinate`, `RuntimeBoundary`, `CoordinateOutcome`, `CoordinateError`, `ExternalTaintOperation`, `abandon_scope`, `AbandonScopeOutcome`, `AbandonRecoveryReason`, `AbandonScopeError`), with `ExternalTaintOperation` riding through `coordinator.rs`'s own `pub use` because `CoordinateError::ExternalTaintMarker` carries it — so the type becomes crate-visible without `protected_worktree` becoming a public module — while every `mod` declaration stays private and `ProtectedWorktree`/`ProtectedWorktreeError`/`WORKTREE_LOCK_TIMEOUT`/`reconcile_worktree` stay internal, kept clippy-clean by `#[allow(unused_imports)]` on the re-exports per the `services/style.rs` precedent rather than a placeholder consumer; and the adapter obligations themselves — a scope is one independently mutation-capable execution so a concurrent main agent and subagent need distinct `ScopeId`s, `Start`/`Advance`/`Close` semantics including that a failed tool still requires `Advance` (the boundary is an observation, not a successful edit) and that a `ScopeId` is never reused after a terminal status (the `NeverSeen` guard silently refuses to reactivate it), `abandon_scope()` requiring positive staleness evidence and never inferring it from `ActorKind`, the `abandon` → `coordinate(Start(successor))` sequence with what each outcome implies for it including that a failed abandonment must never be treated as a safely started successor, that abandonment is not a `RuntimeBoundary` and needs no Quint change, the D1 tradeoff whereby a missing or `NeverSeen` target deliberately forces conservative strong recovery that may invalidate unrelated live scopes because attribution safety outranks preserving potentially valid evidence, and the attribution boundary that `AiExclusive(scope)` means scope exclusivity only and is not standalone proof that no human edited the worktree; a generic `sce hooks mutation-scope` ingress now drives the seam, but no concrete harness lifecycle adapter is wired yet) +- `context/cli/mutation-scope-hook-ingress.md` (the one harness-neutral CLI ingress that drives the mutation-scope runtime, in `cli/src/services/hooks/mutation_scope.rs`, built by the `mutation-scope-hook-ingress` plan: the hidden `sce hooks mutation-scope` command routing through the normal `cli_schema::HooksSubcommand::MutationScope` → `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` → `run_hooks_subcommand_in_repo` stack, reading one normalized JSON lifecycle object from STDIN; the strict `parse_mutation_scope_payload` contract supporting exactly `start`/`advance`/`close`/`flush`/`abandon` with a local `MutationScopePayload` transport enum, `claude_code`/`codex`/`opencode`/`pi` actor mapping, non-blank `scope_id`/`event_id`, exact per-operation key sets, and a dedicated hard rejection for any `worktree_id` key; the operation mapping to `RuntimeBoundary::Start`/`Advance`/`Close`/`Flush` through `coordinate()` or a direct `abandon_scope()` call, forwarding `ScopeId`/`EventId`/`ActorKind` verbatim because `EventId` equality is the runtime replay/idempotency key; identity ownership — the adapter owns `scope_id`/`event_id`/`actor_kind`, SCE owns `worktree_id`/Git tree identities/revisions/attempt IDs, and worktree identity is derived only by the runtime from the invoking checkout; the lazy `FnOnce` DB provider reusing `open_agent_trace_db_for_hook_runtime` so DB acquisition stays inside the runtime's protected-worktree ordering; the non-fail-open error classification by durable completion — malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` → `CliError`/non-zero, while `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are treated as durable success with empty stdout, the marker-cleanup failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion`, and the transition not retried; the empty-stdout success contract; abandonment ownership keeping no-snapshot semantics; and the generic-ingress vs harness-adapter boundary — no concrete Claude Code/Codex/OpenCode/Pi lifecycle adapter, no session→`ScopeId` / tool-call→`EventId` derivation, no PID/staleness detection) - `context/cli/mutation-trace-ref-reconciliation.md` (the conservative per-worktree snapshot-ref reconciliation pass in `cli/src/services/mutation_trace/runtime/ref_reconciliation.rs`, built by the `mutation-cursor-ref-reconciliation` plan: `reconcile_worktree(repository_root, open_db)` → `pub(super) reconcile_worktree_inner(.., on_lock_contention)`, both returning `ReconciliationOutcome` (`Reconciled(ReconciliationReport { local_required, retained, deleted })` for a pass that ran | `SkippedNoCheckoutIdentity` — an `Ok`, not an `Err` — when no current checkout identity could be derived), a variant-per-fallible-step `ReconcileError` with no `Other`, and the module-owned `RECONCILIATION_LOCK_TIMEOUT`; the two-invariant model — a strictly per-worktree fail-closed local-consistency check via `load_tree_roots(W)` vs. a repository-wide deletion-safety set via `load_all_tree_roots()` so an `A`-owned ref is retained whenever any worktree still durably needs its tree — run entirely under the same `/sce/mutation-cursor.lock` `WorktreeLock` `coordinate()` holds, with the repository-wide read kept coherent by being one SQL statement / one DB snapshot rather than a repository-global lock; deletes only SCE-owned refs via one atomic `git update-ref --no-deref --stdin`, writes no `mutation_trace_*` row, never arms `ExternalTaintMarker`, runs no `git gc`; imperative durability maintenance below the verified Quint protocol; reclaims orphan/unreferenced refs only for the namespace of a checkout id a current worktree still derives — a namespace no current worktree owns (identity-based: a deleted linked worktree, or checkout-id metadata loss followed by `get_or_create_checkout_id` minting a fresh id on a still-present worktree) is beyond every per-worktree pass and left to a recorded future repository-scoped unowned-namespace operation, so the current pass does not bound all orphan-ref growth; `reconcile_worktree` has no `pub(crate)` re-export and no harness/command wiring yet) - `context/cli/mutation-trace-snapshot-service.md` (the isolated Git snapshot and ref-pinning service `runtime::git_snapshot::GitSnapshotService` in `cli/src/services/mutation_trace/runtime/git_snapshot.rs`: `new` resolving an absolute `git_dir`, `capture_tree` snapshotting staged/unstaged/untracked/deleted worktree state into the repository's normal object database via a throwaway temp index, `pin_tree` protecting a durable tree with a create-only idempotent **direct** `refs/sce/mutation-cursor//` ref, `diff_trees` emitting `patch.rs`-parseable raw diff text; plus the callerless reconciliation substrate — worktree-scoped `list_pins` inventory returning `Result, PinInventoryError>` that rejects a symbolic ref inside the namespace (mutation-cursor pins are direct refs) as `MalformedRef`, matchable separately from a `git for-each-ref` execution failure, and conditional-atomic `delete_pins` running one `git update-ref --no-deref --stdin` transaction of SHA-conditioned deletes — no-dereference so an inventory→delete direct-ref→symref race cannot escape the inventoried namespace, plus a fail-closed pre-check — that aborts whole if any ref changed since inventory; `REF_NAMESPACE` + `pin_ref_prefix` as the single source of truth for the pin path) - `context/cli/mutation-trace-external-taint.md` (the worktree-local mutation-cursor durability boundary in `cli/src/services/mutation_trace/runtime/external_taint.rs`, built by the `mutation-cursor-external-taint` plan: the `ExternalTaintMarker` primitive — `new(git_dir)`/`exists()`/`persist()`/`clear()` over an empty file at `/sce/mutation-cursor-tainted` whose existence is its entire state, `checkout::persist_checkout_id_inner`-style durability (`sync_data` plus best-effort `#[cfg(unix)]` parent-dir `sync_all`), idempotent persist/clear, `NotFound`-on-clear as success, no `Drop` deletion — as the concrete runtime refinement of the abstract `ProtocolState.external_taint`; armed by the reshaped `coordinate()` entrypoint write-ahead after the `WorktreeLock` and before Agent Trace DB acquisition (a caller-supplied DB provider closure), cleared only on a successful `CoordinateOutcome`, with dedicated fail-closed pre-commit `CoordinateError::ExternalTaintMarker` (`Inspect`/`Persist` only)/`AgentTraceDbUnavailable` variants plus a post-commit `MarkerClearAfterCommit { source, committed }` that carries the durable outcome so a failed trailing clear never hides a committed `MutationEvent`; an inherited marker seeds an invocation-local `external_taint_pending` flag that overlays `protocol::database_failure` onto each freshly loaded projection so `recover` runs once against the captured snapshot, held across a losing recovery CAS and cleared once it lands) @@ -81,7 +82,7 @@ Feature/domain context: - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) - `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,mixed,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact touched-line attribution counts from canonical `post_commit_patch` hunks reusing the same per-hunk classification, plus per-file trace data from patch inputs via `intersect_patches(constructed_patch, post_commit_patch)` then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation with a required lookup `url` derived from top-level `AgentTrace.id`, nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, optional canonical session links derived from matched touched-line provenance, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) -- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence, DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts.) +- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence, DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — deferring the full contract to `context/cli/mutation-scope-hook-ingress.md`.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus current Rust evaluator seam and OpenCode/Claude delegation references, including config schema, argv-prefix matching, shell/nix unwrapping, custom-policy `satisfied_by` wrapper exemption, fixed preset catalog/messages, and precedence rules) - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) diff --git a/context/overview.md b/context/overview.md index 3b12b6a8..fa989a89 100644 --- a/context/overview.md +++ b/context/overview.md @@ -2,7 +2,7 @@ This repository maintains shared assistant configuration for OpenCode, Claude, Pi, and Codex from a single canonical Pkl authoring source. One typed workflow catalog owns the six workflows' shared identity and target routing metadata, while canonical workflow/phase modules own behavior and migrated package-local documents, and target renderers own formatting. Generated target layouts are ephemeral: repository builds consume a pre-Cargo generated payload through `SCE_CLI_GENERATED_INPUT_DIR`, crates.io and Flatpak stage packaging-only fallbacks, and `config/.opencode`, `config/.claude`, `config/.pi`, `config/.agents`, and the generated working-tree SCE config schema are not committed; versioned SCE config schema snapshots live under `schema/v/`. The catalog also marks a workflow `optional` — currently only `brownfield` — which changes nothing about generation and is projected into a generated `config/optional-workflows.json` manifest for install-time consumers. `nix run .#pkl-check-generated` preserves its exact 141-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude, Pi, and Codex; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer). Each of the six catalog workflow skills (not `sce-decision`, which has no user-facing entrypoint on any target) additionally carries a Codex-only `agents/openai.yaml`, rendered by `config/pkl/renderers/codex-metadata.pkl` from the shared catalog's `title`/`description` plus an authored `default_prompt`, with `policy.allow_implicit_invocation: false` so these stateful lifecycle workflows activate only from explicit `$sce-` invocation or Codex's `/skills` discovery, never from conversational relevance alone. The Codex renderer also emits a Codex hook registration file (`config/.codex/hooks.json`) and its fail-open install-guidance hook script (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`), both routing every registered lifecycle event through the single command `sce hooks codex`; the generated command resolves the Git root at invocation time and safely invokes the helper from nested cwd or spaced repository paths, failing open silently when Git-root resolution fails. `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`; setup merges the user-owned `.codex/hooks.json` through the shared structural Codex hook-config service, preserving unrelated valid handlers and rejecting malformed or Codex-invalid documents before the atomic swap. `sce doctor` diagnoses that file per required registration (`PresentAndCurrent`/`Missing`/`Stale`, or `Malformed` for the whole document) rather than by whole-document equality, and separately reports whether Codex has actually marked each structurally current registration trusted by reading (never writing) Codex's own `$CODEX_HOME/config.toml` hook-trust state; `sce doctor --fix` repairs structurally unhealthy registrations through the same merge service but never touches trust state (see `context/sce/doctor-human-text-contract.md`). `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while invalid cwd/path resolution and malformed STDIN payloads fail open with empty stdout, reported model IDs are preserved without fabricated provider prefixes, and missing/blank apply_patch sessions produce no evidence. Delete-File operations and Bash-triggered filesystem mutations remain untracked for Codex. -It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. A new pure, dependency-free `cli/src/services/mutation_trace/` module now exists as a Rust refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol; it currently carries domain types and the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — is registered with `#[allow(dead_code)]`. A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every future harness adapter (Codex, Claude Code, OpenCode, Pi) must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are still not wired into any hook or command. 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`, and `context/cli/mutation-scope-runtime.md`. +It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. A new pure, dependency-free `cli/src/services/mutation_trace/` module now exists as a Rust refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol; it currently carries domain types and the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — is registered with `#[allow(dead_code)]`. A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every future harness adapter (Codex, Claude Code, OpenCode, Pi) must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are now driven by one harness-neutral CLI ingress, the hidden `sce hooks mutation-scope` command (`context/cli/mutation-scope-hook-ingress.md`), which reads a single normalized JSON lifecycle object from STDIN, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, maps it to a `RuntimeBoundary` (`start`/`advance`/`close`/`flush`) or an `abandon_scope()` call forwarding `scope_id`/`event_id`/`actor_kind` verbatim (any `worktree_id` key is refused — worktree identity is derived by the runtime from the invoking checkout), and invokes the runtime with a lazy DB provider closure so DB acquisition stays inside the protected-worktree ordering. Unlike the fail-open `diff-trace`/`conversation-trace` intakes, this ingress never discards a valid boundary: it classifies results by durable completion, returning a non-zero `CliError` for a malformed payload or a pre-completion runtime error while treating the two carried-outcome variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion` — the durable transition succeeded and only the trailing external-taint marker cleanup failed) as empty-stdout success without retrying the transition. Every successful execution emits empty stdout and writes `mutation_trace_*` rows only. It is the generic transport seam; no concrete harness lifecycle adapter (Claude Code, Codex, OpenCode, Pi) — and no `session → ScopeId` / `tool-call → EventId` derivation — is wired to it yet. A separate **read-only** consumer of the same module's persisted mutation-event history is wired, though: the post-commit Agent Trace flow calls `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch(...)` to attribute committed touched lines that direct `diff_traces` evidence does not cover, by replaying the newest 128 events for the invoking worktree (bounded also by a commit attribution cut captured under the worktree lock) oldest-to-newest as one causal per-line provenance lineage rather than matching historical patches independently, resolving the worktree's *existing* checkout identity only, creating no identity and writing no mutation-cursor state, and never inserting into `diff_traces` or `post_commit_patch_intersections` (see `context/cli/mutation-trace-agent-attribution.md` and `context/sce/agent-trace-hooks-command-routing.md`). See also `context/cli/mutation-trace-protocol.md`, `context/cli/mutation-trace-runtime-coordinator.md`, `context/cli/mutation-trace-ref-reconciliation.md`, `context/cli/mutation-trace-protected-worktree.md`, `context/cli/mutation-trace-scope-abandonment.md`, `context/cli/mutation-scope-runtime.md`, and `context/cli/mutation-scope-hook-ingress.md`. The generated `/next-task` workflow persists task-level context-synchronization lifecycle state in each plan (`pending`, `synced`, or `blocked`) so unresolved task synchronization debt survives a session boundary and gates new implementation. Successful `/next-task` execution hands task synchronization an explicit, pre-edit-Git-baseline-relative changed-file list plus implementation, verification, done-check, plan-update, and context-impact evidence, recorded directly on the completed task (`Completed`, `Files changed`, `Result`, `Verify`, `Context impact`, `Context synchronization`); the five-file root context pass remains mandatory. A later-session sync-debt retry reads that same completed task record directly from the plan by plan path and task ID, with no separate persisted synchronization handoff. `/validate` is validation-only: it runs final checks, writes the Validation Report, and reports `validated`, `failed`, or `blocked` without plan-level context synchronization. diff --git a/context/plans/mutation-scope-hook-ingress.md b/context/plans/mutation-scope-hook-ingress.md new file mode 100644 index 00000000..6c111eda --- /dev/null +++ b/context/plans/mutation-scope-hook-ingress.md @@ -0,0 +1,760 @@ +# Plan: mutation-scope-hook-ingress + +## Change summary + +The mutation-scope runtime (`cli/src/services/mutation_trace/runtime/`) is fully +built: `coordinate()` drives observed `Start`/`Advance`/`Close`/`Flush` +boundaries against a real worktree behind the shared `ProtectedWorktree` safety +prefix, and `abandon_scope()` retires a scope whose final boundary was never +observed. Both are `pub(crate)` re-exported from `runtime/mod.rs`, and the +harness-adapter contract is recorded in `context/cli/mutation-scope-runtime.md`. +Before this plan, nothing called either entrypoint — no hook, plugin, extension, +or command. + +This plan adds one harness-neutral CLI ingress, `sce hooks mutation-scope`, that +reads a single normalized JSON object from STDIN, strictly parses and validates +it, translates it directly into a `RuntimeBoundary` (for `start`/`advance`/ +`close`/`flush`) or an `abandon_scope()` call (for `abandon`), and invokes the +existing runtime with a **lazy** DB provider so DB acquisition stays inside the +runtime's protected-worktree ordering. It is the generic 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. + +State now (after T02): `sce hooks mutation-scope` exists and drives +`coordinate()` / `abandon_scope()`. Concrete Claude Code, Codex, OpenCode, and +Pi lifecycle adapters remain out of scope for this plan (T04 records that +boundary in durable context). + +The ingress owns nothing durable. The external adapter owns `scope_id`, +`event_id`, and `actor_kind`; SCE owns `worktree_id`, Git tree identities, +mutation revisions, and attempt IDs. The payload never accepts `worktree_id` — +worktree identity is derived by the runtime from the invoking checkout. +`scope_id` and `event_id` are translated verbatim into `ScopeId(..)` / +`EventId(..)` with no prefixing, hashing, normalization, UUID generation, or +timestamping, because `EventId` equality is the existing replay/idempotency key. + +Unlike `diff-trace` / `conversation-trace`, a lost mutation-scope lifecycle +boundary can change which scope stays live and therefore alter attribution, so a +valid boundary must never be silently discarded. Two failure classes are +distinguished, matching what the runtime already models: + +- An ordinary runtime error *before* durable completion + (`CoordinateError` / `AbandonScopeError` variants other than the two below) is + a command failure with non-zero exit. +- `CoordinateError::MarkerClearAfterCommit { committed, source }` and + `AbandonScopeError::MarkerClearAfterCompletion { completed, source }` mean the + durable mutation operation **already succeeded** and only the trailing + external-taint marker cleanup failed. The ingress treats these as durable + success: it reports the cleanup failure diagnostically, emits empty stdout, and + exits zero. It does **not** re-run or retry the mutation transition. The marker + stays armed, so the next runtime invocation recovers conservatively per + existing runtime semantics. + +Successful operations produce empty stdout; the ingress serializes no outcome, +revision, worktree ID, or scope state. + +This extends existing behavior and disturbs none of it: no change to +`spec/mutation_cursor.qnt`, `protocol.rs`, the mutation-trace SQL schema, +migrations, `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, or +#259 attribution behavior. The pure mutation-domain types gain no serde derives +for this command. + +## 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 mutation-scope` exists and routes through the normal + CLI/hook command stack (`cli_schema::HooksSubcommand::MutationScope` → + `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` + → `run_hooks_subcommand_in_repo`), hidden with the rest of the `hooks` surface. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::parse::command_runtime` plus `services::hooks::` — a parser test asserts `sce hooks mutation-scope` converts to `HookSubcommand::MutationScope`; `sce hooks --help` does not appear in top-level `sce --help`. +- [x] AC2: The normalized JSON contract strictly supports exactly `start`, + `advance`, `close`, `flush`, `abandon` with exact field validation, rejecting + unknown operation, unknown `actor_kind`, missing/empty/blank `scope_id` or + `event_id`, unexpected fields, any `worktree_id` key, wrong JSON type, and + malformed JSON. `flush` accepts no scope/event/actor fields; `abandon` accepts + only `scope_id`. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope` — focused parser tests cover every operation and every listed rejection case. +- [x] AC3: `start` / `advance` / `close` map exactly to `RuntimeBoundary::Start` + / `Advance` / `Close` and forward `ScopeId`, `EventId`, and `ActorKind` + unchanged (`claude_code → ClaudeCode`, `codex → Codex`, `opencode → OpenCode`, + `pi → Pi`). + - Validate: integration test T03-Test1 asserts durable processed-event keys `(A,e1)`,`(A,e2)`,`(A,e3)` exactly as supplied; T03-Test3 asserts a mismatched `actor_kind` reaches `ScopeIdentityConflict`. +- [x] AC4: `flush` maps to `RuntimeBoundary::Flush` with no scope/event/actor + identity, and drives the runtime's real observed-flush behavior. Against a + baseline tree followed by an unscoped filesystem edit, a `{"operation":"flush"}` + advances `cursor_tree` to the edited Git tree, advances `revision` by one, + writes exactly one `mutation_trace_events` row for the tree transition with + `attribution = IneligibleUnscoped`, invents no `mutation_trace_scopes` row, and + invents no `mutation_trace_processed_events` row. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope` — integration test T03-Test5 asserts each durable value against real Git trees; a `Flush => Ok("")` stub fails it. +- [x] AC5: `abandon` calls `abandon_scope()` directly and never acquires + `Close`/`Flush` snapshot semantics. In `start(A,e1) → record cursor_tree → + unobserved filesystem edit → abandon(A)`, `abandon_scope()` captures no Git + snapshot and no mutation boundary, and `cursor_tree` stays the pre-edit tree — + it does **not** become the edited Git tree — with no `mutation_trace_events` + row for the edit. + - Validate: `rg -n 'RuntimeBoundary|GitSnapshotService|capture_tree|pin_tree|diff_trees|coordinate\(' cli/src/services/hooks/mutation_scope.rs` shows the abandon arm calls only `abandon_scope`; integration test T03-Test4 asserts the `cursor_tree` invariance and absent rows. +- [x] AC6: The normalized payload contains no `worktree_id` field. The + production ingress does not accept, derive from input, or construct a + `WorktreeId`; worktree identity remains exclusively derived by the existing + mutation runtime from the invoking checkout. Test-only (`#[cfg(test)]`) code + may construct `WorktreeId` values purely to fabricate injected + `CoordinateOutcome` / `AbandonScopeOutcome` runtime results. + - Validate — wire contract: a parser regression rejects any payload containing + a `worktree_id` key (the production rejection diagnostic + `field 'worktree_id' is not accepted` is expected to contain that literal, so + do not text-ban the string outright). + - Validate — production code: inspecting only the module body *before* + `#[cfg(test)]`, it never constructs `WorktreeId(...)`, never reads a + worktree-identity field from the JSON payload, and never passes a + `WorktreeId` into `coordinate()` / `abandon_scope()`. Confirm by reading the + production section, or with a check scoped to it — e.g. `rg -n 'WorktreeId' + cli/src/services/hooks/mutation_scope.rs` shows matches only within the + `#[cfg(test)]` module. +- [x] AC7: DB acquisition stays lazy inside the runtime's protected-worktree + sequence — the ingress passes a `FnOnce` provider closure to `coordinate()` and + `abandon_scope()`, never an already-open handle, reusing + `open_agent_trace_db_for_hook_runtime`. + - Validate: `rg -n 'open_agent_trace_db_for_hook_runtime|open_db|coordinate\(|abandon_scope\(' cli/src/services/hooks/mutation_scope.rs` shows the DB resolver is invoked only inside the provider closure passed to the runtime entrypoint. +- [x] AC8: Runtime results are classified by durable completion, not fail-open: + - a pre-completion `CoordinateError` / `AbandonScopeError` (any variant other + than the two carried-outcome variants) → `CliError` / non-zero exit; + - `CoordinateError::MarkerClearAfterCommit { committed, .. }` and + `AbandonScopeError::MarkerClearAfterCompletion { completed, .. }` → durable + success: the carried outcome is treated as the result, the marker-cleanup + failure is logged diagnostically, stdout is empty, exit is zero, and the + runtime transition is **not** executed or retried again; + - no `"failed open" / exit 0` branch exists for a dropped or malformed boundary. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope` — tests assert (a) malformed JSON and an injected ordinary runtime error return `Err`; (b) an injected `MarkerClearAfterCommit` and an injected `MarkerClearAfterCompletion` each return `Ok` with empty stdout and the runtime entrypoint is invoked exactly once (no second transition); `rg -n 'fail.open|failed open|exit 0' cli/src/services/hooks/mutation_scope.rs` returns nothing. +- [x] AC9: A successful mutation-scope hook execution produces empty stdout, with + no serialized `CoordinateOutcome`, `AbandonScopeOutcome`, `MutationEvent`, + revision, worktree ID, or scope state. + - Validate: integration tests assert the returned success string is empty (zero stdout bytes). +- [x] AC10: A real Git/DB `Start → edit → Advance → Close` flow through the + ingress creates scope status `Closed`, processed events `(A,e1)`,`(A,e2)`,`(A,e3)`, + exactly one mutation event over the edit interval with `AiExclusive(A)`, and a + cursor tree equal to the final observed Git tree. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::mutation_scope` — integration test T03-Test1 asserts each durable value against real Git trees, with no manually inserted mutation event. +- [x] AC11: A replayed `(ScopeId, EventId)` boundary through the ingress is + fully idempotent. In `start(A,e1) → edit → advance(A,e2)`, with `revision`, + `mutation_trace_events` count, and `mutation_trace_processed_events` count + snapshotted immediately after the first `advance(A,e2)`, a second + `advance(A,e2)` leaves all three counts unchanged and `mutation_trace_processed_events` + holds exactly one key `(A,e2)`. The test fails if the ingress regenerates, + prefixes, hashes, or otherwise transforms `EventId`. + - Validate: integration test T03-Test2 snapshots the three values before replay and asserts equality plus the single `(A,e2)` processed key via direct DB reads. +- [x] AC12: A conflicting `ActorKind` for an existing scope commits no second + boundary. In `start(A,e1,claude_code)` (recording `revision`, processed-event + rows, scope actor/status) → `advance(A,e2,codex)`, the runtime returns + `ScopeIdentityConflict` and afterwards: `revision` is unchanged, `(A,e2)` is + absent from `mutation_trace_processed_events`, scope `A` still has + `actor_kind = claude_code` and an unchanged status, and no new + `mutation_trace_events` row exists (the assertion holds even when the rejected + boundary observed no tree change). + - Validate: integration test T03-Test3 asserts each value. +- [x] AC13: Abandonment through the ingress retains no-snapshot semantics and + the correct durable transition. In `start(A,e1) → record cursor_tree → + unobserved edit → abandon(A)`: scope `A` is `Abandoned`, `revision` advances + exactly once for the abandonment, `needs_rebaseline` is `true`, `cursor_tree` + remains the pre-edit tree (never the edited Git tree), and no + `mutation_trace_events` row for the edit and no `mutation_trace_processed_events` + row from abandon exist. + - Validate: integration test T03-Test4 asserts each durable value after the sequence, exercising the real `abandon_scope()` path. +- [x] AC14: The ingress reaches durable storage only through the existing + mutation runtime, writing `mutation_trace_*` rows only. No production ingress + path writes `diff_traces`, `post_commit_patch_intersections`, or `agent_traces`. + The T03 integration tests are expected to read/assert those three table names in + order to prove they stay empty; the ban is on the production write path, not on + table names appearing in the source file. + - Validate: the T03 regressions assert `diff_traces`, `post_commit_patch_intersections`, and `agent_traces` each hold zero rows after every ingress flow; and the production implementation (excluding the `#[cfg(test)]` section) calls none of `insert_diff_trace`, `DiffTraceInsert`, `insert_post_commit_patch_intersection`, `PostCommitPatchIntersectionInsert`, `insert_agent_trace`, or `AgentTraceInsert` — confirm by inspecting the non-test module body, or `rg -n 'insert_diff_trace|DiffTraceInsert|insert_post_commit_patch_intersection|PostCommitPatchIntersectionInsert|insert_agent_trace|AgentTraceInsert' cli/src/services/hooks/mutation_scope.rs` shows hits only within the `#[cfg(test)]` test module, if any. +- [x] AC15: No change attributable to this plan exists in + `spec/mutation_cursor.qnt`, `cli/src/services/mutation_trace/protocol.rs`, + `cli/migrations/agent-trace-repository/`, or the Agent Trace schema. + - Validate: `git diff origin/mutation-trace-agent-attribution -- spec/mutation_cursor.qnt cli/src/services/mutation_trace/protocol.rs cli/migrations/agent-trace-repository/ config/schema/agent-trace.schema.json` is empty. +- [x] AC16: Durable context establishes this as the generic adapter seam and + states that concrete harness lifecycle integration remains future work. + - Validate: `context/cli/mutation-scope-hook-ingress.md` exists and documents the JSON contract, operation mapping, identity ownership, the no-`worktree_id` rule, the no-`ScopeId`/`EventId`-generation rule, the non-fail-open error semantics including the marker-clear-after-durable-completion classification, the lazy DB-provider requirement, the empty-stdout contract, abandonment ownership, and the generic-ingress vs harness-adapter boundary; `context/cli/mutation-scope-runtime.md` Status says a generic ingress now drives the runtime with no concrete harness adapter wired. + +### Full validation + +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::` +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` +- `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` +- `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` +- `nix run .#pkl-check-generated` +- `nix flake check` +- Confirm the branch diff is against `origin/mutation-trace-agent-attribution`, not `main`. + +### Context sync + +T02's task synchronization already removed the now-false "the runtime is not +wired into any hook or command" / "nothing calls either entrypoint" clauses from +`context/overview.md`, `context/context-map.md`, +`context/cli/mutation-scope-runtime.md`, +`context/cli/mutation-trace-runtime-coordinator.md`, +`context/cli/mutation-trace-scope-abandonment.md`, and +`context/cli/mutation-trace-protocol.md`, and added the command to the +implemented surface list in +`context/sce/agent-trace-hooks-command-routing.md`. The remaining work below is +T04's comprehensive pass — chiefly the new domain file and the full non-fail-open +intake contract. + +- New: `context/cli/mutation-scope-hook-ingress.md` — the normalized JSON + contract, operation mapping, identity ownership, why `worktree_id` is refused, + why `ScopeId`/`EventId` are not generated, why errors are not fail-open, the + marker-clear-after-durable-completion classification, the lazy DB-provider + requirement, the silent-success stdout contract, abandonment ownership, and the + generic-ingress vs harness-adapter boundary. +- `context/cli/mutation-scope-runtime.md` — Status section: a generic + mutation-scope hook ingress now drives the runtime; no concrete Claude Code, + Codex, OpenCode, or Pi lifecycle adapter is wired yet. +- `context/sce/agent-trace-hooks-command-routing.md` — add `sce hooks + mutation-scope` to the implemented command surface and describe its + non-fail-open intake contract (including the two carried-outcome success + variants) distinct from `diff-trace`/`conversation-trace`. +- `context/context-map.md` — register the new domain file and update the + hooks-routing / mutation-scope-runtime annotations. +- `context/overview.md` — finalize the `mutation_trace` paragraph (T02 already + replaced the "still not wired into any hook or command" clause with the + generic-ingress description; T04 confirms it against the shipped contract). + +## 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/services/hooks/mutation_scope.rs` (new), + `cli/src/services/hooks/mod.rs` (module registration + dispatch + + `hook_runtime_invocation_name`), `cli/src/cli_schema.rs` + (`HooksSubcommand::MutationScope`), `cli/src/services/parse/command_runtime.rs` + (`convert_hooks_subcommand_request` + tests), the five durable-context files in + **Context sync**. +- **Out of scope:** any concrete harness mapping (Claude Code hooks, Codex hook + mapping, OpenCode plugin, Pi extension); `SubagentStart`/`SubagentStop`/ + `PostToolUse`/tool-call translation; `session → ScopeId` or `tool-call → + EventId` derivation; PID tracking, process supervisors, staleness detection, + automatic scope abandonment; harness settings generation or `sce setup` + integration for the new hook; new mutation protocol semantics, Quint actions, + DB tables, migrations, retention, or GC; any Agent Trace attribution, + post-commit, diff-trace, or conversation-trace behavior change; #259 + attribution behavior. Changing the underlying runtime error types. +- **Constraints:** stacked on `mutation-trace-agent-attribution`; confirm all + diffs against `origin/mutation-trace-agent-attribution`. Reuse + `open_agent_trace_db_for_hook_runtime` as the sole DB resolver — no second + DB-opening implementation. Keep hook transport types local to + `mutation_scope.rs`. Do not add serde derives to `mutation_trace` domain types. + Preserve the runtime safety-prefix ordering: the DB provider closure must be + the value passed to `coordinate()` / `abandon_scope()`, never invoked before + them. Follow the repo's inline `#[cfg(test)] mod tests` + RAII + `tempfile::TempDir` + real `git init` / `RepositoryAgentTraceDb` pattern + (`context/patterns.md`). +- **Non-goal:** turning `abandon` into a `RuntimeBoundary` variant, or capturing + a Git snapshot on behalf of abandonment. The ingress does not interpret valid + runtime outcomes (`accepted = false`, `observes = false`, duplicate processed + event, no tree change, `Abandoned` / `AlreadyTerminal` / `RecoveryRequired`) — + those stay existing runtime semantics — beyond the marker-clear-after-durable- + completion classification required by AC8. There is never a completed task + state in which `sce hooks mutation-scope` accepts a valid lifecycle boundary + but does not drive the runtime. + +## Assumptions + +- The user's cover note allows ordinary local shape choices ("the exact Rust + shape may differ if repository conventions suggest something cleaner"). +- serde's `deny_unknown_fields` is not honored on the variant structs of an + internally tagged (`tag = "operation"`) enum. The parser therefore validates + "unexpected fields" / `worktree_id` rejection explicitly — e.g. deserialize the + tag first, then deserialize the remainder into a per-operation struct that does + carry `#[serde(deny_unknown_fields)]`, or match on a `serde_json::Map` and + reject unknown keys. This is a local implementation choice recorded here rather + than asked, per the note above. +- The command stays hidden because `HOOKS_SHOW_IN_TOP_LEVEL_HELP` is already + `false`; no new visibility flag is needed. +- The ingress reads the invoking checkout via the same `repository_root` + (`std::env::current_dir()`) that `run_hooks_subcommand` already resolves for + every hook; the runtime derives `git_dir` and `WorktreeId` from it. +- `CoordinateError::MarkerClearAfterCommit { source, committed: Box }` + and `AbandonScopeError::MarkerClearAfterCompletion { source, completed: Box }` + are the exact carried-outcome variants on the current base + (`origin/mutation-trace-agent-attribution`), confirmed by inspection of + `runtime/coordinator.rs` and `runtime/scope_runtime.rs`. +- Flush against an unscoped edit on a healthy, non-rebaseline worktree yields a + single `mutation_trace_events` row with `attribution = IneligibleUnscoped` and + no processed-event row (`is_hook(Flush) == false`), confirmed by inspection of + `protocol.rs` (`evaluate`/`apply`) and `types.rs` (`is_hook`, + `boundary_event_key`). + +## Task stack + +- [x] T01: `Add strict normalized mutation-scope payload parser` (status:done) + - Task ID: T01 + - Scope: In — new `cli/src/services/hooks/mutation_scope.rs` with the local + `MutationScopePayload` transport enum (`Start`/`Advance`/`Close`/`Flush`/ + `Abandon`), the ingress-local actor-kind parser mapping the four wire strings + (`claude_code`/`codex`/`opencode`/`pi`) to `ActorKind`, a + `parse_mutation_scope_payload(&str) -> Result` + function with strict wire-format validation (empty/blank string rejection, + unknown operation, unknown actor kind, missing fields, empty/blank + `scope_id`/`event_id`, unexpected fields, any `worktree_id` key, wrong JSON + type, malformed JSON, `flush` rejecting any scope/event/actor field, + `abandon` accepting only `scope_id`), the `pub mod mutation_scope;` + registration in `hooks/mod.rs` (under the existing `#[allow(dead_code)]` + module policy if needed), and focused `#[cfg(test)] mod tests` covering every + operation and every rejection case. Out — CLI routing, runtime invocation, + STDIN reading, context. + - Dependencies: none + - Done when: the module compiles, `parse_mutation_scope_payload` accepts each + valid operation shape and rejects each listed invalid case, and the parser + tests pass; `cargo clippy --all-targets -- -D warnings` and `fmt --check` are + clean for the new file. + - Verify: `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 clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings`. + - Completed: 2026-09-04 + - Files changed: + - `cli/src/services/hooks/mutation_scope.rs` (new) — `MutationScopePayload` + transport enum (`Start`/`Advance`/`Close`/`Flush`/`Abandon`), private + `parse_actor_kind` wire-string mapper, `parse_mutation_scope_payload` + strict parser, and `#[cfg(test)] mod tests` (20 tests). + - `cli/src/services/hooks/mod.rs` — `#[allow(dead_code)] pub mod mutation_scope;` + registration only. + - Result: Added the ingress-local wire-format parser. `MutationScopePayload` + carries `scope_id`/`event_id` as raw owned strings (no trim/prefix/hash) and + `actor_kind` as the real `ActorKind`. `parse_mutation_scope_payload` guards + empty/blank input, requires a JSON object with a required `operation` string, + and dispatches to a shared scope-boundary parser (`start`/`advance`/`close`), + a flush parser, or an abandon parser. Each enforces its exact allowed key set + via `reject_unexpected_keys`, which emits a dedicated diagnostic for any + `worktree_id` key. Rejected: malformed JSON, non-object JSON, wrong field + types, unknown operation, unknown `actor_kind` (`claude_code`/`codex`/ + `opencode`/`pi` only), missing/blank `scope_id`/`event_id`, unexpected fields, + `flush` with any scope/event/actor field, `abandon` with anything but + `scope_id`. Error type is `anyhow::Result` with the existing + `Invalid