summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-04-30 16:36:25 +0100
committersoryu <soryu@soryu.co>2026-04-30 16:36:25 +0100
commitd77384ca67a9a081f9660bc633d13461e0c234d3 (patch)
tree34c86fc502ea3232b4a50baf3accc9de38bf70c6
parentdb284439a74093a316ba6da52ad82e5cae1246ef (diff)
downloadsoryu-doc-mode-stage4-amendments.tar.gz
soryu-doc-mode-stage4-amendments.zip
feat(directives): amendment lifecycle — inactive status, new draft, before/after diffdoc-mode-stage4-amendments
Stage 4 of the doc-mode revamp. Closes the loop on living-spec contracts: once a contract ships (PR raised) it becomes 'inactive', editing it kicks off an amendment cycle, the planner sees the previously-merged content as context, and "New draft" lets users abandon amendment and start the next contract on a clean slate. ## inactive lifecycle - New status `'inactive'`. Set automatically when `update_directive` detects a `pr_url` transition None → Some, alongside the revision snapshot (set_directive_inactive: idempotent, only flips active/idle/paused). - `update_directive_goal` extends its CASE flip to include 'inactive', so editing a shipped contract's goal reactivates it for the planner. - Frontend: `DirectiveStatus` gains 'inactive'; STATUS_DOT and the legacy STATUS_BADGEs (DirectiveDetail, DirectiveList) get color/label entries. Sidebar sort puts inactive after draft / before archived. ## Amendment diff to the orchestrator `build_planning_prompt` takes a new `previous_merged_revision` parameter. When set, it prepends an "AMENDMENT TO A PREVIOUSLY-MERGED CONTRACT" header that shows the merged content and the amended content explicitly, with guidance to plan a delta rather than a from-scratch rebuild. Both the planning and replanning phases call `get_latest_merged_revision` and pass it through. ## "New draft" affordance - New `repository::reset_directive_for_new_draft`: clears goal to '', status → 'draft', detaches pr_url / pr_branch / orchestrator linkage. Past revisions stay in directive_revisions as history. - New `POST /api/v1/directives/{id}/new-draft` handler. - DirectiveContextMenu surfaces "New draft" only when status === 'inactive', via an optional onNewDraft callback (legacy tabular UI doesn't have to wire it up). After reset, the page navigates to the contract so the user starts typing the next iteration immediately. ## PR-state-aware updates The user's spec — "open ⇒ update, merged ⇒ new PR, closed ⇒ new PR" — is already implemented in `build_completion_prompt`'s `gh pr view` runtime check, so no code change was needed here. The amendment cycle naturally flows through it: inactive → goal save → status flips to active → phase_replanning spawns a planner → completion task picks up the existing pr_url, sees the GitHub state, and decides update vs new PR accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-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