summaryrefslogtreecommitdiff
path: root/makima/src/orchestration/planner.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-05 23:42:48 +0000
committersoryu <soryu@soryu.co>2026-02-05 23:42:48 +0000
commit88a4f15ce1310f8ee8693835be14aa5280233f17 (patch)
tree5c1a0417e02071d2198d13478ffa85533b19f891 /makima/src/orchestration/planner.rs
parentf1a50b80f3969d150bd1c31edde0aff05369157e (diff)
downloadsoryu-88a4f15ce1310f8ee8693835be14aa5280233f17.tar.gz
soryu-88a4f15ce1310f8ee8693835be14aa5280233f17.zip
Add directive-first chain system redesign
Redesigns the chain system with a directive-first architecture where Directive is the top-level entity (the "why/what") and Chains are generated execution plans (the "how") that can be dynamically modified. Backend: - Add database migration for directive system tables - Add Directive, DirectiveChain, ChainStep, DirectiveEvent models - Add DirectiveVerifier and DirectiveApproval models - Add orchestration module with engine, planner, and verifier - Add comprehensive API handlers for directives - Add daemon CLI commands for directive management - Add directive skill documentation - Integrate contract completion with directive engine - Add SSE endpoint for real-time directive events Frontend: - Add directives route with split-view layout - Add 6-tab detail view (Overview, Chain, Events, Evaluations, Approvals, Verifiers) - Add React Flow DAG visualization for chain steps - Add SSE subscription hook for real-time event updates - Add useDirectives and useDirectiveEventSubscription hooks - Add directive types and API functions Fixes: - Fix test failures in ws/protocol, task_output, completion_gate, patch - Fix word boundary matching in looks_like_task() - Fix parse_last() to find actual last completion gate - Fix create_export_patch when merge-base equals HEAD - Clean up clippy warnings in new code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/src/orchestration/planner.rs')
-rw-r--r--makima/src/orchestration/planner.rs742
1 files changed, 742 insertions, 0 deletions
diff --git a/makima/src/orchestration/planner.rs b/makima/src/orchestration/planner.rs
new file mode 100644
index 0000000..cdca8a0
--- /dev/null
+++ b/makima/src/orchestration/planner.rs
@@ -0,0 +1,742 @@
+//! Chain planner for LLM-based execution plan generation.
+//!
+//! Generates chains (DAGs of steps) from directive goals and requirements.
+//! Supports both initial plan generation and replanning while preserving
+//! completed work.
+
+use serde::{Deserialize, Serialize};
+use std::collections::{HashMap, HashSet};
+use thiserror::Error;
+use uuid::Uuid;
+
+use crate::db::models::{AddStepRequest, ChainStep, Directive};
+
+/// Error type for planner operations.
+#[derive(Error, Debug)]
+pub enum PlannerError {
+ #[error("Cycle detected in DAG: {0}")]
+ CycleDetected(String),
+
+ #[error("Invalid dependency: step '{step}' depends on unknown step '{dependency}'")]
+ InvalidDependency { step: String, dependency: String },
+
+ #[error("LLM generation failed: {0}")]
+ LlmError(String),
+
+ #[error("Requirement not covered: {0}")]
+ RequirementNotCovered(String),
+
+ #[error("Invalid plan: {0}")]
+ InvalidPlan(String),
+
+ #[error("Empty plan generated")]
+ EmptyPlan,
+}
+
+/// Generated step from LLM planning.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GeneratedStep {
+ /// Unique name within the chain
+ pub name: String,
+ /// Type of step (e.g., "research", "implement", "test", "review")
+ pub step_type: String,
+ /// Description of what this step accomplishes
+ pub description: String,
+ /// Names of steps this depends on
+ pub depends_on: Vec<String>,
+ /// IDs of requirements this step addresses
+ pub requirement_ids: Vec<String>,
+ /// Contract template fields
+ pub contract_template: Option<ContractTemplate>,
+}
+
+/// Template for contract creation from step.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ContractTemplate {
+ /// Contract name
+ pub name: String,
+ /// Contract description
+ pub description: String,
+ /// Contract type (e.g., "simple", "agentic")
+ pub contract_type: String,
+ /// Phases for the contract
+ pub phases: Vec<String>,
+ /// Tasks within the contract
+ pub tasks: Vec<TaskTemplate>,
+ /// Deliverables expected
+ pub deliverables: Vec<DeliverableTemplate>,
+}
+
+/// Template for task within contract.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TaskTemplate {
+ pub name: String,
+ pub plan: String,
+}
+
+/// Template for deliverable.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DeliverableTemplate {
+ pub id: String,
+ pub name: String,
+ pub priority: String,
+}
+
+/// Generated chain from planning.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GeneratedChain {
+ /// Name for the chain
+ pub name: String,
+ /// Description of the execution plan
+ pub description: String,
+ /// Steps in the chain
+ pub steps: Vec<GeneratedStep>,
+}
+
+/// Chain planner for LLM-based plan generation.
+pub struct ChainPlanner {
+ /// Default step types to suggest (reserved for future use)
+ #[allow(dead_code)]
+ default_step_types: Vec<String>,
+}
+
+impl Default for ChainPlanner {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl ChainPlanner {
+ /// Create a new chain planner.
+ pub fn new() -> Self {
+ Self {
+ default_step_types: vec![
+ "research".to_string(),
+ "design".to_string(),
+ "implement".to_string(),
+ "test".to_string(),
+ "review".to_string(),
+ "document".to_string(),
+ ],
+ }
+ }
+
+ /// Build a planning prompt for the LLM.
+ pub fn build_planning_prompt(&self, directive: &Directive) -> String {
+ let requirements: Vec<String> = directive
+ .requirements
+ .as_array()
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_object())
+ .map(|obj| {
+ let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("?");
+ let desc = obj
+ .get("description")
+ .and_then(|v| v.as_str())
+ .unwrap_or("?");
+ format!("- {}: {}", id, desc)
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+
+ let criteria: Vec<String> = directive
+ .acceptance_criteria
+ .as_array()
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_object())
+ .map(|obj| {
+ let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("?");
+ let criterion = obj
+ .get("criterion")
+ .and_then(|v| v.as_str())
+ .unwrap_or("?");
+ format!("- {}: {}", id, criterion)
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+
+ let constraints: Vec<String> = directive
+ .constraints
+ .as_array()
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str())
+ .map(|s| format!("- {}", s))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ format!(
+ r#"You are a software architect planning an execution chain for a coding task.
+
+## Directive Goal
+{goal}
+
+## Requirements
+{requirements}
+
+## Acceptance Criteria
+{criteria}
+
+## Constraints
+{constraints}
+
+## Instructions
+
+Create an execution plan as a chain of steps. Each step should:
+1. Have a unique, descriptive name (kebab-case)
+2. Specify its type (research, design, implement, test, review, document)
+3. Declare dependencies on prior steps (if any)
+4. Map to specific requirement IDs it addresses
+5. Include a contract template with tasks and deliverables
+
+The chain should form a valid DAG (no cycles). Steps can run in parallel if they don't depend on each other.
+
+Respond with a JSON object in this format:
+```json
+{{
+ "name": "chain-name",
+ "description": "Brief description of the plan",
+ "steps": [
+ {{
+ "name": "step-name",
+ "step_type": "implement",
+ "description": "What this step does",
+ "depends_on": ["prior-step-name"],
+ "requirement_ids": ["REQ-001"],
+ "contract_template": {{
+ "name": "Contract Name",
+ "description": "Contract description",
+ "contract_type": "simple",
+ "phases": ["plan", "execute"],
+ "tasks": [
+ {{"name": "Task 1", "plan": "Detailed plan for this task"}}
+ ],
+ "deliverables": [
+ {{"id": "del-1", "name": "Deliverable 1", "priority": "required"}}
+ ]
+ }}
+ }}
+ ]
+}}
+```
+
+Generate the optimal execution plan now."#,
+ goal = directive.goal,
+ requirements = requirements.join("\n"),
+ criteria = criteria.join("\n"),
+ constraints = constraints.join("\n"),
+ )
+ }
+
+ /// Parse LLM response into a generated chain.
+ pub fn parse_plan_response(&self, response: &str) -> Result<GeneratedChain, PlannerError> {
+ // Extract JSON from response (may be wrapped in markdown code blocks)
+ let json_str = extract_json_from_response(response)?;
+
+ let chain: GeneratedChain = serde_json::from_str(&json_str)
+ .map_err(|e| PlannerError::InvalidPlan(format!("JSON parse error: {}", e)))?;
+
+ if chain.steps.is_empty() {
+ return Err(PlannerError::EmptyPlan);
+ }
+
+ // Validate the chain
+ self.validate_chain(&chain)?;
+
+ Ok(chain)
+ }
+
+ /// Validate a generated chain.
+ pub fn validate_chain(&self, chain: &GeneratedChain) -> Result<(), PlannerError> {
+ // Build step name set
+ let step_names: HashSet<&str> = chain.steps.iter().map(|s| s.name.as_str()).collect();
+
+ // Check for duplicate names
+ if step_names.len() != chain.steps.len() {
+ return Err(PlannerError::InvalidPlan(
+ "Duplicate step names detected".to_string(),
+ ));
+ }
+
+ // Validate dependencies exist
+ for step in &chain.steps {
+ for dep in &step.depends_on {
+ if !step_names.contains(dep.as_str()) {
+ return Err(PlannerError::InvalidDependency {
+ step: step.name.clone(),
+ dependency: dep.clone(),
+ });
+ }
+ }
+ }
+
+ // Check for cycles using DFS
+ self.detect_cycles(chain)?;
+
+ Ok(())
+ }
+
+ /// Detect cycles in the chain DAG using DFS.
+ fn detect_cycles(&self, chain: &GeneratedChain) -> Result<(), PlannerError> {
+ let mut visited = HashSet::new();
+ let mut rec_stack = HashSet::new();
+
+ // Build adjacency map
+ let adj: HashMap<&str, Vec<&str>> = chain
+ .steps
+ .iter()
+ .map(|s| (s.name.as_str(), s.depends_on.iter().map(|d| d.as_str()).collect()))
+ .collect();
+
+ for step in &chain.steps {
+ if !visited.contains(step.name.as_str()) {
+ if self.has_cycle(&step.name, &adj, &mut visited, &mut rec_stack) {
+ return Err(PlannerError::CycleDetected(step.name.clone()));
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ fn has_cycle<'a>(
+ &self,
+ node: &'a str,
+ adj: &HashMap<&'a str, Vec<&'a str>>,
+ visited: &mut HashSet<&'a str>,
+ rec_stack: &mut HashSet<&'a str>,
+ ) -> bool {
+ visited.insert(node);
+ rec_stack.insert(node);
+
+ if let Some(deps) = adj.get(node) {
+ for &dep in deps {
+ if !visited.contains(dep) {
+ if self.has_cycle(dep, adj, visited, rec_stack) {
+ return true;
+ }
+ } else if rec_stack.contains(dep) {
+ return true;
+ }
+ }
+ }
+
+ rec_stack.remove(node);
+ false
+ }
+
+ /// Check that all requirements are covered by at least one step.
+ pub fn check_requirement_coverage(
+ &self,
+ chain: &GeneratedChain,
+ directive: &Directive,
+ ) -> Result<(), PlannerError> {
+ let required_ids: HashSet<String> = directive
+ .requirements
+ .as_array()
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.get("id").and_then(|id| id.as_str()))
+ .map(|s| s.to_string())
+ .collect()
+ })
+ .unwrap_or_default();
+
+ let covered_ids: HashSet<String> = chain
+ .steps
+ .iter()
+ .flat_map(|s| s.requirement_ids.clone())
+ .collect();
+
+ for req_id in required_ids {
+ if !covered_ids.contains(&req_id) {
+ return Err(PlannerError::RequirementNotCovered(req_id));
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Get topological order of steps.
+ pub fn topological_sort<'a>(
+ &self,
+ chain: &'a GeneratedChain,
+ ) -> Result<Vec<&'a str>, PlannerError> {
+ let mut in_degree: HashMap<&str, usize> = HashMap::new();
+ let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
+
+ // Initialize
+ for step in &chain.steps {
+ in_degree.entry(step.name.as_str()).or_insert(0);
+ adj.entry(step.name.as_str()).or_insert_with(Vec::new);
+ }
+
+ // Build graph (reversed - edges from dependency to dependent)
+ for step in &chain.steps {
+ for dep in &step.depends_on {
+ adj.entry(dep.as_str())
+ .or_insert_with(Vec::new)
+ .push(step.name.as_str());
+ *in_degree.entry(step.name.as_str()).or_insert(0) += 1;
+ }
+ }
+
+ // Kahn's algorithm
+ let mut queue: Vec<&str> = in_degree
+ .iter()
+ .filter(|&(_, deg)| *deg == 0)
+ .map(|(&name, _)| name)
+ .collect();
+
+ let mut result = Vec::new();
+
+ while let Some(node) = queue.pop() {
+ result.push(node);
+
+ if let Some(neighbors) = adj.get(node) {
+ for &neighbor in neighbors {
+ let deg = in_degree.get_mut(neighbor).unwrap();
+ *deg -= 1;
+ if *deg == 0 {
+ queue.push(neighbor);
+ }
+ }
+ }
+ }
+
+ if result.len() != chain.steps.len() {
+ return Err(PlannerError::CycleDetected(
+ "Cycle detected during topological sort".to_string(),
+ ));
+ }
+
+ Ok(result)
+ }
+
+ /// Convert generated steps to AddStepRequest for database insertion.
+ pub fn steps_to_requests(
+ &self,
+ chain: &GeneratedChain,
+ step_id_map: &HashMap<String, Uuid>,
+ ) -> Vec<AddStepRequest> {
+ chain
+ .steps
+ .iter()
+ .map(|step| {
+ let depends_on: Vec<Uuid> = step
+ .depends_on
+ .iter()
+ .filter_map(|name| step_id_map.get(name))
+ .copied()
+ .collect();
+
+ let task_plan = step
+ .contract_template
+ .as_ref()
+ .and_then(|t| t.tasks.first())
+ .map(|t| t.plan.clone());
+
+ AddStepRequest {
+ name: step.name.clone(),
+ description: Some(step.description.clone()),
+ step_type: Some(step.step_type.clone()),
+ contract_type: step.contract_template.as_ref().map(|t| t.contract_type.clone()),
+ initial_phase: Some("plan".to_string()),
+ task_plan,
+ phases: step.contract_template.as_ref().map(|t| t.phases.clone()),
+ depends_on: Some(depends_on),
+ parallel_group: None,
+ requirement_ids: Some(step.requirement_ids.clone()),
+ acceptance_criteria_ids: None,
+ verifier_config: None,
+ editor_x: None,
+ editor_y: None,
+ }
+ })
+ .collect()
+ }
+
+ /// Compute editor positions for steps based on DAG layout.
+ pub fn compute_editor_positions(
+ &self,
+ chain: &GeneratedChain,
+ ) -> HashMap<String, (f64, f64)> {
+ let depths = self.get_step_depths(chain);
+ let mut positions: HashMap<String, (f64, f64)> = HashMap::new();
+
+ // Group by depth
+ let mut by_depth: HashMap<usize, Vec<&str>> = HashMap::new();
+ for step in &chain.steps {
+ let depth = depths.get(&step.name).copied().unwrap_or(0);
+ by_depth.entry(depth).or_default().push(&step.name);
+ }
+
+ // Compute positions: x based on depth, y based on index within depth
+ let x_spacing = 250.0;
+ let y_spacing = 150.0;
+
+ for (depth, steps) in &by_depth {
+ let x = (*depth as f64) * x_spacing + 100.0;
+ for (i, name) in steps.iter().enumerate() {
+ let y = (i as f64) * y_spacing + 100.0;
+ positions.insert(name.to_string(), (x, y));
+ }
+ }
+
+ positions
+ }
+
+ /// Get depth of each step in the DAG.
+ fn get_step_depths(&self, chain: &GeneratedChain) -> HashMap<String, usize> {
+ let mut depths: HashMap<String, usize> = HashMap::new();
+
+ // Build dependency map
+ let deps: HashMap<String, Vec<String>> = chain
+ .steps
+ .iter()
+ .map(|s| (s.name.clone(), s.depends_on.clone()))
+ .collect();
+
+ fn compute_depth(
+ name: &str,
+ deps: &HashMap<String, Vec<String>>,
+ depths: &mut HashMap<String, usize>,
+ ) -> usize {
+ if let Some(&d) = depths.get(name) {
+ return d;
+ }
+
+ let depth = deps
+ .get(name)
+ .map(|dep_list| {
+ dep_list
+ .iter()
+ .map(|d| compute_depth(d, deps, depths) + 1)
+ .max()
+ .unwrap_or(0)
+ })
+ .unwrap_or(0);
+
+ depths.insert(name.to_string(), depth);
+ depth
+ }
+
+ for step in &chain.steps {
+ compute_depth(&step.name, &deps, &mut depths);
+ }
+
+ depths
+ }
+
+ /// Build a replanning prompt that preserves completed steps.
+ pub fn build_replan_prompt(
+ &self,
+ directive: &Directive,
+ completed_steps: &[ChainStep],
+ failed_step: Option<&ChainStep>,
+ reason: &str,
+ ) -> String {
+ let completed_summary: Vec<String> = completed_steps
+ .iter()
+ .map(|s| format!("- {} ({}): completed", s.name, s.step_type))
+ .collect();
+
+ let failed_summary = failed_step
+ .map(|s| format!("Failed step: {} - {}", s.name, s.description.as_deref().unwrap_or("")))
+ .unwrap_or_default();
+
+ format!(
+ r#"You are a software architect replanning an execution chain.
+
+## Original Goal
+{goal}
+
+## Completed Steps (preserve these)
+{completed}
+
+## Failure Information
+{failed}
+Reason: {reason}
+
+## Instructions
+Generate a new execution plan that:
+1. Preserves all completed work
+2. Addresses the failure
+3. Continues toward the original goal
+
+Use the same JSON format as before. Do not include already completed steps."#,
+ goal = directive.goal,
+ completed = completed_summary.join("\n"),
+ failed = failed_summary,
+ reason = reason,
+ )
+ }
+}
+
+/// Extract JSON from LLM response (handles markdown code blocks).
+fn extract_json_from_response(response: &str) -> Result<String, PlannerError> {
+ // Try to find JSON in code block
+ if let Some(start) = response.find("```json") {
+ let json_start = start + 7;
+ if let Some(end) = response[json_start..].find("```") {
+ return Ok(response[json_start..json_start + end].trim().to_string());
+ }
+ }
+
+ // Try to find JSON in generic code block
+ if let Some(start) = response.find("```") {
+ let block_start = start + 3;
+ // Skip language identifier if present
+ let json_start = response[block_start..]
+ .find('\n')
+ .map(|i| block_start + i + 1)
+ .unwrap_or(block_start);
+ if let Some(end) = response[json_start..].find("```") {
+ return Ok(response[json_start..json_start + end].trim().to_string());
+ }
+ }
+
+ // Try to parse the whole thing as JSON
+ if response.trim().starts_with('{') {
+ return Ok(response.trim().to_string());
+ }
+
+ Err(PlannerError::InvalidPlan(
+ "Could not extract JSON from response".to_string(),
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_test_chain() -> GeneratedChain {
+ GeneratedChain {
+ name: "test-chain".to_string(),
+ description: "Test chain".to_string(),
+ steps: vec![
+ GeneratedStep {
+ name: "step-a".to_string(),
+ step_type: "research".to_string(),
+ description: "Research step".to_string(),
+ depends_on: vec![],
+ requirement_ids: vec!["REQ-001".to_string()],
+ contract_template: None,
+ },
+ GeneratedStep {
+ name: "step-b".to_string(),
+ step_type: "implement".to_string(),
+ description: "Implementation step".to_string(),
+ depends_on: vec!["step-a".to_string()],
+ requirement_ids: vec!["REQ-002".to_string()],
+ contract_template: None,
+ },
+ GeneratedStep {
+ name: "step-c".to_string(),
+ step_type: "test".to_string(),
+ description: "Test step".to_string(),
+ depends_on: vec!["step-b".to_string()],
+ requirement_ids: vec!["REQ-001".to_string()],
+ contract_template: None,
+ },
+ ],
+ }
+ }
+
+ #[test]
+ fn test_validate_chain_valid() {
+ let planner = ChainPlanner::new();
+ let chain = make_test_chain();
+ assert!(planner.validate_chain(&chain).is_ok());
+ }
+
+ #[test]
+ fn test_validate_chain_invalid_dependency() {
+ let planner = ChainPlanner::new();
+ let mut chain = make_test_chain();
+ chain.steps[1].depends_on = vec!["nonexistent".to_string()];
+
+ let result = planner.validate_chain(&chain);
+ assert!(matches!(result, Err(PlannerError::InvalidDependency { .. })));
+ }
+
+ #[test]
+ fn test_validate_chain_cycle() {
+ let planner = ChainPlanner::new();
+ let chain = GeneratedChain {
+ name: "cyclic".to_string(),
+ description: "Has cycle".to_string(),
+ steps: vec![
+ GeneratedStep {
+ name: "a".to_string(),
+ step_type: "research".to_string(),
+ description: "A".to_string(),
+ depends_on: vec!["c".to_string()],
+ requirement_ids: vec![],
+ contract_template: None,
+ },
+ GeneratedStep {
+ name: "b".to_string(),
+ step_type: "implement".to_string(),
+ description: "B".to_string(),
+ depends_on: vec!["a".to_string()],
+ requirement_ids: vec![],
+ contract_template: None,
+ },
+ GeneratedStep {
+ name: "c".to_string(),
+ step_type: "test".to_string(),
+ description: "C".to_string(),
+ depends_on: vec!["b".to_string()],
+ requirement_ids: vec![],
+ contract_template: None,
+ },
+ ],
+ };
+
+ let result = planner.validate_chain(&chain);
+ assert!(matches!(result, Err(PlannerError::CycleDetected(_))));
+ }
+
+ #[test]
+ fn test_topological_sort() {
+ let planner = ChainPlanner::new();
+ let chain = make_test_chain();
+ let order = planner.topological_sort(&chain).unwrap();
+
+ // step-a must come before step-b, step-b before step-c
+ let pos_a = order.iter().position(|&n| n == "step-a").unwrap();
+ let pos_b = order.iter().position(|&n| n == "step-b").unwrap();
+ let pos_c = order.iter().position(|&n| n == "step-c").unwrap();
+
+ assert!(pos_a < pos_b);
+ assert!(pos_b < pos_c);
+ }
+
+ #[test]
+ fn test_extract_json_from_code_block() {
+ let response = r#"
+Here's the plan:
+
+```json
+{"name": "test"}
+```
+
+That's it!
+"#;
+ let json = extract_json_from_response(response).unwrap();
+ assert_eq!(json, r#"{"name": "test"}"#);
+ }
+
+ #[test]
+ fn test_extract_json_raw() {
+ let response = r#"{"name": "test"}"#;
+ let json = extract_json_from_response(response).unwrap();
+ assert_eq!(json, r#"{"name": "test"}"#);
+ }
+}