summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/src/components/directives/DirectiveContextMenu.tsx28
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx1
-rw-r--r--makima/frontend/src/components/directives/DirectiveList.tsx1
-rw-r--r--makima/frontend/src/lib/api.ts26
-rw-r--r--makima/frontend/src/routes/document-directives.tsx15
-rw-r--r--makima/src/db/repository.rs74
-rw-r--r--makima/src/orchestration/directive.rs52
-rw-r--r--makima/src/server/handlers/directives.rs60
-rw-r--r--makima/src/server/mod.rs1
-rw-r--r--makima/src/server/openapi.rs1
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