Skip to main content

chronicle/annotate/
mod.rs

1pub mod filter;
2pub mod gather;
3pub mod live;
4pub mod squash;
5pub mod staging;
6
7use crate::error::{chronicle_error, Result};
8use crate::git::GitOps;
9use crate::provider::LlmProvider;
10use crate::schema::{v2, v3};
11use snafu::ResultExt;
12
13/// The main annotation entry point. Gathers context, checks filters,
14/// runs the agent, and writes the annotation as a git note.
15///
16/// Produces v3 annotations (wisdom-first). The agent still returns v2-shaped
17/// data internally, which is converted to v3 before writing.
18pub fn run(
19    git_ops: &dyn GitOps,
20    provider: &dyn LlmProvider,
21    commit: &str,
22) -> Result<v3::Annotation> {
23    // 1. Gather context
24    let context = gather::build_context(git_ops, commit)?;
25
26    // 2. Pre-LLM filter
27    let decision = filter::pre_llm_filter(&context);
28
29    let annotation = match decision {
30        filter::FilterDecision::Skip(reason) => {
31            tracing::info!("Skipping annotation: {}", reason);
32            v3::Annotation {
33                schema: "chronicle/v3".to_string(),
34                commit: context.commit_sha.clone(),
35                timestamp: context.timestamp.clone(),
36                summary: format!("Skipped: {reason}"),
37                wisdom: Vec::new(),
38                provenance: v3::Provenance {
39                    source: v3::ProvenanceSource::Batch,
40                    author: None,
41                    derived_from: Vec::new(),
42                    notes: Some(format!("Skipped: {reason}")),
43                },
44            }
45        }
46        filter::FilterDecision::Trivial(reason) => {
47            tracing::info!("Trivial commit: {}", reason);
48            v3::Annotation {
49                schema: "chronicle/v3".to_string(),
50                commit: context.commit_sha.clone(),
51                timestamp: context.timestamp.clone(),
52                summary: context.commit_message.clone(),
53                wisdom: Vec::new(),
54                provenance: v3::Provenance {
55                    source: v3::ProvenanceSource::Batch,
56                    author: None,
57                    derived_from: Vec::new(),
58                    notes: Some(format!("Trivial: {reason}")),
59                },
60            }
61        }
62        filter::FilterDecision::Annotate => {
63            // Call the agent loop for full LLM annotation
64            let (collected, _summary) = crate::agent::run_agent_loop(provider, git_ops, &context)
65                .context(chronicle_error::AgentSnafu)?;
66
67            // The narrative is required (agent loop guarantees it's Some)
68            let narrative = collected.narrative.unwrap();
69
70            // Convert agent output (v2 shapes) to v3 wisdom entries
71            let mut wisdom = Vec::new();
72
73            // Convert markers to wisdom entries
74            for marker in &collected.markers {
75                let (category, content) = match &marker.kind {
76                    v2::MarkerKind::Contract { description, .. } => {
77                        (v3::WisdomCategory::Gotcha, description.clone())
78                    }
79                    v2::MarkerKind::Hazard { description } => {
80                        (v3::WisdomCategory::Gotcha, description.clone())
81                    }
82                    v2::MarkerKind::Dependency {
83                        target_file,
84                        target_anchor,
85                        assumption,
86                    } => (
87                        v3::WisdomCategory::Insight,
88                        format!("Depends on {target_file}:{target_anchor} \u{2014} {assumption}"),
89                    ),
90                    v2::MarkerKind::Unstable { description, .. } => {
91                        (v3::WisdomCategory::UnfinishedThread, description.clone())
92                    }
93                    v2::MarkerKind::Security { description } => {
94                        (v3::WisdomCategory::Gotcha, description.clone())
95                    }
96                    v2::MarkerKind::Performance { description } => {
97                        (v3::WisdomCategory::Gotcha, description.clone())
98                    }
99                    v2::MarkerKind::Deprecated { description, .. } => {
100                        (v3::WisdomCategory::UnfinishedThread, description.clone())
101                    }
102                    v2::MarkerKind::TechDebt { description } => {
103                        (v3::WisdomCategory::UnfinishedThread, description.clone())
104                    }
105                    v2::MarkerKind::TestCoverage { description } => {
106                        (v3::WisdomCategory::Insight, description.clone())
107                    }
108                };
109                wisdom.push(v3::WisdomEntry {
110                    category,
111                    content,
112                    file: Some(marker.file.clone()),
113                    lines: marker.lines,
114                });
115            }
116
117            // Convert decisions to wisdom entries
118            for decision in &collected.decisions {
119                let file = decision
120                    .scope
121                    .first()
122                    .map(|s| s.split(':').next().unwrap_or(s).to_string());
123                wisdom.push(v3::WisdomEntry {
124                    category: v3::WisdomCategory::Insight,
125                    content: format!("{}: {}", decision.what, decision.why),
126                    file,
127                    lines: None,
128                });
129            }
130
131            // Convert rejected alternatives to dead_end entries
132            for ra in &narrative.rejected_alternatives {
133                let content = if ra.reason.is_empty() {
134                    ra.approach.clone()
135                } else {
136                    format!("{}: {}", ra.approach, ra.reason)
137                };
138                wisdom.push(v3::WisdomEntry {
139                    category: v3::WisdomCategory::DeadEnd,
140                    content,
141                    file: None,
142                    lines: None,
143                });
144            }
145
146            v3::Annotation {
147                schema: "chronicle/v3".to_string(),
148                commit: context.commit_sha.clone(),
149                timestamp: context.timestamp.clone(),
150                summary: narrative.summary,
151                wisdom,
152                provenance: v3::Provenance {
153                    source: v3::ProvenanceSource::Batch,
154                    author: None,
155                    derived_from: Vec::new(),
156                    notes: None,
157                },
158            }
159        }
160    };
161
162    // 3. Serialize and write as git note
163    let json = serde_json::to_string_pretty(&annotation).context(chronicle_error::JsonSnafu)?;
164    git_ops
165        .note_write(commit, &json)
166        .context(chronicle_error::GitSnafu)?;
167
168    Ok(annotation)
169}