1use anyhow::{Context, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6const TASKS_DIR: &str = ".vtcode/tasks";
7const CURRENT_TASK_FILE: &str = "current_task.md";
8const CURRENT_SPEC_FILE: &str = "current_spec.md";
9const CURRENT_CONTRACT_FILE: &str = "current_contract.md";
10const CURRENT_EVALUATION_FILE: &str = "current_evaluation.md";
11const CURRENT_SPRINT_CONTRACT_FILE: &str = "current_sprint_contract.md";
12const CURRENT_OUTCOME_VERIFICATION_FILE: &str = "current_outcome_verification.md";
13const CURRENT_FEATURE_LIST_FILE: &str = "current_feature_list.md";
14const SUMMARY_PREVIEW_CHARS: usize = 280;
15
16pub fn current_task_path(workspace_root: &Path) -> PathBuf {
18 workspace_root.join(TASKS_DIR).join(CURRENT_TASK_FILE)
19}
20
21pub fn current_context_reset_path(workspace_root: &Path) -> PathBuf {
23 workspace_root
24 .join(TASKS_DIR)
25 .join(crate::core::agent::context_reset::CONTEXT_RESET_FILE)
26}
27
28pub fn current_spec_path(workspace_root: &Path) -> PathBuf {
30 workspace_root.join(TASKS_DIR).join(CURRENT_SPEC_FILE)
31}
32
33pub fn current_contract_path(workspace_root: &Path) -> PathBuf {
35 workspace_root.join(TASKS_DIR).join(CURRENT_CONTRACT_FILE)
36}
37
38pub fn current_evaluation_path(workspace_root: &Path) -> PathBuf {
40 workspace_root.join(TASKS_DIR).join(CURRENT_EVALUATION_FILE)
41}
42
43pub fn current_sprint_contract_path(workspace_root: &Path) -> PathBuf {
50 workspace_root.join(TASKS_DIR).join(CURRENT_SPRINT_CONTRACT_FILE)
51}
52
53pub fn current_outcome_verification_path(workspace_root: &Path) -> PathBuf {
60 workspace_root.join(TASKS_DIR).join(CURRENT_OUTCOME_VERIFICATION_FILE)
61}
62
63pub fn existing_harness_artifact_paths(workspace_root: &Path) -> Vec<PathBuf> {
65 [
66 current_spec_path(workspace_root),
67 current_contract_path(workspace_root),
68 current_evaluation_path(workspace_root),
69 current_sprint_contract_path(workspace_root),
70 current_outcome_verification_path(workspace_root),
71 current_feature_list_path(workspace_root),
72 ]
73 .into_iter()
74 .filter(|path| path.exists())
75 .collect()
76}
77
78pub fn read_spec_summary(workspace_root: &Path) -> Option<String> {
80 read_markdown_summary(¤t_spec_path(workspace_root), "Spec")
81}
82
83pub fn read_spec_summary_fresh(workspace_root: &Path, not_before: Option<SystemTime>) -> Option<String> {
85 read_markdown_summary_fresh(¤t_spec_path(workspace_root), "Spec", not_before)
86}
87
88pub fn read_contract_summary(workspace_root: &Path) -> Option<String> {
90 read_markdown_summary(¤t_contract_path(workspace_root), "Contract")
91}
92
93pub fn read_evaluation_summary(workspace_root: &Path) -> Option<String> {
95 read_markdown_summary(¤t_evaluation_path(workspace_root), "Evaluation")
96}
97
98pub fn read_evaluation_summary_fresh(workspace_root: &Path, not_before: Option<SystemTime>) -> Option<String> {
100 read_markdown_summary_fresh(¤t_evaluation_path(workspace_root), "Evaluation", not_before)
101}
102
103pub fn read_contract_summary_fresh(workspace_root: &Path, not_before: Option<SystemTime>) -> Option<String> {
105 read_markdown_summary_fresh(¤t_contract_path(workspace_root), "Contract", not_before)
106}
107
108pub fn read_feature_list_summary_fresh(workspace_root: &Path, not_before: Option<SystemTime>) -> Option<String> {
110 read_markdown_summary_fresh(¤t_feature_list_path(workspace_root), "FeatureList", not_before)
111}
112
113pub fn read_sprint_contract_summary_fresh(workspace_root: &Path, not_before: Option<SystemTime>) -> Option<String> {
115 read_markdown_summary_fresh(¤t_sprint_contract_path(workspace_root), "SprintContract", not_before)
116}
117
118pub fn read_outcome_verification_summary_fresh(
120 workspace_root: &Path,
121 not_before: Option<SystemTime>,
122) -> Option<String> {
123 read_markdown_summary_fresh(¤t_outcome_verification_path(workspace_root), "OutcomeVerification", not_before)
124}
125
126pub fn session_artifact_cutoff(workspace_root: &Path, session_id: &str) -> Option<SystemTime> {
130 let candidates = [
133 vtcode_memory::session_directory(workspace_root, session_id),
134 workspace_root.join(".vtcode").join("sessions").join(session_id),
135 ];
136 for dir in candidates {
137 let Ok(metadata) = fs::metadata(&dir) else {
138 continue;
139 };
140 if let Some(cutoff) = artifact_cutoff_from_metadata(&metadata) {
141 return Some(cutoff);
142 }
143 }
144 None
145}
146
147fn artifact_cutoff_from_metadata(metadata: &fs::Metadata) -> Option<SystemTime> {
156 metadata.created().ok().or_else(|| metadata.modified().ok())
157}
158
159pub fn archive_completed_current_task(workspace_root: &Path, session_id: &str) -> Result<Option<PathBuf>> {
167 let task_path = current_task_path(workspace_root);
168 let Ok(content) = fs::read_to_string(&task_path) else {
169 return Ok(None);
170 };
171 let checklist: Vec<&str> = content
172 .lines()
173 .map(str::trim_start)
174 .filter(|line| line.starts_with("- ["))
175 .collect();
176 let is_checked = |line: &str| line.starts_with("- [x]") || line.starts_with("- [X]");
177 if checklist.is_empty() || !checklist.iter().all(|line| is_checked(line)) {
178 return Ok(None);
179 }
180 let archive_dir = workspace_root.join(TASKS_DIR).join("archive");
181 fs::create_dir_all(&archive_dir).with_context(|| format!("create task archive dir {}", archive_dir.display()))?;
182 let archive_name = format!(
183 "current_task-{}-{}-{}.md",
184 filename_safe_id(session_id, 64),
185 chrono::Utc::now().format("%Y%m%dT%H%M%SZ"),
186 uuid::Uuid::new_v4()
187 );
188 let archive_path = archive_dir.join(archive_name);
189 fs::rename(&task_path, &archive_path)
190 .with_context(|| format!("archive completed task tracker to {}", archive_path.display()))?;
191 Ok(Some(archive_path))
192}
193
194fn filename_safe_id(id: &str, max_chars: usize) -> String {
197 id.chars()
198 .map(|c| {
199 if c.is_alphanumeric() || c == '_' || c == '-' {
200 c
201 } else {
202 '_'
203 }
204 })
205 .take(max_chars)
206 .collect()
207}
208
209pub async fn write_spec(workspace_root: &Path, content: &str) -> Result<PathBuf> {
211 let path = current_spec_path(workspace_root);
212 write_artifact(path.as_path(), content, "current spec").await?;
213 Ok(path)
214}
215
216pub async fn write_evaluation(workspace_root: &Path, content: &str) -> Result<PathBuf> {
218 let path = current_evaluation_path(workspace_root);
219 write_artifact(path.as_path(), content, "current evaluation").await?;
220 Ok(path)
221}
222
223pub async fn write_contract(workspace_root: &Path, content: &str) -> Result<PathBuf> {
225 let path = current_contract_path(workspace_root);
226 write_artifact(path.as_path(), content, "current contract").await?;
227 Ok(path)
228}
229
230pub fn read_sprint_contract_summary(workspace_root: &Path) -> Option<String> {
232 read_markdown_summary(¤t_sprint_contract_path(workspace_root), "SprintContract")
233}
234
235pub async fn write_sprint_contract(workspace_root: &Path, content: &str) -> Result<PathBuf> {
240 let path = current_sprint_contract_path(workspace_root);
241 write_artifact(path.as_path(), content, "sprint contract").await?;
242 Ok(path)
243}
244
245pub fn read_outcome_verification_summary(workspace_root: &Path) -> Option<String> {
247 read_markdown_summary(¤t_outcome_verification_path(workspace_root), "OutcomeVerification")
248}
249
250pub fn current_feature_list_path(workspace_root: &Path) -> PathBuf {
259 workspace_root.join(TASKS_DIR).join(CURRENT_FEATURE_LIST_FILE)
260}
261
262pub fn read_feature_list_summary(workspace_root: &Path) -> Option<String> {
264 read_markdown_summary(¤t_feature_list_path(workspace_root), "FeatureList")
265}
266
267pub async fn write_feature_list(workspace_root: &Path, content: &str) -> Result<PathBuf> {
269 let path = current_feature_list_path(workspace_root);
270 write_artifact(path.as_path(), content, "feature list").await?;
271 Ok(path)
272}
273
274pub async fn write_outcome_verification(workspace_root: &Path, content: &str) -> Result<PathBuf> {
279 let path = current_outcome_verification_path(workspace_root);
280 write_artifact(path.as_path(), content, "outcome verification").await?;
281 Ok(path)
282}
283
284async fn write_artifact(path: &Path, content: &str, label: &str) -> Result<()> {
285 if let Some(parent) = path.parent() {
286 tokio::fs::create_dir_all(parent)
287 .await
288 .with_context(|| format!("create {} directory {}", label, parent.display()))?;
289 }
290
291 tokio::fs::write(path, content)
292 .await
293 .with_context(|| format!("write {} {}", label, path.display()))?;
294 Ok(())
295}
296
297fn read_markdown_summary(path: &Path, label: &str) -> Option<String> {
298 let content = fs::read_to_string(path).ok()?;
299 let lines = content
300 .lines()
301 .map(str::trim)
302 .filter(|line| !line.is_empty())
303 .filter(|line| !line.starts_with('#'))
304 .take(4)
305 .collect::<Vec<_>>();
306 if lines.is_empty() {
307 return None;
308 }
309
310 let joined = lines.join(" | ");
311 Some(format!("{label}: {}", truncate_summary(&joined)))
312}
313
314const ARTIFACT_FRESHNESS_GRACE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
321
322pub fn artifact_is_stale(path: &Path, not_before: Option<SystemTime>) -> bool {
329 let Some(not_before) = not_before else {
330 return false;
331 };
332 let Some(modified) = fs::metadata(path).ok().and_then(|meta| meta.modified().ok()) else {
333 return true;
334 };
335 let stale_before = not_before
336 .checked_sub(ARTIFACT_FRESHNESS_GRACE)
337 .unwrap_or(SystemTime::UNIX_EPOCH);
338 modified < stale_before
339}
340
341fn read_markdown_summary_fresh(path: &Path, label: &str, not_before: Option<SystemTime>) -> Option<String> {
348 if artifact_is_stale(path, not_before) {
349 return None;
350 }
351 read_markdown_summary(path, label)
352}
353
354fn truncate_summary(text: &str) -> String {
355 vtcode_commons::formatting::truncate_within(text, SUMMARY_PREVIEW_CHARS, "...")
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use tempfile::tempdir;
362
363 #[tokio::test]
364 async fn writes_and_summarizes_spec_and_evaluation_artifacts() {
365 let temp = tempdir().expect("tempdir");
366
367 write_spec(temp.path(), "# Spec\n\nBuild a stronger exec harness.\n\nKeep it resumable.\n")
368 .await
369 .expect("write spec");
370 write_contract(temp.path(), "# Contract\n\n- Deliver the requested change.\n- Verify with cargo check.\n")
371 .await
372 .expect("write contract");
373 write_evaluation(temp.path(), "# Evaluation\n\nVerdict: fail\n\nNeed another revision round.\n")
374 .await
375 .expect("write evaluation");
376
377 let paths = existing_harness_artifact_paths(temp.path());
378 assert_eq!(paths.len(), 3);
379 assert_eq!(
380 read_spec_summary(temp.path()),
381 Some("Spec: Build a stronger exec harness. | Keep it resumable.".to_string())
382 );
383 assert_eq!(
384 read_contract_summary(temp.path()),
385 Some("Contract: - Deliver the requested change. | - Verify with cargo check.".to_string())
386 );
387 assert_eq!(
388 read_evaluation_summary(temp.path()),
389 Some("Evaluation: Verdict: fail | Need another revision round.".to_string())
390 );
391 }
392
393 #[tokio::test]
394 async fn writes_and_summarizes_sprint_contract() {
395 let temp = tempdir().expect("tempdir");
396
397 write_sprint_contract(
398 temp.path(),
399 "# Sprint Contract\n\nScope: implement login endpoint.\nAcceptance: POST /login returns JWT.\n",
400 )
401 .await
402 .expect("write sprint contract");
403
404 let paths = existing_harness_artifact_paths(temp.path());
405 assert_eq!(paths.len(), 1);
406 assert_eq!(
407 read_sprint_contract_summary(temp.path()),
408 Some("SprintContract: Scope: implement login endpoint. | Acceptance: POST /login returns JWT.".to_string())
409 );
410 }
411
412 #[tokio::test]
413 async fn writes_and_summarizes_outcome_verification() {
414 let temp = tempdir().expect("tempdir");
415
416 write_outcome_verification(
417 temp.path(),
418 "# Outcome Verification\n\nCommand: cargo nextest run\nResult: 12 passed, 0 failed\nBuild: cargo check PASSED\n",
419 )
420 .await
421 .expect("write outcome verification");
422
423 let paths = existing_harness_artifact_paths(temp.path());
424 assert_eq!(paths.len(), 1);
425 assert_eq!(
426 read_outcome_verification_summary(temp.path()),
427 Some(
428 "OutcomeVerification: Command: cargo nextest run | Result: 12 passed, 0 failed | Build: cargo check PASSED"
429 .to_string()
430 )
431 );
432 }
433
434 #[tokio::test]
435 async fn writes_and_summarizes_feature_list() {
436 let temp = tempdir().expect("tempdir");
437
438 write_feature_list(
439 temp.path(),
440 "# Feature List\n\n- [ ] Auth: login endpoint returns JWT\n- [x] API: health check endpoint\n",
441 )
442 .await
443 .expect("write feature list");
444
445 let paths = existing_harness_artifact_paths(temp.path());
446 assert_eq!(paths.len(), 1);
447 assert_eq!(
448 read_feature_list_summary(temp.path()),
449 Some("FeatureList: - [ ] Auth: login endpoint returns JWT | - [x] API: health check endpoint".to_string())
450 );
451 }
452
453 #[tokio::test]
454 async fn all_artifacts_counted_in_existing_paths() {
455 let temp = tempdir().expect("tempdir");
456
457 write_spec(temp.path(), "# Spec\ncontent\n").await.unwrap();
458 write_contract(temp.path(), "# Contract\ncontent\n").await.unwrap();
459 write_evaluation(temp.path(), "# Evaluation\ncontent\n").await.unwrap();
460 write_sprint_contract(temp.path(), "# Sprint\ncontent\n").await.unwrap();
461 write_outcome_verification(temp.path(), "# Outcome\ncontent\n").await.unwrap();
462 write_feature_list(temp.path(), "# Features\ncontent\n").await.unwrap();
463
464 let paths = existing_harness_artifact_paths(temp.path());
465 assert_eq!(paths.len(), 6);
466 }
467
468 #[test]
469 fn stale_spec_summary_is_dropped_for_later_sessions() {
470 let temp = tempdir().expect("tempdir");
471 let spec_path = current_spec_path(temp.path());
472 fs::create_dir_all(spec_path.parent().expect("parent")).expect("tasks dir");
473 fs::write(&spec_path, "# Execution Spec\nExplore the codebase and summarize.\n").expect("write spec");
474 let old = SystemTime::now() - std::time::Duration::from_secs(48 * 60 * 60);
476 let file = fs::File::options().write(true).open(&spec_path).expect("open");
477 file.set_modified(old).expect("set mtime");
478
479 assert!(read_spec_summary(temp.path()).is_some(), "unfiltered read still sees the file");
480 assert!(
481 read_spec_summary_fresh(temp.path(), Some(SystemTime::now())).is_none(),
482 "a leftover fixture must not describe a later session"
483 );
484 assert!(
485 read_spec_summary_fresh(temp.path(), Some(old + std::time::Duration::from_secs(10))).is_some(),
486 "fresh reads still accept artifacts written during the session"
487 );
488 }
489
490 #[test]
491 fn handoff_artifact_written_just_before_session_start_stays_live() {
492 let temp = tempdir().expect("tempdir");
493 let spec_path = current_spec_path(temp.path());
494 fs::create_dir_all(spec_path.parent().expect("parent")).expect("tasks dir");
495 fs::write(&spec_path, "# Spec\n\nShip the residual hygiene fix.\n").expect("write spec");
496
497 let session_start = SystemTime::now();
499 assert!(
500 read_spec_summary_fresh(temp.path(), Some(session_start)).is_some(),
501 "a just-written handoff artifact must survive the freshness cutoff"
502 );
503 }
504
505 #[test]
506 fn archive_completed_current_task_moves_only_fully_checked_trackers() {
507 let temp = tempdir().expect("tempdir");
508 let task_path = current_task_path(temp.path());
509 fs::create_dir_all(task_path.parent().expect("parent")).expect("tasks dir");
510
511 fs::write(&task_path, "# Work\n\n- [ ] open item\n- [x] done item\n").expect("write partial");
512 assert!(
513 archive_completed_current_task(temp.path(), "session-a")
514 .expect("archive")
515 .is_none(),
516 "incomplete checklists stay live"
517 );
518 assert!(task_path.exists());
519
520 fs::write(&task_path, "# Work\n\n- [x] done one\n- [x] done two\n").expect("write complete");
521 let archived = archive_completed_current_task(temp.path(), "session-a")
522 .expect("archive")
523 .expect("fully-checked tracker is archived");
524 assert!(!task_path.exists(), "live path must be clear for the next plan");
525 assert!(
526 archived
527 .file_name()
528 .and_then(|n| n.to_str())
529 .is_some_and(|name| { name.starts_with("current_task-session-a-") && name.ends_with(".md") })
530 );
531 assert!(archived.exists());
532 }
533
534 #[test]
535 fn archive_completed_current_task_never_overwrites_a_prior_archive() {
536 let temp = tempdir().expect("tempdir");
540 let task_path = current_task_path(temp.path());
541 fs::create_dir_all(task_path.parent().expect("parent")).expect("tasks dir");
542
543 fs::write(&task_path, "# Work\n\n- [x] first completion\n").expect("write complete");
544 let first = archive_completed_current_task(temp.path(), "session-a")
545 .expect("archive")
546 .expect("first archive");
547 fs::write(&task_path, "# Work\n\n- [x] second completion\n").expect("write complete");
548 let second = archive_completed_current_task(temp.path(), "session-a")
549 .expect("archive")
550 .expect("second archive");
551
552 assert_ne!(first, second, "each completion gets its own archive file");
553 assert!(first.exists(), "first archive must survive the second completion");
554 assert!(second.exists());
555 assert_eq!(fs::read_to_string(&first).expect("first content"), "# Work\n\n- [x] first completion\n");
556 }
557
558 #[test]
559 fn session_artifact_cutoff_is_some_for_existing_dir_and_none_for_missing() {
560 let temp = tempdir().expect("tempdir");
561 assert!(
562 session_artifact_cutoff(temp.path(), "sess-missing").is_none(),
563 "missing session dir keeps the unfiltered-read fallback"
564 );
565
566 let session_dir = temp.path().join(".vtcode").join("sessions").join("sess-live");
567 fs::create_dir_all(&session_dir).expect("session dir");
568 let cutoff = session_artifact_cutoff(temp.path(), "sess-live").expect("cutoff");
569 assert!(
570 cutoff <= SystemTime::now(),
571 "an existing session dir must yield a real cutoff so the leftover filter stays enabled"
572 );
573 }
574}