//! Structured progress logging for task execution. //! //! This module provides an append-only progress log file system that persists //! learnings, patterns, and context across task iterations. The log is stored //! in the task's worktree directory as `progress.log`. //! //! Format: //! ```markdown //! # progress.log //! # Auto-generated by Makima - DO NOT EDIT MANUALLY //! //! ## [2024-01-15T10:30:00Z] - Task [fc09b908-...]: Implement user authentication //! - Status: done //! - Files changed: src/auth.rs, src/lib.rs //! - **Learnings:** //! - Pattern: Use bcrypt for password hashing //! - Gotcha: Need to handle expired tokens gracefully //! --- //! ``` use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fs::{self, OpenOptions}; use std::io::{self, BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use uuid::Uuid; /// Status of a completed task entry. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProgressEntryStatus { /// Task completed successfully. Done, /// Task failed. Failed, } impl std::fmt::Display for ProgressEntryStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ProgressEntryStatus::Done => write!(f, "done"), ProgressEntryStatus::Failed => write!(f, "failed"), } } } impl std::str::FromStr for ProgressEntryStatus { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "done" | "completed" => Ok(ProgressEntryStatus::Done), "failed" | "error" => Ok(ProgressEntryStatus::Failed), _ => Err(format!("Unknown status: {}", s)), } } } /// A single progress log entry representing a completed task iteration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProgressEntry { /// Timestamp when the entry was created. pub timestamp: DateTime, /// Task ID. pub task_id: Uuid, /// Human-readable task name. pub task_name: String, /// Completion status (done/failed). pub status: ProgressEntryStatus, /// List of files that were changed. pub files_changed: Vec, /// Learnings discovered during task execution. pub learnings: Vec, } impl ProgressEntry { /// Create a new progress entry. pub fn new( task_id: Uuid, task_name: String, status: ProgressEntryStatus, files_changed: Vec, learnings: Vec, ) -> Self { Self { timestamp: Utc::now(), task_id, task_name, status, files_changed, learnings, } } /// Format the entry as human-readable markdown. pub fn to_markdown(&self) -> String { let mut output = String::new(); // Header with timestamp, task ID, and name output.push_str(&format!( "## [{}] - Task [{}]: {}\n", self.timestamp.format("%Y-%m-%dT%H:%M:%SZ"), &self.task_id.to_string()[..8], // Short task ID self.task_name )); // Status output.push_str(&format!("- Status: {}\n", self.status)); // Files changed if self.files_changed.is_empty() { output.push_str("- Files changed: (none)\n"); } else { output.push_str(&format!( "- Files changed: {}\n", self.files_changed.join(", ") )); } // Learnings output.push_str("- **Learnings:**\n"); if self.learnings.is_empty() { output.push_str(" - (none recorded)\n"); } else { for learning in &self.learnings { output.push_str(&format!(" - {}\n", learning)); } } // Separator output.push_str("---\n"); output } /// Parse a progress entry from markdown text. /// /// Returns None if the text doesn't contain a valid progress entry. pub fn from_markdown(text: &str) -> Option { let lines: Vec<&str> = text.lines().collect(); // Parse header: ## [timestamp] - Task [id]: 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 let task_start = header.find("Task [")? + 6; let task_end = header[task_start..].find(']')? + task_start; let task_id_short = &header[task_start..task_end]; // Try to parse as UUID (we stored short version, need to handle that) // For now, we'll generate a placeholder UUID if we can't parse the short version let task_id = Uuid::parse_str(task_id_short) .or_else(|_| { // Try to parse with padding (short IDs are just first 8 chars) Uuid::parse_str(&format!("{}-0000-0000-0000-000000000000", task_id_short)) }) .ok()?; // Extract task name let name_start = header.find("]: ")? + 3; let task_name = header[name_start..].to_string(); // Parse remaining fields let mut status = ProgressEntryStatus::Done; let mut files_changed = Vec::new(); let mut learnings = Vec::new(); let mut in_learnings = false; for line in &lines[1..] { let line = line.trim(); if line.starts_with("- Status:") { let status_str = line.trim_start_matches("- Status:").trim(); status = status_str.parse().unwrap_or(ProgressEntryStatus::Done); in_learnings = false; } else if line.starts_with("- Files changed:") { let files_str = line.trim_start_matches("- Files changed:").trim(); if files_str != "(none)" { files_changed = files_str .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); } in_learnings = false; } else if line.starts_with("- **Learnings:**") { in_learnings = true; } else if in_learnings && line.starts_with("- ") { let learning = line.trim_start_matches("- ").trim(); if learning != "(none recorded)" { learnings.push(learning.to_string()); } } else if line == "---" { break; } } Some(ProgressEntry { timestamp, task_id, task_name, status, files_changed, learnings, }) } } /// Progress log manager for a task's worktree. pub struct ProgressLog { /// Path to the progress.log file. log_path: PathBuf, } /// Default file name for the progress log. pub const PROGRESS_LOG_FILENAME: &str = "progress.log"; /// Default maximum number of entries to inject into prompts. pub const DEFAULT_MAX_ENTRIES_INJECTED: usize = 20; impl ProgressLog { /// Create a new ProgressLog for a worktree directory. pub fn new(worktree_path: &Path) -> Self { Self { log_path: Self::get_log_path(worktree_path), } } /// Get the path to the progress log file for a worktree. pub fn get_log_path(worktree_path: &Path) -> PathBuf { worktree_path.join(PROGRESS_LOG_FILENAME) } /// Get the path to this progress log file. pub fn path(&self) -> &Path { &self.log_path } /// Check if the progress log file exists. pub fn exists(&self) -> bool { self.log_path.exists() } /// Append a progress entry to the log file. /// /// Creates the file with a header if it doesn't exist. pub fn append_entry(&self, entry: &ProgressEntry) -> io::Result<()> { let file_exists = self.log_path.exists(); let mut file = OpenOptions::new() .create(true) .append(true) .open(&self.log_path)?; // Write header if this is a new file if !file_exists { writeln!(file, "# progress.log")?; writeln!(file, "# Auto-generated by Makima - DO NOT EDIT MANUALLY")?; writeln!(file)?; } // Append the entry write!(file, "{}", entry.to_markdown())?; writeln!(file)?; Ok(()) } /// Read all entries from the progress log. pub fn read_all_entries(&self) -> io::Result> { if !self.log_path.exists() { return Ok(Vec::new()); } let file = fs::File::open(&self.log_path)?; let reader = BufReader::new(file); let mut entries = Vec::new(); let mut current_entry = String::new(); for line in reader.lines() { let line = line?; // Start of a new entry if line.starts_with("## [") { // Process previous entry if any if !current_entry.is_empty() { if let Some(entry) = ProgressEntry::from_markdown(¤t_entry) { entries.push(entry); } current_entry.clear(); } } // Skip header lines if line.starts_with("# ") || line.is_empty() { continue; } current_entry.push_str(&line); current_entry.push('\n'); } // Process last entry if !current_entry.is_empty() { if let Some(entry) = ProgressEntry::from_markdown(¤t_entry) { entries.push(entry); } } Ok(entries) } /// Read the most recent entries from the progress log. /// /// Returns up to `max` entries, starting from the most recent. pub fn read_recent_entries(&self, max: usize) -> io::Result> { let mut entries = self.read_all_entries()?; // Return the last `max` entries (most recent) if entries.len() > max { entries = entries.split_off(entries.len() - max); } Ok(entries) } /// Format recent entries for injection into a prompt. /// /// Returns a formatted string containing the recent progress entries /// suitable for including in a Claude prompt. pub fn format_for_prompt(&self, max_entries: usize) -> io::Result { let entries = self.read_recent_entries(max_entries)?; if entries.is_empty() { return Ok(String::new()); } let mut output = String::new(); output.push_str("## Previous Task Progress\n\n"); output.push_str("The following entries show recent task completions and learnings:\n\n"); for entry in &entries { output.push_str(&entry.to_markdown()); output.push('\n'); } Ok(output) } /// Get the total number of entries in the log. pub fn entry_count(&self) -> io::Result { Ok(self.read_all_entries()?.len()) } } /// Helper function to create a progress entry and append it to a log. /// /// This is a convenience function for the common case of appending a single entry. pub fn append_progress_entry( worktree_path: &Path, task_id: Uuid, task_name: String, status: ProgressEntryStatus, files_changed: Vec, learnings: Vec, ) -> io::Result<()> { let log = ProgressLog::new(worktree_path); let entry = ProgressEntry::new(task_id, task_name, status, files_changed, learnings); log.append_entry(&entry) } /// Get the list of files changed in a git worktree. /// /// This runs `git diff --name-only HEAD~1` to get files changed in the last commit, /// falling back to `git diff --name-only` for uncommitted changes if no commits exist. pub async fn get_git_changed_files(worktree_path: &Path) -> Vec { // Try to get files from the last commit first let output = tokio::process::Command::new("git") .args(["diff", "--name-only", "HEAD~1"]) .current_dir(worktree_path) .output() .await; if let Ok(output) = output { if output.status.success() { let files: Vec = String::from_utf8_lossy(&output.stdout) .lines() .filter(|s| !s.is_empty()) .map(String::from) .collect(); if !files.is_empty() { return files; } } } // Fall back to uncommitted changes let output = tokio::process::Command::new("git") .args(["diff", "--name-only", "HEAD"]) .current_dir(worktree_path) .output() .await; if let Ok(output) = output { if output.status.success() { return String::from_utf8_lossy(&output.stdout) .lines() .filter(|s| !s.is_empty()) .map(String::from) .collect(); } } // Try to get all tracked files that have been modified let output = tokio::process::Command::new("git") .args(["status", "--porcelain"]) .current_dir(worktree_path) .output() .await; if let Ok(output) = output { if output.status.success() { return String::from_utf8_lossy(&output.stdout) .lines() .filter(|s| !s.is_empty()) .filter_map(|line| { // Format: "XY filename" where XY is the status if line.len() > 3 { Some(line[3..].to_string()) } else { None } }) .collect(); } } Vec::new() } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::tempdir; #[test] fn test_progress_entry_to_markdown() { let entry = ProgressEntry { timestamp: DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z") .unwrap() .with_timezone(&Utc), task_id: Uuid::parse_str("fc09b908-1234-5678-abcd-ef1234567890").unwrap(), task_name: "Implement user authentication".to_string(), status: ProgressEntryStatus::Done, files_changed: vec!["src/auth.rs".to_string(), "src/lib.rs".to_string()], learnings: vec![ "Pattern: Use bcrypt for password hashing".to_string(), "Gotcha: Need to handle expired tokens gracefully".to_string(), ], }; let markdown = entry.to_markdown(); assert!(markdown.contains("## [2024-01-15T10:30:00Z]")); assert!(markdown.contains("Task [fc09b908]")); assert!(markdown.contains("Implement user authentication")); assert!(markdown.contains("Status: done")); assert!(markdown.contains("src/auth.rs, src/lib.rs")); assert!(markdown.contains("Use bcrypt for password hashing")); assert!(markdown.contains("---")); } #[test] fn test_progress_entry_roundtrip() { let entry = ProgressEntry { timestamp: DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z") .unwrap() .with_timezone(&Utc), task_id: Uuid::parse_str("fc09b908-0000-0000-0000-000000000000").unwrap(), task_name: "Test task".to_string(), status: ProgressEntryStatus::Failed, files_changed: vec!["test.rs".to_string()], learnings: vec!["Learning 1".to_string()], }; let markdown = entry.to_markdown(); let parsed = ProgressEntry::from_markdown(&markdown).unwrap(); assert_eq!(parsed.timestamp, entry.timestamp); assert_eq!(parsed.task_name, entry.task_name); assert_eq!(parsed.status, entry.status); assert_eq!(parsed.files_changed, entry.files_changed); assert_eq!(parsed.learnings, entry.learnings); } #[test] fn test_progress_log_append_and_read() { let dir = tempdir().unwrap(); let log = ProgressLog::new(dir.path()); // Initially no entries assert!(!log.exists()); assert_eq!(log.read_all_entries().unwrap().len(), 0); // Append first entry let entry1 = ProgressEntry::new( Uuid::new_v4(), "Task 1".to_string(), ProgressEntryStatus::Done, vec!["file1.rs".to_string()], vec!["Learning A".to_string()], ); log.append_entry(&entry1).unwrap(); assert!(log.exists()); let entries = log.read_all_entries().unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].task_name, "Task 1"); // Append second entry let entry2 = ProgressEntry::new( Uuid::new_v4(), "Task 2".to_string(), ProgressEntryStatus::Failed, vec!["file2.rs".to_string()], vec!["Learning B".to_string()], ); log.append_entry(&entry2).unwrap(); let entries = log.read_all_entries().unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[1].task_name, "Task 2"); } #[test] fn test_progress_log_read_recent() { let dir = tempdir().unwrap(); let log = ProgressLog::new(dir.path()); // Append 5 entries for i in 1..=5 { let entry = ProgressEntry::new( Uuid::new_v4(), format!("Task {}", i), ProgressEntryStatus::Done, vec![], vec![], ); log.append_entry(&entry).unwrap(); } // Read only last 3 let recent = log.read_recent_entries(3).unwrap(); assert_eq!(recent.len(), 3); assert_eq!(recent[0].task_name, "Task 3"); assert_eq!(recent[1].task_name, "Task 4"); assert_eq!(recent[2].task_name, "Task 5"); } #[test] fn test_progress_log_format_for_prompt() { let dir = tempdir().unwrap(); let log = ProgressLog::new(dir.path()); let entry = ProgressEntry::new( Uuid::new_v4(), "Important task".to_string(), ProgressEntryStatus::Done, vec!["src/main.rs".to_string()], vec!["Key insight".to_string()], ); log.append_entry(&entry).unwrap(); let prompt = log.format_for_prompt(10).unwrap(); assert!(prompt.contains("## Previous Task Progress")); assert!(prompt.contains("Important task")); assert!(prompt.contains("Key insight")); } #[test] fn test_progress_entry_status_display() { assert_eq!(format!("{}", ProgressEntryStatus::Done), "done"); assert_eq!(format!("{}", ProgressEntryStatus::Failed), "failed"); } #[test] fn test_progress_entry_status_parse() { assert_eq!( "done".parse::().unwrap(), ProgressEntryStatus::Done ); assert_eq!( "completed".parse::().unwrap(), ProgressEntryStatus::Done ); assert_eq!( "failed".parse::().unwrap(), ProgressEntryStatus::Failed ); assert_eq!( "error".parse::().unwrap(), ProgressEntryStatus::Failed ); assert!("unknown".parse::().is_err()); } #[test] fn test_empty_files_and_learnings() { let entry = ProgressEntry::new( Uuid::new_v4(), "Empty task".to_string(), ProgressEntryStatus::Done, vec![], vec![], ); let markdown = entry.to_markdown(); assert!(markdown.contains("Files changed: (none)")); assert!(markdown.contains("(none recorded)")); } #[test] fn test_progress_log_file_header() { let dir = tempdir().unwrap(); let log = ProgressLog::new(dir.path()); let entry = ProgressEntry::new( Uuid::new_v4(), "Test".to_string(), ProgressEntryStatus::Done, vec![], vec![], ); log.append_entry(&entry).unwrap(); let content = fs::read_to_string(log.path()).unwrap(); assert!(content.starts_with("# progress.log\n")); assert!(content.contains("Auto-generated by Makima")); } #[test] fn test_append_progress_entry_helper() { let dir = tempdir().unwrap(); append_progress_entry( dir.path(), Uuid::new_v4(), "Helper test".to_string(), ProgressEntryStatus::Done, vec!["file.rs".to_string()], vec!["A learning".to_string()], ) .unwrap(); let log = ProgressLog::new(dir.path()); let entries = log.read_all_entries().unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].task_name, "Helper test"); } }