//! Directive orchestrator — automates the full directive lifecycle.
//!
//! Runs as a background loop, polling every 15s to:
//! 1. Plan: active directives with no steps → spawn planning task
//! 2. Execute: ready steps → spawn execution tasks
//! 3. Monitor: detect task completion → update step status, advance DAG
//! 4. Re-plan: goal updated → spawn new planning task
use sqlx::PgPool;
use uuid::Uuid;
use crate::db::models::{CreateTaskRequest, UpdateTaskRequest};
use crate::db::repository;
use crate::server::state::{DaemonCommand, SharedState};
pub struct DirectiveOrchestrator {
pool: PgPool,
state: SharedState,
}
impl DirectiveOrchestrator {
pub fn new(pool: PgPool, state: SharedState) -> Self {
Self { pool, state }
}
/// Run one orchestration tick — called every 15s.
pub async fn tick(&mut self) -> Result<(), anyhow::Error> {
self.phase_planning().await?;
self.phase_execution().await?;
self.phase_monitoring().await?;
self.phase_replanning().await?;
self.phase_completion().await?;
Ok(())
}
/// Phase 1: Active directives with no steps and no orchestrator task → spawn planning task.
async fn phase_planning(&self) -> Result<(), anyhow::Error> {
let directives = repository::get_directives_needing_planning(&self.pool).await?;
for directive in directives {
tracing::info!(
directive_id = %directive.id,
title = %directive.title,
"Directive needs planning — spawning planning task"
);
let plan = build_planning_prompt(&directive, &[], 1, &[]);
if let Err(e) = self
.spawn_orchestrator_task(
directive.id,
directive.owner_id,
format!("Plan: {}", directive.title),
plan,
directive.repository_url.as_deref(),
directive.base_branch.as_deref(),
)
.await
{
tracing::warn!(
directive_id = %directive.id,
error = %e,
"Failed to spawn planning task"
);
}
}
Ok(())
}
/// Phase 2: Ready steps with no task → create execution task and dispatch.
/// Also retries pending directive tasks that weren't dispatched previously.
async fn phase_execution(&self) -> Result<(), anyhow::Error> {
// Create tasks for ready steps
let steps = repository::get_ready_steps_for_dispatch(&self.pool).await?;
for step in steps {
tracing::info!(
step_id = %step.step_id,
directive_id = %step.directive_id,
step_name = %step.step_name,
"Dispatching execution task for ready step"
);
// Resolve dependency steps to their task IDs for worktree continuation
let dep_tasks =
repository::get_step_dependency_tasks(&self.pool, &step.depends_on).await?;
let mut continue_from_task_id = dep_tasks.first().map(|d| d.task_id);
// If no dependency tasks resolved, try to continue from previous work:
// 1) Use the directive's PR branch as base (contains all previous merged work)
// 2) Fall back to the last completed step's task for worktree continuation
let effective_base_branch = if continue_from_task_id.is_none() {
if step.pr_branch.is_some() {
tracing::info!(
step_id = %step.step_id,
pr_branch = ?step.pr_branch,
"Step has no deps — using directive PR branch as base"
);
step.pr_branch.as_deref()
} else {
// No PR branch yet — try to continue from the last completed step's worktree
match repository::get_last_completed_step_task_id(
&self.pool,
step.directive_id,
)
.await
{
Ok(Some(task_id)) => {
tracing::info!(
step_id = %step.step_id,
continue_from = %task_id,
"Step has no deps, no PR branch — continuing from last completed task"
);
continue_from_task_id = Some(task_id);
step.base_branch.as_deref()
}
_ => step.base_branch.as_deref(),
}
}
} else {
step.base_branch.as_deref()
};
let task_plan = step
.task_plan
.as_deref()
.unwrap_or("Execute the step described below.");
// Build merge instructions for additional dependencies (beyond the first)
let merge_preamble = if dep_tasks.len() > 1 {
use crate::daemon::worktree::{sanitize_name, short_uuid};
let merge_lines: Vec<String> = dep_tasks[1..]
.iter()
.map(|d| {
let branch = format!(
"makima/task-{}-{}",
sanitize_name(&d.task_name),
short_uuid(d.task_id)
);
format!("git merge origin/{} --no-edit", branch)
})
.collect();
format!(
"IMPORTANT — MERGE DEPENDENCY BRANCHES FIRST:\n\
This step continues from one dependency's worktree, but also depends on \
additional branches. Before starting work, run:\n\
```\ngit fetch origin\n{}\n```\n\
Resolve any merge conflicts sensibly, then proceed.\n\n",
merge_lines.join("\n"),
)
} else {
String::new()
};
let plan = format!(
"You are executing a step in directive \"{directive_title}\".\n\n\
STEP: {step_name}\n\
DESCRIPTION: {description}\n\n\
{merge_preamble}\
INSTRUCTIONS:\n{task_plan}\n\
When done, the system will automatically mark this step as completed.\n\
If you cannot complete the task, report the failure clearly.",
directive_title = step.directive_title,
step_name = step.step_name,
description = step.step_description.as_deref().unwrap_or("(none)"),
merge_preamble = merge_preamble,
task_plan = task_plan,
);
match self
.spawn_step_task(
step.step_id,
step.directive_id,
step.owner_id,
format!("{}: {}", step.directive_title, step.step_name),
plan,
step.repository_url.as_deref(),
effective_base_branch,
continue_from_task_id,
)
.await
{
Ok(()) => {}
Err(e) => {
tracing::warn!(
step_id = %step.step_id,
error = %e,
"Failed to spawn execution task for step"
);
}
}
}
// Retry pending directive tasks that weren't dispatched
let pending = repository::get_pending_directive_tasks(&self.pool).await?;
for task in pending {
if self
.try_dispatch_task(task.id, task.owner_id, &task.name, &task.plan, task.version)
.await
{
// Task dispatched — mark its step as running if it has one
if let Some(step_id) = task.directive_step_id {
let _ = repository::set_step_running(&self.pool, step_id).await;
}
}
}
Ok(())
}
/// Phase 3: Monitor running steps and orchestrator tasks.
async fn phase_monitoring(&self) -> Result<(), anyhow::Error> {
// Check running steps
let running = repository::get_running_steps_with_tasks(&self.pool).await?;
for step in running {
match step.task_status.as_str() {
"completed" | "merged" | "done" => {
tracing::info!(
step_id = %step.step_id,
directive_id = %step.directive_id,
task_id = %step.task_id,
"Step task completed — updating step to completed"
);
let update = crate::db::models::UpdateDirectiveStepRequest {
status: Some("completed".to_string()),
..Default::default()
};
repository::update_directive_step(&self.pool, step.step_id, update).await?;
repository::advance_directive_ready_steps(&self.pool, step.directive_id)
.await?;
repository::check_directive_idle(&self.pool, step.directive_id).await?;
}
"failed" | "interrupted" => {
tracing::warn!(
step_id = %step.step_id,
directive_id = %step.directive_id,
task_id = %step.task_id,
task_status = %step.task_status,
"Step task failed — updating step to failed"
);
let update = crate::db::models::UpdateDirectiveStepRequest {
status: Some("failed".to_string()),
..Default::default()
};
repository::update_directive_step(&self.pool, step.step_id, update).await?;
repository::advance_directive_ready_steps(&self.pool, step.directive_id)
.await?;
repository::check_directive_idle(&self.pool, step.directive_id).await?;
}
"paused" => {
// Task is paused (e.g., waiting for user answer in reconcile mode)
// Keep step in running status — task will auto-resume when answered
tracing::debug!(
step_id = %step.step_id,
directive_id = %step.directive_id,
task_id = %step.task_id,
"Step task paused (waiting for user response) — keeping step running"
);
}
_ => {
// Still running — do nothing
}
}
}
// Check orchestrator (planning) tasks
let orch_tasks = repository::get_orchestrator_tasks_to_check(&self.pool).await?;
for orch in orch_tasks {
match orch.task_status.as_str() {
"completed" | "merged" | "done" => {
tracing::info!(
directive_id = %orch.directive_id,
task_id = %orch.orchestrator_task_id,
"Planning task completed — clearing orchestrator task"
);
repository::clear_orchestrator_task(&self.pool, orch.directive_id).await?;
// Advance DAG — planning task should have created steps
repository::advance_directive_ready_steps(&self.pool, orch.directive_id)
.await?;
}
"failed" | "interrupted" => {
tracing::warn!(
directive_id = %orch.directive_id,
task_id = %orch.orchestrator_task_id,
"Planning task failed — pausing directive"
);
repository::clear_orchestrator_task(&self.pool, orch.directive_id).await?;
repository::set_directive_status(
&self.pool,
orch.owner_id,
orch.directive_id,
"paused",
)
.await?;
}
_ => {}
}
}
Ok(())
}
/// Phase 4: Re-planning — goal updated after latest step creation.
async fn phase_replanning(&self) -> Result<(), anyhow::Error> {
let directives = repository::get_directives_needing_replanning(&self.pool).await?;
for directive in directives {
tracing::info!(
directive_id = %directive.id,
title = %directive.title,
"Directive goal updated — spawning re-planning task"
);
let existing_steps =
repository::list_directive_steps(&self.pool, directive.id).await?;
let generation =
repository::get_directive_max_generation(&self.pool, directive.id).await? + 1;
let goal_history =
repository::get_directive_goal_history(&self.pool, directive.id, 3).await?;
let plan =
build_planning_prompt(&directive, &existing_steps, generation, &goal_history);
if let Err(e) = self
.spawn_orchestrator_task(
directive.id,
directive.owner_id,
format!("Re-plan: {}", directive.title),
plan,
directive.repository_url.as_deref(),
directive.base_branch.as_deref(),
)
.await
{
tracing::warn!(
directive_id = %directive.id,
error = %e,
"Failed to spawn re-planning task"
);
}
}
Ok(())
}
/// Spawn a planning/re-planning task and assign it as the directive's orchestrator task.
async fn spawn_orchestrator_task(
&self,
directive_id: Uuid,
owner_id: Uuid,
name: String,
plan: String,
repo_url: Option<&str>,
base_branch: Option<&str>,
) -> Result<(), anyhow::Error> {
let req = CreateTaskRequest {
contract_id: None,
name,
description: Some("Directive planning task".to_string()),
plan,
parent_task_id: None,
is_supervisor: false,
priority: 0,
repository_url: repo_url.map(|s| s.to_string()),
base_branch: base_branch.map(|s| s.to_string()),
target_branch: None,
merge_mode: None,
target_repo_path: None,
completion_action: None,
continue_from_task_id: None,
copy_files: None,
checkpoint_sha: None,
branched_from_task_id: None,
conversation_history: None,
supervisor_worktree_task_id: None,
directive_id: Some(directive_id),
directive_step_id: None,
};
let task = repository::create_task_for_owner(&self.pool, owner_id, req).await?;
repository::assign_orchestrator_task(&self.pool, directive_id, task.id).await?;
// Cancel any old planning tasks for this directive (superseded by the new one)
let cancelled =
repository::cancel_old_planning_tasks(&self.pool, directive_id, task.id).await?;
if cancelled > 0 {
tracing::info!(
directive_id = %directive_id,
cancelled_count = cancelled,
"Cancelled old planning tasks superseded by new plan"
);
}
// Try to dispatch to a daemon
self.try_dispatch_task(task.id, owner_id, &task.name, &task.plan, task.version).await;
Ok(())
}
/// Spawn an execution task for a step.
/// Links the task to the step but only marks the step as 'running' once dispatched.
async fn spawn_step_task(
&self,
step_id: Uuid,
directive_id: Uuid,
owner_id: Uuid,
name: String,
plan: String,
repo_url: Option<&str>,
base_branch: Option<&str>,
continue_from_task_id: Option<Uuid>,
) -> Result<(), anyhow::Error> {
let req = CreateTaskRequest {
contract_id: None,
name,
description: Some("Directive step execution task".to_string()),
plan,
parent_task_id: None,
is_supervisor: false,
priority: 0,
repository_url: repo_url.map(|s| s.to_string()),
base_branch: base_branch.map(|s| s.to_string()),
target_branch: None,
merge_mode: None,
target_repo_path: None,
completion_action: Some("branch".to_string()),
continue_from_task_id,
copy_files: None,
checkpoint_sha: None,
branched_from_task_id: None,
conversation_history: None,
supervisor_worktree_task_id: None,
directive_id: Some(directive_id),
directive_step_id: Some(step_id),
};
let task = repository::create_task_for_owner(&self.pool, owner_id, req).await?;
// Link the task to the step (sets task_id) but keep step as 'ready' for now
repository::link_task_to_step(&self.pool, step_id, task.id).await?;
// Only mark step as 'running' if we can actually dispatch the task
if self
.try_dispatch_task(task.id, owner_id, &task.name, &task.plan, task.version)
.await
{
repository::set_step_running(&self.pool, step_id).await?;
}
Ok(())
}
/// Try to dispatch a task to an available daemon. Returns true if dispatched.
async fn try_dispatch_task(
&self,
task_id: Uuid,
owner_id: Uuid,
task_name: &str,
plan: &str,
version: i32,
) -> bool {
let Some(daemon_id) = self.state.find_alternative_daemon(owner_id, &[]) else {
tracing::info!(
task_id = %task_id,
"No daemon available for directive task — leaving pending for retry"
);
return false;
};
// Update task status to starting and assign daemon
let update_req = UpdateTaskRequest {
status: Some("starting".to_string()),
daemon_id: Some(daemon_id),
version: Some(version),
..Default::default()
};
match repository::update_task_for_owner(&self.pool, task_id, owner_id, update_req).await {
Ok(Some(updated_task)) => {
let command = DaemonCommand::SpawnTask {
task_id,
task_name: task_name.to_string(),
plan: plan.to_string(),
repo_url: updated_task.repository_url.clone(),
base_branch: updated_task.base_branch.clone(),
target_branch: updated_task.target_branch.clone(),
parent_task_id: None,
depth: 0,
is_orchestrator: false,
target_repo_path: None,
completion_action: updated_task.completion_action.clone(),
continue_from_task_id: updated_task.continue_from_task_id,
copy_files: None,
contract_id: None,
is_supervisor: false,
autonomous_loop: false,
resume_session: false,
conversation_history: None,
patch_data: None,
patch_base_sha: None,
local_only: false,
auto_merge_local: false,
supervisor_worktree_task_id: None,
directive_id: updated_task.directive_id,
};
if let Err(e) = self.state.send_daemon_command(daemon_id, command).await {
tracing::warn!(
task_id = %task_id,
daemon_id = %daemon_id,
error = %e,
"Failed to send SpawnTask to daemon for directive task"
);
return false;
} else {
tracing::info!(
task_id = %task_id,
daemon_id = %daemon_id,
"Dispatched directive task to daemon"
);
return true;
}
}
Ok(None) => {
tracing::warn!(task_id = %task_id, "Task not found when trying to dispatch");
}
Err(e) => {
tracing::warn!(task_id = %task_id, error = %e, "Failed to update task for dispatch");
}
}
false
}
/// Phase 5: Completion — spawn PR-creation tasks for idle directives.
async fn phase_completion(&self) -> Result<(), anyhow::Error> {
// Part 1: Spawn completion tasks for idle directives
let directives = repository::get_idle_directives_needing_completion(&self.pool).await?;
for directive in directives {
// Atomically claim this directive for completion using a placeholder.
// This prevents a concurrent tick from also spawning a completion task.
let placeholder_id = Uuid::new_v4();
let claimed = repository::claim_directive_for_completion(
&self.pool,
directive.id,
placeholder_id,
)
.await?;
if !claimed {
tracing::debug!(
directive_id = %directive.id,
"Directive already claimed for completion — skipping"
);
continue;
}
tracing::info!(
directive_id = %directive.id,
title = %directive.title,
"Directive idle — spawning completion task for PR"
);
let step_tasks = repository::get_completed_step_tasks(&self.pool, directive.id).await?;
if step_tasks.is_empty() {
// Release the claim since there's nothing to complete
let _ = repository::clear_completion_task(&self.pool, directive.id).await;
continue;
}
let base_branch = directive
.base_branch
.as_deref()
.unwrap_or("main");
let directive_branch = format!(
"makima/directive-{}-{}",
crate::daemon::worktree::sanitize_name(&directive.title),
crate::daemon::worktree::short_uuid(directive.id),
);
// Compute step branch names using the same formula as execute_completion_action
let step_branches: Vec<String> = step_tasks
.iter()
.map(|st| {
format!(
"makima/{}-{}",
crate::daemon::worktree::sanitize_name(&st.task_name),
crate::daemon::worktree::short_uuid(st.task_id),
)
})
.collect();
let prompt = build_completion_prompt(
&directive,
&step_tasks,
&step_branches,
&directive_branch,
base_branch,
);
match self
.spawn_completion_task(
directive.id,
directive.owner_id,
format!("PR: {}", directive.title),
prompt,
directive.repository_url.as_deref(),
directive.base_branch.as_deref(),
)
.await
{
Ok(task_id) => {
// Store pr_branch on directive immediately
let update = crate::db::models::UpdateDirectiveRequest {
pr_branch: Some(directive_branch.clone()),
..Default::default()
};
let _ = repository::update_directive_for_owner(
&self.pool,
directive.owner_id,
directive.id,
update,
)
.await;
// Replace placeholder with the real task ID
repository::assign_completion_task(&self.pool, directive.id, task_id).await?;
}
Err(e) => {
tracing::warn!(
directive_id = %directive.id,
error = %e,
"Failed to spawn completion task — releasing claim"
);
// Release the claim so it can be retried on the next tick
let _ = repository::clear_completion_task(&self.pool, directive.id).await;
}
}
}
// Part 2: Monitor completion tasks
let checks = repository::get_completion_tasks_to_check(&self.pool).await?;
for check in checks {
match check.task_status.as_str() {
"completed" | "merged" | "done" => {
tracing::info!(
directive_id = %check.directive_id,
task_id = %check.completion_task_id,
"Completion task finished"
);
// If directive has no pr_url yet, try to extract from task output
if check.pr_url.is_none() {
match self.extract_pr_url_from_task(check.completion_task_id).await {
Ok(Some(url)) => {
tracing::info!(
directive_id = %check.directive_id,
pr_url = %url,
"Extracted PR URL from completion task output"
);
let update = crate::db::models::UpdateDirectiveRequest {
pr_url: Some(url),
..Default::default()
};
let _ = repository::update_directive_for_owner(
&self.pool,
check.owner_id,
check.directive_id,
update,
)
.await;
}
Ok(None) => {
if check.task_name.starts_with("Verify PR:") {
// Verification task failed to find/create PR — mark directive completed (one-shot)
tracing::warn!(
directive_id = %check.directive_id,
task_id = %check.completion_task_id,
"Verification task finished but no PR URL found — marking directive completed"
);
let update = crate::db::models::UpdateDirectiveRequest {
status: Some("completed".to_string()),
..Default::default()
};
let _ = repository::update_directive_for_owner(
&self.pool,
check.owner_id,
check.directive_id,
update,
)
.await;
} else {
tracing::warn!(
directive_id = %check.directive_id,
task_id = %check.completion_task_id,
"Completion task finished but no PR URL found — will spawn verifier"
);
}
}
Err(e) => {
tracing::warn!(
directive_id = %check.directive_id,
error = %e,
"Failed to extract PR URL from completion task output"
);
}
}
}
repository::clear_completion_task(&self.pool, check.directive_id).await?;
}
"failed" | "interrupted" => {
tracing::warn!(
directive_id = %check.directive_id,
task_id = %check.completion_task_id,
"Completion task failed"
);
repository::clear_completion_task(&self.pool, check.directive_id).await?;
}
_ => {
// Still running
}
}
}
// Part 3: Spawn verification tasks for directives with pr_branch but no pr_url
let verify_directives =
repository::get_directives_needing_verification(&self.pool).await?;
for directive in verify_directives {
let placeholder_id = Uuid::new_v4();
let claimed = repository::claim_directive_for_completion(
&self.pool,
directive.id,
placeholder_id,
)
.await?;
if !claimed {
continue;
}
tracing::info!(
directive_id = %directive.id,
title = %directive.title,
"Directive has pr_branch but no pr_url — spawning verification task"
);
let pr_branch = directive.pr_branch.as_deref().unwrap_or("unknown");
let base_branch = directive.base_branch.as_deref().unwrap_or("main");
let prompt = build_verification_prompt(&directive, pr_branch, base_branch);
match self
.spawn_completion_task(
directive.id,
directive.owner_id,
format!("Verify PR: {}", directive.title),
prompt,
directive.repository_url.as_deref(),
directive.base_branch.as_deref(),
)
.await
{
Ok(task_id) => {
repository::assign_completion_task(&self.pool, directive.id, task_id).await?;
}
Err(e) => {
tracing::warn!(
directive_id = %directive.id,
error = %e,
"Failed to spawn verification task — releasing claim"
);
let _ = repository::clear_completion_task(&self.pool, directive.id).await;
}
}
}
Ok(())
}
/// Spawn a completion task that creates/updates a PR from step branches.
async fn spawn_completion_task(
&self,
directive_id: Uuid,
owner_id: Uuid,
name: String,
plan: String,
repo_url: Option<&str>,
base_branch: Option<&str>,
) -> Result<Uuid, anyhow::Error> {
let req = CreateTaskRequest {
contract_id: None,
name,
description: Some("Directive PR completion task".to_string()),
plan,
parent_task_id: None,
is_supervisor: false,
priority: 0,
repository_url: repo_url.map(|s| s.to_string()),
base_branch: base_branch.map(|s| s.to_string()),
target_branch: None,
merge_mode: None,
target_repo_path: None,
completion_action: None,
continue_from_task_id: None,
copy_files: None,
checkpoint_sha: None,
branched_from_task_id: None,
conversation_history: None,
supervisor_worktree_task_id: None,
directive_id: Some(directive_id),
directive_step_id: None,
};
let task = repository::create_task_for_owner(&self.pool, owner_id, req).await?;
// Try to dispatch to a daemon
self.try_dispatch_task(task.id, owner_id, &task.name, &task.plan, task.version)
.await;
Ok(task.id)
}
/// Extract a GitHub PR URL from a completion task's output events.
/// Searches task output for patterns like `https://github.com/.../pull/123`.
async fn extract_pr_url_from_task(
&self,
task_id: Uuid,
) -> Result<Option<String>, anyhow::Error> {
let events = repository::get_task_output(&self.pool, task_id, Some(500)).await?;
let pr_url_re = regex::Regex::new(r"https://github\.com/[^/\s]+/[^/\s]+/pull/\d+")?;
// Search from most recent events backwards for the PR URL
for event in events.iter().rev() {
if let Some(ref data) = event.event_data {
// Check the content field inside event_data JSON
if let Some(content) = data.get("content").and_then(|c| c.as_str()) {
if let Some(m) = pr_url_re.find(content) {
return Ok(Some(m.as_str().to_string()));
}
}
// Also check the raw JSON string representation as fallback
let data_str = data.to_string();
if let Some(m) = pr_url_re.find(&data_str) {
return Ok(Some(m.as_str().to_string()));
}
}
}
Ok(None)
}
}
/// Build the planning prompt for a directive.
fn build_planning_prompt(
directive: &crate::db::models::Directive,
existing_steps: &[crate::db::models::DirectiveStep],
generation: i32,
goal_history: &[crate::db::models::DirectiveGoalHistory],
) -> String {
let mut prompt = String::new();
if !existing_steps.is_empty() {
// ── RE-PLANNING header ──────────────────────────────────────
prompt.push_str(&format!(
"⚠️ RE-PLANNING: The GOAL has been updated — you must re-evaluate ALL existing steps.\n\
Previous steps were planned for an earlier version of the goal. Some may no longer be \
relevant. Review each step below and act according to the instructions per status category.\n\n",
));
// ── Goal changes section ──────────────────────────────────
if !goal_history.is_empty() {
prompt.push_str("-- GOAL CHANGES --\n");
prompt.push_str("The goal has been updated. Compare the previous and current goals to understand what changed:\n\n");
for (i, entry) in goal_history.iter().enumerate() {
if i == 0 {
prompt.push_str(&format!(
"PREVIOUS GOAL (replaced at {}):\n{}\n\n",
entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
entry.goal
));
} else {
prompt.push_str(&format!(
"OLDER GOAL (version from {}):\n{}\n\n",
entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
entry.goal
));
}
}
prompt.push_str(&format!(
"CURRENT GOAL (what you must plan for):\n{}\n\n",
directive.goal
));
prompt.push_str(
"IMPORTANT: Analyze what CHANGED between the previous goal and the current goal.\n\
- If the change is minor (e.g., clarification, small addition), try to KEEP existing pending steps and only add/modify what is needed for the delta.\n\
- If the change is major (e.g., completely different objective), you may need to remove most pending steps and create a fresh plan.\n\
- Always preserve completed and running steps - they represent work already done.\n\n",
);
}
prompt.push_str(&format!(
"EXISTING STEPS (generation {}):\n",
generation - 1
));
// Categorise steps by status
let mut completed: Vec<&crate::db::models::DirectiveStep> = Vec::new();
let mut running: Vec<&crate::db::models::DirectiveStep> = Vec::new();
let mut pending_ready: Vec<&crate::db::models::DirectiveStep> = Vec::new();
let mut failed: Vec<&crate::db::models::DirectiveStep> = Vec::new();
let mut skipped: Vec<&crate::db::models::DirectiveStep> = Vec::new();
for step in existing_steps {
match step.status.as_str() {
"completed" => completed.push(step),
"running" => running.push(step),
"pending" | "ready" => pending_ready.push(step),
"failed" => failed.push(step),
"skipped" => skipped.push(step),
_ => pending_ready.push(step),
}
}
// ── Completed steps ─────────────────────────────────────────
if !completed.is_empty() {
prompt.push_str("\n── COMPLETED steps (KEEP — work already done) ──\n");
prompt.push_str("These steps have finished. Their work is committed and available.\n");
prompt.push_str("Do NOT remove them. New steps can depend on them to inherit their changes.\n");
let mut last_completed_id: Option<Uuid> = None;
for step in &completed {
prompt.push_str(&format!(
" ✅ {} (id: {}): {}\n",
step.name,
step.id,
step.description.as_deref().unwrap_or("(no description)")
));
last_completed_id = Some(step.id);
}
if let Some(last_id) = last_completed_id {
prompt.push_str(&format!(
"\nNew steps that build on previous work SHOULD use --depends-on \"{}\" (the last completed step) \
so their worktree inherits all prior changes. Without this dependency, new steps start from a \
fresh checkout and won't see any of the work done by previous steps.\n",
last_id
));
}
}
// ── Running steps ───────────────────────────────────────────
if !running.is_empty() {
prompt.push_str("\n── RUNNING steps (cannot remove — note if obsolete) ──\n");
prompt.push_str("These steps are currently executing and cannot be removed or skipped.\n");
prompt.push_str("If a running step is no longer relevant to the NEW goal, note it but do not attempt to remove it.\n");
for step in &running {
prompt.push_str(&format!(
" 🔄 {} (id: {}): {}\n",
step.name,
step.id,
step.description.as_deref().unwrap_or("(no description)")
));
}
}
// ── Pending / Ready steps ───────────────────────────────────
if !pending_ready.is_empty() {
prompt.push_str("\n── PENDING/READY steps (EVALUATE — remove if no longer relevant) ──\n");
prompt.push_str("These steps have NOT started yet. Evaluate each against the NEW goal:\n");
prompt.push_str(" • If still relevant → leave it in place (no action needed).\n");
prompt.push_str(" • If NO LONGER relevant → remove it with: makima directive remove-step <step_id>\n");
for step in &pending_ready {
prompt.push_str(&format!(
" ⏳ [{}] {} (id: {}): {}\n",
step.status,
step.name,
step.id,
step.description.as_deref().unwrap_or("(no description)")
));
}
}
// ── Failed steps ────────────────────────────────────────────
if !failed.is_empty() {
prompt.push_str("\n── FAILED steps (EVALUATE — remove if no longer relevant) ──\n");
prompt.push_str("These steps failed. Evaluate each against the NEW goal:\n");
prompt.push_str(" • If still relevant → remove the failed step and re-add a corrected version.\n");
prompt.push_str(" • If NO LONGER relevant → remove it with: makima directive remove-step <step_id>\n");
for step in &failed {
prompt.push_str(&format!(
" ❌ {} (id: {}): {}\n",
step.name,
step.id,
step.description.as_deref().unwrap_or("(no description)")
));
}
}
// ── Skipped steps ───────────────────────────────────────────
if !skipped.is_empty() {
prompt.push_str("\n── SKIPPED steps (remove if no longer relevant) ──\n");
for step in &skipped {
prompt.push_str(&format!(
" ⏭️ {} (id: {}): {}\n",
step.name,
step.id,
step.description.as_deref().unwrap_or("(no description)")
));
}
}
// ── Instructions ────────────────────────────────────────────
prompt.push_str(&format!(
"\n── ACTION PLAN ──\n\
1. First, remove any pending/ready/failed/skipped steps that are NOT relevant to the NEW goal:\n\
\x20 makima directive remove-step <step_id>\n\
2. Then, add new steps for the updated goal. Use generation {}.\n\
3. New steps that build on completed work MUST use --depends-on to inherit the worktree.\n\
4. Ensure the new plan fully addresses the UPDATED goal.\n\n",
generation
));
}
prompt.push_str(&format!(
r#"You are planning the implementation of a directive.
DIRECTIVE: "{title}"
GOAL: {goal}
{repo_section}
Your job:
1. Explore the repository to understand the codebase
2. Decompose the goal into concrete, ordered steps
3. Each step = one task for a Claude Code instance to execute
4. Submit ALL steps using the batch command or individual add-step commands
For each step, define:
- name: Short imperative title (e.g., "Add user authentication middleware")
- description: What to do and acceptance criteria
- taskPlan: Full instructions for the Claude instance (include file paths, patterns to follow)
- dependsOn: UUIDs of steps this depends on (use IDs from previous add-step responses)
- orderIndex: Execution phase number. Steps only start after ALL steps with a lower orderIndex complete.
Steps with the same orderIndex run in parallel. Use ascending values (0, 1, 2, ...) to create sequential phases.
Use dependsOn for fine-grained control within the same phase.
Submit steps:
makima directive add-step "Step Name" --description "..." --task-plan "..."
(Use --depends-on "uuid1,uuid2" for dependencies, referencing IDs from earlier add-step outputs)
Or batch:
makima directive batch-add-steps --json '[{{"name":"...","description":"...","taskPlan":"...","dependsOn":[],"orderIndex":0}}]'
DEPENDENCY WORKTREE CONTINUATION:
Each step runs in its own git worktree. How that worktree is initialised depends on dependsOn:
- With dependsOn: the step continues from the first dependency's worktree (inheriting all committed and
uncommitted changes). Additional dependencies are merged in as branches before work starts.
- Without dependsOn: the step starts from a FRESH worktree based on the base branch (or the PR branch if
a PR already exists from previous completions).
Because of this, you MUST chain steps using dependsOn whenever one step's work builds on another's.
If step B modifies files created/changed by step A, step B MUST list step A in its dependsOn — otherwise
step B will start from a blank worktree and won't see step A's changes at all.
Guidelines:
- For sequential work, create a linear chain: step1 → step2 → step3 (each depends on the previous).
- Only omit dependsOn for truly independent steps that can start from a fresh checkout.
- Parallel steps that share no files can omit mutual dependencies, but if they both build on a prior step
they should BOTH list that prior step in dependsOn.
IMPORTANT: Each step's taskPlan must be self-contained. The executing instance won't have your planning context.
"#,
title = directive.title,
goal = directive.goal,
repo_section = match &directive.repository_url {
Some(url) => format!("REPOSITORY: {}\n", url),
None => String::new(),
},
));
prompt
}
/// Build the prompt for a completion task that creates or updates a PR.
fn build_completion_prompt(
directive: &crate::db::models::Directive,
step_tasks: &[crate::db::repository::CompletedStepTask],
step_branches: &[String],
directive_branch: &str,
base_branch: &str,
) -> String {
let merge_commands: String = step_branches
.iter()
.map(|b| format!("git merge origin/{} --no-edit", b))
.collect::<Vec<_>>()
.join("\n");
let step_summary: String = step_tasks
.iter()
.zip(step_branches.iter())
.map(|(st, branch)| format!("- {} (branch: {})", st.step_name, branch))
.collect::<Vec<_>>()
.join("\n");
if directive.pr_url.is_some() {
// Re-completion: PR already exists, merge new branches into existing PR branch
format!(
r#"You are updating an existing PR for directive "{title}".
The PR branch `{directive_branch}` already exists. Merge any new step branches into it.
Steps completed:
{step_summary}
Run these commands:
```
git fetch origin
git checkout {directive_branch}
git pull origin {directive_branch}
{merge_commands}
git push origin {directive_branch}
```
Already-merged branches will be a no-op. If there are merge conflicts, resolve them sensibly.
"#,
title = directive.title,
directive_branch = directive_branch,
step_summary = step_summary,
merge_commands = merge_commands,
)
} else {
// First completion: create new PR
format!(
r#"You are creating a PR for directive "{title}".
Goal: {goal}
Steps completed:
{step_summary}
Run these commands to create a combined branch and PR:
```
git fetch origin
git checkout -b {directive_branch} origin/{base_branch}
{merge_commands}
git push -u origin {directive_branch}
```
Then create the PR:
```
gh pr create --title "{title}" --body "{pr_body}" --head {directive_branch} --base {base_branch}
```
IMPORTANT: After creating the PR, you MUST store the PR URL so the directive system can track it.
1. Run `gh pr create` as shown above and capture its output
2. The output will contain the PR URL (e.g., https://github.com/owner/repo/pull/123)
3. Then run this command to store the URL:
```
makima directive update --pr-url "https://github.com/..."
```
Replace the URL with the actual PR URL from the `gh pr create` output. This step is CRITICAL — the PR will not be tracked by the directive system without it.
If there are merge conflicts, resolve them sensibly before pushing.
"#,
title = directive.title,
goal = directive.goal,
directive_branch = directive_branch,
base_branch = base_branch,
step_summary = step_summary,
merge_commands = merge_commands,
pr_body = format!(
"## Directive\\n\\n{}\\n\\n## Steps\\n\\n{}",
directive.goal.replace('\n', "\\n").replace('"', "\\\""),
step_summary.replace('\n', "\\n").replace('"', "\\\""),
),
)
}
}
/// Build a prompt for verifying whether a PR was created for a directive.
/// This is a one-shot task: if it can't find or create the PR, the directive
/// will be marked completed to avoid infinite retries.
fn build_verification_prompt(
directive: &crate::db::models::Directive,
pr_branch: &str,
base_branch: &str,
) -> String {
format!(
r#"You are verifying whether a PR exists for directive "{title}".
The completion task already ran and pushed branch `{pr_branch}`, but the PR URL was not captured.
Follow these steps IN ORDER:
1. Check if a PR already exists for this branch:
```
gh pr list --head {pr_branch} --json url --jq '.[0].url'
```
2. If the command outputs a URL, store it:
```
makima directive update --pr-url "<URL_FROM_ABOVE>"
```
Done — the PR already exists.
3. If no PR was found, check if the branch exists on the remote:
```
git ls-remote --heads origin {pr_branch}
```
4. If the branch exists, create the PR:
```
gh pr create --title "{title}" --body "Directive PR verification — auto-created" --head {pr_branch} --base {base_branch}
```
Then store the resulting URL:
```
makima directive update --pr-url "<URL_FROM_GH_PR_CREATE>"
```
5. If the branch does NOT exist on the remote, the work was likely merged directly.
Mark the directive as completed:
```
makima directive update --status completed
```
IMPORTANT: You MUST run `makima directive update` with either `--pr-url` or `--status completed` before finishing.
"#,
title = directive.title,
pr_branch = pr_branch,
base_branch = base_branch,
)
}
/// Build a specialized planning prompt for picking up open orders.
///
/// This prompt instructs the planner to evaluate available orders, select an
/// adequate number based on priority and directive capacity, and create steps
/// to fulfil them.
pub fn build_order_pickup_prompt(
directive: &crate::db::models::Directive,
existing_steps: &[crate::db::models::DirectiveStep],
orders: &[crate::db::models::Order],
generation: i32,
goal_history: &[crate::db::models::DirectiveGoalHistory],
) -> String {
let mut prompt = String::new();
// ── Directive context ──────────────────────────────────────────
prompt.push_str(&format!(
"You are planning work for directive \"{title}\".\n\n\
GOAL: {goal}\n\
{repo_section}\n",
title = directive.title,
goal = directive.goal,
repo_section = match &directive.repository_url {
Some(url) => format!("REPOSITORY: {}\n", url),
None => String::new(),
},
));
// ── Goal history (if any) ─────────────────────────────────────
if !goal_history.is_empty() {
prompt.push_str("-- GOAL CHANGES --\n");
for (i, entry) in goal_history.iter().enumerate() {
if i == 0 {
prompt.push_str(&format!(
"PREVIOUS GOAL (replaced at {}):\n{}\n\n",
entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
entry.goal
));
} else {
prompt.push_str(&format!(
"OLDER GOAL (version from {}):\n{}\n\n",
entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
entry.goal
));
}
}
}
// ── Orders being picked up ───────────────────────────────────
prompt.push_str("== ORDERS AVAILABLE FOR PICKUP ==\n");
prompt.push_str("The following open orders have been linked to this directive. \
Review them and create steps to fulfil them.\n\n");
for (i, order) in orders.iter().enumerate() {
prompt.push_str(&format!(
" {}. [{}] [{}] {} (id: {})\n",
i + 1,
order.priority,
order.order_type,
order.title,
order.id,
));
if let Some(ref desc) = order.description {
prompt.push_str(&format!(" Description: {}\n", desc));
}
}
prompt.push('\n');
// ── Existing steps ───────────────────────────────────────────
if !existing_steps.is_empty() {
let mut completed: Vec<&crate::db::models::DirectiveStep> = Vec::new();
let mut running: Vec<&crate::db::models::DirectiveStep> = Vec::new();
let mut pending_ready: Vec<&crate::db::models::DirectiveStep> = Vec::new();
let mut failed: Vec<&crate::db::models::DirectiveStep> = Vec::new();
for step in existing_steps {
match step.status.as_str() {
"completed" => completed.push(step),
"running" => running.push(step),
"pending" | "ready" => pending_ready.push(step),
"failed" => failed.push(step),
_ => pending_ready.push(step),
}
}
prompt.push_str("== EXISTING STEPS ==\n");
if !completed.is_empty() {
prompt.push_str("\n── COMPLETED steps (work already done) ──\n");
let mut last_completed_id: Option<uuid::Uuid> = None;
for step in &completed {
prompt.push_str(&format!(
" ✅ {} (id: {}): {}\n",
step.name,
step.id,
step.description.as_deref().unwrap_or("(no description)")
));
last_completed_id = Some(step.id);
}
if let Some(last_id) = last_completed_id {
prompt.push_str(&format!(
"\nNew steps that build on previous work SHOULD use --depends-on \"{}\" \
so their worktree inherits all prior changes.\n",
last_id
));
}
}
if !running.is_empty() {
prompt.push_str("\n── RUNNING steps (in progress) ──\n");
for step in &running {
prompt.push_str(&format!(
" 🔄 {} (id: {}): {}\n",
step.name,
step.id,
step.description.as_deref().unwrap_or("(no description)")
));
}
}
if !pending_ready.is_empty() {
prompt.push_str("\n── PENDING/READY steps (not yet started) ──\n");
for step in &pending_ready {
prompt.push_str(&format!(
" ⏳ [{}] {} (id: {}): {}\n",
step.status,
step.name,
step.id,
step.description.as_deref().unwrap_or("(no description)")
));
}
}
if !failed.is_empty() {
prompt.push_str("\n── FAILED steps ──\n");
for step in &failed {
prompt.push_str(&format!(
" ❌ {} (id: {}): {}\n",
step.name,
step.id,
step.description.as_deref().unwrap_or("(no description)")
));
}
}
// Determine whether to create fresh steps or combine with existing
let all_terminal = existing_steps
.iter()
.all(|s| matches!(s.status.as_str(), "completed" | "failed" | "skipped"));
if all_terminal {
prompt.push_str(
"\nAll existing steps are in terminal state (completed/failed/skipped). \
Create FRESH steps from the orders above.\n\n",
);
} else if !pending_ready.is_empty() || !running.is_empty() {
prompt.push_str(
"\nThere are existing active/pending steps. Evaluate whether to KEEP them \
and ADD new steps from the orders, creating a combined plan. \
Do not duplicate work already covered by existing steps.\n\n",
);
}
}
// ── Order selection guidance ─────────────────────────────────
prompt.push_str(&format!(
"== ORDER SELECTION GUIDANCE ==\n\
You do NOT need to pick up ALL orders. Select an ADEQUATE number based on:\n\
- Priority: prefer critical and high priority orders first\n\
- Directive scope: consider the directive's current goal and capacity\n\
- Avoid overloading: don't assign too many orders to a single directive\n\
- The orders are already linked to this directive — focus on creating steps\n\n\
If some orders are not relevant to this directive's goal or would overload it, \
you may leave them for a future pickup cycle.\n\n"
));
// ── Step creation instructions ───────────────────────────────
prompt.push_str(&format!(
r#"== STEP CREATION INSTRUCTIONS ==
For each order (or group of related orders), create one or more steps:
- name: Short imperative title (e.g., "Add user authentication middleware")
- description: What to do and acceptance criteria
- taskPlan: Full instructions for the Claude instance (include file paths, patterns to follow)
- dependsOn: UUIDs of steps this depends on (use IDs from previous add-step responses)
- orderIndex: Execution phase number. Steps only start after ALL steps with a lower orderIndex complete.
Steps with the same orderIndex run in parallel. Use ascending values (0, 1, 2, ...) to create sequential phases.
Submit steps using generation {generation}:
makima directive add-step "Step Name" --description "..." --task-plan "..." --generation {generation}
(Use --depends-on "uuid1,uuid2" for dependencies)
Or batch:
makima directive batch-add-steps --json '[{{"name":"...","description":"...","taskPlan":"...","dependsOn":[],"orderIndex":0,"generation":{generation}}}]'
DEPENDENCY WORKTREE CONTINUATION:
Each step runs in its own git worktree. How that worktree is initialised depends on dependsOn:
- With dependsOn: the step continues from the first dependency's worktree (inheriting all committed and
uncommitted changes). Additional dependencies are merged in as branches before work starts.
- Without dependsOn: the step starts from a FRESH worktree based on the base branch (or the PR branch if
a PR already exists from previous completions).
Because of this, you MUST chain steps using dependsOn whenever one step's work builds on another's.
If step B modifies files created/changed by step A, step B MUST list step A in its dependsOn — otherwise
step B will start from a blank worktree and won't see step A's changes at all.
Guidelines:
- For sequential work, create a linear chain: step1 → step2 → step3 (each depends on the previous).
- Only omit dependsOn for truly independent steps that can start from a fresh checkout.
- Parallel steps that share no files can omit mutual dependencies, but if they both build on a prior step
they should BOTH list that prior step in dependsOn.
IMPORTANT: Each step's taskPlan must be self-contained. The executing instance won't have your planning context.
"#,
generation = generation,
));
prompt
}