diff options
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/bin/makima.rs | 7 | ||||
| -rw-r--r-- | makima/src/daemon/api/directive.rs | 16 | ||||
| -rw-r--r-- | makima/src/daemon/cli/directive.rs | 10 | ||||
| -rw-r--r-- | makima/src/daemon/cli/mod.rs | 3 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 40 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 200 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 365 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 203 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 4 |
10 files changed, 163 insertions, 686 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index a84c581..7b8cdb6 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -417,13 +417,6 @@ async fn run_directive( .await?; println!("{}", serde_json::to_string(&result.0)?); } - DirectiveCommand::UpdateGoal(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client - .directive_update_goal(args.common.directive_id, &args.goal) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } DirectiveCommand::BatchAddSteps(args) => { let client = ApiClient::new(args.common.api_url, args.common.api_key)?; let steps: serde_json::Value = serde_json::from_str(&args.json) diff --git a/makima/src/daemon/api/directive.rs b/makima/src/daemon/api/directive.rs index 1088eb7..bf5db09 100644 --- a/makima/src/daemon/api/directive.rs +++ b/makima/src/daemon/api/directive.rs @@ -20,12 +20,6 @@ pub struct CreateStepRequest { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct UpdateGoalRequest { - pub goal: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] pub struct UpdateStepDepsRequest { pub depends_on: Vec<Uuid>, } @@ -125,16 +119,6 @@ impl ApiClient { .await } - /// Update the directive's goal. - pub async fn directive_update_goal( - &self, - directive_id: Uuid, - goal: &str, - ) -> Result<JsonValue, ApiError> { - let req = UpdateGoalRequest { goal: goal.to_string() }; - self.put(&format!("/api/v1/directives/{}/goal", directive_id), &req).await - } - /// Update directive metadata (PR URL, PR branch, status, etc.) pub async fn directive_update( &self, diff --git a/makima/src/daemon/cli/directive.rs b/makima/src/daemon/cli/directive.rs index 0f04720..a27b94e 100644 --- a/makima/src/daemon/cli/directive.rs +++ b/makima/src/daemon/cli/directive.rs @@ -90,15 +90,7 @@ pub struct StepActionArgs { pub step_id: Uuid, } -/// Arguments for update-goal command. -#[derive(Args, Debug)] -pub struct UpdateGoalArgs { - #[command(flatten)] - pub common: DirectiveArgs, - - /// New goal text - pub goal: String, -} +// (UpdateGoalArgs removed — spec edits flow through the contracts API now.) /// Arguments for batch-add-steps command. #[derive(Args, Debug)] diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index b01c161..acad9ad 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -107,9 +107,6 @@ pub enum DirectiveCommand { /// Mark a step as skipped SkipStep(directive::StepActionArgs), - /// Update the directive's goal (triggers re-planning) - UpdateGoal(directive::UpdateGoalArgs), - /// Batch add multiple steps from JSON BatchAddSteps(directive::BatchAddStepsArgs), diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index fcccd05..3fb9667 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2704,14 +2704,16 @@ mod tests { // Directive Types // ============================================================================= -/// A directive — a long-lived top-level entity for managing projects via a DAG of steps. +/// A directive — a long-lived top-level entity that owns a sequence of +/// contracts (see `directive_documents`). The directive itself is a +/// folder; the active contract's body is the spec the orchestrator +/// daemon reads when planning. #[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct Directive { pub id: Uuid, pub owner_id: Uuid, pub title: String, - pub goal: String, /// Status: draft, active, idle, paused, archived pub status: String, pub repository_url: Option<String>, @@ -2723,7 +2725,6 @@ pub struct Directive { pub completion_task_id: Option<Uuid>, /// Question timeout mode: "auto" (30s timeout), "semi-auto" (block indefinitely), "manual" (block + ask many questions) pub reconcile_mode: String, - pub goal_updated_at: DateTime<Utc>, pub started_at: Option<DateTime<Utc>>, pub version: i32, pub created_at: DateTime<Utc>, @@ -2736,16 +2737,6 @@ pub struct Directive { pub is_tmp: bool, } -/// A historical record of a directive goal change. -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DirectiveGoalHistory { - pub id: Uuid, - pub directive_id: Uuid, - pub goal: String, - pub created_at: DateTime<Utc>, -} - /// Per-PR snapshot of a directive's goal — the immutable record of what the /// contract said at the moment a PR was raised. Frozen at PR-creation time; /// `pr_state` mirrors the PR's GitHub lifecycle ('open' | 'merged' | 'closed'). @@ -2808,7 +2799,6 @@ pub struct DirectiveSummary { pub id: Uuid, pub owner_id: Uuid, pub title: String, - pub goal: String, pub status: String, pub repository_url: Option<String>, pub orchestrator_task_id: Option<Uuid>, @@ -2833,12 +2823,18 @@ pub struct DirectiveListResponse { pub total: i64, } -/// Request to create a new directive. +/// Request to create a new directive. The directive itself has no spec +/// text — pass `contractBody` to auto-create a first contract whose +/// body is the spec; if omitted, the directive is created empty and +/// the user will create a contract from the UI. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateDirectiveRequest { pub title: String, - pub goal: String, + /// Optional. When provided, a first contract is auto-created with + /// this body so the directive is immediately ready to start. + #[serde(default)] + pub contract_body: Option<String>, pub repository_url: Option<String>, pub local_path: Option<String>, pub base_branch: Option<String>, @@ -2846,12 +2842,13 @@ pub struct CreateDirectiveRequest { pub reconcile_mode: Option<String>, } -/// Request to update a directive. +/// Request to update a directive's metadata. Spec edits go through the +/// contracts API now — this endpoint only mutates directive-level +/// fields (title, repo, status, etc.). #[derive(Debug, Default, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateDirectiveRequest { pub title: Option<String>, - pub goal: Option<String>, pub status: Option<String>, pub repository_url: Option<String>, pub local_path: Option<String>, @@ -2864,13 +2861,6 @@ pub struct UpdateDirectiveRequest { pub version: Option<i32>, } -/// Request to update a directive's goal (triggers re-planning). -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UpdateGoalRequest { - pub goal: String, -} - /// Response for cleanup_directive_tasks (legacy). #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index e58f58c..20f3268 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -12,7 +12,7 @@ use super::models::{ CreateContractRequest, CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, DeliverableDefinition, Directive, DirectiveDocument, DirectiveStep, DirectiveSummary, - CreateDirectiveRequest, CreateDirectiveStepRequest, DirectiveGoalHistory, + CreateDirectiveRequest, CreateDirectiveStepRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest, CreateOrderRequest, Order, UpdateOrderRequest, CreateDirectiveOrderGroupRequest, DirectiveOrderGroup, UpdateDirectiveOrderGroupRequest, @@ -5125,27 +5125,78 @@ fn truncate_string(s: &str, max_len: usize) -> String { // ============================================================================= /// Create a new directive for an owner. +/// +/// If `req.contract_body` is set, also auto-creates a first contract +/// with that body so the directive is immediately ready to start. Both +/// inserts run in the same transaction. pub async fn create_directive_for_owner( pool: &PgPool, owner_id: Uuid, req: CreateDirectiveRequest, ) -> Result<Directive, sqlx::Error> { - sqlx::query_as::<_, Directive>( + let mut tx = pool.begin().await?; + + let directive = sqlx::query_as::<_, Directive>( r#" - INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch, reconcile_mode) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO directives (owner_id, title, repository_url, local_path, base_branch, reconcile_mode) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING * "#, ) .bind(owner_id) .bind(&req.title) - .bind(&req.goal) .bind(&req.repository_url) .bind(&req.local_path) .bind(&req.base_branch) .bind(req.reconcile_mode.as_deref().unwrap_or("auto")) - .fetch_one(pool) - .await + .fetch_one(&mut *tx) + .await?; + + if let Some(body) = &req.contract_body { + sqlx::query( + r#" + INSERT INTO directive_documents (directive_id, title, body, status, position) + VALUES ($1, '', $2, 'draft', 0) + "#, + ) + .bind(directive.id) + .bind(body) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(directive) +} + +/// Resolve the body of the directive's "current spec" — the active +/// contract's body, falling back to the most-recently-updated draft if +/// none is active. Returns empty string when the directive has no +/// usable contracts (orchestrator should refuse to spawn in that case). +pub async fn get_active_contract_body( + pool: &PgPool, + directive_id: Uuid, +) -> Result<String, sqlx::Error> { + let row: Option<(String,)> = sqlx::query_as( + r#" + SELECT body FROM directive_documents + WHERE directive_id = $1 + AND status IN ('active', 'queued', 'draft') + ORDER BY + CASE status + WHEN 'active' THEN 0 + WHEN 'queued' THEN 1 + WHEN 'draft' THEN 2 + ELSE 3 + END, + updated_at DESC + LIMIT 1 + "#, + ) + .bind(directive_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.0).unwrap_or_default()) } /// Get a single directive for an owner. @@ -5212,7 +5263,7 @@ pub async fn list_directives_for_owner( sqlx::query_as::<_, DirectiveSummary>( r#" SELECT - d.id, d.owner_id, d.title, d.goal, d.status, d.repository_url, + d.id, d.owner_id, d.title, d.status, d.repository_url, d.orchestrator_task_id, d.pr_url, d.completion_task_id, d.reconcile_mode, d.version, d.created_at, d.updated_at, @@ -5271,8 +5322,6 @@ pub async fn update_directive_for_owner( } let title = req.title.as_deref().unwrap_or(¤t.title); - let goal = req.goal.as_deref().unwrap_or(¤t.goal); - let goal_changed = goal != current.goal; let status = req.status.as_deref().unwrap_or(¤t.status); let repository_url = req.repository_url.as_deref().or(current.repository_url.as_deref()); let local_path = req.local_path.as_deref().or(current.local_path.as_deref()); @@ -5285,10 +5334,9 @@ pub async fn update_directive_for_owner( let result = sqlx::query_as::<_, Directive>( r#" UPDATE directives - SET title = $3, goal = $4, status = $5, repository_url = $6, local_path = $7, - base_branch = $8, orchestrator_task_id = $9, pr_url = $10, pr_branch = $11, - reconcile_mode = $12, - goal_updated_at = CASE WHEN $13 THEN NOW() ELSE goal_updated_at END, + SET title = $3, status = $4, repository_url = $5, local_path = $6, + base_branch = $7, orchestrator_task_id = $8, pr_url = $9, pr_branch = $10, + reconcile_mode = $11, version = version + 1, updated_at = NOW() WHERE id = $1 AND owner_id = $2 RETURNING * @@ -5297,7 +5345,6 @@ pub async fn update_directive_for_owner( .bind(id) .bind(owner_id) .bind(title) - .bind(goal) .bind(status) .bind(repository_url) .bind(local_path) @@ -5306,7 +5353,6 @@ pub async fn update_directive_for_owner( .bind(pr_url) .bind(pr_branch) .bind(reconcile_mode) - .bind(goal_changed) .fetch_optional(pool) .await .map_err(RepositoryError::Database)?; @@ -6454,45 +6500,6 @@ pub async fn check_directive_idle( Ok(result.rows_affected() > 0) } -/// Update a directive's goal and bump goal_updated_at. -/// Reactivates draft/idle/paused/inactive directives and clears any stale -/// orchestrator task so that planning/replanning triggers on the next -/// reconciler tick. -/// -/// `draft` flips because the document-mode UI treats the first goal save as -/// the implicit "start". `inactive` flips because editing a contract whose -/// last revision was already shipped is the way the user kicks off an -/// amendment — the planner picks it up via phase_planning/replanning and -/// uses get_latest_merged_revision to learn the BEFORE→AFTER diff. -pub async fn update_directive_goal( - pool: &PgPool, - owner_id: Uuid, - directive_id: Uuid, - goal: &str, -) -> Result<Option<Directive>, sqlx::Error> { - sqlx::query_as::<_, Directive>( - r#" - UPDATE directives - SET goal = $3, - goal_updated_at = NOW(), - status = CASE - WHEN status IN ('draft', 'idle', 'paused', 'inactive') THEN 'active' - ELSE status - END, - orchestrator_task_id = NULL, - updated_at = NOW(), - version = version + 1 - WHERE id = $1 AND owner_id = $2 - RETURNING * - "#, - ) - .bind(directive_id) - .bind(owner_id) - .bind(goal) - .fetch_optional(pool) - .await -} - /// Mark a directive 'inactive'. Used at the moment a PR is raised — at that /// point the contract's current iteration is "shipped" and editing the goal /// (Stage 4) starts an amendment cycle. Idempotent: no-op if status is @@ -6517,11 +6524,10 @@ pub async fn set_directive_inactive( Ok(()) } -/// Reset a directive for a "new draft" cycle: clear the goal back to empty, -/// flip status to 'draft', and detach the current pr_url / pr_branch / -/// orchestrator linkage so the next goal save starts fresh. Prior revisions -/// remain in `directive_revisions` as the historical record. Used by the -/// sidebar's "New draft" right-click on inactive contracts. +/// Reset a directive for a "new draft" cycle: flip status to 'draft' and +/// detach the current pr_url / pr_branch / orchestrator linkage so the +/// next contract activation starts fresh. Prior revisions remain in +/// `directive_revisions` as the historical record. pub async fn reset_directive_for_new_draft( pool: &PgPool, owner_id: Uuid, @@ -6530,9 +6536,7 @@ pub async fn reset_directive_for_new_draft( sqlx::query_as::<_, Directive>( r#" UPDATE directives - SET goal = '', - goal_updated_at = NOW(), - status = 'draft', + SET status = 'draft', pr_url = NULL, pr_branch = NULL, orchestrator_task_id = NULL, @@ -6549,40 +6553,6 @@ pub async fn reset_directive_for_new_draft( .await } -/// Update a directive's goal WITHOUT clearing the orchestrator task id. -/// -/// This is the path used by the goal-edit interrupt cycle: when a small goal -/// edit arrives while a planner is already running, we want to keep the -/// planner attached so a `SendMessage` can summarise the change in-flight -/// instead of cancelling and respawning. We still bump `goal_updated_at` so -/// the timestamp reflects the edit, but we do NOT trigger replanning by -/// clearing the orchestrator task. We also do not flip status from -/// idle/paused → active here, since by definition a planner is already -/// running. -pub async fn update_directive_goal_keep_orchestrator( - pool: &PgPool, - owner_id: Uuid, - directive_id: Uuid, - goal: &str, -) -> Result<Option<Directive>, sqlx::Error> { - sqlx::query_as::<_, Directive>( - r#" - UPDATE directives - SET goal = $3, - goal_updated_at = NOW(), - updated_at = NOW(), - version = version + 1 - WHERE id = $1 AND owner_id = $2 - RETURNING * - "#, - ) - .bind(directive_id) - .bind(owner_id) - .bind(goal) - .fetch_optional(pool) - .await -} - // ============================================================================= // Directive Revisions — per-PR snapshots of the contract content. // ============================================================================= @@ -6701,42 +6671,6 @@ pub async fn get_latest_merged_revision( .await } -/// Save a goal to the directive goal history. -pub async fn save_directive_goal_history( - pool: &PgPool, - directive_id: Uuid, - goal: &str, -) -> Result<(), sqlx::Error> { - sqlx::query( - r#"INSERT INTO directive_goal_history (directive_id, goal) - VALUES ($1, $2)"#, - ) - .bind(directive_id) - .bind(goal) - .execute(pool) - .await?; - Ok(()) -} - -/// Get recent goal history for a directive (most recent first), limited to limit entries. -pub async fn get_directive_goal_history( - pool: &PgPool, - directive_id: Uuid, - limit: i64, -) -> Result<Vec<DirectiveGoalHistory>, sqlx::Error> { - sqlx::query_as::<_, DirectiveGoalHistory>( - r#"SELECT id, directive_id, goal, created_at - FROM directive_goal_history - WHERE directive_id = $1 - ORDER BY created_at DESC - LIMIT $2"#, - ) - .bind(directive_id) - .bind(limit) - .fetch_all(pool) - .await -} - /// Set a directive's status (used for start/pause/archive transitions). pub async fn set_directive_status( pool: &PgPool, 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.) diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index 7b13f1c..6d99179 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -13,7 +13,7 @@ use crate::db::models::{ CreateDirectiveStepRequest, Directive, DirectiveListResponse, DirectiveRevision, DirectiveStep, DirectiveWithSteps, PickUpOrdersResponse, Task, - UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest, + UpdateDirectiveRequest, UpdateDirectiveStepRequest, CreateDirectiveOrderGroupRequest, DirectiveOrderGroup, DirectiveOrderGroupListResponse, UpdateDirectiveOrderGroupRequest, OrderListResponse, @@ -22,9 +22,8 @@ use serde::Serialize; use utoipa::ToSchema; use crate::db::repository; use crate::orchestration::directive::{ - build_cleanup_prompt, build_order_pickup_prompt, classify_goal_change, - try_cancel_running_planner, try_interrupt_planner_with_goal_edit, - GoalChangeKind, GoalEditInterruptResult, + build_cleanup_prompt, build_order_pickup_prompt, + try_cancel_running_planner, }; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; @@ -200,15 +199,19 @@ pub async fn update_directive( match repository::update_directive_for_owner(pool, auth.owner_id, id, req).await { Ok(Some(directive)) => { // Detect "PR was just raised" — pr_url went from None to Some. - // Snapshot the current goal as a revision tied to this PR. - // Best-effort: a snapshot failure should not fail the update, - // because the directive's pr_url has already been written. + // Snapshot the active contract's body as a revision tied to + // this PR. Best-effort: a snapshot failure should not fail + // the update, because the directive's pr_url has already + // been written. if before_pr_url.is_none() { if let Some(ref new_pr_url) = directive.pr_url { + let snapshot_body = repository::get_active_contract_body(pool, directive.id) + .await + .unwrap_or_default(); if let Err(e) = repository::create_directive_revision( pool, directive.id, - &directive.goal, + &snapshot_body, new_pr_url, directive.pr_branch.as_deref(), ) @@ -859,152 +862,10 @@ async fn step_status_change( } } -/// Update a directive's goal (triggers re-planning). -#[utoipa::path( - put, - path = "/api/v1/directives/{id}/goal", - params(("id" = Uuid, Path, description = "Directive ID")), - request_body = UpdateGoalRequest, - responses( - (status = 200, description = "Goal updated", body = Directive), - (status = 404, description = "Not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security(("bearer_auth" = []), ("api_key" = [])), - tag = "Directives" -)] -pub async fn update_goal( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<UpdateGoalRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Fetch the current directive so we can: - // 1. Save the old goal to history (best-effort). - // 2. Decide whether to fire a goal-edit interrupt at a running planner. - let current = match repository::get_directive_for_owner(pool, auth.owner_id, id).await { - Ok(Some(d)) => Some(d), - Ok(None) => None, - Err(e) => { - tracing::warn!( - directive_id = %id, - error = %e, - "Failed to fetch current directive for goal history — continuing with goal update" - ); - None - } - }; - - // Save old goal to history before overwriting (best-effort). - if let Some(ref current) = current { - if let Err(e) = repository::save_directive_goal_history(pool, id, ¤t.goal).await { - tracing::warn!( - directive_id = %id, - error = %e, - "Failed to save goal history before update — continuing with goal update" - ); - } - } - - // Goal-edit interrupt cycle: if a planner task is currently running for - // this directive AND the goal change classifies as 'small', interrupt the - // running planner via SendMessage instead of clearing it (which would - // trigger a fresh replan on the next orchestrator tick). - let mut interrupted = false; - if let Some(ref current) = current { - if current.orchestrator_task_id.is_some() - && classify_goal_change(¤t.goal, &req.goal) == GoalChangeKind::Small - { - match try_interrupt_planner_with_goal_edit( - pool, - &state, - id, - ¤t.goal, - &req.goal, - ) - .await - { - Ok(GoalEditInterruptResult::Sent) => { - interrupted = true; - } - Ok(GoalEditInterruptResult::Skipped) => {} - Err(e) => { - tracing::warn!( - directive_id = %id, - error = %e, - "Goal-edit interrupt attempt errored — falling back to replan" - ); - } - } - } - } - - // If we successfully interrupted a running planner, persist the new goal - // WITHOUT clearing the orchestrator task — the planner will react to the - // SendMessage and adjust in-flight. Otherwise, fall through to the normal - // path which clears orchestrator_task_id and lets phase_replanning kick - // in on the next tick. - // - // CRITICAL: when going down the "clear" path, we must also CANCEL the - // running planner. Otherwise the orphaned task keeps producing add-step - // calls based on the old goal, racing the freshly-spawned replanner. - if !interrupted { - if let Some(ref current) = current { - if let Some(orch_task_id) = current.orchestrator_task_id { - if let Err(e) = try_cancel_running_planner(pool, &state, id, orch_task_id).await { - tracing::warn!( - directive_id = %id, - task_id = %orch_task_id, - error = %e, - "Failed to cancel orphaned planner — proceeding with clear anyway" - ); - } - } - } - } - - let update_result = if interrupted { - repository::update_directive_goal_keep_orchestrator(pool, auth.owner_id, id, &req.goal) - .await - } else { - repository::update_directive_goal(pool, auth.owner_id, id, &req.goal).await - }; - - let response = match update_result { - Ok(Some(directive)) => Json(directive).into_response(), - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Directive not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to update goal: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("UPDATE_FAILED", &e.to_string())), - ) - .into_response(); - } - }; - - // Nudge the directive reconciler so the user does not wait up to 15s for - // the next interval tick before the new planner is spawned (clear path) or - // the small-edit interrupt is consumed (keep path). Best-effort: if the - // channel is full or closed we just rely on the normal interval. - state.kick_directive_reconciler(); - - response -} +// (Goal updates now flow through the contracts API. The directive's +// orchestrator reads the active contract's body when it spawns or +// replans — see repository::get_active_contract_body and the +// orchestration module.) // ============================================================================= // Task Cleanup @@ -1404,16 +1265,13 @@ pub async fn pick_up_orders( } }; - let goal_history = match repository::get_directive_goal_history(pool, id, 3).await { - Ok(h) => h, - Err(e) => { - tracing::warn!("Failed to get goal history: {}", e); - vec![] - } - }; - - // Build the specialized planning prompt - let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &goal_history); + // Build the specialized planning prompt. The orchestrator reads the + // active contract's body itself when it picks up the task; we just + // pass the directive shape + steps + orders + generation here. + let contract_body = repository::get_active_contract_body(pool, id) + .await + .unwrap_or_default(); + let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &contract_body); // Link orders to the directive if let Err(e) = @@ -1984,16 +1842,13 @@ pub async fn pick_up_dog_orders( } }; - let goal_history = match repository::get_directive_goal_history(pool, id, 3).await { - Ok(h) => h, - Err(e) => { - tracing::warn!("Failed to get goal history: {}", e); - vec![] - } - }; - - // Build the specialized planning prompt - let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &goal_history); + // Build the specialized planning prompt. The orchestrator reads the + // active contract's body itself when it picks up the task; we just + // pass the directive shape + steps + orders + generation here. + let contract_body = repository::get_active_contract_body(pool, id) + .await + .unwrap_or_default(); + let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &contract_body); // Link orders to the directive if let Err(e) = diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index a3a1886..604caea 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -191,7 +191,6 @@ pub fn make_router(state: SharedState) -> Router { .route("/directives/{id}/steps/{step_id}/complete", post(directives::complete_step)) .route("/directives/{id}/steps/{step_id}/fail", post(directives::fail_step)) .route("/directives/{id}/steps/{step_id}/skip", post(directives::skip_step)) - .route("/directives/{id}/goal", put(directives::update_goal)) .route("/directives/{id}/revisions", get(directives::list_directive_revisions)) .route("/directives/{id}/new-draft", post(directives::new_directive_draft)) .route( diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 184d12a..437285f 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -25,7 +25,7 @@ use crate::db::models::{ Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, UpdateContractRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest, - UpdateFileRequest, UpdateGoalRequest, UpdateOrderRequest, UpdateTaskRequest, + UpdateFileRequest, UpdateOrderRequest, UpdateTaskRequest, }; use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, @@ -109,7 +109,6 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::complete_step, directives::fail_step, directives::skip_step, - directives::update_goal, directives::list_directive_revisions, directives::new_directive_draft, directives::create_directive_task, @@ -227,7 +226,6 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage crate::server::handlers::directives::CreateDirectiveTaskRequest, CreateDirectiveRequest, UpdateDirectiveRequest, - UpdateGoalRequest, CreateDirectiveStepRequest, UpdateDirectiveStepRequest, CleanupResponse, |
