summaryrefslogtreecommitdiff
path: root/makima/src/daemon/task/progress_log.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-23 20:03:45 +0000
committersoryu <soryu@soryu.co>2026-01-23 20:05:34 +0000
commita8cf9d11360b4e2d1bfcbdd6b81956b1f4419181 (patch)
tree3d994b1d9afd181bfe6095c1a12c6765d348a56c /makima/src/daemon/task/progress_log.rs
parent12cb721dbbe571bd3b2766546b2105ef034e6cf3 (diff)
downloadsoryu-makima/ralph-features-phase1.tar.gz
soryu-makima/ralph-features-phase1.zip
feat: Add Ralph-inspired Phase 1 featuresmakima/ralph-features-phase1
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 <noreply@anthropic.com>
Diffstat (limited to 'makima/src/daemon/task/progress_log.rs')
-rw-r--r--makima/src/daemon/task/progress_log.rs671
1 files changed, 671 insertions, 0 deletions
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<Self, Self::Err> {
+ 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<Utc>,
+ /// 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<String>,
+ /// Learnings discovered during task execution.
+ pub learnings: Vec<String>,
+}
+
+impl ProgressEntry {
+ /// Create a new progress entry.
+ pub fn new(
+ task_id: Uuid,
+ task_name: String,
+ status: ProgressEntryStatus,
+ files_changed: Vec<String>,
+ learnings: Vec<String>,
+ ) -> 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<Self> {
+ 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<Vec<ProgressEntry>> {
+ 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(&current_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(&current_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<Vec<ProgressEntry>> {
+ 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<String> {
+ 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<usize> {
+ 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<String>,
+ learnings: Vec<String>,
+) -> 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<String> {
+ // 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> = 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::<ProgressEntryStatus>().unwrap(),
+ ProgressEntryStatus::Done
+ );
+ assert_eq!(
+ "completed".parse::<ProgressEntryStatus>().unwrap(),
+ ProgressEntryStatus::Done
+ );
+ assert_eq!(
+ "failed".parse::<ProgressEntryStatus>().unwrap(),
+ ProgressEntryStatus::Failed
+ );
+ assert_eq!(
+ "error".parse::<ProgressEntryStatus>().unwrap(),
+ ProgressEntryStatus::Failed
+ );
+ assert!("unknown".parse::<ProgressEntryStatus>().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");
+ }
+}