summaryrefslogtreecommitdiff
path: root/makima/src/llm/task_output.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-11 05:52:14 +0000
committersoryu <soryu@soryu.co>2026-01-15 00:21:16 +0000
commit87044a747b47bd83249d61a45842c7f7b2eae56d (patch)
treeef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/src/llm/task_output.rs
parent077820c4167c168072d217a1b01df840463a12a8 (diff)
downloadsoryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz
soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip
Contract system
Diffstat (limited to 'makima/src/llm/task_output.rs')
-rw-r--r--makima/src/llm/task_output.rs461
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"));
+ }
+}