summaryrefslogtreecommitdiff
path: root/makima/src/daemon/task
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-24 12:54:18 +0000
committersoryu <soryu@soryu.co>2026-01-24 12:54:18 +0000
commitba3906c05d8979236600385656dd454c1aa34352 (patch)
treeb0433a3bb491c0723c5482677803b0f3f534dbcf /makima/src/daemon/task
parent579c983d3efb8f1414ffb45b9e031f741cce5f76 (diff)
downloadsoryu-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.rs2
-rw-r--r--makima/src/daemon/task/progress_log.rs478
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());
+ }
+}