diff --git a/cli/src/services/agent_trace.rs b/cli/src/services/agent_trace.rs index 5ec19dfe4..a6a0862ba 100644 --- a/cli/src/services/agent_trace.rs +++ b/cli/src/services/agent_trace.rs @@ -24,7 +24,7 @@ use uuid::{NoContext, Timestamp, Uuid}; use super::patch::{ intersect_patches, parse_patch, FileChangeKind, ParsedPatch, PatchFileChange, PatchHunk, - TouchedLineKind, + TouchedLine, TouchedLineKind, }; use super::version::PACKAGE_VERSION; @@ -92,6 +92,12 @@ pub struct AgentTraceMetadataInput<'a> { pub tool_version: Option<&'a str>, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AgentTraceEvidence<'a> { + pub direct_patch: &'a ParsedPatch, + pub mutation_ai_patch: &'a ParsedPatch, +} + #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum AgentTraceVcsType { @@ -380,6 +386,7 @@ pub struct AgentTrace { /// touched lines differ from the `post_commit_patch` hunk's touched lines. /// - `HunkContributor::Unknown` when no `intersection_patch` hunk with the same /// `old_start` exists for this `post_commit_patch` hunk. +#[allow(dead_code)] pub fn classify_hunk( post_commit_hunk: &PatchHunk, intersection_hunks: &[PatchHunk], @@ -398,6 +405,60 @@ pub fn classify_hunk( } } +type CoverageLineKey<'a> = (TouchedLineKind, u64, &'a str); + +fn coverage_line_key(line: &TouchedLine) -> CoverageLineKey<'_> { + (line.kind, line.line_number, line.content.as_str()) +} + +fn combined_covered_line_count( + post_commit_hunk: &PatchHunk, + direct_hunk: Option<&PatchHunk>, + mutation_hunk: Option<&PatchHunk>, +) -> usize { + let mut direct_pool: Vec> = direct_hunk + .map(|hunk| hunk.lines.iter().map(coverage_line_key).collect()) + .unwrap_or_default(); + let mut mutation_pool: Vec> = mutation_hunk + .map(|hunk| hunk.lines.iter().map(coverage_line_key).collect()) + .unwrap_or_default(); + + post_commit_hunk + .lines + .iter() + .filter(|line| { + let key = coverage_line_key(line); + if let Some(index) = direct_pool.iter().position(|candidate| *candidate == key) { + direct_pool.swap_remove(index); + true + } else if let Some(index) = mutation_pool.iter().position(|candidate| *candidate == key) + { + mutation_pool.swap_remove(index); + true + } else { + false + } + }) + .count() +} + +fn classify_hunk_combined( + post_commit_hunk: &PatchHunk, + direct_hunk: Option<&PatchHunk>, + mutation_hunk: Option<&PatchHunk>, +) -> HunkContributor { + let total = post_commit_hunk.lines.len(); + let covered = combined_covered_line_count(post_commit_hunk, direct_hunk, mutation_hunk); + + if total > 0 && covered == total { + HunkContributor::Ai + } else if covered > 0 { + HunkContributor::Mixed + } else { + HunkContributor::Unknown + } +} + #[allow(dead_code)] pub(crate) fn patches_have_overlap( candidate_patch: &ParsedPatch, @@ -495,6 +556,7 @@ fn parse_embedded_deleted_patch(file: &PatchFileChange) -> Option { fn build_trace_file( post_commit_file: &PatchFileChange, intersection_patch: &ParsedPatch, + mutation_ai_patch: &ParsedPatch, conversation_url: &str, line_changes: &mut LineChangeAttribution, ) -> Option { @@ -506,33 +568,38 @@ fn build_trace_file( .files .iter() .find(|ifile| ifile.new_path == post_commit_file.new_path); + let mutation_file = mutation_ai_patch + .files + .iter() + .find(|mfile| trace_path(mfile) == trace_path(post_commit_file)); let conversations = post_commit_file .hunks .iter() .map(|post_commit_hunk| { - let (contributor_kind, contributor_model_id, matched_intersection_hunk) = - match intersection_file { - Some(ifile) => { - let contributor_kind = classify_hunk(post_commit_hunk, &ifile.hunks); - let matched_intersection_hunk = ifile - .hunks - .iter() - .find(|h| h.old_start == post_commit_hunk.old_start); - let contributor_model_id = match contributor_kind { - HunkContributor::Ai | HunkContributor::Mixed => { - matched_intersection_hunk.and_then(|hunk| hunk.model_id.clone()) - } - HunkContributor::Unknown => None, - }; - ( - contributor_kind, - contributor_model_id, - matched_intersection_hunk, - ) - } - None => (HunkContributor::Unknown, None, None), - }; + let matched_intersection_hunk = intersection_file.and_then(|ifile| { + ifile + .hunks + .iter() + .find(|h| h.old_start == post_commit_hunk.old_start) + }); + let matched_mutation_hunk = mutation_file.and_then(|mfile| { + mfile + .hunks + .iter() + .find(|h| h.old_start == post_commit_hunk.old_start) + }); + let contributor_kind = classify_hunk_combined( + post_commit_hunk, + matched_intersection_hunk, + matched_mutation_hunk, + ); + let contributor_model_id = match contributor_kind { + HunkContributor::Ai | HunkContributor::Mixed => { + matched_intersection_hunk.and_then(|hunk| hunk.model_id.clone()) + } + HunkContributor::Unknown => None, + }; record_hunk_line_changes(line_changes, contributor_kind, post_commit_hunk); let related_session_ids = matched_intersection_hunk .into_iter() @@ -568,62 +635,71 @@ fn build_trace_file( }) } -/// Build the minimal agent-trace payload from two patches. -/// -/// Computes `intersection_patch = intersect_patches(constructed_patch, post_commit_patch)`, -/// then iterates over `post_commit_patch`'s files and hunks to classify each hunk -/// against `intersection_patch`. Deleted `.patch` files whose removed contents are -/// themselves valid patch text are expanded into trace entries for the embedded -/// patch's files. Metadata-only entries with no hunks are omitted. The output -/// preserves the surrounding `post_commit_patch` file ordering and per-file hunk -/// ordering. -/// -/// Files in `post_commit_patch` that have no corresponding file in -/// `intersection_patch` still appear in the output with all hunks classified -/// as `Unknown`. #[allow(dead_code)] pub fn build_agent_trace( constructed_patch: &ParsedPatch, post_commit_patch: &ParsedPatch, metadata: AgentTraceMetadataInput<'_>, ) -> Result { + let empty_mutation_ai_patch = ParsedPatch { files: Vec::new() }; + + build_agent_trace_from_evidence( + AgentTraceEvidence { + direct_patch: constructed_patch, + mutation_ai_patch: &empty_mutation_ai_patch, + }, + post_commit_patch, + metadata, + ) +} + +#[allow(dead_code)] +pub fn build_agent_trace_from_evidence( + evidence: AgentTraceEvidence<'_>, + post_commit_patch: &ParsedPatch, + metadata: AgentTraceMetadataInput<'_>, +) -> Result { + let AgentTraceEvidence { + direct_patch, + mutation_ai_patch, + } = evidence; + let commit_time = parse_commit_timestamp(metadata.commit_timestamp)?; let id = generate_agent_trace_id(commit_time)?; let conversation_url = agent_trace_conversation_url(&id); let timestamp = metadata.commit_timestamp.to_owned(); - let intersection_patch = intersect_patches(constructed_patch, post_commit_patch); + let intersection_patch = intersect_patches(direct_patch, post_commit_patch); + let empty_mutation_ai_patch = ParsedPatch { files: Vec::new() }; let mut files = Vec::new(); let mut line_changes = LineChangeAttribution::default(); for post_commit_file in &post_commit_patch.files { if let Some(embedded_patch) = parse_embedded_deleted_patch(post_commit_file) { - // The literal deleted-`.patch` file's own hunks describe the actual - // canonical commit content, so they are classified and counted here - // against the top-level `intersection_patch` even though they never - // produce a `Conversation` in this branch. The embedded reconstructed - // hunks below describe the deleted patch's logical content, not the - // canonical commit, and must never be counted toward `line_changes`. - // Matched by `old_path` (always non-empty for a deleted file), not - // `new_path` (always empty for every deleted file, which would - // otherwise collide across multiple deleted files in the same patch). + let direct_file = intersection_patch + .files + .iter() + .find(|ifile| ifile.old_path == post_commit_file.old_path); + let mutation_file = mutation_ai_patch + .files + .iter() + .find(|mfile| trace_path(mfile) == trace_path(post_commit_file)); for hunk in &post_commit_file.hunks { - let kind = intersection_patch - .files - .iter() - .find(|ifile| ifile.old_path == post_commit_file.old_path) - .map_or(HunkContributor::Unknown, |ifile| { - classify_hunk(hunk, &ifile.hunks) - }); + let direct_hunk = direct_file + .and_then(|ifile| ifile.hunks.iter().find(|h| h.old_start == hunk.old_start)); + let mutation_hunk = mutation_file + .and_then(|mfile| mfile.hunks.iter().find(|h| h.old_start == hunk.old_start)); + let kind = classify_hunk_combined(hunk, direct_hunk, mutation_hunk); record_hunk_line_changes(&mut line_changes, kind, hunk); } - let embedded_intersection = intersect_patches(constructed_patch, &embedded_patch); + let embedded_intersection = intersect_patches(direct_patch, &embedded_patch); let mut discarded_line_changes = LineChangeAttribution::default(); files.extend(embedded_patch.files.iter().filter_map(|embedded_file| { build_trace_file( embedded_file, &embedded_intersection, + &empty_mutation_ai_patch, &conversation_url, &mut discarded_line_changes, ) @@ -634,6 +710,7 @@ pub fn build_agent_trace( if let Some(trace_file) = build_trace_file( post_commit_file, &intersection_patch, + mutation_ai_patch, &conversation_url, &mut line_changes, ) { diff --git a/cli/src/services/agent_trace/fixtures/direct_only/direct.patch b/cli/src/services/agent_trace/fixtures/direct_only/direct.patch new file mode 100644 index 000000000..7efe245f0 --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/direct_only/direct.patch @@ -0,0 +1,10 @@ +Index: src/widget.ts +=================================================================== +--- src/widget.ts ++++ src/widget.ts +@@ -2,1 +2,5 @@ + line two ++alpha added ++beta added ++gamma added ++delta added diff --git a/cli/src/services/agent_trace/fixtures/direct_only/golden.json b/cli/src/services/agent_trace/fixtures/direct_only/golden.json new file mode 100644 index 000000000..30f208fe1 --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/direct_only/golden.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://agent-trace.dev/schemas/v1/trace-record.json", + "version": "0.1.0", + "id": "019db9da-fdb0-7a09-bcd3-9565bfba8bf5", + "timestamp": "2026-04-23T10:20:30Z", + "vcs": { + "type": "git", + "revision": "a0b1c2d3e4f5a6b7c8d9e0f11223344556677889" + }, + "tool": { + "name": "claude-code", + "version": "9.9.9" + }, + "metadata": { + "sce": { + "version": "0.1.0", + "line_changes": { + "ai": { "added": 4, "removed": 0 }, + "mixed": { "added": 0, "removed": 0 }, + "unknown": { "added": 0, "removed": 0 } + } + } + }, + "files": [ + { + "path": "src/widget.ts", + "conversations": [ + { + "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "contributor": { + "type": "ai", + "model_id": "claude-sonnet-5" + }, + "ranges": [ + { + "start_line": 2, + "end_line": 6, + "content_hash": "murmur3:191372d8" + } + ], + "related": [ + { + "type": "session", + "url": "https://sce.crocoder.dev/sessions/sess-direct" + } + ] + } + ] + } + ] +} diff --git a/cli/src/services/agent_trace/fixtures/direct_only/mutation_ai.patch b/cli/src/services/agent_trace/fixtures/direct_only/mutation_ai.patch new file mode 100644 index 000000000..e69de29bb diff --git a/cli/src/services/agent_trace/fixtures/direct_only/post_commit.patch b/cli/src/services/agent_trace/fixtures/direct_only/post_commit.patch new file mode 100644 index 000000000..ca55678da --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/direct_only/post_commit.patch @@ -0,0 +1,10 @@ +diff --git a/src/widget.ts b/src/widget.ts +index 1111111..2222222 100644 +--- a/src/widget.ts ++++ b/src/widget.ts +@@ -2,1 +2,5 @@ + line two ++alpha added ++beta added ++gamma added ++delta added diff --git a/cli/src/services/agent_trace/fixtures/direct_plus_mutation/direct.patch b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/direct.patch new file mode 100644 index 000000000..4e89ef912 --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/direct.patch @@ -0,0 +1,10 @@ +Index: src/widget.ts +=================================================================== +--- src/widget.ts ++++ src/widget.ts +@@ -2,1 +2,5 @@ + line two ++alpha added ++beta added + gamma added + delta added diff --git a/cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json new file mode 100644 index 000000000..87ab93f35 --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://agent-trace.dev/schemas/v1/trace-record.json", + "version": "0.1.0", + "id": "019db9da-fdb0-7ed3-b6dc-a0e70ac76862", + "timestamp": "2026-04-23T10:20:30Z", + "vcs": { + "type": "git", + "revision": "a0b1c2d3e4f5a6b7c8d9e0f11223344556677889" + }, + "tool": { + "name": "claude-code", + "version": "9.9.9" + }, + "metadata": { + "sce": { + "version": "0.1.0", + "line_changes": { + "ai": { "added": 4, "removed": 0 }, + "mixed": { "added": 0, "removed": 0 }, + "unknown": { "added": 0, "removed": 0 } + } + } + }, + "files": [ + { + "path": "src/widget.ts", + "conversations": [ + { + "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "contributor": { + "type": "ai", + "model_id": "claude-sonnet-5" + }, + "ranges": [ + { + "start_line": 2, + "end_line": 6, + "content_hash": "murmur3:191372d8" + } + ], + "related": [ + { + "type": "session", + "url": "https://sce.crocoder.dev/sessions/sess-direct" + } + ] + } + ] + } + ] +} diff --git a/cli/src/services/agent_trace/fixtures/direct_plus_mutation/mutation_ai.patch b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/mutation_ai.patch new file mode 100644 index 000000000..de1e23775 --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/mutation_ai.patch @@ -0,0 +1,10 @@ +Index: src/widget.ts +=================================================================== +--- src/widget.ts ++++ src/widget.ts +@@ -2,1 +2,5 @@ + line two + alpha added + beta added ++gamma added ++delta added diff --git a/cli/src/services/agent_trace/fixtures/direct_plus_mutation/post_commit.patch b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/post_commit.patch new file mode 100644 index 000000000..ca55678da --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/post_commit.patch @@ -0,0 +1,10 @@ +diff --git a/src/widget.ts b/src/widget.ts +index 1111111..2222222 100644 +--- a/src/widget.ts ++++ b/src/widget.ts +@@ -2,1 +2,5 @@ + line two ++alpha added ++beta added ++gamma added ++delta added diff --git a/cli/src/services/agent_trace/fixtures/exclusive_without_direct/direct.patch b/cli/src/services/agent_trace/fixtures/exclusive_without_direct/direct.patch new file mode 100644 index 000000000..e69de29bb diff --git a/cli/src/services/agent_trace/fixtures/exclusive_without_direct/golden.json b/cli/src/services/agent_trace/fixtures/exclusive_without_direct/golden.json new file mode 100644 index 000000000..aca4e42fb --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/exclusive_without_direct/golden.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://agent-trace.dev/schemas/v1/trace-record.json", + "version": "0.1.0", + "id": "019db9da-fdb0-7e79-8782-b0584a8d51f4", + "timestamp": "2026-04-23T10:20:30Z", + "vcs": { + "type": "git", + "revision": "a0b1c2d3e4f5a6b7c8d9e0f11223344556677889" + }, + "metadata": { + "sce": { + "version": "0.1.0", + "line_changes": { + "ai": { "added": 4, "removed": 0 }, + "mixed": { "added": 0, "removed": 0 }, + "unknown": { "added": 0, "removed": 0 } + } + } + }, + "files": [ + { + "path": "src/widget.ts", + "conversations": [ + { + "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 2, + "end_line": 6, + "content_hash": "murmur3:191372d8" + } + ] + } + ] + } + ] +} diff --git a/cli/src/services/agent_trace/fixtures/exclusive_without_direct/mutation_ai.patch b/cli/src/services/agent_trace/fixtures/exclusive_without_direct/mutation_ai.patch new file mode 100644 index 000000000..7efe245f0 --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/exclusive_without_direct/mutation_ai.patch @@ -0,0 +1,10 @@ +Index: src/widget.ts +=================================================================== +--- src/widget.ts ++++ src/widget.ts +@@ -2,1 +2,5 @@ + line two ++alpha added ++beta added ++gamma added ++delta added diff --git a/cli/src/services/agent_trace/fixtures/exclusive_without_direct/post_commit.patch b/cli/src/services/agent_trace/fixtures/exclusive_without_direct/post_commit.patch new file mode 100644 index 000000000..ca55678da --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/exclusive_without_direct/post_commit.patch @@ -0,0 +1,10 @@ +diff --git a/src/widget.ts b/src/widget.ts +index 1111111..2222222 100644 +--- a/src/widget.ts ++++ b/src/widget.ts +@@ -2,1 +2,5 @@ + line two ++alpha added ++beta added ++gamma added ++delta added diff --git a/cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/direct.patch b/cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/direct.patch new file mode 100644 index 000000000..e69de29bb diff --git a/cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/golden.json b/cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/golden.json new file mode 100644 index 000000000..51d2b3719 --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/golden.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://agent-trace.dev/schemas/v1/trace-record.json", + "version": "0.1.0", + "id": "019db9da-fdb0-7cb6-9185-75e28b23b264", + "timestamp": "2026-04-23T10:20:30Z", + "vcs": { + "type": "git", + "revision": "a0b1c2d3e4f5a6b7c8d9e0f11223344556677889" + }, + "metadata": { + "sce": { + "version": "0.1.0", + "line_changes": { + "ai": { "added": 2, "removed": 0 }, + "mixed": { "added": 0, "removed": 0 }, + "unknown": { "added": 2, "removed": 0 } + } + } + }, + "files": [ + { + "path": "src/widget.ts", + "conversations": [ + { + "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 2, + "end_line": 4, + "content_hash": "murmur3:ee57a4f8" + } + ] + }, + { + "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "contributor": { + "type": "unknown" + }, + "ranges": [ + { + "start_line": 10, + "end_line": 12, + "content_hash": "murmur3:9d2517c0" + } + ] + } + ] + } + ] +} diff --git a/cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/mutation_ai.patch b/cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/mutation_ai.patch new file mode 100644 index 000000000..ac66464eb --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/mutation_ai.patch @@ -0,0 +1,8 @@ +Index: src/widget.ts +=================================================================== +--- src/widget.ts ++++ src/widget.ts +@@ -2,1 +2,3 @@ + line two ++alpha added ++beta added diff --git a/cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/post_commit.patch b/cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/post_commit.patch new file mode 100644 index 000000000..fda008d92 --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/post_commit.patch @@ -0,0 +1,12 @@ +diff --git a/src/widget.ts b/src/widget.ts +index 1111111..2222222 100644 +--- a/src/widget.ts ++++ b/src/widget.ts +@@ -2,1 +2,3 @@ + line two ++alpha added ++beta added +@@ -8,1 +10,3 @@ + line eight ++gamma added ++delta added diff --git a/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/direct.patch b/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/direct.patch new file mode 100644 index 000000000..e69de29bb diff --git a/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/golden.json b/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/golden.json new file mode 100644 index 000000000..b0197e92d --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/golden.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://agent-trace.dev/schemas/v1/trace-record.json", + "version": "0.1.0", + "id": "019db9da-fdb0-7044-b5f6-95b6f1136990", + "timestamp": "2026-04-23T10:20:30Z", + "vcs": { + "type": "git", + "revision": "a0b1c2d3e4f5a6b7c8d9e0f11223344556677889" + }, + "metadata": { + "sce": { + "version": "0.1.0", + "line_changes": { + "ai": { "added": 0, "removed": 0 }, + "mixed": { "added": 0, "removed": 0 }, + "unknown": { "added": 4, "removed": 0 } + } + } + }, + "files": [ + { + "path": "src/widget.ts", + "conversations": [ + { + "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "contributor": { + "type": "unknown" + }, + "ranges": [ + { + "start_line": 2, + "end_line": 6, + "content_hash": "murmur3:191372d8" + } + ] + } + ] + } + ] +} diff --git a/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/mutation_ai.patch b/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/mutation_ai.patch new file mode 100644 index 000000000..e69de29bb diff --git a/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/post_commit.patch b/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/post_commit.patch new file mode 100644 index 000000000..ca55678da --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/post_commit.patch @@ -0,0 +1,10 @@ +diff --git a/src/widget.ts b/src/widget.ts +index 1111111..2222222 100644 +--- a/src/widget.ts ++++ b/src/widget.ts +@@ -2,1 +2,5 @@ + line two ++alpha added ++beta added ++gamma added ++delta added diff --git a/cli/src/services/agent_trace/fixtures/partial_combined/direct.patch b/cli/src/services/agent_trace/fixtures/partial_combined/direct.patch new file mode 100644 index 000000000..377e8bd03 --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/partial_combined/direct.patch @@ -0,0 +1,10 @@ +Index: src/widget.ts +=================================================================== +--- src/widget.ts ++++ src/widget.ts +@@ -2,1 +2,5 @@ + line two ++alpha added + beta added + gamma added + delta added diff --git a/cli/src/services/agent_trace/fixtures/partial_combined/golden.json b/cli/src/services/agent_trace/fixtures/partial_combined/golden.json new file mode 100644 index 000000000..48b2436af --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/partial_combined/golden.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://agent-trace.dev/schemas/v1/trace-record.json", + "version": "0.1.0", + "id": "019db9da-fdb0-7867-86f7-dba4a8b6649c", + "timestamp": "2026-04-23T10:20:30Z", + "vcs": { + "type": "git", + "revision": "a0b1c2d3e4f5a6b7c8d9e0f11223344556677889" + }, + "tool": { + "name": "claude-code", + "version": "9.9.9" + }, + "metadata": { + "sce": { + "version": "0.1.0", + "line_changes": { + "ai": { "added": 0, "removed": 0 }, + "mixed": { "added": 4, "removed": 0 }, + "unknown": { "added": 0, "removed": 0 } + } + } + }, + "files": [ + { + "path": "src/widget.ts", + "conversations": [ + { + "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "contributor": { + "type": "mixed", + "model_id": "claude-sonnet-5" + }, + "ranges": [ + { + "start_line": 2, + "end_line": 6, + "content_hash": "murmur3:191372d8" + } + ], + "related": [ + { + "type": "session", + "url": "https://sce.crocoder.dev/sessions/sess-direct" + } + ] + } + ] + } + ] +} diff --git a/cli/src/services/agent_trace/fixtures/partial_combined/mutation_ai.patch b/cli/src/services/agent_trace/fixtures/partial_combined/mutation_ai.patch new file mode 100644 index 000000000..0277e75c7 --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/partial_combined/mutation_ai.patch @@ -0,0 +1,10 @@ +Index: src/widget.ts +=================================================================== +--- src/widget.ts ++++ src/widget.ts +@@ -2,1 +2,5 @@ + line two + alpha added ++beta added + gamma added + delta added diff --git a/cli/src/services/agent_trace/fixtures/partial_combined/post_commit.patch b/cli/src/services/agent_trace/fixtures/partial_combined/post_commit.patch new file mode 100644 index 000000000..ca55678da --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/partial_combined/post_commit.patch @@ -0,0 +1,10 @@ +diff --git a/src/widget.ts b/src/widget.ts +index 1111111..2222222 100644 +--- a/src/widget.ts ++++ b/src/widget.ts +@@ -2,1 +2,5 @@ + line two ++alpha added ++beta added ++gamma added ++delta added diff --git a/cli/src/services/agent_trace/tests.rs b/cli/src/services/agent_trace/tests.rs index 73e06baaa..58619d16c 100644 --- a/cli/src/services/agent_trace/tests.rs +++ b/cli/src/services/agent_trace/tests.rs @@ -1,6 +1,7 @@ use super::{ - build_agent_trace, patches_have_overlap, validate_agent_trace_value, AgentTraceMetadataInput, - AgentTraceVcsType, LineRange, AGENT_TRACE_VERSION, + build_agent_trace, build_agent_trace_from_evidence, patches_have_overlap, + validate_agent_trace_value, AgentTraceEvidence, AgentTraceMetadataInput, AgentTraceVcsType, + LineRange, AGENT_TRACE_VERSION, }; use crate::services::{ agent_trace::agent_trace_conversation_url, @@ -393,3 +394,192 @@ fn schema_validation_rejects_vcs_missing_revision() { "expected vcs/revision validation failure, got: {rendered}" ); } + +#[derive(Clone, Copy)] +struct EvidenceScenario { + direct: &'static str, + mutation_ai: &'static str, + post_commit: &'static str, + golden: &'static str, +} + +const EVIDENCE_DIRECT_SESSION_ID: &str = "sess-direct"; +const EVIDENCE_DIRECT_MODEL_ID: &str = "claude-sonnet-5"; +const EVIDENCE_TOOL_NAME: &str = "claude-code"; +const EVIDENCE_TOOL_VERSION: &str = "9.9.9"; + +fn assert_builds_expected_agent_trace_from_evidence(scenario: EvidenceScenario) { + let mut direct_patch = parse_patch(scenario.direct, Some(EVIDENCE_DIRECT_SESSION_ID)) + .expect("direct fixture patch should parse"); + for file in &mut direct_patch.files { + for hunk in &mut file.hunks { + hunk.model_id = Some(String::from(EVIDENCE_DIRECT_MODEL_ID)); + } + } + let mutation_ai_patch = + parse_patch(scenario.mutation_ai, None).expect("mutation-ai fixture patch should parse"); + let post_commit_patch = + parse_patch(scenario.post_commit, None).expect("post-commit fixture patch should parse"); + + let golden: Value = serde_json::from_str(scenario.golden).expect("golden json should load"); + validate_agent_trace_value(&golden).expect("golden json should validate against schema"); + + let actual = build_agent_trace_from_evidence( + AgentTraceEvidence { + direct_patch: &direct_patch, + mutation_ai_patch: &mutation_ai_patch, + }, + &post_commit_patch, + AgentTraceMetadataInput { + commit_timestamp: TEST_COMMIT_TIMESTAMP, + commit_revision: TEST_COMMIT_REVISION, + vcs_type: Some(AgentTraceVcsType::Git), + tool_name: Some(EVIDENCE_TOOL_NAME), + tool_version: Some(EVIDENCE_TOOL_VERSION), + }, + ) + .expect("agent trace should build"); + + assert_eq!(actual.version, AGENT_TRACE_VERSION); + assert_eq!(actual.timestamp, TEST_COMMIT_TIMESTAMP); + + let actual_json = serde_json::to_value(&actual).expect("agent trace should serialize"); + validate_agent_trace_value(&actual_json).expect("actual json should validate against schema"); + + let expected_conversation_url = agent_trace_conversation_url(&actual.id); + let mut expected_files = golden["files"].clone(); + for conversation in expected_files + .as_array_mut() + .expect("golden files should be an array") + .iter_mut() + .flat_map(|file| { + file["conversations"] + .as_array_mut() + .expect("golden conversations should be an array") + .iter_mut() + }) + { + conversation["url"] = Value::String(expected_conversation_url.clone()); + } + + assert_eq!(actual_json["vcs"], golden["vcs"]); + assert_eq!(actual_json["tool"], golden["tool"]); + assert_eq!( + actual_json["metadata"]["sce"]["line_changes"], + golden["metadata"]["sce"]["line_changes"] + ); + assert_eq!(actual_json["files"], expected_files); +} + +#[test] +fn direct_only_evidence_matches_golden_agent_trace() { + assert_builds_expected_agent_trace_from_evidence(EvidenceScenario { + direct: include_str!("fixtures/direct_only/direct.patch"), + mutation_ai: include_str!("fixtures/direct_only/mutation_ai.patch"), + post_commit: include_str!("fixtures/direct_only/post_commit.patch"), + golden: include_str!("fixtures/direct_only/golden.json"), + }); +} + +#[test] +fn exclusive_without_direct_evidence_matches_golden_agent_trace() { + assert_builds_expected_agent_trace_from_evidence(EvidenceScenario { + direct: include_str!("fixtures/exclusive_without_direct/direct.patch"), + mutation_ai: include_str!("fixtures/exclusive_without_direct/mutation_ai.patch"), + post_commit: include_str!("fixtures/exclusive_without_direct/post_commit.patch"), + golden: include_str!("fixtures/exclusive_without_direct/golden.json"), + }); +} + +#[test] +fn direct_plus_mutation_evidence_matches_golden_agent_trace() { + assert_builds_expected_agent_trace_from_evidence(EvidenceScenario { + direct: include_str!("fixtures/direct_plus_mutation/direct.patch"), + mutation_ai: include_str!("fixtures/direct_plus_mutation/mutation_ai.patch"), + post_commit: include_str!("fixtures/direct_plus_mutation/post_commit.patch"), + golden: include_str!("fixtures/direct_plus_mutation/golden.json"), + }); +} + +#[test] +fn partial_combined_evidence_matches_golden_agent_trace() { + assert_builds_expected_agent_trace_from_evidence(EvidenceScenario { + direct: include_str!("fixtures/partial_combined/direct.patch"), + mutation_ai: include_str!("fixtures/partial_combined/mutation_ai.patch"), + post_commit: include_str!("fixtures/partial_combined/post_commit.patch"), + golden: include_str!("fixtures/partial_combined/golden.json"), + }); +} + +#[test] +fn newer_nonexclusive_blocks_evidence_matches_golden_agent_trace() { + assert_builds_expected_agent_trace_from_evidence(EvidenceScenario { + direct: include_str!("fixtures/newer_nonexclusive_blocks/direct.patch"), + mutation_ai: include_str!("fixtures/newer_nonexclusive_blocks/mutation_ai.patch"), + post_commit: include_str!("fixtures/newer_nonexclusive_blocks/post_commit.patch"), + golden: include_str!("fixtures/newer_nonexclusive_blocks/golden.json"), + }); +} + +#[test] +fn mutation_only_no_provenance_evidence_matches_golden_agent_trace() { + assert_builds_expected_agent_trace_from_evidence(EvidenceScenario { + direct: include_str!("fixtures/mutation_only_no_provenance/direct.patch"), + mutation_ai: include_str!("fixtures/mutation_only_no_provenance/mutation_ai.patch"), + post_commit: include_str!("fixtures/mutation_only_no_provenance/post_commit.patch"), + golden: include_str!("fixtures/mutation_only_no_provenance/golden.json"), + }); +} + +#[test] +fn direct_only_evidence_equals_direct_only_build_agent_trace() { + let direct = include_str!("fixtures/direct_only/direct.patch"); + let post_commit = include_str!("fixtures/direct_only/post_commit.patch"); + let empty_mutation_ai = include_str!("fixtures/direct_only/mutation_ai.patch"); + + let constructed_patch = parse_fixture(direct); + let post_commit_patch = parse_fixture(post_commit); + let mutation_ai_patch = parse_fixture(empty_mutation_ai); + + let metadata = AgentTraceMetadataInput { + commit_timestamp: TEST_COMMIT_TIMESTAMP, + commit_revision: TEST_COMMIT_REVISION, + vcs_type: Some(AgentTraceVcsType::Git), + tool_name: Some(EVIDENCE_TOOL_NAME), + tool_version: Some(EVIDENCE_TOOL_VERSION), + }; + + let compat = build_agent_trace(&constructed_patch, &post_commit_patch, metadata) + .expect("compat agent trace should build"); + let evidence = build_agent_trace_from_evidence( + AgentTraceEvidence { + direct_patch: &constructed_patch, + mutation_ai_patch: &mutation_ai_patch, + }, + &post_commit_patch, + metadata, + ) + .expect("evidence agent trace should build"); + + assert_eq!( + without_generated_identifiers(serde_json::to_value(&compat).expect("compat serializes")), + without_generated_identifiers( + serde_json::to_value(&evidence).expect("evidence serializes") + ) + ); +} + +fn without_generated_identifiers(mut trace: Value) -> Value { + trace["id"] = Value::Null; + if let Some(files) = trace["files"].as_array_mut() { + for conversation in files.iter_mut().flat_map(|file| { + file["conversations"] + .as_array_mut() + .expect("conversations should be an array") + .iter_mut() + }) { + conversation["url"] = Value::Null; + } + } + trace +} diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index d72de6f6e..9b36fab8c 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -708,6 +708,7 @@ mod tests { &flow, None, "https://example.invalid/codex-t19.git", + &crate::services::patch::ParsedPatch { files: Vec::new() }, |value| { crate::services::agent_trace::validate_agent_trace_value(value) .map_err(|error| anyhow::anyhow!(error.to_string())) diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index c8c483378..abff0c34e 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -10,8 +10,9 @@ use serde::Serialize; use serde_json::{json, to_string as serialize_to_json, Value}; use crate::services::agent_trace::{ - agent_trace_persisted_url, build_agent_trace, patch_has_touched_lines, patches_have_overlap, - validate_agent_trace_value, AgentTrace, AgentTraceMetadataInput, AgentTraceVcsType, + agent_trace_persisted_url, build_agent_trace_from_evidence, patch_has_touched_lines, + patches_have_overlap, validate_agent_trace_value, AgentTrace, AgentTraceEvidence, + AgentTraceMetadataInput, AgentTraceVcsType, }; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::agent_trace_db::{ @@ -1580,10 +1581,26 @@ fn run_post_commit_agent_trace_flow( "Failed to open Agent Trace DB for post-commit trace.", )?; + // Direct evidence is resolved first with the existing intersection, then the + // committed lines it does not cover are offered to bounded mutation history + // (read-only, current-worktree-only, direct-only fallback on absent identity). + let direct_intersection = intersect_patches_fn( + &flow_result.combined_recent_patch, + &flow_result.post_commit_data.parsed_patch, + ); + let mutation_ai_patch = + crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch( + repository_root, + &db, + &direct_intersection, + &flow_result.post_commit_data.parsed_patch, + ); + run_post_commit_agent_trace_flow_with( flow_result, vcs_type, remote_url, + &mutation_ai_patch, |trace_value| { validate_agent_trace_value(trace_value) .map_err(|error| anyhow!(error.to_string())) @@ -1604,6 +1621,7 @@ fn run_post_commit_agent_trace_flow_with( flow_result: &PostCommitIntersectionFlowResult, vcs_type: Option, remote_url: &str, + mutation_ai_patch: &ParsedPatch, validate_agent_trace: V, persist_agent_trace: I, ) -> Result @@ -1621,8 +1639,11 @@ where })? .to_rfc3339(); - let agent_trace = build_agent_trace( - &flow_result.combined_recent_patch, + let agent_trace = build_agent_trace_from_evidence( + AgentTraceEvidence { + direct_patch: &flow_result.combined_recent_patch, + mutation_ai_patch, + }, &flow_result.post_commit_data.parsed_patch, AgentTraceMetadataInput { commit_timestamp: &commit_timestamp, @@ -3567,6 +3588,7 @@ mod tests { flow_result, vcs_type, remote_url, + &ParsedPatch { files: Vec::new() }, |_| { *validation_called.borrow_mut() = true; Err(anyhow!("Agent Trace validation failed")) @@ -3593,6 +3615,730 @@ mod tests { assert!(!*launch_called.borrow()); } + fn post_commit_flow_result_for( + direct: ParsedPatch, + committed: ParsedPatch, + ) -> PostCommitIntersectionFlowResult { + PostCommitIntersectionFlowResult { + combined_recent_patch: direct, + post_commit_data: PostCommitPatchData { + commit_oid: String::from("abc123"), + commit_time_ms: 1_800_000_000_000, + parsed_patch: committed, + }, + tool_name: Some(String::from("claude")), + tool_version: Some(String::from("9.9.9")), + } + } + + fn persisted_post_commit_trace( + flow_result: &PostCommitIntersectionFlowResult, + mutation_ai_patch: &ParsedPatch, + ) -> Value { + let persisted = RefCell::new(None); + + run_post_commit_agent_trace_flow_with( + flow_result, + Some(AgentTraceVcsType::Git), + "", + mutation_ai_patch, + |_| Ok(()), + |insert| { + *persisted.borrow_mut() = Some(insert.trace_json.to_string()); + Ok(()) + }, + ) + .expect("post-commit Agent Trace flow should build and persist"); + + serde_json::from_str( + persisted + .into_inner() + .expect("trace should have been persisted") + .as_str(), + ) + .expect("persisted trace JSON should parse") + } + + #[test] + fn post_commit_agent_trace_flow_attributes_mutation_only_lines_as_ai_without_provenance() { + let flow_result = post_commit_flow_result_for( + ParsedPatch { files: Vec::new() }, + valid_patch("src/lib.rs", "mutated line"), + ); + let mutation_ai_patch = valid_patch("src/lib.rs", "mutated line"); + + let trace = persisted_post_commit_trace(&flow_result, &mutation_ai_patch); + + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert!( + trace.get("tool").is_none(), + "mutation-only coverage fabricates no tool provenance" + ); + let contributor = &trace["files"][0]["conversations"][0]["contributor"]; + assert_eq!(contributor["type"], json!("ai")); + assert!( + contributor.get("model_id").is_none(), + "mutation-only coverage carries no model provenance" + ); + assert!( + trace["files"][0]["conversations"][0] + .get("related") + .is_none(), + "mutation-only coverage carries no session provenance" + ); + } + + #[test] + fn post_commit_agent_trace_flow_keeps_direct_provenance_when_direct_covers_the_line() { + let flow_result = post_commit_flow_result_for( + valid_patch("src/lib.rs", "shared line"), + valid_patch("src/lib.rs", "shared line"), + ); + + let trace = persisted_post_commit_trace(&flow_result, &ParsedPatch { files: Vec::new() }); + + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["tool"], + json!({ "name": "claude", "version": "9.9.9" }) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("ai") + ); + } + + #[test] + fn post_commit_agent_trace_flow_with_empty_mutation_patch_leaves_uncovered_lines_unknown() { + let flow_result = post_commit_flow_result_for( + ParsedPatch { files: Vec::new() }, + valid_patch("src/lib.rs", "human line"), + ); + + let trace = persisted_post_commit_trace(&flow_result, &ParsedPatch { files: Vec::new() }); + + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(0) + ); + assert!(trace.get("tool").is_none()); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("unknown") + ); + } + + mod mutation_attribution_e2e { + use super::*; + use crate::services::checkout::{get_or_create_checkout_id, resolve_git_dir}; + use crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch; + use crate::services::mutation_trace::store::encode_revision; + + fn git(repo: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .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") + } + + fn commit_all(repo: &Path, message: &str) { + git(repo, &["add", "-A"]); + git( + repo, + &[ + "-c", + "user.name=SCE Test", + "-c", + "user.email=sce@example.invalid", + "commit", + "-qm", + message, + ], + ); + } + + struct E2eRepo { + _temp: tempfile::TempDir, + root: PathBuf, + db_path: PathBuf, + } + + impl E2eRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-mutation-attr-e2e-{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, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.rs"), "one\n").expect("seed file should write"); + commit_all(&root, "base"); + let db_path = temp.path().join("agent-trace.db"); + RepositoryAgentTraceDb::new_at(&db_path) + .expect("repository DB should open with schema"); + Self { + _temp: temp, + root, + db_path, + } + } + + fn db(&self) -> RepositoryAgentTraceDb { + RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&self.db_path) + .expect("repository DB should reopen") + } + + fn head_tree(&self) -> String { + git(&self.root, &["rev-parse", "HEAD^{tree}"]) + .trim() + .to_owned() + } + + fn parent_tree(&self) -> String { + git(&self.root, &["rev-parse", "HEAD~1^{tree}"]) + .trim() + .to_owned() + } + + fn checkout_id(&self) -> String { + let git_dir = resolve_git_dir(&self.root).expect("git dir should resolve"); + get_or_create_checkout_id(&git_dir).expect("checkout identity should resolve") + } + } + + fn seed_event( + db: &RepositoryAgentTraceDb, + worktree_id: &str, + revision: u64, + before_tree: &str, + after_tree: &str, + attribution_kind: &str, + attribution_scope_id: Option<&str>, + ) { + db.execute( + "INSERT INTO mutation_trace_events + (worktree_id, revision, before_tree, after_tree, tainted, failure_kind, + attribution_kind, attribution_scope_id, boundary_kind, boundary_scope_id, + boundary_event_id) + VALUES (?1, ?2, ?3, ?4, 0, 'healthy', ?5, ?6, 'flush', NULL, NULL)", + ( + worktree_id, + encode_revision(revision).as_slice(), + before_tree, + after_tree, + attribution_kind, + attribution_scope_id, + ), + ) + .expect("mutation event insert should succeed"); + } + + fn row_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("count row should exist") + } + + fn touched_line_count(patch: &ParsedPatch) -> usize { + patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .map(|hunk| hunk.lines.len()) + .sum() + } + + fn flow_result_for( + repo: &E2eRepo, + direct: ParsedPatch, + ) -> PostCommitIntersectionFlowResult { + let post_commit_data = capture_post_commit_patch_from_git(&repo.root) + .expect("capturing the post-commit patch should succeed"); + PostCommitIntersectionFlowResult { + combined_recent_patch: direct, + post_commit_data, + tool_name: None, + tool_version: None, + } + } + + fn resolve_mutation_ai( + repo: &E2eRepo, + db: &RepositoryAgentTraceDb, + flow_result: &PostCommitIntersectionFlowResult, + ) -> ParsedPatch { + let direct_intersection = intersect_patches_fn( + &flow_result.combined_recent_patch, + &flow_result.post_commit_data.parsed_patch, + ); + resolve_post_commit_mutation_ai_patch( + &repo.root, + db, + &direct_intersection, + &flow_result.post_commit_data.parsed_patch, + ) + } + + fn persist_trace( + flow_result: &PostCommitIntersectionFlowResult, + db: &RepositoryAgentTraceDb, + mutation_ai_patch: &ParsedPatch, + ) -> Value { + let persisted = RefCell::new(None); + run_post_commit_agent_trace_flow_with( + flow_result, + Some(AgentTraceVcsType::Git), + "git@github.com:acme/widgets.git", + mutation_ai_patch, + |value| { + validate_agent_trace_value(value).map_err(|error| anyhow!(error.to_string())) + }, + |insert| { + *persisted.borrow_mut() = Some(insert.trace_json.to_string()); + db.insert_agent_trace(insert).map(|_| ()) + }, + ) + .expect("the post-commit Agent Trace flow should build, validate, and persist"); + + serde_json::from_str( + persisted + .into_inner() + .expect("a trace should have been persisted") + .as_str(), + ) + .expect("the persisted trace JSON should parse") + } + + #[test] + fn a_mutation_only_line_persists_as_ai_without_fabricated_provenance() { + let repo = E2eRepo::new("mutation-only"); + fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); + commit_all(&repo.root, "add two"); + + let db = repo.db(); + seed_event( + &db, + &repo.checkout_id(), + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-x"), + ); + + let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 1, + "a healthy untainted exclusive event covers the committed line" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert!( + trace.get("tool").is_none(), + "mutation-only coverage fabricates no tool provenance" + ); + let contributor = &trace["files"][0]["conversations"][0]["contributor"]; + assert_eq!(contributor["type"], json!("ai")); + assert!( + contributor.get("model_id").is_none(), + "mutation-only coverage carries no model provenance" + ); + + assert_eq!( + row_count(&db, "diff_traces"), + 0, + "mutation evidence is never inserted into diff_traces" + ); + assert_eq!( + row_count(&db, "post_commit_patch_intersections"), + 0, + "the direct-only intersection table is untouched by this flow" + ); + assert_eq!(row_count(&db, "agent_traces"), 1); + } + + #[test] + fn direct_plus_mutation_evidence_completes_hunk_coverage_and_keeps_direct_provenance() { + let repo = E2eRepo::new("direct-plus-mutation"); + fs::write(repo.root.join("file.rs"), "one\ntwo\nthree\n") + .expect("the edit should write"); + commit_all(&repo.root, "add two and three"); + + let db = repo.db(); + seed_event( + &db, + &repo.checkout_id(), + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-x"), + ); + + let direct = parse_patch_from_text( + "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", + None, + ) + .expect("the direct patch should parse"); + let mut flow_result = flow_result_for(&repo, direct); + flow_result.tool_name = Some(String::from("claude")); + flow_result.tool_version = Some(String::from("9.9.9")); + + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 1, + "only the line direct evidence did not cover is resolved from mutation history" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(2), + "the union of direct and mutation coverage classifies the hunk ai" + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert_eq!( + trace["tool"], + json!({ "name": "claude", "version": "9.9.9" }) + ); + } + + #[test] + fn a_newer_nonexclusive_event_keeps_the_line_non_ai() { + let repo = E2eRepo::new("newer-nonexclusive"); + fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); + commit_all(&repo.root, "add two"); + + let db = repo.db(); + let worktree = repo.checkout_id(); + seed_event( + &db, + &worktree, + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-old"), + ); + seed_event( + &db, + &worktree, + 2, + &repo.parent_tree(), + &repo.head_tree(), + "ai_contended", + None, + ); + + let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 0, + "the newer contended match resolves the line and blocks the older exclusive event" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(0) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("unknown") + ); + } + + #[test] + fn an_adversarial_foreign_worktree_event_cannot_block_the_current_worktrees_exclusive_event( + ) { + let repo = E2eRepo::new("adversarial-linked"); + + let linked_root = repo + .root + .parent() + .expect("the repo should have a parent directory") + .join("linked"); + git( + &repo.root, + &[ + "worktree", + "add", + "-q", + linked_root.to_str().expect("worktree path should be UTF-8"), + ], + ); + + fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); + commit_all(&repo.root, "add two"); + + let db = repo.db(); + let current_worktree = repo.checkout_id(); + let linked_git_dir = + resolve_git_dir(&linked_root).expect("the linked git dir should resolve"); + let foreign_worktree = get_or_create_checkout_id(&linked_git_dir) + .expect("the linked worktree's checkout identity should resolve"); + assert_ne!( + current_worktree, foreign_worktree, + "the linked worktree must derive its own distinct identity" + ); + + seed_event( + &db, + ¤t_worktree, + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-current"), + ); + seed_event( + &db, + &foreign_worktree, + 2, + &repo.parent_tree(), + &repo.head_tree(), + "ai_contended", + None, + ); + + let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 1, + "only the current worktree's history is eligible, so the older exclusive event contributes" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1), + "worktree isolation lets the current worktree's exclusive event classify the target ai" + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("ai") + ); + assert!(trace.get("tool").is_none()); + } + + fn touched_contents(patch: &ParsedPatch) -> Vec { + patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .map(|line| line.content.clone()) + .collect() + } + + #[test] + #[allow(clippy::too_many_lines)] + fn persistence_boundaries_stay_separated_across_diff_traces_intersection_and_agent_trace() { + let repo = E2eRepo::new("persistence-boundary"); + + fs::write(repo.root.join("file.rs"), "one\ntwo\n") + .expect("the direct edit should write"); + git(&repo.root, &["add", "-A"]); + let intermediate_tree = git(&repo.root, &["write-tree"]).trim().to_owned(); + + fs::write(repo.root.join("file.rs"), "one\ntwo\nthree\n") + .expect("the mutation edit should write"); + commit_all(&repo.root, "add two and three"); + + let base_tree = repo.parent_tree(); + let final_tree = repo.head_tree(); + assert_ne!( + base_tree, intermediate_tree, + "the direct edit must move the tree" + ); + assert_ne!( + intermediate_tree, final_tree, + "the mutation edit must move the tree again" + ); + + let db = repo.db(); + + let now_ms = current_unix_time_ms().expect("the clock should resolve"); + db.insert_diff_trace(DiffTraceInsert { + time_ms: now_ms - 60_000, + session_id: "cc_session-direct", + patch: "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", + model_id: Some("claude/model-direct"), + tool_name: "claude", + tool_version: Some("9.9.9"), + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("the direct diff_traces row should insert"); + + seed_event( + &db, + &repo.checkout_id(), + 1, + &intermediate_tree, + &final_tree, + "ai_exclusive", + Some("scope-mutation"), + ); + + let flow_result = run_post_commit_intersection_flow_with( + &repo.root, + capture_post_commit_patch_from_git, + current_unix_time_ms, + |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), + |insert| db.insert_post_commit_patch_intersection(insert).map(|_| ()), + ) + .expect("the real post-commit intersection flow should run"); + assert_eq!( + touched_contents(&flow_result.combined_recent_patch), + vec!["two".to_owned()], + "the combined recent patch comes from the real diff_traces query, not an in-memory patch" + ); + + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_contents(&mutation_ai_patch), + vec!["three".to_owned()], + "mutation history resolves only the committed line direct evidence missed" + ); + + persist_trace(&flow_result, &db, &mutation_ai_patch); + + assert_eq!( + row_count(&db, "diff_traces"), + 1, + "mutation attribution must not create another diff_traces row" + ); + let stored_direct_patch: String = db + .query_map("SELECT patch FROM diff_traces", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("diff_traces query should succeed") + .into_iter() + .next() + .expect("one diff_traces row should exist"); + let stored_direct = parse_patch_from_text(&stored_direct_patch, None) + .expect("the stored direct patch should parse"); + assert_eq!( + touched_contents(&stored_direct), + vec!["two".to_owned()], + "the direct diff_traces row contains 'two' and never 'three'" + ); + + assert_eq!( + row_count(&db, "post_commit_patch_intersections"), + 1, + "the intersection flow persists exactly one direct-only row" + ); + let stored_intersection_json: String = db + .query_map( + "SELECT intersection_patch FROM post_commit_patch_intersections", + (), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("intersection query should succeed") + .into_iter() + .next() + .expect("one intersection row should exist"); + let stored_intersection = load_patch_from_json(&stored_intersection_json) + .expect("the persisted intersection patch should reconstruct"); + assert_eq!( + touched_contents(&stored_intersection), + vec!["two".to_owned()], + "post_commit_patch_intersections stays direct-only; the mutation line 'three' \ + must never contaminate this table" + ); + + assert_eq!(row_count(&db, "agent_traces"), 1); + let stored_trace_json: String = db + .query_map("SELECT trace_json FROM agent_traces", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("agent_traces query should succeed") + .into_iter() + .next() + .expect("one Agent Trace row should exist"); + let trace: Value = serde_json::from_str(&stored_trace_json) + .expect("the persisted Agent Trace JSON should parse"); + validate_agent_trace_value(&trace).expect( + "the persisted agent_traces.trace_json validates against the embedded Agent Trace schema", + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(2), + "direct + mutation coverage classifies both committed added lines as ai" + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("ai") + ); + + assert_eq!( + trace["tool"], + json!({ "name": "claude", "version": "9.9.9" }) + ); + + assert_eq!( + row_count(&db, "mutation_trace_events"), + 1, + "attribution performs no mutation-cursor write" + ); + } + } + #[test] fn post_commit_auto_sync_does_not_launch_when_disabled() { let launch_called = RefCell::new(false); diff --git a/cli/src/services/mutation_trace/attribution.rs b/cli/src/services/mutation_trace/attribution.rs new file mode 100644 index 000000000..6e00ae859 --- /dev/null +++ b/cli/src/services/mutation_trace/attribution.rs @@ -0,0 +1,217 @@ +use std::collections::{BTreeSet, HashSet}; + +use crate::services::patch::{ + ParsedPatch, PatchFileChange, PatchHunk, TouchedLine, TouchedLineKind, +}; + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MutationAttributionResult { + pub mutation_ai_patch: ParsedPatch, + pub resolved_non_ai_patch: ParsedPatch, + pub unresolved_patch: ParsedPatch, +} + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PatchLineLocation { + pub file_index: usize, + pub hunk_index: usize, + pub line_index: usize, +} + +#[must_use] +pub fn exclude_direct_coverage( + target_patch: &ParsedPatch, + direct_coverage: &ParsedPatch, +) -> ParsedPatch { + let direct_lines = direct_line_keys(direct_coverage); + let selected: BTreeSet = all_locations(target_patch) + .into_iter() + .filter(|location| { + let line = line_at(target_patch, *location); + !direct_lines.contains(&( + logical_path(&target_patch.files[location.file_index]).to_owned(), + line.kind, + line.line_number, + line.content.clone(), + )) + }) + .collect(); + patch_for_locations(target_patch, &selected) +} + +fn direct_line_keys(direct_patch: &ParsedPatch) -> HashSet<(String, TouchedLineKind, u64, String)> { + direct_patch + .files + .iter() + .flat_map(|file| { + let path = logical_path(file).to_owned(); + file.hunks.iter().flat_map(move |hunk| { + let path = path.clone(); + hunk.lines.iter().map(move |line| { + ( + path.clone(), + line.kind, + line.line_number, + line.content.clone(), + ) + }) + }) + }) + .collect() +} + +fn all_locations(patch: &ParsedPatch) -> BTreeSet { + patch + .files + .iter() + .enumerate() + .flat_map(|(file_index, file)| { + file.hunks + .iter() + .enumerate() + .flat_map(move |(hunk_index, hunk)| { + (0..hunk.lines.len()).map(move |line_index| PatchLineLocation { + file_index, + hunk_index, + line_index, + }) + }) + }) + .collect() +} + +fn line_at(patch: &ParsedPatch, location: PatchLineLocation) -> &TouchedLine { + &patch.files[location.file_index].hunks[location.hunk_index].lines[location.line_index] +} + +pub(crate) fn logical_path(file: &PatchFileChange) -> &str { + if file.new_path.is_empty() { + &file.old_path + } else { + &file.new_path + } +} + +pub fn patch_for_locations( + patch: &ParsedPatch, + selected: &BTreeSet, +) -> ParsedPatch { + let files = patch + .files + .iter() + .enumerate() + .filter_map(|(file_index, file)| { + let hunks = file + .hunks + .iter() + .enumerate() + .filter_map(|(hunk_index, hunk)| { + let lines = hunk + .lines + .iter() + .enumerate() + .filter_map(|(line_index, line)| { + let location = PatchLineLocation { + file_index, + hunk_index, + line_index, + }; + selected.contains(&location).then(|| line.clone()) + }) + .collect::>(); + (!lines.is_empty()).then(|| PatchHunk { + lines, + ..hunk.clone() + }) + }) + .collect::>(); + (!hunks.is_empty()).then(|| PatchFileChange { + hunks, + ..file.clone() + }) + }) + .collect(); + + ParsedPatch { files } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::patch::FileChangeKind; + + fn line(kind: TouchedLineKind, number: u64, content: &str) -> TouchedLine { + TouchedLine { + kind, + line_number: number, + content: content.to_owned(), + session_id: None, + } + } + + fn patch(path: &str, lines: Vec) -> ParsedPatch { + ParsedPatch { + files: vec![PatchFileChange { + old_path: path.to_owned(), + new_path: path.to_owned(), + kind: FileChangeKind::Modified, + hunks: vec![PatchHunk { + old_start: 1, + old_count: 1, + new_start: 1, + new_count: 1, + model_id: None, + lines, + }], + }], + } + } + + fn contents(result: &ParsedPatch) -> Vec<(u64, String)> { + result + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .map(|line| (line.line_number, line.content.clone())) + .collect() + } + + #[test] + fn exclude_direct_coverage_removes_exactly_the_directly_covered_lines() { + let direct = patch( + "src/lib.rs", + vec![line(TouchedLineKind::Added, 1, "direct")], + ); + let target = patch( + "src/lib.rs", + vec![ + line(TouchedLineKind::Added, 1, "direct"), + line(TouchedLineKind::Added, 2, "mutation"), + ], + ); + + let remaining = exclude_direct_coverage(&target, &direct); + assert_eq!(contents(&remaining), vec![(2, "mutation".to_owned())]); + } + + #[test] + fn exclude_direct_coverage_keeps_everything_when_direct_is_empty() { + let target = patch("src/lib.rs", vec![line(TouchedLineKind::Added, 1, "x")]); + let remaining = exclude_direct_coverage(&target, &ParsedPatch { files: vec![] }); + assert_eq!(contents(&remaining), vec![(1, "x".to_owned())]); + } + + #[test] + fn exclude_direct_coverage_matches_on_content_not_only_position() { + let direct = patch("src/lib.rs", vec![line(TouchedLineKind::Added, 1, "kept")]); + let target = patch( + "src/lib.rs", + vec![line(TouchedLineKind::Added, 1, "different")], + ); + let remaining = exclude_direct_coverage(&target, &direct); + assert_eq!(contents(&remaining), vec![(1, "different".to_owned())]); + } +} diff --git a/cli/src/services/mutation_trace/lineage.rs b/cli/src/services/mutation_trace/lineage.rs new file mode 100644 index 000000000..74e69527a --- /dev/null +++ b/cli/src/services/mutation_trace/lineage.rs @@ -0,0 +1,299 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::services::mutation_trace::types::ScopeId; +use crate::services::patch::{ + ParsedPatch, PatchFileChange, PatchHunk, TouchedLine, TouchedLineKind, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LineProvenance { + Unknown, + MutationAi { scope_id: ScopeId }, + MutationNonAi, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TransitionOrigin { + MutationAi(ScopeId), + MutationNonAi, + Unobserved, +} + +impl TransitionOrigin { + fn added_provenance(&self) -> LineProvenance { + match self { + TransitionOrigin::MutationAi(scope_id) => LineProvenance::MutationAi { + scope_id: scope_id.clone(), + }, + TransitionOrigin::MutationNonAi => LineProvenance::MutationNonAi, + TransitionOrigin::Unobserved => LineProvenance::Unknown, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LineageError { + pub path: String, + pub reason: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ProvenanceLine { + content: String, + provenance: LineProvenance, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MutationLineage { + files: BTreeMap>, +} + +fn unknown_lines(content: Option<&str>) -> Vec { + match content { + None => Vec::new(), + Some(text) => text + .lines() + .map(|line| ProvenanceLine { + content: line.to_owned(), + provenance: LineProvenance::Unknown, + }) + .collect(), + } +} + +impl MutationLineage { + #[must_use] + pub fn from_baseline(files: &BTreeMap>) -> Self { + MutationLineage { + files: files + .iter() + .map(|(path, content)| (path.clone(), unknown_lines(content.as_deref()))) + .collect(), + } + } + + pub fn reset_file(&mut self, path: &str, content: Option<&str>) { + self.files.insert(path.to_owned(), unknown_lines(content)); + } + + pub fn reset_all(&mut self, files: &BTreeMap>) { + self.files = files + .iter() + .map(|(path, content)| (path.clone(), unknown_lines(content.as_deref()))) + .collect(); + } + + pub fn tracked_paths(&self) -> impl Iterator { + self.files.keys() + } + + pub fn apply( + &mut self, + patch: &ParsedPatch, + origin: &TransitionOrigin, + ) -> Result<(), LineageError> { + for file in &patch.files { + self.apply_file(file, origin)?; + } + Ok(()) + } + + fn apply_file( + &mut self, + file: &PatchFileChange, + origin: &TransitionOrigin, + ) -> Result<(), LineageError> { + let tracks_source = !file.old_path.is_empty() && self.files.contains_key(&file.old_path); + let tracks_dest = !file.new_path.is_empty() && self.files.contains_key(&file.new_path); + if !tracks_source && !tracks_dest { + return Ok(()); + } + + let logical_path = if file.new_path.is_empty() { + file.old_path.clone() + } else { + file.new_path.clone() + }; + + let old = self + .files + .get(&file.old_path) + .or_else(|| { + if file.new_path.is_empty() { + None + } else { + self.files.get(&file.new_path) + } + }) + .cloned() + .unwrap_or_default(); + + let new = apply_hunks(&logical_path, &old, file, origin)?; + + if !file.old_path.is_empty() { + self.files.remove(&file.old_path); + } + if !file.new_path.is_empty() { + self.files.insert(file.new_path.clone(), new); + } + Ok(()) + } + + #[must_use] + pub fn provenance_at(&self, path: &str, line_number: u64, content: &str) -> LineProvenance { + let Some(lines) = self.files.get(path) else { + return LineProvenance::Unknown; + }; + let Some(index) = line_number + .checked_sub(1) + .and_then(|index| usize::try_from(index).ok()) + else { + return LineProvenance::Unknown; + }; + match lines.get(index) { + Some(line) if line.content == content => line.provenance.clone(), + _ => LineProvenance::Unknown, + } + } +} + +fn apply_hunks( + path: &str, + old: &[ProvenanceLine], + file: &PatchFileChange, + origin: &TransitionOrigin, +) -> Result, LineageError> { + let mut hunks: Vec<&PatchHunk> = file.hunks.iter().collect(); + hunks.sort_by_key(|hunk| (hunk.old_start, hunk.new_start)); + + let mut new: Vec = Vec::new(); + let mut cursor: usize = 0; + + for hunk in hunks { + cursor = apply_one_hunk(path, old, hunk, origin, &mut new, cursor)?; + } + + new.extend_from_slice(&old[cursor..]); + Ok(new) +} + +fn apply_one_hunk( + path: &str, + old: &[ProvenanceLine], + hunk: &PatchHunk, + origin: &TransitionOrigin, + new: &mut Vec, + mut cursor: usize, +) -> Result { + let fail = |reason: &str| LineageError { + path: path.to_owned(), + reason: reason.to_owned(), + }; + + let old_count = usize::try_from(hunk.old_count).map_err(|_| fail("old_count overflow"))?; + let new_count = usize::try_from(hunk.new_count).map_err(|_| fail("new_count overflow"))?; + + let prefix_end = if old_count == 0 { + usize::try_from(hunk.old_start).map_err(|_| fail("old_start overflow"))? + } else { + usize::try_from(hunk.old_start) + .map_err(|_| fail("old_start overflow"))? + .checked_sub(1) + .ok_or_else(|| fail("old_start below 1 for a non-empty hunk"))? + }; + + if prefix_end < cursor { + return Err(fail("hunks overlap or are out of order")); + } + if prefix_end > old.len() { + return Err(fail("hunk starts past end of file")); + } + new.extend_from_slice(&old[cursor..prefix_end]); + cursor = prefix_end; + + if cursor + old_count > old.len() { + return Err(fail("hunk old region extends past end of file")); + } + let region = &old[cursor..cursor + old_count]; + cursor += old_count; + + let removed: Vec<&TouchedLine> = hunk + .lines + .iter() + .filter(|line| line.kind == TouchedLineKind::Removed) + .collect(); + let added: Vec<&TouchedLine> = hunk + .lines + .iter() + .filter(|line| line.kind == TouchedLineKind::Added) + .collect(); + + if removed.len() > old_count { + return Err(fail("more removed lines than the hunk's old region")); + } + if added.len() > new_count { + return Err(fail("more added lines than the hunk's new region")); + } + if new_count - added.len() != old_count - removed.len() { + return Err(fail("hunk context lengths are inconsistent")); + } + + let mut removed_indices: BTreeSet = BTreeSet::new(); + for line in &removed { + let offset = line + .line_number + .checked_sub(hunk.old_start) + .and_then(|offset| usize::try_from(offset).ok()) + .filter(|offset| *offset < old_count) + .ok_or_else(|| fail("removed line falls outside the hunk old region"))?; + if region[offset].content != line.content { + return Err(fail("removed line content does not match the tracked line")); + } + if !removed_indices.insert(offset) { + return Err(fail("the same old line is removed twice")); + } + } + + let mut carried: Vec<&ProvenanceLine> = region + .iter() + .enumerate() + .filter(|(index, _)| !removed_indices.contains(index)) + .map(|(_, line)| line) + .collect(); + carried.reverse(); + + let added_by_number: BTreeMap = added + .iter() + .map(|line| (line.line_number, line.content.as_str())) + .collect(); + if added_by_number.len() != added.len() { + return Err(fail("two added lines share a line number")); + } + + for position in 0..new_count { + let line_number = hunk + .new_start + .checked_add(position as u64) + .ok_or_else(|| fail("new line number overflow"))?; + if let Some(content) = added_by_number.get(&line_number) { + new.push(ProvenanceLine { + content: (*content).to_owned(), + provenance: origin.added_provenance(), + }); + } else { + let carried_line = carried + .pop() + .ok_or_else(|| fail("ran out of carried context lines"))?; + new.push(carried_line.clone()); + } + } + if !carried.is_empty() { + return Err(fail("carried context lines left unplaced")); + } + + Ok(cursor) +} + +#[cfg(test)] +#[path = "lineage/tests.rs"] +mod tests; diff --git a/cli/src/services/mutation_trace/lineage/tests.rs b/cli/src/services/mutation_trace/lineage/tests.rs new file mode 100644 index 000000000..d2f0f093a --- /dev/null +++ b/cli/src/services/mutation_trace/lineage/tests.rs @@ -0,0 +1,191 @@ +use super::*; +use crate::services::patch::parse_patch; + +fn scope(id: &str) -> ScopeId { + ScopeId(id.to_owned()) +} + +fn baseline(entries: &[(&str, Option<&str>)]) -> MutationLineage { + let map: BTreeMap> = entries + .iter() + .map(|(path, content)| ((*path).to_owned(), content.map(str::to_owned))) + .collect(); + MutationLineage::from_baseline(&map) +} + +fn diff(text: &str) -> ParsedPatch { + parse_patch(text, None).expect("diff should parse") +} + +#[test] +fn an_added_ai_line_carries_ai_provenance() { + let mut lineage = baseline(&[("f.rs", Some("a\n"))]); + lineage + .apply( + &diff("diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n"), + &TransitionOrigin::MutationAi(scope("s1")), + ) + .expect("apply"); + assert_eq!( + lineage.provenance_at("f.rs", 2, "foo"), + LineProvenance::MutationAi { + scope_id: scope("s1") + } + ); + assert_eq!( + lineage.provenance_at("f.rs", 1, "a"), + LineProvenance::Unknown + ); +} + +#[test] +fn a_removed_line_loses_its_provenance_permanently() { + let mut lineage = baseline(&[("f.rs", Some("a\n"))]); + lineage + .apply( + &diff("diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n"), + &TransitionOrigin::MutationAi(scope("s1")), + ) + .expect("add"); + lineage + .apply( + &diff("diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,2 +1,1 @@\n a\n-foo\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect("remove"); + assert_eq!( + lineage.provenance_at("f.rs", 2, "foo"), + LineProvenance::Unknown + ); + assert_eq!(lineage.tracked_paths().count(), 1); +} + +#[test] +fn identical_remove_then_readd_takes_the_new_transition_provenance() { + let mut lineage = baseline(&[("f.rs", Some("a\n"))]); + lineage + .apply( + &diff("diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("add"); + lineage + .apply( + &diff( + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,1 +2,1 @@\n-foo\n+foo\n", + ), + &TransitionOrigin::MutationNonAi, + ) + .expect("replace"); + assert_eq!( + lineage.provenance_at("f.rs", 2, "foo"), + LineProvenance::MutationNonAi + ); +} + +#[test] +fn context_provenance_survives_line_number_movement() { + let mut lineage = baseline(&[("f.rs", Some("b\n"))]); + lineage + .apply( + &diff("diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,1 @@\n-b\n+B\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("seed B as AI"); + lineage + .apply( + &diff( + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,4 @@\n+x\n+y\n+z\n B\n", + ), + &TransitionOrigin::MutationNonAi, + ) + .expect("insert above"); + assert_eq!( + lineage.provenance_at("f.rs", 4, "B"), + LineProvenance::MutationAi { + scope_id: scope("ai") + } + ); +} + +#[test] +fn unobserved_transition_introduces_unknown_lines() { + let mut lineage = baseline(&[("f.rs", Some("a\n"))]); + lineage + .apply( + &diff("diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n"), + &TransitionOrigin::Unobserved, + ) + .expect("apply"); + assert_eq!( + lineage.provenance_at("f.rs", 2, "foo"), + LineProvenance::Unknown + ); +} + +#[test] +fn a_mismatched_removed_line_is_a_lineage_error() { + let mut lineage = baseline(&[("f.rs", Some("a\n"))]); + let error = lineage + .apply( + &diff("diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,1 @@\n-different\n+new\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect_err("content mismatch must fail closed"); + assert_eq!(error.path, "f.rs"); +} + +#[test] +fn duplicate_lines_do_not_let_provenance_jump_between_occurrences() { + let mut lineage = baseline(&[("f.rs", Some("foo\nfoo\nfoo\n"))]); + lineage + .apply( + &diff( + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,1 +2,1 @@\n-foo\n+foo\n", + ), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("replace middle"); + assert_eq!( + lineage.provenance_at("f.rs", 1, "foo"), + LineProvenance::Unknown + ); + assert_eq!( + lineage.provenance_at("f.rs", 2, "foo"), + LineProvenance::MutationAi { + scope_id: scope("ai") + } + ); + assert_eq!( + lineage.provenance_at("f.rs", 3, "foo"), + LineProvenance::Unknown + ); +} + +#[test] +fn a_deleted_file_drops_out_of_the_lineage() { + let mut lineage = baseline(&[("f.rs", Some("a\nb\n"))]); + lineage + .apply( + &diff("diff --git a/f.rs b/f.rs\ndeleted file mode 100644\n--- a/f.rs\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-a\n-b\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect("delete"); + assert_eq!(lineage.tracked_paths().count(), 0); +} + +#[test] +fn reset_file_returns_a_file_to_a_conservative_baseline() { + let mut lineage = baseline(&[("f.rs", Some("a\n"))]); + lineage + .apply( + &diff("diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("add"); + lineage.reset_file("f.rs", Some("a\nfoo\n")); + assert_eq!( + lineage.provenance_at("f.rs", 2, "foo"), + LineProvenance::Unknown + ); +} diff --git a/cli/src/services/mutation_trace/mod.rs b/cli/src/services/mutation_trace/mod.rs index 3c2eb9636..3deff5a48 100644 --- a/cli/src/services/mutation_trace/mod.rs +++ b/cli/src/services/mutation_trace/mod.rs @@ -144,6 +144,8 @@ //! no-change `Flush` still commits successfully at `u64::MAX`, with revision //! unchanged). +pub mod attribution; +pub mod lineage; pub mod protocol; pub(crate) mod runtime; pub mod store; diff --git a/cli/src/services/mutation_trace/runtime/git_snapshot.rs b/cli/src/services/mutation_trace/runtime/git_snapshot.rs index dce69861e..c014edeae 100644 --- a/cli/src/services/mutation_trace/runtime/git_snapshot.rs +++ b/cli/src/services/mutation_trace/runtime/git_snapshot.rs @@ -76,6 +76,31 @@ impl GitSnapshotService { ) } + pub fn head_tree(&self) -> Result { + let tree = self.run_git(&["rev-parse", "HEAD^{tree}"], None)?; + Ok(TreeId(tree.trim().to_string())) + } + + pub fn file_at_tree(&self, tree: &TreeId, path: &str) -> Result> { + let spec = format!("{}:{}", tree.0, path); + let output = Command::new("git") + .args(["cat-file", "blob", &spec]) + .current_dir(&self.repository_root) + .env("GIT_DIR", &self.git_dir) + .output() + .with_context(|| { + format!( + "Failed to run git cat-file blob '{spec}' in '{}'", + self.repository_root.display() + ) + })?; + + if !output.status.success() { + return Ok(None); + } + Ok(String::from_utf8(output.stdout).ok()) + } + /// Inventory every SCE snapshot pin owned by `worktree_id`. /// /// Runs `git for-each-ref` constrained to the single path prefix diff --git a/cli/src/services/mutation_trace/runtime/mod.rs b/cli/src/services/mutation_trace/runtime/mod.rs index fc41f5e20..6022a18e1 100644 --- a/cli/src/services/mutation_trace/runtime/mod.rs +++ b/cli/src/services/mutation_trace/runtime/mod.rs @@ -1,6 +1,7 @@ mod coordinator; mod external_taint; mod git_snapshot; +mod mutation_attribution; mod protected_worktree; mod ref_reconciliation; mod scope_runtime; @@ -14,6 +15,12 @@ pub(crate) use coordinator::{ coordinate, CoordinateError, CoordinateOutcome, ExternalTaintOperation, RuntimeBoundary, }; #[allow(unused_imports)] +pub(crate) use mutation_attribution::{ + resolve_bounded_mutation_attribution, resolve_post_commit_mutation_ai_patch, + BoundedMutationAttribution, MutationAttributionBarrier, MutationEventPageSource, + TreeReadSource, MAX_MUTATION_ATTRIBUTION_EVENTS, +}; +#[allow(unused_imports)] pub(crate) use scope_runtime::{ abandon_scope, AbandonRecoveryReason, AbandonScopeError, AbandonScopeOutcome, }; diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution.rs new file mode 100644 index 000000000..548d9700f --- /dev/null +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution.rs @@ -0,0 +1,442 @@ +use anyhow::Result; + +use std::collections::BTreeSet; +use std::path::Path; +use std::time::Duration; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::checkout::{read_checkout_id, resolve_git_dir}; +use crate::services::mutation_trace::attribution::{ + exclude_direct_coverage, logical_path, patch_for_locations, MutationAttributionResult, + PatchLineLocation, +}; +use crate::services::mutation_trace::lineage::{LineProvenance, MutationLineage, TransitionOrigin}; +use crate::services::mutation_trace::store::{ + AttributionKind, MutationEventPageRow, MutationTraceStore, MUTATION_ATTRIBUTION_PAGE_SIZE, +}; +use crate::services::mutation_trace::types::{FailureKind, TreeId, WorktreeId}; +use crate::services::patch::{parse_patch, ParsedPatch, TouchedLineKind}; + +use super::git_snapshot::GitSnapshotService; +use super::worktree_lock::WorktreeLock; + +pub const MAX_MUTATION_ATTRIBUTION_EVENTS: usize = 128; + +const REVISION_CUT_LOCK_TIMEOUT: Duration = Duration::from_secs(10); + +pub trait MutationEventPageSource { + fn load_mutation_event_page( + &self, + worktree: &WorktreeId, + revision_cursor: Option, + requested_limit: usize, + ) -> Result>; +} + +impl MutationEventPageSource for MutationTraceStore<'_> { + fn load_mutation_event_page( + &self, + worktree: &WorktreeId, + revision_cursor: Option, + requested_limit: usize, + ) -> Result> { + MutationTraceStore::load_mutation_event_page( + self, + worktree, + revision_cursor, + requested_limit, + ) + } +} + +pub trait TreeReadSource { + fn diff_trees(&self, before: &TreeId, after: &TreeId) -> Result; + fn file_at_tree(&self, tree: &TreeId, path: &str) -> Result>; +} + +impl TreeReadSource for GitSnapshotService { + fn diff_trees(&self, before: &TreeId, after: &TreeId) -> Result { + GitSnapshotService::diff_trees(self, before, after) + } + + fn file_at_tree(&self, tree: &TreeId, path: &str) -> Result> { + GitSnapshotService::file_at_tree(self, tree, path) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MutationAttributionBarrier { + PageQuery, + EventReconstruction, + Tail, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BoundedMutationAttribution { + pub result: MutationAttributionResult, + pub loaded_pages: usize, + pub loaded_rows: usize, + pub inspected_events: usize, + pub reconstructed_events: usize, + pub gap_resets: usize, + pub barrier: Option, +} + +impl BoundedMutationAttribution { + fn empty() -> Self { + BoundedMutationAttribution { + result: MutationAttributionResult { + mutation_ai_patch: empty_patch(), + resolved_non_ai_patch: empty_patch(), + unresolved_patch: empty_patch(), + }, + loaded_pages: 0, + loaded_rows: 0, + inspected_events: 0, + reconstructed_events: 0, + gap_resets: 0, + barrier: None, + } + } +} + +fn empty_patch() -> ParsedPatch { + ParsedPatch { files: Vec::new() } +} + +#[allow(clippy::too_many_arguments)] +pub fn resolve_bounded_mutation_attribution( + page_source: &P, + tree_source: &R, + worktree: &WorktreeId, + direct_coverage: &ParsedPatch, + committed_patch: &ParsedPatch, + commit_tree: &TreeId, + revision_ceiling: Option, +) -> BoundedMutationAttribution +where + P: MutationEventPageSource + ?Sized, + R: TreeReadSource + ?Sized, +{ + let target = exclude_direct_coverage(committed_patch, direct_coverage); + let target_paths = target_logical_paths(&target); + if target_paths.is_empty() { + return BoundedMutationAttribution::empty(); + } + + let mut state = ReplayState { + loaded_pages: 0, + loaded_rows: 0, + inspected_events: 0, + reconstructed_events: 0, + gap_resets: 0, + barrier: None, + }; + + let events = load_event_window(page_source, worktree, revision_ceiling, &mut state); + let lineage = if events.is_empty() { + None + } else { + Some(replay( + &events, + &target_paths, + commit_tree, + tree_source, + &mut state, + )) + }; + finish(&target, lineage.as_ref(), &state) +} + +struct ReplayState { + loaded_pages: usize, + loaded_rows: usize, + inspected_events: usize, + reconstructed_events: usize, + gap_resets: usize, + barrier: Option, +} + +fn finish( + target: &ParsedPatch, + lineage: Option<&MutationLineage>, + state: &ReplayState, +) -> BoundedMutationAttribution { + let result = match lineage { + Some(lineage) => project(target, lineage), + None => MutationAttributionResult { + mutation_ai_patch: empty_patch(), + resolved_non_ai_patch: empty_patch(), + unresolved_patch: target.clone(), + }, + }; + + BoundedMutationAttribution { + result, + loaded_pages: state.loaded_pages, + loaded_rows: state.loaded_rows, + inspected_events: state.inspected_events, + reconstructed_events: state.reconstructed_events, + gap_resets: state.gap_resets, + barrier: state.barrier, + } +} + +fn target_logical_paths(target: &ParsedPatch) -> BTreeSet { + target + .files + .iter() + .filter(|file| file.hunks.iter().any(|hunk| !hunk.lines.is_empty())) + .map(|file| logical_path(file).to_owned()) + .collect() +} + +fn load_event_window

( + page_source: &P, + worktree: &WorktreeId, + revision_ceiling: Option, + state: &mut ReplayState, +) -> Vec +where + P: MutationEventPageSource + ?Sized, +{ + let mut rows: Vec = Vec::new(); + let mut cursor: Option = revision_ceiling.and_then(|ceiling| ceiling.checked_add(1)); + + loop { + if rows.len() >= MAX_MUTATION_ATTRIBUTION_EVENTS { + break; + } + let want = MUTATION_ATTRIBUTION_PAGE_SIZE.min(MAX_MUTATION_ATTRIBUTION_EVENTS - rows.len()); + + let Ok(page) = page_source.load_mutation_event_page(worktree, cursor, want) else { + state.barrier = Some(MutationAttributionBarrier::PageQuery); + break; + }; + if page.is_empty() { + break; + } + + state.loaded_pages += 1; + state.loaded_rows += page.len(); + let short = page.len() < want; + cursor = Some(page[page.len() - 1].revision); + rows.extend(page); + + if short { + break; + } + } + + rows.reverse(); + rows +} + +fn replay( + events: &[MutationEventPageRow], + target_paths: &BTreeSet, + commit_tree: &TreeId, + tree_source: &R, + state: &mut ReplayState, +) -> MutationLineage +where + R: TreeReadSource + ?Sized, +{ + let mut lineage = MutationLineage::from_baseline(&load_baseline( + tree_source, + &events[0].before_tree, + target_paths, + )); + let mut prev_after = events[0].before_tree.clone(); + + for row in events { + state.inspected_events += 1; + + if row.before_tree != prev_after { + lineage.reset_all(&load_baseline(tree_source, &row.before_tree, target_paths)); + state.gap_resets += 1; + } + + let reconstructed = tree_source + .diff_trees(&row.before_tree, &row.after_tree) + .ok() + .and_then(|text| parse_patch(&text, None).ok()); + if reconstructed.is_some() { + state.reconstructed_events += 1; + } + + let origin = transition_origin(row); + if apply_or_reset( + &mut lineage, + reconstructed.as_ref(), + &origin, + &row.after_tree, + target_paths, + tree_source, + ) { + state.barrier = Some(MutationAttributionBarrier::EventReconstruction); + state.gap_resets += 1; + } + + prev_after = row.after_tree.clone(); + } + + if prev_after != *commit_tree { + let tail = tree_source + .diff_trees(&prev_after, commit_tree) + .ok() + .and_then(|text| parse_patch(&text, None).ok()); + if apply_or_reset( + &mut lineage, + tail.as_ref(), + &TransitionOrigin::Unobserved, + commit_tree, + target_paths, + tree_source, + ) { + state.barrier = Some(MutationAttributionBarrier::Tail); + state.gap_resets += 1; + } + } + + lineage +} + +fn apply_or_reset( + lineage: &mut MutationLineage, + patch: Option<&ParsedPatch>, + origin: &TransitionOrigin, + fallback_tree: &TreeId, + target_paths: &BTreeSet, + tree_source: &R, +) -> bool +where + R: TreeReadSource + ?Sized, +{ + let applied = patch.is_some_and(|patch| lineage.apply(patch, origin).is_ok()); + if !applied { + lineage.reset_all(&load_baseline(tree_source, fallback_tree, target_paths)); + } + !applied +} + +fn load_baseline( + tree_source: &R, + tree: &TreeId, + target_paths: &BTreeSet, +) -> std::collections::BTreeMap> +where + R: TreeReadSource + ?Sized, +{ + target_paths + .iter() + .map(|path| { + let content = tree_source.file_at_tree(tree, path).unwrap_or(None); + (path.clone(), content) + }) + .collect() +} + +fn transition_origin(row: &MutationEventPageRow) -> TransitionOrigin { + let healthy = !row.tainted && row.failure_kind == FailureKind::Healthy; + match (&row.attribution_kind, &row.attribution_scope_id) { + (AttributionKind::AiExclusive, Some(scope_id)) if healthy => { + TransitionOrigin::MutationAi(scope_id.clone()) + } + _ => TransitionOrigin::MutationNonAi, + } +} + +fn project(target: &ParsedPatch, lineage: &MutationLineage) -> MutationAttributionResult { + let mut ai = BTreeSet::new(); + let mut non_ai = BTreeSet::new(); + let mut unresolved = BTreeSet::new(); + + for (file_index, file) in target.files.iter().enumerate() { + let path = logical_path(file); + for (hunk_index, hunk) in file.hunks.iter().enumerate() { + for (line_index, line) in hunk.lines.iter().enumerate() { + let location = PatchLineLocation { + file_index, + hunk_index, + line_index, + }; + if line.kind != TouchedLineKind::Added { + unresolved.insert(location); + continue; + } + match lineage.provenance_at(path, line.line_number, &line.content) { + LineProvenance::MutationAi { .. } => { + ai.insert(location); + } + LineProvenance::MutationNonAi => { + non_ai.insert(location); + } + LineProvenance::Unknown => { + unresolved.insert(location); + } + } + } + } + } + + MutationAttributionResult { + mutation_ai_patch: patch_for_locations(target, &ai), + resolved_non_ai_patch: patch_for_locations(target, &non_ai), + unresolved_patch: patch_for_locations(target, &unresolved), + } +} + +pub(crate) fn resolve_post_commit_mutation_ai_patch( + repository_root: &Path, + db: &RepositoryAgentTraceDb, + direct_coverage: &ParsedPatch, + committed_patch: &ParsedPatch, +) -> ParsedPatch { + let Ok(git_dir) = resolve_git_dir(repository_root) else { + return empty_patch(); + }; + let Ok(Some(checkout_id)) = read_checkout_id(&git_dir) else { + return empty_patch(); + }; + let Ok(snapshot) = GitSnapshotService::new(repository_root) else { + return empty_patch(); + }; + let Ok(commit_tree) = snapshot.head_tree() else { + return empty_patch(); + }; + let store = MutationTraceStore::new(db); + let worktree = WorktreeId(checkout_id); + + let Some(revision_ceiling) = capture_revision_cut(&git_dir, &store, &worktree) else { + return empty_patch(); + }; + + resolve_bounded_mutation_attribution( + &store, + &snapshot, + &worktree, + direct_coverage, + committed_patch, + &commit_tree, + Some(revision_ceiling), + ) + .result + .mutation_ai_patch +} + +fn capture_revision_cut( + git_dir: &Path, + store: &MutationTraceStore<'_>, + worktree: &WorktreeId, +) -> Option { + let _lock = WorktreeLock::acquire(git_dir, REVISION_CUT_LOCK_TIMEOUT).ok()?; + store + .latest_mutation_event_revision(worktree) + .ok() + .flatten() +} + +#[cfg(test)] +#[path = "mutation_attribution/tests.rs"] +mod tests; diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs new file mode 100644 index 000000000..0b958de0a --- /dev/null +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs @@ -0,0 +1,952 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use super::*; +use crate::services::mutation_trace::store::AttributionKind; +use crate::services::mutation_trace::types::{FailureKind, ScopeId}; +use crate::services::patch::{FileChangeKind, PatchFileChange, PatchHunk, TouchedLine}; + +fn tree(id: &str) -> TreeId { + TreeId(id.to_owned()) +} + +fn worktree() -> WorktreeId { + WorktreeId("wt".to_owned()) +} + +fn empty() -> ParsedPatch { + ParsedPatch { files: Vec::new() } +} + +fn added(number: u64, content: &str) -> TouchedLine { + TouchedLine { + kind: TouchedLineKind::Added, + line_number: number, + content: content.to_owned(), + session_id: None, + } +} + +fn committed( + path: &str, + old_start: u64, + old_count: u64, + new_start: u64, + lines: Vec, +) -> ParsedPatch { + ParsedPatch { + files: vec![PatchFileChange { + old_path: path.to_owned(), + new_path: path.to_owned(), + kind: FileChangeKind::Modified, + hunks: vec![PatchHunk { + old_start, + old_count, + new_start, + new_count: lines.len() as u64, + model_id: None, + lines, + }], + }], + } +} + +fn page_row( + revision: u64, + before: &str, + after: &str, + kind: AttributionKind, + scope: Option<&str>, +) -> MutationEventPageRow { + MutationEventPageRow { + revision, + before_tree: tree(before), + after_tree: tree(after), + tainted: false, + failure_kind: FailureKind::Healthy, + attribution_kind: kind, + attribution_scope_id: scope.map(|id| ScopeId(id.to_owned())), + } +} + +fn ai_row(revision: u64, before: &str, after: &str, scope: &str) -> MutationEventPageRow { + page_row( + revision, + before, + after, + AttributionKind::AiExclusive, + Some(scope), + ) +} + +fn non_ai_row(revision: u64, before: &str, after: &str) -> MutationEventPageRow { + page_row(revision, before, after, AttributionKind::AiContended, None) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PageRequest { + cursor: Option, + limit: usize, +} + +struct FakePageSource { + events: Vec, + fail_on_page: Option, + requests: RefCell>, +} + +impl FakePageSource { + fn new(events: Vec) -> Self { + Self { + events, + fail_on_page: None, + requests: RefCell::new(Vec::new()), + } + } + + fn failing_on_page(mut self, page: usize) -> Self { + self.fail_on_page = Some(page); + self + } + + fn requests(&self) -> Vec { + self.requests.borrow().clone() + } +} + +impl MutationEventPageSource for FakePageSource { + fn load_mutation_event_page( + &self, + _worktree: &WorktreeId, + revision_cursor: Option, + requested_limit: usize, + ) -> Result> { + let page_number = self.requests.borrow().len() + 1; + self.requests.borrow_mut().push(PageRequest { + cursor: revision_cursor, + limit: requested_limit, + }); + if self.fail_on_page == Some(page_number) { + anyhow::bail!("injected page failure"); + } + let start = match revision_cursor { + None => 0, + Some(cursor) => self + .events + .iter() + .position(|event| event.revision < cursor) + .unwrap_or(self.events.len()), + }; + Ok(self + .events + .iter() + .skip(start) + .take(requested_limit) + .cloned() + .collect()) + } +} + +#[derive(Default)] +struct FakeTreeSource { + diffs: HashMap<(String, String), String>, + files: HashMap<(String, String), String>, + fail_diff_on: Option, + diff_calls: RefCell, +} + +impl FakeTreeSource { + fn new() -> Self { + Self::default() + } + + fn with_diff(mut self, before: &str, after: &str, text: &str) -> Self { + self.diffs + .insert((before.to_owned(), after.to_owned()), text.to_owned()); + self + } + + fn with_file(mut self, tree: &str, path: &str, content: &str) -> Self { + self.files + .insert((tree.to_owned(), path.to_owned()), content.to_owned()); + self + } + + fn failing_diff_on(mut self, call: usize) -> Self { + self.fail_diff_on = Some(call); + self + } + + fn diff_calls(&self) -> usize { + *self.diff_calls.borrow() + } +} + +impl TreeReadSource for FakeTreeSource { + fn diff_trees(&self, before: &TreeId, after: &TreeId) -> Result { + let call = { + let mut calls = self.diff_calls.borrow_mut(); + *calls += 1; + *calls + }; + if self.fail_diff_on == Some(call) { + anyhow::bail!("injected diff failure"); + } + if before == after { + return Ok(String::new()); + } + self.diffs + .get(&(before.0.clone(), after.0.clone())) + .cloned() + .ok_or_else(|| anyhow::anyhow!("no canned diff {before:?} -> {after:?}")) + } + + fn file_at_tree(&self, tree: &TreeId, path: &str) -> Result> { + Ok(self.files.get(&(tree.0.clone(), path.to_owned())).cloned()) + } +} + +fn ai_contents(attr: &BoundedMutationAttribution) -> Vec { + attr.result + .mutation_ai_patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .map(|line| line.content.clone()) + .collect() +} + +fn unresolved_contents(attr: &BoundedMutationAttribution) -> Vec { + attr.result + .unresolved_patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .map(|line| line.content.clone()) + .collect() +} + +#[test] +fn no_events_leaves_every_target_line_unresolved() { + let page_source = FakePageSource::new(Vec::new()); + let tree_source = FakeTreeSource::new(); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo")]), + &tree("commit"), + Some(5), + ); + + assert!(ai_contents(&attr).is_empty()); + assert_eq!(unresolved_contents(&attr), vec!["foo".to_owned()]); + assert_eq!( + attr.loaded_pages, 0, + "an empty page is not counted as loaded" + ); + assert_eq!(page_source.requests().len(), 1); +} + +#[test] +fn fully_direct_covered_target_does_zero_mutation_history_work() { + let page_source = FakePageSource::new(vec![ai_row(1, "b", "a", "s")]); + let tree_source = FakeTreeSource::new(); + + let direct = committed("f.rs", 1, 1, 1, vec![added(2, "foo")]); + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &direct, + &direct, + &tree("commit"), + Some(1), + ); + + assert!(page_source.requests().is_empty()); + assert_eq!(tree_source.diff_calls(), 0); + assert_eq!(attr.inspected_events, 0); + assert!(attr.result.mutation_ai_patch.files.is_empty()); +} + +#[test] +fn a_surviving_ai_mutation_line_is_attributed() { + let page_source = FakePageSource::new(vec![ai_row(1, "t0", "t1", "scope-1")]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo")]), + &tree("t1"), + Some(1), + ); + + assert_eq!(ai_contents(&attr), vec!["foo".to_owned()]); + assert!(unresolved_contents(&attr).is_empty()); + assert_eq!(attr.reconstructed_events, 1); + assert_eq!(attr.barrier, None); +} + +#[test] +fn ai_mutation_survives_an_unrelated_later_mutation() { + let page_source = FakePageSource::new(vec![ + non_ai_row(2, "t1", "t2"), + ai_row(1, "t0", "t1", "scope-1"), + ]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n", + ) + .with_diff( + "t1", + "t2", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,0 +3,1 @@\n+bar\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo"), added(3, "bar")]), + &tree("t2"), + Some(2), + ); + + assert_eq!(ai_contents(&attr), vec!["foo".to_owned()]); + let non_ai: Vec = attr + .result + .resolved_non_ai_patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .map(|line| line.content.clone()) + .collect(); + assert_eq!(non_ai, vec!["bar".to_owned()]); +} + +#[test] +fn a_stale_ai_mutation_cannot_resurrect_through_an_unobserved_tail() { + let page_source = FakePageSource::new(vec![ + non_ai_row(2, "t1", "t2"), + ai_row(1, "t0", "t1", "scope-1"), + ]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n", + ) + .with_diff( + "t1", + "t2", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,1 +1,0 @@\n-foo\n", + ) + .with_diff( + "t2", + "commit", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo")]), + &tree("commit"), + Some(2), + ); + + assert!( + ai_contents(&attr).is_empty(), + "the re-added foo must not inherit E1's dead provenance" + ); + assert_eq!(unresolved_contents(&attr), vec!["foo".to_owned()]); +} + +#[test] +fn a_non_ai_replacement_of_an_ai_line_owns_the_new_line() { + let page_source = FakePageSource::new(vec![ + non_ai_row(2, "t1", "t2"), + ai_row(1, "t0", "t1", "scope-1"), + ]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "head\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n head\n+foo = 1\n", + ) + .with_diff( + "t1", + "t2", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,1 +2,1 @@\n-foo = 1\n+foo = 2\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo = 2")]), + &tree("t2"), + Some(2), + ); + + assert!(ai_contents(&attr).is_empty()); + let non_ai: Vec = attr + .result + .resolved_non_ai_patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .map(|line| line.content.clone()) + .collect(); + assert_eq!(non_ai, vec!["foo = 2".to_owned()]); +} + +#[test] +fn a_suffix_related_mutation_file_never_pairs_with_the_committed_target() { + let page_source = FakePageSource::new(vec![ai_row(1, "t0", "t1", "scope-1")]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "src/lib.rs", "a\nunique_line\n") + .with_file("t0", "packages/foo/src/lib.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/packages/foo/src/lib.rs b/packages/foo/src/lib.rs\n\ + --- a/packages/foo/src/lib.rs\n\ + +++ b/packages/foo/src/lib.rs\n\ + @@ -1,1 +1,2 @@\n a\n+unique_line\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("src/lib.rs", 1, 1, 1, vec![added(2, "unique_line")]), + &tree("t1"), + Some(1), + ); + + assert!( + ai_contents(&attr).is_empty(), + "a suffix-related mutation path must not attribute the committed line" + ); + assert_eq!(unresolved_contents(&attr), vec!["unique_line".to_owned()]); +} + +#[test] +fn an_exact_nested_repository_path_still_attributes() { + let page_source = FakePageSource::new(vec![ai_row(1, "t0", "t1", "scope-1")]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "src/lib.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/src/lib.rs b/src/lib.rs\n\ + --- a/src/lib.rs\n\ + +++ b/src/lib.rs\n\ + @@ -1,1 +1,2 @@\n a\n+unique_line\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("src/lib.rs", 1, 1, 1, vec![added(2, "unique_line")]), + &tree("t1"), + Some(1), + ); + + assert_eq!(ai_contents(&attr), vec!["unique_line".to_owned()]); + assert!(unresolved_contents(&attr).is_empty()); +} + +#[test] +fn multiple_similar_nested_mutation_paths_stay_independent_of_the_target() { + let page_source = FakePageSource::new(vec![ai_row(1, "t0", "t1", "scope-1")]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "src/lib.rs", "a\nunique_line\n") + .with_file("t0", "packages/a/src/lib.rs", "a\n") + .with_file("t0", "packages/b/src/lib.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/packages/a/src/lib.rs b/packages/a/src/lib.rs\n\ + --- a/packages/a/src/lib.rs\n\ + +++ b/packages/a/src/lib.rs\n\ + @@ -1,1 +1,2 @@\n a\n+unique_line\n\ + diff --git a/packages/b/src/lib.rs b/packages/b/src/lib.rs\n\ + --- a/packages/b/src/lib.rs\n\ + +++ b/packages/b/src/lib.rs\n\ + @@ -1,1 +1,2 @@\n a\n+unique_line\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("src/lib.rs", 1, 1, 1, vec![added(2, "unique_line")]), + &tree("t1"), + Some(1), + ); + + assert!(ai_contents(&attr).is_empty()); + assert_eq!(unresolved_contents(&attr), vec!["unique_line".to_owned()]); +} + +#[test] +fn a_shared_basename_between_mutation_and_target_is_not_a_match() { + let page_source = FakePageSource::new(vec![ai_row(1, "t0", "t1", "scope-1")]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "bar/config.rs", "a\nunique_line\n") + .with_file("t0", "foo/config.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/foo/config.rs b/foo/config.rs\n\ + --- a/foo/config.rs\n\ + +++ b/foo/config.rs\n\ + @@ -1,1 +1,2 @@\n a\n+unique_line\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("bar/config.rs", 1, 1, 1, vec![added(2, "unique_line")]), + &tree("t1"), + Some(1), + ); + + assert!(ai_contents(&attr).is_empty()); + assert_eq!(unresolved_contents(&attr), vec!["unique_line".to_owned()]); +} + +#[test] +fn a_history_gap_is_not_crossed_by_older_provenance() { + let page_source = FakePageSource::new(vec![ + ai_row(2, "t9", "commit", "scope-2"), + ai_row(1, "t0", "t1", "scope-1"), + ]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_file("t9", "f.rs", "a\nfoo\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n", + ) + .with_diff( + "t9", + "commit", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,0 +3,1 @@\n+bar\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 2, 1, vec![added(2, "foo"), added(3, "bar")]), + &tree("commit"), + Some(2), + ); + + assert_eq!(ai_contents(&attr), vec!["bar".to_owned()]); + assert_eq!(unresolved_contents(&attr), vec!["foo".to_owned()]); + assert_eq!(attr.gap_resets, 1); +} + +#[test] +fn bounded_history_baseline_starts_unknown() { + let page_source = FakePageSource::new(vec![non_ai_row(2, "t1", "t2")]); + let tree_source = FakeTreeSource::new() + .with_file("t1", "f.rs", "a\nfoo\n") + .with_diff( + "t1", + "t2", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,0 +3,1 @@\n+bar\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 2, 1, vec![added(2, "foo")]), + &tree("t2"), + Some(2), + ); + + assert!(ai_contents(&attr).is_empty()); + assert_eq!(unresolved_contents(&attr), vec!["foo".to_owned()]); +} + +#[test] +fn an_unobserved_tail_adds_unknown_lines_but_keeps_surviving_ai_lines() { + let page_source = FakePageSource::new(vec![ai_row(1, "t0", "t1", "scope-1")]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+ai_line\n", + ) + .with_diff( + "t1", + "commit", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,0 +3,1 @@\n+human_line\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed( + "f.rs", + 1, + 1, + 1, + vec![added(2, "ai_line"), added(3, "human_line")], + ), + &tree("commit"), + Some(1), + ); + + assert_eq!(ai_contents(&attr), vec!["ai_line".to_owned()]); + assert_eq!(unresolved_contents(&attr), vec!["human_line".to_owned()]); +} + +#[test] +fn an_event_after_the_commit_cut_has_no_influence() { + let page_source = FakePageSource::new(vec![ + ai_row(2, "t0", "t1", "scope-after"), + non_ai_row(1, "t0", "t0"), + ]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\nfoo\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,0 +2,1 @@\n+foo\n", + ); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 2, 1, vec![added(2, "foo")]), + &tree("t0"), + Some(1), + ); + + assert!( + ai_contents(&attr).is_empty(), + "an event past the cut must not attribute" + ); + assert_eq!(page_source.requests()[0].cursor, Some(2)); +} + +#[test] +fn event_128_within_the_horizon_contributes_and_event_129_is_never_loaded() { + let total = 200u64; + let relevant = total - 127; + let mut events = Vec::new(); + for revision in (1..=total).rev() { + if revision == relevant { + events.push(ai_row(revision, "t0", "t1", "scope-128")); + } else { + events.push(non_ai_row(revision, "t1", "t1")); + } + } + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_diff( + "t0", + "t1", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+target\n", + ); + let page_source = FakePageSource::new(events); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "target")]), + &tree("t1"), + None, + ); + + assert_eq!(ai_contents(&attr), vec!["target".to_owned()]); + assert_eq!(attr.inspected_events, 128); + assert_eq!(attr.loaded_pages, 4); + assert_eq!(attr.loaded_rows, 128); + assert_eq!(attr.barrier, None); +} + +#[test] +fn a_page_query_failure_is_a_conservative_barrier() { + let mut events = Vec::new(); + for revision in (1..=40).rev() { + events.push(non_ai_row(revision, "t1", "t1")); + } + let tree_source = FakeTreeSource::new().with_file("t1", "f.rs", "a\n"); + let page_source = FakePageSource::new(events).failing_on_page(2); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo")]), + &tree("t1"), + None, + ); + + assert_eq!(attr.barrier, Some(MutationAttributionBarrier::PageQuery)); + assert_eq!(attr.loaded_rows, 32); + assert_eq!(unresolved_contents(&attr), vec!["foo".to_owned()]); +} + +#[test] +fn a_reconstruction_failure_reloads_a_conservative_baseline_and_keeps_going() { + let page_source = FakePageSource::new(vec![ + ai_row(2, "t1", "t2", "scope-2"), + ai_row(1, "t0", "t1", "scope-1"), + ]); + let tree_source = FakeTreeSource::new() + .with_file("t0", "f.rs", "a\n") + .with_file("t1", "f.rs", "a\n") + .with_diff( + "t1", + "t2", + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,1 +1,2 @@\n a\n+foo\n", + ) + .failing_diff_on(1); + + let attr = resolve_bounded_mutation_attribution( + &page_source, + &tree_source, + &worktree(), + &empty(), + &committed("f.rs", 1, 1, 1, vec![added(2, "foo")]), + &tree("t2"), + Some(2), + ); + + assert_eq!( + attr.barrier, + Some(MutationAttributionBarrier::EventReconstruction) + ); + assert_eq!(ai_contents(&attr), vec!["foo".to_owned()]); +} + +#[test] +fn real_git_snapshot_and_store_satisfy_the_consumer_seams() { + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; + use crate::services::mutation_trace::store::encode_revision; + use std::process::Command; + + let temp = tempfile::Builder::new() + .prefix("sce-lineage-consumer-") + .tempdir() + .expect("temp dir"); + let repo_root = temp.path().join("repo"); + std::fs::create_dir_all(&repo_root).expect("repo dir"); + let git = |args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(&repo_root) + .output() + .expect("git spawns"); + assert!(output.status.success(), "git {args:?} failed"); + }; + git(&["init", "--quiet"]); + git(&["config", "user.email", "t@example.com"]); + git(&["config", "user.name", "Test"]); + git(&["commit", "--allow-empty", "--quiet", "-m", "init"]); + + let snapshot = GitSnapshotService::new(&repo_root).expect("snapshot service"); + std::fs::write(repo_root.join("file.rs"), b"one\n").expect("write"); + let before = snapshot.capture_tree().expect("capture before"); + std::fs::write(repo_root.join("file.rs"), b"one\ntwo\n").expect("write"); + let after = snapshot.capture_tree().expect("capture after"); + + let db = RepositoryAgentTraceDb::new_at(temp.path().join("agent-trace.db")).expect("db opens"); + db.execute( + "INSERT INTO mutation_trace_events + (worktree_id, revision, before_tree, after_tree, tainted, failure_kind, + attribution_kind, attribution_scope_id, boundary_kind, boundary_scope_id, boundary_event_id) + VALUES (?1, ?2, ?3, ?4, 0, 'healthy', 'ai_exclusive', 'scope-real', 'flush', NULL, NULL)", + ( + "wt-real", + encode_revision(1).as_slice(), + before.0.as_str(), + after.0.as_str(), + ), + ) + .expect("event insert"); + let store = MutationTraceStore::new(&db); + + let attr = resolve_bounded_mutation_attribution( + &store, + &snapshot, + &WorktreeId("wt-real".to_owned()), + &empty(), + &committed("file.rs", 1, 1, 1, vec![added(2, "two")]), + &after, + Some(1), + ); + + assert_eq!(ai_contents(&attr), vec!["two".to_owned()]); + assert_eq!(attr.reconstructed_events, 1); + assert_eq!(attr.barrier, None); +} + +fn init_repo_with_commit(repo_root: &std::path::Path) { + use std::process::Command; + std::fs::create_dir_all(repo_root).expect("repo dir"); + let git = |args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(repo_root) + .output() + .expect("git spawns"); + assert!(output.status.success(), "git {args:?} failed"); + }; + git(&["init", "--quiet"]); + git(&["config", "user.email", "t@example.com"]); + git(&["config", "user.name", "Test"]); + std::fs::write(repo_root.join("seed"), b"seed\n").expect("seed"); + git(&["add", "-A"]); + git(&["commit", "--quiet", "-m", "init"]); +} + +#[test] +fn post_commit_entry_point_without_checkout_identity_yields_empty_and_creates_none() { + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; + + let temp = tempfile::Builder::new() + .prefix("sce-post-commit-lineage-no-identity-") + .tempdir() + .expect("temp dir"); + let repo_root = temp.path().join("repo"); + init_repo_with_commit(&repo_root); + + let git_dir = resolve_git_dir(&repo_root).expect("git dir"); + let checkout_id_path = git_dir.join("sce").join("checkout-id"); + assert!(!checkout_id_path.exists()); + + let db = RepositoryAgentTraceDb::new_at(temp.path().join("agent-trace.db")).expect("db opens"); + + let result = resolve_post_commit_mutation_ai_patch( + &repo_root, + &db, + &empty(), + &committed("file.rs", 1, 1, 1, vec![added(2, "two")]), + ); + + assert!(result.files.is_empty()); + assert!(!checkout_id_path.exists()); +} + +#[test] +fn post_commit_entry_point_resolves_current_worktree_and_ignores_foreign_rows() { + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; + use crate::services::checkout::get_or_create_checkout_id; + use crate::services::mutation_trace::store::encode_revision; + + let temp = tempfile::Builder::new() + .prefix("sce-post-commit-lineage-current-") + .tempdir() + .expect("temp dir"); + let repo_root = temp.path().join("repo"); + init_repo_with_commit(&repo_root); + + let git_dir = resolve_git_dir(&repo_root).expect("git dir"); + let checkout_id = get_or_create_checkout_id(&git_dir).expect("checkout id"); + + let snapshot = GitSnapshotService::new(&repo_root).expect("snapshot service"); + std::fs::write(repo_root.join("file.rs"), b"one\n").expect("write"); + let before = snapshot.capture_tree().expect("capture before"); + std::fs::write(repo_root.join("file.rs"), b"one\ntwo\n").expect("write"); + let after = snapshot.capture_tree().expect("capture after"); + + std::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(&repo_root) + .output() + .expect("git add"); + std::process::Command::new("git") + .args(["commit", "--quiet", "-m", "two"]) + .current_dir(&repo_root) + .output() + .expect("git commit"); + + let db = RepositoryAgentTraceDb::new_at(temp.path().join("agent-trace.db")).expect("db opens"); + for (worktree_id, revision, scope) in [ + (checkout_id.as_str(), 1u64, "scope-current"), + ("wt-foreign", 2u64, "scope-foreign"), + ] { + db.execute( + "INSERT INTO mutation_trace_events + (worktree_id, revision, before_tree, after_tree, tainted, failure_kind, + attribution_kind, attribution_scope_id, boundary_kind, boundary_scope_id, boundary_event_id) + VALUES (?1, ?2, ?3, ?4, 0, 'healthy', 'ai_exclusive', ?5, 'flush', NULL, NULL)", + ( + worktree_id, + encode_revision(revision).as_slice(), + before.0.as_str(), + after.0.as_str(), + scope, + ), + ) + .expect("event insert"); + } + + let result = resolve_post_commit_mutation_ai_patch( + &repo_root, + &db, + &empty(), + &committed("file.rs", 1, 1, 1, vec![added(2, "two")]), + ); + + let contents: Vec = result + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .map(|line| line.content.clone()) + .collect(); + assert_eq!(contents, vec!["two".to_owned()]); +} diff --git a/cli/src/services/mutation_trace/runtime/tests.rs b/cli/src/services/mutation_trace/runtime/tests.rs index de6838639..d193f99ef 100644 --- a/cli/src/services/mutation_trace/runtime/tests.rs +++ b/cli/src/services/mutation_trace/runtime/tests.rs @@ -8,19 +8,21 @@ use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::agent_trace_storage::{ resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, }; -use crate::services::checkout::{read_checkout_id, resolve_git_dir}; +use crate::services::checkout::{get_or_create_checkout_id, read_checkout_id, resolve_git_dir}; use crate::services::mutation_trace::protocol; use crate::services::mutation_trace::store::{ encode_revision, CasResult, DurableTransition, MutationTraceStore, }; use crate::services::mutation_trace::types::{ boundary_event_key, boundary_scope, ActorKind, AttemptId, Boundary, EventId, FailureKind, - ScopeId, ScopeStatus, + ScopeId, ScopeStatus, WorktreeId, }; +use crate::services::patch::{parse_patch, ParsedPatch}; use super::coordinator::{coordinate, coordinate_inner, CoordinateError, RuntimeBoundary}; use super::external_taint::ExternalTaintMarker; use super::git_snapshot::GitSnapshotService; +use super::mutation_attribution::resolve_bounded_mutation_attribution; use super::ref_reconciliation::{ reconcile_worktree, reconcile_worktree_inner, ReconcileError, ReconciliationOutcome, }; @@ -154,6 +156,33 @@ fn seed_event( .expect("event row insert should succeed"); } +fn seed_attribution_event( + db: &RepositoryAgentTraceDb, + worktree_id: &str, + revision: u64, + before_tree: &str, + after_tree: &str, + attribution_kind: &str, + attribution_scope_id: Option<&str>, +) { + db.execute( + "INSERT INTO mutation_trace_events + (worktree_id, revision, before_tree, after_tree, tainted, failure_kind, + attribution_kind, attribution_scope_id, boundary_kind, boundary_scope_id, + boundary_event_id) + VALUES (?1, ?2, ?3, ?4, 0, 'healthy', ?5, ?6, 'flush', NULL, NULL)", + ( + worktree_id, + encode_revision(revision).as_slice(), + before_tree, + after_tree, + attribution_kind, + attribution_scope_id, + ), + ) + .expect("attribution event row insert should succeed"); +} + fn ref_exists(dir: &Path, ref_name: &str) -> bool { Command::new("git") .args(["show-ref", "--verify", "--quiet", ref_name]) @@ -2266,3 +2295,101 @@ fn a_real_thread_cas_race_settles_on_the_competitors_terminal_status() { "the settled no-op writes nothing, so the revision stays at the competitor's" ); } + +#[test] +fn a_relevant_event_behind_128_newer_events_is_never_loaded_or_reconstructed() { + let repo = TestRepo::new("attribution-horizon"); + let git_dir = resolve_git_dir(&repo.repo_root).expect("git dir should resolve"); + let checkout_id = + get_or_create_checkout_id(&git_dir).expect("checkout identity should resolve"); + let snapshot = + GitSnapshotService::new(&repo.repo_root).expect("a snapshot service should build"); + + std::fs::write(repo.repo_root.join("file.rs"), b"one\n").expect("the before edit should write"); + let before = snapshot + .capture_tree() + .expect("capturing the before tree should succeed"); + std::fs::write(repo.repo_root.join("file.rs"), b"one\ntwo\n") + .expect("the after edit should write"); + let after = snapshot + .capture_tree() + .expect("capturing the after tree should succeed"); + + let db = repo.db(); + seed_attribution_event( + &db, + &checkout_id, + 1, + &before.0, + &after.0, + "ai_exclusive", + Some("scope-behind-the-horizon"), + ); + for revision in 2..=129 { + seed_attribution_event( + &db, + &checkout_id, + revision, + &after.0, + &after.0, + "ineligible_unscoped", + None, + ); + } + + let store = MutationTraceStore::new(&db); + let committed = parse_patch( + "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", + None, + ) + .expect("the committed patch should parse"); + + let attribution = resolve_bounded_mutation_attribution( + &store, + &snapshot, + &WorktreeId(checkout_id.clone()), + &ParsedPatch { files: Vec::new() }, + &committed, + &after, + None, + ); + + assert_eq!( + attribution.inspected_events, 128, + "the 128-event horizon caps inspection at exactly 128 events" + ); + assert_eq!( + attribution.reconstructed_events, 128, + "every inspected no-op event still reconstructs" + ); + assert_eq!( + attribution.loaded_pages, 4, + "the 32/128 constants imply exactly four pages here" + ); + assert_eq!( + attribution.loaded_rows, 128, + "the 129th row is never loaded" + ); + assert!( + attribution.barrier.is_none(), + "exhausting the horizon is not a failure barrier" + ); + assert!( + attribution + .result + .mutation_ai_patch + .files + .iter() + .all(|file| file.hunks.iter().all(|hunk| hunk.lines.is_empty())), + "the relevant event beyond the horizon never contributes AI coverage" + ); + assert!( + !attribution + .result + .unresolved_patch + .files + .iter() + .all(|file| file.hunks.iter().all(|hunk| hunk.lines.is_empty())), + "the committed line stays unresolved because its only match was never inspected" + ); +} diff --git a/cli/src/services/mutation_trace/store.rs b/cli/src/services/mutation_trace/store.rs index 213d1df17..3e9d428b3 100644 --- a/cli/src/services/mutation_trace/store.rs +++ b/cli/src/services/mutation_trace/store.rs @@ -110,6 +110,25 @@ pub enum AttributionKind { AiContended, } +/// Maximum number of mutation-event rows returned by one attribution-history +/// page request. +pub const MUTATION_ATTRIBUTION_PAGE_SIZE: usize = 32; + +/// The cold-path subset of a historical mutation event needed by attribution. +/// +/// This deliberately omits boundary data and active scopes: attribution only +/// needs the tree transition and the event's health/attribution state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MutationEventPageRow { + pub revision: u64, + pub before_tree: TreeId, + pub after_tree: TreeId, + pub tainted: bool, + pub failure_kind: FailureKind, + pub attribution_kind: AttributionKind, + pub attribution_scope_id: Option, +} + /// The discriminant of an [`Attribution`] value. pub fn attribution_kind(attribution: &Attribution) -> AttributionKind { match attribution { @@ -199,6 +218,23 @@ const SELECT_PROCESSED_EVENT_SQL: &str = const SELECT_MUTATION_EVENT_SQL: &str = "SELECT before_tree, after_tree, tainted, failure_kind, attribution_kind, attribution_scope_id, boundary_kind, boundary_scope_id, boundary_event_id FROM mutation_trace_events WHERE worktree_id = ?1 AND revision = ?2"; +const SELECT_MUTATION_EVENT_PAGE_SQL: &str = "SELECT revision, before_tree, after_tree, tainted, + failure_kind, attribution_kind, attribution_scope_id + FROM mutation_trace_events + WHERE worktree_id = ?1 + ORDER BY revision DESC + LIMIT ?2"; +const SELECT_MUTATION_EVENT_PAGE_AFTER_SQL: &str = + "SELECT revision, before_tree, after_tree, tainted, + failure_kind, attribution_kind, attribution_scope_id + FROM mutation_trace_events + WHERE worktree_id = ?1 AND revision < ?2 + ORDER BY revision DESC + LIMIT ?3"; +const SELECT_LATEST_MUTATION_EVENT_REVISION_SQL: &str = "SELECT revision FROM mutation_trace_events + WHERE worktree_id = ?1 + ORDER BY revision DESC + LIMIT 1"; const SELECT_MUTATION_EVENT_ACTIVE_SCOPES_SQL: &str = "SELECT scope_id FROM mutation_trace_event_active_scopes WHERE worktree_id = ?1 AND revision = ?2"; /// One worktree's complete durable tree root set — its cursor tree plus the @@ -668,6 +704,58 @@ impl<'a> MutationTraceStore<'a> { })) } + /// Reads one descending page of the historical mutation events for exactly + /// `worktree`. When `revision_cursor` is present, only revisions strictly + /// below it are returned, so the caller can continue from the last row of + /// a prior page without duplicates. The requested limit is always capped + /// at [`MUTATION_ATTRIBUTION_PAGE_SIZE`]. + /// + /// This is a read-only cold path. It does not load active scopes, processed + /// events, boundary data, or any timestamp column. + pub fn load_mutation_event_page( + &self, + worktree: &WorktreeId, + revision_cursor: Option, + requested_limit: usize, + ) -> Result> { + let limit = requested_limit.min(MUTATION_ATTRIBUTION_PAGE_SIZE); + let rows = match revision_cursor { + Some(cursor) => { + let cursor_blob = encode_revision(cursor); + self.db.query_map( + SELECT_MUTATION_EVENT_PAGE_AFTER_SQL, + ( + worktree.0.as_str(), + cursor_blob.as_slice(), + limit_as_i64(limit), + ), + mutation_event_page_row_from_turso, + )? + } + None => self.db.query_map( + SELECT_MUTATION_EVENT_PAGE_SQL, + (worktree.0.as_str(), limit_as_i64(limit)), + mutation_event_page_row_from_turso, + )?, + }; + + Ok(rows) + } + + pub fn latest_mutation_event_revision(&self, worktree: &WorktreeId) -> Result> { + let rows = self.db.query_map( + SELECT_LATEST_MUTATION_EVENT_REVISION_SQL, + (worktree.0.as_str(),), + |row| { + let blob: Vec = row + .get(0) + .context("failed to read mutation_trace_events.revision")?; + decode_revision(&blob) + }, + )?; + Ok(rows.into_iter().next()) + } + /// Reads `worktree`'s complete durable tree root set: its /// `mutation_trace_worktrees.cursor_tree`, plus the `before_tree` and /// `after_tree` of every `mutation_trace_events` row for `worktree`, @@ -902,6 +990,10 @@ fn attribution_scope_id(attribution: &Attribution) -> Option<&str> { } } +fn limit_as_i64(limit: usize) -> i64 { + i64::try_from(limit).expect("mutation attribution page limit should fit in i64") +} + fn boundary_payload(boundary: &Boundary) -> (Option<&str>, Option<&str>) { match boundary { Boundary::Start { scope, event } @@ -989,6 +1081,43 @@ fn scope_row_from_turso(row: &turso::Row) -> Result<(ScopeId, ScopeState)> { )) } +fn mutation_event_page_row_from_turso(row: &turso::Row) -> Result { + let revision_blob: Vec = row + .get(0) + .context("failed to read mutation_trace_events.revision")?; + let before_tree: String = row + .get(1) + .context("failed to read mutation_trace_events.before_tree")?; + let after_tree: String = row + .get(2) + .context("failed to read mutation_trace_events.after_tree")?; + let tainted: bool = row + .get(3) + .context("failed to read mutation_trace_events.tainted")?; + let failure_kind: String = row + .get(4) + .context("failed to read mutation_trace_events.failure_kind")?; + let attribution_kind: String = row + .get(5) + .context("failed to read mutation_trace_events.attribution_kind")?; + let attribution_scope_id: Option = row + .get(6) + .context("failed to read mutation_trace_events.attribution_scope_id")?; + let attribution_kind = decode_attribution_kind(&attribution_kind)?; + reconstruct_attribution(attribution_kind, attribution_scope_id.clone())?; + let attribution_scope_id = attribution_scope_id.map(ScopeId); + + Ok(MutationEventPageRow { + revision: decode_revision(&revision_blob)?, + before_tree: TreeId(before_tree), + after_tree: TreeId(after_tree), + tainted, + failure_kind: decode_failure_kind(&failure_kind)?, + attribution_kind, + attribution_scope_id, + }) +} + /// Raw decoded `mutation_trace_events` row fields, prior to reconstructing /// the full `Attribution`/`Boundary`/`active_scopes` a [`MutationEvent`] /// carries. @@ -4127,4 +4256,118 @@ mod tests { ); assert!(roots.contains(&TreeId("tree-x".to_string()))); } + + mod mutation_attribution { + use super::*; + + #[test] + fn page_reader_orders_big_endian_revisions_and_isolates_worktrees() { + let db_fixture = test_db_path("mutation-attribution-ordering"); + let db = RepositoryAgentTraceDb::new_at(db_fixture.path()) + .expect("repository DB should open"); + let store = MutationTraceStore::new(&db); + + for revision in [1, 255, 256, u64::MAX] { + insert_mutation_event( + &db, + "wt-1", + revision, + "before", + "after", + "ai_exclusive", + Some("scope-1"), + "flush", + None, + None, + &[], + ); + } + insert_mutation_event( + &db, + "wt-2", + 999, + "foreign-before", + "foreign-after", + "ai_exclusive", + Some("foreign-scope"), + "flush", + None, + None, + &[], + ); + + let rows = store + .load_mutation_event_page(&WorktreeId("wt-1".to_string()), None, 100) + .expect("mutation event page should load"); + + assert_eq!( + rows.iter().map(|row| row.revision).collect::>(), + vec![u64::MAX, 256, 255, 1] + ); + assert_eq!(rows[0].before_tree, TreeId("before".to_string())); + assert_eq!(rows[0].after_tree, TreeId("after".to_string())); + assert!(!rows[0].tainted); + assert_eq!(rows[0].failure_kind, FailureKind::Healthy); + assert_eq!(rows[0].attribution_kind, AttributionKind::AiExclusive); + assert_eq!( + rows[0].attribution_scope_id, + Some(ScopeId("scope-1".to_string())) + ); + } + + #[test] + fn page_reader_caps_limits_and_continues_with_an_exclusive_cursor() { + let db_fixture = test_db_path("mutation-attribution-pagination"); + let db = RepositoryAgentTraceDb::new_at(db_fixture.path()) + .expect("repository DB should open"); + let store = MutationTraceStore::new(&db); + + for revision in 1..=33 { + insert_mutation_event( + &db, + "wt-1", + revision, + "before", + "after", + "ineligible_unscoped", + None, + "flush", + None, + None, + &[], + ); + } + + let first_page = store + .load_mutation_event_page(&WorktreeId("wt-1".to_string()), None, 100) + .expect("first mutation event page should load"); + assert_eq!(first_page.len(), MUTATION_ATTRIBUTION_PAGE_SIZE); + assert_eq!(first_page.first().map(|row| row.revision), Some(33)); + assert_eq!(first_page.last().map(|row| row.revision), Some(2)); + + let cursor = first_page + .last() + .expect("the capped page should not be empty") + .revision; + let second_page = store + .load_mutation_event_page( + &WorktreeId("wt-1".to_string()), + Some(cursor), + MUTATION_ATTRIBUTION_PAGE_SIZE, + ) + .expect("second mutation event page should load"); + assert_eq!( + second_page + .iter() + .map(|row| row.revision) + .collect::>(), + vec![1] + ); + + let empty_page = store + .load_mutation_event_page(&WorktreeId("wt-1".to_string()), Some(1), 1) + .expect("page after the final cursor should load"); + assert!(empty_page.is_empty()); + } + } } diff --git a/context/architecture.md b/context/architecture.md index 00191f626..4c6ca08c3 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 result to `post_commit_patch_intersections`, then persists 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`, 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-trace-agent-attribution.md b/context/cli/mutation-trace-agent-attribution.md new file mode 100644 index 000000000..3539ba188 --- /dev/null +++ b/context/cli/mutation-trace-agent-attribution.md @@ -0,0 +1,185 @@ +# Mutation-trace Agent Trace attribution + +Mutation history is a conservative secondary source for committed touched lines +that direct `diff_traces` evidence does not cover. Direct evidence is always +resolved first and is never revoked or replaced by mutation evidence. + +Attribution is **causal**, not textual. The retained mutation history is treated +as one ordered sequence of tree transitions and provenance is propagated forward +through it. Historical mutation events are never searched independently for text +matching the committed patch. Once a later transition removes a line an event +introduced, that event's provenance is dead and no older event can resurrect it. + +## Ordered lineage + +`cli/src/services/mutation_trace/lineage.rs` is a pure module (no Git, SQLite, +filesystem, or mutation state). It tracks, per repo path, a vector of +`(content, LineProvenance)` lines and advances it one transition at a time. + +File identity is **exact logical repository path** equality only. A file's +logical path is `new_path` when non-empty and otherwise `old_path`; the lineage +keys its tracked vectors by that path and a reconstructed transition only +affects a tracked file when its own `old_path`/`new_path` is byte-equal to a +tracked path. There is no normalized-suffix or basename equivalence here: +`packages/foo/src/lib.rs` never pairs with `src/lib.rs`. Suffix matching stays +confined to the direct-evidence matchers in +[patch-service.md](patch-service.md). + +`LineProvenance` is `Unknown`, `MutationAi { scope_id }`, or `MutationNonAi`. +A `TransitionOrigin` is: + +- `MutationAi(scope)` — a recorded event that is untainted, `FailureKind::Healthy`, + and `Attribution::AiExclusive(scope)`; its added lines become `MutationAi`. +- `MutationNonAi` — any other recorded event (contended, unscoped, unhealthy, or + tainted); its added lines become `MutationNonAi`. +- `Unobserved` — a transition with no recorded event: the conservative baseline + reload after a history gap, and the final latest-observed-tree to + committed-tree tail. Its added lines are `Unknown`. + +`MutationLineage::apply(patch, origin)` transforms the tracked line vectors +structurally from the hunk positions (`parse_patch` drops context lines, so +carried context is reconstructed from `old_count`/`new_count` and the removed/ +added line numbers): + +- context / carried line — provenance carried forward unchanged even as its line + number moves; +- removed line — deleted permanently, verified against the tracked line's + content; +- added line — a new entry whose provenance comes only from `origin`; +- replacement (`-old` / `+new`) — remove the old entry, create a new one from + `origin`; textual similarity never transfers provenance; +- duplicate identical lines stay at distinct positions; provenance never jumps + between occurrences. + +Any structurally inconsistent transition (content mismatch, inconsistent hunk +lengths, out-of-range hunk) returns `LineageError`; the caller fails closed for +the affected file. + +## Bounded history consumer + +`resolve_bounded_mutation_attribution` in +`cli/src/services/mutation_trace/runtime/mutation_attribution.rs` composes the +store's descending [`load_mutation_event_page`](mutation-trace-store.md) reader +and read-only Git tree access over two injectable traits — +`MutationEventPageSource` (implemented for `MutationTraceStore`) and +`TreeReadSource` (implemented for `GitSnapshotService`, adding `file_at_tree` +alongside `diff_trees`). + +- **Direct evidence resolves first.** `attribution::exclude_direct_coverage` + removes directly covered committed lines by `(logical path, kind, line_number, + content)` before any mutation-history work. If no lines remain, the consumer + performs zero SQLite and zero Git work. +- **Load window.** The invoking worktree's events are paged newest first, bounded + by both `MAX_MUTATION_ATTRIBUTION_EVENTS = 128` and the commit attribution cut + (`revision <= ceiling`, applied as an exclusive `ceiling + 1` first cursor). + Every request asks for `min(MUTATION_ATTRIBUTION_PAGE_SIZE, 128 − loaded)` + rows; at most four pages under the 32/128 constants. Event 128 may contribute; + event 129 is never loaded. Traversal is current-worktree-only and + timestamp-independent; no `created_at` participates. +- **Replay oldest to newest.** The window is reversed. The baseline is the + oldest retained event's `before_tree`, every line `Unknown`, read with + `file_at_tree`. Each event's transition is `diff_trees(before, after)` parsed + and applied with its `MutationAi`/`MutationNonAi` origin. +- **Transition continuity.** Before each event, if its `before_tree` does not + equal the previous event's `after_tree`, the tracked files are reloaded to an + all-`Unknown` baseline from that `before_tree` and older provenance does not + cross the gap. Newer events still establish provenance. +- **Unobserved tail.** After the last replayed event, if its `after_tree` + differs from `commit_tree`, `diff(after, commit_tree)` is applied as an + `Unobserved` transition: new and replaced tail lines are `Unknown`, surviving + lines keep their provenance. +- **Projection.** Only after the lineage reaches `commit_tree`, each committed + added line is looked up at its exact committed-tree position: + `MutationAi -> mutation AI coverage`, `MutationNonAi -> resolved non-AI`, + `Unknown` / missing / content mismatch -> unresolved. +- **Conservative failure.** A page-query failure truncates history (the window + is simply smaller and the baseline older). A tree-diff / patch-parse / + structural-apply failure reloads the affected files to an all-`Unknown` + baseline from a real tree state and replay continues. A tail failure leaves + tail lines `Unknown`. Bounded history that cannot prove an older line's + provenance is a false negative, never a false positive. The barrier kind is + reported on the result; the function never returns `Err`. +- **Work counters.** `loaded_pages` / `loaded_rows` (database) are separate from + `inspected_events` / `reconstructed_events` (Git); `gap_resets` counts + conservative reloads during replay. + +The consumer performs no mutation-cursor write and creates no worktree or scope +identity. + +## Commit attribution cut + +`resolve_post_commit_mutation_ai_patch(repository_root, &db, direct_coverage, +committed_patch) -> ParsedPatch` is the read-only post-commit entrypoint. It +resolves the invoking worktree's *existing* checkout identity +([`checkout::resolve_git_dir`](checkout-identity.md) + `read_checkout_id`, never +`get_or_create_*`), reads `HEAD^{tree}` as `commit_tree`, and captures the +commit attribution cut: under the same worktree lock that serializes +mutation-event transitions +([`worktree_lock`](mutation-trace-runtime-coordinator.md)), it reads +`MutationTraceStore::latest_mutation_event_revision` for the worktree. An event +produced after the commit has a higher revision and cannot participate. The +critical section is a single indexed read. + +An unresolvable git dir, an absent/unreadable checkout identity, an unavailable +snapshot service, an unreadable `HEAD` tree, a lock timeout, or no mutation +history at all each yield an empty patch, so post-commit falls back to +direct-only Agent Trace behavior. The entrypoint creates no identity and writes +no mutation-cursor state. + +## Post-commit composition + +- **Direct evidence stays authoritative.** The post-commit flow computes the + existing direct `intersect_patches` intersection and passes it as + `direct_coverage`; only committed lines it does not cover reach mutation + history. `post_commit_patch_intersections` keeps its direct-only meaning and + mutation evidence never enters `diff_traces`. +- **No fabricated provenance.** The mutation-AI patch is target-shaped and + carries no model, session, tool, or tool-version metadata. `ScopeId`, + `ActorKind`, and `AiExclusive(scope)` are never translated into direct + provenance. Hunk model/session and the top-level `tool` object still derive + from direct evidence only; mutation-only coverage merely widens `ai` / `mixed` + classification. See + [../sce/agent-trace-minimal-generator.md](../sce/agent-trace-minimal-generator.md). +- **Final persistence.** The single combined Agent Trace (direct + mutation AI + coverage) is validated against the embedded schema and stored in + `agent_traces.trace_json`; no schema, migration, or checkpoint is added. Hook + wiring detail lives in + [../sce/agent-trace-hooks-command-routing.md](../sce/agent-trace-hooks-command-routing.md). + +## Known limitation + +A tree-snapshot system cannot observe an intermediate remove/re-add that leaves +no tree-state difference. If the latest observed tree already contains an AI +line and a human removes and re-adds byte-identical text before the commit so +that the committed tree matches the latest observed tree, the lineage still +reports the surviving AI provenance. The fix this path delivers is that observed +history must be *causal*: it does not reconstruct mutations that produced no +observable tree difference. + +## Regressions + +- `lineage.rs` — added AI line carries provenance; removed line loses it + permanently; identical remove/re-add takes the new transition's provenance; + context provenance survives line-number movement; `Unobserved` introduces + `Unknown`; content mismatch is a `LineageError`; duplicate lines do not let + provenance jump; deleted file drops out. +- `runtime/mutation_attribution/tests.rs` — surviving AI line attributed; AI + survives an unrelated later mutation; a stale AI mutation cannot resurrect + through an unobserved tail; a non-AI replacement owns the new line; a history + gap is not crossed; bounded-history baseline starts `Unknown`; an unobserved + tail adds `Unknown` but keeps surviving AI; an event past the commit cut has + no influence; event 128 contributes and 129 is never loaded; a page-query + failure is a conservative barrier; a reconstruction failure reloads and + continues; real `GitSnapshotService` + store seams. Exact-path identity: a + suffix-related mutation file (`packages/foo/src/lib.rs`) never pairs with a + committed `src/lib.rs` even with identical added text and line number; an + exact nested path still attributes; multiple similar nested mutation paths + stay independent of the target; a shared basename alone is not a match. +- `runtime/tests.rs` — a still-relevant event behind 128 newer events is never + loaded or reconstructed. +- `hooks/mod.rs` (`mutation_attribution_e2e`) — real Git/DB: mutation-only `ai` + without provenance; direct+mutation completion; a newer non-exclusive event + keeps a line non-AI; adversarial linked-worktree isolation; the three + persistence layers stay separated (`diff_traces` and + `post_commit_patch_intersections` direct-only, `agent_traces.trace_json` + combined). diff --git a/context/cli/mutation-trace-snapshot-service.md b/context/cli/mutation-trace-snapshot-service.md index 9abc7feb3..2ba710f5d 100644 --- a/context/cli/mutation-trace-snapshot-service.md +++ b/context/cli/mutation-trace-snapshot-service.md @@ -50,6 +50,13 @@ tree in the same repository. SHAs, returning the raw diff text `patch.rs::parse_patch` already knows how to parse. +`head_tree(&self) -> Result` resolves `HEAD^{tree}`. +`file_at_tree(&self, tree, path) -> Result>` reads one blob's +UTF-8 text via `git cat-file blob :`, returning `None` for a missing +path or a non-UTF-8 blob. Both exist only for post-commit mutation attribution: +`head_tree` is the terminal tree state the lineage must reach, `file_at_tree` +builds the conservative all-`Unknown` baseline and gap/failure reloads. + ## Pin inventory and conditional deletion **Mutation-cursor pins are direct refs.** Every valid pin is a direct ref @@ -105,10 +112,15 @@ helper are the single source of truth for the pin path; `pin_tree`, ## Callers -`coordinator.rs` is the only caller of `capture`/`pin`/`diff_trees` so far, via -its `SnapshotCapture` trait; `list_pins` / `delete_pins` have no caller yet — -the deferred per-worktree ref-reconciliation maintenance pass is their first -consumer. +`coordinator.rs` is the only caller of `capture`/`pin` so far, via its +`SnapshotCapture` trait. `diff_trees`, `head_tree`, and `file_at_tree` have a +second, read-only caller: the bounded mutation-history attribution consumer +(`runtime/mutation_attribution.rs`) reconstructs each historical event's tree +transition and reads baseline file content through its own `TreeReadSource` +trait (see +[`mutation-trace-agent-attribution.md`](mutation-trace-agent-attribution.md)). +`list_pins` / `delete_pins` are consumed by the per-worktree +ref-reconciliation maintenance pass. ## Testing boundary diff --git a/context/cli/mutation-trace-store.md b/context/cli/mutation-trace-store.md index 92e12f10d..d5e7f4add 100644 --- a/context/cli/mutation-trace-store.md +++ b/context/cli/mutation-trace-store.md @@ -73,6 +73,25 @@ cold path: it reconstructs one historical `MutationEvent`, including full called from `load_worktree` or from any hook-boundary path, so a projection load never pays for the full historical event set. +`MutationTraceStore::load_mutation_event_page(worktree, revision_cursor, +requested_limit)` is the attribution-history cold reader. It selects only the +revision, tree transition, taint/failure state, attribution kind, and optional +exclusive scope; filters by exact `worktree_id`; orders the fixed-width +big-endian revision BLOB descending; and applies `revision < cursor` when a +cursor is supplied. Each request caps `requested_limit` at +`MUTATION_ATTRIBUTION_PAGE_SIZE` (32), and it does not read active scopes, +processed events, boundary fields, or timestamps. The caller owns any broader +history horizon and advances the cursor from the last returned revision. +[Attribution](mutation-trace-agent-attribution.md) reverses each page to replay +oldest-to-newest, and starts the cursor at `commit_cut + 1` (exclusive) so only +`revision <= commit_cut` events are eligible. + +`MutationTraceStore::latest_mutation_event_revision(worktree) -> Result>` +is a one-row cold read of the highest `mutation_trace_events.revision` for the +worktree (or `None` when it has none). Post-commit attribution reads it under +the worktree lock as its commit attribution cut: an event with a higher +revision was produced after the commit and must not participate. + `MutationTraceStore::load_scope(scope_id) -> Result>` is the public single-scope read seam: one `mutation_trace_scopes` row by primary key, returning the durable `status` / `actor_kind` / `worktree_id`, or `None` when no diff --git a/context/cli/patch-service.md b/context/cli/patch-service.md index a5d6ff760..20d8ae307 100644 --- a/context/cli/patch-service.md +++ b/context/cli/patch-service.md @@ -66,6 +66,12 @@ Both functions wrap `serde_json::from_str`/`serde_json::from_slice` and map serd - **Determinism**: the same inputs in the same order always produce the same output - **Consumed by**: the post-commit hook runtime combines recent DB diff-trace patches before intersecting (see `agent-trace-hooks-command-routing.md`). +### Direct matching vs. causal mutation lineage + +`intersect_patches` and `combine_patches` are the **direct-evidence** matchers and are unchanged: their historical `(kind, content)` fallback is deliberately permissive so canonical post-commit lines still reconcile with earlier incremental `diff_traces`, even where repeated content stays physically ambiguous. + +Mutation-history attribution does **not** reuse them and does **not** match historical patches against the committed patch at all. `cli/src/services/mutation_trace/lineage.rs` applies each reconstructed transition's hunks structurally to a per-line provenance vector and propagates provenance forward; `attribution.rs` only supplies `exclude_direct_coverage` (drop directly covered lines) and `patch_for_locations` (rebuild a target-shaped patch from selected line locations). File identity for mutation lineage is **exact logical repository path** equality only — the lineage keys tracked files by that path (`new_path` when non-empty, otherwise `old_path`), so a reconstructed transition touching a path that is not byte-equal to a committed target path contributes no provenance. There is no normalized-suffix or basename fallback in the mutation matcher; suffix equivalence stays confined to the direct-evidence matchers above. A committed line is AI only if the line an AI event introduced survives every later transition into the committed tree. See [mutation-trace-agent-attribution.md](mutation-trace-agent-attribution.md). + ### Codex apply_patch boundary Codex `PostToolUse(apply_patch)` evidence enters this service only after the diff --git a/context/context-map.md b/context/context-map.md index 27b05e9bb..35108443a 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -24,9 +24,10 @@ Feature/domain context: - `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-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`; 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-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-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) diff --git a/context/glossary.md b/context/glossary.md index a52b5d3ec..9967e5d12 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -81,6 +81,7 @@ - `agent trace DB adapter`: Modules under `cli/src/services/agent_trace_db/` that define the sole repository-scoped `RepositoryAgentTraceDb = TursoDb` adapter (the checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter and its 15-file migration chain were removed by the `retire-legacy-agent-trace-db` plan). The repository adapter uses the `agent-trace-repository` migration set (fresh baseline schema plus additive `source_instance_id` and `claude_model_state` migrations) with `repository_metadata`, repository-level `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` tables, no row-level `checkout_id`, typed parameterized insert helpers, the non-exported `claude_model_state` latest-locally-observed register with exact `(session_id, agent_id)` reads and guarded writes, and chronological recent `diff_traces` query/parse support. `AgentTraceDbLifecycle` initializes/checks repository-scoped storage through `agent_trace_storage`. - `post-commit Agent Trace auto-sync readiness`: The doctor report fact that explains whether the enabled post-commit trigger is ready without invoking it. Doctor compares the installed `post-commit` hook's SCE managed block using the same currency semantics as setup and resolves config-file-only `agent_trace.auto_sync` with source metadata. JSON states are `ready`, `disabled`, `not_ready`, and `not_applicable`; explicit disable is healthy, while existing hook problems continue to own overall readiness and remediation. See [automatic Agent Trace synchronization](cli/agent-trace-auto-sync.md) and [doctor human text](sce/doctor-human-text-contract.md). - `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; wired into `sce hooks diff-trace` for Claude payload classification at intake and into `RepositoryAgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing, where persisted row `model_id` is assigned to every hunk and persisted canonical row `session_id` to every touched line before downstream reconstruction. +- `MutationLineage` / `causal mutation-lineage attribution`: `cli/src/services/mutation_trace/lineage.rs` is a pure module holding, per repo path, a vector of `(content, LineProvenance)` where `LineProvenance` is `Unknown`, `MutationAi { scope_id }`, or `MutationNonAi`. `MutationLineage::apply(patch, origin)` advances it one reconstructed tree transition at a time, applying hunks structurally: context lines carry provenance forward, removed lines are deleted permanently, added lines take only the introducing `TransitionOrigin` (`MutationAi` for healthy untainted `AiExclusive`, `MutationNonAi` for any other recorded event, `Unobserved` → `Unknown` for gap reloads and the latest-tree→commit-tree tail); a structurally inconsistent transition is a `LineageError` and the caller reloads that file to an all-`Unknown` baseline. Historical mutation patches are never matched independently against the committed patch — that earlier `MutationPatchEvidence` / strict-matcher model let stale evidence resurrect and is removed. `attribution.rs` now only supplies `exclude_direct_coverage` (drop directly covered lines) and `patch_for_locations`. The store supplies events through an exact-worktree-scoped `mutation-event page` ordered by descending 8-byte big-endian revision, continued with an exclusive revision cursor, capped at 32 rows; plus `latest_mutation_event_revision` for the commit attribution cut. The `bounded mutation-history consumer` (`resolve_bounded_mutation_attribution` in `cli/src/services/mutation_trace/runtime/mutation_attribution.rs`) loads the newest ≤128 in-cut events through `MutationEventPageSource` + `TreeReadSource` (`diff_trees` + `file_at_tree`) seams, replays them oldest-to-newest from a conservative baseline through the tail into `commit_tree`, then projects surviving provenance onto the committed patch; it is current-worktree-only and timestamp-independent, owns `MAX_MUTATION_ATTRIBUTION_EVENTS = 128` (event 128 may contribute, 129 is never loaded), tracks database (`loaded_pages`/`loaded_rows`) apart from Git (`inspected_events`/`reconstructed_events`/`gap_resets`) work, and records a `mutation-attribution barrier` on any page-query or reconstruction/tail failure while still returning conservative results. See [mutation-trace store](cli/mutation-trace-store.md) and [mutation-trace Agent Trace attribution](cli/mutation-trace-agent-attribution.md). - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace(...)` under `metadata.sce`, carrying `version` (sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`) and `line_changes` (exact `{ai,mixed,unknown}` × `{added,removed}` `u64` touched-line attribution counts derived from canonical `post_commit_patch` hunks, reusing each hunk's existing `Conversation.contributor.type` classification with no independent second classification pass, `#[serde(default)]` for backward-compatible deserialization of pre-existing payloads); the whole object is schema-validated with the rest of the payload and persisted in AgentTraceDb `agent_traces.trace_json` without changing the top-level Agent Trace payload/schema `version`. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace(...)` inside every `ranges[]` entry as `murmur3:`, computed from the touched-line kind/content of the `post_commit_patch` or embedded-patch hunk used to emit that range while excluding positions, paths, metadata, and database IDs. - `Claude diff-trace attribution`: Diff-trace enrichment rule where one Claude `PostToolUse` event resolves its model with `direct > exact transcript > exact session/agent state > NULL`: direct top-level/nested metadata first, then that event's `transcript_path` matched by `tool_use_id` to an assistant envelope's `tool_use.id`, then one exact `(cc_, agent_id)` lookup in local `claude_model_state`; model sources receive one `claude/` normalization step, ephemeral agent context is never exported, and subagents do not inherit main-session state. @@ -208,7 +209,6 @@ - `sce release authority contract`: Approved release topology where repo-root `.version` is the canonical checked-in release version source, GitHub Releases are the canonical publication surface for signed release artifacts, and Cargo/npm registry publication are separate downstream publish stages that consume already-versioned checked-in package metadata without workflow-side version bumping. - `sce release-npm-package app`: Root-flake app exposed as `nix run .#release-npm-package`; stages the checked-in npm package, rewrites the requested version, runs `npm pack`, and emits `sce-v-npm.tgz` plus `sce-v-npm.json` for release publication. - `sce release-flatpak-package app`: Linux-only root-flake app exposed as `nix run .#release-flatpak-package -- --version --out-dir `; runs the Nix-built `flatpak-version-parity-check` script (parity across `.version`, `cli/Cargo.toml`, `npm/package.json`, and Flatpak AppStream release metadata), requires a resolvable git release commit, stages `packaging/flatpak/` manifest/support files without mutating checked-in sources, uses the Nix manifest expression's commit-pinned flavor to emit the staged manifest, and produces deterministic Flatpak source-manifest tarball/checksum/JSON metadata. - - `sce release-flatpak-bundle app`: Linux-only root-flake app exposed as `nix run .#release-flatpak-bundle -- --version --arch --out-dir `; runs the Nix-built `flatpak-version-parity-check` script, uses the Nix manifest expression's local-checkout-override flavor to produce a Flatpak `type: dir` manifest, runs imperative `flatpak-builder --force-clean --arch=` plus `flatpak build-bundle`, and produces SHA-256 checksum and JSON metadata (`asset_type: flatpak-bundle`); used by `.github/workflows/release-sce-linux.yml` (x86_64) and `.github/workflows/release-sce-linux-arm.yml` (aarch64). - `sce split platform release workflows`: CLI release automation topology where `.github/workflows/release-sce.yml` orchestrates reusable per-platform workflow files; the current reusable workflow set and active orchestrated release matrix are `release-sce-linux.yml`, `release-sce-linux-arm.yml`, and `release-sce-macos-arm.yml`, producing the current automated release target set `x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl`, and `aarch64-apple-darwin`. Each native reusable workflow validates the generated archive before native artifact upload by extracting it, smoke-running `bin/sce version --format json`, and invoking the native portability audit for the lane platform. - `publish-crates workflow`: Dedicated crates.io publish automation in `.github/workflows/publish-crates.yml` that runs after a GitHub release is published (or by manual dispatch), validates `.version`, `cli/Cargo.toml`, and the requested release tag remain aligned, supports a dry-run validation path, and requires `CARGO_REGISTRY_TOKEN` for real publication. @@ -227,14 +227,14 @@ - `load_patch_from_json_bytes`: Public function in `cli/src/services/patch.rs` that reconstructs a `ParsedPatch` from JSON bytes; bytes-oriented counterpart to `load_patch_from_json` for callers working with raw byte data - `intersect_patches`: Public function in `cli/src/services/patch.rs` that computes target-shaped touched-line intersection between two `ParsedPatch` values; takes `constructed_patch` and `post_commit_patch` as inputs, matches files by post-change path identity (exact `new_path` equality or absolute-vs-relative suffix-equivalent path segments), prefers exact touched-line matching by `kind` + `line_number` + `content`, falls back to historical matching by `kind` + `content` when line numbers drift across intermediate edits, and returns a `ParsedPatch` shaped from `post_commit_patch` file/hunk ranges while inheriting result-hunk `model_id` from matched `constructed_patch` hunk provenance when available; consumed by the active post-commit hook runtime - `combine_patches`: Public function in `cli/src/services/patch.rs` that merges multiple `ParsedPatch` values into one deterministic result with later-input-wins semantics; groups files by `new_path`, deduplicates touched lines by identity (`kind` + `line_number` + `content`) with later patches winning, preserves file metadata and hunk metadata from the last contributing patch, orders files by first encounter and hunks by `old_start`; consumed by the active post-commit hook runtime before intersection -- `HunkContributor`: Enum in `cli/src/services/agent_trace.rs` classifying a `post_commit_patch` hunk's origin relative to the intersection patch `intersection_patch = intersect_patches(constructed_patch, post_commit_patch)`: `Ai` (exact line-by-line match), `Mixed` (same slot but different content), `Unknown` (no corresponding slot in `intersection_patch`); serialized as `snake_case` JSON strings +- `HunkContributor`: Enum in `cli/src/services/agent_trace.rs` classifying a `post_commit_patch` hunk from the union of direct AI coverage (`intersection_patch = intersect_patches(direct_patch, post_commit_patch)`) and any mutation-derived AI coverage supplied via `AgentTraceEvidence.mutation_ai_patch`: `Ai` (every touched line covered), `Mixed` (a non-empty proper subset covered), `Unknown` (no line covered); with no mutation evidence this reduces exactly to the direct-only slot rule (`Ai` exact line-by-line match, `Mixed` same slot different content, `Unknown` no matching `old_start` slot); serialized as `snake_case` JSON strings - `Conversation`: Struct in `cli/src/services/agent_trace.rs` representing one per-hunk entry in the minimal agent-trace payload, carrying a nested `contributor` object (`type` plus optional `model_id`) plus `ranges`, where the current implementation emits exactly one `{ start_line, end_line }` entry derived from the `post_commit_patch` hunk - `TraceFile`: Struct in `cli/src/services/agent_trace.rs` representing one per-file entry in the minimal agent-trace payload, carrying `path` (from `post_commit_patch`'s `new_path`) plus `conversations` (one per `post_commit_patch` hunk) - `AgentTraceVcs`: Top-level VCS metadata struct in `cli/src/services/agent_trace.rs` carrying `type` and `revision`; builder behavior maps `type` from caller metadata (`AgentTraceMetadataInput.vcs_type`, enum-backed) and maps revision from caller metadata when VCS metadata is present. - `AgentTrace`: Top-level struct in `cli/src/services/agent_trace.rs` representing the minimal agent-trace payload, carrying top-level `version` (fixed to `0.1.0`, strict numeric `x.y.z`), `id` (UUIDv7 string derived from the same commit-time moment used for `timestamp` in `build_agent_trace(...)`), `timestamp` (caller-provided commit timestamp via `AgentTraceMetadataInput.commit_timestamp`, validated as RFC 3339), optional `vcs` (`Option`, omitted from serialized JSON when `None`), and `files` (`Vec`, one per `post_commit_patch` file); `serde`-serializable with `snake_case` field naming -- `classify_hunk`: Public function in `cli/src/services/agent_trace.rs` that classifies a single `post_commit_patch` hunk against `intersection_patch` hunks by matching on `old_start` slot, returning `HunkContributor::Ai` for exact line-by-line match, `Mixed` for same-slot-but-different-content, or `Unknown` when no matching slot exists +- `classify_hunk`: Public function in `cli/src/services/agent_trace.rs` implementing the direct-only slot rule (retained primitive; the builder itself classifies through the internal combined direct+mutation line-coverage rule): matches a `post_commit_patch` hunk against `intersection_patch` hunks on `old_start` slot, returning `HunkContributor::Ai` for exact line-by-line match, `Mixed` for same-slot-but-different-content, or `Unknown` when no matching slot exists - `AgentTraceMetadataInput`: Metadata input struct in `cli/src/services/agent_trace.rs` that carries `commit_timestamp` (RFC 3339 commit-time value used as `AgentTrace.timestamp`), `commit_revision` (mapped to `AgentTrace.vcs.revision` when VCS metadata is emitted), and optional `vcs_type` (`Option`, mapped to `AgentTrace.vcs.type` and controlling whether top-level `vcs` is emitted); `AgentTraceVcsType` is the schema-aligned `Git`/`Jj`/`Hg`/`Svn` enum serialized as `git`/`jj`/`hg`/`svn`. -- `build_agent_trace`: Public function in `cli/src/services/agent_trace.rs` that computes `intersection_patch = intersect_patches(constructed_patch, post_commit_patch)`, iterates over `post_commit_patch` files and hunks, classifies each hunk against `intersection_patch`, validates `AgentTraceMetadataInput.commit_timestamp` as RFC 3339, derives UUIDv7 `AgentTrace.id` from that same commit-time moment, and returns `Result` with top-level metadata fields plus one `Conversation` per `post_commit_patch` hunk; consumed by the active post-commit hook flow, with no standalone `sce agent-trace` command surface. +- `build_agent_trace`: Public function in `cli/src/services/agent_trace.rs` — the direct-only compatibility path — that delegates to `build_agent_trace_from_evidence` with an empty `mutation_ai_patch`: computes `intersection_patch = intersect_patches(constructed_patch, post_commit_patch)`, iterates over `post_commit_patch` files and hunks, classifies each hunk, validates `AgentTraceMetadataInput.commit_timestamp` as RFC 3339, derives UUIDv7 `AgentTrace.id` from that same commit-time moment, and returns `Result` with top-level metadata fields plus one `Conversation` per `post_commit_patch` hunk; consumed by the active post-commit hook flow, with no standalone `sce agent-trace` command surface. `build_agent_trace_from_evidence(AgentTraceEvidence { direct_patch, mutation_ai_patch }, post_commit_patch, metadata)` is the separated-evidence entrypoint: identical top-level metadata behavior, combined direct+mutation hunk classification, and `model_id` / `related` / top-level `tool` provenance derived from the direct intersection only. - `agent-trace plugin diff extraction seam`: Helper `extractDiffTracePayload` in `config/lib/agent-trace-plugin/opencode-sce-agent-trace-plugin.ts` that accepts a typed `message` event and returns `{ sessionID, diff, time, model_id }` only for user-role messages with non-empty `summary?.diffs`; it joins present object-entry `patch` fields with `\n`, skips entries without `patch`, returns `undefined` when no usable patches remain, uses `Date.now()` for `time`, and builds `model_id` as `providerID/modelID` from `event.properties.info.model`. - `get_or_create_encryption_key`: Public keyring-backed helper in `cli/src/services/db/encryption_key.rs` that retrieves or generates a 64-character hex encryption key from the OS credential store (macOS Keychain, Linux Secret Service via zbus, Windows Credential Store); uses `keyring_core::Entry` with service name `"sce"` and the database name as username. Actively consumed by `EncryptedTursoDb::new()` via the shared adapter constructor. - `conversation-trace mixed batch`: Rust `sce hooks conversation-trace` STDIN contract accepting `{ payloads: [{ type: "message" | "message.part", ... }] }` with top-level `type` ignored and malformed-item skipping. See `context/sce/agent-trace-hooks-command-routing.md` and `context/sce/agent-trace-db.md`. diff --git a/context/overview.md b/context/overview.md index e9ea04bca..3b12b6a81 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 module is still not wired into any hook or command (see `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 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`. 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. @@ -68,7 +68,7 @@ The current supported automated release target matrix is `x86_64-unknown-linux-m Context sync uses an important-change gate: cross-cutting/policy/architecture/terminology changes require root shared-file edits, while localized tasks run verify-only root checks without default churn. OpenCode and Claude no longer generate legacy bootstrap or context-sync skills; `/commit` and `/handover` are generated only as catalog-registered composite workflow packages. OpenCode retains only thin routing agents, while Claude emits no agents. The superseded grouped Markdown catalog and automated OpenCode profile have been removed from Pkl ownership and generated outputs. The prior no-git-wrapper Agent Trace design artifacts under `context/sce/agent-trace-*.md` are retained only as historical reference; the current CLI runtime no longer wires the removed Agent Trace schema adaptation, payload building, retry replay, or rewrite handling paths into local hook execution. - The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); when the preflight returns `NoOverlap` or `Error` (including DB open failure, schema not ready, query error, staged diff read failure, or zero overlap), the trailer is not appended and errors are logged via `sce.hooks.commit_msg.ai_overlap_error`; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days (dispatching `patch` rows through existing unified-diff parsing and `structured` rows through `structured_patch::derive_claude_structured_patch` at read time, then assigning each structured hunk the persisted row model and every structured touched line the persisted canonical `cc_...` row session), combines/intersects patches, persists intersection metadata to `post_commit_patch_intersections`, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows, top-level `metadata.sce.version` from the compiled `sce` CLI package version, always-emitted `metadata.sce.line_changes` touched-line attribution counts, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent or `null` → `None`, present+non-empty → `Some`, present+empty → error), required nullable/non-empty `tool_version`, plus required `u64` millisecond `time`, uses `direct > exact transcript > exact session/agent state > NULL` Claude `model_id` resolution plus direct `tool_version`, with ephemeral agent scope and no generic `session_models` runtime, and continues with `None` when all sources cannot resolve attribution, with same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. Claude structured `PostToolUse` diff-trace intake prefers direct top-level or nested model metadata, then matches the event's `tool_use_id` in its JSONL `transcript_path`, consults exact local lifecycle state only when both event-local sources fail, normalizes model values once with the `claude/` prefix, and fails open to `None` when no source resolves. + The hooks service now uses a minimal attribution-only runtime: `commit-msg` is the only hook that mutates behavior, conditionally injecting exactly one canonical SCE trailer when the attribution-hooks gate is enabled, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); when the preflight returns `NoOverlap` or `Error` (including DB open failure, schema not ready, query error, staged diff read failure, or zero overlap), the trailer is not appended and errors are logged via `sce.hooks.commit_msg.ai_overlap_error`; `pre-commit` and `post-rewrite` remain deterministic no-op entrypoints; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, captures current commit patch, queries recent `diff_traces` from past 7 days (dispatching `patch` rows through existing unified-diff parsing and `structured` rows through `structured_patch::derive_claude_structured_patch` at read time, then assigning each structured hunk the persisted row model and every structured touched line the persisted canonical `cc_...` row session), combines/intersects patches, persists the direct-only intersection metadata to `post_commit_patch_intersections`, then — for committed touched lines that direct intersection did not cover — resolves bounded, read-only mutation-history AI coverage scoped to the invoking worktree's existing identity (newest 128 events replayed oldest-to-newest as one causal tree-transition lineage, bounded also by a commit attribution cut — `revision <= latest_mutation_event_revision` read under the worktree lock — with a committed line attributed AI only if an AI event's line survives every later transition into the committed tree; no provenance, no `diff_traces` or mutation-cursor write) and passes direct and mutation-AI evidence separately into the Agent Trace builder, and persists the schema-validated built Agent Trace payload, including optional top-level `tool` metadata from recent diff-trace rows (direct evidence only), top-level `metadata.sce.version` from the compiled `sce` CLI package version, always-emitted `metadata.sce.line_changes` touched-line attribution counts over the combined direct+mutation coverage, and range-level `content_hash` values, to AgentTraceDb `agent_traces` (DB-only, no post-commit Agent Trace file artifact); `diff-trace` currently validates/persists required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent or `null` → `None`, present+non-empty → `Some`, present+empty → error), required nullable/non-empty `tool_version`, plus required `u64` millisecond `time`, uses `direct > exact transcript > exact session/agent state > NULL` Claude `model_id` resolution plus direct `tool_version`, with ephemeral agent scope and no generic `session_models` runtime, and continues with `None` when all sources cannot resolve attribution, with same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. Claude structured `PostToolUse` diff-trace intake prefers direct top-level or nested model metadata, then matches the event's `tool_use_id` in its JSONL `transcript_path`, consults exact local lifecycle state only when both event-local sources fail, normalizes model values once with the `claude/` prefix, and fails open to `None` when no source resolves. The CLI now also includes an approved operator-environment doctor contract documented in `context/sce/agent-trace-hook-doctor.md`; the runtime includes `sce doctor --fix` parsing/help, stable problem/fix-result reporting, canonical hook-repair reuse, bounded doctor-owned local-DB directory bootstrap for the missing SCE-owned DB parent path, and target-scoped integration inventory: Claude reports `Plugins`, `Commands`, and `Skills`; OpenCode reports `Plugins`, `Agents`, `Commands`, and `Skills`; Pi reports `Extensions`, `Prompts`, and `Skills`; and Codex reports `Skills` and `Hooks` (see `context/sce/doctor-human-text-contract.md`). Its non-launching post-commit Agent Trace auto-sync fact reports enabled/current, explicit disabled, not-ready, and not-applicable states using canonical managed-block currency and resolved configuration; existing hook remediation and readiness semantics remain unchanged. The local DB service now provides `LocalDb` as a thin `TursoDb` alias in `cli/src/services/local_db/mod.rs`; `LocalDbSpec` resolves the canonical local DB path from the shared default-path catalog and currently declares zero migrations. Shared Turso infrastructure lives in `cli/src/services/db/mod.rs`, where `DbSpec` and generic `TursoDb` support local or remote sync-mode opens, parent-directory creation, connection setup, synchronous query helpers, embedded migration execution, and shared DB lifecycle helpers. Auth DB persistence uses encrypted `AuthDb = EncryptedTursoDb` and token storage persists credentials through the `auth_credentials` table. Agent Trace persistence uses the sole `RepositoryAgentTraceDb = TursoDb` adapter at `/sce/repos//agent-trace.db`, with a one-file repository schema for `repository_metadata`, `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes/triggers, and no `checkout_id` columns on trace rows. The checkout-scoped `AgentTraceDb = TursoDb` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, and the 15-file `cli/migrations/agent-trace/` chain were removed by the `retire-legacy-agent-trace-db` plan; active hook runtime writes nullable event-local diff-trace attribution without a `session_models` API/table dependency. The hooks command surface now also supports concrete runtime subcommand routing (`pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, `conversation-trace`, and `claude-model-state`) with deterministic argument/STDIN validation; `session-model` is no longer supported. Claude settings install the model-state command for both `SessionStart` and `PostModelSwitch`; compatibility smoke against Claude Code 2.1.251 and immediately older 2.1.250 showed that the older client safely ignores the unknown event, so installation is unconditional without a raised minimum or capability gate. Current runtime behavior keeps commit-msg attribution enabled by default unless explicitly opted out: the attribution gate enables canonical trailer insertion in `commit-msg` only when the staged-diff AI-overlap preflight confirms AI/editor evidence (no trailer is appended when the preflight finds no overlap or encounters any error); `pre-commit`/`post-rewrite` remain deterministic no-ops, `post-commit` requires validated `--remote-url`, threads that URL into the Agent Trace flow, prints it to stderr, remains the active bounded recent-diff-trace intersection path, and after successful Agent Trace persistence optionally launches the detached sync-owned `sync --format json` child when config-file-only `agent_trace.auto_sync` is true; `diff-trace` is the active intake path for parsed STDIN `{ sessionID, diff, time, model_id?, tool_name, tool_version }` payload persistence with optional `model_id`, required non-empty `tool_name`, required nullable/non-empty `tool_version`, `direct > exact transcript > exact session/agent state > NULL` Claude `model_id` plus direct `tool_version` values (exact local state only; no generic session-model fallback or cache), required `u64` millisecond `time`, same-tool-idempotent stored `session_id` prefixing (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi), non-lossy AgentTraceDb `time_ms` conversion, and no parsed-payload artifact fallback. This behavior is documented in `context/sce/agent-trace-hooks-command-routing.md`. The removed `sce hooks claude-capture` raw capture route is documented in `context/sce/claude-raw-hook-capture.md` as a removed feature. diff --git a/context/patterns.md b/context/patterns.md index a8a04938a..6a257da82 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -189,7 +189,7 @@ - Pure unit tests should test in-memory logic, parsing, validation, and data transformations without external dependencies; prefer mocking/faking external dependencies over creating real filesystem or database state when a fake is available and sufficient. - In-memory database tests (e.g., `LocalDatabaseTarget::InMemory`) are acceptable for unit tests since they don't touch the filesystem. -- A filesystem-touching `#[cfg(test)] mod tests` inline in the module under test is an established, Nix-sandbox-safe pattern in this codebase when every path is a unique, process-local path under `TMPDIR` (itself a writable per-build directory inside the Nix sandbox). Prefer an RAII `tempfile::TempDir` fixture, which cleans itself up even when a test panics — as `cli/src/services/mutation_trace/store.rs`'s `RepositoryAgentTraceDb`-backed tests, `runtime/tests.rs`, `runtime/protected_worktree.rs`, and `runtime/scope_runtime.rs` do. Manually composed unique `std::env::temp_dir()` paths are the older form of the same pattern and remain in `cli/src/services/checkout/mod.rs`'s checkout-identity-lock tests and in `runtime/coordinator.rs`, `runtime/worktree_lock.rs`, and `runtime/external_taint.rs`; they leak their directory on a panicking test, so new tests should use `TempDir`. This is not integration-test-only territory in this repository. +- A filesystem-touching `#[cfg(test)] mod tests` inline in the module under test is an established, Nix-sandbox-safe pattern in this codebase when every path is a unique, process-local path under `TMPDIR` (itself a writable per-build directory inside the Nix sandbox). Prefer an RAII `tempfile::TempDir` fixture, which cleans itself up even when a test panics — as `cli/src/services/mutation_trace/store.rs`'s `RepositoryAgentTraceDb`-backed tests, `runtime/tests.rs`, `runtime/protected_worktree.rs`, `runtime/scope_runtime.rs`, `runtime/mutation_attribution.rs`'s real-store-and-`GitSnapshotService` seam test, and `cli/src/services/hooks/mod.rs`'s `mutation_attribution_e2e` real Git + repository-DB post-commit regressions do. Manually composed unique `std::env::temp_dir()` paths are the older form of the same pattern and remain in `cli/src/services/checkout/mod.rs`'s checkout-identity-lock tests and in `runtime/coordinator.rs`, `runtime/worktree_lock.rs`, and `runtime/external_taint.rs`; they leak their directory on a panicking test, so new tests should use `TempDir`. This is not integration-test-only territory in this repository. - Use integration tests instead of this inline pattern where the behavior genuinely cannot be made deterministic and isolated inside the Nix sandbox this way. - Do not depend on a shared or ambient path (`$HOME`, a fixed `/tmp` file name, the repository's own working tree) from a unit test; each test must construct its own unique, self-cleaning path. - When a unit test needs behavior that cannot be made Nix-sandbox-safe this way (for example, network access, or state shared across the whole test binary), delete it from the unit-test suite and reintroduce that coverage later as an integration test instead of keeping ignored tests in-tree. diff --git a/context/plans/mutation-trace-agent-attribution.md b/context/plans/mutation-trace-agent-attribution.md new file mode 100644 index 000000000..c4b1ee003 --- /dev/null +++ b/context/plans/mutation-trace-agent-attribution.md @@ -0,0 +1,193 @@ +# Plan: mutation-trace-agent-attribution + +## Change summary + +Extend the existing post-commit Agent Trace pipeline so observed mutation-cursor history can attribute committed touched lines that direct `diff_traces` evidence does not cover. Direct evidence remains independently sufficient and is resolved first. Mutation history is a bounded, read-only secondary source: inspect at most the newest 128 events for the current worktree, newest first, and let the first safely matching event resolve each remaining line. Only a healthy, untainted `AiExclusive(scope)` match contributes AI coverage; a safely matching contended, unscoped, unhealthy, or tainted event blocks older evidence for that line without contributing AI coverage. + +This extends the implementation on PR #258 / `mutation-scope-runtime-integration`. It adds no harness adapter, mutation-protocol change, database migration, checkpoint state, timestamp window, or Agent Trace schema change. Raw evidence remains separate: mutation evidence stays in `mutation_trace_*`, direct evidence stays in `diff_traces`, `post_commit_patch_intersections` keeps its current direct-only meaning, and only the final combined Agent Trace is persisted in `agent_traces.trace_json`. + +## Attribution contract + +For every touched line in the canonical post-commit patch: + +1. Resolve direct evidence with the existing direct `intersect_patches` behavior. A direct match is AI, retains its current provenance, is removed from the unresolved set, and is never consulted against mutation history. +2. For unresolved lines only, inspect mutation events for the current `WorktreeId` in descending revision order. Revision, not `created_at`, defines order. Event 128 may contribute; event 129 and older must not be inspected. +3. Within one reconstructed mutation patch, determine safe line matches in two passes. A file's logical path is `new_path` when non-empty and otherwise `old_path`; exact logical-path pairing wins, while normalized suffix equivalence is allowed only when it identifies exactly one mutation file and one unresolved target file. + > **Superseded during implementation (see T03 corrective pass and [../cli/mutation-trace-agent-attribution.md](../cli/mutation-trace-agent-attribution.md)):** the independent per-patch file/line matcher was replaced by a causal ordered lineage. File identity for mutation evidence is now **exact logical repository path equality only** — no normalized-suffix or basename fallback. Suffix matching remains confined to the direct-evidence matchers. + - Exact line pass: within a safely paired file, pair equal `kind`, `line_number`, and `content` one-to-one. + - Historical fallback pass: among lines left unmatched by the exact pass, pair equal `kind` and `content` only when exactly one remaining mutation candidate and exactly one remaining unresolved target line share that key in the paired file. + - Ambiguous repeated-content or file matches are not matches and leave the target unresolved. False negatives are acceptable. The existing permissive direct-evidence fallback is unchanged. +4. The first safely matching event resolves a line. If the event is untainted, has `FailureKind::Healthy`, and has `Attribution::AiExclusive(_)`, add the line to mutation-derived AI coverage. For `AiContended`, `IneligibleUnscoped`, unhealthy, or tainted events, record the line as resolved/non-AI. In either case remove it from the unresolved set so older events cannot reclaim it. +5. A mutation page query/decode failure or an inspected event's tree-diff/patch-parse failure is a barrier. Preserve direct evidence and results from newer successfully reconstructed events, inspect no older event, and leave all remaining lines unresolved/unknown. +6. Fetch descending mutation rows with `requested_limit = min(MUTATION_ATTRIBUTION_PAGE_SIZE, MAX_MUTATION_ATTRIBUTION_EVENTS - inspected_events)`. Every request is therefore capped by both the 32-row page size and the remaining 128-event budget; the reader never requests rows beyond that budget, even if the constants are no longer exact multiples (for example, page size 32 and horizon 130 yields requests of 32, 32, 32, 32, then 2). `inspected_events` increments when the consumer begins reconstructing an event, including an event whose reconstruction fails. Rows returned by SQLite and events reconstructed with Git are distinct counts. +7. Stop reconstructing immediately when the unresolved set becomes empty. If this happens at event 4, events 5–32 may already be loaded in the current page but are not reconstructed or diffed, and no next DB page is requested. +8. Final hunk classification uses the union of direct and mutation-derived AI coverage: all touched lines covered is `ai`, a non-empty proper subset is `mixed`, and no AI-covered line is `unknown`. Resolved/non-AI and still-unresolved lines are both non-AI coverage for this classification. +9. Mutation-only AI coverage carries no model, session, tool, or tool-version provenance. `ScopeId`, `ActorKind`, and mutation attribution fields are never translated into direct provenance. + +## Acceptance criteria + +How this plan is proven complete. Each criterion is observable and names the check that proves it. `/validate` runs these checks; no task in the stack performs final validation. + +- [ ] AC1: Existing direct `diff_trace` attribution and matching are unchanged, direct matches are resolved before mutation history, and mutation state cannot revoke direct AI evidence. + - Validate: existing Agent Trace/direct patch behavior tests remain green; table-driven resolver tests prove directly covered lines are absent from mutation matching; the `direct_only` golden remains schema-valid and semantically unchanged. +- [ ] AC2: For a line without direct coverage, the newest safely matching healthy, untainted `AiExclusive` event within the 128-event horizon is sufficient AI evidence, including for a delayed commit while the event remains in-horizon. + - Validate: pure resolver tests cover exclusive and delayed-event cases; the `exclusive_without_direct` golden and real Git/DB Bash-style mutation regression produce `ai`. +- [ ] AC3: The newest safely matching mutation wins: contended, unscoped, unhealthy, or tainted matches resolve a line as non-mutation-AI and prevent every older event from claiming it. + - Validate: table-driven resolver tests cover every non-positive state, and the `newer_nonexclusive_blocks` golden plus real overwrite regression remain non-AI. +- [ ] AC4: Mutation matching prefers exact file/kind/line/content identity and permits kind/content fallback only for a unique remaining candidate in both the mutation patch and unresolved target; ambiguous repeated-content or path matches never contribute AI evidence. + - Validate: focused pure tests cover exact matching, unique fallback, duplicate mutation candidates, duplicate unresolved targets, and ambiguous logical-file pairing; a repeated-identical-line regression remains unresolved/unknown. + - Superseded (see item 3 note): the shipped causal lineage keys files by **exact logical repository path** only. `runtime/mutation_attribution/tests.rs` proves a suffix-related mutation file never pairs with the committed target, an exact nested path still attributes, similar nested paths stay independent, and a shared basename is insufficient. +- [ ] AC5: Traversal is current-worktree-only, revision-descending, and timestamp-independent; every request is capped by both the 32-row page size and the remaining 128-event budget, so total loaded/inspected events never exceed 128, event 128 may contribute, and event 129 is never loaded or inspected. The current constants imply at most four pages but do not require the horizon to be divisible by page size. + - Validate: store tests order revisions `1`, `255`, `256`, and `u64::MAX`; counting consumer tests assert every requested row count is `min(page size, remaining budget)`, no more than four pages under 32/128, at most 128 loaded/inspected events and tree diffs, event 128 eligibility, and event 129 exclusion; query inspection shows exact `worktree_id`, exclusive revision cursor, `ORDER BY revision DESC`, capped limit, and no `created_at` predicate. +- [ ] AC6: Early termination distinguishes already-loaded rows from reconstruction work: after all lines resolve at event 4, events 5–32 in that page are not reconstructed/diffed and no second page is requested. + - Validate: a counting reader/reconstructor test asserts one page loaded, four events inspected/diffed, zero reconstruction for rows 5–32, and zero next-page request. +- [ ] AC7: Any page query/decode or inspected-event diff/parse failure is a barrier that retains direct/newer results, performs no older reconstruction or page request, and leaves remaining lines unknown. + - Validate: injected failure tests assert the resolved/AI/non-AI/unresolved sets and separate SQLite-row/page versus Git-reconstruction counters at the barrier. +- [ ] AC8: Mutation lookup uses only the invoking linked worktree's existing identity; absent identity or absent mutation history cleanly falls back to direct-only behavior, and attribution lookup itself never creates identity or mutation state. + - Validate: an adversarial linked-worktree fixture inserts a newer safely matching `AiContended` event for another `WorktreeId` and an older safely matching `AiExclusive` event for the invoking `WorktreeId` against the same committed target line; the final result must be `ai`. If foreign history were consulted, newest-match-wins semantics would make the result non-AI, so this fixture detects worktree leakage rather than merely asserting foreign rows are filtered. Beyond that proof, AC8 continues to require that lookup uses only the invoking linked worktree's existing identity, that missing identity does not create one (a focused read-only identity test leaves an absent `/sce/checkout-id` absent and preserves direct-only output), and that attribution lookup remains read-only. +- [ ] AC9: Combined Agent Trace construction uses separate direct and mutation AI patches; mutation-only attribution fabricates no model/session/tool metadata, while direct provenance remains available when direct evidence covers part or all of a hunk. + - Validate: complete `mutation_only_no_provenance`, `direct_plus_mutation`, and `partial_combined` goldens validate expected and actual JSON against the embedded schema and assert provenance fields exactly. +- [ ] AC10: Raw and final persistence boundaries remain unchanged: mutation evidence is never inserted into `diff_traces`, `post_commit_patch_intersections` remains the direct-only intersection, no schema/migration/checkpoint is added, and the schema-valid combined result is stored in `agent_traces.trace_json`. + - Validate: real DB tests inspect all three tables after post-commit; `git diff --name-only origin/mutation-scope-runtime-integration...HEAD -- cli/migrations` is empty. +- [ ] AC11: Real Git/DB post-commit flows prove mutation-only AI attribution, direct-plus-mutation completion, newer non-exclusive blocking, adversarial linked-worktree isolation, and the 128/129 boundary without changing checkpoint or auto-sync ordering. + - Validate: focused post-commit integration tests read and schema-validate persisted `agent_traces.trace_json` and inspect direct-only/raw evidence tables. The linked-worktree isolation regression uses the adversarial foreign-event scenario in the real Git/DB post-commit flow: the target commits `file.rs` line 2 = `"two"`; the invoking worktree holds revision 1 — healthy, untainted, `AiExclusive(scope-current)`, safely matching the target; a foreign worktree holds revision 2 — healthy, untainted, `AiContended`, safely matching the same target. Correct worktree isolation ignores foreign revision 2 because its `worktree_id` differs, lets current revision 1 contribute `AiExclusive`, and persists `ai`. Broken worktree filtering would treat foreign revision 2 as the newest matching event, resolve the target as non-AI under newest-match-wins, and leave older current revision 1 unable to reclaim it, so the persisted trace would not be `ai` — this fixture therefore detects worktree leakage. T03's separate counting tests prove traversal work that is not observable from persisted output. + +### Full validation + +- `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 agent_trace::` +- `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` +- `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` +- Inspect the PR diff against `origin/mutation-scope-runtime-integration` and prove: event 128 is inspected and eligible; event 129 is neither loaded nor inspected; resolving at event 4 leaves already-loaded rows 5–32 unreconstructed and requests no next page; mutation attribution has no `created_at` query; and `cli/migrations/` is unchanged. + +### Context sync + +- Add `context/cli/mutation-trace-agent-attribution.md` with the direct-first, safe-match, current-worktree-only, newest-match-wins, failure-barrier, and 128-event bounded-history invariant. +- Update `context/cli/mutation-trace-store.md` for the descending cursor-paged cold reader and its 8-byte revision ordering contract. +- Update `context/cli/mutation-trace-snapshot-service.md` because the attribution-history consumer becomes a read-only `GitSnapshotService::diff_trees` caller; keep capture/pin ownership with the coordinator and update only the caller inventory needed for this change. +- Update `context/cli/patch-service.md` to distinguish unchanged direct matching from the stricter unique mutation-evidence matcher. +- Update `context/sce/agent-trace-minimal-generator.md` for separated direct/mutation evidence, combined coverage, and direct-only provenance. +- Update `context/sce/agent-trace-hooks-command-routing.md` for bounded post-commit mutation lookup, unchanged direct intersection persistence, and final combined persistence. +- Update `context/context-map.md` and `context/overview.md` to index and summarize the new behavior. +- Inspect `context/architecture.md`, `context/patterns.md`, and `context/glossary.md` during the mandatory root context pass; update only where the shipped architecture or canonical terminology materially changes. + +## 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:** a pure safe mutation-attribution resolver over reconstructed `ParsedPatch` values; a worktree-scoped descending mutation-event reader; a bounded read-only history consumer using the existing tree-diff command; a separated direct/mutation Agent Trace evidence builder; post-commit composition; compact semantic goldens; focused traversal/counting tests; real Git/DB regressions; and the durable context listed above. +- **Out of scope:** Codex, Claude Code, OpenCode, or Pi harness adapters; changes to `spec/mutation_cursor.qnt`, mutation protocol transitions, mutation event production, or scope lifecycle; Agent Trace schema changes; database migrations; mutation consumption/checkpoint state; timestamp-based mutation attribution; configurable page/horizon limits; mutation-history retention/deletion; commit-msg mutation attribution; or a general Git history attribution engine. +- **Constraints:** keep `MUTATION_ATTRIBUTION_PAGE_SIZE = 32` and `MAX_MUTATION_ATTRIBUTION_EVENTS = 128`; keep existing direct `intersect_patches` matching unchanged; use a separate strict mutation matcher with exact-first and unique-fallback semantics; count an event as inspected when reconstruction begins; match mutation evidence by exact logical repository path only (no normalized-suffix or basename fallback; suffix matching stays in the direct-evidence matchers); stop all older traversal at a query/decode/diff/parse barrier; use `resolve_git_dir` plus `read_checkout_id` rather than creating identity solely for attribution; reuse `git diff --binary --full-index --no-ext-diff --no-textconv ` and `parse_patch`; perform no mutation-cursor write; keep `post_commit_patch_intersections` direct-only; persist final combined attribution only to `agent_traces.trace_json`; and derive provenance exclusively from direct evidence. +- **Non-goal:** converting mutation events into synthetic `diff_traces` or treating `AiExclusive(scope)`, `ScopeId`, or `ActorKind` as proof of a model, session, tool, or tool version. + +## Assumptions + +- PR #258 / `origin/mutation-scope-runtime-integration` is the implementation and validation baseline, not `main`. +- `MutationEvent.tainted == false && MutationEvent.failure_kind == FailureKind::Healthy` defines healthy event state; either contrary value makes a safely matching event non-positive and blocking. +- Direct evidence continues using the current `intersect_patches` exact-first then permissive historical fallback. The strict uniqueness rule applies only to mutation-derived matching. +- A page reader may materialize up to 32 decoded SQLite rows before the consumer resolves at an event within that page; early termination bounds subsequent Git reconstruction and prevents the next page rather than retroactively unloading those rows. + +## Task stack + +- [x] T01: `Freeze pure attribution semantics` (status:done) + - Task ID: T01 + - Scope: In — add the pure resolver and strict mutation-line matcher. Inputs are target-shaped direct coverage, unresolved committed lines, and newest-first already-reconstructed `MutationPatchEvidence`; outputs are mutation-derived AI coverage, resolved/non-AI lines, and still-unresolved lines. Resolve exact matches before unique historical fallback and process safe matches newest-first. Out — Agent Trace JSON/goldens, DB pagination, Git reconstruction, worktree identity, hook composition, and persistence. + - Dependencies: none + - Done when: table-driven tests prove direct lines are never mutation-consulted; healthy untainted exclusive matches add AI coverage; every other safe match resolves non-AI and blocks older events; exact matching and unique fallback work one-to-one; duplicate mutation candidates, duplicate unresolved targets, and ambiguous logical-file matches remain unresolved; output sets are deterministic. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_attribution`. + - Completed: 2026-09-03 + - Files changed: `cli/src/services/mutation_trace/attribution.rs`, `cli/src/services/mutation_trace/mod.rs` + - Result: Added the pure `MutationPatchEvidence`/`MutationAttributionResult` domain types, newest-first resolver, and strict mutation matcher. Direct coverage is excluded before mutation matching; exact identity wins over unique kind/content fallback; exact and normalized-suffix file pairing is conservative; healthy untainted `AiExclusive` matches produce mutation AI coverage, while all other safe matches resolve as non-AI and prevent older evidence from reclaiming lines. Added focused table-driven regressions for direct precedence, newest blocking, fallback ordering, ambiguity, and unhealthy/tainted states, plus a regression proving an unrelated newer event does not block an older matching healthy untainted `AiExclusive` event (newest *matching* event wins, not newest event regardless of what it touched). + - Verify (actual): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_attribution` — 7 pure attribution tests passed, 0 failed (plus the 2 nested `store::tests::mutation_attribution` pagination tests the filter also matches). Additional `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` and `nix flake check` runs passed. + - Context impact: Domain. Adds a pure mutation-attribution module and public resolver/matcher types for later pagination, history-consumer, and Agent Trace composition tasks; no persistence, protocol, schema, or repository-wide context change in this task. + - Context synchronization: synced + +- [x] T02: `Add descending mutation-event pagination` (status:done) + - Task ID: T02 + - Scope: In — add a cold-path `MutationTraceStore` page reader filtered by exact `worktree_id`, ordered by fixed-width big-endian `revision DESC`, using an exclusive revision cursor and a requested limit capped at `MUTATION_ATTRIBUTION_PAGE_SIZE = 32`; return only revision, before/after trees, taint/failure, attribution kind, and the attribution scope needed to decode `AiExclusive`. Out — active scopes, processed events, boundary projection, Git reconstruction, the caller-owned 128-event cap, and post-commit wiring. + - Dependencies: T01 + - Done when: one call returns at most 32 rows; pagination has no duplicates or omissions; another worktree cannot contribute; revisions `u64::MAX`, `256`, `255`, and `1` sort correctly; the exclusive cursor continues from the last returned revision; no timestamp field or predicate participates. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace::store::tests::mutation_attribution`. + - Completed: 2026-09-03 + - Files changed: `cli/src/services/mutation_trace/store.rs` + - Result: Added a read-only `MutationTraceStore::load_mutation_event_page` cold reader with exact worktree filtering, descending fixed-width revision ordering, an exclusive revision cursor, and a 32-row cap. The page projection decodes only revision, tree transition, health, attribution kind, and the optional exclusive scope, while validating attribution shape; focused regressions cover u64 boundary ordering, linked-worktree isolation, cap behavior, and cursor continuation. The focused pagination regressions live in the nested `store::tests::mutation_attribution` module, leaving the existing `store::tests` suite in place. + - Verify (actual): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace::store::tests::mutation_attribution` — 2 focused pagination tests passed, 0 failed. `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` also passed. + - Context impact: Interface. Adds the cold paged mutation-event read API and its fixed-width descending revision cursor contract for the bounded history consumer; durable store context synchronization is recorded below. + - Context synchronization: synced + +- [x] T03: `Build bounded mutation-history consumer` (status:done) + - Task ID: T03 + - Scope: In — compose the T02 page reader, existing read-only tree-to-tree Git diff command, patch parsing, and T01 resolver; track separate loaded-row/page, inspected-event, and Git-reconstruction counts; request pages only while unresolved lines remain and fewer than 128 events have been inspected, with every `requested_limit` equal to `min(MUTATION_ATTRIBUTION_PAGE_SIZE, MAX_MUTATION_ATTRIBUTION_EVENTS - inspected_events)`. Out — Agent Trace rendering/provenance and production post-commit composition. + - Dependencies: T01, T02 + - Done when: every DB request is capped by page size and remaining event budget; event 128 can be loaded, inspected, and resolve a line; event 129 is neither loaded nor inspected; total loaded/inspected events never exceed 128; the current 32/128 constants produce no more than four pages without assuming divisibility; resolving at event 4 leaves loaded rows 5–32 unreconstructed and requests no second page; irrelevant events count toward the horizon; query/decode/diff/parse failure stops all older traversal while preserving direct/newer results. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace::runtime::mutation_attribution`. + - Completed: 2026-09-03 + - Files changed: `cli/src/services/mutation_trace/runtime/mutation_attribution.rs`, `cli/src/services/mutation_trace/runtime/mod.rs`, `cli/src/services/mutation_trace/attribution.rs` (corrective pass: extracted `exclude_direct_coverage`) + - Result: Added the bounded, read-only history consumer `resolve_bounded_mutation_attribution` over two injectable seams — `MutationEventPageSource` (implemented for `MutationTraceStore`) and `TreeDiffSource` (implemented for `GitSnapshotService`, reusing its existing tree-to-tree diff). The consumer owns `MAX_MUTATION_ATTRIBUTION_EVENTS = 128`; each page request asks for `requested_page_limit(PAGE_SIZE, HORIZON, inspected_events) = min(32, 128 - inspected_events)`, a helper unit-tested against a non-divisible horizon (32/130 → 32,32,32,32,2). Traversal is newest-first by exclusive revision cursor, current-worktree-only, timestamp-independent; it stops the instant the unresolved set empties (already-loaded rows in that page are left unreconstructed and no next page is requested), stops when 128 events have been inspected (event 129 never loaded), and stops after a short page rather than issuing a guaranteed-empty query. `inspected_events` increments when reconstruction begins (including a failed one); `reconstructed_events` counts only patches fed to the resolver; `loaded_pages`/`loaded_rows` are separate DB counters. Any page-query or tree-diff/patch-parse failure sets a `MutationAttributionBarrier`, keeps direct evidence plus every newer reconstructed event's result, inspects nothing older, and leaves remaining lines unresolved. Per-event resolution reuses the T01 pure resolver on the shrinking unresolved patch, so newest-match-wins/blocking and direct-line exclusion carry over unchanged. + - Corrective pass (2026-09-03): (1) Direct evidence now resolves *before* the first mutation-history page request. The consumer's first step is `attribution::exclude_direct_coverage(committed_target, direct_coverage)` (extracted from the T01 resolver's own direct-exclusion filter, reused by both). A fully direct-covered target performs zero page requests, zero loaded rows, zero inspected events, and zero tree diffs; a partially covered target sends only the remaining lines into traversal. (2) Per-event result parts are now unioned with a mutation-attribution-local helper `combine_mutation_target_patches` keyed on logical target-file identity (`new_path` when non-empty, otherwise `old_path`) instead of `combine_patches` (which keys on raw `new_path` and would collapse two deleted files that both carry an empty `new_path`). The helper preserves target-shaped file/hunk metadata, deterministic ordering, dedupes only identical selected target lines, and adds no provenance. Global `combine_patches` is unchanged. All existing T03 semantics (128-event horizon, barriers, early stop, newest-match) are preserved. + - Verify (actual): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_trace::runtime::mutation_attribution` — 17 tests passed, 0 failed (adds: fully-direct-covered target does zero mutation-history work; only-post-direct lines reach history; two deleted files stay distinct in `mutation_ai_patch`; two deleted files stay distinct in `resolved_non_ai_patch`). Also `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`, and `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::` (314 passed) all clean. + - Context impact: Interface. Adds the internal `runtime` consumer API (`resolve_bounded_mutation_attribution`, `BoundedMutationAttribution`, `MutationAttributionBarrier`, the two source traits, and the 128-event horizon constant) that T05 wires into post-commit; no persistence, schema, protocol, or repository-wide behavior change. Durable context for the bounded failure-barrier history consumer is recorded at plan synchronization. + - Context synchronization: synced + +- [x] T04: `Build Agent Trace from separated evidence` (status:done) + - Task ID: T04 + - Scope: In — add internal `AgentTraceEvidence { direct_patch, mutation_ai_patch }`; keep `build_agent_trace(...)` as the direct-only compatibility path; classify from combined AI coverage while deriving hunk model/session and top-level tool provenance only from direct evidence; add compact complete JSON goldens for `direct_only`, `exclusive_without_direct`, `direct_plus_mutation`, `partial_combined`, `newer_nonexclusive_blocks`, and `mutation_only_no_provenance`. Out — DB reads, Git reconstruction, horizon/page mechanics, worktree identity, and hook persistence. + - Dependencies: T01 + - Done when: expected and actual JSON for every compact fixture validate against the embedded schema before normalized comparison; direct-only output is unchanged; combined full coverage is `ai`, partial coverage is `mixed`, zero coverage is `unknown`; mutation-only output omits invented provenance; direct provenance survives combined classification. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace::`. + - Completed: 2026-09-03 + - Files changed: `cli/src/services/agent_trace.rs`, `cli/src/services/agent_trace/tests.rs`, `cli/src/services/agent_trace/fixtures/direct_only/{direct.patch,mutation_ai.patch,post_commit.patch,golden.json}`, `cli/src/services/agent_trace/fixtures/exclusive_without_direct/{direct.patch,mutation_ai.patch,post_commit.patch,golden.json}`, `cli/src/services/agent_trace/fixtures/direct_plus_mutation/{direct.patch,mutation_ai.patch,post_commit.patch,golden.json}`, `cli/src/services/agent_trace/fixtures/partial_combined/{direct.patch,mutation_ai.patch,post_commit.patch,golden.json}`, `cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/{direct.patch,mutation_ai.patch,post_commit.patch,golden.json}`, `cli/src/services/agent_trace/fixtures/mutation_only_no_provenance/{direct.patch,mutation_ai.patch,post_commit.patch,golden.json}` + - Result: Added the internal `AgentTraceEvidence { direct_patch, mutation_ai_patch }` input and `build_agent_trace_from_evidence(...)`; `build_agent_trace(...)` keeps its signature and delegates with an empty mutation-AI patch. Hunk classification moved from the direct-only slot/exact-match rule (`classify_hunk`) to `classify_hunk_combined`, which counts a `post_commit_patch` hunk's touched lines covered by the union of the direct intersection hunk and the mutation-AI hunk at the same `old_start` — all covered is `ai`, a non-empty proper subset is `mixed`, none is `unknown`. This is line-for-line equivalent to the old rule when the mutation patch is empty because a direct-intersection hunk always holds an ordered sub-multiset of its `post_commit_patch` hunk's lines. `model_id`, session `related`, and the top-level `tool` object stay bound to the direct intersection only, so a hunk classified `ai`/`mixed` purely through mutation coverage carries no provenance and a mutation-only trace has no `tool`. `classify_hunk` is retained (now `#[allow(dead_code)]`) as the documented direct-only primitive. Added an evidence-based golden helper (schema-validates expected and actual before normalized comparison of `vcs`/`tool`/`line_changes`/`files`) with six fixtures plus a test proving the compat path and the evidence path with an empty mutation patch serialize identically apart from the generated `id`/`url`. + - Verify (actual): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace::` — 21 passed, 0 failed (6 new evidence goldens + compat-equivalence test + all pre-existing direct-only goldens unchanged and green). Also `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`, `test ... agent_trace` (172 passed), and `test ... hooks::` (180 passed) all clean. + - Context impact: Interface. Adds the internal `AgentTraceEvidence`/`build_agent_trace_from_evidence` separated-evidence builder and combined direct+mutation coverage classification that T05 wires into post-commit; no persistence, schema, protocol, or repository-wide behavior change. Durable context for separated direct/mutation evidence, combined coverage, and direct-only provenance is recorded at plan synchronization. + - Context synchronization: synced + +- [x] T05: `Wire mutation attribution into post-commit` (status:done) + - Task ID: T05 + - Scope: In — preserve the existing direct diff-trace combination/intersection and `post_commit_patch_intersections` write; resolve existing checkout identity read-only; run T03 only for unresolved committed lines in that worktree; pass direct and mutation AI patches separately to T04; validate and persist the combined result in `agent_traces.trace_json`; retain current checkpoint and auto-sync ordering. Out — identity creation solely for attribution, mutation-cursor writes, changes to `diff_traces`, migrations/schema, commit-msg behavior, and harness adapters. + - Dependencies: T03, T04 + - Done when: missing identity/history falls back to direct-only behavior; foreign-worktree rows cannot contribute; the attribution-specific path uses no identity-creation or mutation-write API; no mutation evidence is copied into `diff_traces` or the direct intersection row; final persistence, passive checkpoint, and auto-sync retain their existing success/failure order. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::post_commit`. + - Completed: 2026-09-03 + - Files changed: `cli/src/services/mutation_trace/runtime/mutation_attribution.rs`, `cli/src/services/mutation_trace/runtime/mod.rs`, `cli/src/services/hooks/mod.rs`, `cli/src/services/hooks/codex/mod.rs` + - Result: Added the read-only post-commit entry point `resolve_post_commit_mutation_ai_patch(repository_root, &db, direct_coverage, committed_patch) -> ParsedPatch` in the `mutation_trace::runtime` module (re-exported `pub(crate)` from `runtime/mod.rs`). It reads the invoking worktree's existing identity with `checkout::resolve_git_dir` + `checkout::read_checkout_id` and fails open to an empty patch on an unresolvable git dir, an absent/unreadable checkout identity, or an unavailable `GitSnapshotService`; otherwise it constructs `GitSnapshotService` + `MutationTraceStore` internally (keeping `GitSnapshotService` private to `runtime`) and returns `resolve_bounded_mutation_attribution(..).result.mutation_ai_patch`. It creates no identity and performs no mutation-cursor write. In `run_post_commit_agent_trace_flow`, direct evidence is resolved first via the unchanged `intersect_patches` of the combined recent patch against the committed patch; that post-commit-shaped direct intersection is passed as `direct_coverage` to the entry point (T03's `exclude_direct_coverage` keys on logical-path + kind + line number + content), and the committed patch's remaining lines are offered to bounded mutation history. `run_post_commit_agent_trace_flow_with` gained a `mutation_ai_patch: &ParsedPatch` parameter and now builds the trace via `build_agent_trace_from_evidence(AgentTraceEvidence { direct_patch: &combined_recent_patch, mutation_ai_patch }, ..)` instead of `build_agent_trace`, keeping direct and mutation AI patches separated end-to-end. `run_post_commit_intersection_flow*` and the `post_commit_patch_intersections` write are untouched (still direct-only); the passive-checkpoint / auto-sync ordering in `run_post_commit_subcommand_with` is unchanged. Updated the two non-production `run_post_commit_agent_trace_flow_with` call sites (one hooks test, the Codex E2E test) to pass an empty mutation patch. + - Verify (actual): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml post_commit` (the planned `services::hooks::post_commit` filter matches nothing — the hook tests live under `services::hooks::tests`; the substring `post_commit` is the runnable equivalent) — 23 passed, 0 failed, including 5 new: mutation-only lines classified `ai` with no tool/model/session provenance; direct provenance retained when direct covers the line; empty mutation patch leaves uncovered lines `unknown`; the entry point yields an empty patch and creates no `checkout-id` when identity is absent; the entry point resolves current-worktree mutation AI coverage while a foreign-worktree row for the same trees does not contribute. Also `test ... services::hooks::` (183 passed), `test ... agent_trace::` (21 passed), `test ... services::mutation_trace::` (316 passed), `clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` (clean), `fmt --manifest-path cli/Cargo.toml -- --check` (clean). + - Context impact: Interface. Wires the T03 bounded consumer and T04 separated-evidence builder into the production post-commit Agent Trace flow behind a new read-only `runtime` entry point; combined direct+mutation attribution is now what post-commit persists to `agent_traces.trace_json`. No persistence schema, migration, protocol, or mutation-cursor state change; `diff_traces` and `post_commit_patch_intersections` semantics unchanged. Durable context for bounded post-commit mutation lookup, unchanged direct intersection persistence, and final combined persistence is recorded at plan synchronization. + - Note (2026-09-03): the T05 worktree-isolation test proves a foreign-worktree row for the same trees does not contribute while a current-worktree row does. That check uses equivalent positive rows and is not by itself a proof of isolation, since a broken filter could still return `ai`. T06 will replace it at the E2E level with an adversarial foreign-worktree fixture (newer foreign `AiContended` + older current `AiExclusive`) so that only a correctly isolated implementation persists `ai`. The T05 implementation is correct; this is strengthening the later E2E proof, not a T05 defect. + - Context synchronization: synced + +- [x] T06: `Add E2E regressions and durable context` (status:done) + - Task ID: T06 + - Corrective pass (2026-09-03): the first round of `mutation_attribution_e2e` tests constructed the direct `ParsedPatch` in memory and hand-composed the flow, proving final Agent Trace composition but not the real persistence chain for AC10 (`diff_traces` → real direct intersection flow → `post_commit_patch_intersections` → mutation attribution → `agent_traces.trace_json`). Closed by adding `persistence_boundaries_stay_separated_across_diff_traces_intersection_and_agent_trace`: it seeds one **real** `diff_traces` row (patch adds only `+two`, `tool_name="claude"`, `tool_version="9.9.9"`, `payload_type="patch"`, timestamp inside the recent-lookup window) plus one **real** `mutation_trace_events` row (healthy untainted `AiExclusive`, `before_tree` = the un-committed intermediate `git write-tree`, `after_tree` = HEAD, so its reconstructed patch adds only `+three`), then drives the production `run_post_commit_intersection_flow_with` against the real DB (`capture_post_commit_patch_from_git`, real `recent_diff_trace_patches`, real `insert_post_commit_patch_intersection`), runs `resolve_post_commit_mutation_ai_patch` on the result, and persists via `run_post_commit_agent_trace_flow_with` calling the real `db.insert_agent_trace` + `validate_agent_trace_value`. It then reads all three persistence layers back **from the DB** and asserts: `diff_traces` has one row whose parsed touched contents are `["two"]` (never `three`); `post_commit_patch_intersections` has one row whose reconstructed touched contents are `["two"]` (mutation line `three` never contaminates it); the persisted `agent_traces.trace_json` is `SELECT`ed back, parsed, schema-validated (`validate_agent_trace_value`), and the final semantic assertions run against that DB-read value — `line_changes.ai.added == 2`, `unknown.added == 0`, contributor `ai`, `tool == {"name":"claude","version":"9.9.9"}` (direct-derived, not from `ScopeId`); and `mutation_trace_events` still has exactly one row (no mutation-cursor write). The pre-insert trace value is no longer what the persistence-boundary assertions run against. Manually passing a direct `ParsedPatch` is not treated as proof of this boundary. No production behaviour changed. + - Scope: In — real Git/repository-DB regressions for Bash-style exclusive mutation without direct evidence, direct-plus-mutation completion, newer non-exclusive overwrite, adversarial linked-worktree isolation (fixture below), a relevant event older than 128 newer events, and persisted final output; add/update the durable context files listed under Context sync. Out — harness adapters, protocol/schema work, and unrelated context repair. + - Dependencies: T05 + - Adversarial linked-worktree fixture: the target commits `file.rs` line 2 = `"two"`. The invoking worktree holds revision 1 — healthy, untainted, `AiExclusive(scope-current)`, safely matching the target. A foreign worktree holds revision 2 — healthy, untainted, `AiContended`, safely matching the same target. With correct worktree isolation, foreign revision 2 is ignored because its `worktree_id` differs, current revision 1 contributes `AiExclusive`, and the target is classified mutation AI. With broken worktree filtering, foreign revision 2 is the newest matching event, its `AiContended` resolves the target as non-AI under newest-match-wins, and older current revision 1 cannot reclaim it. Equivalent positive rows (foreign `AiExclusive` + current `AiExclusive`) do not prove isolation, because both a correct and a broken implementation return `ai`; the deliberately conflicting foreign event is what turns this into an actual proof of worktree isolation. + - Done when: end-to-end post-commit tests read schema-valid combined JSON from `agent_traces.trace_json`; raw/direct-only tables preserve their meanings; mutation-only AI and direct-plus-mutation classify correctly; the newer non-exclusive overwrite regression stays non-AI; the adversarial linked-worktree regression proves that a newer matching foreign-worktree non-exclusive event cannot block an older matching current-worktree `AiExclusive` event and the persisted Agent Trace still classifies the target as `ai` because only the current worktree's history is eligible; the relevant 129th event is never reconstructed; durable context states the direct-first, strict-match, bounded, failure-barrier, provenance, and persistence contracts. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::runtime::tests::`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::`; inspect the context files listed under Context sync against the implemented code. + - Completed: 2026-09-03 + - Files changed: `cli/src/services/hooks/mod.rs` (tests — `mutation_attribution_e2e` module, 5 regressions), `cli/src/services/mutation_trace/runtime/tests.rs` (tests + imports + `seed_attribution_event` helper), plus context files under Context sync + - Result: Added real Git/repository-DB regressions for the shipped bounded post-commit mutation-attribution path. In `services::hooks::tests::mutation_attribution_e2e` (new nested module): each test makes a real commit, seeds real `mutation_trace_events` rows keyed on the invoking worktree's real checkout identity (`get_or_create_checkout_id`), drives the production composition, schema-validates the trace, and asserts the persisted `agent_traces.trace_json` plus the direct-only raw tables. Scenarios: (1) a mutation-only healthy untainted `AiExclusive` line persists as `ai` with no `tool`/`model_id`/session provenance, `diff_traces` and `post_commit_patch_intersections` empty; (2) direct evidence covers line 2 and a seeded exclusive event covers line 3, so the hunk classifies `ai` (`line_changes.ai.added == 2`) while top-level direct `tool` provenance is retained; (3) a newer current-worktree `AiContended` event resolves the line non-mutation-AI and blocks the older `AiExclusive`, so the persisted trace is `unknown`; (4) the adversarial linked-worktree fixture — real `git worktree add`, current worktree revision 1 healthy untainted `AiExclusive(scope-current)`, foreign worktree revision 2 healthy untainted `AiContended`, same trees — persists `ai` because only the current worktree's history is eligible (a broken worktree filter would treat foreign revision 2 as the newest match and persist non-`ai`); (5) `persistence_boundaries_stay_separated_across_diff_traces_intersection_and_agent_trace` — the corrective-pass AC10 regression described in the Corrective pass note: real `diff_traces` direct evidence + real `mutation_trace_events` evidence, the real `run_post_commit_intersection_flow_with` direct intersection flow, then real Agent Trace persistence, asserting `diff_traces = direct only`, `post_commit_patch_intersections = direct only`, `agent_traces.trace_json = direct + mutation`. In `services::mutation_trace::runtime::tests` (new `a_relevant_event_behind_128_newer_events_is_never_loaded_or_reconstructed`): seeds one relevant oldest `AiExclusive` event (revision 1) behind 128 newer no-op events (revisions 2–129) for the current worktree, then asserts via `resolve_bounded_mutation_attribution` counters that exactly 128 events are inspected/reconstructed across 4 pages, `loaded_rows == 128` (the 129th row never loaded), no barrier, the relevant event contributes no AI coverage, and the committed line stays unresolved. + - Verify (actual): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::mutation_trace::runtime::tests::` — 28 passed, 0 failed. `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::` — 188 passed, 0 failed (5 `mutation_attribution_e2e` regressions). `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml services::hooks::tests::mutation_attribution_e2e` — 5 passed, 0 failed. Also `test ... services::mutation_trace::` (317 passed), `clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` (clean), `fmt --manifest-path cli/Cargo.toml -- --check` (clean). Generated/authored comments in the new test code were stripped at the user's request. + - Context impact: Docs/tests. Adds only test coverage (no production code change); the shipped behavior it pins is the direct-first, strict-match, current-worktree-only, newest-match-wins, 128-event-bounded, failure-barrier, direct-only-provenance post-commit attribution path, and the AC10 persistence-boundary separation (`diff_traces` / `post_commit_patch_intersections` direct-only; `agent_traces.trace_json` combined). + - Context synchronization: synced + + Weak versus adversarial worktree-isolation test: + - Weak: foreign `AiExclusive` + current `AiExclusive` for the same line — both a correctly isolated and a broken implementation return `ai`, so the test can pass accidentally. + - Adversarial (required here): newer foreign `AiContended` + older current `AiExclusive` for the same line — only a correctly isolated implementation returns `ai`; a broken one consults foreign history and, under newest-match-wins, resolves the line non-AI. + +## Open questions + +None. The request supplies the precedence, safe matching rule, failure policy, bounded traversal, persistence boundary, and task dependencies needed to implement the change without inventing attribution semantics. diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 13e33cf3e..04399df58 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -54,8 +54,9 @@ - Internal orchestration now returns a typed `PostCommitIntersectionFlowResult` (`combined_recent_patch`, `post_commit_data`, optional `tool_name`, optional `tool_version`) from `run_post_commit_intersection_flow_with()`, where tool metadata comes from the most recent ordered parsed recent-patch row and falls back to `None` when the recent set is empty. - `run_post_commit_subcommand(...)` now threads parsed optional `vcs_type` and validated `remote_url` through post-commit runtime flow into `run_post_commit_agent_trace_flow_with(...)`. - `run_post_commit_agent_trace_flow_with(...)` prints the received remote URL to stderr as `post-commit remote_url=` before building/validating/persisting the Agent Trace payload. -- At the current runtime boundary, parsed optional `vcs_type` is forwarded into `agent_trace::build_agent_trace(...)`; when absent, top-level `vcs` metadata is omitted. - - The run-flow path maps commit-time metadata to RFC3339 and calls `agent_trace::build_agent_trace(...)`. +- At the current runtime boundary, parsed optional `vcs_type` is forwarded into the Agent Trace builder; when absent, top-level `vcs` metadata is omitted. + - The run-flow path maps commit-time metadata to RFC3339 and calls `agent_trace::build_agent_trace_from_evidence(...)` with separated evidence: `direct_patch` is the combined recent patch (intersected against the post-commit patch inside the builder, unchanged), and `mutation_ai_patch` is the bounded mutation-history AI coverage for the committed lines direct evidence did not cover. Direct provenance (`model_id`, related sessions, top-level `tool`) still derives from direct evidence only; mutation-only coverage widens `ai` / `mixed` classification without provenance. See [agent-trace-minimal-generator.md](agent-trace-minimal-generator.md). + - The mutation-AI patch comes from `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch(...)`, a read-only entrypoint that resolves the invoking worktree's *existing* checkout identity (`checkout::resolve_git_dir` + `checkout::read_checkout_id`), reads `HEAD^{tree}`, and — under the worktree lock — captures a commit attribution cut (`latest_mutation_event_revision`) so events produced after the commit cannot participate. It returns an empty patch — direct-only fallback — when identity, mutation history, the `HEAD` tree, or the cut is unavailable. It creates no identity and performs no mutation-cursor write. The `post_commit_patch_intersections` write stays direct-only (`patch::intersect_patches` of the combined recent patch against the post-commit patch); mutation evidence never reaches `diff_traces` or that intersection row. The causal-lineage replay mechanics live in [../cli/mutation-trace-agent-attribution.md](../cli/mutation-trace-agent-attribution.md). - The same run-flow call now also forwards optional `tool_name` / `tool_version` from `PostCommitIntersectionFlowResult` into `AgentTraceMetadataInput`, so built post-commit payloads preserve tool metadata derived from recent parsed diff-trace rows. - The built Agent Trace payload includes top-level `metadata.sce.version` from the compiled `sce` CLI package version, always-emitted `metadata.sce.line_changes` touched-line attribution counts, and range-level `content_hash` values computed from touched post-commit hunk content before conversion to JSON. See [agent-trace-minimal-generator.md](agent-trace-minimal-generator.md) for the full payload contract. - The built Agent Trace payload is converted to JSON `Value` and validated via `agent_trace::validate_agent_trace_value(...)` before persistence. diff --git a/context/sce/agent-trace-minimal-generator.md b/context/sce/agent-trace-minimal-generator.md index 063b64aab..28b6a9db6 100644 --- a/context/sce/agent-trace-minimal-generator.md +++ b/context/sce/agent-trace-minimal-generator.md @@ -6,21 +6,33 @@ Rust library seam at `cli/src/services/agent_trace.rs` that produces the minimal Given a `constructed_patch` (AI candidate) and a `post_commit_patch` (canonical source of truth): -1. Compute `intersection_patch = intersect_patches(constructed_patch, post_commit_patch)` — the touched-line overlap. -2. Compare `intersection_patch` hunks against `post_commit_patch` hunks slot-by-slot (matched by `old_start`). -3. Classify each `post_commit_patch` hunk: - - **`ai`** — `intersection_patch` hunk exists with identical touched lines (same count, kind, `line_number`, content, order). - - **`mixed`** — `intersection_patch` hunk exists at the same slot but content differs. - - **`unknown`** — no `intersection_patch` hunk at the same `old_start` slot. -4. Map `Conversation.contributor.model_id` from the matched `intersection_patch` hunk when contributor type is `ai` or `mixed`; omit `model_id` when provenance is missing (`None`). +1. Compute `intersection_patch = intersect_patches(constructed_patch, post_commit_patch)` — the touched-line overlap (the direct evidence). +2. Classify each `post_commit_patch` hunk from the union of the direct `intersection_patch` hunk and, when supplied, the mutation-derived AI hunk at the same `old_start` slot (see [Separated direct/mutation evidence](#separated-directmutation-evidence)): + - **`ai`** — every touched line in the `post_commit_patch` hunk is covered by direct evidence, mutation-derived AI evidence, or both. + - **`mixed`** — a non-empty proper subset of the hunk's touched lines is covered. + - **`unknown`** — no touched line in the hunk is covered. +3. With no mutation evidence this is equivalent to the direct-only slot rule, since a direct-intersection hunk always holds an ordered sub-multiset of its `post_commit_patch` hunk's touched lines: `ai` when the `intersection_patch` hunk has identical touched lines (same count, kind, `line_number`, content, order), `mixed` when that hunk exists but is a proper subset, `unknown` when no `intersection_patch` hunk shares the `old_start`. The direct-only path `build_agent_trace(...)` produces exactly this classification. +4. Map `Conversation.contributor.model_id` from the matched `intersection_patch` hunk when contributor type is `ai` or `mixed`; omit `model_id` when provenance is missing (`None`). A hunk classified `ai`/`mixed` purely through mutation-derived coverage has no matched direct hunk and therefore no `model_id`. 5. For each emitted conversation, derive optional `conversation.related` entries from non-empty `session_id` values on touched lines in the matched `intersection_patch` hunk; emit related entries as `{ "type": "session", "url": "https://sce.crocoder.dev/sessions/" }`, deduplicated by session ID with deterministic ordering, and omit `related` when no included lines provide `session_id`. Structured diff-trace reconstruction supplies the persisted canonical `cc_...` row session on every touched line, so Claude related-session URLs use canonical persisted provenance rather than the raw payload session. 6. Emit one `Conversation` per `post_commit_patch` hunk, each carrying the trace lookup `url`, one `TraceFile` per `post_commit_patch` file, and one range per hunk with a deterministic `content_hash` computed from that hunk's touched-line kind/content. +## Separated direct/mutation evidence + +`build_agent_trace_from_evidence(evidence, post_commit_patch, metadata)` is the seam that classifies from two independent AI-evidence sources: + +- `evidence.direct_patch` — the reconstructed direct patch, intersected against `post_commit_patch` internally exactly as before. It is the sole source of `Conversation.contributor.model_id`, `Conversation.related` session links, and the top-level `tool` object (still omitted when the direct intersection is empty). +- `evidence.mutation_ai_patch` — a target-shaped, provenance-free set of committed touched lines that causal mutation-lineage replay attributed to AI (an AI event's line that survived every later observed tree transition into the committed tree), produced by [../cli/mutation-trace-agent-attribution.md](../cli/mutation-trace-agent-attribution.md). It only widens combined AI coverage for hunk classification and contributes no model, session, tool, or tool-version metadata. + +Per hunk, a `post_commit_patch` touched line is covered when it pairs one-to-one on `(kind, line_number, content)` with a line in the direct intersection hunk or the mutation-AI hunk at the same `old_start`; the covered fraction drives `ai` / `mixed` / `unknown` as in the Contract above. `line_changes` buckets follow that combined classification. + +`build_agent_trace(constructed_patch, post_commit_patch, metadata)` is retained unchanged and delegates to `build_agent_trace_from_evidence` with an empty `mutation_ai_patch`. Wiring the mutation-AI patch in at post-commit is owned by [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md). + ## Domain types | Type | Purpose | | ----------------------- | ------------------------------------------------------------------------------------------------------------ | | `HunkContributor` | Enum: `Ai`, `Mixed`, `Unknown` | +| `AgentTraceEvidence` | Internal builder input pairing borrowed `direct_patch` (reconstructed direct patch) and `mutation_ai_patch` (target-shaped, provenance-free mutation-AI coverage) | | `Contributor` | Nested per-conversation object carrying `type: HunkContributor` and optional `model_id` omitted when absent | | `ConversationRelated` | Schema-aligned related-link entry shape (`type` as free-form string + `url`) for optional `conversation.related` | | `LineRange` | New-file line span with `start_line` + `end_line` + `content_hash` | @@ -92,14 +104,16 @@ Current output includes top-level metadata fields with this contract: ## Public API -- `classify_hunk(post_commit_hunk, intersection_hunks) -> HunkContributor` — classify a single `post_commit_patch` hunk against `intersection_patch` hunks. +- `classify_hunk(post_commit_hunk, intersection_hunks) -> HunkContributor` — the direct-only slot rule, retained as a primitive; the builder itself now classifies through the internal combined direct+mutation line-coverage rule. - `range_content_hash(hunk) -> String` — internal helper that computes the serialized range-level `murmur3:` content fingerprint from `PatchHunk.lines` using versioned, length-delimited touched-line serialization in patch order. The hash input includes touched-line kind and content, and excludes hunk positions, line numbers, file paths, trace metadata, contributor/model metadata, VCS metadata, tool metadata, and database IDs. -- `build_agent_trace(constructed_patch, post_commit_patch, metadata) -> Result` — full generator entrypoint that validates `metadata.commit_timestamp` as RFC 3339, uses it as top-level `timestamp`, derives a UUIDv7 `id` from that same commit-time moment, derives one conversation URL from that `id`, conditionally emits `vcs` only when `metadata.vcs_type` is present (mapping `vcs.type` from metadata and `vcs.revision` from `metadata.commit_revision`), carries optional tool metadata inputs (`metadata.tool_name`, `metadata.tool_version`) for top-level `tool` mapping, and always emits `metadata.sce.version` from the compiled package version. When `intersection_patch.files` is empty, `tool` is always `None` regardless of metadata values. +- `build_agent_trace(constructed_patch, post_commit_patch, metadata) -> Result` — direct-only entrypoint, retained unchanged; delegates to `build_agent_trace_from_evidence` with an empty `mutation_ai_patch`. It validates `metadata.commit_timestamp` as RFC 3339, uses it as top-level `timestamp`, derives a UUIDv7 `id` from that same commit-time moment, derives one conversation URL from that `id`, conditionally emits `vcs` only when `metadata.vcs_type` is present (mapping `vcs.type` from metadata and `vcs.revision` from `metadata.commit_revision`), carries optional tool metadata inputs (`metadata.tool_name`, `metadata.tool_version`) for top-level `tool` mapping, and always emits `metadata.sce.version` from the compiled package version. When the direct `intersection_patch.files` is empty, `tool` is always `None` regardless of metadata values. +- `build_agent_trace_from_evidence(evidence: AgentTraceEvidence, post_commit_patch, metadata) -> Result` — separated-evidence entrypoint (see [Separated direct/mutation evidence](#separated-directmutation-evidence)): identical top-level metadata behavior, but classifies each hunk from the union of direct and mutation-derived AI coverage while keeping `model_id`, `related`, and `tool` bound to the direct intersection only. ## Test fixture contract - Golden fixtures under `cli/src/services/agent_trace/fixtures/**/golden.json` pin deterministic literal values for top-level `id`, `timestamp`, optional `vcs`, `metadata.sce.version`, `metadata.sce.line_changes`, per-conversation `url`, range-level `content_hash`, and expected file/conversation shapes. -- Tests validate golden fixtures and built payloads against the embedded schema, assert core runtime metadata directly (`version`, `timestamp`, optional `vcs`, and `metadata.sce.version`), and compare `vcs`, `metadata.sce.line_changes`, and normalized `files` against fixture truth. Expected fixture URLs are normalized to the runtime `AgentTrace.id` before the existing file-shape comparison because UUIDv7 generation includes non-deterministic bits. +- Reconstruction fixtures pair `incremental_*.patch` inputs with a `post_commit.patch` and drive `build_agent_trace`. Evidence fixtures (`direct_only`, `exclusive_without_direct`, `direct_plus_mutation`, `partial_combined`, `newer_nonexclusive_blocks`, `mutation_only_no_provenance`) instead pair `direct.patch` + `mutation_ai.patch` + `post_commit.patch` and drive `build_agent_trace_from_evidence`, pinning that mutation-only coverage classifies without fabricating `model_id`, `related`, or top-level `tool`, that direct provenance survives a direct+mutation `ai` hunk, and that the empty-mutation path is byte-identical to `build_agent_trace`. +- Tests validate golden fixtures and built payloads against the embedded schema, assert core runtime metadata directly (`version`, `timestamp`, optional `vcs`, and `metadata.sce.version`), and compare `vcs`, optional `tool`, `metadata.sce.line_changes`, and normalized `files` against fixture truth. Expected fixture URLs are normalized to the runtime `AgentTrace.id` before the existing file-shape comparison because UUIDv7 generation includes non-deterministic bits. ## Relationship to existing patch service