diff options
| author | soryu <soryu@soryu.co> | 2026-01-23 20:03:45 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-23 20:05:34 +0000 |
| commit | a8cf9d11360b4e2d1bfcbdd6b81956b1f4419181 (patch) | |
| tree | 3d994b1d9afd181bfe6095c1a12c6765d348a56c | |
| parent | 12cb721dbbe571bd3b2766546b2105ef034e6cf3 (diff) | |
| download | soryu-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>
| -rw-r--r-- | makima/src/bin/makima.rs | 3 | ||||
| -rw-r--r-- | makima/src/daemon/config.rs | 103 | ||||
| -rw-r--r-- | makima/src/daemon/task/commit_validator.rs | 752 | ||||
| -rw-r--r-- | makima/src/daemon/task/context_recovery.rs | 554 | ||||
| -rw-r--r-- | makima/src/daemon/task/manager.rs | 2 | ||||
| -rw-r--r-- | makima/src/daemon/task/mod.rs | 6 | ||||
| -rw-r--r-- | makima/src/daemon/task/progress_log.rs | 671 |
7 files changed, 2091 insertions, 0 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index 67eefc6..2d19c0e 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -82,6 +82,7 @@ async fn run_daemon( max_tasks: args.max_tasks, log_level: args.log_level, bubblewrap: args.bubblewrap, + max_iterations: args.max_iterations, }; // Load configuration with CLI overrides @@ -190,6 +191,7 @@ async fn run_daemon( api_url, api_key: config.server.api_key.clone(), heartbeat_commit_interval_secs: config.process.heartbeat_commit_interval_secs, + max_iterations: config.process.max_iterations, }; // Create task manager @@ -276,6 +278,7 @@ async fn run_supervisor( contract_id: args.common.contract_id, parent_task_id: args.parent, checkpoint_sha: args.checkpoint, + max_iterations: None, // Use daemon default }; let result = client.supervisor_spawn(req).await?; println!("{}", serde_json::to_string(&result.0)?); diff --git a/makima/src/daemon/config.rs b/makima/src/daemon/config.rs index 79c9341..7ed1b74 100644 --- a/makima/src/daemon/config.rs +++ b/makima/src/daemon/config.rs @@ -37,6 +37,43 @@ fn default_true() -> bool { true } +/// Context recovery configuration for task resumption. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ContextRecoveryConfig { + /// Enable context recovery header injection (default: true). + /// This is an opinionated feature that's always on by default. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Include git status in context recovery (default: true). + #[serde(default = "default_true", alias = "includegitstatus")] + pub include_git_status: bool, + + /// Include recent progress entries from progress.log (default: true). + #[serde(default = "default_true", alias = "includerecentprogress")] + pub include_recent_progress: bool, + + /// Maximum number of progress entries to include (default: 5). + #[serde(default = "default_max_progress_entries", alias = "maxprogressentries")] + pub max_progress_entries: usize, +} + +fn default_max_progress_entries() -> usize { + 5 +} + +impl Default for ContextRecoveryConfig { + fn default() -> Self { + Self { + enabled: true, + include_git_status: true, + include_recent_progress: true, + max_progress_entries: default_max_progress_entries(), + } + } +} + /// Root daemon configuration. #[derive(Debug, Clone, Deserialize)] pub struct DaemonConfig { @@ -63,6 +100,10 @@ pub struct DaemonConfig { /// Repositories to auto-clone on startup. #[serde(default)] pub repos: ReposConfig, + + /// Context recovery settings for task resumption. + #[serde(default, alias = "context_recovery")] + pub context_recovery: ContextRecoveryConfig, } /// Server connection configuration. @@ -171,6 +212,61 @@ impl Default for WorktreeConfig { } } +/// Commit discipline configuration for enforcing structured commits. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct CommitDisciplineConfig { + /// Enable commit discipline (always enabled by default - this is opinionated). + #[serde(default = "default_true")] + pub enabled: bool, + + /// Require tests to pass before commits (optional, controlled by --require-tests flag). + #[serde(default)] + pub require_tests: bool, + + /// Require lint to pass before commits (optional). + #[serde(default)] + pub require_lint: bool, + + /// Commit message format: "conventional" or "simple". + #[serde(default = "default_message_format")] + pub message_format: String, + + /// Custom test command (auto-detected if not set). + #[serde(default)] + pub test_command: Option<String>, + + /// Custom lint command (auto-detected if not set). + #[serde(default)] + pub lint_command: Option<String>, + + /// Timeout for quality checks in seconds. + #[serde(default = "default_check_timeout")] + pub check_timeout_secs: u64, +} + +fn default_message_format() -> String { + "conventional".to_string() +} + +fn default_check_timeout() -> u64 { + 300 // 5 minutes +} + +impl Default for CommitDisciplineConfig { + fn default() -> Self { + Self { + enabled: true, + require_tests: false, + require_lint: false, + message_format: default_message_format(), + test_command: None, + lint_command: None, + check_timeout_secs: default_check_timeout(), + } + } +} + /// Process configuration for Claude Code subprocess execution. #[derive(Debug, Clone, Deserialize)] #[serde(default)] @@ -223,6 +319,10 @@ pub struct ProcessConfig { /// Set to 0 for unlimited (not recommended). Default: 10. #[serde(default = "default_max_iterations", alias = "maxiterations")] pub max_iterations: u32, + + /// Commit discipline configuration. + #[serde(default)] + pub commit_discipline: CommitDisciplineConfig, } fn default_claude_command() -> String { @@ -255,6 +355,7 @@ impl Default for ProcessConfig { bubblewrap: BubblewrapConfig::default(), heartbeat_commit_interval_secs: default_heartbeat_commit_interval(), max_iterations: default_max_iterations(), + commit_discipline: CommitDisciplineConfig::default(), } } } @@ -581,12 +682,14 @@ impl DaemonConfig { bubblewrap: BubblewrapConfig::default(), heartbeat_commit_interval_secs: 300, max_iterations: 10, + commit_discipline: CommitDisciplineConfig::default(), }, local_db: LocalDbConfig { path: PathBuf::from("/tmp/makima-daemon-test/state.db"), }, logging: LoggingConfig::default(), repos: ReposConfig::default(), + context_recovery: ContextRecoveryConfig::default(), } } } diff --git a/makima/src/daemon/task/commit_validator.rs b/makima/src/daemon/task/commit_validator.rs new file mode 100644 index 0000000..4d4dcf5 --- /dev/null +++ b/makima/src/daemon/task/commit_validator.rs @@ -0,0 +1,752 @@ +//! Commit discipline and validation for task commits. +//! +//! This module enforces structured commit messages and optional quality checks +//! before checkpoint commits are created. It follows the conventional commit +//! format and always appends a Co-Authored-By trailer. + +use serde::{Deserialize, Serialize}; +use std::path::Path; +use thiserror::Error; +use tokio::process::Command; +use uuid::Uuid; + +/// Errors that can occur during commit validation. +#[derive(Debug, Error)] +pub enum CommitValidationError { + #[error("Invalid commit message format: {0}")] + InvalidFormat(String), + + #[error("Missing required field: {0}")] + MissingField(String), + + #[error("Quality check failed: {0}")] + QualityCheckFailed(String), + + #[error("Lint check failed: {0}")] + LintFailed(String), + + #[error("Tests failed: {0}")] + TestsFailed(String), + + #[error("Command execution failed: {0}")] + CommandFailed(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Commit message format style. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum MessageFormat { + /// Conventional commit format: feat/fix/chore: [Task ID] - [Summary] + #[default] + Conventional, + /// Simple format: [Task ID] - [Summary] + Simple, +} + +impl MessageFormat { + pub fn as_str(&self) -> &'static str { + match self { + MessageFormat::Conventional => "conventional", + MessageFormat::Simple => "simple", + } + } +} + +impl std::str::FromStr for MessageFormat { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "conventional" => Ok(MessageFormat::Conventional), + "simple" => Ok(MessageFormat::Simple), + _ => Err(format!("Unknown message format: {}. Use 'conventional' or 'simple'", s)), + } + } +} + +/// Commit type for conventional commits. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CommitType { + #[default] + Feat, + Fix, + Chore, + Docs, + Style, + Refactor, + Perf, + Test, + Build, + Ci, +} + +impl CommitType { + pub fn as_str(&self) -> &'static str { + match self { + CommitType::Feat => "feat", + CommitType::Fix => "fix", + CommitType::Chore => "chore", + CommitType::Docs => "docs", + CommitType::Style => "style", + CommitType::Refactor => "refactor", + CommitType::Perf => "perf", + CommitType::Test => "test", + CommitType::Build => "build", + CommitType::Ci => "ci", + } + } +} + +impl std::str::FromStr for CommitType { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "feat" | "feature" => Ok(CommitType::Feat), + "fix" => Ok(CommitType::Fix), + "chore" => Ok(CommitType::Chore), + "docs" | "doc" => Ok(CommitType::Docs), + "style" => Ok(CommitType::Style), + "refactor" => Ok(CommitType::Refactor), + "perf" | "performance" => Ok(CommitType::Perf), + "test" | "tests" => Ok(CommitType::Test), + "build" => Ok(CommitType::Build), + "ci" => Ok(CommitType::Ci), + _ => Err(format!("Unknown commit type: {}. Use feat/fix/chore/docs/style/refactor/perf/test/build/ci", s)), + } + } +} + +/// Result of quality check execution. +#[derive(Debug, Clone)] +pub struct QualityCheckResult { + /// Whether the check passed. + pub passed: bool, + /// Name of the check. + pub check_name: String, + /// Output from the check command. + pub output: String, + /// Exit code from the check command. + pub exit_code: Option<i32>, + /// Duration of the check in milliseconds. + pub duration_ms: u64, +} + +/// Configuration for commit discipline. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct CommitDisciplineConfig { + /// Enable commit discipline (always enabled by default - this is opinionated). + pub enabled: bool, + + /// Require tests to pass before commits (optional, controlled by --require-tests flag). + pub require_tests: bool, + + /// Require lint to pass before commits (optional). + pub require_lint: bool, + + /// Commit message format: "conventional" or "simple". + pub message_format: MessageFormat, + + /// Custom test command (auto-detected if not set). + pub test_command: Option<String>, + + /// Custom lint command (auto-detected if not set). + pub lint_command: Option<String>, + + /// Timeout for quality checks in seconds. + #[serde(default = "default_check_timeout")] + pub check_timeout_secs: u64, +} + +fn default_check_timeout() -> u64 { + 300 // 5 minutes +} + +impl Default for CommitDisciplineConfig { + fn default() -> Self { + Self { + enabled: true, + require_tests: false, + require_lint: false, + message_format: MessageFormat::Conventional, + test_command: None, + lint_command: None, + check_timeout_secs: default_check_timeout(), + } + } +} + +/// Co-Authored-By trailer for commits. +const CO_AUTHOR_TRAILER: &str = "Co-Authored-By: Claude <noreply@anthropic.com>"; + +/// Validator for commit messages and quality checks. +pub struct CommitValidator { + config: CommitDisciplineConfig, +} + +impl CommitValidator { + /// Create a new commit validator with the given configuration. + pub fn new(config: CommitDisciplineConfig) -> Self { + Self { config } + } + + /// Create a new commit validator with default configuration. + pub fn with_defaults() -> Self { + Self::new(CommitDisciplineConfig::default()) + } + + /// Get the current configuration. + pub fn config(&self) -> &CommitDisciplineConfig { + &self.config + } + + /// Validate a commit message against the configured format. + /// + /// Returns Ok(()) if valid, or an error describing the validation failure. + pub fn validate_message(&self, message: &str) -> Result<(), CommitValidationError> { + if message.trim().is_empty() { + return Err(CommitValidationError::MissingField("commit message".to_string())); + } + + // Get first line (subject) + let subject = message.lines().next().unwrap_or(""); + + if subject.is_empty() { + return Err(CommitValidationError::MissingField("commit subject".to_string())); + } + + // Check subject length (recommended max 72 chars) + if subject.len() > 100 { + tracing::warn!( + "Commit subject exceeds recommended length (100 chars): {} chars", + subject.len() + ); + } + + // For conventional format, validate the prefix + if self.config.message_format == MessageFormat::Conventional { + let valid_prefixes = [ + "feat:", "fix:", "chore:", "docs:", "style:", + "refactor:", "perf:", "test:", "build:", "ci:", + // Also allow with scope: feat(scope): + "feat(", "fix(", "chore(", "docs(", "style(", + "refactor(", "perf(", "test(", "build(", "ci(", + ]; + + let has_valid_prefix = valid_prefixes.iter().any(|prefix| { + subject.starts_with(prefix) + }); + + if !has_valid_prefix { + return Err(CommitValidationError::InvalidFormat( + format!( + "Commit message must start with a conventional commit type (feat/fix/chore/docs/style/refactor/perf/test/build/ci). Got: {}", + subject.chars().take(30).collect::<String>() + ) + )); + } + } + + Ok(()) + } + + /// Format a commit message according to the configured format. + /// + /// Always appends the Co-Authored-By trailer. + pub fn format_message( + &self, + task_id: Uuid, + summary: &str, + body: Option<&str>, + commit_type: Option<CommitType>, + ) -> String { + let short_id = &task_id.to_string()[..8]; + let commit_type = commit_type.unwrap_or_default(); + + // Build subject line based on format + let subject = match self.config.message_format { + MessageFormat::Conventional => { + format!("{}: [{}] {}", commit_type.as_str(), short_id, summary.trim()) + } + MessageFormat::Simple => { + format!("[{}] {}", short_id, summary.trim()) + } + }; + + // Build full message with optional body and trailer + let mut message = subject; + + if let Some(body_text) = body { + if !body_text.trim().is_empty() { + message.push_str("\n\n"); + message.push_str(body_text.trim()); + } + } + + // Always append Co-Authored-By trailer + message.push_str("\n\n"); + message.push_str(CO_AUTHOR_TRAILER); + + message + } + + /// Format a heartbeat/WIP commit message. + pub fn format_heartbeat_message(&self, task_id: Uuid, iteration: Option<u32>) -> String { + let short_id = &task_id.to_string()[..8]; + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); + + let summary = match iteration { + Some(n) => format!("WIP checkpoint (iteration {}) - {}", n, timestamp), + None => format!("WIP checkpoint - {}", timestamp), + }; + + match self.config.message_format { + MessageFormat::Conventional => { + format!( + "chore: [{}] {}\n\n{}", + short_id, + summary, + CO_AUTHOR_TRAILER + ) + } + MessageFormat::Simple => { + format!( + "[{}] {}\n\n{}", + short_id, + summary, + CO_AUTHOR_TRAILER + ) + } + } + } + + /// Format a checkpoint commit message with optional progress info. + pub fn format_checkpoint_message( + &self, + task_id: Uuid, + user_message: &str, + files_changed: Option<&[String]>, + ) -> String { + let short_id = &task_id.to_string()[..8]; + + // Use user message as summary, or generate one + let summary = if user_message.trim().is_empty() { + "Checkpoint commit".to_string() + } else { + user_message.trim().to_string() + }; + + // Build body with file list if provided + let body = files_changed.map(|files| { + if files.is_empty() { + String::new() + } else { + let file_list = files.iter() + .take(20) // Limit to 20 files + .map(|f| format!("- {}", f)) + .collect::<Vec<_>>() + .join("\n"); + + if files.len() > 20 { + format!("Files changed:\n{}\n... and {} more", file_list, files.len() - 20) + } else { + format!("Files changed:\n{}", file_list) + } + } + }); + + self.format_message( + task_id, + &summary, + body.as_deref(), + Some(CommitType::Chore), + ) + } + + /// Run quality checks before committing. + /// + /// Returns Ok(results) with all check results, or Err if any required check fails. + pub async fn run_quality_checks( + &self, + worktree_path: &Path, + ) -> Result<Vec<QualityCheckResult>, CommitValidationError> { + let mut results = Vec::new(); + + // Run lint check if configured + if self.config.require_lint { + let lint_result = self.run_lint_check(worktree_path).await?; + let passed = lint_result.passed; + results.push(lint_result); + + if !passed { + return Err(CommitValidationError::LintFailed( + results.last().map(|r| r.output.clone()).unwrap_or_default() + )); + } + } + + // Run tests if configured + if self.config.require_tests { + let test_result = self.run_test_check(worktree_path).await?; + let passed = test_result.passed; + results.push(test_result); + + if !passed { + return Err(CommitValidationError::TestsFailed( + results.last().map(|r| r.output.clone()).unwrap_or_default() + )); + } + } + + Ok(results) + } + + /// Run lint check. + async fn run_lint_check(&self, worktree_path: &Path) -> Result<QualityCheckResult, CommitValidationError> { + let cmd = match &self.config.lint_command { + Some(cmd) => cmd.clone(), + None => self.detect_lint_command(worktree_path).await, + }; + + if cmd.is_empty() { + return Ok(QualityCheckResult { + passed: true, + check_name: "lint".to_string(), + output: "No lint command detected, skipping".to_string(), + exit_code: None, + duration_ms: 0, + }); + } + + self.run_check_command("lint", &cmd, worktree_path).await + } + + /// Run test check. + async fn run_test_check(&self, worktree_path: &Path) -> Result<QualityCheckResult, CommitValidationError> { + let cmd = match &self.config.test_command { + Some(cmd) => cmd.clone(), + None => self.detect_test_command(worktree_path).await, + }; + + if cmd.is_empty() { + return Ok(QualityCheckResult { + passed: true, + check_name: "test".to_string(), + output: "No test command detected, skipping".to_string(), + exit_code: None, + duration_ms: 0, + }); + } + + self.run_check_command("test", &cmd, worktree_path).await + } + + /// Run a check command and collect results. + async fn run_check_command( + &self, + check_name: &str, + command: &str, + worktree_path: &Path, + ) -> Result<QualityCheckResult, CommitValidationError> { + tracing::info!( + check = check_name, + command = command, + path = %worktree_path.display(), + "Running quality check" + ); + + let start = std::time::Instant::now(); + + // Parse command into program and args + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.is_empty() { + return Err(CommitValidationError::CommandFailed( + format!("Empty command for check: {}", check_name) + )); + } + + let program = parts[0]; + let args = &parts[1..]; + + let timeout = std::time::Duration::from_secs(self.config.check_timeout_secs); + + let output = tokio::time::timeout( + timeout, + Command::new(program) + .args(args) + .current_dir(worktree_path) + .output() + ) + .await + .map_err(|_| CommitValidationError::CommandFailed( + format!("Check '{}' timed out after {} seconds", check_name, self.config.check_timeout_secs) + ))? + .map_err(|e| CommitValidationError::CommandFailed( + format!("Failed to run '{}': {}", check_name, e) + ))?; + + let duration_ms = start.elapsed().as_millis() as u64; + let passed = output.status.success(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let combined_output = if stderr.is_empty() { + stdout.to_string() + } else if stdout.is_empty() { + stderr.to_string() + } else { + format!("{}\n{}", stdout, stderr) + }; + + // Limit output size + let output_trimmed = if combined_output.len() > 10000 { + format!("{}...\n[output truncated]", &combined_output[..10000]) + } else { + combined_output + }; + + tracing::info!( + check = check_name, + passed = passed, + duration_ms = duration_ms, + exit_code = output.status.code(), + "Quality check completed" + ); + + Ok(QualityCheckResult { + passed, + check_name: check_name.to_string(), + output: output_trimmed, + exit_code: output.status.code(), + duration_ms, + }) + } + + /// Detect the appropriate test command based on project files. + async fn detect_test_command(&self, worktree_path: &Path) -> String { + // Check for Cargo.toml (Rust) + if worktree_path.join("Cargo.toml").exists() { + return "cargo test".to_string(); + } + + // Check for package.json (Node.js) + if worktree_path.join("package.json").exists() { + // Check if there's a test script + if let Ok(content) = tokio::fs::read_to_string(worktree_path.join("package.json")).await { + if content.contains("\"test\"") { + return "npm test".to_string(); + } + } + } + + // Check for pytest (Python) + if worktree_path.join("pytest.ini").exists() + || worktree_path.join("pyproject.toml").exists() + || worktree_path.join("setup.py").exists() + { + return "pytest".to_string(); + } + + // Check for Go + if worktree_path.join("go.mod").exists() { + return "go test ./...".to_string(); + } + + // Check for Maven (Java) + if worktree_path.join("pom.xml").exists() { + return "mvn test".to_string(); + } + + // Check for Gradle (Java/Kotlin) + if worktree_path.join("build.gradle").exists() || worktree_path.join("build.gradle.kts").exists() { + return "./gradlew test".to_string(); + } + + String::new() + } + + /// Detect the appropriate lint command based on project files. + async fn detect_lint_command(&self, worktree_path: &Path) -> String { + // Check for Cargo.toml (Rust) + if worktree_path.join("Cargo.toml").exists() { + return "cargo clippy --all-targets".to_string(); + } + + // Check for package.json (Node.js) + if worktree_path.join("package.json").exists() { + // Check if there's a lint script + if let Ok(content) = tokio::fs::read_to_string(worktree_path.join("package.json")).await { + if content.contains("\"lint\"") { + return "npm run lint".to_string(); + } + // Check for eslint + if content.contains("eslint") { + return "npx eslint .".to_string(); + } + } + } + + // Check for Python linters + if worktree_path.join("pyproject.toml").exists() { + if let Ok(content) = tokio::fs::read_to_string(worktree_path.join("pyproject.toml")).await { + if content.contains("[tool.ruff]") { + return "ruff check .".to_string(); + } + if content.contains("[tool.flake8]") { + return "flake8".to_string(); + } + } + } + + // Check for Go + if worktree_path.join("go.mod").exists() { + return "go vet ./...".to_string(); + } + + String::new() + } + + /// Append the Co-Authored-By trailer to an existing message if not present. + pub fn ensure_co_author_trailer(&self, message: &str) -> String { + if message.contains(CO_AUTHOR_TRAILER) { + message.to_string() + } else { + format!("{}\n\n{}", message.trim_end(), CO_AUTHOR_TRAILER) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_conventional_message() { + let validator = CommitValidator::with_defaults(); + let task_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + + let msg = validator.format_message( + task_id, + "Add user authentication", + None, + Some(CommitType::Feat), + ); + + assert!(msg.starts_with("feat: [550e8400] Add user authentication")); + assert!(msg.contains(CO_AUTHOR_TRAILER)); + } + + #[test] + fn test_format_simple_message() { + let config = CommitDisciplineConfig { + message_format: MessageFormat::Simple, + ..Default::default() + }; + let validator = CommitValidator::new(config); + let task_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + + let msg = validator.format_message( + task_id, + "Add user authentication", + None, + Some(CommitType::Feat), + ); + + assert!(msg.starts_with("[550e8400] Add user authentication")); + assert!(msg.contains(CO_AUTHOR_TRAILER)); + } + + #[test] + fn test_format_message_with_body() { + let validator = CommitValidator::with_defaults(); + let task_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + + let msg = validator.format_message( + task_id, + "Fix login bug", + Some("- Fixed null pointer exception\n- Added input validation"), + Some(CommitType::Fix), + ); + + assert!(msg.contains("fix: [550e8400] Fix login bug")); + assert!(msg.contains("Fixed null pointer exception")); + assert!(msg.contains(CO_AUTHOR_TRAILER)); + } + + #[test] + fn test_validate_conventional_message() { + let validator = CommitValidator::with_defaults(); + + // Valid messages + assert!(validator.validate_message("feat: add new feature").is_ok()); + assert!(validator.validate_message("fix: resolve bug").is_ok()); + assert!(validator.validate_message("chore: update deps").is_ok()); + assert!(validator.validate_message("feat(auth): add login").is_ok()); + + // Invalid messages + assert!(validator.validate_message("").is_err()); + assert!(validator.validate_message("add new feature").is_err()); + assert!(validator.validate_message("FEAT: uppercase").is_err()); + } + + #[test] + fn test_validate_simple_message() { + let config = CommitDisciplineConfig { + message_format: MessageFormat::Simple, + ..Default::default() + }; + let validator = CommitValidator::new(config); + + // Simple format accepts any non-empty message + assert!(validator.validate_message("any message").is_ok()); + assert!(validator.validate_message("[task-id] description").is_ok()); + assert!(validator.validate_message("").is_err()); + } + + #[test] + fn test_format_heartbeat_message() { + let validator = CommitValidator::with_defaults(); + let task_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + + let msg = validator.format_heartbeat_message(task_id, Some(3)); + + assert!(msg.contains("chore: [550e8400]")); + assert!(msg.contains("WIP checkpoint (iteration 3)")); + assert!(msg.contains(CO_AUTHOR_TRAILER)); + } + + #[test] + fn test_ensure_co_author_trailer() { + let validator = CommitValidator::with_defaults(); + + let msg_without = "feat: something"; + let result = validator.ensure_co_author_trailer(msg_without); + assert!(result.contains(CO_AUTHOR_TRAILER)); + + let msg_with = format!("feat: something\n\n{}", CO_AUTHOR_TRAILER); + let result = validator.ensure_co_author_trailer(&msg_with); + // Should not duplicate + assert_eq!(result.matches(CO_AUTHOR_TRAILER).count(), 1); + } + + #[test] + fn test_commit_type_parsing() { + assert_eq!("feat".parse::<CommitType>().unwrap(), CommitType::Feat); + assert_eq!("feature".parse::<CommitType>().unwrap(), CommitType::Feat); + assert_eq!("fix".parse::<CommitType>().unwrap(), CommitType::Fix); + assert_eq!("docs".parse::<CommitType>().unwrap(), CommitType::Docs); + assert!("invalid".parse::<CommitType>().is_err()); + } + + #[test] + fn test_message_format_parsing() { + assert_eq!("conventional".parse::<MessageFormat>().unwrap(), MessageFormat::Conventional); + assert_eq!("simple".parse::<MessageFormat>().unwrap(), MessageFormat::Simple); + assert!("invalid".parse::<MessageFormat>().is_err()); + } +} diff --git a/makima/src/daemon/task/context_recovery.rs b/makima/src/daemon/task/context_recovery.rs new file mode 100644 index 0000000..8f1cafc --- /dev/null +++ b/makima/src/daemon/task/context_recovery.rs @@ -0,0 +1,554 @@ +//! Context recovery for task resumption and restart. +//! +//! This module provides functionality to rebuild context when tasks resume or restart, +//! ensuring Claude can quickly orient itself with the current state of the worktree. + +use std::path::Path; +use std::process::Command; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Summary of git status information. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GitStatusSummary { + /// Number of staged files. + pub staged: u32, + /// Number of modified (unstaged) files. + pub modified: u32, + /// Number of untracked files. + pub untracked: u32, + /// Number of deleted files. + pub deleted: u32, + /// Brief description (e.g., "3 modified, 1 staged"). + pub summary: String, +} + +/// Information about a checkpoint (git commit). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckpointInfo { + /// Commit SHA (short form). + pub sha: String, + /// Commit message (first line). + pub message: String, + /// Commit timestamp. + pub timestamp: DateTime<Utc>, +} + +/// A single progress entry from the progress log. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgressEntry { + /// Timestamp of the entry. + pub timestamp: DateTime<Utc>, + /// Entry type (e.g., "ITERATION", "TASK_START", "CHECKPOINT"). + pub entry_type: String, + /// Entry message or summary. + pub message: String, +} + +/// Context recovery data for a task. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextRecovery { + /// Current git branch name. + pub current_branch: String, + /// Git status summary. + pub git_status: GitStatusSummary, + /// Last checkpoint information (most recent commit). + pub last_checkpoint: Option<CheckpointInfo>, + /// Recent progress entries from progress.log. + pub recent_progress: Vec<ProgressEntry>, + /// Current phase of the task. + pub current_phase: String, +} + +impl ContextRecovery { + /// Format the context recovery as a markdown header suitable for injection into prompts. + pub fn to_markdown(&self) -> String { + let mut output = String::new(); + output.push_str("## Context Recovery\n"); + output.push_str(&format!("- Current branch: {}\n", self.current_branch)); + output.push_str(&format!("- Git status: {}\n", self.git_status.summary)); + + if let Some(ref checkpoint) = self.last_checkpoint { + output.push_str(&format!( + "- Last checkpoint: {} - {} ({})\n", + checkpoint.sha, + checkpoint.message, + checkpoint.timestamp.format("%Y-%m-%d %H:%M:%S UTC") + )); + } else { + output.push_str("- Last checkpoint: none\n"); + } + + if self.recent_progress.is_empty() { + output.push_str("- Progress log (recent): none\n"); + } else { + output.push_str("- Progress log (recent):\n"); + for entry in &self.recent_progress { + output.push_str(&format!( + " - [{}] {}: {}\n", + entry.timestamp.format("%H:%M:%S"), + entry.entry_type, + entry.message + )); + } + } + + output.push_str(&format!("- Current phase: {}\n", self.current_phase)); + output.push_str("\n---\n\n"); + + output + } +} + +/// Build context recovery information for a task worktree. +/// +/// # Arguments +/// * `worktree_path` - Path to the task's worktree directory +/// * `phase` - Current task phase (e.g., "execute", "review") +/// * `max_progress_entries` - Maximum number of recent progress entries to include +/// +/// # Returns +/// A formatted markdown string containing context recovery information. +pub fn build_context_recovery( + worktree_path: &Path, + phase: &str, + max_progress_entries: usize, +) -> String { + let context = build_context_recovery_data(worktree_path, phase, max_progress_entries); + context.to_markdown() +} + +/// Build context recovery data structure for a task worktree. +/// +/// # Arguments +/// * `worktree_path` - Path to the task's worktree directory +/// * `phase` - Current task phase (e.g., "execute", "review") +/// * `max_progress_entries` - Maximum number of recent progress entries to include +/// +/// # Returns +/// A ContextRecovery struct containing all gathered information. +pub fn build_context_recovery_data( + worktree_path: &Path, + phase: &str, + max_progress_entries: usize, +) -> ContextRecovery { + let current_branch = get_current_branch(worktree_path).unwrap_or_else(|| "unknown".to_string()); + let git_status = get_git_status_summary(worktree_path); + let last_checkpoint = get_last_checkpoint(worktree_path); + let recent_progress = get_recent_progress(worktree_path, max_progress_entries); + + ContextRecovery { + current_branch, + git_status, + last_checkpoint, + recent_progress, + current_phase: phase.to_string(), + } +} + +/// Get the current git branch name. +fn get_current_branch(worktree_path: &Path) -> Option<String> { + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(worktree_path) + .output() + .ok()?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if branch.is_empty() || branch == "HEAD" { + // Detached HEAD state - try to get the commit SHA + let sha_output = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .current_dir(worktree_path) + .output() + .ok()?; + if sha_output.status.success() { + Some(format!("(detached at {})", String::from_utf8_lossy(&sha_output.stdout).trim())) + } else { + Some("(detached)".to_string()) + } + } else { + Some(branch) + } + } else { + None + } +} + +/// Get a summary of the git status. +fn get_git_status_summary(worktree_path: &Path) -> GitStatusSummary { + let output = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(worktree_path) + .output(); + + let output = match output { + Ok(o) if o.status.success() => o, + _ => return GitStatusSummary { + summary: "unable to get git status".to_string(), + ..Default::default() + }, + }; + + let status_output = String::from_utf8_lossy(&output.stdout); + let mut staged = 0u32; + let mut modified = 0u32; + let mut untracked = 0u32; + let mut deleted = 0u32; + + for line in status_output.lines() { + if line.len() < 2 { + continue; + } + let index_status = line.chars().next().unwrap_or(' '); + let worktree_status = line.chars().nth(1).unwrap_or(' '); + + // Count staged changes (index column) + match index_status { + 'A' | 'M' | 'R' | 'C' => staged += 1, + 'D' => { + staged += 1; + deleted += 1; + } + _ => {} + } + + // Count unstaged changes (worktree column) + match worktree_status { + 'M' => modified += 1, + 'D' => { + modified += 1; + deleted += 1; + } + '?' => untracked += 1, + _ => {} + } + } + + // Build summary string + let mut parts = Vec::new(); + if staged > 0 { + parts.push(format!("{} staged", staged)); + } + if modified > 0 { + parts.push(format!("{} modified", modified)); + } + if untracked > 0 { + parts.push(format!("{} untracked", untracked)); + } + + let summary = if parts.is_empty() { + "clean".to_string() + } else { + parts.join(", ") + }; + + GitStatusSummary { + staged, + modified, + untracked, + deleted, + summary, + } +} + +/// Get information about the last checkpoint (most recent commit). +fn get_last_checkpoint(worktree_path: &Path) -> Option<CheckpointInfo> { + // Get the most recent commit info + let output = Command::new("git") + .args([ + "log", + "-1", + "--format=%h|%s|%aI", // short sha | subject | ISO 8601 date + ]) + .current_dir(worktree_path) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let log_output = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let parts: Vec<&str> = log_output.splitn(3, '|').collect(); + + if parts.len() < 3 { + return None; + } + + let sha = parts[0].to_string(); + let message = parts[1].to_string(); + let timestamp = DateTime::parse_from_rfc3339(parts[2]) + .ok()? + .with_timezone(&Utc); + + Some(CheckpointInfo { + sha, + message, + timestamp, + }) +} + +/// Get recent progress entries from the progress.log file. +fn get_recent_progress(worktree_path: &Path, max_entries: usize) -> Vec<ProgressEntry> { + let progress_log_path = worktree_path.join("progress.log"); + + let content = match std::fs::read_to_string(&progress_log_path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let mut entries = Vec::new(); + + // Parse the progress.log file + // Expected format: + // [2024-01-15T10:30:00Z] ENTRY_TYPE + // key: value + // key: value + // (blank line) + let mut lines = content.lines().peekable(); + + while let Some(line) = lines.next() { + // Look for timestamp lines + if line.starts_with('[') && line.contains(']') { + if let Some(entry) = parse_progress_entry(line, &mut lines) { + entries.push(entry); + } + } + } + + // Return the most recent entries (up to max_entries) + let start = if entries.len() > max_entries { + entries.len() - max_entries + } else { + 0 + }; + + entries[start..].to_vec() +} + +/// Parse a single progress entry from the log. +fn parse_progress_entry<'a, I>(header_line: &str, lines: &mut std::iter::Peekable<I>) -> Option<ProgressEntry> +where + I: Iterator<Item = &'a str>, +{ + // Parse header: [2024-01-15T10:30:00Z] ENTRY_TYPE + let close_bracket = header_line.find(']')?; + let timestamp_str = &header_line[1..close_bracket]; + let entry_type = header_line[close_bracket + 1..].trim().to_string(); + + let timestamp = DateTime::parse_from_rfc3339(timestamp_str) + .ok()? + .with_timezone(&Utc); + + // Collect message from following lines until blank line or next entry + let mut message_parts = Vec::new(); + + while let Some(&next_line) = lines.peek() { + if next_line.is_empty() || next_line.starts_with('#') { + lines.next(); // consume blank/comment line + break; + } + if next_line.starts_with('[') { + // Next entry starts, don't consume + break; + } + + let line = lines.next().unwrap(); + // Extract key-value or just the line + if let Some(colon_pos) = line.find(':') { + let key = line[..colon_pos].trim(); + let value = line[colon_pos + 1..].trim(); + if key == "progress" || key == "message" || key == "reason" { + message_parts.push(value.to_string()); + } + } + } + + let message = if message_parts.is_empty() { + entry_type.clone() + } else { + message_parts.join("; ") + }; + + Some(ProgressEntry { + timestamp, + entry_type, + message, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn setup_git_repo() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path(); + + // Initialize git repo + Command::new("git") + .args(["init"]) + .current_dir(path) + .output() + .unwrap(); + + // Configure git for commits + Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(path) + .output() + .unwrap(); + + Command::new("git") + .args(["config", "user.name", "Test User"]) + .current_dir(path) + .output() + .unwrap(); + + // Create initial commit + fs::write(path.join("README.md"), "# Test").unwrap(); + Command::new("git") + .args(["add", "README.md"]) + .current_dir(path) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "Initial commit"]) + .current_dir(path) + .output() + .unwrap(); + + temp_dir + } + + #[test] + fn test_get_current_branch() { + let temp_dir = setup_git_repo(); + let branch = get_current_branch(temp_dir.path()); + // Default branch could be main or master depending on git config + assert!(branch.is_some()); + let branch_name = branch.unwrap(); + assert!(branch_name == "main" || branch_name == "master"); + } + + #[test] + fn test_git_status_clean() { + let temp_dir = setup_git_repo(); + let status = get_git_status_summary(temp_dir.path()); + assert_eq!(status.staged, 0); + assert_eq!(status.modified, 0); + assert_eq!(status.untracked, 0); + assert_eq!(status.summary, "clean"); + } + + #[test] + fn test_git_status_with_changes() { + let temp_dir = setup_git_repo(); + let path = temp_dir.path(); + + // Create untracked file + fs::write(path.join("new_file.txt"), "new content").unwrap(); + + // Modify tracked file + fs::write(path.join("README.md"), "# Modified").unwrap(); + + let status = get_git_status_summary(path); + assert_eq!(status.modified, 1); + assert_eq!(status.untracked, 1); + assert!(status.summary.contains("modified")); + assert!(status.summary.contains("untracked")); + } + + #[test] + fn test_get_last_checkpoint() { + let temp_dir = setup_git_repo(); + let checkpoint = get_last_checkpoint(temp_dir.path()); + assert!(checkpoint.is_some()); + + let checkpoint = checkpoint.unwrap(); + assert_eq!(checkpoint.message, "Initial commit"); + assert!(!checkpoint.sha.is_empty()); + } + + #[test] + fn test_build_context_recovery() { + let temp_dir = setup_git_repo(); + let markdown = build_context_recovery(temp_dir.path(), "execute", 5); + + assert!(markdown.contains("## Context Recovery")); + assert!(markdown.contains("Current branch:")); + assert!(markdown.contains("Git status:")); + assert!(markdown.contains("Last checkpoint:")); + assert!(markdown.contains("Current phase: execute")); + } + + #[test] + fn test_progress_log_parsing() { + let temp_dir = setup_git_repo(); + let path = temp_dir.path(); + + // Create a mock progress.log + let progress_content = r#"# progress.log +# Auto-generated by Makima - DO NOT EDIT MANUALLY + +[2024-01-15T10:30:00Z] TASK_START +task_id: test-123 +task_name: Test Task + +[2024-01-15T10:35:00Z] ITERATION 1 +progress: Analyzed codebase structure +files_modified: 2 + +[2024-01-15T10:40:00Z] ITERATION 2 +progress: Implemented first feature +files_modified: 3 +"#; + fs::write(path.join("progress.log"), progress_content).unwrap(); + + let entries = get_recent_progress(path, 5); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].entry_type, "TASK_START"); + assert_eq!(entries[1].entry_type, "ITERATION 1"); + assert_eq!(entries[2].entry_type, "ITERATION 2"); + } + + #[test] + fn test_context_recovery_to_markdown() { + let context = ContextRecovery { + current_branch: "feature/test".to_string(), + git_status: GitStatusSummary { + staged: 1, + modified: 2, + untracked: 0, + deleted: 0, + summary: "1 staged, 2 modified".to_string(), + }, + last_checkpoint: Some(CheckpointInfo { + sha: "abc1234".to_string(), + message: "Added feature X".to_string(), + timestamp: Utc::now(), + }), + recent_progress: vec![ + ProgressEntry { + timestamp: Utc::now(), + entry_type: "ITERATION 1".to_string(), + message: "Started implementation".to_string(), + }, + ], + current_phase: "execute".to_string(), + }; + + let markdown = context.to_markdown(); + assert!(markdown.contains("## Context Recovery")); + assert!(markdown.contains("feature/test")); + assert!(markdown.contains("1 staged, 2 modified")); + assert!(markdown.contains("abc1234")); + assert!(markdown.contains("Added feature X")); + assert!(markdown.contains("ITERATION 1")); + assert!(markdown.contains("execute")); + } +} diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index 179c07f..b0e4721 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -1292,6 +1292,7 @@ impl TaskManager { false, // autonomous_loop - supervisors don't use this false, // resume_session - respawning from scratch None, // conversation_history - not needed for fresh respawn + None, // max_iterations - use config default ).await { tracing::error!( task_id = %task_id, @@ -4366,6 +4367,7 @@ impl Clone for TaskManagerInner { git_user_name: self.git_user_name.clone(), api_url: self.api_url.clone(), heartbeat_commit_interval_secs: self.heartbeat_commit_interval_secs, + max_iterations: self.max_iterations, } } } diff --git a/makima/src/daemon/task/mod.rs b/makima/src/daemon/task/mod.rs index 3830e1d..8b9a20c 100644 --- a/makima/src/daemon/task/mod.rs +++ b/makima/src/daemon/task/mod.rs @@ -1,9 +1,15 @@ //! Task management and execution. +pub mod commit_validator; pub mod completion_gate; +pub mod context_recovery; pub mod manager; +pub mod progress_log; pub mod state; +pub use commit_validator::{CommitValidator, CommitValidationError, CommitType, MessageFormat}; pub use completion_gate::CompletionGate; +pub use context_recovery::{ContextRecovery, build_context_recovery, build_context_recovery_data}; pub use manager::{ManagedTask, TaskConfig, TaskManager}; +pub use progress_log::{ProgressLog, ProgressEntry, ProgressEntryStatus, append_progress_entry, PROGRESS_LOG_FILENAME}; 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..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(¤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<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"); + } +} |
