//! Progress logging for cross-task learning. //! //! This module implements an append-only progress log that persists learnings, //! patterns, and context across task iterations. Inspired by Ralph's dual-file //! learning system. //! //! The progress log captures: //! - Task completion events with status //! - Files changed during the task //! - Learnings discovered (patterns, gotchas) //! //! Format (in progress.log): //! ``` //! ## [2026-01-24T12:45:00Z] - Task abc123: Implement feature X //! - Status: done //! - Files changed: //! - src/foo.rs //! - src/bar.rs //! - **Learnings:** //! - Pattern: Use XYZ pattern for this type of change //! - Gotcha: Don't forget to update the config //! --- //! ``` use std::fs::{File, OpenOptions}; use std::io::{BufRead, BufReader, Write}; use std::path::Path; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; /// Progress log filename. pub const PROGRESS_LOG_FILENAME: &str = "progress.log"; /// Status of a completed task. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TaskCompletionStatus { /// Task completed successfully. Done, /// Task failed with an error. Failed, } impl TaskCompletionStatus { /// Convert to string representation. pub fn as_str(&self) -> &'static str { match self { TaskCompletionStatus::Done => "done", TaskCompletionStatus::Failed => "failed", } } } impl std::fmt::Display for TaskCompletionStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.as_str()) } } /// A learning captured during task execution. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Learning { /// Type of learning (e.g., "Pattern", "Gotcha", "Tip"). pub kind: String, /// Description of the learning. pub description: String, } impl Learning { /// Create a new pattern learning. pub fn pattern(description: impl Into) -> Self { Self { kind: "Pattern".to_string(), description: description.into(), } } /// Create a new gotcha learning. pub fn gotcha(description: impl Into) -> Self { Self { kind: "Gotcha".to_string(), description: description.into(), } } /// Create a new tip learning. pub fn tip(description: impl Into) -> Self { Self { kind: "Tip".to_string(), description: description.into(), } } } /// An entry in the progress log. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProgressLogEntry { /// Timestamp when the entry was created. pub timestamp: DateTime, /// Task identifier. pub task_id: String, /// Task name/description. pub task_name: String, /// Completion status. pub status: TaskCompletionStatus, /// List of files changed during the task. pub files_changed: Vec, /// Learnings captured during the task. pub learnings: Vec, } impl ProgressLogEntry { /// Create a new progress log entry. pub fn new( task_id: impl Into, task_name: impl Into, status: TaskCompletionStatus, ) -> Self { Self { timestamp: Utc::now(), task_id: task_id.into(), task_name: task_name.into(), status, files_changed: Vec::new(), learnings: Vec::new(), } } /// Add files that were changed. pub fn with_files_changed(mut self, files: Vec) -> Self { self.files_changed = files; self } /// Add learnings. pub fn with_learnings(mut self, learnings: Vec) -> Self { self.learnings = learnings; self } /// Add a single learning. pub fn add_learning(&mut self, learning: Learning) { self.learnings.push(learning); } /// Serialize the entry to markdown format. pub fn to_markdown(&self) -> String { let mut md = String::new(); // Header with timestamp, task ID, and name md.push_str(&format!( "## [{}] - Task {}: {}\n", self.timestamp.format("%Y-%m-%dT%H:%M:%SZ"), self.task_id, self.task_name )); // Status md.push_str(&format!("- Status: {}\n", self.status)); // Files changed md.push_str("- Files changed:\n"); if self.files_changed.is_empty() { md.push_str(" - (none)\n"); } else { for file in &self.files_changed { md.push_str(&format!(" - {}\n", file)); } } // Learnings md.push_str("- **Learnings:**\n"); if self.learnings.is_empty() { md.push_str(" - (none)\n"); } else { for learning in &self.learnings { md.push_str(&format!(" - {}: {}\n", learning.kind, learning.description)); } } // Separator md.push_str("---\n"); md } /// Parse a progress log entry from markdown text. /// /// Returns None if the text does not contain a valid entry. pub fn from_markdown(text: &str) -> Option { let lines: Vec<&str> = text.lines().collect(); if lines.is_empty() { return None; } // Parse header line: ## [timestamp] - Task task_id: task_name let header = lines.first()?; if !header.starts_with("## [") { return None; } // Extract timestamp let timestamp_end = header.find(']')?; let timestamp_str = &header[4..timestamp_end]; let timestamp = DateTime::parse_from_rfc3339(timestamp_str) .ok()? .with_timezone(&Utc); // Extract task ID and name let after_task = header.find(" - Task ")? + 8; let remaining = &header[after_task..]; let colon_pos = remaining.find(": ")?; let task_id = remaining[..colon_pos].to_string(); let task_name = remaining[colon_pos + 2..].to_string(); // Parse status let mut status = TaskCompletionStatus::Done; let mut files_changed = Vec::new(); let mut learnings = Vec::new(); let mut in_files_section = false; let mut in_learnings_section = false; for line in &lines[1..] { let trimmed = line.trim(); if trimmed.starts_with("- Status:") { let status_str = trimmed.trim_start_matches("- Status:").trim(); status = if status_str.eq_ignore_ascii_case("done") { TaskCompletionStatus::Done } else { TaskCompletionStatus::Failed }; in_files_section = false; in_learnings_section = false; } else if trimmed == "- Files changed:" { in_files_section = true; in_learnings_section = false; } else if trimmed == "- **Learnings:**" { in_files_section = false; in_learnings_section = true; } else if trimmed.starts_with("- ") && in_files_section { let file = trimmed.trim_start_matches("- ").trim(); if file != "(none)" { files_changed.push(file.to_string()); } } else if trimmed.starts_with("- ") && in_learnings_section { let learning_text = trimmed.trim_start_matches("- ").trim(); if learning_text != "(none)" { // Parse "Kind: description" format if let Some(colon_pos) = learning_text.find(": ") { let kind = learning_text[..colon_pos].to_string(); let description = learning_text[colon_pos + 2..].to_string(); learnings.push(Learning { kind, description }); } } } else if trimmed == "---" { break; } } Some(Self { timestamp, task_id, task_name, status, files_changed, learnings, }) } } /// Append a progress log entry to the progress.log file in the given worktree. /// /// Creates the file if it doesn't exist. pub fn append_entry(worktree_path: &Path, entry: &ProgressLogEntry) -> std::io::Result<()> { let log_path = worktree_path.join(PROGRESS_LOG_FILENAME); let mut file = OpenOptions::new() .create(true) .append(true) .open(&log_path)?; // Add a newline before the entry if the file is not empty let metadata = file.metadata()?; if metadata.len() > 0 { writeln!(file)?; } write!(file, "{}", entry.to_markdown())?; file.flush()?; tracing::debug!(path = %log_path.display(), task_id = %entry.task_id, "Appended progress log entry"); Ok(()) } /// Read the most recent N entries from the progress.log file. /// /// Returns entries in chronological order (oldest first). /// If the file doesn't exist or has fewer than `limit` entries, returns all available entries. pub fn read_recent_entries(worktree_path: &Path, limit: usize) -> std::io::Result> { let log_path = worktree_path.join(PROGRESS_LOG_FILENAME); if !log_path.exists() { return Ok(Vec::new()); } let file = File::open(&log_path)?; let reader = BufReader::new(file); let mut entries = Vec::new(); let mut current_entry_lines = Vec::new(); for line in reader.lines() { let line = line?; // Check if this is the start of a new entry if line.starts_with("## [") && !current_entry_lines.is_empty() { // Parse the previous entry let entry_text = current_entry_lines.join("\n"); if let Some(entry) = ProgressLogEntry::from_markdown(&entry_text) { entries.push(entry); } current_entry_lines.clear(); } current_entry_lines.push(line); } // Parse the last entry if any if !current_entry_lines.is_empty() { let entry_text = current_entry_lines.join("\n"); if let Some(entry) = ProgressLogEntry::from_markdown(&entry_text) { entries.push(entry); } } // Return the last `limit` entries let start = entries.len().saturating_sub(limit); Ok(entries[start..].to_vec()) } /// Get the path to the progress log file for a worktree. pub fn get_progress_log_path(worktree_path: &Path) -> std::path::PathBuf { worktree_path.join(PROGRESS_LOG_FILENAME) } #[cfg(test)] mod tests { use super::*; #[test] fn test_entry_creation() { let entry = ProgressLogEntry::new("abc123", "Implement feature X", TaskCompletionStatus::Done) .with_files_changed(vec!["src/foo.rs".to_string(), "src/bar.rs".to_string()]) .with_learnings(vec![ Learning::pattern("Use XYZ pattern for this type of change"), Learning::gotcha("Don't forget to update the config"), ]); assert_eq!(entry.task_id, "abc123"); assert_eq!(entry.task_name, "Implement feature X"); assert_eq!(entry.status, TaskCompletionStatus::Done); assert_eq!(entry.files_changed.len(), 2); assert_eq!(entry.learnings.len(), 2); } #[test] fn test_entry_to_markdown() { let mut entry = ProgressLogEntry::new("abc123", "Implement feature X", TaskCompletionStatus::Done); entry.files_changed = vec!["src/foo.rs".to_string(), "src/bar.rs".to_string()]; entry.learnings = vec![ Learning::pattern("Use XYZ pattern"), Learning::gotcha("Watch out for edge cases"), ]; let md = entry.to_markdown(); assert!(md.contains("## [")); assert!(md.contains("] - Task abc123: Implement feature X")); assert!(md.contains("- Status: done")); assert!(md.contains("- Files changed:")); assert!(md.contains(" - src/foo.rs")); assert!(md.contains(" - src/bar.rs")); assert!(md.contains("- **Learnings:**")); assert!(md.contains(" - Pattern: Use XYZ pattern")); assert!(md.contains(" - Gotcha: Watch out for edge cases")); assert!(md.contains("---")); } #[test] fn test_entry_roundtrip() { let original = ProgressLogEntry::new("task-001", "Test task", TaskCompletionStatus::Failed) .with_files_changed(vec!["file1.rs".to_string()]) .with_learnings(vec![Learning::tip("Remember to test")]); let md = original.to_markdown(); let parsed = ProgressLogEntry::from_markdown(&md).unwrap(); assert_eq!(parsed.task_id, original.task_id); assert_eq!(parsed.task_name, original.task_name); assert_eq!(parsed.status, original.status); assert_eq!(parsed.files_changed, original.files_changed); assert_eq!(parsed.learnings.len(), original.learnings.len()); assert_eq!(parsed.learnings[0].kind, original.learnings[0].kind); assert_eq!(parsed.learnings[0].description, original.learnings[0].description); } #[test] fn test_empty_entry() { let entry = ProgressLogEntry::new("empty-task", "Empty task", TaskCompletionStatus::Done); let md = entry.to_markdown(); assert!(md.contains("- Files changed:")); assert!(md.contains(" - (none)")); assert!(md.contains("- **Learnings:**")); assert!(md.contains(" - (none)")); } #[test] fn test_status_display() { assert_eq!(TaskCompletionStatus::Done.as_str(), "done"); assert_eq!(TaskCompletionStatus::Failed.as_str(), "failed"); assert_eq!(format!("{}", TaskCompletionStatus::Done), "done"); } #[test] fn test_learning_constructors() { let pattern = Learning::pattern("test pattern"); assert_eq!(pattern.kind, "Pattern"); assert_eq!(pattern.description, "test pattern"); let gotcha = Learning::gotcha("test gotcha"); assert_eq!(gotcha.kind, "Gotcha"); let tip = Learning::tip("test tip"); assert_eq!(tip.kind, "Tip"); } #[test] fn test_append_and_read_entries() { let temp_dir = std::env::temp_dir().join(format!("progress_log_test_{}", std::process::id())); std::fs::create_dir_all(&temp_dir).unwrap(); // Append first entry let entry1 = ProgressLogEntry::new("task-1", "First task", TaskCompletionStatus::Done) .with_files_changed(vec!["file1.rs".to_string()]); append_entry(&temp_dir, &entry1).unwrap(); // Append second entry let entry2 = ProgressLogEntry::new("task-2", "Second task", TaskCompletionStatus::Failed) .with_learnings(vec![Learning::gotcha("Something went wrong")]); append_entry(&temp_dir, &entry2).unwrap(); // Read all entries let entries = read_recent_entries(&temp_dir, 10).unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[0].task_id, "task-1"); assert_eq!(entries[1].task_id, "task-2"); // Read only last entry let entries = read_recent_entries(&temp_dir, 1).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].task_id, "task-2"); // Cleanup std::fs::remove_dir_all(&temp_dir).ok(); } #[test] fn test_read_nonexistent_file() { let temp_dir = std::env::temp_dir().join("nonexistent_progress_log_test"); let entries = read_recent_entries(&temp_dir, 10).unwrap(); assert!(entries.is_empty()); } }