From c03e9a323e266c6a9a7ccb17bbbb7841296bbd5c Mon Sep 17 00:00:00 2001 From: soryu Date: Thu, 30 Apr 2026 17:09:45 +0100 Subject: feat(directives): amendment lifecycle — inactive status, new draft, before/after diff (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- makima/src/server/handlers/directives.rs | 60 ++++++++++++++++++++++++++++++++ makima/src/server/mod.rs | 1 + makima/src/server/openapi.rs | 1 + 3 files changed, 62 insertions(+) (limited to 'makima/src/server') 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, + Authenticated(auth): Authenticated, + Path(id): Path, +) -> 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 -- cgit v1.2.3