1use crate::git_store::CommitInfo;
2use crate::llm::trace_insights::Llm;
3use crate::permissions::{check_permission, PermissionResult};
4use crate::store::Store;
5use crate::trace::context::synthesize_context_content;
6use crate::trace::running_summary::{self, SummaryEvent};
7use crate::trace::{
8 agent_trace_md,
9 logs::{append_agent_log, summarize_change_no_llm, LogSynthEntry},
10};
11use crate::types::{Action, Actor, DocType};
12use chrono::Utc;
13use std::path::{Path, PathBuf};
14use thiserror::Error;
15
16#[derive(Debug, Error)]
17pub enum WriteDocumentError {
18 #[error("Permission denied: {path} — {reason}")]
19 PermissionDenied { path: PathBuf, reason: String },
20 #[error(transparent)]
21 Other(#[from] anyhow::Error),
22}
23
24fn detected_by_from_source(source: &str) -> &'static str {
25 match source {
26 "mcp_write" | "mcp" => "mcp",
27 "cli_write" | "cli" => "cli",
28 "poll" => "poll",
29 _ => "system",
30 }
31}
32
33fn source_from_prefix(summary_prefix: &str) -> &'static str {
34 if summary_prefix.starts_with("mcp") {
35 "mcp_write"
36 } else if summary_prefix.starts_with("agent") {
37 "cli_write"
38 } else {
39 "system"
40 }
41}
42
43pub fn write_document(
44 root: &Path,
45 file: &Path,
46 content: &str,
47 actor: &Actor,
48 summary_prefix: &str,
49 session_id: Option<&str>,
50) -> std::result::Result<PathBuf, WriteDocumentError> {
51 let rel = if file.is_absolute() {
52 file.strip_prefix(root).unwrap_or(file).to_path_buf()
53 } else {
54 file.to_path_buf()
55 };
56
57 let mut store = Store::open(root).map_err(WriteDocumentError::Other)?;
58
59 let (doc_type, was_tracked) = match store.manifest.find_by_path(&rel) {
60 Some(entry) => (entry.doc_type.clone(), true),
61 None => (DocType::Scratch, false),
62 };
63
64 match check_permission(&doc_type, actor, &store.overrides, Some(&rel)) {
65 PermissionResult::Denied { reason } => {
66 return Err(WriteDocumentError::PermissionDenied { path: rel, reason });
67 }
68 PermissionResult::Allowed | PermissionResult::RequiresConfirmation { .. } => {}
69 }
70
71 let full_path = root.join(&rel);
72 if let Some(parent) = full_path.parent() {
73 std::fs::create_dir_all(parent).map_err(|e| WriteDocumentError::Other(e.into()))?;
74 }
75
76 let action = if full_path.exists() {
77 Action::Modify
78 } else {
79 Action::Create
80 };
81 std::fs::write(&full_path, content).map_err(|e| WriteDocumentError::Other(e.into()))?;
82
83 let files_to_commit = vec![(rel.clone(), action, doc_type)];
84
85 if !was_tracked {
86 store
87 .manifest
88 .register(&rel, DocType::Scratch, actor.agent_name().unwrap_or(""))
89 .map_err(WriteDocumentError::Other)?;
90 store
91 .manifest
92 .save(root)
93 .map_err(WriteDocumentError::Other)?;
94 }
95
96 let info = CommitInfo {
97 action: files_to_commit[0].1.clone(),
98 files: files_to_commit,
99 actor: actor.clone(),
100 summary: format!("{}: {}", summary_prefix, rel.display()),
101 agent_name: actor.agent_name().map(String::from),
102 session_id: session_id.map(String::from),
103 };
104 store.commit(&info).map_err(WriteDocumentError::Other)?;
105
106 let source = source_from_prefix(summary_prefix);
107 apply_trace_hooks(
108 root,
109 &store.git,
110 &store.manifest,
111 actor,
112 session_id,
113 &info.files,
114 source,
115 )
116 .map_err(WriteDocumentError::Other)?;
117
118 Ok(rel)
119}
120
121pub fn apply_trace_hooks(
122 store_root: &Path,
123 git: &crate::git_store::GitStore,
124 manifest: &crate::manifest::Manifest,
125 actor: &Actor,
126 session_id: Option<&str>,
127 changed_files: &[(PathBuf, Action, DocType)],
128 source: &str,
129) -> anyhow::Result<()> {
130 if changed_files.is_empty() {
131 return Ok(());
132 }
133
134 let trace_insights = Llm::from_store_root(store_root)?;
138
139 if actor.is_agent() {
140 if let (Some(agent_name), Some(sid)) = (actor.agent_name(), session_id) {
141 let entries: Vec<LogSynthEntry> = changed_files
142 .iter()
143 .map(|(path, _, doc_type)| {
144 let stats = git.diff_stats(path, None, None).unwrap_or_default();
145 let summary = {
146 let diff = format!(
147 "+{} lines\n-{} lines\n",
148 stats.lines_added, stats.lines_removed
149 );
150 match trace_insights.summarize_change(path, doc_type, &diff) {
151 Ok(s) => s,
152 Err(e) => {
153 tracing::warn!(
154 "LLM summarize_change failed for {}, using template: {}",
155 path.display(),
156 e
157 );
158 summarize_change_no_llm(path, doc_type, &stats, agent_name)
159 }
160 }
161 };
162 Ok(LogSynthEntry {
163 timestamp: Utc::now(),
164 path: path.clone(),
165 summary,
166 })
167 })
168 .collect::<anyhow::Result<Vec<_>>>()?;
169 append_agent_log(store_root, git, agent_name, sid, &entries)?;
170 }
171 }
172
173 for (path, action, doc_type) in changed_files {
174 let stats = git.diff_stats(path, None, None).unwrap_or_default();
175 let event_summary = {
176 let diff = format!(
177 "+{} lines\n-{} lines\n",
178 stats.lines_added, stats.lines_removed
179 );
180 match trace_insights.summarize_change(path, doc_type, &diff) {
181 Ok(s) => s,
182 Err(e) => {
183 if trace_insights.is_degraded() {
184 summarize_change_no_llm(
185 path,
186 doc_type,
187 &stats,
188 actor.agent_name().unwrap_or("system"),
189 )
190 } else {
191 tracing::warn!(
192 "LLM summarize_change failed for event {}, using template: {}",
193 path.display(),
194 e
195 );
196 summarize_change_no_llm(
197 path,
198 doc_type,
199 &stats,
200 actor.agent_name().unwrap_or("system"),
201 )
202 }
203 }
204 }
205 };
206 let event = SummaryEvent {
207 timestamp: Utc::now().to_rfc3339(),
208 session_id: session_id.map(String::from),
209 agent_name: actor.agent_name().map(String::from),
210 actor: actor.to_string(),
211 action: action.to_string(),
212 change_kind: action.to_string(),
213 path: path.display().to_string(),
214 doc_type: doc_type.to_string(),
215 summary: event_summary,
216 source: source.to_string(),
217 detected_by: detected_by_from_source(source).to_string(),
218 lines_added: stats.lines_added,
219 lines_removed: stats.lines_removed,
220 };
221 running_summary::append_event(store_root, event)?;
222 }
223
224 if let Err(e) = running_summary::refresh_template(store_root, git, manifest) {
225 tracing::warn!("running summary template refresh failed: {e}");
226 }
227 running_summary::schedule_synthesis_refresh(store_root.to_path_buf());
228
229 sync_agent_trace_md(store_root, git, manifest)?;
230
231 let changed_paths: Vec<PathBuf> = changed_files
234 .iter()
235 .map(|(p, _, _)| p.clone())
236 .filter(|p| crate::git_store::should_track_activity(p))
237 .collect();
238 if !changed_paths.is_empty() {
239 sync_context_md(store_root, git, manifest, &trace_insights, &changed_paths)?;
240 }
241
242 Ok(())
243}
244
245fn sync_agent_trace_md(
246 store_root: &Path,
247 git: &crate::git_store::GitStore,
248 manifest: &crate::manifest::Manifest,
249) -> anyhow::Result<()> {
250 agent_trace_md::sync(store_root, manifest, git)
251}
252
253fn sync_context_md(
254 store_root: &Path,
255 git: &crate::git_store::GitStore,
256 manifest: &crate::manifest::Manifest,
257 trace_insights: &Llm,
258 changed_paths: &[PathBuf],
259) -> anyhow::Result<()> {
260 let (new_content, commit_label) =
261 synthesize_context_content(store_root, manifest, trace_insights, changed_paths)?;
262 let target = store_root.join("context.md");
263 let existing = std::fs::read_to_string(&target).unwrap_or_default();
264 if existing == new_content {
265 return Ok(());
266 }
267
268 crate::trace::context::write_context(store_root, &new_content)?;
269 let info = CommitInfo {
270 action: Action::Modify,
271 files: vec![(
272 PathBuf::from("context.md"),
273 Action::Modify,
274 DocType::Context,
275 )],
276 actor: Actor::System,
277 summary: format!("refresh synthesized context ({commit_label})"),
278 agent_name: None,
279 session_id: None,
280 };
281 git.commit(&info)?;
282 Ok(())
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::config::StoreInfo;
289 use crate::git_store::GitStore;
290 use crate::manifest::Manifest;
291 use tempfile::TempDir;
292
293 fn setup(tmp: &TempDir) -> (PathBuf, Manifest, GitStore) {
294 let root = tmp.path().to_path_buf();
295 std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
296 let git = GitStore::init(&root).unwrap();
297 let info = StoreInfo::new("test".into());
298 let manifest = Manifest::create_empty(info.clone(), &root).unwrap();
299 let store_cfg = crate::config::StoreConfig {
300 store: info,
301 llm: None,
302 synthesis: Some(crate::config::SynthesisConfig::for_unit_tests_degraded()),
303 polling: crate::config::PollingConfig::default(),
304 };
305 store_cfg.save(&root).unwrap();
306 (root, manifest, git)
307 }
308
309 #[test]
310 fn scratch_write_triggers_context_refresh() {
311 let tmp = TempDir::new().unwrap();
312 let (root, mut manifest, git) = setup(&tmp);
313 let scratch_path = PathBuf::from("notes.md");
314 std::fs::write(
315 root.join(&scratch_path),
316 "scratch body: reconnect watermark test",
317 )
318 .unwrap();
319 manifest
320 .register(&scratch_path, DocType::Scratch, "")
321 .unwrap();
322 manifest.save(&root).unwrap();
323
324 let changed = vec![(scratch_path, Action::Modify, DocType::Scratch)];
325 apply_trace_hooks(
326 &root,
327 &git,
328 &manifest,
329 &Actor::User,
330 None,
331 &changed,
332 "cli_write",
333 )
334 .unwrap();
335
336 let ctx = std::fs::read_to_string(root.join("context.md")).expect("context.md created");
337 assert!(ctx.contains("reconnect watermark test"));
338 assert!(ctx.contains("[scratch] notes.md:"));
339 }
340
341 #[test]
342 fn build_trace_documents_includes_unmanifested_changed_paths() {
343 let tmp = TempDir::new().unwrap();
344 let (root, mut manifest, _git) = setup(&tmp);
345
346 std::fs::write(root.join("plan.md"), "# Plan\n- [ ] step one\n").unwrap();
348 manifest
349 .register(&PathBuf::from("plan.md"), DocType::Plan, "")
350 .unwrap();
351
352 std::fs::write(root.join("worker.py"), "print('worker activity')\n").unwrap();
354
355 let docs = crate::trace::context::build_trace_documents(
356 &root,
357 &manifest,
358 &[PathBuf::from("worker.py")],
359 );
360
361 assert!(
362 docs.iter().any(|d| d.path == "plan.md"),
363 "should include manifest plan document"
364 );
365 let worker = docs
366 .iter()
367 .find(|d| d.path == "worker.py")
368 .expect("should include unmanifested changed path");
369 assert_eq!(worker.doc_type, DocType::Scratch);
370 assert!(worker.content_snippet.contains("worker activity"));
371
372 assert!(!manifest.is_tracked(&PathBuf::from("worker.py")));
374 }
375
376 #[test]
377 fn build_trace_documents_does_not_duplicate_managed_paths() {
378 let tmp = TempDir::new().unwrap();
379 let (root, mut manifest, _git) = setup(&tmp);
380 std::fs::write(root.join("notes.md"), "scratch note\n").unwrap();
381 manifest
382 .register(&PathBuf::from("notes.md"), DocType::Scratch, "")
383 .unwrap();
384
385 let docs = crate::trace::context::build_trace_documents(
386 &root,
387 &manifest,
388 &[PathBuf::from("notes.md")],
389 );
390 let count = docs.iter().filter(|d| d.path == "notes.md").count();
391 assert_eq!(count, 1, "managed + changed path must not be duplicated");
392 }
393}