summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-23 20:03:45 +0000
committersoryu <soryu@soryu.co>2026-01-23 20:05:34 +0000
commita8cf9d11360b4e2d1bfcbdd6b81956b1f4419181 (patch)
tree3d994b1d9afd181bfe6095c1a12c6765d348a56c
parent12cb721dbbe571bd3b2766546b2105ef034e6cf3 (diff)
downloadsoryu-a8cf9d11360b4e2d1bfcbdd6b81956b1f4419181.tar.gz
soryu-a8cf9d11360b4e2d1bfcbdd6b81956b1f4419181.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.rs3
-rw-r--r--makima/src/daemon/config.rs103
-rw-r--r--makima/src/daemon/task/commit_validator.rs752
-rw-r--r--makima/src/daemon/task/context_recovery.rs554
-rw-r--r--makima/src/daemon/task/manager.rs2
-rw-r--r--makima/src/daemon/task/mod.rs6
-rw-r--r--makima/src/daemon/task/progress_log.rs671
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(&current_entry) {
+ entries.push(entry);
+ }
+ current_entry.clear();
+ }
+ }
+
+ // Skip header lines
+ if line.starts_with("# ") || line.is_empty() {
+ continue;
+ }
+
+ current_entry.push_str(&line);
+ current_entry.push('\n');
+ }
+
+ // Process last entry
+ if !current_entry.is_empty() {
+ if let Some(entry) = ProgressEntry::from_markdown(&current_entry) {
+ entries.push(entry);
+ }
+ }
+
+ Ok(entries)
+ }
+
+ /// Read the most recent entries from the progress log.
+ ///
+ /// Returns up to `max` entries, starting from the most recent.
+ pub fn read_recent_entries(&self, max: usize) -> io::Result<Vec<ProgressEntry>> {
+ let mut entries = self.read_all_entries()?;
+
+ // Return the last `max` entries (most recent)
+ if entries.len() > max {
+ entries = entries.split_off(entries.len() - max);
+ }
+
+ Ok(entries)
+ }
+
+ /// Format recent entries for injection into a prompt.
+ ///
+ /// Returns a formatted string containing the recent progress entries
+ /// suitable for including in a Claude prompt.
+ pub fn format_for_prompt(&self, max_entries: usize) -> io::Result<String> {
+ let entries = self.read_recent_entries(max_entries)?;
+
+ if entries.is_empty() {
+ return Ok(String::new());
+ }
+
+ let mut output = String::new();
+ output.push_str("## Previous Task Progress\n\n");
+ output.push_str("The following entries show recent task completions and learnings:\n\n");
+
+ for entry in &entries {
+ output.push_str(&entry.to_markdown());
+ output.push('\n');
+ }
+
+ Ok(output)
+ }
+
+ /// Get the total number of entries in the log.
+ pub fn entry_count(&self) -> io::Result<usize> {
+ Ok(self.read_all_entries()?.len())
+ }
+}
+
+/// Helper function to create a progress entry and append it to a log.
+///
+/// This is a convenience function for the common case of appending a single entry.
+pub fn append_progress_entry(
+ worktree_path: &Path,
+ task_id: Uuid,
+ task_name: String,
+ status: ProgressEntryStatus,
+ files_changed: Vec<String>,
+ learnings: Vec<String>,
+) -> io::Result<()> {
+ let log = ProgressLog::new(worktree_path);
+ let entry = ProgressEntry::new(task_id, task_name, status, files_changed, learnings);
+ log.append_entry(&entry)
+}
+
+/// Get the list of files changed in a git worktree.
+///
+/// This runs `git diff --name-only HEAD~1` to get files changed in the last commit,
+/// falling back to `git diff --name-only` for uncommitted changes if no commits exist.
+pub async fn get_git_changed_files(worktree_path: &Path) -> Vec<String> {
+ // Try to get files from the last commit first
+ let output = tokio::process::Command::new("git")
+ .args(["diff", "--name-only", "HEAD~1"])
+ .current_dir(worktree_path)
+ .output()
+ .await;
+
+ if let Ok(output) = output {
+ if output.status.success() {
+ let files: Vec<String> = String::from_utf8_lossy(&output.stdout)
+ .lines()
+ .filter(|s| !s.is_empty())
+ .map(String::from)
+ .collect();
+ if !files.is_empty() {
+ return files;
+ }
+ }
+ }
+
+ // Fall back to uncommitted changes
+ let output = tokio::process::Command::new("git")
+ .args(["diff", "--name-only", "HEAD"])
+ .current_dir(worktree_path)
+ .output()
+ .await;
+
+ if let Ok(output) = output {
+ if output.status.success() {
+ return String::from_utf8_lossy(&output.stdout)
+ .lines()
+ .filter(|s| !s.is_empty())
+ .map(String::from)
+ .collect();
+ }
+ }
+
+ // Try to get all tracked files that have been modified
+ let output = tokio::process::Command::new("git")
+ .args(["status", "--porcelain"])
+ .current_dir(worktree_path)
+ .output()
+ .await;
+
+ if let Ok(output) = output {
+ if output.status.success() {
+ return String::from_utf8_lossy(&output.stdout)
+ .lines()
+ .filter(|s| !s.is_empty())
+ .filter_map(|line| {
+ // Format: "XY filename" where XY is the status
+ if line.len() > 3 {
+ Some(line[3..].to_string())
+ } else {
+ None
+ }
+ })
+ .collect();
+ }
+ }
+
+ Vec::new()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+ use tempfile::tempdir;
+
+ #[test]
+ fn test_progress_entry_to_markdown() {
+ let entry = ProgressEntry {
+ timestamp: DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
+ .unwrap()
+ .with_timezone(&Utc),
+ task_id: Uuid::parse_str("fc09b908-1234-5678-abcd-ef1234567890").unwrap(),
+ task_name: "Implement user authentication".to_string(),
+ status: ProgressEntryStatus::Done,
+ files_changed: vec!["src/auth.rs".to_string(), "src/lib.rs".to_string()],
+ learnings: vec![
+ "Pattern: Use bcrypt for password hashing".to_string(),
+ "Gotcha: Need to handle expired tokens gracefully".to_string(),
+ ],
+ };
+
+ let markdown = entry.to_markdown();
+ assert!(markdown.contains("## [2024-01-15T10:30:00Z]"));
+ assert!(markdown.contains("Task [fc09b908]"));
+ assert!(markdown.contains("Implement user authentication"));
+ assert!(markdown.contains("Status: done"));
+ assert!(markdown.contains("src/auth.rs, src/lib.rs"));
+ assert!(markdown.contains("Use bcrypt for password hashing"));
+ assert!(markdown.contains("---"));
+ }
+
+ #[test]
+ fn test_progress_entry_roundtrip() {
+ let entry = ProgressEntry {
+ timestamp: DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
+ .unwrap()
+ .with_timezone(&Utc),
+ task_id: Uuid::parse_str("fc09b908-0000-0000-0000-000000000000").unwrap(),
+ task_name: "Test task".to_string(),
+ status: ProgressEntryStatus::Failed,
+ files_changed: vec!["test.rs".to_string()],
+ learnings: vec!["Learning 1".to_string()],
+ };
+
+ let markdown = entry.to_markdown();
+ let parsed = ProgressEntry::from_markdown(&markdown).unwrap();
+
+ assert_eq!(parsed.timestamp, entry.timestamp);
+ assert_eq!(parsed.task_name, entry.task_name);
+ assert_eq!(parsed.status, entry.status);
+ assert_eq!(parsed.files_changed, entry.files_changed);
+ assert_eq!(parsed.learnings, entry.learnings);
+ }
+
+ #[test]
+ fn test_progress_log_append_and_read() {
+ let dir = tempdir().unwrap();
+ let log = ProgressLog::new(dir.path());
+
+ // Initially no entries
+ assert!(!log.exists());
+ assert_eq!(log.read_all_entries().unwrap().len(), 0);
+
+ // Append first entry
+ let entry1 = ProgressEntry::new(
+ Uuid::new_v4(),
+ "Task 1".to_string(),
+ ProgressEntryStatus::Done,
+ vec!["file1.rs".to_string()],
+ vec!["Learning A".to_string()],
+ );
+ log.append_entry(&entry1).unwrap();
+
+ assert!(log.exists());
+ let entries = log.read_all_entries().unwrap();
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].task_name, "Task 1");
+
+ // Append second entry
+ let entry2 = ProgressEntry::new(
+ Uuid::new_v4(),
+ "Task 2".to_string(),
+ ProgressEntryStatus::Failed,
+ vec!["file2.rs".to_string()],
+ vec!["Learning B".to_string()],
+ );
+ log.append_entry(&entry2).unwrap();
+
+ let entries = log.read_all_entries().unwrap();
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[1].task_name, "Task 2");
+ }
+
+ #[test]
+ fn test_progress_log_read_recent() {
+ let dir = tempdir().unwrap();
+ let log = ProgressLog::new(dir.path());
+
+ // Append 5 entries
+ for i in 1..=5 {
+ let entry = ProgressEntry::new(
+ Uuid::new_v4(),
+ format!("Task {}", i),
+ ProgressEntryStatus::Done,
+ vec![],
+ vec![],
+ );
+ log.append_entry(&entry).unwrap();
+ }
+
+ // Read only last 3
+ let recent = log.read_recent_entries(3).unwrap();
+ assert_eq!(recent.len(), 3);
+ assert_eq!(recent[0].task_name, "Task 3");
+ assert_eq!(recent[1].task_name, "Task 4");
+ assert_eq!(recent[2].task_name, "Task 5");
+ }
+
+ #[test]
+ fn test_progress_log_format_for_prompt() {
+ let dir = tempdir().unwrap();
+ let log = ProgressLog::new(dir.path());
+
+ let entry = ProgressEntry::new(
+ Uuid::new_v4(),
+ "Important task".to_string(),
+ ProgressEntryStatus::Done,
+ vec!["src/main.rs".to_string()],
+ vec!["Key insight".to_string()],
+ );
+ log.append_entry(&entry).unwrap();
+
+ let prompt = log.format_for_prompt(10).unwrap();
+ assert!(prompt.contains("## Previous Task Progress"));
+ assert!(prompt.contains("Important task"));
+ assert!(prompt.contains("Key insight"));
+ }
+
+ #[test]
+ fn test_progress_entry_status_display() {
+ assert_eq!(format!("{}", ProgressEntryStatus::Done), "done");
+ assert_eq!(format!("{}", ProgressEntryStatus::Failed), "failed");
+ }
+
+ #[test]
+ fn test_progress_entry_status_parse() {
+ assert_eq!(
+ "done".parse::<ProgressEntryStatus>().unwrap(),
+ ProgressEntryStatus::Done
+ );
+ assert_eq!(
+ "completed".parse::<ProgressEntryStatus>().unwrap(),
+ ProgressEntryStatus::Done
+ );
+ assert_eq!(
+ "failed".parse::<ProgressEntryStatus>().unwrap(),
+ ProgressEntryStatus::Failed
+ );
+ assert_eq!(
+ "error".parse::<ProgressEntryStatus>().unwrap(),
+ ProgressEntryStatus::Failed
+ );
+ assert!("unknown".parse::<ProgressEntryStatus>().is_err());
+ }
+
+ #[test]
+ fn test_empty_files_and_learnings() {
+ let entry = ProgressEntry::new(
+ Uuid::new_v4(),
+ "Empty task".to_string(),
+ ProgressEntryStatus::Done,
+ vec![],
+ vec![],
+ );
+
+ let markdown = entry.to_markdown();
+ assert!(markdown.contains("Files changed: (none)"));
+ assert!(markdown.contains("(none recorded)"));
+ }
+
+ #[test]
+ fn test_progress_log_file_header() {
+ let dir = tempdir().unwrap();
+ let log = ProgressLog::new(dir.path());
+
+ let entry = ProgressEntry::new(
+ Uuid::new_v4(),
+ "Test".to_string(),
+ ProgressEntryStatus::Done,
+ vec![],
+ vec![],
+ );
+ log.append_entry(&entry).unwrap();
+
+ let content = fs::read_to_string(log.path()).unwrap();
+ assert!(content.starts_with("# progress.log\n"));
+ assert!(content.contains("Auto-generated by Makima"));
+ }
+
+ #[test]
+ fn test_append_progress_entry_helper() {
+ let dir = tempdir().unwrap();
+
+ append_progress_entry(
+ dir.path(),
+ Uuid::new_v4(),
+ "Helper test".to_string(),
+ ProgressEntryStatus::Done,
+ vec!["file.rs".to_string()],
+ vec!["A learning".to_string()],
+ )
+ .unwrap();
+
+ let log = ProgressLog::new(dir.path());
+ let entries = log.read_all_entries().unwrap();
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].task_name, "Helper test");
+ }
+}