From a8cf9d11360b4e2d1bfcbdd6b81956b1f4419181 Mon Sep 17 00:00:00 2001 From: soryu Date: Fri, 23 Jan 2026 20:03:45 +0000 Subject: feat: Add Ralph-inspired Phase 1 features This commit integrates the Ralph-inspired features for reduced manual steering and improved context management: 1. Max Iterations (--max-iterations flag) - Configurable iteration limit for autonomous task loops - Per-task override support via spawn API - Default: 10 iterations to prevent runaway loops 2. Structured Progress Logging (progress.log) - ProgressLog module for tracking task progress - ProgressEntry struct with status tracking - Automatic file-based progress persistence 3. Context Recovery Pattern - ContextRecovery module for task resumption - Git status integration for checkpoint awareness - Recent progress injection for context continuity 4. Commit Discipline - CommitValidator for structured commit messages - Conventional commit format enforcement - Co-Authored-By trailer automation - Optional test/lint quality checks Phase 1 of Ralph Features Implementation Co-Authored-By: Claude --- makima/src/daemon/task/progress_log.rs | 671 +++++++++++++++++++++++++++++++++ 1 file changed, 671 insertions(+) create mode 100644 makima/src/daemon/task/progress_log.rs (limited to 'makima/src/daemon/task/progress_log.rs') diff --git a/makima/src/daemon/task/progress_log.rs b/makima/src/daemon/task/progress_log.rs new file mode 100644 index 0000000..394a055 --- /dev/null +++ b/makima/src/daemon/task/progress_log.rs @@ -0,0 +1,671 @@ +//! 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"); + } +} -- cgit v1.2.3