From df774a06324954b9c90497f83eca44279b70f6d5 Mon Sep 17 00:00:00 2001 From: soryu Date: Sat, 24 Jan 2026 12:35:24 +0000 Subject: Reclone if can't find repository --- makima/src/daemon/worktree/manager.rs | 64 +++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 21 deletions(-) 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) } -- cgit v1.2.3 From 1abc91f1da9beb96a54e466102f97e8e8c0b70e5 Mon Sep 17 00:00:00 2001 From: soryu Date: Sat, 24 Jan 2026 14:17:30 +0000 Subject: Fix pushing heartbeat commits --- makima/src/daemon/task/manager.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index 3fdde9b..e1a45c2 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -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; -- cgit v1.2.3 From 9b028d8085d308e43239972348ab117d473caf73 Mon Sep 17 00:00:00 2001 From: soryu Date: Sat, 24 Jan 2026 14:54:21 +0000 Subject: Clean up questions after removing tasks or contracts --- makima/src/server/handlers/contracts.rs | 3 ++ makima/src/server/handlers/mesh.rs | 3 ++ makima/src/server/state.rs | 64 +++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) 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 = 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 = 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. -- cgit v1.2.3 From 4ea35373c08ca7c212dbc7739901168ee4b30753 Mon Sep 17 00:00:00 2001 From: soryu Date: Sat, 24 Jan 2026 15:43:45 +0000 Subject: Increase concurrency limit --- makima/src/daemon/config.rs | 2 +- makima/src/daemon/task/manager.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 e1a45c2..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(), -- cgit v1.2.3 From 792d12df6b1b1bc4f327cbe8e71e7986c67e98f6 Mon Sep 17 00:00:00 2001 From: soryu Date: Sat, 24 Jan 2026 18:11:41 +0000 Subject: Fix history and add retries to makima CLI --- .../20250124000000_fix_history_events_owner_fk.sql | 12 + makima/src/daemon/api/client.rs | 287 ++++++++++++++++----- 2 files changed, 240 insertions(+), 59 deletions(-) create mode 100644 makima/migrations/20250124000000_fix_history_events_owner_fk.sql diff --git a/makima/migrations/20250124000000_fix_history_events_owner_fk.sql b/makima/migrations/20250124000000_fix_history_events_owner_fk.sql new file mode 100644 index 0000000..1f97779 --- /dev/null +++ b/makima/migrations/20250124000000_fix_history_events_owner_fk.sql @@ -0,0 +1,12 @@ +-- Fix history_events owner_id foreign key +-- The owner_id should reference owners(id), not users(id) +-- This was causing all history event inserts to fail silently + +-- Drop the incorrect foreign key constraint +ALTER TABLE history_events + DROP CONSTRAINT IF EXISTS history_events_owner_id_fkey; + +-- Add the correct foreign key constraint referencing owners +ALTER TABLE history_events + ADD CONSTRAINT history_events_owner_id_fkey + FOREIGN KEY (owner_id) REFERENCES owners(id) ON DELETE CASCADE; 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(&self, path: &str) -> Result { 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( &self, path: &str, body: &B, ) -> Result { 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(&self, path: &str) -> Result { 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( &self, path: &str, body: &B, ) -> Result { 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)) + } } -- cgit v1.2.3 From 6364363d1418728351f252b799d397b756e1f985 Mon Sep 17 00:00:00 2001 From: soryu Date: Sat, 24 Jan 2026 20:06:28 +0000 Subject: feat: Simplify contract deliverables and add Templates UI ## Backend Changes ### Phase Deliverables Simplified - **Simple contract type**: - Plan phase: Only 'Plan' deliverable (required) - Execute phase: Only 'PR' deliverable (required) - **Specification contract type**: - Research phase: Only 'Research Notes' deliverable (required) - Specify phase: Only 'Requirements Document' deliverable (required) - Plan phase: Only 'Plan' deliverable (required) - Execute phase: Only 'PR' deliverable (required) - Review phase: Only 'Release Notes' deliverable (required) ### New 'execute' Contract Type - Only has 'execute' phase (no plan or review phases) - NO deliverables at all - executes tasks directly - Added to ContractType enum with proper Display/FromStr implementations - Added helper methods: `initial_phase()`, `terminal_phase()` ### API Updates - Added `get_phase_deliverables_for_type()` for contract-type-aware deliverables - Added `get_phase_checklist_for_type()` for contract-type-aware checklists - Added `check_phase_completion_for_type()` for contract-type-aware completion checks - Added `check_deliverables_met()` function for deliverable validation - Added `should_auto_progress()` for autonomous contract progression - Added new ContractToolRequest::CheckDeliverablesMet tool ## Frontend Changes (makima/frontend) ### Templates Page - Add TemplateEditor component for editing phase deliverables - Create Templates page with template card grid layout - Add navigation link in NavStrip - Implement three built-in templates: Simple, Specification, Execute - Support for creating custom templates with configurable phases/deliverables - Templates are persisted to localStorage Co-Authored-By: Claude Opus 4.5 --- makima/frontend/src/components/NavStrip.tsx | 1 + .../src/components/templates/TemplateEditor.tsx | 248 ++++++++ makima/frontend/src/main.tsx | 9 + makima/frontend/src/routes/templates.tsx | 268 +++++++++ makima/frontend/src/types/templates.ts | 89 +++ makima/src/db/models.rs | 36 +- makima/src/llm/contract_tools.rs | 20 + makima/src/llm/mod.rs | 5 +- makima/src/llm/phase_guidance.rs | 643 ++++++++++++++++++--- makima/src/server/handlers/contract_chat.rs | 168 +++++- makima/src/server/handlers/contract_daemon.rs | 6 +- 11 files changed, 1386 insertions(+), 107 deletions(-) create mode 100644 makima/frontend/src/components/templates/TemplateEditor.tsx create mode 100644 makima/frontend/src/routes/templates.tsx create mode 100644 makima/frontend/src/types/templates.ts diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 7e12c75..2838469 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -15,6 +15,7 @@ const NAV_LINKS: NavLink[] = [ { label: "Board", href: "/workflow", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, { label: "History", href: "/history", requiresAuth: true }, + { label: "Templates", href: "/templates", requiresAuth: true }, ]; export function NavStrip() { diff --git a/makima/frontend/src/components/templates/TemplateEditor.tsx b/makima/frontend/src/components/templates/TemplateEditor.tsx new file mode 100644 index 0000000..03382f3 --- /dev/null +++ b/makima/frontend/src/components/templates/TemplateEditor.tsx @@ -0,0 +1,248 @@ +import { useState } from "react"; +import type { ContractTemplate, Phase, Deliverable } from "../../types/templates"; + +interface Props { + template: ContractTemplate; + onSave: (template: ContractTemplate) => void; + onCancel: () => void; +} + +export function TemplateEditor({ template, onSave, onCancel }: Props) { + const [editedTemplate, setEditedTemplate] = useState({ + ...template, + phases: template.phases.map((p) => ({ + ...p, + deliverables: [...p.deliverables], + })), + }); + const [newDeliverableName, setNewDeliverableName] = useState<{ + [phaseId: string]: string; + }>({}); + + const handlePhaseNameChange = (phaseId: string, newName: string) => { + setEditedTemplate((prev) => ({ + ...prev, + phases: prev.phases.map((p) => + p.id === phaseId ? { ...p, name: newName } : p + ), + })); + }; + + const handleDeliverableNameChange = ( + phaseId: string, + deliverableId: string, + newName: string + ) => { + setEditedTemplate((prev) => ({ + ...prev, + phases: prev.phases.map((p) => + p.id === phaseId + ? { + ...p, + deliverables: p.deliverables.map((d) => + d.id === deliverableId ? { ...d, name: newName } : d + ), + } + : p + ), + })); + }; + + const handleAddDeliverable = (phaseId: string) => { + const name = newDeliverableName[phaseId]?.trim(); + if (!name) return; + + const newDeliverable: Deliverable = { + id: `deliverable-${Date.now()}`, + name, + }; + + setEditedTemplate((prev) => ({ + ...prev, + phases: prev.phases.map((p) => + p.id === phaseId + ? { ...p, deliverables: [...p.deliverables, newDeliverable] } + : p + ), + })); + setNewDeliverableName((prev) => ({ ...prev, [phaseId]: "" })); + }; + + const handleRemoveDeliverable = (phaseId: string, deliverableId: string) => { + setEditedTemplate((prev) => ({ + ...prev, + phases: prev.phases.map((p) => + p.id === phaseId + ? { + ...p, + deliverables: p.deliverables.filter((d) => d.id !== deliverableId), + } + : p + ), + })); + }; + + const handleAddPhase = () => { + const newPhase: Phase = { + id: `phase-${Date.now()}`, + name: "New Phase", + deliverables: [], + }; + setEditedTemplate((prev) => ({ + ...prev, + phases: [...prev.phases, newPhase], + })); + }; + + const handleRemovePhase = (phaseId: string) => { + setEditedTemplate((prev) => ({ + ...prev, + phases: prev.phases.filter((p) => p.id !== phaseId), + })); + }; + + return ( +
+ {/* Header */} +
+

+ Edit Template: {template.name} +

+

+ {template.description} +

+
+ + {/* Phases */} +
+ {editedTemplate.phases.map((phase, phaseIndex) => ( +
+ {/* Phase Header */} +
+ + {phaseIndex + 1} + + handlePhaseNameChange(phase.id, e.target.value)} + placeholder="Phase name" + /> + {!template.isBuiltIn && ( + + )} +
+ + {/* Deliverables */} +
+ {phase.deliverables.length === 0 ? ( +
+ No deliverables +
+ ) : ( + phase.deliverables.map((deliverable) => ( +
+ - + + handleDeliverableNameChange( + phase.id, + deliverable.id, + e.target.value + ) + } + /> + +
+ )) + )} + + {/* Add Deliverable */} +
+ + setNewDeliverableName((prev) => ({ + ...prev, + [phase.id]: e.target.value, + })) + } + onKeyPress={(e) => { + if (e.key === "Enter") { + handleAddDeliverable(phase.id); + } + }} + /> + +
+
+
+ ))} +
+ + {/* Add Phase (only for custom templates) */} + {!template.isBuiltIn && ( + + )} + + {/* Footer Actions */} +
+ + +
+
+ ); +} diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 19f02d1..0464495 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -17,6 +17,7 @@ import MeshPage from "./routes/mesh"; import HistoryPage from "./routes/history"; import LoginPage from "./routes/login"; import SettingsPage from "./routes/settings"; +import TemplatesPage from "./routes/templates"; createRoot(document.getElementById("root")!).render( @@ -117,6 +118,14 @@ createRoot(document.getElementById("root")!).render( } /> + + + + } + /> diff --git a/makima/frontend/src/routes/templates.tsx b/makima/frontend/src/routes/templates.tsx new file mode 100644 index 0000000..ce944a8 --- /dev/null +++ b/makima/frontend/src/routes/templates.tsx @@ -0,0 +1,268 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { TemplateEditor } from "../components/templates/TemplateEditor"; +import { useAuth } from "../contexts/AuthContext"; +import type { ContractTemplate } from "../types/templates"; +import { DEFAULT_TEMPLATES } from "../types/templates"; + +const STORAGE_KEY = "makima_contract_templates"; + +export default function TemplatesPage() { + const navigate = useNavigate(); + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + + const [templates, setTemplates] = useState(() => { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return DEFAULT_TEMPLATES; + } + } + return DEFAULT_TEMPLATES; + }); + + const [editingTemplate, setEditingTemplate] = useState( + null + ); + const [showNewTemplateForm, setShowNewTemplateForm] = useState(false); + const [newTemplateName, setNewTemplateName] = useState(""); + const [newTemplateDescription, setNewTemplateDescription] = useState(""); + + // Redirect to login if not authenticated + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + const saveTemplates = (newTemplates: ContractTemplate[]) => { + setTemplates(newTemplates); + localStorage.setItem(STORAGE_KEY, JSON.stringify(newTemplates)); + }; + + const handleSaveTemplate = (updatedTemplate: ContractTemplate) => { + const newTemplates = templates.map((t) => + t.id === updatedTemplate.id ? updatedTemplate : t + ); + saveTemplates(newTemplates); + setEditingTemplate(null); + }; + + const handleCreateTemplate = () => { + if (!newTemplateName.trim()) return; + + const newTemplate: ContractTemplate = { + id: `custom-${Date.now()}`, + name: newTemplateName.trim(), + description: newTemplateDescription.trim() || "Custom contract template", + isBuiltIn: false, + phases: [ + { + id: `phase-${Date.now()}`, + name: "Execute", + deliverables: [], + }, + ], + }; + + saveTemplates([...templates, newTemplate]); + setNewTemplateName(""); + setNewTemplateDescription(""); + setShowNewTemplateForm(false); + }; + + const handleDeleteTemplate = (templateId: string) => { + const template = templates.find((t) => t.id === templateId); + if (template?.isBuiltIn) return; + + if (window.confirm(`Are you sure you want to delete "${template?.name}"?`)) { + saveTemplates(templates.filter((t) => t.id !== templateId)); + } + }; + + const handleResetToDefaults = () => { + if ( + window.confirm( + "Reset all templates to defaults? This will remove any custom templates." + ) + ) { + saveTemplates(DEFAULT_TEMPLATES); + } + }; + + // Show loading state + if (authLoading) { + return ( +
+
+ Loading... +
+
+ ); + } + + // Editor view + if (editingTemplate) { + return ( +
+ +
+ setEditingTemplate(null)} + /> +
+
+ ); + } + + return ( +
+ +
+ {/* Header */} +
+
+

+ Contract Templates +

+

+ Manage contract types and their phase deliverables +

+
+
+ + +
+
+ + {/* New Template Form */} + {showNewTemplateForm && ( +
+
+ setNewTemplateName(e.target.value)} + /> + setNewTemplateDescription(e.target.value)} + /> + + +
+
+ )} + + {/* Templates Grid */} +
+ {templates.map((template) => ( +
+ {/* Card Header */} +
+

{template.name}

+ {template.isBuiltIn ? ( + + Built-in + + ) : ( + + Custom + + )} +
+ + {/* Card Body */} +
+

+ {template.description} +

+ + {/* Phases */} +
+ {template.phases.map((phase, index) => ( +
+
+ + {index < template.phases.length - 1 && ( + + )} +
+
+ + {phase.name} + + + {phase.deliverables.length === 0 + ? "(no deliverables)" + : phase.deliverables.map((d) => d.name).join(", ")} + +
+
+ ))} +
+
+ + {/* Card Footer */} +
+ + {!template.isBuiltIn && ( + + )} +
+
+ ))} +
+
+
+ ); +} diff --git a/makima/frontend/src/types/templates.ts b/makima/frontend/src/types/templates.ts new file mode 100644 index 0000000..77ba89e --- /dev/null +++ b/makima/frontend/src/types/templates.ts @@ -0,0 +1,89 @@ +// Contract Template types +export interface Deliverable { + id: string; + name: string; +} + +export interface Phase { + id: string; + name: string; + deliverables: Deliverable[]; +} + +export interface ContractTemplate { + id: string; + name: string; + description: string; + phases: Phase[]; + isBuiltIn: boolean; +} + +// Default built-in templates +export const DEFAULT_TEMPLATES: ContractTemplate[] = [ + { + id: "simple", + name: "Simple", + description: "A simple contract with plan and execute phases.", + isBuiltIn: true, + phases: [ + { + id: "plan", + name: "Plan", + deliverables: [{ id: "plan-deliverable", name: "Plan" }], + }, + { + id: "execute", + name: "Execute", + deliverables: [{ id: "pr-deliverable", name: "PR" }], + }, + ], + }, + { + id: "specification", + name: "Specification", + description: + "A comprehensive contract with research, specification, planning, execution, and review phases.", + isBuiltIn: true, + phases: [ + { + id: "research", + name: "Research", + deliverables: [{ id: "research-notes", name: "Research Notes" }], + }, + { + id: "specify", + name: "Specify", + deliverables: [{ id: "requirements", name: "Requirements Document" }], + }, + { + id: "plan", + name: "Plan", + deliverables: [{ id: "plan-deliverable", name: "Plan" }], + }, + { + id: "execute", + name: "Execute", + deliverables: [{ id: "pr-deliverable", name: "PR" }], + }, + { + id: "review", + name: "Review", + deliverables: [{ id: "release-notes", name: "Release Notes" }], + }, + ], + }, + { + id: "execute", + name: "Execute", + description: + "A minimal contract with only an execute phase and no deliverables.", + isBuiltIn: true, + phases: [ + { + id: "execute", + name: "Execute", + deliverables: [], + }, + ], + }, +]; 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> = 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 = 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, + /// List of required deliverables and their status + pub required_deliverables: Vec, + /// List of what's missing (if any) + pub missing: Vec, + /// 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, +} + +/// 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 { + 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, + /// 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 = cwr.files.iter().map(|f| FileInfo { + id: f.id, + name: f.name.clone(), + contract_phase: f.contract_phase.clone(), + }).collect(); + + let task_infos: Vec = 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 = 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 = cwr.files.iter().map(|f| FileInfo { + id: f.id, + name: f.name.clone(), + contract_phase: f.contract_phase.clone(), + }).collect(); + + let task_infos: Vec = 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() { -- cgit v1.2.3 From a279ec29efb863fefd1ca82e5b490f2e8784cf3c Mon Sep 17 00:00:00 2001 From: soryu Date: Sun, 25 Jan 2026 00:01:25 +0000 Subject: Move files tab and file pages to be accessible via contracts (#27) * feat: remove Files from top-level navigation Co-Authored-By: Claude Opus 4.5 * feat: update file links to use contract-scoped routes Co-Authored-By: Claude Opus 4.5 * feat: add contract context to FileDetail component - Add contractId, contractName, and onContractClick props to FileDetailProps - Update breadcrumb navigation to show contract name with path separator when viewing file within a contract context - Fall back to "Back to list" when no contract context is provided - This enables the FileDetail component to be used within the /contracts/:contractId/files/:fileId route Co-Authored-By: Claude Opus 4.5 * feat: update routes to nest files under contracts - Add react-router-dom for client-side routing - Create ContractList component to list all contracts - Create ContractDetail component with tabs (overview, files, tasks, repos) - Create FileDetail component to view individual files - Configure routes: - /contracts - list all contracts - /contracts/:id - view contract details with Files tab - /contracts/:contractId/files/:fileId - view file in contract context - Remove standalone file routes (/files, /files/:id) Files are now only accessible through their parent contract. Co-Authored-By: Claude Opus 4.5 * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint * Task completion checkpoint --------- Co-authored-by: Claude Opus 4.5 --- frontend/package-lock.json | 50 +++++ frontend/package.json | 1 + frontend/src/components/ContractDetail.tsx | 232 +++++++++++++++++++++ frontend/src/components/ContractList.tsx | 83 ++++++++ frontend/src/components/FileDetail.tsx | 97 +++++++++ frontend/src/main.tsx | 33 ++- frontend/tsconfig.tsbuildinfo | 2 +- makima/frontend/src/components/NavStrip.tsx | 1 - .../frontend/src/components/files/FileDetail.tsx | 35 +++- makima/frontend/src/routes/contracts.tsx | 6 +- 10 files changed, 529 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/ContractDetail.tsx create mode 100644 frontend/src/components/ContractList.tsx create mode 100644 frontend/src/components/FileDetail.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d7c37c7..230ed07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "nanostores": "^1.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router-dom": "^6.22.0", "three": "^0.180.0" }, "devDependencies": { @@ -65,6 +66,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -726,6 +728,15 @@ "react": ">=18.0.0" } }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1049,6 +1060,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -1064,6 +1076,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", "dev": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1146,6 +1159,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -1377,6 +1391,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -1425,6 +1440,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1436,6 +1452,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -1453,6 +1470,38 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/rollup": { "version": "4.49.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz", @@ -1577,6 +1626,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/package.json b/frontend/package.json index 53e4c2c..197c3d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "nanostores": "^1.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router-dom": "^6.22.0", "three": "^0.180.0" }, "devDependencies": { diff --git a/frontend/src/components/ContractDetail.tsx b/frontend/src/components/ContractDetail.tsx new file mode 100644 index 0000000..72527ce --- /dev/null +++ b/frontend/src/components/ContractDetail.tsx @@ -0,0 +1,232 @@ +import React, { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' + +interface FileSummary { + id: string + name: string + description?: string + contract_phase?: string +} + +interface TaskSummary { + id: string + name: string + status: string +} + +interface ContractRepository { + id: string + name: string + source_type: string + is_primary: boolean +} + +interface Contract { + id: string + name: string + description?: string + contract_type: string + phase: string + status: string + version: number + created_at: string +} + +interface ContractWithRelations { + contract: Contract + repositories: ContractRepository[] + files: FileSummary[] + tasks: TaskSummary[] +} + +type Tab = 'overview' | 'files' | 'tasks' | 'repositories' + +export function ContractDetail() { + const { id } = useParams<{ id: string }>() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [activeTab, setActiveTab] = useState('overview') + + useEffect(() => { + async function fetchContract() { + if (!id) return + + try { + setLoading(true) + const response = await fetch(`/api/v1/contracts/${id}`) + if (!response.ok) { + throw new Error(`Failed to fetch contract: ${response.statusText}`) + } + const contractData = await response.json() + setData(contractData) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + } + } + + fetchContract() + }, [id]) + + if (loading) { + return ( +
+
Loading contract...
+
+ ) + } + + if (error) { + return ( +
+
Error: {error}
+ + Back to Contracts + +
+ ) + } + + if (!data) { + return ( +
+
Contract not found
+ + Back to Contracts + +
+ ) + } + + const { contract, repositories, files, tasks } = data + + return ( +
+
+ + Back to Contracts + +

{contract.name}

+ {contract.description && ( +

{contract.description}

+ )} +
+ Phase: {contract.phase} + Status: {contract.status} + Version: {contract.version} +
+
+ +
+ + + + +
+ +
+ {activeTab === 'overview' && ( +
+

Contract Overview

+
+
Type
+
{contract.contract_type}
+
Phase
+
{contract.phase}
+
Status
+
{contract.status}
+
Created
+
{new Date(contract.created_at).toLocaleString()}
+
+
+ )} + + {activeTab === 'files' && ( +
+

Files

+ {files.length === 0 ? ( +

No files in this contract

+ ) : ( +
    + {files.map((file) => ( +
  • + +

    {file.name}

    + {file.description &&

    {file.description}

    } + {file.contract_phase && ( + Phase: {file.contract_phase} + )} + +
  • + ))} +
+ )} +
+ )} + + {activeTab === 'tasks' && ( +
+

Tasks

+ {tasks.length === 0 ? ( +

No tasks in this contract

+ ) : ( +
    + {tasks.map((task) => ( +
  • +

    {task.name}

    + + {task.status} + +
  • + ))} +
+ )} +
+ )} + + {activeTab === 'repositories' && ( +
+

Repositories

+ {repositories.length === 0 ? ( +

No repositories linked to this contract

+ ) : ( +
    + {repositories.map((repo) => ( +
  • +

    + {repo.name} + {repo.is_primary && Primary} +

    + {repo.source_type} +
  • + ))} +
+ )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/ContractList.tsx b/frontend/src/components/ContractList.tsx new file mode 100644 index 0000000..77012db --- /dev/null +++ b/frontend/src/components/ContractList.tsx @@ -0,0 +1,83 @@ +import React, { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +interface ContractSummary { + id: string + name: string + description?: string + contract_type: string + phase: string + status: string + file_count: number + task_count: number + repository_count: number + created_at: string +} + +export function ContractList() { + const [contracts, setContracts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function fetchContracts() { + try { + setLoading(true) + const response = await fetch('/api/v1/contracts') + if (!response.ok) { + throw new Error(`Failed to fetch contracts: ${response.statusText}`) + } + const data = await response.json() + setContracts(data.contracts || []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + } + } + + fetchContracts() + }, []) + + if (loading) { + return ( +
+
Loading contracts...
+
+ ) + } + + if (error) { + return ( +
+
Error: {error}
+
+ ) + } + + return ( +
+

Contracts

+ {contracts.length === 0 ? ( +

No contracts found

+ ) : ( +
    + {contracts.map((contract) => ( +
  • + +

    {contract.name}

    + {contract.description &&

    {contract.description}

    } +
    + Phase: {contract.phase} + Status: {contract.status} + Files: {contract.file_count} + Tasks: {contract.task_count} +
    + +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/FileDetail.tsx b/frontend/src/components/FileDetail.tsx new file mode 100644 index 0000000..31228ef --- /dev/null +++ b/frontend/src/components/FileDetail.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' + +interface File { + id: string + name: string + description?: string + body?: string + contract_id?: string + version: number + created_at: string +} + +export function FileDetail() { + const { contractId, fileId } = useParams<{ contractId: string; fileId: string }>() + const [file, setFile] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function fetchFile() { + if (!fileId) return + + try { + setLoading(true) + const response = await fetch(`/api/v1/files/${fileId}`) + if (!response.ok) { + throw new Error(`Failed to fetch file: ${response.statusText}`) + } + const data = await response.json() + setFile(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + } + } + + fetchFile() + }, [fileId]) + + if (loading) { + return ( +
+
Loading file...
+
+ ) + } + + if (error) { + return ( +
+
Error: {error}
+ + Back to Contract + +
+ ) + } + + if (!file) { + return ( +
+
File not found
+ + Back to Contract + +
+ ) + } + + return ( +
+
+ + Back to Contract + +

{file.name}

+ {file.description && ( +

{file.description}

+ )} +
+ Version: {file.version} + Created: {new Date(file.created_at).toLocaleString()} +
+
+ +
+ {file.body ? ( +
{file.body}
+ ) : ( +

No content

+ )} +
+
+ ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9373927..a6eae5b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,11 +1,42 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import { createBrowserRouter, RouterProvider } from 'react-router-dom' import App from './App' +import { ContractList } from './components/ContractList' +import { ContractDetail } from './components/ContractDetail' +import { FileDetail } from './components/FileDetail' import './styles/pc98.css' import './styles/mobile.css' +// Route configuration: +// - /contracts - List all contracts +// - /contracts/:id - View contract details with tabs (including Files tab) +// - /contracts/:contractId/files/:fileId - View a specific file within contract context +// +// Note: Standalone file routes (/files, /files/:id) have been removed. +// Files are now only accessible through their parent contract. + +const router = createBrowserRouter([ + { + path: '/', + element: , + }, + { + path: '/contracts', + element: , + }, + { + path: '/contracts/:id', + element: , + }, + { + path: '/contracts/:contractId/files/:fileId', + element: , + }, +]) + ReactDOM.createRoot(document.getElementById('root')!).render( - + ) diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 93d1fec..79408dc 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/dialoguebox.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"} \ No newline at end of file diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 2838469..f44799b 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -10,7 +10,6 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, - { label: "Files", href: "/files", requiresAuth: true }, { label: "Contracts", href: "/contracts", requiresAuth: true }, { label: "Board", href: "/workflow", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx index a030c57..e1fe85f 100644 --- a/makima/frontend/src/components/files/FileDetail.tsx +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -37,6 +37,10 @@ interface FileDetailProps { onSelectVersion?: (version: number) => void; onRestoreVersion?: (version: number) => void; onClearVersionSelection?: () => void; + // Contract context props (for when file is viewed within a contract) + contractId?: string; + contractName?: string; + onContractClick?: () => void; } export function FileDetail({ @@ -65,6 +69,9 @@ export function FileDetail({ onSelectVersion, onRestoreVersion, onClearVersionSelection, + contractId, + contractName, + onContractClick, }: FileDetailProps) { const [isEditing, setIsEditing] = useState(false); const [name, setName] = useState(file.name); @@ -134,13 +141,29 @@ export function FileDetail({
{/* Header */}
+ {/* Breadcrumb navigation */}
- +
+ {contractId && contractName ? ( + <> + + / + Files + + ) : ( + + )} +
{isEditing ? ( <> diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx index 6acda29..6946cb8 100644 --- a/makima/frontend/src/routes/contracts.tsx +++ b/makima/frontend/src/routes/contracts.tsx @@ -375,9 +375,11 @@ function ContractsPageContent() { // File/task navigation handlers const handleFileSelect = useCallback( (fileId: string) => { - navigate(`/files/${fileId}`); + if (contractDetail) { + navigate(`/contracts/${contractDetail.id}/files/${fileId}`); + } }, - [navigate] + [navigate, contractDetail] ); const handleTaskSelect = useCallback( -- cgit v1.2.3 From 32dc1d2d3e80dda194569b20d5414cd39ce7f320 Mon Sep 17 00:00:00 2001 From: soryu Date: Sun, 25 Jan 2026 01:25:28 +0000 Subject: Fix CSS on tempaltes page --- makima/frontend/src/index.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/makima/frontend/src/index.css b/makima/frontend/src/index.css index 722c8a3..5c08006 100644 --- a/makima/frontend/src/index.css +++ b/makima/frontend/src/index.css @@ -69,7 +69,7 @@ body { position: fixed; inset: 0; pointer-events: none; - z-index: 1; + z-index: 0; } .grid-overlay::before { -- cgit v1.2.3 From 2003544969e5b7248ecd242b5cec50b324fa751b Mon Sep 17 00:00:00 2001 From: soryu Date: Sun, 25 Jan 2026 01:34:27 +0000 Subject: Fix CSS on templates page --- makima/frontend/src/routes/templates.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/makima/frontend/src/routes/templates.tsx b/makima/frontend/src/routes/templates.tsx index ce944a8..15bf95c 100644 --- a/makima/frontend/src/routes/templates.tsx +++ b/makima/frontend/src/routes/templates.tsx @@ -96,7 +96,7 @@ export default function TemplatesPage() { // Show loading state if (authLoading) { return ( -
+
Loading...
@@ -107,7 +107,7 @@ export default function TemplatesPage() { // Editor view if (editingTemplate) { return ( -
+
+
{/* Header */} -- cgit v1.2.3