diff options
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveContextMenu.tsx | 28 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 1 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveList.tsx | 1 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 26 | ||||
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 15 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 74 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 52 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 60 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 1 |
10 files changed, 249 insertions, 10 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveContextMenu.tsx b/makima/frontend/src/components/directives/DirectiveContextMenu.tsx index 07322e2..3f24ce1 100644 --- a/makima/frontend/src/components/directives/DirectiveContextMenu.tsx +++ b/makima/frontend/src/components/directives/DirectiveContextMenu.tsx @@ -11,6 +11,12 @@ interface DirectiveContextMenuProps { onArchive: () => void; onDelete: () => void; onGoToPR: () => void; + /** + * Reset the contract to a fresh empty draft (clears goal + pr_url, status + * back to 'draft'). Past revisions stay as history. Optional so the legacy + * tabular UI doesn't have to wire it up. + */ + onNewDraft?: () => void; } export function DirectiveContextMenu({ @@ -23,6 +29,7 @@ export function DirectiveContextMenu({ onArchive, onDelete, onGoToPR, + onNewDraft, }: DirectiveContextMenuProps) { const menuRef = useRef<HTMLDivElement>(null); @@ -73,6 +80,10 @@ export function DirectiveContextMenu({ const showPause = directive.status === "active"; const showArchive = directive.status !== "archived"; const showGoToPR = !!directive.prUrl; + // "New draft" appears once the contract is inactive (its iteration has + // shipped) — that's the explicit affordance for starting the next cycle + // on a clean slate while keeping prior revisions as history. + const showNewDraft = !!onNewDraft && directive.status === "inactive"; return ( <div @@ -85,6 +96,23 @@ export function DirectiveContextMenu({ {directive.title} </div> + {/* New draft — the canonical action on an inactive (shipped) contract. */} + {showNewDraft && ( + <> + <button + className={menuItemClass} + onClick={() => { + onNewDraft?.(); + onClose(); + }} + > + <span className="text-emerald-300">+</span> + New draft + </button> + <div className={dividerClass} /> + </> + )} + {/* Status actions */} {showStart && ( <button diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx index e3302e4..4931afa 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -13,6 +13,7 @@ const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = active: { color: "text-green-400 border-green-800", label: "ACTIVE" }, idle: { color: "text-yellow-400 border-yellow-800", label: "IDLE" }, paused: { color: "text-orange-400 border-orange-800", label: "PAUSED" }, + inactive: { color: "text-[#9bc3ff] border-[#3f6fb3]", label: "INACTIVE" }, archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, }; diff --git a/makima/frontend/src/components/directives/DirectiveList.tsx b/makima/frontend/src/components/directives/DirectiveList.tsx index 38a7caa..a35c8b1 100644 --- a/makima/frontend/src/components/directives/DirectiveList.tsx +++ b/makima/frontend/src/components/directives/DirectiveList.tsx @@ -8,6 +8,7 @@ const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = active: { color: "text-green-400 border-green-800", label: "ACTIVE" }, idle: { color: "text-yellow-400 border-yellow-800", label: "IDLE" }, paused: { color: "text-orange-400 border-orange-800", label: "PAUSED" }, + inactive: { color: "text-[#9bc3ff] border-[#3f6fb3]", label: "INACTIVE" }, archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, }; diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index e3dbc30..3fcd728 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3194,7 +3194,13 @@ export async function listTaskPatches(taskId: string, contractId: string): Promi // Directive Types & API // ============================================================================= -export type DirectiveStatus = "draft" | "active" | "idle" | "paused" | "archived"; +export type DirectiveStatus = + | "draft" + | "active" + | "idle" + | "paused" + | "inactive" + | "archived"; export type StepStatus = "pending" | "ready" | "running" | "completed" | "failed" | "skipped"; export interface Directive { @@ -3485,6 +3491,24 @@ export async function listDirectiveRevisions( return res.json(); } +/** + * Reset a directive for a new draft cycle: clears its goal and detaches the + * current PR linkage. Past revisions remain attached as history. Used by the + * sidebar's "New draft" right-click on an inactive contract. + */ +export async function newDirectiveDraft( + directiveId: string, +): Promise<Directive> { + const res = await authFetch( + `${API_BASE}/api/v1/directives/${directiveId}/new-draft`, + { method: "POST" }, + ); + if (!res.ok) { + throw new Error(`Failed to reset directive for new draft: ${res.statusText}`); + } + return res.json(); +} + export async function createDirectivePR(id: string): Promise<DirectiveWithSteps> { const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/create-pr`, { method: "POST" }); if (!res.ok) throw new Error(`Failed to create PR: ${res.statusText}`); diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 87102a2..d442a41 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -17,6 +17,7 @@ import { skipDirectiveStep, stopTask, listDirectiveRevisions, + newDirectiveDraft, } from "../lib/api"; import type { DirectiveStatus, @@ -32,6 +33,7 @@ const STATUS_DOT: Record<DirectiveStatus, string> = { active: "bg-green-400", idle: "bg-yellow-400", paused: "bg-orange-400", + inactive: "bg-[#75aafc]", archived: "bg-[#3a4a6a]", }; @@ -797,14 +799,16 @@ function DocumentSidebar({ return { directivesWithPending: dirs, tasksWithPending: tasks }; }, [pendingQuestions]); - // Sort active first, then idle, then paused, then archived. + // Sort active first, then idle, then paused, then drafts, then inactive + // (shipped contracts are quieter), then archived. const sorted = useMemo(() => { const order: Record<DirectiveStatus, number> = { active: 0, paused: 1, idle: 2, draft: 3, - archived: 4, + inactive: 4, + archived: 5, }; return [...directives].sort((a, b) => { const oa = order[a.status] ?? 99; @@ -1177,6 +1181,13 @@ export default function DocumentDirectivesPage() { window.open(contextMenu.directive.prUrl, "_blank", "noreferrer"); } }} + onNewDraft={async () => { + await newDirectiveDraft(contextMenu.directive.id); + await refreshList(); + // Send the user into the freshly-cleared contract so they can + // start typing the next iteration immediately. + navigate(`/directives/${contextMenu.directive.id}`); + }} /> )} {contextMenu?.kind === "task" && ( diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 1021c35..27bd47e 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -5625,12 +5625,15 @@ pub async fn check_directive_idle( } /// Update a directive's goal and bump goal_updated_at. -/// Reactivates draft/idle/paused directives and clears any stale orchestrator -/// task so that planning/replanning triggers on the next reconciler tick. +/// Reactivates draft/idle/paused/inactive directives and clears any stale +/// orchestrator task so that planning/replanning triggers on the next +/// reconciler tick. /// -/// `draft` is included in the flip set because the document-mode UI treats -/// the first goal save as the implicit "start" — without this, a brand-new -/// directive's goal save would persist but never spawn a planner. +/// `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, @@ -5642,7 +5645,10 @@ pub async fn update_directive_goal( UPDATE directives SET goal = $3, goal_updated_at = NOW(), - status = CASE WHEN status IN ('draft', 'idle', 'paused') THEN 'active' ELSE status END, + status = CASE + WHEN status IN ('draft', 'idle', 'paused', 'inactive') THEN 'active' + ELSE status + END, orchestrator_task_id = NULL, updated_at = NOW(), version = version + 1 @@ -5657,6 +5663,62 @@ pub async fn update_directive_goal( .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 +/// already inactive or already past it (e.g. archived). +pub async fn set_directive_inactive( + pool: &PgPool, + directive_id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + UPDATE directives + SET status = 'inactive', + updated_at = NOW(), + version = version + 1 + WHERE id = $1 + AND status IN ('active', 'idle', 'paused') + "#, + ) + .bind(directive_id) + .execute(pool) + .await?; + 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. +pub async fn reset_directive_for_new_draft( + pool: &PgPool, + owner_id: Uuid, + directive_id: Uuid, +) -> Result<Option<Directive>, sqlx::Error> { + sqlx::query_as::<_, Directive>( + r#" + UPDATE directives + SET goal = '', + goal_updated_at = NOW(), + status = 'draft', + pr_url = NULL, + pr_branch = NULL, + orchestrator_task_id = NULL, + completion_task_id = NULL, + updated_at = NOW(), + version = version + 1 + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(directive_id) + .bind(owner_id) + .fetch_optional(pool) + .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 diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 7dbfe65..1e004bf 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -56,7 +56,21 @@ impl DirectiveOrchestrator { "Directive needs planning — spawning planning task" ); - let plan = build_planning_prompt(&directive, &[], 1, &[], None); + // If the contract has previously-merged revisions, this is an + // amendment — pass the latest merged revision so the planner can + // reason about the delta instead of replanning from scratch. + let prev_merged = repository::get_latest_merged_revision(&self.pool, directive.id) + .await + .unwrap_or(None); + + let plan = build_planning_prompt( + &directive, + &[], + 1, + &[], + None, + prev_merged.as_ref(), + ); if let Err(e) = self .spawn_orchestrator_task( @@ -484,12 +498,20 @@ impl DirectiveOrchestrator { let progress_summary = summarize_in_progress_steps(&existing_steps); + // If the contract has previously-merged revisions, this is an + // amendment — pass the latest merged revision so the planner + // sees the BEFORE→AFTER diff for the new PR. + let prev_merged = repository::get_latest_merged_revision(&self.pool, directive.id) + .await + .unwrap_or(None); + let plan = build_planning_prompt( &directive, &existing_steps, generation, &goal_history, progress_summary.as_deref(), + prev_merged.as_ref(), ); if let Err(e) = self @@ -1475,9 +1497,37 @@ fn build_planning_prompt( generation: i32, goal_history: &[crate::db::models::DirectiveGoalHistory], progress_summary: Option<&str>, + previous_merged_revision: Option<&crate::db::models::DirectiveRevision>, ) -> String { let mut prompt = String::new(); + // Amendments to a previously-shipped contract. When the user edits a + // contract whose prior revision was already merged, the planner needs to + // reason about the BEFORE→AFTER diff so the new PR reflects only the + // intended delta, not a from-scratch reinterpretation. + if let Some(prev) = previous_merged_revision { + prompt.push_str("── AMENDMENT TO A PREVIOUSLY-MERGED CONTRACT ──\n"); + prompt.push_str(&format!( + "This contract was previously shipped via PR {} (revision v{}, frozen {}). \ + The user has now edited the contract to amend or extend that work. \ + Plan the new PR as a DELTA on top of the merged prior PR, not a fresh build.\n\n", + prev.pr_url, + prev.version, + prev.frozen_at.format("%Y-%m-%d %H:%M:%S UTC"), + )); + 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( + "\n\nIMPORTANT:\n\ + - Identify what CHANGED between the previously-merged contract and the amended one.\n\ + - Keep work that already shipped — only plan the delta.\n\ + - The amended PR should land on top of master containing JUST the additions/edits \ + implied by the diff, not a re-implementation of the original contract.\n\n", + ); + } + if let Some(progress) = progress_summary { let trimmed = progress.trim(); if !trimmed.is_empty() { diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index 91f5892..7a7aff4 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -227,6 +227,17 @@ pub async fn update_directive( "Snapshotted directive revision on PR creation" ); } + + // Transition the contract to 'inactive' now that its + // iteration is "shipped" — editing the goal again starts + // an amendment cycle, surfaced via the New draft action. + if let Err(e) = repository::set_directive_inactive(pool, directive.id).await { + tracing::warn!( + directive_id = %directive.id, + error = %e, + "Failed to mark directive inactive after PR creation" + ); + } } } Json(directive).into_response() @@ -2094,6 +2105,55 @@ pub struct DirectiveRevisionListResponse { pub total: i64, } +/// Reset a directive for a new draft cycle — clears its goal and detaches +/// the current PR linkage. Past revisions remain attached as history. +/// +/// Intended for the sidebar's "New draft" right-click on an inactive +/// directive: the contract has shipped, the user wants to start the next +/// iteration on a clean slate without losing the prior PR's record. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/new-draft", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "Directive reset to draft", 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 new_directive_draft( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::reset_directive_for_new_draft(pool, auth.owner_id, id).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to reset directive for new draft: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("RESET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + /// List all per-PR revisions for a directive, newest first. #[utoipa::path( get, diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 31052bf..c577904 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -254,6 +254,7 @@ pub fn make_router(state: SharedState) -> Router { .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("/directives/{id}/cleanup", post(directives::cleanup_directive)) .route("/directives/{id}/create-pr", post(directives::create_pr)) .route("/directives/{id}/pick-up-orders", post(directives::pick_up_orders)) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index e3ff757..e6d4547 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -131,6 +131,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::skip_step, directives::update_goal, directives::list_directive_revisions, + directives::new_directive_draft, directives::cleanup_directive, directives::create_pr, // Order endpoints |
