summaryrefslogtreecommitdiff
path: root/makima/src/orchestration/directive.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-08 16:33:36 +0100
committersoryu <soryu@soryu.co>2026-05-08 16:33:36 +0100
commit7af816032fbc54d5e0a8e94d4a000f307cd3b370 (patch)
tree50b6aad1aa47e56b61f0700e224028bb7578cb91 /makima/src/orchestration/directive.rs
parente4f1622a0f0ac74707cc1c9810e0b99e948d1319 (diff)
downloadsoryu-drop-directive-goal.tar.gz
soryu-drop-directive-goal.zip
feat(directives): drop directives.goal — orchestration reads contract bodydrop-directive-goal
Hard cut. The unified contracts surface owns spec text now; the directive itself is just a folder. The orchestrator daemon reads the active contract's body when it spawns, replans, or runs completion. Schema (migration 20260510000000): - DROP TABLE directive_goal_history - ALTER TABLE directives DROP COLUMN goal - ALTER TABLE directives DROP COLUMN goal_updated_at New repo helper: - get_active_contract_body(directive_id) — picks the active|queued|draft contract (in that order), most-recent first. Backend cuts: - Directive / DirectiveSummary / CreateDirectiveRequest / UpdateDirectiveRequest lose goal & goalUpdatedAt. - CreateDirectiveRequest gains optional `contractBody` — when provided, create_directive_for_owner auto-creates a first contract with that body in the same transaction. - Removed: update_directive_goal, update_directive_goal_keep_orchestrator, save_directive_goal_history, get_directive_goal_history, DirectiveGoalHistory model, UpdateGoalRequest. - Removed handlers::directives::update_goal + the /directives/{id}/goal route. - orchestration::directive::build_planning_prompt / build_completion_prompt / build_order_pickup_prompt now take a `contract_body: &str` instead of `goal_history`. classify_goal_change + try_interrupt_planner_with_goal_edit + GoalChangeKind + GoalEditInterruptResult removed (they were only useful for the small-vs-large goal-edit interrupt cycle). CLI: - `makima directive update-goal` removed (UpdateGoalArgs deleted, Commands enum trimmed, ApiClient::directive_update_goal + UpdateGoalRequest deleted). Frontend: - Directive / DirectiveSummary / CreateDirectiveRequest types lose goal & goalUpdatedAt; CreateDirectiveRequest gains `contractBody`. - useDirective drops updateGoal helper. - api.ts updateDirectiveGoal removed. - Legacy DirectiveList + DirectiveDetail components deleted; the /directives route now always renders the document-mode page. The user-settings documentModeEnabled flag is no longer consulted at the route level. - NewContractModal passes body via contractBody. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/src/orchestration/directive.rs')
-rw-r--r--makima/src/orchestration/directive.rs365
1 files changed, 50 insertions, 315 deletions
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs
index 7897c2c..384fa23 100644
--- a/makima/src/orchestration/directive.rs
+++ b/makima/src/orchestration/directive.rs
@@ -81,12 +81,14 @@ impl DirectiveOrchestrator {
let prev_merged = repository::get_latest_merged_revision(&self.pool, directive.id)
.await
.unwrap_or(None);
+ let contract_body =
+ repository::get_active_contract_body(&self.pool, directive.id).await?;
let plan = build_planning_prompt(
&directive,
&[],
1,
- &[],
+ &contract_body,
None,
prev_merged.as_ref(),
);
@@ -485,8 +487,8 @@ impl DirectiveOrchestrator {
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 contract_body =
+ repository::get_active_contract_body(&self.pool, directive.id).await?;
// If steps are currently running (or recently completed), build a
// WORK IN PROGRESS summary for the planner so it doesn't re-issue
@@ -506,7 +508,7 @@ impl DirectiveOrchestrator {
&directive,
&existing_steps,
generation,
- &goal_history,
+ &contract_body,
progress_summary.as_deref(),
prev_merged.as_ref(),
);
@@ -825,8 +827,12 @@ impl DirectiveOrchestrator {
})
.collect();
+ let contract_body = repository::get_active_contract_body(&self.pool, directive.id)
+ .await
+ .unwrap_or_default();
let prompt = build_completion_prompt(
&directive,
+ &contract_body,
&step_tasks,
&step_branches,
&directive_branch,
@@ -1355,7 +1361,10 @@ pub async fn trigger_completion_task(
})
.collect();
- let prompt = build_completion_prompt(&directive, &step_tasks, &step_branches, &directive_branch, base_branch);
+ let contract_body = repository::get_active_contract_body(pool, directive_id)
+ .await
+ .unwrap_or_default();
+ let prompt = build_completion_prompt(&directive, &contract_body, &step_tasks, &step_branches, &directive_branch, base_branch);
let task_name = if directive.pr_url.is_some() {
format!("Update PR: {}", directive.title)
@@ -1543,7 +1552,7 @@ fn build_planning_prompt(
directive: &crate::db::models::Directive,
existing_steps: &[crate::db::models::DirectiveStep],
generation: i32,
- goal_history: &[crate::db::models::DirectiveGoalHistory],
+ contract_body: &str,
progress_summary: Option<&str>,
previous_merged_revision: Option<&crate::db::models::DirectiveRevision>,
) -> String {
@@ -1566,7 +1575,7 @@ fn build_planning_prompt(
prompt.push_str("PREVIOUSLY-MERGED CONTRACT (frozen content):\n");
prompt.push_str(&prev.content);
prompt.push_str("\n\nAMENDED CONTRACT (what the user wants now):\n");
- prompt.push_str(&directive.goal);
+ prompt.push_str(contract_body);
prompt.push_str(
"\n\nIMPORTANT:\n\
- Identify what CHANGED between the previously-merged contract and the amended one.\n\
@@ -1591,6 +1600,17 @@ fn build_planning_prompt(
}
}
+ // Always include the current contract body so the planner has the
+ // up-to-date spec, regardless of whether there are existing steps.
+ prompt.push_str("CURRENT GOAL (active contract body):\n");
+ prompt.push_str(contract_body);
+ prompt.push_str("\n\n");
+
+ // Suppress unused warning for `directive` — kept in the signature so
+ // callers don't have to plumb the contract body separately when we
+ // expand the prompt later.
+ let _ = directive;
+
if !existing_steps.is_empty() {
// ── RE-PLANNING header ──────────────────────────────────────
prompt.push_str(&format!(
@@ -1599,37 +1619,6 @@ fn build_planning_prompt(
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
@@ -1763,7 +1752,18 @@ 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
+4. Submit ALL steps using the batch command or individual add-step commands"#,
+ title = directive.title,
+ goal = contract_body,
+ repo_section = match &directive.repository_url {
+ Some(url) => format!("REPOSITORY: {}\n", url),
+ None => String::new(),
+ },
+ ));
+
+ // The original tail (orders, dependency rules, etc.) follows below;
+ // re-attached intact so the prompt structure is unchanged.
+ prompt.push_str(r#"
For each step, define:
- name: Short imperative title (e.g., "Add user authentication middleware")
@@ -1854,14 +1854,7 @@ When to create orders:
Do NOT create orders for:
- Work that should be a step in the current plan
- Tasks that are part of the current goal
-"#,
- title = directive.title,
- goal = directive.goal,
- repo_section = match &directive.repository_url {
- Some(url) => format!("REPOSITORY: {}\n", url),
- None => String::new(),
- },
- ));
+"#);
prompt
}
@@ -1869,6 +1862,7 @@ Do NOT create orders for:
/// Build the prompt for a completion task that creates or updates a PR.
fn build_completion_prompt(
directive: &crate::db::models::Directive,
+ contract_body: &str,
step_tasks: &[crate::db::repository::CompletedStepTask],
step_branches: &[String],
directive_branch: &str,
@@ -2050,7 +2044,7 @@ If you encounter issues you cannot resolve (e.g., persistent merge conflicts, PR
makima directive ask "Your question" --phaseguard
"#,
title = directive.title,
- goal = directive.goal,
+ goal = contract_body,
pr_url = pr_url,
directive_branch = directive_branch,
base_branch = base_branch,
@@ -2058,7 +2052,7 @@ If you encounter issues you cannot resolve (e.g., persistent merge conflicts, PR
merge_commands = merge_commands,
pr_body = format!(
"## Directive\\n\\n{}\\n\\n## Steps\\n\\n{}",
- directive.goal.replace('\n', "\\n").replace('"', "\\\""),
+ contract_body.replace('\n', "\\n").replace('"', "\\\""),
step_summary.replace('\n', "\\n").replace('"', "\\\""),
),
)
@@ -2156,14 +2150,14 @@ If you encounter issues you cannot resolve (e.g., persistent merge conflicts, PR
makima directive ask "Your question" --phaseguard
"#,
title = directive.title,
- goal = directive.goal,
+ goal = contract_body,
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('"', "\\\""),
+ contract_body.replace('\n', "\\n").replace('"', "\\\""),
step_summary.replace('\n', "\\n").replace('"', "\\\""),
),
)
@@ -2316,7 +2310,7 @@ pub fn build_order_pickup_prompt(
existing_steps: &[crate::db::models::DirectiveStep],
orders: &[crate::db::models::Order],
generation: i32,
- goal_history: &[crate::db::models::DirectiveGoalHistory],
+ contract_body: &str,
) -> String {
let mut prompt = String::new();
@@ -2326,33 +2320,13 @@ pub fn build_order_pickup_prompt(
GOAL: {goal}\n\
{repo_section}\n",
title = directive.title,
- goal = directive.goal,
+ goal = contract_body,
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 PLANNING ==\n");
prompt.push_str("The following open orders have been linked to this directive. \
@@ -2558,93 +2532,9 @@ Do NOT ask questions for trivial decisions — use your best judgment.
}
// =============================================================================
-// Goal-edit classification (small vs large) and interrupt helpers
+// Planner cancellation helper
// =============================================================================
-/// Classification of a goal change for the goal-edit interrupt cycle.
-///
-/// When a user edits a directive's goal while a planning/replanning task is
-/// already running, we want to differentiate between:
-/// • Small edits (typo fixes, clarifications, small additions) → interrupt
-/// the current planner with a `SendMessage` so it can adjust its in-flight
-/// plan rather than throwing away its work.
-/// • Large edits (substantial rewrites, completely different objective) →
-/// fall back to the existing replan path (cancel + spawn a new planner).
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum GoalChangeKind {
- /// Small change — interrupt the running planner with the diff.
- Small,
- /// Large change — proceed with full replan.
- Large,
-}
-
-/// Heuristic: classify a goal edit as small or large.
-///
-/// Rules (POC heuristic, kept deliberately simple):
-/// 1. Empty old goal or empty new goal → Large (treat as a fresh start).
-/// 2. If one goal is a prefix of the other → Small (pure addition / truncation).
-/// 3. If the absolute length difference relative to the longer goal is < 0.3,
-/// classify as Small. Otherwise Large.
-pub fn classify_goal_change(old: &str, new: &str) -> GoalChangeKind {
- let old = old.trim();
- let new = new.trim();
-
- if old.is_empty() || new.is_empty() {
- return GoalChangeKind::Large;
- }
-
- if old == new {
- // No content change — treat as small (no-op for the planner).
- return GoalChangeKind::Small;
- }
-
- // Pure prefix changes (added a sentence at the end, or removed a trailing
- // clause) are almost always small.
- if old.starts_with(new) || new.starts_with(old) {
- return GoalChangeKind::Small;
- }
-
- let old_len = old.chars().count();
- let new_len = new.chars().count();
- let longer = old_len.max(new_len) as f64;
- let diff = (old_len as i64 - new_len as i64).unsigned_abs() as f64;
- if longer == 0.0 {
- return GoalChangeKind::Large;
- }
- let length_ratio = diff / longer;
-
- if length_ratio < 0.3 {
- GoalChangeKind::Small
- } else {
- GoalChangeKind::Large
- }
-}
-
-/// Format the goal-edit interrupt message sent to a running planner task
-/// when the user edits the directive goal mid-flight.
-pub fn build_goal_edit_interrupt_message(old_goal: &str, new_goal: &str) -> String {
- format!(
- "GOAL_UPDATED: The user has edited the directive goal. Summary of changes follows. \
- Adjust your current plan in-flight rather than starting over.\n\
- --- OLD GOAL ---\n\
- {old}\n\
- --- NEW GOAL ---\n\
- {new}\n",
- old = old_goal,
- new = new_goal,
- )
-}
-
-/// Result of attempting to send a goal-edit interrupt to a running planner.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum GoalEditInterruptResult {
- /// A `SendMessage` daemon command was dispatched to the running planner.
- Sent,
- /// No suitable planner task was running, or the change was classified as
- /// large — caller should fall through to the regular replanning path.
- Skipped,
-}
-
/// Best-effort cancellation of a directive's currently-running orchestrator
/// (planner) task. Used by the goal-update path: when we are about to clear
/// `orchestrator_task_id` from the directive, the still-running task would
@@ -2727,160 +2617,5 @@ pub async fn try_cancel_running_planner(
Ok(true)
}
-/// Attempt to interrupt a directive's currently-running planner with a goal
-/// edit summary instead of replanning from scratch.
-///
-/// Returns `Ok(GoalEditInterruptResult::Sent)` when a `SendMessage` was
-/// dispatched. Returns `Ok(GoalEditInterruptResult::Skipped)` when the change
-/// was large, no orchestrator task exists, the task has already finished, or
-/// no daemon is currently assigned.
-///
-/// This function is best-effort: errors talking to the daemon are logged and
-/// translated into `Skipped` so the caller can fall through to the normal
-/// replan path.
-pub async fn try_interrupt_planner_with_goal_edit(
- pool: &PgPool,
- state: &SharedState,
- directive_id: Uuid,
- old_goal: &str,
- new_goal: &str,
-) -> Result<GoalEditInterruptResult, anyhow::Error> {
- // Only fire if the change classifies as small.
- if classify_goal_change(old_goal, new_goal) != GoalChangeKind::Small {
- tracing::debug!(
- directive_id = %directive_id,
- "Goal change classified as large — skipping planner interrupt"
- );
- return Ok(GoalEditInterruptResult::Skipped);
- }
-
- // Look up the directive's current orchestrator task (planner).
- let directive = match repository::get_directive(pool, directive_id).await? {
- Some(d) => d,
- None => return Ok(GoalEditInterruptResult::Skipped),
- };
- let Some(orchestrator_task_id) = directive.orchestrator_task_id else {
- return Ok(GoalEditInterruptResult::Skipped);
- };
-
- // Fetch the planner task to confirm it's still queued/running.
- let task = match repository::get_task(pool, orchestrator_task_id).await? {
- Some(t) => t,
- None => return Ok(GoalEditInterruptResult::Skipped),
- };
-
- let interruptible = matches!(
- task.status.as_str(),
- "queued" | "pending" | "starting" | "running"
- );
- if !interruptible {
- tracing::debug!(
- directive_id = %directive_id,
- task_id = %orchestrator_task_id,
- task_status = %task.status,
- "Planner task is not in an interruptible state — skipping interrupt"
- );
- return Ok(GoalEditInterruptResult::Skipped);
- }
-
- let Some(daemon_id) = task.daemon_id else {
- tracing::debug!(
- directive_id = %directive_id,
- task_id = %orchestrator_task_id,
- "Planner task has no assigned daemon — skipping interrupt"
- );
- return Ok(GoalEditInterruptResult::Skipped);
- };
-
- let message = build_goal_edit_interrupt_message(old_goal, new_goal);
- let command = DaemonCommand::SendMessage {
- task_id: orchestrator_task_id,
- message,
- };
-
- match state.send_daemon_command(daemon_id, command).await {
- Ok(()) => {
- tracing::info!(
- directive_id = %directive_id,
- task_id = %orchestrator_task_id,
- daemon_id = %daemon_id,
- "Sent goal-edit interrupt to running planner"
- );
- Ok(GoalEditInterruptResult::Sent)
- }
- Err(e) => {
- tracing::warn!(
- directive_id = %directive_id,
- task_id = %orchestrator_task_id,
- daemon_id = %daemon_id,
- error = %e,
- "Failed to send goal-edit interrupt — falling back to replan"
- );
- Ok(GoalEditInterruptResult::Skipped)
- }
- }
-}
-
-// =============================================================================
-// Tests
-// =============================================================================
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn classifier_identical_goal_is_small() {
- assert_eq!(
- classify_goal_change("Build a todo app", "Build a todo app"),
- GoalChangeKind::Small
- );
- }
-
- #[test]
- fn classifier_pure_addition_is_small() {
- let old = "Build a todo app";
- let new = "Build a todo app with authentication";
- assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
- }
-
- #[test]
- fn classifier_pure_truncation_is_small() {
- let old = "Build a todo app with authentication and tests";
- let new = "Build a todo app";
- assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
- }
-
- #[test]
- fn classifier_typo_fix_is_small() {
- // Same length, single character diff — well below 0.3 length ratio.
- let old = "Build a todo aap with authentication and tests today";
- let new = "Build a todo app with authentication and tests today";
- assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
- }
-
- #[test]
- fn classifier_completely_different_is_large() {
- // Wildly different lengths and content.
- let old = "Build a todo app";
- let new = "Migrate the entire backend to Rust, port the frontend to Svelte, \
- and add a new realtime collaboration feature with operational transforms";
- assert_eq!(classify_goal_change(old, new), GoalChangeKind::Large);
- }
-
- #[test]
- fn classifier_empty_goals_are_large() {
- assert_eq!(classify_goal_change("", "Anything"), GoalChangeKind::Large);
- assert_eq!(classify_goal_change("Anything", ""), GoalChangeKind::Large);
- }
-
- #[test]
- fn interrupt_message_contains_old_and_new() {
- let msg = build_goal_edit_interrupt_message("OLD", "NEW");
- assert!(msg.contains("GOAL_UPDATED"));
- assert!(msg.contains("OLD"));
- assert!(msg.contains("NEW"));
- assert!(msg.contains("--- OLD GOAL ---"));
- assert!(msg.contains("--- NEW GOAL ---"));
- }
-}
+// (Goal-edit classification + interrupt helpers were tied to directive.goal,
+// which has been dropped. Their unit tests went with them.)