diff options
| author | soryu <soryu@soryu.co> | 2026-01-17 19:47:35 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-17 19:47:35 +0000 |
| commit | 900472091e4d9b4000508b0d266d786ef41107bd (patch) | |
| tree | 55fb5ec80b8df6693a8a2960071148dfd88e928a /makima/src | |
| parent | 2f62df1cc89a23a5bd30e1a3f68a39bcfce9665c (diff) | |
| download | soryu-900472091e4d9b4000508b0d266d786ef41107bd.tar.gz soryu-900472091e4d9b4000508b0d266d786ef41107bd.zip | |
Add phase guard toggle for contract phase confirmation (#2)
* Add phase_guard field to Contract model and database
This adds a new boolean field to control whether the supervisor should
wait for user confirmation before progressing to the next phase. When
enabled, users can review and potentially amend phase outputs (like
plans, requirements docs) before the contract continues.
Changes:
- Add migration for phase_guard column (defaults to false)
- Add phase_guard to Contract, CreateContractRequest, and
UpdateContractRequest structs
- Update create_contract_for_owner and update_contract_for_owner
repository functions to handle phase_guard
- Update all CreateContractRequest instantiations with phase_guard field
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: Add phase_guard for contract phase transitions
Implement phase_guard logic in the advance_phase tool. When a contract
has phase_guard enabled, the phase transition now:
1. Asks for user confirmation before advancing
2. Allows users to request changes to phase deliverables
3. Passes feedback to the task without advancing if changes requested
Changes:
- Add phase_guard field to Contract model and CreateContractRequest
- Add PhaseTransitionRequest, PhaseFileInfo, PhaseTaskInfo structs
- Update ChangePhaseRequest with confirmed and feedback fields
- Update ContractToolRequest::AdvancePhase with confirmed/feedback
- Modify advance_phase handling in contract_chat.rs with phase_guard logic
- Update change_phase endpoint in contracts.rs with phase_guard support
- Add database migration for phase_guard column
When phase_guard=false: Phase advances immediately (current behavior)
When phase_guard=true: Returns pending_confirmation status with deliverables
If user provides feedback: Returns feedback to task, doesn't advance
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(frontend): Add UI for phase transition confirmation requests
When phase_guard is enabled and a supervisor tries to advance the contract
phase, users now see a confirmation modal with:
- Current and proposed next phase visualization
- Phase deliverables checklist (if available)
- Summary of the phase work
- Options to "Approve & Advance" or "Request Changes" with feedback
Components added:
- PhaseConfirmationModal: Full modal dialog for phase confirmations
- PhaseConfirmationInline: Inline variant for task output view
- PhaseConfirmationNotification: Global notification wrapper
- PhaseConfirmationToast: Alternative toast-style notification
Integration:
- Added phase_confirmation message type to TaskOutput renderer
- Extended PendingQuestion API type with phase confirmation data
- Integrated notification into main app layout
The UI uses the existing supervisor question infrastructure (polling via
/api/v1/mesh/questions) and responds with APPROVE or CHANGES_REQUESTED
prefixed feedback.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(frontend): Add Phase Guard toggle to AutopilotPanel
Added the phase_guard toggle to the AutopilotPanel component, which allows
users to enable/disable requiring confirmation before phase transitions.
Changes:
- Added phaseGuard and autonomousLoop fields to Contract interface in api.ts
- Added phaseGuard field to UpdateContractRequest in api.ts
- Added Phase Guard toggle UI in AutopilotPanel with similar styling to master
- Toggle shows an 'active' badge when enabled
- Connected toggle to contract update API
The toggle appears below the autopilot control buttons and allows users to
require confirmation before the supervisor advances to the next phase.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/db/models.rs | 60 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 15 | ||||
| -rw-r--r-- | makima/src/llm/contract_tools.rs | 34 | ||||
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 87 | ||||
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 112 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/transcript_analysis.rs | 1 |
7 files changed, 288 insertions, 22 deletions
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 72ba6f2..99c8b8e 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -1264,6 +1264,11 @@ pub struct Contract { /// without a COMPLETION_GATE indicating ready: true. #[serde(default)] pub autonomous_loop: bool, + /// Whether to wait for user confirmation before progressing to the next phase. + /// When enabled, the supervisor will pause and ask the user to review and approve + /// phase outputs (like plans, requirements, etc.) before continuing. + #[serde(default)] + pub phase_guard: bool, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -1389,6 +1394,11 @@ pub struct CreateContractRequest { /// without a COMPLETION_GATE indicating ready: true. #[serde(default)] pub autonomous_loop: Option<bool>, + /// Enable phase guard mode for this contract. + /// When enabled, the supervisor will pause and ask the user to review and approve + /// phase outputs before progressing to the next phase. + #[serde(default)] + pub phase_guard: Option<bool>, } /// Request payload for updating a contract @@ -1405,6 +1415,11 @@ pub struct UpdateContractRequest { /// Enable or disable autonomous loop mode for tasks in this contract. #[serde(default)] pub autonomous_loop: Option<bool>, + /// Enable or disable phase guard mode for this contract. + /// When enabled, the supervisor will pause and ask the user to review and approve + /// phase outputs before progressing to the next phase. + #[serde(default)] + pub phase_guard: Option<bool>, /// Version for optimistic locking pub version: Option<i32>, } @@ -1443,6 +1458,51 @@ pub struct CreateManagedRepositoryRequest { #[serde(rename_all = "camelCase")] pub struct ChangePhaseRequest { pub phase: String, + /// If phase_guard is enabled, this must be true to confirm the transition. + /// If not provided or false, returns phase deliverables for review. + #[serde(default)] + pub confirmed: Option<bool>, + /// User feedback for changes (used when not confirming) + #[serde(skip_serializing_if = "Option::is_none")] + pub feedback: Option<String>, +} + +/// Response for phase transition when phase_guard is enabled +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PhaseTransitionRequest { + /// Current contract phase + pub current_phase: String, + /// Requested next phase + pub next_phase: String, + /// Summary of phase deliverables/outputs + pub deliverables_summary: String, + /// List of files created in this phase + pub phase_files: Vec<PhaseFileInfo>, + /// List of completed tasks in this phase + pub phase_tasks: Vec<PhaseTaskInfo>, + /// Whether user confirmation is required + pub requires_confirmation: bool, + /// Unique ID for tracking this transition request + pub transition_id: String, +} + +/// File info for phase transition review +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PhaseFileInfo { + pub id: Uuid, + pub name: String, + pub description: Option<String>, +} + +/// Task info for phase transition review +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PhaseTaskInfo { + pub id: Uuid, + pub name: String, + pub status: String, } /// Contract event record from the database diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 43b8e3a..3d1efd1 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -2136,11 +2136,12 @@ pub async fn create_contract_for_owner( } let autonomous_loop = req.autonomous_loop.unwrap_or(false); + let phase_guard = req.phase_guard.unwrap_or(false); sqlx::query_as::<_, Contract>( r#" - INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop, phase_guard) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * "#, ) @@ -2150,6 +2151,7 @@ pub async fn create_contract_for_owner( .bind(contract_type) .bind(phase) .bind(autonomous_loop) + .bind(phase_guard) .fetch_one(pool) .await } @@ -2249,14 +2251,15 @@ pub async fn update_contract_for_owner( let status = req.status.unwrap_or(existing.status); let supervisor_task_id = req.supervisor_task_id.or(existing.supervisor_task_id); let autonomous_loop = req.autonomous_loop.unwrap_or(existing.autonomous_loop); + let phase_guard = req.phase_guard.unwrap_or(existing.phase_guard); let result = if req.version.is_some() { sqlx::query_as::<_, Contract>( r#" UPDATE contracts SET name = $3, description = $4, phase = $5, status = $6, - supervisor_task_id = $7, autonomous_loop = $8, version = version + 1, updated_at = NOW() - WHERE id = $1 AND owner_id = $2 AND version = $9 + supervisor_task_id = $7, autonomous_loop = $8, phase_guard = $9, version = version + 1, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 AND version = $10 RETURNING * "#, ) @@ -2268,6 +2271,7 @@ pub async fn update_contract_for_owner( .bind(&status) .bind(supervisor_task_id) .bind(autonomous_loop) + .bind(phase_guard) .bind(req.version.unwrap()) .fetch_optional(pool) .await? @@ -2276,7 +2280,7 @@ pub async fn update_contract_for_owner( r#" UPDATE contracts SET name = $3, description = $4, phase = $5, status = $6, - supervisor_task_id = $7, autonomous_loop = $8, version = version + 1, updated_at = NOW() + supervisor_task_id = $7, autonomous_loop = $8, phase_guard = $9, version = version + 1, updated_at = NOW() WHERE id = $1 AND owner_id = $2 RETURNING * "#, @@ -2289,6 +2293,7 @@ pub async fn update_contract_for_owner( .bind(&status) .bind(supervisor_task_id) .bind(autonomous_loop) + .bind(phase_guard) .fetch_optional(pool) .await? }; diff --git a/makima/src/llm/contract_tools.rs b/makima/src/llm/contract_tools.rs index 7a3d09a..07de1fe 100644 --- a/makima/src/llm/contract_tools.rs +++ b/makima/src/llm/contract_tools.rs @@ -203,7 +203,7 @@ pub static CONTRACT_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::L }, Tool { name: "advance_phase".to_string(), - description: "Advance the contract to the NEXT phase in sequence. Phases progress: research -> specify -> plan -> execute -> review. You can ONLY advance forward one step. Always use suggest_phase_transition first to check readiness and find the correct next phase.".to_string(), + description: "Advance the contract to the NEXT phase in sequence. Phases progress: research -> specify -> plan -> execute -> review. You can ONLY advance forward one step. Always use suggest_phase_transition first to check readiness and find the correct next phase. If the contract has phase_guard enabled, this will first return a pending_confirmation status with phase deliverables for user review. Call again with confirmed=true to complete the transition, or with feedback to request changes.".to_string(), parameters: json!({ "type": "object", "properties": { @@ -211,6 +211,14 @@ pub static CONTRACT_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::L "type": "string", "enum": ["specify", "plan", "execute", "review"], "description": "The next phase to transition to. Must be exactly one step ahead of current phase (e.g., research->specify, specify->plan, plan->execute, execute->review)" + }, + "confirmed": { + "type": "boolean", + "description": "Set to true to confirm the phase transition when phase_guard is enabled. If omitted or false, returns deliverables for review." + }, + "feedback": { + "type": "string", + "description": "User feedback when requesting changes instead of confirming the transition. The feedback will be passed back to the task to address." } }, "required": ["new_phase"] @@ -500,7 +508,13 @@ pub enum ContractToolRequest { // Phase management GetPhaseInfo, SuggestPhaseTransition, - AdvancePhase { new_phase: String }, + AdvancePhase { + new_phase: String, + /// Whether the user has confirmed the phase transition (for phase_guard) + confirmed: bool, + /// User feedback when they request changes instead of confirming + feedback: Option<String>, + }, // Repository management ListDaemonDirectories, @@ -870,12 +884,28 @@ fn parse_advance_phase(call: &super::tools::ToolCall) -> ContractToolExecutionRe return error_result("Invalid phase. Must be one of: research, specify, plan, execute, review"); } + // Parse optional confirmed flag (defaults to false for initial phase_guard check) + let confirmed = call + .arguments + .get("confirmed") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Parse optional feedback (for when user requests changes) + let feedback = call + .arguments + .get("feedback") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + ContractToolExecutionResult { success: true, message: format!("Advancing to '{}' phase...", new_phase), data: None, request: Some(ContractToolRequest::AdvancePhase { new_phase: new_phase.to_string(), + confirmed, + feedback, }), pending_questions: None, } diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs index 101b257..29ec620 100644 --- a/makima/src/server/handlers/contract_chat.rs +++ b/makima/src/server/handlers/contract_chat.rs @@ -1689,7 +1689,7 @@ async fn handle_contract_request( } } - ContractToolRequest::AdvancePhase { new_phase } => { + ContractToolRequest::AdvancePhase { new_phase, confirmed, feedback } => { let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { Ok(Some(c)) => c, Ok(None) => { @@ -1723,7 +1723,88 @@ async fn handle_contract_request( }; } - // Update phase + // Check if phase_guard is enabled + if contract.phase_guard { + // If user provided feedback, return it for the task to address + if let Some(ref user_feedback) = feedback { + return ContractRequestResult { + success: true, + message: format!( + "Phase transition to '{}' requires changes. User feedback: {}", + new_phase, user_feedback + ), + data: Some(json!({ + "status": "changes_requested", + "currentPhase": current_phase, + "requestedPhase": new_phase, + "feedback": user_feedback, + "action": "Address the user feedback and try again when ready" + })), + }; + } + + // If not confirmed, return pending confirmation with phase deliverables + if !confirmed { + // Get files created in this phase + let phase_files = match repository::list_files_in_contract(pool, contract_id, owner_id).await { + Ok(files) => files + .into_iter() + .filter(|f| f.contract_phase.as_deref() == Some(current_phase)) + .map(|f| json!({ + "id": f.id, + "name": f.name, + "description": f.description + })) + .collect::<Vec<_>>(), + Err(_) => Vec::new(), + }; + + // Get tasks completed in this contract + let phase_tasks = match repository::list_tasks_in_contract(pool, contract_id, owner_id).await { + Ok(tasks) => tasks + .into_iter() + .filter(|t| t.status == "done" || t.status == "completed") + .map(|t| json!({ + "id": t.id, + "name": t.name, + "status": t.status + })) + .collect::<Vec<_>>(), + Err(_) => Vec::new(), + }; + + // Build deliverables summary + let deliverables_summary = format!( + "Phase '{}' deliverables: {} files created, {} tasks completed.", + current_phase, + phase_files.len(), + phase_tasks.len() + ); + + let transition_id = uuid::Uuid::new_v4().to_string(); + + return ContractRequestResult { + success: true, + message: format!( + "Phase transition to '{}' requires user confirmation. Review the deliverables and call advance_phase again with confirmed=true to proceed, or provide feedback to request changes.", + new_phase + ), + data: Some(json!({ + "status": "pending_confirmation", + "transitionId": transition_id, + "currentPhase": current_phase, + "nextPhase": new_phase, + "deliverablesSummary": deliverables_summary, + "phaseFiles": phase_files, + "phaseTasks": phase_tasks, + "requiresConfirmation": true, + "instructions": "To proceed: call advance_phase with confirmed=true. To request changes: call advance_phase with feedback='your feedback here'" + })), + }; + } + } + + // Update phase (either phase_guard is disabled, or user confirmed) match repository::change_contract_phase_for_owner(pool, contract_id, owner_id, &new_phase).await { Ok(Some(updated)) => { // Get deliverables for the new phase @@ -1748,6 +1829,7 @@ async fn handle_contract_request( current_phase, new_phase, deliverables.guidance ), data: Some(json!({ + "status": "advanced", "previousPhase": current_phase, "newPhase": updated.phase, "phaseGuidance": deliverables.guidance, @@ -2377,6 +2459,7 @@ async fn handle_contract_request( contract_type: Some("specification".to_string()), initial_phase: Some("research".to_string()), autonomous_loop: None, + phase_guard: None, }; let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await { diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs index 684ab2b..4f4a94b 100644 --- a/makima/src/server/handlers/contracts.rs +++ b/makima/src/server/handlers/contracts.rs @@ -1267,14 +1267,100 @@ pub async fn change_phase( .into_response(); }; + // First, get the contract to check phase_guard + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // If phase_guard is enabled and not confirmed, return phase deliverables for review + if contract.phase_guard && !req.confirmed.unwrap_or(false) { + // If user provided feedback, return it + if let Some(ref feedback) = req.feedback { + return Json(serde_json::json!({ + "status": "changes_requested", + "currentPhase": contract.phase, + "requestedPhase": req.phase, + "feedback": feedback, + "message": "Feedback has been noted. Address the changes and try again." + })) + .into_response(); + } + + // Get files created in this phase + let phase_files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { + Ok(files) => files + .into_iter() + .filter(|f| f.contract_phase.as_deref() == Some(&contract.phase)) + .map(|f| serde_json::json!({ + "id": f.id, + "name": f.name, + "description": f.description + })) + .collect::<Vec<_>>(), + Err(_) => Vec::new(), + }; + + // Get tasks completed in this contract + let phase_tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { + Ok(tasks) => tasks + .into_iter() + .filter(|t| t.status == "done" || t.status == "completed") + .map(|t| serde_json::json!({ + "id": t.id, + "name": t.name, + "status": t.status + })) + .collect::<Vec<_>>(), + Err(_) => Vec::new(), + }; + + let deliverables_summary = format!( + "Phase '{}' deliverables: {} files created, {} tasks completed.", + contract.phase, + phase_files.len(), + phase_tasks.len() + ); + + let transition_id = uuid::Uuid::new_v4().to_string(); + + return Json(serde_json::json!({ + "status": "pending_confirmation", + "transitionId": transition_id, + "currentPhase": contract.phase, + "nextPhase": req.phase, + "deliverablesSummary": deliverables_summary, + "phaseFiles": phase_files, + "phaseTasks": phase_tasks, + "requiresConfirmation": true, + "message": "Phase transition requires confirmation. Set confirmed=true in the request to proceed." + })) + .into_response(); + } + + // Phase guard is disabled or user confirmed - proceed with phase change match repository::change_contract_phase_for_owner(pool, id, auth.owner_id, &req.phase).await { - Ok(Some(contract)) => { + Ok(Some(updated_contract)) => { // Notify supervisor of phase change - if let Some(supervisor_task_id) = contract.supervisor_task_id { + if let Some(supervisor_task_id) = updated_contract.supervisor_task_id { if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { let state_clone = state.clone(); - let contract_id = contract.id; - let new_phase = contract.phase.clone(); + let contract_id = updated_contract.id; + let new_phase = updated_contract.phase.clone(); tokio::spawn(async move { state_clone.notify_supervisor_of_phase_change( supervisor.id, @@ -1302,21 +1388,21 @@ pub async fn change_phase( ).await; // Get summary with counts - match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await + match repository::get_contract_summary_for_owner(pool, updated_contract.id, auth.owner_id).await { Ok(Some(summary)) => Json(summary).into_response(), _ => Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, + id: updated_contract.id, + name: updated_contract.name, + description: updated_contract.description, + contract_type: updated_contract.contract_type, + phase: updated_contract.phase, + status: updated_contract.status, file_count: 0, task_count: 0, repository_count: 0, - version: contract.version, - created_at: contract.created_at, + version: updated_contract.version, + created_at: updated_contract.created_at, }) .into_response(), } diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index 5a08a49..f8df69f 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -3239,6 +3239,7 @@ pub async fn create_adhoc_task( contract_type: Some(CONTRACT_TYPE_TASK.to_string()), initial_phase: Some("execute".to_string()), // Skip planning autonomous_loop: Some(false), + phase_guard: None, }; let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await { diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs index 275905e..99f9ea7 100644 --- a/makima/src/server/handlers/transcript_analysis.rs +++ b/makima/src/server/handlers/transcript_analysis.rs @@ -277,6 +277,7 @@ pub async fn create_contract_from_analysis( contract_type: Some("specification".to_string()), initial_phase: Some("research".to_string()), autonomous_loop: None, + phase_guard: None, }; let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await { |
