diff options
Diffstat (limited to 'makima/src/llm/task_output.rs')
| -rw-r--r-- | makima/src/llm/task_output.rs | 461 |
1 files changed, 461 insertions, 0 deletions
diff --git a/makima/src/llm/task_output.rs b/makima/src/llm/task_output.rs new file mode 100644 index 0000000..c71c05a --- /dev/null +++ b/makima/src/llm/task_output.rs @@ -0,0 +1,461 @@ +//! Task output processing and task derivation utilities. +//! +//! This module provides utilities for: +//! - Parsing task lists from markdown documents +//! - Analyzing completed task outputs +//! - Suggesting follow-up actions based on task results + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A parsed task from a markdown document +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParsedTask { + /// Task name/title + pub name: String, + /// Task description or plan + pub description: Option<String>, + /// Group/phase this task belongs to + pub group: Option<String>, + /// Order within the group (0-indexed) + pub order: usize, + /// Whether this task was marked as completed in source + pub completed: bool, + /// Dependencies (names of other tasks) + pub dependencies: Vec<String>, +} + +/// Result of parsing tasks from a document +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskParseResult { + /// Successfully parsed tasks + pub tasks: Vec<ParsedTask>, + /// Groups/phases found + pub groups: Vec<String>, + /// Total tasks found + pub total: usize, + /// Any parsing warnings + pub warnings: Vec<String>, +} + +/// Impact on contract phase +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhaseImpact { + /// Current phase + pub phase: String, + /// Whether phase targets are now met + pub targets_met: bool, + /// Tasks remaining in phase + pub tasks_remaining: usize, + /// Suggestion for phase transition + pub transition_suggestion: Option<String>, +} + +/// Suggested action based on task output +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SuggestedAction { + /// Create a follow-up task + CreateTask { + name: String, + plan: String, + chain_from: Option<Uuid>, + }, + /// Create a new file from template + CreateFile { + template_id: String, + name: String, + seed_content: Option<String>, + }, + /// Update an existing file + UpdateFile { + file_id: Uuid, + file_name: String, + additions: String, + }, + /// Advance to next phase + AdvancePhase { + to_phase: String, + }, + /// Run the next chained task + RunNextTask { + task_id: Uuid, + task_name: String, + }, +} + +/// Analysis of a completed task's output +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskOutputAnalysis { + /// Summary of what was accomplished + pub summary: String, + /// Files that were created/modified (from diff) + pub files_affected: Vec<String>, + /// Suggested next actions + pub next_steps: Vec<SuggestedAction>, + /// Impact on contract phase + pub phase_impact: Option<PhaseImpact>, +} + +/// Parse tasks from a markdown task breakdown document +/// +/// Supports formats like: +/// - `[ ] Task name` +/// - `[x] Completed task` +/// - `1. Task name` +/// - `- Task name` +/// +/// Groups are detected from `## Phase/Section` headings. +pub fn parse_tasks_from_breakdown(content: &str) -> TaskParseResult { + let mut tasks = Vec::new(); + let mut groups = Vec::new(); + let mut warnings = Vec::new(); + let mut current_group: Option<String> = None; + let mut task_order = 0; + + // Patterns for task items + let checkbox_pattern = Regex::new(r"^\s*[-*]\s*\[([ xX])\]\s*(.+)$").unwrap(); + let numbered_checkbox = Regex::new(r"^\s*\d+\.\s*\[([ xX])\]\s*(.+)$").unwrap(); + let numbered_pattern = Regex::new(r"^\s*\d+\.\s+(.+)$").unwrap(); + let bullet_pattern = Regex::new(r"^\s*[-*]\s+(.+)$").unwrap(); + let heading_pattern = Regex::new(r"^##\s+(?:Phase\s*\d*:?\s*)?(.+)$").unwrap(); + + // Patterns for dependencies (inline) + let depends_pattern = Regex::new(r"(?i)(?:depends on|after|requires):?\s*(.+)").unwrap(); + + for line in content.lines() { + let trimmed = line.trim(); + + // Skip empty lines + if trimmed.is_empty() { + continue; + } + + // Check for section headings + if let Some(caps) = heading_pattern.captures(trimmed) { + let group_name = caps[1].trim().to_string(); + if !groups.contains(&group_name) { + groups.push(group_name.clone()); + } + current_group = Some(group_name); + task_order = 0; + continue; + } + + // Try to parse as a task + let mut task_name: Option<String> = None; + let mut completed = false; + + // Try checkbox patterns first (more specific) + if let Some(caps) = checkbox_pattern.captures(trimmed) { + completed = &caps[1] != " "; + task_name = Some(caps[2].trim().to_string()); + } else if let Some(caps) = numbered_checkbox.captures(trimmed) { + completed = &caps[1] != " "; + task_name = Some(caps[2].trim().to_string()); + } else if let Some(caps) = numbered_pattern.captures(trimmed) { + task_name = Some(caps[1].trim().to_string()); + } else if let Some(caps) = bullet_pattern.captures(trimmed) { + // Only treat as task if it looks like a task (has actionable verbs) + let text = caps[1].trim(); + if looks_like_task(text) { + task_name = Some(text.to_string()); + } + } + + if let Some(name) = task_name { + // Skip items that are clearly not tasks + if name.to_lowercase().starts_with("note:") || + name.to_lowercase().starts_with("todo:") && name.len() < 10 || + name.starts_with('#') { + continue; + } + + // Extract dependencies if present + let dependencies = if let Some(dep_caps) = depends_pattern.captures(&name) { + dep_caps[1] + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } else { + Vec::new() + }; + + // Clean task name (remove dependency info) + let clean_name = depends_pattern.replace(&name, "").trim().to_string(); + + // Extract description if there's a colon + let (final_name, description) = if let Some(idx) = clean_name.find(':') { + let (n, d) = clean_name.split_at(idx); + (n.trim().to_string(), Some(d[1..].trim().to_string())) + } else { + (clean_name, None) + }; + + tasks.push(ParsedTask { + name: final_name, + description, + group: current_group.clone(), + order: task_order, + completed, + dependencies, + }); + + task_order += 1; + } + } + + let total = tasks.len(); + + // Add warnings + if tasks.is_empty() { + warnings.push("No tasks found in document. Ensure tasks are formatted as checkbox items (- [ ] Task) or numbered lists (1. Task).".to_string()); + } + + TaskParseResult { + tasks, + groups, + total, + warnings, + } +} + +/// Check if text looks like a task (has action verbs) +fn looks_like_task(text: &str) -> bool { + let lower = text.to_lowercase(); + let action_verbs = [ + "add", "create", "implement", "build", "write", "fix", "update", + "refactor", "test", "configure", "set up", "setup", "deploy", + "integrate", "migrate", "design", "review", "document", "remove", + "delete", "modify", "change", "improve", "optimize", "enable", + "disable", "install", "initialize", "define", "extend", "extract", + ]; + + action_verbs.iter().any(|verb| lower.starts_with(verb) || lower.contains(&format!(" {}", verb))) +} + +/// Analyze a completed task's output to suggest next actions +pub fn analyze_task_output( + _task_id: Uuid, + task_name: &str, + task_result: Option<&str>, + task_diff: Option<&str>, + contract_phase: &str, + total_tasks: usize, + completed_tasks: usize, + next_task: Option<(Uuid, String)>, + dev_notes_file: Option<(Uuid, String)>, +) -> TaskOutputAnalysis { + let mut next_steps = Vec::new(); + let mut files_affected = Vec::new(); + + // Parse files from diff if available + if let Some(diff) = task_diff { + files_affected = extract_files_from_diff(diff); + } + + // Generate summary + let summary = if let Some(result) = task_result { + if result.len() > 200 { + format!("{}...", &result[..200]) + } else { + result.to_string() + } + } else { + format!("Task '{}' completed", task_name) + }; + + // If there's a next chained task, suggest running it + if let Some((next_id, next_name)) = next_task { + next_steps.push(SuggestedAction::RunNextTask { + task_id: next_id, + task_name: next_name, + }); + } + + // Suggest updating Dev Notes if in execute phase and file exists + if contract_phase == "execute" { + if let Some((file_id, file_name)) = dev_notes_file { + let additions = format!( + "\n## Task: {}\n\n{}\n\n### Files Modified\n{}\n", + task_name, + summary, + files_affected.iter() + .map(|f| format!("- {}", f)) + .collect::<Vec<_>>() + .join("\n") + ); + + next_steps.push(SuggestedAction::UpdateFile { + file_id, + file_name, + additions, + }); + } else { + // Suggest creating Dev Notes + next_steps.push(SuggestedAction::CreateFile { + template_id: "dev-notes".to_string(), + name: "Development Notes".to_string(), + seed_content: Some(format!( + "# Development Notes\n\n## Task: {}\n\n{}\n", + task_name, summary + )), + }); + } + } + + // Calculate phase impact + let new_completed = completed_tasks + 1; + let targets_met = new_completed >= total_tasks && total_tasks > 0; + let tasks_remaining = total_tasks.saturating_sub(new_completed); + + let transition_suggestion = if targets_met && contract_phase == "execute" { + Some("All tasks complete. Ready to advance to Review phase.".to_string()) + } else { + None + }; + + // If targets are met, suggest phase transition + if targets_met && contract_phase == "execute" { + next_steps.push(SuggestedAction::AdvancePhase { + to_phase: "review".to_string(), + }); + } + + let phase_impact = Some(PhaseImpact { + phase: contract_phase.to_string(), + targets_met, + tasks_remaining, + transition_suggestion, + }); + + TaskOutputAnalysis { + summary, + files_affected, + next_steps, + phase_impact, + } +} + +/// Extract file paths from a git diff +fn extract_files_from_diff(diff: &str) -> Vec<String> { + let mut files = Vec::new(); + let file_pattern = Regex::new(r"^(?:diff --git a/|[+]{3} b/|[-]{3} a/)(.+)$").unwrap(); + + for line in diff.lines() { + if let Some(caps) = file_pattern.captures(line) { + let path = caps[1].trim().to_string(); + // Skip /dev/null and duplicates + if path != "/dev/null" && !files.contains(&path) { + // Clean up path (remove a/ or b/ prefix from git diff) + let clean_path = path.trim_start_matches("a/").trim_start_matches("b/").to_string(); + if !files.contains(&clean_path) { + files.push(clean_path); + } + } + } + } + + files +} + +/// Format parsed tasks for display +pub fn format_parsed_tasks(result: &TaskParseResult) -> String { + let mut output = String::new(); + + if result.tasks.is_empty() { + output.push_str("No tasks found in the document.\n"); + for warning in &result.warnings { + output.push_str(&format!("Warning: {}\n", warning)); + } + return output; + } + + output.push_str(&format!("Found {} task(s)", result.total)); + if !result.groups.is_empty() { + output.push_str(&format!(" in {} group(s)", result.groups.len())); + } + output.push_str(":\n\n"); + + let mut current_group: Option<&str> = None; + for (i, task) in result.tasks.iter().enumerate() { + // Print group header if changed + if task.group.as_deref() != current_group { + current_group = task.group.as_deref(); + if let Some(group) = current_group { + output.push_str(&format!("**{}**\n", group)); + } + } + + let status = if task.completed { "[x]" } else { "[ ]" }; + output.push_str(&format!("{}. {} {}", i + 1, status, task.name)); + + if !task.dependencies.is_empty() { + output.push_str(&format!(" (depends on: {})", task.dependencies.join(", "))); + } + + output.push('\n'); + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_checkbox_tasks() { + let content = r#" +## Phase 1: Setup +- [ ] Set up project structure +- [x] Configure dev environment + +## Phase 2: Features +1. [ ] Implement authentication +2. [ ] Add user dashboard +"#; + + let result = parse_tasks_from_breakdown(content); + assert_eq!(result.total, 4); + assert_eq!(result.groups.len(), 2); + assert!(!result.tasks[0].completed); + assert!(result.tasks[1].completed); + } + + #[test] + fn test_parse_with_dependencies() { + let content = r#" +- [ ] Task A +- [ ] Task B (depends on: Task A) +"#; + + let result = parse_tasks_from_breakdown(content); + assert_eq!(result.tasks[1].dependencies, vec!["Task A"]); + } + + #[test] + fn test_extract_files_from_diff() { + let diff = r#" +diff --git a/src/main.rs b/src/main.rs +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,3 +1,4 @@ ++fn new_function() {} +"#; + + let files = extract_files_from_diff(diff); + assert!(files.contains(&"src/main.rs".to_string())); + } + + #[test] + fn test_looks_like_task() { + assert!(looks_like_task("Add authentication")); + assert!(looks_like_task("Create user model")); + assert!(looks_like_task("implement feature X")); + assert!(!looks_like_task("This is a note")); + assert!(!looks_like_task("Summary of changes")); + } +} |
