summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-25 02:19:01 +0000
committersoryu <soryu@soryu.co>2026-01-25 02:19:01 +0000
commita4c5e9a601b49d08e5ef3d7a36cdd29372ce2003 (patch)
tree061a880c6ea2cd3bee2fa80137a2e7e3bf3ec6fb /makima/src
parent1f223e55be79805bb1061213db4351925bc0b368 (diff)
parent2003544969e5b7248ecd242b5cec50b324fa751b (diff)
downloadsoryu-makima/files-under-contracts-combined.tar.gz
soryu-makima/files-under-contracts-combined.zip
Merge origin/master into makima/files-under-contracts-combined - resolve import conflictsmakima/files-under-contracts-combined
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/daemon/api/client.rs287
-rw-r--r--makima/src/daemon/config.rs2
-rw-r--r--makima/src/daemon/task/manager.rs5
-rw-r--r--makima/src/daemon/worktree/manager.rs64
-rw-r--r--makima/src/db/models.rs36
-rw-r--r--makima/src/llm/contract_tools.rs20
-rw-r--r--makima/src/llm/mod.rs5
-rw-r--r--makima/src/llm/phase_guidance.rs643
-rw-r--r--makima/src/server/handlers/contract_chat.rs168
-rw-r--r--makima/src/server/handlers/contract_daemon.rs6
-rw-r--r--makima/src/server/handlers/contracts.rs3
-rw-r--r--makima/src/server/handlers/mesh.rs3
-rw-r--r--makima/src/server/state.rs64
13 files changed, 1116 insertions, 190 deletions
diff --git a/makima/src/daemon/api/client.rs b/makima/src/daemon/api/client.rs
index ca1b2a8..4ba4778 100644
--- a/makima/src/daemon/api/client.rs
+++ b/makima/src/daemon/api/client.rs
@@ -2,6 +2,7 @@
use reqwest::Client;
use serde::{de::DeserializeOwned, Serialize};
+use std::time::Duration;
use thiserror::Error;
/// API client errors.
@@ -17,6 +18,12 @@ pub enum ApiError {
Parse(String),
}
+/// Maximum number of retry attempts for failed requests.
+const MAX_RETRIES: u32 = 3;
+
+/// Initial backoff delay in milliseconds.
+const INITIAL_BACKOFF_MS: u64 = 100;
+
/// HTTP client for makima API.
pub struct ApiClient {
client: Client,
@@ -37,94 +44,236 @@ impl ApiClient {
})
}
- /// Make a GET request.
+ /// Make a GET request with retry.
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
let url = format!("{}{}", self.base_url, path);
- let response = self.client
- .get(&url)
- // Send both headers - server will try tool key first, then API key
- .header("X-Makima-Tool-Key", &self.api_key)
- .header("X-Makima-API-Key", &self.api_key)
- .send()
- .await?;
-
- self.handle_response(response).await
+ let mut last_error = None;
+
+ for attempt in 0..MAX_RETRIES {
+ if attempt > 0 {
+ tokio::time::sleep(Self::backoff_delay(attempt - 1)).await;
+ }
+
+ let result = self.client
+ .get(&url)
+ // Send both headers - server will try tool key first, then API key
+ .header("X-Makima-Tool-Key", &self.api_key)
+ .header("X-Makima-API-Key", &self.api_key)
+ .send()
+ .await;
+
+ match result {
+ Ok(response) => {
+ match self.handle_response(response).await {
+ Ok(value) => return Ok(value),
+ Err(e) if Self::is_retryable(&e) && attempt < MAX_RETRIES - 1 => {
+ last_error = Some(e);
+ continue;
+ }
+ Err(e) => return Err(e),
+ }
+ }
+ Err(e) => {
+ let error = ApiError::Request(e);
+ if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 {
+ last_error = Some(error);
+ continue;
+ }
+ return Err(error);
+ }
+ }
+ }
+
+ Err(last_error.unwrap())
}
- /// Make a POST request with JSON body.
+ /// Make a POST request with JSON body and retry.
pub async fn post<T: DeserializeOwned, B: Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T, ApiError> {
let url = format!("{}{}", self.base_url, path);
- let response = self.client
- .post(&url)
- // Send both headers - server will try tool key first, then API key
- .header("X-Makima-Tool-Key", &self.api_key)
- .header("X-Makima-API-Key", &self.api_key)
- .header("Content-Type", "application/json")
- .json(body)
- .send()
- .await?;
-
- self.handle_response(response).await
+ let mut last_error = None;
+
+ for attempt in 0..MAX_RETRIES {
+ if attempt > 0 {
+ tokio::time::sleep(Self::backoff_delay(attempt - 1)).await;
+ }
+
+ let result = self.client
+ .post(&url)
+ // Send both headers - server will try tool key first, then API key
+ .header("X-Makima-Tool-Key", &self.api_key)
+ .header("X-Makima-API-Key", &self.api_key)
+ .header("Content-Type", "application/json")
+ .json(body)
+ .send()
+ .await;
+
+ match result {
+ Ok(response) => {
+ match self.handle_response(response).await {
+ Ok(value) => return Ok(value),
+ Err(e) if Self::is_retryable(&e) && attempt < MAX_RETRIES - 1 => {
+ last_error = Some(e);
+ continue;
+ }
+ Err(e) => return Err(e),
+ }
+ }
+ Err(e) => {
+ let error = ApiError::Request(e);
+ if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 {
+ last_error = Some(error);
+ continue;
+ }
+ return Err(error);
+ }
+ }
+ }
+
+ Err(last_error.unwrap())
}
- /// Make a POST request without body.
+ /// Make a POST request without body and retry.
pub async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
let url = format!("{}{}", self.base_url, path);
- let response = self.client
- .post(&url)
- // Send both headers - server will try tool key first, then API key
- .header("X-Makima-Tool-Key", &self.api_key)
- .header("X-Makima-API-Key", &self.api_key)
- .send()
- .await?;
-
- self.handle_response(response).await
+ let mut last_error = None;
+
+ for attempt in 0..MAX_RETRIES {
+ if attempt > 0 {
+ tokio::time::sleep(Self::backoff_delay(attempt - 1)).await;
+ }
+
+ let result = self.client
+ .post(&url)
+ // Send both headers - server will try tool key first, then API key
+ .header("X-Makima-Tool-Key", &self.api_key)
+ .header("X-Makima-API-Key", &self.api_key)
+ .send()
+ .await;
+
+ match result {
+ Ok(response) => {
+ match self.handle_response(response).await {
+ Ok(value) => return Ok(value),
+ Err(e) if Self::is_retryable(&e) && attempt < MAX_RETRIES - 1 => {
+ last_error = Some(e);
+ continue;
+ }
+ Err(e) => return Err(e),
+ }
+ }
+ Err(e) => {
+ let error = ApiError::Request(e);
+ if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 {
+ last_error = Some(error);
+ continue;
+ }
+ return Err(error);
+ }
+ }
+ }
+
+ Err(last_error.unwrap())
}
- /// Make a PUT request with JSON body.
+ /// Make a PUT request with JSON body and retry.
pub async fn put<T: DeserializeOwned, B: Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T, ApiError> {
let url = format!("{}{}", self.base_url, path);
- let response = self.client
- .put(&url)
- // Send both headers - server will try tool key first, then API key
- .header("X-Makima-Tool-Key", &self.api_key)
- .header("X-Makima-API-Key", &self.api_key)
- .header("Content-Type", "application/json")
- .json(body)
- .send()
- .await?;
-
- self.handle_response(response).await
+ let mut last_error = None;
+
+ for attempt in 0..MAX_RETRIES {
+ if attempt > 0 {
+ tokio::time::sleep(Self::backoff_delay(attempt - 1)).await;
+ }
+
+ let result = self.client
+ .put(&url)
+ // Send both headers - server will try tool key first, then API key
+ .header("X-Makima-Tool-Key", &self.api_key)
+ .header("X-Makima-API-Key", &self.api_key)
+ .header("Content-Type", "application/json")
+ .json(body)
+ .send()
+ .await;
+
+ match result {
+ Ok(response) => {
+ match self.handle_response(response).await {
+ Ok(value) => return Ok(value),
+ Err(e) if Self::is_retryable(&e) && attempt < MAX_RETRIES - 1 => {
+ last_error = Some(e);
+ continue;
+ }
+ Err(e) => return Err(e),
+ }
+ }
+ Err(e) => {
+ let error = ApiError::Request(e);
+ if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 {
+ last_error = Some(error);
+ continue;
+ }
+ return Err(error);
+ }
+ }
+ }
+
+ Err(last_error.unwrap())
}
- /// Make a DELETE request.
+ /// Make a DELETE request with retry.
pub async fn delete(&self, path: &str) -> Result<(), ApiError> {
let url = format!("{}{}", self.base_url, path);
- let response = self.client
- .delete(&url)
- .header("X-Makima-Tool-Key", &self.api_key)
- .header("X-Makima-API-Key", &self.api_key)
- .send()
- .await?;
+ let mut last_error = None;
- let status = response.status();
- if !status.is_success() {
- let body = response.text().await.unwrap_or_default();
- return Err(ApiError::Api {
- status: status.as_u16(),
- message: body,
- });
+ for attempt in 0..MAX_RETRIES {
+ if attempt > 0 {
+ tokio::time::sleep(Self::backoff_delay(attempt - 1)).await;
+ }
+
+ let result = self.client
+ .delete(&url)
+ .header("X-Makima-Tool-Key", &self.api_key)
+ .header("X-Makima-API-Key", &self.api_key)
+ .send()
+ .await;
+
+ match result {
+ Ok(response) => {
+ let status = response.status();
+ if !status.is_success() {
+ let body = response.text().await.unwrap_or_default();
+ let error = ApiError::Api {
+ status: status.as_u16(),
+ message: body,
+ };
+ if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 {
+ last_error = Some(error);
+ continue;
+ }
+ return Err(error);
+ }
+ return Ok(());
+ }
+ Err(e) => {
+ let error = ApiError::Request(e);
+ if Self::is_retryable(&error) && attempt < MAX_RETRIES - 1 {
+ last_error = Some(error);
+ continue;
+ }
+ return Err(error);
+ }
+ }
}
- Ok(())
+ Err(last_error.unwrap())
}
/// Handle API response.
@@ -156,4 +305,24 @@ impl ApiClient {
.map_err(|e| ApiError::Parse(format!("{}: {}", e, body)))
}
}
+
+ /// Check if an error is retryable (connection errors or 5xx server errors).
+ fn is_retryable(error: &ApiError) -> bool {
+ match error {
+ ApiError::Request(e) => {
+ // Retry on connection errors, timeouts, etc.
+ e.is_connect() || e.is_timeout() || e.is_request()
+ }
+ ApiError::Api { status, .. } => {
+ // Retry on 5xx server errors
+ *status >= 500
+ }
+ ApiError::Parse(_) => false,
+ }
+ }
+
+ /// Calculate backoff delay for a given attempt (exponential backoff).
+ fn backoff_delay(attempt: u32) -> Duration {
+ Duration::from_millis(INITIAL_BACKOFF_MS * 2u64.pow(attempt))
+ }
}
diff --git a/makima/src/daemon/config.rs b/makima/src/daemon/config.rs
index b7cb1e8..0b28701 100644
--- a/makima/src/daemon/config.rs
+++ b/makima/src/daemon/config.rs
@@ -276,7 +276,7 @@ fn default_heartbeat_commit_interval() -> u64 {
}
fn default_max_tasks() -> u32 {
- 4
+ 10
}
fn default_max_tasks_per_contract() -> u32 {
diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs
index 3fdde9b..6ba0f52 100644
--- a/makima/src/daemon/task/manager.rs
+++ b/makima/src/daemon/task/manager.rs
@@ -1000,7 +1000,7 @@ pub struct TaskConfig {
impl Default for TaskConfig {
fn default() -> Self {
Self {
- max_concurrent_tasks: 4,
+ max_concurrent_tasks: 10,
max_tasks_per_contract: 10,
worktree_base_dir: WorktreeManager::default_base_dir(),
env_vars: HashMap::new(),
@@ -4993,9 +4993,10 @@ impl TaskManagerInner {
.unwrap_or_else(|| "unknown".to_string());
// 7. Push to remote (best effort - don't fail if push fails)
+ // Use -u origin HEAD to set upstream if not already set (new branches won't have upstream)
let push_output = tokio::process::Command::new("git")
.current_dir(worktree_path)
- .args(["push"])
+ .args(["push", "-u", "origin", "HEAD"])
.output()
.await;
diff --git a/makima/src/daemon/worktree/manager.rs b/makima/src/daemon/worktree/manager.rs
index 04cb307..fa8a9de 100644
--- a/makima/src/daemon/worktree/manager.rs
+++ b/makima/src/daemon/worktree/manager.rs
@@ -286,34 +286,56 @@ impl WorktreeManager {
tokio::fs::create_dir_all(&self.repos_dir).await?;
if repo_path.exists() {
- // Fetch latest changes
- tracing::info!("Fetching updates for existing repo: {}", repo_name);
- let output = Command::new("git")
- .args(["fetch", "--all", "--prune"])
+ // Verify this is actually a git repository before trying to fetch
+ let is_git_repo = Command::new("git")
+ .args(["rev-parse", "--is-bare-repository"])
.current_dir(&repo_path)
.output()
- .await?;
+ .await
+ .map(|o| o.status.success())
+ .unwrap_or(false);
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- tracing::warn!("Git fetch warning: {}", stderr);
- // Don't fail on fetch errors, repo might still be usable
- }
- } else {
- // Clone the repository
- tracing::info!("Cloning repository: {} -> {}", url, repo_path.display());
- let output = Command::new("git")
- .args(["clone", "--bare", url])
- .arg(&repo_path)
- .output()
- .await?;
+ if !is_git_repo {
+ // Directory exists but is not a git repository - remove and re-clone
+ tracing::warn!(
+ "Directory {} exists but is not a git repository, removing and re-cloning",
+ repo_path.display()
+ );
+ tokio::fs::remove_dir_all(&repo_path).await?;
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- return Err(WorktreeError::CloneFailed(stderr.to_string()));
+ // Fall through to clone below
+ } else {
+ // Fetch latest changes
+ tracing::info!("Fetching updates for existing repo: {}", repo_name);
+ let output = Command::new("git")
+ .args(["fetch", "--all", "--prune"])
+ .current_dir(&repo_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ tracing::warn!("Git fetch warning: {}", stderr);
+ // Don't fail on fetch errors, repo might still be usable
+ }
+
+ return Ok(repo_path);
}
}
+ // Clone the repository
+ tracing::info!("Cloning repository: {} -> {}", url, repo_path.display());
+ let output = Command::new("git")
+ .args(["clone", "--bare", url])
+ .arg(&repo_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::CloneFailed(stderr.to_string()));
+ }
+
Ok(repo_path)
}
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 58f4da1..0c1d9f2 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -1108,15 +1108,19 @@ pub struct MergeCompleteCheckResponse {
pub enum ContractType {
/// Simple Plan -> Execute workflow (default)
/// - Plan phase: requires a "Plan" document
- /// - Execute phase: no documents, fulfills the plan
+ /// - Execute phase: requires a "PR" document
Simple,
/// Specification-based development with TDD
- /// - Research: gather requirements and context
- /// - Specify: write specifications and test cases
- /// - Plan: create implementation plan
- /// - Execute: implement according to specs
- /// - Review: verify against specifications
+ /// - Research: requires "Research Notes" document
+ /// - Specify: requires "Requirements Document"
+ /// - Plan: requires "Plan" document
+ /// - Execute: requires "PR" document
+ /// - Review: requires "Release Notes" document
Specification,
+ /// Execute-only workflow with no deliverables
+ /// - Only has "execute" phase
+ /// - NO deliverables at all - just execute tasks directly
+ Execute,
}
impl Default for ContractType {
@@ -1130,6 +1134,7 @@ impl std::fmt::Display for ContractType {
match self {
ContractType::Simple => write!(f, "simple"),
ContractType::Specification => write!(f, "specification"),
+ ContractType::Execute => write!(f, "execute"),
}
}
}
@@ -1141,6 +1146,7 @@ impl std::str::FromStr for ContractType {
match s.to_lowercase().as_str() {
"simple" => Ok(ContractType::Simple),
"specification" => Ok(ContractType::Specification),
+ "execute" => Ok(ContractType::Execute),
_ => Err(format!("Unknown contract type: {}", s)),
}
}
@@ -1347,9 +1353,27 @@ impl Contract {
ContractPhase::Execute,
ContractPhase::Review,
],
+ "execute" => vec![ContractPhase::Execute], // Execute-only, single phase
_ => vec![ContractPhase::Plan, ContractPhase::Execute], // Default to simple
}
}
+
+ /// Get the initial phase for this contract type
+ pub fn initial_phase(&self) -> ContractPhase {
+ match self.contract_type.as_str() {
+ "specification" => ContractPhase::Research,
+ "execute" => ContractPhase::Execute,
+ _ => ContractPhase::Plan, // simple and default
+ }
+ }
+
+ /// Get the terminal phase for this contract type (phase where contract can be completed)
+ pub fn terminal_phase(&self) -> ContractPhase {
+ match self.contract_type.as_str() {
+ "specification" => ContractPhase::Review,
+ _ => ContractPhase::Execute, // simple and execute both end at execute
+ }
+ }
}
/// Contract repository record from the database
diff --git a/makima/src/llm/contract_tools.rs b/makima/src/llm/contract_tools.rs
index 855a2fe..44c1e20 100644
--- a/makima/src/llm/contract_tools.rs
+++ b/makima/src/llm/contract_tools.rs
@@ -287,6 +287,14 @@ pub static CONTRACT_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::L
"properties": {}
}),
},
+ Tool {
+ name: "check_deliverables_met".to_string(),
+ description: "Check if all required deliverables are met for the current phase and whether the contract is ready to advance to the next phase. Returns detailed status including: deliverables_met (bool), ready_to_advance (bool), required_deliverables (list with status), missing items, and auto_progress_recommended (bool). Use this before calling advance_phase to ensure all requirements are satisfied. For simple contracts: Plan phase needs Plan document + Repository, Execute phase needs completed tasks + PR. For specification contracts: Each phase has specific required documents.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
// =============================================================================
// Task Derivation Tools
// =============================================================================
@@ -528,6 +536,7 @@ pub enum ContractToolRequest {
// Phase guidance
GetPhaseChecklist,
+ CheckDeliverablesMet,
// Task derivation
DeriveTasksFromFile { file_id: Uuid },
@@ -604,6 +613,7 @@ pub fn parse_contract_tool_call(call: &super::tools::ToolCall) -> ContractToolEx
// Phase guidance
"get_phase_checklist" => parse_get_phase_checklist(),
+ "check_deliverables_met" => parse_check_deliverables_met(),
// Task derivation
"derive_tasks_from_file" => parse_derive_tasks_from_file(call),
@@ -1057,6 +1067,16 @@ fn parse_get_phase_checklist() -> ContractToolExecutionResult {
}
}
+fn parse_check_deliverables_met() -> ContractToolExecutionResult {
+ ContractToolExecutionResult {
+ success: true,
+ message: "Checking if deliverables are met...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::CheckDeliverablesMet),
+ pending_questions: None,
+ }
+}
+
// =============================================================================
// Task Derivation Tool Parsing
// =============================================================================
diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs
index c4f8e50..fc3802b 100644
--- a/makima/src/llm/mod.rs
+++ b/makima/src/llm/mod.rs
@@ -19,7 +19,10 @@ pub use contract_tools::{
pub use groq::GroqClient;
pub use mesh_tools::{parse_mesh_tool_call, MeshToolExecutionResult, MeshToolRequest, MESH_TOOLS};
pub use phase_guidance::{
- check_phase_completion, format_checklist_markdown, get_phase_checklist, get_phase_deliverables,
+ check_deliverables_met, check_phase_completion, check_phase_completion_for_type,
+ format_checklist_markdown, generate_deliverable_prompt_guidance, get_next_phase_for_contract,
+ get_phase_checklist, get_phase_checklist_for_type, get_phase_deliverables, get_phase_deliverables_for_type,
+ should_auto_progress, AutoProgressAction, AutoProgressDecision, DeliverableCheckResult, DeliverableItem,
DeliverableStatus, FileInfo, FilePriority, PhaseChecklist, PhaseDeliverables, RecommendedFile,
TaskInfo, TaskStats,
};
diff --git a/makima/src/llm/phase_guidance.rs b/makima/src/llm/phase_guidance.rs
index 0d4bb3d..03f7c76 100644
--- a/makima/src/llm/phase_guidance.rs
+++ b/makima/src/llm/phase_guidance.rs
@@ -2,6 +2,22 @@
//!
//! This module provides structured guidance for each contract phase, tracking
//! expected deliverables and completion criteria.
+//!
+//! ## Contract Types
+//!
+//! ### Simple
+//! - **Plan phase**: One required deliverable: "Plan"
+//! - **Execute phase**: One required deliverable: "PR"
+//!
+//! ### Specification
+//! - **Research phase**: One required deliverable: "Research Notes"
+//! - **Specify phase**: One required deliverable: "Requirements Document"
+//! - **Plan phase**: One required deliverable: "Plan"
+//! - **Execute phase**: One required deliverable: "PR"
+//! - **Review phase**: One required deliverable: "Release Notes"
+//!
+//! ### Execute
+//! - **Execute phase only**: No deliverables at all
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -109,8 +125,86 @@ pub struct TaskInfo {
pub status: String,
}
-/// Get phase deliverables configuration
+/// Get phase deliverables configuration (legacy, defaults to "simple" contract type)
pub fn get_phase_deliverables(phase: &str) -> PhaseDeliverables {
+ get_phase_deliverables_for_type(phase, "simple")
+}
+
+/// Get phase deliverables configuration for a specific contract type
+///
+/// ## Contract Types
+///
+/// ### Simple
+/// - Plan: Only "Plan" deliverable (required)
+/// - Execute: Only "PR" deliverable (required)
+///
+/// ### Specification
+/// - Research: Only "Research Notes" deliverable (required)
+/// - Specify: Only "Requirements Document" deliverable (required)
+/// - Plan: Only "Plan" deliverable (required)
+/// - Execute: Only "PR" deliverable (required)
+/// - Review: Only "Release Notes" deliverable (required)
+///
+/// ### Execute
+/// - Execute: No deliverables at all
+pub fn get_phase_deliverables_for_type(phase: &str, contract_type: &str) -> PhaseDeliverables {
+ match contract_type {
+ "execute" => get_execute_type_deliverables(phase),
+ "specification" => get_specification_type_deliverables(phase),
+ "simple" | _ => get_simple_type_deliverables(phase),
+ }
+}
+
+/// Get deliverables for 'simple' contract type
+/// - Plan phase: Only "Plan" deliverable (required)
+/// - Execute phase: Only "PR" deliverable (required)
+fn get_simple_type_deliverables(phase: &str) -> PhaseDeliverables {
+ match phase {
+ "plan" => PhaseDeliverables {
+ phase: "plan".to_string(),
+ recommended_files: vec![
+ RecommendedFile {
+ template_id: "plan".to_string(),
+ name_suggestion: "Plan".to_string(),
+ priority: FilePriority::Required,
+ description: "Implementation plan detailing the approach and tasks".to_string(),
+ },
+ ],
+ requires_repository: true,
+ requires_tasks: false,
+ guidance: "Create a plan document that outlines the implementation approach. A repository must be configured before moving to Execute phase.".to_string(),
+ },
+ "execute" => PhaseDeliverables {
+ phase: "execute".to_string(),
+ recommended_files: vec![
+ RecommendedFile {
+ template_id: "pr".to_string(),
+ name_suggestion: "PR".to_string(),
+ priority: FilePriority::Required,
+ description: "Pull request with the implemented changes".to_string(),
+ },
+ ],
+ requires_repository: true,
+ requires_tasks: true,
+ guidance: "Execute the plan and create a PR with the implemented changes. Complete all tasks to finish the contract.".to_string(),
+ },
+ _ => PhaseDeliverables {
+ phase: phase.to_string(),
+ recommended_files: vec![],
+ requires_repository: false,
+ requires_tasks: false,
+ guidance: "Unknown phase for simple contract type".to_string(),
+ },
+ }
+}
+
+/// Get deliverables for 'specification' contract type
+/// - Research: Only "Research Notes" deliverable (required)
+/// - Specify: Only "Requirements Document" deliverable (required)
+/// - Plan: Only "Plan" deliverable (required)
+/// - Execute: Only "PR" deliverable (required)
+/// - Review: Only "Release Notes" deliverable (required)
+fn get_specification_type_deliverables(phase: &str) -> PhaseDeliverables {
match phase {
"research" => PhaseDeliverables {
phase: "research".to_string(),
@@ -118,25 +212,13 @@ pub fn get_phase_deliverables(phase: &str) -> PhaseDeliverables {
RecommendedFile {
template_id: "research-notes".to_string(),
name_suggestion: "Research Notes".to_string(),
- priority: FilePriority::Recommended,
+ priority: FilePriority::Required,
description: "Document findings and insights during research".to_string(),
},
- RecommendedFile {
- template_id: "competitor-analysis".to_string(),
- name_suggestion: "Competitor Analysis".to_string(),
- priority: FilePriority::Recommended,
- description: "Analyze competitors and market positioning".to_string(),
- },
- RecommendedFile {
- template_id: "user-research".to_string(),
- name_suggestion: "User Research".to_string(),
- priority: FilePriority::Optional,
- description: "Document user interviews and persona insights".to_string(),
- },
],
requires_repository: false,
requires_tasks: false,
- guidance: "Focus on understanding the problem space, gathering information, and documenting findings. Create at least one research document before moving to Specify phase.".to_string(),
+ guidance: "Focus on understanding the problem space and document your findings in the Research Notes before moving to Specify phase.".to_string(),
},
"specify" => PhaseDeliverables {
phase: "specify".to_string(),
@@ -147,74 +229,38 @@ pub fn get_phase_deliverables(phase: &str) -> PhaseDeliverables {
priority: FilePriority::Required,
description: "Define functional and non-functional requirements".to_string(),
},
- RecommendedFile {
- template_id: "user-stories".to_string(),
- name_suggestion: "User Stories".to_string(),
- priority: FilePriority::Recommended,
- description: "Define features from the user's perspective".to_string(),
- },
- RecommendedFile {
- template_id: "acceptance-criteria".to_string(),
- name_suggestion: "Acceptance Criteria".to_string(),
- priority: FilePriority::Recommended,
- description: "Define testable conditions for completion".to_string(),
- },
],
requires_repository: false,
requires_tasks: false,
- guidance: "Define what needs to be built with clear requirements and acceptance criteria. Ensure specifications are detailed enough for planning.".to_string(),
+ guidance: "Define what needs to be built with clear requirements in the Requirements Document. Ensure specifications are detailed enough for planning.".to_string(),
},
"plan" => PhaseDeliverables {
phase: "plan".to_string(),
recommended_files: vec![
RecommendedFile {
- template_id: "architecture".to_string(),
- name_suggestion: "Architecture Document".to_string(),
- priority: FilePriority::Recommended,
- description: "Document system architecture and design decisions".to_string(),
- },
- RecommendedFile {
- template_id: "task-breakdown".to_string(),
- name_suggestion: "Task Breakdown".to_string(),
+ template_id: "plan".to_string(),
+ name_suggestion: "Plan".to_string(),
priority: FilePriority::Required,
- description: "Break down work into implementable tasks".to_string(),
- },
- RecommendedFile {
- template_id: "technical-design".to_string(),
- name_suggestion: "Technical Design".to_string(),
- priority: FilePriority::Optional,
- description: "Detailed technical specification".to_string(),
+ description: "Implementation plan detailing the approach and tasks".to_string(),
},
],
requires_repository: true,
requires_tasks: false,
- guidance: "Design the solution and break down work into tasks. A repository must be configured before moving to Execute phase.".to_string(),
+ guidance: "Create a plan document that outlines the implementation approach. A repository must be configured before moving to Execute phase.".to_string(),
},
"execute" => PhaseDeliverables {
phase: "execute".to_string(),
recommended_files: vec![
RecommendedFile {
- template_id: "dev-notes".to_string(),
- name_suggestion: "Development Notes".to_string(),
- priority: FilePriority::Recommended,
- description: "Track implementation details and decisions".to_string(),
- },
- RecommendedFile {
- template_id: "test-plan".to_string(),
- name_suggestion: "Test Plan".to_string(),
- priority: FilePriority::Optional,
- description: "Document testing strategy and test cases".to_string(),
- },
- RecommendedFile {
- template_id: "implementation-log".to_string(),
- name_suggestion: "Implementation Log".to_string(),
- priority: FilePriority::Optional,
- description: "Chronological log of implementation progress".to_string(),
+ template_id: "pr".to_string(),
+ name_suggestion: "PR".to_string(),
+ priority: FilePriority::Required,
+ description: "Pull request with the implemented changes".to_string(),
},
],
requires_repository: true,
requires_tasks: true,
- guidance: "Execute the planned tasks, implement features, and track progress. Complete all tasks before moving to Review phase.".to_string(),
+ guidance: "Execute the plan and create a PR with the implemented changes. Complete all tasks before moving to Review phase.".to_string(),
},
"review" => PhaseDeliverables {
phase: "review".to_string(),
@@ -225,41 +271,61 @@ pub fn get_phase_deliverables(phase: &str) -> PhaseDeliverables {
priority: FilePriority::Required,
description: "Document changes for release communication".to_string(),
},
- RecommendedFile {
- template_id: "review-checklist".to_string(),
- name_suggestion: "Review Checklist".to_string(),
- priority: FilePriority::Recommended,
- description: "Comprehensive checklist for code and feature review".to_string(),
- },
- RecommendedFile {
- template_id: "retrospective".to_string(),
- name_suggestion: "Retrospective".to_string(),
- priority: FilePriority::Optional,
- description: "Reflect on the project and capture learnings".to_string(),
- },
],
requires_repository: false,
requires_tasks: false,
- guidance: "Review completed work, document the release, and conduct a retrospective. The contract can be completed after review.".to_string(),
+ guidance: "Review completed work and document the release in the Release Notes. The contract can be completed after review.".to_string(),
+ },
+ _ => PhaseDeliverables {
+ phase: phase.to_string(),
+ recommended_files: vec![],
+ requires_repository: false,
+ requires_tasks: false,
+ guidance: "Unknown phase for specification contract type".to_string(),
+ },
+ }
+}
+
+/// Get deliverables for 'execute' contract type
+/// - Execute phase only: No deliverables at all
+fn get_execute_type_deliverables(phase: &str) -> PhaseDeliverables {
+ match phase {
+ "execute" => PhaseDeliverables {
+ phase: "execute".to_string(),
+ recommended_files: vec![], // No deliverables for execute-only contract type
+ requires_repository: true,
+ requires_tasks: true,
+ guidance: "Execute the tasks directly. No deliverable documents are required for this contract type.".to_string(),
},
_ => PhaseDeliverables {
phase: phase.to_string(),
recommended_files: vec![],
requires_repository: false,
requires_tasks: false,
- guidance: "Unknown phase".to_string(),
+ guidance: "The 'execute' contract type only supports the 'execute' phase.".to_string(),
},
}
}
-/// Build a phase checklist comparing expected vs actual deliverables
+/// Build a phase checklist comparing expected vs actual deliverables (legacy, defaults to "simple")
pub fn get_phase_checklist(
phase: &str,
files: &[FileInfo],
tasks: &[TaskInfo],
has_repository: bool,
) -> PhaseChecklist {
- let deliverables = get_phase_deliverables(phase);
+ get_phase_checklist_for_type(phase, files, tasks, has_repository, "simple")
+}
+
+/// Build a phase checklist comparing expected vs actual deliverables for a specific contract type
+pub fn get_phase_checklist_for_type(
+ phase: &str,
+ files: &[FileInfo],
+ tasks: &[TaskInfo],
+ has_repository: bool,
+ contract_type: &str,
+) -> PhaseChecklist {
+ let deliverables = get_phase_deliverables_for_type(phase, contract_type);
// Match files to expected deliverables
let file_deliverables: Vec<DeliverableStatus> = deliverables
@@ -475,14 +541,25 @@ fn generate_phase_summary(
}
}
-/// Check if phase targets are met for transition
+/// Check if phase targets are met for transition (legacy, defaults to "simple")
pub fn check_phase_completion(
phase: &str,
files: &[FileInfo],
tasks: &[TaskInfo],
has_repository: bool,
) -> bool {
- let checklist = get_phase_checklist(phase, files, tasks, has_repository);
+ check_phase_completion_for_type(phase, files, tasks, has_repository, "simple")
+}
+
+/// Check if phase targets are met for transition for a specific contract type
+pub fn check_phase_completion_for_type(
+ phase: &str,
+ files: &[FileInfo],
+ tasks: &[TaskInfo],
+ has_repository: bool,
+ contract_type: &str,
+) -> bool {
+ let checklist = get_phase_checklist_for_type(phase, files, tasks, has_repository, contract_type);
// Check required files are complete
let required_files_complete = checklist.file_deliverables.iter()
@@ -502,6 +579,302 @@ pub fn check_phase_completion(
required_files_complete && repository_ok && tasks_ok
}
+/// Result of checking if deliverables are met for the current phase
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct DeliverableCheckResult {
+ /// Whether all required deliverables are met
+ pub deliverables_met: bool,
+ /// Whether the phase is ready to advance (includes all readiness checks)
+ pub ready_to_advance: bool,
+ /// Current phase
+ pub phase: String,
+ /// Next phase (if available)
+ pub next_phase: Option<String>,
+ /// List of required deliverables and their status
+ pub required_deliverables: Vec<DeliverableItem>,
+ /// List of what's missing (if any)
+ pub missing: Vec<String>,
+ /// Human-readable summary
+ pub summary: String,
+ /// Whether auto-progress is recommended
+ pub auto_progress_recommended: bool,
+}
+
+/// A single deliverable item status
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct DeliverableItem {
+ /// Name of the deliverable
+ pub name: String,
+ /// Type: "file", "repository", "pr", "tasks"
+ pub deliverable_type: String,
+ /// Whether it's met
+ pub met: bool,
+ /// Additional details
+ pub details: Option<String>,
+}
+
+/// Check if all required deliverables for the current phase are met
+/// This is used for both prompts and the check_deliverables_met tool
+pub fn check_deliverables_met(
+ phase: &str,
+ contract_type: &str,
+ files: &[FileInfo],
+ tasks: &[TaskInfo],
+ has_repository: bool,
+ pr_url: Option<&str>,
+) -> DeliverableCheckResult {
+ let mut required_deliverables = Vec::new();
+ let mut missing = Vec::new();
+
+ // Get the deliverables for this contract type and phase
+ let deliverables = get_phase_deliverables_for_type(phase, contract_type);
+
+ // Check required files for this phase
+ for rec in &deliverables.recommended_files {
+ if rec.priority == FilePriority::Required {
+ let matched = files.iter().any(|f| {
+ f.contract_phase.as_deref() == Some(phase) &&
+ (f.name.to_lowercase().contains(&rec.name_suggestion.to_lowercase()) ||
+ rec.name_suggestion.to_lowercase().contains(&f.name.to_lowercase()) ||
+ f.name.to_lowercase().contains(&rec.template_id.replace("-", " ")))
+ });
+
+ required_deliverables.push(DeliverableItem {
+ name: rec.name_suggestion.clone(),
+ deliverable_type: "file".to_string(),
+ met: matched,
+ details: if matched {
+ Some("Document exists".to_string())
+ } else {
+ None
+ },
+ });
+
+ if !matched {
+ missing.push(format!("Create {} (required)", rec.name_suggestion));
+ }
+ }
+ }
+
+ // Check repository for phases that require it
+ if deliverables.requires_repository {
+ required_deliverables.push(DeliverableItem {
+ name: "Repository".to_string(),
+ deliverable_type: "repository".to_string(),
+ met: has_repository,
+ details: if has_repository {
+ Some("Repository configured".to_string())
+ } else {
+ None
+ },
+ });
+
+ if !has_repository {
+ missing.push("Configure a repository".to_string());
+ }
+ }
+
+ // Check tasks for execute phase
+ if deliverables.requires_tasks {
+ let total_tasks = tasks.len();
+ let done_tasks = tasks.iter().filter(|t| t.status == "done").count();
+ let tasks_complete = total_tasks > 0 && done_tasks == total_tasks;
+
+ required_deliverables.push(DeliverableItem {
+ name: "Tasks Completed".to_string(),
+ deliverable_type: "tasks".to_string(),
+ met: tasks_complete,
+ details: Some(format!("{}/{} tasks done", done_tasks, total_tasks)),
+ });
+
+ if !tasks_complete {
+ if total_tasks == 0 {
+ missing.push("Create and complete tasks".to_string());
+ } else {
+ missing.push(format!("Complete remaining {} task(s)", total_tasks - done_tasks));
+ }
+ }
+ }
+
+ // For simple/specification contracts in execute phase, PR is a key deliverable
+ if (contract_type == "simple" || contract_type == "specification") && phase == "execute" {
+ let has_pr = pr_url.is_some() && !pr_url.unwrap_or("").is_empty();
+ required_deliverables.push(DeliverableItem {
+ name: "Pull Request".to_string(),
+ deliverable_type: "pr".to_string(),
+ met: has_pr,
+ details: pr_url.map(|u| format!("PR: {}", u)),
+ });
+
+ if !has_pr {
+ missing.push("Create a Pull Request for the completed work".to_string());
+ }
+ }
+
+ let deliverables_met = required_deliverables.iter().all(|d| d.met);
+ let next_phase = get_next_phase_for_contract(contract_type, phase);
+ let ready_to_advance = deliverables_met && next_phase.is_some();
+
+ let summary = if deliverables_met {
+ if let Some(ref next) = next_phase {
+ format!("All deliverables met for {} phase. Ready to advance to {} phase.", phase, next)
+ } else {
+ format!("All deliverables met for {} phase. This is the final phase.", phase)
+ }
+ } else {
+ format!("{} deliverable(s) still needed for {} phase.", missing.len(), phase)
+ };
+
+ DeliverableCheckResult {
+ deliverables_met,
+ ready_to_advance,
+ phase: phase.to_string(),
+ next_phase,
+ required_deliverables,
+ missing,
+ summary,
+ auto_progress_recommended: deliverables_met && ready_to_advance,
+ }
+}
+
+/// Get the next phase based on contract type
+pub fn get_next_phase_for_contract(contract_type: &str, current_phase: &str) -> Option<String> {
+ match contract_type {
+ "simple" => match current_phase {
+ "plan" => Some("execute".to_string()),
+ "execute" => None, // Terminal phase for simple contracts
+ _ => None,
+ },
+ "execute" => None, // Execute-only contracts don't have phase transitions
+ "specification" | _ => match current_phase {
+ "research" => Some("specify".to_string()),
+ "specify" => Some("plan".to_string()),
+ "plan" => Some("execute".to_string()),
+ "execute" => Some("review".to_string()),
+ "review" => None, // Final phase
+ _ => None,
+ },
+ }
+}
+
+/// Determine if the contract should auto-progress to the next phase
+/// This is called when deliverables are met and autonomous_loop is enabled
+pub fn should_auto_progress(
+ phase: &str,
+ contract_type: &str,
+ files: &[FileInfo],
+ tasks: &[TaskInfo],
+ has_repository: bool,
+ pr_url: Option<&str>,
+ autonomous_loop: bool,
+) -> AutoProgressDecision {
+ let check = check_deliverables_met(phase, contract_type, files, tasks, has_repository, pr_url);
+
+ if !check.deliverables_met {
+ return AutoProgressDecision {
+ should_progress: false,
+ next_phase: None,
+ reason: format!("Deliverables not met: {}", check.missing.join(", ")),
+ action: AutoProgressAction::WaitForDeliverables,
+ };
+ }
+
+ if check.next_phase.is_none() {
+ return AutoProgressDecision {
+ should_progress: false,
+ next_phase: None,
+ reason: "This is the terminal phase. Contract can be completed.".to_string(),
+ action: AutoProgressAction::CompleteContract,
+ };
+ }
+
+ if autonomous_loop {
+ AutoProgressDecision {
+ should_progress: true,
+ next_phase: check.next_phase,
+ reason: "All deliverables met and autonomous_loop is enabled.".to_string(),
+ action: AutoProgressAction::AdvancePhase,
+ }
+ } else {
+ AutoProgressDecision {
+ should_progress: false,
+ next_phase: check.next_phase,
+ reason: "All deliverables met. Suggest advancing to next phase.".to_string(),
+ action: AutoProgressAction::SuggestAdvance,
+ }
+ }
+}
+
+/// Result of auto-progress decision
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AutoProgressDecision {
+ /// Whether to automatically progress
+ pub should_progress: bool,
+ /// The next phase to progress to
+ pub next_phase: Option<String>,
+ /// Reason for the decision
+ pub reason: String,
+ /// Recommended action
+ pub action: AutoProgressAction,
+}
+
+/// Actions that can be taken based on auto-progress decision
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum AutoProgressAction {
+ /// Wait for required deliverables
+ WaitForDeliverables,
+ /// Automatically advance to next phase
+ AdvancePhase,
+ /// Suggest user to advance (when not autonomous)
+ SuggestAdvance,
+ /// Contract is complete, mark as done
+ CompleteContract,
+}
+
+/// Generate enhanced prompt guidance for deliverable checking
+pub fn generate_deliverable_prompt_guidance(
+ phase: &str,
+ contract_type: &str,
+ check_result: &DeliverableCheckResult,
+) -> String {
+ let mut guidance = String::new();
+
+ guidance.push_str("\n## Phase Deliverables Status\n\n");
+ guidance.push_str(&format!("**Current Phase**: {} | **Contract Type**: {}\n\n",
+ capitalize(phase), contract_type));
+
+ // Show required deliverables checklist
+ guidance.push_str("### Required Deliverables Checklist\n");
+ for item in &check_result.required_deliverables {
+ let status = if item.met { "[x]" } else { "[ ]" };
+ let details = item.details.as_ref().map(|d| format!(" - {}", d)).unwrap_or_default();
+ guidance.push_str(&format!("{} **{}** ({}){}\n", status, item.name, item.deliverable_type, details));
+ }
+
+ // Show status and next actions
+ guidance.push_str("\n### Status\n");
+ if check_result.deliverables_met {
+ guidance.push_str("**All deliverables are met.**\n\n");
+ if let Some(ref next) = check_result.next_phase {
+ guidance.push_str(&format!("Ready to advance to **{}** phase.\n", next));
+ if check_result.auto_progress_recommended {
+ guidance.push_str(&format!("\n**ACTION REQUIRED**: Since all deliverables are met, you should call `advance_phase` with `new_phase=\"{}\"` to progress the contract.\n", next));
+ }
+ } else {
+ guidance.push_str("This is the terminal phase. The contract can be marked as completed.\n");
+ }
+ } else {
+ guidance.push_str("**Deliverables not yet met.**\n\n");
+ guidance.push_str("Missing:\n");
+ for item in &check_result.missing {
+ guidance.push_str(&format!("- {}\n", item));
+ }
+ guidance.push_str("\nComplete the missing deliverables before advancing to the next phase.\n");
+ }
+
+ guidance
+}
+
/// Format checklist as markdown for LLM context
pub fn format_checklist_markdown(checklist: &PhaseChecklist) -> String {
let mut md = format!("## Phase Progress ({} Phase)\n\n", capitalize(&checklist.phase));
@@ -572,26 +945,93 @@ mod tests {
use super::*;
#[test]
- fn test_get_phase_deliverables() {
- let research = get_phase_deliverables("research");
+ fn test_get_phase_deliverables_simple() {
+ // Simple contract type: Plan phase has only "Plan" deliverable
+ let plan = get_phase_deliverables_for_type("plan", "simple");
+ assert_eq!(plan.phase, "plan");
+ assert!(plan.requires_repository);
+ assert_eq!(plan.recommended_files.len(), 1);
+ assert_eq!(plan.recommended_files[0].template_id, "plan");
+ assert_eq!(plan.recommended_files[0].priority, FilePriority::Required);
+
+ // Simple contract type: Execute phase has only "PR" deliverable
+ let execute = get_phase_deliverables_for_type("execute", "simple");
+ assert_eq!(execute.phase, "execute");
+ assert!(execute.requires_repository);
+ assert!(execute.requires_tasks);
+ assert_eq!(execute.recommended_files.len(), 1);
+ assert_eq!(execute.recommended_files[0].template_id, "pr");
+ assert_eq!(execute.recommended_files[0].priority, FilePriority::Required);
+ }
+
+ #[test]
+ fn test_get_phase_deliverables_specification() {
+ // Specification: Research phase has only "Research Notes" deliverable
+ let research = get_phase_deliverables_for_type("research", "specification");
assert_eq!(research.phase, "research");
assert!(!research.requires_repository);
- assert_eq!(research.recommended_files.len(), 3);
+ assert_eq!(research.recommended_files.len(), 1);
+ assert_eq!(research.recommended_files[0].template_id, "research-notes");
+ assert_eq!(research.recommended_files[0].priority, FilePriority::Required);
- let plan = get_phase_deliverables("plan");
- assert!(plan.requires_repository);
- assert!(plan.recommended_files.iter().any(|f| f.template_id == "task-breakdown"));
+ // Specification: Specify phase has only "Requirements Document" deliverable
+ let specify = get_phase_deliverables_for_type("specify", "specification");
+ assert_eq!(specify.phase, "specify");
+ assert_eq!(specify.recommended_files.len(), 1);
+ assert_eq!(specify.recommended_files[0].template_id, "requirements");
+ assert_eq!(specify.recommended_files[0].priority, FilePriority::Required);
+
+ // Specification: Plan phase has only "Plan" deliverable
+ let plan = get_phase_deliverables_for_type("plan", "specification");
+ assert_eq!(plan.phase, "plan");
+ assert_eq!(plan.recommended_files.len(), 1);
+ assert_eq!(plan.recommended_files[0].template_id, "plan");
+
+ // Specification: Execute phase has only "PR" deliverable
+ let execute = get_phase_deliverables_for_type("execute", "specification");
+ assert_eq!(execute.phase, "execute");
+ assert_eq!(execute.recommended_files.len(), 1);
+ assert_eq!(execute.recommended_files[0].template_id, "pr");
+
+ // Specification: Review phase has only "Release Notes" deliverable
+ let review = get_phase_deliverables_for_type("review", "specification");
+ assert_eq!(review.phase, "review");
+ assert_eq!(review.recommended_files.len(), 1);
+ assert_eq!(review.recommended_files[0].template_id, "release-notes");
+ assert_eq!(review.recommended_files[0].priority, FilePriority::Required);
}
#[test]
- fn test_phase_checklist_empty() {
- let checklist = get_phase_checklist("research", &[], &[], false);
+ fn test_get_phase_deliverables_execute_type() {
+ // Execute contract type: Only execute phase, NO deliverables
+ let execute = get_phase_deliverables_for_type("execute", "execute");
+ assert_eq!(execute.phase, "execute");
+ assert!(execute.requires_repository);
+ assert!(execute.requires_tasks);
+ assert!(execute.recommended_files.is_empty()); // NO deliverables
+
+ // Execute contract type: Other phases should return empty deliverables
+ let plan = get_phase_deliverables_for_type("plan", "execute");
+ assert!(plan.recommended_files.is_empty());
+ }
+
+ #[test]
+ fn test_phase_checklist_empty_simple() {
+ let checklist = get_phase_checklist_for_type("plan", &[], &[], false, "simple");
assert_eq!(checklist.completion_percentage, 0);
assert!(!checklist.suggestions.is_empty());
}
#[test]
- fn test_check_phase_completion() {
+ fn test_phase_checklist_execute_type_no_deliverables() {
+ // Execute contract type with no file deliverables
+ let checklist = get_phase_checklist_for_type("execute", &[], &[], true, "execute");
+ // Should have no file deliverables
+ assert!(checklist.file_deliverables.is_empty());
+ }
+
+ #[test]
+ fn test_check_phase_completion_specification() {
let files = vec![
FileInfo {
id: Uuid::new_v4(),
@@ -600,8 +1040,31 @@ mod tests {
},
];
- // Specify phase has required file
- let complete = check_phase_completion("specify", &files, &[], false);
+ // Specify phase has required file for specification contract type
+ let complete = check_phase_completion_for_type("specify", &files, &[], false, "specification");
assert!(complete);
}
+
+ #[test]
+ fn test_check_phase_completion_simple() {
+ let files = vec![
+ FileInfo {
+ id: Uuid::new_v4(),
+ name: "Plan".to_string(),
+ contract_phase: Some("plan".to_string()),
+ },
+ ];
+
+ // Plan phase has required "Plan" file for simple contract type
+ let complete = check_phase_completion_for_type("plan", &files, &[], true, "simple");
+ assert!(complete);
+ }
+
+ #[test]
+ fn test_legacy_functions_default_to_simple() {
+ // Legacy get_phase_deliverables defaults to simple
+ let plan = get_phase_deliverables("plan");
+ assert_eq!(plan.recommended_files.len(), 1);
+ assert_eq!(plan.recommended_files[0].template_id, "plan");
+ }
}
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs
index e2adb72..28c3436 100644
--- a/makima/src/server/handlers/contract_chat.rs
+++ b/makima/src/server/handlers/contract_chat.rs
@@ -20,7 +20,7 @@ use crate::db::{
};
use crate::llm::{
all_templates, analyze_task_output, body_to_markdown, format_checklist_markdown,
- format_parsed_tasks, get_phase_checklist, parse_tasks_from_breakdown,
+ format_parsed_tasks, parse_tasks_from_breakdown,
claude::{self, ClaudeClient, ClaudeError, ClaudeModel},
groq::{GroqClient, GroqError, Message, ToolCallResponse},
parse_contract_tool_call, templates_for_phase, ContractToolRequest, FileInfo,
@@ -433,8 +433,8 @@ When a new contract is created or the user seems unsure:
fn build_contract_context(contract: &crate::db::models::ContractWithRelations) -> String {
let c = &contract.contract;
let mut context = format!(
- "Name: {}\nID: {}\nPhase: {}\nStatus: {}\n",
- c.name, c.id, c.phase, c.status
+ "Name: {}\nID: {}\nPhase: {}\nStatus: {}\nContract Type: {}\nAutonomous Loop: {}\n",
+ c.name, c.id, c.phase, c.status, c.contract_type, c.autonomous_loop
);
if let Some(ref desc) = c.description {
@@ -455,12 +455,31 @@ fn build_contract_context(contract: &crate::db::models::ContractWithRelations) -
}).collect();
let has_repository = !contract.repositories.is_empty();
- let phase_checklist = get_phase_checklist(&c.phase, &file_infos, &task_infos, has_repository);
+ let phase_checklist = crate::llm::get_phase_checklist_for_type(&c.phase, &file_infos, &task_infos, has_repository, &c.contract_type);
// Add phase checklist to context
context.push_str("\n");
context.push_str(&format_checklist_markdown(&phase_checklist));
+ // Add deliverable check result for phase transition readiness
+ // Note: pr_url is not available in TaskSummary, so we pass None here
+ // Full PR checking should be done via the check_deliverables_met tool
+ let deliverable_check = crate::llm::check_deliverables_met(
+ &c.phase,
+ &c.contract_type,
+ &file_infos,
+ &task_infos,
+ has_repository,
+ None, // pr_url not available in TaskSummary
+ );
+
+ // Add deliverable prompt guidance
+ context.push_str(&crate::llm::generate_deliverable_prompt_guidance(
+ &c.phase,
+ &c.contract_type,
+ &deliverable_check,
+ ));
+
// Files summary
context.push_str(&format!("\n### Files ({} total)\n", contract.files.len()));
if !contract.files.is_empty() {
@@ -1732,6 +1751,65 @@ async fn handle_contract_request(
};
}
+ // Check if deliverables are met before allowing transition
+ let cwr = match get_contract_with_relations(pool, contract_id, owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) | Err(_) => {
+ // Fall through - we'll just skip the deliverables check
+ return ContractRequestResult {
+ success: false,
+ message: "Failed to load contract for deliverables check".to_string(),
+ data: None,
+ };
+ }
+ };
+
+ let file_infos: Vec<FileInfo> = cwr.files.iter().map(|f| FileInfo {
+ id: f.id,
+ name: f.name.clone(),
+ contract_phase: f.contract_phase.clone(),
+ }).collect();
+
+ let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo {
+ id: t.id,
+ name: t.name.clone(),
+ status: t.status.clone(),
+ }).collect();
+
+ let has_repository = !cwr.repositories.is_empty();
+ // Note: pr_url is not available in TaskSummary, so we skip PR checking here
+ // For simple contracts, the PR deliverable check will need to be done
+ // by fetching full task details if needed
+
+ let check_result = crate::llm::check_deliverables_met(
+ current_phase,
+ &contract.contract_type,
+ &file_infos,
+ &task_infos,
+ has_repository,
+ None, // pr_url not available in TaskSummary
+ );
+
+ // Block transition if deliverables are not met
+ if !check_result.deliverables_met {
+ return ContractRequestResult {
+ success: false,
+ message: format!(
+ "Cannot advance to '{}' phase: deliverables not met. {}",
+ new_phase, check_result.summary
+ ),
+ data: Some(json!({
+ "status": "deliverables_not_met",
+ "currentPhase": current_phase,
+ "requestedPhase": new_phase,
+ "deliverablesMet": false,
+ "requiredDeliverables": check_result.required_deliverables,
+ "missing": check_result.missing,
+ "action": "Complete the missing deliverables before advancing to the next phase"
+ })),
+ };
+ }
+
// Check if phase_guard is enabled
if contract.phase_guard {
// If user provided feedback, return it for the task to address
@@ -1816,8 +1894,8 @@ async fn handle_contract_request(
// 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
- let deliverables = crate::llm::get_phase_deliverables(&new_phase);
+ // Get deliverables for the new phase (using contract type)
+ let deliverables = crate::llm::get_phase_deliverables_for_type(&new_phase, &contract.contract_type);
// Build suggested files list
let suggested_files: Vec<serde_json::Value> = deliverables
@@ -1963,7 +2041,7 @@ async fn handle_contract_request(
}).collect();
let has_repository = !cwr.repositories.is_empty();
- let checklist = get_phase_checklist(&cwr.contract.phase, &file_infos, &task_infos, has_repository);
+ let checklist = crate::llm::get_phase_checklist_for_type(&cwr.contract.phase, &file_infos, &task_infos, has_repository, &cwr.contract.contract_type);
ContractRequestResult {
success: true,
@@ -1993,6 +2071,82 @@ async fn handle_contract_request(
}
}
+ ContractToolRequest::CheckDeliverablesMet => {
+ match get_contract_with_relations(pool, contract_id, owner_id).await {
+ Ok(Some(cwr)) => {
+ let file_infos: Vec<FileInfo> = cwr.files.iter().map(|f| FileInfo {
+ id: f.id,
+ name: f.name.clone(),
+ contract_phase: f.contract_phase.clone(),
+ }).collect();
+
+ let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo {
+ id: t.id,
+ name: t.name.clone(),
+ status: t.status.clone(),
+ }).collect();
+
+ let has_repository = !cwr.repositories.is_empty();
+
+ // Note: pr_url is not available in TaskSummary
+ // For simple contracts needing PR checking, full task details would need to be fetched
+ // For now, we pass None and the LLM can guide the user to ensure a PR exists
+
+ let check_result = crate::llm::check_deliverables_met(
+ &cwr.contract.phase,
+ &cwr.contract.contract_type,
+ &file_infos,
+ &task_infos,
+ has_repository,
+ None, // pr_url not available in TaskSummary
+ );
+
+ // Check if we should auto-progress
+ let auto_progress = crate::llm::should_auto_progress(
+ &cwr.contract.phase,
+ &cwr.contract.contract_type,
+ &file_infos,
+ &task_infos,
+ has_repository,
+ None, // pr_url not available in TaskSummary
+ cwr.contract.autonomous_loop,
+ );
+
+ ContractRequestResult {
+ success: true,
+ message: check_result.summary.clone(),
+ data: Some(json!({
+ "deliverablesMet": check_result.deliverables_met,
+ "readyToAdvance": check_result.ready_to_advance,
+ "phase": check_result.phase,
+ "nextPhase": check_result.next_phase,
+ "requiredDeliverables": check_result.required_deliverables,
+ "missing": check_result.missing,
+ "summary": check_result.summary,
+ "autoProgressRecommended": check_result.auto_progress_recommended,
+ "autoProgress": {
+ "shouldProgress": auto_progress.should_progress,
+ "nextPhase": auto_progress.next_phase,
+ "reason": auto_progress.reason,
+ "action": format!("{:?}", auto_progress.action),
+ },
+ "autonomousLoop": cwr.contract.autonomous_loop,
+ })),
+ }
+ }
+ Ok(None) => ContractRequestResult {
+ success: false,
+ message: "Contract not found".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
// =============================================================================
// Task Derivation Handlers
// =============================================================================
diff --git a/makima/src/server/handlers/contract_daemon.rs b/makima/src/server/handlers/contract_daemon.rs
index 13c5640..5b23831 100644
--- a/makima/src/server/handlers/contract_daemon.rs
+++ b/makima/src/server/handlers/contract_daemon.rs
@@ -280,7 +280,7 @@ pub async fn get_contract_checklist(
Err(_) => false,
};
- let checklist = phase_guidance::get_phase_checklist(&contract.phase, &files, &tasks, has_repository);
+ let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &files, &tasks, has_repository, &contract.contract_type);
Json(checklist).into_response()
}
@@ -319,7 +319,7 @@ pub async fn get_contract_goals(
match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
Ok(Some(contract)) => {
- let deliverables = phase_guidance::get_phase_deliverables(&contract.phase);
+ let deliverables = phase_guidance::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type);
Json(ContractGoalsResponse {
description: contract.description,
phase: contract.phase,
@@ -491,7 +491,7 @@ pub async fn get_suggest_action(
.map(|r| !r.is_empty())
.unwrap_or(false);
- let checklist = phase_guidance::get_phase_checklist(&contract.phase, &files, &tasks, has_repository);
+ let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &files, &tasks, has_repository, &contract.contract_type);
// Determine suggested action based on checklist
let (action, description) = if !checklist.suggestions.is_empty() {
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs
index 462b385..f16f33d 100644
--- a/makima/src/server/handlers/contracts.rs
+++ b/makima/src/server/handlers/contracts.rs
@@ -612,6 +612,9 @@ pub async fn delete_contract(
}
}
+ // Clean up any pending supervisor questions for this contract
+ state.remove_pending_questions_for_contract(id);
+
// Clean up all task worktrees BEFORE deleting the contract
// (because CASCADE delete will remove tasks from DB)
cleanup_contract_worktrees(pool, &state, id).await;
diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs
index 3d05f35..3d64eb4 100644
--- a/makima/src/server/handlers/mesh.rs
+++ b/makima/src/server/handlers/mesh.rs
@@ -482,6 +482,9 @@ pub async fn delete_task(
}
}
+ // Clean up any pending supervisor questions for this task
+ state.remove_pending_questions_for_task(id);
+
match repository::delete_task_for_owner(pool, id, auth.owner_id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (
diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs
index 5b75281..32c0af3 100644
--- a/makima/src/server/state.rs
+++ b/makima/src/server/state.rs
@@ -797,6 +797,70 @@ impl AppState {
self.question_responses.remove(&question_id);
}
+ /// Remove all pending questions for a specific task.
+ ///
+ /// This should be called when a task is deleted to clean up orphaned questions.
+ /// Returns the number of questions removed.
+ pub fn remove_pending_questions_for_task(&self, task_id: Uuid) -> usize {
+ // Collect question IDs to remove
+ let question_ids: Vec<Uuid> = self
+ .pending_questions
+ .iter()
+ .filter(|entry| entry.value().task_id == task_id)
+ .map(|entry| entry.value().question_id)
+ .collect();
+
+ let count = question_ids.len();
+
+ // Remove pending questions and their responses
+ for question_id in question_ids {
+ self.pending_questions.remove(&question_id);
+ self.question_responses.remove(&question_id);
+ }
+
+ if count > 0 {
+ tracing::info!(
+ task_id = %task_id,
+ count = count,
+ "Cleaned up pending questions for deleted task"
+ );
+ }
+
+ count
+ }
+
+ /// Remove all pending questions for a specific contract.
+ ///
+ /// This should be called when a contract is deleted to clean up orphaned questions.
+ /// Returns the number of questions removed.
+ pub fn remove_pending_questions_for_contract(&self, contract_id: Uuid) -> usize {
+ // Collect question IDs to remove
+ let question_ids: Vec<Uuid> = self
+ .pending_questions
+ .iter()
+ .filter(|entry| entry.value().contract_id == contract_id)
+ .map(|entry| entry.value().question_id)
+ .collect();
+
+ let count = question_ids.len();
+
+ // Remove pending questions and their responses
+ for question_id in question_ids {
+ self.pending_questions.remove(&question_id);
+ self.question_responses.remove(&question_id);
+ }
+
+ if count > 0 {
+ tracing::info!(
+ contract_id = %contract_id,
+ count = count,
+ "Cleaned up pending questions for deleted contract"
+ );
+ }
+
+ count
+ }
+
/// Register a new daemon connection.
///
/// Returns the connection_id for later reference.