From a7f833bd0b1a5df952c77c6df035a61e55b62f72 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 6 Nov 2026 16:55:11 +0100 Subject: [PATCH 01/11] agent-trace: Attribute committed lines through causal mutation lineage Replace historical patch overlap with line-level attribution so stale or ambiguous direct evidence cannot claim committed changes. Replay bounded mutation lineage first, then accept only fresh exact single-session direct matches for uncovered lines, while preserving non-AI overrides and keeping post-commit intersection evidence separate. Refactor fixtures and regression coverage around the Agent Trace service, and update the context documentation and flake inputs to describe the new contract. Co-authored-by: SCE --- cli/src/services/agent_trace.rs | 366 ++++---- cli/src/services/agent_trace/evidence.rs | 113 +++ .../average_age_reconstruction/golden.json | 10 +- .../golden.json | 40 + .../incremental_01.patch | 0 .../incremental_02.patch | 0 .../post_commit.patch | 0 .../golden.json | 8 +- cli/src/services/agent_trace/tests.rs | 28 +- cli/src/services/hooks/commit_hooks.rs | 125 ++- .../hooks/commit_hooks/attribution_tests.rs | 783 ++++++++++++++++++ .../services/mutation_trace/attribution.rs | 151 +--- cli/src/services/mutation_trace/lineage.rs | 13 +- .../services/mutation_trace/lineage/tests.rs | 78 ++ .../services/mutation_trace/runtime/mod.rs | 4 +- .../runtime/mutation_attribution.rs | 46 +- .../runtime/mutation_attribution/tests.rs | 385 +++++++++ cli/src/services/patch.rs | 311 +------ .../average_age_reconstruction/golden.json | 549 ------------ .../incremental_01.patch | 28 - .../incremental_02.patch | 28 - .../incremental_03.patch | 25 - .../incremental_04.patch | 15 - .../incremental_05.patch | 23 - .../incremental_06.patch | 55 -- .../incremental_07.patch | 33 - .../post_commit.patch | 122 --- .../file_rename_reconstruction/golden.json | 10 - .../incremental_01.patch | 4 - .../post_commit.patch | 4 - .../hello_world_reconstruction/golden.json | 44 - .../incremental_01.patch | 10 - .../post_commit.patch | 11 - .../golden.json | 29 - .../mixed_change_reconstruction/golden.json | 90 -- .../incremental_01.patch | 12 - .../incremental_02.patch | 4 - .../incremental_03.patch | 6 - .../incremental_04.patch | 9 - .../post_commit.patch | 65 -- .../poem_edit_reconstruction/golden.json | 47 -- .../incremental_01.patch | 14 - .../incremental_02.patch | 11 - .../post_commit.patch | 31 - .../poem_write_reconstruction/golden.json | 134 --- .../incremental_01.patch | 29 - .../post_commit.patch | 30 - .../golden.json | 549 ------------ .../incremental_01.patch | 10 - .../incremental_02.patch | 30 - .../incremental_03.patch | 10 - .../incremental_04.patch | 13 - .../incremental_05.patch | 10 - .../incremental_06.patch | 11 - .../incremental_07.patch | 7 - .../incremental_08.patch | 8 - .../incremental_09.patch | 12 - .../incremental_10.patch | 15 - .../incremental_11.patch | 18 - .../incremental_12.patch | 20 - .../incremental_13.patch | 17 - .../incremental_14.patch | 19 - .../incremental_15.patch | 10 - .../incremental_16.patch | 10 - .../incremental_17.patch | 14 - .../incremental_18.patch | 14 - .../incremental_19.patch | 10 - .../incremental_20.patch | 13 - .../incremental_21.patch | 10 - .../incremental_22.patch | 11 - .../incremental_23.patch | 7 - .../incremental_24.patch | 8 - .../incremental_25.patch | 8 - .../incremental_26.patch | 8 - .../post_commit.patch | 164 ---- cli/src/services/patch/tests.rs | 152 ---- context/architecture.md | 4 +- .../cli/mutation-trace-agent-attribution.md | 148 ++-- context/cli/patch-service.md | 48 +- context/context-map.md | 8 +- context/glossary.md | 7 +- context/overview.md | 2 +- .../agent-trace-commit-msg-coauthor-policy.md | 9 +- context/sce/agent-trace-db.md | 6 +- .../sce/agent-trace-hooks-command-routing.md | 26 +- context/sce/agent-trace-minimal-generator.md | 45 +- context/sce/codex-apply-patch-diff-runtime.md | 9 +- flake.nix | 1 - 88 files changed, 1938 insertions(+), 3496 deletions(-) create mode 100644 cli/src/services/agent_trace/evidence.rs create mode 100644 cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json rename cli/src/services/{patch => agent_trace}/fixtures/line_and_file_deletion_reconstruction/incremental_01.patch (100%) rename cli/src/services/{patch => agent_trace}/fixtures/line_and_file_deletion_reconstruction/incremental_02.patch (100%) rename cli/src/services/{patch => agent_trace}/fixtures/line_and_file_deletion_reconstruction/post_commit.patch (100%) create mode 100644 cli/src/services/hooks/commit_hooks/attribution_tests.rs create mode 100644 cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs delete mode 100644 cli/src/services/patch/fixtures/average_age_reconstruction/golden.json delete mode 100644 cli/src/services/patch/fixtures/average_age_reconstruction/incremental_01.patch delete mode 100644 cli/src/services/patch/fixtures/average_age_reconstruction/incremental_02.patch delete mode 100644 cli/src/services/patch/fixtures/average_age_reconstruction/incremental_03.patch delete mode 100644 cli/src/services/patch/fixtures/average_age_reconstruction/incremental_04.patch delete mode 100644 cli/src/services/patch/fixtures/average_age_reconstruction/incremental_05.patch delete mode 100644 cli/src/services/patch/fixtures/average_age_reconstruction/incremental_06.patch delete mode 100644 cli/src/services/patch/fixtures/average_age_reconstruction/incremental_07.patch delete mode 100644 cli/src/services/patch/fixtures/average_age_reconstruction/post_commit.patch delete mode 100644 cli/src/services/patch/fixtures/file_rename_reconstruction/golden.json delete mode 100644 cli/src/services/patch/fixtures/file_rename_reconstruction/incremental_01.patch delete mode 100644 cli/src/services/patch/fixtures/file_rename_reconstruction/post_commit.patch delete mode 100644 cli/src/services/patch/fixtures/hello_world_reconstruction/golden.json delete mode 100644 cli/src/services/patch/fixtures/hello_world_reconstruction/incremental_01.patch delete mode 100644 cli/src/services/patch/fixtures/hello_world_reconstruction/post_commit.patch delete mode 100644 cli/src/services/patch/fixtures/line_and_file_deletion_reconstruction/golden.json delete mode 100644 cli/src/services/patch/fixtures/mixed_change_reconstruction/golden.json delete mode 100644 cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_01.patch delete mode 100644 cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_02.patch delete mode 100644 cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_03.patch delete mode 100644 cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_04.patch delete mode 100644 cli/src/services/patch/fixtures/mixed_change_reconstruction/post_commit.patch delete mode 100644 cli/src/services/patch/fixtures/poem_edit_reconstruction/golden.json delete mode 100644 cli/src/services/patch/fixtures/poem_edit_reconstruction/incremental_01.patch delete mode 100644 cli/src/services/patch/fixtures/poem_edit_reconstruction/incremental_02.patch delete mode 100644 cli/src/services/patch/fixtures/poem_edit_reconstruction/post_commit.patch delete mode 100644 cli/src/services/patch/fixtures/poem_write_reconstruction/golden.json delete mode 100644 cli/src/services/patch/fixtures/poem_write_reconstruction/incremental_01.patch delete mode 100644 cli/src/services/patch/fixtures/poem_write_reconstruction/post_commit.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/golden.json delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_01.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_02.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_03.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_04.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_05.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_06.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_07.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_08.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_09.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_10.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_11.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_12.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_13.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_14.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_15.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_16.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_17.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_18.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_19.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_20.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_21.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_22.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_23.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_24.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_25.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_26.patch delete mode 100644 cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/post_commit.patch delete mode 100644 cli/src/services/patch/tests.rs diff --git a/cli/src/services/agent_trace.rs b/cli/src/services/agent_trace.rs index 0120affdf..ff2887684 100644 --- a/cli/src/services/agent_trace.rs +++ b/cli/src/services/agent_trace.rs @@ -1,18 +1,24 @@ //! Minimal agent-trace domain model and hunk classification contract. //! //! This module defines the domain types for producing a minimal agent-trace -//! JSON payload from patch data. The classification logic computes the -//! intersection of a constructed patch and a post-commit patch -//! (`intersection_patch = intersect_patches(constructed_patch, post_commit_patch)`), -//! then compares `intersection_patch` against `post_commit_patch` hunk by hunk, -//! anchored to `post_commit_patch` as the canonical source of truth. +//! JSON payload from patch data. Each `post_commit_patch` touched line is +//! resolved independently: AI-exclusive mutation lineage wins, non-exclusive +//! mutation lineage blocks direct evidence, and otherwise only an exact, +//! single-session direct match attributes the line. //! //! Classification rules: -//! - **exact** line-by-line match between `intersection_patch` and `post_commit_patch` hunk => `ai` -//! - same hunk slot in `post_commit_patch` but not exact line-by-line match => `mixed` -//! - hunk present in `post_commit_patch` but missing from `intersection_patch` => `unknown` - -use std::{collections::BTreeSet, error::Error, fmt, io::Cursor, path::Path, sync::OnceLock}; +//! - every touched line in the `post_commit_patch` hunk is attributed => `ai` +//! - some touched lines in the hunk are attributed => `mixed` +//! - no touched line in the hunk is attributed => `unknown` + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + error::Error, + fmt, + io::Cursor, + path::Path, + sync::OnceLock, +}; use anyhow::{Context, Result}; use chrono::{DateTime, FixedOffset}; @@ -22,12 +28,17 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::{NoContext, Timestamp, Uuid}; +use super::mutation_trace::attribution::{logical_path, PatchLineLocation, PatchLineProvenance}; use super::patch::{ - intersect_patches, parse_patch, FileChangeKind, ParsedPatch, PatchFileChange, PatchHunk, - TouchedLine, TouchedLineKind, + parse_patch, FileChangeKind, ParsedPatch, PatchFileChange, PatchHunk, TouchedLineKind, }; use super::version::PACKAGE_VERSION; +mod evidence; + +pub(crate) use evidence::{direct_evidence_patch, has_direct_evidence}; +use evidence::{direct_line_evidence, DirectLineEvidence}; + pub const AGENT_TRACE_VERSION: &str = "0.1.0"; pub(crate) const SCE_WEB_BASE_URL: &str = "https://sce.crocoder.dev"; @@ -94,8 +105,9 @@ pub struct AgentTraceMetadataInput<'a> { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct AgentTraceEvidence<'a> { - pub direct_patch: &'a ParsedPatch, + pub direct_patches: &'a [ParsedPatch], pub mutation_ai_patch: &'a ParsedPatch, + pub mutation_non_ai_patch: &'a ParsedPatch, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -336,51 +348,155 @@ pub struct AgentTrace { pub files: Vec, } -type CoverageLineKey<'a> = (TouchedLineKind, u64, &'a str); +type MutationLineKey<'a> = (&'a str, TouchedLineKind, u64, &'a str); + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ResolvedLine { + provenance: PatchLineProvenance, + direct_supported: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum MutationLineEvidence { + Ai(PatchLineProvenance), + NonAi, + Unresolved, +} + +struct MutationEvidenceIndex<'a> { + ai: HashMap, PatchLineProvenance>, + non_ai: HashSet>, +} + +impl<'a> MutationEvidenceIndex<'a> { + fn new(mutation_ai_patch: &'a ParsedPatch, mutation_non_ai_patch: &'a ParsedPatch) -> Self { + let mut ai = HashMap::new(); + for file in &mutation_ai_patch.files { + for hunk in &file.hunks { + for line in &hunk.lines { + ai.insert( + ( + logical_path(file), + line.kind, + line.line_number, + line.content.as_str(), + ), + PatchLineProvenance { + session_id: line.session_id.clone(), + model_id: hunk.model_id.clone(), + }, + ); + } + } + } + let non_ai = mutation_non_ai_patch + .files + .iter() + .flat_map(|file| { + file.hunks.iter().flat_map(move |hunk| { + hunk.lines.iter().map(move |line| { + ( + logical_path(file), + line.kind, + line.line_number, + line.content.as_str(), + ) + }) + }) + }) + .collect(); + MutationEvidenceIndex { ai, non_ai } + } -fn coverage_line_key(line: &TouchedLine) -> CoverageLineKey<'_> { - (line.kind, line.line_number, line.content.as_str()) + fn lookup(&self, key: &MutationLineKey<'_>) -> MutationLineEvidence { + if let Some(provenance) = self.ai.get(key) { + MutationLineEvidence::Ai(provenance.clone()) + } else if self.non_ai.contains(key) { + MutationLineEvidence::NonAi + } else { + MutationLineEvidence::Unresolved + } + } } -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(); +fn resolve_line( + mutation: MutationLineEvidence, + direct: Option<&DirectLineEvidence>, +) -> Option { + match (mutation, direct) { + (MutationLineEvidence::Ai(mutation), Some(DirectLineEvidence::Unique(direct))) + if direct.session_id == mutation.session_id => + { + Some(ResolvedLine { + provenance: PatchLineProvenance { + session_id: mutation.session_id, + model_id: mutation.model_id.or_else(|| direct.model_id.clone()), + }, + direct_supported: true, + }) + } + (MutationLineEvidence::Ai(mutation), _) => Some(ResolvedLine { + provenance: mutation, + direct_supported: false, + }), + (MutationLineEvidence::Unresolved, Some(DirectLineEvidence::Unique(direct))) => { + Some(ResolvedLine { + provenance: direct.clone(), + direct_supported: true, + }) + } + (MutationLineEvidence::NonAi | MutationLineEvidence::Unresolved, _) => None, + } +} - 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 +fn resolve_patch_lines( + target: &ParsedPatch, + direct_patches: &[ParsedPatch], + mutation: &MutationEvidenceIndex<'_>, +) -> BTreeMap { + let direct = direct_line_evidence(direct_patches, target); + let mut resolved = BTreeMap::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, + }; + let key = (path, line.kind, line.line_number, line.content.as_str()); + if let Some(line) = resolve_line(mutation.lookup(&key), direct.get(&location)) { + resolved.insert(location, line); + } } - }) - .count() + } + } + resolved +} + +fn hunk_resolved_lines( + resolved: &BTreeMap, + file_index: usize, + hunk_index: usize, +) -> impl Iterator { + resolved + .range( + PatchLineLocation { + file_index, + hunk_index, + line_index: 0, + }..=PatchLineLocation { + file_index, + hunk_index, + line_index: usize::MAX, + }, + ) + .map(|(_, line)| line) } -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); - +fn classify_hunk(hunk: &PatchHunk, covered: usize) -> HunkContributor { + let total = hunk.lines.len(); if total > 0 && covered == total { HunkContributor::Ai } else if covered > 0 { @@ -390,27 +506,17 @@ fn classify_hunk_combined( } } -fn combined_model_id( - direct_hunk: Option<&PatchHunk>, - mutation_hunk: Option<&PatchHunk>, -) -> Option { - match (direct_hunk, mutation_hunk) { - (Some(direct), Some(mutation)) if direct.model_id == mutation.model_id => { - direct.model_id.clone() +fn agreed_model_id<'r>(lines: impl Iterator) -> Option { + let mut model: Option<&str> = None; + for line in lines { + let candidate = line.provenance.model_id.as_deref()?; + match model { + None => model = Some(candidate), + Some(existing) if existing == candidate => {} + Some(_) => return None, } - (Some(direct), None) => direct.model_id.clone(), - (None, Some(mutation)) => mutation.model_id.clone(), - (None, None) | (Some(_), Some(_)) => None, } -} - -pub(crate) fn patches_have_overlap( - candidate_patch: &ParsedPatch, - target_patch: &ParsedPatch, -) -> bool { - let intersection_patch = intersect_patches(candidate_patch, target_patch); - - patch_has_touched_lines(&intersection_patch) + model.map(ToOwned::to_owned) } pub(crate) fn patch_has_touched_lines(patch: &ParsedPatch) -> bool { @@ -488,8 +594,8 @@ fn parse_embedded_deleted_patch(file: &PatchFileChange) -> Option { fn build_trace_file( post_commit_file: &PatchFileChange, - intersection_patch: &ParsedPatch, - mutation_ai_patch: &ParsedPatch, + file_index: usize, + resolved: &BTreeMap, conversation_url: &str, line_changes: &mut LineChangeAttribution, ) -> Option { @@ -497,48 +603,22 @@ fn build_trace_file( return None; } - let intersection_file = intersection_patch - .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 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, - ); + .enumerate() + .map(|(hunk_index, post_commit_hunk)| { + let covered = hunk_resolved_lines(resolved, file_index, hunk_index).count(); + let contributor_kind = classify_hunk(post_commit_hunk, covered); let contributor_model_id = match contributor_kind { HunkContributor::Ai | HunkContributor::Mixed => { - combined_model_id(matched_intersection_hunk, matched_mutation_hunk) + agreed_model_id(hunk_resolved_lines(resolved, file_index, hunk_index)) } HunkContributor::Unknown => None, }; record_hunk_line_changes(line_changes, contributor_kind, post_commit_hunk); - let related_session_ids = matched_intersection_hunk - .into_iter() - .chain(matched_mutation_hunk) - .flat_map(|hunk| hunk.lines.iter()) - .filter_map(|line| line.session_id.as_deref()) + let related_session_ids = hunk_resolved_lines(resolved, file_index, hunk_index) + .filter_map(|line| line.provenance.session_id.as_deref()) .filter(|session_id| !session_id.is_empty()) .collect::>(); let related = (!related_session_ids.is_empty()).then(|| { @@ -575,57 +655,51 @@ pub fn build_agent_trace_from_evidence( metadata: AgentTraceMetadataInput<'_>, ) -> Result { let AgentTraceEvidence { - direct_patch, + direct_patches, mutation_ai_patch, + mutation_non_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(direct_patch, post_commit_patch); - let empty_mutation_ai_patch = ParsedPatch { files: Vec::new() }; + let mutation = MutationEvidenceIndex::new(mutation_ai_patch, mutation_non_ai_patch); + let resolved = resolve_patch_lines(post_commit_patch, direct_patches, &mutation); + let empty_patch = ParsedPatch { files: Vec::new() }; + let no_mutation = MutationEvidenceIndex::new(&empty_patch, &empty_patch); let mut files = Vec::new(); let mut line_changes = LineChangeAttribution::default(); - for post_commit_file in &post_commit_patch.files { + for (file_index, post_commit_file) in post_commit_patch.files.iter().enumerate() { if let Some(embedded_patch) = parse_embedded_deleted_patch(post_commit_file) { - 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 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); + for (hunk_index, hunk) in post_commit_file.hunks.iter().enumerate() { + let covered = hunk_resolved_lines(&resolved, file_index, hunk_index).count(); + record_hunk_line_changes(&mut line_changes, classify_hunk(hunk, covered), hunk); } - let embedded_intersection = intersect_patches(direct_patch, &embedded_patch); + let embedded_resolved = + resolve_patch_lines(&embedded_patch, direct_patches, &no_mutation); 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, - ) - })); + files.extend(embedded_patch.files.iter().enumerate().filter_map( + |(embedded_index, embedded_file)| { + build_trace_file( + embedded_file, + embedded_index, + &embedded_resolved, + &conversation_url, + &mut discarded_line_changes, + ) + }, + )); continue; } if let Some(trace_file) = build_trace_file( post_commit_file, - &intersection_patch, - mutation_ai_patch, + file_index, + &resolved, &conversation_url, &mut line_changes, ) { @@ -633,16 +707,16 @@ pub fn build_agent_trace_from_evidence( } } - let tool = if !intersection_patch.files.is_empty() - && (metadata.tool_name.is_some() || metadata.tool_version.is_some()) - { - Some(AgentTraceTool { - name: metadata.tool_name.map(ToOwned::to_owned), - version: metadata.tool_version.map(ToOwned::to_owned), - }) - } else { - None - }; + let direct_supported = resolved.values().any(|line| line.direct_supported); + let tool = + if direct_supported && (metadata.tool_name.is_some() || metadata.tool_version.is_some()) { + Some(AgentTraceTool { + name: metadata.tool_name.map(ToOwned::to_owned), + version: metadata.tool_version.map(ToOwned::to_owned), + }) + } else { + None + }; Ok(AgentTrace { version: default_agent_trace_version(), diff --git a/cli/src/services/agent_trace/evidence.rs b/cli/src/services/agent_trace/evidence.rs new file mode 100644 index 000000000..8d9b6c966 --- /dev/null +++ b/cli/src/services/agent_trace/evidence.rs @@ -0,0 +1,113 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use crate::services::mutation_trace::attribution::{ + logical_path, patch_for_locations_with_provenance, PatchLineLocation, PatchLineProvenance, +}; +use crate::services::patch::{paths_refer_to_same_file, ParsedPatch, TouchedLineKind}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum DirectLineEvidence { + Unique(PatchLineProvenance), + Conflicting, +} + +type ExactLineKey<'a> = (TouchedLineKind, u64, &'a str); + +pub(crate) fn direct_line_evidence( + candidates: &[ParsedPatch], + target: &ParsedPatch, +) -> BTreeMap { + let mut evidence = BTreeMap::new(); + + for (file_index, target_file) in target.files.iter().enumerate() { + let target_path = logical_path(target_file); + let mut by_line: HashMap, Vec> = HashMap::new(); + for candidate_file in candidates.iter().flat_map(|patch| patch.files.iter()) { + if !paths_refer_to_same_file(logical_path(candidate_file), target_path) { + continue; + } + for hunk in &candidate_file.hunks { + for line in &hunk.lines { + by_line + .entry((line.kind, line.line_number, line.content.as_str())) + .or_default() + .push(PatchLineProvenance { + session_id: line.session_id.clone(), + model_id: hunk.model_id.clone(), + }); + } + } + } + if by_line.is_empty() { + continue; + } + + for (hunk_index, hunk) in target_file.hunks.iter().enumerate() { + for (line_index, line) in hunk.lines.iter().enumerate() { + let Some(sources) = + by_line.get(&(line.kind, line.line_number, line.content.as_str())) + else { + continue; + }; + evidence.insert( + PatchLineLocation { + file_index, + hunk_index, + line_index, + }, + resolve_sources(sources), + ); + } + } + } + + evidence +} + +fn resolve_sources(sources: &[PatchLineProvenance]) -> DirectLineEvidence { + let sessions: BTreeSet> = sources + .iter() + .map(|source| source.session_id.as_deref()) + .collect(); + if sessions.len() != 1 { + return DirectLineEvidence::Conflicting; + } + + let models: BTreeSet> = sources + .iter() + .map(|source| source.model_id.as_deref()) + .collect(); + let model_id = match models.into_iter().collect::>().as_slice() { + [Some(model)] => Some((*model).to_owned()), + _ => None, + }; + + DirectLineEvidence::Unique(PatchLineProvenance { + session_id: sources[0].session_id.clone(), + model_id, + }) +} + +pub(crate) fn attributable_direct_evidence( + candidates: &[ParsedPatch], + target: &ParsedPatch, +) -> BTreeMap { + direct_line_evidence(candidates, target) + .into_iter() + .filter_map(|(location, evidence)| match evidence { + DirectLineEvidence::Unique(provenance) => Some((location, provenance)), + DirectLineEvidence::Conflicting => None, + }) + .collect() +} + +pub(crate) fn direct_evidence_patch( + candidates: &[ParsedPatch], + target: &ParsedPatch, +) -> ParsedPatch { + patch_for_locations_with_provenance(target, &attributable_direct_evidence(candidates, target)) +} + +pub(crate) fn has_direct_evidence(candidates: &[ParsedPatch], target: &ParsedPatch) -> bool { + !direct_line_evidence(candidates, target).is_empty() +} diff --git a/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json index 8c669c097..52b458cd3 100644 --- a/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json @@ -11,8 +11,8 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 91, "removed": 9 }, - "mixed": { "added": 0, "removed": 0 }, + "ai": { "added": 0, "removed": 0 }, + "mixed": { "added": 91, "removed": 9 }, "unknown": { "added": 0, "removed": 0 } } } @@ -24,7 +24,7 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "ai" + "type": "mixed" }, "ranges": [ { @@ -42,7 +42,7 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "ai" + "type": "mixed" }, "ranges": [ { @@ -60,7 +60,7 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "ai" + "type": "mixed" }, "ranges": [ { diff --git a/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json new file mode 100644 index 000000000..a2af92ed1 --- /dev/null +++ b/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://agent-trace.dev/schemas/v1/trace-record.json", + "version": "0.1.0", + "id": "01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "timestamp": "2026-04-23T10:20:30Z", + "vcs": { + "type": "git", + "revision": "a0b1c2d3e4f5a6b7c8d9e0f11223344556677889" + }, + "metadata": { + "sce": { + "version": "0.2.0", + "line_changes": { + "ai": { "added": 0, "removed": 0 }, + "mixed": { "added": 2, "removed": 0 }, + "unknown": { "added": 0, "removed": 0 } + } + } + }, + "files": [ + { + "path": "first.txt", + "conversations": [ + { + "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "contributor": { + "type": "mixed" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 2, + "content_hash": "murmur3:3c5fec84" + } + ] + } + ] + } + ] +} diff --git a/cli/src/services/patch/fixtures/line_and_file_deletion_reconstruction/incremental_01.patch b/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/incremental_01.patch similarity index 100% rename from cli/src/services/patch/fixtures/line_and_file_deletion_reconstruction/incremental_01.patch rename to cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/incremental_01.patch diff --git a/cli/src/services/patch/fixtures/line_and_file_deletion_reconstruction/incremental_02.patch b/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/incremental_02.patch similarity index 100% rename from cli/src/services/patch/fixtures/line_and_file_deletion_reconstruction/incremental_02.patch rename to cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/incremental_02.patch diff --git a/cli/src/services/patch/fixtures/line_and_file_deletion_reconstruction/post_commit.patch b/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/post_commit.patch similarity index 100% rename from cli/src/services/patch/fixtures/line_and_file_deletion_reconstruction/post_commit.patch rename to cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/post_commit.patch diff --git a/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json index 6689ea49f..77f46324b 100644 --- a/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json @@ -11,8 +11,8 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 64, "removed": 0 }, - "mixed": { "added": 0, "removed": 0 }, + "ai": { "added": 55, "removed": 0 }, + "mixed": { "added": 9, "removed": 0 }, "unknown": { "added": 4, "removed": 0 } } } @@ -42,7 +42,7 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "ai" + "type": "mixed" }, "ranges": [ { @@ -276,7 +276,7 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "ai" + "type": "mixed" }, "ranges": [ { diff --git a/cli/src/services/agent_trace/tests.rs b/cli/src/services/agent_trace/tests.rs index 22ae65678..7e95b53a3 100644 --- a/cli/src/services/agent_trace/tests.rs +++ b/cli/src/services/agent_trace/tests.rs @@ -4,7 +4,7 @@ use super::{ }; use crate::services::{ agent_trace::agent_trace_conversation_url, - patch::{combine_patches, parse_patch, ParsedPatch}, + patch::{parse_patch, ParsedPatch}, }; use serde_json::Value; @@ -55,16 +55,17 @@ const TEXT_FILE_LIFECYCLE_RECONSTRUCTION_INCREMENTALS: &[&str] = &[ ]; fn assert_builds_expected_agent_trace(scenario: AgentTraceScenario) { - let constructed_patch = combine_patches(&parse_fixtures(scenario.incremental)); + let direct_patches = parse_fixtures(scenario.incremental); let post_commit_patch = parse_patch(scenario.post_commit, None).expect("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 empty_mutation_ai_patch = ParsedPatch { files: Vec::new() }; + let empty_mutation_patch = ParsedPatch { files: Vec::new() }; let actual = build_agent_trace_from_evidence( AgentTraceEvidence { - direct_patch: &constructed_patch, - mutation_ai_patch: &empty_mutation_ai_patch, + direct_patches: &direct_patches, + mutation_ai_patch: &empty_mutation_patch, + mutation_non_ai_patch: &empty_mutation_patch, }, &post_commit_patch, AgentTraceMetadataInput { @@ -202,6 +203,20 @@ fn file_rename_reconstruction_matches_golden_agent_trace() { }); } +#[test] +fn line_and_file_deletion_reconstruction_matches_golden_agent_trace() { + assert_builds_expected_agent_trace(AgentTraceScenario { + incremental: &[ + include_str!("fixtures/line_and_file_deletion_reconstruction/incremental_01.patch"), + include_str!("fixtures/line_and_file_deletion_reconstruction/incremental_02.patch"), + ], + post_commit: include_str!( + "fixtures/line_and_file_deletion_reconstruction/post_commit.patch" + ), + golden: include_str!("fixtures/line_and_file_deletion_reconstruction/golden.json"), + }); +} + #[derive(Clone, Copy)] struct EvidenceScenario { direct: &'static str, @@ -233,8 +248,9 @@ fn assert_builds_expected_agent_trace_from_evidence(scenario: EvidenceScenario) let actual = build_agent_trace_from_evidence( AgentTraceEvidence { - direct_patch: &direct_patch, + direct_patches: std::slice::from_ref(&direct_patch), mutation_ai_patch: &mutation_ai_patch, + mutation_non_ai_patch: &ParsedPatch { files: Vec::new() }, }, &post_commit_patch, AgentTraceMetadataInput { diff --git a/cli/src/services/hooks/commit_hooks.rs b/cli/src/services/hooks/commit_hooks.rs index 013ac5d2f..586f1ef22 100644 --- a/cli/src/services/hooks/commit_hooks.rs +++ b/cli/src/services/hooks/commit_hooks.rs @@ -6,19 +6,17 @@ use chrono::{DateTime, Utc}; use serde_json::{to_string as serialize_to_json, Value}; use crate::services::agent_trace::{ - agent_trace_persisted_url, build_agent_trace_from_evidence, patch_has_touched_lines, - patches_have_overlap, validate_agent_trace_value, AgentTrace, AgentTraceEvidence, - AgentTraceMetadataInput, AgentTraceVcsType, + agent_trace_persisted_url, build_agent_trace_from_evidence, direct_evidence_patch, + has_direct_evidence, patch_has_touched_lines, validate_agent_trace_value, AgentTrace, + AgentTraceEvidence, AgentTraceMetadataInput, AgentTraceVcsType, }; use crate::services::agent_trace_db::{ AgentTraceInsert, ParsedDiffTracePatch, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, }; use crate::services::config::{self, ResolvedHookRuntimeConfig}; -use crate::services::patch::{ - combine_patches as combine_patches_fn, intersect_patches as intersect_patches_fn, - parse_patch as parse_patch_from_text, ParsedPatch, -}; +use crate::services::mutation_trace::attribution::MutationAttributionResult; +use crate::services::patch::{parse_patch as parse_patch_from_text, ParsedPatch}; use crate::services::sync::auto_sync; use super::commit_patch_id::compute_commit_patch_id; @@ -205,7 +203,7 @@ where Ok(format!( "post-commit hook processed intersection: commit={}, intersection_files={}", result.post_commit_data.commit_oid, - result.combined_recent_patch.files.len() + result.intersection_patch.files.len() )) } @@ -241,15 +239,10 @@ pub(crate) async fn run_post_commit_agent_trace_flow< ) .await?; - 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( + let mutation = + crate::services::mutation_trace::runtime::resolve_post_commit_mutation_attribution( repository_root, &mut db, - &direct_intersection, &flow_result.post_commit_data.parsed_patch, ) .await; @@ -265,7 +258,7 @@ pub(crate) async fn run_post_commit_agent_trace_flow< flow_result, vcs_type, remote_url, - &mutation_ai_patch, + &mutation, commit_patch_id.as_deref(), |trace_value| { validate_agent_trace_value(trace_value) @@ -314,7 +307,7 @@ pub(crate) async fn run_post_commit_agent_trace_flow_with( flow_result: &PostCommitIntersectionFlowResult, vcs_type: Option, remote_url: &str, - mutation_ai_patch: &ParsedPatch, + mutation: &MutationAttributionResult, commit_patch_id: Option<&str>, validate_agent_trace: V, persist_agent_trace: I, @@ -335,8 +328,9 @@ where let agent_trace = build_agent_trace_from_evidence( AgentTraceEvidence { - direct_patch: &flow_result.combined_recent_patch, - mutation_ai_patch, + direct_patches: &flow_result.direct_patches, + mutation_ai_patch: &mutation.mutation_ai_patch, + mutation_non_ai_patch: &mutation.resolved_non_ai_patch, }, &flow_result.post_commit_data.parsed_patch, AgentTraceMetadataInput { @@ -377,6 +371,14 @@ where } pub(crate) const RECENT_DAYS_MILLIS: i64 = 7 * 24 * 60 * 60 * 1000; +pub(crate) const COMMIT_TIME_RESOLUTION_MILLIS: i64 = 1000; + +pub(crate) fn direct_evidence_cutoff_ms(now_ms: i64, prior_commit_time_ms: Option) -> i64 { + let recent_cutoff_ms = now_ms - RECENT_DAYS_MILLIS; + prior_commit_time_ms.map_or(recent_cutoff_ms, |commit_time_ms| { + recent_cutoff_ms.max(commit_time_ms.saturating_add(COMMIT_TIME_RESOLUTION_MILLIS)) + }) +} pub(crate) async fn run_post_commit_intersection_flow( repository_root: &Path, @@ -446,6 +448,7 @@ pub(crate) async fn staged_diff_has_ai_overlap< let result = staged_diff_has_ai_overlap_with( repository_root, capture_staged_patch_from_git, + |root| Ok(capture_head_timestamp_from_git(root).ok()), current_unix_time_ms, async |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms).await, ) @@ -465,14 +468,16 @@ pub(crate) async fn staged_diff_has_ai_overlap< result } -pub(crate) async fn staged_diff_has_ai_overlap_with( +pub(crate) async fn staged_diff_has_ai_overlap_with( repository_root: &Path, capture_staged_patch: C, + capture_head_commit_time_ms: H, now_ms: N, query_recent_patches: Q, ) -> StagedDiffAiOverlapResult where C: FnOnce(&Path) -> Result, + H: FnOnce(&Path) -> Result>, N: FnOnce() -> Result, Q: std::ops::AsyncFnOnce(i64, i64) -> Result, { @@ -484,19 +489,24 @@ where return StagedDiffAiOverlapResult::NoOverlap; } + let Ok(head_commit_time_ms) = capture_head_commit_time_ms(repository_root) else { + return StagedDiffAiOverlapResult::Error; + }; let Ok(now_ms) = now_ms() else { return StagedDiffAiOverlapResult::Error; }; - let cutoff_ms = now_ms - RECENT_DAYS_MILLIS; + let cutoff_ms = direct_evidence_cutoff_ms(now_ms, head_commit_time_ms); let Ok(recent_patches) = query_recent_patches(cutoff_ms, now_ms).await else { return StagedDiffAiOverlapResult::Error; }; - let has_overlap = recent_patches.patches.into_iter().any(|recent_patch| { - let combined_recent_patch = combine_patches_fn(&[recent_patch.patch]); - patches_have_overlap(&combined_recent_patch, &staged_patch) - }); + let direct_patches: Vec = recent_patches + .patches + .into_iter() + .map(|recent_patch| recent_patch.patch) + .collect(); + let has_overlap = has_direct_evidence(&direct_patches, &staged_patch); if has_overlap { StagedDiffAiOverlapResult::Overlap @@ -548,7 +558,7 @@ where let post_commit_data = capture_post_commit_patch(repository_root)?; let now_ms = now_ms()?; - let cutoff_ms = now_ms - RECENT_DAYS_MILLIS; + let cutoff_ms = direct_evidence_cutoff_ms(now_ms, post_commit_data.parent_commit_time_ms); let recent_patches = query_recent_patches(cutoff_ms, now_ms).await?; @@ -560,16 +570,13 @@ where let (tool_name, tool_version) = latest_overlapping_patch_tool(&recent_patches.patches, &post_commit_data.parsed_patch); - let recent_patches_slice: Vec = recent_patches + let direct_patches: Vec = recent_patches .patches .into_iter() .map(|p| p.patch) .collect(); - let combined_recent_patch = combine_patches_fn(&recent_patches_slice); - - let intersection_patch = - intersect_patches_fn(&combined_recent_patch, &post_commit_data.parsed_patch); + let intersection_patch = direct_evidence_patch(&direct_patches, &post_commit_data.parsed_patch); let serialized_intersection = serialize_to_json(&intersection_patch) .context("Failed to serialize intersection patch.")?; @@ -587,7 +594,8 @@ where persist_intersection(insert_input).await?; Ok(PostCommitIntersectionFlowResult { - combined_recent_patch, + direct_patches, + intersection_patch, post_commit_data, tool_name, tool_version, @@ -602,9 +610,7 @@ fn latest_overlapping_patch_tool( .iter() .rev() .find(|recent_patch| { - let combined_recent_patch = - combine_patches_fn(std::slice::from_ref(&recent_patch.patch)); - patches_have_overlap(&combined_recent_patch, post_commit_patch) + has_direct_evidence(std::slice::from_ref(&recent_patch.patch), post_commit_patch) }) .map_or((None, None), |recent_patch| { ( @@ -699,12 +705,14 @@ pub fn apply_commit_msg_coauthor_policy( pub struct PostCommitPatchData { pub commit_oid: String, pub commit_time_ms: i64, + pub parent_commit_time_ms: Option, pub parsed_patch: ParsedPatch, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct PostCommitIntersectionFlowResult { - pub combined_recent_patch: ParsedPatch, + pub direct_patches: Vec, + pub intersection_patch: ParsedPatch, pub post_commit_data: PostCommitPatchData, pub tool_name: Option, pub tool_version: Option, @@ -713,6 +721,12 @@ pub struct PostCommitIntersectionFlowResult { pub fn capture_post_commit_patch_from_git(repository_root: &Path) -> Result { let commit_oid = capture_head_oid_from_git(repository_root)?; let commit_time_ms = capture_head_timestamp_from_git(repository_root)?; + let parent_commit_time_ms = capture_commit_timestamp_from_git( + repository_root, + "HEAD^", + "Failed to capture parent commit timestamp from git.", + ) + .ok(); let patch_text = capture_head_patch_from_git(repository_root)?; let parsed_patch = parse_patch_from_text(&patch_text, None).map_err(|e| { anyhow!(post_commit_patch_error( @@ -724,6 +738,7 @@ pub fn capture_post_commit_patch_from_git(repository_root: &Path) -> Result Result Result { - let output = run_git_command_capture_stdout( + capture_commit_timestamp_from_git( repository_root, - &["show", "--format=%ct", "--no-patch", "HEAD"], + "HEAD", "Failed to capture HEAD commit timestamp from git.", + ) +} + +pub(crate) fn capture_commit_timestamp_from_git( + repository_root: &Path, + revision: &str, + failure_context: &str, +) -> Result { + let output = run_git_command_capture_stdout( + repository_root, + &["show", "--format=%ct", "--no-patch", revision, "--"], + failure_context, )?; let timestamp_str = output.trim(); let timestamp_seconds: i64 = timestamp_str.parse().map_err(|_| { @@ -771,6 +798,10 @@ pub(crate) fn post_commit_patch_error(detail: &str, context: &str) -> String { format!("Post-commit patch capture error: {detail} ({context}).") } +#[cfg(test)] +#[path = "commit_hooks/attribution_tests.rs"] +mod attribution_tests; + #[cfg(test)] mod tests { use std::cell::RefCell; @@ -784,6 +815,7 @@ mod tests { use crate::services::agent_trace_db::{AgentTraceInsert, ParsedDiffTracePatch}; use crate::services::error::CliError; + use crate::services::mutation_trace::attribution::MutationAttributionResult; use crate::services::observability::traits::Logger; use crate::services::patch::{parse_patch, ParsedPatch}; @@ -884,7 +916,8 @@ mod tests { fn flow_result(root: &Path) -> PostCommitIntersectionFlowResult { PostCommitIntersectionFlowResult { - combined_recent_patch: empty_patch(), + direct_patches: Vec::new(), + intersection_patch: empty_patch(), post_commit_data: capture_post_commit_patch_from_git(root) .expect("post-commit patch capture"), tool_name: None, @@ -896,6 +929,14 @@ mod tests { ParsedPatch { files: Vec::new() } } + fn empty_mutation() -> MutationAttributionResult { + MutationAttributionResult { + mutation_ai_patch: empty_patch(), + resolved_non_ai_patch: empty_patch(), + unresolved_patch: empty_patch(), + } + } + async fn run_flow_capturing( flow_result: &PostCommitIntersectionFlowResult, commit_patch_id: Option<&str>, @@ -905,7 +946,7 @@ mod tests { flow_result, None, REMOTE_URL, - &empty_patch(), + &empty_mutation(), commit_patch_id, |trace_value| { validate_agent_trace_value(trace_value).map_err(|error| anyhow!(error.to_string())) @@ -1078,7 +1119,7 @@ diff --git a//tmp/commit-message.txt b//tmp/commit-message.txt &flow_result, None, REMOTE_URL, - &empty_patch(), + &empty_mutation(), Some(&commit_patch_id), |_| Err(anyhow!("validation rejected")), async |_insert: AgentTraceInsert<'_>| -> Result<()> { @@ -1098,7 +1139,7 @@ diff --git a//tmp/commit-message.txt b//tmp/commit-message.txt &flow_result, None, REMOTE_URL, - &empty_patch(), + &empty_mutation(), Some(&commit_patch_id), |_| Ok(()), async |_insert: AgentTraceInsert<'_>| -> Result<()> { diff --git a/cli/src/services/hooks/commit_hooks/attribution_tests.rs b/cli/src/services/hooks/commit_hooks/attribution_tests.rs new file mode 100644 index 000000000..db5a1062b --- /dev/null +++ b/cli/src/services/hooks/commit_hooks/attribution_tests.rs @@ -0,0 +1,783 @@ +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::Path; +use std::process::Command; + +use anyhow::{anyhow, Result}; +use serde_json::Value; + +use crate::services::agent_trace::{ + agent_trace_session_url, validate_agent_trace_value, AgentTrace, HunkContributor, +}; +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_db::{ + AgentTraceInsert, DiffTraceInsert, ParsedDiffTracePatch, PostCommitPatchIntersectionInsert, + RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, +}; +use crate::services::mutation_trace::attribution::MutationAttributionResult; +use crate::services::mutation_trace::runtime::GitSnapshotService; +use crate::services::mutation_trace::runtime::{ + resolve_bounded_mutation_attribution, MutationEventPageSource, +}; +use crate::services::mutation_trace::store::{ + AttributionKind, MutationEventPageRow, MutationTraceStore, ScopeProvenance, + MUTATION_ATTRIBUTION_PAGE_SIZE, +}; +use crate::services::mutation_trace::types::{FailureKind, ScopeId, TreeId, WorktreeId}; +use crate::services::patch::{parse_patch, ParsedPatch}; + +use super::{ + capture_post_commit_patch_from_git, run_post_commit_agent_trace_flow_with, + run_post_commit_intersection_flow_with, staged_diff_has_ai_overlap_with, + PostCommitIntersectionFlowResult, StagedDiffAiOverlapResult, +}; + +const PLAN_PATH: &str = "context/plans/plan.md"; +const BOILERPLATE: [&str; 3] = [" - Completed: 2026-11-06", " - Result:", " - Verify:"]; +const WORKTREE: &str = "main"; +const REMOTE_URL: &str = "https://example.invalid/repo.git"; + +const BASE_TIME_S: i64 = 1_793_960_000; +const COMMIT_A_TIME_S: i64 = BASE_TIME_S + 1_000; +const COMMIT_B_TIME_S: i64 = BASE_TIME_S + 2_000; +const COMMIT_C_TIME_S: i64 = BASE_TIME_S + 3_000; + +const SESSION_A: &str = "cc_session-a"; +const SESSION_B: &str = "cc_session-b"; +const SESSION_C: &str = "cc_session-c"; +const SESSION_X: &str = "cc_session-x"; +const MODEL_A: &str = "model-a"; +const MODEL_B: &str = "model-b"; +const MODEL_C: &str = "model-c"; +const MODEL_X: &str = "model-x"; +const SCOPE_A: &str = "scope-a"; +const SCOPE_B: &str = "scope-b"; +const SCOPE_C: &str = "scope-c"; + +const fn ms(seconds: i64) -> i64 { + seconds * 1_000 +} + +fn plan(done: [bool; 3]) -> String { + let mut text = String::from("# Plan\n"); + for (index, section_done) in done.iter().enumerate() { + writeln!(text, "- T0{}", index + 1).expect("write plan section"); + if *section_done { + for line in BOILERPLATE { + text.push_str(line); + text.push('\n'); + } + } + } + text +} + +fn boilerplate_patch(old_start: u64, new_start: u64) -> String { + let mut text = format!( + "diff --git a/{PLAN_PATH} b/{PLAN_PATH}\n--- a/{PLAN_PATH}\n+++ b/{PLAN_PATH}\n@@ -{old_start},0 +{new_start},3 @@\n" + ); + for line in BOILERPLATE { + text.push('+'); + text.push_str(line); + text.push('\n'); + } + text +} + +const EXACT_C_POSITIONS: (u64, u64) = (10, 11); +const COMMIT_B_POSITIONS: (u64, u64) = (6, 7); + +struct HistoryRepo { + dir: tempfile::TempDir, + trees: [TreeId; 4], +} + +impl HistoryRepo { + fn new() -> Self { + let dir = tempfile::tempdir().expect("temp dir"); + let root = dir.path(); + git(root, &["init", "-q", "-b", "main"], None); + git( + root, + &["config", "user.email", "attribution@example.invalid"], + None, + ); + git(root, &["config", "user.name", "Attribution Test"], None); + git(root, &["config", "commit.gpgsign", "false"], None); + std::fs::create_dir_all(root.join("context/plans")).expect("plan dir"); + + let mut trees = Vec::new(); + for (done, time_s) in [ + ([false, false, false], BASE_TIME_S), + ([true, false, false], COMMIT_A_TIME_S), + ([true, true, false], COMMIT_B_TIME_S), + ([true, true, true], COMMIT_C_TIME_S), + ] { + std::fs::write(root.join(PLAN_PATH), plan(done)).expect("write plan"); + git(root, &["add", "-A"], None); + git(root, &["commit", "-q", "-m", "plan"], Some(time_s)); + trees.push(TreeId(git(root, &["rev-parse", "HEAD^{tree}"], None))); + } + + HistoryRepo { + dir, + trees: trees.try_into().expect("four trees"), + } + } + + fn root(&self) -> &Path { + self.dir.path() + } + + fn commit_tree(&self) -> &TreeId { + &self.trees[3] + } + + fn mutation_rows(&self, final_kind: AttributionKind) -> Vec { + let final_scope = (final_kind == AttributionKind::AiExclusive).then_some(SCOPE_C); + [Some(SCOPE_A), Some(SCOPE_B), final_scope] + .into_iter() + .enumerate() + .map(|(index, scope)| MutationEventPageRow { + revision: index as u64 + 1, + before_tree: self.trees[index].clone(), + after_tree: self.trees[index + 1].clone(), + failure_kind: FailureKind::Healthy, + attribution_kind: if scope.is_some() { + AttributionKind::AiExclusive + } else { + final_kind + }, + attribution_scope_id: scope.map(|scope| ScopeId(scope.to_owned())), + }) + .collect() + } +} + +fn git(root: &Path, args: &[&str], time_s: Option) -> String { + let mut command = Command::new("git"); + command + .args(args) + .current_dir(root) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1"); + if let Some(time_s) = time_s { + let date = format!("@{time_s} +0000"); + command + .env("GIT_AUTHOR_DATE", &date) + .env("GIT_COMMITTER_DATE", &date); + } + let output = command.output().expect("git command should run"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("git output is UTF-8") + .trim() + .to_owned() +} + +struct VecPages { + rows: Vec, +} + +fn scope_provenance(scope_id: &ScopeId) -> Option { + let (session_id, model_id) = match scope_id.0.as_str() { + SCOPE_A => (SESSION_A, MODEL_A), + SCOPE_B => (SESSION_B, MODEL_B), + SCOPE_C => (SESSION_C, MODEL_C), + _ => return None, + }; + Some(ScopeProvenance { + scope_id: scope_id.clone(), + session_id: session_id.to_owned(), + model_id: Some(model_id.to_owned()), + }) +} + +impl MutationEventPageSource for VecPages { + async fn load_mutation_event_page( + &self, + _worktree: &WorktreeId, + revision_cursor: Option, + requested_limit: usize, + ) -> Result> { + let mut rows: Vec<_> = self + .rows + .iter() + .filter(|row| revision_cursor.is_none_or(|cursor| row.revision < cursor)) + .cloned() + .collect(); + rows.sort_by_key(|row| std::cmp::Reverse(row.revision)); + rows.truncate(requested_limit.min(MUTATION_ATTRIBUTION_PAGE_SIZE)); + Ok(rows) + } + + async fn load_scope_provenance(&self, scope_id: &ScopeId) -> Result> { + Ok(scope_provenance(scope_id)) + } +} + +struct DiffTraceRow { + time_ms: i64, + session_id: &'static str, + model_id: &'static str, + patch: String, +} + +fn stale_rows() -> Vec { + vec![ + DiffTraceRow { + time_ms: ms(COMMIT_A_TIME_S) - 500, + session_id: SESSION_A, + model_id: MODEL_A, + patch: boilerplate_patch(EXACT_C_POSITIONS.0, EXACT_C_POSITIONS.1), + }, + DiffTraceRow { + time_ms: ms(COMMIT_B_TIME_S) - 500, + session_id: SESSION_B, + model_id: MODEL_B, + patch: boilerplate_patch(COMMIT_B_POSITIONS.0, COMMIT_B_POSITIONS.1), + }, + ] +} + +fn fresh_row( + session_id: &'static str, + model_id: &'static str, + positions: (u64, u64), +) -> DiffTraceRow { + DiffTraceRow { + time_ms: ms(COMMIT_C_TIME_S) - 500, + session_id, + model_id, + patch: boilerplate_patch(positions.0, positions.1), + } +} + +fn parsed_row(id: i64, row: &DiffTraceRow) -> ParsedDiffTracePatch { + let mut patch = parse_patch(&row.patch, Some(row.session_id)).expect("diff trace parses"); + for file in &mut patch.files { + for hunk in &mut file.hunks { + hunk.model_id = Some(row.model_id.to_owned()); + } + } + ParsedDiffTracePatch { + id, + time_ms: row.time_ms, + session_id: row.session_id.to_owned(), + patch, + tool_name: Some(String::from("claude")), + tool_version: None, + payload_type: String::from(PAYLOAD_TYPE_PATCH), + } +} + +fn rows_in_window(rows: &[DiffTraceRow], cutoff_ms: i64, end_ms: i64) -> RecentDiffTracePatches { + RecentDiffTracePatches { + patches: rows + .iter() + .enumerate() + .filter(|(_, row)| row.time_ms >= cutoff_ms && row.time_ms <= end_ms) + .map(|(index, row)| parsed_row(i64::try_from(index).expect("row index fits") + 1, row)) + .collect(), + skipped: Vec::new(), + } +} + +struct PersistedIntersection { + loaded: i64, + patch: ParsedPatch, +} + +async fn intersection_flow( + repo: &HistoryRepo, + rows: &[DiffTraceRow], +) -> (PostCommitIntersectionFlowResult, PersistedIntersection) { + let persisted = RefCell::new(None); + let flow = run_post_commit_intersection_flow_with( + repo.root(), + capture_post_commit_patch_from_git, + || Ok(ms(COMMIT_C_TIME_S) + 500), + async |cutoff_ms, end_ms| Ok(rows_in_window(rows, cutoff_ms, end_ms)), + async |insert: PostCommitPatchIntersectionInsert<'_>| -> Result<()> { + *persisted.borrow_mut() = Some(PersistedIntersection { + loaded: insert.loaded_diff_trace_count, + patch: serde_json::from_str(insert.intersection_patch).expect("intersection JSON"), + }); + Ok(()) + }, + ) + .await + .expect("intersection flow"); + ( + flow, + persisted.into_inner().expect("intersection persisted"), + ) +} + +async fn mutation_attribution( + repo: &HistoryRepo, + rows: Vec, + committed: &ParsedPatch, +) -> MutationAttributionResult { + let ceiling = rows.iter().map(|row| row.revision).max(); + resolve_bounded_mutation_attribution( + &VecPages { rows }, + &GitSnapshotService::new(repo.root()) + .await + .expect("snapshot"), + &WorktreeId(WORKTREE.to_owned()), + committed, + repo.commit_tree(), + ceiling, + ) + .await + .result +} + +fn no_mutation() -> MutationAttributionResult { + let empty = ParsedPatch { files: Vec::new() }; + MutationAttributionResult { + mutation_ai_patch: empty.clone(), + resolved_non_ai_patch: empty.clone(), + unresolved_patch: empty, + } +} + +async fn agent_trace( + flow: &PostCommitIntersectionFlowResult, + mutation: &MutationAttributionResult, +) -> AgentTrace { + run_post_commit_agent_trace_flow_with( + flow, + None, + REMOTE_URL, + mutation, + None, + |value| validate_agent_trace_value(value).map_err(|error| anyhow!(error.to_string())), + async |_insert: AgentTraceInsert<'_>| -> Result<()> { Ok(()) }, + ) + .await + .expect("agent trace flow") +} + +fn related_sessions(trace: &AgentTrace) -> Vec { + trace.files[0].conversations[0] + .related + .iter() + .flatten() + .map(|related| related.url.clone()) + .collect() +} + +fn assert_contains_none(json: &str, needles: &[&str]) { + for needle in needles { + assert!(!json.contains(needle), "unexpected {needle} in {json}"); + } +} + +fn touched_line_count(patch: &ParsedPatch) -> usize { + patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .map(|hunk| hunk.lines.len()) + .sum() +} + +#[tokio::test] +async fn stale_sessions_from_already_committed_history_do_not_credit_a_later_commit() { + let repo = HistoryRepo::new(); + let (flow, persisted) = intersection_flow(&repo, &stale_rows()).await; + + assert_eq!(persisted.loaded, 0); + assert_eq!(touched_line_count(&persisted.patch), 0); + + let mutation = mutation_attribution( + &repo, + repo.mutation_rows(AttributionKind::AiExclusive), + &flow.post_commit_data.parsed_patch, + ) + .await; + let trace = agent_trace(&flow, &mutation).await; + let conversation = &trace.files[0].conversations[0]; + + assert_eq!(conversation.contributor.kind, HunkContributor::Ai); + assert_eq!(conversation.contributor.model_id.as_deref(), Some(MODEL_C)); + assert_eq!( + related_sessions(&trace), + vec![agent_trace_session_url(SESSION_C)] + ); + assert_contains_none( + &serde_json::to_string(&trace).expect("trace serializes"), + &[SESSION_A, SESSION_B, MODEL_A, MODEL_B], + ); +} + +#[tokio::test] +async fn causal_mutation_lineage_overrides_a_fresh_exact_direct_match_from_another_session() { + let repo = HistoryRepo::new(); + let rows = vec![fresh_row(SESSION_X, MODEL_X, EXACT_C_POSITIONS)]; + let (flow, persisted) = intersection_flow(&repo, &rows).await; + + assert_eq!(touched_line_count(&persisted.patch), BOILERPLATE.len()); + + let mutation = mutation_attribution( + &repo, + repo.mutation_rows(AttributionKind::AiExclusive), + &flow.post_commit_data.parsed_patch, + ) + .await; + assert_eq!( + touched_line_count(&mutation.mutation_ai_patch), + BOILERPLATE.len() + ); + + let trace = agent_trace(&flow, &mutation).await; + let conversation = &trace.files[0].conversations[0]; + assert_eq!(conversation.contributor.kind, HunkContributor::Ai); + assert_eq!(conversation.contributor.model_id.as_deref(), Some(MODEL_C)); + assert_eq!( + related_sessions(&trace), + vec![agent_trace_session_url(SESSION_C)] + ); + assert_eq!(trace.tool, None); + assert_contains_none( + &serde_json::to_string(&trace).expect("trace serializes"), + &[SESSION_X, MODEL_X], + ); +} + +#[tokio::test] +async fn non_exclusive_mutation_lineage_is_not_overridden_by_a_direct_match() { + let repo = HistoryRepo::new(); + let rows = vec![fresh_row(SESSION_X, MODEL_X, EXACT_C_POSITIONS)]; + let (flow, _) = intersection_flow(&repo, &rows).await; + + let mutation = mutation_attribution( + &repo, + repo.mutation_rows(AttributionKind::IneligibleUnscoped), + &flow.post_commit_data.parsed_patch, + ) + .await; + assert_eq!( + touched_line_count(&mutation.resolved_non_ai_patch), + BOILERPLATE.len() + ); + + let trace = agent_trace(&flow, &mutation).await; + assert_eq!( + trace.files[0].conversations[0].contributor.kind, + HunkContributor::Unknown + ); + assert!(related_sessions(&trace).is_empty()); +} + +#[tokio::test] +async fn a_historical_content_match_at_other_positions_does_not_attribute_without_lineage() { + let repo = HistoryRepo::new(); + let rows = vec![fresh_row(SESSION_X, MODEL_X, COMMIT_B_POSITIONS)]; + let (flow, persisted) = intersection_flow(&repo, &rows).await; + + assert_eq!(persisted.loaded, 1); + assert_eq!(touched_line_count(&persisted.patch), 0); + + let trace = agent_trace(&flow, &no_mutation()).await; + assert_eq!( + trace.files[0].conversations[0].contributor.kind, + HunkContributor::Unknown + ); + assert_contains_none( + &serde_json::to_string(&trace).expect("trace serializes"), + &[SESSION_X, MODEL_X], + ); +} + +#[tokio::test] +async fn fresh_exact_direct_evidence_still_attributes_without_mutation_history() { + let repo = HistoryRepo::new(); + let mut rows = stale_rows(); + rows.push(fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS)); + let (flow, _) = intersection_flow(&repo, &rows).await; + + let trace = agent_trace(&flow, &no_mutation()).await; + let conversation = &trace.files[0].conversations[0]; + assert_eq!(conversation.contributor.kind, HunkContributor::Ai); + assert_eq!(conversation.contributor.model_id.as_deref(), Some(MODEL_C)); + assert_eq!( + related_sessions(&trace), + vec![agent_trace_session_url(SESSION_C)] + ); + assert_eq!( + trace.tool.as_ref().and_then(|tool| tool.name.as_deref()), + Some("claude") + ); + assert_contains_none( + &serde_json::to_string(&trace).expect("trace serializes"), + &[SESSION_A, SESSION_B], + ); +} + +#[tokio::test] +async fn conflicting_fresh_direct_sessions_fail_closed_without_lineage() { + let repo = HistoryRepo::new(); + let rows = vec![ + fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS), + fresh_row(SESSION_X, MODEL_X, EXACT_C_POSITIONS), + ]; + let (flow, persisted) = intersection_flow(&repo, &rows).await; + + assert_eq!(touched_line_count(&persisted.patch), 0); + let trace = agent_trace(&flow, &no_mutation()).await; + assert_eq!( + trace.files[0].conversations[0].contributor.kind, + HunkContributor::Unknown + ); + assert!(related_sessions(&trace).is_empty()); +} + +#[test] +fn one_direct_source_line_covers_only_its_own_duplicate_occurrence() { + let committed = parse_patch( + "diff --git a/plan.md b/plan.md\n--- a/plan.md\n+++ b/plan.md\n@@ -1,0 +2,1 @@\n+ - Verify:\n@@ -20,0 +22,1 @@\n+ - Verify:\n", + None, + ) + .expect("committed patch parses"); + let direct = parse_patch( + "diff --git a/plan.md b/plan.md\n--- a/plan.md\n+++ b/plan.md\n@@ -1,0 +2,1 @@\n+ - Verify:\n", + Some(SESSION_C), + ) + .expect("direct patch parses"); + + let trace = crate::services::agent_trace::build_agent_trace_from_evidence( + crate::services::agent_trace::AgentTraceEvidence { + direct_patches: std::slice::from_ref(&direct), + mutation_ai_patch: &ParsedPatch { files: Vec::new() }, + mutation_non_ai_patch: &ParsedPatch { files: Vec::new() }, + }, + &committed, + crate::services::agent_trace::AgentTraceMetadataInput { + commit_timestamp: "2026-11-06T12:23:44Z", + commit_revision: "0000000000000000000000000000000000000001", + vcs_type: None, + tool_name: None, + tool_version: None, + }, + ) + .expect("trace builds"); + + let kinds: Vec<_> = trace.files[0] + .conversations + .iter() + .map(|conversation| conversation.contributor.kind) + .collect(); + assert_eq!(kinds, vec![HunkContributor::Ai, HunkContributor::Unknown]); + assert_eq!(trace.metadata.sce.line_changes.ai.added, 1); + assert_eq!(trace.metadata.sce.line_changes.unknown.added, 1); +} + +async fn staged_overlap( + rows: &[DiffTraceRow], + head_time_s: Option, +) -> StagedDiffAiOverlapResult { + let staged = parse_patch( + &boilerplate_patch(EXACT_C_POSITIONS.0, EXACT_C_POSITIONS.1), + None, + ) + .expect("staged patch parses"); + staged_diff_has_ai_overlap_with( + Path::new("."), + |_| Ok(staged), + |_| Ok(head_time_s.map(ms)), + || Ok(ms(COMMIT_C_TIME_S)), + async |cutoff_ms, end_ms| Ok(rows_in_window(rows, cutoff_ms, end_ms)), + ) + .await +} + +#[tokio::test] +async fn staged_coauthor_check_ignores_stale_and_historical_only_diff_traces() { + assert_eq!( + staged_overlap(&stale_rows(), Some(COMMIT_B_TIME_S)).await, + StagedDiffAiOverlapResult::NoOverlap + ); + assert_eq!( + staged_overlap( + &[fresh_row(SESSION_X, MODEL_X, COMMIT_B_POSITIONS)], + Some(COMMIT_B_TIME_S) + ) + .await, + StagedDiffAiOverlapResult::NoOverlap + ); +} + +#[tokio::test] +async fn staged_coauthor_check_keeps_fresh_exact_direct_evidence() { + assert_eq!( + staged_overlap( + &[fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS)], + Some(COMMIT_B_TIME_S) + ) + .await, + StagedDiffAiOverlapResult::Overlap + ); + assert_eq!( + staged_overlap(&stale_rows(), None).await, + StagedDiffAiOverlapResult::Overlap + ); +} + +async fn insert_mutation_rows(db: &RepositoryAgentTraceDb, rows: &[MutationEventPageRow]) { + for row in rows { + let scope_id = row + .attribution_scope_id + .as_ref() + .expect("persisted rows are AI-exclusive"); + let provenance = scope_provenance(scope_id).expect("known scope"); + db.execute( + "INSERT INTO mutation_trace_scopes (scope_id, worktree_id, actor_kind, status) VALUES (?1, ?2, 'claude_code', 'closed')", + (scope_id.0.as_str(), WORKTREE), + ) + .await + .expect("insert scope"); + db.execute( + "INSERT INTO mutation_trace_scope_provenance (scope_id, session_id, model_id) VALUES (?1, ?2, ?3)", + ( + scope_id.0.as_str(), + provenance.session_id.as_str(), + provenance.model_id.as_deref(), + ), + ) + .await + .expect("insert scope provenance"); + let revision = row.revision.to_be_bytes(); + 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, 'close', ?5, ?6)", + ( + WORKTREE, + revision.as_slice(), + row.before_tree.0.as_str(), + row.after_tree.0.as_str(), + scope_id.0.as_str(), + format!("event-{}", row.revision), + ), + ) + .await + .expect("insert mutation event"); + } +} + +#[tokio::test] +async fn persisted_agent_trace_keeps_causal_session_and_drops_stale_sessions() { + let repo = HistoryRepo::new(); + let db_dir = tempfile::tempdir().expect("db dir"); + let mut db = RepositoryAgentTraceDb::new_at(db_dir.path().join("agent-trace.db")) + .await + .expect("open agent trace db"); + + let mut rows = stale_rows(); + rows.push(fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS)); + for row in &rows { + db.insert_diff_trace(DiffTraceInsert { + time_ms: row.time_ms, + session_id: row.session_id, + patch: &row.patch, + model_id: Some(row.model_id), + tool_name: "claude", + tool_version: None, + payload_type: PAYLOAD_TYPE_PATCH, + }) + .await + .expect("insert diff trace"); + } + let mutation_rows = repo.mutation_rows(AttributionKind::AiExclusive); + insert_mutation_rows(&db, &mutation_rows).await; + + let flow = run_post_commit_intersection_flow_with( + repo.root(), + capture_post_commit_patch_from_git, + || Ok(ms(COMMIT_C_TIME_S) + 500), + async |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms).await, + async |insert| { + db.insert_post_commit_patch_intersection(insert) + .await + .map(|_| ()) + }, + ) + .await + .expect("intersection flow"); + + let mutation = { + let store = MutationTraceStore::new(&mut db); + resolve_bounded_mutation_attribution( + &store, + &GitSnapshotService::new(repo.root()) + .await + .expect("snapshot"), + &WorktreeId(WORKTREE.to_owned()), + &flow.post_commit_data.parsed_patch, + repo.commit_tree(), + Some(mutation_rows.len() as u64), + ) + .await + .result + }; + + run_post_commit_agent_trace_flow_with( + &flow, + None, + REMOTE_URL, + &mutation, + None, + |value| validate_agent_trace_value(value).map_err(|error| anyhow!(error.to_string())), + async |insert| db.insert_agent_trace(insert).await.map(|_| ()), + ) + .await + .expect("agent trace flow"); + + let intersections: Vec<(i64, String)> = db + .query_map( + "SELECT loaded_diff_trace_count, intersection_patch FROM post_commit_patch_intersections", + (), + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .await + .expect("read intersections"); + assert_eq!(intersections.len(), 1); + assert_eq!(intersections[0].0, 1); + assert_contains_none(&intersections[0].1, &[SESSION_A, SESSION_B]); + + let traces: Vec = db + .query_map("SELECT trace_json FROM agent_traces", (), |row| { + Ok(row.get(0)?) + }) + .await + .expect("read agent traces"); + assert_eq!(traces.len(), 1); + let persisted: Value = serde_json::from_str(&traces[0]).expect("trace JSON"); + validate_agent_trace_value(&persisted).expect("persisted trace validates"); + assert_contains_none(&traces[0], &[SESSION_A, SESSION_B, MODEL_A, MODEL_B]); + + let conversation = &persisted["files"][0]["conversations"][0]; + assert_eq!(conversation["contributor"]["type"], "ai"); + assert_eq!(conversation["contributor"]["model_id"], MODEL_C); + let related: BTreeMap = conversation["related"] + .as_array() + .expect("related sessions") + .iter() + .map(|related| { + ( + related["url"].as_str().unwrap().to_owned(), + related["type"].as_str().unwrap().to_owned(), + ) + }) + .collect(); + assert_eq!( + related.into_keys().collect::>(), + vec![agent_trace_session_url(SESSION_C)] + ); +} diff --git a/cli/src/services/mutation_trace/attribution.rs b/cli/src/services/mutation_trace/attribution.rs index d7039bed0..848f3c04e 100644 --- a/cli/src/services/mutation_trace/attribution.rs +++ b/cli/src/services/mutation_trace/attribution.rs @@ -1,8 +1,6 @@ -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet}; -use crate::services::patch::{ - ParsedPatch, PatchFileChange, PatchHunk, TouchedLine, TouchedLineKind, -}; +use crate::services::patch::{ParsedPatch, PatchFileChange, PatchHunk}; #[allow(clippy::struct_field_names)] #[derive(Clone, Debug, Eq, PartialEq)] @@ -26,72 +24,6 @@ pub struct PatchLineProvenance { pub model_id: Option, } -#[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 @@ -209,82 +141,3 @@ fn agreed_model( } model.map(ToOwned::to_owned) } - -#[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 index 4f28203ed..7dd2f7a04 100644 --- a/cli/src/services/mutation_trace/lineage.rs +++ b/cli/src/services/mutation_trace/lineage.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::services::mutation_trace::types::ScopeId; use crate::services::patch::{ - ParsedPatch, PatchFileChange, PatchHunk, TouchedLine, TouchedLineKind, + FileChangeKind, ParsedPatch, PatchFileChange, PatchHunk, TouchedLine, TouchedLineKind, }; #[derive(Clone, Debug, Eq, PartialEq)] @@ -120,10 +120,17 @@ impl MutationLineage { .cloned() .unwrap_or_default(); + if file.kind == FileChangeKind::Added && !old.is_empty() { + return Err(LineageError { + path: logical_path, + reason: String::from("added file is already tracked with content"), + }); + } + let new = apply_hunks(&logical_path, &old, file, origin)?; - if !file.old_path.is_empty() { - self.files.remove(&file.old_path); + if tracks_source && file.old_path != file.new_path { + self.files.insert(file.old_path.clone(), Vec::new()); } if !file.new_path.is_empty() { self.files.insert(file.new_path.clone(), new); diff --git a/cli/src/services/mutation_trace/lineage/tests.rs b/cli/src/services/mutation_trace/lineage/tests.rs index 4a49dd069..cb8a26675 100644 --- a/cli/src/services/mutation_trace/lineage/tests.rs +++ b/cli/src/services/mutation_trace/lineage/tests.rs @@ -160,3 +160,81 @@ fn duplicate_lines_do_not_let_provenance_jump_between_occurrences() { LineProvenance::Unknown ); } + +#[test] +fn a_deleted_then_recreated_file_stays_tracked_and_takes_the_new_provenance() { + let mut lineage = baseline(&[("plan.md", Some("a\n"))]); + lineage + .apply( + &diff( + "diff --git a/plan.md b/plan.md\ndeleted file mode 100644\n--- a/plan.md\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-a\n", + ), + &TransitionOrigin::MutationNonAi, + ) + .expect("delete"); + lineage + .apply( + &diff( + "diff --git a/plan.md b/plan.md\nnew file mode 100644\n--- /dev/null\n+++ b/plan.md\n@@ -0,0 +1,1 @@\n+a\n", + ), + &TransitionOrigin::MutationNonAi, + ) + .expect("recreate"); + lineage + .apply( + &diff("diff --git a/plan.md b/plan.md\n--- a/plan.md\n+++ b/plan.md\n@@ -1,1 +1,2 @@\n a\n+ - Verify:\n"), + &TransitionOrigin::MutationAi(scope("author")), + ) + .expect("edit after recreation"); + + assert_eq!( + lineage.provenance_at("plan.md", 2, " - Verify:"), + LineProvenance::MutationAi { + scope_id: scope("author") + } + ); + assert_eq!( + lineage.provenance_at("plan.md", 1, "a"), + LineProvenance::MutationNonAi + ); +} + +#[test] +fn a_renamed_away_file_stays_tracked_as_absent() { + let mut lineage = baseline(&[("old.md", Some("a\n")), ("new.md", None)]); + lineage + .apply( + &diff( + "diff --git a/old.md b/new.md\nsimilarity index 100%\nrename from old.md\nrename to new.md\n", + ), + &TransitionOrigin::MutationNonAi, + ) + .expect("rename"); + lineage + .apply( + &diff( + "diff --git a/old.md b/old.md\nnew file mode 100644\n--- /dev/null\n+++ b/old.md\n@@ -0,0 +1,1 @@\n+b\n", + ), + &TransitionOrigin::MutationAi(scope("s1")), + ) + .expect("recreate the renamed-away path"); + + assert_eq!( + lineage.provenance_at("old.md", 1, "b"), + LineProvenance::MutationAi { + scope_id: scope("s1") + } + ); +} + +#[test] +fn adding_a_file_that_is_tracked_with_content_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\nnew file mode 100644\n--- /dev/null\n+++ b/f.rs\n@@ -0,0 +1,1 @@\n+b\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect_err("a stale tracked file must fail closed"); + assert_eq!(error.path, "f.rs"); +} diff --git a/cli/src/services/mutation_trace/runtime/mod.rs b/cli/src/services/mutation_trace/runtime/mod.rs index 4722da2da..988be30e0 100644 --- a/cli/src/services/mutation_trace/runtime/mod.rs +++ b/cli/src/services/mutation_trace/runtime/mod.rs @@ -23,10 +23,10 @@ pub(crate) use external_mutation_guard::{ GuardRequest, }; #[allow(unused_imports)] -pub(crate) use git_snapshot::{resolve_git_dir, resolve_worktree_id}; +pub(crate) use git_snapshot::{resolve_git_dir, resolve_worktree_id, GitSnapshotService}; #[allow(unused_imports)] pub(crate) use mutation_attribution::{ - resolve_bounded_mutation_attribution, resolve_post_commit_mutation_ai_patch, + resolve_bounded_mutation_attribution, resolve_post_commit_mutation_attribution, BoundedMutationAttribution, MutationAttributionBarrier, MutationEventPageSource, TreeReadSource, MAX_MUTATION_ATTRIBUTION_EVENTS, }; diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution.rs index cf2f18af1..158f9c7fa 100644 --- a/cli/src/services/mutation_trace/runtime/mutation_attribution.rs +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution.rs @@ -6,9 +6,8 @@ use std::time::Duration; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::mutation_trace::attribution::{ - exclude_direct_coverage, logical_path, patch_for_locations, - patch_for_locations_with_provenance, MutationAttributionResult, PatchLineLocation, - PatchLineProvenance, + logical_path, patch_for_locations, patch_for_locations_with_provenance, + MutationAttributionResult, PatchLineLocation, PatchLineProvenance, }; use crate::services::mutation_trace::lineage::{LineProvenance, MutationLineage, TransitionOrigin}; use crate::services::mutation_trace::store::{ @@ -96,11 +95,7 @@ pub struct BoundedMutationAttribution { impl BoundedMutationAttribution { fn empty() -> Self { BoundedMutationAttribution { - result: MutationAttributionResult { - mutation_ai_patch: empty_patch(), - resolved_non_ai_patch: empty_patch(), - unresolved_patch: empty_patch(), - }, + result: empty_attribution(), loaded_pages: 0, loaded_rows: 0, inspected_events: 0, @@ -115,12 +110,19 @@ fn empty_patch() -> ParsedPatch { ParsedPatch { files: Vec::new() } } +fn empty_attribution() -> MutationAttributionResult { + MutationAttributionResult { + mutation_ai_patch: empty_patch(), + resolved_non_ai_patch: empty_patch(), + unresolved_patch: empty_patch(), + } +} + #[allow(clippy::too_many_arguments)] pub async 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, @@ -129,8 +131,7 @@ where P: MutationEventPageSource + ?Sized, R: TreeReadSource, { - let target = exclude_direct_coverage(committed_patch, direct_coverage); - let target_paths = target_logical_paths(&target); + let target_paths = target_logical_paths(committed_patch); if target_paths.is_empty() { return BoundedMutationAttribution::empty(); } @@ -150,7 +151,7 @@ where } else { Some(replay(&events, &target_paths, commit_tree, tree_source, &mut state).await) }; - finish(&target, lineage.as_ref(), &state, page_source).await + finish(committed_patch, lineage.as_ref(), &state, page_source).await } struct ReplayState { @@ -432,42 +433,39 @@ where } } -pub(crate) async fn resolve_post_commit_mutation_ai_patch( +pub(crate) async fn resolve_post_commit_mutation_attribution( repository_root: &Path, db: &mut RepositoryAgentTraceDb, - direct_coverage: &ParsedPatch, committed_patch: &ParsedPatch, -) -> ParsedPatch { +) -> MutationAttributionResult { let Ok(git_dir) = resolve_git_dir(repository_root).await else { - return empty_patch(); + return empty_attribution(); }; let Ok(worktree) = resolve_worktree_id(repository_root).await else { - return empty_patch(); + return empty_attribution(); }; let Ok(snapshot) = GitSnapshotService::new(repository_root).await else { - return empty_patch(); + return empty_attribution(); }; let Ok(commit_tree) = snapshot.head_tree().await else { - return empty_patch(); + return empty_attribution(); }; let store = MutationTraceStore::new(db); let Some(revision_ceiling) = capture_revision_cut(&git_dir, &store, &worktree).await else { - return empty_patch(); + return empty_attribution(); }; resolve_bounded_mutation_attribution( &store, &snapshot, &worktree, - direct_coverage, committed_patch, &commit_tree, Some(revision_ceiling), ) .await .result - .mutation_ai_patch } async fn capture_revision_cut( @@ -484,3 +482,7 @@ async fn capture_revision_cut( .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..66df631bd --- /dev/null +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs @@ -0,0 +1,385 @@ +use std::process::Command; + +use super::*; +use crate::services::agent_trace::{ + build_agent_trace_from_evidence, AgentTrace, AgentTraceEvidence, AgentTraceMetadataInput, + HunkContributor, +}; + +const PLAN_PATH: &str = "plan.md"; +const BOILERPLATE_LINE: &str = " - Verify:"; +const AUTHOR_SCOPE: &str = "scope-author"; +const AUTHOR_SESSION: &str = "session-author"; +const AUTHOR_MODEL: &str = "model-author"; +const OLD_SESSION: &str = "session-old"; +const OLD_MODEL: &str = "model-old"; +const WORKTREE: &str = "main"; +const COMMIT_TIMESTAMP: &str = "2026-11-06T12:23:44Z"; + +struct VecPages { + rows: Vec, +} + +impl MutationEventPageSource for VecPages { + async fn load_mutation_event_page( + &self, + _worktree: &WorktreeId, + revision_cursor: Option, + requested_limit: usize, + ) -> Result> { + let mut rows: Vec<_> = self + .rows + .iter() + .filter(|row| revision_cursor.is_none_or(|cursor| row.revision < cursor)) + .cloned() + .collect(); + rows.sort_by_key(|row| std::cmp::Reverse(row.revision)); + rows.truncate(requested_limit.min(MUTATION_ATTRIBUTION_PAGE_SIZE)); + Ok(rows) + } + + async fn load_scope_provenance(&self, scope_id: &ScopeId) -> Result> { + Ok((scope_id.0 == AUTHOR_SCOPE).then(|| ScopeProvenance { + scope_id: scope_id.clone(), + session_id: AUTHOR_SESSION.to_owned(), + model_id: Some(AUTHOR_MODEL.to_owned()), + })) + } +} + +struct TreeRepo { + dir: tempfile::TempDir, +} + +impl TreeRepo { + fn new() -> Self { + let dir = tempfile::tempdir().expect("temp dir"); + let repo = TreeRepo { dir }; + repo.git(&["init", "-q", "-b", "main"]); + repo + } + + fn root(&self) -> &Path { + self.dir.path() + } + + fn git(&self, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(self.root()) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .output() + .expect("git command should run"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("git output is UTF-8") + .trim() + .to_owned() + } + + fn tree(&self, plan: Option<&str>) -> TreeId { + let path = self.root().join(PLAN_PATH); + match plan { + Some(content) => std::fs::write(&path, content).expect("write plan"), + None => { + let _ = std::fs::remove_file(&path); + } + } + self.git(&["add", "-A"]); + TreeId(self.git(&["write-tree"])) + } + + async fn snapshot(&self) -> GitSnapshotService { + GitSnapshotService::new(self.root()) + .await + .expect("snapshot service") + } +} + +fn event(revision: u64, before: &TreeId, after: &TreeId, ai: bool) -> MutationEventPageRow { + MutationEventPageRow { + revision, + before_tree: before.clone(), + after_tree: after.clone(), + failure_kind: FailureKind::Healthy, + attribution_kind: if ai { + AttributionKind::AiExclusive + } else { + AttributionKind::IneligibleUnscoped + }, + attribution_scope_id: ai.then(|| ScopeId(AUTHOR_SCOPE.to_owned())), + } +} + +async fn committed_patch( + snapshot: &GitSnapshotService, + parent: &TreeId, + commit: &TreeId, +) -> ParsedPatch { + let text = snapshot.diff_trees(parent, commit).await.expect("diff"); + parse_patch(&text, None).expect("committed patch parses") +} + +async fn resolve( + repo: &TreeRepo, + rows: Vec, + committed: &ParsedPatch, + commit_tree: &TreeId, + ceiling: Option, +) -> BoundedMutationAttribution { + resolve_bounded_mutation_attribution( + &VecPages { rows }, + &repo.snapshot().await, + &WorktreeId(WORKTREE.to_owned()), + committed, + commit_tree, + ceiling, + ) + .await +} + +fn old_session_historical_match() -> ParsedPatch { + let mut patch = parse_patch( + &format!( + "diff --git a/{PLAN_PATH} b/{PLAN_PATH}\n--- a/{PLAN_PATH}\n+++ b/{PLAN_PATH}\n@@ -1,0 +1,1 @@\n+{BOILERPLATE_LINE}\n" + ), + Some(OLD_SESSION), + ) + .expect("historical candidate parses"); + patch.files[0].hunks[0].model_id = Some(OLD_MODEL.to_owned()); + patch +} + +fn build_trace( + committed: &ParsedPatch, + attribution: &MutationAttributionResult, + direct_patches: &[ParsedPatch], +) -> AgentTrace { + build_agent_trace_from_evidence( + AgentTraceEvidence { + direct_patches, + mutation_ai_patch: &attribution.mutation_ai_patch, + mutation_non_ai_patch: &attribution.resolved_non_ai_patch, + }, + committed, + AgentTraceMetadataInput { + commit_timestamp: COMMIT_TIMESTAMP, + commit_revision: "0000000000000000000000000000000000000001", + vcs_type: None, + tool_name: None, + tool_version: None, + }, + ) + .expect("agent trace builds") +} + +fn added_line_numbers(patch: &ParsedPatch) -> Vec { + patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .filter(|line| line.kind == TouchedLineKind::Added) + .map(|line| line.line_number) + .collect() +} + +fn assert_barrier_keeps_line_unknown( + attribution: &BoundedMutationAttribution, + committed: &ParsedPatch, +) { + assert!(added_line_numbers(&attribution.result.mutation_ai_patch).is_empty()); + assert_eq!( + added_line_numbers(&attribution.result.unresolved_patch), + vec![3] + ); + + let trace = build_trace( + committed, + &attribution.result, + &[old_session_historical_match()], + ); + let conversation = &trace.files[0].conversations[0]; + assert_eq!(conversation.contributor.kind, HunkContributor::Unknown); + assert_eq!(conversation.contributor.model_id, None); + assert_eq!(conversation.related, None); + let json = serde_json::to_string(&trace).expect("trace serializes"); + assert!(!json.contains(OLD_SESSION)); + assert!(!json.contains(OLD_MODEL)); +} + +const BASE: &str = "a\nb\n"; +const WITH_LINE: &str = "a\nb\n - Verify:\n"; + +#[tokio::test] +async fn an_observed_ai_event_attributes_the_line_over_an_old_historical_match() { + let repo = TreeRepo::new(); + let base = repo.tree(Some(BASE)); + let commit = repo.tree(Some(WITH_LINE)); + let snapshot = repo.snapshot().await; + let committed = committed_patch(&snapshot, &base, &commit).await; + + let attribution = resolve( + &repo, + vec![event(1, &base, &commit, true)], + &committed, + &commit, + Some(1), + ) + .await; + assert_eq!(attribution.barrier, None); + assert_eq!( + added_line_numbers(&attribution.result.mutation_ai_patch), + vec![3] + ); + + let trace = build_trace( + &committed, + &attribution.result, + &[old_session_historical_match()], + ); + let conversation = &trace.files[0].conversations[0]; + assert_eq!(conversation.contributor.kind, HunkContributor::Ai); + assert_eq!( + conversation.contributor.model_id.as_deref(), + Some(AUTHOR_MODEL) + ); + let related: Vec<_> = conversation + .related + .as_ref() + .expect("related sessions") + .iter() + .map(|related| related.url.clone()) + .collect(); + assert_eq!( + related, + vec![crate::services::agent_trace::agent_trace_session_url( + AUTHOR_SESSION + )] + ); +} + +#[tokio::test] +async fn ai_edits_to_a_deleted_and_recreated_file_keep_mutation_provenance() { + let repo = TreeRepo::new(); + let original = repo.tree(Some(BASE)); + let deleted = repo.tree(None); + let recreated = repo.tree(Some(BASE)); + let commit = repo.tree(Some(WITH_LINE)); + let snapshot = repo.snapshot().await; + let committed = committed_patch(&snapshot, &recreated, &commit).await; + + let attribution = resolve( + &repo, + vec![ + event(1, &original, &deleted, false), + event(2, &deleted, &recreated, false), + event(3, &recreated, &commit, true), + ], + &committed, + &commit, + Some(3), + ) + .await; + + assert_eq!(attribution.barrier, None); + assert_eq!(attribution.gap_resets, 0); + assert_eq!( + added_line_numbers(&attribution.result.mutation_ai_patch), + vec![3] + ); + assert!(added_line_numbers(&attribution.result.unresolved_patch).is_empty()); +} + +#[tokio::test] +async fn an_unobserved_tail_keeps_the_line_unknown() { + let repo = TreeRepo::new(); + let base = repo.tree(Some(BASE)); + let commit = repo.tree(Some(WITH_LINE)); + let snapshot = repo.snapshot().await; + let committed = committed_patch(&snapshot, &base, &commit).await; + + let attribution = resolve( + &repo, + vec![event(1, &base, &base, true)], + &committed, + &commit, + Some(1), + ) + .await; + + assert_eq!(attribution.barrier, None); + assert_barrier_keeps_line_unknown(&attribution, &committed); +} + +#[tokio::test] +async fn a_history_gap_keeps_the_line_unknown() { + let repo = TreeRepo::new(); + let base = repo.tree(Some(BASE)); + let observed = repo.tree(Some(WITH_LINE)); + let diverged = repo.tree(Some("a\nb\n - Verify:\nz\n")); + let snapshot = repo.snapshot().await; + let committed = committed_patch(&snapshot, &base, &observed).await; + + let attribution = resolve( + &repo, + vec![ + event(1, &base, &observed, true), + event(2, &diverged, &observed, false), + ], + &committed, + &observed, + Some(2), + ) + .await; + + assert_eq!(attribution.gap_resets, 1); + assert_barrier_keeps_line_unknown(&attribution, &committed); +} + +#[tokio::test] +async fn an_event_outside_the_replay_window_keeps_the_line_unknown() { + let repo = TreeRepo::new(); + let base = repo.tree(Some(BASE)); + let commit = repo.tree(Some(WITH_LINE)); + let snapshot = repo.snapshot().await; + let committed = committed_patch(&snapshot, &base, &commit).await; + + let newest = MAX_MUTATION_ATTRIBUTION_EVENTS as u64 + 1; + let mut rows = vec![event(1, &base, &commit, true)]; + rows.extend((2..=newest).map(|revision| event(revision, &commit, &commit, false))); + + let attribution = resolve(&repo, rows, &committed, &commit, Some(newest)).await; + + assert_eq!(attribution.loaded_rows, MAX_MUTATION_ATTRIBUTION_EVENTS); + assert_barrier_keeps_line_unknown(&attribution, &committed); +} + +#[tokio::test] +async fn an_event_beyond_the_revision_ceiling_keeps_the_line_unknown() { + let repo = TreeRepo::new(); + let base = repo.tree(Some(BASE)); + let commit = repo.tree(Some(WITH_LINE)); + let snapshot = repo.snapshot().await; + let committed = committed_patch(&snapshot, &base, &commit).await; + + let attribution = resolve( + &repo, + vec![ + event(1, &base, &base, false), + event(2, &base, &commit, true), + ], + &committed, + &commit, + Some(1), + ) + .await; + + assert_eq!(attribution.loaded_rows, 1); + assert_barrier_keeps_line_unknown(&attribution, &committed); +} diff --git a/cli/src/services/patch.rs b/cli/src/services/patch.rs index 60572dda6..370adf5db 100644 --- a/cli/src/services/patch.rs +++ b/cli/src/services/patch.rs @@ -145,175 +145,7 @@ pub fn load_patch_from_json(input: &str) -> Result }) } -/// Compute the touched-line intersection of two patches. -/// -/// Returns a `ParsedPatch` containing only the touched lines from -/// `post_commit_patch` that are also represented in `constructed_patch` for the -/// same logical file. Files are matched by their post-change path identity: -/// exact `new_path` equality, or an absolute path whose normalized path segments -/// end with the same relative path segments. -/// -/// Matching prefers exact touched-line identity (`kind`, `line_number`, and -/// `content`). When no exact match exists, it falls back to historical -/// reconstruction matching by `kind` and `content` only, which lets callers -/// compare a canonical post-commit patch against earlier incremental diffs even -/// when line numbers drift across intermediate edits. -/// -/// Files with no overlapping touched lines are excluded from the result. -/// Within matched files, hunks are reconstructed from the overlapping lines in -/// `post_commit_patch`, preserving `post_commit_patch` hunk range metadata -/// (`old_start`, `old_count`, `new_start`, `new_count`) so the result can be -/// compared directly to the canonical target patch. Hunk `model_id` provenance -/// is inherited from the matched `constructed_patch` hunk when available. The -/// output is deterministic: the same inputs always produce the same result. -/// -/// # Examples -/// -/// ``` -/// use sce::services::patch::{intersect_patches, parse_patch}; -/// -/// let constructed_patch = parse_patch("...", None)?; -/// let post_commit_patch = parse_patch("...", None)?; -/// let overlap = intersect_patches(&constructed_patch, &post_commit_patch); -/// ``` -pub fn intersect_patches( - constructed_patch: &ParsedPatch, - post_commit_patch: &ParsedPatch, -) -> ParsedPatch { - let mut result_files: Vec = Vec::new(); - - for post_commit_file in &post_commit_patch.files { - // Only consider files that also appear in `constructed_patch` by equivalent post-change path. - let Some(constructed_file) = constructed_patch.files.iter().find(|constructed_file| { - paths_refer_to_same_file(&constructed_file.new_path, &post_commit_file.new_path) - }) else { - continue; - }; - - let available_lines: Vec> = constructed_file - .hunks - .iter() - .flat_map(|hunk| { - hunk.lines.iter().map(move |line| AvailableTouchedLine { - line, - model_id: hunk.model_id.as_deref(), - }) - }) - .collect(); - let mut used_lines = vec![false; available_lines.len()]; - - // Filter hunks in `post_commit_file` to only include lines that are also - // represented in `constructed_file`, preferring exact line-number matches - // and falling back to same-kind/same-content historical matches when line - // numbers have drifted. - let mut result_hunks: Vec = Vec::new(); - for post_commit_hunk in &post_commit_file.hunks { - let mut matched_model_id: Option = None; - let overlapping_lines: Vec = post_commit_hunk - .lines - .iter() - .filter_map(|line| { - if let Some(index) = find_available_line_match( - &available_lines, - &used_lines, - line, - touched_lines_match_exact, - ) { - if matched_model_id.is_none() { - matched_model_id = available_lines[index].model_id.map(str::to_string); - } - used_lines[index] = true; - let mut overlapping_line = line.clone(); - overlapping_line - .session_id - .clone_from(&available_lines[index].line.session_id); - return Some(overlapping_line); - } - - if let Some(index) = find_available_line_match( - &available_lines, - &used_lines, - line, - touched_lines_match_historical, - ) { - if matched_model_id.is_none() { - matched_model_id = available_lines[index].model_id.map(str::to_string); - } - used_lines[index] = true; - let mut overlapping_line = line.clone(); - overlapping_line - .session_id - .clone_from(&available_lines[index].line.session_id); - return Some(overlapping_line); - } - - None - }) - .collect(); - - if overlapping_lines.is_empty() { - continue; - } - - result_hunks.push(PatchHunk { - old_start: post_commit_hunk.old_start, - old_count: post_commit_hunk.old_count, - new_start: post_commit_hunk.new_start, - new_count: post_commit_hunk.new_count, - model_id: matched_model_id, - lines: overlapping_lines, - }); - } - - if result_hunks.is_empty() - && !(post_commit_file.hunks.is_empty() && constructed_file.hunks.is_empty()) - { - continue; - } - - result_files.push(PatchFileChange { - old_path: post_commit_file.old_path.clone(), - new_path: post_commit_file.new_path.clone(), - kind: post_commit_file.kind, - hunks: result_hunks, - }); - } - - ParsedPatch { - files: result_files, - } -} - -struct AvailableTouchedLine<'a> { - line: &'a TouchedLine, - model_id: Option<&'a str>, -} - -fn find_available_line_match( - available_lines: &[AvailableTouchedLine<'_>], - used_lines: &[bool], - target: &TouchedLine, - matcher: fn(&TouchedLine, &TouchedLine) -> bool, -) -> Option { - available_lines - .iter() - .enumerate() - .find_map(|(index, candidate)| { - (!used_lines[index] && matcher(candidate.line, target)).then_some(index) - }) -} - -fn touched_lines_match_exact(candidate: &TouchedLine, target: &TouchedLine) -> bool { - candidate.kind == target.kind - && candidate.line_number == target.line_number - && candidate.content == target.content -} - -fn touched_lines_match_historical(candidate: &TouchedLine, target: &TouchedLine) -> bool { - candidate.kind == target.kind && candidate.content == target.content -} - -fn paths_refer_to_same_file(path_a: &str, path_b: &str) -> bool { +pub(crate) fn paths_refer_to_same_file(path_a: &str, path_b: &str) -> bool { if path_a == path_b { return true; } @@ -345,143 +177,6 @@ fn path_has_relative_suffix<'a>(full_path: &[&'a str], suffix_candidate: &[&'a s && !suffix_candidate.is_empty() } -/// Combine multiple patches into one deterministic result. -/// -/// Merges all file changes from the input patches, grouped by `new_path`. -/// When multiple patches touch the same file, touched-line entries are -/// deduplicated by identity (`kind`, `line_number`, `content`), with later -/// patches winning over earlier ones for the same identity. -/// -/// File metadata (`old_path`, `kind`) is also taken from the last patch -/// that contributed to each file. Hunk metadata is preserved from the -/// last patch that contributed each surviving touched line. -/// -/// Files appear in the result in the order they are first encountered -/// across the input patches. Within each file, hunks are ordered by -/// `old_start` and lines within each hunk are ordered by `line_number` -/// with `Removed` lines before `Added` lines at the same position. -/// -/// The result is deterministic: the same inputs in the same order always -/// produce the same output. -/// -/// # Examples -/// -/// ``` -/// use sce::services::patch::combine_patches; -/// -/// let combined = combine_patches(&[patch_a, patch_b]); -/// ``` -pub fn combine_patches(patches: &[ParsedPatch]) -> ParsedPatch { - use std::collections::HashMap; - - /// Touched-line identity key: (`kind`, `line_number`, `content`). - type LineKey = (TouchedLineKind, u64, String); - /// Hunk metadata key: (`old_start`, `old_count`, `new_start`, `new_count`). - type HunkMeta = (u64, u64, u64, u64); - /// Hunk provenance key: hunk metadata + source `model_id`. - type HunkGroupKey = (HunkMeta, Option); - - #[allow(clippy::type_complexity)] - struct FileAcc { - old_path: String, - kind: FileChangeKind, - lines: HashMap, - } - - let mut file_order: Vec = Vec::new(); - let mut files: HashMap = HashMap::new(); - - for patch in patches { - for file in &patch.files { - let acc = files.entry(file.new_path.clone()).or_insert_with(|| { - file_order.push(file.new_path.clone()); - FileAcc { - old_path: file.old_path.clone(), - kind: file.kind, - lines: HashMap::new(), - } - }); - // Later patch wins for file metadata. - acc.old_path.clone_from(&file.old_path); - acc.kind = file.kind; - - for hunk in &file.hunks { - let hunk_meta: HunkMeta = ( - hunk.old_start, - hunk.old_count, - hunk.new_start, - hunk.new_count, - ); - let hunk_group_key: HunkGroupKey = (hunk_meta, hunk.model_id.clone()); - for line in &hunk.lines { - let line_key = (line.kind, line.line_number, line.content.clone()); - acc.lines - .insert(line_key, (line.clone(), hunk_group_key.clone())); - } - } - } - } - - let mut result_files = Vec::new(); - - for path in file_order { - let acc = files.remove(&path).unwrap(); - - // Group surviving lines by their hunk metadata. - let mut hunk_groups: HashMap> = HashMap::new(); - for (_line_key, (line, hunk_group_key)) in acc.lines { - hunk_groups.entry(hunk_group_key).or_default().push(line); - } - - // Sort hunk groups by old_start for deterministic output. - let mut sorted_hunks: Vec<_> = hunk_groups.into_iter().collect(); - sorted_hunks.sort_by_key(|((meta, model_id), _)| { - (meta.0, meta.1, meta.2, meta.3, model_id.clone()) - }); - - let mut hunks = Vec::new(); - for ((meta, model_id), mut lines) in sorted_hunks { - // Sort lines within each hunk: by line_number, then Removed before - // Added, then by content for full determinism. - lines.sort_by(|a, b| { - a.line_number - .cmp(&b.line_number) - .then_with(|| { - let a_order = match a.kind { - TouchedLineKind::Removed => 0, - TouchedLineKind::Added => 1, - }; - let b_order = match b.kind { - TouchedLineKind::Removed => 0, - TouchedLineKind::Added => 1, - }; - a_order.cmp(&b_order) - }) - .then_with(|| a.content.cmp(&b.content)) - }); - hunks.push(PatchHunk { - old_start: meta.0, - old_count: meta.1, - new_start: meta.2, - new_count: meta.3, - model_id, - lines, - }); - } - - result_files.push(PatchFileChange { - old_path: acc.old_path, - new_path: path, - kind: acc.kind, - hunks, - }); - } - - ParsedPatch { - files: result_files, - } -} - /// Parse raw unified-diff text into a `ParsedPatch`. /// /// Supports both `Index:` (SVN-style) and `diff --git` (git-style) patch @@ -878,7 +573,3 @@ fn parse_range_part(s: &str, prefix: char) -> Result<(u64, u64), ParseError> { }; Ok((start, count)) } - -#[cfg(test)] -#[path = "patch/tests.rs"] -mod tests; diff --git a/cli/src/services/patch/fixtures/average_age_reconstruction/golden.json b/cli/src/services/patch/fixtures/average_age_reconstruction/golden.json deleted file mode 100644 index f0f1e1d84..000000000 --- a/cli/src/services/patch/fixtures/average_age_reconstruction/golden.json +++ /dev/null @@ -1,549 +0,0 @@ -{ - "files": [ - { - "old_path": "hunks/fib.ts", - "new_path": "hunks/fib.ts", - "kind": "modified", - "hunks": [ - { - "old_start": 1, - "old_count": 14, - "new_start": 1, - "new_count": 53, - "lines": [ - { - "kind": "removed", - "line_number": 1, - "content": "function fibonacci(n: number): number {" - }, - { - "kind": "removed", - "line_number": 2, - "content": " if (n <= 1) return n;" - }, - { - "kind": "added", - "line_number": 1, - "content": "type User = {" - }, - { - "kind": "added", - "line_number": 2, - "content": " age: number;" - }, - { - "kind": "added", - "line_number": 3, - "content": "};" - }, - { - "kind": "removed", - "line_number": 4, - "content": " let prev = 0;" - }, - { - "kind": "removed", - "line_number": 5, - "content": " let curr = 1;" - }, - { - "kind": "added", - "line_number": 5, - "content": "export function getAvarageAgeOptimized(users: User[]): number {" - }, - { - "kind": "added", - "line_number": 6, - "content": " if (users.length === 0) {" - }, - { - "kind": "added", - "line_number": 7, - "content": " return 0;" - }, - { - "kind": "added", - "line_number": 8, - "content": " }" - }, - { - "kind": "added", - "line_number": 9, - "content": "" - }, - { - "kind": "added", - "line_number": 10, - "content": " let totalAge = 0;" - }, - { - "kind": "added", - "line_number": 11, - "content": "" - }, - { - "kind": "added", - "line_number": 12, - "content": " for (const user of users) {" - }, - { - "kind": "added", - "line_number": 13, - "content": " totalAge += user.age;" - }, - { - "kind": "added", - "line_number": 14, - "content": " }" - }, - { - "kind": "added", - "line_number": 15, - "content": "" - }, - { - "kind": "added", - "line_number": 16, - "content": " return totalAge / users.length;" - }, - { - "kind": "added", - "line_number": 17, - "content": "}" - }, - { - "kind": "added", - "line_number": 18, - "content": "" - }, - { - "kind": "added", - "line_number": 19, - "content": "function fibonacciSequence(n: number): number[] {" - }, - { - "kind": "added", - "line_number": 20, - "content": " if (n <= 0) return [];" - }, - { - "kind": "added", - "line_number": 21, - "content": " if (n === 1) return [0];" - }, - { - "kind": "added", - "line_number": 22, - "content": "" - }, - { - "kind": "added", - "line_number": 23, - "content": " const sequence: number[] = [0, 1];" - }, - { - "kind": "added", - "line_number": 24, - "content": "" - }, - { - "kind": "added", - "line_number": 25, - "content": " for (let i = 2; i < n; i++) {" - }, - { - "kind": "added", - "line_number": 26, - "content": " sequence.push(sequence[i - 1] + sequence[i - 2]);" - }, - { - "kind": "added", - "line_number": 27, - "content": " }" - }, - { - "kind": "added", - "line_number": 28, - "content": "" - }, - { - "kind": "added", - "line_number": 29, - "content": " return sequence;" - }, - { - "kind": "added", - "line_number": 30, - "content": "}" - }, - { - "kind": "added", - "line_number": 31, - "content": "" - }, - { - "kind": "added", - "line_number": 32, - "content": "export function getAvarageAgeUnoptimized(users: User[]): number {" - }, - { - "kind": "added", - "line_number": 33, - "content": " if (users.length === 0) {" - }, - { - "kind": "added", - "line_number": 34, - "content": " // Return a safe default when there are no users." - }, - { - "kind": "added", - "line_number": 35, - "content": " return 0;" - }, - { - "kind": "added", - "line_number": 36, - "content": " }" - }, - { - "kind": "added", - "line_number": 37, - "content": "" - }, - { - "kind": "added", - "line_number": 38, - "content": " let totalAge = 0;" - }, - { - "kind": "added", - "line_number": 39, - "content": "" - }, - { - "kind": "added", - "line_number": 40, - "content": " for (let i = 0; i < users.length; i += 1) {" - }, - { - "kind": "added", - "line_number": 41, - "content": " // Intentionally restart summing from index 0 on every outer iteration." - }, - { - "kind": "added", - "line_number": 42, - "content": " let recalculatedTotal = 0;" - }, - { - "kind": "added", - "line_number": 43, - "content": "" - }, - { - "kind": "added", - "line_number": 44, - "content": " for (let j = 0; j <= i; j += 1) {" - }, - { - "kind": "added", - "line_number": 45, - "content": " recalculatedTotal += users[j].age;" - }, - { - "kind": "added", - "line_number": 46, - "content": " }" - }, - { - "kind": "removed", - "line_number": 7, - "content": " for (let i = 2; i <= n; i++) {" - }, - { - "kind": "removed", - "line_number": 8, - "content": " const next = prev + curr;" - }, - { - "kind": "removed", - "line_number": 9, - "content": " prev = curr;" - }, - { - "kind": "removed", - "line_number": 10, - "content": " curr = next;" - }, - { - "kind": "added", - "line_number": 48, - "content": " // Keep only the latest partial total; this adds extra work for no benefit." - }, - { - "kind": "added", - "line_number": 49, - "content": " totalAge = recalculatedTotal;" - }, - { - "kind": "removed", - "line_number": 13, - "content": " return curr;" - }, - { - "kind": "added", - "line_number": 52, - "content": " return totalAge / users.length;" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "hunks/optimized.ts", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 17, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "type User = {" - }, - { - "kind": "added", - "line_number": 2, - "content": " age: number;" - }, - { - "kind": "added", - "line_number": 3, - "content": "};" - }, - { - "kind": "added", - "line_number": 4, - "content": "" - }, - { - "kind": "added", - "line_number": 5, - "content": "export function getAvarageAgeOptimized(users: User[]): number {" - }, - { - "kind": "added", - "line_number": 6, - "content": " if (users.length === 0) {" - }, - { - "kind": "added", - "line_number": 7, - "content": " return 0;" - }, - { - "kind": "added", - "line_number": 8, - "content": " }" - }, - { - "kind": "added", - "line_number": 9, - "content": "" - }, - { - "kind": "added", - "line_number": 10, - "content": " let totalAge = 0;" - }, - { - "kind": "added", - "line_number": 11, - "content": "" - }, - { - "kind": "added", - "line_number": 12, - "content": " for (const user of users) {" - }, - { - "kind": "added", - "line_number": 13, - "content": " totalAge += user.age;" - }, - { - "kind": "added", - "line_number": 14, - "content": " }" - }, - { - "kind": "added", - "line_number": 15, - "content": "" - }, - { - "kind": "added", - "line_number": 16, - "content": " return totalAge / users.length;" - }, - { - "kind": "added", - "line_number": 17, - "content": "}" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "hunks/unoptimized.ts", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 26, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "type User = {" - }, - { - "kind": "added", - "line_number": 2, - "content": " age: number;" - }, - { - "kind": "added", - "line_number": 3, - "content": "};" - }, - { - "kind": "added", - "line_number": 4, - "content": "" - }, - { - "kind": "added", - "line_number": 5, - "content": "export function getAverageAgeUnoptimized(users: User[]): number {" - }, - { - "kind": "added", - "line_number": 6, - "content": " if (users.length === 0) {" - }, - { - "kind": "added", - "line_number": 7, - "content": " // Return a safe default when there are no users." - }, - { - "kind": "added", - "line_number": 8, - "content": " return 0;" - }, - { - "kind": "added", - "line_number": 9, - "content": " }" - }, - { - "kind": "added", - "line_number": 10, - "content": "" - }, - { - "kind": "added", - "line_number": 11, - "content": " let totalAge = 0;" - }, - { - "kind": "added", - "line_number": 12, - "content": "" - }, - { - "kind": "added", - "line_number": 13, - "content": " for (let i = 0; i < users.length; i += 1) {" - }, - { - "kind": "added", - "line_number": 14, - "content": " // Intentionally restart summing from index 0 on every outer iteration." - }, - { - "kind": "added", - "line_number": 15, - "content": " let recalculatedTotal = 0;" - }, - { - "kind": "added", - "line_number": 16, - "content": "" - }, - { - "kind": "added", - "line_number": 17, - "content": " for (let j = 0; j <= i; j += 1) {" - }, - { - "kind": "added", - "line_number": 18, - "content": " recalculatedTotal += users[j].age;" - }, - { - "kind": "added", - "line_number": 19, - "content": " }" - }, - { - "kind": "added", - "line_number": 20, - "content": "" - }, - { - "kind": "added", - "line_number": 21, - "content": " // Keep only the latest partial total; this adds extra work for no benefit." - }, - { - "kind": "added", - "line_number": 22, - "content": " totalAge = recalculatedTotal;" - }, - { - "kind": "added", - "line_number": 23, - "content": " }" - }, - { - "kind": "added", - "line_number": 24, - "content": "" - }, - { - "kind": "added", - "line_number": 25, - "content": " return totalAge / users.length;" - }, - { - "kind": "added", - "line_number": 26, - "content": "}" - } - ] - } - ] - } - ] -} diff --git a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_01.patch b/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_01.patch deleted file mode 100644 index d6027087a..000000000 --- a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_01.patch +++ /dev/null @@ -1,28 +0,0 @@ -Index: hunks/unoptimized.ts -=================================================================== ---- hunks/unoptimized.ts -+++ hunks/unoptimized.ts -@@ -0,0 +1,23 @@ -+type User = { -+ age: number; -+}; -+ -+export function getAverageAgeUnoptimized(users: User[]): number { -+ if (users.length === 0) { -+ return 0; -+ } -+ -+ let totalAge = 0; -+ -+ for (let i = 0; i < users.length; i += 1) { -+ let recalculatedTotal = 0; -+ -+ for (let j = 0; j <= i; j += 1) { -+ recalculatedTotal += users[j].age; -+ } -+ -+ totalAge = recalculatedTotal; -+ } -+ -+ return totalAge / users.length; -+} diff --git a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_02.patch b/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_02.patch deleted file mode 100644 index f3f3cca5f..000000000 --- a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_02.patch +++ /dev/null @@ -1,28 +0,0 @@ -Index: hunks/unoptimized.ts -=================================================================== ---- hunks/unoptimized.ts -+++ hunks/unoptimized.ts -@@ -3,20 +3,23 @@ - }; - - export function getAverageAgeUnoptimized(users: User[]): number { - if (users.length === 0) { -+ // Return a safe default when there are no users. - return 0; - } - - let totalAge = 0; - - for (let i = 0; i < users.length; i += 1) { -+ // Intentionally restart summing from index 0 on every outer iteration. - let recalculatedTotal = 0; - - for (let j = 0; j <= i; j += 1) { - recalculatedTotal += users[j].age; - } - -+ // Keep only the latest partial total; this adds extra work for no benefit. - totalAge = recalculatedTotal; - } - - return totalAge / users.length; diff --git a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_03.patch b/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_03.patch deleted file mode 100644 index 611f27b36..000000000 --- a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_03.patch +++ /dev/null @@ -1,25 +0,0 @@ -Index: hunks/optimized.ts -=================================================================== ---- hunks/optimized.ts -+++ hunks/optimized.ts -@@ -0,0 +1,20 @@ -+type User = { -+ age: number; -+}; -+ -+export function getAverageAgeUnoptimized(users: User[]): number { -+ if (users.length === 0) { -+ // Return a safe default when there are no users. -+ return 0; -+ } -+ -+ let totalAge = 0; -+ -+ // Sum ages in one pass to keep runtime linear. -+ for (const user of users) { -+ totalAge += user.age; -+ } -+ -+ // Divide once at the end to compute the average. -+ return totalAge / users.length; -+} diff --git a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_04.patch b/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_04.patch deleted file mode 100644 index 5a8460b14..000000000 --- a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_04.patch +++ /dev/null @@ -1,15 +0,0 @@ -Index: hunks/optimized.ts -=================================================================== ---- hunks/optimized.ts -+++ hunks/optimized.ts -@@ -1,9 +1,9 @@ - type User = { - age: number; - }; - --export function getAverageAgeUnoptimized(users: User[]): number { -+export function getAvarageAgeOptimized(users: User[]): number { - if (users.length === 0) { - // Return a safe default when there are no users. - return 0; - } diff --git a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_05.patch b/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_05.patch deleted file mode 100644 index 44de58ae3..000000000 --- a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_05.patch +++ /dev/null @@ -1,23 +0,0 @@ -Index: hunks/optimized.ts -=================================================================== ---- hunks/optimized.ts -+++ hunks/optimized.ts -@@ -3,18 +3,15 @@ - }; - - export function getAvarageAgeOptimized(users: User[]): number { - if (users.length === 0) { -- // Return a safe default when there are no users. - return 0; - } - - let totalAge = 0; - -- // Sum ages in one pass to keep runtime linear. - for (const user of users) { - totalAge += user.age; - } - -- // Divide once at the end to compute the average. - return totalAge / users.length; - } diff --git a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_06.patch b/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_06.patch deleted file mode 100644 index 153377c55..000000000 --- a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_06.patch +++ /dev/null @@ -1,55 +0,0 @@ -Index: hunks/fib.ts -=================================================================== ---- hunks/fib.ts -+++ hunks/fib.ts -@@ -1,4 +1,22 @@ -+type User = { -+ age: number; -+}; -+ -+export function getAvarageAgeOptimized(users: User[]): number { -+ if (users.length === 0) { -+ return 0; -+ } -+ -+ let totalAge = 0; -+ -+ for (const user of users) { -+ totalAge += user.age; -+ } -+ -+ return totalAge / users.length; -+} -+ - function fibonacci(n: number): number { - if (n <= 1) return n; - - let prev = 0; -@@ -11,4 +29,27 @@ - } - - return curr; - } -+ -+export function getAvarageAgeUnoptimized(users: User[]): number { -+ if (users.length === 0) { -+ // Return a safe default when there are no users. -+ return 0; -+ } -+ -+ let totalAge = 0; -+ -+ for (let i = 0; i < users.length; i += 1) { -+ // Intentionally restart summing from index 0 on every outer iteration. -+ let recalculatedTotal = 0; -+ -+ for (let j = 0; j <= i; j += 1) { -+ recalculatedTotal += users[j].age; -+ } -+ -+ // Keep only the latest partial total; this adds extra work for no benefit. -+ totalAge = recalculatedTotal; -+ } -+ -+ return totalAge / users.length; -+} diff --git a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_07.patch b/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_07.patch deleted file mode 100644 index 41f193e11..000000000 --- a/cli/src/services/patch/fixtures/average_age_reconstruction/incremental_07.patch +++ /dev/null @@ -1,33 +0,0 @@ -Index: hunks/fib.ts -=================================================================== ---- hunks/fib.ts -+++ hunks/fib.ts -@@ -15,21 +15,19 @@ - - return totalAge / users.length; - } - --function fibonacci(n: number): number { -- if (n <= 1) return n; -+function fibonacciSequence(n: number): number[] { -+ if (n <= 0) return []; -+ if (n === 1) return [0]; - -- let prev = 0; -- let curr = 1; -+ const sequence: number[] = [0, 1]; - -- for (let i = 2; i <= n; i++) { -- const next = prev + curr; -- prev = curr; -- curr = next; -+ for (let i = 2; i < n; i++) { -+ sequence.push(sequence[i - 1] + sequence[i - 2]); - } - -- return curr; -+ return sequence; - } - - export function getAvarageAgeUnoptimized(users: User[]): number { - if (users.length === 0) { diff --git a/cli/src/services/patch/fixtures/average_age_reconstruction/post_commit.patch b/cli/src/services/patch/fixtures/average_age_reconstruction/post_commit.patch deleted file mode 100644 index 4e5a3ca00..000000000 --- a/cli/src/services/patch/fixtures/average_age_reconstruction/post_commit.patch +++ /dev/null @@ -1,122 +0,0 @@ -diff --git a/hunks/fib.ts b/hunks/fib.ts -index a6f80dd..42c5fd8 100644 ---- a/hunks/fib.ts -+++ b/hunks/fib.ts -@@ -1,14 +1,53 @@ --function fibonacci(n: number): number { -- if (n <= 1) return n; -+type User = { -+ age: number; -+}; - -- let prev = 0; -- let curr = 1; -+export function getAvarageAgeOptimized(users: User[]): number { -+ if (users.length === 0) { -+ return 0; -+ } -+ -+ let totalAge = 0; -+ -+ for (const user of users) { -+ totalAge += user.age; -+ } -+ -+ return totalAge / users.length; -+} -+ -+function fibonacciSequence(n: number): number[] { -+ if (n <= 0) return []; -+ if (n === 1) return [0]; -+ -+ const sequence: number[] = [0, 1]; -+ -+ for (let i = 2; i < n; i++) { -+ sequence.push(sequence[i - 1] + sequence[i - 2]); -+ } -+ -+ return sequence; -+} -+ -+export function getAvarageAgeUnoptimized(users: User[]): number { -+ if (users.length === 0) { -+ // Return a safe default when there are no users. -+ return 0; -+ } -+ -+ let totalAge = 0; -+ -+ for (let i = 0; i < users.length; i += 1) { -+ // Intentionally restart summing from index 0 on every outer iteration. -+ let recalculatedTotal = 0; -+ -+ for (let j = 0; j <= i; j += 1) { -+ recalculatedTotal += users[j].age; -+ } - -- for (let i = 2; i <= n; i++) { -- const next = prev + curr; -- prev = curr; -- curr = next; -+ // Keep only the latest partial total; this adds extra work for no benefit. -+ totalAge = recalculatedTotal; - } - -- return curr; -+ return totalAge / users.length; - } -diff --git a/hunks/optimized.ts b/hunks/optimized.ts -new file mode 100644 -index 0000000..be64927 ---- /dev/null -+++ b/hunks/optimized.ts -@@ -0,0 +1,17 @@ -+type User = { -+ age: number; -+}; -+ -+export function getAvarageAgeOptimized(users: User[]): number { -+ if (users.length === 0) { -+ return 0; -+ } -+ -+ let totalAge = 0; -+ -+ for (const user of users) { -+ totalAge += user.age; -+ } -+ -+ return totalAge / users.length; -+} -diff --git a/hunks/unoptimized.ts b/hunks/unoptimized.ts -new file mode 100644 -index 0000000..447486c ---- /dev/null -+++ b/hunks/unoptimized.ts -@@ -0,0 +1,26 @@ -+type User = { -+ age: number; -+}; -+ -+export function getAverageAgeUnoptimized(users: User[]): number { -+ if (users.length === 0) { -+ // Return a safe default when there are no users. -+ return 0; -+ } -+ -+ let totalAge = 0; -+ -+ for (let i = 0; i < users.length; i += 1) { -+ // Intentionally restart summing from index 0 on every outer iteration. -+ let recalculatedTotal = 0; -+ -+ for (let j = 0; j <= i; j += 1) { -+ recalculatedTotal += users[j].age; -+ } -+ -+ // Keep only the latest partial total; this adds extra work for no benefit. -+ totalAge = recalculatedTotal; -+ } -+ -+ return totalAge / users.length; -+} diff --git a/cli/src/services/patch/fixtures/file_rename_reconstruction/golden.json b/cli/src/services/patch/fixtures/file_rename_reconstruction/golden.json deleted file mode 100644 index c30b266e6..000000000 --- a/cli/src/services/patch/fixtures/file_rename_reconstruction/golden.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "files": [ - { - "old_path": ".version", - "new_path": ".very_cool_version_file", - "kind": "renamed", - "hunks": [] - } - ] -} diff --git a/cli/src/services/patch/fixtures/file_rename_reconstruction/incremental_01.patch b/cli/src/services/patch/fixtures/file_rename_reconstruction/incremental_01.patch deleted file mode 100644 index 02d997e84..000000000 --- a/cli/src/services/patch/fixtures/file_rename_reconstruction/incremental_01.patch +++ /dev/null @@ -1,4 +0,0 @@ -diff --git a/.version b/.very_cool_version_file -similarity index 100% -rename from .version -rename to .very_cool_version_file diff --git a/cli/src/services/patch/fixtures/file_rename_reconstruction/post_commit.patch b/cli/src/services/patch/fixtures/file_rename_reconstruction/post_commit.patch deleted file mode 100644 index 02d997e84..000000000 --- a/cli/src/services/patch/fixtures/file_rename_reconstruction/post_commit.patch +++ /dev/null @@ -1,4 +0,0 @@ -diff --git a/.version b/.very_cool_version_file -similarity index 100% -rename from .version -rename to .very_cool_version_file diff --git a/cli/src/services/patch/fixtures/hello_world_reconstruction/golden.json b/cli/src/services/patch/fixtures/hello_world_reconstruction/golden.json deleted file mode 100644 index e5760248d..000000000 --- a/cli/src/services/patch/fixtures/hello_world_reconstruction/golden.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "files": [ - { - "old_path": "", - "new_path": "hunks/hello.ts", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 5, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "function helloWorld(): void {" - }, - { - "kind": "added", - "line_number": 2, - "content": " console.log(\"Hello World\");" - }, - { - "kind": "added", - "line_number": 3, - "content": "}" - }, - { - "kind": "added", - "line_number": 4, - "content": "" - }, - { - "kind": "added", - "line_number": 5, - "content": "helloWorld();" - } - ] - } - ] - } - ] -} diff --git a/cli/src/services/patch/fixtures/hello_world_reconstruction/incremental_01.patch b/cli/src/services/patch/fixtures/hello_world_reconstruction/incremental_01.patch deleted file mode 100644 index 4a44d5ef5..000000000 --- a/cli/src/services/patch/fixtures/hello_world_reconstruction/incremental_01.patch +++ /dev/null @@ -1,10 +0,0 @@ -Index: hunks/hello.ts -=================================================================== ---- hunks/hello.ts -+++ hunks/hello.ts -@@ -0,0 +1,5 @@ -+function helloWorld(): void { -+ console.log("Hello World"); -+} -+ -+helloWorld(); diff --git a/cli/src/services/patch/fixtures/hello_world_reconstruction/post_commit.patch b/cli/src/services/patch/fixtures/hello_world_reconstruction/post_commit.patch deleted file mode 100644 index f1cca2af3..000000000 --- a/cli/src/services/patch/fixtures/hello_world_reconstruction/post_commit.patch +++ /dev/null @@ -1,11 +0,0 @@ -diff --git a/hunks/hello.ts b/hunks/hello.ts -new file mode 100644 -index 0000000..45237ee ---- /dev/null -+++ b/hunks/hello.ts -@@ -0,0 +1,5 @@ -+function helloWorld(): void { -+ console.log("Hello World"); -+} -+ -+helloWorld(); diff --git a/cli/src/services/patch/fixtures/line_and_file_deletion_reconstruction/golden.json b/cli/src/services/patch/fixtures/line_and_file_deletion_reconstruction/golden.json deleted file mode 100644 index d5ecc6052..000000000 --- a/cli/src/services/patch/fixtures/line_and_file_deletion_reconstruction/golden.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "files": [ - { - "old_path": "", - "new_path": "first.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 2, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "one" - }, - { - "kind": "added", - "line_number": 2, - "content": "three" - } - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/cli/src/services/patch/fixtures/mixed_change_reconstruction/golden.json b/cli/src/services/patch/fixtures/mixed_change_reconstruction/golden.json deleted file mode 100644 index 081e12641..000000000 --- a/cli/src/services/patch/fixtures/mixed_change_reconstruction/golden.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "files": [ - { - "old_path": ".version", - "new_path": "", - "kind": "deleted", - "hunks": [ - { - "old_start": 1, - "old_count": 1, - "new_start": 0, - "new_count": 0, - "lines": [ - { - "kind": "removed", - "line_number": 1, - "content": "0.2.0" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "empty_poem_ai.md", - "kind": "added", - "hunks": [] - }, - { - "old_path": "", - "new_path": "poem_life.md", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 4, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "Life is a road of lift and bend," - }, - { - "kind": "added", - "line_number": 2, - "content": "Bright starts, hard stops, and mends we send." - }, - { - "kind": "added", - "line_number": 3, - "content": "We fall, we rise, we find our way," - }, - { - "kind": "added", - "line_number": 4, - "content": "And grow a little more each day." - } - ] - } - ] - }, - { - "old_path": "poem_people.md", - "new_path": "poem_people.md", - "kind": "modified", - "hunks": [ - { - "old_start": 1, - "old_count": 4, - "new_start": 1, - "new_count": 4, - "lines": [ - { - "kind": "removed", - "line_number": 4, - "content": "Connected close, we lift and try." - }, - { - "kind": "added", - "line_number": 4, - "content": "Conected close, we lift and try." - } - ] - } - ] - } - ] -} diff --git a/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_01.patch b/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_01.patch deleted file mode 100644 index fd0d3d968..000000000 --- a/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_01.patch +++ /dev/null @@ -1,12 +0,0 @@ -Index: poem_people.md -=================================================================== ---- poem_people.md -+++ poem_people.md -@@ -1,4 +1,4 @@ - Hands that build and hearts that care, - Voices rising everywhere. - Strangers met beneath the sky, --Connected close, we lift and try. -\ No newline at end of file -+Conected close, we lift and try. -\ No newline at end of file diff --git a/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_02.patch b/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_02.patch deleted file mode 100644 index a24d28431..000000000 --- a/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_02.patch +++ /dev/null @@ -1,4 +0,0 @@ -Index: empty_poem_ai.md -=================================================================== ---- empty_poem_ai.md -+++ empty_poem_ai.md diff --git a/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_03.patch b/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_03.patch deleted file mode 100644 index af3d39203..000000000 --- a/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_03.patch +++ /dev/null @@ -1,6 +0,0 @@ -Index: .version -=================================================================== ---- .version -+++ .version -@@ -1,1 +0,0 @@ --0.2.0 diff --git a/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_04.patch b/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_04.patch deleted file mode 100644 index dfe375998..000000000 --- a/cli/src/services/patch/fixtures/mixed_change_reconstruction/incremental_04.patch +++ /dev/null @@ -1,9 +0,0 @@ -Index: poem_life.md -=================================================================== ---- poem_life.md -+++ poem_life.md -@@ -0,0 +1,4 @@ -+Life is a road of lift and bend, -+Bright starts, hard stops, and mends we send. -+We fall, we rise, we find our way, -+And grow a little more each day. diff --git a/cli/src/services/patch/fixtures/mixed_change_reconstruction/post_commit.patch b/cli/src/services/patch/fixtures/mixed_change_reconstruction/post_commit.patch deleted file mode 100644 index 73f63cb05..000000000 --- a/cli/src/services/patch/fixtures/mixed_change_reconstruction/post_commit.patch +++ /dev/null @@ -1,65 +0,0 @@ -diff --git a/.version b/.version -deleted file mode 100644 -index 0ea3a94..0000000 ---- a/.version -+++ /dev/null -@@ -1 +0,0 @@ --0.2.0 -diff --git a/cli/src/services/patch/fixtures/poem_edit_reconstruction/incremental_01.patch b/cli/src/services/patch/fixtures/poem_edit_reconstruction/incremental_01.patch -deleted file mode 100644 -index 25fc540..0000000 ---- a/cli/src/services/patch/fixtures/poem_edit_reconstruction/incremental_01.patch -+++ /dev/null -@@ -1,14 +0,0 @@ --Index: poem.md --=================================================================== ----- poem.md --+++ poem.md --@@ -1,8 +1,8 @@ -- # Q Moment of Code -- -- In circuits deep where logic flows, ---A thousand silent processes glow, --+Q thousand silent processes glow, -- Through paths of thought, both near and far, -- We build the world the way things are. -- -- Each line we write, each function named, -diff --git a/empty_poem_ai.md b/empty_poem_ai.md -new file mode 100644 -index 0000000..e69de29 -diff --git a/empty_poem_human.md b/empty_poem_human.md -new file mode 100644 -index 0000000..e69de29 -diff --git a/poem_life.md b/poem_life.md -new file mode 100644 -index 0000000..1778f01 ---- /dev/null -+++ b/poem_life.md -@@ -0,0 +1,4 @@ -+Life is a road of lift and bend, -+Bright starts, hard stops, and mends we send. -+We fall, we rise, we find our way, -+And grow a little more each day. -diff --git a/poem_nature.md b/poem_nature.md -index 8d72018..928c13c 100644 ---- a/poem_nature.md -+++ b/poem_nature.md -@@ -1,4 +1,4 @@ --Whispering pines in morning light, -+whispering pines in morning light, - A silver stream runs clear and bright. - Wildflowers sway where soft winds roam, - And every leaf feels close to home. -diff --git a/poem_people.md b/poem_people.md -index 14e921e..8dc5c7d 100644 ---- a/poem_people.md -+++ b/poem_people.md -@@ -1,4 +1,4 @@ - Hands that build and hearts that care, - Voices rising everywhere. - Strangers met beneath the sky, --Connected close, we lift and try. -\ No newline at end of file -+Conected close, we lift and try. -\ No newline at end of file diff --git a/cli/src/services/patch/fixtures/poem_edit_reconstruction/golden.json b/cli/src/services/patch/fixtures/poem_edit_reconstruction/golden.json deleted file mode 100644 index cd5eb0e30..000000000 --- a/cli/src/services/patch/fixtures/poem_edit_reconstruction/golden.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "files": [ - { - "old_path": "poem.md", - "new_path": "poem.md", - "kind": "modified", - "hunks": [ - { - "old_start": 1, - "old_count": 8, - "new_start": 1, - "new_count": 8, - "lines": [ - { - "kind": "removed", - "line_number": 4, - "content": "A thousand silent processes glow," - }, - { - "kind": "added", - "line_number": 4, - "content": "Q thousand silent processes glow," - } - ] - }, - { - "old_start": 21, - "old_count": 4, - "new_start": 21, - "new_count": 4, - "lines": [ - { - "kind": "removed", - "line_number": 24, - "content": "*Written with ❤️ and a touch of caffeine*" - }, - { - "kind": "added", - "line_number": 24, - "content": "*Written with ❤️ and a touch of caffeinw*" - } - ] - } - ] - } - ] -} diff --git a/cli/src/services/patch/fixtures/poem_edit_reconstruction/incremental_01.patch b/cli/src/services/patch/fixtures/poem_edit_reconstruction/incremental_01.patch deleted file mode 100644 index 25fc54049..000000000 --- a/cli/src/services/patch/fixtures/poem_edit_reconstruction/incremental_01.patch +++ /dev/null @@ -1,14 +0,0 @@ -Index: poem.md -=================================================================== ---- poem.md -+++ poem.md -@@ -1,8 +1,8 @@ - # Q Moment of Code - - In circuits deep where logic flows, --A thousand silent processes glow, -+Q thousand silent processes glow, - Through paths of thought, both near and far, - We build the world the way things are. - - Each line we write, each function named, diff --git a/cli/src/services/patch/fixtures/poem_edit_reconstruction/incremental_02.patch b/cli/src/services/patch/fixtures/poem_edit_reconstruction/incremental_02.patch deleted file mode 100644 index 4e8f83170..000000000 --- a/cli/src/services/patch/fixtures/poem_edit_reconstruction/incremental_02.patch +++ /dev/null @@ -1,11 +0,0 @@ -Index: poem.md -=================================================================== ---- poem.md -+++ poem.md -@@ -20,5 +20,5 @@ - And in this dance of bit and byte, - We shape tomorrow's morning light. - - --- --*Written with ❤️ and a touch of caffeine* -+*Written with ❤️ and a touch of caffeinw* diff --git a/cli/src/services/patch/fixtures/poem_edit_reconstruction/post_commit.patch b/cli/src/services/patch/fixtures/poem_edit_reconstruction/post_commit.patch deleted file mode 100644 index be21445eb..000000000 --- a/cli/src/services/patch/fixtures/poem_edit_reconstruction/post_commit.patch +++ /dev/null @@ -1,31 +0,0 @@ -diff --git a/poem.md b/poem.md -index a930e11..d8cf41a 100644 ---- a/poem.md -+++ b/poem.md -@@ -1,8 +1,8 @@ --# A Moment of Code -+# Q Moment of Code - - In circuits deep where logic flows, --A thousand silent processes glow, --Through paths of thought, both near and far, -+Q thousand silent processes glow, -+Through patho of thought, both near and far, - We build the world the way things are. - - Each line we write, each function named, -@@ -10,7 +10,7 @@ A story told, a problem tamed, - The compiler hums, the tests turn green, - In spaces where the code is clean. - --We dream in loops of endless while, -+We dream in lowops of endless while, - Yet find in bugs a reason to smile, - Forth every error teaches true, - The better paths to journey through. -@@ -21,4 +21,4 @@ And in this dance of bit and byte, - We shape tomorrow's morning light. - - --- --*Written with ❤️ and a touch of caffeine* -+*Written with ❤️ and a touch of caffeinw* diff --git a/cli/src/services/patch/fixtures/poem_write_reconstruction/golden.json b/cli/src/services/patch/fixtures/poem_write_reconstruction/golden.json deleted file mode 100644 index ee9cbdf00..000000000 --- a/cli/src/services/patch/fixtures/poem_write_reconstruction/golden.json +++ /dev/null @@ -1,134 +0,0 @@ -{ - "files": [ - { - "old_path": "", - "new_path": "poem.md", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 24, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "# A Moment of Code" - }, - { - "kind": "added", - "line_number": 2, - "content": "" - }, - { - "kind": "added", - "line_number": 3, - "content": "In circuits deep where logic flows," - }, - { - "kind": "added", - "line_number": 4, - "content": "A thousand silent processes glow," - }, - { - "kind": "added", - "line_number": 5, - "content": "Through paths of thought, both near and far," - }, - { - "kind": "added", - "line_number": 6, - "content": "We build the world the way things are." - }, - { - "kind": "added", - "line_number": 7, - "content": "" - }, - { - "kind": "added", - "line_number": 8, - "content": "Each line we write, each function named," - }, - { - "kind": "added", - "line_number": 9, - "content": "A story told, a problem tamed," - }, - { - "kind": "added", - "line_number": 10, - "content": "The compiler hums, the tests turn green," - }, - { - "kind": "added", - "line_number": 11, - "content": "In spaces where the code is clean." - }, - { - "kind": "added", - "line_number": 12, - "content": "" - }, - { - "kind": "added", - "line_number": 13, - "content": "We dream in loops of endless while," - }, - { - "kind": "added", - "line_number": 14, - "content": "Yet find in bugs a reason to smile," - }, - { - "kind": "added", - "line_number": 16, - "content": "The better paths to journey through." - }, - { - "kind": "added", - "line_number": 17, - "content": "" - }, - { - "kind": "added", - "line_number": 18, - "content": "So let us craft with care and art," - }, - { - "kind": "added", - "line_number": 19, - "content": "The systems that connect each heart," - }, - { - "kind": "added", - "line_number": 20, - "content": "And in this dance of bit and byte," - }, - { - "kind": "added", - "line_number": 21, - "content": "We shape tomorrow's morning light." - }, - { - "kind": "added", - "line_number": 22, - "content": "" - }, - { - "kind": "added", - "line_number": 23, - "content": "---" - }, - { - "kind": "added", - "line_number": 24, - "content": "*Written with ❤️ and a touch of caffeine*" - } - ] - } - ] - } - ] -} diff --git a/cli/src/services/patch/fixtures/poem_write_reconstruction/incremental_01.patch b/cli/src/services/patch/fixtures/poem_write_reconstruction/incremental_01.patch deleted file mode 100644 index 2350f278e..000000000 --- a/cli/src/services/patch/fixtures/poem_write_reconstruction/incremental_01.patch +++ /dev/null @@ -1,29 +0,0 @@ -Index: poem.md -=================================================================== ---- poem.md -+++ poem.md -@@ -0,0 +1,24 @@ -+# A Moment of Code -+ -+In circuits deep where logic flows, -+A thousand silent processes glow, -+Through paths of thought, both near and far, -+We build the world the way things are. -+ -+Each line we write, each function named, -+A story told, a problem tamed, -+The compiler hums, the tests turn green, -+In spaces where the code is clean. -+ -+We dream in loops of endless while, -+Yet find in bugs a reason to smile, -+For every error teaches true, -+The better paths to journey through. -+ -+So let us craft with care and art, -+The systems that connect each heart, -+And in this dance of bit and byte, -+We shape tomorrow's morning light. -+ -+--- -+*Written with ❤️ and a touch of caffeine* diff --git a/cli/src/services/patch/fixtures/poem_write_reconstruction/post_commit.patch b/cli/src/services/patch/fixtures/poem_write_reconstruction/post_commit.patch deleted file mode 100644 index bd31b61b4..000000000 --- a/cli/src/services/patch/fixtures/poem_write_reconstruction/post_commit.patch +++ /dev/null @@ -1,30 +0,0 @@ -diff --git a/poem.md b/poem.md -new file mode 100644 -index 0000000..a930e11 ---- /dev/null -+++ b/poem.md -@@ -0,0 +1,24 @@ -+# A Moment of Code -+ -+In circuits deep where logic flows, -+A thousand silent processes glow, -+Through paths of thought, both near and far, -+We build the world the way things are. -+ -+Each line we write, each function named, -+A story told, a problem tamed, -+The compiler hums, the tests turn green, -+In spaces where the code is clean. -+ -+We dream in loops of endless while, -+Yet find in bugs a reason to smile, -+Forth every error teaches true, -+The better paths to journey through. -+ -+So let us craft with care and art, -+The systems that connect each heart, -+And in this dance of bit and byte, -+We shape tomorrow's morning light. -+ -+--- -+*Written with ❤️ and a touch of caffeine* diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/golden.json b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/golden.json deleted file mode 100644 index f0bc1e69d..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/golden.json +++ /dev/null @@ -1,549 +0,0 @@ -{ - "files": [ - { - "old_path": "", - "new_path": "animals.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 3, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "fox" - }, - { - "kind": "added", - "line_number": 2, - "content": "lynx" - }, - { - "kind": "added", - "line_number": 3, - "content": "bear" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "cleanup.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 6, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "one" - }, - { - "kind": "added", - "line_number": 2, - "content": "three" - }, - { - "kind": "added", - "line_number": 3, - "content": "four" - }, - { - "kind": "added", - "line_number": 4, - "content": "five" - }, - { - "kind": "added", - "line_number": 5, - "content": "six" - }, - { - "kind": "added", - "line_number": 6, - "content": "eight" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "colors.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 4, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "red" - }, - { - "kind": "added", - "line_number": 2, - "content": "emerald" - }, - { - "kind": "added", - "line_number": 3, - "content": "blue" - }, - { - "kind": "added", - "line_number": 4, - "content": "yellow" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "flowers.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 3, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "rose" - }, - { - "kind": "added", - "line_number": 2, - "content": "orchid" - }, - { - "kind": "added", - "line_number": 3, - "content": "tulip" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "list.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 6, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "alpha" - }, - { - "kind": "added", - "line_number": 2, - "content": "beta-updated" - }, - { - "kind": "added", - "line_number": 3, - "content": "gamma" - }, - { - "kind": "added", - "line_number": 4, - "content": "delta-updated" - }, - { - "kind": "added", - "line_number": 5, - "content": "epsilon" - }, - { - "kind": "added", - "line_number": 6, - "content": "zeta-updated" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "notes.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 7, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "Line 1: apples" - }, - { - "kind": "added", - "line_number": 2, - "content": "Line 2: pears" - }, - { - "kind": "added", - "line_number": 3, - "content": "Line 3: plums" - }, - { - "kind": "added", - "line_number": 4, - "content": "Line 4: grapes" - }, - { - "kind": "added", - "line_number": 5, - "content": "Line 5: oranges" - }, - { - "kind": "added", - "line_number": 6, - "content": "Line 6: bananas" - }, - { - "kind": "added", - "line_number": 7, - "content": "Line 7: cherries" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "poem-one.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 4, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "Morning light spills on the floor." - }, - { - "kind": "added", - "line_number": 2, - "content": "A quiet bird waits by the tree." - }, - { - "kind": "added", - "line_number": 3, - "content": "Nothing asks for more today." - }, - { - "kind": "added", - "line_number": 4, - "content": "The world is slow and clear." - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "rain.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 4, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "Rain taps softly on the glass." - }, - { - "kind": "added", - "line_number": 2, - "content": "Clouds erase the distant hill." - }, - { - "kind": "added", - "line_number": 3, - "content": "Every road becomes a mirror." - }, - { - "kind": "added", - "line_number": 4, - "content": "Everything is calm and still." - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "replace-me.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 3, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "New line A" - }, - { - "kind": "added", - "line_number": 2, - "content": "New line B" - }, - { - "kind": "added", - "line_number": 3, - "content": "New line C" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "sentence.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 1, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "The striped cat sleeps near the window." - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "single-letter.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 1, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "colour" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "story.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 11, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "Chapter 1" - }, - { - "kind": "added", - "line_number": 2, - "content": "The road was silent." - }, - { - "kind": "added", - "line_number": 3, - "content": "The sun was low." - }, - { - "kind": "added", - "line_number": 4, - "content": "" - }, - { - "kind": "added", - "line_number": 5, - "content": "Chapter 2" - }, - { - "kind": "added", - "line_number": 6, - "content": "A cart moved slowly." - }, - { - "kind": "added", - "line_number": 7, - "content": "The horses were tired." - }, - { - "kind": "added", - "line_number": 8, - "content": "" - }, - { - "kind": "added", - "line_number": 9, - "content": "Chapter 3" - }, - { - "kind": "added", - "line_number": 10, - "content": "Night came early." - }, - { - "kind": "added", - "line_number": 11, - "content": "The village kept its lamps lit." - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "sun.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 4, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "Sun above the empty street." - }, - { - "kind": "added", - "line_number": 2, - "content": "Warmth arrives before the sound." - }, - { - "kind": "added", - "line_number": 3, - "content": "Windows open one by one." - }, - { - "kind": "added", - "line_number": 4, - "content": "Morning settles on the town." - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "tasks.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 3, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "task-a" - }, - { - "kind": "added", - "line_number": 2, - "content": "task-b" - }, - { - "kind": "added", - "line_number": 3, - "content": "task-d" - } - ] - } - ] - }, - { - "old_path": "", - "new_path": "wind.txt", - "kind": "added", - "hunks": [ - { - "old_start": 0, - "old_count": 0, - "new_start": 1, - "new_count": 4, - "lines": [ - { - "kind": "added", - "line_number": 1, - "content": "Wind moves lightly through the leaves." - }, - { - "kind": "added", - "line_number": 2, - "content": "Branches bend but do not break." - }, - { - "kind": "added", - "line_number": 3, - "content": "Air carries the scent of evening." - }, - { - "kind": "added", - "line_number": 4, - "content": "Day prepares to fade away." - } - ] - } - ] - } - ] -} diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_01.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_01.patch deleted file mode 100644 index d0161b539..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_01.patch +++ /dev/null @@ -1,10 +0,0 @@ -Index: poem-one.txt -=================================================================== ---- poem-one.txt -+++ poem-one.txt -@@ -0,0 +1,4 @@ -+Morning light spills on the floor. -+A quiet bird waits by the tree. -+Nothing asks for more today. -+The world is slow and clear. - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_02.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_02.patch deleted file mode 100644 index e4d9bbf17..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_02.patch +++ /dev/null @@ -1,30 +0,0 @@ -Index: sun.txt -=================================================================== ---- sun.txt -+++ sun.txt -@@ -0,0 +1,4 @@ -+Sun above the empty street. -+Warmth arrives before the sound. -+Windows open one by one. -+Morning settles on the town. - -Index: rain.txt -=================================================================== ---- rain.txt -+++ rain.txt -@@ -0,0 +1,4 @@ -+Rain taps softly on the glass. -+Clouds erase the distant hill. -+Every road becomes a mirror. -+Everything is calm and still. - -Index: wind.txt -=================================================================== ---- wind.txt -+++ wind.txt -@@ -0,0 +1,4 @@ -+Wind moves lightly through the leaves. -+Branches bend but do not break. -+Air carries the scent of evening. -+Day prepares to fade away. - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_03.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_03.patch deleted file mode 100644 index 9ef36e431..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_03.patch +++ /dev/null @@ -1,10 +0,0 @@ -Index: notes.txt -=================================================================== ---- notes.txt -+++ notes.txt -@@ -0,0 +1,4 @@ -+Line 1: apples -+Line 2: pears -+Line 3: plums -+Line 4: grapes - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_04.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_04.patch deleted file mode 100644 index 4d6210452..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_04.patch +++ /dev/null @@ -1,13 +0,0 @@ -Index: notes.txt -=================================================================== ---- notes.txt -+++ notes.txt -@@ -1,4 +1,7 @@ - Line 1: apples - Line 2: pears - Line 3: plums - Line 4: grapes -+Line 5: oranges -+Line 6: bananas -+Line 7: cherries - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_05.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_05.patch deleted file mode 100644 index 5652a60f1..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_05.patch +++ /dev/null @@ -1,10 +0,0 @@ -Index: colors.txt -=================================================================== ---- colors.txt -+++ colors.txt -@@ -0,0 +1,4 @@ -+red -+green -+blue -+yellow - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_06.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_06.patch deleted file mode 100644 index c07886a42..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_06.patch +++ /dev/null @@ -1,11 +0,0 @@ -Index: colors.txt -=================================================================== ---- colors.txt -+++ colors.txt -@@ -1,4 +1,4 @@ - red --green -+emerald - blue - yellow - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_07.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_07.patch deleted file mode 100644 index d27d1390f..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_07.patch +++ /dev/null @@ -1,7 +0,0 @@ -Index: sentence.txt -=================================================================== ---- sentence.txt -+++ sentence.txt -@@ -0,0 +1,1 @@ -+The small cat sleeps near the window. - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_08.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_08.patch deleted file mode 100644 index 940211459..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_08.patch +++ /dev/null @@ -1,8 +0,0 @@ -Index: sentence.txt -=================================================================== ---- sentence.txt -+++ sentence.txt -@@ -1,1 +1,1 @@ --The small cat sleeps near the window. -+The striped cat sleeps near the window. - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_09.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_09.patch deleted file mode 100644 index dfc8f4311..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_09.patch +++ /dev/null @@ -1,12 +0,0 @@ -Index: list.txt -=================================================================== ---- list.txt -+++ list.txt -@@ -0,0 +1,6 @@ -+alpha -+beta -+gamma -+delta -+epsilon -+zeta - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_10.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_10.patch deleted file mode 100644 index 1c75b5818..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_10.patch +++ /dev/null @@ -1,15 +0,0 @@ -Index: list.txt -=================================================================== ---- list.txt -+++ list.txt -@@ -1,6 +1,6 @@ - alpha --beta -+beta-updated - gamma --delta -+delta-updated - epsilon --zeta -+zeta-updated - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_11.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_11.patch deleted file mode 100644 index 155039e18..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_11.patch +++ /dev/null @@ -1,18 +0,0 @@ -Index: animals.txt -=================================================================== ---- animals.txt -+++ animals.txt -@@ -0,0 +1,3 @@ -+fox -+wolf -+bear - -Index: flowers.txt -=================================================================== ---- flowers.txt -+++ flowers.txt -@@ -0,0 +1,3 @@ -+rose -+lily -+tulip - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_12.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_12.patch deleted file mode 100644 index 9a35de14f..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_12.patch +++ /dev/null @@ -1,20 +0,0 @@ -Index: animals.txt -=================================================================== ---- animals.txt -+++ animals.txt -@@ -1,3 +1,3 @@ - fox --wolf -+lynx - bear - -Index: flowers.txt -=================================================================== ---- flowers.txt -+++ flowers.txt -@@ -1,3 +1,3 @@ - rose --lily -+orchid - tulip - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_13.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_13.patch deleted file mode 100644 index df5069644..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_13.patch +++ /dev/null @@ -1,17 +0,0 @@ -Index: story.txt -=================================================================== ---- story.txt -+++ story.txt -@@ -0,0 +1,11 @@ -+Chapter 1 -+The road was empty. -+The sun was low. -+ -+Chapter 2 -+A cart moved slowly. -+The horses were tired. -+ -+Chapter 3 -+Night came early. -+The village stayed silent. - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_14.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_14.patch deleted file mode 100644 index 0533be852..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_14.patch +++ /dev/null @@ -1,19 +0,0 @@ -Index: story.txt -=================================================================== ---- story.txt -+++ story.txt -@@ -1,11 +1,11 @@ - Chapter 1 --The road was empty. -+The road was silent. - The sun was low. - - Chapter 2 - A cart moved slowly. - The horses were tired. - - Chapter 3 - Night came early. --The village stayed silent. -+The village kept its lamps lit. - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_15.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_15.patch deleted file mode 100644 index 926628bd9..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_15.patch +++ /dev/null @@ -1,10 +0,0 @@ -Index: tasks.txt -=================================================================== ---- tasks.txt -+++ tasks.txt -@@ -0,0 +1,4 @@ -+task-a -+task-b -+task-c -+task-d - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_16.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_16.patch deleted file mode 100644 index 0dbba6fca..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_16.patch +++ /dev/null @@ -1,10 +0,0 @@ -Index: tasks.txt -=================================================================== ---- tasks.txt -+++ tasks.txt -@@ -1,4 +1,3 @@ - task-a - task-b --task-c - task-d - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_17.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_17.patch deleted file mode 100644 index 1e49cc8d6..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_17.patch +++ /dev/null @@ -1,14 +0,0 @@ -Index: cleanup.txt -=================================================================== ---- cleanup.txt -+++ cleanup.txt -@@ -0,0 +1,8 @@ -+one -+two -+three -+four -+five -+six -+seven -+eight - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_18.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_18.patch deleted file mode 100644 index 38e58eef5..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_18.patch +++ /dev/null @@ -1,14 +0,0 @@ -Index: cleanup.txt -=================================================================== ---- cleanup.txt -+++ cleanup.txt -@@ -1,8 +1,6 @@ - one --two - three - four - five - six --seven - eight - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_19.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_19.patch deleted file mode 100644 index 095865e94..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_19.patch +++ /dev/null @@ -1,10 +0,0 @@ -Index: replace-me.txt -=================================================================== ---- replace-me.txt -+++ replace-me.txt -@@ -0,0 +1,4 @@ -+Old line 1 -+Old line 2 -+Old line 3 -+Old line 4 - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_20.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_20.patch deleted file mode 100644 index 35cf3f914..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_20.patch +++ /dev/null @@ -1,13 +0,0 @@ -Index: replace-me.txt -=================================================================== ---- replace-me.txt -+++ replace-me.txt -@@ -1,4 +1,3 @@ --Old line 1 --Old line 2 --Old line 3 --Old line 4 -+New line A -+New line B -+New line C - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_21.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_21.patch deleted file mode 100644 index de52b09da..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_21.patch +++ /dev/null @@ -1,10 +0,0 @@ -Index: draft-poem.txt -=================================================================== ---- draft-poem.txt -+++ draft-poem.txt -@@ -0,0 +1,4 @@ -+A lamp is glowing in the hall. -+A shadow leans against the wall. -+No one speaks above the floor. -+Evening waits beside the door. - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_22.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_22.patch deleted file mode 100644 index 9c3325472..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_22.patch +++ /dev/null @@ -1,11 +0,0 @@ -Index: draft-poem.txt -=================================================================== ---- draft-poem.txt -+++ draft-poem.txt -@@ -1,4 +1,4 @@ - A lamp is glowing in the hall. - A shadow leans against the wall. --No one speaks above the floor. -+No one stirs behind the door. - Evening waits beside the door. - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_23.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_23.patch deleted file mode 100644 index 7c62c4538..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_23.patch +++ /dev/null @@ -1,7 +0,0 @@ -Index: single-letter.txt -=================================================================== ---- single-letter.txt -+++ single-letter.txt -@@ -0,0 +1,1 @@ -+color - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_24.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_24.patch deleted file mode 100644 index cc3448c5e..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_24.patch +++ /dev/null @@ -1,8 +0,0 @@ -Index: single-letter.txt -=================================================================== ---- single-letter.txt -+++ single-letter.txt -@@ -1,1 +1,1 @@ --color -+colour - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_25.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_25.patch deleted file mode 100644 index 5b97c2d18..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_25.patch +++ /dev/null @@ -1,8 +0,0 @@ -Index: temporary-note.txt -=================================================================== ---- temporary-note.txt -+++ temporary-note.txt -@@ -0,0 +1,2 @@ -+This file exists only for testing. -+It should be deleted in the next step. - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_26.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_26.patch deleted file mode 100644 index 729bc2b68..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/incremental_26.patch +++ /dev/null @@ -1,8 +0,0 @@ -Index: temporary-note.txt -=================================================================== ---- temporary-note.txt -+++ temporary-note.txt -@@ -1,2 +0,0 @@ --This file exists only for testing. --It should be deleted in the next step. - diff --git a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/post_commit.patch b/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/post_commit.patch deleted file mode 100644 index a43cb8990..000000000 --- a/cli/src/services/patch/fixtures/text_file_lifecycle_reconstruction/post_commit.patch +++ /dev/null @@ -1,164 +0,0 @@ -diff --git a/animals.txt b/animals.txt -new file mode 100644 -index 0000000..4eb7e8d ---- /dev/null -+++ b/animals.txt -@@ -0,0 +1,3 @@ -+fox -+lynx -+bear -diff --git a/cleanup.txt b/cleanup.txt -new file mode 100644 -index 0000000..22d09ae ---- /dev/null -+++ b/cleanup.txt -@@ -0,0 +1,6 @@ -+one -+three -+four -+five -+six -+eight -diff --git a/colors.txt b/colors.txt -new file mode 100644 -index 0000000..7dcf88e ---- /dev/null -+++ b/colors.txt -@@ -0,0 +1,4 @@ -+red -+emerald -+blue -+yellow -diff --git a/final-poem.txt b/final-poem.txt -new file mode 100644 -index 0000000..9fd1feb ---- /dev/null -+++ b/final-poem.txt -@@ -0,0 +1,4 @@ -+A lamp is glowing in the hall. -+A shadow leans against the wall. -+No one stirs behind the door. -+Evening waits beside the door. -diff --git a/flowers.txt b/flowers.txt -new file mode 100644 -index 0000000..6b7eda2 ---- /dev/null -+++ b/flowers.txt -@@ -0,0 +1,3 @@ -+rose -+orchid -+tulip -diff --git a/list.txt b/list.txt -new file mode 100644 -index 0000000..2794901 ---- /dev/null -+++ b/list.txt -@@ -0,0 +1,6 @@ -+alpha -+beta-updated -+gamma -+delta-updated -+epsilon -+zeta-updated -diff --git a/notes.txt b/notes.txt -new file mode 100644 -index 0000000..ad9e0b0 ---- /dev/null -+++ b/notes.txt -@@ -0,0 +1,7 @@ -+Line 1: apples -+Line 2: pears -+Line 3: plums -+Line 4: grapes -+Line 5: oranges -+Line 6: bananas -+Line 7: cherries -diff --git a/poem-one.txt b/poem-one.txt -new file mode 100644 -index 0000000..fd17f47 ---- /dev/null -+++ b/poem-one.txt -@@ -0,0 +1,4 @@ -+Morning light spills on the floor. -+A quiet bird waits by the tree. -+Nothing asks for more today. -+The world is slow and clear. -diff --git a/rain.txt b/rain.txt -new file mode 100644 -index 0000000..f0f762d ---- /dev/null -+++ b/rain.txt -@@ -0,0 +1,4 @@ -+Rain taps softly on the glass. -+Clouds erase the distant hill. -+Every road becomes a mirror. -+Everything is calm and still. -diff --git a/replace-me.txt b/replace-me.txt -new file mode 100644 -index 0000000..1f62a56 ---- /dev/null -+++ b/replace-me.txt -@@ -0,0 +1,3 @@ -+New line A -+New line B -+New line C -diff --git a/sentence.txt b/sentence.txt -new file mode 100644 -index 0000000..6bd3c5f ---- /dev/null -+++ b/sentence.txt -@@ -0,0 +1 @@ -+The striped cat sleeps near the window. -diff --git a/single-letter.txt b/single-letter.txt -new file mode 100644 -index 0000000..b0d95fa ---- /dev/null -+++ b/single-letter.txt -@@ -0,0 +1 @@ -+colour -diff --git a/story.txt b/story.txt -new file mode 100644 -index 0000000..7ddfb18 ---- /dev/null -+++ b/story.txt -@@ -0,0 +1,11 @@ -+Chapter 1 -+The road was silent. -+The sun was low. -+ -+Chapter 2 -+A cart moved slowly. -+The horses were tired. -+ -+Chapter 3 -+Night came early. -+The village kept its lamps lit. -diff --git a/sun.txt b/sun.txt -new file mode 100644 -index 0000000..6277a80 ---- /dev/null -+++ b/sun.txt -@@ -0,0 +1,4 @@ -+Sun above the empty street. -+Warmth arrives before the sound. -+Windows open one by one. -+Morning settles on the town. -diff --git a/tasks.txt b/tasks.txt -new file mode 100644 -index 0000000..1e39e1e ---- /dev/null -+++ b/tasks.txt -@@ -0,0 +1,3 @@ -+task-a -+task-b -+task-d -diff --git a/wind.txt b/wind.txt -new file mode 100644 -index 0000000..5bce757 ---- /dev/null -+++ b/wind.txt -@@ -0,0 +1,4 @@ -+Wind moves lightly through the leaves. -+Branches bend but do not break. -+Air carries the scent of evening. -+Day prepares to fade away. diff --git a/cli/src/services/patch/tests.rs b/cli/src/services/patch/tests.rs deleted file mode 100644 index 14c43f19d..000000000 --- a/cli/src/services/patch/tests.rs +++ /dev/null @@ -1,152 +0,0 @@ -use super::{combine_patches, intersect_patches, parse_patch, ParsedPatch}; - -#[derive(Clone, Copy)] -struct PatchScenario { - incremental: &'static [&'static str], - post_commit: &'static str, - golden: &'static str, -} - -fn parse_fixtures(fixtures: &[&str]) -> Vec { - fixtures - .iter() - .map(|fixture| parse_patch(fixture, None).expect("fixture patch should parse")) - .collect() -} - -const TEXT_FILE_LIFECYCLE_RECONSTRUCTION_INCREMENTALS: &[&str] = &[ - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_01.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_02.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_03.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_04.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_05.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_06.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_07.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_08.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_09.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_10.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_11.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_12.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_13.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_14.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_15.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_16.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_17.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_18.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_19.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_20.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_21.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_22.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_23.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_24.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_25.patch"), - include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_26.patch"), -]; - -fn assert_reconstructs_post_commit(scenario: PatchScenario) { - let combined = combine_patches(&parse_fixtures(scenario.incremental)); - let post_commit = parse_patch(scenario.post_commit, None).expect("fixture patch should parse"); - let golden: ParsedPatch = - serde_json::from_str(scenario.golden).expect("golden json should load"); - - assert_eq!(intersect_patches(&combined, &post_commit), golden); -} - -#[test] -fn average_age_reconstruction_matches_post_commit() { - assert_reconstructs_post_commit(PatchScenario { - incremental: &[ - include_str!("fixtures/average_age_reconstruction/incremental_01.patch"), - include_str!("fixtures/average_age_reconstruction/incremental_02.patch"), - include_str!("fixtures/average_age_reconstruction/incremental_03.patch"), - include_str!("fixtures/average_age_reconstruction/incremental_04.patch"), - include_str!("fixtures/average_age_reconstruction/incremental_05.patch"), - include_str!("fixtures/average_age_reconstruction/incremental_06.patch"), - include_str!("fixtures/average_age_reconstruction/incremental_07.patch"), - ], - post_commit: include_str!("fixtures/average_age_reconstruction/post_commit.patch"), - golden: include_str!("fixtures/average_age_reconstruction/golden.json"), - }); -} - -#[test] -fn hello_world_reconstruction_matches_post_commit() { - assert_reconstructs_post_commit(PatchScenario { - incremental: &[include_str!( - "fixtures/hello_world_reconstruction/incremental_01.patch" - )], - post_commit: include_str!("fixtures/hello_world_reconstruction/post_commit.patch"), - golden: include_str!("fixtures/hello_world_reconstruction/golden.json"), - }); -} - -#[test] -fn text_file_lifecycle_reconstruction_matches_post_commit() { - assert_reconstructs_post_commit(PatchScenario { - incremental: TEXT_FILE_LIFECYCLE_RECONSTRUCTION_INCREMENTALS, - post_commit: include_str!("fixtures/text_file_lifecycle_reconstruction/post_commit.patch"), - golden: include_str!("fixtures/text_file_lifecycle_reconstruction/golden.json"), - }); -} - -#[test] -fn poem_write_reconstruction_matches_post_commit() { - assert_reconstructs_post_commit(PatchScenario { - incremental: &[include_str!( - "fixtures/poem_write_reconstruction/incremental_01.patch" - )], - post_commit: include_str!("fixtures/poem_write_reconstruction/post_commit.patch"), - golden: include_str!("fixtures/poem_write_reconstruction/golden.json"), - }); -} - -#[test] -fn poem_edit_reconstruction_matches_post_commit() { - assert_reconstructs_post_commit(PatchScenario { - incremental: &[ - include_str!("fixtures/poem_edit_reconstruction/incremental_01.patch"), - include_str!("fixtures/poem_edit_reconstruction/incremental_02.patch"), - ], - post_commit: include_str!("fixtures/poem_edit_reconstruction/post_commit.patch"), - golden: include_str!("fixtures/poem_edit_reconstruction/golden.json"), - }); -} - -#[test] -fn mixed_change_reconstruction_matches_post_commit() { - assert_reconstructs_post_commit(PatchScenario { - incremental: &[ - include_str!("fixtures/mixed_change_reconstruction/incremental_01.patch"), - include_str!("fixtures/mixed_change_reconstruction/incremental_02.patch"), - include_str!("fixtures/mixed_change_reconstruction/incremental_03.patch"), - include_str!("fixtures/mixed_change_reconstruction/incremental_04.patch"), - ], - post_commit: include_str!("fixtures/mixed_change_reconstruction/post_commit.patch"), - golden: include_str!("fixtures/mixed_change_reconstruction/golden.json"), - }); -} - -#[test] -fn line_and_file_deletion_reconstruction_matches_post_commit() { - assert_reconstructs_post_commit(PatchScenario { - incremental: &[ - include_str!("fixtures/line_and_file_deletion_reconstruction/incremental_01.patch"), - include_str!("fixtures/line_and_file_deletion_reconstruction/incremental_02.patch"), - ], - post_commit: include_str!( - "fixtures/line_and_file_deletion_reconstruction/post_commit.patch" - ), - golden: include_str!("fixtures/line_and_file_deletion_reconstruction/golden.json"), - }); -} - -#[test] -fn file_rename_reconstruction_matches_post_commit() { - assert_reconstructs_post_commit(PatchScenario { - incremental: &[include_str!( - "fixtures/file_rename_reconstruction/incremental_01.patch" - )], - post_commit: include_str!("fixtures/file_rename_reconstruction/post_commit.patch"), - golden: include_str!("fixtures/file_rename_reconstruction/golden.json"), - }); -} diff --git a/context/architecture.md b/context/architecture.md index fc3d15685..5a1ae8f25 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -76,13 +76,13 @@ 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 required registration — the four `sce hooks codex` registrations plus the six `sce hooks codex-mutation-scope` ones, the latter reported under distinct `#(mutation-scope)` rows — as `PresentAndCurrent`/`Missing`/`Stale` (or the whole document as `Malformed` when it cannot be structurally validated) without writing anything, so unrelated user handlers never create a false whole-document mismatch. For a structurally current registration, `codex_hook_trust` separately reads (never writes) Codex's own durable `$CODEX_HOME/config.toml` hook-trust state — reproducing upstream's `hook_hash`/`hook_key`/`hook_trust_status` exactly — and reports `Trusted`/`Untrusted`/`Modified`/`Disabled`/`Unknown`, and `codex_hook_policy` probes Codex's effective hook-discovery policy once per invocation; only a structurally current registration that is trusted *and* policy-allowed renders healthy. `sce doctor --fix` repairs a structurally unhealthy `.codex/hooks.json` through the existing merge-install path, but a registration that is current yet not-yet-trusted or policy-blocked is never "fixed", since SCE cannot grant Codex hook trust or change managed policy. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` payload used by the static `RuntimeCommand` enum. -- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`), including the `claude-model-state` lifecycle intake (silent) delegated to `cli/src/services/hooks/claude_model_state.rs`, the hidden non-fail-open `mutation-scope` runtime ingress delegated to `cli/src/services/hooks/mutation_scope.rs` (STDIN normalized JSON → one `mutation_trace::runtime` `coordinate()` / `abandon_scope()` call with a lazy DB provider; see `context/cli/mutation-scope-hook-ingress.md`), the hidden non-fail-open `claude-mutation-scope` adapter delegated to `cli/src/services/hooks/claude_mutation_scope/` (STDIN raw Claude hook JSON → tool classification + identity/`ScopeId` derivation → the same `mutation_scope.rs` `pub(crate)` in-process seam, one mutation `ScopeId` per mutation-capable tool execution; registered by `sce setup`; see `context/cli/claude-mutation-scope-integration.md`), plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, combines valid patches via `patch::combine_patches`, intersects with post-commit patch via `patch::intersect_patches`, persists the direct-only intersection metadata to `post_commit_patch_intersections`, then — for committed touched lines that direct intersection did not cover — resolves bounded, read-only mutation-history AI coverage scoped to the invoking worktree's existing identity (newest 128 events replayed oldest-to-newest as one causal tree-transition provenance lineage bounded by a worktree-lock-captured commit attribution cut — `revision <= latest_mutation_event_revision` read under the worktree lock — with a committed line attributed AI only if an AI event's line survives every later transition into the committed tree; no provenance, no `diff_traces` or mutation-cursor write) and passes direct and mutation-AI evidence separately into the Agent Trace builder, and persists the built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the setup-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child when its resolved value is true (a newly created setup config supplies the explicit `true`; omitted configuration resolves to `false`), with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution with `direct > exact transcript > exact session/agent state > bridge-derived chain state > NULL`: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`, then persistence performs one exact local `claude_model_state` lookup using canonical session and ephemeral agent scope. Only for a raw structured payload in the exact main-session scope, a state miss then resolves the newest `claude_model_state` observation across the transcript's bridge-linked chain members (bounded leading-record reads, one exact-scope read per member, winner by `observed_at_ms`) and, on a hit, writes a `claude_model_state` row for the current session (`source="bridge_inherited"`) before using that model — the only diff-trace resolution path that writes state. Model values normalize once through `claude/`, subagents do not inherit main-session state, and unresolved values remain nullable. `session-model` is no longer a supported hook route. +- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`), including the `claude-model-state` lifecycle intake (silent) delegated to `cli/src/services/hooks/claude_model_state.rs`, the hidden non-fail-open `mutation-scope` runtime ingress delegated to `cli/src/services/hooks/mutation_scope.rs` (STDIN normalized JSON → one `mutation_trace::runtime` `coordinate()` / `abandon_scope()` call with a lazy DB provider; see `context/cli/mutation-scope-hook-ingress.md`), the hidden non-fail-open `claude-mutation-scope` adapter delegated to `cli/src/services/hooks/claude_mutation_scope/` (STDIN raw Claude hook JSON → tool classification + identity/`ScopeId` derivation → the same `mutation_scope.rs` `pub(crate)` in-process seam, one mutation `ScopeId` per mutation-capable tool execution; registered by `sce setup`; see `context/cli/claude-mutation-scope-integration.md`), plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, keeps each row separate and matches it against the post-commit patch with fresh, exact, single-session direct evidence (`agent_trace/evidence.rs`), persists the direct-only intersection metadata to `post_commit_patch_intersections`, then — for committed touched lines that direct intersection did not cover — resolves bounded, read-only mutation-history AI coverage scoped to the invoking worktree's existing identity (newest 128 events replayed oldest-to-newest as one causal tree-transition provenance lineage bounded by a worktree-lock-captured commit attribution cut — `revision <= latest_mutation_event_revision` read under the worktree lock — with a committed line attributed AI only if an AI event's line survives every later transition into the committed tree; no provenance, no `diff_traces` or mutation-cursor write) and passes direct and mutation-AI evidence separately into the Agent Trace builder, and persists the built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the setup-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child when its resolved value is true (a newly created setup config supplies the explicit `true`; omitted configuration resolves to `false`), with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution with `direct > exact transcript > exact session/agent state > bridge-derived chain state > NULL`: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`, then persistence performs one exact local `claude_model_state` lookup using canonical session and ephemeral agent scope. Only for a raw structured payload in the exact main-session scope, a state miss then resolves the newest `claude_model_state` observation across the transcript's bridge-linked chain members (bounded leading-record reads, one exact-scope read per member, winner by `observed_at_ms`) and, on a hit, writes a `claude_model_state` row for the current session (`source="bridge_inherited"`) before using that model — the only diff-trace resolution path that writes state. Model values normalize once through `claude/`, subagents do not inherit main-session state, and unresolved values remain nullable. `session-model` is no longer a supported hook route. - 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. - `cli/src/services/sync/progress.rs` owns the sync-local, consumer-typed progress seam: generic `ProgressReporter` supports event delivery plus explicit successful finalization, closure-based collectors, and a no-op implementation alongside the fixed `indicatif` stderr presentation adapter. `cli/src/services/sync/sync.rs` owns `SyncProgressEvent` and its payload semantics for the three remotely synchronized streams (`messages`, `parts`, `agent_traces`; `diff_traces` is captured locally but not synchronized), while `sync/command.rs` selects the terminal adapter for text and the no-op reporter for JSON. There is no top-level `cli/src/services/progress/` module; sync orchestration depends only on its sync-owned contract, so terminal-library details stay at the sync presentation boundary. - `sce sync [--format text|json]` is implemented: `cli/src/services/sync/sync.rs` first rejects an effective `auth.mode: disabled` (`auth.disabled_by_config`, see `context/cli/access-policy.md`), then resolves repository-scoped Agent Trace storage, authenticates against the control plane with stored WorkOS credentials, uses the config-resolved `control_plane_base_url` with baked default `https://sce.crocoderlab.dev`, calls the ingestion `/state` endpoint once, then starts the three remote `messages`/`parts`/`agent_traces` stream state machines concurrently (`diff_traces` remains a local-only compatibility report) via `AgentTraceExportReader` and a shared per-stream reconciliation engine. Batches and cursor refreshes remain sequential within each stream, while fixed stream order is retained for final and stream-completion reporting; `cli/src/services/sync/render_sync.rs` renders the converged `AgentTraceSyncReport` as concise per-stream text or `camelCase` JSON without a nested subcommand field (see `context/cli/sync-command.md`). Local DB bootstrap and setup-time repository-scoped Agent Trace DB initialization otherwise still flow through lifecycle providers aggregated by setup, while repository-scoped DB health/repair flows through the doctor surface. The former trace database inspection and nested sync surfaces are unavailable. -- `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helper (`load_patch_from_json` for string input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. Its patch-set operations now include deterministic ordered combination plus target-shaped intersection that prefers exact touched-line matches and falls back to historical `kind`+`content` matching when incremental diffs and canonical post-commit diffs have drifted line numbers; `parse_patch`, `combine_patches`, and `intersect_patches` are consumed by the active post-commit hook runtime. +- `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helper (`load_patch_from_json` for string input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. It also exposes `paths_refer_to_same_file` for absolute-vs-relative file identity; the former `combine_patches`/`intersect_patches` set operations were removed, and `parse_patch` is consumed by the active post-commit hook runtime. - `cli/src/services/structured_patch.rs` defines the synchronous structured editor-hook derivation seam. It derives Claude `PostToolUse` `Write` structured-update hunks, `Write` `tool_input.content` create fallback, and `Edit` structured-patch payloads into canonical `ParsedPatch` values plus Claude session/tool metadata, returning deterministic skip reasons for unsupported events/tools/payload shapes. The module is pure and side-effect-free. It is wired into `sce hooks diff-trace` for Claude payload classification at intake (T04) and into `RepositoryAgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing dispatch at read time (T05). - `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, bash_policy, version, completion, help, patch, sync, SCE web URL helpers, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. The former `cli/src/services/checkout/` checkout-identity module was removed by the `remove-checkout-id` plan (see `context/cli/checkout-identity.md`); there is no per-clone identity, worktrees are identified by Git topology (`resolve_worktree_id`), active setup/hooks use `agent_trace_storage` to initialize/open the repository-scoped DB, and `sce doctor` surfaces credential-safe repository Agent Trace DB metadata. There is no checkout-scoped discovery or former trace inspection surface; any pre-migration `agent-trace-*.db` files on disk are never touched and no longer inspectable via the CLI. `cli/src/services/bash_policy.rs` owns both the CLI-agnostic evaluator logic and the hidden `sce policy bash` command adapter used by OpenCode, Claude, and Pi callers; Codex delegates in-process to the same evaluator for its native `PreToolUse(Bash)` response. `cli/src/services/command_registry.rs` defines the static `RuntimeCommand` enum, deterministic `CommandRegistry` name catalog, and `build_default_registry()` function for command dispatch metadata. Service-owned command modules own the runtime command payload structs for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync. - `cli/README.md` is the crate-local onboarding and usage source of truth for placeholder behavior, safety limitations, and roadmap mapping back to service contracts. diff --git a/context/cli/mutation-trace-agent-attribution.md b/context/cli/mutation-trace-agent-attribution.md index ee0ab64fb..369604bc1 100644 --- a/context/cli/mutation-trace-agent-attribution.md +++ b/context/cli/mutation-trace-agent-attribution.md @@ -1,8 +1,9 @@ # 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. +Mutation history is the causal source of committed-line attribution. Every +committed touched line is projected through mutation lineage; direct +`diff_traces` evidence only attributes lines that lineage cannot resolve, and +only when it meets the trust requirements below. Attribution is **causal**, not textual. The retained mutation history is treated as one ordered sequence of tree transitions and provenance is propagated forward @@ -51,7 +52,11 @@ added line numbers): - 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. + between occurrences; +- a deleted or renamed-away tracked path stays tracked as an absent (empty) + file, so a later transition that recreates it is applied rather than silently + skipped. An `Added` transition for a path still tracked with content is a + `LineageError`. Any structurally inconsistent transition (content mismatch, inconsistent hunk lengths, out-of-range hunk) returns `LineageError`; the caller fails closed for @@ -67,10 +72,10 @@ and read-only Git tree access over two injectable traits — `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. +- **Every committed line is projected.** Direct evidence no longer removes + lines before replay, so a weak or stale textual match can never hide a line + from causal lineage. A committed patch with no touched lines 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). @@ -116,8 +121,10 @@ 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 +`resolve_post_commit_mutation_attribution(repository_root, &db, +committed_patch) -> MutationAttributionResult` is the read-only post-commit +entrypoint. It returns the mutation-AI, resolved non-AI, and unresolved line +sets. It resolves the invoking worktree's identity from Git topology ([`git_snapshot::resolve_git_dir` + `resolve_worktree_id`](mutation-trace-runtime-coordinator.md), which derive `WorktreeId` from `git rev-parse --absolute-git-dir` / @@ -131,30 +138,75 @@ critical section is a single indexed read. An unresolvable git dir, an unresolvable worktree 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 +history at all each yield an empty result (every line unresolved), so +post-commit falls back to trusted direct-only Agent Trace behavior. The entrypoint creates no identity and writes no mutation-cursor state. +## Evidence precedence + +Each committed touched line is resolved independently in +`build_agent_trace_from_evidence` (`cli/src/services/agent_trace.rs`), with +direct candidate matching in `cli/src/services/agent_trace/evidence.rs`: + +| Mutation lineage | Direct evidence | Result | +| --- | --- | --- | +| `MutationAi` (session S) | unique, session S | S; model from scope, else from the direct row | +| `MutationAi` (session S) | anything else | S; the direct candidate is discarded | +| `MutationNonAi` | anything | not AI; direct evidence cannot override | +| unresolved | unique trusted match | the direct session and model | +| unresolved | conflicting sessions, historical-only, or none | unknown | + +- **When is direct evidence sufficient?** Only when lineage left the line + unresolved and a direct candidate is (1) *fresh* — recorded after the + parent commit's committer-time second (`direct_evidence_cutoff_ms`), still + inside the 7-day window; (2) an *exact* touched-line match on file identity, + `kind`, `line_number`, and `content`; and (3) *unambiguous* — every fresh + exact candidate for that line comes from one session. Each candidate row is + kept separate (never merged into one patch), so evidence is consumed by its exact + occurrence and a duplicate line elsewhere in the file is not covered. +- **When does lineage override direct evidence?** Whenever lineage resolves + the line: `MutationAi` attributes to the owning scope's session, and + `MutationNonAi` (contended, unscoped, or unhealthy transitions) keeps it + non-AI. +- **What happens when sources disagree?** The causal lineage result wins over + a textual match. Disagreeing direct candidates for one line fail closed to + unknown; SCE never picks the newest or first row. Overridden candidates do + not reach `conversation.related`, `contributor.model_id`, or the `tool` + object (emitted only when a direct candidate supports a final line + attribution). +- **What happens when history is incomplete?** Gaps, the 128-event window, the + revision ceiling, and the unobserved tail keep lines `Unknown`, as before. + An unknown line then needs fresh, exact, single-session direct evidence; + historical `(kind, content)` similarity alone never attributes it. +- **How are already-committed changes not credited again?** Direct rows + recorded before the parent commit are outside the query window, positional + exactness rejects drifted boilerplate, and lineage attributes only the + transition that most recently produced the committed line. +- **What remains unsupported?** Removed lines are never resolved by lineage + (only exact fresh direct evidence can cover them). Direct-only edits whose + line numbers drift through later uncaptured edits now resolve to unknown or + `mixed` instead of `ai`. The freshness bound uses the committer time of the + first parent, so an uncommitted edit made before the parent commit (partial + staging) loses direct attribution. Mutation scopes without a recorded model + still produce hunks without `model_id`. + +The former `intersect_patches` / `combine_patches` helpers and their historical +`(kind, content)` fallback were removed from `patch.rs`. + ## 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`. +- **Evidence stays separated.** `post_commit_patch_intersections` stores only + the trusted direct-evidence projection (fresh, exact, single-session) and its + `recent_window_cutoff_ms` records the freshness-bounded cutoff. Mutation + evidence never enters `diff_traces`. - **No fabricated direct provenance.** The mutation-AI patch is target-shaped and carries only the resolved observational session/model metadata described above; it carries no tool or tool-version metadata. `ScopeId`, `ActorKind`, - and `AiExclusive(scope)` are never translated into direct provenance. The - current Agent Trace builder still derives hunk model/session and the - top-level `tool` object from direct evidence only; mutation-only coverage - widens `ai` / `mixed` classification while preserving the resolved metadata - for the combined attribution step. See + and `AiExclusive(scope)` are never translated into direct provenance. 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 +- **Final persistence.** The single resolved Agent Trace is validated against + the embedded schema and stored in `agent_traces.trace_json`; no schema or + migration is added. Hook wiring detail lives in [../sce/agent-trace-hooks-command-routing.md](../sce/agent-trace-hooks-command-routing.md). ## Known limitation @@ -169,30 +221,26 @@ observable tree difference. ## Regressions -- `lineage.rs` — added AI line carries provenance; removed line loses it +- `lineage/tests.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. -- `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). -- `hooks/mod.rs` (`mutation_provenance_e2e`) — real Claude and Codex `Bash` - adapter admission through generic mutation scope, commit, causal projection, - and persisted Agent Trace JSON; mutation protocol attribution remains - distinct from scope provenance. + provenance jump; a deleted-then-recreated file stays tracked; a renamed-away + path stays tracked as absent; adding a file still tracked with content fails + closed. +- `runtime/mutation_attribution/tests.rs` — real Git trees: an observed AI event + beats an old session's historical text match; AI edits to a deleted and + recreated file keep provenance (the `control-plane` `494e5693` incident + shape); an unobserved tail, a history gap, an event outside the 128-event + window, and an event beyond the revision ceiling each keep the line unknown + and keep the old session out of the trace. +- `hooks/commit_hooks/attribution_tests.rs` — dated Git history: stale + already-committed sessions do not credit a later commit; lineage overrides a + fresh exact match from another session; non-exclusive lineage blocks direct + evidence; historical content at other positions does not attribute; fresh + exact direct-only evidence still attributes with session, model, and tool; + conflicting direct sessions fail closed; one source line covers one duplicate + occurrence; the staged co-author check ignores stale and historical-only + rows; real Turso persistence from `diff_traces` through + `post_commit_patch_intersections`, `MutationTraceStore`, and + `agent_traces.trace_json`. diff --git a/context/cli/patch-service.md b/context/cli/patch-service.md index 4ede0648b..2ae040e42 100644 --- a/context/cli/patch-service.md +++ b/context/cli/patch-service.md @@ -43,33 +43,11 @@ Both functions wrap `serde_json::from_str`/`serde_json::from_slice` and map serd ## Set operations -### Intersection - -`intersect_patches(a: &ParsedPatch, b: &ParsedPatch) -> ParsedPatch` returns a `ParsedPatch` containing only the touched lines from `b` that are also represented in `a` for the same logical file. - -- **File matching**: files are matched by post-change path identity — exact `new_path` equality, or absolute-vs-relative path variants whose normalized path segments share the same relative suffix -- **Touched-line matching**: matching prefers exact identity (`kind`, `line_number`, and `content`); when no exact match exists, it falls back to historical reconstruction matching by `kind` and `content` only so canonical post-commit patches can still intersect with earlier incremental diffs whose line numbers drifted -- **Result structure**: only files with at least one overlapping touched line appear in the result; hunks with no overlapping lines are excluded; hunk range metadata (`old_start`, `old_count`, `new_start`, `new_count`) is preserved from the second patch (`b`) so the result keeps the target patch shape, while matched-line `session_id` and hunk `model_id` provenance are inherited from the first patch (`a`) when available (and remain `None` when matched constructed provenance is absent) -- **Determinism**: the same inputs always produce the same output -- **Equivalent-hunk behavior**: semantically identical hunks still intersect when they differ only in surrounding context windows, hunk header ranges, or absolute-vs-relative `Index:` path spelling, as long as their touched-line identities match exactly -- **Consumed by**: the post-commit hook runtime combines recent DB diff-trace patches and then intersects with the current commit patch (see `agent-trace-hooks-command-routing.md`). - -### Combination - -`combine_patches(patches: &[ParsedPatch]) -> ParsedPatch` merges multiple `ParsedPatch` values into one deterministic result with later-input-wins semantics for duplicate/conflicting touched-line entries. - -- **File matching**: files are grouped by `new_path`; file metadata (`old_path`, `kind`) is taken from the last patch that contributed to each file -- **Touched-line identity and deduplication**: touched lines are deduplicated by identity (`kind`, `line_number`, `content`); when multiple patches describe the same file and logical touched-line slot, the later input's entry is retained -- **Hunk reconstruction and provenance**: surviving lines are grouped by their hunk metadata from the last contributing patch; each reconstructed hunk preserves that winning hunk's `model_id` provenance; hunks are ordered by `old_start`; lines within each hunk are ordered by `line_number` with `Removed` before `Added` at the same position, then by `content` for full determinism -- **File ordering**: files appear in the result in the order they are first encountered across the input patches -- **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. +The former `intersect_patches` / `combine_patches` set operations were removed: their historical `(kind, content)` fallback was too permissive for attribution and they had no remaining runtime caller. Attribution-grade direct matching lives in `cli/src/services/agent_trace/evidence.rs`, keeps each `diff_traces` row separate, requires an exact `(file, kind, line_number, content)` match, and fails closed when candidates for one line come from different sessions. It reuses `paths_refer_to_same_file` for absolute-vs-relative file identity. -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). +Mutation-history attribution 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 `patch_for_locations` / `patch_for_locations_with_provenance` (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 @@ -81,14 +59,13 @@ bounded deterministic synthetic line identities from `tool_use_id`; those identities are evidence keys, not physical source line numbers. Delete File, pure rename, and Bash filesystem mutations produce no line-level evidence. -The existing `combine_patches` and `intersect_patches` operations remain -unchanged. Their historical `kind` + `content` fallback reconciles synthetic -Codex positions with real post-commit positions, while repeated identical -content can remain physically ambiguous because Codex supplies no true line -ranges and SCE takes no filesystem snapshot. The Codex handler persists through -the existing `diff_traces` row shape and the post-commit runtime consumes it -through the same combination/intersection path; no Codex-specific Agent Trace -builder, pending state, or schema migration is introduced. +Attribution-grade direct matching requires exact line numbers, and synthetic +Codex positions (offsets of at least 2^31 per event) never equal real +post-commit positions. Codex `apply_patch` rows therefore cannot attribute or +falsely attribute a committed line; Codex edits are attributed through Codex +mutation scopes and causal lineage. The Codex handler still persists through +the existing `diff_traces` row shape; no Codex-specific Agent Trace builder, +pending state, or schema migration is introduced. ### Runtime wiring status @@ -96,18 +73,15 @@ builder, pending state, or schema migration is introduced. |-----------|-----------|-------| | `parse_patch` | Hook runtime, Agent Trace DB recent-row parsing, tests | Consumed by `post-commit` capture flow and `recent_diff_trace_patches` to parse stored raw `diff_traces.patch` text | | `load_patch_from_json` | Storage-agnostic JSON reconstruction callers | Reconstructs serialized `ParsedPatch` JSON when callers already have JSON payloads; not used for raw `diff_traces.patch` text in `recent_diff_trace_patches` | -| `intersect_patches` | Post-commit hook runtime | Combines recent patches then intersects with current commit patch | -| `combine_patches` | Post-commit hook runtime | Combines chronological recent patches before intersection | -For `payload_type="structured"` database rows, the repository adapter enriches the derived `ParsedPatch` before these set operations: every hunk receives the persisted row `model_id`, and every touched line receives the persisted tool-prefixed canonical row `session_id`. The raw unprefixed session carried inside the stored Claude payload is not reconstructed as touched-line provenance. +For `payload_type="structured"` database rows, the repository adapter enriches the derived `ParsedPatch` before direct-evidence matching: every hunk receives the persisted row `model_id`, and every touched line receives the persisted tool-prefixed canonical row `session_id`. The raw unprefixed session carried inside the stored Claude payload is not reconstructed as touched-line provenance. Public types consumed by the parser or load helpers have `#[allow(dead_code)]` removed; other module internals that are not yet consumed outside the crate retain `#[allow(dead_code)]`. ## Reconstruction fixture suites -Patch reconstruction tests use deterministic fixture suites under `cli/src/services/patch/fixtures/`. +Reconstruction scenarios (`average_age`, `hello_world`, `mixed_change`, `poem_edit`, `poem_write`, `text_file_lifecycle`, `file_rename`, `line_and_file_deletion`) live under `cli/src/services/agent_trace/fixtures/` and are asserted end-to-end through `build_agent_trace_from_evidence` golden Agent Trace payloads in `cli/src/services/agent_trace/tests.rs`. -- Existing suites remain intact (`average_age_reconstruction`, `hello_world_reconstruction`). - `diff_creation/` is the checked-in Claude diff-render golden fixture suite used by Rust derivation coverage, located at `cli/src/services/structured_patch/fixtures/`. Each active golden scenario consumes: - `claude-post-tool-use.json` containing a sanitized synthetic Claude `PostToolUse` payload shaped for `derive_claude_structured_patch(...)` - `expected.patch` containing the expected unified diff output diff --git a/context/context-map.md b/context/context-map.md index 55670b051..9170d0807 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -17,7 +17,7 @@ Feature/domain context: - `context/cli/repository-identity.md` (repository identity module in `cli/src/services/repository_identity/`: pure scheme-neutral `host[:port]/path` canonicalization for SCP/`ssh://`/HTTPS/`git://` remote URLs with credential stripping, hostname lowercasing, default-port removal, and query/fragment/trailing-`.git` cleanup, trim-only explicit-identity handling, `sha256("sce-repository-id-v1\0" + canonical_identity)` repository IDs, credential-safe fieldless errors, plus the `resolve` runtime submodule applying explicit-config-then-configured-remote precedence with `git config --get remote..url` lookup, `RepositoryIdentitySource` provenance, and `.sce/config.json`-guidance resolution errors that never echo URLs; consumed by the T04 `agent_trace_storage` resolver) - `context/cli/agent-trace-storage.md` (async repository-scoped Agent Trace storage resolver in `cli/src/services/agent_trace_storage/`: `AgentTraceStorageContext` inputs mirroring the `agent_trace.*` config keys, shared degradation of invalid default-discovered config before identity fallback while explicit selections remain fatal, `ResolvedAgentTraceStorage` carrying repository identity + `/sce/repos//agent-trace.db` path + open `RepositoryAgentTraceDb` + typed `RepositoryMetadata`, `resolve_agent_trace_storage{,_at_state_root}` setup/lifecycle entrypoints with idempotent concurrent-safe first open via bounded fast-path-then-migrate retry plus narrow one-file schema migration-metadata repair and repository metadata validation, a separate no-migration `resolve_agent_trace_storage_for_hook_runtime{,_at_state_root}` pair that high-frequency hook callers use exclusively and that never runs migration `002` or any migration, path-unsafe repository ID rejection in `default_paths::agent_trace_db_path_for_repository{,_at}`, strict never-touch boundary for any pre-migration checkout-scoped/global DB files, and active hook/runtime plus Agent Trace lifecycle setup call-site consumption after T08) - `context/cli/checkout-identity.md` (checkout identity infrastructure, `cli/src/services/checkout/` and its `/sce/checkout-id` UUIDv7 storage, was removed by the `remove-checkout-id` plan; documents the removal plus the never-touch on-disk handling of any pre-existing checkout-id/pre-migration checkout-scoped DB files left behind) -- `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, and set operations in `cli/src/services/patch.rs` for in-memory parsed unified-diff representation, capturing only touched lines plus minimal per-file/per-hunk metadata, supporting both `Index:` SVN-style and `diff --git` git-style formats, with `ParseError` for actionable malformed-input diagnostics, `PatchLoadError`/`load_patch_from_json` for storage-agnostic JSON reconstruction, `intersect_patches` for target-shaped overlap with exact-match-first and historical `kind`+`content` fallback semantics plus matched-constructed-line `session_id` and matched-constructed-hunk `model_id` provenance inheritance, and `combine_patches` for ordered patch combination with later-wins conflict resolution plus winning-hunk `model_id` provenance inheritance; repository structured-row reconstruction supplies persisted hunk-model and canonical touched-line-session provenance before these operations; `parse_patch`, `intersect_patches`, and `combine_patches` are consumed by the active post-commit hook runtime) +- `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, and path-identity helper in `cli/src/services/patch.rs` for in-memory parsed unified-diff representation, capturing only touched lines plus minimal per-file/per-hunk metadata, supporting both `Index:` SVN-style and `diff --git` git-style formats, with `ParseError` for actionable malformed-input diagnostics, `PatchLoadError`/`load_patch_from_json` for storage-agnostic JSON reconstruction, and `paths_refer_to_same_file` for absolute-vs-relative file identity (the former `intersect_patches`/`combine_patches` set operations were removed; attribution-grade direct matching lives in `agent_trace/evidence.rs`); repository structured-row reconstruction supplies persisted hunk-model and canonical touched-line-session provenance before these operations; `parse_patch` is consumed by the active post-commit hook runtime) - `context/cli/structured-patch-service.md` (Claude structured editor-hook derivation in `cli/src/services/structured_patch.rs`, including `Write` structured-update hunks, `Write` `tool_input.content` create fallback, `Edit` structured patches, deterministic skip reasons, `ParsedPatch` output semantics, Rust golden fixture coverage, and repository read-time enrichment that assigns persisted row `model_id` to each hunk and canonical row `session_id` to each touched line) - `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces including sync completion markers, and per-column right-to-left RGB gradient banner rendering) - `context/cli/agent-trace-auto-sync.md` (setup-enabled post-commit Agent Trace synchronization with explicit-false opt-out plus doctor readiness reporting: a newly created repo-local config supplies the explicit `true` opt-in while omitted runtime values resolve to `false`; the existing `sce sync` command is launched once through the current executable after local persistence, with detached null-standard-stream behavior, fail-open startup, canonical managed-block proof of hook readiness, stable text/JSON enabled/disabled/not-ready/not-applicable states, no daemon/queue/high-frequency trigger, and retryability through manual sync and control-plane cursor authority) @@ -30,7 +30,7 @@ Feature/domain context: - `context/cli/service-lifecycle.md` (current async lifecycle seam in `cli/src/services/lifecycle.rs`, including default no-op `ServiceLifecycle` diagnose/fix/setup methods against narrow `HasRepoRoot`, lifecycle-owned health/fix/setup result types with generic setup messages, doctor/setup adapter boundaries, the static `LifecycleProvider` enum catalog/dispatcher, hook/config/local_db/auth_db/agent_trace_db lifecycle providers including setup-time repository-scoped Agent Trace DB initialization plus checkout identity diagnostics, implemented doctor aggregation over diagnose/fix providers, and implemented setup aggregation over `setup` providers in order config → local_db → auth_db → agent_trace_db → hooks when requested) - `context/cli/mutation-trace-protocol.md` (pure, dependency-free `cli/src/services/mutation_trace/` refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol: the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — plus cross-action sequence/invariant tests and a module-level Quint refinement matrix are all complete (`types.rs`, `protocol.rs`, `mod.rs`, `tests.rs`, registered with `#[allow(dead_code)]`); opaque-newtype identity refinement decisions vs. the Quint model's bounded enums, and the `coordinator.rs` target end-state seam this layout leaves room for but does not create — `store.rs`, `runtime/git_snapshot.rs`, and `runtime/coordinator.rs` (including its public `coordinate()` entrypoint) now exist, built out by the `mutation-cursor-store-persistence` and `mutation-cursor-runtime-coordinator` plans respectively; `protocol.rs` is invoked only through the runtime, and the runtime is now driven by the generic `sce hooks mutation-scope` CLI ingress and a first Claude Code adapter driver, registered by `sce setup` and reachable by a real Claude Code session, now proven against real Git repositories and a real Agent Trace DB by the `claude-mutation-scope-integration` plan's T08 regressions) - `context/cli/mutation-trace-runtime-materialization.md` (how the finite, pre-populated Quint `SCOPES`/`WORKTREES` universes map onto the Rust protocol's unbounded identity space: the coordinator/store layer materializes scope and worktree state, scope identity is immutable once materialized, and a missing scope is distinct from a `NeverSeen` scope; `protocol.rs` only transitions already-known identities) -- `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`) +- `context/cli/mutation-trace-agent-attribution.md` (causal mutation-lineage attribution over every committed line with explicit precedence over fresh, exact, single-session direct evidence, and deleted/recreated paths kept tracked; 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 the `patch_for_locations` helpers. 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_attribution` entrypoint — existing checkout identity only, trusted-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/commit_hooks/attribution_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; migration `006_mutation_trace_health_invariant.sql`'s transactional rebuild of the worktree and event tables with a cross-column `tainted`/`failure_kind` health `CHECK`, `failure_kind`-authoritative legacy normalization, and a re-runnable SQL body, plus the shared fail-closed `validate_health_encoding` used by all three health-row decoders; `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) @@ -76,7 +76,7 @@ Feature/domain context: - `context/sce/agent-trace-schema-adapter.md` (historical Agent Trace adapter reference for the removed `cli/src/services/agent_trace.rs` surface) - `context/sce/agent-trace-payload-builder-validation.md` (historical Agent Trace builder/validation reference for the removed runtime surface) - `context/sce/agent-trace-pre-commit-staged-checkpoint.md` (historical pre-commit staged-checkpoint contract; current runtime baseline has replaced this path with a deterministic no-op) -- `context/sce/agent-trace-commit-msg-coauthor-policy.md` (current commit-msg canonical co-author trailer policy with enabled-by-default attribution hooks, explicit opt-out controls, `SCE_DISABLED` kill switch, caller-provided `ai_contribution_present` transformer seam wired from staged-diff AI-overlap preflight, idempotent dedupe, the `agent_trace::patches_have_overlap` pure overlap seam, the `StagedDiffAiOverlapResult` three-valued evidence gate, and `sce.hooks.commit_msg.ai_overlap_error` error logging) +- `context/sce/agent-trace-commit-msg-coauthor-policy.md` (current commit-msg canonical co-author trailer policy with enabled-by-default attribution hooks, explicit opt-out controls, `SCE_DISABLED` kill switch, caller-provided `ai_contribution_present` transformer seam wired from staged-diff AI-overlap preflight, idempotent dedupe, the `agent_trace::has_direct_evidence` fresh exact-match seam, the `StagedDiffAiOverlapResult` three-valued evidence gate, and `sce.hooks.commit_msg.ai_overlap_error` error logging) - `context/sce/agent-trace-post-commit-dual-write.md` (historical post-commit no-op/dual-write reference; current post-commit behavior is documented in `agent-trace-hooks-command-routing.md`) - `context/sce/agent-trace-hook-doctor.md` (approved operator-environment contract for broadening `sce doctor` into the canonical health-and-repair entrypoint, including stable problem taxonomy — now including `mutation_scope_health` — `--fix` semantics, repository-scoped Agent Trace DB reporting, post-commit Agent Trace auto-sync readiness proof and opt-out behavior, setup-to-doctor alignment rules, canonical Git-hook payload restoration, and the approved downstream human text-mode layout/status/integration contract) - `context/sce/doctor-human-text-contract.md` (implemented compact `sce doctor` human text contract: Environment/Repository/Integrations hierarchy, post-commit Agent Trace auto-sync readiness labels, `[PASS]`/`[WARN]`/`[FAIL]`/`[MISS]` status vocabulary, healthy-row metadata suppression, typed Claude Code/OpenCode/Pi/Codex target and area ordering, configured/detected/empty target resolution, selection-scoped optional-workflow inventory, the Codex hook trust/review reminder, the per-target `Mutation-scope health` row, the `SCE login` row under `auth.mode: mandatory`, no-installed-integrations guidance, and JSON as the full-detail route) @@ -102,7 +102,7 @@ Feature/domain context: - `context/sce/agent-trace-core-schema-migrations.md` (historical reference for removed local DB schema bootstrap behavior; T03 now implements the actual local DB with migrations) - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) -- `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,mixed,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact touched-line attribution counts from canonical `post_commit_patch` hunks reusing the same per-hunk classification, plus per-file trace data from patch inputs via `intersect_patches(constructed_patch, post_commit_patch)` then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation with a required lookup `url` derived from top-level `AgentTrace.id`, nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, optional canonical session links derived from matched touched-line provenance, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) +- `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,mixed,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact touched-line attribution counts from canonical `post_commit_patch` hunks reusing the same per-hunk classification, plus per-file trace data from fresh, exact, single-session direct evidence (`agent_trace/evidence.rs`) plus mutation-lineage evidence, then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation with a required lookup `url` derived from top-level `AgentTrace.id`, nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, optional canonical session links derived from matched touched-line provenance, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) - `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence with a best-effort canonical `git patch-id --stable` commit patch ID stored in nullable `agent_traces.commit_patch_id` (sanitized warning and `NULL` on failure), DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > bridge-derived chain state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, a main-session-scope-only bridge chain state-miss fallback that seeds a `source="bridge_inherited"` row for the current session (the one diff-trace path that writes state), `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes whose own model-less `SessionStart` bridge attempt cannot read its lazily created transcript, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — and the hidden non-fail-open `sce hooks claude-mutation-scope` first-concrete-adapter route (now registered by `sce setup`), deferring the full contracts to `context/cli/mutation-scope-hook-ingress.md` and `context/cli/claude-mutation-scope-integration.md`.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus current Rust evaluator seam and OpenCode/Claude delegation references, including config schema, argv-prefix matching, shell/nix unwrapping, custom-policy `satisfied_by` wrapper exemption, fixed preset catalog/messages, and precedence rules) diff --git a/context/glossary.md b/context/glossary.md index 97358c90e..39c45a585 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -85,7 +85,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`; `disabled` covers both the explicit opt-out and an access policy that disallows background cloud operations, and 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). +- `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 the `patch_for_locations` helpers; lineage projects every committed line, and deleted or renamed-away tracked paths stay tracked as absent files. 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_from_evidence(...)` 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`. - `ScopeProvenance`: Optional insert-once metadata keyed by a mutation `ScopeId`, storing canonical SCE `session_id` and nullable first-observed `model_id`. It is registered only at `Start` admission while the owning scope is `NeverSeen`, remains outside `ProtocolState` and the CAS transition, and is resolved during mutation projection. `ScopeId` proves mutation ownership; `ScopeProvenance` describes the owning scope. Model/session provenance is observational and never changes `AiExclusive`/`AiContended`/`IneligibleUnscoped` protocol decisions. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace_from_evidence(...)` 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. @@ -229,9 +229,8 @@ - `TouchedLineKind`: Enum in `cli/src/services/patch.rs` classifying a touched line as `Added` or `Removed`; serialized as `snake_case` JSON strings; derives `Hash` to support set-based intersection operations - `PatchLoadError`: Error type in `cli/src/services/patch.rs` produced when `load_patch_from_json` encounters invalid JSON or a payload that does not match the expected `ParsedPatch` structure; carries an actionable `message` field - `load_patch_from_json`: Public function in `cli/src/services/patch.rs` that reconstructs a `ParsedPatch` from a JSON string; storage-agnostic entrypoint for callers who have already read serialized JSON content from a database, file, or other source -- `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 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 +- `HunkContributor`: Enum in `cli/src/services/agent_trace.rs` classifying a `post_commit_patch` hunk from its per-line attributions (AI-exclusive lineage first, non-exclusive lineage blocking, then fresh exact single-session direct evidence): `Ai` (every touched line attributed), `Mixed` (a non-empty proper subset attributed), `Unknown` (no line attributed); serialized as `snake_case` JSON strings +- `trusted direct evidence`: A `diff_traces` candidate that may attribute a committed line lineage left unresolved: recorded after the parent commit's committer-time second, an exact `(file, kind, line_number, content)` match, and the only session with an exact candidate for that line. Matching lives in `cli/src/services/agent_trace/evidence.rs` - `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. diff --git a/context/overview.md b/context/overview.md index 0eaa0537f..bcf379a9e 100644 --- a/context/overview.md +++ b/context/overview.md @@ -45,7 +45,7 @@ for `/validate`. See 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 145-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude, Pi, and Codex; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer). Each of the six catalog workflow skills (not `sce-decision`, which has no user-facing entrypoint on any target) additionally carries a Codex-only `agents/openai.yaml`, rendered by `config/pkl/renderers/codex-metadata.pkl` from the shared catalog's `title`/`description` plus an authored `default_prompt`, with `policy.allow_implicit_invocation: false` so these stateful lifecycle workflows activate only from explicit `$sce-` invocation or Codex's `/skills` discovery, never from conversational relevance alone. The Codex renderer also emits a Codex hook registration file (`config/.codex/hooks.json`) and its install-guidance helper (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`). The existing conversation/diff registrations (`UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash`, `PostToolUse` for `apply_patch`) route through `sce hooks codex` and retain their fail-open behavior where designed. The separate mutation-scope registrations route through `sce hooks codex-mutation-scope`: `PreToolUse` and `PostToolUse` use matcher `^(Bash|apply_patch)$`, while `Stop`, `Interrupt`, `SubagentStop`, and `SessionEnd` omit matcher and are unmatched. The tracked mutation `PreToolUse` bootstrap fails closed if Git-root resolution, helper or `sce` discovery, adapter startup, runtime `Start`, or Bash-policy evaluation cannot establish attribution; mutation-scope `PostToolUse` and cleanup hooks do not use this bootstrap behavior. Delegation, MCP, and unknown tools do not match these generated mutation Pre/Post registrations. `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`; setup merges the user-owned `.codex/hooks.json` through the shared structural Codex hook-config service, preserving unrelated valid handlers and rejecting malformed or Codex-invalid documents before the atomic swap. `sce doctor` diagnoses that file per required registration (`PresentAndCurrent`/`Missing`/`Stale`, or `Malformed` for the whole document) rather than by whole-document equality, reporting the mutation-scope registrations under distinct `#(mutation-scope)` rows separate from the `sce hooks codex` rows, and separately reports whether Codex has actually marked each structurally current registration trusted and whether its effective hook-discovery policy allows project hooks, by reading (never writing) Codex's own `$CODEX_HOME/config.toml` and policy state; `sce doctor --fix` repairs structurally unhealthy registrations through the same merge service but never touches trust or policy state (see `context/sce/doctor-human-text-contract.md`). `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while invalid cwd/path resolution and malformed STDIN payloads fail open with empty stdout, reported model IDs are preserved without fabricated provider prefixes, and missing/blank apply_patch sessions produce no evidence. Delete-File operations and Bash filesystem effects do not become `diff_traces` in this existing conversation/diff pipeline, and its apply_patch evidence has separate operation limitations. Separately, the Codex mutation-scope adapter tracks `Bash` and `apply_patch` executions as `TrackedMutation` scopes; `AiExclusive` means tracked-scope exclusivity, not that Bash authored every mutation in the interval. -It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. A new pure, dependency-free `cli/src/services/mutation_trace/` module now exists as a Rust refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol; it currently carries domain types and the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — is registered without a module-wide dead-code allowance (only declaration-level, reasoned exceptions remain, and snapshot-ref reconciliation is wired explicitly through `sce doctor --fix`; automatic reclamation is a deferred follow-up). A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every current or future harness adapter must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are now driven by one harness-neutral CLI ingress, the hidden `sce hooks mutation-scope` command (`context/cli/mutation-scope-hook-ingress.md`), which reads a single normalized JSON lifecycle object from STDIN, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, maps it to a `RuntimeBoundary` (`start`/`advance`/`close`/`flush`) or an `abandon_scope()` call forwarding `scope_id`/`event_id`/`actor_kind` verbatim (any `worktree_id` key is refused — worktree identity is derived by the runtime from the invoking checkout), and invokes the runtime with a lazy DB provider closure so DB acquisition stays inside the protected-worktree ordering. Unlike the fail-open `diff-trace`/`conversation-trace` intakes, this ingress never discards a valid boundary: it classifies results by durable completion, returning a non-zero `CliError` for a malformed payload or a pre-completion runtime error while treating the two carried-outcome variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion` — the durable transition succeeded and only the trailing external-taint marker cleanup failed) as empty-stdout success without retrying the transition. Every successful execution emits empty stdout and writes `mutation_trace_*` rows only. It is the generic transport seam. Concrete harness lifecycle adapters for Claude Code and Codex are wired to it in-process via a `pub(crate)` seam rather than by re-invoking the ingress command; they map each harness's lifecycle events onto one mutation `ScopeId` per mutation-capable tool execution with write-ahead fail-closed `Start`, terminal `Close`, cleanup, recovery barriers, and harness-specific unsupported-boundary handling. A Pi adapter driver (`cli/src/services/hooks/pi_mutation_scope/`) also now consumes this same `pub(crate)` in-process seam through its hidden `sce hooks pi-mutation-scope` command and canonical generated Pi extension, with a `PendingStart`→`Executed`→`Closed`/`PendingAbandon` attempt-phase lifecycle keyed on Pi's `tool_result` event rather than `tool_execution_start` (see `context/cli/pi-mutation-scope-integration.md`); ordinary Pi sessions now reach it, and its human `user_bash` path uses the separate guarded external-mutation supervisor. Codex execution identity and `ScopeId`/`EventId` derivation are owned by `context/cli/codex-mutation-scope-integration.md`. A separate **read-only** consumer of the same module's persisted mutation-event history is wired, though: the post-commit Agent Trace flow calls `mutation_trace::runtime::resolve_post_commit_mutation_ai_patch(...)` to attribute committed touched lines that direct `diff_traces` evidence does not cover, by replaying the newest 128 events for the invoking worktree (bounded also by a commit attribution cut captured under the worktree lock) oldest-to-newest as one causal per-line provenance lineage rather than matching historical patches independently, resolving the worktree's *existing* checkout identity only, creating no identity and writing no mutation-cursor state, and never inserting into `diff_traces` or `post_commit_patch_intersections` (see `context/cli/mutation-trace-agent-attribution.md` and `context/sce/agent-trace-hooks-command-routing.md`). See also `context/cli/mutation-trace-protocol.md`, `context/cli/mutation-trace-runtime-coordinator.md`, `context/cli/mutation-trace-ref-reconciliation.md`, `context/cli/mutation-trace-protected-worktree.md`, `context/cli/mutation-trace-scope-abandonment.md`, `context/cli/mutation-scope-runtime.md`, `context/cli/mutation-scope-hook-ingress.md`, `context/cli/claude-mutation-scope-integration.md`, and `context/cli/mutation-trace-external-mutation-guard.md` (the harness-neutral process supervisor that lets Pi's `user_bash` mutate a worktree, guarded by the same `WorktreeLock`, without ever creating a scope). +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 without a module-wide dead-code allowance (only declaration-level, reasoned exceptions remain, and snapshot-ref reconciliation is wired explicitly through `sce doctor --fix`; automatic reclamation is a deferred follow-up). A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every current or future harness adapter must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are now driven by one harness-neutral CLI ingress, the hidden `sce hooks mutation-scope` command (`context/cli/mutation-scope-hook-ingress.md`), which reads a single normalized JSON lifecycle object from STDIN, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, maps it to a `RuntimeBoundary` (`start`/`advance`/`close`/`flush`) or an `abandon_scope()` call forwarding `scope_id`/`event_id`/`actor_kind` verbatim (any `worktree_id` key is refused — worktree identity is derived by the runtime from the invoking checkout), and invokes the runtime with a lazy DB provider closure so DB acquisition stays inside the protected-worktree ordering. Unlike the fail-open `diff-trace`/`conversation-trace` intakes, this ingress never discards a valid boundary: it classifies results by durable completion, returning a non-zero `CliError` for a malformed payload or a pre-completion runtime error while treating the two carried-outcome variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion` — the durable transition succeeded and only the trailing external-taint marker cleanup failed) as empty-stdout success without retrying the transition. Every successful execution emits empty stdout and writes `mutation_trace_*` rows only. It is the generic transport seam. Concrete harness lifecycle adapters for Claude Code and Codex are wired to it in-process via a `pub(crate)` seam rather than by re-invoking the ingress command; they map each harness's lifecycle events onto one mutation `ScopeId` per mutation-capable tool execution with write-ahead fail-closed `Start`, terminal `Close`, cleanup, recovery barriers, and harness-specific unsupported-boundary handling. A Pi adapter driver (`cli/src/services/hooks/pi_mutation_scope/`) also now consumes this same `pub(crate)` in-process seam through its hidden `sce hooks pi-mutation-scope` command and canonical generated Pi extension, with a `PendingStart`→`Executed`→`Closed`/`PendingAbandon` attempt-phase lifecycle keyed on Pi's `tool_result` event rather than `tool_execution_start` (see `context/cli/pi-mutation-scope-integration.md`); ordinary Pi sessions now reach it, and its human `user_bash` path uses the separate guarded external-mutation supervisor. Codex execution identity and `ScopeId`/`EventId` derivation are owned by `context/cli/codex-mutation-scope-integration.md`. A separate **read-only** consumer of the same module's persisted mutation-event history is wired, though: the post-commit Agent Trace flow calls `mutation_trace::runtime::resolve_post_commit_mutation_attribution(...)` to attribute every committed touched line, taking precedence over fresh, exact, single-session direct `diff_traces` evidence, by replaying the newest 128 events for the invoking worktree (bounded also by a commit attribution cut captured under the worktree lock) oldest-to-newest as one causal per-line provenance lineage rather than matching historical patches independently, resolving the worktree's *existing* checkout identity only, creating no identity and writing no mutation-cursor state, and never inserting into `diff_traces` or `post_commit_patch_intersections` (see `context/cli/mutation-trace-agent-attribution.md` and `context/sce/agent-trace-hooks-command-routing.md`). See also `context/cli/mutation-trace-protocol.md`, `context/cli/mutation-trace-runtime-coordinator.md`, `context/cli/mutation-trace-ref-reconciliation.md`, `context/cli/mutation-trace-protected-worktree.md`, `context/cli/mutation-trace-scope-abandonment.md`, `context/cli/mutation-scope-runtime.md`, `context/cli/mutation-scope-hook-ingress.md`, `context/cli/claude-mutation-scope-integration.md`, and `context/cli/mutation-trace-external-mutation-guard.md` (the harness-neutral process supervisor that lets Pi's `user_bash` mutate a worktree, guarded by the same `WorktreeLock`, without ever creating a scope). The generated `/next-task` workflow persists task-level context-synchronization lifecycle state in each plan (`pending`, `synced`, or `blocked`) so unresolved task synchronization debt survives a session boundary and gates new implementation. Successful `/next-task` execution hands task synchronization an explicit, pre-edit-Git-baseline-relative changed-file list plus implementation, verification, done-check, plan-update, and context-impact evidence, recorded directly on the completed task (`Completed`, `Files changed`, `Result`, `Verify`, `Context impact`, `Context synchronization`); the five-file root context pass remains mandatory. A later-session sync-debt retry reads that same completed task record directly from the plan by plan path and task ID, with no separate persisted synchronization handoff. `/validate` is validation-only: it runs final checks, writes the Validation Report, and reports `validated`, `failed`, or `blocked` without plan-level context synchronization. diff --git a/context/sce/agent-trace-commit-msg-coauthor-policy.md b/context/sce/agent-trace-commit-msg-coauthor-policy.md index 0ecc3135d..28d977ac8 100644 --- a/context/sce/agent-trace-commit-msg-coauthor-policy.md +++ b/context/sce/agent-trace-commit-msg-coauthor-policy.md @@ -33,17 +33,16 @@ ## Staged AI-overlap evidence gate -- `cli/src/services/agent_trace.rs` owns the pure patch-overlap helper (`patches_have_overlap`) for Agent Trace evidence checks; fixture-backed unit coverage in `cli/src/services/agent_trace/tests.rs` covers overlap, no-overlap, empty/untouched patches, and Claude structured-patch-derived input. +- `cli/src/services/agent_trace.rs` owns the pure direct-evidence predicate `has_direct_evidence`, which reports an exact `(file, kind, line_number, content)` touched-line match between candidate rows and the staged patch. Historical `(kind, content)`-only similarity is not evidence. - `cli/src/services/hooks/mod.rs` owns the staged-diff AI-overlap evidence gate, which is now wired into `run_commit_msg_subcommand_in_repo`: - `StagedDiffAiOverlapResult` enum (`Overlap`/`NoOverlap`/`Error`) is the three-valued result from the injectable `_with` variant, enabling testable branch coverage and caller-side error logging. - - `staged_diff_has_ai_overlap_with` is the injectable variant that accepts staged-patch/time/recent-trace dependencies and returns `StagedDiffAiOverlapResult`; available for future test coverage. + - `staged_diff_has_ai_overlap_with` is the injectable variant that accepts staged-patch, `HEAD` commit time, clock, and recent-trace dependencies and returns `StagedDiffAiOverlapResult`; `hooks/commit_hooks/attribution_tests.rs` covers stale, historical-only, and fresh exact rows. - `staged_diff_has_ai_overlap` is the live wrapper that opens Agent Trace DB through the no-migration hook path, delegates to `_with`, and logs `sce.hooks.commit_msg.ai_overlap_error` on `Error` results. - Live helper path: - opens Agent Trace DB through `RepositoryAgentTraceDb::open_for_hooks_without_migrations_at()` and `ensure_schema_ready_for_hooks()`; - captures the staged patch with `git diff --cached --patch --no-ext-diff`; - - queries recent diff traces using the same bounded 7-day window as post-commit; - - combines each recent patch and checks overlap through `agent_trace::patches_have_overlap`, which uses the existing patch intersection primitive; - - short-circuits on the first positive overlap. + - queries diff traces recorded after the current `HEAD` commit's committer-time second, bounded by 7 days (`direct_evidence_cutoff_ms`; with no `HEAD`, just the 7-day bound), so rows describing already-committed work are ignored; + - reports `Overlap` only when a fresh row exactly matches a staged touched line through `agent_trace::has_direct_evidence`. - No-evidence/error posture: DB open/readiness failure, staged-diff capture/parse failure, clock/query failure, empty staged diff, no recent rows, malformed-only rows, or zero overlap all return `StagedDiffAiOverlapResult::Error` or `NoOverlap`, both mapping to `ai_contribution_present = false`. There is no fail-open mode. ## Verification evidence diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index 11417e372..09cd4e499 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -167,12 +167,12 @@ Post-commit intersection rows are written by the active `post-commit` hook flow ## Staged-diff AI-overlap evidence gate -`cli/src/services/agent_trace.rs` owns the pure patch-overlap helper `patches_have_overlap`, which is consumed by the commit-msg staged-diff AI-overlap evidence gate in `cli/src/services/hooks/mod.rs`: +`cli/src/services/agent_trace.rs` owns the pure direct-evidence predicate `has_direct_evidence`, which is consumed by the commit-msg staged-diff AI-overlap evidence gate in `cli/src/services/hooks/commit_hooks.rs`: -- `patches_have_overlap(staged_patch, recent_patch)` returns `true` when the staged diff and a recent AI/editor diff trace share at least one touched line, and `false` otherwise (including empty/untouched patches). This is the pure boolean predicate used by the commit-msg evidence gate. +- `has_direct_evidence(candidate_rows, staged_patch)` returns `true` only when a candidate row exactly matches a staged touched line on file identity, `kind`, `line_number`, and `content`. Candidate rows are limited to those recorded after the current `HEAD` commit, so stale and historical-only similarity never add the trailer. - `StagedDiffAiOverlapResult` (`Overlap`/`NoOverlap`/`Error`) is the three-valued result from the injectable `staged_diff_has_ai_overlap_with` variant, enabling testable branch coverage and caller-side error logging. - `staged_diff_has_ai_overlap` is the live wrapper that opens Agent Trace DB through the no-migration hook path, delegates to `_with`, and logs `sce.hooks.commit_msg.ai_overlap_error` on `Error` results. - The commit-msg evidence gate invokes the preflight only when the attribution gate passes (`attribution_hooks_enabled && !sce_disabled`); both `NoOverlap` and `Error` map to `ai_contribution_present = false`, suppressing the trailer. There is no fail-open mode. -- Fixture-backed unit coverage for `patches_have_overlap` lives in `cli/src/services/agent_trace/tests.rs`, covering overlap, no-overlap, empty/untouched patches, and Claude structured-patch-derived input. +- Regression coverage lives in `cli/src/services/hooks/commit_hooks/attribution_tests.rs`. See also: [shared-turso-db.md](shared-turso-db.md), [local-db.md](local-db.md), [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md), [agent-trace-commit-msg-coauthor-policy.md](agent-trace-commit-msg-coauthor-policy.md), [context-map.md](../context-map.md) diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index 73bfae75d..c7d099354 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -44,7 +44,7 @@ Interactive `sce setup` persists the independent confirmations at `agent_trace.a - Reads the message file as UTF-8. - Applies exactly one canonical trailer: `Co-authored-by: SCE `. - Writes back only when the attribution gate is enabled, `SCE_DISABLED` is false, the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`), and the transformed content differs. - - The staged-diff AI-overlap preflight is wired into `run_commit_msg_subcommand_in_repo`: it resolves repository-scoped Agent Trace storage, captures the staged diff via `git diff --cached`, queries recent repository-level diff traces from the past 7 days, and checks overlap through `agent_trace::patches_have_overlap` with short-circuit on first positive match. + - The staged-diff AI-overlap preflight is wired into `run_commit_msg_subcommand_in_repo`: it resolves repository-scoped Agent Trace storage, captures the staged diff via `git diff --cached`, queries repository-level diff traces recorded after the current `HEAD` commit's committer-time second (bounded by the past 7 days; just the 7 days when there is no `HEAD`), and reports overlap through `agent_trace::has_direct_evidence` only for an exact `(file, kind, line_number, content)` match. Stale rows and historical `(kind, content)`-only similarity never add the trailer. - 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; `Error` results are logged via `sce.hooks.commit_msg.ai_overlap_error`. - The preflight is invoked only when the attribution gate passes; when the gate does not pass, no DB read or staged-diff capture occurs. - `pre-commit` is a deterministic no-op entrypoint. @@ -52,19 +52,18 @@ Interactive `sce setup` persists the independent confirmations at `agent_trace.a - Agent Trace DB access resolves repository-scoped storage from `agent_trace.repository_id` or the configured Git remote, then opens `/sce/repos//agent-trace.db` through the repository storage resolver. - The resolver tries a no-migration open/readiness check plus repository metadata validation first and falls back to migration-running initialization when the repository DB is absent or schema metadata is incomplete. - Captures the current commit's patch from git using `capture_post_commit_patch_from_git()`. - - Queries recent `diff_traces` patches from the past 7 days via `RepositoryAgentTraceDb::recent_diff_trace_patches()`. + - Queries `diff_traces` patches via `RepositoryAgentTraceDb::recent_diff_trace_patches()` from `direct_evidence_cutoff_ms`: the later of 7 days ago and one second after the parent commit's committer time (`HEAD^`; a root commit uses only the 7-day bound). Rows recorded before the parent commit describe already-committed work and are not loaded. - Recent-row parsing dispatches on `payload_type`: `patch` rows parse through existing `parse_patch`, while `structured` rows parse stored JSON through `structured_patch::derive_claude_structured_patch` at read time to produce `ParsedPatch`. - Parsed `PatchHunk` entries carry nullable row `model_id` for both paths, so combined/intersection patch inputs retain per-hunk model provenance for downstream Agent Trace attribution building. - - Combines valid recent patches in chronological order via `patch::combine_patches`. - - Intersects the combined recent patch with the post-commit patch via `patch::intersect_patches`. - - Persists the serialized intersection result to `post_commit_patch_intersections` table with commit metadata (OID, timestamp), window bounds (cutoff_ms, end_ms), and loaded/skipped counts. + - Keeps each fresh row's patch separate (`direct_patches`) and projects trusted direct evidence through `agent_trace::direct_evidence_patch`: exact touched-line matches whose candidates all come from one session. + - Persists the serialized trusted direct projection to `post_commit_patch_intersections` table with commit metadata (OID, timestamp), window bounds (cutoff_ms, end_ms), and loaded/skipped counts. - Empty recent patch set produces deterministic empty intersection result (no crash). - - 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. + - Internal orchestration now returns a typed `PostCommitIntersectionFlowResult` (`direct_patches`, `intersection_patch`, `post_commit_data`, optional `tool_name`, optional `tool_version`) from `run_post_commit_intersection_flow_with()`, where tool metadata comes from the newest fresh row with an exact overlap and falls back to `None`. - `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 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 Git dir and topology-derived worktree ID (`resolve_git_dir` + `resolve_worktree_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 run-flow path maps commit-time metadata to RFC3339 and calls `agent_trace::build_agent_trace_from_evidence(...)` with separated evidence: per-row `direct_patches`, plus the mutation-AI and resolved non-AI line sets from bounded lineage over every committed line. Lineage takes precedence over direct candidates line by line. See [agent-trace-minimal-generator.md](agent-trace-minimal-generator.md). + - The mutation evidence comes from `mutation_trace::runtime::resolve_post_commit_mutation_attribution(...)`, a read-only entrypoint that resolves the invoking worktree's Git dir and topology-derived worktree ID (`resolve_git_dir` + `resolve_worktree_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 result — trusted 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; 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. @@ -137,11 +136,12 @@ Interactive `sce setup` persists the independent confirmations at `agent_trace.a - `sce hooks codex-mutation-scope` (hidden) is the second concrete harness adapter: it reads raw Codex lifecycle events and drives the same in-process seam with one tracked scope per `Bash`/`apply_patch` execution, write-ahead fail-closed admission, proven terminal close, identity-scoped cleanup, and checkout-local recovery bookkeeping. MCP and unknown tools remain usable but are untracked and outside individual mutation attribution; their lifecycle is not treated as read-only. It is registered by `sce setup --codex` alongside the existing `sce hooks codex` conversation/diff contract. Full mapping and the tested Codex 0.153.4 boundary are in [../cli/codex-mutation-scope-integration.md](../cli/codex-mutation-scope-integration.md). - `sce hooks codex-mutation-scope` (hidden) is the second concrete harness adapter: it reads raw Codex lifecycle events and drives the same in-process seam with one tracked scope per `Bash`/`apply_patch` execution, write-ahead fail-closed admission, proven terminal close, identity-scoped cleanup, and checkout-local recovery bookkeeping. MCP and unknown tools remain usable but are untracked and outside individual mutation attribution; their lifecycle is not treated as read-only. It is registered by `sce setup --codex` alongside the existing `sce hooks codex` conversation/diff contract. Full mapping and the tested Codex 0.153.4 boundary are in [../cli/codex-mutation-scope-integration.md](../cli/codex-mutation-scope-integration.md). -The `mutation_provenance_e2e` regressions in `cli/src/services/hooks/mod.rs` -exercise both concrete adapters through a real repository, commit, mutation -projection, and repository DB write. They verify the final Agent Trace JSON -contains mutation-derived model/session provenance while direct evidence tables -remain separate. +`persisted_agent_trace_keeps_causal_session_and_drops_stale_sessions` in +`cli/src/services/hooks/commit_hooks/attribution_tests.rs` exercises a dated +Git history, a real repository DB, `MutationTraceStore`, and the +`agent_traces` write. It verifies the persisted, schema-valid Agent Trace JSON +keeps the causal session and model and contains no stale session, while +`post_commit_patch_intersections` holds only fresh direct evidence. ## Commit patch ID helper diff --git a/context/sce/agent-trace-minimal-generator.md b/context/sce/agent-trace-minimal-generator.md index 5c5928010..1d46d617f 100644 --- a/context/sce/agent-trace-minimal-generator.md +++ b/context/sce/agent-trace-minimal-generator.md @@ -4,34 +4,32 @@ Rust library seam at `cli/src/services/agent_trace.rs` that produces the minimal ## Contract -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 (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`. `build_agent_trace_from_evidence(...)` with an empty `mutation_ai_patch` produces exactly this classification. -4. Map `Conversation.contributor.model_id` from the matched direct and mutation hunks when contributor type is `ai` or `mixed`: an absent evidence source does not count as unknown, one present model passes through, and both present models must be known and equal. Omit `model_id` when both sources are present but either model is missing or they disagree. A hunk classified `ai`/`mixed` purely through mutation-derived coverage may therefore emit the mutation model. -5. For each emitted conversation, derive optional `conversation.related` entries from non-empty `session_id` values on touched lines in both matched evidence hunks; 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 direct lines, while mutation reconstruction supplies scope provenance on mutation-attributed lines. +Given per-row direct candidate patches, mutation lineage evidence, and a `post_commit_patch` (canonical source of truth): + +1. Resolve every `post_commit_patch` touched line independently with the precedence in [../cli/mutation-trace-agent-attribution.md](../cli/mutation-trace-agent-attribution.md#evidence-precedence): AI-exclusive mutation lineage wins; non-exclusive mutation lineage keeps the line non-AI; otherwise a fresh, exact `(file, kind, line_number, content)`, single-session direct candidate attributes it; otherwise the line is unattributed. Historical `(kind, content)` similarity never attributes a line. +2. Classify each `post_commit_patch` hunk from its attributed lines: + - **`ai`** — every touched line in the hunk is attributed. + - **`mixed`** — a non-empty proper subset of the hunk's touched lines is attributed. + - **`unknown`** — no touched line in the hunk is attributed. +3. Map `Conversation.contributor.model_id` for `ai` / `mixed` hunks only when every attributed line has the same known model. A mutation-attributed line uses its scope model, falling back to a same-session exact direct candidate's model. +4. Derive optional `conversation.related` entries from the sessions of attributed lines only, emitted as `{ "type": "session", "url": "https://sce.crocoder.dev/sessions/" }`, deduplicated and deterministically ordered. Overridden or conflicting direct candidates never appear. +5. Emit the top-level `tool` object only when at least one final line attribution is supported by a direct candidate. 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 only supported generator entrypoint. It handles direct-only evidence (empty `mutation_ai_patch`), mutation-only evidence, and combined evidence through one path, classifying from two independent AI-evidence sources: - -- `evidence.direct_patch` — the reconstructed direct patch, intersected against `post_commit_patch` internally exactly as before. It supplies direct model/session provenance and remains the sole source of the top-level `tool` object (still omitted when the direct intersection is empty). -- `evidence.mutation_ai_patch` — a target-shaped set of committed touched lines with resolved scope provenance 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 widens combined AI coverage for hunk classification and supplies mutation model/session metadata when available. - -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_from_evidence(evidence, post_commit_patch, metadata)` is the only supported generator entrypoint. `AgentTraceEvidence` keeps the sources separate: +- `direct_patches` — one `ParsedPatch` per freshness-bounded `diff_traces` row, never combined, so conflicting sessions and duplicate occurrences stay visible. Matching lives in `agent_trace/evidence.rs`. +- `mutation_ai_patch` — target-shaped committed lines that causal lineage attributed to an AI-exclusive scope, with resolved scope provenance. +- `mutation_non_ai_patch` — target-shaped committed lines that lineage resolved to a non-exclusive transition; these block direct attribution. ## 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 mutation-AI coverage with optional scope provenance) | +| `AgentTraceEvidence` | Builder input: borrowed per-row `direct_patches`, target-shaped `mutation_ai_patch` (with scope provenance), and `mutation_non_ai_patch` | | `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` | @@ -45,12 +43,13 @@ Per hunk, a `post_commit_patch` touched line is covered when it pairs one-to-one | `LineChangeAttribution` | `metadata.sce.line_changes` shape: `{ ai, mixed, unknown }`, each a `LineChangeCounts`, `#[serde(default)]` | | `AgentTrace` | Top-level payload: `version`, `id`, `timestamp`, optional `vcs`, optional `tool`, `metadata`, `files` | -All types are `serde`-serializable with `snake_case` field naming. `Conversation.url` is always serialized as `https://sce.crocoder.dev/conversations/{agent_trace.id}` for the generated top-level trace ID. `Conversation.contributor` serializes as a nested object with a JSON field named `type`; `model_id` is present only when the combined direct/mutation evidence has one agreed concrete value. `Conversation.related` is optional and omitted when `None` (`skip_serializing_if = "Option::is_none"`) and populated from matched direct and mutation touched-line `session_id` provenance as deduplicated session links. +All types are `serde`-serializable with `snake_case` field naming. `Conversation.url` is always serialized as `https://sce.crocoder.dev/conversations/{agent_trace.id}` for the generated top-level trace ID. `Conversation.contributor` serializes as a nested object with a JSON field named `type`; `model_id` is present only when every attributed line in the hunk has one agreed concrete value. `Conversation.related` is optional and omitted when `None` (`skip_serializing_if = "Option::is_none"`) and populated from the sessions of attributed lines as deduplicated session links. -The persisted Agent Trace path is covered with real Claude and Codex `Bash` -mutations: mutation-only AI hunks retain their resolved model and canonical -session link, while direct evidence remains a separate source. This preserves -the boundary that mutation protocol attribution is not itself scope provenance. +The persisted Agent Trace path is covered by +`cli/src/services/hooks/commit_hooks/attribution_tests.rs`: lineage-attributed +hunks retain their resolved model and canonical session link, stale or +overridden direct candidates never reach the payload, and mutation protocol +attribution is not itself scope provenance. ## Payload shape @@ -119,7 +118,7 @@ Current output includes top-level metadata fields with this contract: ## Relationship to existing patch service -Consumes `intersect_patches` and `ParsedPatch`/`PatchHunk`/`TouchedLine` types from `cli/src/services/patch.rs`. Does not introduce a separate patch model. +Consumes `ParsedPatch`/`PatchHunk`/`TouchedLine` types and the path-identity helper `paths_refer_to_same_file` from `cli/src/services/patch.rs`. It does not introduce a separate patch model. ## Out of scope diff --git a/context/sce/codex-apply-patch-diff-runtime.md b/context/sce/codex-apply-patch-diff-runtime.md index 2860c1ced..40adff715 100644 --- a/context/sce/codex-apply-patch-diff-runtime.md +++ b/context/sce/codex-apply-patch-diff-runtime.md @@ -44,10 +44,11 @@ the idempotent `cx_` session prefix, and a reported non-blank model ID preserved without a fabricated provider prefix. Timestamp failure skips the insert. Every successful or fail-open path returns empty stdout. -After commit, the existing `combine_patches` and `intersect_patches` behavior -uses the event-scoped identities and historical `kind`+`content` fallback. It -does not prove which physical occurrence of repeated identical content came -from a particular event. Delete File, pure rename, and Bash-created filesystem +After commit, attribution-grade direct matching requires exact line numbers, +so these event-scoped synthetic identities never attribute a committed line; +Codex edits are attributed through Codex mutation scopes and causal lineage +instead. Synthetic identities do not prove which physical occurrence of +repeated identical content came from a particular event. Delete File, pure rename, and Bash-created filesystem mutations have no line-level evidence in this complementary pipeline. This path writes only `diff_traces`; the Codex mutation-scope adapter writes diff --git a/flake.nix b/flake.nix index 0da586de1..7cce1a470 100644 --- a/flake.nix +++ b/flake.nix @@ -283,7 +283,6 @@ (pkgs.lib.fileset.maybeMissing ./.version) (pkgs.lib.fileset.maybeMissing ./cli/src/services/default_paths.rs) (pkgs.lib.fileset.maybeMissing ./cli/src/services/agent_trace/fixtures) - (pkgs.lib.fileset.maybeMissing ./cli/src/services/patch/fixtures) (pkgs.lib.fileset.maybeMissing ./cli/src/services/structured_patch/fixtures) (pkgs.lib.fileset.maybeMissing ./cli/src/services/hooks/claude_mutation_scope/fixtures) (pkgs.lib.fileset.maybeMissing ./cli/src/services/hooks/codex_mutation_scope/fixtures) From cc891cfa5bbea1bcacc5e83cf0c657c47cd24bcd Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 6 Nov 2026 17:19:19 +0100 Subject: [PATCH 02/11] runtime: Anchor direct evidence to repository file identity Replace suffix-based file matching with normalized repository-relative identity, rejecting paths outside the repository and resolving structured Claude paths against their recorded cwd. Preserve each direct-trace row through attribution so conflicting sessions are rejected and tool metadata is derived only from accepted final evidence. Update the co-author preflight, post-commit builder, tests, and context documentation to enforce the same fail-closed rules. Co-authored-by: SCE --- cli/src/services/agent_trace.rs | 97 ++-- cli/src/services/agent_trace/evidence.rs | 162 ++++-- .../services/agent_trace/repository_path.rs | 202 +++++++ cli/src/services/agent_trace/tests.rs | 527 +++++++++++++++++- cli/src/services/agent_trace_db/mod.rs | 21 +- cli/src/services/hooks/commit_hooks.rs | 141 ++--- .../hooks/commit_hooks/attribution_tests.rs | 307 ++++++++-- .../runtime/mutation_attribution/tests.rs | 11 +- cli/src/services/patch.rs | 34 -- context/architecture.md | 2 +- .../cli/mutation-trace-agent-attribution.md | 20 +- context/cli/patch-service.md | 4 +- context/context-map.md | 2 +- .../agent-trace-commit-msg-coauthor-policy.md | 6 +- context/sce/agent-trace-db.md | 4 +- .../sce/agent-trace-hooks-command-routing.md | 8 +- context/sce/agent-trace-minimal-generator.md | 15 +- 17 files changed, 1273 insertions(+), 290 deletions(-) create mode 100644 cli/src/services/agent_trace/repository_path.rs diff --git a/cli/src/services/agent_trace.rs b/cli/src/services/agent_trace.rs index ff2887684..a0c9157fb 100644 --- a/cli/src/services/agent_trace.rs +++ b/cli/src/services/agent_trace.rs @@ -1,16 +1,3 @@ -//! Minimal agent-trace domain model and hunk classification contract. -//! -//! This module defines the domain types for producing a minimal agent-trace -//! JSON payload from patch data. Each `post_commit_patch` touched line is -//! resolved independently: AI-exclusive mutation lineage wins, non-exclusive -//! mutation lineage blocks direct evidence, and otherwise only an exact, -//! single-session direct match attributes the line. -//! -//! Classification rules: -//! - every touched line in the `post_commit_patch` hunk is attributed => `ai` -//! - some touched lines in the hunk are attributed => `mixed` -//! - no touched line in the hunk is attributed => `unknown` - use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, error::Error, @@ -35,9 +22,14 @@ use super::patch::{ use super::version::PACKAGE_VERSION; mod evidence; +mod repository_path; +pub use evidence::DirectEvidenceSource; +use evidence::{ + accepted_direct_tool, direct_line_evidence, AcceptedDirectLine, DirectLineEvidence, +}; pub(crate) use evidence::{direct_evidence_patch, has_direct_evidence}; -use evidence::{direct_line_evidence, DirectLineEvidence}; +pub use repository_path::RepositoryRoot; pub const AGENT_TRACE_VERSION: &str = "0.1.0"; pub(crate) const SCE_WEB_BASE_URL: &str = "https://sce.crocoder.dev"; @@ -99,13 +91,12 @@ pub struct AgentTraceMetadataInput<'a> { pub commit_timestamp: &'a str, pub commit_revision: &'a str, pub vcs_type: Option, - pub tool_name: Option<&'a str>, - pub tool_version: Option<&'a str>, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct AgentTraceEvidence<'a> { - pub direct_patches: &'a [ParsedPatch], + pub repository_root: &'a RepositoryRoot, + pub direct_sources: &'a [DirectEvidenceSource<'a>], pub mutation_ai_patch: &'a ParsedPatch, pub mutation_non_ai_patch: &'a ParsedPatch, } @@ -353,7 +344,7 @@ type MutationLineKey<'a> = (&'a str, TouchedLineKind, u64, &'a str); #[derive(Clone, Debug, Eq, PartialEq)] struct ResolvedLine { provenance: PatchLineProvenance, - direct_supported: bool, + direct_sources: BTreeSet, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -424,25 +415,27 @@ fn resolve_line( direct: Option<&DirectLineEvidence>, ) -> Option { match (mutation, direct) { - (MutationLineEvidence::Ai(mutation), Some(DirectLineEvidence::Unique(direct))) - if direct.session_id == mutation.session_id => - { - Some(ResolvedLine { - provenance: PatchLineProvenance { - session_id: mutation.session_id, - model_id: mutation.model_id.or_else(|| direct.model_id.clone()), - }, - direct_supported: true, - }) - } + ( + MutationLineEvidence::Ai(mutation), + Some(DirectLineEvidence::Accepted(AcceptedDirectLine { + provenance: direct, + sources, + })), + ) if direct.session_id == mutation.session_id => Some(ResolvedLine { + provenance: PatchLineProvenance { + session_id: mutation.session_id, + model_id: mutation.model_id.or_else(|| direct.model_id.clone()), + }, + direct_sources: sources.clone(), + }), (MutationLineEvidence::Ai(mutation), _) => Some(ResolvedLine { provenance: mutation, - direct_supported: false, + direct_sources: BTreeSet::new(), }), - (MutationLineEvidence::Unresolved, Some(DirectLineEvidence::Unique(direct))) => { + (MutationLineEvidence::Unresolved, Some(DirectLineEvidence::Accepted(accepted))) => { Some(ResolvedLine { - provenance: direct.clone(), - direct_supported: true, + provenance: accepted.provenance.clone(), + direct_sources: accepted.sources.clone(), }) } (MutationLineEvidence::NonAi | MutationLineEvidence::Unresolved, _) => None, @@ -451,10 +444,11 @@ fn resolve_line( fn resolve_patch_lines( target: &ParsedPatch, - direct_patches: &[ParsedPatch], + direct_sources: &[DirectEvidenceSource<'_>], + repository_root: &RepositoryRoot, mutation: &MutationEvidenceIndex<'_>, ) -> BTreeMap { - let direct = direct_line_evidence(direct_patches, target); + let direct = direct_line_evidence(direct_sources, target, repository_root); let mut resolved = BTreeMap::new(); for (file_index, file) in target.files.iter().enumerate() { let path = logical_path(file); @@ -655,7 +649,8 @@ pub fn build_agent_trace_from_evidence( metadata: AgentTraceMetadataInput<'_>, ) -> Result { let AgentTraceEvidence { - direct_patches, + repository_root, + direct_sources, mutation_ai_patch, mutation_non_ai_patch, } = evidence; @@ -665,7 +660,12 @@ pub fn build_agent_trace_from_evidence( let conversation_url = agent_trace_conversation_url(&id); let timestamp = metadata.commit_timestamp.to_owned(); let mutation = MutationEvidenceIndex::new(mutation_ai_patch, mutation_non_ai_patch); - let resolved = resolve_patch_lines(post_commit_patch, direct_patches, &mutation); + let resolved = resolve_patch_lines( + post_commit_patch, + direct_sources, + repository_root, + &mutation, + ); let empty_patch = ParsedPatch { files: Vec::new() }; let no_mutation = MutationEvidenceIndex::new(&empty_patch, &empty_patch); @@ -679,8 +679,12 @@ pub fn build_agent_trace_from_evidence( record_hunk_line_changes(&mut line_changes, classify_hunk(hunk, covered), hunk); } - let embedded_resolved = - resolve_patch_lines(&embedded_patch, direct_patches, &no_mutation); + let embedded_resolved = resolve_patch_lines( + &embedded_patch, + direct_sources, + repository_root, + &no_mutation, + ); let mut discarded_line_changes = LineChangeAttribution::default(); files.extend(embedded_patch.files.iter().enumerate().filter_map( |(embedded_index, embedded_file)| { @@ -707,16 +711,11 @@ pub fn build_agent_trace_from_evidence( } } - let direct_supported = resolved.values().any(|line| line.direct_supported); - let tool = - if direct_supported && (metadata.tool_name.is_some() || metadata.tool_version.is_some()) { - Some(AgentTraceTool { - name: metadata.tool_name.map(ToOwned::to_owned), - version: metadata.tool_version.map(ToOwned::to_owned), - }) - } else { - None - }; + let accepted_sources: BTreeSet = resolved + .values() + .flat_map(|line| line.direct_sources.iter().copied()) + .collect(); + let tool = accepted_direct_tool(direct_sources, &accepted_sources); Ok(AgentTrace { version: default_agent_trace_version(), diff --git a/cli/src/services/agent_trace/evidence.rs b/cli/src/services/agent_trace/evidence.rs index 8d9b6c966..12551a317 100644 --- a/cli/src/services/agent_trace/evidence.rs +++ b/cli/src/services/agent_trace/evidence.rs @@ -1,40 +1,99 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; +use super::repository_path::{RepositoryPath, RepositoryRoot}; +use super::AgentTraceTool; +use crate::services::agent_trace_db::ParsedDiffTracePatch; use crate::services::mutation_trace::attribution::{ logical_path, patch_for_locations_with_provenance, PatchLineLocation, PatchLineProvenance, }; -use crate::services::patch::{paths_refer_to_same_file, ParsedPatch, TouchedLineKind}; +use crate::services::patch::{ParsedPatch, PatchFileChange, TouchedLineKind}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DirectEvidenceSource<'a> { + pub patch: &'a ParsedPatch, + pub base_dir: Option<&'a str>, + pub tool_name: Option<&'a str>, + pub tool_version: Option<&'a str>, +} + +#[cfg(test)] +impl<'a> DirectEvidenceSource<'a> { + pub fn repository_relative(patch: &'a ParsedPatch) -> Self { + DirectEvidenceSource { + patch, + base_dir: None, + tool_name: None, + tool_version: None, + } + } +} + +impl<'a> From<&'a ParsedDiffTracePatch> for DirectEvidenceSource<'a> { + fn from(row: &'a ParsedDiffTracePatch) -> Self { + DirectEvidenceSource { + patch: &row.patch, + base_dir: row.path_base_dir.as_deref(), + tool_name: row.tool_name.as_deref(), + tool_version: row.tool_version.as_deref(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AcceptedDirectLine { + pub(crate) provenance: PatchLineProvenance, + pub(crate) sources: BTreeSet, +} #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum DirectLineEvidence { - Unique(PatchLineProvenance), + Accepted(AcceptedDirectLine), Conflicting, } type ExactLineKey<'a> = (TouchedLineKind, u64, &'a str); pub(crate) fn direct_line_evidence( - candidates: &[ParsedPatch], + sources: &[DirectEvidenceSource<'_>], target: &ParsedPatch, + repository_root: &RepositoryRoot, ) -> BTreeMap { - let mut evidence = BTreeMap::new(); + let candidate_files: Vec<(usize, &PatchFileChange, RepositoryPath)> = sources + .iter() + .enumerate() + .flat_map(|(source_index, source)| { + source.patch.files.iter().filter_map(move |file| { + repository_root + .repository_path(logical_path(file), source.base_dir) + .map(|path| (source_index, file, path)) + }) + }) + .collect(); + let mut evidence = BTreeMap::new(); for (file_index, target_file) in target.files.iter().enumerate() { - let target_path = logical_path(target_file); - let mut by_line: HashMap, Vec> = HashMap::new(); - for candidate_file in candidates.iter().flat_map(|patch| patch.files.iter()) { - if !paths_refer_to_same_file(logical_path(candidate_file), target_path) { - continue; - } + let Some(target_path) = repository_root.repository_path(logical_path(target_file), None) + else { + continue; + }; + let mut by_line: HashMap, Vec<(usize, PatchLineProvenance)>> = + HashMap::new(); + for (source_index, candidate_file, _) in candidate_files + .iter() + .filter(|(_, _, candidate_path)| *candidate_path == target_path) + { for hunk in &candidate_file.hunks { for line in &hunk.lines { by_line .entry((line.kind, line.line_number, line.content.as_str())) .or_default() - .push(PatchLineProvenance { - session_id: line.session_id.clone(), - model_id: hunk.model_id.clone(), - }); + .push(( + *source_index, + PatchLineProvenance { + session_id: line.session_id.clone(), + model_id: hunk.model_id.clone(), + }, + )); } } } @@ -44,7 +103,7 @@ pub(crate) fn direct_line_evidence( for (hunk_index, hunk) in target_file.hunks.iter().enumerate() { for (line_index, line) in hunk.lines.iter().enumerate() { - let Some(sources) = + let Some(candidates) = by_line.get(&(line.kind, line.line_number, line.content.as_str())) else { continue; @@ -55,7 +114,7 @@ pub(crate) fn direct_line_evidence( hunk_index, line_index, }, - resolve_sources(sources), + resolve_candidates(candidates), ); } } @@ -64,50 +123,87 @@ pub(crate) fn direct_line_evidence( evidence } -fn resolve_sources(sources: &[PatchLineProvenance]) -> DirectLineEvidence { - let sessions: BTreeSet> = sources +fn resolve_candidates(candidates: &[(usize, PatchLineProvenance)]) -> DirectLineEvidence { + let sessions: BTreeSet> = candidates .iter() - .map(|source| source.session_id.as_deref()) + .map(|(_, provenance)| provenance.session_id.as_deref()) .collect(); if sessions.len() != 1 { return DirectLineEvidence::Conflicting; } - let models: BTreeSet> = sources + let models: BTreeSet> = candidates .iter() - .map(|source| source.model_id.as_deref()) + .map(|(_, provenance)| provenance.model_id.as_deref()) .collect(); let model_id = match models.into_iter().collect::>().as_slice() { [Some(model)] => Some((*model).to_owned()), _ => None, }; - DirectLineEvidence::Unique(PatchLineProvenance { - session_id: sources[0].session_id.clone(), - model_id, + DirectLineEvidence::Accepted(AcceptedDirectLine { + provenance: PatchLineProvenance { + session_id: candidates[0].1.session_id.clone(), + model_id, + }, + sources: candidates.iter().map(|(index, _)| *index).collect(), }) } -pub(crate) fn attributable_direct_evidence( - candidates: &[ParsedPatch], +pub(crate) fn accepted_direct_evidence( + sources: &[DirectEvidenceSource<'_>], target: &ParsedPatch, -) -> BTreeMap { - direct_line_evidence(candidates, target) + repository_root: &RepositoryRoot, +) -> BTreeMap { + direct_line_evidence(sources, target, repository_root) .into_iter() .filter_map(|(location, evidence)| match evidence { - DirectLineEvidence::Unique(provenance) => Some((location, provenance)), + DirectLineEvidence::Accepted(accepted) => Some((location, accepted)), DirectLineEvidence::Conflicting => None, }) .collect() } pub(crate) fn direct_evidence_patch( - candidates: &[ParsedPatch], + sources: &[DirectEvidenceSource<'_>], target: &ParsedPatch, + repository_root: &RepositoryRoot, ) -> ParsedPatch { - patch_for_locations_with_provenance(target, &attributable_direct_evidence(candidates, target)) + let provenance = accepted_direct_evidence(sources, target, repository_root) + .into_iter() + .map(|(location, accepted)| (location, accepted.provenance)) + .collect(); + patch_for_locations_with_provenance(target, &provenance) +} + +pub(crate) fn has_direct_evidence( + sources: &[DirectEvidenceSource<'_>], + target: &ParsedPatch, + repository_root: &RepositoryRoot, +) -> bool { + !accepted_direct_evidence(sources, target, repository_root).is_empty() +} + +pub(crate) fn accepted_direct_tool( + sources: &[DirectEvidenceSource<'_>], + accepted_sources: &BTreeSet, +) -> Option { + let accepted: Vec<&DirectEvidenceSource<'_>> = accepted_sources + .iter() + .filter_map(|index| sources.get(*index)) + .collect(); + let name = single_value(accepted.iter().map(|source| source.tool_name))?; + Some(AgentTraceTool { + name: Some(name.to_owned()), + version: single_value(accepted.iter().map(|source| source.tool_version)) + .map(ToOwned::to_owned), + }) } -pub(crate) fn has_direct_evidence(candidates: &[ParsedPatch], target: &ParsedPatch) -> bool { - !direct_line_evidence(candidates, target).is_empty() +fn single_value<'a>(values: impl Iterator>) -> Option<&'a str> { + let distinct: BTreeSet> = values.collect(); + match distinct.into_iter().collect::>().as_slice() { + [Some(value)] => Some(value), + _ => None, + } } diff --git a/cli/src/services/agent_trace/repository_path.rs b/cli/src/services/agent_trace/repository_path.rs new file mode 100644 index 000000000..37e20ef1f --- /dev/null +++ b/cli/src/services/agent_trace/repository_path.rs @@ -0,0 +1,202 @@ +use std::path::{Component, Path}; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) struct RepositoryPath(String); + +impl RepositoryPath { + #[cfg(test)] + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RepositoryRoot { + roots: Vec>, +} + +impl RepositoryRoot { + pub fn new(root: &Path) -> Self { + RepositoryRoot { + roots: absolute_components(root).into_iter().collect(), + } + } + + pub fn resolve(root: &Path) -> Self { + let mut repository_root = Self::new(root); + if let Some(canonical) = root + .canonicalize() + .ok() + .and_then(|canonical| absolute_components(&canonical)) + { + if !repository_root.roots.contains(&canonical) { + repository_root.roots.push(canonical); + } + } + repository_root + } + + pub(crate) fn repository_path( + &self, + path: &str, + base_dir: Option<&str>, + ) -> Option { + if path.is_empty() { + return None; + } + let path = Path::new(path); + if path.is_absolute() { + return self.strip_root(&absolute_components(path)?); + } + match base_dir.map(Path::new).filter(|base| base.is_absolute()) { + Some(base) => self.strip_root(&absolute_components(&base.join(path))?), + None => { + relative_components(path).map(|components| RepositoryPath(components.join("/"))) + } + } + } + + fn strip_root(&self, components: &[String]) -> Option { + self.roots.iter().find_map(|root| { + (components.len() > root.len() && components.starts_with(root)) + .then(|| RepositoryPath(components[root.len()..].join("/"))) + }) + } +} + +fn absolute_components(path: &Path) -> Option> { + if !path.is_absolute() { + return None; + } + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => components.push(prefix.as_os_str().to_str()?.to_owned()), + Component::RootDir | Component::CurDir => {} + Component::ParentDir => { + components.pop()?; + } + Component::Normal(part) => components.push(part.to_str()?.to_owned()), + } + } + Some(components) +} + +fn relative_components(path: &Path) -> Option> { + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + components.pop()?; + } + Component::Normal(part) => components.push(part.to_str()?.to_owned()), + Component::Prefix(_) | Component::RootDir => return None, + } + } + (!components.is_empty()).then_some(components) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::RepositoryRoot; + + const ROOT: &str = "/workspace/project"; + + fn identity(path: &str, base_dir: Option<&str>) -> Option { + RepositoryRoot::new(Path::new(ROOT)) + .repository_path(path, base_dir) + .map(|path| path.as_str().to_owned()) + } + + #[test] + fn relative_paths_normalize_to_exact_repository_identity() { + assert_eq!(identity("src/lib.rs", None).as_deref(), Some("src/lib.rs")); + assert_eq!( + identity("./src/../src/lib.rs", None).as_deref(), + Some("src/lib.rs") + ); + assert_ne!( + identity("src/lib.rs", None), + identity("packages/foo/src/lib.rs", None) + ); + } + + #[test] + fn absolute_paths_inside_the_repository_become_repository_relative() { + assert_eq!( + identity("/workspace/project/src/lib.rs", None).as_deref(), + Some("src/lib.rs") + ); + assert_eq!( + identity("/workspace/project/./src/../src/lib.rs", None).as_deref(), + Some("src/lib.rs") + ); + } + + #[test] + fn absolute_paths_outside_the_repository_are_rejected() { + assert_eq!( + identity("/workspace/another-project/src/lib.rs", None), + None + ); + assert_eq!(identity("/workspace/project-two/src/lib.rs", None), None); + assert_eq!(identity("/workspace/project", None), None); + assert_eq!(identity("/src/lib.rs", None), None); + } + + #[test] + fn traversal_cannot_escape_the_repository() { + assert_eq!(identity("../project/src/lib.rs", None), None); + assert_eq!(identity("src/../../lib.rs", None), None); + assert_eq!( + identity("/workspace/project/../another-project/src/lib.rs", None), + None + ); + assert_eq!(identity("../src/lib.rs", Some("/workspace/project")), None); + assert_eq!(identity("", None), None); + } + + #[test] + fn relative_paths_resolve_against_an_absolute_base_directory() { + assert_eq!( + identity("src/lib.rs", Some("/workspace/project/cli")).as_deref(), + Some("cli/src/lib.rs") + ); + assert_eq!( + identity("src/lib.rs", Some("/workspace/project")).as_deref(), + Some("src/lib.rs") + ); + assert_eq!( + identity("../cli/src/lib.rs", Some("/workspace/project/config")).as_deref(), + Some("cli/src/lib.rs") + ); + assert_eq!(identity("src/lib.rs", Some("/workspace/another")), None); + assert_eq!( + identity("src/lib.rs", Some("relative/base")).as_deref(), + Some("src/lib.rs") + ); + } + + #[cfg(unix)] + #[test] + fn a_symlinked_checkout_spelling_resolves_through_the_canonical_root() { + let real = tempfile::tempdir().expect("real root"); + let links = tempfile::tempdir().expect("link dir"); + let link = links.path().join("checkout"); + std::os::unix::fs::symlink(real.path(), &link).expect("symlink checkout"); + + let root = RepositoryRoot::resolve(&link); + let canonical = real.path().canonicalize().expect("canonical root"); + for spelling in [link.join("src/lib.rs"), canonical.join("src/lib.rs")] { + assert_eq!( + root.repository_path(spelling.to_str().expect("utf-8 path"), None) + .map(|path| path.as_str().to_owned()) + .as_deref(), + Some("src/lib.rs") + ); + } + } +} diff --git a/cli/src/services/agent_trace/tests.rs b/cli/src/services/agent_trace/tests.rs index 7e95b53a3..b16a1aca1 100644 --- a/cli/src/services/agent_trace/tests.rs +++ b/cli/src/services/agent_trace/tests.rs @@ -1,6 +1,10 @@ +use std::fmt::Write as _; +use std::path::Path; + use super::{ build_agent_trace_from_evidence, validate_agent_trace_value, AgentTraceEvidence, - AgentTraceMetadataInput, AgentTraceVcsType, AGENT_TRACE_VERSION, + AgentTraceMetadataInput, AgentTraceVcsType, DirectEvidenceSource, RepositoryRoot, + AGENT_TRACE_VERSION, }; use crate::services::{ agent_trace::agent_trace_conversation_url, @@ -10,6 +14,7 @@ use serde_json::Value; const TEST_COMMIT_TIMESTAMP: &str = "2026-04-23T10:20:30Z"; const TEST_COMMIT_REVISION: &str = "a0b1c2d3e4f5a6b7c8d9e0f11223344556677889"; +const TEST_REPOSITORY_ROOT: &str = "/workspace/project"; #[derive(Clone, Copy)] struct AgentTraceScenario { @@ -61,9 +66,14 @@ fn assert_builds_expected_agent_trace(scenario: AgentTraceScenario) { 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 empty_mutation_patch = ParsedPatch { files: Vec::new() }; + let direct_sources: Vec<_> = direct_patches + .iter() + .map(DirectEvidenceSource::repository_relative) + .collect(); let actual = build_agent_trace_from_evidence( AgentTraceEvidence { - direct_patches: &direct_patches, + repository_root: &RepositoryRoot::new(Path::new(TEST_REPOSITORY_ROOT)), + direct_sources: &direct_sources, mutation_ai_patch: &empty_mutation_patch, mutation_non_ai_patch: &empty_mutation_patch, }, @@ -72,8 +82,6 @@ fn assert_builds_expected_agent_trace(scenario: AgentTraceScenario) { commit_timestamp: TEST_COMMIT_TIMESTAMP, commit_revision: TEST_COMMIT_REVISION, vcs_type: Some(AgentTraceVcsType::Git), - tool_name: None, - tool_version: None, }, ) .expect("agent trace should build"); @@ -246,9 +254,15 @@ fn assert_builds_expected_agent_trace_from_evidence(scenario: EvidenceScenario) 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 direct_source = DirectEvidenceSource { + tool_name: Some(EVIDENCE_TOOL_NAME), + tool_version: Some(EVIDENCE_TOOL_VERSION), + ..DirectEvidenceSource::repository_relative(&direct_patch) + }; let actual = build_agent_trace_from_evidence( AgentTraceEvidence { - direct_patches: std::slice::from_ref(&direct_patch), + repository_root: &RepositoryRoot::new(Path::new(TEST_REPOSITORY_ROOT)), + direct_sources: std::slice::from_ref(&direct_source), mutation_ai_patch: &mutation_ai_patch, mutation_non_ai_patch: &ParsedPatch { files: Vec::new() }, }, @@ -257,8 +271,6 @@ fn assert_builds_expected_agent_trace_from_evidence(scenario: EvidenceScenario) 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"); @@ -353,3 +365,504 @@ fn mutation_only_no_provenance_evidence_matches_golden_agent_trace() { golden: include_str!("fixtures/mutation_only_no_provenance/golden.json"), }); } + +const SOURCE_PATH: &str = "src/lib.rs"; +const LINE_X: (u64, &str) = (2, "let alpha = 1;"); +const LINE_Y: (u64, &str) = (20, "let omega = 2;"); +const SESSION_A: &str = "cc_session-a"; +const SESSION_B: &str = "oc_session-b"; +const SESSION_C: &str = "cc_session-c"; +const MODEL_A: &str = "model-a"; +const MODEL_B: &str = "model-b"; +const MODEL_C: &str = "model-c"; + +fn added_lines_patch(path: &str, lines: &[(u64, &str)]) -> String { + let mut text = format!("diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n"); + for (line_number, content) in lines { + write!( + text, + "@@ -{},0 +{line_number},1 @@\n+{content}\n", + line_number - 1 + ) + .expect("write hunk"); + } + text +} + +fn attributed_patch(path: &str, lines: &[(u64, &str)], session: &str, model: &str) -> ParsedPatch { + let mut attributed = parse_patch(&added_lines_patch(path, lines), Some(session)) + .expect("attributed patch should parse"); + for hunk in attributed + .files + .iter_mut() + .flat_map(|file| file.hunks.iter_mut()) + { + hunk.model_id = Some(model.to_owned()); + } + attributed +} + +fn committed_patch() -> ParsedPatch { + parse_patch(&added_lines_patch(SOURCE_PATH, &[LINE_X, LINE_Y]), None) + .expect("committed patch should parse") +} + +struct ToolSource { + patch: ParsedPatch, + tool_name: Option<&'static str>, + tool_version: Option<&'static str>, +} + +fn tool_source( + path: &str, + lines: &[(u64, &str)], + session: &str, + model: &str, + tool: Option<(&'static str, Option<&'static str>)>, +) -> ToolSource { + ToolSource { + patch: attributed_patch(path, lines, session, model), + tool_name: tool.map(|(name, _)| name), + tool_version: tool.and_then(|(_, version)| version), + } +} + +fn direct_sources(sources: &[ToolSource]) -> Vec> { + sources + .iter() + .map(|source| DirectEvidenceSource { + tool_name: source.tool_name, + tool_version: source.tool_version, + ..DirectEvidenceSource::repository_relative(&source.patch) + }) + .collect() +} + +fn test_root() -> RepositoryRoot { + RepositoryRoot::new(Path::new(TEST_REPOSITORY_ROOT)) +} + +fn build_with_sources( + sources: &[ToolSource], + mutation_ai_patch: &ParsedPatch, +) -> super::AgentTrace { + let direct_sources = direct_sources(sources); + let trace = build_agent_trace_from_evidence( + AgentTraceEvidence { + repository_root: &test_root(), + direct_sources: &direct_sources, + mutation_ai_patch, + mutation_non_ai_patch: &ParsedPatch { files: Vec::new() }, + }, + &committed_patch(), + AgentTraceMetadataInput { + commit_timestamp: TEST_COMMIT_TIMESTAMP, + commit_revision: TEST_COMMIT_REVISION, + vcs_type: Some(AgentTraceVcsType::Git), + }, + ) + .expect("agent trace should build"); + let json = serde_json::to_value(&trace).expect("agent trace should serialize"); + validate_agent_trace_value(&json).expect("agent trace should validate against schema"); + trace +} + +fn no_mutation() -> ParsedPatch { + ParsedPatch { files: Vec::new() } +} + +fn tool_of(trace: &super::AgentTrace) -> Option<(Option<&str>, Option<&str>)> { + trace + .tool + .as_ref() + .map(|tool| (tool.name.as_deref(), tool.version.as_deref())) +} + +fn contributor_kinds(trace: &super::AgentTrace) -> Vec { + trace + .files + .iter() + .flat_map(|file| file.conversations.iter()) + .map(|conversation| conversation.contributor.kind) + .collect() +} + +fn accepted_line_count(sources: &[ToolSource], target: &ParsedPatch) -> usize { + super::direct_evidence_patch(&direct_sources(sources), target, &test_root()) + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .map(|hunk| hunk.lines.len()) + .sum() +} + +#[test] +fn two_sessions_claiming_one_line_yield_no_accepted_direct_evidence() { + let sources = [ + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_A, + MODEL_A, + Some(("claude", None)), + ), + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_B, + MODEL_B, + Some(("opencode", None)), + ), + ]; + + assert!(!super::has_direct_evidence( + &direct_sources(&sources), + &committed_patch(), + &test_root() + )); + assert_eq!(accepted_line_count(&sources, &committed_patch()), 0); + + let trace = build_with_sources(&sources, &no_mutation()); + assert_eq!( + contributor_kinds(&trace), + vec![ + super::HunkContributor::Unknown, + super::HunkContributor::Unknown + ] + ); + assert_eq!(tool_of(&trace), None); +} + +#[test] +fn several_rows_from_one_session_are_accepted_as_that_session() { + let sources = [ + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_A, + MODEL_A, + Some(("claude", None)), + ), + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_A, + MODEL_A, + Some(("claude", None)), + ), + ]; + + assert!(super::has_direct_evidence( + &direct_sources(&sources), + &committed_patch(), + &test_root() + )); + let trace = build_with_sources(&sources, &no_mutation()); + let conversation = &trace.files[0].conversations[0]; + assert_eq!(conversation.contributor.kind, super::HunkContributor::Ai); + assert_eq!(conversation.contributor.model_id.as_deref(), Some(MODEL_A)); + assert_eq!(tool_of(&trace), Some((Some("claude"), None))); +} + +#[test] +fn a_conflicted_line_does_not_hide_or_join_a_valid_line() { + let sources = [ + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_A, + MODEL_A, + Some(("claude", None)), + ), + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_B, + MODEL_B, + Some(("opencode", None)), + ), + tool_source( + SOURCE_PATH, + &[LINE_Y], + SESSION_A, + MODEL_A, + Some(("claude", None)), + ), + ]; + + assert_eq!(accepted_line_count(&sources, &committed_patch()), 1); + let trace = build_with_sources(&sources, &no_mutation()); + assert_eq!( + contributor_kinds(&trace), + vec![super::HunkContributor::Unknown, super::HunkContributor::Ai] + ); + assert_eq!(tool_of(&trace), Some((Some("claude"), None))); +} + +#[test] +fn identical_lines_in_a_different_file_with_the_same_suffix_do_not_attribute() { + let sources = [tool_source( + "packages/foo/src/lib.rs", + &[LINE_X, LINE_Y], + SESSION_B, + MODEL_B, + Some(("opencode", None)), + )]; + + assert!(!super::has_direct_evidence( + &direct_sources(&sources), + &committed_patch(), + &test_root() + )); + let trace = build_with_sources(&sources, &no_mutation()); + assert_eq!( + contributor_kinds(&trace), + vec![ + super::HunkContributor::Unknown, + super::HunkContributor::Unknown + ] + ); + assert_eq!(tool_of(&trace), None); +} + +#[test] +fn absolute_evidence_paths_attribute_only_inside_the_repository_root() { + let inside = format!("{TEST_REPOSITORY_ROOT}/{SOURCE_PATH}"); + let outside = format!("/workspace/another-project/{SOURCE_PATH}"); + let sources = [ + tool_source( + &inside, + &[LINE_X], + SESSION_A, + MODEL_A, + Some(("claude", None)), + ), + tool_source( + &outside, + &[LINE_Y], + SESSION_B, + MODEL_B, + Some(("opencode", None)), + ), + ]; + + let trace = build_with_sources(&sources, &no_mutation()); + assert_eq!( + contributor_kinds(&trace), + vec![super::HunkContributor::Ai, super::HunkContributor::Unknown] + ); + assert_eq!(tool_of(&trace), Some((Some("claude"), None))); +} + +#[test] +fn deleted_files_match_direct_evidence_by_their_old_path() { + let deletion = |path: &str| { + format!( + "diff --git a/{path} b/{path}\ndeleted file mode 100644\n--- a/{path}\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-gone\n" + ) + }; + let committed = parse_patch(&deletion("src/old.rs"), None).expect("deletion parses"); + let matching = parse_patch(&deletion("src/old.rs"), Some(SESSION_A)).expect("deletion parses"); + let other = + parse_patch(&deletion("vendor/src/old.rs"), Some(SESSION_A)).expect("deletion parses"); + + for (candidate, expected) in [(&matching, true), (&other, false)] { + assert_eq!( + super::has_direct_evidence( + &[DirectEvidenceSource::repository_relative(candidate)], + &committed, + &test_root() + ), + expected + ); + } +} + +#[test] +fn tool_comes_from_an_older_accepted_source_when_the_newest_is_rejected_by_lineage() { + let sources = [ + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_A, + MODEL_A, + Some(("claude", Some("1.0"))), + ), + tool_source( + SOURCE_PATH, + &[LINE_Y], + SESSION_B, + MODEL_B, + Some(("opencode", Some("0.9"))), + ), + ]; + let mut mutation_ai = attributed_patch(SOURCE_PATH, &[LINE_X], SESSION_A, MODEL_A); + mutation_ai + .files + .extend(attributed_patch(SOURCE_PATH, &[LINE_Y], SESSION_C, MODEL_C).files); + + let trace = build_with_sources(&sources, &mutation_ai); + assert_eq!(tool_of(&trace), Some((Some("claude"), Some("1.0")))); + let json = serde_json::to_string(&trace).expect("trace serializes"); + assert!(!json.contains("opencode") && !json.contains(SESSION_B) && !json.contains(MODEL_B)); +} + +#[test] +fn accepted_sources_from_one_tool_keep_that_tool() { + let sources = [ + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_A, + MODEL_A, + Some(("claude", Some("1.0"))), + ), + tool_source( + SOURCE_PATH, + &[LINE_Y], + SESSION_C, + MODEL_C, + Some(("claude", Some("1.0"))), + ), + ]; + + let trace = build_with_sources(&sources, &no_mutation()); + assert_eq!( + contributor_kinds(&trace), + vec![super::HunkContributor::Ai, super::HunkContributor::Ai] + ); + assert_eq!(tool_of(&trace), Some((Some("claude"), Some("1.0")))); +} + +#[test] +fn accepted_sources_from_different_tools_omit_the_tool() { + let sources = [ + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_A, + MODEL_A, + Some(("claude", None)), + ), + tool_source( + SOURCE_PATH, + &[LINE_Y], + SESSION_B, + MODEL_B, + Some(("opencode", None)), + ), + ]; + + let trace = build_with_sources(&sources, &no_mutation()); + assert_eq!( + contributor_kinds(&trace), + vec![super::HunkContributor::Ai, super::HunkContributor::Ai] + ); + assert_eq!(tool_of(&trace), None); +} + +#[test] +fn conflicting_tool_versions_keep_the_name_and_omit_the_version() { + let sources = [ + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_A, + MODEL_A, + Some(("claude", Some("1.0"))), + ), + tool_source( + SOURCE_PATH, + &[LINE_Y], + SESSION_C, + MODEL_C, + Some(("claude", Some("2.0"))), + ), + ]; + + let trace = build_with_sources(&sources, &no_mutation()); + assert_eq!(tool_of(&trace), Some((Some("claude"), None))); +} + +#[test] +fn an_accepted_source_without_tool_metadata_is_not_filled_from_other_rows() { + let sources = [ + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_A, + MODEL_A, + Some(("claude", None)), + ), + tool_source(SOURCE_PATH, &[LINE_Y], SESSION_C, MODEL_C, None), + ]; + + let trace = build_with_sources(&sources, &no_mutation()); + assert_eq!(tool_of(&trace), None); +} + +#[test] +fn direct_matches_fully_overridden_by_lineage_emit_no_tool() { + let sources = [tool_source( + SOURCE_PATH, + &[LINE_X, LINE_Y], + SESSION_B, + MODEL_B, + Some(("opencode", None)), + )]; + let mutation_ai = attributed_patch(SOURCE_PATH, &[LINE_X, LINE_Y], SESSION_C, MODEL_C); + + let trace = build_with_sources(&sources, &mutation_ai); + assert_eq!( + contributor_kinds(&trace), + vec![super::HunkContributor::Ai, super::HunkContributor::Ai] + ); + assert_eq!( + trace.files[0].conversations[0] + .contributor + .model_id + .as_deref(), + Some(MODEL_C) + ); + assert_eq!(tool_of(&trace), None); +} + +#[test] +fn mutation_only_attribution_emits_no_tool() { + let mutation_ai = attributed_patch(SOURCE_PATH, &[LINE_X, LINE_Y], SESSION_C, MODEL_C); + + let trace = build_with_sources(&[], &mutation_ai); + assert_eq!( + contributor_kinds(&trace), + vec![super::HunkContributor::Ai, super::HunkContributor::Ai] + ); + assert_eq!(tool_of(&trace), None); +} + +#[test] +fn a_session_conflicted_source_cannot_supply_the_tool() { + let sources = [ + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_A, + MODEL_A, + Some(("claude", None)), + ), + tool_source( + SOURCE_PATH, + &[LINE_X], + SESSION_B, + MODEL_B, + Some(("opencode", None)), + ), + ]; + let mutation_ai = attributed_patch(SOURCE_PATH, &[LINE_Y], SESSION_C, MODEL_C); + + let trace = build_with_sources(&sources, &mutation_ai); + assert_eq!( + contributor_kinds(&trace), + vec![super::HunkContributor::Unknown, super::HunkContributor::Ai] + ); + assert_eq!(tool_of(&trace), None); +} diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index cf4a2f6c5..55ae33fce 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -170,7 +170,6 @@ pub struct DiffTracePatchRow { pub payload_type: String, } -/// Parsed recent diff trace patch ready for comparison flows. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ParsedDiffTracePatch { pub id: i64, @@ -180,9 +179,9 @@ pub struct ParsedDiffTracePatch { pub tool_name: Option, pub tool_version: Option, pub payload_type: String, + pub path_base_dir: Option, } -/// Deterministic skipped-row report for invalid recent diff trace patches. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SkippedDiffTracePatch { pub id: i64, @@ -191,7 +190,6 @@ pub struct SkippedDiffTracePatch { pub reason: String, } -/// Parsed recent diff trace query result with accounting for valid and skipped rows. #[derive(Clone, Debug, Eq, PartialEq)] pub struct RecentDiffTracePatches { pub patches: Vec, @@ -553,6 +551,7 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif let is_structured = row.payload_type == PAYLOAD_TYPE_STRUCTURED; let parse_result = match row.payload_type.as_str() { PAYLOAD_TYPE_PATCH => parse_patch(&row.patch, Some(row.session_id.as_str())) + .map(|patch| (patch, None)) .map_err(|error| skipped_diff_trace_patch_reason(&error)), PAYLOAD_TYPE_STRUCTURED => match serde_json::from_str::(&row.patch) { Ok(payload) => match derive_claude_structured_patch( @@ -561,7 +560,9 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif u64::try_from(row.time_ms).expect("diff trace time_ms should be non-negative"), row.tool_version.as_deref(), ) { - ClaudeStructuredPatchDerivationResult::Derived(derived) => Ok(derived.patch), + ClaudeStructuredPatchDerivationResult::Derived(derived) => { + Ok((derived.patch, structured_payload_cwd(&payload))) + } ClaudeStructuredPatchDerivationResult::Skipped(reason) => { Err(reason.to_string()) } @@ -572,7 +573,7 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif }; match parse_result { - Ok(mut patch) => { + Ok((mut patch, path_base_dir)) => { for file in &mut patch.files { for hunk in &mut file.hunks { hunk.model_id.clone_from(&row.model_id); @@ -592,6 +593,7 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif tool_name: row.tool_name, tool_version: row.tool_version, payload_type: row.payload_type, + path_base_dir, }); } Err(reason) => skipped.push(SkippedDiffTracePatch { @@ -606,6 +608,15 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif RecentDiffTracePatches { patches, skipped } } +fn structured_payload_cwd(payload: &Value) -> Option { + payload + .get("cwd") + .and_then(Value::as_str) + .map(str::trim) + .filter(|cwd| std::path::Path::new(cwd).is_absolute()) + .map(ToOwned::to_owned) +} + fn skipped_diff_trace_patch_reason(error: &ParseError) -> String { error.to_string() } diff --git a/cli/src/services/hooks/commit_hooks.rs b/cli/src/services/hooks/commit_hooks.rs index 586f1ef22..446f390f5 100644 --- a/cli/src/services/hooks/commit_hooks.rs +++ b/cli/src/services/hooks/commit_hooks.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; use chrono::{DateTime, Utc}; @@ -8,7 +8,8 @@ use serde_json::{to_string as serialize_to_json, Value}; use crate::services::agent_trace::{ agent_trace_persisted_url, build_agent_trace_from_evidence, direct_evidence_patch, has_direct_evidence, patch_has_touched_lines, validate_agent_trace_value, AgentTrace, - AgentTraceEvidence, AgentTraceMetadataInput, AgentTraceVcsType, + AgentTraceEvidence, AgentTraceMetadataInput, AgentTraceVcsType, DirectEvidenceSource, + RepositoryRoot, }; use crate::services::agent_trace_db::{ AgentTraceInsert, ParsedDiffTracePatch, PostCommitPatchIntersectionInsert, @@ -326,9 +327,16 @@ where })? .to_rfc3339(); + let repository_root = RepositoryRoot::resolve(&flow_result.repository_root); + let direct_sources: Vec> = flow_result + .direct_traces + .iter() + .map(DirectEvidenceSource::from) + .collect(); let agent_trace = build_agent_trace_from_evidence( AgentTraceEvidence { - direct_patches: &flow_result.direct_patches, + repository_root: &repository_root, + direct_sources: &direct_sources, mutation_ai_patch: &mutation.mutation_ai_patch, mutation_non_ai_patch: &mutation.resolved_non_ai_patch, }, @@ -337,8 +345,6 @@ where commit_timestamp: &commit_timestamp, commit_revision: &flow_result.post_commit_data.commit_oid, vcs_type, - tool_name: flow_result.tool_name.as_deref(), - tool_version: flow_result.tool_version.as_deref(), }, ) .context("Failed to build Agent Trace payload from post-commit intersection flow result.")?; @@ -501,12 +507,16 @@ where return StagedDiffAiOverlapResult::Error; }; - let direct_patches: Vec = recent_patches + let direct_sources: Vec> = recent_patches .patches - .into_iter() - .map(|recent_patch| recent_patch.patch) + .iter() + .map(DirectEvidenceSource::from) .collect(); - let has_overlap = has_direct_evidence(&direct_patches, &staged_patch); + let has_overlap = has_direct_evidence( + &direct_sources, + &staged_patch, + &RepositoryRoot::resolve(repository_root), + ); if has_overlap { StagedDiffAiOverlapResult::Overlap @@ -567,16 +577,16 @@ where #[allow(clippy::cast_possible_wrap)] let skipped_count = recent_patches.skipped_count() as i64; - let (tool_name, tool_version) = - latest_overlapping_patch_tool(&recent_patches.patches, &post_commit_data.parsed_patch); - - let direct_patches: Vec = recent_patches + let direct_sources: Vec> = recent_patches .patches - .into_iter() - .map(|p| p.patch) + .iter() + .map(DirectEvidenceSource::from) .collect(); - - let intersection_patch = direct_evidence_patch(&direct_patches, &post_commit_data.parsed_patch); + let intersection_patch = direct_evidence_patch( + &direct_sources, + &post_commit_data.parsed_patch, + &RepositoryRoot::resolve(repository_root), + ); let serialized_intersection = serialize_to_json(&intersection_patch) .context("Failed to serialize intersection patch.")?; @@ -594,32 +604,13 @@ where persist_intersection(insert_input).await?; Ok(PostCommitIntersectionFlowResult { - direct_patches, + repository_root: repository_root.to_path_buf(), + direct_traces: recent_patches.patches, intersection_patch, post_commit_data, - tool_name, - tool_version, }) } -fn latest_overlapping_patch_tool( - recent_patches: &[ParsedDiffTracePatch], - post_commit_patch: &ParsedPatch, -) -> (Option, Option) { - recent_patches - .iter() - .rev() - .find(|recent_patch| { - has_direct_evidence(std::slice::from_ref(&recent_patch.patch), post_commit_patch) - }) - .map_or((None, None), |recent_patch| { - ( - recent_patch.tool_name.clone(), - recent_patch.tool_version.clone(), - ) - }) -} - pub(crate) async fn run_post_commit_subcommand_with_trace< L: crate::services::observability::traits::Logger, >( @@ -711,11 +702,10 @@ pub struct PostCommitPatchData { #[derive(Clone, Debug, Eq, PartialEq)] pub struct PostCommitIntersectionFlowResult { - pub direct_patches: Vec, + pub repository_root: PathBuf, + pub direct_traces: Vec, pub intersection_patch: ParsedPatch, pub post_commit_data: PostCommitPatchData, - pub tool_name: Option, - pub tool_version: Option, } pub fn capture_post_commit_patch_from_git(repository_root: &Path) -> Result { @@ -812,18 +802,18 @@ mod tests { use anyhow::{anyhow, Result}; use crate::services::agent_trace::{validate_agent_trace_value, AgentTrace}; - use crate::services::agent_trace_db::{AgentTraceInsert, ParsedDiffTracePatch}; + use crate::services::agent_trace_db::AgentTraceInsert; use crate::services::error::CliError; use crate::services::mutation_trace::attribution::MutationAttributionResult; use crate::services::observability::traits::Logger; - use crate::services::patch::{parse_patch, ParsedPatch}; + use crate::services::patch::ParsedPatch; use super::super::commit_patch_id::compute_commit_patch_id; use super::{ - capture_post_commit_patch_from_git, latest_overlapping_patch_tool, - resolve_post_commit_commit_patch_id, run_post_commit_agent_trace_flow_with, - PostCommitIntersectionFlowResult, COMMIT_PATCH_ID_UNAVAILABLE_EVENT, + capture_post_commit_patch_from_git, resolve_post_commit_commit_patch_id, + run_post_commit_agent_trace_flow_with, PostCommitIntersectionFlowResult, + COMMIT_PATCH_ID_UNAVAILABLE_EVENT, }; const REMOTE_URL: &str = "https://example.invalid/repo.git"; @@ -916,12 +906,11 @@ mod tests { fn flow_result(root: &Path) -> PostCommitIntersectionFlowResult { PostCommitIntersectionFlowResult { - direct_patches: Vec::new(), + repository_root: root.to_path_buf(), + direct_traces: Vec::new(), intersection_patch: empty_patch(), post_commit_data: capture_post_commit_patch_from_git(root) .expect("post-commit patch capture"), - tool_name: None, - tool_version: None, } } @@ -968,60 +957,6 @@ mod tests { .replace(&insert.agent_trace_id, TRACE_ID_PLACEHOLDER) } - fn diff_trace_patch(id: i64, tool_name: &str, patch_text: &str) -> ParsedDiffTracePatch { - ParsedDiffTracePatch { - id, - time_ms: id, - session_id: format!("{tool_name}_session"), - patch: parse_patch(patch_text, None).expect("diff trace patch parses"), - tool_name: Some(tool_name.to_string()), - tool_version: None, - payload_type: String::from("patch"), - } - } - - const COMMITTED_FILE_PATCH: &str = "\ -diff --git a/src/lib.rs b/src/lib.rs ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -1,0 +2,1 @@ -+committed line -"; - - const SCRATCH_FILE_PATCH: &str = "\ -diff --git a//tmp/commit-message.txt b//tmp/commit-message.txt ---- /dev/null -+++ b//tmp/commit-message.txt -@@ -0,0 +1,1 @@ -+commit subject -"; - - #[test] - fn tool_comes_from_newest_overlapping_diff_trace_not_newest_overall() { - let post_commit_patch = parse_patch(COMMITTED_FILE_PATCH, None).expect("post-commit patch"); - let recent_patches = vec![ - diff_trace_patch(1, "claude", COMMITTED_FILE_PATCH), - diff_trace_patch(2, "pi", SCRATCH_FILE_PATCH), - ]; - - let (tool_name, tool_version) = - latest_overlapping_patch_tool(&recent_patches, &post_commit_patch); - - assert_eq!(tool_name.as_deref(), Some("claude")); - assert_eq!(tool_version, None); - } - - #[test] - fn tool_is_absent_when_no_diff_trace_overlaps_commit() { - let post_commit_patch = parse_patch(COMMITTED_FILE_PATCH, None).expect("post-commit patch"); - let recent_patches = vec![diff_trace_patch(1, "pi", SCRATCH_FILE_PATCH)]; - - assert_eq!( - latest_overlapping_patch_tool(&recent_patches, &post_commit_patch), - (None, None) - ); - } - #[tokio::test] async fn commit_patch_id_is_attached_to_post_commit_insert_without_changing_trace_json() { let dir = tempfile::tempdir().expect("temp dir"); diff --git a/cli/src/services/hooks/commit_hooks/attribution_tests.rs b/cli/src/services/hooks/commit_hooks/attribution_tests.rs index db5a1062b..97b31d663 100644 --- a/cli/src/services/hooks/commit_hooks/attribution_tests.rs +++ b/cli/src/services/hooks/commit_hooks/attribution_tests.rs @@ -8,12 +8,13 @@ use anyhow::{anyhow, Result}; use serde_json::Value; use crate::services::agent_trace::{ - agent_trace_session_url, validate_agent_trace_value, AgentTrace, HunkContributor, + agent_trace_session_url, validate_agent_trace_value, AgentTrace, DirectEvidenceSource, + HunkContributor, RepositoryRoot, }; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::agent_trace_db::{ AgentTraceInsert, DiffTraceInsert, ParsedDiffTracePatch, PostCommitPatchIntersectionInsert, - RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, + RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, PAYLOAD_TYPE_STRUCTURED, }; use crate::services::mutation_trace::attribution::MutationAttributionResult; use crate::services::mutation_trace::runtime::GitSnapshotService; @@ -54,6 +55,8 @@ const MODEL_X: &str = "model-x"; const SCOPE_A: &str = "scope-a"; const SCOPE_B: &str = "scope-b"; const SCOPE_C: &str = "scope-c"; +const CLAUDE_TOOL: &str = "claude"; +const OPENCODE_TOOL: &str = "opencode"; const fn ms(seconds: i64) -> i64 { seconds * 1_000 @@ -74,8 +77,12 @@ fn plan(done: [bool; 3]) -> String { } fn boilerplate_patch(old_start: u64, new_start: u64) -> String { + boilerplate_patch_at(PLAN_PATH, old_start, new_start) +} + +fn boilerplate_patch_at(path: &str, old_start: u64, new_start: u64) -> String { let mut text = format!( - "diff --git a/{PLAN_PATH} b/{PLAN_PATH}\n--- a/{PLAN_PATH}\n+++ b/{PLAN_PATH}\n@@ -{old_start},0 +{new_start},3 @@\n" + "diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -{old_start},0 +{new_start},3 @@\n" ); for line in BOILERPLATE { text.push('+'); @@ -225,6 +232,7 @@ struct DiffTraceRow { time_ms: i64, session_id: &'static str, model_id: &'static str, + tool_name: &'static str, patch: String, } @@ -234,12 +242,14 @@ fn stale_rows() -> Vec { time_ms: ms(COMMIT_A_TIME_S) - 500, session_id: SESSION_A, model_id: MODEL_A, + tool_name: CLAUDE_TOOL, patch: boilerplate_patch(EXACT_C_POSITIONS.0, EXACT_C_POSITIONS.1), }, DiffTraceRow { time_ms: ms(COMMIT_B_TIME_S) - 500, session_id: SESSION_B, model_id: MODEL_B, + tool_name: CLAUDE_TOOL, patch: boilerplate_patch(COMMIT_B_POSITIONS.0, COMMIT_B_POSITIONS.1), }, ] @@ -254,10 +264,24 @@ fn fresh_row( time_ms: ms(COMMIT_C_TIME_S) - 500, session_id, model_id, + tool_name: CLAUDE_TOOL, patch: boilerplate_patch(positions.0, positions.1), } } +fn fresh_row_at( + path: &str, + session_id: &'static str, + model_id: &'static str, + tool_name: &'static str, +) -> DiffTraceRow { + DiffTraceRow { + tool_name, + patch: boilerplate_patch_at(path, EXACT_C_POSITIONS.0, EXACT_C_POSITIONS.1), + ..fresh_row(session_id, model_id, EXACT_C_POSITIONS) + } +} + fn parsed_row(id: i64, row: &DiffTraceRow) -> ParsedDiffTracePatch { let mut patch = parse_patch(&row.patch, Some(row.session_id)).expect("diff trace parses"); for file in &mut patch.files { @@ -270,9 +294,10 @@ fn parsed_row(id: i64, row: &DiffTraceRow) -> ParsedDiffTracePatch { time_ms: row.time_ms, session_id: row.session_id.to_owned(), patch, - tool_name: Some(String::from("claude")), + tool_name: Some(row.tool_name.to_owned()), tool_version: None, payload_type: String::from(PAYLOAD_TYPE_PATCH), + path_base_dir: None, } } @@ -555,7 +580,8 @@ fn one_direct_source_line_covers_only_its_own_duplicate_occurrence() { let trace = crate::services::agent_trace::build_agent_trace_from_evidence( crate::services::agent_trace::AgentTraceEvidence { - direct_patches: std::slice::from_ref(&direct), + repository_root: &RepositoryRoot::new(Path::new("/workspace/project")), + direct_sources: &[DirectEvidenceSource::repository_relative(&direct)], mutation_ai_patch: &ParsedPatch { files: Vec::new() }, mutation_non_ai_patch: &ParsedPatch { files: Vec::new() }, }, @@ -564,8 +590,6 @@ fn one_direct_source_line_covers_only_its_own_duplicate_occurrence() { commit_timestamp: "2026-11-06T12:23:44Z", commit_revision: "0000000000000000000000000000000000000001", vcs_type: None, - tool_name: None, - tool_version: None, }, ) .expect("trace builds"); @@ -583,6 +607,14 @@ fn one_direct_source_line_covers_only_its_own_duplicate_occurrence() { async fn staged_overlap( rows: &[DiffTraceRow], head_time_s: Option, +) -> StagedDiffAiOverlapResult { + staged_overlap_in(Path::new("."), rows, head_time_s).await +} + +async fn staged_overlap_in( + root: &Path, + rows: &[DiffTraceRow], + head_time_s: Option, ) -> StagedDiffAiOverlapResult { let staged = parse_patch( &boilerplate_patch(EXACT_C_POSITIONS.0, EXACT_C_POSITIONS.1), @@ -590,7 +622,7 @@ async fn staged_overlap( ) .expect("staged patch parses"); staged_diff_has_ai_overlap_with( - Path::new("."), + root, |_| Ok(staged), |_| Ok(head_time_s.map(ms)), || Ok(ms(COMMIT_C_TIME_S)), @@ -671,31 +703,41 @@ async fn insert_mutation_rows(db: &RepositoryAgentTraceDb, rows: &[MutationEvent } } -#[tokio::test] -async fn persisted_agent_trace_keeps_causal_session_and_drops_stale_sessions() { - let repo = HistoryRepo::new(); +async fn open_db() -> (tempfile::TempDir, RepositoryAgentTraceDb) { let db_dir = tempfile::tempdir().expect("db dir"); - let mut db = RepositoryAgentTraceDb::new_at(db_dir.path().join("agent-trace.db")) + let db = RepositoryAgentTraceDb::new_at(db_dir.path().join("agent-trace.db")) .await .expect("open agent trace db"); + (db_dir, db) +} - let mut rows = stale_rows(); - rows.push(fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS)); - for row in &rows { +async fn insert_rows(db: &RepositoryAgentTraceDb, rows: &[DiffTraceRow]) { + for row in rows { db.insert_diff_trace(DiffTraceInsert { time_ms: row.time_ms, session_id: row.session_id, patch: &row.patch, model_id: Some(row.model_id), - tool_name: "claude", + tool_name: row.tool_name, tool_version: None, payload_type: PAYLOAD_TYPE_PATCH, }) .await .expect("insert diff trace"); } - let mutation_rows = repo.mutation_rows(AttributionKind::AiExclusive); - insert_mutation_rows(&db, &mutation_rows).await; +} + +struct PersistedTrace { + json: String, + value: Value, +} + +async fn persist_trace( + repo: &HistoryRepo, + db: &mut RepositoryAgentTraceDb, + mutation_rows: &[MutationEventPageRow], +) -> PersistedTrace { + insert_mutation_rows(db, mutation_rows).await; let flow = run_post_commit_intersection_flow_with( repo.root(), @@ -712,7 +754,7 @@ async fn persisted_agent_trace_keeps_causal_session_and_drops_stale_sessions() { .expect("intersection flow"); let mutation = { - let store = MutationTraceStore::new(&mut db); + let store = MutationTraceStore::new(db); resolve_bounded_mutation_attribution( &store, &GitSnapshotService::new(repo.root()) @@ -721,7 +763,7 @@ async fn persisted_agent_trace_keeps_causal_session_and_drops_stale_sessions() { &WorktreeId(WORKTREE.to_owned()), &flow.post_commit_data.parsed_patch, repo.commit_tree(), - Some(mutation_rows.len() as u64), + mutation_rows.iter().map(|row| row.revision).max(), ) .await .result @@ -739,6 +781,36 @@ async fn persisted_agent_trace_keeps_causal_session_and_drops_stale_sessions() { .await .expect("agent trace flow"); + let traces: Vec = db + .query_map("SELECT trace_json FROM agent_traces", (), |row| { + Ok(row.get(0)?) + }) + .await + .expect("read agent traces"); + assert_eq!(traces.len(), 1); + let value: Value = serde_json::from_str(&traces[0]).expect("trace JSON"); + validate_agent_trace_value(&value).expect("persisted trace validates"); + PersistedTrace { + json: traces[0].clone(), + value, + } +} + +#[tokio::test] +async fn persisted_agent_trace_keeps_causal_session_and_drops_stale_sessions() { + let repo = HistoryRepo::new(); + let (_db_dir, mut db) = open_db().await; + + let mut rows = stale_rows(); + rows.push(fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS)); + insert_rows(&db, &rows).await; + let persisted = persist_trace( + &repo, + &mut db, + &repo.mutation_rows(AttributionKind::AiExclusive), + ) + .await; + let intersections: Vec<(i64, String)> = db .query_map( "SELECT loaded_diff_trace_count, intersection_patch FROM post_commit_patch_intersections", @@ -750,19 +822,9 @@ async fn persisted_agent_trace_keeps_causal_session_and_drops_stale_sessions() { assert_eq!(intersections.len(), 1); assert_eq!(intersections[0].0, 1); assert_contains_none(&intersections[0].1, &[SESSION_A, SESSION_B]); + assert_contains_none(&persisted.json, &[SESSION_A, SESSION_B, MODEL_A, MODEL_B]); - let traces: Vec = db - .query_map("SELECT trace_json FROM agent_traces", (), |row| { - Ok(row.get(0)?) - }) - .await - .expect("read agent traces"); - assert_eq!(traces.len(), 1); - let persisted: Value = serde_json::from_str(&traces[0]).expect("trace JSON"); - validate_agent_trace_value(&persisted).expect("persisted trace validates"); - assert_contains_none(&traces[0], &[SESSION_A, SESSION_B, MODEL_A, MODEL_B]); - - let conversation = &persisted["files"][0]["conversations"][0]; + let conversation = &persisted.value["files"][0]["conversations"][0]; assert_eq!(conversation["contributor"]["type"], "ai"); assert_eq!(conversation["contributor"]["model_id"], MODEL_C); let related: BTreeMap = conversation["related"] @@ -780,4 +842,185 @@ async fn persisted_agent_trace_keeps_causal_session_and_drops_stale_sessions() { related.into_keys().collect::>(), vec![agent_trace_session_url(SESSION_C)] ); + assert_eq!(persisted.value["tool"]["name"], CLAUDE_TOOL); +} + +#[tokio::test] +async fn persisted_agent_trace_omits_tool_metadata_from_rejected_direct_evidence() { + let repo = HistoryRepo::new(); + let (_db_dir, mut db) = open_db().await; + + insert_rows( + &db, + &[fresh_row_at(PLAN_PATH, SESSION_X, MODEL_X, OPENCODE_TOOL)], + ) + .await; + let persisted = persist_trace( + &repo, + &mut db, + &repo.mutation_rows(AttributionKind::AiExclusive), + ) + .await; + + let conversation = &persisted.value["files"][0]["conversations"][0]; + assert_eq!(conversation["contributor"]["type"], "ai"); + assert_eq!(conversation["contributor"]["model_id"], MODEL_C); + assert!(persisted.value.get("tool").is_none()); + assert_contains_none(&persisted.json, &[OPENCODE_TOOL, SESSION_X, MODEL_X]); +} + +#[tokio::test] +async fn staged_coauthor_check_rejects_conflicting_direct_sessions() { + assert_eq!( + staged_overlap( + &[ + fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS), + fresh_row(SESSION_X, MODEL_X, EXACT_C_POSITIONS), + ], + Some(COMMIT_B_TIME_S) + ) + .await, + StagedDiffAiOverlapResult::NoOverlap + ); + assert_eq!( + staged_overlap( + &[ + fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS), + fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS), + ], + Some(COMMIT_B_TIME_S) + ) + .await, + StagedDiffAiOverlapResult::Overlap + ); +} + +fn file_identity_cases(repo: &HistoryRepo, outside: &Path) -> Vec<(String, bool)> { + vec![ + (PLAN_PATH.to_owned(), true), + (format!("{}/{PLAN_PATH}", repo.root().display()), true), + (format!("./context/../{PLAN_PATH}"), true), + (String::from("plans/plan.md"), false), + (format!("vendor/{PLAN_PATH}"), false), + (format!("{}/{PLAN_PATH}", outside.display()), false), + (format!("../{PLAN_PATH}"), false), + ] +} + +#[tokio::test] +async fn staged_coauthor_check_requires_repository_file_identity() { + let repo = HistoryRepo::new(); + let outside = tempfile::tempdir().expect("outside dir"); + for (path, expected) in file_identity_cases(&repo, outside.path()) { + let rows = [fresh_row_at(&path, SESSION_C, MODEL_C, CLAUDE_TOOL)]; + let expected = if expected { + StagedDiffAiOverlapResult::Overlap + } else { + StagedDiffAiOverlapResult::NoOverlap + }; + assert_eq!( + staged_overlap_in(repo.root(), &rows, Some(COMMIT_B_TIME_S)).await, + expected, + "{path}" + ); + } +} + +#[tokio::test] +async fn post_commit_direct_evidence_requires_repository_file_identity() { + let repo = HistoryRepo::new(); + let outside = tempfile::tempdir().expect("outside dir"); + for (path, expected) in file_identity_cases(&repo, outside.path()) { + let rows = [fresh_row_at(&path, SESSION_C, MODEL_C, CLAUDE_TOOL)]; + let (flow, persisted) = intersection_flow(&repo, &rows).await; + let trace = agent_trace(&flow, &no_mutation()).await; + + let (kind, tool, lines) = if expected { + (HunkContributor::Ai, Some(CLAUDE_TOOL), BOILERPLATE.len()) + } else { + (HunkContributor::Unknown, None, 0) + }; + assert_eq!( + trace.files[0].conversations[0].contributor.kind, kind, + "{path}" + ); + assert_eq!( + trace.tool.as_ref().and_then(|tool| tool.name.as_deref()), + tool, + "{path}" + ); + assert_eq!(touched_line_count(&persisted.patch), lines, "{path}"); + } +} + +fn claude_write_payload(cwd: &str, file_path: &str) -> String { + serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": SESSION_C, + "cwd": cwd, + "tool_name": "Write", + "tool_input": { + "file_path": file_path, + "content": plan([true, true, true]), + }, + "tool_response": { "originalFile": null }, + }) + .to_string() +} + +#[tokio::test] +async fn claude_structured_rows_resolve_paths_against_their_recorded_cwd() { + let repo = HistoryRepo::new(); + let root = repo.root().display().to_string(); + let cases = [ + ( + format!("{root}/context"), + format!("{root}/{PLAN_PATH}"), + true, + ), + ( + format!("{root}/context"), + String::from("plans/plan.md"), + true, + ), + (root.clone(), format!("{root}/{PLAN_PATH}"), true), + ( + format!("{root}/other"), + String::from("plans/plan.md"), + false, + ), + (root.clone(), String::from("plans/plan.md"), false), + ]; + for (cwd, file_path, expected) in cases { + let (_db_dir, mut db) = open_db().await; + db.insert_diff_trace(DiffTraceInsert { + time_ms: ms(COMMIT_C_TIME_S) - 500, + session_id: SESSION_C, + patch: &claude_write_payload(&cwd, &file_path), + model_id: Some(MODEL_C), + tool_name: CLAUDE_TOOL, + tool_version: Some("2.0.0"), + payload_type: PAYLOAD_TYPE_STRUCTURED, + }) + .await + .expect("insert structured diff trace"); + + let persisted = persist_trace(&repo, &mut db, &[]).await; + let conversation = &persisted.value["files"][0]["conversations"][0]; + if expected { + assert_eq!( + conversation["contributor"]["type"], "ai", + "{cwd} {file_path}" + ); + assert_eq!(conversation["contributor"]["model_id"], MODEL_C); + assert_eq!(persisted.value["tool"]["name"], CLAUDE_TOOL); + assert_eq!(persisted.value["tool"]["version"], "2.0.0"); + } else { + assert_eq!( + conversation["contributor"]["type"], "unknown", + "{cwd} {file_path}" + ); + assert!(persisted.value.get("tool").is_none()); + } + } } diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs index 66df631bd..c43cd9043 100644 --- a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs @@ -3,7 +3,7 @@ use std::process::Command; use super::*; use crate::services::agent_trace::{ build_agent_trace_from_evidence, AgentTrace, AgentTraceEvidence, AgentTraceMetadataInput, - HunkContributor, + DirectEvidenceSource, HunkContributor, RepositoryRoot, }; const PLAN_PATH: &str = "plan.md"; @@ -160,9 +160,14 @@ fn build_trace( attribution: &MutationAttributionResult, direct_patches: &[ParsedPatch], ) -> AgentTrace { + let direct_sources: Vec<_> = direct_patches + .iter() + .map(DirectEvidenceSource::repository_relative) + .collect(); build_agent_trace_from_evidence( AgentTraceEvidence { - direct_patches, + repository_root: &RepositoryRoot::new(std::path::Path::new("/workspace/project")), + direct_sources: &direct_sources, mutation_ai_patch: &attribution.mutation_ai_patch, mutation_non_ai_patch: &attribution.resolved_non_ai_patch, }, @@ -171,8 +176,6 @@ fn build_trace( commit_timestamp: COMMIT_TIMESTAMP, commit_revision: "0000000000000000000000000000000000000001", vcs_type: None, - tool_name: None, - tool_version: None, }, ) .expect("agent trace builds") diff --git a/cli/src/services/patch.rs b/cli/src/services/patch.rs index 370adf5db..13fd143ea 100644 --- a/cli/src/services/patch.rs +++ b/cli/src/services/patch.rs @@ -14,8 +14,6 @@ //! unified-diff formats and produces deterministic `ParsedPatch` structs from //! raw patch text. -use std::path::{Component, Path}; - use serde::{Deserialize, Serialize}; /// Top-level parsed patch containing one or more file changes. @@ -145,38 +143,6 @@ pub fn load_patch_from_json(input: &str) -> Result }) } -pub(crate) fn paths_refer_to_same_file(path_a: &str, path_b: &str) -> bool { - if path_a == path_b { - return true; - } - - let a_components = normalized_path_components(path_a); - let b_components = normalized_path_components(path_b); - - if a_components.is_empty() || b_components.is_empty() { - return false; - } - - path_has_relative_suffix(&a_components, &b_components) - || path_has_relative_suffix(&b_components, &a_components) -} - -fn normalized_path_components(path: &str) -> Vec<&str> { - Path::new(path) - .components() - .filter_map(|component| match component { - Component::Normal(part) => part.to_str(), - _ => None, - }) - .collect() -} - -fn path_has_relative_suffix<'a>(full_path: &[&'a str], suffix_candidate: &[&'a str]) -> bool { - full_path.len() > suffix_candidate.len() - && full_path.ends_with(suffix_candidate) - && !suffix_candidate.is_empty() -} - /// Parse raw unified-diff text into a `ParsedPatch`. /// /// Supports both `Index:` (SVN-style) and `diff --git` (git-style) patch diff --git a/context/architecture.md b/context/architecture.md index 5a1ae8f25..0a1e610cf 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -82,7 +82,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `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. - `cli/src/services/sync/progress.rs` owns the sync-local, consumer-typed progress seam: generic `ProgressReporter` supports event delivery plus explicit successful finalization, closure-based collectors, and a no-op implementation alongside the fixed `indicatif` stderr presentation adapter. `cli/src/services/sync/sync.rs` owns `SyncProgressEvent` and its payload semantics for the three remotely synchronized streams (`messages`, `parts`, `agent_traces`; `diff_traces` is captured locally but not synchronized), while `sync/command.rs` selects the terminal adapter for text and the no-op reporter for JSON. There is no top-level `cli/src/services/progress/` module; sync orchestration depends only on its sync-owned contract, so terminal-library details stay at the sync presentation boundary. - `sce sync [--format text|json]` is implemented: `cli/src/services/sync/sync.rs` first rejects an effective `auth.mode: disabled` (`auth.disabled_by_config`, see `context/cli/access-policy.md`), then resolves repository-scoped Agent Trace storage, authenticates against the control plane with stored WorkOS credentials, uses the config-resolved `control_plane_base_url` with baked default `https://sce.crocoderlab.dev`, calls the ingestion `/state` endpoint once, then starts the three remote `messages`/`parts`/`agent_traces` stream state machines concurrently (`diff_traces` remains a local-only compatibility report) via `AgentTraceExportReader` and a shared per-stream reconciliation engine. Batches and cursor refreshes remain sequential within each stream, while fixed stream order is retained for final and stream-completion reporting; `cli/src/services/sync/render_sync.rs` renders the converged `AgentTraceSyncReport` as concise per-stream text or `camelCase` JSON without a nested subcommand field (see `context/cli/sync-command.md`). Local DB bootstrap and setup-time repository-scoped Agent Trace DB initialization otherwise still flow through lifecycle providers aggregated by setup, while repository-scoped DB health/repair flows through the doctor surface. The former trace database inspection and nested sync surfaces are unavailable. -- `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helper (`load_patch_from_json` for string input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. It also exposes `paths_refer_to_same_file` for absolute-vs-relative file identity; the former `combine_patches`/`intersect_patches` set operations were removed, and `parse_patch` is consumed by the active post-commit hook runtime. +- `cli/src/services/patch.rs` defines the standalone patch domain model (`ParsedPatch`, `PatchFileChange`, `FileChangeKind`, `PatchHunk`, `TouchedLine`, `TouchedLineKind`) for in-memory parsed unified-diff representation, capturing only touched lines (added/removed) plus minimal per-file/per-hunk metadata while excluding non-hunk headers and unchanged context lines. All types are `serde`-serializable/deserializable with `snake_case` JSON field naming. The module also provides `parse_patch`, a public parser function that converts raw unified-diff text (both `Index:` SVN-style and `diff --git` git-style formats) into `ParsedPatch` structs, with `ParseError` for actionable malformed-input diagnostics. Storage-agnostic JSON load helper (`load_patch_from_json` for string input) reconstruct `ParsedPatch` from serialized JSON content with `PatchLoadError` for actionable deserialization diagnostics. The former `combine_patches`/`intersect_patches` set operations and the suffix-equivalence path helper were removed; attribution-grade file identity lives in `cli/src/services/agent_trace/repository_path.rs`, and `parse_patch` is consumed by the active post-commit hook runtime. - `cli/src/services/structured_patch.rs` defines the synchronous structured editor-hook derivation seam. It derives Claude `PostToolUse` `Write` structured-update hunks, `Write` `tool_input.content` create fallback, and `Edit` structured-patch payloads into canonical `ParsedPatch` values plus Claude session/tool metadata, returning deterministic skip reasons for unsupported events/tools/payload shapes. The module is pure and side-effect-free. It is wired into `sce hooks diff-trace` for Claude payload classification at intake (T04) and into `RepositoryAgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing dispatch at read time (T05). - `cli/src/services/` contains module boundaries for command_registry, lifecycle, auth_command, config, setup, doctor, hooks, bash_policy, version, completion, help, patch, sync, SCE web URL helpers, shared database infrastructure, local DB adapters, encrypted auth DB adapters, and Agent Trace DB adapters with explicit trait seams for future implementations. The former `cli/src/services/checkout/` checkout-identity module was removed by the `remove-checkout-id` plan (see `context/cli/checkout-identity.md`); there is no per-clone identity, worktrees are identified by Git topology (`resolve_worktree_id`), active setup/hooks use `agent_trace_storage` to initialize/open the repository-scoped DB, and `sce doctor` surfaces credential-safe repository Agent Trace DB metadata. There is no checkout-scoped discovery or former trace inspection surface; any pre-migration `agent-trace-*.db` files on disk are never touched and no longer inspectable via the CLI. `cli/src/services/bash_policy.rs` owns both the CLI-agnostic evaluator logic and the hidden `sce policy bash` command adapter used by OpenCode, Claude, and Pi callers; Codex delegates in-process to the same evaluator for its native `PreToolUse(Bash)` response. `cli/src/services/command_registry.rs` defines the static `RuntimeCommand` enum, deterministic `CommandRegistry` name catalog, and `build_default_registry()` function for command dispatch metadata. Service-owned command modules own the runtime command payload structs for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync. - `cli/README.md` is the crate-local onboarding and usage source of truth for placeholder behavior, safety limitations, and roadmap mapping back to service contracts. diff --git a/context/cli/mutation-trace-agent-attribution.md b/context/cli/mutation-trace-agent-attribution.md index 369604bc1..83b38e68c 100644 --- a/context/cli/mutation-trace-agent-attribution.md +++ b/context/cli/mutation-trace-agent-attribution.md @@ -159,8 +159,12 @@ direct candidate matching in `cli/src/services/agent_trace/evidence.rs`: - **When is direct evidence sufficient?** Only when lineage left the line unresolved and a direct candidate is (1) *fresh* — recorded after the parent commit's committer-time second (`direct_evidence_cutoff_ms`), still - inside the 7-day window; (2) an *exact* touched-line match on file identity, - `kind`, `line_number`, and `content`; and (3) *unambiguous* — every fresh + inside the 7-day window; (2) an *exact* touched-line match on repository + file identity, `kind`, `line_number`, and `content` — file identity is the + normalized repository-relative path (absolute paths only inside the + repository root, Claude structured paths resolved against their recorded + `cwd`, no suffix equivalence, `..` escapes rejected; see + `agent_trace/repository_path.rs`); and (3) *unambiguous* — every fresh exact candidate for that line comes from one session. Each candidate row is kept separate (never merged into one patch), so evidence is consumed by its exact occurrence and a duplicate line elsewhere in the file is not covered. @@ -172,8 +176,16 @@ direct candidate matching in `cli/src/services/agent_trace/evidence.rs`: a textual match. Disagreeing direct candidates for one line fail closed to unknown; SCE never picks the newest or first row. Overridden candidates do not reach `conversation.related`, `contributor.model_id`, or the `tool` - object (emitted only when a direct candidate supports a final line - attribution). + object. The `tool` object is derived only from the direct rows supporting + finally accepted lines and is omitted when those rows name different tools + or any lacks a tool name; a differing version is dropped. +- **Which evidence may influence outputs?** Only accepted evidence. The same + acceptance (`agent_trace/evidence.rs`: repository file identity → exact line + match → single-session resolution) feeds the staged co-author preflight, the + persisted direct intersection, and the builder; the builder then reconciles + with mutation lineage before deriving session, model, and tool metadata. The + co-author preflight runs before mutation resolution and does not consult + mutation events, but still rejects conflicting direct candidates. - **What happens when history is incomplete?** Gaps, the 128-event window, the revision ceiling, and the unobserved tail keep lines `Unknown`, as before. An unknown line then needs fresh, exact, single-session direct evidence; diff --git a/context/cli/patch-service.md b/context/cli/patch-service.md index 2ae040e42..0c02e5fc7 100644 --- a/context/cli/patch-service.md +++ b/context/cli/patch-service.md @@ -45,9 +45,9 @@ Both functions wrap `serde_json::from_str`/`serde_json::from_slice` and map serd ### Direct matching vs. causal mutation lineage -The former `intersect_patches` / `combine_patches` set operations were removed: their historical `(kind, content)` fallback was too permissive for attribution and they had no remaining runtime caller. Attribution-grade direct matching lives in `cli/src/services/agent_trace/evidence.rs`, keeps each `diff_traces` row separate, requires an exact `(file, kind, line_number, content)` match, and fails closed when candidates for one line come from different sessions. It reuses `paths_refer_to_same_file` for absolute-vs-relative file identity. +The former `intersect_patches` / `combine_patches` set operations were removed: their historical `(kind, content)` fallback was too permissive for attribution and they had no remaining runtime caller. Attribution-grade direct matching lives in `cli/src/services/agent_trace/evidence.rs`, keeps each `diff_traces` row separate, requires an exact `(file, kind, line_number, content)` match, and fails closed when candidates for one line come from different sessions. File identity is attribution-grade repository identity from `cli/src/services/agent_trace/repository_path.rs` (`RepositoryRoot`): relative paths must be equal after lexical normalization, absolute paths count only inside the repository root (or its canonical spelling), relative paths from a source with a recorded absolute base directory (Claude structured `cwd`) are resolved against it, and `..` escapes are rejected. The former suffix-equivalence helper `paths_refer_to_same_file` was removed. -Mutation-history attribution 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 `patch_for_locations` / `patch_for_locations_with_provenance` (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). +Mutation-history attribution 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 `patch_for_locations` / `patch_for_locations_with_provenance` (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 either the mutation matcher or the direct-evidence matcher. 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 diff --git a/context/context-map.md b/context/context-map.md index 9170d0807..639e76934 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -17,7 +17,7 @@ Feature/domain context: - `context/cli/repository-identity.md` (repository identity module in `cli/src/services/repository_identity/`: pure scheme-neutral `host[:port]/path` canonicalization for SCP/`ssh://`/HTTPS/`git://` remote URLs with credential stripping, hostname lowercasing, default-port removal, and query/fragment/trailing-`.git` cleanup, trim-only explicit-identity handling, `sha256("sce-repository-id-v1\0" + canonical_identity)` repository IDs, credential-safe fieldless errors, plus the `resolve` runtime submodule applying explicit-config-then-configured-remote precedence with `git config --get remote..url` lookup, `RepositoryIdentitySource` provenance, and `.sce/config.json`-guidance resolution errors that never echo URLs; consumed by the T04 `agent_trace_storage` resolver) - `context/cli/agent-trace-storage.md` (async repository-scoped Agent Trace storage resolver in `cli/src/services/agent_trace_storage/`: `AgentTraceStorageContext` inputs mirroring the `agent_trace.*` config keys, shared degradation of invalid default-discovered config before identity fallback while explicit selections remain fatal, `ResolvedAgentTraceStorage` carrying repository identity + `/sce/repos//agent-trace.db` path + open `RepositoryAgentTraceDb` + typed `RepositoryMetadata`, `resolve_agent_trace_storage{,_at_state_root}` setup/lifecycle entrypoints with idempotent concurrent-safe first open via bounded fast-path-then-migrate retry plus narrow one-file schema migration-metadata repair and repository metadata validation, a separate no-migration `resolve_agent_trace_storage_for_hook_runtime{,_at_state_root}` pair that high-frequency hook callers use exclusively and that never runs migration `002` or any migration, path-unsafe repository ID rejection in `default_paths::agent_trace_db_path_for_repository{,_at}`, strict never-touch boundary for any pre-migration checkout-scoped/global DB files, and active hook/runtime plus Agent Trace lifecycle setup call-site consumption after T08) - `context/cli/checkout-identity.md` (checkout identity infrastructure, `cli/src/services/checkout/` and its `/sce/checkout-id` UUIDv7 storage, was removed by the `remove-checkout-id` plan; documents the removal plus the never-touch on-disk handling of any pre-existing checkout-id/pre-migration checkout-scoped DB files left behind) -- `context/cli/patch-service.md` (standalone patch domain model, parser, JSON load helpers, and path-identity helper in `cli/src/services/patch.rs` for in-memory parsed unified-diff representation, capturing only touched lines plus minimal per-file/per-hunk metadata, supporting both `Index:` SVN-style and `diff --git` git-style formats, with `ParseError` for actionable malformed-input diagnostics, `PatchLoadError`/`load_patch_from_json` for storage-agnostic JSON reconstruction, and `paths_refer_to_same_file` for absolute-vs-relative file identity (the former `intersect_patches`/`combine_patches` set operations were removed; attribution-grade direct matching lives in `agent_trace/evidence.rs`); repository structured-row reconstruction supplies persisted hunk-model and canonical touched-line-session provenance before these operations; `parse_patch` is consumed by the active post-commit hook runtime) +- `context/cli/patch-service.md` (standalone patch domain model, parser, and JSON load helpers in `cli/src/services/patch.rs` for in-memory parsed unified-diff representation, capturing only touched lines plus minimal per-file/per-hunk metadata, supporting both `Index:` SVN-style and `diff --git` git-style formats, with `ParseError` for actionable malformed-input diagnostics, `PatchLoadError`/`load_patch_from_json` for storage-agnostic JSON reconstruction (the former `intersect_patches`/`combine_patches` set operations and suffix path helper were removed; attribution-grade direct matching lives in `agent_trace/evidence.rs` with repository file identity in `agent_trace/repository_path.rs`); repository structured-row reconstruction supplies persisted hunk-model and canonical touched-line-session provenance before these operations; `parse_patch` is consumed by the active post-commit hook runtime) - `context/cli/structured-patch-service.md` (Claude structured editor-hook derivation in `cli/src/services/structured_patch.rs`, including `Write` structured-update hunks, `Write` `tool_input.content` create fallback, `Edit` structured patches, deterministic skip reasons, `ParsedPatch` output semantics, Rust golden fixture coverage, and repository read-time enrichment that assigns persisted row `model_id` to each hunk and canonical row `session_id` to each touched line) - `context/cli/styling-service.md` (CLI text-mode output styling with `owo-colors`, TTY/`NO_COLOR` policy, shared helper API for human-facing surfaces including sync completion markers, and per-column right-to-left RGB gradient banner rendering) - `context/cli/agent-trace-auto-sync.md` (setup-enabled post-commit Agent Trace synchronization with explicit-false opt-out plus doctor readiness reporting: a newly created repo-local config supplies the explicit `true` opt-in while omitted runtime values resolve to `false`; the existing `sce sync` command is launched once through the current executable after local persistence, with detached null-standard-stream behavior, fail-open startup, canonical managed-block proof of hook readiness, stable text/JSON enabled/disabled/not-ready/not-applicable states, no daemon/queue/high-frequency trigger, and retryability through manual sync and control-plane cursor authority) diff --git a/context/sce/agent-trace-commit-msg-coauthor-policy.md b/context/sce/agent-trace-commit-msg-coauthor-policy.md index 28d977ac8..08bc6f328 100644 --- a/context/sce/agent-trace-commit-msg-coauthor-policy.md +++ b/context/sce/agent-trace-commit-msg-coauthor-policy.md @@ -33,16 +33,16 @@ ## Staged AI-overlap evidence gate -- `cli/src/services/agent_trace.rs` owns the pure direct-evidence predicate `has_direct_evidence`, which reports an exact `(file, kind, line_number, content)` touched-line match between candidate rows and the staged patch. Historical `(kind, content)`-only similarity is not evidence. +- `cli/src/services/agent_trace.rs` owns the pure direct-evidence predicate `has_direct_evidence`, which reports whether at least one staged touched line has accepted direct evidence: repository file identity anchored at the repository root, an exact `(kind, line_number, content)` match, and a single claiming session. Historical `(kind, content)`-only similarity, suffix-only path similarity, and lines claimed by conflicting sessions are not evidence. - `cli/src/services/hooks/mod.rs` owns the staged-diff AI-overlap evidence gate, which is now wired into `run_commit_msg_subcommand_in_repo`: - `StagedDiffAiOverlapResult` enum (`Overlap`/`NoOverlap`/`Error`) is the three-valued result from the injectable `_with` variant, enabling testable branch coverage and caller-side error logging. - - `staged_diff_has_ai_overlap_with` is the injectable variant that accepts staged-patch, `HEAD` commit time, clock, and recent-trace dependencies and returns `StagedDiffAiOverlapResult`; `hooks/commit_hooks/attribution_tests.rs` covers stale, historical-only, and fresh exact rows. + - `staged_diff_has_ai_overlap_with` is the injectable variant that accepts staged-patch, `HEAD` commit time, clock, and recent-trace dependencies and returns `StagedDiffAiOverlapResult`; `hooks/commit_hooks/attribution_tests.rs` covers stale, historical-only, fresh exact, conflicting-session, and repository-file-identity rows. - `staged_diff_has_ai_overlap` is the live wrapper that opens Agent Trace DB through the no-migration hook path, delegates to `_with`, and logs `sce.hooks.commit_msg.ai_overlap_error` on `Error` results. - Live helper path: - opens Agent Trace DB through `RepositoryAgentTraceDb::open_for_hooks_without_migrations_at()` and `ensure_schema_ready_for_hooks()`; - captures the staged patch with `git diff --cached --patch --no-ext-diff`; - queries diff traces recorded after the current `HEAD` commit's committer-time second, bounded by 7 days (`direct_evidence_cutoff_ms`; with no `HEAD`, just the 7-day bound), so rows describing already-committed work are ignored; - - reports `Overlap` only when a fresh row exactly matches a staged touched line through `agent_trace::has_direct_evidence`. + - reports `Overlap` only when a fresh row supplies accepted direct evidence for a staged touched line through `agent_trace::has_direct_evidence`. - No-evidence/error posture: DB open/readiness failure, staged-diff capture/parse failure, clock/query failure, empty staged diff, no recent rows, malformed-only rows, or zero overlap all return `StagedDiffAiOverlapResult::Error` or `NoOverlap`, both mapping to `ai_contribution_present = false`. There is no fail-open mode. ## Verification evidence diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index 09cd4e499..b7cf24c65 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -157,7 +157,7 @@ Post-commit intersection rows are written by the active `post-commit` hook flow - SQL reads `id`, `time_ms`, `session_id`, `patch`, nullable `model_id` + `tool_name` + `tool_version`, and `payload_type` from `diff_traces` where `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`. - Rows are ordered by `time_ms ASC, id ASC` for deterministic chronological processing. - Valid row patches are parsed through `cli/src/services/patch.rs` `parse_patch` for `payload_type="patch"` rows (OpenCode unified-diff payloads), while `payload_type="structured"` rows (Claude `PostToolUse` structured payloads) are parsed from stored JSON through `cli/src/services/structured_patch.rs` `derive_claude_structured_patch` at read time to produce `ParsedPatch` without pre-rendered unified-diff text. -- Each produced `PatchHunk` is annotated with the originating row `model_id` (`Some(value)` propagated verbatim, `NULL` propagated as `None`) for both patch and structured paths. For structured rows, every produced touched line is also assigned the persisted tool-prefixed canonical row `session_id`; the raw unprefixed session inside the stored Claude payload is not used as reconstructed touched-line provenance. Parsed row records carry nullable `tool_name`/`tool_version` and `payload_type` from the same source row and are returned as `ParsedDiffTracePatch` records. +- Each produced `PatchHunk` is annotated with the originating row `model_id` (`Some(value)` propagated verbatim, `NULL` propagated as `None`) for both patch and structured paths. For structured rows, every produced touched line is also assigned the persisted tool-prefixed canonical row `session_id`; the raw unprefixed session inside the stored Claude payload is not used as reconstructed touched-line provenance. Parsed row records carry nullable `tool_name`/`tool_version` and `payload_type` from the same source row and are returned as `ParsedDiffTracePatch` records. Structured rows also carry `path_base_dir` — the stored payload's absolute `cwd` — because Claude file paths are derived relative to that `cwd`; direct-evidence matching resolves them against it to obtain repository identity. Patch rows have `path_base_dir = None`. No column was added; the value is read from the stored payload. - Malformed recent row patches (invalid unified-diff text, invalid structured JSON, unsupported payload types, or unsupported Claude structured payloads) are returned as `SkippedDiffTracePatch` records with deterministic parse-error or derivation-skip reasons; malformed historical rows do not fail the operation. - `RecentDiffTracePatches::loaded_count()` and `skipped_count()` expose accounting for later hook output and persistence metadata. @@ -169,7 +169,7 @@ Post-commit intersection rows are written by the active `post-commit` hook flow `cli/src/services/agent_trace.rs` owns the pure direct-evidence predicate `has_direct_evidence`, which is consumed by the commit-msg staged-diff AI-overlap evidence gate in `cli/src/services/hooks/commit_hooks.rs`: -- `has_direct_evidence(candidate_rows, staged_patch)` returns `true` only when a candidate row exactly matches a staged touched line on file identity, `kind`, `line_number`, and `content`. Candidate rows are limited to those recorded after the current `HEAD` commit, so stale and historical-only similarity never add the trailer. +- `has_direct_evidence(sources, staged_patch, repository_root)` returns `true` only when at least one staged touched line has accepted direct evidence: repository file identity, exact `kind`/`line_number`/`content`, and a single claiming session (it shares `accepted_direct_evidence` with the builder, so conflicting candidates never count). Candidate rows are limited to those recorded after the current `HEAD` commit, so stale and historical-only similarity never add the trailer. - `StagedDiffAiOverlapResult` (`Overlap`/`NoOverlap`/`Error`) is the three-valued result from the injectable `staged_diff_has_ai_overlap_with` variant, enabling testable branch coverage and caller-side error logging. - `staged_diff_has_ai_overlap` is the live wrapper that opens Agent Trace DB through the no-migration hook path, delegates to `_with`, and logs `sce.hooks.commit_msg.ai_overlap_error` on `Error` results. - The commit-msg evidence gate invokes the preflight only when the attribution gate passes (`attribution_hooks_enabled && !sce_disabled`); both `NoOverlap` and `Error` map to `ai_contribution_present = false`, suppressing the trailer. There is no fail-open mode. diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index c7d099354..debfee190 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -44,7 +44,7 @@ Interactive `sce setup` persists the independent confirmations at `agent_trace.a - Reads the message file as UTF-8. - Applies exactly one canonical trailer: `Co-authored-by: SCE `. - Writes back only when the attribution gate is enabled, `SCE_DISABLED` is false, the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`), and the transformed content differs. - - The staged-diff AI-overlap preflight is wired into `run_commit_msg_subcommand_in_repo`: it resolves repository-scoped Agent Trace storage, captures the staged diff via `git diff --cached`, queries repository-level diff traces recorded after the current `HEAD` commit's committer-time second (bounded by the past 7 days; just the 7 days when there is no `HEAD`), and reports overlap through `agent_trace::has_direct_evidence` only for an exact `(file, kind, line_number, content)` match. Stale rows and historical `(kind, content)`-only similarity never add the trailer. + - The staged-diff AI-overlap preflight is wired into `run_commit_msg_subcommand_in_repo`: it resolves repository-scoped Agent Trace storage, captures the staged diff via `git diff --cached`, queries repository-level diff traces recorded after the current `HEAD` commit's committer-time second (bounded by the past 7 days; just the 7 days when there is no `HEAD`), and reports overlap through `agent_trace::has_direct_evidence` only when at least one staged line has *accepted* direct evidence: repository file identity anchored at the repository root, an exact `(kind, line_number, content)` match, and a single claiming session. Stale rows, historical `(kind, content)`-only similarity, suffix-only path similarity, and lines claimed by conflicting sessions never add the trailer. The preflight runs before mutation resolution and does not consult mutation events. - 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; `Error` results are logged via `sce.hooks.commit_msg.ai_overlap_error`. - The preflight is invoked only when the attribution gate passes; when the gate does not pass, no DB read or staged-diff capture occurs. - `pre-commit` is a deterministic no-op entrypoint. @@ -58,13 +58,13 @@ Interactive `sce setup` persists the independent confirmations at `agent_trace.a - Keeps each fresh row's patch separate (`direct_patches`) and projects trusted direct evidence through `agent_trace::direct_evidence_patch`: exact touched-line matches whose candidates all come from one session. - Persists the serialized trusted direct projection to `post_commit_patch_intersections` table with commit metadata (OID, timestamp), window bounds (cutoff_ms, end_ms), and loaded/skipped counts. - Empty recent patch set produces deterministic empty intersection result (no crash). - - Internal orchestration now returns a typed `PostCommitIntersectionFlowResult` (`direct_patches`, `intersection_patch`, `post_commit_data`, optional `tool_name`, optional `tool_version`) from `run_post_commit_intersection_flow_with()`, where tool metadata comes from the newest fresh row with an exact overlap and falls back to `None`. + - Internal orchestration now returns a typed `PostCommitIntersectionFlowResult` (`repository_root`, `direct_traces: Vec`, `intersection_patch`, `post_commit_data`) from `run_post_commit_intersection_flow_with()`. Each row keeps its id, session, model, tool, and path base directory until final line resolution; no tool is selected before attribution is resolved. - `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 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: per-row `direct_patches`, plus the mutation-AI and resolved non-AI line sets from bounded lineage over every committed line. Lineage takes precedence over direct candidates line by line. See [agent-trace-minimal-generator.md](agent-trace-minimal-generator.md). + - The run-flow path maps commit-time metadata to RFC3339 and calls `agent_trace::build_agent_trace_from_evidence(...)` with separated evidence: per-row `DirectEvidenceSource`s built from `direct_traces`, the repository root, plus the mutation-AI and resolved non-AI line sets from bounded lineage over every committed line. Lineage takes precedence over direct candidates line by line. See [agent-trace-minimal-generator.md](agent-trace-minimal-generator.md). - The mutation evidence comes from `mutation_trace::runtime::resolve_post_commit_mutation_attribution(...)`, a read-only entrypoint that resolves the invoking worktree's Git dir and topology-derived worktree ID (`resolve_git_dir` + `resolve_worktree_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 result — trusted 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; 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. + - Top-level `tool` metadata is derived inside the builder from the direct rows that support finally accepted lines; rejected, conflicting, or lineage-overridden rows never supply it, and differing tools omit it. - 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. - Validation failures are returned through the same post-commit runtime failure path/class used for Agent Trace DB insertion failures (no silent fallback). diff --git a/context/sce/agent-trace-minimal-generator.md b/context/sce/agent-trace-minimal-generator.md index 1d46d617f..b6a26b0d7 100644 --- a/context/sce/agent-trace-minimal-generator.md +++ b/context/sce/agent-trace-minimal-generator.md @@ -13,14 +13,17 @@ Given per-row direct candidate patches, mutation lineage evidence, and a `post_c - **`unknown`** — no touched line in the hunk is attributed. 3. Map `Conversation.contributor.model_id` for `ai` / `mixed` hunks only when every attributed line has the same known model. A mutation-attributed line uses its scope model, falling back to a same-session exact direct candidate's model. 4. Derive optional `conversation.related` entries from the sessions of attributed lines only, emitted as `{ "type": "session", "url": "https://sce.crocoder.dev/sessions/" }`, deduplicated and deterministically ordered. Overridden or conflicting direct candidates never appear. -5. Emit the top-level `tool` object only when at least one final line attribution is supported by a direct candidate. +5. Derive the top-level `tool` object only from the direct sources that support finally accepted lines (after mutation reconciliation). It is omitted when no direct source survived, when surviving sources name different tools, or when any surviving source lacks a tool name; `version` is kept only when every surviving source reports the same one. Row order never picks a winner, and rejected or overridden sources never contribute. 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 only supported generator entrypoint. `AgentTraceEvidence` keeps the sources separate: -- `direct_patches` — one `ParsedPatch` per freshness-bounded `diff_traces` row, never combined, so conflicting sessions and duplicate occurrences stay visible. Matching lives in `agent_trace/evidence.rs`. +- `repository_root` — `RepositoryRoot` anchoring attribution-grade file identity (`agent_trace/repository_path.rs`). +- `direct_sources` — one `DirectEvidenceSource` per freshness-bounded `diff_traces` row (patch, optional absolute `base_dir`, `tool_name`, `tool_version`), never combined, so conflicting sessions, duplicate occurrences, and per-row tool metadata stay attached until resolution. Matching lives in `agent_trace/evidence.rs`. + +Only *accepted* direct evidence may influence authorship, co-author eligibility, or tool provenance: a candidate is accepted when its file resolves to the same repository identity as the target file (`agent_trace/repository_path.rs`), its touched line matches exactly on `(kind, line_number, content)`, and every matching candidate for that line comes from one session. Conflicting candidates are rejected everywhere. - `mutation_ai_patch` — target-shaped committed lines that causal lineage attributed to an AI-exclusive scope, with resolved scope provenance. - `mutation_non_ai_patch` — target-shaped committed lines that lineage resolved to a non-exclusive transition; these block direct attribution. @@ -29,7 +32,7 @@ Given per-row direct candidate patches, mutation lineage evidence, and a `post_c | Type | Purpose | | ----------------------- | ------------------------------------------------------------------------------------------------------------ | | `HunkContributor` | Enum: `Ai`, `Mixed`, `Unknown` | -| `AgentTraceEvidence` | Builder input: borrowed per-row `direct_patches`, target-shaped `mutation_ai_patch` (with scope provenance), and `mutation_non_ai_patch` | +| `AgentTraceEvidence` | Builder input: `repository_root`, borrowed per-row `direct_sources`, target-shaped `mutation_ai_patch` (with scope provenance), and `mutation_non_ai_patch` | | `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` | @@ -60,7 +63,7 @@ Current output includes top-level metadata fields with this contract: - `timestamp` is sourced from explicit commit metadata input (`AgentTraceMetadataInput.commit_timestamp`) and must be RFC 3339 - `vcs` is emitted only when explicit commit metadata input includes `AgentTraceMetadataInput.vcs_type` - when `vcs` is emitted, `vcs.type` is sourced from the schema-aligned enum (`git | jj | hg | svn`) and `vcs.revision` is sourced from `AgentTraceMetadataInput.commit_revision` -- `tool` is omitted when `intersection_patch.files` is empty (no AI content overlapped with the post-commit patch) or when both `AgentTraceMetadataInput.tool_name` and `AgentTraceMetadataInput.tool_version` are `None`; when `intersection_patch.files` is non-empty and either metadata value is present, builder construction sets `AgentTrace.tool` and it serializes as `{ "name"?: string, "version"?: string }` with each nested field omitted when absent +- `tool` is derived from accepted direct sources only (see rule 5 above); `AgentTraceMetadataInput` no longer carries tool metadata. When emitted it serializes as `{ "name": string, "version"?: string }` - `metadata.sce.version` is always emitted and is sourced from `env!("CARGO_PKG_VERSION")`, the compiled `sce` CLI package version; it is implementation metadata and does not change top-level Agent Trace `version` semantics - `metadata.sce.line_changes` is always emitted (`{ ai, mixed, unknown }`, each `{ added, removed }`) and carries exact touched-line attribution counts derived from `PatchHunk.lines` on the canonical `post_commit_patch` — the same hunk-level classification already used for `Conversation.contributor.type` (no independent second classification pass for the common per-file/per-hunk path); a `mixed` hunk's *entire* touched-line count is recorded, not just the subset also present in `intersection_patch`; the deleted-`.patch` embedded-expansion branch counts only the deleted file's own literal `post_commit_patch` hunks (classified by `old_path` against the top-level `intersection_patch`, since a deleted file's `new_path` is always empty and would otherwise collide with other deleted files in the same patch), never the embedded reconstructed hunks used to synthesize that branch's `Conversation` entries; `#[serde(default)]` on `line_changes` and its parent keeps pre-existing `metadata.sce.version`-only payloads deserializing with all-zero counts - every `Conversation.url` is the absolute URI `https://sce.crocoder.dev/conversations/{agent_trace.id}` derived from the generated top-level `AgentTrace.id`; all conversations in one payload therefore share the same URL @@ -108,7 +111,7 @@ Current output includes top-level metadata fields with this contract: ## Public API - `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_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, classifies each hunk from the union of direct and mutation-derived AI coverage, unions direct/mutation related sessions, and emits a model only when the present evidence sources agree under the combined rule; top-level `tool` remains bound to the direct intersection. +- `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, classifies each hunk from the union of direct and mutation-derived AI coverage, unions direct/mutation related sessions, and emits a model only when the present evidence sources agree under the combined rule; top-level `tool` is derived from the direct sources supporting finally accepted lines. ## Test fixture contract @@ -118,7 +121,7 @@ Current output includes top-level metadata fields with this contract: ## Relationship to existing patch service -Consumes `ParsedPatch`/`PatchHunk`/`TouchedLine` types and the path-identity helper `paths_refer_to_same_file` from `cli/src/services/patch.rs`. It does not introduce a separate patch model. +Consumes `ParsedPatch`/`PatchHunk`/`TouchedLine` types from `cli/src/services/patch.rs`; file identity comes from `agent_trace/repository_path.rs`. It does not introduce a separate patch model. ## Out of scope From f83ae33bca456a5e653b152ff47a06b254812200 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 6 Nov 2026 17:36:01 +0100 Subject: [PATCH 03/11] agent-trace: Anchor structured paths to their coordinate system Claude structured diff paths previously fell back to repository-relative matching when cwd was unavailable, which could attribute an edit to the wrong repository file. Carry an explicit path origin through parsing and attribution: repository-relative Git paths, absolute cwd-based Claude paths, and absolute-only Claude paths without a usable cwd. Reject unresolved relative identities while preserving absolute paths, and add regression coverage and context documentation. Co-authored-by: SCE --- cli/src/services/agent_trace/evidence.rs | 16 +- .../services/agent_trace/repository_path.rs | 127 +++++---- cli/src/services/agent_trace/tests.rs | 143 +++++++++++ cli/src/services/agent_trace_db/mod.rs | 39 ++- .../hooks/commit_hooks/attribution_tests.rs | 240 +++++++++++++++++- cli/src/services/structured_patch.rs | 14 +- .../cli/mutation-trace-agent-attribution.md | 5 +- .../agent-trace-commit-msg-coauthor-policy.md | 2 +- context/sce/agent-trace-db.md | 7 +- context/sce/agent-trace-minimal-generator.md | 6 +- 10 files changed, 518 insertions(+), 81 deletions(-) diff --git a/cli/src/services/agent_trace/evidence.rs b/cli/src/services/agent_trace/evidence.rs index 12551a317..f15c15237 100644 --- a/cli/src/services/agent_trace/evidence.rs +++ b/cli/src/services/agent_trace/evidence.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use super::repository_path::{RepositoryPath, RepositoryRoot}; use super::AgentTraceTool; -use crate::services::agent_trace_db::ParsedDiffTracePatch; +use crate::services::agent_trace_db::{DiffTracePathOrigin, ParsedDiffTracePatch}; use crate::services::mutation_trace::attribution::{ logical_path, patch_for_locations_with_provenance, PatchLineLocation, PatchLineProvenance, }; @@ -11,7 +11,7 @@ use crate::services::patch::{ParsedPatch, PatchFileChange, TouchedLineKind}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DirectEvidenceSource<'a> { pub patch: &'a ParsedPatch, - pub base_dir: Option<&'a str>, + pub path_origin: &'a DiffTracePathOrigin, pub tool_name: Option<&'a str>, pub tool_version: Option<&'a str>, } @@ -21,7 +21,7 @@ impl<'a> DirectEvidenceSource<'a> { pub fn repository_relative(patch: &'a ParsedPatch) -> Self { DirectEvidenceSource { patch, - base_dir: None, + path_origin: &DiffTracePathOrigin::RepositoryRelative, tool_name: None, tool_version: None, } @@ -32,7 +32,7 @@ impl<'a> From<&'a ParsedDiffTracePatch> for DirectEvidenceSource<'a> { fn from(row: &'a ParsedDiffTracePatch) -> Self { DirectEvidenceSource { patch: &row.patch, - base_dir: row.path_base_dir.as_deref(), + path_origin: &row.path_origin, tool_name: row.tool_name.as_deref(), tool_version: row.tool_version.as_deref(), } @@ -64,7 +64,7 @@ pub(crate) fn direct_line_evidence( .flat_map(|(source_index, source)| { source.patch.files.iter().filter_map(move |file| { repository_root - .repository_path(logical_path(file), source.base_dir) + .repository_path(logical_path(file), source.path_origin) .map(|path| (source_index, file, path)) }) }) @@ -72,8 +72,10 @@ pub(crate) fn direct_line_evidence( let mut evidence = BTreeMap::new(); for (file_index, target_file) in target.files.iter().enumerate() { - let Some(target_path) = repository_root.repository_path(logical_path(target_file), None) - else { + let Some(target_path) = repository_root.repository_path( + logical_path(target_file), + &DiffTracePathOrigin::RepositoryRelative, + ) else { continue; }; let mut by_line: HashMap, Vec<(usize, PatchLineProvenance)>> = diff --git a/cli/src/services/agent_trace/repository_path.rs b/cli/src/services/agent_trace/repository_path.rs index 37e20ef1f..cae097044 100644 --- a/cli/src/services/agent_trace/repository_path.rs +++ b/cli/src/services/agent_trace/repository_path.rs @@ -1,5 +1,7 @@ use std::path::{Component, Path}; +use crate::services::agent_trace_db::DiffTracePathOrigin; + #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct RepositoryPath(String); @@ -36,10 +38,13 @@ impl RepositoryRoot { repository_root } + /// Resolves `path` to its repository identity. Absolute paths stand on + /// their own; a relative path is interpreted only in the coordinate system + /// `origin` establishes, and is rejected when there is none. pub(crate) fn repository_path( &self, path: &str, - base_dir: Option<&str>, + origin: &DiffTracePathOrigin, ) -> Option { if path.is_empty() { return None; @@ -48,11 +53,14 @@ impl RepositoryRoot { if path.is_absolute() { return self.strip_root(&absolute_components(path)?); } - match base_dir.map(Path::new).filter(|base| base.is_absolute()) { - Some(base) => self.strip_root(&absolute_components(&base.join(path))?), - None => { + match origin { + DiffTracePathOrigin::RepositoryRelative => { relative_components(path).map(|components| RepositoryPath(components.join("/"))) } + DiffTracePathOrigin::WorkingDirectory(cwd) => { + self.strip_root(&absolute_components(&Path::new(cwd).join(path))?) + } + DiffTracePathOrigin::AbsoluteOnly => None, } } @@ -102,82 +110,110 @@ mod tests { use std::path::Path; use super::RepositoryRoot; + use crate::services::agent_trace_db::DiffTracePathOrigin; const ROOT: &str = "/workspace/project"; - fn identity(path: &str, base_dir: Option<&str>) -> Option { + fn resolve(path: &str, origin: &DiffTracePathOrigin) -> Option { RepositoryRoot::new(Path::new(ROOT)) - .repository_path(path, base_dir) + .repository_path(path, origin) .map(|path| path.as_str().to_owned()) } + fn identity(path: &str) -> Option { + resolve(path, &DiffTracePathOrigin::RepositoryRelative) + } + + fn in_cwd(path: &str, cwd: &str) -> Option { + resolve(path, &DiffTracePathOrigin::WorkingDirectory(cwd.to_owned())) + } + + fn absolute_only(path: &str) -> Option { + resolve(path, &DiffTracePathOrigin::AbsoluteOnly) + } + #[test] fn relative_paths_normalize_to_exact_repository_identity() { - assert_eq!(identity("src/lib.rs", None).as_deref(), Some("src/lib.rs")); + assert_eq!(identity("src/lib.rs").as_deref(), Some("src/lib.rs")); assert_eq!( - identity("./src/../src/lib.rs", None).as_deref(), + identity("./src/../src/lib.rs").as_deref(), Some("src/lib.rs") ); - assert_ne!( - identity("src/lib.rs", None), - identity("packages/foo/src/lib.rs", None) - ); + assert_ne!(identity("src/lib.rs"), identity("packages/foo/src/lib.rs")); } #[test] fn absolute_paths_inside_the_repository_become_repository_relative() { + for resolve_absolute in [identity, absolute_only] { + assert_eq!( + resolve_absolute("/workspace/project/src/lib.rs").as_deref(), + Some("src/lib.rs") + ); + assert_eq!( + resolve_absolute("/workspace/project/./src/../src/lib.rs").as_deref(), + Some("src/lib.rs") + ); + } assert_eq!( - identity("/workspace/project/src/lib.rs", None).as_deref(), - Some("src/lib.rs") - ); - assert_eq!( - identity("/workspace/project/./src/../src/lib.rs", None).as_deref(), + in_cwd("/workspace/project/src/lib.rs", "/elsewhere").as_deref(), Some("src/lib.rs") ); } #[test] fn absolute_paths_outside_the_repository_are_rejected() { - assert_eq!( - identity("/workspace/another-project/src/lib.rs", None), - None - ); - assert_eq!(identity("/workspace/project-two/src/lib.rs", None), None); - assert_eq!(identity("/workspace/project", None), None); - assert_eq!(identity("/src/lib.rs", None), None); + for resolve_absolute in [identity, absolute_only] { + assert_eq!( + resolve_absolute("/workspace/another-project/src/lib.rs"), + None + ); + assert_eq!(resolve_absolute("/workspace/project-two/src/lib.rs"), None); + assert_eq!(resolve_absolute("/workspace/project"), None); + assert_eq!(resolve_absolute("/src/lib.rs"), None); + } + assert_eq!(in_cwd("/workspace/another-project/src/lib.rs", ROOT), None); } #[test] fn traversal_cannot_escape_the_repository() { - assert_eq!(identity("../project/src/lib.rs", None), None); - assert_eq!(identity("src/../../lib.rs", None), None); + assert_eq!(identity("../project/src/lib.rs"), None); + assert_eq!(identity("src/../../lib.rs"), None); assert_eq!( - identity("/workspace/project/../another-project/src/lib.rs", None), + identity("/workspace/project/../another-project/src/lib.rs"), None ); - assert_eq!(identity("../src/lib.rs", Some("/workspace/project")), None); - assert_eq!(identity("", None), None); - } - - #[test] - fn relative_paths_resolve_against_an_absolute_base_directory() { + assert_eq!(in_cwd("../src/lib.rs", ROOT), None); assert_eq!( - identity("src/lib.rs", Some("/workspace/project/cli")).as_deref(), - Some("cli/src/lib.rs") + in_cwd("../../other/src/lib.rs", "/workspace/project/cli"), + None ); assert_eq!( - identity("src/lib.rs", Some("/workspace/project")).as_deref(), - Some("src/lib.rs") + in_cwd("../../../../../src/lib.rs", "/workspace/project/cli"), + None ); + assert_eq!(identity(""), None); + } + + #[test] + fn relative_paths_resolve_against_an_absolute_working_directory() { assert_eq!( - identity("../cli/src/lib.rs", Some("/workspace/project/config")).as_deref(), + in_cwd("src/lib.rs", "/workspace/project/cli").as_deref(), Some("cli/src/lib.rs") ); - assert_eq!(identity("src/lib.rs", Some("/workspace/another")), None); + assert_eq!(in_cwd("src/lib.rs", ROOT).as_deref(), Some("src/lib.rs")); assert_eq!( - identity("src/lib.rs", Some("relative/base")).as_deref(), - Some("src/lib.rs") + in_cwd("../cli/src/lib.rs", "/workspace/project/config").as_deref(), + Some("cli/src/lib.rs") ); + assert_eq!(in_cwd("src/lib.rs", "/workspace/another"), None); + } + + #[test] + fn relative_paths_without_an_established_coordinate_system_are_rejected() { + assert_eq!(absolute_only("src/lib.rs"), None); + assert_eq!(absolute_only("./src/lib.rs"), None); + assert_eq!(in_cwd("src/lib.rs", "relative/base"), None); + assert_eq!(in_cwd("src/lib.rs", ""), None); } #[cfg(unix)] @@ -192,9 +228,12 @@ mod tests { let canonical = real.path().canonicalize().expect("canonical root"); for spelling in [link.join("src/lib.rs"), canonical.join("src/lib.rs")] { assert_eq!( - root.repository_path(spelling.to_str().expect("utf-8 path"), None) - .map(|path| path.as_str().to_owned()) - .as_deref(), + root.repository_path( + spelling.to_str().expect("utf-8 path"), + &DiffTracePathOrigin::AbsoluteOnly + ) + .map(|path| path.as_str().to_owned()) + .as_deref(), Some("src/lib.rs") ); } diff --git a/cli/src/services/agent_trace/tests.rs b/cli/src/services/agent_trace/tests.rs index b16a1aca1..20eb2c376 100644 --- a/cli/src/services/agent_trace/tests.rs +++ b/cli/src/services/agent_trace/tests.rs @@ -8,6 +8,7 @@ use super::{ }; use crate::services::{ agent_trace::agent_trace_conversation_url, + agent_trace_db::DiffTracePathOrigin, patch::{parse_patch, ParsedPatch}, }; use serde_json::Value; @@ -411,6 +412,7 @@ struct ToolSource { patch: ParsedPatch, tool_name: Option<&'static str>, tool_version: Option<&'static str>, + path_origin: DiffTracePathOrigin, } fn tool_source( @@ -424,15 +426,32 @@ fn tool_source( patch: attributed_patch(path, lines, session, model), tool_name: tool.map(|(name, _)| name), tool_version: tool.and_then(|(_, version)| version), + path_origin: DiffTracePathOrigin::RepositoryRelative, } } +fn claude_source( + path: &str, + lines: &[(u64, &str)], + path_origin: DiffTracePathOrigin, +) -> ToolSource { + ToolSource { + path_origin, + ..tool_source(path, lines, SESSION_A, MODEL_A, Some(("claude", None))) + } +} + +fn working_directory(cwd: &str) -> DiffTracePathOrigin { + DiffTracePathOrigin::WorkingDirectory(cwd.to_owned()) +} + fn direct_sources(sources: &[ToolSource]) -> Vec> { sources .iter() .map(|source| DirectEvidenceSource { tool_name: source.tool_name, tool_version: source.tool_version, + path_origin: &source.path_origin, ..DirectEvidenceSource::repository_relative(&source.patch) }) .collect() @@ -866,3 +885,127 @@ fn a_session_conflicted_source_cannot_supply_the_tool() { ); assert_eq!(tool_of(&trace), None); } + +#[test] +fn claude_relative_paths_without_an_absolute_cwd_cannot_attribute_or_supply_the_tool() { + for path_origin in [ + DiffTracePathOrigin::AbsoluteOnly, + working_directory("relative/base"), + working_directory(""), + ] { + let sources = [ + claude_source(SOURCE_PATH, &[LINE_X], path_origin.clone()), + tool_source( + SOURCE_PATH, + &[LINE_Y], + SESSION_B, + MODEL_B, + Some(("opencode", Some("0.9"))), + ), + ]; + + assert_eq!( + accepted_line_count(&sources, &committed_patch()), + 1, + "{path_origin:?}" + ); + assert!(!super::has_direct_evidence( + &direct_sources(&sources[..1]), + &committed_patch(), + &test_root() + )); + let trace = build_with_sources(&sources, &no_mutation()); + assert_eq!( + contributor_kinds(&trace), + vec![super::HunkContributor::Unknown, super::HunkContributor::Ai], + "{path_origin:?}" + ); + assert_eq!(tool_of(&trace), Some((Some("opencode"), Some("0.9")))); + } +} + +#[test] +fn claude_absolute_paths_attribute_without_a_cwd_only_inside_the_repository() { + let sources = [ + claude_source( + &format!("{TEST_REPOSITORY_ROOT}/{SOURCE_PATH}"), + &[LINE_X], + DiffTracePathOrigin::AbsoluteOnly, + ), + claude_source( + &format!("/workspace/another-project/{SOURCE_PATH}"), + &[LINE_Y], + DiffTracePathOrigin::AbsoluteOnly, + ), + ]; + + let trace = build_with_sources(&sources, &no_mutation()); + assert_eq!( + contributor_kinds(&trace), + vec![super::HunkContributor::Ai, super::HunkContributor::Unknown] + ); + assert_eq!(tool_of(&trace), Some((Some("claude"), None))); +} + +#[test] +fn claude_relative_paths_resolve_against_their_absolute_cwd() { + let subdirectory = format!("{TEST_REPOSITORY_ROOT}/cli"); + for (path, cwd, expected) in [ + (SOURCE_PATH, TEST_REPOSITORY_ROOT, true), + ("../src/lib.rs", subdirectory.as_str(), true), + (SOURCE_PATH, subdirectory.as_str(), false), + ("../../project/src/lib.rs", subdirectory.as_str(), true), + ( + "../../another-project/src/lib.rs", + subdirectory.as_str(), + false, + ), + ("../../../../src/lib.rs", subdirectory.as_str(), false), + ] { + let sources = [claude_source(path, &[LINE_X], working_directory(cwd))]; + assert_eq!( + super::has_direct_evidence(&direct_sources(&sources), &committed_patch(), &test_root()), + expected, + "{cwd} {path}" + ); + } + + let nested = parse_patch(&added_lines_patch("cli/src/lib.rs", &[LINE_X]), None) + .expect("nested committed patch parses"); + let sources = [claude_source( + SOURCE_PATH, + &[LINE_X], + working_directory(&subdirectory), + )]; + assert!(super::has_direct_evidence( + &direct_sources(&sources), + &nested, + &test_root() + )); +} + +#[test] +fn mutation_lineage_still_attributes_when_claude_direct_evidence_lacks_a_cwd() { + let sources = [claude_source( + SOURCE_PATH, + &[LINE_X, LINE_Y], + DiffTracePathOrigin::AbsoluteOnly, + )]; + let mutation_ai = attributed_patch(SOURCE_PATH, &[LINE_X], SESSION_C, MODEL_C); + + let trace = build_with_sources(&sources, &mutation_ai); + assert_eq!( + contributor_kinds(&trace), + vec![super::HunkContributor::Ai, super::HunkContributor::Unknown] + ); + assert_eq!( + trace.files[0].conversations[0] + .contributor + .model_id + .as_deref(), + Some(MODEL_C) + ); + assert_eq!(tool_of(&trace), None); + let json = serde_json::to_string(&trace).expect("trace serializes"); + assert!(!json.contains(SESSION_A) && !json.contains(MODEL_A)); +} diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index 55ae33fce..89da3df15 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -179,7 +179,23 @@ pub struct ParsedDiffTracePatch { pub tool_name: Option, pub tool_version: Option, pub payload_type: String, - pub path_base_dir: Option, + pub path_origin: DiffTracePathOrigin, +} + +/// Coordinate system in which a diff-trace row's file paths are expressed. +/// +/// Absolute paths resolve on their own under every origin; the origin only +/// decides how a relative path is interpreted. An unknown origin fails closed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DiffTracePathOrigin { + /// Git patch paths: relative paths are repository-relative. + RepositoryRelative, + /// Claude structured paths with a recorded absolute `cwd`: relative paths + /// are relative to that working directory. + WorkingDirectory(String), + /// Claude structured paths without a usable absolute `cwd`: relative paths + /// have no established coordinate system and cannot be resolved. + AbsoluteOnly, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -551,7 +567,7 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif let is_structured = row.payload_type == PAYLOAD_TYPE_STRUCTURED; let parse_result = match row.payload_type.as_str() { PAYLOAD_TYPE_PATCH => parse_patch(&row.patch, Some(row.session_id.as_str())) - .map(|patch| (patch, None)) + .map(|patch| (patch, DiffTracePathOrigin::RepositoryRelative)) .map_err(|error| skipped_diff_trace_patch_reason(&error)), PAYLOAD_TYPE_STRUCTURED => match serde_json::from_str::(&row.patch) { Ok(payload) => match derive_claude_structured_patch( @@ -561,7 +577,11 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif row.tool_version.as_deref(), ) { ClaudeStructuredPatchDerivationResult::Derived(derived) => { - Ok((derived.patch, structured_payload_cwd(&payload))) + let path_origin = derived.working_directory.map_or( + DiffTracePathOrigin::AbsoluteOnly, + DiffTracePathOrigin::WorkingDirectory, + ); + Ok((derived.patch, path_origin)) } ClaudeStructuredPatchDerivationResult::Skipped(reason) => { Err(reason.to_string()) @@ -573,7 +593,7 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif }; match parse_result { - Ok((mut patch, path_base_dir)) => { + Ok((mut patch, path_origin)) => { for file in &mut patch.files { for hunk in &mut file.hunks { hunk.model_id.clone_from(&row.model_id); @@ -593,7 +613,7 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif tool_name: row.tool_name, tool_version: row.tool_version, payload_type: row.payload_type, - path_base_dir, + path_origin, }); } Err(reason) => skipped.push(SkippedDiffTracePatch { @@ -608,15 +628,6 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec) -> RecentDif RecentDiffTracePatches { patches, skipped } } -fn structured_payload_cwd(payload: &Value) -> Option { - payload - .get("cwd") - .and_then(Value::as_str) - .map(str::trim) - .filter(|cwd| std::path::Path::new(cwd).is_absolute()) - .map(ToOwned::to_owned) -} - fn skipped_diff_trace_patch_reason(error: &ParseError) -> String { error.to_string() } diff --git a/cli/src/services/hooks/commit_hooks/attribution_tests.rs b/cli/src/services/hooks/commit_hooks/attribution_tests.rs index 97b31d663..94bc6a8a6 100644 --- a/cli/src/services/hooks/commit_hooks/attribution_tests.rs +++ b/cli/src/services/hooks/commit_hooks/attribution_tests.rs @@ -13,8 +13,9 @@ use crate::services::agent_trace::{ }; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::agent_trace_db::{ - AgentTraceInsert, DiffTraceInsert, ParsedDiffTracePatch, PostCommitPatchIntersectionInsert, - RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, PAYLOAD_TYPE_STRUCTURED, + AgentTraceInsert, DiffTraceInsert, DiffTracePathOrigin, ParsedDiffTracePatch, + PostCommitPatchIntersectionInsert, RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, + PAYLOAD_TYPE_STRUCTURED, }; use crate::services::mutation_trace::attribution::MutationAttributionResult; use crate::services::mutation_trace::runtime::GitSnapshotService; @@ -102,6 +103,12 @@ struct HistoryRepo { impl HistoryRepo { fn new() -> Self { + Self::with_plan_paths(&[PLAN_PATH]) + } + + /// Every path receives the same plan content in every commit, so the + /// committed touched lines are identical at identical positions. + fn with_plan_paths(paths: &[&str]) -> Self { let dir = tempfile::tempdir().expect("temp dir"); let root = dir.path(); git(root, &["init", "-q", "-b", "main"], None); @@ -112,7 +119,10 @@ impl HistoryRepo { ); git(root, &["config", "user.name", "Attribution Test"], None); git(root, &["config", "commit.gpgsign", "false"], None); - std::fs::create_dir_all(root.join("context/plans")).expect("plan dir"); + for path in paths { + std::fs::create_dir_all(root.join(path).parent().expect("plan parent")) + .expect("plan dir"); + } let mut trees = Vec::new(); for (done, time_s) in [ @@ -121,7 +131,9 @@ impl HistoryRepo { ([true, true, false], COMMIT_B_TIME_S), ([true, true, true], COMMIT_C_TIME_S), ] { - std::fs::write(root.join(PLAN_PATH), plan(done)).expect("write plan"); + for path in paths { + std::fs::write(root.join(path), plan(done)).expect("write plan"); + } git(root, &["add", "-A"], None); git(root, &["commit", "-q", "-m", "plan"], Some(time_s)); trees.push(TreeId(git(root, &["rev-parse", "HEAD^{tree}"], None))); @@ -297,7 +309,7 @@ fn parsed_row(id: i64, row: &DiffTraceRow) -> ParsedDiffTracePatch { tool_name: Some(row.tool_name.to_owned()), tool_version: None, payload_type: String::from(PAYLOAD_TYPE_PATCH), - path_base_dir: None, + path_origin: DiffTracePathOrigin::RepositoryRelative, } } @@ -954,18 +966,38 @@ async fn post_commit_direct_evidence_requires_repository_file_identity() { } fn claude_write_payload(cwd: &str, file_path: &str) -> String { - serde_json::json!({ + claude_write_payload_with_cwd(Some(Value::from(cwd)), file_path) +} + +fn claude_write_payload_with_cwd(cwd: Option, file_path: &str) -> String { + let mut payload = serde_json::json!({ "hook_event_name": "PostToolUse", "session_id": SESSION_C, - "cwd": cwd, "tool_name": "Write", "tool_input": { "file_path": file_path, "content": plan([true, true, true]), }, "tool_response": { "originalFile": null }, + }); + if let Some(cwd) = cwd { + payload["cwd"] = cwd; + } + payload.to_string() +} + +async fn insert_structured_row(db: &RepositoryAgentTraceDb, payload: &str) { + db.insert_diff_trace(DiffTraceInsert { + time_ms: ms(COMMIT_C_TIME_S) - 500, + session_id: SESSION_C, + patch: payload, + model_id: Some(MODEL_C), + tool_name: CLAUDE_TOOL, + tool_version: Some("2.0.0"), + payload_type: PAYLOAD_TYPE_STRUCTURED, }) - .to_string() + .await + .expect("insert structured diff trace"); } #[tokio::test] @@ -1024,3 +1056,195 @@ async fn claude_structured_rows_resolve_paths_against_their_recorded_cwd() { } } } + +/// Claude actually edited this file from `cwd = /cli`; the repository +/// root holds a twin at [`PLAN_PATH`] with identical committed lines. +const TWIN_PLAN_PATH: &str = "cli/context/plans/plan.md"; + +struct StructuredOutcome { + contributors: BTreeMap, + tool: Option, + intersection_lines: usize, + staged: StagedDiffAiOverlapResult, +} + +async fn structured_outcome(repo: &HistoryRepo, payload: &str) -> StructuredOutcome { + let (_db_dir, mut db) = open_db().await; + insert_structured_row(&db, payload).await; + + let parsed = db + .recent_diff_trace_patches(ms(COMMIT_C_TIME_S) - 1_000, ms(COMMIT_C_TIME_S)) + .await + .expect("read structured rows"); + assert_eq!(parsed.loaded_count(), 1, "structured row is retained"); + assert_eq!(parsed.skipped_count(), 0); + + let staged = parse_patch( + &boilerplate_patch(EXACT_C_POSITIONS.0, EXACT_C_POSITIONS.1), + None, + ) + .expect("staged patch parses"); + let staged = staged_diff_has_ai_overlap_with( + repo.root(), + |_| Ok(staged), + |_| Ok(Some(ms(COMMIT_B_TIME_S))), + || Ok(ms(COMMIT_C_TIME_S)), + async |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms).await, + ) + .await; + + let persisted = persist_trace(repo, &mut db, &[]).await; + let intersections: Vec = db + .query_map( + "SELECT intersection_patch FROM post_commit_patch_intersections", + (), + |row| Ok(row.get(0)?), + ) + .await + .expect("read intersections"); + assert_eq!(intersections.len(), 1); + let intersection: ParsedPatch = + serde_json::from_str(&intersections[0]).expect("intersection JSON"); + + StructuredOutcome { + contributors: persisted.value["files"] + .as_array() + .expect("trace files") + .iter() + .map(|file| { + ( + file["path"].as_str().expect("file path").to_owned(), + file["conversations"][0]["contributor"]["type"] + .as_str() + .expect("contributor type") + .to_owned(), + ) + }) + .collect(), + tool: persisted.value.get("tool").cloned(), + intersection_lines: touched_line_count(&intersection), + staged, + } +} + +fn contributors(root: &str, twin: &str) -> BTreeMap { + BTreeMap::from([ + (PLAN_PATH.to_owned(), root.to_owned()), + (TWIN_PLAN_PATH.to_owned(), twin.to_owned()), + ]) +} + +#[tokio::test] +async fn claude_relative_paths_without_an_absolute_cwd_never_attribute_the_repository_root_twin() { + let repo = HistoryRepo::with_plan_paths(&[PLAN_PATH, TWIN_PLAN_PATH]); + let cases = [ + ("missing", None), + ("relative", Some(Value::from("cli"))), + ("empty", Some(Value::from(""))), + ("blank", Some(Value::from(" "))), + ("non-string", Some(Value::from(42))), + ("null", Some(Value::Null)), + ]; + for (label, cwd) in cases { + let outcome = + structured_outcome(&repo, &claude_write_payload_with_cwd(cwd, PLAN_PATH)).await; + assert_eq!( + outcome.contributors, + contributors("unknown", "unknown"), + "{label} cwd" + ); + assert_eq!(outcome.tool, None, "{label} cwd"); + assert_eq!(outcome.intersection_lines, 0, "{label} cwd"); + assert_eq!( + outcome.staged, + StagedDiffAiOverlapResult::NoOverlap, + "{label} cwd" + ); + } +} + +#[tokio::test] +async fn claude_structured_paths_attribute_only_the_file_their_coordinate_system_names() { + let repo = HistoryRepo::with_plan_paths(&[PLAN_PATH, TWIN_PLAN_PATH]); + let root = repo.root().display().to_string(); + let cli = format!("{root}/cli"); + let outside = tempfile::tempdir().expect("outside dir"); + let cases = [ + (Some(cli.as_str()), PLAN_PATH.to_owned(), "twin"), + (Some(cli.as_str()), format!("../cli/./{PLAN_PATH}"), "twin"), + (Some(cli.as_str()), format!("../{PLAN_PATH}"), "root"), + ( + Some(cli.as_str()), + format!( + "../../{}/{PLAN_PATH}", + repo.root().file_name().unwrap().to_str().unwrap() + ), + "root", + ), + (Some(cli.as_str()), format!("../../../{PLAN_PATH}"), "none"), + (None, format!("{root}/{TWIN_PLAN_PATH}"), "twin"), + (None, format!("{root}/{PLAN_PATH}"), "root"), + ( + None, + format!("{}/{PLAN_PATH}", outside.path().display()), + "none", + ), + ( + Some(cli.as_str()), + format!("{}/{PLAN_PATH}", outside.path().display()), + "none", + ), + ]; + for (cwd, file_path, expected) in cases { + let outcome = structured_outcome( + &repo, + &claude_write_payload_with_cwd(cwd.map(Value::from), &file_path), + ) + .await; + let (root_kind, twin_kind) = match expected { + "root" => ("ai", "unknown"), + "twin" => ("unknown", "ai"), + _ => ("unknown", "unknown"), + }; + let label = format!("{cwd:?} {file_path}"); + assert_eq!( + outcome.contributors, + contributors(root_kind, twin_kind), + "{label}" + ); + assert_eq!(outcome.tool.is_some(), expected != "none", "{label}"); + assert_eq!( + outcome.intersection_lines, + if expected == "none" { + 0 + } else { + BOILERPLATE.len() + }, + "{label}" + ); + let staged = if expected == "root" { + StagedDiffAiOverlapResult::Overlap + } else { + StagedDiffAiOverlapResult::NoOverlap + }; + assert_eq!(outcome.staged, staged, "{label}"); + } +} + +#[tokio::test] +async fn mutation_lineage_still_attributes_when_claude_direct_evidence_lacks_a_cwd() { + let repo = HistoryRepo::new(); + let (_db_dir, mut db) = open_db().await; + insert_structured_row(&db, &claude_write_payload_with_cwd(None, PLAN_PATH)).await; + let persisted = persist_trace( + &repo, + &mut db, + &repo.mutation_rows(AttributionKind::AiExclusive), + ) + .await; + + let conversation = &persisted.value["files"][0]["conversations"][0]; + assert_eq!(conversation["contributor"]["type"], "ai"); + assert_eq!(conversation["contributor"]["model_id"], MODEL_C); + assert!(persisted.value.get("tool").is_none()); +} diff --git a/cli/src/services/structured_patch.rs b/cli/src/services/structured_patch.rs index bf273eed3..1fdcc6d5b 100644 --- a/cli/src/services/structured_patch.rs +++ b/cli/src/services/structured_patch.rs @@ -22,6 +22,9 @@ pub struct ClaudeStructuredPatch { pub time: u64, pub tool_name: String, pub tool_version: Option, + /// The payload's absolute `cwd`, the base of any relative patch path. + /// `None` when `cwd` is missing, empty, or not absolute. + pub working_directory: Option, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -129,6 +132,7 @@ pub fn derive_claude_structured_patch( time, tool_name: CLAUDE_TOOL_NAME.to_string(), tool_version: extract_claude_tool_version(tool_version, payload_object), + working_directory: absolute_cwd(payload_object), }) } @@ -154,7 +158,7 @@ fn build_write_create_patch(payload: &Map) -> PatchBuildResult { string_field(tool_input, &["file_path", "filePath"]) .or_else(|| string_field(tool_response, &["file_path", "filePath"])) .as_deref(), - string_field(payload, &["cwd"]).as_deref(), + absolute_cwd(payload).as_deref(), ); let Some(file_path) = file_path else { return skipped_build(ClaudeStructuredPatchSkipReason::MissingFilePath); @@ -194,7 +198,7 @@ fn build_edit_structured_patch(payload: &Map) -> PatchBuildResult let file_path = normalize_patch_path( string_field(tool_input, &["file_path", "filePath"]).as_deref(), - string_field(payload, &["cwd"]).as_deref(), + absolute_cwd(payload).as_deref(), ); let Some(file_path) = file_path else { return skipped_build(ClaudeStructuredPatchSkipReason::MissingFilePath); @@ -435,6 +439,12 @@ fn normalize_optional_version(value: Option<&Value>) -> VersionField { } } +/// The payload `cwd` only when it is absolute: a relative `cwd` cannot anchor +/// a relative patch path. +fn absolute_cwd(payload: &Map) -> Option { + string_field(payload, &["cwd"]).filter(|cwd| Path::new(cwd).is_absolute()) +} + fn normalize_patch_path(file_path: Option<&str>, cwd: Option<&str>) -> Option { let mut normalized = file_path?.trim().to_string(); if normalized.is_empty() { diff --git a/context/cli/mutation-trace-agent-attribution.md b/context/cli/mutation-trace-agent-attribution.md index 83b38e68c..89df9f37e 100644 --- a/context/cli/mutation-trace-agent-attribution.md +++ b/context/cli/mutation-trace-agent-attribution.md @@ -162,8 +162,9 @@ direct candidate matching in `cli/src/services/agent_trace/evidence.rs`: inside the 7-day window; (2) an *exact* touched-line match on repository file identity, `kind`, `line_number`, and `content` — file identity is the normalized repository-relative path (absolute paths only inside the - repository root, Claude structured paths resolved against their recorded - `cwd`, no suffix equivalence, `..` escapes rejected; see + repository root, Claude structured relative paths resolved only against + their recorded absolute `cwd` and rejected without one, no suffix + equivalence, `..` escapes rejected; see `agent_trace/repository_path.rs`); and (3) *unambiguous* — every fresh exact candidate for that line comes from one session. Each candidate row is kept separate (never merged into one patch), so evidence is consumed by its exact diff --git a/context/sce/agent-trace-commit-msg-coauthor-policy.md b/context/sce/agent-trace-commit-msg-coauthor-policy.md index 08bc6f328..ead298591 100644 --- a/context/sce/agent-trace-commit-msg-coauthor-policy.md +++ b/context/sce/agent-trace-commit-msg-coauthor-policy.md @@ -33,7 +33,7 @@ ## Staged AI-overlap evidence gate -- `cli/src/services/agent_trace.rs` owns the pure direct-evidence predicate `has_direct_evidence`, which reports whether at least one staged touched line has accepted direct evidence: repository file identity anchored at the repository root, an exact `(kind, line_number, content)` match, and a single claiming session. Historical `(kind, content)`-only similarity, suffix-only path similarity, and lines claimed by conflicting sessions are not evidence. +- `cli/src/services/agent_trace.rs` owns the pure direct-evidence predicate `has_direct_evidence`, which reports whether at least one staged touched line has accepted direct evidence: repository file identity anchored at the repository root, an exact `(kind, line_number, content)` match, and a single claiming session. Historical `(kind, content)`-only similarity, suffix-only path similarity, Claude structured relative paths without a recorded absolute `cwd`, and lines claimed by conflicting sessions are not evidence. - `cli/src/services/hooks/mod.rs` owns the staged-diff AI-overlap evidence gate, which is now wired into `run_commit_msg_subcommand_in_repo`: - `StagedDiffAiOverlapResult` enum (`Overlap`/`NoOverlap`/`Error`) is the three-valued result from the injectable `_with` variant, enabling testable branch coverage and caller-side error logging. - `staged_diff_has_ai_overlap_with` is the injectable variant that accepts staged-patch, `HEAD` commit time, clock, and recent-trace dependencies and returns `StagedDiffAiOverlapResult`; `hooks/commit_hooks/attribution_tests.rs` covers stale, historical-only, fresh exact, conflicting-session, and repository-file-identity rows. diff --git a/context/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index b7cf24c65..e01337f36 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -157,7 +157,12 @@ Post-commit intersection rows are written by the active `post-commit` hook flow - SQL reads `id`, `time_ms`, `session_id`, `patch`, nullable `model_id` + `tool_name` + `tool_version`, and `payload_type` from `diff_traces` where `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`. - Rows are ordered by `time_ms ASC, id ASC` for deterministic chronological processing. - Valid row patches are parsed through `cli/src/services/patch.rs` `parse_patch` for `payload_type="patch"` rows (OpenCode unified-diff payloads), while `payload_type="structured"` rows (Claude `PostToolUse` structured payloads) are parsed from stored JSON through `cli/src/services/structured_patch.rs` `derive_claude_structured_patch` at read time to produce `ParsedPatch` without pre-rendered unified-diff text. -- Each produced `PatchHunk` is annotated with the originating row `model_id` (`Some(value)` propagated verbatim, `NULL` propagated as `None`) for both patch and structured paths. For structured rows, every produced touched line is also assigned the persisted tool-prefixed canonical row `session_id`; the raw unprefixed session inside the stored Claude payload is not used as reconstructed touched-line provenance. Parsed row records carry nullable `tool_name`/`tool_version` and `payload_type` from the same source row and are returned as `ParsedDiffTracePatch` records. Structured rows also carry `path_base_dir` — the stored payload's absolute `cwd` — because Claude file paths are derived relative to that `cwd`; direct-evidence matching resolves them against it to obtain repository identity. Patch rows have `path_base_dir = None`. No column was added; the value is read from the stored payload. +- Each produced `PatchHunk` is annotated with the originating row `model_id` (`Some(value)` propagated verbatim, `NULL` propagated as `None`) for both patch and structured paths. For structured rows, every produced touched line is also assigned the persisted tool-prefixed canonical row `session_id`; the raw unprefixed session inside the stored Claude payload is not used as reconstructed touched-line provenance. Parsed row records carry nullable `tool_name`/`tool_version` and `payload_type` from the same source row and are returned as `ParsedDiffTracePatch` records. Every parsed row also carries a `path_origin: DiffTracePathOrigin` naming the coordinate system its file paths are expressed in, because a relative path means different things per source: + - `RepositoryRelative` — `patch` rows: Git-produced paths are repository-relative. + - `WorkingDirectory(cwd)` — `structured` rows whose stored payload has an absolute `cwd`: Claude relative `file_path`s are relative to that `cwd` (not necessarily the repository root). `derive_claude_structured_patch` exposes the same absolute `cwd` it used to normalize paths as `ClaudeStructuredPatch::working_directory`, so the origin and the normalized path always agree. + - `AbsoluteOnly` — `structured` rows whose `cwd` is missing, empty, blank, non-string, or relative: absolute `file_path`s still resolve, but a relative `file_path` has no established coordinate system and is never reinterpreted as repository-relative. + + Such rows are still loaded (not skipped); they simply cannot contribute direct attribution for a path whose file identity cannot be established. No column was added; the origin is derived from the stored payload. - Malformed recent row patches (invalid unified-diff text, invalid structured JSON, unsupported payload types, or unsupported Claude structured payloads) are returned as `SkippedDiffTracePatch` records with deterministic parse-error or derivation-skip reasons; malformed historical rows do not fail the operation. - `RecentDiffTracePatches::loaded_count()` and `skipped_count()` expose accounting for later hook output and persistence metadata. diff --git a/context/sce/agent-trace-minimal-generator.md b/context/sce/agent-trace-minimal-generator.md index b6a26b0d7..fc14bd087 100644 --- a/context/sce/agent-trace-minimal-generator.md +++ b/context/sce/agent-trace-minimal-generator.md @@ -21,9 +21,11 @@ Given per-row direct candidate patches, mutation lineage evidence, and a `post_c `build_agent_trace_from_evidence(evidence, post_commit_patch, metadata)` is the only supported generator entrypoint. `AgentTraceEvidence` keeps the sources separate: - `repository_root` — `RepositoryRoot` anchoring attribution-grade file identity (`agent_trace/repository_path.rs`). -- `direct_sources` — one `DirectEvidenceSource` per freshness-bounded `diff_traces` row (patch, optional absolute `base_dir`, `tool_name`, `tool_version`), never combined, so conflicting sessions, duplicate occurrences, and per-row tool metadata stay attached until resolution. Matching lives in `agent_trace/evidence.rs`. +- `direct_sources` — one `DirectEvidenceSource` per freshness-bounded `diff_traces` row (patch, `path_origin`, `tool_name`, `tool_version`), never combined, so conflicting sessions, duplicate occurrences, and per-row tool metadata stay attached until resolution. Matching lives in `agent_trace/evidence.rs`. -Only *accepted* direct evidence may influence authorship, co-author eligibility, or tool provenance: a candidate is accepted when its file resolves to the same repository identity as the target file (`agent_trace/repository_path.rs`), its touched line matches exactly on `(kind, line_number, content)`, and every matching candidate for that line comes from one session. Conflicting candidates are rejected everywhere. +Only *accepted* direct evidence may influence authorship, co-author eligibility, or tool provenance: a candidate is accepted when its file resolves to the same repository identity as the target file (`agent_trace/repository_path.rs`; the committed target is always repository-relative), its touched line matches exactly on `(kind, line_number, content)`, and every matching candidate for that line comes from one session. Conflicting candidates are rejected everywhere. + +File identity requires an established coordinate system (`DiffTracePathOrigin`, see [agent-trace-db.md](agent-trace-db.md)). `RepositoryRoot::repository_path` resolves absolute paths directly and only inside the repository root; a relative path resolves as repository-relative for Git patch rows, against the recorded absolute `cwd` for Claude structured rows (then checked for repository membership, so `..` cannot escape), and is rejected for Claude rows without an absolute `cwd`. An unknown origin fails closed rather than defaulting to the repository root, so a Claude edit to `/cli/src/lib.rs` recorded as `src/lib.rs` without `cwd` cannot credit `/src/lib.rs`. These rules are shared by post-commit attribution, the persisted `post_commit_patch_intersections`, staged co-author detection, and accepted-source tool metadata. - `mutation_ai_patch` — target-shaped committed lines that causal lineage attributed to an AI-exclusive scope, with resolved scope provenance. - `mutation_non_ai_patch` — target-shaped committed lines that lineage resolved to a non-exclusive transition; these block direct attribution. From 3e4c45d3d92fc14844bc73353433432f3c8fb924 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 6 Nov 2026 18:18:02 +0100 Subject: [PATCH 04/11] runtime: Count Agent Trace attribution per touched line Resolve each canonical committed line to one AI, non-exclusive, or unknown decision and derive hunk classifications and metadata counters from that shared map. This preserves non-exclusive lineage against direct evidence and makes totals independent of hunk boundaries. Expand fixture, integration, and randomized coverage for the per-line attribution contract. Co-authored-by: SCE --- cli/src/services/agent_trace.rs | 115 ++-- .../average_age_reconstruction/golden.json | 6 +- .../fixtures/direct_only/golden.json | 2 +- .../fixtures/direct_plus_mutation/golden.json | 2 +- .../exclusive_without_direct/golden.json | 2 +- .../file_rename_reconstruction/golden.json | 2 +- .../hello_world_reconstruction/golden.json | 2 +- .../golden.json | 6 +- .../mixed_change_reconstruction/golden.json | 2 +- .../mutation_only_no_provenance/golden.json | 2 +- .../newer_nonexclusive_blocks/golden.json | 2 +- .../fixtures/partial_combined/golden.json | 6 +- .../poem_edit_reconstruction/golden.json | 6 +- .../poem_write_reconstruction/golden.json | 6 +- .../golden.json | 6 +- cli/src/services/agent_trace/tests.rs | 629 +++++++++++++++++- .../hooks/commit_hooks/attribution_tests.rs | 89 ++- .../cli/mutation-trace-agent-attribution.md | 11 +- context/context-map.md | 2 +- context/glossary.md | 3 +- context/overview.md | 2 +- context/sce/agent-trace-minimal-generator.md | 18 +- 22 files changed, 823 insertions(+), 98 deletions(-) diff --git a/cli/src/services/agent_trace.rs b/cli/src/services/agent_trace.rs index a0c9157fb..36f303672 100644 --- a/cli/src/services/agent_trace.rs +++ b/cli/src/services/agent_trace.rs @@ -136,13 +136,10 @@ pub struct AgentTraceMetadata { #[serde(rename_all = "snake_case")] pub struct AgentTraceSceMetadata { pub version: String, - /// Exact touched-line attribution counts derived from canonical - /// `post_commit_patch` hunks, bucketed by hunk classification. #[serde(default)] pub line_changes: LineChangeAttribution, } -/// Exact added/removed touched-line counts for one hunk-classification bucket. #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub struct LineChangeCounts { @@ -150,39 +147,43 @@ pub struct LineChangeCounts { pub removed: u64, } -/// Exact touched-line attribution counts, bucketed by hunk classification. #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub struct LineChangeAttribution { #[serde(default)] pub ai: LineChangeCounts, #[serde(default)] - pub mixed: LineChangeCounts, + pub non_exclusive: LineChangeCounts, #[serde(default)] pub unknown: LineChangeCounts, } -/// Tally one `post_commit_patch` hunk's touched lines into the bucket matching -/// `kind`. A hunk's entire touched-line count is recorded in a single bucket: -/// a `mixed` hunk contributes all of its touched lines, not just the subset -/// that also appears in the AI intersection. -fn record_hunk_line_changes( - counts: &mut LineChangeAttribution, - kind: HunkContributor, - hunk: &PatchHunk, -) { - let bucket = match kind { - HunkContributor::Ai => &mut counts.ai, - HunkContributor::Mixed => &mut counts.mixed, - HunkContributor::Unknown => &mut counts.unknown, - }; - - for line in &hunk.lines { - match line.kind { - TouchedLineKind::Added => bucket.added += 1, - TouchedLineKind::Removed => bucket.removed += 1, +fn line_change_attribution( + patch: &ParsedPatch, + decisions: &BTreeMap, +) -> LineChangeAttribution { + let mut counts = LineChangeAttribution::default(); + for (file_index, file) in patch.files.iter().enumerate() { + 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, + }; + let bucket = match decisions.get(&location) { + Some(LineAttribution::Ai(_)) => &mut counts.ai, + Some(LineAttribution::NonExclusive) => &mut counts.non_exclusive, + Some(LineAttribution::Unknown) | None => &mut counts.unknown, + }; + match line.kind { + TouchedLineKind::Added => bucket.added += 1, + TouchedLineKind::Removed => bucket.removed += 1, + } + } } } + counts } fn parse_commit_timestamp(commit_timestamp: &str) -> Result> { @@ -347,6 +348,22 @@ struct ResolvedLine { direct_sources: BTreeSet, } +#[derive(Clone, Debug, Eq, PartialEq)] +enum LineAttribution { + Ai(ResolvedLine), + NonExclusive, + Unknown, +} + +impl LineAttribution { + fn accepted_ai(&self) -> Option<&ResolvedLine> { + match self { + Self::Ai(line) => Some(line), + Self::NonExclusive | Self::Unknown => None, + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] enum MutationLineEvidence { Ai(PatchLineProvenance), @@ -413,7 +430,7 @@ impl<'a> MutationEvidenceIndex<'a> { fn resolve_line( mutation: MutationLineEvidence, direct: Option<&DirectLineEvidence>, -) -> Option { +) -> LineAttribution { match (mutation, direct) { ( MutationLineEvidence::Ai(mutation), @@ -421,24 +438,27 @@ fn resolve_line( provenance: direct, sources, })), - ) if direct.session_id == mutation.session_id => Some(ResolvedLine { + ) if direct.session_id == mutation.session_id => LineAttribution::Ai(ResolvedLine { provenance: PatchLineProvenance { session_id: mutation.session_id, model_id: mutation.model_id.or_else(|| direct.model_id.clone()), }, direct_sources: sources.clone(), }), - (MutationLineEvidence::Ai(mutation), _) => Some(ResolvedLine { + (MutationLineEvidence::Ai(mutation), _) => LineAttribution::Ai(ResolvedLine { provenance: mutation, direct_sources: BTreeSet::new(), }), + (MutationLineEvidence::NonAi, _) => LineAttribution::NonExclusive, (MutationLineEvidence::Unresolved, Some(DirectLineEvidence::Accepted(accepted))) => { - Some(ResolvedLine { + LineAttribution::Ai(ResolvedLine { provenance: accepted.provenance.clone(), direct_sources: accepted.sources.clone(), }) } - (MutationLineEvidence::NonAi | MutationLineEvidence::Unresolved, _) => None, + (MutationLineEvidence::Unresolved, Some(DirectLineEvidence::Conflicting) | None) => { + LineAttribution::Unknown + } } } @@ -447,7 +467,7 @@ fn resolve_patch_lines( direct_sources: &[DirectEvidenceSource<'_>], repository_root: &RepositoryRoot, mutation: &MutationEvidenceIndex<'_>, -) -> BTreeMap { +) -> BTreeMap { let direct = direct_line_evidence(direct_sources, target, repository_root); let mut resolved = BTreeMap::new(); for (file_index, file) in target.files.iter().enumerate() { @@ -460,9 +480,10 @@ fn resolve_patch_lines( line_index, }; let key = (path, line.kind, line.line_number, line.content.as_str()); - if let Some(line) = resolve_line(mutation.lookup(&key), direct.get(&location)) { - resolved.insert(location, line); - } + resolved.insert( + location, + resolve_line(mutation.lookup(&key), direct.get(&location)), + ); } } } @@ -470,7 +491,7 @@ fn resolve_patch_lines( } fn hunk_resolved_lines( - resolved: &BTreeMap, + resolved: &BTreeMap, file_index: usize, hunk_index: usize, ) -> impl Iterator { @@ -486,7 +507,7 @@ fn hunk_resolved_lines( line_index: usize::MAX, }, ) - .map(|(_, line)| line) + .filter_map(|(_, line)| line.accepted_ai()) } fn classify_hunk(hunk: &PatchHunk, covered: usize) -> HunkContributor { @@ -589,9 +610,8 @@ fn parse_embedded_deleted_patch(file: &PatchFileChange) -> Option { fn build_trace_file( post_commit_file: &PatchFileChange, file_index: usize, - resolved: &BTreeMap, + resolved: &BTreeMap, conversation_url: &str, - line_changes: &mut LineChangeAttribution, ) -> Option { if post_commit_file.hunks.is_empty() { return None; @@ -610,7 +630,6 @@ fn build_trace_file( } HunkContributor::Unknown => None, }; - record_hunk_line_changes(line_changes, contributor_kind, post_commit_hunk); let related_session_ids = hunk_resolved_lines(resolved, file_index, hunk_index) .filter_map(|line| line.provenance.session_id.as_deref()) .filter(|session_id| !session_id.is_empty()) @@ -666,26 +685,20 @@ pub fn build_agent_trace_from_evidence( repository_root, &mutation, ); + let line_changes = line_change_attribution(post_commit_patch, &resolved); let empty_patch = ParsedPatch { files: Vec::new() }; let no_mutation = MutationEvidenceIndex::new(&empty_patch, &empty_patch); let mut files = Vec::new(); - let mut line_changes = LineChangeAttribution::default(); for (file_index, post_commit_file) in post_commit_patch.files.iter().enumerate() { if let Some(embedded_patch) = parse_embedded_deleted_patch(post_commit_file) { - for (hunk_index, hunk) in post_commit_file.hunks.iter().enumerate() { - let covered = hunk_resolved_lines(&resolved, file_index, hunk_index).count(); - record_hunk_line_changes(&mut line_changes, classify_hunk(hunk, covered), hunk); - } - let embedded_resolved = resolve_patch_lines( &embedded_patch, direct_sources, repository_root, &no_mutation, ); - let mut discarded_line_changes = LineChangeAttribution::default(); files.extend(embedded_patch.files.iter().enumerate().filter_map( |(embedded_index, embedded_file)| { build_trace_file( @@ -693,26 +706,22 @@ pub fn build_agent_trace_from_evidence( embedded_index, &embedded_resolved, &conversation_url, - &mut discarded_line_changes, ) }, )); continue; } - if let Some(trace_file) = build_trace_file( - post_commit_file, - file_index, - &resolved, - &conversation_url, - &mut line_changes, - ) { + if let Some(trace_file) = + build_trace_file(post_commit_file, file_index, &resolved, &conversation_url) + { files.push(trace_file); } } let accepted_sources: BTreeSet = resolved .values() + .filter_map(LineAttribution::accepted_ai) .flat_map(|line| line.direct_sources.iter().copied()) .collect(); let tool = accepted_direct_tool(direct_sources, &accepted_sources); diff --git a/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json index 52b458cd3..38426bc23 100644 --- a/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json @@ -11,9 +11,9 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 0, "removed": 0 }, - "mixed": { "added": 91, "removed": 9 }, - "unknown": { "added": 0, "removed": 0 } + "ai": { "added": 40, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, + "unknown": { "added": 51, "removed": 9 } } } }, diff --git a/cli/src/services/agent_trace/fixtures/direct_only/golden.json b/cli/src/services/agent_trace/fixtures/direct_only/golden.json index 30f208fe1..d02933216 100644 --- a/cli/src/services/agent_trace/fixtures/direct_only/golden.json +++ b/cli/src/services/agent_trace/fixtures/direct_only/golden.json @@ -16,7 +16,7 @@ "version": "0.1.0", "line_changes": { "ai": { "added": 4, "removed": 0 }, - "mixed": { "added": 0, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, "unknown": { "added": 0, "removed": 0 } } } 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 index 322f2ef7e..8766347ad 100644 --- a/cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json +++ b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json @@ -16,7 +16,7 @@ "version": "0.1.0", "line_changes": { "ai": { "added": 4, "removed": 0 }, - "mixed": { "added": 0, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, "unknown": { "added": 0, "removed": 0 } } } 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 index aca4e42fb..015356d1a 100644 --- a/cli/src/services/agent_trace/fixtures/exclusive_without_direct/golden.json +++ b/cli/src/services/agent_trace/fixtures/exclusive_without_direct/golden.json @@ -12,7 +12,7 @@ "version": "0.1.0", "line_changes": { "ai": { "added": 4, "removed": 0 }, - "mixed": { "added": 0, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, "unknown": { "added": 0, "removed": 0 } } } diff --git a/cli/src/services/agent_trace/fixtures/file_rename_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/file_rename_reconstruction/golden.json index 0f3f57045..fad096cbb 100644 --- a/cli/src/services/agent_trace/fixtures/file_rename_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/file_rename_reconstruction/golden.json @@ -12,7 +12,7 @@ "version": "0.2.0", "line_changes": { "ai": { "added": 0, "removed": 0 }, - "mixed": { "added": 0, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, "unknown": { "added": 0, "removed": 0 } } } diff --git a/cli/src/services/agent_trace/fixtures/hello_world_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/hello_world_reconstruction/golden.json index d61b42ccc..dc067fe32 100644 --- a/cli/src/services/agent_trace/fixtures/hello_world_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/hello_world_reconstruction/golden.json @@ -12,7 +12,7 @@ "version": "0.2.0", "line_changes": { "ai": { "added": 5, "removed": 0 }, - "mixed": { "added": 0, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, "unknown": { "added": 0, "removed": 0 } } } diff --git a/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json index a2af92ed1..6dee94bed 100644 --- a/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json @@ -11,9 +11,9 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 0, "removed": 0 }, - "mixed": { "added": 2, "removed": 0 }, - "unknown": { "added": 0, "removed": 0 } + "ai": { "added": 1, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, + "unknown": { "added": 1, "removed": 0 } } } }, diff --git a/cli/src/services/agent_trace/fixtures/mixed_change_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/mixed_change_reconstruction/golden.json index e8dad0116..cd3f494e3 100644 --- a/cli/src/services/agent_trace/fixtures/mixed_change_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/mixed_change_reconstruction/golden.json @@ -12,7 +12,7 @@ "version": "0.2.0", "line_changes": { "ai": { "added": 5, "removed": 2 }, - "mixed": { "added": 0, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, "unknown": { "added": 1, "removed": 15 } } } 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 index 51d2b3719..254eb2c4b 100644 --- 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 @@ -12,7 +12,7 @@ "version": "0.1.0", "line_changes": { "ai": { "added": 2, "removed": 0 }, - "mixed": { "added": 0, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, "unknown": { "added": 2, "removed": 0 } } } 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 index b0197e92d..f53827616 100644 --- a/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/golden.json +++ b/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/golden.json @@ -12,7 +12,7 @@ "version": "0.1.0", "line_changes": { "ai": { "added": 0, "removed": 0 }, - "mixed": { "added": 0, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, "unknown": { "added": 4, "removed": 0 } } } diff --git a/cli/src/services/agent_trace/fixtures/partial_combined/golden.json b/cli/src/services/agent_trace/fixtures/partial_combined/golden.json index f1a2e2cdf..e6dec5c73 100644 --- a/cli/src/services/agent_trace/fixtures/partial_combined/golden.json +++ b/cli/src/services/agent_trace/fixtures/partial_combined/golden.json @@ -15,9 +15,9 @@ "sce": { "version": "0.1.0", "line_changes": { - "ai": { "added": 0, "removed": 0 }, - "mixed": { "added": 4, "removed": 0 }, - "unknown": { "added": 0, "removed": 0 } + "ai": { "added": 2, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, + "unknown": { "added": 2, "removed": 0 } } } }, diff --git a/cli/src/services/agent_trace/fixtures/poem_edit_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/poem_edit_reconstruction/golden.json index 2b90a3cc4..9a3592ece 100644 --- a/cli/src/services/agent_trace/fixtures/poem_edit_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/poem_edit_reconstruction/golden.json @@ -11,9 +11,9 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 1, "removed": 1 }, - "mixed": { "added": 3, "removed": 3 }, - "unknown": { "added": 1, "removed": 1 } + "ai": { "added": 2, "removed": 2 }, + "non_exclusive": { "added": 0, "removed": 0 }, + "unknown": { "added": 3, "removed": 3 } } } }, diff --git a/cli/src/services/agent_trace/fixtures/poem_write_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/poem_write_reconstruction/golden.json index ecf8b8270..5e64f358a 100644 --- a/cli/src/services/agent_trace/fixtures/poem_write_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/poem_write_reconstruction/golden.json @@ -11,9 +11,9 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 0, "removed": 0 }, - "mixed": { "added": 24, "removed": 0 }, - "unknown": { "added": 0, "removed": 0 } + "ai": { "added": 23, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, + "unknown": { "added": 1, "removed": 0 } } } }, diff --git a/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json index 77f46324b..351c92ea9 100644 --- a/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json @@ -11,9 +11,9 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 55, "removed": 0 }, - "mixed": { "added": 9, "removed": 0 }, - "unknown": { "added": 4, "removed": 0 } + "ai": { "added": 58, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, + "unknown": { "added": 10, "removed": 0 } } } }, diff --git a/cli/src/services/agent_trace/tests.rs b/cli/src/services/agent_trace/tests.rs index 20eb2c376..8cba4a6ba 100644 --- a/cli/src/services/agent_trace/tests.rs +++ b/cli/src/services/agent_trace/tests.rs @@ -1,17 +1,23 @@ +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; use std::path::Path; +use rand::{rngs::StdRng, Rng, SeedableRng}; +use serde_json::Value; + use super::{ - build_agent_trace_from_evidence, validate_agent_trace_value, AgentTraceEvidence, - AgentTraceMetadataInput, AgentTraceVcsType, DirectEvidenceSource, RepositoryRoot, - AGENT_TRACE_VERSION, + build_agent_trace_from_evidence, line_change_attribution, resolve_line, resolve_patch_lines, + validate_agent_trace_value, AcceptedDirectLine, AgentTraceEvidence, AgentTraceMetadataInput, + AgentTraceVcsType, DirectEvidenceSource, DirectLineEvidence, HunkContributor, LineAttribution, + LineChangeAttribution, LineChangeCounts, MutationEvidenceIndex, MutationLineEvidence, + RepositoryRoot, ResolvedLine, AGENT_TRACE_VERSION, }; use crate::services::{ agent_trace::agent_trace_conversation_url, agent_trace_db::DiffTracePathOrigin, - patch::{parse_patch, ParsedPatch}, + mutation_trace::attribution::{PatchLineLocation, PatchLineProvenance}, + patch::{parse_patch, ParsedPatch, TouchedLineKind}, }; -use serde_json::Value; const TEST_COMMIT_TIMESTAMP: &str = "2026-04-23T10:20:30Z"; const TEST_COMMIT_REVISION: &str = "a0b1c2d3e4f5a6b7c8d9e0f11223344556677889"; @@ -464,6 +470,20 @@ fn test_root() -> RepositoryRoot { fn build_with_sources( sources: &[ToolSource], mutation_ai_patch: &ParsedPatch, +) -> super::AgentTrace { + build_trace( + &committed_patch(), + sources, + mutation_ai_patch, + &ParsedPatch { files: Vec::new() }, + ) +} + +fn build_trace( + committed: &ParsedPatch, + sources: &[ToolSource], + mutation_ai_patch: &ParsedPatch, + mutation_non_ai_patch: &ParsedPatch, ) -> super::AgentTrace { let direct_sources = direct_sources(sources); let trace = build_agent_trace_from_evidence( @@ -471,9 +491,9 @@ fn build_with_sources( repository_root: &test_root(), direct_sources: &direct_sources, mutation_ai_patch, - mutation_non_ai_patch: &ParsedPatch { files: Vec::new() }, + mutation_non_ai_patch, }, - &committed_patch(), + committed, AgentTraceMetadataInput { commit_timestamp: TEST_COMMIT_TIMESTAMP, commit_revision: TEST_COMMIT_REVISION, @@ -1009,3 +1029,598 @@ fn mutation_lineage_still_attributes_when_claude_direct_evidence_lacks_a_cwd() { let json = serde_json::to_string(&trace).expect("trace serializes"); assert!(!json.contains(SESSION_A) && !json.contains(MODEL_A)); } + +const ADDED: char = '+'; +const REMOVED: char = '-'; + +type Buckets = ((u64, u64), (u64, u64), (u64, u64)); + +fn buckets(trace: &super::AgentTrace) -> Buckets { + let pair = |counts: LineChangeCounts| (counts.added, counts.removed); + let LineChangeAttribution { + ai, + non_exclusive, + unknown, + } = trace.metadata.sce.line_changes; + (pair(ai), pair(non_exclusive), pair(unknown)) +} + +fn line_evidence_patch(path: &str, lines: &[(char, u64, &str)]) -> String { + let mut text = format!("diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n"); + for (kind, line_number, content) in lines { + let header = if *kind == ADDED { + format!("@@ -{},0 +{line_number},1 @@", line_number - 1) + } else { + format!("@@ -{line_number},1 +{},0 @@", line_number - 1) + }; + writeln!(text, "{header}\n{kind}{content}").expect("write hunk"); + } + text +} + +fn line_source(path: &str, lines: &[(char, u64, &str)], session: &str, model: &str) -> ToolSource { + ToolSource { + patch: mutation_ai_lines(path, lines, session, Some(model)), + tool_name: Some("claude"), + tool_version: Some("1.0"), + path_origin: DiffTracePathOrigin::RepositoryRelative, + } +} + +fn mutation_ai_lines( + path: &str, + lines: &[(char, u64, &str)], + session: &str, + model: Option<&str>, +) -> ParsedPatch { + let mut attributed = + parse_patch(&line_evidence_patch(path, lines), Some(session)).expect("evidence parses"); + for hunk in attributed + .files + .iter_mut() + .flat_map(|file| file.hunks.iter_mut()) + { + hunk.model_id = model.map(ToOwned::to_owned); + } + attributed +} + +fn mutation_non_ai_lines(path: &str, lines: &[(char, u64, &str)]) -> ParsedPatch { + parse_patch(&line_evidence_patch(path, lines), None).expect("mutation parses") +} + +fn touched_totals(patch: &ParsedPatch) -> (u64, u64) { + let mut totals = (0, 0); + for line in patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + { + match line.kind { + TouchedLineKind::Added => totals.0 += 1, + TouchedLineKind::Removed => totals.1 += 1, + } + } + totals +} + +fn assert_counters_cover(trace: &super::AgentTrace, committed: &ParsedPatch) { + let (ai, non_exclusive, unknown) = buckets(trace); + assert_eq!( + ( + ai.0 + non_exclusive.0 + unknown.0, + ai.1 + non_exclusive.1 + unknown.1 + ), + touched_totals(committed), + "line_changes must cover every canonical touched line exactly once" + ); +} + +const MIXED_PATH: &str = "src/mixed.rs"; +const MIXED_HUNK: &str = "diff --git a/src/mixed.rs b/src/mixed.rs\n--- a/src/mixed.rs\n+++ b/src/mixed.rs\n@@ -1,3 +1,5 @@\n keep\n-old direct\n-old unknown\n+new direct\n+new lineage\n+new contended\n+new manual\n keep too\n"; +const MIXED_DIRECT: &[(char, u64, &str)] = &[(REMOVED, 2, "old direct"), (ADDED, 2, "new direct")]; +const MIXED_LINEAGE: &[(char, u64, &str)] = &[(ADDED, 3, "new lineage")]; +const MIXED_CONTENDED: &[(char, u64, &str)] = &[(ADDED, 4, "new contended")]; +const MIXED_ALL: &[(char, u64, &str)] = &[ + (REMOVED, 2, "old direct"), + (REMOVED, 3, "old unknown"), + (ADDED, 2, "new direct"), + (ADDED, 3, "new lineage"), + (ADDED, 4, "new contended"), + (ADDED, 5, "new manual"), +]; + +fn mixed_committed() -> ParsedPatch { + parse_patch(MIXED_HUNK, None).expect("mixed hunk parses") +} + +#[test] +fn mixed_hunk_counts_each_touched_line_by_its_own_decision() { + let committed = mixed_committed(); + let trace = build_trace( + &committed, + &[line_source(MIXED_PATH, MIXED_DIRECT, SESSION_A, MODEL_A)], + &mutation_ai_lines(MIXED_PATH, MIXED_LINEAGE, SESSION_A, Some(MODEL_A)), + &mutation_non_ai_lines(MIXED_PATH, MIXED_CONTENDED), + ); + + assert_eq!(buckets(&trace), ((2, 1), (1, 0), (1, 1))); + assert_counters_cover(&trace, &committed); + assert_eq!(contributor_kinds(&trace), vec![HunkContributor::Mixed]); + assert_eq!( + trace.files[0].conversations[0] + .contributor + .model_id + .as_deref(), + Some(MODEL_A) + ); + assert_eq!(tool_of(&trace), Some((Some("claude"), Some("1.0")))); +} + +#[test] +fn every_canonical_touched_line_receives_exactly_one_decision() { + let committed = mixed_committed(); + let sources = [line_source(MIXED_PATH, MIXED_DIRECT, SESSION_A, MODEL_A)]; + let mutation_ai = mutation_ai_lines(MIXED_PATH, MIXED_LINEAGE, SESSION_A, Some(MODEL_A)); + let mutation_non_ai = mutation_non_ai_lines(MIXED_PATH, MIXED_CONTENDED); + let decisions = resolve_patch_lines( + &committed, + &direct_sources(&sources), + &test_root(), + &MutationEvidenceIndex::new(&mutation_ai, &mutation_non_ai), + ); + + let expected: BTreeSet = (0..MIXED_ALL.len()) + .map(|line_index| PatchLineLocation { + file_index: 0, + hunk_index: 0, + line_index, + }) + .collect(); + assert_eq!(decisions.keys().copied().collect::>(), expected); + let kinds: Vec<&str> = decisions + .values() + .map(|decision| match decision { + LineAttribution::Ai(_) => "ai", + LineAttribution::NonExclusive => "non_exclusive", + LineAttribution::Unknown => "unknown", + }) + .collect(); + assert_eq!( + kinds, + vec!["ai", "unknown", "ai", "ai", "non_exclusive", "unknown"] + ); +} + +#[test] +fn entirely_ai_hunk_counts_every_line_as_ai() { + let committed = mixed_committed(); + let trace = build_trace( + &committed, + &[line_source(MIXED_PATH, MIXED_ALL, SESSION_A, MODEL_A)], + &no_mutation(), + &no_mutation(), + ); + + assert_eq!(buckets(&trace), ((4, 2), (0, 0), (0, 0))); + assert_counters_cover(&trace, &committed); + assert_eq!(contributor_kinds(&trace), vec![HunkContributor::Ai]); +} + +#[test] +fn entirely_unknown_hunk_counts_every_line_as_unknown() { + let committed = mixed_committed(); + let trace = build_trace(&committed, &[], &no_mutation(), &no_mutation()); + + assert_eq!(buckets(&trace), ((0, 0), (0, 0), (4, 2))); + assert_counters_cover(&trace, &committed); + assert_eq!(contributor_kinds(&trace), vec![HunkContributor::Unknown]); +} + +#[test] +fn non_exclusive_mutation_overrides_direct_ai_evidence_in_counters_and_provenance() { + let committed = committed_patch(); + let lines: &[(char, u64, &str)] = &[(ADDED, LINE_X.0, LINE_X.1)]; + let trace = build_trace( + &committed, + &[line_source(SOURCE_PATH, lines, SESSION_B, MODEL_B)], + &no_mutation(), + &mutation_non_ai_lines(SOURCE_PATH, lines), + ); + + assert_eq!(buckets(&trace), ((0, 0), (1, 0), (1, 0))); + assert_counters_cover(&trace, &committed); + assert_eq!( + contributor_kinds(&trace), + vec![HunkContributor::Unknown, HunkContributor::Unknown] + ); + let conversation = &trace.files[0].conversations[0]; + assert_eq!(conversation.contributor.model_id, None); + assert_eq!(conversation.related, None); + assert_eq!(tool_of(&trace), None); + let json = serde_json::to_string(&trace).expect("trace serializes"); + assert!(!json.contains(SESSION_B) && !json.contains(MODEL_B) && !json.contains("claude")); +} + +#[test] +fn conflicting_direct_sessions_count_as_unknown() { + let committed = committed_patch(); + let lines: &[(char, u64, &str)] = &[(ADDED, LINE_X.0, LINE_X.1)]; + let trace = build_trace( + &committed, + &[ + line_source(SOURCE_PATH, lines, SESSION_A, MODEL_A), + line_source(SOURCE_PATH, lines, SESSION_B, MODEL_B), + ], + &no_mutation(), + &no_mutation(), + ); + + assert_eq!(buckets(&trace), ((0, 0), (0, 0), (2, 0))); + assert_eq!(tool_of(&trace), None); +} + +#[test] +fn mutation_only_attribution_without_a_model_counts_as_ai() { + let committed = committed_patch(); + let trace = build_trace( + &committed, + &[], + &mutation_ai_lines(SOURCE_PATH, &[(ADDED, LINE_X.0, LINE_X.1)], SESSION_C, None), + &no_mutation(), + ); + + assert_eq!(buckets(&trace), ((1, 0), (0, 0), (1, 0))); + let conversation = &trace.files[0].conversations[0]; + assert_eq!(conversation.contributor.kind, HunkContributor::Ai); + assert_eq!(conversation.contributor.model_id, None); +} + +#[test] +fn a_removed_line_without_direct_evidence_counts_as_unknown_regardless_of_its_author() { + let committed = parse_patch( + &line_evidence_patch(SOURCE_PATH, &[(REMOVED, LINE_X.0, LINE_X.1)]), + None, + ) + .expect("deletion parses"); + let introduced: &[(char, u64, &str)] = &[(ADDED, LINE_X.0, LINE_X.1)]; + let trace = build_trace( + &committed, + &[line_source(SOURCE_PATH, introduced, SESSION_A, MODEL_A)], + &mutation_ai_lines(SOURCE_PATH, introduced, SESSION_A, Some(MODEL_A)), + &no_mutation(), + ); + + assert_eq!(buckets(&trace), ((0, 0), (0, 0), (0, 1))); + assert_eq!(contributor_kinds(&trace), vec![HunkContributor::Unknown]); + assert_eq!(tool_of(&trace), None); +} + +#[test] +fn duplicate_identical_lines_are_each_counted_once_and_only_supported_ones_are_ai() { + let committed = parse_patch( + "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,0 +2,3 @@\n+dup\n+dup\n+dup\n@@ -9,0 +12,1 @@\n+dup\n", + None, + ) + .expect("committed parses"); + let trace = build_trace( + &committed, + &[line_source( + SOURCE_PATH, + &[(ADDED, 3, "dup")], + SESSION_A, + MODEL_A, + )], + &mutation_ai_lines(SOURCE_PATH, &[(ADDED, 12, "dup")], SESSION_C, Some(MODEL_C)), + &no_mutation(), + ); + + assert_eq!(buckets(&trace), ((2, 0), (0, 0), (2, 0))); + assert_counters_cover(&trace, &committed); + assert_eq!( + contributor_kinds(&trace), + vec![HunkContributor::Mixed, HunkContributor::Ai] + ); +} + +#[test] +fn counters_sum_every_touched_line_across_multiple_hunks_and_files() { + let committed = parse_patch( + "diff --git a/src/a.rs b/src/a.rs\n--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1,1 +1,2 @@\n-a old\n+a one\n+a two\n@@ -10,0 +12,1 @@\n+a three\ndiff --git a/src/b.rs b/src/b.rs\n--- a/src/b.rs\n+++ b/src/b.rs\n@@ -4,2 +4,1 @@\n-b old one\n-b old two\n+b new\n", + None, + ) + .expect("committed parses"); + let trace = build_trace( + &committed, + &[ + line_source("src/a.rs", &[(ADDED, 1, "a one")], SESSION_A, MODEL_A), + line_source("src/b.rs", &[(REMOVED, 5, "b old two")], SESSION_A, MODEL_A), + ], + &mutation_ai_lines( + "src/a.rs", + &[(ADDED, 12, "a three")], + SESSION_A, + Some(MODEL_A), + ), + &mutation_non_ai_lines("src/b.rs", &[(ADDED, 4, "b new")]), + ); + + assert_eq!(buckets(&trace), ((2, 1), (1, 0), (1, 2))); + assert_counters_cover(&trace, &committed); + assert_eq!( + contributor_kinds(&trace), + vec![ + HunkContributor::Mixed, + HunkContributor::Ai, + HunkContributor::Mixed + ] + ); +} + +#[test] +fn a_deleted_patch_file_counts_only_its_literal_deleted_lines() { + let embedded = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,0 +2,1 @@\n+let alpha = 1;\n"; + let embedded_lines = embedded.lines().count(); + let mut committed_text = format!( + "diff --git a/changes.patch b/changes.patch\ndeleted file mode 100644\n--- a/changes.patch\n+++ /dev/null\n@@ -1,{embedded_lines} +0,0 @@\n" + ); + for line in embedded.lines() { + writeln!(committed_text, "-{line}").expect("write deleted line"); + } + let committed = parse_patch(&committed_text, None).expect("committed parses"); + let trace = build_trace( + &committed, + &[line_source( + SOURCE_PATH, + &[(ADDED, LINE_X.0, LINE_X.1)], + SESSION_A, + MODEL_A, + )], + &no_mutation(), + &no_mutation(), + ); + + assert_eq!(trace.files.len(), 1); + assert_eq!(trace.files[0].path, SOURCE_PATH); + assert_eq!(contributor_kinds(&trace), vec![HunkContributor::Ai]); + assert_eq!( + buckets(&trace), + ((0, 0), (0, 0), (0, embedded_lines as u64)) + ); + assert_counters_cover(&trace, &committed); +} + +fn accepted(session: &str, model: Option<&str>, source: usize) -> DirectLineEvidence { + DirectLineEvidence::Accepted(AcceptedDirectLine { + provenance: provenance(Some(session), model), + sources: BTreeSet::from([source]), + }) +} + +fn provenance(session: Option<&str>, model: Option<&str>) -> PatchLineProvenance { + PatchLineProvenance { + session_id: session.map(ToOwned::to_owned), + model_id: model.map(ToOwned::to_owned), + } +} + +fn ai_decision(session: &str, model: Option<&str>, direct_sources: &[usize]) -> LineAttribution { + LineAttribution::Ai(ResolvedLine { + provenance: provenance(Some(session), model), + direct_sources: direct_sources.iter().copied().collect(), + }) +} + +#[test] +fn resolve_line_follows_mutation_precedence_over_direct_evidence() { + let lineage = || MutationLineEvidence::Ai(provenance(Some(SESSION_C), None)); + let cases = [ + ( + lineage(), + Some(accepted(SESSION_C, Some(MODEL_C), 0)), + ai_decision(SESSION_C, Some(MODEL_C), &[0]), + ), + ( + lineage(), + Some(accepted(SESSION_A, Some(MODEL_A), 0)), + ai_decision(SESSION_C, None, &[]), + ), + ( + lineage(), + Some(DirectLineEvidence::Conflicting), + ai_decision(SESSION_C, None, &[]), + ), + (lineage(), None, ai_decision(SESSION_C, None, &[])), + ( + MutationLineEvidence::NonAi, + Some(accepted(SESSION_A, Some(MODEL_A), 0)), + LineAttribution::NonExclusive, + ), + ( + MutationLineEvidence::NonAi, + Some(DirectLineEvidence::Conflicting), + LineAttribution::NonExclusive, + ), + ( + MutationLineEvidence::NonAi, + None, + LineAttribution::NonExclusive, + ), + ( + MutationLineEvidence::Unresolved, + Some(accepted(SESSION_A, Some(MODEL_A), 1)), + ai_decision(SESSION_A, Some(MODEL_A), &[1]), + ), + ( + MutationLineEvidence::Unresolved, + Some(DirectLineEvidence::Conflicting), + LineAttribution::Unknown, + ), + ( + MutationLineEvidence::Unresolved, + None, + LineAttribution::Unknown, + ), + ]; + + for (mutation, direct, expected) in cases { + let label = format!("{mutation:?} + {direct:?}"); + assert_eq!(resolve_line(mutation, direct.as_ref()), expected, "{label}"); + } +} + +const RANDOMIZED_SEEDS: u64 = 256; + +type FlatLines = Vec<(usize, TouchedLineKind, LineAttribution)>; + +fn random_decision(rng: &mut StdRng) -> LineAttribution { + match rng.gen_range(0..3) { + 0 => ai_decision(SESSION_A, Some(MODEL_A), &[]), + 1 => LineAttribution::NonExclusive, + _ => LineAttribution::Unknown, + } +} + +fn random_lines(rng: &mut StdRng) -> FlatLines { + let files = rng.gen_range(1..=4); + let mut lines = Vec::new(); + for file in 0..files { + for _ in 0..rng.gen_range(1..=12) { + let kind = if rng.gen_bool(0.5) { + TouchedLineKind::Added + } else { + TouchedLineKind::Removed + }; + lines.push((file, kind, random_decision(rng))); + } + } + lines +} + +fn layout( + lines: &FlatLines, + file_order: &[usize], + max_hunk_len: usize, +) -> (ParsedPatch, BTreeMap) { + let mut text = String::new(); + let mut decisions = BTreeMap::new(); + for (file_index, file) in file_order.iter().enumerate() { + let path = format!("src/file_{file}.rs"); + write!( + text, + "diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n" + ) + .expect("write file header"); + let file_lines: Vec<_> = lines.iter().filter(|line| line.0 == *file).collect(); + let (mut old_start, mut new_start) = (1_u64, 1_u64); + for (hunk_index, hunk) in file_lines.chunks(max_hunk_len).enumerate() { + let removed = hunk + .iter() + .filter(|line| line.1 == TouchedLineKind::Removed) + .count() as u64; + let added = hunk.len() as u64 - removed; + writeln!(text, "@@ -{old_start},{removed} +{new_start},{added} @@") + .expect("write hunk header"); + for (line_index, (_, kind, decision)) in hunk.iter().enumerate() { + let marker = match kind { + TouchedLineKind::Added => ADDED, + TouchedLineKind::Removed => REMOVED, + }; + writeln!(text, "{marker}dup").expect("write line"); + decisions.insert( + PatchLineLocation { + file_index, + hunk_index, + line_index, + }, + decision.clone(), + ); + } + old_start += removed + 1; + new_start += added + 1; + } + } + ( + parse_patch(&text, None).expect("randomized patch parses"), + decisions, + ) +} + +fn expected_counts(lines: &FlatLines) -> LineChangeAttribution { + let mut counts = LineChangeAttribution::default(); + for (_, kind, decision) in lines { + let bucket = match decision { + LineAttribution::Ai(_) => &mut counts.ai, + LineAttribution::NonExclusive => &mut counts.non_exclusive, + LineAttribution::Unknown => &mut counts.unknown, + }; + match kind { + TouchedLineKind::Added => bucket.added += 1, + TouchedLineKind::Removed => bucket.removed += 1, + } + } + counts +} + +#[test] +fn randomized_counters_are_exact_and_independent_of_grouping_and_order() { + for seed in 0..RANDOMIZED_SEEDS { + let mut rng = StdRng::seed_from_u64(seed); + let lines = random_lines(&mut rng); + let files: Vec = (0..=lines.iter().map(|line| line.0).max().unwrap()).collect(); + let expected = expected_counts(&lines); + + let (patch, decisions) = layout(&lines, &files, rng.gen_range(1..=6)); + let counts = line_change_attribution(&patch, &decisions); + assert_eq!(counts, expected, "seed {seed}"); + assert_eq!(decisions.len(), lines.len(), "seed {seed}"); + assert_eq!( + ( + counts.ai.added + counts.non_exclusive.added + counts.unknown.added, + counts.ai.removed + counts.non_exclusive.removed + counts.unknown.removed, + ), + touched_totals(&patch), + "seed {seed}" + ); + + for max_hunk_len in [1, 3, usize::MAX] { + let (patch, decisions) = layout(&lines, &files, max_hunk_len); + assert_eq!( + line_change_attribution(&patch, &decisions), + expected, + "seed {seed} hunk partition {max_hunk_len}" + ); + } + + let reversed: Vec = files.iter().rev().copied().collect(); + let (patch, decisions) = layout(&lines, &reversed, 2); + assert_eq!( + line_change_attribution(&patch, &decisions), + expected, + "seed {seed} reordered files" + ); + + if let Some(index) = lines + .iter() + .position(|line| matches!(line.2, LineAttribution::Ai(_))) + { + let mut demoted = lines.clone(); + demoted[index].2 = LineAttribution::Unknown; + let (patch, decisions) = layout(&demoted, &files, 4); + let counts = line_change_attribution(&patch, &decisions); + let mut adjusted = expected; + match lines[index].1 { + TouchedLineKind::Added => { + adjusted.ai.added -= 1; + adjusted.unknown.added += 1; + } + TouchedLineKind::Removed => { + adjusted.ai.removed -= 1; + adjusted.unknown.removed += 1; + } + } + assert_eq!(counts, adjusted, "seed {seed} demoted line {index}"); + } + } +} diff --git a/cli/src/services/hooks/commit_hooks/attribution_tests.rs b/cli/src/services/hooks/commit_hooks/attribution_tests.rs index 94bc6a8a6..9ad69fbde 100644 --- a/cli/src/services/hooks/commit_hooks/attribution_tests.rs +++ b/cli/src/services/hooks/commit_hooks/attribution_tests.rs @@ -9,7 +9,7 @@ use serde_json::Value; use crate::services::agent_trace::{ agent_trace_session_url, validate_agent_trace_value, AgentTrace, DirectEvidenceSource, - HunkContributor, RepositoryRoot, + HunkContributor, LineChangeAttribution, LineChangeCounts, RepositoryRoot, }; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::agent_trace_db::{ @@ -512,6 +512,16 @@ async fn non_exclusive_mutation_lineage_is_not_overridden_by_a_direct_match() { HunkContributor::Unknown ); assert!(related_sessions(&trace).is_empty()); + let line_changes = trace.metadata.sce.line_changes; + assert_eq!(line_changes.ai, LineChangeCounts::default()); + assert_eq!( + line_changes.non_exclusive, + LineChangeCounts { + added: BOILERPLATE.len() as u64, + removed: 0, + } + ); + assert_eq!(line_changes.unknown, LineChangeCounts::default()); } #[tokio::test] @@ -742,6 +752,9 @@ async fn insert_rows(db: &RepositoryAgentTraceDb, rows: &[DiffTraceRow]) { struct PersistedTrace { json: String, value: Value, + trace: AgentTrace, + committed: ParsedPatch, + intersection_touched_lines: usize, } async fn persist_trace( @@ -781,7 +794,7 @@ async fn persist_trace( .result }; - run_post_commit_agent_trace_flow_with( + let trace = run_post_commit_agent_trace_flow_with( &flow, None, REMOTE_URL, @@ -792,6 +805,7 @@ async fn persist_trace( ) .await .expect("agent trace flow"); + let intersection_touched_lines = touched_line_count(&flow.intersection_patch); let traces: Vec = db .query_map("SELECT trace_json FROM agent_traces", (), |row| { @@ -805,6 +819,9 @@ async fn persist_trace( PersistedTrace { json: traces[0].clone(), value, + trace, + committed: flow.post_commit_data.parsed_patch, + intersection_touched_lines, } } @@ -1248,3 +1265,71 @@ async fn mutation_lineage_still_attributes_when_claude_direct_evidence_lacks_a_c assert_eq!(conversation["contributor"]["model_id"], MODEL_C); assert!(persisted.value.get("tool").is_none()); } + +async fn persisted_line_changes(rows: &[DiffTraceRow], lineage: bool) -> LineChangeAttribution { + let repo = HistoryRepo::new(); + let (_db_dir, mut db) = open_db().await; + insert_rows(&db, rows).await; + let mutation_rows = if lineage { + repo.mutation_rows(AttributionKind::AiExclusive) + } else { + Vec::new() + }; + let persisted = persist_trace(&repo, &mut db, &mutation_rows).await; + + assert_eq!(touched_line_count(&persisted.committed), BOILERPLATE.len()); + assert_eq!( + persisted.intersection_touched_lines, + rows.len() * BOILERPLATE.len() + ); + let persisted_counts: LineChangeAttribution = + serde_json::from_value(persisted.value["metadata"]["sce"]["line_changes"].clone()) + .expect("persisted line_changes deserialize"); + assert_eq!(persisted_counts, persisted.trace.metadata.sce.line_changes); + persisted_counts +} + +const fn added(count: usize) -> LineChangeCounts { + LineChangeCounts { + added: count as u64, + removed: 0, + } +} + +#[tokio::test] +async fn persisted_agent_trace_counts_causal_lineage_lines_as_ai() { + let counts = + persisted_line_changes(&[fresh_row(SESSION_X, MODEL_X, EXACT_C_POSITIONS)], true).await; + assert_eq!( + counts, + LineChangeAttribution { + ai: added(BOILERPLATE.len()), + ..LineChangeAttribution::default() + } + ); +} + +#[tokio::test] +async fn persisted_agent_trace_counts_direct_only_lines_as_ai() { + let counts = + persisted_line_changes(&[fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS)], false).await; + assert_eq!( + counts, + LineChangeAttribution { + ai: added(BOILERPLATE.len()), + ..LineChangeAttribution::default() + } + ); +} + +#[tokio::test] +async fn persisted_agent_trace_counts_unresolved_lines_as_unknown() { + let counts = persisted_line_changes(&[], false).await; + assert_eq!( + counts, + LineChangeAttribution { + unknown: added(BOILERPLATE.len()), + ..LineChangeAttribution::default() + } + ); +} diff --git a/context/cli/mutation-trace-agent-attribution.md b/context/cli/mutation-trace-agent-attribution.md index 89df9f37e..87afa1216 100644 --- a/context/cli/mutation-trace-agent-attribution.md +++ b/context/cli/mutation-trace-agent-attribution.md @@ -195,8 +195,17 @@ direct candidate matching in `cli/src/services/agent_trace/evidence.rs`: recorded before the parent commit are outside the query window, positional exactness rejects drifted boilerplate, and lineage attributes only the transition that most recently produced the committed line. +- **How are touched lines counted?** Each committed touched line receives + exactly one final decision — `Ai`, `NonExclusive`, or `Unknown` — and + `metadata.sce.line_changes` increments one `{ai, non_exclusive, unknown}` + bucket per line from that decision. `non_exclusive` means lineage resolved a + non-exclusive or unscoped transition (not human authorship); `unknown` means + unresolved. Hunk `mixed` is presentation only. Counters cover the canonical + committed patch exactly and do not depend on hunk grouping. See + [../sce/agent-trace-minimal-generator.md](../sce/agent-trace-minimal-generator.md). - **What remains unsupported?** Removed lines are never resolved by lineage - (only exact fresh direct evidence can cover them). Direct-only edits whose + (only exact fresh direct evidence can cover them; otherwise they count as + `unknown`, even when the line's original author is known). Direct-only edits whose line numbers drift through later uncaptured edits now resolve to unknown or `mixed` instead of `ai`. The freshness bound uses the committer time of the first parent, so an uncommitted edit made before the parent commit (partial diff --git a/context/context-map.md b/context/context-map.md index 639e76934..b562e3dca 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -102,7 +102,7 @@ Feature/domain context: - `context/sce/agent-trace-core-schema-migrations.md` (historical reference for removed local DB schema bootstrap behavior; T03 now implements the actual local DB with migrations) - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) -- `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,mixed,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact touched-line attribution counts from canonical `post_commit_patch` hunks reusing the same per-hunk classification, plus per-file trace data from fresh, exact, single-session direct evidence (`agent_trace/evidence.rs`) plus mutation-lineage evidence, then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation with a required lookup `url` derived from top-level `AgentTrace.id`, nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, optional canonical session links derived from matched touched-line provenance, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) +- `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,non_exclusive,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact per-line attribution counts with one bucket increment per canonical `post_commit_patch` touched line from the same line decisions that drive hunk classification, plus per-file trace data from fresh, exact, single-session direct evidence (`agent_trace/evidence.rs`) plus mutation-lineage evidence, then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation with a required lookup `url` derived from top-level `AgentTrace.id`, nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, optional canonical session links derived from matched touched-line provenance, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) - `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence with a best-effort canonical `git patch-id --stable` commit patch ID stored in nullable `agent_traces.commit_patch_id` (sanitized warning and `NULL` on failure), DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > bridge-derived chain state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, a main-session-scope-only bridge chain state-miss fallback that seeds a `source="bridge_inherited"` row for the current session (the one diff-trace path that writes state), `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes whose own model-less `SessionStart` bridge attempt cannot read its lazily created transcript, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — and the hidden non-fail-open `sce hooks claude-mutation-scope` first-concrete-adapter route (now registered by `sce setup`), deferring the full contracts to `context/cli/mutation-scope-hook-ingress.md` and `context/cli/claude-mutation-scope-integration.md`.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus current Rust evaluator seam and OpenCode/Claude delegation references, including config schema, argv-prefix matching, shell/nix unwrapping, custom-policy `satisfied_by` wrapper exemption, fixed preset catalog/messages, and precedence rules) diff --git a/context/glossary.md b/context/glossary.md index 39c45a585..aa6f6868c 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -86,7 +86,8 @@ - `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`; `disabled` covers both the explicit opt-out and an access policy that disallows background cloud operations, and 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 the `patch_for_locations` helpers; lineage projects every committed line, and deleted or renamed-away tracked paths stay tracked as absent files. 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_from_evidence(...)` 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`. +- `LineAttribution`: The single final per-line attribution decision in `cli/src/services/agent_trace.rs` — `Ai(ResolvedLine)` (AI-exclusive mutation lineage, or accepted direct evidence for an otherwise unresolved line), `NonExclusive` (mutation lineage resolved a non-exclusive or unscoped transition; not human authorship; cannot be overridden by direct evidence), or `Unknown` (unresolved). Exactly one decision exists per canonical touched line and feeds both hunk classification and `metadata.sce.line_changes`. See `context/sce/agent-trace-minimal-generator.md`. +- `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace_from_evidence(...)` under `metadata.sce`, carrying `version` (sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`) and `line_changes` (exact `{ai,non_exclusive,unknown}` × `{added,removed}` `u64` per-line attribution counts: every canonical `post_commit_patch` touched line increments exactly one bucket from its final `LineAttribution` decision — the same decisions that drive hunk classification — so totals equal the committed patch's touched lines independent of hunk boundaries; `mixed` is only a hunk-level presentation type with no counter bucket; `#[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`. - `ScopeProvenance`: Optional insert-once metadata keyed by a mutation `ScopeId`, storing canonical SCE `session_id` and nullable first-observed `model_id`. It is registered only at `Start` admission while the owning scope is `NeverSeen`, remains outside `ProtocolState` and the CAS transition, and is resolved during mutation projection. `ScopeId` proves mutation ownership; `ScopeProvenance` describes the owning scope. Model/session provenance is observational and never changes `AiExclusive`/`AiContended`/`IneligibleUnscoped` protocol decisions. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace_from_evidence(...)` 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. - `external-mutation guard` / `external-mutation supervisor`: `arm_external_mutation_guard` + `ArmedExternalMutationGuard::exec` (`cli/src/services/mutation_trace/runtime/external_mutation_guard.rs`, hidden `sce hooks external-mutation-guard`, Unix-only), the harness-neutral, not-Pi-specific mechanism that lets a human-initiated shell command (Pi's `!`/`!!` `user_bash`) mutate a worktree without ever creating a mutation scope: it acquires the same `ProtectedWorktree` guard `coordinate()` uses, reports it armed, itself spawns the human shell as its own child (never signaled or stopped by control-channel closure — only by an explicit cancel request, which it enacts by signaling the shell's own process group), finishes exclusively on the shell's real process termination, and only then forces the existing `database_failure`+`recover` composition on the already-held worktree via `coordinate_on_held_worktree` before clearing the marker with `ProtectedWorktree::complete()`. See [`mutation-trace-external-mutation-guard.md`](cli/mutation-trace-external-mutation-guard.md). diff --git a/context/overview.md b/context/overview.md index bcf379a9e..8f6288b57 100644 --- a/context/overview.md +++ b/context/overview.md @@ -113,7 +113,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 the direct-only intersection metadata to `post_commit_patch_intersections`, then — for committed touched lines that direct intersection did not cover — resolves bounded, read-only mutation-history AI coverage scoped to the invoking worktree's existing identity (newest 128 events replayed oldest-to-newest as one causal tree-transition provenance lineage bounded by a worktree-lock-captured commit attribution cut — `revision <= latest_mutation_event_revision` read under the worktree lock — with a committed line attributed AI only if an AI event's line survives every later transition into the committed tree; scope provenance enriches surviving mutation lines when available, without writing `diff_traces` or mutation-cursor state) and passes direct and mutation-AI evidence separately into the Agent Trace builder, which unions their related sessions and emits a model only when present evidence agrees, 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 > bridge-derived chain 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 provenance lineage bounded by a worktree-lock-captured commit attribution cut — `revision <= latest_mutation_event_revision` read under the worktree lock — with a committed line attributed AI only if an AI event's line survives every later transition into the committed tree; scope provenance enriches surviving mutation lines when available, without writing `diff_traces` or mutation-cursor state) and passes direct and mutation-AI evidence separately into the Agent Trace builder, which unions their related sessions and emits a model only when present evidence agrees, 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` per-line `{ai,non_exclusive,unknown}` attribution counts over the combined direct+mutation line decisions, 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 > bridge-derived chain 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 > bridge-derived chain 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/sce/agent-trace-minimal-generator.md b/context/sce/agent-trace-minimal-generator.md index fc14bd087..c783fd7fc 100644 --- a/context/sce/agent-trace-minimal-generator.md +++ b/context/sce/agent-trace-minimal-generator.md @@ -6,8 +6,8 @@ Rust library seam at `cli/src/services/agent_trace.rs` that produces the minimal Given per-row direct candidate patches, mutation lineage evidence, and a `post_commit_patch` (canonical source of truth): -1. Resolve every `post_commit_patch` touched line independently with the precedence in [../cli/mutation-trace-agent-attribution.md](../cli/mutation-trace-agent-attribution.md#evidence-precedence): AI-exclusive mutation lineage wins; non-exclusive mutation lineage keeps the line non-AI; otherwise a fresh, exact `(file, kind, line_number, content)`, single-session direct candidate attributes it; otherwise the line is unattributed. Historical `(kind, content)` similarity never attributes a line. -2. Classify each `post_commit_patch` hunk from its attributed lines: +1. Resolve every `post_commit_patch` touched line independently to exactly one final `LineAttribution` decision with the precedence in [../cli/mutation-trace-agent-attribution.md](../cli/mutation-trace-agent-attribution.md#evidence-precedence): AI-exclusive mutation lineage wins (`Ai`); non-exclusive mutation lineage keeps the line non-AI (`NonExclusive`); otherwise a fresh, exact `(file, kind, line_number, content)`, single-session direct candidate attributes it (`Ai`); otherwise the line is unresolved (`Unknown`). Historical `(kind, content)` similarity never attributes a line. Hunk classification, metadata, and `metadata.sce.line_changes` all derive from this one decision map. +2. Classify each `post_commit_patch` hunk from its `Ai` line decisions (a hunk of only `NonExclusive` lines is therefore `unknown`; the contributor schema has no non-exclusive type): - **`ai`** — every touched line in the hunk is attributed. - **`mixed`** — a non-empty proper subset of the hunk's touched lines is attributed. - **`unknown`** — no touched line in the hunk is attributed. @@ -44,8 +44,8 @@ File identity requires an established coordinate system (`DiffTracePathOrigin`, | `AgentTraceTool` | Optional top-level tool metadata object carrying optional `name` + optional `version` | | `AgentTraceMetadata` | Top-level implementation metadata object carrying SCE-owned metadata | | `AgentTraceSceMetadata` | Nested `metadata.sce` object carrying the compiled SCE CLI package `version` plus `line_changes` | -| `LineChangeCounts` | `{ added, removed }` `u64` touched-line counters for one hunk-classification bucket | -| `LineChangeAttribution` | `metadata.sce.line_changes` shape: `{ ai, mixed, unknown }`, each a `LineChangeCounts`, `#[serde(default)]` | +| `LineChangeCounts` | `{ added, removed }` `u64` touched-line counters for one per-line attribution bucket | +| `LineChangeAttribution` | `metadata.sce.line_changes` shape: `{ ai, non_exclusive, unknown }`, each a `LineChangeCounts`, `#[serde(default)]` | | `AgentTrace` | Top-level payload: `version`, `id`, `timestamp`, optional `vcs`, optional `tool`, `metadata`, `files` | All types are `serde`-serializable with `snake_case` field naming. `Conversation.url` is always serialized as `https://sce.crocoder.dev/conversations/{agent_trace.id}` for the generated top-level trace ID. `Conversation.contributor` serializes as a nested object with a JSON field named `type`; `model_id` is present only when every attributed line in the hunk has one agreed concrete value. `Conversation.related` is optional and omitted when `None` (`skip_serializing_if = "Option::is_none"`) and populated from the sessions of attributed lines as deduplicated session links. @@ -67,7 +67,12 @@ Current output includes top-level metadata fields with this contract: - when `vcs` is emitted, `vcs.type` is sourced from the schema-aligned enum (`git | jj | hg | svn`) and `vcs.revision` is sourced from `AgentTraceMetadataInput.commit_revision` - `tool` is derived from accepted direct sources only (see rule 5 above); `AgentTraceMetadataInput` no longer carries tool metadata. When emitted it serializes as `{ "name": string, "version"?: string }` - `metadata.sce.version` is always emitted and is sourced from `env!("CARGO_PKG_VERSION")`, the compiled `sce` CLI package version; it is implementation metadata and does not change top-level Agent Trace `version` semantics -- `metadata.sce.line_changes` is always emitted (`{ ai, mixed, unknown }`, each `{ added, removed }`) and carries exact touched-line attribution counts derived from `PatchHunk.lines` on the canonical `post_commit_patch` — the same hunk-level classification already used for `Conversation.contributor.type` (no independent second classification pass for the common per-file/per-hunk path); a `mixed` hunk's *entire* touched-line count is recorded, not just the subset also present in `intersection_patch`; the deleted-`.patch` embedded-expansion branch counts only the deleted file's own literal `post_commit_patch` hunks (classified by `old_path` against the top-level `intersection_patch`, since a deleted file's `new_path` is always empty and would otherwise collide with other deleted files in the same patch), never the embedded reconstructed hunks used to synthesize that branch's `Conversation` entries; `#[serde(default)]` on `line_changes` and its parent keeps pre-existing `metadata.sce.version`-only payloads deserializing with all-zero counts +- `metadata.sce.line_changes` is always emitted (`{ ai, non_exclusive, unknown }`, each `{ added, removed }`) and carries exact **per-line** attribution counts: `line_change_attribution(...)` walks every touched line of the canonical `post_commit_patch` once and increments exactly one bucket from that line's final `LineAttribution` decision — the same decision map the hunk classification uses, never a second attribution pass. Buckets mean: + - `ai` — accepted AI-attributed lines (AI-exclusive mutation lineage, or accepted direct evidence for an unresolved line); + - `non_exclusive` — mutation evidence established a non-exclusive or unscoped (contended/ineligible) transition; this is not a claim of human authorship, and direct AI evidence cannot move these lines to `ai`; + - `unknown` — attribution is unresolved (no accepted evidence, or only conflicting direct candidates). + + `mixed` is only a hunk-level presentation classification and has no counter bucket; a mixed hunk contributes each of its lines to that line's own bucket. Counter totals exactly cover the canonical committed patch (`ai + non_exclusive + unknown` equals its added and removed touched-line counts), are independent of hunk boundaries and range presentation, never count hunk context lines, and never deduplicate identical lines. Removed lines are not yet resolved by mutation lineage, so they count as `ai` only with accepted exact direct evidence and are otherwise `unknown`. The deleted-`.patch` embedded-expansion branch counts only the deleted file's literal `post_commit_patch` lines, never the embedded reconstructed hunks used to synthesize that branch's `Conversation` entries. `#[serde(default)]` on `line_changes`, its buckets, and its parent keeps older payloads deserializing (the previous `mixed` key is ignored and `non_exclusive` defaults to zero); historical traces are not migrated - every `Conversation.url` is the absolute URI `https://sce.crocoder.dev/conversations/{agent_trace.id}` derived from the generated top-level `AgentTrace.id`; all conversations in one payload therefore share the same URL ```json @@ -84,7 +89,7 @@ Current output includes top-level metadata fields with this contract: "version": "0.2.0", "line_changes": { "ai": { "added": 5, "removed": 0 }, - "mixed": { "added": 0, "removed": 0 }, + "non_exclusive": { "added": 0, "removed": 0 }, "unknown": { "added": 0, "removed": 0 } } } @@ -119,6 +124,7 @@ Current output includes top-level metadata fields with this 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. - Evidence fixtures (`direct_only`, `exclusive_without_direct`, `direct_plus_mutation`, `partial_combined`, `newer_nonexclusive_blocks`, `mutation_only_no_provenance`) pair `direct.patch` + `mutation_ai.patch` + `post_commit.patch` and drive `build_agent_trace_from_evidence`, pinning mutation-only coverage with and without resolved provenance, the combined model-agreement and session-union rules, the absence of fabricated metadata when provenance is unknown, and the direct-only case (empty `mutation_ai_patch`), whose classification and provenance match the direct-intersection rule. +- Per-line counter tests in `agent_trace/tests.rs` cover mixed, all-AI, and all-unknown hunks; non-exclusive override of direct AI evidence; conflicting direct sessions; mutation-only attribution without a model; removed lines without direct evidence; duplicate identical lines; multi-hunk/multi-file totals; deleted `.patch` files; the `resolve_line` precedence table; and a seeded randomized test asserting exact totals and invariance under hunk re-partitioning, file reordering, and single-decision demotion. `hooks/commit_hooks/attribution_tests.rs` checks persisted `agent_traces.trace_json` counters against the in-memory trace through the real Git + Turso post-commit flow. - 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 From f06fde5c1029b09144592fc6e2363cf816f81af7 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 6 Nov 2026 20:09:24 +0100 Subject: [PATCH 05/11] tests: Correct mixed-hunk line counts Keep the mixed-path parser fixture's hunk header aligned with its four removed and six added lines. This preserves the test's intended line-number coverage for direct, lineage, contended, and manual additions. --- cli/src/services/agent_trace/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/services/agent_trace/tests.rs b/cli/src/services/agent_trace/tests.rs index 8cba4a6ba..40400b8a5 100644 --- a/cli/src/services/agent_trace/tests.rs +++ b/cli/src/services/agent_trace/tests.rs @@ -1118,7 +1118,7 @@ fn assert_counters_cover(trace: &super::AgentTrace, committed: &ParsedPatch) { } const MIXED_PATH: &str = "src/mixed.rs"; -const MIXED_HUNK: &str = "diff --git a/src/mixed.rs b/src/mixed.rs\n--- a/src/mixed.rs\n+++ b/src/mixed.rs\n@@ -1,3 +1,5 @@\n keep\n-old direct\n-old unknown\n+new direct\n+new lineage\n+new contended\n+new manual\n keep too\n"; +const MIXED_HUNK: &str = "diff --git a/src/mixed.rs b/src/mixed.rs\n--- a/src/mixed.rs\n+++ b/src/mixed.rs\n@@ -1,4 +1,6 @@\n keep\n-old direct\n-old unknown\n+new direct\n+new lineage\n+new contended\n+new manual\n keep too\n"; const MIXED_DIRECT: &[(char, u64, &str)] = &[(REMOVED, 2, "old direct"), (ADDED, 2, "new direct")]; const MIXED_LINEAGE: &[(char, u64, &str)] = &[(ADDED, 3, "new lineage")]; const MIXED_CONTENDED: &[(char, u64, &str)] = &[(ADDED, 4, "new contended")]; From 542b98d07a44a2603c5cda27a5e92421dfb27ad6 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 6 Nov 2026 21:09:36 +0100 Subject: [PATCH 06/11] agent-trace: Derive ranges from committed line provenance Replace Git-hunk-based Agent Trace conversations with revision-positioned ranges built from final per-line attribution decisions. Read committed blobs at the recorded revision to validate positions and hash exact line bytes, while preserving conservative handling for unavailable or non-line-addressable files. This removes fabricated mixed hunk ownership and embedded deleted-patch expansion, groups ranges by accepted provenance, and updates the hook integration, fixtures, tests, and context contracts for the new payload semantics. Co-authored-by: SCE --- cli/src/services/agent_trace.rs | 364 +++-- .../services/agent_trace/committed_content.rs | 161 +++ .../average_age_reconstruction/golden.json | 143 +- .../fixtures/direct_only/golden.json | 25 +- .../fixtures/direct_plus_mutation/golden.json | 37 +- .../exclusive_without_direct/golden.json | 21 +- .../file_rename_reconstruction/golden.json | 15 +- .../hello_world_reconstruction/golden.json | 17 +- .../golden.json | 32 +- .../mixed_change_reconstruction/golden.json | 61 +- .../mutation_only_no_provenance/golden.json | 27 +- .../newer_nonexclusive_blocks/golden.json | 21 +- .../fixtures/partial_combined/golden.json | 52 +- .../poem_edit_reconstruction/golden.json | 51 +- .../poem_write_reconstruction/golden.json | 37 +- .../golden.json | 77 +- cli/src/services/agent_trace/ranges.rs | 159 +++ cli/src/services/agent_trace/tests.rs | 1207 ++++++++++++++++- cli/src/services/hooks/commit_hooks.rs | 23 +- .../hooks/commit_hooks/attribution_tests.rs | 337 ++++- .../runtime/mutation_attribution/tests.rs | 37 +- .../cli/mutation-trace-agent-attribution.md | 21 +- context/context-map.md | 2 +- context/glossary.md | 13 +- context/sce/agent-trace-minimal-generator.md | 96 +- 25 files changed, 2536 insertions(+), 500 deletions(-) create mode 100644 cli/src/services/agent_trace/committed_content.rs create mode 100644 cli/src/services/agent_trace/ranges.rs diff --git a/cli/src/services/agent_trace.rs b/cli/src/services/agent_trace.rs index 36f303672..70465ddad 100644 --- a/cli/src/services/agent_trace.rs +++ b/cli/src/services/agent_trace.rs @@ -2,28 +2,29 @@ use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, error::Error, fmt, - io::Cursor, - path::Path, sync::OnceLock, }; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use chrono::{DateTime, FixedOffset}; use jsonschema::{validator_for, Validator}; -use murmur3::murmur3_32; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::{NoContext, Timestamp, Uuid}; use super::mutation_trace::attribution::{logical_path, PatchLineLocation, PatchLineProvenance}; -use super::patch::{ - parse_patch, FileChangeKind, ParsedPatch, PatchFileChange, PatchHunk, TouchedLineKind, -}; +use super::patch::{ParsedPatch, PatchFileChange, TouchedLineKind}; use super::version::PACKAGE_VERSION; +mod committed_content; mod evidence; +mod ranges; mod repository_path; +#[cfg(test)] +pub use committed_content::NoCommittedFiles; +use committed_content::{content_hash, CommittedFileCache, CommittedLines}; +pub use committed_content::{CommittedFile, CommittedFileReader, GitCommittedFiles}; pub use evidence::DirectEvidenceSource; use evidence::{ accepted_direct_tool, direct_line_evidence, AcceptedDirectLine, DirectLineEvidence, @@ -38,11 +39,6 @@ pub(crate) fn sce_config_schema_url() -> String { format!("{SCE_WEB_BASE_URL}/v{PACKAGE_VERSION}/config.json") } -const RANGE_CONTENT_HASH_PREFIX: &str = "murmur3:"; -const RANGE_CONTENT_HASH_INPUT_VERSION: &[u8] = b"sce-agent-trace-range-content-hash-v1\0"; -const TOUCHED_LINE_ADDED_TAG: &[u8] = b"added\0"; -const TOUCHED_LINE_REMOVED_TAG: &[u8] = b"removed\0"; - pub(crate) fn agent_trace_conversation_url(agent_trace_id: &str) -> String { format!("{SCE_WEB_BASE_URL}/conversations/{agent_trace_id}") } @@ -242,30 +238,22 @@ pub(crate) fn validate_agent_trace_value(value: &Value) -> Result<(), AgentTrace Err(AgentTraceValidationError::SchemaValidation { errors }) } -/// Classification of a single hunk's origin relative to the AI candidate patch. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] -pub enum HunkContributor { - /// All touched lines in the `post_commit_patch` hunk are present identically - /// in `intersection_patch`. +pub enum ContributorType { + Human, Ai, - /// The `post_commit_patch` hunk has a corresponding slot in `intersection_patch` - /// but the content differs. Mixed, - /// The `post_commit_patch` hunk has no corresponding slot in `intersection_patch` - /// (missing from AI candidate). Unknown, } -/// A single conversation entry derived from one `post_commit_patch` hunk. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub struct Conversation { /// Absolute lookup URL for the generated top-level agent trace. pub url: String, - /// Classification of this hunk's origin. - pub contributor: Contributor, - /// Line ranges in the new file, derived from the `post_commit_patch` hunk metadata. + #[serde(skip_serializing_if = "Option::is_none")] + pub contributor: Option, pub ranges: Vec, /// Optional related resources for this conversation entry. #[serde(skip_serializing_if = "Option::is_none")] @@ -283,34 +271,32 @@ pub struct ConversationRelated { pub url: String, } -/// Nested contributor object for a conversation entry. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub struct Contributor { - /// Classification of this hunk's origin. #[serde(rename = "type")] - pub kind: HunkContributor, + pub kind: ContributorType, /// Model provenance for this contributor; omitted when unknown. #[serde(skip_serializing_if = "Option::is_none")] pub model_id: Option, } -/// A single line range in the new file. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub struct LineRange { pub start_line: u64, pub end_line: u64, - pub content_hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contributor: Option, } /// A file-level entry in the minimal agent-trace payload. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub struct TraceFile { - /// Post-change file path (from `post_commit_patch`). pub path: String, - /// One conversation per `post_commit_patch` hunk, in `post_commit_patch` hunk order. pub conversations: Vec, } @@ -336,7 +322,6 @@ pub struct AgentTrace { /// Implementation-specific metadata for SCE-generated traces. #[serde(default = "default_agent_trace_metadata")] pub metadata: AgentTraceMetadata, - /// File-level trace entries, one per file present in `post_commit_patch`. pub files: Vec, } @@ -490,50 +475,6 @@ fn resolve_patch_lines( resolved } -fn hunk_resolved_lines( - resolved: &BTreeMap, - file_index: usize, - hunk_index: usize, -) -> impl Iterator { - resolved - .range( - PatchLineLocation { - file_index, - hunk_index, - line_index: 0, - }..=PatchLineLocation { - file_index, - hunk_index, - line_index: usize::MAX, - }, - ) - .filter_map(|(_, line)| line.accepted_ai()) -} - -fn classify_hunk(hunk: &PatchHunk, covered: usize) -> HunkContributor { - let total = hunk.lines.len(); - if total > 0 && covered == total { - HunkContributor::Ai - } else if covered > 0 { - HunkContributor::Mixed - } else { - HunkContributor::Unknown - } -} - -fn agreed_model_id<'r>(lines: impl Iterator) -> Option { - let mut model: Option<&str> = None; - for line in lines { - let candidate = line.provenance.model_id.as_deref()?; - match model { - None => model = Some(candidate), - Some(existing) if existing == candidate => {} - Some(_) => return None, - } - } - model.map(ToOwned::to_owned) -} - pub(crate) fn patch_has_touched_lines(patch: &ParsedPatch) -> bool { patch .files @@ -541,131 +482,155 @@ pub(crate) fn patch_has_touched_lines(patch: &ParsedPatch) -> bool { .any(|file| file.hunks.iter().any(|hunk| !hunk.lines.is_empty())) } -pub(crate) fn range_content_hash(hunk: &PatchHunk) -> String { - let mut input = Vec::new(); - input.extend_from_slice(RANGE_CONTENT_HASH_INPUT_VERSION); - - for line in &hunk.lines { - let kind_tag = match line.kind { - TouchedLineKind::Added => TOUCHED_LINE_ADDED_TAG, - TouchedLineKind::Removed => TOUCHED_LINE_REMOVED_TAG, - }; - let content = line.content.as_bytes(); - - input.extend_from_slice(kind_tag); - input.extend_from_slice(&(content.len() as u64).to_be_bytes()); - input.extend_from_slice(content); - input.push(0); - } - - let hash = murmur3_32(&mut Cursor::new(input), 0) - .expect("murmur3 hashing from in-memory cursor should not fail"); - format!("{RANGE_CONTENT_HASH_PREFIX}{hash:08x}") -} - -fn line_range_from_hunk(file: &PatchFileChange, hunk: &PatchHunk) -> LineRange { - let (start_line, line_count) = match file.kind { - FileChangeKind::Deleted if hunk.new_count == 0 => (hunk.old_start, hunk.old_count), - _ => (hunk.new_start, hunk.new_count), - }; - let end_line = start_line.saturating_add(line_count.saturating_sub(1)); - - LineRange { - start_line, - end_line, - content_hash: range_content_hash(hunk), - } -} - -fn trace_path(file: &PatchFileChange) -> &str { - if file.new_path.is_empty() { - &file.old_path - } else { - &file.new_path - } -} - -fn parse_embedded_deleted_patch(file: &PatchFileChange) -> Option { - if file.kind != FileChangeKind::Deleted - || !Path::new(&file.old_path) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("patch")) - { - return None; - } - - let embedded_patch = file +fn attributed_added_lines<'p>( + file: &'p PatchFileChange, + file_index: usize, + resolved: &BTreeMap, +) -> Vec<(&'p str, ranges::AttributedLine)> { + let mut lines: Vec<_> = file .hunks .iter() - .flat_map(|hunk| hunk.lines.iter()) - .filter(|line| line.kind == TouchedLineKind::Removed) - .map(|line| line.content.as_str()) - .collect::>() - .join("\n"); + .enumerate() + .flat_map(|(hunk_index, hunk)| { + hunk.lines + .iter() + .enumerate() + .filter(|(_, line)| line.kind == TouchedLineKind::Added) + .map(move |(line_index, line)| { + let location = PatchLineLocation { + file_index, + hunk_index, + line_index, + }; + ( + line.content.as_str(), + ranges::AttributedLine { + line_number: line.line_number, + attribution: ranges::RangeAttribution::from_decision( + resolved.get(&location), + ), + }, + ) + }) + }) + .collect(); + lines.sort_by_key(|(_, line)| line.line_number); + lines +} + +fn validate_added_line_positions( + path: &str, + lines: &[(&str, ranges::AttributedLine)], + committed: Option<&CommittedLines<'_>>, + revision: &str, +) -> Result<()> { + let mut previous = 0; + for (content, line) in lines { + if line.line_number <= previous { + bail!( + "Invalid Agent Trace range position for '{path}': line {} is zero or repeated.", + line.line_number + ); + } + previous = line.line_number; + if committed.is_some_and(|committed| !committed.line_matches(line.line_number, content)) { + bail!( + "Invalid Agent Trace range position for '{path}': line {} does not match the committed content at revision '{revision}'.", + line.line_number + ); + } + } + Ok(()) +} - let parsed_patch = parse_patch(&embedded_patch, None).ok()?; - (!parsed_patch.files.is_empty()).then_some(parsed_patch) +fn session_related(session_id: Option<&str>) -> Option> { + session_id.map(|session_id| { + vec![ConversationRelated { + kind: String::from("session"), + url: agent_trace_session_url(session_id), + }] + }) } fn build_trace_file( - post_commit_file: &PatchFileChange, + file: &PatchFileChange, file_index: usize, resolved: &BTreeMap, conversation_url: &str, -) -> Option { - if post_commit_file.hunks.is_empty() { - return None; + committed_files: &mut CommittedFileCache<'_>, + revision: &str, +) -> Result> { + let lines = attributed_added_lines(file, file_index, resolved); + if lines.is_empty() { + return Ok(None); } - let conversations = post_commit_file - .hunks - .iter() - .enumerate() - .map(|(hunk_index, post_commit_hunk)| { - let covered = hunk_resolved_lines(resolved, file_index, hunk_index).count(); - let contributor_kind = classify_hunk(post_commit_hunk, covered); - let contributor_model_id = match contributor_kind { - HunkContributor::Ai | HunkContributor::Mixed => { - agreed_model_id(hunk_resolved_lines(resolved, file_index, hunk_index)) - } - HunkContributor::Unknown => None, - }; - let related_session_ids = hunk_resolved_lines(resolved, file_index, hunk_index) - .filter_map(|line| line.provenance.session_id.as_deref()) - .filter(|session_id| !session_id.is_empty()) - .collect::>(); - let related = (!related_session_ids.is_empty()).then(|| { - related_session_ids - .into_iter() - .map(|session_id| ConversationRelated { - kind: String::from("session"), - url: agent_trace_session_url(session_id), - }) - .collect() - }); - - Conversation { - url: conversation_url.to_owned(), - contributor: Contributor { - kind: contributor_kind, - model_id: contributor_model_id, - }, - ranges: vec![line_range_from_hunk(post_commit_file, post_commit_hunk)], - related, - } + let path = logical_path(file); + let committed = match committed_files.get(path) { + CommittedFile::Blob(bytes) => Some(CommittedLines::new(bytes)), + CommittedFile::NotLineAddressable => return Ok(None), + CommittedFile::Unavailable => None, + }; + validate_added_line_positions(path, &lines, committed.as_ref(), revision)?; + + let lines: Vec = lines.into_iter().map(|(_, line)| line).collect(); + let conversations = ranges::group_conversations(ranges::coalesce_ranges(&lines)) + .into_iter() + .map(|group| Conversation { + url: conversation_url.to_owned(), + contributor: group.contributor(), + ranges: group + .ranges + .iter() + .map(|range| LineRange { + start_line: range.start_line, + end_line: range.end_line, + content_hash: committed + .as_ref() + .and_then(|committed| committed.span(range.start_line, range.end_line)) + .map(content_hash), + contributor: group.range_contributor(range), + }) + .collect(), + related: session_related(group.session_id()), }) .collect(); - Some(TraceFile { - path: trace_path(post_commit_file).to_string(), + Ok(Some(TraceFile { + path: path.to_owned(), conversations, - }) + })) +} + +fn build_trace_files( + post_commit_patch: &ParsedPatch, + resolved: &BTreeMap, + conversation_url: &str, + committed_files: &dyn CommittedFileReader, + revision: &str, +) -> Result> { + let mut committed_files = CommittedFileCache::new(committed_files); + let mut files = Vec::new(); + for (file_index, file) in post_commit_patch.files.iter().enumerate() { + if let Some(trace_file) = build_trace_file( + file, + file_index, + resolved, + conversation_url, + &mut committed_files, + revision, + )? { + files.push(trace_file); + } + } + Ok(files) } pub fn build_agent_trace_from_evidence( evidence: AgentTraceEvidence<'_>, post_commit_patch: &ParsedPatch, metadata: AgentTraceMetadataInput<'_>, + committed_files: &dyn CommittedFileReader, ) -> Result { let AgentTraceEvidence { repository_root, @@ -686,38 +651,13 @@ pub fn build_agent_trace_from_evidence( &mutation, ); let line_changes = line_change_attribution(post_commit_patch, &resolved); - let empty_patch = ParsedPatch { files: Vec::new() }; - let no_mutation = MutationEvidenceIndex::new(&empty_patch, &empty_patch); - - let mut files = Vec::new(); - - for (file_index, post_commit_file) in post_commit_patch.files.iter().enumerate() { - if let Some(embedded_patch) = parse_embedded_deleted_patch(post_commit_file) { - let embedded_resolved = resolve_patch_lines( - &embedded_patch, - direct_sources, - repository_root, - &no_mutation, - ); - files.extend(embedded_patch.files.iter().enumerate().filter_map( - |(embedded_index, embedded_file)| { - build_trace_file( - embedded_file, - embedded_index, - &embedded_resolved, - &conversation_url, - ) - }, - )); - continue; - } - - if let Some(trace_file) = - build_trace_file(post_commit_file, file_index, &resolved, &conversation_url) - { - files.push(trace_file); - } - } + let files = build_trace_files( + post_commit_patch, + &resolved, + &conversation_url, + committed_files, + metadata.commit_revision, + )?; let accepted_sources: BTreeSet = resolved .values() diff --git a/cli/src/services/agent_trace/committed_content.rs b/cli/src/services/agent_trace/committed_content.rs new file mode 100644 index 000000000..982169e79 --- /dev/null +++ b/cli/src/services/agent_trace/committed_content.rs @@ -0,0 +1,161 @@ +use std::collections::HashMap; +use std::io::Cursor; +use std::path::Path; +use std::process::Command; + +use murmur3::murmur3_32; + +const CONTENT_HASH_PREFIX: &str = "murmur3:"; +const CONTENT_HASH_SEED: u32 = 0; +const GIT_BLOB_OBJECT_TYPE: &str = "blob"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CommittedFile { + Blob(Vec), + NotLineAddressable, + Unavailable, +} + +pub trait CommittedFileReader { + fn read_committed_file(&self, path: &str) -> CommittedFile; +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct NoCommittedFiles; + +#[cfg(test)] +impl CommittedFileReader for NoCommittedFiles { + fn read_committed_file(&self, _path: &str) -> CommittedFile { + CommittedFile::Unavailable + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GitCommittedFiles<'a> { + repository_root: &'a Path, + revision: &'a str, +} + +impl<'a> GitCommittedFiles<'a> { + pub fn new(repository_root: &'a Path, revision: &'a str) -> Self { + Self { + repository_root, + revision, + } + } + + fn git_stdout(&self, args: &[&str]) -> Option> { + let output = Command::new("git") + .arg("--literal-pathspecs") + .args(args) + .current_dir(self.repository_root) + .output() + .ok()?; + output.status.success().then_some(output.stdout) + } +} + +impl CommittedFileReader for GitCommittedFiles<'_> { + fn read_committed_file(&self, path: &str) -> CommittedFile { + let Some(listing) = + self.git_stdout(&["ls-tree", "-z", "--full-tree", self.revision, "--", path]) + else { + return CommittedFile::Unavailable; + }; + let Some((object_type, object_id)) = parse_ls_tree_entry(&listing, path) else { + return CommittedFile::Unavailable; + }; + if object_type != GIT_BLOB_OBJECT_TYPE { + return CommittedFile::NotLineAddressable; + } + self.git_stdout(&["cat-file", "blob", object_id]) + .map_or(CommittedFile::Unavailable, CommittedFile::Blob) + } +} + +fn parse_ls_tree_entry<'o>(listing: &'o [u8], path: &str) -> Option<(&'o str, &'o str)> { + listing + .split(|byte| *byte == 0) + .filter_map(|entry| std::str::from_utf8(entry).ok()) + .find_map(|entry| { + let (header, entry_path) = entry.split_once('\t')?; + if entry_path != path { + return None; + } + let mut fields = header.split(' '); + let _mode = fields.next()?; + Some((fields.next()?, fields.next()?)) + }) +} + +pub(super) struct CommittedFileCache<'r> { + reader: &'r dyn CommittedFileReader, + files: HashMap, +} + +impl<'r> CommittedFileCache<'r> { + pub(super) fn new(reader: &'r dyn CommittedFileReader) -> Self { + Self { + reader, + files: HashMap::new(), + } + } + + pub(super) fn get(&mut self, path: &str) -> &CommittedFile { + self.files + .entry(path.to_owned()) + .or_insert_with(|| self.reader.read_committed_file(path)) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CommittedLines<'a> { + bytes: &'a [u8], + bounds: Vec, +} + +impl<'a> CommittedLines<'a> { + pub(crate) fn new(bytes: &'a [u8]) -> Self { + let mut bounds = vec![0]; + bounds.extend( + bytes + .iter() + .enumerate() + .filter(|(_, byte)| **byte == b'\n') + .map(|(index, _)| index + 1), + ); + if bounds.last() != Some(&bytes.len()) { + bounds.push(bytes.len()); + } + Self { bytes, bounds } + } + + pub(crate) fn line_count(&self) -> u64 { + (self.bounds.len() - 1) as u64 + } + + pub(crate) fn span(&self, start_line: u64, end_line: u64) -> Option<&'a [u8]> { + if start_line == 0 || end_line < start_line || end_line > self.line_count() { + return None; + } + let start = self.bounds[usize::try_from(start_line - 1).ok()?]; + let end = self.bounds[usize::try_from(end_line).ok()?]; + Some(&self.bytes[start..end]) + } + + pub(crate) fn line_matches(&self, line_number: u64, content: &str) -> bool { + let Some(line) = self.span(line_number, line_number) else { + return false; + }; + let without_lf = line.strip_suffix(b"\n").unwrap_or(line); + without_lf == content.as_bytes() + || without_lf.strip_suffix(b"\r") == Some(content.as_bytes()) + } +} + +pub(crate) fn content_hash(bytes: &[u8]) -> String { + let hash = murmur3_32(&mut Cursor::new(bytes), CONTENT_HASH_SEED) + .expect("murmur3 hashing from an in-memory cursor should not fail"); + format!("{CONTENT_HASH_PREFIX}{hash:08x}") +} diff --git a/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json index 38426bc23..2fa4b6091 100644 --- a/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/average_age_reconstruction/golden.json @@ -11,9 +11,18 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 40, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 51, "removed": 9 } + "ai": { + "added": 40, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 51, + "removed": 9 + } } } }, @@ -24,13 +33,81 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "mixed" + "type": "ai" }, "ranges": [ { "start_line": 1, - "end_line": 53, - "content_hash": "murmur3:c243a227" + "end_line": 3, + "content_hash": "murmur3:6e181631" + }, + { + "start_line": 5, + "end_line": 21, + "content_hash": "murmur3:b82453cf" + }, + { + "start_line": 23, + "end_line": 23, + "content_hash": "murmur3:088f1fbb" + }, + { + "start_line": 25, + "end_line": 26, + "content_hash": "murmur3:7e5c2b9d" + }, + { + "start_line": 29, + "end_line": 29, + "content_hash": "murmur3:2f1fb1ad" + }, + { + "start_line": 39, + "end_line": 39, + "content_hash": "murmur3:924ee0ab" + } + ] + }, + { + "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "contributor": { + "type": "unknown" + }, + "ranges": [ + { + "start_line": 22, + "end_line": 22, + "content_hash": "murmur3:924ee0ab" + }, + { + "start_line": 24, + "end_line": 24, + "content_hash": "murmur3:924ee0ab" + }, + { + "start_line": 27, + "end_line": 28, + "content_hash": "murmur3:27ed9155" + }, + { + "start_line": 30, + "end_line": 38, + "content_hash": "murmur3:b63e60bb" + }, + { + "start_line": 40, + "end_line": 46, + "content_hash": "murmur3:ddce4161" + }, + { + "start_line": 48, + "end_line": 49, + "content_hash": "murmur3:c43ecdd1" + }, + { + "start_line": 52, + "end_line": 52, + "content_hash": "murmur3:5b7d7d05" } ] } @@ -42,13 +119,26 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "mixed" + "type": "ai" }, "ranges": [ { "start_line": 1, + "end_line": 6, + "content_hash": "murmur3:6bd38686" + } + ] + }, + { + "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "contributor": { + "type": "unknown" + }, + "ranges": [ + { + "start_line": 7, "end_line": 17, - "content_hash": "murmur3:a33199de" + "content_hash": "murmur3:22a31e54" } ] } @@ -60,13 +150,46 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "mixed" + "type": "ai" }, "ranges": [ { "start_line": 1, + "end_line": 7, + "content_hash": "murmur3:392d8e80" + }, + { + "start_line": 14, + "end_line": 14, + "content_hash": "murmur3:91ae0083" + }, + { + "start_line": 21, + "end_line": 21, + "content_hash": "murmur3:b0592d49" + } + ] + }, + { + "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "contributor": { + "type": "unknown" + }, + "ranges": [ + { + "start_line": 8, + "end_line": 13, + "content_hash": "murmur3:93bc82c4" + }, + { + "start_line": 15, + "end_line": 20, + "content_hash": "murmur3:57c576a2" + }, + { + "start_line": 22, "end_line": 26, - "content_hash": "murmur3:d5e2e562" + "content_hash": "murmur3:443e9882" } ] } diff --git a/cli/src/services/agent_trace/fixtures/direct_only/golden.json b/cli/src/services/agent_trace/fixtures/direct_only/golden.json index d02933216..df220f8d4 100644 --- a/cli/src/services/agent_trace/fixtures/direct_only/golden.json +++ b/cli/src/services/agent_trace/fixtures/direct_only/golden.json @@ -15,9 +15,18 @@ "sce": { "version": "0.1.0", "line_changes": { - "ai": { "added": 4, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 0, "removed": 0 } + "ai": { + "added": 4, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 0, + "removed": 0 + } } } }, @@ -26,16 +35,16 @@ "path": "src/widget.ts", "conversations": [ { - "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "url": "https://sce.crocoder.dev/conversations/019db9da-fdb0-7a09-bcd3-9565bfba8bf5", "contributor": { - "type": "ai", - "model_id": "claude-sonnet-5" + "model_id": "claude-sonnet-5", + "type": "ai" }, "ranges": [ { - "start_line": 2, + "start_line": 3, "end_line": 6, - "content_hash": "murmur3:191372d8" + "content_hash": "murmur3:779f392e" } ], "related": [ 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 index 8766347ad..0c09d996c 100644 --- a/cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json +++ b/cli/src/services/agent_trace/fixtures/direct_plus_mutation/golden.json @@ -15,9 +15,18 @@ "sce": { "version": "0.1.0", "line_changes": { - "ai": { "added": 4, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 0, "removed": 0 } + "ai": { + "added": 4, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 0, + "removed": 0 + } } } }, @@ -26,15 +35,16 @@ "path": "src/widget.ts", "conversations": [ { - "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "url": "https://sce.crocoder.dev/conversations/019db9da-fdb0-7ed3-b6dc-a0e70ac76862", "contributor": { + "model_id": "claude-sonnet-5", "type": "ai" }, "ranges": [ { - "start_line": 2, - "end_line": 6, - "content_hash": "murmur3:191372d8" + "start_line": 3, + "end_line": 4, + "content_hash": "murmur3:bde358d6" } ], "related": [ @@ -43,6 +53,19 @@ "url": "https://sce.crocoder.dev/sessions/sess-direct" } ] + }, + { + "url": "https://sce.crocoder.dev/conversations/019db9da-fdb0-7ed3-b6dc-a0e70ac76862", + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 5, + "end_line": 6, + "content_hash": "murmur3:d121bdf6" + } + ] } ] } 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 index 015356d1a..db0ce0b78 100644 --- a/cli/src/services/agent_trace/fixtures/exclusive_without_direct/golden.json +++ b/cli/src/services/agent_trace/fixtures/exclusive_without_direct/golden.json @@ -11,9 +11,18 @@ "sce": { "version": "0.1.0", "line_changes": { - "ai": { "added": 4, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 0, "removed": 0 } + "ai": { + "added": 4, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 0, + "removed": 0 + } } } }, @@ -22,15 +31,15 @@ "path": "src/widget.ts", "conversations": [ { - "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "url": "https://sce.crocoder.dev/conversations/019db9da-fdb0-7e79-8782-b0584a8d51f4", "contributor": { "type": "ai" }, "ranges": [ { - "start_line": 2, + "start_line": 3, "end_line": 6, - "content_hash": "murmur3:191372d8" + "content_hash": "murmur3:779f392e" } ] } diff --git a/cli/src/services/agent_trace/fixtures/file_rename_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/file_rename_reconstruction/golden.json index fad096cbb..889593c47 100644 --- a/cli/src/services/agent_trace/fixtures/file_rename_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/file_rename_reconstruction/golden.json @@ -11,9 +11,18 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 0, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 0, "removed": 0 } + "ai": { + "added": 0, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 0, + "removed": 0 + } } } }, diff --git a/cli/src/services/agent_trace/fixtures/hello_world_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/hello_world_reconstruction/golden.json index dc067fe32..fd3143b20 100644 --- a/cli/src/services/agent_trace/fixtures/hello_world_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/hello_world_reconstruction/golden.json @@ -11,9 +11,18 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 5, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 0, "removed": 0 } + "ai": { + "added": 5, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 0, + "removed": 0 + } } } }, @@ -30,7 +39,7 @@ { "start_line": 1, "end_line": 5, - "content_hash": "murmur3:f0b7603b" + "content_hash": "murmur3:7082961b" } ] } diff --git a/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json index 6dee94bed..b15d738c1 100644 --- a/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/line_and_file_deletion_reconstruction/golden.json @@ -11,9 +11,18 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 1, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 1, "removed": 0 } + "ai": { + "added": 1, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 1, + "removed": 0 + } } } }, @@ -24,13 +33,26 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "mixed" + "type": "ai" }, "ranges": [ { "start_line": 1, + "end_line": 1, + "content_hash": "murmur3:924e4290" + } + ] + }, + { + "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "contributor": { + "type": "unknown" + }, + "ranges": [ + { + "start_line": 2, "end_line": 2, - "content_hash": "murmur3:3c5fec84" + "content_hash": "murmur3:8abc72bc" } ] } diff --git a/cli/src/services/agent_trace/fixtures/mixed_change_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/mixed_change_reconstruction/golden.json index cd3f494e3..071b5656c 100644 --- a/cli/src/services/agent_trace/fixtures/mixed_change_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/mixed_change_reconstruction/golden.json @@ -11,49 +11,22 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 5, "removed": 2 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 1, "removed": 15 } + "ai": { + "added": 5, + "removed": 2 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 1, + "removed": 15 + } } } }, "files": [ - { - "path": ".version", - "conversations": [ - { - "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", - "contributor": { - "type": "ai" - }, - "ranges": [ - { - "start_line": 1, - "end_line": 1, - "content_hash": "murmur3:bae67384" - } - ] - } - ] - }, - { - "path": "poem.md", - "conversations": [ - { - "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", - "contributor": { - "type": "unknown" - }, - "ranges": [ - { - "start_line": 1, - "end_line": 8, - "content_hash": "murmur3:66792e88" - } - ] - } - ] - }, { "path": "poem_life.md", "conversations": [ @@ -66,7 +39,7 @@ { "start_line": 1, "end_line": 4, - "content_hash": "murmur3:9a75f476" + "content_hash": "murmur3:e10a5b68" } ] } @@ -83,8 +56,8 @@ "ranges": [ { "start_line": 1, - "end_line": 4, - "content_hash": "murmur3:13e28dae" + "end_line": 1, + "content_hash": "murmur3:76184b87" } ] } @@ -100,9 +73,9 @@ }, "ranges": [ { - "start_line": 1, + "start_line": 4, "end_line": 4, - "content_hash": "murmur3:eeb8e8f1" + "content_hash": "murmur3:3d891c9c" } ] } 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 index 254eb2c4b..030e73311 100644 --- 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 @@ -11,9 +11,18 @@ "sce": { "version": "0.1.0", "line_changes": { - "ai": { "added": 2, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 2, "removed": 0 } + "ai": { + "added": 2, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 2, + "removed": 0 + } } } }, @@ -22,28 +31,28 @@ "path": "src/widget.ts", "conversations": [ { - "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "url": "https://sce.crocoder.dev/conversations/019db9da-fdb0-7cb6-9185-75e28b23b264", "contributor": { "type": "ai" }, "ranges": [ { - "start_line": 2, + "start_line": 3, "end_line": 4, - "content_hash": "murmur3:ee57a4f8" + "content_hash": "murmur3:bde358d6" } ] }, { - "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "url": "https://sce.crocoder.dev/conversations/019db9da-fdb0-7cb6-9185-75e28b23b264", "contributor": { "type": "unknown" }, "ranges": [ { - "start_line": 10, + "start_line": 11, "end_line": 12, - "content_hash": "murmur3:9d2517c0" + "content_hash": "murmur3:d121bdf6" } ] } 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 index f53827616..5f67dc609 100644 --- a/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/golden.json +++ b/cli/src/services/agent_trace/fixtures/newer_nonexclusive_blocks/golden.json @@ -11,9 +11,18 @@ "sce": { "version": "0.1.0", "line_changes": { - "ai": { "added": 0, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 4, "removed": 0 } + "ai": { + "added": 0, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 4, + "removed": 0 + } } } }, @@ -22,15 +31,15 @@ "path": "src/widget.ts", "conversations": [ { - "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "url": "https://sce.crocoder.dev/conversations/019db9da-fdb0-7044-b5f6-95b6f1136990", "contributor": { "type": "unknown" }, "ranges": [ { - "start_line": 2, + "start_line": 3, "end_line": 6, - "content_hash": "murmur3:191372d8" + "content_hash": "murmur3:779f392e" } ] } diff --git a/cli/src/services/agent_trace/fixtures/partial_combined/golden.json b/cli/src/services/agent_trace/fixtures/partial_combined/golden.json index e6dec5c73..922ab206a 100644 --- a/cli/src/services/agent_trace/fixtures/partial_combined/golden.json +++ b/cli/src/services/agent_trace/fixtures/partial_combined/golden.json @@ -15,9 +15,18 @@ "sce": { "version": "0.1.0", "line_changes": { - "ai": { "added": 2, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 2, "removed": 0 } + "ai": { + "added": 2, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 2, + "removed": 0 + } } } }, @@ -26,15 +35,16 @@ "path": "src/widget.ts", "conversations": [ { - "url": "https://sce.crocoder.dev/conversations/PLACEHOLDER", + "url": "https://sce.crocoder.dev/conversations/019db9da-fdb0-7867-86f7-dba4a8b6649c", "contributor": { - "type": "mixed" + "model_id": "claude-sonnet-5", + "type": "ai" }, "ranges": [ { - "start_line": 2, - "end_line": 6, - "content_hash": "murmur3:191372d8" + "start_line": 3, + "end_line": 3, + "content_hash": "murmur3:56e077ea" } ], "related": [ @@ -43,6 +53,32 @@ "url": "https://sce.crocoder.dev/sessions/sess-direct" } ] + }, + { + "url": "https://sce.crocoder.dev/conversations/019db9da-fdb0-7867-86f7-dba4a8b6649c", + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 4, + "end_line": 4, + "content_hash": "murmur3:3361129a" + } + ] + }, + { + "url": "https://sce.crocoder.dev/conversations/019db9da-fdb0-7867-86f7-dba4a8b6649c", + "contributor": { + "type": "unknown" + }, + "ranges": [ + { + "start_line": 5, + "end_line": 6, + "content_hash": "murmur3:d121bdf6" + } + ] } ] } diff --git a/cli/src/services/agent_trace/fixtures/poem_edit_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/poem_edit_reconstruction/golden.json index 9a3592ece..4c0616517 100644 --- a/cli/src/services/agent_trace/fixtures/poem_edit_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/poem_edit_reconstruction/golden.json @@ -11,9 +11,18 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 2, "removed": 2 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 3, "removed": 3 } + "ai": { + "added": 2, + "removed": 2 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 3, + "removed": 3 + } } } }, @@ -24,26 +33,23 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "mixed" + "type": "unknown" }, "ranges": [ { "start_line": 1, - "end_line": 8, - "content_hash": "murmur3:25e05a40" - } - ] - }, - { - "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", - "contributor": { - "type": "unknown" - }, - "ranges": [ + "end_line": 1, + "content_hash": "murmur3:1bcdcbc7" + }, + { + "start_line": 5, + "end_line": 5, + "content_hash": "murmur3:dcb067c6" + }, { - "start_line": 10, - "end_line": 16, - "content_hash": "murmur3:bc5d346b" + "start_line": 13, + "end_line": 13, + "content_hash": "murmur3:5b8141a4" } ] }, @@ -54,9 +60,14 @@ }, "ranges": [ { - "start_line": 21, + "start_line": 4, + "end_line": 4, + "content_hash": "murmur3:a46bb5f2" + }, + { + "start_line": 24, "end_line": 24, - "content_hash": "murmur3:c8621bcb" + "content_hash": "murmur3:80227513" } ] } diff --git a/cli/src/services/agent_trace/fixtures/poem_write_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/poem_write_reconstruction/golden.json index 5e64f358a..37d4ad3f1 100644 --- a/cli/src/services/agent_trace/fixtures/poem_write_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/poem_write_reconstruction/golden.json @@ -11,9 +11,18 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 23, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 1, "removed": 0 } + "ai": { + "added": 23, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 1, + "removed": 0 + } } } }, @@ -24,13 +33,31 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "mixed" + "type": "ai" }, "ranges": [ { "start_line": 1, + "end_line": 14, + "content_hash": "murmur3:bc9ea670" + }, + { + "start_line": 16, "end_line": 24, - "content_hash": "murmur3:118e756f" + "content_hash": "murmur3:b8e2384e" + } + ] + }, + { + "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "contributor": { + "type": "unknown" + }, + "ranges": [ + { + "start_line": 15, + "end_line": 15, + "content_hash": "murmur3:cf9c230e" } ] } diff --git a/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json b/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json index 351c92ea9..59d7b5747 100644 --- a/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json +++ b/cli/src/services/agent_trace/fixtures/text_file_lifecycle_reconstruction/golden.json @@ -11,9 +11,18 @@ "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 58, "removed": 0 }, - "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 10, "removed": 0 } + "ai": { + "added": 58, + "removed": 0 + }, + "non_exclusive": { + "added": 0, + "removed": 0 + }, + "unknown": { + "added": 10, + "removed": 0 + } } } }, @@ -30,7 +39,7 @@ { "start_line": 1, "end_line": 3, - "content_hash": "murmur3:95af52da" + "content_hash": "murmur3:1431a80e" } ] } @@ -42,13 +51,26 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "mixed" + "type": "ai" }, "ranges": [ { "start_line": 1, + "end_line": 1, + "content_hash": "murmur3:924e4290" + } + ] + }, + { + "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "contributor": { + "type": "unknown" + }, + "ranges": [ + { + "start_line": 2, "end_line": 6, - "content_hash": "murmur3:aa8019fa" + "content_hash": "murmur3:65c468b1" } ] } @@ -66,7 +88,7 @@ { "start_line": 1, "end_line": 4, - "content_hash": "murmur3:ca155945" + "content_hash": "murmur3:e75788f5" } ] } @@ -84,7 +106,7 @@ { "start_line": 1, "end_line": 4, - "content_hash": "murmur3:430e85ad" + "content_hash": "murmur3:3e0f67ac" } ] } @@ -102,7 +124,7 @@ { "start_line": 1, "end_line": 3, - "content_hash": "murmur3:32c8c442" + "content_hash": "murmur3:ef56adb5" } ] } @@ -120,7 +142,7 @@ { "start_line": 1, "end_line": 6, - "content_hash": "murmur3:9a5b4af4" + "content_hash": "murmur3:bc20907c" } ] } @@ -138,7 +160,7 @@ { "start_line": 1, "end_line": 7, - "content_hash": "murmur3:716f18c3" + "content_hash": "murmur3:45f5f4bc" } ] } @@ -156,7 +178,7 @@ { "start_line": 1, "end_line": 4, - "content_hash": "murmur3:2c7f92dd" + "content_hash": "murmur3:9eb0a1fd" } ] } @@ -174,7 +196,7 @@ { "start_line": 1, "end_line": 4, - "content_hash": "murmur3:30658650" + "content_hash": "murmur3:2207bc1f" } ] } @@ -192,7 +214,7 @@ { "start_line": 1, "end_line": 3, - "content_hash": "murmur3:f207dcc0" + "content_hash": "murmur3:2b303264" } ] } @@ -210,7 +232,7 @@ { "start_line": 1, "end_line": 1, - "content_hash": "murmur3:9c42ab4a" + "content_hash": "murmur3:d3f3d3ce" } ] } @@ -228,7 +250,7 @@ { "start_line": 1, "end_line": 1, - "content_hash": "murmur3:072bc2ba" + "content_hash": "murmur3:00515048" } ] } @@ -246,7 +268,7 @@ { "start_line": 1, "end_line": 11, - "content_hash": "murmur3:75d7360b" + "content_hash": "murmur3:cb145f59" } ] } @@ -264,7 +286,7 @@ { "start_line": 1, "end_line": 4, - "content_hash": "murmur3:95d1f1b8" + "content_hash": "murmur3:c87ee8ae" } ] } @@ -276,13 +298,26 @@ { "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { - "type": "mixed" + "type": "ai" }, "ranges": [ { "start_line": 1, + "end_line": 2, + "content_hash": "murmur3:f8ea23ba" + } + ] + }, + { + "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "contributor": { + "type": "unknown" + }, + "ranges": [ + { + "start_line": 3, "end_line": 3, - "content_hash": "murmur3:95088c7a" + "content_hash": "murmur3:811e266d" } ] } @@ -300,7 +335,7 @@ { "start_line": 1, "end_line": 4, - "content_hash": "murmur3:7c448977" + "content_hash": "murmur3:d9aa3f19" } ] } diff --git a/cli/src/services/agent_trace/ranges.rs b/cli/src/services/agent_trace/ranges.rs new file mode 100644 index 000000000..c7a96a13f --- /dev/null +++ b/cli/src/services/agent_trace/ranges.rs @@ -0,0 +1,159 @@ +use std::collections::BTreeMap; + +use super::{Contributor, ContributorType, LineAttribution}; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(super) enum RangeAttribution { + Ai { + session_id: Option, + model_id: Option, + }, + NonExclusive, + Unknown, +} + +impl RangeAttribution { + pub(super) fn from_decision(decision: Option<&LineAttribution>) -> Self { + match decision { + Some(LineAttribution::Ai(line)) => Self::Ai { + session_id: line + .provenance + .session_id + .clone() + .filter(|session_id| !session_id.is_empty()), + model_id: line.provenance.model_id.clone(), + }, + Some(LineAttribution::NonExclusive) => Self::NonExclusive, + Some(LineAttribution::Unknown) | None => Self::Unknown, + } + } + + pub(super) fn contributor(&self) -> Contributor { + match self { + Self::Ai { model_id, .. } => Contributor { + kind: ContributorType::Ai, + model_id: model_id.clone(), + }, + Self::NonExclusive | Self::Unknown => Contributor { + kind: ContributorType::Unknown, + model_id: None, + }, + } + } + + fn conversation_key(&self) -> ConversationKey { + match self { + Self::Ai { + session_id: Some(session_id), + .. + } => ConversationKey::Session(session_id.clone()), + Self::Ai { + session_id: None, + model_id, + } => ConversationKey::Sessionless { + model_id: model_id.clone(), + }, + Self::NonExclusive => ConversationKey::NonExclusive, + Self::Unknown => ConversationKey::Unknown, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct AttributedLine { + pub(super) line_number: u64, + pub(super) attribution: RangeAttribution, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct AttributedRange { + pub(super) start_line: u64, + pub(super) end_line: u64, + pub(super) attribution: RangeAttribution, +} + +pub(super) fn coalesce_ranges(lines: &[AttributedLine]) -> Vec { + let mut ranges: Vec = Vec::new(); + for line in lines { + match ranges.last_mut() { + Some(range) + if range.end_line + 1 == line.line_number + && range.attribution == line.attribution => + { + range.end_line = line.line_number; + } + _ => ranges.push(AttributedRange { + start_line: line.line_number, + end_line: line.line_number, + attribution: line.attribution.clone(), + }), + } + } + ranges +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum ConversationKey { + Session(String), + Sessionless { model_id: Option }, + NonExclusive, + Unknown, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct ConversationGroup { + key: ConversationKey, + pub(super) ranges: Vec, +} + +impl ConversationGroup { + pub(super) fn session_id(&self) -> Option<&str> { + match &self.key { + ConversationKey::Session(session_id) => Some(session_id), + ConversationKey::Sessionless { .. } + | ConversationKey::NonExclusive + | ConversationKey::Unknown => None, + } + } + + fn is_homogeneous(&self) -> bool { + self.ranges + .windows(2) + .all(|pair| pair[0].attribution == pair[1].attribution) + } + + pub(super) fn contributor(&self) -> Option { + let first = self.ranges.first()?; + if self.is_homogeneous() { + return Some(first.attribution.contributor()); + } + let kind = first.attribution.contributor().kind; + self.ranges + .iter() + .all(|range| range.attribution.contributor().kind == kind) + .then_some(Contributor { + kind, + model_id: None, + }) + } + + pub(super) fn range_contributor(&self, range: &AttributedRange) -> Option { + (!self.is_homogeneous()).then(|| range.attribution.contributor()) + } +} + +pub(super) fn group_conversations(ranges: Vec) -> Vec { + let mut groups: BTreeMap> = BTreeMap::new(); + for range in ranges { + groups + .entry(range.attribution.conversation_key()) + .or_default() + .push(range); + } + let mut groups: Vec = groups + .into_iter() + .map(|(key, ranges)| ConversationGroup { key, ranges }) + .collect(); + groups.sort_by_key(|group| group.ranges.first().map(|range| range.start_line)); + groups +} diff --git a/cli/src/services/agent_trace/tests.rs b/cli/src/services/agent_trace/tests.rs index 40400b8a5..405fe81b5 100644 --- a/cli/src/services/agent_trace/tests.rs +++ b/cli/src/services/agent_trace/tests.rs @@ -8,12 +8,13 @@ use serde_json::Value; use super::{ build_agent_trace_from_evidence, line_change_attribution, resolve_line, resolve_patch_lines, validate_agent_trace_value, AcceptedDirectLine, AgentTraceEvidence, AgentTraceMetadataInput, - AgentTraceVcsType, DirectEvidenceSource, DirectLineEvidence, HunkContributor, LineAttribution, - LineChangeAttribution, LineChangeCounts, MutationEvidenceIndex, MutationLineEvidence, - RepositoryRoot, ResolvedLine, AGENT_TRACE_VERSION, + AgentTraceVcsType, CommittedFile, CommittedFileReader, ContributorType, DirectEvidenceSource, + DirectLineEvidence, LineAttribution, LineChangeAttribution, LineChangeCounts, + MutationEvidenceIndex, MutationLineEvidence, NoCommittedFiles, RepositoryRoot, ResolvedLine, + AGENT_TRACE_VERSION, }; use crate::services::{ - agent_trace::agent_trace_conversation_url, + agent_trace::{agent_trace_conversation_url, agent_trace_session_url}, agent_trace_db::DiffTracePathOrigin, mutation_trace::attribution::{PatchLineLocation, PatchLineProvenance}, patch::{parse_patch, ParsedPatch, TouchedLineKind}, @@ -30,6 +31,137 @@ struct AgentTraceScenario { golden: &'static str, } +#[derive(Clone, Debug, Default)] +struct PatchNewSideFiles(BTreeMap>); + +impl PatchNewSideFiles { + fn from_patch(text: &str) -> Self { + let mut files: BTreeMap>> = BTreeMap::new(); + let mut path: Option = None; + let (mut new_line, mut old_left, mut new_left) = (0_u64, 0_u64, 0_u64); + let mut last_new_line: Option = None; + for raw in text.split_inclusive('\n') { + let terminated; + let raw = if raw.ends_with('\n') { + raw + } else { + terminated = format!("{raw}\n"); + terminated.as_str() + }; + if old_left > 0 || new_left > 0 { + let (marker, body) = if raw == "\n" { + (' ', "\n") + } else { + (raw.chars().next().expect("hunk body line"), &raw[1..]) + }; + let file = files.entry(path.clone().unwrap_or_default()).or_default(); + match marker { + '+' => { + file.insert(new_line, body.as_bytes().to_vec()); + last_new_line = Some(new_line); + new_line += 1; + new_left -= 1; + } + '-' => { + last_new_line = None; + old_left -= 1; + } + ' ' => { + file.insert(new_line, body.as_bytes().to_vec()); + last_new_line = Some(new_line); + new_line += 1; + old_left -= 1; + new_left -= 1; + } + '\\' => { + Self::strip_final_newline(&mut files, path.as_deref(), last_new_line); + } + other => panic!("unexpected hunk body marker {other:?}"), + } + continue; + } + if raw.starts_with("\\ ") { + Self::strip_final_newline(&mut files, path.as_deref(), last_new_line); + continue; + } + last_new_line = None; + let line = raw.trim_end_matches('\n'); + if let Some(rest) = line.strip_prefix("diff --git ") { + path = rest.split_once(" b/").map(|(_, new)| new.to_owned()); + } else if let Some(rest) = line.strip_prefix("+++ ") { + path = rest.strip_prefix("b/").map(ToOwned::to_owned); + } else if let Some(rest) = line.strip_prefix("@@ -") { + let (old, rest) = rest.split_once(" +").expect("hunk new range"); + let new = rest.split_once(" @@").expect("hunk header end").0; + let count = |range: &str| { + range + .split_once(',') + .map_or(Ok(1), |(_, count)| count.parse()) + .expect("hunk count") + }; + old_left = count(old); + new_left = count(new); + new_line = new + .split(',') + .next() + .and_then(|start| start.parse().ok()) + .expect("hunk new start"); + } + } + + Self( + files + .into_iter() + .filter(|(path, _)| !path.is_empty()) + .map(|(path, lines)| { + let last = lines.keys().copied().max().unwrap_or(0); + let bytes = (1..=last) + .flat_map(|line_number| { + lines.get(&line_number).cloned().unwrap_or_else(|| { + format!("\n").into_bytes() + }) + }) + .collect(); + (path, bytes) + }) + .collect(), + ) + } + + fn strip_final_newline( + files: &mut BTreeMap>>, + path: Option<&str>, + line_number: Option, + ) { + if let (Some(path), Some(line_number)) = (path, line_number) { + if let Some(line) = files + .get_mut(path) + .and_then(|lines| lines.get_mut(&line_number)) + { + assert_eq!( + line.pop(), + Some(b'\n'), + "missing newline marker after {line_number}" + ); + } + } + } + + fn bytes(&self, path: &str) -> &[u8] { + self.0.get(path).expect("committed file") + } +} + +impl CommittedFileReader for PatchNewSideFiles { + fn read_committed_file(&self, path: &str) -> CommittedFile { + self.0 + .get(path) + .map_or(CommittedFile::Unavailable, |bytes| { + CommittedFile::Blob(bytes.clone()) + }) + } +} + fn parse_fixtures(fixtures: &[&str]) -> Vec { fixtures .iter() @@ -90,6 +222,7 @@ fn assert_builds_expected_agent_trace(scenario: AgentTraceScenario) { commit_revision: TEST_COMMIT_REVISION, vcs_type: Some(AgentTraceVcsType::Git), }, + &PatchNewSideFiles::from_patch(scenario.post_commit), ) .expect("agent trace should build"); assert_eq!(actual.version, AGENT_TRACE_VERSION); @@ -279,6 +412,7 @@ fn assert_builds_expected_agent_trace_from_evidence(scenario: EvidenceScenario) commit_revision: TEST_COMMIT_REVISION, vcs_type: Some(AgentTraceVcsType::Git), }, + &PatchNewSideFiles::from_patch(scenario.post_commit), ) .expect("agent trace should build"); @@ -484,6 +618,22 @@ fn build_trace( sources: &[ToolSource], mutation_ai_patch: &ParsedPatch, mutation_non_ai_patch: &ParsedPatch, +) -> super::AgentTrace { + build_trace_with_content( + committed, + sources, + mutation_ai_patch, + mutation_non_ai_patch, + &NoCommittedFiles, + ) +} + +fn build_trace_with_content( + committed: &ParsedPatch, + sources: &[ToolSource], + mutation_ai_patch: &ParsedPatch, + mutation_non_ai_patch: &ParsedPatch, + committed_files: &dyn CommittedFileReader, ) -> super::AgentTrace { let direct_sources = direct_sources(sources); let trace = build_agent_trace_from_evidence( @@ -499,6 +649,7 @@ fn build_trace( commit_revision: TEST_COMMIT_REVISION, vcs_type: Some(AgentTraceVcsType::Git), }, + committed_files, ) .expect("agent trace should build"); let json = serde_json::to_value(&trace).expect("agent trace should serialize"); @@ -517,15 +668,68 @@ fn tool_of(trace: &super::AgentTrace) -> Option<(Option<&str>, Option<&str>)> { .map(|tool| (tool.name.as_deref(), tool.version.as_deref())) } -fn contributor_kinds(trace: &super::AgentTrace) -> Vec { +fn range_kinds(trace: &super::AgentTrace) -> Vec { trace .files .iter() - .flat_map(|file| file.conversations.iter()) - .map(|conversation| conversation.contributor.kind) + .flat_map(|file| { + let mut ranges: Vec<_> = file + .conversations + .iter() + .flat_map(|conversation| { + conversation.ranges.iter().map(move |range| { + let contributor = range + .contributor + .as_ref() + .or(conversation.contributor.as_ref()) + .expect("every range has an effective contributor"); + (range.start_line, contributor.kind) + }) + }) + .collect(); + ranges.sort_by_key(|(start_line, _)| *start_line); + ranges.into_iter().map(|(_, kind)| kind) + }) + .collect() +} + +fn range_positions(trace: &super::AgentTrace) -> Vec<(u64, u64, ContributorType)> { + let mut positions = Vec::new(); + for file in &trace.files { + let mut ranges: Vec<_> = file + .conversations + .iter() + .flat_map(|conversation| { + conversation.ranges.iter().map(move |range| { + let contributor = range + .contributor + .as_ref() + .or(conversation.contributor.as_ref()) + .expect("every range has an effective contributor"); + (range.start_line, range.end_line, contributor.kind) + }) + }) + .collect(); + ranges.sort_by_key(|(start_line, _, _)| *start_line); + positions.extend(ranges); + } + positions +} + +fn related_urls(conversation: &super::Conversation) -> Vec { + conversation + .related + .iter() + .flatten() + .map(|related| related.url.clone()) .collect() } +fn expected_hash(bytes: &[u8]) -> String { + let hash = murmur3::murmur3_32(&mut std::io::Cursor::new(bytes), 0).expect("murmur3"); + format!("murmur3:{hash:08x}") +} + fn accepted_line_count(sources: &[ToolSource], target: &ParsedPatch) -> usize { super::direct_evidence_patch(&direct_sources(sources), target, &test_root()) .files @@ -563,11 +767,8 @@ fn two_sessions_claiming_one_line_yield_no_accepted_direct_evidence() { let trace = build_with_sources(&sources, &no_mutation()); assert_eq!( - contributor_kinds(&trace), - vec![ - super::HunkContributor::Unknown, - super::HunkContributor::Unknown - ] + range_kinds(&trace), + vec![ContributorType::Unknown, ContributorType::Unknown] ); assert_eq!(tool_of(&trace), None); } @@ -598,8 +799,23 @@ fn several_rows_from_one_session_are_accepted_as_that_session() { )); let trace = build_with_sources(&sources, &no_mutation()); let conversation = &trace.files[0].conversations[0]; - assert_eq!(conversation.contributor.kind, super::HunkContributor::Ai); - assert_eq!(conversation.contributor.model_id.as_deref(), Some(MODEL_A)); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .kind, + ContributorType::Ai + ); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .model_id + .as_deref(), + Some(MODEL_A) + ); assert_eq!(tool_of(&trace), Some((Some("claude"), None))); } @@ -632,8 +848,8 @@ fn a_conflicted_line_does_not_hide_or_join_a_valid_line() { assert_eq!(accepted_line_count(&sources, &committed_patch()), 1); let trace = build_with_sources(&sources, &no_mutation()); assert_eq!( - contributor_kinds(&trace), - vec![super::HunkContributor::Unknown, super::HunkContributor::Ai] + range_kinds(&trace), + vec![ContributorType::Unknown, ContributorType::Ai] ); assert_eq!(tool_of(&trace), Some((Some("claude"), None))); } @@ -655,11 +871,8 @@ fn identical_lines_in_a_different_file_with_the_same_suffix_do_not_attribute() { )); let trace = build_with_sources(&sources, &no_mutation()); assert_eq!( - contributor_kinds(&trace), - vec![ - super::HunkContributor::Unknown, - super::HunkContributor::Unknown - ] + range_kinds(&trace), + vec![ContributorType::Unknown, ContributorType::Unknown] ); assert_eq!(tool_of(&trace), None); } @@ -687,8 +900,8 @@ fn absolute_evidence_paths_attribute_only_inside_the_repository_root() { let trace = build_with_sources(&sources, &no_mutation()); assert_eq!( - contributor_kinds(&trace), - vec![super::HunkContributor::Ai, super::HunkContributor::Unknown] + range_kinds(&trace), + vec![ContributorType::Ai, ContributorType::Unknown] ); assert_eq!(tool_of(&trace), Some((Some("claude"), None))); } @@ -767,8 +980,8 @@ fn accepted_sources_from_one_tool_keep_that_tool() { let trace = build_with_sources(&sources, &no_mutation()); assert_eq!( - contributor_kinds(&trace), - vec![super::HunkContributor::Ai, super::HunkContributor::Ai] + range_kinds(&trace), + vec![ContributorType::Ai, ContributorType::Ai] ); assert_eq!(tool_of(&trace), Some((Some("claude"), Some("1.0")))); } @@ -794,8 +1007,8 @@ fn accepted_sources_from_different_tools_omit_the_tool() { let trace = build_with_sources(&sources, &no_mutation()); assert_eq!( - contributor_kinds(&trace), - vec![super::HunkContributor::Ai, super::HunkContributor::Ai] + range_kinds(&trace), + vec![ContributorType::Ai, ContributorType::Ai] ); assert_eq!(tool_of(&trace), None); } @@ -853,12 +1066,14 @@ fn direct_matches_fully_overridden_by_lineage_emit_no_tool() { let trace = build_with_sources(&sources, &mutation_ai); assert_eq!( - contributor_kinds(&trace), - vec![super::HunkContributor::Ai, super::HunkContributor::Ai] + range_kinds(&trace), + vec![ContributorType::Ai, ContributorType::Ai] ); assert_eq!( trace.files[0].conversations[0] .contributor + .as_ref() + .expect("conversation contributor") .model_id .as_deref(), Some(MODEL_C) @@ -872,8 +1087,8 @@ fn mutation_only_attribution_emits_no_tool() { let trace = build_with_sources(&[], &mutation_ai); assert_eq!( - contributor_kinds(&trace), - vec![super::HunkContributor::Ai, super::HunkContributor::Ai] + range_kinds(&trace), + vec![ContributorType::Ai, ContributorType::Ai] ); assert_eq!(tool_of(&trace), None); } @@ -900,8 +1115,8 @@ fn a_session_conflicted_source_cannot_supply_the_tool() { let trace = build_with_sources(&sources, &mutation_ai); assert_eq!( - contributor_kinds(&trace), - vec![super::HunkContributor::Unknown, super::HunkContributor::Ai] + range_kinds(&trace), + vec![ContributorType::Unknown, ContributorType::Ai] ); assert_eq!(tool_of(&trace), None); } @@ -936,8 +1151,8 @@ fn claude_relative_paths_without_an_absolute_cwd_cannot_attribute_or_supply_the_ )); let trace = build_with_sources(&sources, &no_mutation()); assert_eq!( - contributor_kinds(&trace), - vec![super::HunkContributor::Unknown, super::HunkContributor::Ai], + range_kinds(&trace), + vec![ContributorType::Unknown, ContributorType::Ai], "{path_origin:?}" ); assert_eq!(tool_of(&trace), Some((Some("opencode"), Some("0.9")))); @@ -961,8 +1176,8 @@ fn claude_absolute_paths_attribute_without_a_cwd_only_inside_the_repository() { let trace = build_with_sources(&sources, &no_mutation()); assert_eq!( - contributor_kinds(&trace), - vec![super::HunkContributor::Ai, super::HunkContributor::Unknown] + range_kinds(&trace), + vec![ContributorType::Ai, ContributorType::Unknown] ); assert_eq!(tool_of(&trace), Some((Some("claude"), None))); } @@ -1015,12 +1230,14 @@ fn mutation_lineage_still_attributes_when_claude_direct_evidence_lacks_a_cwd() { let trace = build_with_sources(&sources, &mutation_ai); assert_eq!( - contributor_kinds(&trace), - vec![super::HunkContributor::Ai, super::HunkContributor::Unknown] + range_kinds(&trace), + vec![ContributorType::Ai, ContributorType::Unknown] ); assert_eq!( trace.files[0].conversations[0] .contributor + .as_ref() + .expect("conversation contributor") .model_id .as_deref(), Some(MODEL_C) @@ -1138,23 +1355,54 @@ fn mixed_committed() -> ParsedPatch { #[test] fn mixed_hunk_counts_each_touched_line_by_its_own_decision() { let committed = mixed_committed(); - let trace = build_trace( + let trace = build_trace_with_content( &committed, &[line_source(MIXED_PATH, MIXED_DIRECT, SESSION_A, MODEL_A)], &mutation_ai_lines(MIXED_PATH, MIXED_LINEAGE, SESSION_A, Some(MODEL_A)), &mutation_non_ai_lines(MIXED_PATH, MIXED_CONTENDED), + &PatchNewSideFiles::from_patch(MIXED_HUNK), ); assert_eq!(buckets(&trace), ((2, 1), (1, 0), (1, 1))); assert_counters_cover(&trace, &committed); - assert_eq!(contributor_kinds(&trace), vec![HunkContributor::Mixed]); + let url = agent_trace_conversation_url(&trace.id); assert_eq!( - trace.files[0].conversations[0] - .contributor - .model_id - .as_deref(), - Some(MODEL_A) + serde_json::to_value(&trace.files).expect("files serialize"), + serde_json::json!([{ + "path": MIXED_PATH, + "conversations": [ + { + "url": url, + "contributor": { "type": "ai", "model_id": MODEL_A }, + "ranges": [{ + "start_line": 2, + "end_line": 3, + "content_hash": expected_hash(b"new direct\nnew lineage\n"), + }], + "related": [{ "type": "session", "url": agent_trace_session_url(SESSION_A) }], + }, + { + "url": url, + "contributor": { "type": "unknown" }, + "ranges": [{ + "start_line": 4, + "end_line": 4, + "content_hash": expected_hash(b"new contended\n"), + }], + }, + { + "url": url, + "contributor": { "type": "unknown" }, + "ranges": [{ + "start_line": 5, + "end_line": 5, + "content_hash": expected_hash(b"new manual\n"), + }], + }, + ], + }]) ); + assert!(!range_kinds(&trace).contains(&ContributorType::Mixed)); assert_eq!(tool_of(&trace), Some((Some("claude"), Some("1.0")))); } @@ -1205,7 +1453,7 @@ fn entirely_ai_hunk_counts_every_line_as_ai() { assert_eq!(buckets(&trace), ((4, 2), (0, 0), (0, 0))); assert_counters_cover(&trace, &committed); - assert_eq!(contributor_kinds(&trace), vec![HunkContributor::Ai]); + assert_eq!(range_kinds(&trace), vec![ContributorType::Ai]); } #[test] @@ -1215,7 +1463,7 @@ fn entirely_unknown_hunk_counts_every_line_as_unknown() { assert_eq!(buckets(&trace), ((0, 0), (0, 0), (4, 2))); assert_counters_cover(&trace, &committed); - assert_eq!(contributor_kinds(&trace), vec![HunkContributor::Unknown]); + assert_eq!(range_kinds(&trace), vec![ContributorType::Unknown]); } #[test] @@ -1232,11 +1480,18 @@ fn non_exclusive_mutation_overrides_direct_ai_evidence_in_counters_and_provenanc assert_eq!(buckets(&trace), ((0, 0), (1, 0), (1, 0))); assert_counters_cover(&trace, &committed); assert_eq!( - contributor_kinds(&trace), - vec![HunkContributor::Unknown, HunkContributor::Unknown] + range_kinds(&trace), + vec![ContributorType::Unknown, ContributorType::Unknown] ); let conversation = &trace.files[0].conversations[0]; - assert_eq!(conversation.contributor.model_id, None); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .model_id, + None + ); assert_eq!(conversation.related, None); assert_eq!(tool_of(&trace), None); let json = serde_json::to_string(&trace).expect("trace serializes"); @@ -1273,8 +1528,22 @@ fn mutation_only_attribution_without_a_model_counts_as_ai() { assert_eq!(buckets(&trace), ((1, 0), (0, 0), (1, 0))); let conversation = &trace.files[0].conversations[0]; - assert_eq!(conversation.contributor.kind, HunkContributor::Ai); - assert_eq!(conversation.contributor.model_id, None); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .kind, + ContributorType::Ai + ); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .model_id, + None + ); } #[test] @@ -1293,7 +1562,7 @@ fn a_removed_line_without_direct_evidence_counts_as_unknown_regardless_of_its_au ); assert_eq!(buckets(&trace), ((0, 0), (0, 0), (0, 1))); - assert_eq!(contributor_kinds(&trace), vec![HunkContributor::Unknown]); + assert!(trace.files.is_empty()); assert_eq!(tool_of(&trace), None); } @@ -1319,8 +1588,26 @@ fn duplicate_identical_lines_are_each_counted_once_and_only_supported_ones_are_a assert_eq!(buckets(&trace), ((2, 0), (0, 0), (2, 0))); assert_counters_cover(&trace, &committed); assert_eq!( - contributor_kinds(&trace), - vec![HunkContributor::Mixed, HunkContributor::Ai] + range_positions(&trace), + vec![ + (2, 2, ContributorType::Unknown), + (3, 3, ContributorType::Ai), + (4, 4, ContributorType::Unknown), + (12, 12, ContributorType::Ai) + ] + ); + let sessions: Vec<_> = trace.files[0] + .conversations + .iter() + .map(related_urls) + .collect(); + assert_eq!( + sessions, + vec![ + vec![], + vec![agent_trace_session_url(SESSION_A)], + vec![agent_trace_session_url(SESSION_C)] + ] ); } @@ -1349,13 +1636,27 @@ fn counters_sum_every_touched_line_across_multiple_hunks_and_files() { assert_eq!(buckets(&trace), ((2, 1), (1, 0), (1, 2))); assert_counters_cover(&trace, &committed); assert_eq!( - contributor_kinds(&trace), + range_kinds(&trace), vec![ - HunkContributor::Mixed, - HunkContributor::Ai, - HunkContributor::Mixed + ContributorType::Ai, + ContributorType::Unknown, + ContributorType::Ai, + ContributorType::Unknown ] ); + let session_a = &trace.files[0].conversations[0]; + assert_eq!( + session_a + .ranges + .iter() + .map(|range| (range.start_line, range.end_line)) + .collect::>(), + vec![(1, 1), (12, 12)] + ); + assert_eq!( + related_urls(session_a), + vec![agent_trace_session_url(SESSION_A)] + ); } #[test] @@ -1381,9 +1682,8 @@ fn a_deleted_patch_file_counts_only_its_literal_deleted_lines() { &no_mutation(), ); - assert_eq!(trace.files.len(), 1); - assert_eq!(trace.files[0].path, SOURCE_PATH); - assert_eq!(contributor_kinds(&trace), vec![HunkContributor::Ai]); + assert!(trace.files.is_empty()); + assert_eq!(tool_of(&trace), None); assert_eq!( buckets(&trace), ((0, 0), (0, 0), (0, embedded_lines as u64)) @@ -1624,3 +1924,784 @@ fn randomized_counters_are_exact_and_independent_of_grouping_and_order() { } } } + +const EXAMPLE_PATH: &str = "src/example.ts"; +const EXAMPLE_PATCH: &str = "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -10,2 +10,4 @@\n-old\n+let a = 1;\n+let b = 2;\n+let c = 3;\n unchanged\n"; + +#[test] +fn a_hunk_with_ai_and_unresolved_additions_emits_homogeneous_conversations_without_context() { + let committed = parse_patch(EXAMPLE_PATCH, None).expect("example parses"); + let trace = build_trace_with_content( + &committed, + &[line_source( + EXAMPLE_PATH, + &[(ADDED, 10, "let a = 1;"), (ADDED, 11, "let b = 2;")], + SESSION_A, + MODEL_A, + )], + &no_mutation(), + &no_mutation(), + &PatchNewSideFiles::from_patch(EXAMPLE_PATCH), + ); + + let url = agent_trace_conversation_url(&trace.id); + assert_eq!( + serde_json::to_value(&trace.files).expect("files serialize"), + serde_json::json!([{ + "path": EXAMPLE_PATH, + "conversations": [ + { + "url": url, + "contributor": { "type": "ai", "model_id": MODEL_A }, + "ranges": [{ + "start_line": 10, + "end_line": 11, + "content_hash": expected_hash(b"let a = 1;\nlet b = 2;\n"), + }], + "related": [{ "type": "session", "url": agent_trace_session_url(SESSION_A) }], + }, + { + "url": url, + "contributor": { "type": "unknown" }, + "ranges": [{ + "start_line": 12, + "end_line": 12, + "content_hash": expected_hash(b"let c = 3;\n"), + }], + }, + ], + }]) + ); + assert_eq!(buckets(&trace), ((2, 0), (0, 0), (1, 1))); +} + +fn unresolved_trace(patch: &str) -> super::AgentTrace { + build_trace_with_content( + &parse_patch(patch, None).expect("patch parses"), + &[], + &no_mutation(), + &no_mutation(), + &PatchNewSideFiles::from_patch(patch), + ) +} + +#[test] +fn additions_separated_by_context_produce_separate_ranges_in_one_conversation() { + let trace = unresolved_trace( + "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -4,2 +4,5 @@\n+one\n keep\n+two\n+three\n keep too\n", + ); + assert_eq!(trace.files[0].conversations.len(), 1); + assert_eq!( + range_positions(&trace), + vec![ + (4, 4, ContributorType::Unknown), + (6, 7, ContributorType::Unknown) + ] + ); +} + +#[test] +fn new_file_ranges_start_at_line_one() { + let trace = unresolved_trace( + "diff --git a/new.txt b/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1,2 @@\n+first\n+second\n", + ); + assert_eq!(trace.files[0].path, "new.txt"); + assert_eq!( + range_positions(&trace), + vec![(1, 2, ContributorType::Unknown)] + ); +} + +#[test] +fn deleted_files_and_pure_renames_emit_no_ranges_but_keep_counters() { + let patch = "diff --git a/gone.txt b/gone.txt\ndeleted file mode 100644\n--- a/gone.txt\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-a\n-b\ndiff --git a/old.rs b/new.rs\nsimilarity index 100%\nrename from old.rs\nrename to new.rs\n"; + let trace = unresolved_trace(patch); + assert!(trace.files.is_empty()); + assert_eq!(buckets(&trace), ((0, 0), (0, 0), (0, 2))); +} + +#[test] +fn renamed_files_with_additions_use_the_new_path_and_positions() { + let trace = unresolved_trace( + "diff --git a/old.rs b/new.rs\nsimilarity index 80%\nrename from old.rs\nrename to new.rs\n--- a/old.rs\n+++ b/new.rs\n@@ -2,1 +2,2 @@\n keep\n+added\n", + ); + assert_eq!(trace.files.len(), 1); + assert_eq!(trace.files[0].path, "new.rs"); + assert_eq!( + range_positions(&trace), + vec![(3, 3, ContributorType::Unknown)] + ); +} + +#[test] +fn adjacent_lines_from_different_sessions_become_separate_conversations() { + let patch = + "diff --git a/s.rs b/s.rs\n--- a/s.rs\n+++ b/s.rs\n@@ -0,0 +1,2 @@\n+from a\n+from b\n"; + let trace = build_trace_with_content( + &parse_patch(patch, None).expect("patch parses"), + &[ + line_source("s.rs", &[(ADDED, 1, "from a")], SESSION_A, MODEL_A), + line_source("s.rs", &[(ADDED, 2, "from b")], SESSION_B, MODEL_A), + ], + &no_mutation(), + &no_mutation(), + &PatchNewSideFiles::from_patch(patch), + ); + let conversations = &trace.files[0].conversations; + assert_eq!(conversations.len(), 2); + assert_eq!( + related_urls(&conversations[0]), + vec![agent_trace_session_url(SESSION_A)] + ); + assert_eq!( + related_urls(&conversations[1]), + vec![agent_trace_session_url(SESSION_B)] + ); + assert_eq!( + ( + conversations[0].ranges[0].start_line, + conversations[1].ranges[0].start_line + ), + (1, 2) + ); +} + +#[test] +fn sessionless_lines_with_different_models_never_share_a_model() { + let patch = + "diff --git a/m.rs b/m.rs\n--- a/m.rs\n+++ b/m.rs\n@@ -0,0 +1,2 @@\n+model a\n+model b\n"; + let mut mutation_ai = mutation_ai_lines("m.rs", &[(ADDED, 1, "model a")], "", Some(MODEL_A)); + mutation_ai + .files + .extend(mutation_ai_lines("m.rs", &[(ADDED, 2, "model b")], "", Some(MODEL_B)).files); + let trace = build_trace_with_content( + &parse_patch(patch, None).expect("patch parses"), + &[], + &mutation_ai, + &no_mutation(), + &PatchNewSideFiles::from_patch(patch), + ); + let models: Vec<_> = trace.files[0] + .conversations + .iter() + .map(|conversation| { + assert_eq!(conversation.related, None); + conversation + .contributor + .as_ref() + .and_then(|contributor| contributor.model_id.clone()) + }) + .collect(); + assert_eq!( + models, + vec![Some(MODEL_A.to_owned()), Some(MODEL_B.to_owned())] + ); +} + +#[test] +fn one_session_with_several_models_keeps_one_conversation_with_range_overrides() { + let patch = "diff --git a/h.rs b/h.rs\n--- a/h.rs\n+++ b/h.rs\n@@ -0,0 +1,3 @@\n+first\n+second\n+third\n"; + let mut mutation_ai = mutation_ai_lines( + "h.rs", + &[(ADDED, 1, "first"), (ADDED, 3, "third")], + SESSION_A, + Some(MODEL_A), + ); + mutation_ai + .files + .extend(mutation_ai_lines("h.rs", &[(ADDED, 2, "second")], SESSION_A, Some(MODEL_B)).files); + let trace = build_trace_with_content( + &parse_patch(patch, None).expect("patch parses"), + &[], + &mutation_ai, + &no_mutation(), + &PatchNewSideFiles::from_patch(patch), + ); + + let url = agent_trace_conversation_url(&trace.id); + let range = |line: u64, bytes: &[u8], model: &str| { + serde_json::json!({ + "start_line": line, + "end_line": line, + "content_hash": expected_hash(bytes), + "contributor": { "type": "ai", "model_id": model }, + }) + }; + assert_eq!( + serde_json::to_value(&trace.files[0].conversations).expect("serialize"), + serde_json::json!([{ + "url": url, + "contributor": { "type": "ai" }, + "ranges": [ + range(1, b"first\n", MODEL_A), + range(2, b"second\n", MODEL_B), + range(3, b"third\n", MODEL_A), + ], + "related": [{ "type": "session", "url": agent_trace_session_url(SESSION_A) }], + }]) + ); +} + +#[test] +fn unknown_and_non_exclusive_lines_never_share_a_range() { + use super::ranges::{coalesce_ranges, AttributedLine, RangeAttribution}; + + let line = |line_number, attribution| AttributedLine { + line_number, + attribution, + }; + let ranges = coalesce_ranges(&[ + line(1, RangeAttribution::Unknown), + line(2, RangeAttribution::Unknown), + line(3, RangeAttribution::NonExclusive), + line(4, RangeAttribution::NonExclusive), + line(6, RangeAttribution::NonExclusive), + ]); + let spans: Vec<_> = ranges + .iter() + .map(|range| (range.start_line, range.end_line, range.attribution.clone())) + .collect(); + assert_eq!( + spans, + vec![ + (1, 2, RangeAttribution::Unknown), + (3, 4, RangeAttribution::NonExclusive), + (6, 6, RangeAttribution::NonExclusive), + ] + ); +} + +#[test] +fn legacy_records_with_mixed_contributors_and_hunk_hashes_still_deserialize() { + let legacy = serde_json::json!({ + "version": "0.1.0", + "id": "01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "timestamp": "2026-04-23T10:20:30Z", + "files": [{ + "path": "src/lib.rs", + "conversations": [{ + "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "contributor": { "type": "mixed", "model_id": MODEL_A }, + "ranges": [{ "start_line": 1, "end_line": 4, "content_hash": "murmur3:0badf00d" }], + }, { + "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "ranges": [{ "start_line": 7, "end_line": 7, "contributor": { "type": "human" } }], + }], + }], + }); + validate_agent_trace_value(&legacy).expect("legacy record validates"); + let trace: super::AgentTrace = serde_json::from_value(legacy).expect("legacy deserializes"); + let conversations = &trace.files[0].conversations; + assert_eq!( + conversations[0].contributor.as_ref().map(|c| c.kind), + Some(ContributorType::Mixed) + ); + assert_eq!( + conversations[0].ranges[0].content_hash.as_deref(), + Some("murmur3:0badf00d") + ); + assert_eq!(conversations[1].contributor, None); + assert_eq!( + conversations[1].ranges[0] + .contributor + .as_ref() + .map(|c| c.kind), + Some(ContributorType::Human) + ); + assert_eq!(conversations[1].ranges[0].content_hash, None); +} + +#[test] +fn content_hash_matches_known_murmur3_vectors() { + use super::committed_content::content_hash; + + assert_eq!(content_hash(b""), "murmur3:00000000"); + assert_eq!(content_hash(b"hello"), "murmur3:248bfa47"); + assert_eq!( + content_hash(b"The quick brown fox jumps over the lazy dog"), + "murmur3:2e4ff723" + ); +} + +#[test] +fn committed_lines_keep_exact_terminators_and_unicode() { + use super::committed_content::CommittedLines; + + let bytes = "lf\r\ncrlf\r\n\n \nünïcødé ✓\nno final newline".as_bytes(); + let lines = CommittedLines::new(bytes); + assert_eq!(lines.line_count(), 6); + assert_eq!(lines.span(1, 1), Some(&b"lf\r\n"[..])); + assert_eq!(lines.span(2, 3), Some(&b"crlf\r\n\n"[..])); + assert_eq!(lines.span(4, 4), Some(&b" \n"[..])); + assert_eq!(lines.span(5, 5), Some("ünïcødé ✓\n".as_bytes())); + assert_eq!(lines.span(6, 6), Some(&b"no final newline"[..])); + assert_eq!(lines.span(1, 6), Some(bytes)); + assert_eq!(lines.span(0, 1), None); + assert_eq!(lines.span(3, 2), None); + assert_eq!(lines.span(6, 7), None); + assert!(lines.line_matches(2, "crlf")); + assert!(lines.line_matches(3, "")); + assert!(lines.line_matches(6, "no final newline")); + assert!(!lines.line_matches(4, "")); + assert_eq!(CommittedLines::new(b"").line_count(), 0); + assert_eq!(CommittedLines::new(b"\n").line_count(), 1); +} + +#[test] +fn identical_committed_bytes_hash_identically_across_paths_positions_and_hunks() { + let patch = "diff --git a/one.rs b/one.rs\n--- a/one.rs\n+++ b/one.rs\n@@ -0,0 +1,2 @@\n+same\n+bytes\ndiff --git a/two.rs b/two.rs\n--- a/two.rs\n+++ b/two.rs\n@@ -10,0 +11,1 @@\n+same\n@@ -20,0 +22,1 @@\n+bytes\n@@ -30,0 +33,2 @@\n+same\n+bytes\n"; + let trace = unresolved_trace(patch); + let hashes: Vec)>> = trace + .files + .iter() + .map(|file| { + file.conversations[0] + .ranges + .iter() + .map(|range| (range.start_line, range.content_hash.clone())) + .collect() + }) + .collect(); + let pair = Some(expected_hash(b"same\nbytes\n")); + assert_eq!( + hashes, + vec![ + vec![(1, pair.clone())], + vec![ + (11, Some(expected_hash(b"same\n"))), + (22, Some(expected_hash(b"bytes\n"))), + (33, pair), + ], + ] + ); +} + +#[test] +fn crlf_and_missing_final_newline_are_hashed_byte_exactly() { + let patch = "diff --git a/w.txt b/w.txt\nnew file mode 100644\n--- /dev/null\n+++ b/w.txt\n@@ -0,0 +1,3 @@\n+crlf\r\n+ \r\n+tail ✓\n\\ No newline at end of file\n"; + let trace = unresolved_trace(patch); + assert_eq!( + trace.files[0].conversations[0].ranges[0].content_hash, + Some(expected_hash("crlf\r\n \r\ntail ✓".as_bytes())) + ); + assert_ne!( + trace.files[0].conversations[0].ranges[0].content_hash, + Some(expected_hash("crlf\n \ntail ✓\n".as_bytes())) + ); +} + +#[test] +fn unavailable_committed_content_keeps_positions_without_a_hash() { + let committed = parse_patch(EXAMPLE_PATCH, None).expect("example parses"); + let trace = build_trace(&committed, &[], &no_mutation(), &no_mutation()); + let range = &trace.files[0].conversations[0].ranges[0]; + assert_eq!((range.start_line, range.end_line), (10, 12)); + assert_eq!(range.content_hash, None); +} + +struct FixedCommittedFile(CommittedFile); + +impl CommittedFileReader for FixedCommittedFile { + fn read_committed_file(&self, _path: &str) -> CommittedFile { + self.0.clone() + } +} + +#[test] +fn non_line_addressable_paths_emit_no_ranges() { + let committed = parse_patch(EXAMPLE_PATCH, None).expect("example parses"); + let trace = build_trace_with_content( + &committed, + &[], + &no_mutation(), + &no_mutation(), + &FixedCommittedFile(CommittedFile::NotLineAddressable), + ); + assert!(trace.files.is_empty()); + assert_eq!(buckets(&trace), ((0, 0), (0, 0), (3, 1))); +} + +#[test] +fn positions_that_disagree_with_committed_content_are_rejected() { + let committed = parse_patch(EXAMPLE_PATCH, None).expect("example parses"); + let mut short = PatchNewSideFiles::from_patch(EXAMPLE_PATCH); + short + .0 + .insert(EXAMPLE_PATH.to_owned(), b"only one line\n".to_vec()); + let mut different = PatchNewSideFiles::from_patch(EXAMPLE_PATCH); + let mut bytes = different.bytes(EXAMPLE_PATH).to_vec(); + let edited = bytes.len() - "unchanged\n".len() - 2; + bytes[edited] = b'9'; + different.0.insert(EXAMPLE_PATH.to_owned(), bytes); + + for reader in [short, different] { + let error = build_agent_trace_from_evidence( + AgentTraceEvidence { + repository_root: &test_root(), + direct_sources: &[], + mutation_ai_patch: &no_mutation(), + mutation_non_ai_patch: &no_mutation(), + }, + &committed, + AgentTraceMetadataInput { + commit_timestamp: TEST_COMMIT_TIMESTAMP, + commit_revision: TEST_COMMIT_REVISION, + vcs_type: Some(AgentTraceVcsType::Git), + }, + &reader, + ) + .expect_err("inconsistent positions must not be accepted"); + assert!( + error + .to_string() + .starts_with("Invalid Agent Trace range position for 'src/example.ts'"), + "{error}" + ); + } +} + +const RANDOM_LINE_CONTENTS: &[&str] = &["", " ", "dup", "x = 1;", "ünïcødé ✓", "\tfn f() {}"]; + +#[derive(Clone, Debug)] +struct RandomFile { + path: String, + new_lines: Vec<(Vec, Option)>, + patch: crate::services::patch::PatchFileChange, + decisions: Vec, +} + +fn random_attribution(rng: &mut StdRng) -> LineAttribution { + let ai = |session: Option<&str>, model: Option<&str>| { + LineAttribution::Ai(ResolvedLine { + provenance: provenance(session, model), + direct_sources: BTreeSet::new(), + }) + }; + match rng.gen_range(0..7) { + 0 => ai(Some(SESSION_A), Some(MODEL_A)), + 1 => ai(Some(SESSION_A), Some(MODEL_B)), + 2 => ai(Some(SESSION_B), Some(MODEL_A)), + 3 => ai(None, Some(MODEL_A)), + 4 => ai(None, None), + 5 => LineAttribution::NonExclusive, + _ => LineAttribution::Unknown, + } +} + +fn random_line(rng: &mut StdRng) -> Vec { + let content = RANDOM_LINE_CONTENTS[rng.gen_range(0..RANDOM_LINE_CONTENTS.len())]; + let terminator = if rng.gen_bool(0.25) { "\r\n" } else { "\n" }; + format!("{content}{terminator}").into_bytes() +} + +fn touched_line( + kind: TouchedLineKind, + line_number: u64, + bytes: &[u8], +) -> crate::services::patch::TouchedLine { + let text = std::str::from_utf8(bytes).expect("utf-8 line"); + let text = text.strip_suffix('\n').unwrap_or(text); + crate::services::patch::TouchedLine { + kind, + line_number, + content: text.strip_suffix('\r').unwrap_or(text).to_owned(), + session_id: None, + } +} + +fn random_file(rng: &mut StdRng, index: usize) -> RandomFile { + use crate::services::patch::{FileChangeKind, PatchFileChange}; + + let mut new_lines = Vec::new(); + let mut touched = Vec::new(); + let mut old_line = 1_u64; + for _ in 0..rng.gen_range(1..=8) { + if rng.gen_bool(0.4) { + for _ in 0..rng.gen_range(1..=3) { + new_lines.push((random_line(rng), None)); + old_line += 1; + } + } else { + for _ in 0..rng.gen_range(0..=2) { + let decision = random_attribution(rng); + touched.push(( + touched_line(TouchedLineKind::Removed, old_line, &random_line(rng)), + decision.clone(), + )); + old_line += 1; + } + for _ in 0..rng.gen_range(0..=4) { + let bytes = random_line(rng); + let decision = random_attribution(rng); + let line_number = new_lines.len() as u64 + 1; + touched.push(( + touched_line(TouchedLineKind::Added, line_number, &bytes), + decision.clone(), + )); + new_lines.push((bytes, Some(decision))); + } + } + } + let last_has_content = new_lines + .last() + .is_some_and(|(bytes, _)| !matches!(bytes.as_slice(), b"\n" | b"\r\n")); + if last_has_content && rng.gen_bool(0.3) { + if let Some((last, _)) = new_lines.last_mut() { + while matches!(last.last(), Some(b'\n' | b'\r')) { + last.pop(); + } + if let Some((line, _)) = touched.iter_mut().rev().find(|(line, _)| { + line.kind == TouchedLineKind::Added && line.line_number == new_lines.len() as u64 + }) { + *line = touched_line( + TouchedLineKind::Added, + line.line_number, + &new_lines.last().expect("last line").0, + ); + } + } + } + + let hunk = crate::services::patch::PatchHunk { + old_start: 1, + old_count: old_line - 1, + new_start: 1, + new_count: new_lines.len() as u64, + model_id: None, + lines: Vec::new(), + }; + let file_path = format!("src/random_{index}.rs"); + let mut patch = PatchFileChange { + old_path: file_path.clone(), + new_path: file_path.clone(), + kind: FileChangeKind::Modified, + hunks: vec![hunk], + }; + patch.hunks[0].lines = touched.iter().map(|(line, _)| line.clone()).collect(); + RandomFile { + path: file_path, + new_lines, + patch, + decisions: touched.into_iter().map(|(_, decision)| decision).collect(), + } +} + +impl RandomFile { + fn bytes(&self) -> Vec { + self.new_lines + .iter() + .flat_map(|(bytes, _)| bytes.clone()) + .collect() + } +} + +fn partition( + files: &[RandomFile], + max_hunk_len: usize, +) -> (ParsedPatch, BTreeMap) { + let mut decisions = BTreeMap::new(); + let patch_files = files + .iter() + .enumerate() + .map(|(file_index, file)| { + let whole = &file.patch.hunks[0]; + let hunks = whole + .lines + .chunks(max_hunk_len.max(1)) + .zip(file.decisions.chunks(max_hunk_len.max(1))) + .enumerate() + .map(|(hunk_index, (lines, hunk_decisions))| { + for (line_index, decision) in hunk_decisions.iter().enumerate() { + decisions.insert( + PatchLineLocation { + file_index, + hunk_index, + line_index, + }, + decision.clone(), + ); + } + crate::services::patch::PatchHunk { + lines: lines.to_vec(), + ..whole.clone() + } + }) + .collect(); + crate::services::patch::PatchFileChange { + hunks, + ..file.patch.clone() + } + }) + .collect(); + (ParsedPatch { files: patch_files }, decisions) +} + +type ExpectedKey = (Option, Option, &'static str); + +fn expected_key(decision: &LineAttribution) -> ExpectedKey { + match decision { + LineAttribution::Ai(line) => ( + line.provenance.session_id.clone(), + line.provenance.model_id.clone(), + "ai", + ), + LineAttribution::NonExclusive => (None, None, "non_exclusive"), + LineAttribution::Unknown => (None, None, "unknown"), + } +} + +fn expected_ranges(file: &RandomFile) -> Vec<(u64, u64, ExpectedKey)> { + let mut ranges: Vec<(u64, u64, ExpectedKey)> = Vec::new(); + for (index, (_, decision)) in file.new_lines.iter().enumerate() { + let line_number = index as u64 + 1; + let Some(decision) = decision else { continue }; + let key = expected_key(decision); + match ranges.last_mut() { + Some((_, end, last)) if *end + 1 == line_number && *last == key => *end = line_number, + _ => ranges.push((line_number, line_number, key)), + } + } + ranges +} + +struct RandomFiles(BTreeMap>); + +impl CommittedFileReader for RandomFiles { + fn read_committed_file(&self, path: &str) -> CommittedFile { + self.0 + .get(path) + .map_or(CommittedFile::Unavailable, |bytes| { + CommittedFile::Blob(bytes.clone()) + }) + } +} + +fn assert_random_trace_file(seed: u64, trace_file: &super::TraceFile, file: &RandomFile) { + assert_eq!(trace_file.path, file.path); + let mut actual = Vec::new(); + for conversation in &trace_file.conversations { + let mut conversation_keys = BTreeSet::new(); + for range in &conversation.ranges { + assert!(1 <= range.start_line && range.start_line <= range.end_line); + let start = usize::try_from(range.start_line).expect("start fits") - 1; + let end = usize::try_from(range.end_line).expect("end fits"); + assert!(end <= file.new_lines.len()); + let lines = &file.new_lines[start..end]; + let first = lines[0].1.as_ref().map(expected_key); + assert!( + lines.iter().all(|(_, decision)| { + decision.is_some() && decision.as_ref().map(expected_key) == first + }), + "seed {seed}: range crosses context or an attribution boundary" + ); + let key = first.expect("added line"); + let bytes: Vec = lines.iter().flat_map(|(bytes, _)| bytes.clone()).collect(); + assert_eq!( + range.content_hash, + Some(expected_hash(&bytes)), + "seed {seed}" + ); + + let contributor = range + .contributor + .as_ref() + .or(conversation.contributor.as_ref()) + .expect("effective contributor"); + let expected_kind = if key.2 == "ai" { + ContributorType::Ai + } else { + ContributorType::Unknown + }; + assert_eq!(contributor.kind, expected_kind, "seed {seed}"); + let expected_model = if key.2 == "ai" { key.1.clone() } else { None }; + assert_eq!(contributor.model_id, expected_model, "seed {seed}"); + conversation_keys.insert(match key.2 { + "ai" if key.0.is_some() => (key.0.clone(), None, "session"), + "ai" => (None, key.1.clone(), "sessionless"), + other => (None, None, other), + }); + actual.push((range.start_line, range.end_line, key)); + } + assert_eq!( + conversation_keys.len(), + 1, + "seed {seed}: conversation provenance" + ); + let (session, _, _) = conversation_keys.into_iter().next().expect("key"); + assert_eq!( + related_urls(conversation), + session + .map(|session| vec![agent_trace_session_url(&session)]) + .unwrap_or_default(), + "seed {seed}: related sessions" + ); + } + actual.sort_by_key(|(start, _, _)| *start); + assert_eq!(actual, expected_ranges(file), "seed {seed}"); +} + +#[test] +fn randomized_ranges_reference_exact_committed_content_with_defensible_provenance() { + for seed in 0..RANDOMIZED_SEEDS { + let mut rng = StdRng::seed_from_u64(seed); + let files: Vec = (0..rng.gen_range(1..=3)) + .map(|index| random_file(&mut rng, index)) + .collect(); + let blobs = RandomFiles( + files + .iter() + .map(|file| (file.path.clone(), file.bytes())) + .collect(), + ); + let url = agent_trace_conversation_url("00000000-0000-7000-8000-000000000000"); + + let (patch, decisions) = partition(&files, usize::MAX); + let trace_files = + super::build_trace_files(&patch, &decisions, &url, &blobs, TEST_COMMIT_REVISION) + .expect("random trace files build"); + + for max_hunk_len in [1, 2, 5] { + let (repartitioned, repartitioned_decisions) = partition(&files, max_hunk_len); + assert_eq!( + line_change_attribution(&repartitioned, &repartitioned_decisions), + line_change_attribution(&patch, &decisions), + "seed {seed} counters hunk partition {max_hunk_len}" + ); + assert_eq!( + super::build_trace_files( + &repartitioned, + &repartitioned_decisions, + &url, + &blobs, + TEST_COMMIT_REVISION + ) + .expect("repartitioned trace files build"), + trace_files, + "seed {seed} hunk partition {max_hunk_len}" + ); + } + + let record = serde_json::json!({ + "version": AGENT_TRACE_VERSION, + "id": "00000000-0000-7000-8000-000000000000", + "timestamp": TEST_COMMIT_TIMESTAMP, + "files": trace_files, + }); + validate_agent_trace_value(&record).expect("random trace validates"); + let json = record.to_string(); + assert!(!json.contains("\"human\"") && !json.contains("\"mixed\"")); + + let expected_files: Vec<&RandomFile> = files + .iter() + .filter(|file| { + file.new_lines + .iter() + .any(|(_, decision)| decision.is_some()) + }) + .collect(); + assert_eq!(trace_files.len(), expected_files.len(), "seed {seed}"); + for (trace_file, file) in trace_files.iter().zip(expected_files) { + assert_random_trace_file(seed, trace_file, file); + } + } +} diff --git a/cli/src/services/hooks/commit_hooks.rs b/cli/src/services/hooks/commit_hooks.rs index 446f390f5..b651ad673 100644 --- a/cli/src/services/hooks/commit_hooks.rs +++ b/cli/src/services/hooks/commit_hooks.rs @@ -9,7 +9,7 @@ use crate::services::agent_trace::{ agent_trace_persisted_url, build_agent_trace_from_evidence, direct_evidence_patch, has_direct_evidence, patch_has_touched_lines, validate_agent_trace_value, AgentTrace, AgentTraceEvidence, AgentTraceMetadataInput, AgentTraceVcsType, DirectEvidenceSource, - RepositoryRoot, + GitCommittedFiles, RepositoryRoot, }; use crate::services::agent_trace_db::{ AgentTraceInsert, ParsedDiffTracePatch, PostCommitPatchIntersectionInsert, @@ -346,6 +346,10 @@ where commit_revision: &flow_result.post_commit_data.commit_oid, vcs_type, }, + &GitCommittedFiles::new( + &flow_result.repository_root, + &flow_result.post_commit_data.commit_oid, + ), ) .context("Failed to build Agent Trace payload from post-commit intersection flow result.")?; @@ -717,7 +721,7 @@ pub fn capture_post_commit_patch_from_git(repository_root: &Path) -> Result Result { +pub(crate) fn capture_commit_patch_from_git( + repository_root: &Path, + commit_oid: &str, +) -> Result { run_git_command_capture_stdout( repository_root, - &["show", "--format=", "--patch", "--no-ext-diff", "HEAD"], + &[ + "show", + "--format=", + "--patch", + "--no-ext-diff", + "--no-textconv", + commit_oid, + "--", + ], "Failed to capture HEAD patch from git.", ) } diff --git a/cli/src/services/hooks/commit_hooks/attribution_tests.rs b/cli/src/services/hooks/commit_hooks/attribution_tests.rs index 9ad69fbde..f7ab42879 100644 --- a/cli/src/services/hooks/commit_hooks/attribution_tests.rs +++ b/cli/src/services/hooks/commit_hooks/attribution_tests.rs @@ -8,8 +8,8 @@ use anyhow::{anyhow, Result}; use serde_json::Value; use crate::services::agent_trace::{ - agent_trace_session_url, validate_agent_trace_value, AgentTrace, DirectEvidenceSource, - HunkContributor, LineChangeAttribution, LineChangeCounts, RepositoryRoot, + agent_trace_session_url, validate_agent_trace_value, AgentTrace, AgentTraceVcsType, + ContributorType, DirectEvidenceSource, LineChangeAttribution, LineChangeCounts, RepositoryRoot, }; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::agent_trace_db::{ @@ -443,8 +443,23 @@ async fn stale_sessions_from_already_committed_history_do_not_credit_a_later_com let trace = agent_trace(&flow, &mutation).await; let conversation = &trace.files[0].conversations[0]; - assert_eq!(conversation.contributor.kind, HunkContributor::Ai); - assert_eq!(conversation.contributor.model_id.as_deref(), Some(MODEL_C)); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .kind, + ContributorType::Ai + ); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .model_id + .as_deref(), + Some(MODEL_C) + ); assert_eq!( related_sessions(&trace), vec![agent_trace_session_url(SESSION_C)] @@ -476,8 +491,23 @@ async fn causal_mutation_lineage_overrides_a_fresh_exact_direct_match_from_anoth let trace = agent_trace(&flow, &mutation).await; let conversation = &trace.files[0].conversations[0]; - assert_eq!(conversation.contributor.kind, HunkContributor::Ai); - assert_eq!(conversation.contributor.model_id.as_deref(), Some(MODEL_C)); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .kind, + ContributorType::Ai + ); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .model_id + .as_deref(), + Some(MODEL_C) + ); assert_eq!( related_sessions(&trace), vec![agent_trace_session_url(SESSION_C)] @@ -508,8 +538,12 @@ async fn non_exclusive_mutation_lineage_is_not_overridden_by_a_direct_match() { let trace = agent_trace(&flow, &mutation).await; assert_eq!( - trace.files[0].conversations[0].contributor.kind, - HunkContributor::Unknown + trace.files[0].conversations[0] + .contributor + .as_ref() + .expect("conversation contributor") + .kind, + ContributorType::Unknown ); assert!(related_sessions(&trace).is_empty()); let line_changes = trace.metadata.sce.line_changes; @@ -535,8 +569,12 @@ async fn a_historical_content_match_at_other_positions_does_not_attribute_withou let trace = agent_trace(&flow, &no_mutation()).await; assert_eq!( - trace.files[0].conversations[0].contributor.kind, - HunkContributor::Unknown + trace.files[0].conversations[0] + .contributor + .as_ref() + .expect("conversation contributor") + .kind, + ContributorType::Unknown ); assert_contains_none( &serde_json::to_string(&trace).expect("trace serializes"), @@ -553,8 +591,23 @@ async fn fresh_exact_direct_evidence_still_attributes_without_mutation_history() let trace = agent_trace(&flow, &no_mutation()).await; let conversation = &trace.files[0].conversations[0]; - assert_eq!(conversation.contributor.kind, HunkContributor::Ai); - assert_eq!(conversation.contributor.model_id.as_deref(), Some(MODEL_C)); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .kind, + ContributorType::Ai + ); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .model_id + .as_deref(), + Some(MODEL_C) + ); assert_eq!( related_sessions(&trace), vec![agent_trace_session_url(SESSION_C)] @@ -581,8 +634,12 @@ async fn conflicting_fresh_direct_sessions_fail_closed_without_lineage() { assert_eq!(touched_line_count(&persisted.patch), 0); let trace = agent_trace(&flow, &no_mutation()).await; assert_eq!( - trace.files[0].conversations[0].contributor.kind, - HunkContributor::Unknown + trace.files[0].conversations[0] + .contributor + .as_ref() + .expect("conversation contributor") + .kind, + ContributorType::Unknown ); assert!(related_sessions(&trace).is_empty()); } @@ -613,15 +670,22 @@ fn one_direct_source_line_covers_only_its_own_duplicate_occurrence() { commit_revision: "0000000000000000000000000000000000000001", vcs_type: None, }, + &crate::services::agent_trace::NoCommittedFiles, ) .expect("trace builds"); let kinds: Vec<_> = trace.files[0] .conversations .iter() - .map(|conversation| conversation.contributor.kind) + .map(|conversation| { + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .kind + }) .collect(); - assert_eq!(kinds, vec![HunkContributor::Ai, HunkContributor::Unknown]); + assert_eq!(kinds, vec![ContributorType::Ai, ContributorType::Unknown]); assert_eq!(trace.metadata.sce.line_changes.ai.added, 1); assert_eq!(trace.metadata.sce.line_changes.unknown.added, 1); } @@ -965,12 +1029,17 @@ async fn post_commit_direct_evidence_requires_repository_file_identity() { let trace = agent_trace(&flow, &no_mutation()).await; let (kind, tool, lines) = if expected { - (HunkContributor::Ai, Some(CLAUDE_TOOL), BOILERPLATE.len()) + (ContributorType::Ai, Some(CLAUDE_TOOL), BOILERPLATE.len()) } else { - (HunkContributor::Unknown, None, 0) + (ContributorType::Unknown, None, 0) }; assert_eq!( - trace.files[0].conversations[0].contributor.kind, kind, + trace.files[0].conversations[0] + .contributor + .as_ref() + .expect("conversation contributor") + .kind, + kind, "{path}" ); assert_eq!( @@ -1333,3 +1402,233 @@ async fn persisted_agent_trace_counts_unresolved_lines_as_unknown() { } ); } + +const RANGE_APP_PATH: &str = "src/app.ts"; +const RANGE_NOTES_PATH: &str = "notes.md"; +const RANGE_BASE_APP: &str = "keep 1\r\nold line\r\nkeep 2\r\n"; +const RANGE_COMMITTED_APP: &str = + "keep 1\r\nai one ✓\r\nai two\r\nunresolved\r\nkeep 2\r\ntail without newline"; +const RANGE_COMMITTED_NOTES: &str = "x\n\n y\n"; +const RANGE_DELETED_PATCH: &str = + "diff --git a/notes.md b/notes.md\n--- a/notes.md\n+++ b/notes.md\n@@ -0,0 +1 @@\n+fabricated\n"; + +fn committed_blob(root: &Path, commit: &str, path: &str) -> Vec { + let output = Command::new("git") + .args(["cat-file", "blob", &format!("{commit}:{path}")]) + .current_dir(root) + .output() + .expect("git cat-file runs"); + assert!(output.status.success()); + output.stdout +} + +fn independent_line_slice(bytes: &[u8], start_line: u64, end_line: u64) -> Option> { + let lines: Vec<&[u8]> = bytes.split_inclusive(|byte| *byte == b'\n').collect(); + let start = usize::try_from(start_line).ok()?.checked_sub(1)?; + let end = usize::try_from(end_line).ok()?; + (start < end && end <= lines.len()).then(|| lines[start..end].concat()) +} + +fn murmur3_tag(bytes: &[u8]) -> String { + let hash = murmur3::murmur3_32(&mut std::io::Cursor::new(bytes), 0).expect("murmur3"); + format!("murmur3:{hash:08x}") +} + +fn commit_range_fixture(root: &Path) -> String { + git(root, &["init", "-q", "-b", "main"], None); + git( + root, + &["config", "user.email", "ranges@example.invalid"], + None, + ); + git(root, &["config", "user.name", "Ranges Test"], None); + git(root, &["config", "commit.gpgsign", "false"], None); + git(root, &["config", "core.autocrlf", "false"], None); + std::fs::create_dir_all(root.join("src")).expect("src dir"); + std::fs::write(root.join(RANGE_APP_PATH), RANGE_BASE_APP).expect("write app"); + std::fs::write(root.join("gone.txt"), "a\nb\n").expect("write gone"); + std::fs::write(root.join("changes.patch"), RANGE_DELETED_PATCH).expect("write patch"); + git(root, &["add", "-A"], None); + git(root, &["commit", "-q", "-m", "base"], Some(BASE_TIME_S)); + + std::fs::write(root.join(RANGE_APP_PATH), RANGE_COMMITTED_APP).expect("write app"); + std::fs::write(root.join(RANGE_NOTES_PATH), RANGE_COMMITTED_NOTES).expect("write notes"); + std::fs::remove_file(root.join("gone.txt")).expect("remove gone"); + std::fs::remove_file(root.join("changes.patch")).expect("remove patch"); + git(root, &["add", "-A"], None); + git( + root, + &["commit", "-q", "-m", "ranges"], + Some(COMMIT_A_TIME_S), + ); + git(root, &["rev-parse", "HEAD"], None) +} + +async fn persist_range_trace(root: &Path, db: &RepositoryAgentTraceDb) -> (AgentTrace, String) { + insert_rows( + db, + &[DiffTraceRow { + time_ms: ms(COMMIT_A_TIME_S) - 500, + session_id: SESSION_C, + model_id: MODEL_C, + tool_name: CLAUDE_TOOL, + patch: format!( + "diff --git a/{RANGE_APP_PATH} b/{RANGE_APP_PATH}\n--- a/{RANGE_APP_PATH}\n+++ b/{RANGE_APP_PATH}\n@@ -1,0 +2,2 @@\n+ai one ✓\n+ai two\n" + ), + }], + ) + .await; + let flow = run_post_commit_intersection_flow_with( + root, + capture_post_commit_patch_from_git, + || Ok(ms(COMMIT_A_TIME_S) + 500), + async |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms).await, + async |insert| { + db.insert_post_commit_patch_intersection(insert) + .await + .map(|_| ()) + }, + ) + .await + .expect("intersection flow"); + let trace = run_post_commit_agent_trace_flow_with( + &flow, + Some(AgentTraceVcsType::Git), + REMOTE_URL, + &no_mutation(), + None, + |value| validate_agent_trace_value(value).map_err(|error| anyhow!(error.to_string())), + async |insert| db.insert_agent_trace(insert).await.map(|_| ()), + ) + .await + .expect("agent trace flow"); + + let persisted: Vec = db + .query_map("SELECT trace_json FROM agent_traces", (), |row| { + Ok(row.get(0)?) + }) + .await + .expect("read agent traces"); + assert_eq!(persisted.len(), 1); + (trace, persisted[0].clone()) +} + +type ObservedRange = ( + String, + u64, + u64, + String, + ContributorType, + Option, + Vec, +); + +fn observed_ranges(root: &Path, commit: &str, trace: &AgentTrace) -> Vec { + let mut observed = Vec::new(); + for file in &trace.files { + let blob = committed_blob(root, commit, &file.path); + for conversation in &file.conversations { + for range in &conversation.ranges { + let bytes = independent_line_slice(&blob, range.start_line, range.end_line) + .expect("range exists at the recorded revision"); + assert_eq!(range.content_hash, Some(murmur3_tag(&bytes))); + let contributor = range + .contributor + .as_ref() + .or(conversation.contributor.as_ref()) + .expect("effective contributor"); + observed.push(( + file.path.clone(), + range.start_line, + range.end_line, + String::from_utf8(bytes).expect("utf-8 range"), + contributor.kind, + contributor.model_id.clone(), + conversation + .related + .iter() + .flatten() + .map(|related| related.url.clone()) + .collect(), + )); + } + } + } + observed +} + +fn unknown_range(path: &str, start: u64, end: u64, text: &str) -> ObservedRange { + ( + path.to_owned(), + start, + end, + text.to_owned(), + ContributorType::Unknown, + None, + Vec::new(), + ) +} + +#[tokio::test] +async fn persisted_agent_trace_ranges_reference_exact_committed_blobs() { + let dir = tempfile::tempdir().expect("temp dir"); + let root = dir.path(); + let commit = commit_range_fixture(root); + let (_db_dir, db) = open_db().await; + let (trace, persisted) = persist_range_trace(root, &db).await; + + let persisted_value: Value = serde_json::from_str(&persisted).expect("trace JSON"); + validate_agent_trace_value(&persisted_value).expect("persisted trace validates"); + let persisted_trace: AgentTrace = + serde_json::from_value(persisted_value).expect("persisted trace deserializes"); + assert_eq!(persisted_trace, trace); + assert_eq!( + persisted_trace + .vcs + .as_ref() + .map(|vcs| vcs.revision.as_str()), + Some(commit.as_str()) + ); + + assert_eq!( + observed_ranges(root, &commit, &persisted_trace), + vec![ + unknown_range(RANGE_NOTES_PATH, 1, 3, RANGE_COMMITTED_NOTES), + ( + RANGE_APP_PATH.to_owned(), + 2, + 3, + "ai one ✓\r\nai two\r\n".to_owned(), + ContributorType::Ai, + Some(MODEL_C.to_owned()), + vec![agent_trace_session_url(SESSION_C)] + ), + unknown_range(RANGE_APP_PATH, 4, 4, "unresolved\r\n"), + unknown_range(RANGE_APP_PATH, 6, 6, "tail without newline"), + ] + ); + assert_eq!( + persisted_trace.files[1].conversations.len(), + 2, + "unresolved app ranges share one conversation" + ); + assert_eq!( + persisted_trace.tool.and_then(|tool| tool.name).as_deref(), + Some(CLAUDE_TOOL) + ); + let deleted_patch_lines = RANGE_DELETED_PATCH.lines().count() as u64; + assert_eq!( + persisted_trace.metadata.sce.line_changes, + LineChangeAttribution { + ai: LineChangeCounts { + added: 2, + removed: 0 + }, + non_exclusive: LineChangeCounts::default(), + unknown: LineChangeCounts { + added: 5, + removed: 1 + 2 + deleted_patch_lines + }, + } + ); +} diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs index c43cd9043..d3ba4e5a1 100644 --- a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs @@ -3,7 +3,7 @@ use std::process::Command; use super::*; use crate::services::agent_trace::{ build_agent_trace_from_evidence, AgentTrace, AgentTraceEvidence, AgentTraceMetadataInput, - DirectEvidenceSource, HunkContributor, RepositoryRoot, + ContributorType, DirectEvidenceSource, NoCommittedFiles, RepositoryRoot, }; const PLAN_PATH: &str = "plan.md"; @@ -177,6 +177,7 @@ fn build_trace( commit_revision: "0000000000000000000000000000000000000001", vcs_type: None, }, + &NoCommittedFiles, ) .expect("agent trace builds") } @@ -208,8 +209,22 @@ fn assert_barrier_keeps_line_unknown( &[old_session_historical_match()], ); let conversation = &trace.files[0].conversations[0]; - assert_eq!(conversation.contributor.kind, HunkContributor::Unknown); - assert_eq!(conversation.contributor.model_id, None); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .kind, + ContributorType::Unknown + ); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .model_id, + None + ); assert_eq!(conversation.related, None); let json = serde_json::to_string(&trace).expect("trace serializes"); assert!(!json.contains(OLD_SESSION)); @@ -247,9 +262,21 @@ async fn an_observed_ai_event_attributes_the_line_over_an_old_historical_match() &[old_session_historical_match()], ); let conversation = &trace.files[0].conversations[0]; - assert_eq!(conversation.contributor.kind, HunkContributor::Ai); assert_eq!( - conversation.contributor.model_id.as_deref(), + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .kind, + ContributorType::Ai + ); + assert_eq!( + conversation + .contributor + .as_ref() + .expect("conversation contributor") + .model_id + .as_deref(), Some(AUTHOR_MODEL) ); let related: Vec<_> = conversation diff --git a/context/cli/mutation-trace-agent-attribution.md b/context/cli/mutation-trace-agent-attribution.md index 87afa1216..4fe7c58a3 100644 --- a/context/cli/mutation-trace-agent-attribution.md +++ b/context/cli/mutation-trace-agent-attribution.md @@ -200,17 +200,26 @@ direct candidate matching in `cli/src/services/agent_trace/evidence.rs`: `metadata.sce.line_changes` increments one `{ai, non_exclusive, unknown}` bucket per line from that decision. `non_exclusive` means lineage resolved a non-exclusive or unscoped transition (not human authorship); `unknown` means - unresolved. Hunk `mixed` is presentation only. Counters cover the canonical - committed patch exactly and do not depend on hunk grouping. See + unresolved. Counters cover the canonical committed patch exactly and do not + depend on hunk grouping. See [../sce/agent-trace-minimal-generator.md](../sce/agent-trace-minimal-generator.md). +- **How do decisions become exported ranges?** Only surviving added lines get + ranges, at their post-commit positions. Contiguous lines with the same + decision, accepted session, and model coalesce; ranges group into one + conversation per accepted session (or per model for session-less AI, or per + unresolved decision kind). A Git hunk never decides a contributor, and SCE + does not emit `mixed` for partial AI coverage. Hashes cover the exact + committed bytes of each range. See + [../sce/agent-trace-minimal-generator.md](../sce/agent-trace-minimal-generator.md#ranges-and-conversations). - **What remains unsupported?** Removed lines are never resolved by lineage (only exact fresh direct evidence can cover them; otherwise they count as - `unknown`, even when the line's original author is known). Direct-only edits whose - line numbers drift through later uncaptured edits now resolve to unknown or - `mixed` instead of `ai`. The freshness bound uses the committer time of the + `unknown`, even when the line's original author is known). Removed lines have + no post-commit position, so their decisions appear only in counters, never as + ranges. Direct-only edits whose line numbers drift through later uncaptured + edits resolve to `unknown` instead of `ai`. The freshness bound uses the committer time of the first parent, so an uncommitted edit made before the parent commit (partial staging) loses direct attribution. Mutation scopes without a recorded model - still produce hunks without `model_id`. + still produce ranges without `model_id`. The former `intersect_patches` / `combine_patches` helpers and their historical `(kind, content)` fallback were removed from `patch.rs`. diff --git a/context/context-map.md b/context/context-map.md index b562e3dca..3eb20dcb9 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -102,7 +102,7 @@ Feature/domain context: - `context/sce/agent-trace-core-schema-migrations.md` (historical reference for removed local DB schema bootstrap behavior; T03 now implements the actual local DB with migrations) - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) -- `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,non_exclusive,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact per-line attribution counts with one bucket increment per canonical `post_commit_patch` touched line from the same line decisions that drive hunk classification, plus per-file trace data from fresh, exact, single-session direct evidence (`agent_trace/evidence.rs`) plus mutation-lineage evidence, then `post_commit_patch`-anchored hunk classification into `ai`/`mixed`/`unknown` contributor categories, serialized per conversation with a required lookup `url` derived from top-level `AgentTrace.id`, nested `contributor.type` with optional `contributor.model_id` omitted when provenance is missing, optional canonical session links derived from matched touched-line provenance, one derived `ranges[{start_line,end_line,content_hash}]` entry per post-commit or embedded-patch hunk, and range `content_hash` values that hash touched-line kind/content independent of positions and metadata) +- `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,non_exclusive,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact per-line attribution counts with one bucket increment per canonical `post_commit_patch` touched line from the same per-line decisions that build revision-positioned ranges: only surviving added lines at their post-commit positions, coalesced by identical decision/session/model and grouped into conversations by accepted session (Git hunks never decide contributors; SCE never generates `mixed`), with optional conversation-level `contributor`, optional range-level `contributor` overrides, session links only from a conversation's own lines, and optional range `content_hash` = Murmur3 (seed 0) of the exact committed blob bytes at `vcs.revision`, read by `GitCommittedFiles` and omitted when unavailable) - `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence with a best-effort canonical `git patch-id --stable` commit patch ID stored in nullable `agent_traces.commit_patch_id` (sanitized warning and `NULL` on failure), DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > bridge-derived chain state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, a main-session-scope-only bridge chain state-miss fallback that seeds a `source="bridge_inherited"` row for the current session (the one diff-trace path that writes state), `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes whose own model-less `SessionStart` bridge attempt cannot read its lazily created transcript, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — and the hidden non-fail-open `sce hooks claude-mutation-scope` first-concrete-adapter route (now registered by `sce setup`), deferring the full contracts to `context/cli/mutation-scope-hook-ingress.md` and `context/cli/claude-mutation-scope-integration.md`.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus current Rust evaluator seam and OpenCode/Claude delegation references, including config schema, argv-prefix matching, shell/nix unwrapping, custom-policy `satisfied_by` wrapper exemption, fixed preset catalog/messages, and precedence rules) diff --git a/context/glossary.md b/context/glossary.md index aa6f6868c..232ac715e 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -86,10 +86,10 @@ - `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`; `disabled` covers both the explicit opt-out and an access policy that disallows background cloud operations, and 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 the `patch_for_locations` helpers; lineage projects every committed line, and deleted or renamed-away tracked paths stay tracked as absent files. 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). -- `LineAttribution`: The single final per-line attribution decision in `cli/src/services/agent_trace.rs` — `Ai(ResolvedLine)` (AI-exclusive mutation lineage, or accepted direct evidence for an otherwise unresolved line), `NonExclusive` (mutation lineage resolved a non-exclusive or unscoped transition; not human authorship; cannot be overridden by direct evidence), or `Unknown` (unresolved). Exactly one decision exists per canonical touched line and feeds both hunk classification and `metadata.sce.line_changes`. See `context/sce/agent-trace-minimal-generator.md`. -- `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace_from_evidence(...)` under `metadata.sce`, carrying `version` (sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`) and `line_changes` (exact `{ai,non_exclusive,unknown}` × `{added,removed}` `u64` per-line attribution counts: every canonical `post_commit_patch` touched line increments exactly one bucket from its final `LineAttribution` decision — the same decisions that drive hunk classification — so totals equal the committed patch's touched lines independent of hunk boundaries; `mixed` is only a hunk-level presentation type with no counter bucket; `#[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`. +- `LineAttribution`: The single final per-line attribution decision in `cli/src/services/agent_trace.rs` — `Ai(ResolvedLine)` (AI-exclusive mutation lineage, or accepted direct evidence for an otherwise unresolved line), `NonExclusive` (mutation lineage resolved a non-exclusive or unscoped transition; not human authorship; cannot be overridden by direct evidence), or `Unknown` (unresolved). Exactly one decision exists per canonical touched line and feeds both revision-positioned ranges and `metadata.sce.line_changes`. See `context/sce/agent-trace-minimal-generator.md`. +- `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace_from_evidence(...)` under `metadata.sce`, carrying `version` (sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`) and `line_changes` (exact `{ai,non_exclusive,unknown}` × `{added,removed}` `u64` per-line attribution counts: every canonical `post_commit_patch` touched line increments exactly one bucket from its final `LineAttribution` decision — the same decisions ranges are built from — so totals equal the committed patch's touched lines independent of hunk boundaries and of emitted ranges; `#[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`. - `ScopeProvenance`: Optional insert-once metadata keyed by a mutation `ScopeId`, storing canonical SCE `session_id` and nullable first-observed `model_id`. It is registered only at `Start` admission while the owning scope is `NeverSeen`, remains outside `ProtocolState` and the CAS transition, and is resolved during mutation projection. `ScopeId` proves mutation ownership; `ScopeProvenance` describes the owning scope. Model/session provenance is observational and never changes `AiExclusive`/`AiContended`/`IneligibleUnscoped` protocol decisions. -- `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace_from_evidence(...)` 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. +- `Agent Trace range content_hash`: Optional per-range `content_hash` emitted as `murmur3:<8 lowercase hex>` (Murmur3 x86 32-bit, seed 0) over exactly the committed bytes of the inclusive range at `vcs.revision`, including the lines' own LF/CRLF terminators; excludes paths, positions, hunk metadata, attribution, and IDs. Omitted, never fabricated, when the committed blob is unavailable. See `context/sce/agent-trace-minimal-generator.md`. - `external-mutation guard` / `external-mutation supervisor`: `arm_external_mutation_guard` + `ArmedExternalMutationGuard::exec` (`cli/src/services/mutation_trace/runtime/external_mutation_guard.rs`, hidden `sce hooks external-mutation-guard`, Unix-only), the harness-neutral, not-Pi-specific mechanism that lets a human-initiated shell command (Pi's `!`/`!!` `user_bash`) mutate a worktree without ever creating a mutation scope: it acquires the same `ProtectedWorktree` guard `coordinate()` uses, reports it armed, itself spawns the human shell as its own child (never signaled or stopped by control-channel closure — only by an explicit cancel request, which it enacts by signaling the shell's own process group), finishes exclusively on the shell's real process termination, and only then forces the existing `database_failure`+`recover` composition on the already-held worktree via `coordinate_on_held_worktree` before clearing the marker with `ProtectedWorktree::complete()`. See [`mutation-trace-external-mutation-guard.md`](cli/mutation-trace-external-mutation-guard.md). - `Claude diff-trace attribution`: Diff-trace enrichment rule where one Claude `PostToolUse` event resolves its model with `direct > exact transcript > exact session/agent state > bridge-derived chain 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`. Only for a raw structured Claude payload in the exact main-session scope (`agent_id` empty), a state miss then falls back to `bridge session correlation` across the transcript's bridge-linked chain; when that resolves, persistence writes a `claude_model_state` row for the current session (`source="bridge_inherited"`) — the one diff-trace resolution path that writes state — and uses that model, so later traces of the same session resolve from their own exact-scope row. Model sources receive one `claude/` normalization step, ephemeral agent context is never exported, and subagents do not inherit main-session state. - `bridge session correlation`: Claude-specific local correlation using the `bridgeSessionId` in leading transcript records to relate a Claude session's transcript to its sibling transcripts sharing that ID. Selection resolves the newest `claude_model_state` observation across all bridge-linked chain members by `observed_at_ms` (deterministic session-ID tie-break), not the chain origin and not the most recently modified sibling transcript. Discovery is bounded (leading records only) and fail-open, uses existing exact-scope `claude_model_state` rows without persisting the bridge ID, and does not claim authoritative session ordering. One selection rule serves both call sites — the `diff-trace` state-miss path and the `sce hooks claude-model-state` `SessionStart` path — with no most-recently-modified-sibling pick left in the code. See [the read-path bridge-seeding decision](decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md) and [the bridge-session inheritance decision](decisions/2026-09-08-claude-bridge-session-model-inheritance.md). @@ -230,10 +230,11 @@ - `TouchedLineKind`: Enum in `cli/src/services/patch.rs` classifying a touched line as `Added` or `Removed`; serialized as `snake_case` JSON strings; derives `Hash` to support set-based intersection operations - `PatchLoadError`: Error type in `cli/src/services/patch.rs` produced when `load_patch_from_json` encounters invalid JSON or a payload that does not match the expected `ParsedPatch` structure; carries an actionable `message` field - `load_patch_from_json`: Public function in `cli/src/services/patch.rs` that reconstructs a `ParsedPatch` from a JSON string; storage-agnostic entrypoint for callers who have already read serialized JSON content from a database, file, or other source -- `HunkContributor`: Enum in `cli/src/services/agent_trace.rs` classifying a `post_commit_patch` hunk from its per-line attributions (AI-exclusive lineage first, non-exclusive lineage blocking, then fresh exact single-session direct evidence): `Ai` (every touched line attributed), `Mixed` (a non-empty proper subset attributed), `Unknown` (no line attributed); serialized as `snake_case` JSON strings +- `ContributorType`: Standard Agent Trace contributor enum in `cli/src/services/agent_trace.rs` (`human`, `ai`, `mixed`, `unknown`). SCE generates only `ai` and `unknown`; `mixed` and `human` are accepted when reading records. Replaced the former hunk-level `HunkContributor`, which labelled any partially attributed Git hunk `mixed`. - `trusted direct evidence`: A `diff_traces` candidate that may attribute a committed line lineage left unresolved: recorded after the parent commit's committer-time second, an exact `(file, kind, line_number, content)` match, and the only session with an exact candidate for that line. Matching lives in `cli/src/services/agent_trace/evidence.rs` -- `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) +- `Conversation`: Struct in `cli/src/services/agent_trace.rs` grouping one file's ranges that share provenance (one accepted session, one session-less model, or one unresolved decision kind), with optional conversation-level `contributor`, `ranges`, and optional `related` session link. Git hunks are not conversation boundaries. +- `revision-positioned range`: An Agent Trace `LineRange` built from surviving added lines at their post-commit line numbers (inclusive, 1-indexed), coalescing contiguous lines with identical decision, accepted session, and model; never includes context or removed lines, and may carry a `contributor` override when a single-session conversation spans several models. See `context/sce/agent-trace-minimal-generator.md`. +- `TraceFile`: Struct in `cli/src/services/agent_trace.rs` for one committed file with surviving added lines, carrying its post-commit `path` and conversations ordered by first line; deleted files and pure renames produce none. - `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_from_evidence(...)`), `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 - `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`. diff --git a/context/sce/agent-trace-minimal-generator.md b/context/sce/agent-trace-minimal-generator.md index c783fd7fc..67f206ccf 100644 --- a/context/sce/agent-trace-minimal-generator.md +++ b/context/sce/agent-trace-minimal-generator.md @@ -7,14 +7,44 @@ Rust library seam at `cli/src/services/agent_trace.rs` that produces the minimal Given per-row direct candidate patches, mutation lineage evidence, and a `post_commit_patch` (canonical source of truth): 1. Resolve every `post_commit_patch` touched line independently to exactly one final `LineAttribution` decision with the precedence in [../cli/mutation-trace-agent-attribution.md](../cli/mutation-trace-agent-attribution.md#evidence-precedence): AI-exclusive mutation lineage wins (`Ai`); non-exclusive mutation lineage keeps the line non-AI (`NonExclusive`); otherwise a fresh, exact `(file, kind, line_number, content)`, single-session direct candidate attributes it (`Ai`); otherwise the line is unresolved (`Unknown`). Historical `(kind, content)` similarity never attributes a line. Hunk classification, metadata, and `metadata.sce.line_changes` all derive from this one decision map. -2. Classify each `post_commit_patch` hunk from its `Ai` line decisions (a hunk of only `NonExclusive` lines is therefore `unknown`; the contributor schema has no non-exclusive type): - - **`ai`** — every touched line in the hunk is attributed. - - **`mixed`** — a non-empty proper subset of the hunk's touched lines is attributed. - - **`unknown`** — no touched line in the hunk is attributed. -3. Map `Conversation.contributor.model_id` for `ai` / `mixed` hunks only when every attributed line has the same known model. A mutation-attributed line uses its scope model, falling back to a same-session exact direct candidate's model. -4. Derive optional `conversation.related` entries from the sessions of attributed lines only, emitted as `{ "type": "session", "url": "https://sce.crocoder.dev/sessions/" }`, deduplicated and deterministically ordered. Overridden or conflicting direct candidates never appear. +2. Build revision-positioned ranges from those same decisions (see [Ranges and conversations](#ranges-and-conversations)). Git hunks are not consulted for ownership, range boundaries, contributors, or hashes. +3. Map contributor `model_id` only from accepted provenance of the lines a range covers. A mutation-attributed line uses its scope model, falling back to a same-session exact direct candidate's model. +4. Derive optional `conversation.related` only from the accepted session of the conversation's own lines, emitted as `{ "type": "session", "url": "https://sce.crocoder.dev/sessions/" }`. Unresolved conversations never inherit neighbouring sessions; overridden or conflicting direct candidates never appear. 5. Derive the top-level `tool` object only from the direct sources that support finally accepted lines (after mutation reconciliation). It is omitted when no direct source survived, when surviving sources name different tools, or when any surviving source lacks a tool name; `version` is kept only when every surviving source reports the same one. Row order never picks a winner, and rejected or overridden sources never contribute. -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. +6. Emit one `TraceFile` per committed file that has surviving added lines, with conversations ordered by first line and every range carrying the trace lookup `url` of its conversation and a `content_hash` of its exact committed bytes. + +## Ranges and conversations + +**Why hunks are not authorship boundaries.** A Git hunk is a diff presentation artifact: it bundles context lines, removals, and additions from possibly different authors. Earlier output emitted one range per hunk from `new_start`/`new_count`, which included unchanged context, placed removed-only hunks at old-file coordinates, and labelled any hunk with partial AI coverage `mixed`. SCE now derives every range from individual committed lines. + +**Range construction** (`agent_trace/ranges.rs`, wired by `build_trace_files` in `agent_trace.rs`): + +1. For each committed file, enumerate the canonical touched lines and keep only `Added` lines; their `TouchedLine.line_number` is the post-commit (new-file) position. Removed lines and context lines never occupy a range. +2. Look up each line's final `LineAttribution` and map it to a `RangeAttribution`: `Ai { session_id, model_id }` (empty session treated as absent), `NonExclusive`, or `Unknown`. `NonExclusive` and `Unknown` both serialize as `{ "type": "unknown" }` but stay distinct internally. `NonExclusive` is never reported as `human`. +3. Validate positions: line numbers must be ≥ 1 and strictly increasing; when the committed blob is available each line must exist there and carry the text the patch recorded (ignoring its `\n`/`\r\n` terminator). Any disagreement fails trace generation with `Invalid Agent Trace range position for '' ...` instead of emitting an invalid coordinate. +4. Coalesce contiguous lines whose `RangeAttribution` is identical (same classification, same accepted session, same model including absence). Ranges never cross files, gaps, sessions, models, or decision kinds; hunk boundaries play no role, so re-partitioning hunks cannot change output. +5. Group ranges of a file into conversations by provenance: one per accepted session; one per model for session-less AI evidence; one for `NonExclusive`; one for `Unknown`. Adjacent lines from different sessions are therefore separate conversations, and different models never share fabricated model provenance. + +**Contributors.** + +- A homogeneous conversation carries the conversation-level contributor (`{ "type": "ai", "model_id"? }` or `{ "type": "unknown" }`) and no range overrides. +- A conversation that is one established session but whose ranges carry different models keeps a single entry (splitting it would misrepresent one session as several) with conversation contributor `{ "type": "ai" }` and an explicit `contributor` override on every range. +- If a conversation had no common classification, the conversation-level contributor would be omitted; range overrides carry the attribution. +- SCE never generates `mixed` (partial AI coverage of a hunk is not evidence of mixed authorship) and never generates `human`. Both remain valid standard types and are read back from existing records. + +**Content hashes** (`agent_trace/committed_content.rs`). For an emitted inclusive range `[start_line, end_line]` the hash input is exactly the contiguous bytes of those lines in the committed blob at `vcs.revision`, including each line's own terminator (LF or CRLF) and excluding a final newline that does not exist. Algorithm: Murmur3 x86 32-bit, seed `0`, formatted `murmur3:<8 lowercase hex digits>`. Paths, line numbers, hunk headers, `+`/`-` markers, attribution, session/model/trace IDs, and any version prefix are not part of the input, so identical byte spans hash identically wherever they appear. Bytes are never reconstructed from `TouchedLine.content`, which has lost terminators. + +**Committed-blob retrieval.** `build_agent_trace_from_evidence` takes a `CommittedFileReader`. The post-commit hook passes `GitCommittedFiles::new(repository_root, commit_oid)` for the captured commit ID (the same ID whose patch is parsed, captured with `git show --no-ext-diff --no-textconv --` so patch lines describe raw blobs). It runs `git --literal-pathspecs ls-tree -z --full-tree -- ` and `git cat-file blob `: committed objects only, no working tree, index, network, or object writes. Each path is read at most once per trace (`CommittedFileCache`), and only for files with surviving added lines. Outcomes: + +- `Blob(bytes)` — positions are validated and every range gets a hash. +- `NotLineAddressable` (for example a submodule gitlink) — the file emits no ranges. +- `Unavailable` — ranges keep their patch positions and omit `content_hash`; no hash is fabricated and the post-commit flow continues. + +Range building and hashing are pure and unit-tested with in-memory readers. + +**Deletions.** Removed lines have no position at the recorded revision, so they never produce ranges, are never moved onto a nearby surviving line, and never become zero-length ranges. Their decisions still count in `metadata.sce.line_changes`. A deletion-only commit, a deleted file, or a pure rename can legitimately produce `"files": []` while its counters are non-zero. Mutation-lineage deletion attribution remains out of scope. + +**Deleted `.patch` files.** A deleted `*.patch` file is an ordinary deleted file: its literal deleted lines are counted and nothing else. The former expansion of its embedded diff into synthetic `TraceFile` entries was removed because those changes cannot be verified against the recorded revision. ## Separated direct/mutation evidence @@ -33,13 +63,14 @@ File identity requires an established coordinate system (`DiffTracePathOrigin`, | Type | Purpose | | ----------------------- | ------------------------------------------------------------------------------------------------------------ | -| `HunkContributor` | Enum: `Ai`, `Mixed`, `Unknown` | +| `ContributorType` | Standard contributor enum: `Human`, `Ai`, `Mixed`, `Unknown` (SCE generates only `Ai` and `Unknown`) | | `AgentTraceEvidence` | Builder input: `repository_root`, borrowed per-row `direct_sources`, target-shaped `mutation_ai_patch` (with scope provenance), and `mutation_non_ai_patch` | -| `Contributor` | Nested per-conversation object carrying `type: HunkContributor` and optional `model_id` omitted when absent | +| `Contributor` | Conversation or range contributor: `type: ContributorType` 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` | -| `Conversation` | Per-hunk entry: trace lookup `url`, nested contributor, `ranges` (currently exactly one range derived from `post_commit_patch`), and optional `related` omitted when `None` | -| `TraceFile` | Per-file entry: path + conversations | +| `LineRange` | Inclusive 1-indexed post-commit span: `start_line`, `end_line`, optional `content_hash`, optional `contributor` override | +| `Conversation` | Ranges sharing provenance: trace lookup `url`, optional `contributor`, `ranges`, optional `related` | +| `TraceFile` | Per-file entry: post-commit path + conversations ordered by first line | +| `CommittedFileReader` | Seam returning `CommittedFile::{Blob, NotLineAddressable, Unavailable}` for a post-commit path; `GitCommittedFiles` reads one exact commit | | `AgentTraceVcs` | Optional top-level VCS metadata object carrying `type` + `revision` when present | | `AgentTraceTool` | Optional top-level tool metadata object carrying optional `name` + optional `version` | | `AgentTraceMetadata` | Top-level implementation metadata object carrying SCE-owned metadata | @@ -48,11 +79,11 @@ File identity requires an established coordinate system (`DiffTracePathOrigin`, | `LineChangeAttribution` | `metadata.sce.line_changes` shape: `{ ai, non_exclusive, unknown }`, each a `LineChangeCounts`, `#[serde(default)]` | | `AgentTrace` | Top-level payload: `version`, `id`, `timestamp`, optional `vcs`, optional `tool`, `metadata`, `files` | -All types are `serde`-serializable with `snake_case` field naming. `Conversation.url` is always serialized as `https://sce.crocoder.dev/conversations/{agent_trace.id}` for the generated top-level trace ID. `Conversation.contributor` serializes as a nested object with a JSON field named `type`; `model_id` is present only when every attributed line in the hunk has one agreed concrete value. `Conversation.related` is optional and omitted when `None` (`skip_serializing_if = "Option::is_none"`) and populated from the sessions of attributed lines as deduplicated session links. +All types are `serde`-serializable with `snake_case` field naming. `Conversation.url` is always serialized as `https://sce.crocoder.dev/conversations/{agent_trace.id}` for the generated top-level trace ID. `Conversation.contributor`, `LineRange.contributor`, `LineRange.content_hash`, and `Conversation.related` are optional and omitted when `None`; existing records with a required hash or a `mixed` contributor still deserialize. The persisted Agent Trace path is covered by `cli/src/services/hooks/commit_hooks/attribution_tests.rs`: lineage-attributed -hunks retain their resolved model and canonical session link, stale or +ranges retain their resolved model and canonical session link, stale or overridden direct candidates never reach the payload, and mutation protocol attribution is not itself scope provenance. @@ -67,14 +98,16 @@ Current output includes top-level metadata fields with this contract: - when `vcs` is emitted, `vcs.type` is sourced from the schema-aligned enum (`git | jj | hg | svn`) and `vcs.revision` is sourced from `AgentTraceMetadataInput.commit_revision` - `tool` is derived from accepted direct sources only (see rule 5 above); `AgentTraceMetadataInput` no longer carries tool metadata. When emitted it serializes as `{ "name": string, "version"?: string }` - `metadata.sce.version` is always emitted and is sourced from `env!("CARGO_PKG_VERSION")`, the compiled `sce` CLI package version; it is implementation metadata and does not change top-level Agent Trace `version` semantics -- `metadata.sce.line_changes` is always emitted (`{ ai, non_exclusive, unknown }`, each `{ added, removed }`) and carries exact **per-line** attribution counts: `line_change_attribution(...)` walks every touched line of the canonical `post_commit_patch` once and increments exactly one bucket from that line's final `LineAttribution` decision — the same decision map the hunk classification uses, never a second attribution pass. Buckets mean: +- `metadata.sce.line_changes` is always emitted (`{ ai, non_exclusive, unknown }`, each `{ added, removed }`) and carries exact **per-line** attribution counts: `line_change_attribution(...)` walks every touched line of the canonical `post_commit_patch` once and increments exactly one bucket from that line's final `LineAttribution` decision — the same decision map ranges are built from, never a second attribution pass and never derived from conversations or ranges. Buckets mean: - `ai` — accepted AI-attributed lines (AI-exclusive mutation lineage, or accepted direct evidence for an unresolved line); - `non_exclusive` — mutation evidence established a non-exclusive or unscoped (contended/ineligible) transition; this is not a claim of human authorship, and direct AI evidence cannot move these lines to `ai`; - `unknown` — attribution is unresolved (no accepted evidence, or only conflicting direct candidates). - `mixed` is only a hunk-level presentation classification and has no counter bucket; a mixed hunk contributes each of its lines to that line's own bucket. Counter totals exactly cover the canonical committed patch (`ai + non_exclusive + unknown` equals its added and removed touched-line counts), are independent of hunk boundaries and range presentation, never count hunk context lines, and never deduplicate identical lines. Removed lines are not yet resolved by mutation lineage, so they count as `ai` only with accepted exact direct evidence and are otherwise `unknown`. The deleted-`.patch` embedded-expansion branch counts only the deleted file's literal `post_commit_patch` lines, never the embedded reconstructed hunks used to synthesize that branch's `Conversation` entries. `#[serde(default)]` on `line_changes`, its buckets, and its parent keeps older payloads deserializing (the previous `mixed` key is ignored and `non_exclusive` defaults to zero); historical traces are not migrated + There is no `mixed` bucket. Counter totals exactly cover the canonical committed patch (`ai + non_exclusive + unknown` equals its added and removed touched-line counts), are independent of hunk boundaries and range presentation, never count hunk context lines, and never deduplicate identical lines. Removed lines are not yet resolved by mutation lineage, so they count as `ai` only with accepted exact direct evidence and are otherwise `unknown`. A deleted `.patch` file counts only its literal deleted lines. `#[serde(default)]` on `line_changes`, its buckets, and its parent keeps older payloads deserializing (the previous `mixed` key is ignored and `non_exclusive` defaults to zero); historical traces are not migrated - every `Conversation.url` is the absolute URI `https://sce.crocoder.dev/conversations/{agent_trace.id}` derived from the generated top-level `AgentTrace.id`; all conversations in one payload therefore share the same URL +Example for a hunk `@@ -10,2 +10,4 @@` that removes one line, adds two session-A lines and one unresolved line, and keeps one context line: the context and removed lines produce no ranges, and nothing is `mixed`. + ```json { "version": "0.1.0", @@ -88,9 +121,9 @@ Current output includes top-level metadata fields with this contract: "sce": { "version": "0.2.0", "line_changes": { - "ai": { "added": 5, "removed": 0 }, + "ai": { "added": 2, "removed": 0 }, "non_exclusive": { "added": 0, "removed": 0 }, - "unknown": { "added": 0, "removed": 0 } + "unknown": { "added": 1, "removed": 1 } } } }, @@ -102,11 +135,17 @@ Current output includes top-level metadata fields with this contract: "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", "contributor": { "type": "ai", "model_id": "model-ai" }, "ranges": [ - { - "start_line": 10, - "end_line": 14, - "content_hash": "murmur3:a1b2c3d4" - } + { "start_line": 10, "end_line": 11, "content_hash": "murmur3:" } + ], + "related": [ + { "type": "session", "url": "https://sce.crocoder.dev/sessions/" } + ] + }, + { + "url": "https://sce.crocoder.dev/conversations/01962f15-2d3d-7c85-9f6b-0a8b4f6b2fd1", + "contributor": { "type": "unknown" }, + "ranges": [ + { "start_line": 12, "end_line": 12, "content_hash": "murmur3:" } ] } ] @@ -117,14 +156,15 @@ Current output includes top-level metadata fields with this contract: ## Public API -- `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_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, classifies each hunk from the union of direct and mutation-derived AI coverage, unions direct/mutation related sessions, and emits a model only when the present evidence sources agree under the combined rule; top-level `tool` is derived from the direct sources supporting finally accepted lines. +- `build_agent_trace_from_evidence(evidence: AgentTraceEvidence, post_commit_patch, metadata, committed_files: &dyn CommittedFileReader) -> Result` — separated-evidence entrypoint (see [Separated direct/mutation evidence](#separated-directmutation-evidence)); resolves per-line decisions, builds counters and revision-positioned ranges from them, and derives `tool` from the direct sources supporting finally accepted lines. Fails only when a range position contradicts the committed blob. +- `GitCommittedFiles::new(repository_root, revision)` — production `CommittedFileReader` for one exact commit. ## 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. -- Evidence fixtures (`direct_only`, `exclusive_without_direct`, `direct_plus_mutation`, `partial_combined`, `newer_nonexclusive_blocks`, `mutation_only_no_provenance`) pair `direct.patch` + `mutation_ai.patch` + `post_commit.patch` and drive `build_agent_trace_from_evidence`, pinning mutation-only coverage with and without resolved provenance, the combined model-agreement and session-union rules, the absence of fabricated metadata when provenance is unknown, and the direct-only case (empty `mutation_ai_patch`), whose classification and provenance match the direct-intersection rule. -- Per-line counter tests in `agent_trace/tests.rs` cover mixed, all-AI, and all-unknown hunks; non-exclusive override of direct AI evidence; conflicting direct sessions; mutation-only attribution without a model; removed lines without direct evidence; duplicate identical lines; multi-hunk/multi-file totals; deleted `.patch` files; the `resolve_line` precedence table; and a seeded randomized test asserting exact totals and invariance under hunk re-partitioning, file reordering, and single-decision demotion. `hooks/commit_hooks/attribution_tests.rs` checks persisted `agent_traces.trace_json` counters against the in-memory trace through the real Git + Turso post-commit flow. +- Golden fixtures under `cli/src/services/agent_trace/fixtures/**/golden.json` pin the complete exported `files` structure, including exact range positions, contributors, range overrides, related sessions, and `content_hash` values, plus `metadata.sce.line_changes`. Fixture tests supply committed content through a test reader that rebuilds new-side bytes from the raw `post_commit.patch` text (exact terminators and `\ No newline at end of file`; lines outside hunks become placeholders) without the production parser. The golden hashes were cross-checked by an independent Murmur3 implementation that re-derived every range from the raw patch. +- Evidence fixtures (`direct_only`, `exclusive_without_direct`, `direct_plus_mutation`, `partial_combined`, `newer_nonexclusive_blocks`, `mutation_only_no_provenance`) pair `direct.patch` + `mutation_ai.patch` + `post_commit.patch` and drive `build_agent_trace_from_evidence`, pinning mutation-only coverage with and without resolved provenance, the absence of fabricated metadata when provenance is unknown, and the direct-only case. +- `agent_trace/tests.rs` covers: hunks with context and AI/unresolved additions (no `mixed`); additions separated by context; new, deleted, renamed, and pure-rename files; deletion-only commits; sessions and models never merged; one session with several models using range overrides; `Unknown`/`NonExclusive` never coalesced; legacy `mixed`/`human` record deserialization; Murmur3 vectors; LF/CRLF/missing-final-newline/whitespace/Unicode byte spans; hash stability across paths, positions, and hunks; unavailable and non-line-addressable blobs; rejection of positions that contradict the blob; per-line counters; the `resolve_line` precedence table; and seeded randomized tests that check ranges, hashes, provenance, schema validity, and hunk re-partitioning invariance against an independently computed model. +- `hooks/commit_hooks/attribution_tests.rs` runs the real Git + Turso post-commit flow, including a commit with CRLF, Unicode, a missing final newline, a deleted file, and a deleted `.patch` file, and checks every persisted range and hash against blobs read independently with `git cat-file`. - 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 From 9154b8772dac67f8f73bb3907ad1c8f5bfa01220 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 6 Nov 2026 21:54:58 +0100 Subject: [PATCH 07/11] patch+agent-trace: Decode Git paths and fail closed on committed-file lookup Decode Git's C-quoted paths to preserve exact repository identity and reject malformed or ambiguous patch headers. Make committed-file reads distinguish missing, non-line-addressable, and explicitly unverified files while propagating tree and blob lookup failures instead of persisting unverified ranges. Add focused parser, committed-content, and post-commit path identity coverage, and update the attribution documentation. Co-authored-by: SCE --- cli/src/services/agent_trace.rs | 12 +- .../services/agent_trace/committed_content.rs | 215 +++++++-- .../agent_trace/committed_content/tests.rs | 447 ++++++++++++++++++ cli/src/services/agent_trace/tests.rs | 35 +- cli/src/services/hooks/commit_hooks.rs | 4 + .../hooks/commit_hooks/attribution_tests.rs | 2 +- .../hooks/commit_hooks/path_identity_tests.rs | 418 ++++++++++++++++ .../runtime/mutation_attribution/tests.rs | 4 +- cli/src/services/patch.rs | 153 +++--- cli/src/services/patch/git_path.rs | 302 ++++++++++++ cli/src/services/patch/tests.rs | 167 +++++++ .../cli/mutation-trace-agent-attribution.md | 7 +- context/glossary.md | 4 +- context/sce/agent-trace-minimal-generator.md | 34 +- 14 files changed, 1675 insertions(+), 129 deletions(-) create mode 100644 cli/src/services/agent_trace/committed_content/tests.rs create mode 100644 cli/src/services/hooks/commit_hooks/path_identity_tests.rs create mode 100644 cli/src/services/patch/git_path.rs create mode 100644 cli/src/services/patch/tests.rs diff --git a/cli/src/services/agent_trace.rs b/cli/src/services/agent_trace.rs index 70465ddad..094930d76 100644 --- a/cli/src/services/agent_trace.rs +++ b/cli/src/services/agent_trace.rs @@ -22,7 +22,7 @@ mod ranges; mod repository_path; #[cfg(test)] -pub use committed_content::NoCommittedFiles; +pub use committed_content::UnverifiedCommittedFiles; use committed_content::{content_hash, CommittedFileCache, CommittedLines}; pub use committed_content::{CommittedFile, CommittedFileReader, GitCommittedFiles}; pub use evidence::DirectEvidenceSource; @@ -566,10 +566,16 @@ fn build_trace_file( } let path = logical_path(file); - let committed = match committed_files.get(path) { + let committed = match committed_files + .get(path) + .with_context(|| format!("Failed to verify Agent Trace ranges for '{path}'."))? + { CommittedFile::Blob(bytes) => Some(CommittedLines::new(bytes)), CommittedFile::NotLineAddressable => return Ok(None), - CommittedFile::Unavailable => None, + CommittedFile::Missing => bail!( + "Invalid Agent Trace file '{path}': the patch adds lines to it, but it is absent from revision '{revision}'." + ), + CommittedFile::Unverified => None, }; validate_added_line_positions(path, &lines, committed.as_ref(), revision)?; diff --git a/cli/src/services/agent_trace/committed_content.rs b/cli/src/services/agent_trace/committed_content.rs index 982169e79..eff211b14 100644 --- a/cli/src/services/agent_trace/committed_content.rs +++ b/cli/src/services/agent_trace/committed_content.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::fmt; use std::io::Cursor; use std::path::Path; use std::process::Command; @@ -7,30 +8,113 @@ use murmur3::murmur3_32; const CONTENT_HASH_PREFIX: &str = "murmur3:"; const CONTENT_HASH_SEED: u32 = 0; -const GIT_BLOB_OBJECT_TYPE: &str = "blob"; +const GIT_PROGRAM: &str = "git"; #[derive(Clone, Debug, Eq, PartialEq)] pub enum CommittedFile { Blob(Vec), NotLineAddressable, - Unavailable, + Missing, + Unverified, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CommittedFileReadError { + TreeLookupFailed { + path: String, + revision: String, + detail: String, + }, + MalformedTreeEntry { + path: String, + revision: String, + detail: String, + }, + BlobReadFailed { + path: String, + revision: String, + object_id: String, + detail: String, + }, +} + +impl fmt::Display for CommittedFileReadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TreeLookupFailed { + path, + revision, + detail, + } => write!( + f, + "Failed to look up '{path}' in revision '{revision}' with 'git ls-tree': {detail}" + ), + Self::MalformedTreeEntry { + path, + revision, + detail, + } => write!( + f, + "Unexpected 'git ls-tree' entry for '{path}' in revision '{revision}': {detail}" + ), + Self::BlobReadFailed { + path, + revision, + object_id, + detail, + } => write!( + f, + "Failed to read blob '{object_id}' for '{path}' in revision '{revision}' with 'git cat-file': {detail}" + ), + } + } +} + +impl std::error::Error for CommittedFileReadError {} + +pub type CommittedFileRead = Result; + pub trait CommittedFileReader { - fn read_committed_file(&self, path: &str) -> CommittedFile; + fn read_committed_file(&self, path: &str) -> CommittedFileRead; } #[cfg(test)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct NoCommittedFiles; +pub struct UnverifiedCommittedFiles; #[cfg(test)] -impl CommittedFileReader for NoCommittedFiles { - fn read_committed_file(&self, _path: &str) -> CommittedFile { - CommittedFile::Unavailable +impl CommittedFileReader for UnverifiedCommittedFiles { + fn read_committed_file(&self, _path: &str) -> CommittedFileRead { + Ok(CommittedFile::Unverified) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum GitEntryKind { + RegularFile, + Symlink, + Gitlink, + Directory, +} + +impl GitEntryKind { + pub(crate) fn classify(mode: &str, object_type: &str) -> Option { + match (mode, object_type) { + ("100644" | "100755", "blob") => Some(Self::RegularFile), + ("120000", "blob") => Some(Self::Symlink), + ("160000", "commit") => Some(Self::Gitlink), + ("040000", "tree") => Some(Self::Directory), + _ => None, + } } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct GitTreeEntry { + pub(crate) kind: GitEntryKind, + pub(crate) object_id: String, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct GitCommittedFiles<'a> { repository_root: &'a Path, @@ -45,53 +129,105 @@ impl<'a> GitCommittedFiles<'a> { } } - fn git_stdout(&self, args: &[&str]) -> Option> { - let output = Command::new("git") + fn git_stdout(&self, args: &[&str]) -> Result, String> { + let output = Command::new(GIT_PROGRAM) .arg("--literal-pathspecs") .args(args) .current_dir(self.repository_root) .output() - .ok()?; - output.status.success().then_some(output.stdout) + .map_err(|error| format!("could not run git: {error}"))?; + if output.status.success() { + return Ok(output.stdout); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + Err(if stderr.is_empty() { + format!("git exited with {}", output.status) + } else { + stderr + }) } } impl CommittedFileReader for GitCommittedFiles<'_> { - fn read_committed_file(&self, path: &str) -> CommittedFile { - let Some(listing) = - self.git_stdout(&["ls-tree", "-z", "--full-tree", self.revision, "--", path]) - else { - return CommittedFile::Unavailable; - }; - let Some((object_type, object_id)) = parse_ls_tree_entry(&listing, path) else { - return CommittedFile::Unavailable; + fn read_committed_file(&self, path: &str) -> CommittedFileRead { + let listing = self + .git_stdout(&["ls-tree", "-z", "--full-tree", self.revision, "--", path]) + .map_err(|detail| CommittedFileReadError::TreeLookupFailed { + path: path.to_owned(), + revision: self.revision.to_owned(), + detail, + })?; + let entry = parse_ls_tree_entry(&listing, path).map_err(|detail| { + CommittedFileReadError::MalformedTreeEntry { + path: path.to_owned(), + revision: self.revision.to_owned(), + detail, + } + })?; + let Some(entry) = entry else { + return Ok(CommittedFile::Missing); }; - if object_type != GIT_BLOB_OBJECT_TYPE { - return CommittedFile::NotLineAddressable; + if entry.kind != GitEntryKind::RegularFile { + return Ok(CommittedFile::NotLineAddressable); } - self.git_stdout(&["cat-file", "blob", object_id]) - .map_or(CommittedFile::Unavailable, CommittedFile::Blob) + self.git_stdout(&["cat-file", "blob", &entry.object_id]) + .map(CommittedFile::Blob) + .map_err(|detail| CommittedFileReadError::BlobReadFailed { + path: path.to_owned(), + revision: self.revision.to_owned(), + object_id: entry.object_id, + detail, + }) } } -fn parse_ls_tree_entry<'o>(listing: &'o [u8], path: &str) -> Option<(&'o str, &'o str)> { - listing +pub(crate) fn parse_ls_tree_entry( + listing: &[u8], + path: &str, +) -> Result, String> { + let mut found = None; + for record in listing .split(|byte| *byte == 0) - .filter_map(|entry| std::str::from_utf8(entry).ok()) - .find_map(|entry| { - let (header, entry_path) = entry.split_once('\t')?; - if entry_path != path { - return None; - } - let mut fields = header.split(' '); - let _mode = fields.next()?; - Some((fields.next()?, fields.next()?)) - }) + .filter(|record| !record.is_empty()) + { + let tab = record + .iter() + .position(|byte| *byte == b'\t') + .ok_or_else(|| { + format!( + "record without a path: {:?}", + String::from_utf8_lossy(record) + ) + })?; + let (header, entry_path) = (&record[..tab], &record[tab + 1..]); + let header = std::str::from_utf8(header) + .map_err(|_| format!("non-UTF-8 header: {:?}", String::from_utf8_lossy(header)))?; + let fields: Vec<&str> = header.split(' ').collect(); + let [mode, object_type, object_id] = fields.as_slice() else { + return Err(format!("expected ' ', got {header:?}")); + }; + if object_id.is_empty() || !object_id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!("invalid object id {object_id:?}")); + } + let kind = GitEntryKind::classify(mode, object_type) + .ok_or_else(|| format!("unsupported mode '{mode}' for object type '{object_type}'"))?; + if entry_path != path.as_bytes() { + continue; + } + if found.is_some() { + return Err(String::from("more than one entry matches the path")); + } + found = Some(GitTreeEntry { + kind, + object_id: (*object_id).to_owned(), + }); + } + Ok(found) } pub(super) struct CommittedFileCache<'r> { reader: &'r dyn CommittedFileReader, - files: HashMap, + files: HashMap, } impl<'r> CommittedFileCache<'r> { @@ -102,10 +238,12 @@ impl<'r> CommittedFileCache<'r> { } } - pub(super) fn get(&mut self, path: &str) -> &CommittedFile { + pub(super) fn get(&mut self, path: &str) -> Result<&CommittedFile, CommittedFileReadError> { self.files .entry(path.to_owned()) .or_insert_with(|| self.reader.read_committed_file(path)) + .as_ref() + .map_err(Clone::clone) } } @@ -159,3 +297,6 @@ pub(crate) fn content_hash(bytes: &[u8]) -> String { .expect("murmur3 hashing from an in-memory cursor should not fail"); format!("{CONTENT_HASH_PREFIX}{hash:08x}") } + +#[cfg(test)] +mod tests; diff --git a/cli/src/services/agent_trace/committed_content/tests.rs b/cli/src/services/agent_trace/committed_content/tests.rs new file mode 100644 index 000000000..ac7051852 --- /dev/null +++ b/cli/src/services/agent_trace/committed_content/tests.rs @@ -0,0 +1,447 @@ +use std::path::Path; +use std::process::{Command, Stdio}; + +use std::io::Write as _; + +use super::{ + parse_ls_tree_entry, CommittedFile, CommittedFileReadError, CommittedFileReader, + GitCommittedFiles, GitEntryKind, +}; + +const REGULAR_PATH: &str = "src/lib.rs"; +const EXECUTABLE_PATH: &str = "bin/run.sh"; +const SYMLINK_PATH: &str = "src/link.rs"; +const GITLINK_PATH: &str = "vendor/sub"; +const DIRECTORY_PATH: &str = "src"; +const REGULAR_BYTES: &[u8] = b"pub fn f() {}\n"; +const EXECUTABLE_BYTES: &[u8] = b"#!/bin/sh\necho hi\n"; +const OBJECT_ID: &str = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"; + +fn git_with_input(root: &Path, args: &[&str], input: &[u8]) -> String { + let mut child = Command::new("git") + .args(args) + .current_dir(root) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_AUTHOR_DATE", "@1793960000 +0000") + .env("GIT_COMMITTER_DATE", "@1793960000 +0000") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("git should start"); + child + .stdin + .take() + .expect("stdin") + .write_all(input) + .expect("write stdin"); + let output = child.wait_with_output().expect("git should finish"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("utf-8") + .trim() + .to_owned() +} + +fn git(root: &Path, args: &[&str]) -> String { + git_with_input(root, args, b"") +} + +fn hash_object(root: &Path, bytes: &[u8]) -> String { + git_with_input(root, &["hash-object", "-w", "--stdin"], bytes) +} + +fn commit_index(root: &Path) -> String { + let tree = git(root, &["write-tree"]); + git( + root, + &[ + "-c", + "user.email=t@example.invalid", + "-c", + "user.name=t", + "commit-tree", + &tree, + "-m", + "entries", + ], + ) +} + +struct EntryRepo { + dir: tempfile::TempDir, + revision: String, +} + +impl EntryRepo { + fn new() -> Self { + let dir = tempfile::tempdir().expect("temp dir"); + let root = dir.path(); + git(root, &["init", "-q"]); + let regular = hash_object(root, REGULAR_BYTES); + let executable = hash_object(root, EXECUTABLE_BYTES); + let link_target = hash_object(root, b"lib.rs"); + let submodule_commit = { + let empty_tree = git_with_input(root, &["mktree"], b""); + git( + root, + &[ + "-c", + "user.email=t@example.invalid", + "-c", + "user.name=t", + "commit-tree", + &empty_tree, + "-m", + "sub", + ], + ) + }; + for (mode, object, path) in [ + ("100644", ®ular, REGULAR_PATH), + ("100755", &executable, EXECUTABLE_PATH), + ("120000", &link_target, SYMLINK_PATH), + ("160000", &submodule_commit, GITLINK_PATH), + ] { + git( + root, + &[ + "update-index", + "--add", + "--cacheinfo", + &format!("{mode},{object},{path}"), + ], + ); + } + let revision = commit_index(root); + EntryRepo { dir, revision } + } + + fn reader(&self) -> GitCommittedFiles<'_> { + GitCommittedFiles::new(self.dir.path(), &self.revision) + } +} + +#[test] +fn regular_and_executable_files_are_read_as_blobs() { + let repo = EntryRepo::new(); + assert_eq!( + repo.reader().read_committed_file(REGULAR_PATH), + Ok(CommittedFile::Blob(REGULAR_BYTES.to_vec())) + ); + assert_eq!( + repo.reader().read_committed_file(EXECUTABLE_PATH), + Ok(CommittedFile::Blob(EXECUTABLE_BYTES.to_vec())) + ); +} + +#[test] +fn symlinks_gitlinks_and_directories_are_not_line_addressable() { + let repo = EntryRepo::new(); + for path in [SYMLINK_PATH, GITLINK_PATH, DIRECTORY_PATH] { + assert_eq!( + repo.reader().read_committed_file(path), + Ok(CommittedFile::NotLineAddressable), + "{path}" + ); + } +} + +#[test] +fn absent_paths_are_missing_and_never_matched_by_prefix_or_suffix() { + let repo = EntryRepo::new(); + for path in [ + "src/absent.rs", + "lib.rs", + "src/lib", + "SRC/lib.rs", + "src/*.rs", + ] { + assert_eq!( + repo.reader().read_committed_file(path), + Ok(CommittedFile::Missing), + "{path}" + ); + } +} + +#[test] +fn a_failed_tree_lookup_is_an_error_not_a_missing_file() { + let repo = EntryRepo::new(); + let unknown = "0".repeat(40); + let error = GitCommittedFiles::new(repo.dir.path(), &unknown) + .read_committed_file(REGULAR_PATH) + .expect_err("unknown revision must fail"); + assert!( + matches!(&error, CommittedFileReadError::TreeLookupFailed { path, revision, .. } + if path == REGULAR_PATH && *revision == unknown), + "{error:?}" + ); + + let not_a_repository = tempfile::tempdir().expect("plain dir"); + assert!(matches!( + GitCommittedFiles::new(not_a_repository.path(), &repo.revision) + .read_committed_file(REGULAR_PATH), + Err(CommittedFileReadError::TreeLookupFailed { .. }) + )); +} + +#[test] +fn a_missing_blob_object_is_a_blob_read_error() { + let repo = EntryRepo::new(); + let root = repo.dir.path(); + let absent_blob = "1".repeat(40); + let tree = git_with_input( + root, + &["mktree", "--missing"], + format!("100644 blob {absent_blob}\tghost.rs\n").as_bytes(), + ); + let revision = git( + root, + &[ + "-c", + "user.email=t@example.invalid", + "-c", + "user.name=t", + "commit-tree", + &tree, + "-m", + "ghost", + ], + ); + let error = GitCommittedFiles::new(root, &revision) + .read_committed_file("ghost.rs") + .expect_err("absent blob must fail"); + assert!( + matches!(&error, CommittedFileReadError::BlobReadFailed { object_id, .. } + if *object_id == absent_blob), + "{error:?}" + ); +} + +#[test] +fn modes_and_object_types_must_agree() { + let accepted = [ + ("100644", "blob", GitEntryKind::RegularFile), + ("100755", "blob", GitEntryKind::RegularFile), + ("120000", "blob", GitEntryKind::Symlink), + ("160000", "commit", GitEntryKind::Gitlink), + ("040000", "tree", GitEntryKind::Directory), + ]; + for (mode, object_type, kind) in accepted { + assert_eq!(GitEntryKind::classify(mode, object_type), Some(kind)); + } + for (mode, object_type) in [ + ("100644", "tree"), + ("120000", "commit"), + ("160000", "blob"), + ("040000", "blob"), + ("100664", "blob"), + ("100644", "tag"), + ] { + assert_eq!(GitEntryKind::classify(mode, object_type), None); + } +} + +fn listing(records: &[&str]) -> Vec { + records + .iter() + .flat_map(|record| record.bytes().chain([0])) + .collect() +} + +#[test] +fn malformed_or_ambiguous_listings_are_rejected() { + let malformed = [ + listing(&["100644 blob"]), + listing(&[&format!("100644 blob {OBJECT_ID} src/lib.rs")]), + listing(&[&format!("100644 {OBJECT_ID}\tsrc/lib.rs")]), + listing(&["100644 blob not-hex\tsrc/lib.rs"]), + listing(&[&format!("100644 tree {OBJECT_ID}\tsrc/lib.rs")]), + listing(&[&format!("100644 blob {OBJECT_ID}\tsrc/lib.rs")]), + listing(&[ + &format!("100644 blob {OBJECT_ID}\tsrc/lib.rs"), + &format!("100755 blob {OBJECT_ID}\tsrc/lib.rs"), + ]), + ]; + for bytes in malformed { + assert!( + parse_ls_tree_entry(&bytes, REGULAR_PATH).is_err(), + "{:?}", + String::from_utf8_lossy(&bytes) + ); + } +} + +#[test] +fn listings_match_paths_exactly() { + let bytes = listing(&[&format!("100644 blob {OBJECT_ID}\tsrc/caf\u{e9}.rs")]); + assert_eq!( + parse_ls_tree_entry(&bytes, "src/caf\u{e9}.rs").map(|entry| entry.map(|entry| entry.kind)), + Ok(Some(GitEntryKind::RegularFile)) + ); + assert_eq!(parse_ls_tree_entry(&bytes, "caf\u{e9}.rs"), Ok(None)); + assert_eq!(parse_ls_tree_entry(b"", REGULAR_PATH), Ok(None)); +} + +fn added_file_patch(path: &str, mode: &str, lines: &[&str]) -> String { + let mut text = format!( + "diff --git a/{path} b/{path}\nnew file mode {mode}\n--- /dev/null\n+++ b/{path}\n@@ -0,0 +1,{} @@\n", + lines.len() + ); + for line in lines { + text.push('+'); + text.push_str(line); + text.push('\n'); + } + text +} + +fn entry_patch() -> crate::services::patch::ParsedPatch { + let text = [ + added_file_patch(REGULAR_PATH, "100644", &["pub fn f() {}"]), + added_file_patch(EXECUTABLE_PATH, "100755", &["#!/bin/sh", "echo hi"]), + added_file_patch(SYMLINK_PATH, "120000", &["lib.rs"]), + added_file_patch(GITLINK_PATH, "160000", &["Subproject commit 0000"]), + ] + .concat(); + crate::services::patch::parse_patch(&text, None).expect("entry patch parses") +} + +fn build_entry_trace( + patch: &crate::services::patch::ParsedPatch, + reader: &dyn CommittedFileReader, +) -> anyhow::Result { + use crate::services::agent_trace::{ + build_agent_trace_from_evidence, AgentTraceEvidence, AgentTraceMetadataInput, + RepositoryRoot, + }; + let empty = crate::services::patch::ParsedPatch { files: Vec::new() }; + build_agent_trace_from_evidence( + AgentTraceEvidence { + repository_root: &RepositoryRoot::new(Path::new("/workspace/project")), + direct_sources: &[], + mutation_ai_patch: &empty, + mutation_non_ai_patch: &empty, + }, + patch, + AgentTraceMetadataInput { + commit_timestamp: "2026-11-06T10:00:00Z", + commit_revision: OBJECT_ID, + vcs_type: None, + }, + reader, + ) +} + +#[test] +fn only_regular_git_entries_receive_ranges_while_counters_cover_every_entry() { + let repo = EntryRepo::new(); + let trace = build_entry_trace(&entry_patch(), &repo.reader()).expect("trace builds"); + let value = serde_json::to_value(&trace).expect("serialize"); + crate::services::agent_trace::validate_agent_trace_value(&value).expect("schema"); + + let ranges: Vec<(&str, u64, u64, Option<&str>)> = trace + .files + .iter() + .flat_map(|file| { + file.conversations + .iter() + .flat_map(|conversation| conversation.ranges.iter()) + .map(|range| { + ( + file.path.as_str(), + range.start_line, + range.end_line, + range.content_hash.as_deref(), + ) + }) + }) + .collect(); + let executable_hash = super::content_hash(EXECUTABLE_BYTES); + let regular_hash = super::content_hash(REGULAR_BYTES); + assert_eq!( + ranges, + vec![ + (REGULAR_PATH, 1, 1, Some(regular_hash.as_str())), + (EXECUTABLE_PATH, 1, 2, Some(executable_hash.as_str())), + ] + ); + assert_eq!(trace.metadata.sce.line_changes.unknown.added, 5); + assert_eq!(trace.metadata.sce.line_changes.unknown.removed, 0); +} + +struct FailingReader(CommittedFileReadError); + +impl CommittedFileReader for FailingReader { + fn read_committed_file(&self, _path: &str) -> super::CommittedFileRead { + Err(self.0.clone()) + } +} + +struct FixedReader(CommittedFile); + +impl CommittedFileReader for FixedReader { + fn read_committed_file(&self, _path: &str) -> super::CommittedFileRead { + Ok(self.0.clone()) + } +} + +#[test] +fn missing_paths_and_read_errors_never_become_hashless_ranges() { + let patch = crate::services::patch::parse_patch( + &added_file_patch(REGULAR_PATH, "100644", &["pub fn f() {}"]), + None, + ) + .expect("patch parses"); + let detail = String::from("fatal: boom"); + let failures: [(&dyn CommittedFileReader, &str); 4] = [ + ( + &FixedReader(CommittedFile::Missing), + "is absent from revision", + ), + ( + &FailingReader(CommittedFileReadError::TreeLookupFailed { + path: REGULAR_PATH.to_owned(), + revision: OBJECT_ID.to_owned(), + detail: detail.clone(), + }), + "with 'git ls-tree': fatal: boom", + ), + ( + &FailingReader(CommittedFileReadError::MalformedTreeEntry { + path: REGULAR_PATH.to_owned(), + revision: OBJECT_ID.to_owned(), + detail: detail.clone(), + }), + "Unexpected 'git ls-tree' entry", + ), + ( + &FailingReader(CommittedFileReadError::BlobReadFailed { + path: REGULAR_PATH.to_owned(), + revision: OBJECT_ID.to_owned(), + object_id: OBJECT_ID.to_owned(), + detail, + }), + "with 'git cat-file': fatal: boom", + ), + ]; + for (reader, expected) in failures { + let error = build_entry_trace(&patch, reader).expect_err("must fail closed"); + let chain = format!("{error:#}"); + assert!(chain.contains(expected), "{chain}"); + assert!(chain.contains(REGULAR_PATH), "{chain}"); + assert!(chain.contains(OBJECT_ID), "{chain}"); + } + + let unverified = + build_entry_trace(&patch, &FixedReader(CommittedFile::Unverified)).expect("best effort"); + let range = &unverified.files[0].conversations[0].ranges[0]; + assert_eq!((range.start_line, range.end_line), (1, 1)); + assert_eq!(range.content_hash, None); +} diff --git a/cli/src/services/agent_trace/tests.rs b/cli/src/services/agent_trace/tests.rs index 405fe81b5..7765ce0c6 100644 --- a/cli/src/services/agent_trace/tests.rs +++ b/cli/src/services/agent_trace/tests.rs @@ -5,13 +5,14 @@ use std::path::Path; use rand::{rngs::StdRng, Rng, SeedableRng}; use serde_json::Value; +use super::committed_content::CommittedFileRead; use super::{ build_agent_trace_from_evidence, line_change_attribution, resolve_line, resolve_patch_lines, validate_agent_trace_value, AcceptedDirectLine, AgentTraceEvidence, AgentTraceMetadataInput, AgentTraceVcsType, CommittedFile, CommittedFileReader, ContributorType, DirectEvidenceSource, DirectLineEvidence, LineAttribution, LineChangeAttribution, LineChangeCounts, - MutationEvidenceIndex, MutationLineEvidence, NoCommittedFiles, RepositoryRoot, ResolvedLine, - AGENT_TRACE_VERSION, + MutationEvidenceIndex, MutationLineEvidence, RepositoryRoot, ResolvedLine, + UnverifiedCommittedFiles, AGENT_TRACE_VERSION, }; use crate::services::{ agent_trace::{agent_trace_conversation_url, agent_trace_session_url}, @@ -153,12 +154,10 @@ impl PatchNewSideFiles { } impl CommittedFileReader for PatchNewSideFiles { - fn read_committed_file(&self, path: &str) -> CommittedFile { - self.0 - .get(path) - .map_or(CommittedFile::Unavailable, |bytes| { - CommittedFile::Blob(bytes.clone()) - }) + fn read_committed_file(&self, path: &str) -> CommittedFileRead { + Ok(self.0.get(path).map_or(CommittedFile::Missing, |bytes| { + CommittedFile::Blob(bytes.clone()) + })) } } @@ -624,7 +623,7 @@ fn build_trace( sources, mutation_ai_patch, mutation_non_ai_patch, - &NoCommittedFiles, + &UnverifiedCommittedFiles, ) } @@ -2291,7 +2290,7 @@ fn crlf_and_missing_final_newline_are_hashed_byte_exactly() { } #[test] -fn unavailable_committed_content_keeps_positions_without_a_hash() { +fn an_explicitly_unverified_reader_keeps_positions_without_a_hash() { let committed = parse_patch(EXAMPLE_PATCH, None).expect("example parses"); let trace = build_trace(&committed, &[], &no_mutation(), &no_mutation()); let range = &trace.files[0].conversations[0].ranges[0]; @@ -2299,10 +2298,10 @@ fn unavailable_committed_content_keeps_positions_without_a_hash() { assert_eq!(range.content_hash, None); } -struct FixedCommittedFile(CommittedFile); +struct FixedCommittedFile(CommittedFileRead); impl CommittedFileReader for FixedCommittedFile { - fn read_committed_file(&self, _path: &str) -> CommittedFile { + fn read_committed_file(&self, _path: &str) -> CommittedFileRead { self.0.clone() } } @@ -2315,7 +2314,7 @@ fn non_line_addressable_paths_emit_no_ranges() { &[], &no_mutation(), &no_mutation(), - &FixedCommittedFile(CommittedFile::NotLineAddressable), + &FixedCommittedFile(Ok(CommittedFile::NotLineAddressable)), ); assert!(trace.files.is_empty()); assert_eq!(buckets(&trace), ((0, 0), (0, 0), (3, 1))); @@ -2567,12 +2566,10 @@ fn expected_ranges(file: &RandomFile) -> Vec<(u64, u64, ExpectedKey)> { struct RandomFiles(BTreeMap>); impl CommittedFileReader for RandomFiles { - fn read_committed_file(&self, path: &str) -> CommittedFile { - self.0 - .get(path) - .map_or(CommittedFile::Unavailable, |bytes| { - CommittedFile::Blob(bytes.clone()) - }) + fn read_committed_file(&self, path: &str) -> CommittedFileRead { + Ok(self.0.get(path).map_or(CommittedFile::Missing, |bytes| { + CommittedFile::Blob(bytes.clone()) + })) } } diff --git a/cli/src/services/hooks/commit_hooks.rs b/cli/src/services/hooks/commit_hooks.rs index b651ad673..963c020a9 100644 --- a/cli/src/services/hooks/commit_hooks.rs +++ b/cli/src/services/hooks/commit_hooks.rs @@ -807,6 +807,10 @@ pub(crate) fn post_commit_patch_error(detail: &str, context: &str) -> String { #[path = "commit_hooks/attribution_tests.rs"] mod attribution_tests; +#[cfg(test)] +#[path = "commit_hooks/path_identity_tests.rs"] +mod path_identity_tests; + #[cfg(test)] mod tests { use std::cell::RefCell; diff --git a/cli/src/services/hooks/commit_hooks/attribution_tests.rs b/cli/src/services/hooks/commit_hooks/attribution_tests.rs index f7ab42879..d9e1aedf8 100644 --- a/cli/src/services/hooks/commit_hooks/attribution_tests.rs +++ b/cli/src/services/hooks/commit_hooks/attribution_tests.rs @@ -670,7 +670,7 @@ fn one_direct_source_line_covers_only_its_own_duplicate_occurrence() { commit_revision: "0000000000000000000000000000000000000001", vcs_type: None, }, - &crate::services::agent_trace::NoCommittedFiles, + &crate::services::agent_trace::UnverifiedCommittedFiles, ) .expect("trace builds"); diff --git a/cli/src/services/hooks/commit_hooks/path_identity_tests.rs b/cli/src/services/hooks/commit_hooks/path_identity_tests.rs new file mode 100644 index 000000000..8186fe4af --- /dev/null +++ b/cli/src/services/hooks/commit_hooks/path_identity_tests.rs @@ -0,0 +1,418 @@ +use std::collections::BTreeMap; +use std::io::Cursor; +use std::path::Path; +use std::process::Command; + +use anyhow::{anyhow, Result}; +use serde_json::Value; + +use crate::services::agent_trace::{agent_trace_session_url, validate_agent_trace_value}; +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_db::{DiffTraceInsert, PAYLOAD_TYPE_PATCH}; +use crate::services::mutation_trace::attribution::MutationAttributionResult; +use crate::services::patch::{ParsedPatch, TouchedLineKind}; + +use super::{ + capture_post_commit_patch_from_git, run_post_commit_agent_trace_flow_with, + run_post_commit_intersection_flow_with, PostCommitIntersectionFlowResult, +}; + +const REMOTE_URL: &str = "https://example.invalid/repo.git"; +const BASE_TIME_S: i64 = 1_793_960_000; +const COMMIT_TIME_S: i64 = BASE_TIME_S + 1_000; +const SESSION: &str = "cc_session-unicode"; +const MODEL: &str = "model-unicode"; +const TOOL: &str = "claude"; + +const UNICODE_PATH: &str = "src/café.rs"; +const PLAIN_PATH: &str = "src/plain.rs"; +const EXECUTABLE_PATH: &str = "bin/run.sh"; +const SYMLINK_PATH: &str = "src/link.rs"; +const SEPARATOR_PATH: &str = "src/x b/y.rs"; +const TRAILING_PATH: &str = "src/trailing "; +const ESCAPED_PATHS: [&str; 5] = [ + "src/file name.rs", + "src/file\tname.rs", + "src/file\nname.rs", + "src/file\"name.rs", + "src/file\\name.rs", +]; +const UNICODE_LINES: [&str; 3] = ["fn café() {", " \"ünïcødé ✓\"", "}"]; + +const fn ms(seconds: i64) -> i64 { + seconds * 1_000 +} + +fn git(root: &Path, args: &[&str], time_s: Option) -> String { + let mut command = Command::new("git"); + command + .args(args) + .current_dir(root) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1"); + if let Some(time_s) = time_s { + let date = format!("@{time_s} +0000"); + command + .env("GIT_AUTHOR_DATE", &date) + .env("GIT_COMMITTER_DATE", &date); + } + let output = command.output().expect("git command should run"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("git output is UTF-8") + .trim() + .to_owned() +} + +fn committed_blob(root: &Path, path: &str) -> Vec { + let output = Command::new("git") + .args(["cat-file", "blob", &format!("HEAD:{path}")]) + .current_dir(root) + .output() + .expect("git cat-file should run"); + assert!(output.status.success(), "no committed blob for {path:?}"); + output.stdout +} + +fn independent_hash(bytes: &[u8]) -> String { + let hash = murmur3::murmur3_32(&mut Cursor::new(bytes), 0).expect("murmur3"); + format!("murmur3:{hash:08x}") +} + +fn source_lines(path: &str) -> Vec { + if path == UNICODE_PATH { + return UNICODE_LINES + .iter() + .map(|line| (*line).to_owned()) + .collect(); + } + vec![format!("const PATH: &str = {path:?};"), String::from("pub fn f() {}")] +} + +fn source_text(path: &str) -> String { + let mut text = source_lines(path).join("\n"); + text.push('\n'); + text +} + +fn line_addressable_paths() -> Vec<&'static str> { + let mut paths = vec![ + UNICODE_PATH, + PLAIN_PATH, + EXECUTABLE_PATH, + SEPARATOR_PATH, + TRAILING_PATH, + ]; + paths.extend(ESCAPED_PATHS); + paths.sort_unstable(); + paths +} + +struct QuotedPathRepo { + dir: tempfile::TempDir, +} + +impl QuotedPathRepo { + fn new() -> Self { + let dir = tempfile::tempdir().expect("temp dir"); + let root = dir.path(); + git(root, &["init", "-q", "-b", "main"], None); + for (key, value) in [ + ("user.email", "paths@example.invalid"), + ("user.name", "Path Identity Test"), + ("commit.gpgsign", "false"), + ("core.quotePath", "true"), + ("core.symlinks", "true"), + ] { + git(root, &["config", key, value], None); + } + std::fs::write(root.join("README"), "base\n").expect("write base"); + git(root, &["add", "-A"], None); + git(root, &["commit", "-q", "-m", "base"], Some(BASE_TIME_S)); + + for path in line_addressable_paths() { + let file = root.join(path); + std::fs::create_dir_all(file.parent().expect("parent")).expect("dir"); + std::fs::write(file, source_text(path)).expect("write source"); + } + std::os::unix::fs::symlink("plain.rs", root.join(SYMLINK_PATH)).expect("symlink"); + git(root, &["add", "-A"], None); + git( + root, + &["update-index", "--chmod=+x", "--", EXECUTABLE_PATH], + None, + ); + git(root, &["commit", "-q", "-m", "paths"], Some(COMMIT_TIME_S)); + QuotedPathRepo { dir } + } + + fn root(&self) -> &Path { + self.dir.path() + } +} + +fn unicode_direct_patch() -> String { + let quoted = r#""a/src/caf\303\251.rs""#; + let quoted_new = r#""b/src/caf\303\251.rs""#; + let mut text = format!( + "diff --git {quoted} {quoted_new}\nnew file mode 100644\n--- /dev/null\n+++ {quoted_new}\n@@ -0,0 +1,{} @@\n", + UNICODE_LINES.len() + ); + for line in UNICODE_LINES { + text.push('+'); + text.push_str(line); + text.push('\n'); + } + text +} + +async fn open_db() -> (tempfile::TempDir, RepositoryAgentTraceDb) { + let db_dir = tempfile::tempdir().expect("db dir"); + let db = RepositoryAgentTraceDb::new_at(db_dir.path().join("agent-trace.db")) + .await + .expect("open agent trace db"); + (db_dir, db) +} + +async fn insert_unicode_direct_evidence(db: &RepositoryAgentTraceDb) { + db.insert_diff_trace(DiffTraceInsert { + time_ms: ms(COMMIT_TIME_S) - 500, + session_id: SESSION, + patch: &unicode_direct_patch(), + model_id: Some(MODEL), + tool_name: TOOL, + tool_version: None, + payload_type: PAYLOAD_TYPE_PATCH, + }) + .await + .expect("insert diff trace"); +} + +async fn intersection_flow( + repo: &QuotedPathRepo, + db: &RepositoryAgentTraceDb, +) -> PostCommitIntersectionFlowResult { + run_post_commit_intersection_flow_with( + repo.root(), + capture_post_commit_patch_from_git, + || Ok(ms(COMMIT_TIME_S) + 500), + async |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms).await, + async |insert| { + db.insert_post_commit_patch_intersection(insert) + .await + .map(|_| ()) + }, + ) + .await + .expect("intersection flow") +} + +fn no_mutation() -> MutationAttributionResult { + let empty = ParsedPatch { files: Vec::new() }; + MutationAttributionResult { + mutation_ai_patch: empty.clone(), + resolved_non_ai_patch: empty.clone(), + unresolved_patch: empty, + } +} + +async fn persist( + db: &mut RepositoryAgentTraceDb, + flow: &PostCommitIntersectionFlowResult, +) -> Result<()> { + run_post_commit_agent_trace_flow_with( + flow, + None, + REMOTE_URL, + &no_mutation(), + None, + |value| validate_agent_trace_value(value).map_err(|error| anyhow!(error.to_string())), + async |insert| db.insert_agent_trace(insert).await.map(|_| ()), + ) + .await + .map(|_| ()) +} + +async fn persisted_traces(db: &RepositoryAgentTraceDb) -> Vec { + db.query_map("SELECT trace_json FROM agent_traces", (), |row| { + Ok(row.get(0)?) + }) + .await + .expect("read agent traces") +} + +fn added_lines(patch: &ParsedPatch) -> u64 { + patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .filter(|line| line.kind == TouchedLineKind::Added) + .count() as u64 +} + +#[cfg(unix)] +#[tokio::test] +async fn quoted_unicode_and_escaped_paths_persist_exact_identities_and_verified_hashes() { + let repo = QuotedPathRepo::new(); + let (_db_dir, mut db) = open_db().await; + insert_unicode_direct_evidence(&db).await; + + let flow = intersection_flow(&repo, &db).await; + let mut parsed_paths: Vec<&str> = flow + .post_commit_data + .parsed_patch + .files + .iter() + .map(|file| file.new_path.as_str()) + .collect(); + parsed_paths.sort_unstable(); + let mut expected_parsed = line_addressable_paths(); + expected_parsed.push(SYMLINK_PATH); + expected_parsed.sort_unstable(); + assert_eq!(parsed_paths, expected_parsed); + + persist(&mut db, &flow).await.expect("agent trace flow"); + let traces = persisted_traces(&db).await; + assert_eq!(traces.len(), 1); + let value: Value = serde_json::from_str(&traces[0]).expect("trace JSON"); + validate_agent_trace_value(&value).expect("persisted trace validates"); + + let files: BTreeMap = value["files"] + .as_array() + .expect("files") + .iter() + .map(|file| (file["path"].as_str().expect("path").to_owned(), file)) + .collect(); + assert_eq!( + files.keys().map(String::as_str).collect::>(), + line_addressable_paths() + ); + + for (path, file) in &files { + let blob = committed_blob(repo.root(), path); + assert_eq!(blob, source_text(path).into_bytes(), "{path:?}"); + let ranges: Vec<&Value> = file["conversations"] + .as_array() + .expect("conversations") + .iter() + .flat_map(|conversation| conversation["ranges"].as_array().expect("ranges")) + .collect(); + assert_eq!(ranges.len(), 1, "{path:?}"); + assert_eq!(ranges[0]["start_line"], 1); + assert_eq!(ranges[0]["end_line"], source_lines(path).len()); + assert_eq!( + ranges[0]["content_hash"].as_str(), + Some(independent_hash(&blob).as_str()), + "{path:?}" + ); + } + + let unicode = &files[UNICODE_PATH]["conversations"][0]; + assert_eq!(unicode["contributor"]["type"], "ai"); + assert_eq!(unicode["contributor"]["model_id"], MODEL); + assert_eq!( + unicode["related"][0]["url"].as_str(), + Some(agent_trace_session_url(SESSION).as_str()) + ); + for (path, file) in &files { + if path != UNICODE_PATH { + assert_eq!(file["conversations"][0]["contributor"]["type"], "unknown"); + } + } + + let total_added = added_lines(&flow.post_commit_data.parsed_patch); + let expected_added: u64 = line_addressable_paths() + .iter() + .map(|path| source_lines(path).len() as u64) + .sum::() + + 1; + assert_eq!(total_added, expected_added); + let line_changes = &value["metadata"]["sce"]["line_changes"]; + assert_eq!(line_changes["ai"]["added"], UNICODE_LINES.len()); + assert_eq!( + line_changes["unknown"]["added"], + expected_added - UNICODE_LINES.len() as u64 + ); + assert_eq!(line_changes["non_exclusive"]["added"], 0); + for bucket in ["ai", "non_exclusive", "unknown"] { + assert_eq!(line_changes[bucket]["removed"], 0); + } +} + +async fn assert_flow_persists_nothing( + repo: &QuotedPathRepo, + edit: impl FnOnce(&mut PostCommitIntersectionFlowResult), + expected: &str, +) { + let (_db_dir, mut db) = open_db().await; + let mut flow = intersection_flow(repo, &db).await; + edit(&mut flow); + let error = persist(&mut db, &flow) + .await + .expect_err("unverifiable committed content must fail the flow"); + let chain = format!("{error:#}"); + assert!(chain.contains(expected), "{chain}"); + assert!(persisted_traces(&db).await.is_empty()); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_path_missing_from_the_recorded_revision_persists_no_trace() { + let repo = QuotedPathRepo::new(); + assert_flow_persists_nothing( + &repo, + |flow| { + let file = flow + .post_commit_data + .parsed_patch + .files + .iter_mut() + .find(|file| file.new_path == UNICODE_PATH) + .expect("unicode file"); + file.new_path = String::from("src/cafe\u{301}.rs"); + }, + "is absent from revision", + ) + .await; +} + +#[cfg(unix)] +#[tokio::test] +async fn a_failed_tree_lookup_persists_no_trace() { + let repo = QuotedPathRepo::new(); + assert_flow_persists_nothing( + &repo, + |flow| flow.post_commit_data.commit_oid = "0".repeat(40), + "with 'git ls-tree'", + ) + .await; +} + +#[cfg(unix)] +#[tokio::test] +async fn a_missing_blob_object_persists_no_trace() { + let repo = QuotedPathRepo::new(); + let blob = git( + repo.root(), + &["rev-parse", &format!("HEAD:{PLAIN_PATH}")], + None, + ); + assert_flow_persists_nothing( + &repo, + |flow| { + let object = flow + .repository_root + .join(".git/objects") + .join(&blob[..2]) + .join(&blob[2..]); + std::fs::remove_file(object).expect("remove loose blob"); + }, + "with 'git cat-file'", + ) + .await; +} diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs index d3ba4e5a1..f0dd036bf 100644 --- a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs @@ -3,7 +3,7 @@ use std::process::Command; use super::*; use crate::services::agent_trace::{ build_agent_trace_from_evidence, AgentTrace, AgentTraceEvidence, AgentTraceMetadataInput, - ContributorType, DirectEvidenceSource, NoCommittedFiles, RepositoryRoot, + ContributorType, DirectEvidenceSource, RepositoryRoot, UnverifiedCommittedFiles, }; const PLAN_PATH: &str = "plan.md"; @@ -177,7 +177,7 @@ fn build_trace( commit_revision: "0000000000000000000000000000000000000001", vcs_type: None, }, - &NoCommittedFiles, + &UnverifiedCommittedFiles, ) .expect("agent trace builds") } diff --git a/cli/src/services/patch.rs b/cli/src/services/patch.rs index 13fd143ea..2a85d7e93 100644 --- a/cli/src/services/patch.rs +++ b/cli/src/services/patch.rs @@ -16,6 +16,14 @@ use serde::{Deserialize, Serialize}; +mod git_path; + +use git_path::{decode_marker_path, decode_path, split_diff_header, GitPathError}; + +const GIT_SOURCE_PREFIX: &str = "a/"; +const GIT_DESTINATION_PREFIX: &str = "b/"; +const DEV_NULL: &str = "/dev/null"; + /// Top-level parsed patch containing one or more file changes. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ParsedPatch { @@ -164,10 +172,13 @@ pub fn parse_patch(input: &str, session_id: Option<&str>) -> Result FileBuilder::new(paths.old_path, paths.new_path), + None => FileBuilder::unresolved(line), + }); continue; } @@ -175,7 +186,7 @@ pub fn parse_patch(input: &str, session_id: Option<&str>) -> Result) -> Result) -> Result) -> Result) -> Result) -> Result, + old_path: Option, + new_path: Option, kind: Option, hunks: Vec, } @@ -266,19 +285,30 @@ struct FileBuilder { impl FileBuilder { fn new(old_path: String, new_path: String) -> Self { Self { - old_path, - new_path, + header: None, + old_path: Some(old_path), + new_path: Some(new_path), + kind: None, + hunks: Vec::new(), + } + } + + fn unresolved(header: &str) -> Self { + Self { + header: Some(header.to_owned()), + old_path: None, + new_path: None, kind: None, hunks: Vec::new(), } } fn set_old_path(&mut self, path: String) { - self.old_path = path; + self.old_path = Some(path); } fn set_new_path(&mut self, path: String) { - self.new_path = path; + self.new_path = Some(path); } fn mark_added(&mut self) { @@ -297,20 +327,27 @@ impl FileBuilder { self.hunks.push(hunk); } - fn build(self) -> PatchFileChange { + fn build(self) -> Result { + let (Some(old_path), Some(new_path)) = (self.old_path, self.new_path) else { + return Err(ParseError { + message: format!( + "cannot determine both paths of ambiguous header {:?}", + self.header.unwrap_or_default() + ), + }); + }; let inferred_kind = self.kind.unwrap_or_else(|| { infer_file_kind_from_hunks(&self.hunks) - .unwrap_or_else(|| determine_file_kind(&self.old_path, &self.new_path)) + .unwrap_or_else(|| determine_file_kind(&old_path, &new_path)) }); - let (old_path, new_path) = - normalize_paths_for_kind(self.old_path, self.new_path, inferred_kind); + let (old_path, new_path) = normalize_paths_for_kind(old_path, new_path, inferred_kind); - PatchFileChange { + Ok(PatchFileChange { old_path, new_path, kind: inferred_kind, hunks: self.hunks, - } + }) } } @@ -364,32 +401,11 @@ fn determine_file_kind(old_path: &str, new_path: &str) -> FileChangeKind { } } -/// Parse a `diff --git a/old b/new` header line (after stripping the prefix). -fn parse_git_diff_header(rest: &str) -> DiffPaths { - // Format: "a/old_path b/new_path" - // The paths can contain spaces, so we need to split on " b/" carefully. - // Git format: diff --git a/path b/path - if let Some(idx) = rest.find(" b/") { - let old = rest[..idx] - .strip_prefix("a/") - .unwrap_or(&rest[..idx]) - .to_string(); - let new = rest[idx + 3..] - .strip_prefix("b/") - .unwrap_or(&rest[idx + 3..]) - .to_string(); - DiffPaths { - old_path: old, - new_path: new, - } - } else { - // Fallback: treat the whole thing as both paths - let path = rest.trim().to_string(); - DiffPaths { - old_path: path.clone(), - new_path: path, - } - } +fn parse_git_diff_header(rest: &str) -> Result, GitPathError> { + Ok(split_diff_header(rest)?.map(|(old, new)| DiffPaths { + old_path: strip_git_prefix(old, GIT_SOURCE_PREFIX), + new_path: strip_git_prefix(new, GIT_DESTINATION_PREFIX), + })) } struct DiffPaths { @@ -397,22 +413,40 @@ struct DiffPaths { new_path: String, } -/// Parse a `---` or `+++` path line, stripping prefixes and trailing whitespace. -fn parse_diff_path(rest: &str) -> String { - let trimmed = rest.trim_end(); - // Strip common prefixes: a/ for git-style, /dev/null for new/deleted files - if trimmed == "/dev/null" { - return String::new(); +fn parse_diff_path(rest: &str, prefix: &str) -> Result { + if !rest.starts_with('"') && rest.split('\t').next() == Some(DEV_NULL) { + return Ok(String::new()); + } + decode_marker_path(rest).map(|path| strip_git_prefix(path, prefix)) +} + +fn strip_git_prefix(path: String, prefix: &str) -> String { + match path.strip_prefix(prefix) { + Some(stripped) => stripped.to_owned(), + None => path, } - if let Some(stripped) = trimmed.strip_prefix("a/") { - return stripped.to_string(); +} + +fn apply_metadata_path(fb: &mut FileBuilder, line: &str) -> Result<(), ParseError> { + let decode = |rest: &str| decode_path(rest).map_err(|error| path_error(line, &error)); + if let Some(rest) = line + .strip_prefix("rename from ") + .or_else(|| line.strip_prefix("copy from ")) + { + fb.set_old_path(decode(rest)?); + } else if let Some(rest) = line + .strip_prefix("rename to ") + .or_else(|| line.strip_prefix("copy to ")) + { + fb.set_new_path(decode(rest)?); } - if let Some(stripped) = trimmed.strip_prefix("b/") { - return stripped.to_string(); + Ok(()) +} + +fn path_error(line: &str, error: &GitPathError) -> ParseError { + ParseError { + message: format!("invalid path in {line:?}: {error}"), } - // Strip absolute path prefix if present (Index:-style diffs sometimes - // use absolute paths like /home/user/repo/file) - trimmed.to_string() } /// Parse a hunk header (the part after `@@ `) and then consume hunk body lines @@ -539,3 +573,6 @@ fn parse_range_part(s: &str, prefix: char) -> Result<(u64, u64), ParseError> { }; Ok((start, count)) } + +#[cfg(test)] +mod tests; diff --git a/cli/src/services/patch/git_path.rs b/cli/src/services/patch/git_path.rs new file mode 100644 index 000000000..82efeb7c9 --- /dev/null +++ b/cli/src/services/patch/git_path.rs @@ -0,0 +1,302 @@ +use std::fmt; + +const QUOTE: u8 = b'"'; +const BACKSLASH: u8 = b'\\'; +const GIT_SOURCE_PREFIX: &str = "a/"; +const GIT_DESTINATION_PREFIX: &str = "b/"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitPathError { + UnterminatedQuote, + InvalidEscape(String), + NulByte, + InvalidUtf8, + TrailingContent(String), + MissingSecondPath, +} + +impl fmt::Display for GitPathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnterminatedQuote => write!(f, "quoted path has no closing quote"), + Self::InvalidEscape(escape) => { + write!(f, "quoted path contains invalid escape sequence {escape:?}") + } + Self::NulByte => write!(f, "quoted path decodes to a NUL byte"), + Self::InvalidUtf8 => write!(f, "quoted path does not decode to valid UTF-8"), + Self::TrailingContent(rest) => { + write!(f, "unexpected content {rest:?} after quoted path") + } + Self::MissingSecondPath => write!(f, "header does not name two paths"), + } + } +} + +impl std::error::Error for GitPathError {} + +pub(super) fn read_quoted(input: &str) -> Result<(String, &str), GitPathError> { + let bytes = input.as_bytes(); + debug_assert_eq!(bytes.first(), Some("E)); + let mut decoded = Vec::new(); + let mut index = 1; + while let Some(&byte) = bytes.get(index) { + match byte { + QUOTE => return Ok((into_path(decoded)?, &input[index + 1..])), + BACKSLASH => { + let (value, width) = unescape(&bytes[index + 1..])?; + decoded.push(value); + index += 1 + width; + } + _ => { + decoded.push(byte); + index += 1; + } + } + } + Err(GitPathError::UnterminatedQuote) +} + +fn unescape(after_backslash: &[u8]) -> Result<(u8, usize), GitPathError> { + let simple = match after_backslash.first() { + Some(b'a') => Some(0x07), + Some(b'b') => Some(0x08), + Some(b't') => Some(b'\t'), + Some(b'n') => Some(b'\n'), + Some(b'v') => Some(0x0b), + Some(b'f') => Some(0x0c), + Some(b'r') => Some(b'\r'), + Some("E) => Some(QUOTE), + Some(&BACKSLASH) => Some(BACKSLASH), + _ => None, + }; + if let Some(value) = simple { + return Ok((value, 1)); + } + if let [high @ b'0'..=b'3', mid @ b'0'..=b'7', low @ b'0'..=b'7', ..] = after_backslash { + return Ok(((high - b'0') << 6 | (mid - b'0') << 3 | (low - b'0'), 3)); + } + let shown = after_backslash.iter().take(3).copied().collect::>(); + Err(GitPathError::InvalidEscape(format!( + "\\{}", + String::from_utf8_lossy(&shown) + ))) +} + +fn into_path(bytes: Vec) -> Result { + if bytes.contains(&0) { + return Err(GitPathError::NulByte); + } + String::from_utf8(bytes).map_err(|_| GitPathError::InvalidUtf8) +} + +pub(super) fn decode_path(token: &str) -> Result { + if !token.starts_with('"') { + return Ok(token.to_owned()); + } + match read_quoted(token)? { + (path, "") => Ok(path), + (_, rest) => Err(GitPathError::TrailingContent(rest.to_owned())), + } +} + +pub(super) fn decode_marker_path(rest: &str) -> Result { + if rest.starts_with('"') { + let (path, after) = read_quoted(rest)?; + if after.is_empty() || after.starts_with('\t') { + return Ok(path); + } + return Err(GitPathError::TrailingContent(after.to_owned())); + } + Ok(rest.split('\t').next().unwrap_or_default().to_owned()) +} + +pub(super) fn split_diff_header(rest: &str) -> Result, GitPathError> { + if rest.starts_with('"') { + let (old, after) = read_quoted(rest)?; + let new = after + .strip_prefix(' ') + .ok_or(GitPathError::MissingSecondPath)?; + return Ok(Some((old, decode_path(new)?))); + } + if rest.contains('"') { + let index = rest.find(" \"").ok_or(GitPathError::MissingSecondPath)?; + return Ok(Some(( + rest[..index].to_owned(), + decode_path(&rest[index + 1..])?, + ))); + } + if let Some((old, new)) = equal_split(rest) { + return Ok(Some((old.to_owned(), new.to_owned()))); + } + let mut splits = rest.match_indices(" b/").map(|(index, _)| index); + match (splits.next(), splits.next()) { + (Some(index), None) => Ok(Some(( + rest[..index].to_owned(), + rest[index + 1..].to_owned(), + ))), + (Some(_), Some(_)) => Ok(None), + (None, _) => Err(GitPathError::MissingSecondPath), + } +} + +fn equal_split(rest: &str) -> Option<(&str, &str)> { + if rest.len().is_multiple_of(2) { + return None; + } + let middle = rest.len() / 2; + if rest.as_bytes()[middle] != b' ' { + return None; + } + let (old, new) = (&rest[..middle], &rest[middle + 1..]); + let unprefixed_old = old.strip_prefix(GIT_SOURCE_PREFIX).unwrap_or(old); + let unprefixed_new = new.strip_prefix(GIT_DESTINATION_PREFIX).unwrap_or(new); + (unprefixed_old == unprefixed_new).then_some((old, new)) +} + +#[cfg(test)] +mod tests { + use super::{decode_marker_path, decode_path, split_diff_header, GitPathError}; + + #[test] + fn unquoted_paths_are_literal() { + for path in [ + "src/lib.rs", + "src/file name.rs", + "src/trailing ", + "a/b c/d", + ] { + assert_eq!(decode_path(path).as_deref(), Ok(path)); + } + } + + #[test] + fn quoted_paths_decode_every_escape_git_writes() { + let cases = [ + (r#""a/src/caf\303\251.rs""#, "a/src/café.rs"), + (r#""a/src/file\tname.rs""#, "a/src/file\tname.rs"), + (r#""a/src/file\nname.rs""#, "a/src/file\nname.rs"), + (r#""a/src/file\"name.rs""#, "a/src/file\"name.rs"), + (r#""a/src/file\\name.rs""#, "a/src/file\\name.rs"), + (r#""a/\a\b\v\f\r""#, "a/\x07\x08\x0b\x0c\r"), + (r#""a/\001\177""#, "a/\x01\x7f"), + (r#""a/\360\237\246\200 crab""#, "a/🦀 crab"), + ("\"a/raw café\"", "a/raw café"), + ]; + for (quoted, expected) in cases { + assert_eq!(decode_path(quoted).as_deref(), Ok(expected), "{quoted}"); + } + } + + #[test] + fn octal_escapes_are_bytes_not_code_points() { + assert_eq!( + decode_path(r#""caf\351.rs""#), + Err(GitPathError::InvalidUtf8) + ); + assert_eq!(decode_path(r#""\303""#), Err(GitPathError::InvalidUtf8)); + } + + #[test] + fn malformed_quoted_paths_are_rejected() { + assert_eq!( + decode_path(r#""src/lib.rs"#), + Err(GitPathError::UnterminatedQuote) + ); + assert_eq!( + decode_path(r#""src/lib.rs\""#), + Err(GitPathError::UnterminatedQuote) + ); + for invalid in [r#""\q""#, r#""\400""#, r#""\38""#, r#""\12""#, r#""\""#] { + assert!( + matches!( + decode_path(invalid), + Err(GitPathError::InvalidEscape(_) | GitPathError::UnterminatedQuote) + ), + "{invalid}" + ); + } + assert!(matches!( + decode_path(r#""\q""#), + Err(GitPathError::InvalidEscape(_)) + )); + assert_eq!(decode_path(r#""a\000b""#), Err(GitPathError::NulByte)); + assert_eq!( + decode_path(r#""a"b"#), + Err(GitPathError::TrailingContent(String::from("b"))) + ); + } + + #[test] + fn marker_paths_drop_only_the_tab_suffix() { + assert_eq!( + decode_marker_path("a/src/file name.rs\t").as_deref(), + Ok("a/src/file name.rs") + ); + assert_eq!( + decode_marker_path("a/src/trailing \t").as_deref(), + Ok("a/src/trailing ") + ); + assert_eq!(decode_marker_path("a/src/x ").as_deref(), Ok("a/src/x ")); + assert_eq!( + decode_marker_path("src/x\t(revision 12)").as_deref(), + Ok("src/x") + ); + assert_eq!( + decode_marker_path(concat!(r#""b/src/caf\303\251 x.rs""#, "\t")).as_deref(), + Ok("b/src/café x.rs") + ); + assert!(decode_marker_path(r#""b/x" y"#).is_err()); + } + + fn split(rest: &str) -> Option<(String, String)> { + split_diff_header(rest).expect("header splits") + } + + #[test] + fn header_splits_respect_quoting_and_identity() { + let pair = |old: &str, new: &str| Some((old.to_owned(), new.to_owned())); + assert_eq!( + split("a/src/lib.rs b/src/lib.rs"), + pair("a/src/lib.rs", "b/src/lib.rs") + ); + assert_eq!( + split("a/src/x b/y.rs b/src/x b/y.rs"), + pair("a/src/x b/y.rs", "b/src/x b/y.rs") + ); + assert_eq!( + split("a/src/trailing b/src/trailing "), + pair("a/src/trailing ", "b/src/trailing ") + ); + assert_eq!( + split(r#""a/src/caf\303\251.rs" "b/src/caf\303\251.rs""#), + pair("a/src/café.rs", "b/src/café.rs") + ); + assert_eq!( + split(r#""a/src/x\" b/y" "b/src/x\" b/y""#), + pair("a/src/x\" b/y", "b/src/x\" b/y") + ); + assert_eq!( + split(r#"a/old name.rs "b/caf\303\251.rs""#), + pair("a/old name.rs", "b/café.rs") + ); + assert_eq!( + split(r#""a/caf\303\251.rs" b/new name.rs"#), + pair("a/café.rs", "b/new name.rs") + ); + assert_eq!(split("a/old.rs b/new.rs"), pair("a/old.rs", "b/new.rs")); + } + + #[test] + fn ambiguous_or_incomplete_headers_are_not_guessed() { + assert_eq!(split("a/x b/y b/z"), None); + assert_eq!( + split_diff_header("a/only-one-path"), + Err(GitPathError::MissingSecondPath) + ); + assert_eq!( + split_diff_header(r#""a/x""#), + Err(GitPathError::MissingSecondPath) + ); + assert!(split_diff_header(r#""a/\xyz" "b/x""#).is_err()); + } +} diff --git a/cli/src/services/patch/tests.rs b/cli/src/services/patch/tests.rs new file mode 100644 index 000000000..8ca254221 --- /dev/null +++ b/cli/src/services/patch/tests.rs @@ -0,0 +1,167 @@ +use std::fmt::Write as _; + +use super::{parse_patch, FileChangeKind, PatchFileChange}; + +fn quote(path: &str) -> String { + let mut quoted = String::from("\""); + for byte in path.bytes() { + match byte { + b'"' => quoted.push_str("\\\""), + b'\\' => quoted.push_str("\\\\"), + b'\t' => quoted.push_str("\\t"), + b'\n' => quoted.push_str("\\n"), + 0x20..=0x7e => quoted.push(char::from(byte)), + _ => write!(quoted, "\\{byte:03o}").expect("write to string"), + } + } + quoted.push('"'); + quoted +} + +fn needs_quoting(path: &str) -> bool { + path.bytes() + .any(|byte| matches!(byte, b'"' | b'\\') || !(0x20..=0x7e).contains(&byte)) +} + +fn git_name(prefix: &str, path: &str) -> String { + let name = format!("{prefix}{path}"); + if needs_quoting(&name) { + quote(&name) + } else { + name + } +} + +fn marker_name(prefix: &str, path: &str) -> String { + let name = git_name(prefix, path); + if name.contains(' ') { + format!("{name}\t") + } else { + name + } +} + +fn modified_patch(path: &str) -> String { + format!( + "diff --git {} {}\nindex 1111111..2222222 100644\n--- {}\n+++ {}\n@@ -1 +1 @@\n-old\n+new\n", + git_name("a/", path), + git_name("b/", path), + marker_name("a/", path), + marker_name("b/", path), + ) +} + +fn single_file(text: &str) -> PatchFileChange { + let mut patch = parse_patch(text, None).expect("patch parses"); + assert_eq!(patch.files.len(), 1, "{text}"); + patch.files.remove(0) +} + +const PATHS: [&str; 10] = [ + "src/café.rs", + "src/file name.rs", + "src/file\tname.rs", + "src/file\nname.rs", + "src/file\"name.rs", + "src/file\\name.rs", + "src/x b/y.rs", + "src/trailing ", + "a/b/nested.rs", + "src/🦀.rs", +]; + +#[test] +fn git_paths_decode_to_their_exact_identity() { + for path in PATHS { + let file = single_file(&modified_patch(path)); + assert_eq!(file.old_path, path, "{path:?}"); + assert_eq!(file.new_path, path, "{path:?}"); + assert_eq!(file.kind, FileChangeKind::Modified, "{path:?}"); + assert_eq!(file.hunks[0].lines.len(), 2); + } +} + +#[test] +fn header_paths_stand_alone_when_no_markers_follow() { + for path in PATHS { + let text = format!( + "diff --git {} {}\nold mode 100644\nnew mode 100755\n", + git_name("a/", path), + git_name("b/", path), + ); + let file = single_file(&text); + assert_eq!( + (file.old_path.as_str(), file.new_path.as_str()), + (path, path) + ); + } +} + +#[test] +fn created_and_deleted_quoted_files_keep_dev_null_semantics() { + let path = "src/café.rs"; + let created = format!( + "diff --git {a} {b}\nnew file mode 100644\n--- /dev/null\n+++ {b}\n@@ -0,0 +1 @@\n+x\n", + a = git_name("a/", path), + b = git_name("b/", path), + ); + let file = single_file(&created); + assert_eq!(file.kind, FileChangeKind::Added); + assert_eq!((file.old_path.as_str(), file.new_path.as_str()), ("", path)); + + let deleted = format!( + "diff --git {a} {b}\ndeleted file mode 100644\n--- {a}\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n", + a = git_name("a/", path), + b = git_name("b/", path), + ); + let file = single_file(&deleted); + assert_eq!(file.kind, FileChangeKind::Deleted); + assert_eq!((file.old_path.as_str(), file.new_path.as_str()), (path, "")); +} + +#[test] +fn renames_take_their_paths_from_quoted_metadata() { + let text = format!( + "diff --git a/src/x b/y.rs b/src/x b/z.rs\nsimilarity index 100%\nrename from {}\nrename to {}\n", + "src/x b/y.rs", + quote("src/x b/z\u{e9}.rs"), + ); + let file = single_file(&text); + assert_eq!(file.kind, FileChangeKind::Renamed); + assert_eq!(file.old_path, "src/x b/y.rs"); + assert_eq!(file.new_path, "src/x b/zé.rs"); +} + +#[test] +fn ambiguous_headers_without_resolving_lines_are_rejected() { + let error = parse_patch( + "diff --git a/x b/y b/z\nold mode 100644\nnew mode 100755\n", + None, + ) + .expect_err("ambiguous header must not be guessed"); + assert!(error.message.contains("ambiguous header"), "{error}"); +} + +#[test] +fn malformed_quoted_paths_fail_explicitly() { + for text in [ + "diff --git \"a/caf\\q.rs\" \"b/caf\\q.rs\"\n", + "diff --git a/x b/x\n--- \"a/caf\\351.rs\"\n+++ b/x\n", + "diff --git a/x b/x\n--- a/x\n+++ \"b/unterminated\n", + "diff --git a/x b/x\nrename to \"b\\000\"\n", + ] { + let error = parse_patch(text, None).expect_err("malformed path must fail"); + assert!(error.message.starts_with("invalid path in"), "{error}"); + } +} + +#[test] +fn index_style_patches_still_parse() { + let text = "Index: src/lib.rs\n===================================================================\n--- src/lib.rs\t(revision 1)\n+++ src/lib.rs\t(working copy)\n@@ -1 +1 @@\n-old\n+new\n"; + let file = single_file(text); + assert_eq!( + (file.old_path.as_str(), file.new_path.as_str()), + ("src/lib.rs", "src/lib.rs") + ); + assert_eq!(file.kind, FileChangeKind::Modified); +} diff --git a/context/cli/mutation-trace-agent-attribution.md b/context/cli/mutation-trace-agent-attribution.md index 4fe7c58a3..33111d0d4 100644 --- a/context/cli/mutation-trace-agent-attribution.md +++ b/context/cli/mutation-trace-agent-attribution.md @@ -209,7 +209,12 @@ direct candidate matching in `cli/src/services/agent_trace/evidence.rs`: conversation per accepted session (or per model for session-less AI, or per unresolved decision kind). A Git hunk never decides a contributor, and SCE does not emit `mixed` for partial AI coverage. Hashes cover the exact - committed bytes of each range. See + committed bytes of each range. Paths are the decoded Git identity (C-quoted + names are decoded byte-exactly and must be UTF-8). Only regular and + executable blobs at the recorded revision are line-addressable; symlinks, + gitlinks, and directories emit no ranges but keep their counters. In the + post-commit hook a missing path or failed Git read aborts the trace instead + of persisting unverified ranges. See [../sce/agent-trace-minimal-generator.md](../sce/agent-trace-minimal-generator.md#ranges-and-conversations). - **What remains unsupported?** Removed lines are never resolved by lineage (only exact fresh direct evidence can cover them; otherwise they count as diff --git a/context/glossary.md b/context/glossary.md index 232ac715e..ed08dfcb3 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -89,7 +89,9 @@ - `LineAttribution`: The single final per-line attribution decision in `cli/src/services/agent_trace.rs` — `Ai(ResolvedLine)` (AI-exclusive mutation lineage, or accepted direct evidence for an otherwise unresolved line), `NonExclusive` (mutation lineage resolved a non-exclusive or unscoped transition; not human authorship; cannot be overridden by direct evidence), or `Unknown` (unresolved). Exactly one decision exists per canonical touched line and feeds both revision-positioned ranges and `metadata.sce.line_changes`. See `context/sce/agent-trace-minimal-generator.md`. - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace_from_evidence(...)` under `metadata.sce`, carrying `version` (sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`) and `line_changes` (exact `{ai,non_exclusive,unknown}` × `{added,removed}` `u64` per-line attribution counts: every canonical `post_commit_patch` touched line increments exactly one bucket from its final `LineAttribution` decision — the same decisions ranges are built from — so totals equal the committed patch's touched lines independent of hunk boundaries and of emitted ranges; `#[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`. - `ScopeProvenance`: Optional insert-once metadata keyed by a mutation `ScopeId`, storing canonical SCE `session_id` and nullable first-observed `model_id`. It is registered only at `Start` admission while the owning scope is `NeverSeen`, remains outside `ProtocolState` and the CAS transition, and is resolved during mutation projection. `ScopeId` proves mutation ownership; `ScopeProvenance` describes the owning scope. Model/session provenance is observational and never changes `AiExclusive`/`AiContended`/`IneligibleUnscoped` protocol decisions. -- `Agent Trace range content_hash`: Optional per-range `content_hash` emitted as `murmur3:<8 lowercase hex>` (Murmur3 x86 32-bit, seed 0) over exactly the committed bytes of the inclusive range at `vcs.revision`, including the lines' own LF/CRLF terminators; excludes paths, positions, hunk metadata, attribution, and IDs. Omitted, never fabricated, when the committed blob is unavailable. See `context/sce/agent-trace-minimal-generator.md`. +- `Agent Trace range content_hash`: Optional per-range `content_hash` emitted as `murmur3:<8 lowercase hex>` (Murmur3 x86 32-bit, seed 0) over exactly the committed bytes of the inclusive range at `vcs.revision`, including the lines' own LF/CRLF terminators; excludes paths, positions, hunk metadata, attribution, and IDs. Omitted, never fabricated, only when the caller explicitly reports `CommittedFile::Unverified`; the production post-commit hook never does, and fails instead on a missing path or Git read error. See `context/sce/agent-trace-minimal-generator.md`. +- `committed-file outcome`: The result of `CommittedFileReader::read_committed_file` for a post-commit path: `Blob` (regular or executable file, `100644`/`100755` blob; ranges validated and hashed), `NotLineAddressable` (symlink `120000` blob, gitlink `160000` commit, or directory `040000` tree; no ranges, counters kept), `Missing` (absent at the revision; trace generation fails), or `Unverified` (deliberate best-effort caller; hashless ranges whose positions are not independently verified). Operational failures are a separate `CommittedFileReadError` (`TreeLookupFailed`, `MalformedTreeEntry`, `BlobReadFailed`) and also fail trace generation. Canonical counters in `metadata.sce.line_changes` always cover every touched line regardless of which files emit ranges. +- `Git path identity`: The exact repository-relative path of a patch file after decoding Git's C-quoted form (`patch/git_path.rs`): escapes decode to bytes, the result must be valid UTF-8 without NUL, and `a/`/`b/` prefixes are removed only after decoding. Undecodable names fail patch parsing explicitly. The same identity is used for attribution lookup, `files[].path`, and committed-file lookup. - `external-mutation guard` / `external-mutation supervisor`: `arm_external_mutation_guard` + `ArmedExternalMutationGuard::exec` (`cli/src/services/mutation_trace/runtime/external_mutation_guard.rs`, hidden `sce hooks external-mutation-guard`, Unix-only), the harness-neutral, not-Pi-specific mechanism that lets a human-initiated shell command (Pi's `!`/`!!` `user_bash`) mutate a worktree without ever creating a mutation scope: it acquires the same `ProtectedWorktree` guard `coordinate()` uses, reports it armed, itself spawns the human shell as its own child (never signaled or stopped by control-channel closure — only by an explicit cancel request, which it enacts by signaling the shell's own process group), finishes exclusively on the shell's real process termination, and only then forces the existing `database_failure`+`recover` composition on the already-held worktree via `coordinate_on_held_worktree` before clearing the marker with `ProtectedWorktree::complete()`. See [`mutation-trace-external-mutation-guard.md`](cli/mutation-trace-external-mutation-guard.md). - `Claude diff-trace attribution`: Diff-trace enrichment rule where one Claude `PostToolUse` event resolves its model with `direct > exact transcript > exact session/agent state > bridge-derived chain 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`. Only for a raw structured Claude payload in the exact main-session scope (`agent_id` empty), a state miss then falls back to `bridge session correlation` across the transcript's bridge-linked chain; when that resolves, persistence writes a `claude_model_state` row for the current session (`source="bridge_inherited"`) — the one diff-trace resolution path that writes state — and uses that model, so later traces of the same session resolve from their own exact-scope row. Model sources receive one `claude/` normalization step, ephemeral agent context is never exported, and subagents do not inherit main-session state. - `bridge session correlation`: Claude-specific local correlation using the `bridgeSessionId` in leading transcript records to relate a Claude session's transcript to its sibling transcripts sharing that ID. Selection resolves the newest `claude_model_state` observation across all bridge-linked chain members by `observed_at_ms` (deterministic session-ID tie-break), not the chain origin and not the most recently modified sibling transcript. Discovery is bounded (leading records only) and fail-open, uses existing exact-scope `claude_model_state` rows without persisting the bridge ID, and does not claim authoritative session ordering. One selection rule serves both call sites — the `diff-trace` state-miss path and the `sce hooks claude-model-state` `SessionStart` path — with no most-recently-modified-sibling pick left in the code. See [the read-path bridge-seeding decision](decisions/2026-09-10-claude-bridge-seeding-on-diff-trace.md) and [the bridge-session inheritance decision](decisions/2026-09-08-claude-bridge-session-model-inheritance.md). diff --git a/context/sce/agent-trace-minimal-generator.md b/context/sce/agent-trace-minimal-generator.md index 67f206ccf..ec7ecb5df 100644 --- a/context/sce/agent-trace-minimal-generator.md +++ b/context/sce/agent-trace-minimal-generator.md @@ -32,13 +32,31 @@ Given per-row direct candidate patches, mutation lineage evidence, and a `post_c - If a conversation had no common classification, the conversation-level contributor would be omitted; range overrides carry the attribution. - SCE never generates `mixed` (partial AI coverage of a hunk is not evidence of mixed authorship) and never generates `human`. Both remain valid standard types and are read back from existing records. +**Git path identity** (`patch/git_path.rs`). Git C-quotes a path that contains control characters, `"`, `\`, or (with `core.quotePath`, the default) any byte ≥ 0x80, writing each non-ASCII byte as its own `\ooo` octal escape. The patch parser decodes quoted names in `diff --git` headers, `---`/`+++` lines, and `rename`/`copy from`/`to` lines with one decoder: escapes become bytes first, then the whole path is validated as UTF-8 (never per escape, never lossily). Malformed escapes, unterminated quotes, NUL, or non-UTF-8 results fail parsing with `invalid path in ...` rather than inventing an identity. The `diff --git` header is split without looking inside quoted names; two unquoted names are split at the unique midpoint where they agree, so a name containing ` b/` is safe, and an ambiguous rename header must be resolved by the following `---`/`+++` or `rename` lines or parsing fails. Git's `a/`/`b/` prefixes are stripped only after decoding, `/dev/null` still marks created and deleted files, and on unquoted `---`/`+++` lines only the tab suffix Git appends (to names containing a space) is dropped, so significant trailing spaces survive. The decoded repository-relative path is the identity used by attribution lookup, `files[].path`, and `git ls-tree`; repository-root normalization and boundary checks are unchanged and run after decoding. + **Content hashes** (`agent_trace/committed_content.rs`). For an emitted inclusive range `[start_line, end_line]` the hash input is exactly the contiguous bytes of those lines in the committed blob at `vcs.revision`, including each line's own terminator (LF or CRLF) and excluding a final newline that does not exist. Algorithm: Murmur3 x86 32-bit, seed `0`, formatted `murmur3:<8 lowercase hex digits>`. Paths, line numbers, hunk headers, `+`/`-` markers, attribution, session/model/trace IDs, and any version prefix are not part of the input, so identical byte spans hash identically wherever they appear. Bytes are never reconstructed from `TouchedLine.content`, which has lost terminators. -**Committed-blob retrieval.** `build_agent_trace_from_evidence` takes a `CommittedFileReader`. The post-commit hook passes `GitCommittedFiles::new(repository_root, commit_oid)` for the captured commit ID (the same ID whose patch is parsed, captured with `git show --no-ext-diff --no-textconv --` so patch lines describe raw blobs). It runs `git --literal-pathspecs ls-tree -z --full-tree -- ` and `git cat-file blob `: committed objects only, no working tree, index, network, or object writes. Each path is read at most once per trace (`CommittedFileCache`), and only for files with surviving added lines. Outcomes: +**Committed-blob retrieval.** `build_agent_trace_from_evidence` takes a `CommittedFileReader`. The post-commit hook passes `GitCommittedFiles::new(repository_root, commit_oid)` for the captured commit ID (the same ID whose patch is parsed, captured with `git show --no-ext-diff --no-textconv --` so patch lines describe raw blobs). It runs `git --literal-pathspecs ls-tree -z --full-tree -- ` and `git cat-file blob `: committed objects only, no working tree, index, network, or object writes. Each path is read at most once per trace (`CommittedFileCache`), and only for files with surviving added lines. The NUL-delimited `ls-tree` listing is parsed strictly: every record must be ` \t`, the path must equal the requested path byte for byte (no prefix or suffix matching), and more than one exact match is an error. + +`CommittedFileReader::read_committed_file` returns `Result`. Mode-aware classification (`GitEntryKind`) checks both the Git mode and the object type: + +| Mode | Type | Kind | Result | +| --- | --- | --- | --- | +| `100644`, `100755` | `blob` | regular / executable file | `Blob(bytes)` | +| `120000` | `blob` | symlink | `NotLineAddressable` (the target string is not source) | +| `160000` | `commit` | gitlink / submodule | `NotLineAddressable` | +| `040000` | `tree` | directory | `NotLineAddressable` | +| anything else | | | `MalformedTreeEntry` error | + +Outcomes and policy: + +- `Blob(bytes)` — every added line is validated against the blob and every range gets a hash. +- `NotLineAddressable` — the file emits no ranges and no hashes; its touched lines still count in `metadata.sce.line_changes`. +- `Missing` — the path is absent from the recorded revision. A file with surviving additions must exist there, so this fails trace generation with `Invalid Agent Trace file '': ... absent from revision ''`; the file is neither silently dropped nor emitted unverified. +- `Unverified` — an explicit caller decision that no committed content is available (for example `UnverifiedCommittedFiles` in tests, or a future non-Git caller). Ranges keep their patch positions and omit `content_hash`, which the schema allows; nothing is fabricated, and such positions are **not** independently verified. +- `Err(CommittedFileReadError)` — an operational failure, kept distinct from a missing file: `TreeLookupFailed` (`git ls-tree` could not run or exited non-zero), `MalformedTreeEntry` (unparseable, ambiguous, or unsupported mode/type entry), or `BlobReadFailed` (`git cat-file` failed, for example a missing object). Each names the path, revision, and Git diagnostic, and fails trace generation. -- `Blob(bytes)` — positions are validated and every range gets a hash. -- `NotLineAddressable` (for example a submodule gitlink) — the file emits no ranges. -- `Unavailable` — ranges keep their patch positions and omit `content_hash`; no hash is fabricated and the post-commit flow continues. +`GitCommittedFiles` never returns `Unverified`, so the production post-commit hook is strict: a missing path or Git read error aborts the flow before validation and persistence, and no hashless range is ever persisted because of a failed read. Contradictory positions fail closed as before. Range building and hashing are pure and unit-tested with in-memory readers. @@ -70,7 +88,7 @@ File identity requires an established coordinate system (`DiffTracePathOrigin`, | `LineRange` | Inclusive 1-indexed post-commit span: `start_line`, `end_line`, optional `content_hash`, optional `contributor` override | | `Conversation` | Ranges sharing provenance: trace lookup `url`, optional `contributor`, `ranges`, optional `related` | | `TraceFile` | Per-file entry: post-commit path + conversations ordered by first line | -| `CommittedFileReader` | Seam returning `CommittedFile::{Blob, NotLineAddressable, Unavailable}` for a post-commit path; `GitCommittedFiles` reads one exact commit | +| `CommittedFileReader` | Seam returning `Result` for a post-commit path; `GitCommittedFiles` reads one exact commit and never returns `Unverified` | | `AgentTraceVcs` | Optional top-level VCS metadata object carrying `type` + `revision` when present | | `AgentTraceTool` | Optional top-level tool metadata object carrying optional `name` + optional `version` | | `AgentTraceMetadata` | Top-level implementation metadata object carrying SCE-owned metadata | @@ -156,14 +174,16 @@ Example for a hunk `@@ -10,2 +10,4 @@` that removes one line, adds two session-A ## Public API -- `build_agent_trace_from_evidence(evidence: AgentTraceEvidence, post_commit_patch, metadata, committed_files: &dyn CommittedFileReader) -> Result` — separated-evidence entrypoint (see [Separated direct/mutation evidence](#separated-directmutation-evidence)); resolves per-line decisions, builds counters and revision-positioned ranges from them, and derives `tool` from the direct sources supporting finally accepted lines. Fails only when a range position contradicts the committed blob. +- `build_agent_trace_from_evidence(evidence: AgentTraceEvidence, post_commit_patch, metadata, committed_files: &dyn CommittedFileReader) -> Result` — separated-evidence entrypoint (see [Separated direct/mutation evidence](#separated-directmutation-evidence)); resolves per-line decisions, builds counters and revision-positioned ranges from them, and derives `tool` from the direct sources supporting finally accepted lines. Fails when a range position contradicts the committed blob, when a file with added lines is `Missing` at the revision, or when the reader returns a `CommittedFileReadError`. - `GitCommittedFiles::new(repository_root, revision)` — production `CommittedFileReader` for one exact commit. ## Test fixture contract - Golden fixtures under `cli/src/services/agent_trace/fixtures/**/golden.json` pin the complete exported `files` structure, including exact range positions, contributors, range overrides, related sessions, and `content_hash` values, plus `metadata.sce.line_changes`. Fixture tests supply committed content through a test reader that rebuilds new-side bytes from the raw `post_commit.patch` text (exact terminators and `\ No newline at end of file`; lines outside hunks become placeholders) without the production parser. The golden hashes were cross-checked by an independent Murmur3 implementation that re-derived every range from the raw patch. - Evidence fixtures (`direct_only`, `exclusive_without_direct`, `direct_plus_mutation`, `partial_combined`, `newer_nonexclusive_blocks`, `mutation_only_no_provenance`) pair `direct.patch` + `mutation_ai.patch` + `post_commit.patch` and drive `build_agent_trace_from_evidence`, pinning mutation-only coverage with and without resolved provenance, the absence of fabricated metadata when provenance is unknown, and the direct-only case. -- `agent_trace/tests.rs` covers: hunks with context and AI/unresolved additions (no `mixed`); additions separated by context; new, deleted, renamed, and pure-rename files; deletion-only commits; sessions and models never merged; one session with several models using range overrides; `Unknown`/`NonExclusive` never coalesced; legacy `mixed`/`human` record deserialization; Murmur3 vectors; LF/CRLF/missing-final-newline/whitespace/Unicode byte spans; hash stability across paths, positions, and hunks; unavailable and non-line-addressable blobs; rejection of positions that contradict the blob; per-line counters; the `resolve_line` precedence table; and seeded randomized tests that check ranges, hashes, provenance, schema validity, and hunk re-partitioning invariance against an independently computed model. +- `agent_trace/tests.rs` covers: hunks with context and AI/unresolved additions (no `mixed`); additions separated by context; new, deleted, renamed, and pure-rename files; deletion-only commits; sessions and models never merged; one session with several models using range overrides; `Unknown`/`NonExclusive` never coalesced; legacy `mixed`/`human` record deserialization; Murmur3 vectors; LF/CRLF/missing-final-newline/whitespace/Unicode byte spans; hash stability across paths, positions, and hunks; explicitly unverified and non-line-addressable blobs; rejection of positions that contradict the blob; per-line counters; the `resolve_line` precedence table; and seeded randomized tests that check ranges, hashes, provenance, schema validity, and hunk re-partitioning invariance against an independently computed model. +- `agent_trace/committed_content/tests.rs` builds real Git tree entries (regular, executable, symlink, gitlink, directory, absent blob object) and covers missing paths, failed `ls-tree`/`cat-file`, malformed or ambiguous listings, mode/type agreement, and fail-closed trace building for `Missing` and every read error. +- `hooks/commit_hooks/path_identity_tests.rs` runs the real Git + Turso post-commit flow with `core.quotePath=true` over Unicode, space, tab, newline, quote, backslash, ` b/`, and trailing-space filenames plus an executable file and a symlink; it checks decoded `files[].path`, hashes against independently read blobs, direct evidence on the quoted Unicode file, symlink exclusion, and counters, and proves that a missing path, failed `ls-tree`, or missing blob persists no trace. - `hooks/commit_hooks/attribution_tests.rs` runs the real Git + Turso post-commit flow, including a commit with CRLF, Unicode, a missing final newline, a deleted file, and a deleted `.patch` file, and checks every persisted range and hash against blobs read independently with `git cat-file`. - 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. From 36c373321e35ce19a3a274c67aeed5a50faede78 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 6 Nov 2026 23:14:40 +0100 Subject: [PATCH 08/11] runtime: Resolve removed-line attribution from captured commits Track parent-line identities through bounded mutation replay so deleted lines are attributed to the transition that removed them rather than their original author. Anchor removal replay to the exact first-parent tree and captured commit OID, reconcile paths and unobserved tails conservatively, and keep unresolved cases unknown across gaps, partial staging, restorations, root or merge commits, and HEAD moves. Extend hook and runtime coverage and document the effective-remover contract. Co-authored-by: SCE --- cli/src/services/hooks/commit_hooks.rs | 1 + .../hooks/commit_hooks/attribution_tests.rs | 351 +++++++- .../hooks/commit_hooks/path_identity_tests.rs | 5 +- cli/src/services/mutation_trace/lineage.rs | 268 +++++- .../services/mutation_trace/lineage/tests.rs | 837 ++++++++++++++++++ .../mutation_trace/runtime/git_snapshot.rs | 38 +- .../runtime/mutation_attribution.rs | 272 +++++- .../runtime/mutation_attribution/tests.rs | 429 +++++++++ .../cli/mutation-trace-agent-attribution.md | 154 +++- context/glossary.md | 4 +- context/sce/agent-trace-minimal-generator.md | 4 +- 11 files changed, 2305 insertions(+), 58 deletions(-) diff --git a/cli/src/services/hooks/commit_hooks.rs b/cli/src/services/hooks/commit_hooks.rs index 963c020a9..3131523d5 100644 --- a/cli/src/services/hooks/commit_hooks.rs +++ b/cli/src/services/hooks/commit_hooks.rs @@ -244,6 +244,7 @@ pub(crate) async fn run_post_commit_agent_trace_flow< crate::services::mutation_trace::runtime::resolve_post_commit_mutation_attribution( repository_root, &mut db, + &flow_result.post_commit_data.commit_oid, &flow_result.post_commit_data.parsed_patch, ) .await; diff --git a/cli/src/services/hooks/commit_hooks/attribution_tests.rs b/cli/src/services/hooks/commit_hooks/attribution_tests.rs index d9e1aedf8..08dc5a593 100644 --- a/cli/src/services/hooks/commit_hooks/attribution_tests.rs +++ b/cli/src/services/hooks/commit_hooks/attribution_tests.rs @@ -20,14 +20,15 @@ use crate::services::agent_trace_db::{ use crate::services::mutation_trace::attribution::MutationAttributionResult; use crate::services::mutation_trace::runtime::GitSnapshotService; use crate::services::mutation_trace::runtime::{ - resolve_bounded_mutation_attribution, MutationEventPageSource, + resolve_bounded_mutation_attribution, resolve_post_commit_mutation_attribution, + MutationEventPageSource, }; use crate::services::mutation_trace::store::{ AttributionKind, MutationEventPageRow, MutationTraceStore, ScopeProvenance, MUTATION_ATTRIBUTION_PAGE_SIZE, }; use crate::services::mutation_trace::types::{FailureKind, ScopeId, TreeId, WorktreeId}; -use crate::services::patch::{parse_patch, ParsedPatch}; +use crate::services::patch::{parse_patch, ParsedPatch, TouchedLineKind}; use super::{ capture_post_commit_patch_from_git, run_post_commit_agent_trace_flow_with, @@ -153,6 +154,10 @@ impl HistoryRepo { &self.trees[3] } + fn parent_tree(&self) -> &TreeId { + &self.trees[2] + } + fn mutation_rows(&self, final_kind: AttributionKind) -> Vec { let final_scope = (final_kind == AttributionKind::AiExclusive).then_some(SCOPE_C); [Some(SCOPE_A), Some(SCOPE_B), final_scope] @@ -370,6 +375,7 @@ async fn mutation_attribution( &WorktreeId(WORKTREE.to_owned()), committed, repo.commit_tree(), + Some(repo.parent_tree()), ceiling, ) .await @@ -852,6 +858,7 @@ async fn persist_trace( &WorktreeId(WORKTREE.to_owned()), &flow.post_commit_data.parsed_patch, repo.commit_tree(), + Some(repo.parent_tree()), mutation_rows.iter().map(|row| row.revision).max(), ) .await @@ -1632,3 +1639,343 @@ async fn persisted_agent_trace_ranges_reference_exact_committed_blobs() { } ); } + +const DELETION_A_PATH: &str = "src/a.rs"; +const DELETION_B_PATH: &str = "src/b.rs"; +const DELETION_C_PATH: &str = "src/c.rs"; +const DELETION_A_PARENT: &str = "a1\na2\na3\na4\na5\na6\n"; +const DELETION_B_PARENT: &str = "b1\nb2\nb3\n"; +const DELETION_C_PARENT: &str = "c1\nc2\nc3\n"; + +struct DeletionRepo { + dir: tempfile::TempDir, + parent_tree: TreeId, +} + +impl DeletionRepo { + fn new() -> Self { + let mut repo = DeletionRepo { + dir: tempfile::tempdir().expect("temp dir"), + parent_tree: TreeId(String::new()), + }; + let root = repo.root(); + git(root, &["init", "-q", "-b", "main"], None); + git( + root, + &["config", "user.email", "deletions@example.invalid"], + None, + ); + git(root, &["config", "user.name", "Deletion Test"], None); + git(root, &["config", "commit.gpgsign", "false"], None); + std::fs::create_dir_all(root.join("src")).expect("src dir"); + repo.parent_tree = repo.stage(&[ + (DELETION_A_PATH, DELETION_A_PARENT), + (DELETION_B_PATH, DELETION_B_PARENT), + (DELETION_C_PATH, DELETION_C_PARENT), + ]); + repo.commit(BASE_TIME_S); + repo + } + + fn root(&self) -> &Path { + self.dir.path() + } + + fn stage(&self, files: &[(&str, &str)]) -> TreeId { + for (path, content) in files { + std::fs::write(self.root().join(path), content).expect("write file"); + } + git(self.root(), &["add", "-A"], None); + TreeId(git(self.root(), &["write-tree"], None)) + } + + fn commit(&self, time_s: i64) { + git(self.root(), &["commit", "-q", "-m", "change"], Some(time_s)); + } +} + +fn deletion_event( + revision: u64, + before: &TreeId, + after: &TreeId, + scope: Option<&str>, +) -> MutationEventPageRow { + MutationEventPageRow { + revision, + before_tree: before.clone(), + after_tree: after.clone(), + failure_kind: FailureKind::Healthy, + attribution_kind: if scope.is_some() { + AttributionKind::AiExclusive + } else { + AttributionKind::IneligibleUnscoped + }, + attribution_scope_id: scope.map(|scope| ScopeId(scope.to_owned())), + } +} + +async fn insert_mixed_mutation_rows(db: &RepositoryAgentTraceDb, rows: &[MutationEventPageRow]) { + let (ai, non_exclusive): (Vec<_>, Vec<_>) = rows + .iter() + .cloned() + .partition(|row| row.attribution_scope_id.is_some()); + insert_mutation_rows(db, &ai).await; + for row in non_exclusive { + let revision = row.revision.to_be_bytes(); + 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', 'ineligible_unscoped', NULL, 'flush', NULL, NULL)", + ( + WORKTREE, + revision.as_slice(), + row.before_tree.0.as_str(), + row.after_tree.0.as_str(), + ), + ) + .await + .expect("insert non-exclusive mutation event"); + } +} + +struct PersistedDeletionTrace { + value: Value, + trace: AgentTrace, + mutation: MutationAttributionResult, + committed: ParsedPatch, +} + +/// Runs the real post-commit capture, the exact-OID mutation resolver over +/// Turso-persisted events, and Agent Trace persistence. `HEAD` moves before +/// the resolver runs, so it must use the captured commit, not `HEAD`. +async fn persist_deletion_trace( + repo: &DeletionRepo, + rows: &[MutationEventPageRow], +) -> PersistedDeletionTrace { + let (_db_dir, mut db) = open_db().await; + insert_mixed_mutation_rows(&db, rows).await; + + let flow = run_post_commit_intersection_flow_with( + repo.root(), + capture_post_commit_patch_from_git, + || Ok(ms(COMMIT_A_TIME_S) + 500), + async |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms).await, + async |insert| { + db.insert_post_commit_patch_intersection(insert) + .await + .map(|_| ()) + }, + ) + .await + .expect("intersection flow"); + + repo.stage(&[(DELETION_A_PATH, "moved\n")]); + repo.commit(COMMIT_B_TIME_S); + + let mutation = resolve_post_commit_mutation_attribution( + repo.root(), + &mut db, + &flow.post_commit_data.commit_oid, + &flow.post_commit_data.parsed_patch, + ) + .await; + + let trace = run_post_commit_agent_trace_flow_with( + &flow, + Some(AgentTraceVcsType::Git), + REMOTE_URL, + &mutation, + None, + |value| validate_agent_trace_value(value).map_err(|error| anyhow!(error.to_string())), + async |insert| db.insert_agent_trace(insert).await.map(|_| ()), + ) + .await + .expect("agent trace flow"); + + let traces: Vec = db + .query_map("SELECT trace_json FROM agent_traces", (), |row| { + Ok(row.get(0)?) + }) + .await + .expect("read agent traces"); + assert_eq!(traces.len(), 1); + let value: Value = serde_json::from_str(&traces[0]).expect("trace JSON"); + validate_agent_trace_value(&value).expect("persisted trace validates"); + let persisted: AgentTrace = + serde_json::from_value(value.clone()).expect("persisted trace deserializes"); + assert_eq!(persisted, trace); + + PersistedDeletionTrace { + value, + trace, + mutation, + committed: flow.post_commit_data.parsed_patch, + } +} + +fn removed_lines_by_path(patch: &ParsedPatch) -> Vec<(String, u64, Option)> { + patch + .files + .iter() + .flat_map(|file| { + file.hunks.iter().flat_map(move |hunk| { + hunk.lines + .iter() + .filter(|line| line.kind == TouchedLineKind::Removed) + .map(move |line| { + ( + file.old_path.clone(), + line.line_number, + line.session_id.clone(), + ) + }) + }) + }) + .collect() +} + +fn committed_removed_count(patch: &ParsedPatch) -> u64 { + removed_lines_by_path(patch).len() as u64 +} + +#[tokio::test] +async fn persisted_deletion_only_commit_counts_each_removal_in_its_proven_bucket() { + let repo = DeletionRepo::new(); + let after_ai = repo.stage(&[(DELETION_A_PATH, "a1\na5\na6\n")]); + let after_non_exclusive = repo.stage(&[(DELETION_B_PATH, "b1\nb3\n")]); + repo.stage(&[(DELETION_C_PATH, "c1\nc3\n")]); + repo.commit(COMMIT_A_TIME_S); + + let persisted = persist_deletion_trace( + &repo, + &[ + deletion_event(1, &repo.parent_tree, &after_ai, Some(SCOPE_A)), + deletion_event(2, &after_ai, &after_non_exclusive, None), + ], + ) + .await; + + let ai_removals = removed_lines_by_path(&persisted.mutation.mutation_ai_patch); + assert_eq!( + ai_removals, + [2, 3, 4] + .map(|line| (DELETION_A_PATH.to_owned(), line, Some(SESSION_A.to_owned()))) + .to_vec() + ); + assert!(persisted + .mutation + .mutation_ai_patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .all(|hunk| hunk.model_id.as_deref() == Some(MODEL_A))); + assert_eq!( + removed_lines_by_path(&persisted.mutation.resolved_non_ai_patch), + vec![(DELETION_B_PATH.to_owned(), 2, None)] + ); + assert_eq!( + removed_lines_by_path(&persisted.mutation.unresolved_patch), + vec![(DELETION_C_PATH.to_owned(), 2, None)] + ); + + let line_changes = persisted.trace.metadata.sce.line_changes; + assert_eq!( + line_changes, + LineChangeAttribution { + ai: LineChangeCounts { + added: 0, + removed: 3 + }, + non_exclusive: LineChangeCounts { + added: 0, + removed: 1 + }, + unknown: LineChangeCounts { + added: 0, + removed: 1 + }, + } + ); + assert_eq!( + line_changes.ai.removed + line_changes.non_exclusive.removed + line_changes.unknown.removed, + committed_removed_count(&persisted.committed) + ); + let persisted_counts: LineChangeAttribution = + serde_json::from_value(persisted.value["metadata"]["sce"]["line_changes"].clone()) + .expect("persisted line_changes deserialize"); + assert_eq!(persisted_counts, line_changes); + assert_eq!(persisted.value["files"], serde_json::json!([])); + assert!(persisted.trace.tool.is_none()); +} + +#[tokio::test] +async fn persisted_mixed_commit_keeps_added_line_attribution_and_adds_removals() { + let repo = DeletionRepo::new(); + let after_ai = repo.stage(&[(DELETION_A_PATH, "a1\nA2\na4\na5\na6\na7\n")]); + repo.commit(COMMIT_A_TIME_S); + let rows = [deletion_event( + 1, + &repo.parent_tree, + &after_ai, + Some(SCOPE_A), + )]; + + let persisted = persist_deletion_trace(&repo, &rows).await; + + assert_eq!( + persisted.trace.metadata.sce.line_changes, + LineChangeAttribution { + ai: LineChangeCounts { + added: 2, + removed: 2 + }, + ..LineChangeAttribution::default() + } + ); + let file = &persisted.trace.files[0]; + assert_eq!(file.path, DELETION_A_PATH); + let ranges: Vec<(u64, u64)> = file.conversations[0] + .ranges + .iter() + .map(|range| (range.start_line, range.end_line)) + .collect(); + assert_eq!(ranges, vec![(2, 2), (6, 6)], "only added lines get ranges"); + assert_eq!( + file.conversations[0] + .contributor + .as_ref() + .and_then(|contributor| contributor.model_id.as_deref()), + Some(MODEL_A) + ); + + let without_parent = resolve_bounded_mutation_attribution( + &VecPages { + rows: rows.to_vec(), + }, + &GitSnapshotService::new(repo.root()) + .await + .expect("snapshot"), + &WorktreeId(WORKTREE.to_owned()), + &persisted.committed, + &after_ai, + None, + Some(1), + ) + .await + .result; + let added_only = |patch: &ParsedPatch| { + let mut patch = patch.clone(); + for file in &mut patch.files { + for hunk in &mut file.hunks { + hunk.lines + .retain(|line| line.kind == TouchedLineKind::Added); + } + file.hunks.retain(|hunk| !hunk.lines.is_empty()); + } + patch.files.retain(|file| !file.hunks.is_empty()); + patch + }; + assert_eq!( + added_only(&persisted.mutation.mutation_ai_patch), + added_only(&without_parent.mutation_ai_patch), + "removal anchoring leaves added-line attribution unchanged" + ); +} diff --git a/cli/src/services/hooks/commit_hooks/path_identity_tests.rs b/cli/src/services/hooks/commit_hooks/path_identity_tests.rs index 8186fe4af..a1c4dbe62 100644 --- a/cli/src/services/hooks/commit_hooks/path_identity_tests.rs +++ b/cli/src/services/hooks/commit_hooks/path_identity_tests.rs @@ -90,7 +90,10 @@ fn source_lines(path: &str) -> Vec { .map(|line| (*line).to_owned()) .collect(); } - vec![format!("const PATH: &str = {path:?};"), String::from("pub fn f() {}")] + vec![ + format!("const PATH: &str = {path:?};"), + String::from("pub fn f() {}"), + ] } fn source_text(path: &str) -> String { diff --git a/cli/src/services/mutation_trace/lineage.rs b/cli/src/services/mutation_trace/lineage.rs index 7dd2f7a04..77edf9643 100644 --- a/cli/src/services/mutation_trace/lineage.rs +++ b/cli/src/services/mutation_trace/lineage.rs @@ -20,7 +20,10 @@ pub enum TransitionOrigin { } impl TransitionOrigin { - fn added_provenance(&self) -> LineProvenance { + /// The provenance of a line this transition adds, and equally of a parent + /// line this transition removes: the effective actor is the transition, + /// never the actor that originally introduced the line. + fn provenance(&self) -> LineProvenance { match self { TransitionOrigin::MutationAi(scope_id) => LineProvenance::MutationAi { scope_id: scope_id.clone(), @@ -37,15 +40,63 @@ pub struct LineageError { pub reason: String, } +/// Stable identity of one line of the exact parent commit: its parent-side +/// path and 1-based line number. It never changes while the line survives, +/// however other lines move around it. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ParentLineId { + pub path: String, + pub line_number: u64, +} + #[derive(Clone, Debug, Eq, PartialEq)] struct ProvenanceLine { content: String, provenance: LineProvenance, + /// Set only for lines carried unchanged from the parent baseline; lines + /// introduced by any later transition have no parent identity. + parent_identity: Option, +} + +/// The effective removal of a parent line: who removed it, and the content it +/// had. Its provenance is invalidated to `Unknown` once the same content is +/// plausibly restored, because text equality never proves identity. +#[derive(Clone, Debug, Eq, PartialEq)] +struct RemovalRecord { + content: String, + provenance: LineProvenance, +} + +#[derive(Debug, Default)] +struct TransitionEffects { + removed: Vec<(ParentLineId, String)>, + added: Vec<(String, String)>, +} + +impl TransitionEffects { + /// Records the parent identity of every removed line that has one; lines + /// introduced after the parent are removed without a trace. + fn record_removed(&mut self, region: &[ProvenanceLine], offsets: &BTreeSet) { + for offset in offsets { + let line = ®ion[*offset]; + if let Some(identity) = &line.parent_identity { + self.removed.push((identity.clone(), line.content.clone())); + } + } + } + + fn record_added(&mut self, path: &str, content: &str) { + self.added.push((path.to_owned(), content.to_owned())); + } } #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct MutationLineage { files: BTreeMap>, + removals: BTreeMap, + /// Maps a tracked path to the parent path whose lines it holds, following + /// only renames the replay observed. + parent_paths: BTreeMap, } fn unknown_lines(content: Option<&str>) -> Vec { @@ -56,6 +107,25 @@ fn unknown_lines(content: Option<&str>) -> Vec { .map(|line| ProvenanceLine { content: line.to_owned(), provenance: LineProvenance::Unknown, + parent_identity: None, + }) + .collect(), + } +} + +fn parent_lines(path: &str, content: Option<&str>) -> Vec { + match content { + None => Vec::new(), + Some(text) => text + .lines() + .zip(1_u64..) + .map(|(line, line_number)| ProvenanceLine { + content: line.to_owned(), + provenance: LineProvenance::Unknown, + parent_identity: Some(ParentLineId { + path: path.to_owned(), + line_number, + }), }) .collect(), } @@ -69,14 +139,33 @@ impl MutationLineage { .iter() .map(|(path, content)| (path.clone(), unknown_lines(content.as_deref()))) .collect(), + removals: BTreeMap::new(), + parent_paths: BTreeMap::new(), + } + } + + /// Starts a lineage at the exact parent commit: every baseline line gets + /// a stable parent identity so later removals of it can be recorded. + #[must_use] + pub fn from_parent(files: &BTreeMap>) -> Self { + MutationLineage { + files: files + .iter() + .map(|(path, content)| (path.clone(), parent_lines(path, content.as_deref()))) + .collect(), + removals: BTreeMap::new(), + parent_paths: files + .iter() + .filter(|(_, content)| content.is_some()) + .map(|(path, _)| (path.clone(), path.clone())) + .collect(), } } + /// Restarts from an unanchored baseline. Every parent identity and + /// removal claim is dropped: none survives an unverified reset. pub fn reset_all(&mut self, files: &BTreeMap>) { - self.files = files - .iter() - .map(|(path, content)| (path.clone(), unknown_lines(content.as_deref()))) - .collect(); + *self = MutationLineage::from_baseline(files); } pub fn apply( @@ -84,16 +173,49 @@ impl MutationLineage { patch: &ParsedPatch, origin: &TransitionOrigin, ) -> Result<(), LineageError> { + let mut effects = TransitionEffects::default(); for file in &patch.files { - self.apply_file(file, origin)?; + self.apply_file(file, origin, &mut effects)?; + } + self.invalidate_restored_removals(&effects.added); + for (identity, content) in effects.removed { + self.removals.insert( + identity, + RemovalRecord { + content, + provenance: origin.provenance(), + }, + ); } Ok(()) } + /// An added line whose content equals an earlier-removed line of the same + /// parent file may be a restoration of it. Identity cannot be proven from + /// text, so the earlier removal claim becomes unknown for good. + fn invalidate_restored_removals(&mut self, added: &[(String, String)]) { + if self.removals.is_empty() { + return; + } + let mut restorable: BTreeSet<(&str, &str)> = BTreeSet::new(); + for (path, content) in added { + restorable.insert((path, content)); + if let Some(parent_path) = self.parent_paths.get(path) { + restorable.insert((parent_path, content)); + } + } + for (identity, record) in &mut self.removals { + if restorable.contains(&(identity.path.as_str(), record.content.as_str())) { + record.provenance = LineProvenance::Unknown; + } + } + } + fn apply_file( &mut self, file: &PatchFileChange, origin: &TransitionOrigin, + effects: &mut TransitionEffects, ) -> 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); @@ -127,8 +249,21 @@ impl MutationLineage { }); } - let new = apply_hunks(&logical_path, &old, file, origin)?; + let new = apply_hunks(&logical_path, &old, file, origin, effects)?; + let renamed = !file.old_path.is_empty() + && !file.new_path.is_empty() + && file.old_path != file.new_path; + if renamed { + match self.parent_paths.remove(&file.old_path) { + Some(parent_path) if tracks_source => { + self.parent_paths.insert(file.new_path.clone(), parent_path); + } + _ => { + self.parent_paths.remove(&file.new_path); + } + } + } if tracks_source && file.old_path != file.new_path { self.files.insert(file.old_path.clone(), Vec::new()); } @@ -154,6 +289,116 @@ impl MutationLineage { _ => LineProvenance::Unknown, } } + + /// The tracked paths, including every path a rename moved lines into. + pub fn tracked_paths(&self) -> impl Iterator { + self.files.keys().map(String::as_str) + } + + /// Whether the tracked lines of `path` are exactly the lines of `content`. + #[must_use] + pub fn matches_content(&self, path: &str, content: Option<&str>) -> bool { + let Some(tracked) = self.files.get(path) else { + return false; + }; + let expected: Vec<&str> = content + .map(|text| text.lines().collect()) + .unwrap_or_default(); + tracked.len() == expected.len() + && tracked + .iter() + .zip(&expected) + .all(|(line, expected)| line.content == *expected) + } + + /// Makes every removal claim that the lines at `path` bear on unknown: + /// removals from `path` itself and from the parent path it holds. + pub fn distrust_removals(&mut self, path: &str) { + let parent_path = self.parent_paths.get(path).cloned(); + for (identity, record) in &mut self.removals { + if identity.path == path || parent_path.as_deref() == Some(identity.path.as_str()) { + record.provenance = LineProvenance::Unknown; + } + } + } + + /// The parent path whose lines `path` holds, when the replay established it. + #[must_use] + pub fn parent_path_of(&self, path: &str) -> Option<&str> { + self.parent_paths.get(path).map(String::as_str) + } + + /// Every recorded parent-line removal with its effective provenance. + #[cfg(test)] + #[must_use] + pub fn removed_identities(&self) -> Vec<(ParentLineId, LineProvenance)> { + self.removals + .iter() + .map(|(identity, record)| (identity.clone(), record.provenance.clone())) + .collect() + } + + fn removals_of(&self, parent_path: &str) -> BTreeMap { + self.removals + .iter() + .filter(|(identity, _)| identity.path == parent_path) + .map(|(identity, record)| (identity.line_number, record)) + .collect() + } + + /// Resolves the committed old-side removed lines of one canonical file + /// against the replayed parent identities. + /// + /// The committed file must hold the lines of `parent_path` by an observed + /// path history. Where the canonical diff and the replay disagree about + /// which parent lines were removed, they chose different alignments among + /// equal content, so every committed removal sharing that content is + /// ambiguous and stays `Unknown`. + #[must_use] + pub fn committed_removals( + &self, + current_path: &str, + parent_path: &str, + committed: &[(u64, &str)], + ) -> Vec { + if self.parent_path_of(current_path) != Some(parent_path) { + return vec![LineProvenance::Unknown; committed.len()]; + } + let recorded = self.removals_of(parent_path); + let mut committed_numbers: BTreeSet = BTreeSet::new(); + let mut repeated: BTreeSet = BTreeSet::new(); + for (line_number, _) in committed { + if !committed_numbers.insert(*line_number) { + repeated.insert(*line_number); + } + } + + let mut ambiguous: BTreeSet<&str> = BTreeSet::new(); + for (line_number, record) in &recorded { + if !committed_numbers.contains(line_number) { + ambiguous.insert(&record.content); + } + } + for (line_number, content) in committed { + if !recorded.contains_key(line_number) { + ambiguous.insert(content); + } + } + + committed + .iter() + .map(|(line_number, content)| match recorded.get(line_number) { + Some(record) + if record.content == *content + && !ambiguous.contains(content) + && !repeated.contains(line_number) => + { + record.provenance.clone() + } + _ => LineProvenance::Unknown, + }) + .collect() + } } fn apply_hunks( @@ -161,6 +406,7 @@ fn apply_hunks( old: &[ProvenanceLine], file: &PatchFileChange, origin: &TransitionOrigin, + effects: &mut TransitionEffects, ) -> Result, LineageError> { let mut hunks: Vec<&PatchHunk> = file.hunks.iter().collect(); hunks.sort_by_key(|hunk| (hunk.old_start, hunk.new_start)); @@ -169,7 +415,7 @@ fn apply_hunks( let mut cursor: usize = 0; for hunk in hunks { - cursor = apply_one_hunk(path, old, hunk, origin, &mut new, cursor)?; + cursor = apply_one_hunk(path, old, hunk, origin, &mut new, cursor, effects)?; } new.extend_from_slice(&old[cursor..]); @@ -183,6 +429,7 @@ fn apply_one_hunk( origin: &TransitionOrigin, new: &mut Vec, mut cursor: usize, + effects: &mut TransitionEffects, ) -> Result { let fail = |reason: &str| LineageError { path: path.to_owned(), @@ -252,6 +499,7 @@ fn apply_one_hunk( return Err(fail("the same old line is removed twice")); } } + effects.record_removed(region, &removed_indices); let mut carried: Vec<&ProvenanceLine> = region .iter() @@ -277,8 +525,10 @@ fn apply_one_hunk( if let Some(content) = added_by_number.get(&line_number) { new.push(ProvenanceLine { content: (*content).to_owned(), - provenance: origin.added_provenance(), + provenance: origin.provenance(), + parent_identity: None, }); + effects.record_added(path, content); } else { let carried_line = carried .pop() diff --git a/cli/src/services/mutation_trace/lineage/tests.rs b/cli/src/services/mutation_trace/lineage/tests.rs index cb8a26675..d1ccc97b3 100644 --- a/cli/src/services/mutation_trace/lineage/tests.rs +++ b/cli/src/services/mutation_trace/lineage/tests.rs @@ -238,3 +238,840 @@ fn adding_a_file_that_is_tracked_with_content_is_a_lineage_error() { .expect_err("a stale tracked file must fail closed"); assert_eq!(error.path, "f.rs"); } + +fn parent(entries: &[(&str, Option<&str>)]) -> MutationLineage { + let map: BTreeMap> = entries + .iter() + .map(|(path, content)| ((*path).to_owned(), content.map(str::to_owned))) + .collect(); + MutationLineage::from_parent(&map) +} + +fn id(path: &str, line_number: u64) -> ParentLineId { + ParentLineId { + path: path.to_owned(), + line_number, + } +} + +fn ai(scope_id: &str) -> LineProvenance { + LineProvenance::MutationAi { + scope_id: scope(scope_id), + } +} + +fn edit(path: &str, hunks: &str) -> ParsedPatch { + diff(&format!( + "diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n{hunks}" + )) +} + +#[test] +fn an_ai_exclusive_mutation_records_its_removal_of_one_parent_line() { + let mut lineage = parent(&[("f.rs", Some("a\nb\nc\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -2,1 +1,0 @@\n-b\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove b"); + assert_eq!( + lineage.removed_identities(), + vec![(id("f.rs", 2), ai("ai"))] + ); + assert_eq!( + lineage.committed_removals("f.rs", "f.rs", &[(2, "b")]), + vec![ai("ai")] + ); +} + +#[test] +fn a_non_exclusive_mutation_records_a_non_exclusive_removal() { + let mut lineage = parent(&[("f.rs", Some("a\nb\nc\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -2,1 +1,0 @@\n-b\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect("remove b"); + assert_eq!( + lineage.removed_identities(), + vec![(id("f.rs", 2), LineProvenance::MutationNonAi)] + ); +} + +#[test] +fn an_unobserved_transition_records_an_unknown_removal() { + let mut lineage = parent(&[("f.rs", Some("a\nb\nc\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -2,1 +1,0 @@\n-b\n"), + &TransitionOrigin::Unobserved, + ) + .expect("remove b"); + assert_eq!( + lineage.removed_identities(), + vec![(id("f.rs", 2), LineProvenance::Unknown)] + ); + assert_eq!( + lineage.committed_removals("f.rs", "f.rs", &[(2, "b")]), + vec![LineProvenance::Unknown] + ); +} + +#[test] +fn an_insertion_above_shifts_a_parent_line_without_changing_its_identity() { + let mut lineage = parent(&[("f.rs", Some("a\nb\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -1,2 +1,4 @@\n+x\n+y\n a\n b\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect("insert above"); + lineage + .apply( + &edit("f.rs", "@@ -4,1 +3,0 @@\n-b\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove shifted b"); + assert_eq!( + lineage.removed_identities(), + vec![(id("f.rs", 2), ai("ai"))] + ); +} + +#[test] +fn removals_in_one_hunk_keep_distinct_identities() { + let mut lineage = parent(&[("f.rs", Some("a\nb\nc\nd\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -1,4 +1,2 @@\n a\n-b\n-c\n d\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove b and c"); + assert_eq!( + lineage.removed_identities(), + vec![(id("f.rs", 2), ai("ai")), (id("f.rs", 3), ai("ai"))] + ); +} + +#[test] +fn removals_in_separate_hunks_keep_their_own_identities() { + let mut lineage = parent(&[("f.rs", Some("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -2,1 +1,0 @@\n-2\n@@ -8,1 +6,0 @@\n-8\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect("remove 2 and 8"); + assert_eq!( + lineage.removed_identities(), + vec![ + (id("f.rs", 2), LineProvenance::MutationNonAi), + (id("f.rs", 8), LineProvenance::MutationNonAi) + ] + ); +} + +#[test] +fn identical_text_at_two_parent_positions_keeps_two_identities() { + let mut lineage = parent(&[("f.rs", Some("x\na\nx\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -3,1 +2,0 @@\n-x\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove the second x"); + assert_eq!( + lineage.removed_identities(), + vec![(id("f.rs", 3), ai("ai"))] + ); + assert_eq!( + lineage.committed_removals("f.rs", "f.rs", &[(3, "x")]), + vec![ai("ai")] + ); + assert_eq!( + lineage.committed_removals("f.rs", "f.rs", &[(1, "x")]), + vec![LineProvenance::Unknown], + "a canonical diff aligned to the other x must not borrow its removal" + ); +} + +#[test] +fn a_disagreeing_alignment_among_duplicates_leaves_every_such_removal_unknown() { + let mut lineage = parent(&[("f.rs", Some("x\nx\na\nx\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -1,3 +1,1 @@\n-x\n-x\n a\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove the first two x"); + assert_eq!( + lineage.committed_removals("f.rs", "f.rs", &[(2, "x"), (4, "x")]), + vec![LineProvenance::Unknown, LineProvenance::Unknown] + ); + assert_eq!( + lineage.committed_removals("f.rs", "f.rs", &[(1, "x"), (2, "x")]), + vec![ai("ai"), ai("ai")] + ); +} + +#[test] +fn removing_a_line_added_after_the_parent_records_no_parent_removal() { + let mut lineage = parent(&[("f.rs", Some("a\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -1,0 +2,1 @@\n+n\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect("add n"); + lineage + .apply( + &edit("f.rs", "@@ -2,1 +1,0 @@\n-n\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove n"); + assert!(lineage.removed_identities().is_empty()); + assert_eq!( + lineage.committed_removals("f.rs", "f.rs", &[(2, "n")]), + vec![LineProvenance::Unknown] + ); +} + +#[test] +fn restoring_removed_content_invalidates_the_removal() { + let mut lineage = parent(&[("f.rs", Some("a\nb\nc\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -2,1 +1,0 @@\n-b\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove b"); + lineage + .apply( + &edit("f.rs", "@@ -1,0 +2,1 @@\n+b\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect("restore b"); + assert_eq!( + lineage.removed_identities(), + vec![(id("f.rs", 2), LineProvenance::Unknown)] + ); + assert_eq!( + lineage.provenance_at("f.rs", 2, "b"), + LineProvenance::MutationNonAi, + "the restored line is a new line, not the parent line" + ); +} + +#[test] +fn a_restored_then_redeleted_line_never_resurrects_the_older_ai_removal() { + let mut lineage = parent(&[("f.rs", Some("a\nb\nc\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -2,1 +1,0 @@\n-b\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove b"); + lineage + .apply( + &edit("f.rs", "@@ -1,0 +2,1 @@\n+b\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect("restore b"); + lineage + .apply( + &edit("f.rs", "@@ -2,1 +1,0 @@\n-b\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect("remove b again"); + assert_eq!( + lineage.removed_identities(), + vec![(id("f.rs", 2), LineProvenance::Unknown)] + ); + assert_eq!( + lineage.committed_removals("f.rs", "f.rs", &[(2, "b")]), + vec![LineProvenance::Unknown] + ); +} + +#[test] +fn matching_content_added_to_an_unrelated_file_is_not_a_restoration() { + let mut lineage = parent(&[("f.rs", Some("a\nb\n")), ("g.rs", Some("z\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -2,1 +1,0 @@\n-b\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove b"); + lineage + .apply( + &edit("g.rs", "@@ -1,0 +2,1 @@\n+b\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect("add b elsewhere"); + assert_eq!( + lineage.removed_identities(), + vec![(id("f.rs", 2), ai("ai"))] + ); +} + +#[test] +fn deleting_a_whole_file_removes_every_parent_line() { + let mut lineage = parent(&[("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::MutationAi(scope("ai")), + ) + .expect("delete"); + assert_eq!( + lineage.committed_removals("f.rs", "f.rs", &[(1, "a"), (2, "b")]), + vec![ai("ai"), ai("ai")] + ); +} + +#[test] +fn recreating_a_deleted_file_invalidates_only_the_restored_content() { + let mut lineage = parent(&[("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::MutationAi(scope("ai")), + ) + .expect("delete"); + lineage + .apply( + &diff("diff --git a/f.rs b/f.rs\nnew file mode 100644\n--- /dev/null\n+++ b/f.rs\n@@ -0,0 +1,2 @@\n+a\n+c\n"), + &TransitionOrigin::MutationNonAi, + ) + .expect("recreate"); + assert_eq!( + lineage.removed_identities(), + vec![ + (id("f.rs", 1), LineProvenance::Unknown), + (id("f.rs", 2), ai("ai")) + ] + ); +} + +#[test] +fn an_observed_rename_keeps_parent_identity_under_the_old_path() { + let mut lineage = parent(&[("old.md", Some("a\nb\nc\n")), ("new.md", None)]); + lineage + .apply( + &diff("diff --git a/old.md b/new.md\nsimilarity index 66%\nrename from old.md\nrename to new.md\n--- a/old.md\n+++ b/new.md\n@@ -1,3 +1,2 @@\n a\n-b\n c\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("rename and remove b"); + assert_eq!(lineage.parent_path_of("new.md"), Some("old.md")); + assert_eq!(lineage.parent_path_of("old.md"), None); + assert_eq!( + lineage.committed_removals("new.md", "old.md", &[(2, "b")]), + vec![ai("ai")] + ); +} + +#[test] +fn a_delete_and_add_pair_does_not_establish_a_rename() { + let mut lineage = parent(&[("old.md", Some("a\nb\n")), ("new.md", None)]); + lineage + .apply( + &diff("diff --git a/old.md b/old.md\ndeleted file mode 100644\n--- a/old.md\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-a\n-b\n\ + diff --git a/new.md b/new.md\nnew file mode 100644\n--- /dev/null\n+++ b/new.md\n@@ -0,0 +1,1 @@\n+a\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("delete and add"); + assert_eq!(lineage.parent_path_of("new.md"), None); + assert_eq!( + lineage.committed_removals("new.md", "old.md", &[(2, "b")]), + vec![LineProvenance::Unknown] + ); +} + +#[test] +fn a_baseline_reset_drops_every_removal_claim() { + let mut lineage = parent(&[("f.rs", Some("a\nb\n"))]); + lineage + .apply( + &edit("f.rs", "@@ -2,1 +1,0 @@\n-b\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove b"); + let map: BTreeMap> = + [("f.rs".to_owned(), Some("a\n".to_owned()))].into(); + lineage.reset_all(&map); + assert!(lineage.removed_identities().is_empty()); + lineage + .apply( + &edit("f.rs", "@@ -1,1 +0,0 @@\n-a\n"), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove a after the reset"); + assert!( + lineage.removed_identities().is_empty(), + "lines after a reset carry no parent identity" + ); + assert_eq!(lineage.parent_path_of("f.rs"), None); +} + +#[test] +fn distrusting_a_path_makes_its_removals_unknown() { + let mut lineage = parent(&[("f.rs", Some("a\nb\n")), ("g.rs", Some("c\nd\n"))]); + lineage + .apply( + &diff( + "diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -2,1 +1,0 @@\n-b\n\ + diff --git a/g.rs b/g.rs\n--- a/g.rs\n+++ b/g.rs\n@@ -2,1 +1,0 @@\n-d\n", + ), + &TransitionOrigin::MutationAi(scope("ai")), + ) + .expect("remove b and d"); + lineage.distrust_removals("f.rs"); + assert_eq!( + lineage.removed_identities(), + vec![ + (id("f.rs", 2), LineProvenance::Unknown), + (id("g.rs", 2), ai("ai")) + ] + ); +} + +/// Deterministic randomized checks of parent-line identity and removal +/// provenance against an independent reference model. +/// +/// The reference applies each generated operation directly to lines whose +/// parent identity is known by construction; the lineage only ever sees the +/// unified diff of that operation. The canonical committed diff is derived by +/// an independent LCS, so it can align duplicate content differently from the +/// replayed history. +mod randomized_tests { + use std::collections::BTreeMap; + use std::fmt::Write as _; + + use crate::services::mutation_trace::lineage::{ + LineProvenance, MutationLineage, ParentLineId, TransitionOrigin, + }; + use crate::services::mutation_trace::types::ScopeId; + use crate::services::patch::parse_patch; + + const PATH: &str = "f.rs"; + const SEEDS: u64 = 600; + const ALPHABET: [&str; 4] = ["a", "b", "c", "d"]; + const MAX_PARENT_LINES: u64 = 8; + const MAX_OPERATIONS: u64 = 10; + + struct SplitMix(u64); + + impl SplitMix { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + fn below(&mut self, bound: u64) -> u64 { + self.next() % bound + } + + fn index(&mut self, len: usize) -> usize { + usize::try_from(self.below(len as u64)).expect("index fits") + } + + fn content(&mut self) -> String { + ALPHABET[self.index(ALPHABET.len())].to_owned() + } + + fn origin(&mut self) -> TransitionOrigin { + match self.below(4) { + 0 => TransitionOrigin::MutationAi(ScopeId("scope-1".to_owned())), + 1 => TransitionOrigin::MutationAi(ScopeId("scope-2".to_owned())), + 2 => TransitionOrigin::MutationNonAi, + _ => TransitionOrigin::Unobserved, + } + } + } + + /// The reference's own mapping from transition to effective provenance. + fn expected_provenance(origin: &TransitionOrigin) -> LineProvenance { + match origin { + TransitionOrigin::MutationAi(scope_id) => LineProvenance::MutationAi { + scope_id: scope_id.clone(), + }, + TransitionOrigin::MutationNonAi => LineProvenance::MutationNonAi, + TransitionOrigin::Unobserved => LineProvenance::Unknown, + } + } + + #[derive(Clone)] + struct ReferenceLine { + content: String, + parent: Option, + provenance: LineProvenance, + } + + struct Reference { + lines: Vec, + removals: BTreeMap, + } + + impl Reference { + fn new(parent: &[String]) -> Self { + Reference { + lines: parent + .iter() + .zip(1_u64..) + .map(|(content, line_number)| ReferenceLine { + content: content.clone(), + parent: Some(line_number), + provenance: LineProvenance::Unknown, + }) + .collect(), + removals: BTreeMap::new(), + } + } + + /// Restoring content that an earlier transition removed makes that + /// removal unprovable; removals of the current transition are untouched. + fn restore_check(&mut self, content: &str) { + for (removed, provenance) in self.removals.values_mut() { + if removed == content { + *provenance = LineProvenance::Unknown; + } + } + } + + fn remove(&mut self, position: usize, origin: &TransitionOrigin) -> ReferenceLine { + let line = self.lines.remove(position); + if let Some(parent) = line.parent { + self.removals + .insert(parent, (line.content.clone(), expected_provenance(origin))); + } + line + } + + fn insert(&mut self, position: usize, content: &str, origin: &TransitionOrigin) { + self.lines.insert( + position, + ReferenceLine { + content: content.to_owned(), + parent: None, + provenance: expected_provenance(origin), + }, + ); + } + + fn gap(&mut self) { + self.removals.clear(); + for line in &mut self.lines { + line.parent = None; + line.provenance = LineProvenance::Unknown; + } + } + + fn text(&self) -> String { + lines_text(self.lines.iter().map(|line| line.content.as_str())) + } + } + + fn lines_text<'a>(lines: impl Iterator) -> String { + lines.fold(String::new(), |mut text, line| { + text.push_str(line); + text.push('\n'); + text + }) + } + + fn file_diff(hunk: &str) -> String { + format!("diff --git a/{PATH} b/{PATH}\n--- a/{PATH}\n+++ b/{PATH}\n{hunk}") + } + + /// A whole-file hunk, as Git emits it, that removes `old[position]` when + /// `removed` and then inserts `added` at `position`. + fn whole_file_hunk( + old: &[String], + position: usize, + removed: bool, + added: Option<&str>, + ) -> String { + let mut body = String::new(); + for (index, line) in old.iter().enumerate() { + if index == position { + if removed { + writeln!(body, "-{line}").expect("write hunk line"); + } + if let Some(added) = added { + writeln!(body, "+{added}").expect("write hunk line"); + } + if removed { + continue; + } + } + writeln!(body, " {line}").expect("write hunk line"); + } + if position == old.len() { + if let Some(added) = added { + writeln!(body, "+{added}").expect("write hunk line"); + } + } + let new_len = old.len() - usize::from(removed) + usize::from(added.is_some()); + let start = |len: usize| usize::from(len > 0); + format!( + "@@ -{},{} +{},{} @@\n{body}", + start(old.len()), + old.len(), + start(new_len), + new_len + ) + } + + /// Old-side line numbers an LCS diff from `old` to `new` removes. + fn lcs_removed(old: &[&str], new: &[&str]) -> Vec { + let mut table = vec![vec![0_usize; new.len() + 1]; old.len() + 1]; + for i in (0..old.len()).rev() { + for j in (0..new.len()).rev() { + table[i][j] = if old[i] == new[j] { + table[i + 1][j + 1] + 1 + } else { + table[i + 1][j].max(table[i][j + 1]) + }; + } + } + let (mut i, mut j) = (0, 0); + let mut removed = Vec::new(); + while i < old.len() { + if j < new.len() && old[i] == new[j] && table[i][j] == table[i + 1][j + 1] + 1 { + i += 1; + j += 1; + } else if j >= new.len() || table[i + 1][j] >= table[i][j + 1] { + removed.push(i as u64 + 1); + i += 1; + } else { + j += 1; + } + } + removed + } + + /// After a gap, no parent line can resolve as removed, whichever lines the + /// canonical diff names. + fn committed_removal_results_are_unknown(lineage: &MutationLineage, parent: &[String]) -> bool { + let every_parent_line: Vec<(u64, &str)> = parent + .iter() + .zip(1_u64..) + .map(|(content, line_number)| (line_number, content.as_str())) + .collect(); + lineage + .committed_removals(PATH, PATH, &every_parent_line) + .iter() + .all(|provenance| *provenance == LineProvenance::Unknown) + } + + #[derive(Default)] + struct Coverage { + ai_removals: usize, + non_ai_removals: usize, + unknown_removals: usize, + invalidated_restorations: usize, + gaps: usize, + } + + #[allow(clippy::too_many_lines)] + fn run_seed(seed: u64, coverage: &mut Coverage) { + let mut rng = SplitMix(seed); + let parent_len = rng.below(MAX_PARENT_LINES) + 1; + let parent: Vec = (0..parent_len).map(|_| rng.content()).collect(); + let parent_text = lines_text(parent.iter().map(String::as_str)); + let files: BTreeMap> = + [(PATH.to_owned(), Some(parent_text.clone()))].into(); + + let mut lineage = MutationLineage::from_parent(&files); + let mut added_only = MutationLineage::from_baseline(&files); + let mut reference = Reference::new(&parent); + let mut gapped = false; + + for _ in 0..=rng.below(MAX_OPERATIONS) { + if rng.below(12) == 0 { + let current: BTreeMap> = + [(PATH.to_owned(), Some(reference.text()))].into(); + lineage.reset_all(¤t); + added_only.reset_all(¤t); + reference.gap(); + gapped = true; + coverage.gaps += 1; + continue; + } + + let origin = rng.origin(); + let len = reference.lines.len(); + let old: Vec = reference + .lines + .iter() + .map(|line| line.content.clone()) + .collect(); + let hunk = match rng.below(4) { + 0 if len > 0 => { + let position = rng.index(len); + reference.remove(position, &origin); + whole_file_hunk(&old, position, true, None) + } + 1 if len > 0 => { + let position = rng.index(len); + let content = rng.content(); + reference.restore_check(&content); + reference.remove(position, &origin); + reference.insert(position, &content, &origin); + whole_file_hunk(&old, position, true, Some(&content)) + } + 2 if !reference.removals.is_empty() => { + let removed: Vec = reference + .removals + .values() + .map(|(content, _)| content.clone()) + .collect(); + let content = removed[rng.index(removed.len())].clone(); + let position = rng.index(len + 1); + if reference.removals.values().any(|(removed, provenance)| { + *removed == content && *provenance != LineProvenance::Unknown + }) { + coverage.invalidated_restorations += 1; + } + reference.restore_check(&content); + reference.insert(position, &content, &origin); + whole_file_hunk(&old, position, false, Some(&content)) + } + _ => { + let position = rng.index(len + 1); + let content = rng.content(); + reference.restore_check(&content); + reference.insert(position, &content, &origin); + whole_file_hunk(&old, position, false, Some(&content)) + } + }; + let patch = parse_patch(&file_diff(&hunk), None).expect("generated diff parses"); + lineage.apply(&patch, &origin).unwrap_or_else(|error| { + panic!("seed {seed}: lineage rejected {hunk:?}: {error:?}") + }); + added_only + .apply(&patch, &origin) + .expect("added-line lineage applies the same diff"); + } + + let expected_removals: Vec<(ParentLineId, LineProvenance)> = reference + .removals + .iter() + .map(|(line_number, (_, provenance))| { + ( + ParentLineId { + path: PATH.to_owned(), + line_number: *line_number, + }, + provenance.clone(), + ) + }) + .collect(); + assert_eq!( + lineage.removed_identities(), + expected_removals, + "seed {seed}: removal identities" + ); + for (identity, _) in &expected_removals { + assert!(identity.line_number >= 1 && identity.line_number <= parent_len); + } + if gapped { + assert!( + committed_removal_results_are_unknown(&lineage, &parent), + "seed {seed}: a gap must not preserve removal ownership" + ); + } + + for (index, line) in reference.lines.iter().enumerate() { + let line_number = index as u64 + 1; + assert_eq!( + lineage.provenance_at(PATH, line_number, &line.content), + line.provenance, + "seed {seed}: added-line provenance at {line_number}" + ); + assert_eq!( + added_only.provenance_at(PATH, line_number, &line.content), + line.provenance, + "seed {seed}: identity tracking changed added-line provenance" + ); + } + + let old: Vec<&str> = parent.iter().map(String::as_str).collect(); + let new: Vec<&str> = reference + .lines + .iter() + .map(|line| line.content.as_str()) + .collect(); + let committed: Vec<(u64, &str)> = lcs_removed(&old, &new) + .into_iter() + .map(|line_number| { + let index = usize::try_from(line_number - 1).expect("index fits"); + (line_number, old[index]) + }) + .collect(); + let results = lineage.committed_removals(PATH, PATH, &committed); + assert_eq!(results.len(), committed.len()); + + let surviving_parents: Vec = reference + .lines + .iter() + .filter_map(|line| line.parent) + .collect(); + for ((line_number, content), result) in committed.iter().zip(&results) { + match result { + LineProvenance::Unknown => coverage.unknown_removals += 1, + resolved => { + let (removed_content, provenance) = + reference.removals.get(line_number).unwrap_or_else(|| { + panic!("seed {seed}: {line_number} resolved without removal") + }); + assert_eq!(removed_content, content, "seed {seed}"); + assert_eq!(provenance, resolved, "seed {seed}: removal provenance"); + assert!( + !surviving_parents.contains(line_number), + "seed {seed}: a surviving parent line was attributed as removed" + ); + if matches!(resolved, LineProvenance::MutationAi { .. }) { + coverage.ai_removals += 1; + } else { + coverage.non_ai_removals += 1; + } + } + } + } + } + + #[test] + fn randomized_histories_match_the_reference_removal_model() { + let mut coverage = Coverage::default(); + for seed in 0..SEEDS { + run_seed(seed, &mut coverage); + } + assert!(coverage.ai_removals > 0, "no AI removal was exercised"); + assert!( + coverage.non_ai_removals > 0, + "no non-AI removal was exercised" + ); + assert!( + coverage.unknown_removals > 0, + "no unknown removal was exercised" + ); + assert!( + coverage.invalidated_restorations > 0, + "no restoration of an attributed removal was exercised" + ); + assert!(coverage.gaps > 0, "no gap was exercised"); + } + + #[test] + fn lcs_reference_diff_removes_only_unmatched_old_lines() { + assert_eq!(lcs_removed(&["a", "b", "c"], &["a", "c"]), vec![2]); + assert_eq!(lcs_removed(&["a", "b"], &[]), vec![1, 2]); + assert_eq!(lcs_removed(&["a"], &["b", "a"]), Vec::::new()); + } +} diff --git a/cli/src/services/mutation_trace/runtime/git_snapshot.rs b/cli/src/services/mutation_trace/runtime/git_snapshot.rs index ff596bff6..2f0d931db 100644 --- a/cli/src/services/mutation_trace/runtime/git_snapshot.rs +++ b/cli/src/services/mutation_trace/runtime/git_snapshot.rs @@ -19,6 +19,12 @@ const REF_NAMESPACE: &str = "refs/sce/mutation-cursor"; const FOR_EACH_REF_PIN_FORMAT: &str = "--format=%(refname)%00%(objectname)%00%(objecttype)%00%(symref)"; +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommittedTrees { + pub commit_tree: TreeId, + pub parent_tree: Option, +} + pub struct GitSnapshotService { git_dir: PathBuf, repository_root: PathBuf, @@ -198,8 +204,36 @@ impl GitSnapshotService { .await } - pub async fn head_tree(&self) -> Result { - let tree = self.run_git(&["rev-parse", "HEAD^{tree}"], None).await?; + /// Resolves the tree of `commit_oid` and, for a single-parent commit, the + /// tree of its first parent, by explicit object ID and never through `HEAD`. + /// Root and merge commits have no parent tree to anchor removals to. + pub async fn committed_trees(&self, commit_oid: &str) -> Result { + if commit_oid.is_empty() || !commit_oid.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("commit OID '{commit_oid}' is not a hexadecimal object ID"); + } + let commit_tree = self.object_tree(commit_oid).await?; + let parents_line = self + .run_git( + &["rev-list", "--parents", "--max-count=1", commit_oid], + None, + ) + .await?; + let parents: Vec<&str> = parents_line.split_whitespace().skip(1).collect(); + let parent_tree = match parents.as_slice() { + [parent] => Some(self.object_tree(parent).await?), + _ => None, + }; + Ok(CommittedTrees { + commit_tree, + parent_tree, + }) + } + + async fn object_tree(&self, object_id: &str) -> Result { + let spec = format!("{object_id}^{{tree}}"); + let tree = self + .run_git(&["rev-parse", "--verify", "--quiet", &spec], None) + .await?; Ok(TreeId(tree.trim().to_string())) } diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution.rs index 158f9c7fa..e80c0a68b 100644 --- a/cli/src/services/mutation_trace/runtime/mutation_attribution.rs +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution.rs @@ -15,9 +15,11 @@ use crate::services::mutation_trace::store::{ MUTATION_ATTRIBUTION_PAGE_SIZE, }; use crate::services::mutation_trace::types::{FailureKind, ScopeId, TreeId, WorktreeId}; -use crate::services::patch::{parse_patch, ParsedPatch, TouchedLineKind}; +use crate::services::patch::{parse_patch, ParsedPatch, PatchFileChange, TouchedLineKind}; -use super::git_snapshot::{resolve_git_dir, resolve_worktree_id, GitSnapshotService}; +use super::git_snapshot::{ + resolve_git_dir, resolve_worktree_id, CommittedTrees, GitSnapshotService, +}; use super::worktree_lock::WorktreeLock; pub const MAX_MUTATION_ATTRIBUTION_EVENTS: usize = 128; @@ -90,6 +92,10 @@ pub struct BoundedMutationAttribution { pub reconstructed_events: usize, pub gap_resets: usize, pub barrier: Option, + /// Whether an uninterrupted replay from the exact parent tree reached the + /// last loaded event. Removed lines resolve only when it did, and only in + /// paths that also reconciled with the commit tree. + pub removal_replay_anchored: bool, } impl BoundedMutationAttribution { @@ -102,6 +108,7 @@ impl BoundedMutationAttribution { reconstructed_events: 0, gap_resets: 0, barrier: None, + removal_replay_anchored: false, } } } @@ -118,6 +125,11 @@ fn empty_attribution() -> MutationAttributionResult { } } +/// Resolves committed touched lines from bounded mutation history. +/// +/// Added lines use the existing gap-tolerant replay. Removed lines resolve only +/// through a separate, stricter replay anchored at `parent_tree`; without a +/// parent tree (root or merge commits) every removed line stays unresolved. #[allow(clippy::too_many_arguments)] pub async fn resolve_bounded_mutation_attribution( page_source: &P, @@ -125,6 +137,7 @@ pub async fn resolve_bounded_mutation_attribution( worktree: &WorktreeId, committed_patch: &ParsedPatch, commit_tree: &TreeId, + parent_tree: Option<&TreeId>, revision_ceiling: Option, ) -> BoundedMutationAttribution where @@ -146,12 +159,26 @@ where }; let events = load_event_window(page_source, worktree, revision_ceiling, &mut state).await; - let lineage = if events.is_empty() { + let lineages = if events.is_empty() { None } else { - Some(replay(&events, &target_paths, commit_tree, tree_source, &mut state).await) + let removal_anchor = parent_tree.map(|parent_tree| RemovalAnchor { + parent_tree, + paths: removal_parent_paths(committed_patch), + }); + Some( + replay( + &events, + &target_paths, + removal_anchor, + commit_tree, + tree_source, + &mut state, + ) + .await, + ) }; - finish(committed_patch, lineage.as_ref(), &state, page_source).await + finish(committed_patch, lineages.as_ref(), &state, page_source).await } struct ReplayState { @@ -163,17 +190,30 @@ struct ReplayState { barrier: Option, } +struct ReplayedLineages { + added: MutationLineage, + removals: Option, +} + async fn finish

( target: &ParsedPatch, - lineage: Option<&MutationLineage>, + lineages: Option<&ReplayedLineages>, state: &ReplayState, page_source: &P, ) -> BoundedMutationAttribution where P: MutationEventPageSource + ?Sized, { - let result = match lineage { - Some(lineage) => project(target, lineage, page_source).await, + let result = match lineages { + Some(lineages) => { + project( + target, + &lineages.added, + lineages.removals.as_ref(), + page_source, + ) + .await + } None => MutationAttributionResult { mutation_ai_patch: empty_patch(), resolved_non_ai_patch: empty_patch(), @@ -189,6 +229,7 @@ where reconstructed_events: state.reconstructed_events, gap_resets: state.gap_resets, barrier: state.barrier, + removal_replay_anchored: lineages.is_some_and(|lineages| lineages.removals.is_some()), } } @@ -201,6 +242,111 @@ fn target_logical_paths(target: &ParsedPatch) -> BTreeSet { .collect() } +/// Every path whose parent lines a committed removal can name, plus the path +/// that holds them in the commit. +fn removal_parent_paths(target: &ParsedPatch) -> BTreeSet { + target + .files + .iter() + .filter(|file| { + file.hunks.iter().any(|hunk| { + hunk.lines + .iter() + .any(|line| line.kind == TouchedLineKind::Removed) + }) + }) + .flat_map(|file| [file.old_path.clone(), file.new_path.clone()]) + .filter(|path| !path.is_empty()) + .collect() +} + +struct RemovalAnchor<'a> { + parent_tree: &'a TreeId, + paths: BTreeSet, +} + +/// The removal replay: it starts at the last event leaving the exact parent +/// tree and is abandoned, never reset, at the first gap or failed transition. +struct RemovalReplay { + anchor: usize, + lineage: MutationLineage, +} + +async fn start_removal_replay( + events: &[MutationEventPageRow], + anchor: Option>, + tree_source: &R, +) -> Option +where + R: TreeReadSource, +{ + let anchor_spec = anchor?; + let anchor = events + .iter() + .rposition(|row| row.before_tree == *anchor_spec.parent_tree)?; + let mut baseline = BTreeMap::new(); + for path in &anchor_spec.paths { + let content = tree_source + .file_at_tree(anchor_spec.parent_tree, path) + .await + .ok()?; + baseline.insert(path.clone(), content); + } + Some(RemovalReplay { + anchor, + lineage: MutationLineage::from_parent(&baseline), + }) +} + +fn advance_removal_replay( + replay: &mut Option, + index: usize, + row: &MutationEventPageRow, + prev_after: &TreeId, + reconstructed: Option<&ParsedPatch>, + origin: &TransitionOrigin, +) { + let Some(active) = replay.as_mut() else { + return; + }; + if index < active.anchor { + return; + } + let contiguous = index == active.anchor || row.before_tree == *prev_after; + let applied = contiguous + && reconstructed.is_some_and(|patch| active.lineage.apply(patch, origin).is_ok()); + if !applied { + *replay = None; + } +} + +/// Reconciles the removal replay with the commit tree, path by path. A path +/// keeps its removal claims only when it holds exactly its committed lines and +/// any unobserved tail between the last event and the commit left it +/// byte-identical; otherwise (for example a partially staged file) every +/// removal claim the path bears on becomes unknown. +async fn settle_removal_replay( + replay: Option, + last_after: &TreeId, + commit_tree: &TreeId, + tree_source: &R, +) -> Option +where + R: TreeReadSource, +{ + let mut lineage = replay?.lineage; + let paths: Vec = lineage.tracked_paths().map(ToOwned::to_owned).collect(); + for path in &paths { + let committed = tree_source.file_at_tree(commit_tree, path).await.ok()?; + let tail_touches_path = last_after != commit_tree + && tree_source.file_at_tree(last_after, path).await.ok()? != committed; + if tail_touches_path || !lineage.matches_content(path, committed.as_deref()) { + lineage.distrust_removals(path); + } + } + Some(lineage) +} + async fn load_event_window

( page_source: &P, worktree: &WorktreeId, @@ -248,19 +394,21 @@ where async fn replay( events: &[MutationEventPageRow], target_paths: &BTreeSet, + removal_anchor: Option>, commit_tree: &TreeId, tree_source: &R, state: &mut ReplayState, -) -> MutationLineage +) -> ReplayedLineages where R: TreeReadSource, { + let mut removal_replay = start_removal_replay(events, removal_anchor, tree_source).await; let mut lineage = MutationLineage::from_baseline( &load_baseline(tree_source, &events[0].before_tree, target_paths).await, ); let mut prev_after = events[0].before_tree.clone(); - for row in events { + for (index, row) in events.iter().enumerate() { state.inspected_events += 1; if row.before_tree != prev_after { @@ -278,6 +426,14 @@ where } let origin = transition_origin(row); + advance_removal_replay( + &mut removal_replay, + index, + row, + &prev_after, + reconstructed.as_ref(), + &origin, + ); if apply_or_reset( &mut lineage, reconstructed.as_ref(), @@ -295,6 +451,9 @@ where prev_after = row.after_tree.clone(); } + let removals = + settle_removal_replay(removal_replay, &prev_after, commit_tree, tree_source).await; + if prev_after != *commit_tree { let tail = tree_source .diff_trees(&prev_after, commit_tree) @@ -316,7 +475,10 @@ where } } - lineage + ReplayedLineages { + added: lineage, + removals, + } } async fn apply_or_reset( @@ -363,20 +525,59 @@ fn transition_origin(row: &MutationEventPageRow) -> TransitionOrigin { } } +#[derive(Default)] +struct ProjectedLines { + ai: BTreeMap, + non_ai: BTreeSet, + unresolved: BTreeSet, +} + +impl ProjectedLines { + fn assign(&mut self, location: PatchLineLocation, provenance: LineProvenance) { + match provenance { + LineProvenance::MutationAi { scope_id } => { + self.ai.insert(location, scope_id); + } + LineProvenance::MutationNonAi => { + self.non_ai.insert(location); + } + LineProvenance::Unknown => { + self.unresolved.insert(location); + } + } + } +} + +/// Removed lines are resolved by their canonical old-side identity: the +/// file's old path and old line number, never new-file coordinates. +fn removed_line_provenance( + file: &PatchFileChange, + removals: Option<&MutationLineage>, + removed: &[(u64, &str)], +) -> Vec { + match removals { + Some(lineage) if !file.old_path.is_empty() => { + lineage.committed_removals(logical_path(file), &file.old_path, removed) + } + _ => vec![LineProvenance::Unknown; removed.len()], + } +} + async fn project

( target: &ParsedPatch, lineage: &MutationLineage, + removals: Option<&MutationLineage>, page_source: &P, ) -> MutationAttributionResult where P: MutationEventPageSource + ?Sized, { - let mut ai: BTreeMap = BTreeMap::new(); - let mut non_ai = BTreeSet::new(); - let mut unresolved = BTreeSet::new(); + let mut projected = ProjectedLines::default(); for (file_index, file) in target.files.iter().enumerate() { let path = logical_path(file); + let mut removed_locations = Vec::new(); + let mut removed_lines = Vec::new(); for (hunk_index, hunk) in file.hunks.iter().enumerate() { for (line_index, line) in hunk.lines.iter().enumerate() { let location = PatchLineLocation { @@ -384,24 +585,28 @@ where 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 { scope_id } => { - ai.insert(location, scope_id); - } - LineProvenance::MutationNonAi => { - non_ai.insert(location); - } - LineProvenance::Unknown => { - unresolved.insert(location); + match line.kind { + TouchedLineKind::Added => projected.assign( + location, + lineage.provenance_at(path, line.line_number, &line.content), + ), + TouchedLineKind::Removed => { + removed_locations.push(location); + removed_lines.push((line.line_number, line.content.as_str())); } } } } + let provenances = removed_line_provenance(file, removals, &removed_lines); + for (location, provenance) in removed_locations.into_iter().zip(provenances) { + projected.assign(location, provenance); + } } + let ProjectedLines { + ai, + non_ai, + unresolved, + } = projected; let mut scope_provenance = BTreeMap::new(); for scope_id in ai.values().collect::>() { @@ -433,9 +638,15 @@ where } } +/// Resolves mutation attribution for the commit captured as `commit_oid`. +/// +/// Both the commit tree and its first-parent tree are resolved from that exact +/// object ID, never from a mutable `HEAD`, so a later `HEAD` move cannot +/// change which revisions removed lines are anchored to. pub(crate) async fn resolve_post_commit_mutation_attribution( repository_root: &Path, db: &mut RepositoryAgentTraceDb, + commit_oid: &str, committed_patch: &ParsedPatch, ) -> MutationAttributionResult { let Ok(git_dir) = resolve_git_dir(repository_root).await else { @@ -447,7 +658,11 @@ pub(crate) async fn resolve_post_commit_mutation_attribution( let Ok(snapshot) = GitSnapshotService::new(repository_root).await else { return empty_attribution(); }; - let Ok(commit_tree) = snapshot.head_tree().await else { + let Ok(CommittedTrees { + commit_tree, + parent_tree, + }) = snapshot.committed_trees(commit_oid).await + else { return empty_attribution(); }; let store = MutationTraceStore::new(db); @@ -462,6 +677,7 @@ pub(crate) async fn resolve_post_commit_mutation_attribution( &worktree, committed_patch, &commit_tree, + parent_tree.as_ref(), Some(revision_ceiling), ) .await diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs index f0dd036bf..e73041d5a 100644 --- a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs @@ -94,6 +94,38 @@ impl TreeRepo { TreeId(self.git(&["write-tree"])) } + fn trees_with(&self, files: &[(&str, Option<&str>)]) -> TreeId { + for (path, content) in files { + let path = self.root().join(path); + match content { + Some(content) => std::fs::write(&path, content).expect("write file"), + None => { + let _ = std::fs::remove_file(&path); + } + } + } + self.git(&["add", "-A"]); + TreeId(self.git(&["write-tree"])) + } + + /// Commits whatever `tree` last staged and returns the commit OID. + fn commit(&self, message: &str) -> String { + self.git(&[ + "-c", + "user.name=Attribution Test", + "-c", + "user.email=attribution@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "--allow-empty", + "-m", + message, + ]); + self.git(&["rev-parse", "HEAD"]) + } + async fn snapshot(&self) -> GitSnapshotService { GitSnapshotService::new(self.root()) .await @@ -131,6 +163,17 @@ async fn resolve( committed: &ParsedPatch, commit_tree: &TreeId, ceiling: Option, +) -> BoundedMutationAttribution { + resolve_from(repo, rows, committed, commit_tree, None, ceiling).await +} + +async fn resolve_from( + repo: &TreeRepo, + rows: Vec, + committed: &ParsedPatch, + commit_tree: &TreeId, + parent_tree: Option<&TreeId>, + ceiling: Option, ) -> BoundedMutationAttribution { resolve_bounded_mutation_attribution( &VecPages { rows }, @@ -138,6 +181,7 @@ async fn resolve( &WorktreeId(WORKTREE.to_owned()), committed, commit_tree, + parent_tree, ceiling, ) .await @@ -413,3 +457,388 @@ async fn an_event_beyond_the_revision_ceiling_keeps_the_line_unknown() { assert_eq!(attribution.loaded_rows, 1); assert_barrier_keeps_line_unknown(&attribution, &committed); } + +fn removed_line_numbers(patch: &ParsedPatch) -> Vec { + patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .filter(|line| line.kind == TouchedLineKind::Removed) + .map(|line| line.line_number) + .collect() +} + +fn removed_in(patch: &ParsedPatch, old_path: &str) -> Vec { + removed_line_numbers(&ParsedPatch { + files: patch + .files + .iter() + .filter(|file| file.old_path == old_path) + .cloned() + .collect(), + }) +} + +const ABC: &str = "a\nb\nc\n"; +const AC: &str = "a\nc\n"; + +struct RemovalCase { + repo: TreeRepo, + parent: TreeId, + commit: TreeId, + committed: ParsedPatch, +} + +async fn removal_case(parent: &str, commit: &str) -> RemovalCase { + let repo = TreeRepo::new(); + let parent = repo.tree(Some(parent)); + let commit = repo.tree(Some(commit)); + let committed = committed_patch(&repo.snapshot().await, &parent, &commit).await; + RemovalCase { + repo, + parent, + commit, + committed, + } +} + +impl RemovalCase { + async fn resolve(&self, rows: Vec) -> BoundedMutationAttribution { + let ceiling = rows.iter().map(|row| row.revision).max(); + resolve_from( + &self.repo, + rows, + &self.committed, + &self.commit, + Some(&self.parent), + ceiling, + ) + .await + } +} + +fn assert_removal_unresolved(attribution: &BoundedMutationAttribution, expected: &[u64]) { + assert!(removed_line_numbers(&attribution.result.mutation_ai_patch).is_empty()); + assert!(removed_line_numbers(&attribution.result.resolved_non_ai_patch).is_empty()); + assert_eq!( + removed_line_numbers(&attribution.result.unresolved_patch), + expected + ); +} + +#[tokio::test] +async fn a_proven_ai_exclusive_removal_is_attributed_with_scope_provenance() { + let case = removal_case(ABC, AC).await; + let attribution = case + .resolve(vec![event(1, &case.parent, &case.commit, true)]) + .await; + + assert!(attribution.removal_replay_anchored); + assert_eq!( + removed_line_numbers(&attribution.result.mutation_ai_patch), + vec![2] + ); + let hunk = &attribution.result.mutation_ai_patch.files[0].hunks[0]; + assert_eq!(hunk.lines[0].content, "b"); + assert_eq!(hunk.lines[0].session_id.as_deref(), Some(AUTHOR_SESSION)); + assert_eq!(hunk.model_id.as_deref(), Some(AUTHOR_MODEL)); + assert!(removed_line_numbers(&attribution.result.unresolved_patch).is_empty()); + + let trace = build_trace(&case.committed, &attribution.result, &[]); + assert!(trace.files.is_empty(), "deleted lines produce no ranges"); + assert_eq!(trace.metadata.sce.line_changes.ai.removed, 1); + assert_eq!(trace.metadata.sce.line_changes.unknown.removed, 0); +} + +#[tokio::test] +async fn a_proven_non_exclusive_removal_is_non_exclusive() { + let case = removal_case(ABC, AC).await; + let attribution = case + .resolve(vec![event(1, &case.parent, &case.commit, false)]) + .await; + + assert!(removed_line_numbers(&attribution.result.mutation_ai_patch).is_empty()); + assert_eq!( + removed_line_numbers(&attribution.result.resolved_non_ai_patch), + vec![2] + ); + let trace = build_trace(&case.committed, &attribution.result, &[]); + assert_eq!(trace.metadata.sce.line_changes.non_exclusive.removed, 1); +} + +#[tokio::test] +async fn history_without_the_parent_tree_leaves_removals_unresolved() { + let case = removal_case(ABC, AC).await; + let elsewhere = case.repo.tree(Some("a\nb\nc\nz\n")); + let attribution = case + .resolve(vec![event(1, &elsewhere, &case.commit, true)]) + .await; + + assert!(!attribution.removal_replay_anchored); + assert_removal_unresolved(&attribution, &[2]); +} + +#[tokio::test] +async fn a_history_gap_after_the_parent_anchor_leaves_removals_unresolved() { + let case = removal_case(ABC, AC).await; + let diverged = case.repo.tree(Some("a\nc\nx\n")); + let attribution = case + .resolve(vec![ + event(1, &case.parent, &case.commit, true), + event(2, &diverged, &case.commit, true), + ]) + .await; + + assert!(!attribution.removal_replay_anchored); + assert_removal_unresolved(&attribution, &[2]); +} + +#[tokio::test] +async fn a_parent_transition_outside_the_replay_window_leaves_removals_unresolved() { + let case = removal_case(ABC, AC).await; + let newest = MAX_MUTATION_ATTRIBUTION_EVENTS as u64 + 1; + let mut rows = vec![event(1, &case.parent, &case.commit, true)]; + rows.extend((2..=newest).map(|revision| event(revision, &case.commit, &case.commit, true))); + let attribution = case.resolve(rows).await; + + assert_eq!(attribution.loaded_rows, MAX_MUTATION_ATTRIBUTION_EVENTS); + assert!(!attribution.removal_replay_anchored); + assert_removal_unresolved(&attribution, &[2]); +} + +#[tokio::test] +async fn an_unobserved_tail_removal_stays_unresolved() { + let case = removal_case(ABC, AC).await; + let attribution = case + .resolve(vec![event(1, &case.parent, &case.parent, true)]) + .await; + + assert!(attribution.removal_replay_anchored); + assert_removal_unresolved(&attribution, &[2]); +} + +#[tokio::test] +async fn an_unobserved_tail_only_distrusts_the_paths_it_touches() { + let repo = TreeRepo::new(); + let parent = repo.trees_with(&[(PLAN_PATH, Some(ABC)), ("other.txt", Some("o\n"))]); + let observed = repo.trees_with(&[(PLAN_PATH, Some(AC))]); + let commit = repo.trees_with(&[("other.txt", Some("o2\n"))]); + let committed = committed_patch(&repo.snapshot().await, &parent, &commit).await; + + let attribution = resolve_from( + &repo, + vec![event(1, &parent, &observed, true)], + &committed, + &commit, + Some(&parent), + Some(1), + ) + .await; + + assert_eq!( + removed_in(&attribution.result.mutation_ai_patch, PLAN_PATH), + vec![2] + ); + assert_eq!( + removed_in(&attribution.result.unresolved_patch, "other.txt"), + vec![1] + ); +} + +#[tokio::test] +async fn a_partially_staged_removal_is_not_attributed_from_the_larger_worktree_change() { + let case = removal_case("A\nB\nC\n", "A\nC\n").await; + let worktree = case.repo.tree(Some("A\n")); + let attribution = case + .resolve(vec![event(1, &case.parent, &worktree, true)]) + .await; + + assert!(attribution.removal_replay_anchored); + assert_removal_unresolved(&attribution, &[2]); +} + +#[tokio::test] +async fn a_restored_then_redeleted_line_keeps_no_stale_ai_removal() { + let case = removal_case(ABC, AC).await; + let restored = case.repo.tree(Some("a\nc\nb\n")); + let attribution = case + .resolve(vec![ + event(1, &case.parent, &case.commit, true), + event(2, &case.commit, &restored, false), + event(3, &restored, &case.commit, false), + ]) + .await; + + assert!(attribution.removal_replay_anchored); + assert_removal_unresolved(&attribution, &[2]); +} + +#[tokio::test] +async fn an_exact_return_to_the_parent_tree_reanchors_at_the_latest_removal() { + let case = removal_case(ABC, AC).await; + let attribution = case + .resolve(vec![ + event(1, &case.parent, &case.commit, true), + event(2, &case.commit, &case.parent, false), + event(3, &case.parent, &case.commit, false), + ]) + .await; + + assert!(removed_line_numbers(&attribution.result.mutation_ai_patch).is_empty()); + assert_eq!( + removed_line_numbers(&attribution.result.resolved_non_ai_patch), + vec![2] + ); +} + +#[tokio::test] +async fn a_duplicate_removal_maps_to_its_own_parent_line() { + let case = removal_case("x\na\nx\n", "x\na\n").await; + let attribution = case + .resolve(vec![event(1, &case.parent, &case.commit, true)]) + .await; + + let removed: Vec<_> = attribution.result.mutation_ai_patch.files[0].hunks[0] + .lines + .iter() + .map(|line| (line.line_number, line.content.as_str())) + .collect(); + assert_eq!(removed, vec![(3, "x")]); +} + +#[tokio::test] +async fn removed_and_added_lines_resolve_independently() { + let case = removal_case(ABC, "a\nB\nc\nnew\n").await; + let attribution = case + .resolve(vec![event(1, &case.parent, &case.commit, true)]) + .await; + + assert_eq!( + removed_line_numbers(&attribution.result.mutation_ai_patch), + vec![2] + ); + assert_eq!( + added_line_numbers(&attribution.result.mutation_ai_patch), + vec![2, 4] + ); + + let without_parent = resolve( + &case.repo, + vec![event(1, &case.parent, &case.commit, true)], + &case.committed, + &case.commit, + Some(1), + ) + .await; + assert_eq!( + added_line_numbers(&without_parent.result.mutation_ai_patch), + vec![2, 4], + "added lines resolve the same without a parent anchor" + ); + assert_eq!( + removed_line_numbers(&without_parent.result.unresolved_patch), + vec![2] + ); +} + +#[tokio::test] +async fn a_renamed_file_resolves_removals_under_its_old_path() { + let repo = TreeRepo::new(); + let parent = repo.trees_with(&[("old.md", Some("one\ntwo\nthree\nfour\nfive\n"))]); + let commit = repo.trees_with(&[("old.md", None), ("new.md", Some("one\ntwo\nfour\nfive\n"))]); + let committed = committed_patch(&repo.snapshot().await, &parent, &commit).await; + assert_eq!(committed.files[0].old_path, "old.md"); + assert_eq!(committed.files[0].new_path, "new.md"); + + let attribution = resolve_from( + &repo, + vec![event(1, &parent, &commit, true)], + &committed, + &commit, + Some(&parent), + Some(1), + ) + .await; + assert_eq!( + removed_in(&attribution.result.mutation_ai_patch, "old.md"), + vec![3] + ); +} + +#[tokio::test] +async fn committed_trees_follow_the_captured_commit_after_head_moves() { + let repo = TreeRepo::new(); + let parent = repo.tree(Some(ABC)); + repo.commit("parent"); + let commit = repo.tree(Some(AC)); + let commit_oid = repo.commit("commit"); + repo.tree(Some("a\n")); + repo.commit("later"); + assert_ne!(TreeId(repo.git(&["rev-parse", "HEAD^{tree}"])), commit); + + let snapshot = repo.snapshot().await; + let trees = snapshot + .committed_trees(&commit_oid) + .await + .expect("committed trees"); + assert_eq!(trees.commit_tree, commit); + assert_eq!(trees.parent_tree.as_ref(), Some(&parent)); + + let committed = committed_patch(&snapshot, &parent, &commit).await; + let attribution = resolve_from( + &repo, + vec![event(1, &parent, &commit, true)], + &committed, + &trees.commit_tree, + trees.parent_tree.as_ref(), + Some(1), + ) + .await; + assert_eq!( + removed_line_numbers(&attribution.result.mutation_ai_patch), + vec![2] + ); +} + +#[tokio::test] +async fn root_and_merge_commits_have_no_parent_tree() { + let repo = TreeRepo::new(); + repo.tree(Some(ABC)); + let root = repo.commit("root"); + let snapshot = repo.snapshot().await; + assert_eq!( + snapshot + .committed_trees(&root) + .await + .expect("root trees") + .parent_tree, + None + ); + + repo.git(&["checkout", "-q", "-b", "side"]); + repo.tree(Some("a\nb\nc\nside\n")); + repo.commit("side"); + repo.git(&["checkout", "-q", "main"]); + repo.tree(Some("main\na\nb\nc\n")); + repo.commit("main"); + repo.git(&[ + "-c", + "user.name=Attribution Test", + "-c", + "user.email=attribution@example.invalid", + "merge", + "-q", + "--no-ff", + "--no-edit", + "side", + ]); + let merge = repo.git(&["rev-parse", "HEAD"]); + let trees = snapshot.committed_trees(&merge).await.expect("merge trees"); + assert_eq!(trees.parent_tree, None); + assert_eq!( + trees.commit_tree, + TreeId(repo.git(&["rev-parse", "HEAD^{tree}"])) + ); + assert!(snapshot.committed_trees("HEAD").await.is_err()); +} diff --git a/context/cli/mutation-trace-agent-attribution.md b/context/cli/mutation-trace-agent-attribution.md index 33111d0d4..38a035ff7 100644 --- a/context/cli/mutation-trace-agent-attribution.md +++ b/context/cli/mutation-trace-agent-attribution.md @@ -15,7 +15,8 @@ introduced, that event's provenance is dead and no older event can resurrect it. `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. +`(content, LineProvenance, Option)` 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 @@ -62,6 +63,42 @@ Any structurally inconsistent transition (content mismatch, inconsistent hunk lengths, out-of-range hunk) returns `LineageError`; the caller fails closed for the affected file. +### Parent-line identity + +A removed line no longer exists in the committed file, so its identity cannot +come from new-file coordinates. `MutationLineage::from_parent` seeds a lineage +from the exact parent commit's tree and gives every baseline line a +`ParentLineId { path, line_number }`: its parent-side path and 1-based line +number. `from_baseline` (the added-line replay) gives no line a parent +identity. + +- A carried line keeps its `ParentLineId` however far its position moves. +- Every line a transition adds has no parent identity, even when its text equals + a parent line. Text equality never proves identity. +- When a transition removes a line that has a parent identity, the lineage + records a removal of that identity, with the line's content and the + provenance of the **removing** transition (`MutationAi` → AI with its scope, + `MutationNonAi` → non-exclusive, `Unobserved` → `Unknown`). The provenance of + the line's original author is never consulted. Removing a line that was added + after the parent records nothing. +- **Restoration.** When a later transition adds content equal to an + already-removed parent line of the same parent file (the current path, or the + parent path it holds by an observed rename), that earlier removal claim + becomes `Unknown` for good. A later removal of the restored line has no parent + identity, so it cannot resurrect the older claim. Removals made by the same + transition are not invalidated by its own additions. +- **Renames.** The lineage maps each tracked path to the parent path whose + lines it holds. Only a rename the replayed diff itself reports moves that + mapping; a delete-plus-add pair does not establish it. +- `reset_all` drops every parent identity and removal claim. + +`committed_removals(current_path, parent_path, lines)` resolves a canonical +file's removed lines. It returns `Unknown` for every line unless the lineage +establishes that `current_path` holds `parent_path`. Where the canonical diff +and the replay disagree about which parent lines were removed, the two chose +different alignments among equal content. In that case every committed removal +sharing the disagreeing content stays `Unknown`. + ## Bounded history consumer `resolve_bounded_mutation_attribution` in @@ -119,17 +156,83 @@ alongside `diff_trees`). The consumer performs no mutation-cursor write and creates no worktree or scope identity. +## Removed-line attribution + +Added and removed lines answer different questions: + +- **Added line:** which transition most recently introduced the surviving + content? This is the gap-tolerant replay above. +- **Removed line:** which transition effectively removed this exact line of the + parent commit? The remover is the transition that removed it, not the actor + that originally wrote it. A human removing an AI-written line is not an AI + removal. An AI removing a human-written parent line can be. + +Removed lines use a second, stricter lineage. It shares the same event window +and each event's reconstructed `diff_trees` patch with the added-line replay, +but it never resets, so it cannot weaken added-line attribution. One commit can +resolve its additions and still leave its removals unknown. + +A committed removed line receives mutation-derived attribution only when +**all** of these hold: + +1. **Exact parent.** The commit has exactly one parent. Root and merge commits + have no `parent_tree`, so every removal stays unresolved. +2. **Anchor.** Some loaded event (within the 128-event window and the revision + cut) has `before_tree == parent_tree`. The replay starts at the **last** such + event. When the worktree returns to the exact parent tree, identity + restarts there, because the tree is byte-identical to the parent. No anchor + means every removal stays unresolved; the window is never widened to find one. +3. **Uninterrupted chain.** From the anchor to the last loaded event, each + `before_tree` equals the previous `after_tree`, and every transition + reconstructs and applies cleanly. The first gap or failure abandons the + whole removal replay. Removal claims are never carried across a reset. +4. **Per-path reconciliation.** For every tracked path, the replayed lines must + equal the commit tree's file, and any unobserved tail from the last event's + `after_tree` to `commit_tree` must leave that path byte-identical. A path that + fails either check has its removal claims (and those of the parent path it + holds) made `Unknown`. A tail touching only other paths, such as untracked + or unstaged files, does not affect it. +5. **Canonical identity.** The canonical removed line's old-side identity + (`old_path`, old line number, content) names a recorded removal: the + committed file holds that parent path by observed history, the content + matches, the alignment agrees for that content, and no later restoration + invalidated it. + +The verified result is AI (with the scope's session and model, resolved through +the existing scope-provenance lookup) for a healthy `AiExclusive` remover, and +`NonExclusive` for any other recorded remover. It is never human authorship. +Every other committed removal is unresolved. `BoundedMutationAttribution. +removal_replay_anchored` reports whether conditions 1–3 held. + +**Partial staging.** Removal attribution trusts a path only when the committed +file equals the last observed worktree file. If the AI removed `B` and `C` from +the worktree but only the removal of `B` was staged, the committed file differs +from the observed tree, so `B` stays unresolved. This proves only what the +observed tree transitions support. It is not an audit of who ran `git add` or +assembled the index, and an unobserved change that nets to zero on a path cannot +be detected. + +**Persistence.** No commit-boundary or per-line history is persisted across +commits. The parent anchor must be found in the bounded mutation-event window, +which is why a long session (more than 128 events since the parent tree) or +history before a gap leaves removals unknown. + ## Commit attribution cut -`resolve_post_commit_mutation_attribution(repository_root, &db, +`resolve_post_commit_mutation_attribution(repository_root, &db, commit_oid, committed_patch) -> MutationAttributionResult` is the read-only post-commit entrypoint. It returns the mutation-AI, resolved non-AI, and unresolved line sets. It resolves the invoking worktree's identity from Git topology ([`git_snapshot::resolve_git_dir` + `resolve_worktree_id`](mutation-trace-runtime-coordinator.md), which derive `WorktreeId` from `git rev-parse --absolute-git-dir` / -`--git-common-dir` rather than any persisted identity file), reads -`HEAD^{tree}` as `commit_tree`, and captures the commit attribution cut: under +`--git-common-dir` rather than any persisted identity file). It resolves +`commit_tree` and the first-parent `parent_tree` from the commit OID captured at +hook time (`GitSnapshotService::committed_trees`: `rev-parse --verify +^{tree}`, `rev-list --parents --max-count=1 `). It never reads a +mutable `HEAD`, so a `HEAD` move after capture cannot change the anchoring. +`parent_tree` is `None` for root and merge commits. It then 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 @@ -137,7 +240,7 @@ produced after the commit has a higher revision and cannot participate. The critical section is a single indexed read. An unresolvable git dir, an unresolvable worktree identity, an unavailable -snapshot service, an unreadable `HEAD` tree, a lock timeout, or no mutation +snapshot service, an unreadable commit tree, a lock timeout, or no mutation history at all each yield an empty result (every line unresolved), so post-commit falls back to trusted direct-only Agent Trace behavior. The entrypoint creates no identity and writes no mutation-cursor state. @@ -189,6 +292,8 @@ direct candidate matching in `cli/src/services/agent_trace/evidence.rs`: mutation events, but still rejects conflicting direct candidates. - **What happens when history is incomplete?** Gaps, the 128-event window, the revision ceiling, and the unobserved tail keep lines `Unknown`, as before. + Removed lines additionally need the exact parent anchor and an uninterrupted + chain (see [Removed-line attribution](#removed-line-attribution)). An unknown line then needs fresh, exact, single-session direct evidence; historical `(kind, content)` similarity alone never attributes it. - **How are already-committed changes not credited again?** Direct rows @@ -216,11 +321,12 @@ direct candidate matching in `cli/src/services/agent_trace/evidence.rs`: post-commit hook a missing path or failed Git read aborts the trace instead of persisting unverified ranges. See [../sce/agent-trace-minimal-generator.md](../sce/agent-trace-minimal-generator.md#ranges-and-conversations). -- **What remains unsupported?** Removed lines are never resolved by lineage - (only exact fresh direct evidence can cover them; otherwise they count as - `unknown`, even when the line's original author is known). Removed lines have - no post-commit position, so their decisions appear only in counters, never as - ranges. Direct-only edits whose line numbers drift through later uncaptured +- **What remains unsupported?** Removed lines resolve through lineage only under + the [removed-line conditions](#removed-line-attribution). Otherwise, only + exact fresh direct evidence can cover them, and the precedence above is + unchanged. They count as `unknown` even when the line's original author is + known. Removed lines have no post-commit position, so their decisions appear + only in counters, never as ranges. Direct-only edits whose line numbers drift through later uncaptured edits resolve to `unknown` instead of `ai`. The freshness bound uses the committer time of the first parent, so an uncommitted edit made before the parent commit (partial staging) loses direct attribution. Mutation scopes without a recorded model @@ -263,13 +369,31 @@ observable tree difference. `Unknown`; content mismatch is a `LineageError`; duplicate lines do not let provenance jump; a deleted-then-recreated file stays tracked; a renamed-away path stays tracked as absent; adding a file still tracked with content fails - closed. + closed. Parent identity: AI, non-exclusive, and unobserved removals; identity + stable under insertion above; distinct identities across one hunk and across + hunks; duplicate text keeps two identities and a disagreeing alignment stays + unknown; removing a post-parent line records nothing; restoration invalidates, + and restore-then-redelete never resurrects the AI claim; matching text in an + unrelated file is not a restoration; whole-file deletion; deleted-then-recreated + files; observed renames versus delete-plus-add; reset and per-path distrust. + A seeded randomized test (600 histories of insert/remove/replace/restore with + repeated content, two AI scopes, non-exclusive, unobserved transitions, and + gaps) checks removal identities, added-line provenance, and canonical + projection against an independent reference model and LCS diff. - `runtime/mutation_attribution/tests.rs` — real Git trees: an observed AI event beats an old session's historical text match; AI edits to a deleted and recreated file keep provenance (the `control-plane` `494e5693` incident shape); an unobserved tail, a history gap, an event outside the 128-event window, and an event beyond the revision ceiling each keep the line unknown - and keep the old session out of the trace. + and keep the old session out of the trace. Removals: proven AI (with scope + session/model, no ranges) and non-exclusive removals; a missing parent + anchor, a gap, a parent outside the window, an unobserved tail removal, and a + partially staged removal stay unresolved; a tail distrusts only the paths it + touches; restore-then-redelete keeps no stale AI claim, while an exact return + to the parent tree re-anchors; duplicates map to their own parent line; a + rename resolves under its old path; added lines resolve identically with or + without a parent anchor; `committed_trees` follows the captured OID after + `HEAD` moves and yields no parent for root and merge commits. - `hooks/commit_hooks/attribution_tests.rs` — dated Git history: stale already-committed sessions do not credit a later commit; lineage overrides a fresh exact match from another session; non-exclusive lineage blocks direct @@ -279,4 +403,8 @@ observable tree difference. occurrence; the staged co-author check ignores stale and historical-only rows; real Turso persistence from `diff_traces` through `post_commit_patch_intersections`, `MutationTraceStore`, and - `agent_traces.trace_json`. + `agent_traces.trace_json`. A deletion-only commit, resolved through + `resolve_post_commit_mutation_attribution` after `HEAD` has moved, persists + `ai.removed = 3`, `non_exclusive.removed = 1`, `unknown.removed = 1`, and + `"files": []`. A mixed add/modify/delete commit keeps the same added-line + attribution and ranges as an unanchored replay. diff --git a/context/glossary.md b/context/glossary.md index ed08dfcb3..6fa5f0e97 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -85,7 +85,9 @@ - `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`; `disabled` covers both the explicit opt-out and an access policy that disallows background cloud operations, and 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 the `patch_for_locations` helpers; lineage projects every committed line, and deleted or renamed-away tracked paths stay tracked as absent files. 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). +- `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 the `patch_for_locations` helpers; lineage projects every committed line, and deleted or renamed-away tracked paths stay tracked as absent files. 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. Removed lines are projected separately, through a non-resetting replay anchored at the exact first-parent tree (see `effective remover`), and the commit and parent trees are resolved from the captured commit OID, not `HEAD`. 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). +- `parent-line identity` / `ParentLineId`: Internal `MutationLineage` identity `{ path, line_number }` of one line of the exact first-parent commit tree, assigned by `MutationLineage::from_parent` and kept unchanged while the line survives observed transitions, however far it moves. Lines added after the parent never get one, even with identical text. A canonical removed line resolves through its old-side identity (old path, old line number, content), never new-file coordinates. See [Removed-line attribution](cli/mutation-trace-agent-attribution.md#removed-line-attribution). +- `effective remover`: The recorded transition that removed a parent line, as opposed to whoever originally wrote it. That removal must still hold in the committed tree. A healthy `AiExclusive` remover yields AI attribution with its scope provenance, any other recorded remover yields `NonExclusive`, and an `Unobserved` remover yields `Unknown`. A later addition of matching content to the same parent file (plausible restoration) makes the removal claim `Unknown` permanently. The claim counts only when an uninterrupted replay from the exact parent tree reconciles with the commit tree for that path; otherwise the removal is unresolved. Deleted lines appear only in `metadata.sce.line_changes`, never as ranges. - `LineAttribution`: The single final per-line attribution decision in `cli/src/services/agent_trace.rs` — `Ai(ResolvedLine)` (AI-exclusive mutation lineage, or accepted direct evidence for an otherwise unresolved line), `NonExclusive` (mutation lineage resolved a non-exclusive or unscoped transition; not human authorship; cannot be overridden by direct evidence), or `Unknown` (unresolved). Exactly one decision exists per canonical touched line and feeds both revision-positioned ranges and `metadata.sce.line_changes`. See `context/sce/agent-trace-minimal-generator.md`. - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace_from_evidence(...)` under `metadata.sce`, carrying `version` (sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`) and `line_changes` (exact `{ai,non_exclusive,unknown}` × `{added,removed}` `u64` per-line attribution counts: every canonical `post_commit_patch` touched line increments exactly one bucket from its final `LineAttribution` decision — the same decisions ranges are built from — so totals equal the committed patch's touched lines independent of hunk boundaries and of emitted ranges; `#[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`. - `ScopeProvenance`: Optional insert-once metadata keyed by a mutation `ScopeId`, storing canonical SCE `session_id` and nullable first-observed `model_id`. It is registered only at `Start` admission while the owning scope is `NeverSeen`, remains outside `ProtocolState` and the CAS transition, and is resolved during mutation projection. `ScopeId` proves mutation ownership; `ScopeProvenance` describes the owning scope. Model/session provenance is observational and never changes `AiExclusive`/`AiContended`/`IneligibleUnscoped` protocol decisions. diff --git a/context/sce/agent-trace-minimal-generator.md b/context/sce/agent-trace-minimal-generator.md index ec7ecb5df..9b61f8896 100644 --- a/context/sce/agent-trace-minimal-generator.md +++ b/context/sce/agent-trace-minimal-generator.md @@ -60,7 +60,7 @@ Outcomes and policy: Range building and hashing are pure and unit-tested with in-memory readers. -**Deletions.** Removed lines have no position at the recorded revision, so they never produce ranges, are never moved onto a nearby surviving line, and never become zero-length ranges. Their decisions still count in `metadata.sce.line_changes`. A deletion-only commit, a deleted file, or a pure rename can legitimately produce `"files": []` while its counters are non-zero. Mutation-lineage deletion attribution remains out of scope. +**Deletions.** Removed lines have no position at the recorded revision, so they never produce ranges, are never moved onto a nearby surviving line, and never become zero-length ranges. Their decisions still count in `metadata.sce.line_changes`. A deletion-only commit, a deleted file, or a pure rename can legitimately produce `"files": []` while its counters are non-zero. For example, a commit whose only change is three proven AI-exclusive removals has `"files": []` and `line_changes.ai.removed = 3`. Mutation lineage resolves a removed line by its canonical old-side identity (old path, old line number, content) against an uninterrupted replay anchored at the exact first-parent tree. The verified remover, not the line's original author, decides the bucket. Anything not proven stays `unknown`. See [Removed-line attribution](../cli/mutation-trace-agent-attribution.md#removed-line-attribution). **Deleted `.patch` files.** A deleted `*.patch` file is an ordinary deleted file: its literal deleted lines are counted and nothing else. The former expansion of its embedded diff into synthetic `TraceFile` entries was removed because those changes cannot be verified against the recorded revision. @@ -121,7 +121,7 @@ Current output includes top-level metadata fields with this contract: - `non_exclusive` — mutation evidence established a non-exclusive or unscoped (contended/ineligible) transition; this is not a claim of human authorship, and direct AI evidence cannot move these lines to `ai`; - `unknown` — attribution is unresolved (no accepted evidence, or only conflicting direct candidates). - There is no `mixed` bucket. Counter totals exactly cover the canonical committed patch (`ai + non_exclusive + unknown` equals its added and removed touched-line counts), are independent of hunk boundaries and range presentation, never count hunk context lines, and never deduplicate identical lines. Removed lines are not yet resolved by mutation lineage, so they count as `ai` only with accepted exact direct evidence and are otherwise `unknown`. A deleted `.patch` file counts only its literal deleted lines. `#[serde(default)]` on `line_changes`, its buckets, and its parent keeps older payloads deserializing (the previous `mixed` key is ignored and `non_exclusive` defaults to zero); historical traces are not migrated + There is no `mixed` bucket. Counter totals exactly cover the canonical committed patch (`ai + non_exclusive + unknown` equals its added and removed touched-line counts), are independent of hunk boundaries and range presentation, never count hunk context lines, and never deduplicate identical lines. A removed line counts as `ai` when parent-anchored mutation lineage proves an AI-exclusive transition effectively removed it, or, if lineage leaves it unresolved, with accepted exact direct evidence. It counts as `non_exclusive` when lineage proves a non-exclusive or unscoped remover, and otherwise as `unknown`. The existing precedence is unchanged. Removals are counted only, never ranged. A deleted `.patch` file counts only its literal deleted lines. `#[serde(default)]` on `line_changes`, its buckets, and its parent keeps older payloads deserializing (the previous `mixed` key is ignored and `non_exclusive` defaults to zero); historical traces are not migrated - every `Conversation.url` is the absolute URI `https://sce.crocoder.dev/conversations/{agent_trace.id}` derived from the generated top-level `AgentTrace.id`; all conversations in one payload therefore share the same URL Example for a hunk `@@ -10,2 +10,4 @@` that removes one line, adds two session-A lines and one unresolved line, and keeps one context line: the context and removed lines produce no ranges, and nothing is `mixed`. From 354150bb521598bcedd8a2c7bffd941ba88e07d6 Mon Sep 17 00:00:00 2001 From: David Abram Date: Fri, 6 Nov 2026 23:58:36 +0100 Subject: [PATCH 09/11] agent-trace: Gate unverified file handling to tests Keep the synthetic `Unverified` committed-file state and its matching validation branch available only in test builds. This removes the test-only fallback from production code while preserving the existing test behavior. --- cli/src/services/agent_trace.rs | 1 + cli/src/services/agent_trace/committed_content.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/cli/src/services/agent_trace.rs b/cli/src/services/agent_trace.rs index 094930d76..75223df1a 100644 --- a/cli/src/services/agent_trace.rs +++ b/cli/src/services/agent_trace.rs @@ -575,6 +575,7 @@ fn build_trace_file( CommittedFile::Missing => bail!( "Invalid Agent Trace file '{path}': the patch adds lines to it, but it is absent from revision '{revision}'." ), + #[cfg(test)] CommittedFile::Unverified => None, }; validate_added_line_positions(path, &lines, committed.as_ref(), revision)?; diff --git a/cli/src/services/agent_trace/committed_content.rs b/cli/src/services/agent_trace/committed_content.rs index eff211b14..2fe9a744e 100644 --- a/cli/src/services/agent_trace/committed_content.rs +++ b/cli/src/services/agent_trace/committed_content.rs @@ -15,6 +15,7 @@ pub enum CommittedFile { Blob(Vec), NotLineAddressable, Missing, + #[cfg(test)] Unverified, } From fb50bbc580740f1c56524176e8f8bff2e24ec4ad Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 7 Nov 2026 00:58:08 +0100 Subject: [PATCH 10/11] runtime: Bound post-commit attribution by trusted commit time Prevent late mutation events and direct evidence from claiming lines in an already-created commit. Capture the committer timestamp from the captured commit OID, reject untrusted timestamps, and apply the insertion-time cutoff in both evidence queries and mutation-event paging. When the timestamp is untrusted, fail closed to unknown attribution while still persisting the trace. Add database helpers, bounded replay coverage, hook integration, and documentation for the conservative cutoff behavior. Co-authored-by: SCE --- cli/src/services/agent_trace_db/mod.rs | 54 ++ cli/src/services/agent_trace_db/repository.rs | 17 +- cli/src/services/hooks/commit_hooks.rs | 70 ++- .../hooks/commit_hooks/attribution_tests.rs | 594 +++++++++++++++++- .../hooks/commit_hooks/path_identity_tests.rs | 8 +- .../runtime/mutation_attribution.rs | 37 +- .../runtime/mutation_attribution/tests.rs | 153 +++++ cli/src/services/mutation_trace/store.rs | 56 ++ .../cli/mutation-trace-agent-attribution.md | 102 ++- context/sce/agent-trace-db-schema.md | 2 +- context/sce/agent-trace-minimal-generator.md | 2 +- 11 files changed, 1059 insertions(+), 36 deletions(-) diff --git a/cli/src/services/agent_trace_db/mod.rs b/cli/src/services/agent_trace_db/mod.rs index 89da3df15..9762f412f 100644 --- a/cli/src/services/agent_trace_db/mod.rs +++ b/cli/src/services/agent_trace_db/mod.rs @@ -36,6 +36,41 @@ FROM diff_traces WHERE time_ms >= ?1 AND time_ms <= ?2 ORDER BY time_ms ASC, id ASC"; +pub const SELECT_DIFF_TRACE_PATCHES_BEFORE_COMMIT_SQL: &str = + "SELECT id, time_ms, session_id, patch, model_id, tool_name, tool_version, payload_type +FROM diff_traces +WHERE time_ms >= ?1 + AND time_ms < ?2 + AND created_at < strftime('%Y-%m-%dT%H:%M:%fZ', ?3, 'unixepoch') +ORDER BY time_ms ASC, id ASC"; + +const MAX_CUTOFF_UNIX_SECONDS: i64 = 253_402_300_799; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CommitTimeCutoff { + unix_seconds: i64, +} + +impl CommitTimeCutoff { + pub fn from_commit_time_ms(commit_time_ms: i64) -> Option { + if commit_time_ms.checked_rem(1_000)? != 0 { + return None; + } + let unix_seconds = commit_time_ms.checked_div(1_000)?; + (0..=MAX_CUTOFF_UNIX_SECONDS) + .contains(&unix_seconds) + .then_some(CommitTimeCutoff { unix_seconds }) + } + + pub fn unix_seconds(self) -> i64 { + self.unix_seconds + } + + pub fn start_ms(self) -> i64 { + self.unix_seconds * 1_000 + } +} + pub const INSERT_POST_COMMIT_PATCH_INTERSECTION_SQL: &str = "INSERT INTO post_commit_patch_intersections ( commit_id, @@ -540,6 +575,25 @@ async fn recent_diff_trace_patches_with( Ok(parse_recent_diff_trace_patch_rows(rows)) } +async fn diff_trace_patches_before_commit_with( + db: &TursoDb, + lower_time_ms: i64, + cutoff: CommitTimeCutoff, +) -> Result { + if lower_time_ms >= cutoff.start_ms() { + return Ok(parse_recent_diff_trace_patch_rows(Vec::new())); + } + let rows = db + .query_map( + SELECT_DIFF_TRACE_PATCHES_BEFORE_COMMIT_SQL, + (lower_time_ms, cutoff.start_ms(), cutoff.unix_seconds()), + diff_trace_patch_row_from_turso, + ) + .await?; + + Ok(parse_recent_diff_trace_patch_rows(rows)) +} + fn diff_trace_patch_row_from_turso(row: &turso::Row) -> Result { Ok(DiffTracePatchRow { id: row.get(0).context("failed to read diff_traces.id")?, diff --git a/cli/src/services/agent_trace_db/repository.rs b/cli/src/services/agent_trace_db/repository.rs index a5096f5d0..e9f29d506 100644 --- a/cli/src/services/agent_trace_db/repository.rs +++ b/cli/src/services/agent_trace_db/repository.rs @@ -9,10 +9,11 @@ use crate::{ }; use super::{ - insert_agent_trace_with, insert_conversation_text_event_with, insert_diff_trace_with, - insert_messages_with, insert_parts_with, insert_post_commit_patch_intersection_with, - recent_diff_trace_patches_with, upsert_claude_model_state_with, AgentTraceInsert, - ClaudeModelStateObservation, DiffTraceInsert, InsertMessageInsert, InsertPartInsert, + diff_trace_patches_before_commit_with, insert_agent_trace_with, + insert_conversation_text_event_with, insert_diff_trace_with, insert_messages_with, + insert_parts_with, insert_post_commit_patch_intersection_with, recent_diff_trace_patches_with, + upsert_claude_model_state_with, AgentTraceInsert, ClaudeModelStateObservation, + CommitTimeCutoff, DiffTraceInsert, InsertMessageInsert, InsertPartInsert, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, }; @@ -427,6 +428,14 @@ impl RepositoryAgentTraceDb { recent_diff_trace_patches_with(self, cutoff_time_ms, end_time_ms).await } + pub async fn recent_diff_trace_patches_before_commit( + &self, + lower_time_ms: i64, + cutoff: CommitTimeCutoff, + ) -> Result { + diff_trace_patches_before_commit_with(self, lower_time_ms, cutoff).await + } + /// Insert message rows with one multi-row statement, ignoring duplicate /// `(session_id, message_id)` rows. pub async fn insert_messages(&self, inputs: Vec) -> Result { diff --git a/cli/src/services/hooks/commit_hooks.rs b/cli/src/services/hooks/commit_hooks.rs index 3131523d5..74a9c1791 100644 --- a/cli/src/services/hooks/commit_hooks.rs +++ b/cli/src/services/hooks/commit_hooks.rs @@ -12,7 +12,7 @@ use crate::services::agent_trace::{ GitCommittedFiles, RepositoryRoot, }; use crate::services::agent_trace_db::{ - AgentTraceInsert, ParsedDiffTracePatch, PostCommitPatchIntersectionInsert, + AgentTraceInsert, CommitTimeCutoff, ParsedDiffTracePatch, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, }; use crate::services::config::{self, ResolvedHookRuntimeConfig}; @@ -246,6 +246,7 @@ pub(crate) async fn run_post_commit_agent_trace_flow< &mut db, &flow_result.post_commit_data.commit_oid, &flow_result.post_commit_data.parsed_patch, + flow_result.attribution_cutoff, ) .await; @@ -383,6 +384,27 @@ where pub(crate) const RECENT_DAYS_MILLIS: i64 = 7 * 24 * 60 * 60 * 1000; pub(crate) const COMMIT_TIME_RESOLUTION_MILLIS: i64 = 1000; +pub(crate) const COMMIT_TIME_MAX_AGE_MILLIS: i64 = 5 * 60 * 1000; +pub(crate) const GIT_COMMITTER_DATE_ENV: &str = "GIT_COMMITTER_DATE"; + +pub(crate) fn trusted_commit_time_cutoff( + commit_time_ms: i64, + entry_ms: i64, + committer_date_overridden: bool, +) -> Option { + if committer_date_overridden { + return None; + } + let cutoff = CommitTimeCutoff::from_commit_time_ms(commit_time_ms)?; + let age_ms = entry_ms.checked_sub(cutoff.start_ms())?; + (0..=COMMIT_TIME_MAX_AGE_MILLIS) + .contains(&age_ms) + .then_some(cutoff) +} + +pub(crate) fn committer_date_override_present() -> bool { + std::env::var_os(GIT_COMMITTER_DATE_ENV).is_some() +} pub(crate) fn direct_evidence_cutoff_ms(now_ms: i64, prior_commit_time_ms: Option) -> i64 { let recent_cutoff_ms = now_ms - RECENT_DAYS_MILLIS; @@ -404,12 +426,11 @@ pub(crate) async fn run_post_commit_intersection_flow( repository_root, capture_post_commit_patch_from_git, current_unix_time_ms, - async |cutoff_ms, end_ms| { - { - db.recent_diff_trace_patches(cutoff_ms, end_ms) - .await - .context("Failed to query recent diff trace patches.") - } + committer_date_override_present(), + async |lower_ms, cutoff| { + db.recent_diff_trace_patches_before_commit(lower_ms, cutoff) + .await + .context("Failed to query recent diff trace patches.") }, async |insert_input| { db.insert_post_commit_patch_intersection(insert_input) @@ -561,21 +582,39 @@ pub(crate) async fn run_post_commit_intersection_flow_with( repository_root: &Path, capture_post_commit_patch: C, now_ms: N, + committer_date_overridden: bool, query_recent_patches: Q, persist_intersection: P, ) -> Result where C: FnOnce(&Path) -> Result, N: FnOnce() -> Result, - Q: std::ops::AsyncFnOnce(i64, i64) -> Result, + Q: std::ops::AsyncFnOnce(i64, CommitTimeCutoff) -> Result, P: for<'a> std::ops::AsyncFnOnce(PostCommitPatchIntersectionInsert<'a>) -> Result<()>, { let post_commit_data = capture_post_commit_patch(repository_root)?; let now_ms = now_ms()?; let cutoff_ms = direct_evidence_cutoff_ms(now_ms, post_commit_data.parent_commit_time_ms); + let attribution_cutoff = trusted_commit_time_cutoff( + post_commit_data.commit_time_ms, + now_ms, + committer_date_overridden, + ); - let recent_patches = query_recent_patches(cutoff_ms, now_ms).await?; + let (recent_patches, window_end_ms) = match attribution_cutoff { + Some(cutoff) => ( + query_recent_patches(cutoff_ms, cutoff).await?, + cutoff.start_ms(), + ), + None => ( + RecentDiffTracePatches { + patches: Vec::new(), + skipped: Vec::new(), + }, + cutoff_ms, + ), + }; #[allow(clippy::cast_possible_wrap)] let loaded_count = recent_patches.loaded_count() as i64; @@ -600,7 +639,7 @@ where commit_id: &post_commit_data.commit_oid, post_commit_time_ms: post_commit_data.commit_time_ms, recent_window_cutoff_ms: cutoff_ms, - recent_window_end_ms: now_ms, + recent_window_end_ms: window_end_ms, loaded_diff_trace_count: loaded_count, skipped_diff_trace_count: skipped_count, intersection_patch: &serialized_intersection, @@ -613,6 +652,7 @@ where direct_traces: recent_patches.patches, intersection_patch, post_commit_data, + attribution_cutoff, }) } @@ -711,14 +751,19 @@ pub struct PostCommitIntersectionFlowResult { pub direct_traces: Vec, pub intersection_patch: ParsedPatch, pub post_commit_data: PostCommitPatchData, + pub attribution_cutoff: Option, } pub fn capture_post_commit_patch_from_git(repository_root: &Path) -> Result { let commit_oid = capture_head_oid_from_git(repository_root)?; - let commit_time_ms = capture_head_timestamp_from_git(repository_root)?; + let commit_time_ms = capture_commit_timestamp_from_git( + repository_root, + &commit_oid, + "Failed to capture commit timestamp.", + )?; let parent_commit_time_ms = capture_commit_timestamp_from_git( repository_root, - "HEAD^", + &format!("{commit_oid}^"), "Failed to capture parent commit timestamp from git.", ) .ok(); @@ -931,6 +976,7 @@ mod tests { intersection_patch: empty_patch(), post_commit_data: capture_post_commit_patch_from_git(root) .expect("post-commit patch capture"), + attribution_cutoff: None, } } diff --git a/cli/src/services/hooks/commit_hooks/attribution_tests.rs b/cli/src/services/hooks/commit_hooks/attribution_tests.rs index 08dc5a593..c6a21f0c8 100644 --- a/cli/src/services/hooks/commit_hooks/attribution_tests.rs +++ b/cli/src/services/hooks/commit_hooks/attribution_tests.rs @@ -13,7 +13,7 @@ use crate::services::agent_trace::{ }; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; use crate::services::agent_trace_db::{ - AgentTraceInsert, DiffTraceInsert, DiffTracePathOrigin, ParsedDiffTracePatch, + AgentTraceInsert, CommitTimeCutoff, DiffTraceInsert, DiffTracePathOrigin, ParsedDiffTracePatch, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, PAYLOAD_TYPE_STRUCTURED, }; @@ -64,6 +64,37 @@ const fn ms(seconds: i64) -> i64 { seconds * 1_000 } +const PRE_COMMIT_CREATED_MS: i64 = ms(BASE_TIME_S) + 500; + +pub(super) fn created_at(time_ms: i64) -> String { + chrono::DateTime::::from_timestamp_millis(time_ms) + .expect("valid created_at") + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string() +} + +pub(super) async fn stamp_latest_diff_trace(db: &RepositoryAgentTraceDb, created_ms: i64) { + db.execute( + "UPDATE diff_traces SET created_at = ?1 WHERE id = (SELECT MAX(id) FROM diff_traces)", + (created_at(created_ms),), + ) + .await + .expect("stamp diff trace created_at"); +} + +async fn stamp_mutation_event(db: &RepositoryAgentTraceDb, revision: u64, created_ms: i64) { + db.execute( + "UPDATE mutation_trace_events SET created_at = ?1 WHERE worktree_id = ?2 AND revision = ?3", + ( + created_at(created_ms), + WORKTREE, + revision.to_be_bytes().as_slice(), + ), + ) + .await + .expect("stamp mutation event created_at"); +} + fn plan(done: [bool; 3]) -> String { let mut text = String::from("# Plan\n"); for (index, section_done) in done.iter().enumerate() { @@ -344,7 +375,10 @@ async fn intersection_flow( repo.root(), capture_post_commit_patch_from_git, || Ok(ms(COMMIT_C_TIME_S) + 500), - async |cutoff_ms, end_ms| Ok(rows_in_window(rows, cutoff_ms, end_ms)), + false, + async |lower_ms, cutoff: CommitTimeCutoff| { + Ok(rows_in_window(rows, lower_ms, cutoff.start_ms() - 1)) + }, async |insert: PostCommitPatchIntersectionInsert<'_>| -> Result<()> { *persisted.borrow_mut() = Some(PersistedIntersection { loaded: insert.loaded_diff_trace_count, @@ -780,7 +814,7 @@ async fn insert_mutation_rows(db: &RepositoryAgentTraceDb, rows: &[MutationEvent .expect("insert scope provenance"); let revision = row.revision.to_be_bytes(); 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, 'close', ?5, ?6)", + "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, created_at) VALUES (?1, ?2, ?3, ?4, 0, 'healthy', 'ai_exclusive', ?5, 'close', ?5, ?6, ?7)", ( WORKTREE, revision.as_slice(), @@ -788,6 +822,7 @@ async fn insert_mutation_rows(db: &RepositoryAgentTraceDb, rows: &[MutationEvent row.after_tree.0.as_str(), scope_id.0.as_str(), format!("event-{}", row.revision), + created_at(PRE_COMMIT_CREATED_MS), ), ) .await @@ -816,6 +851,7 @@ async fn insert_rows(db: &RepositoryAgentTraceDb, rows: &[DiffTraceRow]) { }) .await .expect("insert diff trace"); + stamp_latest_diff_trace(db, row.time_ms).await; } } @@ -838,7 +874,11 @@ async fn persist_trace( repo.root(), capture_post_commit_patch_from_git, || Ok(ms(COMMIT_C_TIME_S) + 500), - async |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms).await, + false, + async |lower_ms, cutoff| { + db.recent_diff_trace_patches_before_commit(lower_ms, cutoff) + .await + }, async |insert| { db.insert_post_commit_patch_intersection(insert) .await @@ -1091,6 +1131,7 @@ async fn insert_structured_row(db: &RepositoryAgentTraceDb, payload: &str) { }) .await .expect("insert structured diff trace"); + stamp_latest_diff_trace(db, ms(COMMIT_C_TIME_S) - 500).await; } #[tokio::test] @@ -1129,6 +1170,7 @@ async fn claude_structured_rows_resolve_paths_against_their_recorded_cwd() { }) .await .expect("insert structured diff trace"); + stamp_latest_diff_trace(&db, ms(COMMIT_C_TIME_S) - 500).await; let persisted = persist_trace(&repo, &mut db, &[]).await; let conversation = &persisted.value["files"][0]["conversations"][0]; @@ -1489,7 +1531,11 @@ async fn persist_range_trace(root: &Path, db: &RepositoryAgentTraceDb) -> (Agent root, capture_post_commit_patch_from_git, || Ok(ms(COMMIT_A_TIME_S) + 500), - async |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms).await, + false, + async |lower_ms, cutoff| { + db.recent_diff_trace_patches_before_commit(lower_ms, cutoff) + .await + }, async |insert| { db.insert_post_commit_patch_intersection(insert) .await @@ -1723,12 +1769,13 @@ async fn insert_mixed_mutation_rows(db: &RepositoryAgentTraceDb, rows: &[Mutatio for row in non_exclusive { let revision = row.revision.to_be_bytes(); 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', 'ineligible_unscoped', NULL, 'flush', NULL, NULL)", + "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, created_at) VALUES (?1, ?2, ?3, ?4, 0, 'healthy', 'ineligible_unscoped', NULL, 'flush', NULL, NULL, ?5)", ( WORKTREE, revision.as_slice(), row.before_tree.0.as_str(), row.after_tree.0.as_str(), + created_at(PRE_COMMIT_CREATED_MS), ), ) .await @@ -1757,7 +1804,11 @@ async fn persist_deletion_trace( repo.root(), capture_post_commit_patch_from_git, || Ok(ms(COMMIT_A_TIME_S) + 500), - async |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms).await, + false, + async |lower_ms, cutoff| { + db.recent_diff_trace_patches_before_commit(lower_ms, cutoff) + .await + }, async |insert| { db.insert_post_commit_patch_intersection(insert) .await @@ -1775,6 +1826,7 @@ async fn persist_deletion_trace( &mut db, &flow.post_commit_data.commit_oid, &flow.post_commit_data.parsed_patch, + flow.attribution_cutoff, ) .await; @@ -1979,3 +2031,531 @@ async fn persisted_mixed_commit_keeps_added_line_attribution_and_adds_removals() "removal anchoring leaves added-line attribution unchanged" ); } + +const SPEC_CUTOFF_UNIX_SECONDS: i64 = 1_731_067_204; + +#[tokio::test] +async fn commit_bounded_direct_evidence_uses_insertion_time_and_excludes_the_commit_second() { + let (_db_dir, db) = open_db().await; + let cutoff = + CommitTimeCutoff::from_commit_time_ms(ms(SPEC_CUTOFF_UNIX_SECONDS)).expect("valid cutoff"); + let start_ms = cutoff.start_ms(); + let reported_ms = start_ms - 10_000; + let cases = [ + (reported_ms, start_ms - 1), + (reported_ms, start_ms), + (reported_ms, start_ms + 1), + (reported_ms, start_ms + 999), + (reported_ms, start_ms + 1_000), + (start_ms, start_ms - 5_000), + (start_ms - 1, start_ms - 5_000), + ]; + for (time_ms, created_ms) in cases { + db.insert_diff_trace(DiffTraceInsert { + time_ms, + session_id: SESSION_X, + patch: &boilerplate_patch(EXACT_C_POSITIONS.0, EXACT_C_POSITIONS.1), + model_id: Some(MODEL_X), + tool_name: CLAUDE_TOOL, + tool_version: None, + payload_type: PAYLOAD_TYPE_PATCH, + }) + .await + .expect("insert diff trace"); + stamp_latest_diff_trace(&db, created_ms).await; + } + + let eligible = db + .recent_diff_trace_patches_before_commit(start_ms - 60_000, cutoff) + .await + .expect("bounded direct evidence"); + let eligible_ids: Vec = eligible.patches.iter().map(|patch| patch.id).collect(); + assert_eq!(eligible_ids, vec![1, 7]); + + let empty_window = db + .recent_diff_trace_patches_before_commit(start_ms, cutoff) + .await + .expect("empty window"); + assert_eq!(empty_window.loaded_count(), 0); + + let unbounded = db + .recent_diff_trace_patches(start_ms - 60_000, start_ms + 60_000) + .await + .expect("staged preflight query is unchanged"); + assert_eq!(unbounded.loaded_count(), cases.len()); +} + +#[test] +fn commit_time_trust_policy_rejects_untrusted_commit_timestamps() { + use super::{trusted_commit_time_cutoff, COMMIT_TIME_MAX_AGE_MILLIS}; + + let commit_ms = ms(SPEC_CUTOFF_UNIX_SECONDS); + let expected = CommitTimeCutoff::from_commit_time_ms(commit_ms); + assert!(expected.is_some()); + assert_eq!( + trusted_commit_time_cutoff(commit_ms, commit_ms, false), + expected + ); + assert_eq!( + trusted_commit_time_cutoff(commit_ms, commit_ms + COMMIT_TIME_MAX_AGE_MILLIS, false), + expected + ); + + assert_eq!(trusted_commit_time_cutoff(commit_ms, commit_ms, true), None); + assert_eq!( + trusted_commit_time_cutoff(commit_ms, commit_ms - 1, false), + None + ); + assert_eq!( + trusted_commit_time_cutoff(commit_ms, commit_ms + COMMIT_TIME_MAX_AGE_MILLIS + 1, false), + None + ); + assert_eq!( + trusted_commit_time_cutoff(commit_ms + 1, commit_ms + 5, false), + None + ); + assert_eq!(trusted_commit_time_cutoff(-1_000, 0, false), None); + assert_eq!(trusted_commit_time_cutoff(i64::MIN, i64::MAX, false), None); + assert_eq!( + trusted_commit_time_cutoff(ms(253_402_300_800), ms(253_402_300_800), false), + None + ); +} + +struct TimedDeletionOutcome { + flow: PostCommitIntersectionFlowResult, + mutation: MutationAttributionResult, + unbounded: MutationAttributionResult, + trace: AgentTrace, +} + +async fn timed_deletion_trace( + repo: &DeletionRepo, + rows: &[(MutationEventPageRow, i64)], + entry_ms: i64, +) -> TimedDeletionOutcome { + let (_db_dir, mut db) = open_db().await; + let events: Vec = rows.iter().map(|(row, _)| row.clone()).collect(); + insert_mixed_mutation_rows(&db, &events).await; + for (row, created_ms) in rows { + stamp_mutation_event(&db, row.revision, *created_ms).await; + } + let commit_tree = TreeId(git(repo.root(), &["rev-parse", "HEAD^{tree}"], None)); + + let flow = run_post_commit_intersection_flow_with( + repo.root(), + capture_post_commit_patch_from_git, + || Ok(entry_ms), + false, + async |lower_ms, cutoff| { + db.recent_diff_trace_patches_before_commit(lower_ms, cutoff) + .await + }, + async |insert| { + db.insert_post_commit_patch_intersection(insert) + .await + .map(|_| ()) + }, + ) + .await + .expect("intersection flow"); + + let mutation = resolve_post_commit_mutation_attribution( + repo.root(), + &mut db, + &flow.post_commit_data.commit_oid, + &flow.post_commit_data.parsed_patch, + flow.attribution_cutoff, + ) + .await; + let unbounded = { + let store = MutationTraceStore::new(&mut db); + resolve_bounded_mutation_attribution( + &store, + &GitSnapshotService::new(repo.root()) + .await + .expect("snapshot"), + &WorktreeId(WORKTREE.to_owned()), + &flow.post_commit_data.parsed_patch, + &commit_tree, + Some(&repo.parent_tree), + events.iter().map(|row| row.revision).max(), + ) + .await + .result + }; + let trace = run_post_commit_agent_trace_flow_with( + &flow, + Some(AgentTraceVcsType::Git), + REMOTE_URL, + &mutation, + None, + |value| validate_agent_trace_value(value).map_err(|error| anyhow!(error.to_string())), + async |insert| db.insert_agent_trace(insert).await.map(|_| ()), + ) + .await + .expect("agent trace flow"); + + TimedDeletionOutcome { + flow, + mutation, + unbounded, + trace, + } +} + +fn committed_added_count(patch: &ParsedPatch) -> u64 { + patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .filter(|line| line.kind == TouchedLineKind::Added) + .count() as u64 +} + +fn assert_line_totals(trace: &AgentTrace, committed: &ParsedPatch) { + let counts = &trace.metadata.sce.line_changes; + assert_eq!( + counts.ai.added + counts.non_exclusive.added + counts.unknown.added, + committed_added_count(committed) + ); + assert_eq!( + counts.ai.removed + counts.non_exclusive.removed + counts.unknown.removed, + committed_removed_count(committed) + ); +} + +fn unknown_removals(count: u64) -> LineChangeAttribution { + LineChangeAttribution { + unknown: LineChangeCounts { + added: 0, + removed: count, + }, + ..LineChangeAttribution::default() + } +} + +const A_WITHOUT_A2: &str = "a1\na3\na4\na5\na6\n"; + +#[tokio::test] +async fn an_ai_deletion_recorded_after_the_commit_second_never_claims_the_human_deletion() { + let repo = DeletionRepo::new(); + let after_ai = repo.stage(&[(DELETION_A_PATH, A_WITHOUT_A2)]); + repo.commit(COMMIT_A_TIME_S); + let commit_ms = ms(COMMIT_A_TIME_S); + + let outcome = timed_deletion_trace( + &repo, + &[ + ( + deletion_event(1, &repo.parent_tree, &repo.parent_tree, None), + commit_ms - 1_000, + ), + ( + deletion_event(2, &repo.parent_tree, &after_ai, Some(SCOPE_A)), + commit_ms + 1_000, + ), + ], + commit_ms + 3_000, + ) + .await; + + assert_eq!( + removed_lines_by_path(&outcome.unbounded.mutation_ai_patch), + vec![(DELETION_A_PATH.to_owned(), 2, Some(SESSION_A.to_owned()))], + "without the cutoff the late event would claim the deletion" + ); + assert!(outcome.mutation.mutation_ai_patch.files.is_empty()); + assert_eq!(outcome.trace.metadata.sce.line_changes, unknown_removals(1)); + assert_line_totals(&outcome.trace, &outcome.flow.post_commit_data.parsed_patch); + let json = serde_json::to_string(&outcome.trace).expect("trace JSON"); + assert_contains_none(&json, &[SESSION_A, MODEL_A]); + assert!(outcome.trace.files.is_empty()); +} + +#[tokio::test] +async fn an_ai_deletion_recorded_before_the_commit_second_keeps_its_session() { + let repo = DeletionRepo::new(); + let after_ai = repo.stage(&[(DELETION_A_PATH, A_WITHOUT_A2)]); + repo.commit(COMMIT_A_TIME_S); + let commit_ms = ms(COMMIT_A_TIME_S); + + let outcome = timed_deletion_trace( + &repo, + &[( + deletion_event(1, &repo.parent_tree, &after_ai, Some(SCOPE_A)), + commit_ms - 2_000, + )], + commit_ms + 500, + ) + .await; + + assert_eq!( + removed_lines_by_path(&outcome.mutation.mutation_ai_patch), + vec![(DELETION_A_PATH.to_owned(), 2, Some(SESSION_A.to_owned()))] + ); + assert!(outcome + .mutation + .mutation_ai_patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .all(|hunk| hunk.model_id.as_deref() == Some(MODEL_A))); + assert_eq!( + outcome.trace.metadata.sce.line_changes, + LineChangeAttribution { + ai: LineChangeCounts { + added: 0, + removed: 1 + }, + ..LineChangeAttribution::default() + } + ); + assert_line_totals(&outcome.trace, &outcome.flow.post_commit_data.parsed_patch); + assert!(outcome.trace.files.is_empty()); +} + +#[tokio::test] +async fn a_delayed_post_commit_hook_does_not_widen_the_eligible_event_window() { + let repo = DeletionRepo::new(); + let after_ai = repo.stage(&[(DELETION_A_PATH, A_WITHOUT_A2)]); + repo.commit(COMMIT_A_TIME_S); + let restored = repo.stage(&[(DELETION_A_PATH, DELETION_A_PARENT)]); + let redeleted = repo.stage(&[(DELETION_A_PATH, A_WITHOUT_A2)]); + let commit_ms = ms(COMMIT_A_TIME_S); + + let outcome = timed_deletion_trace( + &repo, + &[ + ( + deletion_event(1, &repo.parent_tree, &after_ai, Some(SCOPE_A)), + commit_ms - 1_000, + ), + ( + deletion_event(2, &after_ai, &restored, None), + commit_ms + 1_000, + ), + ( + deletion_event(3, &restored, &redeleted, Some(SCOPE_B)), + commit_ms + 1_500, + ), + ], + commit_ms + 5_000, + ) + .await; + + assert_eq!( + removed_lines_by_path(&outcome.unbounded.mutation_ai_patch), + vec![(DELETION_A_PATH.to_owned(), 2, Some(SESSION_B.to_owned()))], + "the late re-deletion would win without the cutoff" + ); + assert_eq!( + removed_lines_by_path(&outcome.mutation.mutation_ai_patch), + vec![(DELETION_A_PATH.to_owned(), 2, Some(SESSION_A.to_owned()))] + ); + assert_line_totals(&outcome.trace, &outcome.flow.post_commit_data.parsed_patch); + let json = serde_json::to_string(&outcome.trace).expect("trace JSON"); + assert_contains_none(&json, &[SESSION_B, MODEL_B]); +} + +#[tokio::test] +async fn evidence_recorded_inside_the_commit_second_is_an_accepted_false_negative() { + let repo = DeletionRepo::new(); + let after_ai = repo.stage(&[(DELETION_A_PATH, A_WITHOUT_A2)]); + repo.commit(COMMIT_A_TIME_S); + let commit_ms = ms(COMMIT_A_TIME_S); + + let outcome = timed_deletion_trace( + &repo, + &[( + deletion_event(1, &repo.parent_tree, &after_ai, Some(SCOPE_A)), + commit_ms + 200, + )], + commit_ms + 900, + ) + .await; + + assert_eq!( + removed_lines_by_path(&outcome.unbounded.mutation_ai_patch), + vec![(DELETION_A_PATH.to_owned(), 2, Some(SESSION_A.to_owned()))] + ); + assert!(outcome.mutation.mutation_ai_patch.files.is_empty()); + assert_eq!(outcome.trace.metadata.sce.line_changes, unknown_removals(1)); + assert_line_totals(&outcome.trace, &outcome.flow.post_commit_data.parsed_patch); +} + +async fn direct_trace_created_at(created_ms: i64) -> PersistedTrace { + let repo = HistoryRepo::new(); + let (_db_dir, mut db) = open_db().await; + insert_rows(&db, &[fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS)]).await; + stamp_latest_diff_trace(&db, created_ms).await; + persist_trace(&repo, &mut db, &[]).await +} + +#[tokio::test] +async fn direct_evidence_inserted_after_the_commit_cannot_claim_committed_lines() { + let eligible = direct_trace_created_at(ms(COMMIT_C_TIME_S) - 500).await; + assert_eq!(eligible.trace.metadata.sce.line_changes.ai.added, 3); + + for created_ms in [ + ms(COMMIT_C_TIME_S), + ms(COMMIT_C_TIME_S) + 400, + ms(COMMIT_C_TIME_S) + 2_000, + ] { + let late = direct_trace_created_at(created_ms).await; + assert_eq!(late.intersection_touched_lines, 0); + assert_eq!( + late.trace.metadata.sce.line_changes, + LineChangeAttribution { + unknown: LineChangeCounts { + added: 3, + removed: 0 + }, + ..LineChangeAttribution::default() + } + ); + assert_line_totals(&late.trace, &late.committed); + assert_contains_none(&late.json, &[SESSION_C, MODEL_C]); + } +} + +async fn untrusted_flow( + repo: &HistoryRepo, + entry_ms: i64, + committer_date_overridden: bool, +) -> ( + PostCommitIntersectionFlowResult, + PersistedIntersection, + bool, +) { + let rows = [fresh_row(SESSION_C, MODEL_C, EXACT_C_POSITIONS)]; + let queried = RefCell::new(false); + let persisted = RefCell::new(None); + let flow = run_post_commit_intersection_flow_with( + repo.root(), + capture_post_commit_patch_from_git, + || Ok(entry_ms), + committer_date_overridden, + async |lower_ms, cutoff: CommitTimeCutoff| { + *queried.borrow_mut() = true; + Ok(rows_in_window(&rows, lower_ms, cutoff.start_ms() - 1)) + }, + async |insert: PostCommitPatchIntersectionInsert<'_>| -> Result<()> { + assert_eq!( + insert.recent_window_cutoff_ms, insert.recent_window_end_ms, + "an untrusted commit time persists an empty window" + ); + *persisted.borrow_mut() = Some(PersistedIntersection { + loaded: insert.loaded_diff_trace_count, + patch: serde_json::from_str(insert.intersection_patch).expect("intersection JSON"), + }); + Ok(()) + }, + ) + .await + .expect("intersection flow"); + ( + flow, + persisted.into_inner().expect("intersection persisted"), + queried.into_inner(), + ) +} + +#[tokio::test] +async fn untrusted_commit_time_disables_both_evidence_sources_without_failing_the_trace() { + let repo = HistoryRepo::new(); + let commit_ms = ms(COMMIT_C_TIME_S); + let mutation_rows = repo.mutation_rows(AttributionKind::AiExclusive); + for (entry_ms, overridden) in [ + (commit_ms + 500, true), + (commit_ms - 1, false), + (commit_ms + super::COMMIT_TIME_MAX_AGE_MILLIS + 1, false), + ] { + let (flow, persisted, queried) = untrusted_flow(&repo, entry_ms, overridden).await; + assert!(!queried, "no direct evidence query runs"); + assert_eq!(flow.attribution_cutoff, None); + assert!(flow.direct_traces.is_empty()); + assert_eq!(persisted.loaded, 0); + assert!(persisted.patch.files.is_empty()); + + let (_db_dir, mut db) = open_db().await; + insert_mutation_rows(&db, &mutation_rows).await; + let mutation = resolve_post_commit_mutation_attribution( + repo.root(), + &mut db, + &flow.post_commit_data.commit_oid, + &flow.post_commit_data.parsed_patch, + flow.attribution_cutoff, + ) + .await; + assert_eq!(mutation, no_mutation()); + + let trace = agent_trace(&flow, &mutation).await; + assert_eq!( + trace.metadata.sce.line_changes, + LineChangeAttribution { + unknown: LineChangeCounts { + added: 3, + removed: 0 + }, + ..LineChangeAttribution::default() + } + ); + assert_line_totals(&trace, &flow.post_commit_data.parsed_patch); + } +} + +#[tokio::test] +async fn failed_commit_metadata_capture_never_queries_evidence() { + let repo = HistoryRepo::new(); + let queried = RefCell::new(false); + let result = run_post_commit_intersection_flow_with( + repo.root(), + |_| Err(anyhow!("commit OID unavailable")), + || Ok(ms(COMMIT_C_TIME_S)), + false, + async |_lower_ms, _cutoff| { + *queried.borrow_mut() = true; + Ok(rows_in_window(&[], 0, 0)) + }, + async |_insert| Ok(()), + ) + .await; + assert!(result.is_err()); + assert!(!queried.into_inner()); + + let not_a_repo = tempfile::tempdir().expect("temp dir"); + assert!(capture_post_commit_patch_from_git(not_a_repo.path()).is_err()); +} + +#[test] +fn commit_timestamps_come_from_the_captured_oid_after_head_moves() { + let repo = DeletionRepo::new(); + repo.stage(&[(DELETION_A_PATH, A_WITHOUT_A2)]); + repo.commit(COMMIT_A_TIME_S); + let captured = git(repo.root(), &["rev-parse", "HEAD"], None); + repo.stage(&[(DELETION_A_PATH, "moved\n")]); + repo.commit(COMMIT_B_TIME_S); + + assert_eq!( + super::capture_commit_timestamp_from_git(repo.root(), &captured, "commit time") + .expect("captured commit time"), + ms(COMMIT_A_TIME_S) + ); + assert_eq!( + super::capture_commit_timestamp_from_git( + repo.root(), + &format!("{captured}^"), + "parent commit time" + ) + .expect("captured parent time"), + ms(BASE_TIME_S) + ); + assert!(super::capture_commit_timestamp_from_git( + repo.root(), + &format!("{captured}^^"), + "missing parent" + ) + .is_err()); +} diff --git a/cli/src/services/hooks/commit_hooks/path_identity_tests.rs b/cli/src/services/hooks/commit_hooks/path_identity_tests.rs index a1c4dbe62..5e1c47f4e 100644 --- a/cli/src/services/hooks/commit_hooks/path_identity_tests.rs +++ b/cli/src/services/hooks/commit_hooks/path_identity_tests.rs @@ -12,6 +12,7 @@ use crate::services::agent_trace_db::{DiffTraceInsert, PAYLOAD_TYPE_PATCH}; use crate::services::mutation_trace::attribution::MutationAttributionResult; use crate::services::patch::{ParsedPatch, TouchedLineKind}; +use super::attribution_tests::stamp_latest_diff_trace; use super::{ capture_post_commit_patch_from_git, run_post_commit_agent_trace_flow_with, run_post_commit_intersection_flow_with, PostCommitIntersectionFlowResult, @@ -193,6 +194,7 @@ async fn insert_unicode_direct_evidence(db: &RepositoryAgentTraceDb) { }) .await .expect("insert diff trace"); + stamp_latest_diff_trace(db, ms(COMMIT_TIME_S) - 500).await; } async fn intersection_flow( @@ -203,7 +205,11 @@ async fn intersection_flow( repo.root(), capture_post_commit_patch_from_git, || Ok(ms(COMMIT_TIME_S) + 500), - async |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms).await, + false, + async |lower_ms, cutoff| { + db.recent_diff_trace_patches_before_commit(lower_ms, cutoff) + .await + }, async |insert| { db.insert_post_commit_patch_intersection(insert) .await diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution.rs index e80c0a68b..e45c2b9b5 100644 --- a/cli/src/services/mutation_trace/runtime/mutation_attribution.rs +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution.rs @@ -5,6 +5,7 @@ use std::path::Path; use std::time::Duration; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_db::CommitTimeCutoff; use crate::services::mutation_trace::attribution::{ logical_path, patch_for_locations, patch_for_locations_with_provenance, MutationAttributionResult, PatchLineLocation, PatchLineProvenance, @@ -61,6 +62,33 @@ impl MutationEventPageSource for MutationTraceStore<'_> { } } +struct CommitBoundedEvents<'s, 'db> { + store: &'s MutationTraceStore<'db>, + cutoff: CommitTimeCutoff, +} + +impl MutationEventPageSource for CommitBoundedEvents<'_, '_> { + async fn load_mutation_event_page( + &self, + worktree: &WorktreeId, + revision_cursor: Option, + requested_limit: usize, + ) -> Result> { + self.store + .load_mutation_event_page_before_commit( + worktree, + revision_cursor, + requested_limit, + self.cutoff, + ) + .await + } + + async fn load_scope_provenance(&self, scope_id: &ScopeId) -> Result> { + self.store.load_scope_provenance(scope_id).await + } +} + pub trait TreeReadSource { async fn diff_trees(&self, before: &TreeId, after: &TreeId) -> Result; async fn file_at_tree(&self, tree: &TreeId, path: &str) -> Result>; @@ -648,7 +676,11 @@ pub(crate) async fn resolve_post_commit_mutation_attribution( db: &mut RepositoryAgentTraceDb, commit_oid: &str, committed_patch: &ParsedPatch, + cutoff: Option, ) -> MutationAttributionResult { + let Some(cutoff) = cutoff else { + return empty_attribution(); + }; let Ok(git_dir) = resolve_git_dir(repository_root).await else { return empty_attribution(); }; @@ -672,7 +704,10 @@ pub(crate) async fn resolve_post_commit_mutation_attribution( }; resolve_bounded_mutation_attribution( - &store, + &CommitBoundedEvents { + store: &store, + cutoff, + }, &snapshot, &worktree, committed_patch, diff --git a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs index e73041d5a..5a94b80be 100644 --- a/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs +++ b/cli/src/services/mutation_trace/runtime/mutation_attribution/tests.rs @@ -842,3 +842,156 @@ async fn root_and_merge_commits_have_no_parent_tree() { ); assert!(snapshot.committed_trees("HEAD").await.is_err()); } + +const CUTOFF_UNIX_SECONDS: i64 = 1_731_067_204; +const CUTOFF_MS: i64 = CUTOFF_UNIX_SECONDS * 1_000; + +fn cutoff() -> CommitTimeCutoff { + CommitTimeCutoff::from_commit_time_ms(CUTOFF_MS).expect("valid cutoff") +} + +fn created_at(time_ms: i64) -> String { + chrono::DateTime::::from_timestamp_millis(time_ms) + .expect("valid created_at") + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string() +} + +fn fake_tree(revision: u64) -> String { + format!("{revision:040x}") +} + +async fn open_event_db() -> (tempfile::TempDir, RepositoryAgentTraceDb) { + let dir = tempfile::tempdir().expect("db dir"); + let db = RepositoryAgentTraceDb::new_at(dir.path().join("agent-trace.db")) + .await + .expect("open agent trace db"); + (dir, db) +} + +async fn insert_timed_event(db: &RepositoryAgentTraceDb, revision: u64, created_ms: i64) { + 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, created_at) VALUES (?1, ?2, ?3, ?4, 0, 'healthy', 'ineligible_unscoped', NULL, 'flush', NULL, NULL, ?5)", + ( + WORKTREE, + revision.to_be_bytes().as_slice(), + fake_tree(revision - 1), + fake_tree(revision), + created_at(created_ms), + ), + ) + .await + .expect("insert timed mutation event"); +} + +fn revisions(rows: &[MutationEventPageRow]) -> Vec { + rows.iter().map(|row| row.revision).collect() +} + +#[tokio::test] +async fn commit_bounded_event_pages_exclude_the_whole_commit_second() { + let (_dir, mut db) = open_event_db().await; + let created = [ + CUTOFF_MS - 1, + CUTOFF_MS, + CUTOFF_MS + 1, + CUTOFF_MS + 999, + CUTOFF_MS + 1_000, + ]; + for (index, created_ms) in created.into_iter().enumerate() { + insert_timed_event(&db, index as u64 + 1, created_ms).await; + } + let store = MutationTraceStore::new(&mut db); + let worktree = WorktreeId(WORKTREE.to_owned()); + + let bounded = store + .load_mutation_event_page_before_commit(&worktree, None, 32, cutoff()) + .await + .expect("bounded page"); + assert_eq!(revisions(&bounded), vec![1]); + + let unbounded = store + .load_mutation_event_page(&worktree, None, 32) + .await + .expect("unbounded page"); + assert_eq!(revisions(&unbounded), vec![5, 4, 3, 2, 1]); +} + +#[tokio::test] +async fn late_events_never_consume_the_bounded_event_budget() { + let (_dir, mut db) = open_event_db().await; + let eligible = 150_u64; + let late = 140_u64; + for revision in 1..=eligible { + insert_timed_event(&db, revision, CUTOFF_MS - 60_000 + revision.cast_signed()).await; + } + for revision in eligible + 1..=eligible + late { + insert_timed_event(&db, revision, CUTOFF_MS + revision.cast_signed()).await; + } + let store = MutationTraceStore::new(&mut db); + let worktree = WorktreeId(WORKTREE.to_owned()); + + let first = store + .load_mutation_event_page_before_commit(&worktree, None, 32, cutoff()) + .await + .expect("first page"); + assert_eq!(revisions(&first), (119..=150).rev().collect::>()); + let second = store + .load_mutation_event_page_before_commit(&worktree, Some(119), 32, cutoff()) + .await + .expect("second page"); + assert_eq!(revisions(&second), (87..=118).rev().collect::>()); + + let source = CommitBoundedEvents { + store: &store, + cutoff: cutoff(), + }; + let mut state = ReplayState { + loaded_pages: 0, + loaded_rows: 0, + inspected_events: 0, + reconstructed_events: 0, + gap_resets: 0, + barrier: None, + }; + let window = load_event_window(&source, &worktree, Some(eligible + late), &mut state).await; + assert_eq!(revisions(&window), (23..=150).collect::>()); + assert_eq!(window.len(), MAX_MUTATION_ATTRIBUTION_EVENTS); + assert_eq!(state.loaded_pages, 4); + assert_eq!(state.loaded_rows, MAX_MUTATION_ATTRIBUTION_EVENTS); + assert_eq!(state.barrier, None); +} + +#[tokio::test] +async fn a_short_eligible_history_behind_many_late_events_is_loaded_completely() { + let (_dir, mut db) = open_event_db().await; + for revision in 1..=40_u64 { + insert_timed_event(&db, revision, CUTOFF_MS - 60_000 + revision.cast_signed()).await; + } + for revision in 41..=180_u64 { + insert_timed_event(&db, revision, CUTOFF_MS + revision.cast_signed()).await; + } + let store = MutationTraceStore::new(&mut db); + let source = CommitBoundedEvents { + store: &store, + cutoff: cutoff(), + }; + let mut state = ReplayState { + loaded_pages: 0, + loaded_rows: 0, + inspected_events: 0, + reconstructed_events: 0, + gap_resets: 0, + barrier: None, + }; + let window = load_event_window( + &source, + &WorktreeId(WORKTREE.to_owned()), + Some(180), + &mut state, + ) + .await; + assert_eq!(revisions(&window), (1..=40).collect::>()); + assert_eq!(state.loaded_pages, 2); + assert_eq!(state.loaded_rows, 40); +} diff --git a/cli/src/services/mutation_trace/store.rs b/cli/src/services/mutation_trace/store.rs index 68a0430ba..94009069c 100644 --- a/cli/src/services/mutation_trace/store.rs +++ b/cli/src/services/mutation_trace/store.rs @@ -13,6 +13,7 @@ use std::collections::{BTreeMap, BTreeSet}; use anyhow::{bail, Context, Result}; use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_db::CommitTimeCutoff; use crate::services::db::TransactionStatement; use super::types::{ @@ -247,6 +248,23 @@ const SELECT_MUTATION_EVENT_PAGE_AFTER_SQL: &str = WHERE worktree_id = ?1 AND revision < ?2 ORDER BY revision DESC LIMIT ?3"; +const SELECT_MUTATION_EVENT_PAGE_BEFORE_COMMIT_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 created_at < strftime('%Y-%m-%dT%H:%M:%fZ', ?2, 'unixepoch') + ORDER BY revision DESC + LIMIT ?3"; +const SELECT_MUTATION_EVENT_PAGE_AFTER_BEFORE_COMMIT_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 + AND created_at < strftime('%Y-%m-%dT%H:%M:%fZ', ?3, 'unixepoch') + ORDER BY revision DESC + LIMIT ?4"; const SELECT_LATEST_MUTATION_EVENT_REVISION_SQL: &str = "SELECT revision FROM mutation_trace_events WHERE worktree_id = ?1 ORDER BY revision DESC @@ -801,6 +819,44 @@ impl<'a> MutationTraceStore<'a> { Ok(rows) } + pub async fn load_mutation_event_page_before_commit( + &self, + worktree: &WorktreeId, + revision_cursor: Option, + requested_limit: usize, + cutoff: CommitTimeCutoff, + ) -> Result> { + let limit = limit_as_i64(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_BEFORE_COMMIT_SQL, + ( + worktree.0.as_str(), + cursor_blob.as_slice(), + cutoff.unix_seconds(), + limit, + ), + mutation_event_page_row_from_turso, + ) + .await? + } + None => { + self.db + .query_map( + SELECT_MUTATION_EVENT_PAGE_BEFORE_COMMIT_SQL, + (worktree.0.as_str(), cutoff.unix_seconds(), limit), + mutation_event_page_row_from_turso, + ) + .await? + } + }; + + Ok(rows) + } + pub async fn latest_mutation_event_revision( &self, worktree: &WorktreeId, diff --git a/context/cli/mutation-trace-agent-attribution.md b/context/cli/mutation-trace-agent-attribution.md index 38a035ff7..d06dd7f61 100644 --- a/context/cli/mutation-trace-agent-attribution.md +++ b/context/cli/mutation-trace-agent-attribution.md @@ -118,8 +118,11 @@ alongside `diff_trees`). (`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. + event 129 is never loaded. Traversal is current-worktree-only. The generic + consumer is timestamp-independent; the post-commit entrypoint pages through a + commit-bounded source whose SQL also requires `created_at` before the + [commit-time cutoff](#commit-time-cutoff), so the 128-event budget counts only + temporally eligible events. - **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 @@ -220,8 +223,9 @@ history before a gap leaves removals unknown. ## Commit attribution cut `resolve_post_commit_mutation_attribution(repository_root, &db, commit_oid, -committed_patch) -> MutationAttributionResult` is the read-only post-commit -entrypoint. It returns the mutation-AI, resolved non-AI, and unresolved line +committed_patch, cutoff) -> MutationAttributionResult` is the read-only +post-commit entrypoint. A `None` cutoff (untrusted commit time) returns an +empty result immediately. It returns the mutation-AI, resolved non-AI, and unresolved line sets. It resolves the invoking worktree's identity from Git topology ([`git_snapshot::resolve_git_dir` + `resolve_worktree_id`](mutation-trace-runtime-coordinator.md), @@ -245,6 +249,67 @@ history at all each yield an empty result (every line unresolved), so post-commit falls back to trusted direct-only Agent Trace behavior. The entrypoint creates no identity and writes no mutation-cursor state. +The revision ceiling is only a stable-snapshot bound. It is read inside +post-commit processing, after Git has already created the commit, so events +recorded between commit creation and the hook (for example an agent deleting a +line again after the working tree was restored) still sit below it. Temporal +eligibility comes from the commit-time cutoff below. + +## Commit-time cutoff + +This is a conservative risk-reduction rule, not a causal commit boundary. + +- **Source.** `capture_post_commit_patch_from_git` reads `HEAD` once for the + commit OID, then reads the committer timestamp with `git show --format=%ct + --no-patch ` and the parent timestamp from `^`. Mutable `HEAD` is + never read again. A root commit keeps `parent_commit_time_ms = None`. +- **Cutoff.** `CommitTimeCutoff` (`agent_trace_db/mod.rs`) is the start of the + committer-timestamp Unix second. Evidence is eligible only when its + database-generated `created_at` is strictly before that instant, so anything + recorded during the commit's second or later is excluded. Git timestamps have + one-second resolution, so the system cannot tell which side of the commit an + event in that second falls on; it prefers a false negative. +- **Insertion time, not event time.** The rule uses `created_at` (database + insertion time). `diff_traces.time_ms` is the reported event time and can be + old for a late insert, so it never replaces the insertion-time check. +- **Both sources.** Mutation events and direct evidence use the same rule. + Filtering only one would let the other reintroduce a late AI claim: a late + direct row could otherwise recover an `Unknown` line that lineage correctly + left unresolved. +- **Before the limit.** The predicate is part of every mutation page query: + + ```sql + WHERE worktree_id = ?1 + [AND revision < ?cursor] + AND created_at < strftime('%Y-%m-%dT%H:%M:%fZ', ?cutoff_seconds, 'unixepoch') + ORDER BY revision DESC LIMIT ?limit + ``` + + Filtering after loading the newest 128 events would let a burst of late + events fill the window and push out eligible older ones. Only the post-commit + path uses `load_mutation_event_page_before_commit`; `load_mutation_event_page` + and its other consumers are unchanged. +- **Trust check.** `trusted_commit_time_cutoff` accepts the timestamp only when + `GIT_COMMITTER_DATE` is absent from the hook environment, the value is a + whole non-negative Unix second with a four-digit UTC year, the commit second + does not start after post-commit entry (no future tolerance; Git and the + database read the same host clock), and it started at most + `COMMIT_TIME_MAX_AGE_MILLIS` (5 minutes) before entry. The check cannot prove + the timestamp is authentic. A slow, legitimate hook beyond 5 minutes loses + attribution; that false negative is accepted. +- **Fail closed.** When the timestamp is untrusted, `attribution_cutoff` is + `None`: no direct-evidence query runs, mutation attribution returns an empty + result, and every committed touched line is `Unknown`. The Agent Trace is + still built and persisted with its usual schema and the real commit + timestamp, and the commit itself is never blocked. Nothing falls back to the + current time or an unlimited query. If the commit OID or metadata cannot be + read, the flow errors before any evidence is queried. +- **Assumptions.** The rule depends on the committer timestamp and the database + clock roughly agreeing. It does not protect against backdated or + future-dated commits that pass the trust check, clock rollback, significant + skew, evidence delayed before insertion, or old evidence that already + matched an unrelated change. + ## Evidence precedence Each committed touched line is resolved independently in @@ -260,9 +325,10 @@ direct candidate matching in `cli/src/services/agent_trace/evidence.rs`: | unresolved | conflicting sessions, historical-only, or none | unknown | - **When is direct evidence sufficient?** Only when lineage left the line - unresolved and a direct candidate is (1) *fresh* — recorded after the + unresolved and a direct candidate is (1) *fresh* — reported after the parent commit's committer-time second (`direct_evidence_cutoff_ms`), still - inside the 7-day window; (2) an *exact* touched-line match on repository + inside the 7-day window, reported before and inserted into the database + before the [commit-time cutoff](#commit-time-cutoff); (2) an *exact* touched-line match on repository file identity, `kind`, `line_number`, and `content` — file identity is the normalized repository-relative path (absolute paths only inside the repository root, Claude structured relative paths resolved only against @@ -338,9 +404,12 @@ The former `intersect_patches` / `combine_patches` helpers and their historical ## Post-commit composition - **Evidence stays separated.** `post_commit_patch_intersections` stores only - the trusted direct-evidence projection (fresh, exact, single-session) and its - `recent_window_cutoff_ms` records the freshness-bounded cutoff. Mutation - evidence never enters `diff_traces`. + the trusted direct-evidence projection (fresh, exact, single-session). + `recent_window_cutoff_ms` records the inclusive freshness lower bound and + `recent_window_end_ms` the exclusive upper bound: the start of the commit + second (rows must also have `created_at` before it). When the commit time is + untrusted, both hold the lower bound, an empty window, and no rows load. + Mutation evidence never enters `diff_traces`. - **No fabricated direct provenance.** The mutation-AI patch is target-shaped and carries only the resolved observational session/model metadata described above; it carries no tool or tool-version metadata. `ScopeId`, `ActorKind`, @@ -408,3 +477,18 @@ observable tree difference. `ai.removed = 3`, `non_exclusive.removed = 1`, `unknown.removed = 1`, and `"files": []`. A mixed add/modify/delete commit keeps the same added-line attribution and ranges as an unanchored replay. +- Commit-time cutoff. `runtime/mutation_attribution/tests.rs`: rows created at + `:03.999` are eligible and `:04.000`, `:04.001`, `:04.999`, `:05.000` are not; + 140 late events do not consume the 128-event budget, pages span + correctly, and the cap still holds. `hooks/commit_hooks/attribution_tests.rs`: + the same boundary for `diff_traces` (including old `time_ms` with late + `created_at`) while the staged preflight query is unchanged; a late AI + deletion stays `unknown` (an unbounded control would credit it); a + pre-commit AI deletion keeps its session and model; a delayed hook keeps the + earlier session while a late restore-and-redelete is ignored; an in-second + pre-commit event is the documented false negative; late direct evidence + cannot claim lines; an overridden, future, or stale commit time disables + both sources while still persisting a trace; a failed metadata read queries + nothing; timestamps follow the captured OID after `HEAD` moves. Each flow + test checks `ai + non_exclusive + unknown` against the committed added and + removed lines. diff --git a/context/sce/agent-trace-db-schema.md b/context/sce/agent-trace-db-schema.md index 71ef33c45..3eb4726bf 100644 --- a/context/sce/agent-trace-db-schema.md +++ b/context/sce/agent-trace-db-schema.md @@ -20,7 +20,7 @@ The `post_commit_patch_intersections` baseline migration creates: - `commit_id TEXT NOT NULL` - `post_commit_time_ms INTEGER NOT NULL` - `recent_window_cutoff_ms INTEGER NOT NULL` -- `recent_window_end_ms INTEGER NOT NULL` +- `recent_window_end_ms INTEGER NOT NULL` — exclusive upper bound of the post-commit direct-evidence window: the start of the captured commit's committer-timestamp second. Equal to `recent_window_cutoff_ms` (an empty window) when the commit time is untrusted. - `loaded_diff_trace_count INTEGER NOT NULL CHECK (loaded_diff_trace_count >= 0)` - `skipped_diff_trace_count INTEGER NOT NULL CHECK (skipped_diff_trace_count >= 0)` - `intersection_patch TEXT NOT NULL` diff --git a/context/sce/agent-trace-minimal-generator.md b/context/sce/agent-trace-minimal-generator.md index 9b61f8896..a46030e35 100644 --- a/context/sce/agent-trace-minimal-generator.md +++ b/context/sce/agent-trace-minimal-generator.md @@ -69,7 +69,7 @@ Range building and hashing are pure and unit-tested with in-memory readers. `build_agent_trace_from_evidence(evidence, post_commit_patch, metadata)` is the only supported generator entrypoint. `AgentTraceEvidence` keeps the sources separate: - `repository_root` — `RepositoryRoot` anchoring attribution-grade file identity (`agent_trace/repository_path.rs`). -- `direct_sources` — one `DirectEvidenceSource` per freshness-bounded `diff_traces` row (patch, `path_origin`, `tool_name`, `tool_version`), never combined, so conflicting sessions, duplicate occurrences, and per-row tool metadata stay attached until resolution. Matching lives in `agent_trace/evidence.rs`. +- `direct_sources` — one `DirectEvidenceSource` per eligible `diff_traces` row (patch, `path_origin`, `tool_name`, `tool_version`), never combined, so conflicting sessions, duplicate occurrences, and per-row tool metadata stay attached until resolution. Matching lives in `agent_trace/evidence.rs`. A row is eligible when it is inside the freshness window, reported before the commit second, and inserted (`created_at`) before the commit second; see [../cli/mutation-trace-agent-attribution.md](../cli/mutation-trace-agent-attribution.md#commit-time-cutoff). An untrusted commit timestamp yields no direct sources and an empty mutation result, so every touched line is `Unknown`; the trace is still built and persisted. Only *accepted* direct evidence may influence authorship, co-author eligibility, or tool provenance: a candidate is accepted when its file resolves to the same repository identity as the target file (`agent_trace/repository_path.rs`; the committed target is always repository-relative), its touched line matches exactly on `(kind, line_number, content)`, and every matching candidate for that line comes from one session. Conflicting candidates are rejected everywhere. From 291b2c559081f0976116b84a0cb3a2703e7fa542 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sat, 7 Nov 2026 01:11:38 +0100 Subject: [PATCH 11/11] context: Document commit-time bounds for Agent Trace attribution Clarify that post-commit direct evidence and mutation history are eligible only when both reported and database timestamps precede a trusted commit-time cutoff. Document captured-commit tree resolution, SQL filtering before the bounded mutation-event window, and fail-closed unknown attribution when the cutoff is untrusted. Update the related database, storage, structured-patch, architecture, glossary, overview, and context-map descriptions to match the runtime contract. Co-authored-by: SCE --- context/architecture.md | 2 +- .../cli/mutation-trace-agent-attribution.md | 17 ++++++++------ context/cli/mutation-trace-store.md | 22 +++++++++++++++---- context/cli/structured-patch-service.md | 2 +- context/context-map.md | 8 +++---- context/glossary.md | 6 ++--- context/overview.md | 4 ++-- context/sce/agent-trace-db.md | 20 ++++++++++++++--- .../sce/agent-trace-hooks-command-routing.md | 14 +++++++----- .../agent-trace-implementation-contract.md | 2 +- context/sce/agent-trace-minimal-generator.md | 2 +- .../agent-trace-payload-builder-validation.md | 2 +- 12 files changed, 67 insertions(+), 34 deletions(-) diff --git a/context/architecture.md b/context/architecture.md index 0a1e610cf..317c717ff 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -76,7 +76,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 required registration — the four `sce hooks codex` registrations plus the six `sce hooks codex-mutation-scope` ones, the latter reported under distinct `#(mutation-scope)` rows — as `PresentAndCurrent`/`Missing`/`Stale` (or the whole document as `Malformed` when it cannot be structurally validated) without writing anything, so unrelated user handlers never create a false whole-document mismatch. For a structurally current registration, `codex_hook_trust` separately reads (never writes) Codex's own durable `$CODEX_HOME/config.toml` hook-trust state — reproducing upstream's `hook_hash`/`hook_key`/`hook_trust_status` exactly — and reports `Trusted`/`Untrusted`/`Modified`/`Disabled`/`Unknown`, and `codex_hook_policy` probes Codex's effective hook-discovery policy once per invocation; only a structurally current registration that is trusted *and* policy-allowed renders healthy. `sce doctor --fix` repairs a structurally unhealthy `.codex/hooks.json` through the existing merge-install path, but a registration that is current yet not-yet-trusted or policy-blocked is never "fixed", since SCE cannot grant Codex hook trust or change managed policy. - `cli/src/services/version/mod.rs` defines the version command parser/rendering contract (`parse_version_request`, `render_version`) with deterministic text output and stable JSON runtime-identification fields; `cli/src/services/version/command.rs` owns the `VersionCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/services/completion/mod.rs` defines completion parser/rendering contract (`parse_completion_request`, `render_completion`) with deterministic Bash/Zsh/Fish script output aligned to current parser-valid command/flag surfaces; `cli/src/services/completion/command.rs` owns the `CompletionCommand` payload used by the static `RuntimeCommand` enum. -- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`), including the `claude-model-state` lifecycle intake (silent) delegated to `cli/src/services/hooks/claude_model_state.rs`, the hidden non-fail-open `mutation-scope` runtime ingress delegated to `cli/src/services/hooks/mutation_scope.rs` (STDIN normalized JSON → one `mutation_trace::runtime` `coordinate()` / `abandon_scope()` call with a lazy DB provider; see `context/cli/mutation-scope-hook-ingress.md`), the hidden non-fail-open `claude-mutation-scope` adapter delegated to `cli/src/services/hooks/claude_mutation_scope/` (STDIN raw Claude hook JSON → tool classification + identity/`ScopeId` derivation → the same `mutation_scope.rs` `pub(crate)` in-process seam, one mutation `ScopeId` per mutation-capable tool execution; registered by `sce setup`; see `context/cli/claude-mutation-scope-integration.md`), plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures current commit patch, queries recent repository-level `diff_traces` from the bounded past-7-days window, keeps each row separate and matches it against the post-commit patch with fresh, exact, single-session direct evidence (`agent_trace/evidence.rs`), persists the direct-only intersection metadata to `post_commit_patch_intersections`, then — for committed touched lines that direct intersection did not cover — resolves bounded, read-only mutation-history AI coverage scoped to the invoking worktree's existing identity (newest 128 events replayed oldest-to-newest as one causal tree-transition provenance lineage bounded by a worktree-lock-captured commit attribution cut — `revision <= latest_mutation_event_revision` read under the worktree lock — with a committed line attributed AI only if an AI event's line survives every later transition into the committed tree; no provenance, no `diff_traces` or mutation-cursor write) and passes direct and mutation-AI evidence separately into the Agent Trace builder, and persists the built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the setup-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child when its resolved value is true (a newly created setup config supplies the explicit `true`; omitted configuration resolves to `false`), with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution with `direct > exact transcript > exact session/agent state > bridge-derived chain state > NULL`: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`, then persistence performs one exact local `claude_model_state` lookup using canonical session and ephemeral agent scope. Only for a raw structured payload in the exact main-session scope, a state miss then resolves the newest `claude_model_state` observation across the transcript's bridge-linked chain members (bounded leading-record reads, one exact-scope read per member, winner by `observed_at_ms`) and, on a hit, writes a `claude_model_state` row for the current session (`source="bridge_inherited"`) before using that model — the only diff-trace resolution path that writes state. Model values normalize once through `claude/`, subagents do not inherit main-session state, and unresolved values remain nullable. `session-model` is no longer a supported hook route. +- `cli/src/services/hooks/mod.rs` defines the current local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`), including the `claude-model-state` lifecycle intake (silent) delegated to `cli/src/services/hooks/claude_model_state.rs`, the hidden non-fail-open `mutation-scope` runtime ingress delegated to `cli/src/services/hooks/mutation_scope.rs` (STDIN normalized JSON → one `mutation_trace::runtime` `coordinate()` / `abandon_scope()` call with a lazy DB provider; see `context/cli/mutation-scope-hook-ingress.md`), the hidden non-fail-open `claude-mutation-scope` adapter delegated to `cli/src/services/hooks/claude_mutation_scope/` (STDIN raw Claude hook JSON → tool classification + identity/`ScopeId` derivation → the same `mutation_scope.rs` `pub(crate)` in-process seam, one mutation `ScopeId` per mutation-capable tool execution; registered by `sce setup`; see `context/cli/claude-mutation-scope-integration.md`), plus a commit-msg co-author policy seam (`apply_commit_msg_coauthor_policy`) that injects one canonical SCE trailer only when the enabled-by-default attribution-hooks config/env control is not opted out, `SCE_DISABLED` is false, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); the preflight is wired into `run_commit_msg_subcommand_in_repo` and logs `sce.hooks.commit_msg.ai_overlap_error` on error paths; `cli/src/services/hooks/command.rs` owns the `HooksCommand` payload used by the static `RuntimeCommand` enum. In the current attribution-only baseline, `pre-commit` and `post-rewrite` are deterministic no-op surfaces; `post-commit` requires validated `--remote-url`, threads that URL through the Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace persistence entrypoint (captures the exact commit OID, timestamp, and patch; derives a trusted commit-time cutoff or, when the timestamp is untrusted, makes both evidence sources unavailable so touched lines are unknown; queries repository-level `diff_traces` whose reported time and `created_at` precede the commit second, keeping each row separate for fresh, exact, single-session matching (`agent_trace/evidence.rs`); persists the direct-only intersection metadata to `post_commit_patch_intersections`; resolves read-only mutation lineage for every committed touched line from up to 128 of the invoking worktree's events inserted before the cutoff, against the captured commit's trees, with no `diff_traces` or mutation-cursor write; resolves each line with mutation lineage first and accepted direct evidence only for lines lineage leaves unresolved — see `context/cli/mutation-trace-agent-attribution.md`; and persists the built Agent Trace payloads with range-level `content_hash` values to `agent_traces` in the repository-scoped Agent Trace DB without post-commit file artifacts); after successful validation and persistence, the setup-enabled config-file-only `agent_trace.auto_sync` gate launches one detached sync-owned `sync --format json` child when its resolved value is true (a newly created setup config supplies the explicit `true`; omitted configuration resolves to `false`), with launcher failures ignored and no high-frequency hook trigger; `diff-trace` performs STDIN JSON intake, validates required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id` (absent/`null` → `None`), required nullable/non-empty `tool_version` plus required `u64` `time` (Unix epoch milliseconds), rejects values that cannot fit signed `time_ms` storage, prefixes the stored `diff_traces.session_id` before insert construction (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi, same-tool idempotent), and inserts the parsed payload fields into `RepositoryAgentTraceDb` without creating a parsed-payload `context/tmp` artifact; Claude structured `PostToolUse` diff-trace intake resolves model attribution with `direct > exact transcript > exact session/agent state > bridge-derived chain state > NULL`: direct top-level or nested metadata wins, otherwise the event's `transcript_path` is scanned for the assistant envelope whose `tool_use.id` matches `tool_use_id`, then persistence performs one exact local `claude_model_state` lookup using canonical session and ephemeral agent scope. Only for a raw structured payload in the exact main-session scope, a state miss then resolves the newest `claude_model_state` observation across the transcript's bridge-linked chain members (bounded leading-record reads, one exact-scope read per member, winner by `observed_at_ms`) and, on a hit, writes a `claude_model_state` row for the current session (`source="bridge_inherited"`) before using that model — the only diff-trace resolution path that writes state. Model values normalize once through `claude/`, subagents do not inherit main-session state, and unresolved values remain nullable. `session-model` is no longer a supported hook route. - 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 index d06dd7f61..d0498ea6f 100644 --- a/context/cli/mutation-trace-agent-attribution.md +++ b/context/cli/mutation-trace-agent-attribution.md @@ -239,8 +239,7 @@ mutable `HEAD`, so a `HEAD` move after capture cannot change the anchoring. 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 +`MutationTraceStore::latest_mutation_event_revision` for the worktree. The critical section is a single indexed read. An unresolvable git dir, an unresolvable worktree identity, an unavailable @@ -249,11 +248,15 @@ history at all each yield an empty result (every line unresolved), so post-commit falls back to trusted direct-only Agent Trace behavior. The entrypoint creates no identity and writes no mutation-cursor state. -The revision ceiling is only a stable-snapshot bound. It is read inside -post-commit processing, after Git has already created the commit, so events -recorded between commit creation and the hook (for example an agent deleting a -line again after the working tree was restored) still sit below it. Temporal -eligibility comes from the commit-time cutoff below. +The revision ceiling provides a stable upper bound for mutation-history replay. +It is captured during post-commit processing and does not independently +establish whether an event preceded commit creation: events recorded between +commit creation and the hook (for example an agent deleting a line again after +the working tree was restored) still sit below it. Temporal eligibility is +enforced separately through the trusted [commit-time +cutoff](#commit-time-cutoff), using each event's database insertion timestamp. +The captured commit and first-parent trees fix structural identity, and +mutation lineage decides causal ownership within that eligible history. ## Commit-time cutoff diff --git a/context/cli/mutation-trace-store.md b/context/cli/mutation-trace-store.md index 5f19aea2e..aa743734b 100644 --- a/context/cli/mutation-trace-store.md +++ b/context/cli/mutation-trace-store.md @@ -104,14 +104,28 @@ cursor is supplied. Each request caps `requested_limit` at 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. +oldest-to-newest, and starts the cursor at `ceiling + 1` (exclusive) so only +`revision <= ceiling` events are eligible. + +`MutationTraceStore::load_mutation_event_page_before_commit(worktree, +revision_cursor, requested_limit, cutoff)` is the post-commit attribution +variant. It returns the same row shape with the same 32-row page cap, +descending revision order, and exclusive `revision < cursor` semantics, and +additionally requires `created_at < strftime('%Y-%m-%dT%H:%M:%fZ', , 'unixepoch')`: database insertion strictly before the start of the +trusted commit second. The predicate sits in the `WHERE` clause, so it filters +before `ORDER BY revision DESC LIMIT` and late events never displace eligible +ones from a page. `load_mutation_event_page` stays timestamp-independent for +its other consumers. See [commit-time +cutoff](mutation-trace-agent-attribution.md#commit-time-cutoff). `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. +the worktree lock as its revision ceiling: the highest revision visible when +post-commit takes its snapshot, not necessarily the highest revision that +existed at commit creation. Temporal eligibility is enforced by the +commit-time filter above. `MutationTraceStore::load_scope(scope_id) -> Result>` is the public single-scope read seam: one `mutation_trace_scopes` row by primary key, diff --git a/context/cli/structured-patch-service.md b/context/cli/structured-patch-service.md index 007f16121..90edaa66a 100644 --- a/context/cli/structured-patch-service.md +++ b/context/cli/structured-patch-service.md @@ -28,7 +28,7 @@ The module is wired into `sce hooks diff-trace` for Claude payload classification at intake (T04): when `hook_event_name` is present and the event is a supported `PostToolUse` (`Write` structured update, `Write` content create fallback, or `Edit` structured patch), the raw JSON is persisted as a `structured` payload type in `diff_traces` without conversion to unified-diff text. Unsupported Claude events (non-`PostToolUse`, unsupported tools) produce deterministic no-op results. OpenCode normalized payloads continue to be stored as `patch` payloads unchanged. -Post-commit parsing dispatch through `structured_patch.rs` is implemented: `RepositoryAgentTraceDb::recent_diff_trace_patches` reads `payload_type` from each `diff_traces` row and dispatches `patch` rows through existing `parse_patch` while dispatching `structured` rows through `derive_claude_structured_patch` at read time. Before downstream combine/intersect operations, every structured-row hunk receives the persisted row `model_id` and every touched line receives the persisted tool-prefixed canonical row `session_id`; reconstruction does not reuse the raw unprefixed session inside the stored Claude payload. +Post-commit parsing dispatch through `structured_patch.rs` is implemented: the shared row parser behind `RepositoryAgentTraceDb::recent_diff_trace_patches` and the post-commit `recent_diff_trace_patches_before_commit` reads `payload_type` from each `diff_traces` row and dispatches `patch` rows through existing `parse_patch` while dispatching `structured` rows through `derive_claude_structured_patch` at read time. Before downstream combine/intersect operations, every structured-row hunk receives the persisted row `model_id` and every touched line receives the persisted tool-prefixed canonical row `session_id`; reconstruction does not reuse the raw unprefixed session inside the stored Claude payload. ## Test status diff --git a/context/context-map.md b/context/context-map.md index 3eb20dcb9..c0ea8e312 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -30,10 +30,10 @@ Feature/domain context: - `context/cli/service-lifecycle.md` (current async lifecycle seam in `cli/src/services/lifecycle.rs`, including default no-op `ServiceLifecycle` diagnose/fix/setup methods against narrow `HasRepoRoot`, lifecycle-owned health/fix/setup result types with generic setup messages, doctor/setup adapter boundaries, the static `LifecycleProvider` enum catalog/dispatcher, hook/config/local_db/auth_db/agent_trace_db lifecycle providers including setup-time repository-scoped Agent Trace DB initialization plus checkout identity diagnostics, implemented doctor aggregation over diagnose/fix providers, and implemented setup aggregation over `setup` providers in order config → local_db → auth_db → agent_trace_db → hooks when requested) - `context/cli/mutation-trace-protocol.md` (pure, dependency-free `cli/src/services/mutation_trace/` refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol: the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — plus cross-action sequence/invariant tests and a module-level Quint refinement matrix are all complete (`types.rs`, `protocol.rs`, `mod.rs`, `tests.rs`, registered with `#[allow(dead_code)]`); opaque-newtype identity refinement decisions vs. the Quint model's bounded enums, and the `coordinator.rs` target end-state seam this layout leaves room for but does not create — `store.rs`, `runtime/git_snapshot.rs`, and `runtime/coordinator.rs` (including its public `coordinate()` entrypoint) now exist, built out by the `mutation-cursor-store-persistence` and `mutation-cursor-runtime-coordinator` plans respectively; `protocol.rs` is invoked only through the runtime, and the runtime is now driven by the generic `sce hooks mutation-scope` CLI ingress and a first Claude Code adapter driver, registered by `sce setup` and reachable by a real Claude Code session, now proven against real Git repositories and a real Agent Trace DB by the `claude-mutation-scope-integration` plan's T08 regressions) - `context/cli/mutation-trace-runtime-materialization.md` (how the finite, pre-populated Quint `SCOPES`/`WORKTREES` universes map onto the Rust protocol's unbounded identity space: the coordinator/store layer materializes scope and worktree state, scope identity is immutable once materialized, and a missing scope is distinct from a `NeverSeen` scope; `protocol.rs` only transitions already-known identities) -- `context/cli/mutation-trace-agent-attribution.md` (causal mutation-lineage attribution over every committed line with explicit precedence over fresh, exact, single-session direct evidence, and deleted/recreated paths kept tracked; 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 the `patch_for_locations` helpers. 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_attribution` entrypoint — existing checkout identity only, trusted-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/commit_hooks/attribution_tests.rs`) +- `context/cli/mutation-trace-agent-attribution.md` (causal mutation-lineage attribution over every committed line with explicit precedence over fresh, exact, single-session direct evidence, and deleted/recreated paths kept tracked; up to 128 of the invoking worktree's events inserted before the trusted [commit-time cutoff](cli/mutation-trace-agent-attribution.md#commit-time-cutoff) (filtered in SQL before the limit, and bounded by a revision ceiling that is only a snapshot bound) are replayed oldest-to-newest as one ordered sequence of tree transitions; an untrusted commit time leaves every line unknown. `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 the `patch_for_locations` helpers. 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_attribution` entrypoint — existing checkout identity only, commit and parent trees from the captured commit OID, trusted-direct-only fallback on absent identity/history/unavailable ceiling, 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/commit_hooks/attribution_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; migration `006_mutation_trace_health_invariant.sql`'s transactional rebuild of the worktree and event tables with a cross-column `tainted`/`failure_kind` health `CHECK`, `failure_kind`-authoritative legacy normalization, and a re-runnable SQL body, plus the shared fail-closed `validate_health_encoding` used by all three health-row decoders; `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-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; migration `006_mutation_trace_health_invariant.sql`'s transactional rebuild of the worktree and event tables with a cross-column `tainted`/`failure_kind` health `CHECK`, `failure_kind`-authoritative legacy normalization, and a re-runnable SQL body, plus the shared fail-closed `validate_health_encoding` used by all three health-row decoders; `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 plus its post-commit `load_mutation_event_page_before_commit` variant that filters `created_at` by the commit-time cutoff before the limit; 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-scope-provenance.md` (the insert-once `mutation_trace_scope_provenance` table from migration `005_mutation_scope_provenance.sql` and its `MutationTraceStore::register_scope_provenance` / `load_scope_provenance` seams: `scope_id -> session_id + model_id?` metadata *about* an established scope, stored beside but never inside protocol state — outside `ProtocolState` and the CAS batch, and never an input to `IneligibleUnscoped` / `AiExclusive` / `AiContended`; `session_id` is immutable identity and `model_id` immutable first-observed metadata, so the first persisted row wins in both directions and only a differing `session_id` for an existing `scope_id` errors; the supported `MutationTraceStore` write seam requires an existing owning `mutation_trace_scopes` row and never creates one implicitly, enforced in the store rather than by a `FOREIGN KEY`, leaving `004_mutation_trace_protocol.sql` unchanged; creation is additionally admission-bounded in the runtime — a row may be created only while the durable scope is `NeverSeen`, so after admission absent provenance stays absent permanently and a later `Start` replay cannot backfill it, while an existing row is still validated on every provenance-carrying `Start`; producers own canonicalization — both Codex and Claude adapters supply provenance today, with Claude resolving exact `(cc_, agent_id)` model state at admission and degrading unavailable models to `NULL`) - `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_async(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; an inherited external-taint marker is overlaid onto `protocol::database_failure` recovery on the next invocation, against the single captured snapshot and re-injected across a losing recovery CAS; the private `runtime::ref_reconciliation` per-worktree snapshot-ref maintenance pass (documented in `mutation-trace-ref-reconciliation.md`) shares the same `WorktreeLock`; `coordinate()` and `abandon_scope()` are now `pub(crate)` re-exported from `runtime/mod.rs` (documented in `mutation-scope-runtime.md`) while the runtime submodules themselves stay private; a generic `sce hooks mutation-scope` CLI ingress now drives both entrypoints, joined by current Claude Code and Codex adapter drivers (registered by `sce setup`/`sce setup --codex` and reachable) and an OpenCode adapter driver (lifecycle + recovery wired, and now generated as the final `sce-mutation-scope.ts` plugin and installed by `sce setup`), documented in `mutation-scope-runtime.md`) - `context/cli/mutation-trace-protected-worktree.md` (the shared safety prefix every mutation-cursor runtime entrypoint runs behind, in `cli/src/services/mutation_trace/runtime/protected_worktree.rs`, extracted from `coordinate()` by the `mutation-scope-runtime-integration` plan so a second entrypoint cannot drift from it: `ProtectedWorktree::acquire(repository_root)` running the safety-critical fixed order resolve `git_dir` → `WorktreeLock` (module-owned 10s `WORKTREE_LOCK_TIMEOUT`) → `ExternalTaintMarker::exists()` → `persist()` (fence armed write-ahead of every fallible step that follows, including DB acquisition) → `resolve_worktree_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 }` | `WorktreeIdentity`) each entrypoint maps onto its own error surface — `coordinate()` onto exactly the `CoordinateError` variants that step produced before the extraction) @@ -96,14 +96,14 @@ Feature/domain context: - `context/sce/agent-trace-db-write-contention-evidence.md` (measured Agent Trace DB write-contention behavior under the busy-timeout/outer-retry/contention-deadline contract: the `lock_contention_tests.rs` suite with its lock-budget boundary, strict N=2–4 in-process and real-hook gates and N=8 stress characterization, policy history from 500 / 1250 to the current 1000 / 2250 defaults, the supported-load failure that motivated the tuning, before-vs-after comparison, and the bounded-policy exhaustion failure mode) - `context/sce/auth-db.md` (encrypted `AuthDb = EncryptedTursoDb` adapter, canonical `/sce/auth.db` path, build-time generated `AUTH_MIGRATIONS` from `cli/migrations/auth/`, auth credential schema and updated-at trigger baseline, lifecycle setup/doctor integration, async encrypted token-storage persistence through a Result-valued Tokio OnceCell, and `SCE_AUTH_DB_ENCRYPTION_KEY`/OS credential-store key handling) - `context/sce/telemetry-credential.md` (telemetry-only OpenBao credential: typed Control Plane issuance client, save-on-issue, single-row encrypted `auth.db` storage that never mints on lookup, deterministic auth-login `telemetry_provisioning` status/message rendering with secret-free unavailable warnings, production issue-and-save after WorkOS persistence on fresh/valid-stored/renewed paths, logout deletion alongside WorkOS credentials, issuing-token identity binding with session-ownership checks, repository-to-workspace enrollment bindings, local deletion not revoking the OpenBao token, and its use as the managed OTLP bearer source, the hidden `telemetry-headers` helper and its OS-user trust boundary with no endpoint-pinning claim, and Claude-native telemetry setup, removal of SCE-owned settings on global-config withdrawal, rotation, logout, conflict handling, and override limits) -- `context/sce/agent-trace-db.md` (implemented Agent Trace database adapter: the sole repository-scoped `RepositoryAgentTraceDb` backed by the fresh multi-statement baseline schema plus additive `source_instance_id`, `claude_model_state`, mutation-trace, and `007_commit_patch_id` (nullable `agent_traces.commit_patch_id`, setup-only upgrade) migrations, with `repository_metadata` carrying both `repository_id` and a concurrency-safe atomic-claim `source_instance_id` (physical database identity, independent of `repository_id`), narrow concurrent-first-open repair for missing one-file baseline migration metadata after all required schema tables exist, no trace-table `checkout_id` columns, repository-level typed insert helpers for diff traces, post-commit intersections, Agent Trace rows, messages, parts, and the non-exported exact-scope Claude model-state register, repository-level recent diff-trace reads without checkout filtering including persisted hunk-model plus canonical touched-line-session enrichment for structured rows, async on-demand command/hook initialization with no daemon/background service, and the never-touch on-disk boundary for any pre-migration checkout-scoped/global DB files; the checkout-scoped `AgentTraceDb` 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 writers/readers and Agent Trace setup/lifecycle resolve repository storage through `agent_trace_storage`) +- `context/sce/agent-trace-db.md` (implemented Agent Trace database adapter: the sole repository-scoped `RepositoryAgentTraceDb` backed by the fresh multi-statement baseline schema plus additive `source_instance_id`, `claude_model_state`, mutation-trace, and `007_commit_patch_id` (nullable `agent_traces.commit_patch_id`, setup-only upgrade) migrations, with `repository_metadata` carrying both `repository_id` and a concurrency-safe atomic-claim `source_instance_id` (physical database identity, independent of `repository_id`), narrow concurrent-first-open repair for missing one-file baseline migration metadata after all required schema tables exist, no trace-table `checkout_id` columns, repository-level typed insert helpers for diff traces, post-commit intersections, Agent Trace rows, messages, parts, and the non-exported exact-scope Claude model-state register, repository-level recent diff-trace reads without checkout filtering (the inclusive-window reader plus the commit-time-bounded `recent_diff_trace_patches_before_commit` post-commit reader) including persisted hunk-model plus canonical touched-line-session enrichment for structured rows, async on-demand command/hook initialization with no daemon/background service, and the never-touch on-disk boundary for any pre-migration checkout-scoped/global DB files; the checkout-scoped `AgentTraceDb` 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 writers/readers and Agent Trace setup/lifecycle resolve repository storage through `agent_trace_storage`) - `context/sce/agent-trace-db-schema.md` (per-table columns, lookup indexes, and `updated_at` triggers of the repository-scoped Agent Trace database, including the nullable `agent_traces.commit_patch_id` column) - `context/sce/agent-trace-export-readers.md` (implemented `AgentTraceExportReader<'a>` in `cli/src/services/agent_trace_export/mod.rs`: the read-only async local export boundary over `RepositoryAgentTraceDb` — `read_messages_after`/`read_parts_after`/`read_agent_traces_after`, each cursor/limit/JS-safe-integer validated, materialized into owned camelCase `serde::Serialize` DTOs, with `agent_traces` rows carrying an always-present nullable `commitPatchId`; composes directly with `ResolvedAgentTraceStorage` without owning `source_instance_id`; `diff_traces` has no reader; no local sync cursor, no `agent-trace-sync.db`, no Turso Sync, no ETL, no DWH) - `context/sce/agent-trace-core-schema-migrations.md` (historical reference for removed local DB schema bootstrap behavior; T03 now implements the actual local DB with migrations) - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) - `context/sce/agent-trace-local-hooks-mvp-contract-gap-matrix.md` (T01 Local Hooks MVP production contract freeze and deterministic gap matrix for `agent-trace-local-hooks-production-mvp`) - `context/sce/agent-trace-minimal-generator.md` (implemented a library minimal Agent Trace generator seam at `cli/src/services/agent_trace.rs`, used by the active post-commit hook flow to produce strict `0.1.0` JSON payloads with top-level `version`, UUIDv7 `id` derived from commit-time metadata, caller-provided commit-time `timestamp`, optional top-level `vcs` metadata emitted when present (`type` from enum `git|jj|hg|svn`, `revision` from metadata input; current post-commit flow provides `git`), optional top-level `tool` metadata (`name`/`version`) sourced from builder metadata inputs when overlapping AI content exists, always-emitted `metadata.sce.version` sourced from the compiled `sce` CLI package version, and always-emitted `metadata.sce.line_changes` (`{ai,non_exclusive,unknown}` each `{added,removed}` `u64` counters, `#[serde(default)]` for backward-compatible deserialization) carrying exact per-line attribution counts with one bucket increment per canonical `post_commit_patch` touched line from the same per-line decisions that build revision-positioned ranges: only surviving added lines at their post-commit positions, coalesced by identical decision/session/model and grouped into conversations by accepted session (Git hunks never decide contributors; SCE never generates `mixed`), with optional conversation-level `contributor`, optional range-level `contributor` overrides, session links only from a conversation's own lines, and optional range `content_hash` = Murmur3 (seed 0) of the exact committed blob bytes at `vcs.revision`, read by `GitCommittedFiles` and omitted when unavailable) -- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence with a best-effort canonical `git patch-id --stable` commit patch ID stored in nullable `agent_traces.commit_patch_id` (sanitized warning and `NULL` on failure), DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > bridge-derived chain state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, a main-session-scope-only bridge chain state-miss fallback that seeds a `source="bridge_inherited"` row for the current session (the one diff-trace path that writes state), `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes whose own model-less `SessionStart` bridge attempt cannot read its lazily created transcript, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — and the hidden non-fail-open `sce hooks claude-mutation-scope` first-concrete-adapter route (now registered by `sce setup`), deferring the full contracts to `context/cli/mutation-scope-hook-ingress.md` and `context/cli/claude-mutation-scope-integration.md`.) +- `context/sce/agent-trace-hooks-command-routing.md` (implemented `sce hooks` command routing plus current runtime behavior: enabled-by-default commit-msg attribution with explicit opt-out controls, no-op `pre-commit`/`post-rewrite` entrypoints, active `post-commit` intersection and Agent Trace DB persistence (direct and mutation evidence both restricted by a trusted commit-time cutoff that fails closed to unknown attribution) with a best-effort canonical `git patch-id --stable` commit patch ID stored in nullable `agent_traces.commit_patch_id` (sanitized warning and `NULL` on failure), DB-only `diff-trace` STDIN intake with OpenCode normalized payloads and Claude structured `PostToolUse` payload classification, tool-prefixed stored `diff_traces.session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) while hook diagnostics route with unprefixed producer-native sessions when available, nullable `model_id`/direct `tool_version` persistence with Claude `direct > exact transcript > exact session/agent state > bridge-derived chain state > NULL` attribution, Claude direct-first model metadata extraction from top-level or nested fields followed by fail-open `transcript_path` + `tool_use_id` JSONL lookup and exact local state lookup when model is absent, a main-session-scope-only bridge chain state-miss fallback that seeds a `source="bridge_inherited"` row for the current session (the one diff-trace path that writes state), `claude/` prefix normalization, no parsed-payload artifact persistence under `context/tmp`, the silent local-only `sce hooks claude-model-state` lifecycle intake for synchronous `SessionStart` and asynchronous `PostModelSwitch` writes whose own model-less `SessionStart` bridge attempt cannot read its lazily created transcript, `session-model` removed from the supported hook surface, and `conversation-trace` STDIN intake for normalized batches plus supported raw Claude events with per-item and first-valid-insert batch diagnostic routing; this document also owns the current `diff-trace`, `conversation-trace`, and Claude model-state fail-open intake contracts, and now also routes the hidden non-fail-open `sce hooks mutation-scope` mutation-scope-runtime ingress with its two carried-outcome success variants — distinct from the fail-open `diff-trace`/`conversation-trace` intakes — and the hidden non-fail-open `sce hooks claude-mutation-scope` first-concrete-adapter route (now registered by `sce setup`), deferring the full contracts to `context/cli/mutation-scope-hook-ingress.md` and `context/cli/claude-mutation-scope-integration.md`.) - `context/sce/automated-profile-contract.md` (deterministic gate policy for automated OpenCode profile, including 10 gate categories, permission mappings, automated `/commit` single-commit execution behavior, and automated profile constraints) - `context/sce/bash-tool-policy-enforcement-contract.md` (approved bash-tool blocking contract plus current Rust evaluator seam and OpenCode/Claude delegation references, including config schema, argv-prefix matching, shell/nix unwrapping, custom-policy `satisfied_by` wrapper exemption, fixed preset catalog/messages, and precedence rules) - `context/sce/bash-policy-satisfied-by-wrapper-exemption.md` (custom-policy `satisfied_by` field: optional list of wrapper argv prefixes that exempt a policy from firing when the matched command was unwrapped from one of them; `NormalizedSegment` wrapper-chain tracking, matching model, examples, and scope) diff --git a/context/glossary.md b/context/glossary.md index 6fa5f0e97..7e7359f4e 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -85,14 +85,14 @@ - `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`; `disabled` covers both the explicit opt-out and an access policy that disallows background cloud operations, and 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 the `patch_for_locations` helpers; lineage projects every committed line, and deleted or renamed-away tracked paths stay tracked as absent files. 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. Removed lines are projected separately, through a non-resetting replay anchored at the exact first-parent tree (see `effective remover`), and the commit and parent trees are resolved from the captured commit OID, not `HEAD`. 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). +- `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 the `patch_for_locations` helpers; lineage projects every committed line, and deleted or renamed-away tracked paths stay tracked as absent files. 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 revision ceiling (a snapshot bound, not proof an event preceded the commit). The `bounded mutation-history consumer` (`resolve_bounded_mutation_attribution` in `cli/src/services/mutation_trace/runtime/mutation_attribution.rs`) loads the newest ≤128 eligible events at or below the ceiling 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. Removed lines are projected separately, through a non-resetting replay anchored at the exact first-parent tree (see `effective remover`), and the commit and parent trees are resolved from the captured commit OID, not `HEAD`. It is current-worktree-only and the generic replay is timestamp-independent; the production post-commit event source additionally requires each event's `created_at` before the trusted [commit-time cutoff](cli/mutation-trace-agent-attribution.md#commit-time-cutoff), filtered in SQL before the window limit. It 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). - `parent-line identity` / `ParentLineId`: Internal `MutationLineage` identity `{ path, line_number }` of one line of the exact first-parent commit tree, assigned by `MutationLineage::from_parent` and kept unchanged while the line survives observed transitions, however far it moves. Lines added after the parent never get one, even with identical text. A canonical removed line resolves through its old-side identity (old path, old line number, content), never new-file coordinates. See [Removed-line attribution](cli/mutation-trace-agent-attribution.md#removed-line-attribution). - `effective remover`: The recorded transition that removed a parent line, as opposed to whoever originally wrote it. That removal must still hold in the committed tree. A healthy `AiExclusive` remover yields AI attribution with its scope provenance, any other recorded remover yields `NonExclusive`, and an `Unobserved` remover yields `Unknown`. A later addition of matching content to the same parent file (plausible restoration) makes the removal claim `Unknown` permanently. The claim counts only when an uninterrupted replay from the exact parent tree reconciles with the commit tree for that path; otherwise the removal is unresolved. Deleted lines appear only in `metadata.sce.line_changes`, never as ranges. - `LineAttribution`: The single final per-line attribution decision in `cli/src/services/agent_trace.rs` — `Ai(ResolvedLine)` (AI-exclusive mutation lineage, or accepted direct evidence for an otherwise unresolved line), `NonExclusive` (mutation lineage resolved a non-exclusive or unscoped transition; not human authorship; cannot be overridden by direct evidence), or `Unknown` (unresolved). Exactly one decision exists per canonical touched line and feeds both revision-positioned ranges and `metadata.sce.line_changes`. See `context/sce/agent-trace-minimal-generator.md`. - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace_from_evidence(...)` under `metadata.sce`, carrying `version` (sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`) and `line_changes` (exact `{ai,non_exclusive,unknown}` × `{added,removed}` `u64` per-line attribution counts: every canonical `post_commit_patch` touched line increments exactly one bucket from its final `LineAttribution` decision — the same decisions ranges are built from — so totals equal the committed patch's touched lines independent of hunk boundaries and of emitted ranges; `#[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`. - `ScopeProvenance`: Optional insert-once metadata keyed by a mutation `ScopeId`, storing canonical SCE `session_id` and nullable first-observed `model_id`. It is registered only at `Start` admission while the owning scope is `NeverSeen`, remains outside `ProtocolState` and the CAS transition, and is resolved during mutation projection. `ScopeId` proves mutation ownership; `ScopeProvenance` describes the owning scope. Model/session provenance is observational and never changes `AiExclusive`/`AiContended`/`IneligibleUnscoped` protocol decisions. - `Agent Trace range content_hash`: Optional per-range `content_hash` emitted as `murmur3:<8 lowercase hex>` (Murmur3 x86 32-bit, seed 0) over exactly the committed bytes of the inclusive range at `vcs.revision`, including the lines' own LF/CRLF terminators; excludes paths, positions, hunk metadata, attribution, and IDs. Omitted, never fabricated, only when the caller explicitly reports `CommittedFile::Unverified`; the production post-commit hook never does, and fails instead on a missing path or Git read error. See `context/sce/agent-trace-minimal-generator.md`. -- `committed-file outcome`: The result of `CommittedFileReader::read_committed_file` for a post-commit path: `Blob` (regular or executable file, `100644`/`100755` blob; ranges validated and hashed), `NotLineAddressable` (symlink `120000` blob, gitlink `160000` commit, or directory `040000` tree; no ranges, counters kept), `Missing` (absent at the revision; trace generation fails), or `Unverified` (deliberate best-effort caller; hashless ranges whose positions are not independently verified). Operational failures are a separate `CommittedFileReadError` (`TreeLookupFailed`, `MalformedTreeEntry`, `BlobReadFailed`) and also fail trace generation. Canonical counters in `metadata.sce.line_changes` always cover every touched line regardless of which files emit ranges. +- `committed-file outcome`: The result of `CommittedFileReader::read_committed_file` for a post-commit path: `Blob` (regular or executable file, `100644`/`100755` blob; ranges validated and hashed), `NotLineAddressable` (symlink `120000` blob, gitlink `160000` commit, or directory `040000` tree; no ranges, counters kept), `Missing` (absent at the revision; trace generation fails), or `Unverified` (test-only, `#[cfg(test)]`: hashless ranges whose positions are not independently verified; production readers never produce it). Operational failures are a separate `CommittedFileReadError` (`TreeLookupFailed`, `MalformedTreeEntry`, `BlobReadFailed`) and also fail trace generation. Canonical counters in `metadata.sce.line_changes` always cover every touched line regardless of which files emit ranges. - `Git path identity`: The exact repository-relative path of a patch file after decoding Git's C-quoted form (`patch/git_path.rs`): escapes decode to bytes, the result must be valid UTF-8 without NUL, and `a/`/`b/` prefixes are removed only after decoding. Undecodable names fail patch parsing explicitly. The same identity is used for attribution lookup, `files[].path`, and committed-file lookup. - `external-mutation guard` / `external-mutation supervisor`: `arm_external_mutation_guard` + `ArmedExternalMutationGuard::exec` (`cli/src/services/mutation_trace/runtime/external_mutation_guard.rs`, hidden `sce hooks external-mutation-guard`, Unix-only), the harness-neutral, not-Pi-specific mechanism that lets a human-initiated shell command (Pi's `!`/`!!` `user_bash`) mutate a worktree without ever creating a mutation scope: it acquires the same `ProtectedWorktree` guard `coordinate()` uses, reports it armed, itself spawns the human shell as its own child (never signaled or stopped by control-channel closure — only by an explicit cancel request, which it enacts by signaling the shell's own process group), finishes exclusively on the shell's real process termination, and only then forces the existing `database_failure`+`recover` composition on the already-held worktree via `coordinate_on_held_worktree` before clearing the marker with `ProtectedWorktree::complete()`. See [`mutation-trace-external-mutation-guard.md`](cli/mutation-trace-external-mutation-guard.md). - `Claude diff-trace attribution`: Diff-trace enrichment rule where one Claude `PostToolUse` event resolves its model with `direct > exact transcript > exact session/agent state > bridge-derived chain 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`. Only for a raw structured Claude payload in the exact main-session scope (`agent_id` empty), a state miss then falls back to `bridge session correlation` across the transcript's bridge-linked chain; when that resolves, persistence writes a `claude_model_state` row for the current session (`source="bridge_inherited"`) — the one diff-trace resolution path that writes state — and uses that model, so later traces of the same session resolve from their own exact-scope row. Model sources receive one `claude/` normalization step, ephemeral agent context is never exported, and subagents do not inherit main-session state. @@ -235,7 +235,7 @@ - `PatchLoadError`: Error type in `cli/src/services/patch.rs` produced when `load_patch_from_json` encounters invalid JSON or a payload that does not match the expected `ParsedPatch` structure; carries an actionable `message` field - `load_patch_from_json`: Public function in `cli/src/services/patch.rs` that reconstructs a `ParsedPatch` from a JSON string; storage-agnostic entrypoint for callers who have already read serialized JSON content from a database, file, or other source - `ContributorType`: Standard Agent Trace contributor enum in `cli/src/services/agent_trace.rs` (`human`, `ai`, `mixed`, `unknown`). SCE generates only `ai` and `unknown`; `mixed` and `human` are accepted when reading records. Replaced the former hunk-level `HunkContributor`, which labelled any partially attributed Git hunk `mixed`. -- `trusted direct evidence`: A `diff_traces` candidate that may attribute a committed line lineage left unresolved: recorded after the parent commit's committer-time second, an exact `(file, kind, line_number, content)` match, and the only session with an exact candidate for that line. Matching lives in `cli/src/services/agent_trace/evidence.rs` +- `trusted direct evidence`: A `diff_traces` candidate that may attribute a committed line lineage left unresolved: reported after the parent commit's committer-time second (within the 7-day freshness bound), with both reported `time_ms` and database `created_at` strictly before the trusted commit second ([commit-time cutoff](cli/mutation-trace-agent-attribution.md#commit-time-cutoff)), an exact repository file identity and `(kind, line_number, content)` match, and the only session with an exact candidate for that line. Matching lives in `cli/src/services/agent_trace/evidence.rs` - `Conversation`: Struct in `cli/src/services/agent_trace.rs` grouping one file's ranges that share provenance (one accepted session, one session-less model, or one unresolved decision kind), with optional conversation-level `contributor`, `ranges`, and optional `related` session link. Git hunks are not conversation boundaries. - `revision-positioned range`: An Agent Trace `LineRange` built from surviving added lines at their post-commit line numbers (inclusive, 1-indexed), coalescing contiguous lines with identical decision, accepted session, and model; never includes context or removed lines, and may carry a `contributor` override when a single-session conversation spans several models. See `context/sce/agent-trace-minimal-generator.md`. - `TraceFile`: Struct in `cli/src/services/agent_trace.rs` for one committed file with surviving added lines, carrying its post-commit `path` and conversations ordered by first line; deleted files and pure renames produce none. diff --git a/context/overview.md b/context/overview.md index 8f6288b57..494621b01 100644 --- a/context/overview.md +++ b/context/overview.md @@ -45,7 +45,7 @@ for `/validate`. See 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 145-path artifact, metadata/package, phase-reference, internal-reference, optional-workflow-manifest, workflow-orchestration, OpenCode-permission, required-path, and forbidden-path checks while delegating deterministic payload production and inventories to the shared generated-input producer; `nix flake check` runs the same contract. The target matrix contains one manual OpenCode profile plus Claude, Pi, and Codex; the former automated OpenCode profile has been removed. A fourth Pkl renderer, `config/pkl/renderers/codex-content.pkl`, also consumes the same canonical workflow composition to emit skills-only Codex output under `config/.agents/skills/**` (no per-target frontmatter, matching Pi, and no command/prompt layer). Each of the six catalog workflow skills (not `sce-decision`, which has no user-facing entrypoint on any target) additionally carries a Codex-only `agents/openai.yaml`, rendered by `config/pkl/renderers/codex-metadata.pkl` from the shared catalog's `title`/`description` plus an authored `default_prompt`, with `policy.allow_implicit_invocation: false` so these stateful lifecycle workflows activate only from explicit `$sce-` invocation or Codex's `/skills` discovery, never from conversational relevance alone. The Codex renderer also emits a Codex hook registration file (`config/.codex/hooks.json`) and its install-guidance helper (`config/.codex/hooks/run-sce-or-show-install-guidance.sh`). The existing conversation/diff registrations (`UserPromptSubmit`, `Stop`, `PreToolUse` for `Bash`, `PostToolUse` for `apply_patch`) route through `sce hooks codex` and retain their fail-open behavior where designed. The separate mutation-scope registrations route through `sce hooks codex-mutation-scope`: `PreToolUse` and `PostToolUse` use matcher `^(Bash|apply_patch)$`, while `Stop`, `Interrupt`, `SubagentStop`, and `SessionEnd` omit matcher and are unmatched. The tracked mutation `PreToolUse` bootstrap fails closed if Git-root resolution, helper or `sce` discovery, adapter startup, runtime `Start`, or Bash-policy evaluation cannot establish attribution; mutation-scope `PostToolUse` and cleanup hooks do not use this bootstrap behavior. Delegation, MCP, and unknown tools do not match these generated mutation Pre/Post registrations. `sce setup --codex` (and `--all`) now installs it as a fourth `SetupTarget`; setup merges the user-owned `.codex/hooks.json` through the shared structural Codex hook-config service, preserving unrelated valid handlers and rejecting malformed or Codex-invalid documents before the atomic swap. `sce doctor` diagnoses that file per required registration (`PresentAndCurrent`/`Missing`/`Stale`, or `Malformed` for the whole document) rather than by whole-document equality, reporting the mutation-scope registrations under distinct `#(mutation-scope)` rows separate from the `sce hooks codex` rows, and separately reports whether Codex has actually marked each structurally current registration trusted and whether its effective hook-discovery policy allows project hooks, by reading (never writing) Codex's own `$CODEX_HOME/config.toml` and policy state; `sce doctor --fix` repairs structurally unhealthy registrations through the same merge service but never touches trust or policy state (see `context/sce/doctor-human-text-contract.md`). `sce hooks codex` now exists as a typed dispatcher (`cli/src/services/hooks/codex/`): it parses the raw hook JSON into a `CodexHookEvent` and classifies `(hook_event_name, tool_name)` into `UserPromptSubmit`, `Stop`, `PreToolUse(Bash)`, `PostToolUse(apply_patch)`, or a fail-open `NoOp` fallthrough covering every other combination — including `apply_patch` under `PreToolUse`. `UserPromptSubmit` and `Stop` each capture one `messages`/`parts` row (`role="user"`/`role="assistant"`) into the repository Agent Trace DB under the idempotent `cx_` session prefix, `PreToolUse(Bash)` delegates to the existing Bash policy engine, and `PostToolUse(apply_patch)` parses Codex's own `apply_patch` text, resolves paths from the event `cwd` against the real Git root into safe repository-relative paths, normalizes Add/Update evidence into an SCE unified diff under deterministic event-scoped synthetic line identities derived from `tool_use_id`, and persists it as one `diff_traces` row when non-empty (see `context/sce/codex-integration-runtime.md`) — while invalid cwd/path resolution and malformed STDIN payloads fail open with empty stdout, reported model IDs are preserved without fabricated provider prefixes, and missing/blank apply_patch sessions produce no evidence. Delete-File operations and Bash filesystem effects do not become `diff_traces` in this existing conversation/diff pipeline, and its apply_patch evidence has separate operation limitations. Separately, the Codex mutation-scope adapter tracks `Bash` and `apply_patch` executions as `TrackedMutation` scopes; `AiExclusive` means tracked-scope exclusivity, not that Bash authored every mutation in the interval. -It also includes a Rust CLI (`sce`) for Shared Context Engineering workflows: auth, config inspection, setup, doctor, Agent Trace hooks and synchronization, bash-policy evaluation, and repository-scoped Agent Trace storage infrastructure. See `context/architecture.md` for module-level boundaries and `context/context-map.md` for the full domain file index. A new pure, dependency-free `cli/src/services/mutation_trace/` module now exists as a Rust refinement of the verified `spec/mutation_cursor.qnt` mutation-cursor protocol; it currently carries domain types and the full pure action set — `prepare`/`commit` transition logic, attribution/mutation-event materialization, snapshot-failure/database-failure taint actions, scope abandonment, and recovery with an explicit observed-tree input — is registered without a module-wide dead-code allowance (only declaration-level, reasoned exceptions remain, and snapshot-ref reconciliation is wired explicitly through `sce doctor --fix`; automatic reclamation is a deferred follow-up). A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every current or future harness adapter must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are now driven by one harness-neutral CLI ingress, the hidden `sce hooks mutation-scope` command (`context/cli/mutation-scope-hook-ingress.md`), which reads a single normalized JSON lifecycle object from STDIN, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, maps it to a `RuntimeBoundary` (`start`/`advance`/`close`/`flush`) or an `abandon_scope()` call forwarding `scope_id`/`event_id`/`actor_kind` verbatim (any `worktree_id` key is refused — worktree identity is derived by the runtime from the invoking checkout), and invokes the runtime with a lazy DB provider closure so DB acquisition stays inside the protected-worktree ordering. Unlike the fail-open `diff-trace`/`conversation-trace` intakes, this ingress never discards a valid boundary: it classifies results by durable completion, returning a non-zero `CliError` for a malformed payload or a pre-completion runtime error while treating the two carried-outcome variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion` — the durable transition succeeded and only the trailing external-taint marker cleanup failed) as empty-stdout success without retrying the transition. Every successful execution emits empty stdout and writes `mutation_trace_*` rows only. It is the generic transport seam. Concrete harness lifecycle adapters for Claude Code and Codex are wired to it in-process via a `pub(crate)` seam rather than by re-invoking the ingress command; they map each harness's lifecycle events onto one mutation `ScopeId` per mutation-capable tool execution with write-ahead fail-closed `Start`, terminal `Close`, cleanup, recovery barriers, and harness-specific unsupported-boundary handling. A Pi adapter driver (`cli/src/services/hooks/pi_mutation_scope/`) also now consumes this same `pub(crate)` in-process seam through its hidden `sce hooks pi-mutation-scope` command and canonical generated Pi extension, with a `PendingStart`→`Executed`→`Closed`/`PendingAbandon` attempt-phase lifecycle keyed on Pi's `tool_result` event rather than `tool_execution_start` (see `context/cli/pi-mutation-scope-integration.md`); ordinary Pi sessions now reach it, and its human `user_bash` path uses the separate guarded external-mutation supervisor. Codex execution identity and `ScopeId`/`EventId` derivation are owned by `context/cli/codex-mutation-scope-integration.md`. A separate **read-only** consumer of the same module's persisted mutation-event history is wired, though: the post-commit Agent Trace flow calls `mutation_trace::runtime::resolve_post_commit_mutation_attribution(...)` to attribute every committed touched line, taking precedence over fresh, exact, single-session direct `diff_traces` evidence, by replaying the newest 128 events for the invoking worktree (bounded also by a commit attribution cut captured under the worktree lock) oldest-to-newest as one causal per-line provenance lineage rather than matching historical patches independently, resolving the worktree's *existing* checkout identity only, creating no identity and writing no mutation-cursor state, and never inserting into `diff_traces` or `post_commit_patch_intersections` (see `context/cli/mutation-trace-agent-attribution.md` and `context/sce/agent-trace-hooks-command-routing.md`). See also `context/cli/mutation-trace-protocol.md`, `context/cli/mutation-trace-runtime-coordinator.md`, `context/cli/mutation-trace-ref-reconciliation.md`, `context/cli/mutation-trace-protected-worktree.md`, `context/cli/mutation-trace-scope-abandonment.md`, `context/cli/mutation-scope-runtime.md`, `context/cli/mutation-scope-hook-ingress.md`, `context/cli/claude-mutation-scope-integration.md`, and `context/cli/mutation-trace-external-mutation-guard.md` (the harness-neutral process supervisor that lets Pi's `user_bash` mutate a worktree, guarded by the same `WorktreeLock`, without ever creating a scope). +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 without a module-wide dead-code allowance (only declaration-level, reasoned exceptions remain, and snapshot-ref reconciliation is wired explicitly through `sce doctor --fix`; automatic reclamation is a deferred follow-up). A separate `cli/src/services/mutation_trace/store.rs` persistence layer (built out by the `mutation-cursor-store-persistence` plan) now provides a real, CAS-guarded database call site against the repository-scoped Agent Trace DB, and a `cli/src/services/mutation_trace/runtime/` coordinator layer (built out by the `mutation-cursor-runtime-coordinator` plan) adds the per-worktree advisory lock, the isolated Git snapshot/ref-pinning service, and a public `coordinate()` entrypoint that drives the protocol against a real worktree under that lock, plus a per-worktree `ref_reconciliation` maintenance pass (built out by the `mutation-cursor-ref-reconciliation` plan) that, only for the namespace of a checkout id a current worktree still derives, reclaims orphaned SCE-owned snapshot refs under that same lock while retaining every tree any durable mutation-cursor state still references (a namespace no current worktree owns — via a deleted linked worktree or checkout-id metadata loss/recreation — is out of reach and left to future repository-scoped work). That runtime now has a second protected entrypoint alongside `coordinate()`: `cli/src/services/mutation_trace/runtime/scope_runtime.rs`'s `abandon_scope()` (built out by the `mutation-scope-runtime-integration` plan) retires a scope whose final worktree boundary was never observed — a dead agent process leaves no `Close` behind — sharing `coordinate()`'s extracted `runtime/protected_worktree.rs` safety prefix but deliberately capturing no Git snapshot, so abandonment is not a `RuntimeBoundary` and needs no Quint change. Both entrypoints are now reachable crate-wide through nine `pub(crate)` re-exports in `runtime/mod.rs` while every module behind them stays private, and the lifecycle contract every current or future harness adapter must uphold is recorded in `context/cli/mutation-scope-runtime.md`. The protocol runtime entrypoints (`coordinate()` / `abandon_scope()`) are now driven by one harness-neutral CLI ingress, the hidden `sce hooks mutation-scope` command (`context/cli/mutation-scope-hook-ingress.md`), which reads a single normalized JSON lifecycle object from STDIN, strictly parses it into exactly `start`/`advance`/`close`/`flush`/`abandon`, maps it to a `RuntimeBoundary` (`start`/`advance`/`close`/`flush`) or an `abandon_scope()` call forwarding `scope_id`/`event_id`/`actor_kind` verbatim (any `worktree_id` key is refused — worktree identity is derived by the runtime from the invoking checkout), and invokes the runtime with a lazy DB provider closure so DB acquisition stays inside the protected-worktree ordering. Unlike the fail-open `diff-trace`/`conversation-trace` intakes, this ingress never discards a valid boundary: it classifies results by durable completion, returning a non-zero `CliError` for a malformed payload or a pre-completion runtime error while treating the two carried-outcome variants (`MarkerClearAfterCommit` / `MarkerClearAfterCompletion` — the durable transition succeeded and only the trailing external-taint marker cleanup failed) as empty-stdout success without retrying the transition. Every successful execution emits empty stdout and writes `mutation_trace_*` rows only. It is the generic transport seam. Concrete harness lifecycle adapters for Claude Code and Codex are wired to it in-process via a `pub(crate)` seam rather than by re-invoking the ingress command; they map each harness's lifecycle events onto one mutation `ScopeId` per mutation-capable tool execution with write-ahead fail-closed `Start`, terminal `Close`, cleanup, recovery barriers, and harness-specific unsupported-boundary handling. A Pi adapter driver (`cli/src/services/hooks/pi_mutation_scope/`) also now consumes this same `pub(crate)` in-process seam through its hidden `sce hooks pi-mutation-scope` command and canonical generated Pi extension, with a `PendingStart`→`Executed`→`Closed`/`PendingAbandon` attempt-phase lifecycle keyed on Pi's `tool_result` event rather than `tool_execution_start` (see `context/cli/pi-mutation-scope-integration.md`); ordinary Pi sessions now reach it, and its human `user_bash` path uses the separate guarded external-mutation supervisor. Codex execution identity and `ScopeId`/`EventId` derivation are owned by `context/cli/codex-mutation-scope-integration.md`. A separate **read-only** consumer of the same module's persisted mutation-event history is wired, though: the post-commit Agent Trace flow calls `mutation_trace::runtime::resolve_post_commit_mutation_attribution(...)` to attribute every committed touched line, taking precedence over fresh, exact, single-session direct `diff_traces` evidence (which may only claim lines lineage leaves unresolved), by replaying up to 128 events for the invoking worktree that were inserted before the trusted commit-time cutoff (also bounded by a revision ceiling captured under the worktree lock as a snapshot bound) oldest-to-newest as one causal per-line provenance lineage rather than matching historical patches independently, resolving the worktree's *existing* checkout identity only, creating no identity and writing no mutation-cursor state, and never inserting into `diff_traces` or `post_commit_patch_intersections` (see `context/cli/mutation-trace-agent-attribution.md` and `context/sce/agent-trace-hooks-command-routing.md`). See also `context/cli/mutation-trace-protocol.md`, `context/cli/mutation-trace-runtime-coordinator.md`, `context/cli/mutation-trace-ref-reconciliation.md`, `context/cli/mutation-trace-protected-worktree.md`, `context/cli/mutation-trace-scope-abandonment.md`, `context/cli/mutation-scope-runtime.md`, `context/cli/mutation-scope-hook-ingress.md`, `context/cli/claude-mutation-scope-integration.md`, and `context/cli/mutation-trace-external-mutation-guard.md` (the harness-neutral process supervisor that lets Pi's `user_bash` mutate a worktree, guarded by the same `WorktreeLock`, without ever creating a scope). 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. @@ -113,7 +113,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 the direct-only intersection metadata to `post_commit_patch_intersections`, then — for committed touched lines that direct intersection did not cover — resolves bounded, read-only mutation-history AI coverage scoped to the invoking worktree's existing identity (newest 128 events replayed oldest-to-newest as one causal tree-transition provenance lineage bounded by a worktree-lock-captured commit attribution cut — `revision <= latest_mutation_event_revision` read under the worktree lock — with a committed line attributed AI only if an AI event's line survives every later transition into the committed tree; scope provenance enriches surviving mutation lines when available, without writing `diff_traces` or mutation-cursor state) and passes direct and mutation-AI evidence separately into the Agent Trace builder, which unions their related sessions and emits a model only when present evidence agrees, 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` per-line `{ai,non_exclusive,unknown}` attribution counts over the combined direct+mutation line decisions, 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 > bridge-derived chain 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, then (1) captures the exact commit OID once from `HEAD` and reads that OID's committer timestamp and patch; (2) accepts the timestamp as a trusted commit-time cutoff only under a conservative heuristic (no `GIT_COMMITTER_DATE` override, not in the future, at most 5 minutes old), otherwise both evidence sources become unavailable and every committed touched line is unknown while the trace is still persisted; (3) queries direct `diff_traces` evidence whose reported `time_ms` and database `created_at` both precede the commit second (dispatching `patch` rows through unified-diff parsing and `structured` rows through `structured_patch::derive_claude_structured_patch` at read time); (4) resolves read-only mutation lineage for the invoking worktree from mutation events whose `created_at` precedes the same cutoff (filtered in SQL before the 128-event window) against the commit and first-parent trees of the captured OID, without writing `diff_traces` or mutation-cursor state; (5) resolves each committed touched line independently — AI-exclusive lineage → AI, non-exclusive lineage → `NonExclusive`, unresolved lineage plus accepted exact single-session direct evidence → AI, otherwise `Unknown` (conflicting direct candidates fail closed to unknown, and a matching direct session may only supply a model the mutation scope lacks); (6) builds and validates ranges, hashes, and per-line counters; and (7) persists the schema-validated built Agent Trace payload (the direct-only intersection metadata is already written to `post_commit_patch_intersections` right after step 3) (see `context/cli/mutation-trace-agent-attribution.md#commit-time-cutoff`), 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` per-line `{ai,non_exclusive,unknown}` attribution counts over the combined direct+mutation line decisions, 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 > bridge-derived chain 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 > bridge-derived chain 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/sce/agent-trace-db.md b/context/sce/agent-trace-db.md index e01337f36..101d0a88f 100644 --- a/context/sce/agent-trace-db.md +++ b/context/sce/agent-trace-db.md @@ -16,9 +16,10 @@ - `insert_diff_trace()`: domain-specific insert helper using parameterized SQL. - `RecentDiffTracePatches`: parsed recent `diff_traces` query result containing valid parsed patches plus skipped-row reports. - `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)`: chronological `diff_traces` read helper for rows in the inclusive window `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`; parses raw patch text through `parse_patch` and skips malformed rows without failing the query. +- `CommitTimeCutoff` / `recent_diff_trace_patches_before_commit(lower_time_ms, cutoff)`: the post-commit direct-evidence reader; same row parsing, but restricted to the commit-time cutoff (see [Recent patch reads](#recent-patch-reads)). - `PostCommitPatchIntersectionInsert<'a>`: insert payload for post-commit intersection results with commit metadata, window bounds, loaded/skipped counts, and serialized patch JSON. - `insert_post_commit_patch_intersection()`: domain-specific insert helper using parameterized SQL. -- `AgentTraceInsert<'a>`: insert payload for built Agent Trace rows with `commit_id`, `commit_time_ms`, serialized `trace_json`, `agent_trace_id`, non-null `url`, required `remote_url: &'a str` (Rust-API-only; DB column stays nullable), and `commit_patch_id: Option<&'a str>` bound to the nullable `agent_traces.commit_patch_id` column in the same insert (`None` persists `NULL`). The post-commit writer currently passes `None`. +- `AgentTraceInsert<'a>`: insert payload for built Agent Trace rows with `commit_id`, `commit_time_ms`, serialized `trace_json`, `agent_trace_id`, non-null `url`, required `remote_url: &'a str` (Rust-API-only; DB column stays nullable), and `commit_patch_id: Option<&'a str>` bound to the nullable `agent_traces.commit_patch_id` column in the same insert (`None` persists `NULL`). The post-commit writer passes a best-effort canonical Git patch ID for the captured commit, or `None` when it is unavailable (see [agent-trace-hooks-command-routing.md](agent-trace-hooks-command-routing.md)). - `insert_agent_trace()`: domain-specific insert helper for `agent_traces` using parameterized SQL. - `MessageRole` enum: `User` / `Assistant` — maps to `messages.role` DB constraint. - `InsertMessageInsert`: owned payload struct with insertable parent `messages` columns (`session_id`, `message_id`, `role`, `generated_at_unix_ms`); message body text belongs to `parts.text`, not the parent message row. @@ -43,7 +44,7 @@ Repository persistence, readiness checks, transactions, and migration operations This adapter has no canonical `DbSpec::db_path()`; callers must resolve `/sce/repos//agent-trace.db` first and use explicit-path `TursoDb` constructors. Its migration list is `generated_migrations::AGENT_TRACE_REPOSITORY_MIGRATIONS`: the fresh multi-statement baseline `cli/migrations/agent-trace-repository/001_repository_schema.sql` plus the additive `002_repository_source_instance_id.sql` (adds `repository_metadata.source_instance_id`), `003_claude_model_state.sql` (adds the non-exported Claude model-state register), `004_mutation_trace_protocol.sql`, `005_mutation_scope_provenance.sql`, and `006_mutation_trace_health_invariant.sql` (the mutation-cursor protocol tables, the scope-provenance table, and the health-invariant rebuild; see [mutation-trace store](../cli/mutation-trace-store.md) and [mutation-scope provenance](../cli/mutation-scope-provenance.md)), plus `007_commit_patch_id.sql` (adds the nullable `agent_traces.commit_patch_id` column). The baseline schema includes `repository_metadata` plus the existing repository-level Agent Trace tables, indexes, and triggers, and intentionally has no `checkout_id` columns on trace tables. `RepositoryAgentTraceDb::verify_or_initialize_repository_metadata(repository_id) -> Result` inserts the singleton metadata row on first initialization, errors if an existing DB stores a different repository ID, and atomically claims `source_instance_id` for this physical database via `UPDATE ... WHERE source_instance_id = ''` (a losing racer's generated candidate is discarded and an already-valid stored value is never overwritten), returning the typed `RepositoryMetadata { repository_id, source_instance_id }`. `source_instance_id` is generated by application code (`generate_source_instance_id()`, UUID v4) and validated with `is_valid_source_instance_id()` (non-empty once trimmed); it is never derived from `repository_id`, remote URL, checkout ID, filesystem path, hostname, or user/workspace identity, and stays stable across reopen and repeated `sce setup` runs. `RepositoryAgentTraceDb::repair_missing_repository_schema_migration_metadata()` is a narrow concurrent-first-open repair seam: it never creates trace tables, but if every required repository schema table already exists and only the one-file baseline migration record is missing, it records `001_repository_schema` and rechecks readiness. -`RepositoryAgentTraceDb` exposes repository-level write helpers for the current row families by delegating to the same typed insert payloads and parameterized SQL used by the checkout-scoped adapter: `insert_diff_trace`, `insert_post_commit_patch_intersection`, `insert_agent_trace`, `insert_messages`, `insert_parts`, and `insert_conversation_text_event`. It also exposes `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` by delegating to the shared recent diff-trace query/parser helper, so repository-scoped attribution reads use the same chronological inclusive window semantics without a checkout filter. These methods preserve the existing row shapes and do not add checkout provenance columns or checkout-scoped write/query APIs. +`RepositoryAgentTraceDb` exposes repository-level write helpers for the current row families by delegating to the same typed insert payloads and parameterized SQL used by the checkout-scoped adapter: `insert_diff_trace`, `insert_post_commit_patch_intersection`, `insert_agent_trace`, `insert_messages`, `insert_parts`, and `insert_conversation_text_event`. It also exposes `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` by delegating to the shared recent diff-trace query/parser helper (chronological inclusive window, no checkout filter), and `recent_diff_trace_patches_before_commit(lower_time_ms, cutoff)`, which uses the same parser with the commit-time-bounded query described in [Recent patch reads](#recent-patch-reads). These methods preserve the existing row shapes and do not add checkout provenance columns or checkout-scoped write/query APIs. The repository-scoped adapter is consumed by `agent_trace_storage`, active hook runtime opening, Agent Trace setup/doctor lifecycle, and `sce sync`. Hook writers/readers resolve the current repository storage context before using `RepositoryAgentTraceDb`. The migration-running `new_at(path)` constructor is used by setup/lifecycle; hook runtime uses the no-migration constructor and fails open with `Run 'sce setup'.` guidance when schema readiness is not met. There is no longer a checkout-scoped adapter or trace database inspection service. @@ -152,7 +153,20 @@ Post-commit intersection rows are written by the active `post-commit` hook flow ## Recent patch reads -`RepositoryAgentTraceDb::recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` supports the post-commit comparison flow without changing `diff_traces` writes: +Two readers share the row parsing below; neither changes `diff_traces` writes or the schema. + +- `RepositoryAgentTraceDb::recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` keeps the inclusive event-time window `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`. The staged `commit-msg` preflight uses it. +- `RepositoryAgentTraceDb::recent_diff_trace_patches_before_commit(lower_time_ms, cutoff)` is the active post-commit attribution reader: + + ```sql + time_ms >= ?1 + AND time_ms < ?2 + AND created_at < strftime('%Y-%m-%dT%H:%M:%fZ', ?3, 'unixepoch') + ``` + + `?1` is `lower_time_ms`, `?2` is the start of the commit second in milliseconds, and `?3` is the commit timestamp in Unix seconds. `time_ms` is the reported event time and `created_at` the database insertion time; both must precede the commit second. When `lower_time_ms` is not before the cutoff it returns no rows without querying. See [commit-time cutoff](../cli/mutation-trace-agent-attribution.md#commit-time-cutoff). + +Row handling for both readers: - SQL reads `id`, `time_ms`, `session_id`, `patch`, nullable `model_id` + `tool_name` + `tool_version`, and `payload_type` from `diff_traces` where `time_ms >= cutoff_time_ms AND time_ms <= end_time_ms`. - Rows are ordered by `time_ms ASC, id ASC` for deterministic chronological processing. diff --git a/context/sce/agent-trace-hooks-command-routing.md b/context/sce/agent-trace-hooks-command-routing.md index debfee190..23dc54497 100644 --- a/context/sce/agent-trace-hooks-command-routing.md +++ b/context/sce/agent-trace-hooks-command-routing.md @@ -50,20 +50,22 @@ Interactive `sce setup` persists the independent confirmations at `agent_trace.a - `pre-commit` is a deterministic no-op entrypoint. - **`post-commit` is an active intersection entrypoint** (see [agent-trace-db.md](agent-trace-db.md)): - Agent Trace DB access resolves repository-scoped storage from `agent_trace.repository_id` or the configured Git remote, then opens `/sce/repos//agent-trace.db` through the repository storage resolver. - - The resolver tries a no-migration open/readiness check plus repository metadata validation first and falls back to migration-running initialization when the repository DB is absent or schema metadata is incomplete. - - Captures the current commit's patch from git using `capture_post_commit_patch_from_git()`. - - Queries `diff_traces` patches via `RepositoryAgentTraceDb::recent_diff_trace_patches()` from `direct_evidence_cutoff_ms`: the later of 7 days ago and one second after the parent commit's committer time (`HEAD^`; a root commit uses only the 7-day bound). Rows recorded before the parent commit describe already-committed work and are not loaded. + - The hook-runtime resolver (`open_repository_db_for_hook_runtime`) opens the DB with `RepositoryAgentTraceDb::open_for_hooks_without_migrations_at` and never applies migrations. It checks schema readiness (`ensure_schema_ready_for_hooks`); if that fails, it only records the missing baseline `__sce_migrations` row when every required repository table already exists (`repair_missing_repository_schema_migration_metadata`, never creating tables), otherwise it fails with `Run 'sce setup'.` guidance. It then verifies or initializes repository metadata. + - Captures the commit with `capture_post_commit_patch_from_git()`: reads `HEAD` once for the commit OID, then reads that OID's committer time (`git show --format=%ct`), its first parent's committer time (`^`), and its patch. Later steps use only the captured OID. + - Evaluates the commit time with `trusted_commit_time_cutoff(...)`, producing `attribution_cutoff: Option` (start of the committer-timestamp second, so the whole commit second is excluded). The cutoff is `None` (untrusted) when `GIT_COMMITTER_DATE` is set in the hook environment, when the commit second starts after post-commit entry (future), or when it started more than `COMMIT_TIME_MAX_AGE_MILLIS` (5 minutes) before entry. This is a conservative heuristic, not proof that the timestamp is authentic. See [commit-time cutoff](../cli/mutation-trace-agent-attribution.md#commit-time-cutoff). + - With a trusted cutoff, queries `diff_traces` via `RepositoryAgentTraceDb::recent_diff_trace_patches_before_commit(lower_time_ms, cutoff)`: `time_ms >= lower_time_ms AND time_ms < commit_second_start_ms AND created_at < commit_second_start_utc` (the last predicate via parameterized SQLite `strftime`). `time_ms` is the reported event time and `created_at` the database insertion time; both must precede the commit second, so evidence inserted during or after it is ineligible. `lower_time_ms` is `direct_evidence_cutoff_ms`: the later of 7 days ago and one second after the parent commit's committer time (a root commit uses only the 7-day bound). Rows recorded before the parent commit describe already-committed work and are not loaded. The unrestricted inclusive-window `recent_diff_trace_patches()` remains in use for the staged `commit-msg` preflight. + - With an untrusted cutoff, no direct-evidence query runs and mutation attribution returns an empty result, so every committed touched line is `Unknown`; the Agent Trace is still built, validated, and persisted. - Recent-row parsing dispatches on `payload_type`: `patch` rows parse through existing `parse_patch`, while `structured` rows parse stored JSON through `structured_patch::derive_claude_structured_patch` at read time to produce `ParsedPatch`. - Parsed `PatchHunk` entries carry nullable row `model_id` for both paths, so combined/intersection patch inputs retain per-hunk model provenance for downstream Agent Trace attribution building. - Keeps each fresh row's patch separate (`direct_patches`) and projects trusted direct evidence through `agent_trace::direct_evidence_patch`: exact touched-line matches whose candidates all come from one session. - - Persists the serialized trusted direct projection to `post_commit_patch_intersections` table with commit metadata (OID, timestamp), window bounds (cutoff_ms, end_ms), and loaded/skipped counts. + - Persists the serialized trusted direct projection to `post_commit_patch_intersections` table with commit metadata (OID, timestamp), window bounds (cutoff_ms; end_ms is the exclusive commit-second start, or equal to cutoff_ms when the commit time is untrusted), and loaded/skipped counts. - Empty recent patch set produces deterministic empty intersection result (no crash). - - Internal orchestration now returns a typed `PostCommitIntersectionFlowResult` (`repository_root`, `direct_traces: Vec`, `intersection_patch`, `post_commit_data`) from `run_post_commit_intersection_flow_with()`. Each row keeps its id, session, model, tool, and path base directory until final line resolution; no tool is selected before attribution is resolved. + - Internal orchestration now returns a typed `PostCommitIntersectionFlowResult` (`repository_root`, `direct_traces: Vec`, `intersection_patch`, `post_commit_data`, `attribution_cutoff: Option`) from `run_post_commit_intersection_flow_with()`. Each row keeps its id, session, model, tool, and path base directory until final line resolution; no tool is selected before attribution is resolved. - `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 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: per-row `DirectEvidenceSource`s built from `direct_traces`, the repository root, plus the mutation-AI and resolved non-AI line sets from bounded lineage over every committed line. Lineage takes precedence over direct candidates line by line. See [agent-trace-minimal-generator.md](agent-trace-minimal-generator.md). - - The mutation evidence comes from `mutation_trace::runtime::resolve_post_commit_mutation_attribution(...)`, a read-only entrypoint that resolves the invoking worktree's Git dir and topology-derived worktree ID (`resolve_git_dir` + `resolve_worktree_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 result — trusted 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; 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 mutation evidence comes from `mutation_trace::runtime::resolve_post_commit_mutation_attribution(...)`, a read-only entrypoint that receives the captured commit OID and `attribution_cutoff`, returns an empty result immediately when the cutoff is `None`, resolves the invoking worktree's Git dir and topology-derived worktree ID (`resolve_git_dir` + `resolve_worktree_id`), resolves the commit and first-parent trees from the captured OID (never mutable `HEAD`), and — under the worktree lock — captures a revision ceiling (`latest_mutation_event_revision`). The ceiling is only a snapshot bound for replay; it is read after the commit exists and does not by itself exclude events recorded after commit creation. Temporal eligibility comes from `MutationTraceStore::load_mutation_event_page_before_commit`, which filters `created_at` by the same cutoff in SQL before `ORDER BY revision DESC LIMIT`, so late events never consume the 128-event replay budget. It returns an empty result — trusted direct-only fallback — when identity, mutation history, the committed trees, or the ceiling is unavailable. It creates no identity and performs no mutation-cursor write. The `post_commit_patch_intersections` write stays direct-only; 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). - Top-level `tool` metadata is derived inside the builder from the direct rows that support finally accepted lines; rejected, conflicting, or lineage-overridden rows never supply it, and differing tools omit it. - 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-implementation-contract.md b/context/sce/agent-trace-implementation-contract.md index d87e4d082..12359b776 100644 --- a/context/sce/agent-trace-implementation-contract.md +++ b/context/sce/agent-trace-implementation-contract.md @@ -4,7 +4,7 @@ - This document is retained as historical reference only. - The current CLI runtime does not implement the Agent Trace contract described by the original no-git-wrapper plan. -- Local hooks are currently attribution-only: `commit-msg` may append the canonical SCE co-author trailer when the attribution gate is enabled, while `pre-commit`, `post-commit`, and `post-rewrite` are deterministic no-ops. +- Current local hook behavior (including the active `post-commit` Agent Trace flow) is documented in `agent-trace-hooks-command-routing.md`; `pre-commit` and `post-rewrite` remain deterministic no-ops. ## Historical scope diff --git a/context/sce/agent-trace-minimal-generator.md b/context/sce/agent-trace-minimal-generator.md index a46030e35..240825ece 100644 --- a/context/sce/agent-trace-minimal-generator.md +++ b/context/sce/agent-trace-minimal-generator.md @@ -53,7 +53,7 @@ Outcomes and policy: - `Blob(bytes)` — every added line is validated against the blob and every range gets a hash. - `NotLineAddressable` — the file emits no ranges and no hashes; its touched lines still count in `metadata.sce.line_changes`. - `Missing` — the path is absent from the recorded revision. A file with surviving additions must exist there, so this fails trace generation with `Invalid Agent Trace file '': ... absent from revision ''`; the file is neither silently dropped nor emitted unverified. -- `Unverified` — an explicit caller decision that no committed content is available (for example `UnverifiedCommittedFiles` in tests, or a future non-Git caller). Ranges keep their patch positions and omit `content_hash`, which the schema allows; nothing is fabricated, and such positions are **not** independently verified. +- `Unverified` — test-only (`#[cfg(test)]`, produced by `UnverifiedCommittedFiles`): an explicit decision that no committed content is available. The variant and its builder branch are not compiled into production. Ranges keep their patch positions and omit `content_hash`, which the schema allows; nothing is fabricated, and such positions are **not** independently verified. - `Err(CommittedFileReadError)` — an operational failure, kept distinct from a missing file: `TreeLookupFailed` (`git ls-tree` could not run or exited non-zero), `MalformedTreeEntry` (unparseable, ambiguous, or unsupported mode/type entry), or `BlobReadFailed` (`git cat-file` failed, for example a missing object). Each names the path, revision, and Git diagnostic, and fails trace generation. `GitCommittedFiles` never returns `Unverified`, so the production post-commit hook is strict: a missing path or Git read error aborts the flow before validation and persistence, and no hashless range is ever persisted because of a failed read. Contradictory positions fail closed as before. diff --git a/context/sce/agent-trace-payload-builder-validation.md b/context/sce/agent-trace-payload-builder-validation.md index 219b7cffb..d66dc0459 100644 --- a/context/sce/agent-trace-payload-builder-validation.md +++ b/context/sce/agent-trace-payload-builder-validation.md @@ -3,7 +3,7 @@ ## Current status - This document is retained as historical reference only. -- The current CLI runtime does not expose `cli/src/services/agent_trace.rs` or build Agent Trace payloads during local hook execution. +- The design below is superseded. The current `post-commit` hook builds, validates, and persists Agent Trace payloads through `cli/src/services/agent_trace.rs`; see `agent-trace-hooks-command-routing.md` and `agent-trace-minimal-generator.md`. ## Historical scope