diff options
| author | soryu <soryu@soryu.co> | 2026-01-24 12:54:18 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-24 12:54:18 +0000 |
| commit | ba3906c05d8979236600385656dd454c1aa34352 (patch) | |
| tree | b0433a3bb491c0723c5482677803b0f3f534dbcf /makima/src/daemon/task | |
| parent | 579c983d3efb8f1414ffb45b9e031f741cce5f76 (diff) | |
| download | soryu-ba3906c05d8979236600385656dd454c1aa34352.tar.gz soryu-ba3906c05d8979236600385656dd454c1aa34352.zip | |
feat: Add progress logging module for cross-task learning
Implement structured progress logging as specified in ralph-features-spec.md
Section 1.1. This module provides:
- ProgressLogEntry struct capturing timestamp, task ID/name, status,
files changed, and learnings (patterns, gotchas, tips)
- append_entry() to write entries to progress.log in worktree
- read_recent_entries() to retrieve last N entries for context injection
- Markdown format for human readability and easy parsing
This is the foundation for Ralph-inspired cross-task learning that
persists context across task iterations.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/src/daemon/task')
| -rw-r--r-- | makima/src/daemon/task/mod.rs | 2 | ||||
| -rw-r--r-- | makima/src/daemon/task/progress_log.rs | 478 |
2 files changed, 480 insertions, 0 deletions
diff --git a/makima/src/daemon/task/mod.rs b/makima/src/daemon/task/mod.rs index 3830e1d..d69b055 100644 --- a/makima/src/daemon/task/mod.rs +++ b/makima/src/daemon/task/mod.rs @@ -2,8 +2,10 @@ pub mod completion_gate; pub mod manager; +pub mod progress_log; pub mod state; pub use completion_gate::CompletionGate; pub use manager::{ManagedTask, TaskConfig, TaskManager}; +pub use progress_log::{Learning, ProgressLogEntry, TaskCompletionStatus}; pub use state::TaskState; diff --git a/makima/src/daemon/task/progress_log.rs b/makima/src/daemon/task/progress_log.rs new file mode 100644 index 0000000..77ec8c1 --- /dev/null +++ b/makima/src/daemon/task/progress_log.rs @@ -0,0 +1,478 @@ +//! 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<String>) -> Self { + Self { + kind: "Pattern".to_string(), + description: description.into(), + } + } + + /// Create a new gotcha learning. + pub fn gotcha(description: impl Into<String>) -> Self { + Self { + kind: "Gotcha".to_string(), + description: description.into(), + } + } + + /// Create a new tip learning. + pub fn tip(description: impl Into<String>) -> 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<Utc>, + /// 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<String>, + /// Learnings captured during the task. + pub learnings: Vec<Learning>, +} + +impl ProgressLogEntry { + /// Create a new progress log entry. + pub fn new( + task_id: impl Into<String>, + task_name: impl Into<String>, + 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<String>) -> Self { + self.files_changed = files; + self + } + + /// Add learnings. + pub fn with_learnings(mut self, learnings: Vec<Learning>) -> 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<Self> { + 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<Vec<ProgressLogEntry>> { + 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()); + } +} |
