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