diff options
Diffstat (limited to 'makima')
| -rw-r--r-- | makima/frontend/src/components/PhaseConfirmationNotification.tsx | 141 | ||||
| -rw-r--r-- | makima/frontend/src/components/contracts/AutopilotPanel.tsx | 50 | ||||
| -rw-r--r-- | makima/frontend/src/components/contracts/PhaseConfirmationModal.tsx | 411 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskOutput.tsx | 66 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 21 | ||||
| -rw-r--r-- | makima/frontend/src/main.tsx | 2 | ||||
| -rw-r--r-- | makima/migrations/20250119000000_add_phase_guard.sql | 9 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 60 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 15 | ||||
| -rw-r--r-- | makima/src/llm/contract_tools.rs | 34 | ||||
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 87 | ||||
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 112 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/transcript_analysis.rs | 1 |
14 files changed, 988 insertions, 22 deletions
diff --git a/makima/frontend/src/components/PhaseConfirmationNotification.tsx b/makima/frontend/src/components/PhaseConfirmationNotification.tsx new file mode 100644 index 0000000..516211f --- /dev/null +++ b/makima/frontend/src/components/PhaseConfirmationNotification.tsx @@ -0,0 +1,141 @@ +import { useNavigate } from "react-router"; +import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; +import { PhaseConfirmationModal, type PhaseConfirmationData } from "./contracts/PhaseConfirmationModal"; +import type { PendingQuestion } from "../lib/api"; + +/** + * Notification component for phase confirmation requests. + * Shows a modal when there are pending phase_confirmation type questions. + * Uses the same question infrastructure as supervisor questions. + */ +export function PhaseConfirmationNotification() { + const { notificationQuestions, submitAnswer, dismissNotification } = + useSupervisorQuestions(); + + // Filter for phase_confirmation type questions + const phaseConfirmationQuestions = notificationQuestions.filter( + (q) => q.questionType === "phase_confirmation" + ); + + if (phaseConfirmationQuestions.length === 0) { + return null; + } + + // Show the first phase confirmation question as a modal + const question = phaseConfirmationQuestions[0]; + + // Build phase confirmation data from the question + const data: PhaseConfirmationData = { + questionId: question.questionId, + contractId: question.contractId, + contractName: question.phaseConfirmation?.contractName, + currentPhase: question.phaseConfirmation?.currentPhase || "research", + nextPhase: question.phaseConfirmation?.nextPhase || "specify", + summary: question.phaseConfirmation?.summary, + deliverables: question.phaseConfirmation?.deliverables, + }; + + const handleApprove = async (questionId: string) => { + const success = await submitAnswer(questionId, "APPROVE"); + if (success) { + dismissNotification(questionId); + } + }; + + const handleRequestChanges = async (questionId: string, feedback: string) => { + const success = await submitAnswer( + questionId, + `CHANGES_REQUESTED: ${feedback}` + ); + if (success) { + dismissNotification(questionId); + } + }; + + const handleDismiss = () => { + // Dismiss to notification (user can still respond via task output) + dismissNotification(question.questionId); + }; + + return ( + <PhaseConfirmationModal + data={data} + onApprove={handleApprove} + onRequestChanges={handleRequestChanges} + onDismiss={handleDismiss} + /> + ); +} + +/** + * Alternative: Notification toast-style for phase confirmations + * Shows as a small notification in the corner (like regular supervisor questions) + */ +export function PhaseConfirmationToast() { + const navigate = useNavigate(); + const { notificationQuestions, dismissNotification } = useSupervisorQuestions(); + + // Filter for phase_confirmation type questions + const phaseConfirmationQuestions = notificationQuestions.filter( + (q) => q.questionType === "phase_confirmation" + ); + + if (phaseConfirmationQuestions.length === 0) { + return null; + } + + const handleGoToTask = (question: PendingQuestion) => { + dismissNotification(question.questionId); + navigate(`/mesh/${question.taskId}`); + }; + + return ( + <div className="fixed bottom-4 right-4 z-50 max-w-md space-y-2"> + {phaseConfirmationQuestions.map((question) => ( + <div + key={question.questionId} + className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.5)] rounded-lg shadow-lg overflow-hidden" + > + {/* Header */} + <div className="flex items-center justify-between px-4 py-3 bg-[rgba(117,170,252,0.1)]"> + <div className="flex items-center gap-2"> + <span className="text-[#75aafc] text-lg">?</span> + <span className="font-mono text-sm text-[#9bc3ff] uppercase"> + Phase Transition + </span> + </div> + <button + onClick={() => handleGoToTask(question)} + className="px-3 py-1 font-mono text-xs text-[#75aafc] border border-[rgba(117,170,252,0.3)] hover:border-[rgba(117,170,252,0.5)] hover:bg-[rgba(117,170,252,0.1)] transition-colors uppercase" + > + Review + </button> + </div> + + {/* Content preview */} + <div className="px-4 py-3"> + {question.phaseConfirmation && ( + <div className="flex items-center gap-2 mb-2"> + <span className="font-mono text-xs text-purple-400"> + {question.phaseConfirmation.currentPhase} + </span> + <span className="text-[#555] font-mono text-xs">→</span> + <span className="font-mono text-xs text-green-400"> + {question.phaseConfirmation.nextPhase} + </span> + </div> + )} + <p className="text-sm text-[#dbe7ff] font-mono line-clamp-2"> + {question.question} + </p> + {question.phaseConfirmation?.contractName && ( + <p className="text-xs text-[#555] font-mono mt-1"> + Contract: {question.phaseConfirmation.contractName} + </p> + )} + </div> + </div> + ))} + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/AutopilotPanel.tsx b/makima/frontend/src/components/contracts/AutopilotPanel.tsx index a8a8e2e..cf42e44 100644 --- a/makima/frontend/src/components/contracts/AutopilotPanel.tsx +++ b/makima/frontend/src/components/contracts/AutopilotPanel.tsx @@ -5,6 +5,7 @@ import { startSupervisor, stopSupervisor, resumeSupervisor, + updateContract, type SupervisorStatus, } from "../../lib/api"; @@ -111,6 +112,23 @@ export function AutopilotPanel({ contract, onUpdate }: AutopilotPanelProps) { } }, [contract.id, supervisorStatus.supervisorTaskId, onUpdate]); + const handlePhaseGuardChange = useCallback(async (enabled: boolean) => { + setLoading(true); + setError(null); + + try { + await updateContract(contract.id, { + phaseGuard: enabled, + version: contract.version, + }); + onUpdate(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update phase guard setting"); + } finally { + setLoading(false); + } + }, [contract.id, contract.version, onUpdate]); + // Don't show panel for task-type contracts (they don't have supervisors) if (contract.contractType === "task") { return null; @@ -185,6 +203,38 @@ export function AutopilotPanel({ contract, onUpdate }: AutopilotPanelProps) { )} </div> + {/* Phase Guard Toggle */} + <div className="pt-3 border-t border-dashed border-[rgba(117,170,252,0.2)]"> + <label className="flex items-start gap-3 cursor-pointer group"> + <div className="relative mt-0.5"> + <input + type="checkbox" + checked={contract.phaseGuard ?? false} + onChange={(e) => handlePhaseGuardChange(e.target.checked)} + disabled={loading} + className="sr-only peer" + /> + <div className="w-9 h-5 bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.3)] rounded-full peer-checked:bg-[rgba(117,170,252,0.3)] transition-colors peer-disabled:opacity-50" /> + <div className="absolute left-0.5 top-0.5 w-4 h-4 bg-[#555] rounded-full transition-transform peer-checked:translate-x-4 peer-checked:bg-[#75aafc] peer-disabled:opacity-50" /> + </div> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <span className="font-mono text-sm text-[#dbe7ff] group-hover:text-white transition-colors"> + Phase Guard + </span> + {contract.phaseGuard && ( + <span className="px-1.5 py-0.5 text-[9px] font-mono uppercase bg-yellow-500/20 text-yellow-400 border border-yellow-400/30 rounded"> + active + </span> + )} + </div> + <div className="font-mono text-xs text-[#555] mt-0.5"> + Ask for confirmation before advancing to the next phase + </div> + </div> + </label> + </div> + {/* Show running indicator when active */} {supervisorStatus.status === "running" && ( <div className="flex items-center gap-2 pt-2 border-t border-dashed border-[rgba(117,170,252,0.2)]"> diff --git a/makima/frontend/src/components/contracts/PhaseConfirmationModal.tsx b/makima/frontend/src/components/contracts/PhaseConfirmationModal.tsx new file mode 100644 index 0000000..96cc1d4 --- /dev/null +++ b/makima/frontend/src/components/contracts/PhaseConfirmationModal.tsx @@ -0,0 +1,411 @@ +import { useState } from "react"; +import type { ContractPhase } from "../../lib/api"; + +/** Phase configuration for styling */ +const phaseConfig: Record< + ContractPhase, + { label: string; color: string; borderColor: string } +> = { + research: { + label: "Research", + color: "text-purple-400", + borderColor: "border-purple-400/50", + }, + specify: { + label: "Specify", + color: "text-blue-400", + borderColor: "border-blue-400/50", + }, + plan: { + label: "Plan", + color: "text-cyan-400", + borderColor: "border-cyan-400/50", + }, + execute: { + label: "Execute", + color: "text-green-400", + borderColor: "border-green-400/50", + }, + review: { + label: "Review", + color: "text-yellow-400", + borderColor: "border-yellow-400/50", + }, +}; + +export interface PhaseConfirmationData { + questionId: string; + contractId: string; + contractName?: string; + currentPhase: ContractPhase; + nextPhase: ContractPhase; + summary?: string; + deliverables?: Array<{ + name: string; + completed: boolean; + }>; +} + +interface PhaseConfirmationModalProps { + data: PhaseConfirmationData; + onApprove: (questionId: string) => Promise<void>; + onRequestChanges: (questionId: string, feedback: string) => Promise<void>; + onDismiss?: () => void; +} + +export function PhaseConfirmationModal({ + data, + onApprove, + onRequestChanges, + onDismiss, +}: PhaseConfirmationModalProps) { + const [mode, setMode] = useState<"choice" | "feedback">("choice"); + const [feedback, setFeedback] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState<string | null>(null); + + const currentConfig = phaseConfig[data.currentPhase]; + const nextConfig = phaseConfig[data.nextPhase]; + + const handleApprove = async () => { + setSubmitting(true); + setError(null); + try { + await onApprove(data.questionId); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to approve"); + } finally { + setSubmitting(false); + } + }; + + const handleRequestChanges = () => { + setMode("feedback"); + }; + + const handleSubmitFeedback = async () => { + if (!feedback.trim()) return; + setSubmitting(true); + setError(null); + try { + await onRequestChanges(data.questionId, feedback.trim()); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit feedback"); + } finally { + setSubmitting(false); + } + }; + + const handleBack = () => { + setMode("choice"); + setFeedback(""); + setError(null); + }; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"> + <div className="w-full max-w-lg mx-4 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-2xl"> + {/* Header */} + <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center gap-2"> + <span className="text-amber-400 text-lg">?</span> + <h2 className="font-mono text-sm text-[#75aafc] uppercase tracking-wide"> + Phase Transition Confirmation + </h2> + </div> + {onDismiss && ( + <button + onClick={onDismiss} + className="text-[#555] hover:text-[#9bc3ff] transition-colors" + aria-label="Dismiss" + > + <span className="text-xl">×</span> + </button> + )} + </div> + + {/* Content */} + <div className="p-4 space-y-4"> + {/* Contract name */} + {data.contractName && ( + <p className="font-mono text-sm text-[#9bc3ff]"> + Contract: <span className="text-[#dbe7ff]">{data.contractName}</span> + </p> + )} + + {/* Phase transition indicator */} + <div className="flex items-center justify-center gap-3 py-4"> + <div + className={`px-4 py-2 border ${currentConfig.borderColor} ${currentConfig.color} font-mono text-sm uppercase`} + > + {currentConfig.label} + </div> + <div className="text-[#555] font-mono">→</div> + <div + className={`px-4 py-2 border ${nextConfig.borderColor} ${nextConfig.color} font-mono text-sm uppercase`} + > + {nextConfig.label} + </div> + </div> + + {/* Summary */} + {data.summary && ( + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.15)] p-3"> + <p className="font-mono text-xs text-[#9bc3ff] mb-2 uppercase"> + Phase Summary + </p> + <p className="font-mono text-sm text-[#dbe7ff] whitespace-pre-wrap"> + {data.summary} + </p> + </div> + )} + + {/* Deliverables checklist */} + {data.deliverables && data.deliverables.length > 0 && ( + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.15)] p-3"> + <p className="font-mono text-xs text-[#9bc3ff] mb-2 uppercase"> + Phase Deliverables + </p> + <ul className="space-y-1"> + {data.deliverables.map((d, idx) => ( + <li key={idx} className="flex items-center gap-2"> + <span + className={ + d.completed ? "text-green-400" : "text-[#555]" + } + > + {d.completed ? "+" : "-"} + </span> + <span + className={`font-mono text-sm ${ + d.completed ? "text-[#9bc3ff]" : "text-[#555]" + }`} + > + {d.name} + </span> + </li> + ))} + </ul> + </div> + )} + + {/* Error message */} + {error && ( + <div className="bg-red-900/20 border border-red-500/30 p-3 text-red-400 font-mono text-sm"> + {error} + </div> + )} + + {/* Choice mode */} + {mode === "choice" && ( + <div className="space-y-3 pt-4 border-t border-[rgba(117,170,252,0.2)]"> + <p className="font-mono text-sm text-[#dbe7ff]"> + Ready to advance to the {nextConfig.label.toLowerCase()} phase? + </p> + <div className="flex gap-3"> + <button + onClick={handleApprove} + disabled={submitting} + className="flex-1 px-4 py-2.5 font-mono text-sm text-[#dbe7ff] bg-green-700/30 border border-green-400/50 hover:bg-green-700/40 hover:border-green-400/70 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {submitting ? "Advancing..." : "Approve & Advance"} + </button> + <button + onClick={handleRequestChanges} + disabled={submitting} + className="flex-1 px-4 py-2.5 font-mono text-sm text-amber-300 border border-amber-500/50 hover:border-amber-400/70 hover:bg-amber-900/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + Request Changes + </button> + </div> + </div> + )} + + {/* Feedback mode */} + {mode === "feedback" && ( + <div className="space-y-3 pt-4 border-t border-[rgba(117,170,252,0.2)]"> + <div> + <label className="block font-mono text-xs text-[#9bc3ff] uppercase mb-2"> + What changes are needed before advancing? + </label> + <textarea + value={feedback} + onChange={(e) => setFeedback(e.target.value)} + placeholder="Describe what needs to be changed or completed..." + rows={4} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + autoFocus + /> + </div> + <div className="flex gap-3"> + <button + onClick={handleBack} + disabled={submitting} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors uppercase" + > + ← Back + </button> + <button + onClick={handleSubmitFeedback} + disabled={submitting || !feedback.trim()} + className="flex-1 px-4 py-2 font-mono text-sm text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {submitting ? "Submitting..." : "Submit Feedback"} + </button> + </div> + </div> + )} + </div> + </div> + </div> + ); +} + +/** Inline variant for task output view */ +interface PhaseConfirmationInlineProps { + data: PhaseConfirmationData; + isPending: boolean; + onApprove: (questionId: string) => Promise<void>; + onRequestChanges: (questionId: string, feedback: string) => Promise<void>; +} + +export function PhaseConfirmationInline({ + data, + isPending, + onApprove, + onRequestChanges, +}: PhaseConfirmationInlineProps) { + const [mode, setMode] = useState<"choice" | "feedback">("choice"); + const [feedback, setFeedback] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const currentConfig = phaseConfig[data.currentPhase]; + const nextConfig = phaseConfig[data.nextPhase]; + + const handleApprove = async () => { + setSubmitting(true); + try { + await onApprove(data.questionId); + } finally { + setSubmitting(false); + } + }; + + const handleSubmitFeedback = async () => { + if (!feedback.trim()) return; + setSubmitting(true); + try { + await onRequestChanges(data.questionId, feedback.trim()); + setFeedback(""); + } finally { + setSubmitting(false); + } + }; + + return ( + <div className="bg-[#0f1825] border border-[rgba(117,170,252,0.4)] rounded p-3 my-2"> + <div className="flex items-center gap-2 mb-3"> + <span className="text-[#75aafc] text-lg">?</span> + <span className="font-mono text-sm text-[#75aafc] uppercase"> + Phase Transition + </span> + {!isPending && ( + <span className="text-green-400 text-xs font-mono">(Responded)</span> + )} + </div> + + {/* Phase transition indicator */} + <div className="flex items-center gap-2 mb-3"> + <span className={`font-mono text-sm ${currentConfig.color}`}> + {currentConfig.label} + </span> + <span className="text-[#555] font-mono">→</span> + <span className={`font-mono text-sm ${nextConfig.color}`}> + {nextConfig.label} + </span> + </div> + + {/* Summary */} + {data.summary && ( + <p className="font-mono text-xs text-[#9bc3ff] mb-3 whitespace-pre-wrap"> + {data.summary} + </p> + )} + + {/* Deliverables */} + {data.deliverables && data.deliverables.length > 0 && ( + <div className="mb-3"> + <p className="font-mono text-[10px] text-[#555] uppercase mb-1"> + Deliverables + </p> + <div className="flex flex-wrap gap-2"> + {data.deliverables.map((d, idx) => ( + <span + key={idx} + className={`px-2 py-0.5 font-mono text-xs border ${ + d.completed + ? "border-green-400/30 text-green-400" + : "border-[#555] text-[#555]" + }`} + > + {d.completed ? "+" : "-"} {d.name} + </span> + ))} + </div> + </div> + )} + + {isPending && ( + <> + {mode === "choice" && ( + <div className="flex flex-wrap gap-2"> + <button + onClick={handleApprove} + disabled={submitting} + className="px-3 py-1.5 font-mono text-xs bg-green-700/30 border border-green-400/50 text-green-300 hover:bg-green-700/40 disabled:opacity-50 transition-colors" + > + {submitting ? "..." : "Approve & Advance"} + </button> + <button + onClick={() => setMode("feedback")} + disabled={submitting} + className="px-3 py-1.5 font-mono text-xs border border-amber-500/50 text-amber-300 hover:bg-amber-900/20 disabled:opacity-50 transition-colors" + > + Request Changes + </button> + </div> + )} + + {mode === "feedback" && ( + <div className="space-y-2"> + <textarea + value={feedback} + onChange={(e) => setFeedback(e.target.value)} + placeholder="Describe what changes are needed..." + rows={2} + className="w-full px-2 py-1.5 bg-[#0a1525] border border-[rgba(117,170,252,0.3)] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc] resize-none" + autoFocus + /> + <div className="flex gap-2"> + <button + onClick={() => setMode("choice")} + disabled={submitting} + className="px-2 py-1 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleSubmitFeedback} + disabled={submitting || !feedback.trim()} + className="px-3 py-1 font-mono text-xs bg-[#0f3c78] border border-[#3f6fb3] text-[#dbe7ff] hover:bg-[#153667] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + > + {submitting ? "..." : "Submit"} + </button> + </div> + </div> + )} + </> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx index d53429d..7140c8a 100644 --- a/makima/frontend/src/components/mesh/TaskOutput.tsx +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -2,6 +2,11 @@ import { useRef, useEffect, useState, useCallback } from "react"; import { SimpleMarkdown } from "../SimpleMarkdown"; import type { TaskOutputEvent } from "../../hooks/useTaskSubscription"; import { sendTaskMessage } from "../../lib/api"; +import { + PhaseConfirmationInline, + type PhaseConfirmationData, +} from "../contracts/PhaseConfirmationModal"; +import type { ContractPhase } from "../../lib/api"; interface TaskOutputProps { /** Array of parsed output events from the backend */ @@ -312,6 +317,15 @@ function OutputEntryRenderer({ entry, pendingQuestionIds, onAnswerQuestion }: Ou /> ); + case "phase_confirmation": + return ( + <PhaseConfirmationEntry + entry={entry} + pendingQuestionIds={pendingQuestionIds} + onAnswerQuestion={onAnswerQuestion} + /> + ); + default: return null; } @@ -520,3 +534,55 @@ function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) { </div> ); } + +/** Entry for phase transition confirmations */ +function PhaseConfirmationEntry({ + entry, + pendingQuestionIds, + onAnswerQuestion, +}: { + entry: TaskOutputEvent; + pendingQuestionIds?: Set<string>; + onAnswerQuestion?: (questionId: string, response: string) => Promise<void>; +}) { + const questionId = entry.toolInput?.question_id as string; + const currentPhase = entry.toolInput?.current_phase as ContractPhase; + const nextPhase = entry.toolInput?.next_phase as ContractPhase; + const contractId = entry.toolInput?.contract_id as string; + const contractName = entry.toolInput?.contract_name as string | undefined; + const summary = entry.toolInput?.summary as string | undefined; + const deliverables = entry.toolInput?.deliverables as + | Array<{ name: string; completed: boolean }> + | undefined; + + const isPending = pendingQuestionIds?.has(questionId) ?? false; + + const data: PhaseConfirmationData = { + questionId, + contractId, + contractName, + currentPhase, + nextPhase, + summary, + deliverables, + }; + + const handleApprove = async (qId: string) => { + if (!onAnswerQuestion) return; + await onAnswerQuestion(qId, "APPROVE"); + }; + + const handleRequestChanges = async (qId: string, feedback: string) => { + if (!onAnswerQuestion) return; + await onAnswerQuestion(qId, `CHANGES_REQUESTED: ${feedback}`); + }; + + return ( + <PhaseConfirmationInline + data={data} + isPending={isPending} + onApprove={handleApprove} + onRequestChanges={handleRequestChanges} + /> + ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index ee04935..9b725bc 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1478,6 +1478,10 @@ export interface Contract { status: ContractStatus; /** Supervisor task ID for contract orchestration */ supervisorTaskId: string | null; + /** Whether tasks for this contract should run in autonomous loop mode */ + autonomousLoop: boolean; + /** Whether to wait for user confirmation before progressing to the next phase */ + phaseGuard: boolean; version: number; createdAt: string; updatedAt: string; @@ -1518,6 +1522,10 @@ export interface UpdateContractRequest { description?: string; phase?: ContractPhase; status?: ContractStatus; + /** Enable or disable autonomous loop mode */ + autonomousLoop?: boolean; + /** Enable or disable phase guard mode */ + phaseGuard?: boolean; version?: number; } @@ -1938,6 +1946,19 @@ export interface PendingQuestion { choices: string[]; context: string | null; createdAt: string; + /** Question type - "general" for regular questions, "phase_confirmation" for phase transitions */ + questionType?: "general" | "phase_confirmation"; + /** Phase confirmation specific data (when questionType is "phase_confirmation") */ + phaseConfirmation?: { + currentPhase: ContractPhase; + nextPhase: ContractPhase; + contractName?: string; + summary?: string; + deliverables?: Array<{ + name: string; + completed: boolean; + }>; + }; } export interface AnswerQuestionRequest { diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 5fd6a4e..19f02d1 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -6,6 +6,7 @@ import { AuthProvider } from "./contexts/AuthContext"; import { SupervisorQuestionsProvider } from "./contexts/SupervisorQuestionsContext"; import { GridOverlay } from "./components/GridOverlay"; import { SupervisorQuestionNotification } from "./components/SupervisorQuestionNotification"; +import { PhaseConfirmationNotification } from "./components/PhaseConfirmationNotification"; import { ProtectedRoute } from "./components/ProtectedRoute"; import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; @@ -24,6 +25,7 @@ createRoot(document.getElementById("root")!).render( <BrowserRouter> <GridOverlay /> <SupervisorQuestionNotification /> + <PhaseConfirmationNotification /> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/login" element={<LoginPage />} /> diff --git a/makima/migrations/20250119000000_add_phase_guard.sql b/makima/migrations/20250119000000_add_phase_guard.sql new file mode 100644 index 0000000..dfa642a --- /dev/null +++ b/makima/migrations/20250119000000_add_phase_guard.sql @@ -0,0 +1,9 @@ +-- Add phase_guard column to contracts table +-- When enabled, the supervisor will wait for user confirmation before progressing to the next phase. +-- This allows users to review and potentially amend phase outputs (plans, requirements, etc.) +-- before the contract continues to the next phase. + +ALTER TABLE contracts +ADD COLUMN IF NOT EXISTS phase_guard BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN contracts.phase_guard IS 'Whether to wait for user confirmation before progressing to the next phase'; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 72ba6f2..99c8b8e 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -1264,6 +1264,11 @@ pub struct Contract { /// without a COMPLETION_GATE indicating ready: true. #[serde(default)] pub autonomous_loop: bool, + /// Whether to wait for user confirmation before progressing to the next phase. + /// When enabled, the supervisor will pause and ask the user to review and approve + /// phase outputs (like plans, requirements, etc.) before continuing. + #[serde(default)] + pub phase_guard: bool, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -1389,6 +1394,11 @@ pub struct CreateContractRequest { /// without a COMPLETION_GATE indicating ready: true. #[serde(default)] pub autonomous_loop: Option<bool>, + /// Enable phase guard mode for this contract. + /// When enabled, the supervisor will pause and ask the user to review and approve + /// phase outputs before progressing to the next phase. + #[serde(default)] + pub phase_guard: Option<bool>, } /// Request payload for updating a contract @@ -1405,6 +1415,11 @@ pub struct UpdateContractRequest { /// Enable or disable autonomous loop mode for tasks in this contract. #[serde(default)] pub autonomous_loop: Option<bool>, + /// Enable or disable phase guard mode for this contract. + /// When enabled, the supervisor will pause and ask the user to review and approve + /// phase outputs before progressing to the next phase. + #[serde(default)] + pub phase_guard: Option<bool>, /// Version for optimistic locking pub version: Option<i32>, } @@ -1443,6 +1458,51 @@ pub struct CreateManagedRepositoryRequest { #[serde(rename_all = "camelCase")] pub struct ChangePhaseRequest { pub phase: String, + /// If phase_guard is enabled, this must be true to confirm the transition. + /// If not provided or false, returns phase deliverables for review. + #[serde(default)] + pub confirmed: Option<bool>, + /// User feedback for changes (used when not confirming) + #[serde(skip_serializing_if = "Option::is_none")] + pub feedback: Option<String>, +} + +/// Response for phase transition when phase_guard is enabled +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PhaseTransitionRequest { + /// Current contract phase + pub current_phase: String, + /// Requested next phase + pub next_phase: String, + /// Summary of phase deliverables/outputs + pub deliverables_summary: String, + /// List of files created in this phase + pub phase_files: Vec<PhaseFileInfo>, + /// List of completed tasks in this phase + pub phase_tasks: Vec<PhaseTaskInfo>, + /// Whether user confirmation is required + pub requires_confirmation: bool, + /// Unique ID for tracking this transition request + pub transition_id: String, +} + +/// File info for phase transition review +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PhaseFileInfo { + pub id: Uuid, + pub name: String, + pub description: Option<String>, +} + +/// Task info for phase transition review +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PhaseTaskInfo { + pub id: Uuid, + pub name: String, + pub status: String, } /// Contract event record from the database diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 43b8e3a..3d1efd1 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -2136,11 +2136,12 @@ pub async fn create_contract_for_owner( } let autonomous_loop = req.autonomous_loop.unwrap_or(false); + let phase_guard = req.phase_guard.unwrap_or(false); sqlx::query_as::<_, Contract>( r#" - INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop, phase_guard) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * "#, ) @@ -2150,6 +2151,7 @@ pub async fn create_contract_for_owner( .bind(contract_type) .bind(phase) .bind(autonomous_loop) + .bind(phase_guard) .fetch_one(pool) .await } @@ -2249,14 +2251,15 @@ pub async fn update_contract_for_owner( let status = req.status.unwrap_or(existing.status); let supervisor_task_id = req.supervisor_task_id.or(existing.supervisor_task_id); let autonomous_loop = req.autonomous_loop.unwrap_or(existing.autonomous_loop); + let phase_guard = req.phase_guard.unwrap_or(existing.phase_guard); let result = if req.version.is_some() { sqlx::query_as::<_, Contract>( r#" UPDATE contracts SET name = $3, description = $4, phase = $5, status = $6, - supervisor_task_id = $7, autonomous_loop = $8, version = version + 1, updated_at = NOW() - WHERE id = $1 AND owner_id = $2 AND version = $9 + supervisor_task_id = $7, autonomous_loop = $8, phase_guard = $9, version = version + 1, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 AND version = $10 RETURNING * "#, ) @@ -2268,6 +2271,7 @@ pub async fn update_contract_for_owner( .bind(&status) .bind(supervisor_task_id) .bind(autonomous_loop) + .bind(phase_guard) .bind(req.version.unwrap()) .fetch_optional(pool) .await? @@ -2276,7 +2280,7 @@ pub async fn update_contract_for_owner( r#" UPDATE contracts SET name = $3, description = $4, phase = $5, status = $6, - supervisor_task_id = $7, autonomous_loop = $8, version = version + 1, updated_at = NOW() + supervisor_task_id = $7, autonomous_loop = $8, phase_guard = $9, version = version + 1, updated_at = NOW() WHERE id = $1 AND owner_id = $2 RETURNING * "#, @@ -2289,6 +2293,7 @@ pub async fn update_contract_for_owner( .bind(&status) .bind(supervisor_task_id) .bind(autonomous_loop) + .bind(phase_guard) .fetch_optional(pool) .await? }; diff --git a/makima/src/llm/contract_tools.rs b/makima/src/llm/contract_tools.rs index 7a3d09a..07de1fe 100644 --- a/makima/src/llm/contract_tools.rs +++ b/makima/src/llm/contract_tools.rs @@ -203,7 +203,7 @@ pub static CONTRACT_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::L }, Tool { name: "advance_phase".to_string(), - description: "Advance the contract to the NEXT phase in sequence. Phases progress: research -> specify -> plan -> execute -> review. You can ONLY advance forward one step. Always use suggest_phase_transition first to check readiness and find the correct next phase.".to_string(), + description: "Advance the contract to the NEXT phase in sequence. Phases progress: research -> specify -> plan -> execute -> review. You can ONLY advance forward one step. Always use suggest_phase_transition first to check readiness and find the correct next phase. If the contract has phase_guard enabled, this will first return a pending_confirmation status with phase deliverables for user review. Call again with confirmed=true to complete the transition, or with feedback to request changes.".to_string(), parameters: json!({ "type": "object", "properties": { @@ -211,6 +211,14 @@ pub static CONTRACT_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::L "type": "string", "enum": ["specify", "plan", "execute", "review"], "description": "The next phase to transition to. Must be exactly one step ahead of current phase (e.g., research->specify, specify->plan, plan->execute, execute->review)" + }, + "confirmed": { + "type": "boolean", + "description": "Set to true to confirm the phase transition when phase_guard is enabled. If omitted or false, returns deliverables for review." + }, + "feedback": { + "type": "string", + "description": "User feedback when requesting changes instead of confirming the transition. The feedback will be passed back to the task to address." } }, "required": ["new_phase"] @@ -500,7 +508,13 @@ pub enum ContractToolRequest { // Phase management GetPhaseInfo, SuggestPhaseTransition, - AdvancePhase { new_phase: String }, + AdvancePhase { + new_phase: String, + /// Whether the user has confirmed the phase transition (for phase_guard) + confirmed: bool, + /// User feedback when they request changes instead of confirming + feedback: Option<String>, + }, // Repository management ListDaemonDirectories, @@ -870,12 +884,28 @@ fn parse_advance_phase(call: &super::tools::ToolCall) -> ContractToolExecutionRe return error_result("Invalid phase. Must be one of: research, specify, plan, execute, review"); } + // Parse optional confirmed flag (defaults to false for initial phase_guard check) + let confirmed = call + .arguments + .get("confirmed") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Parse optional feedback (for when user requests changes) + let feedback = call + .arguments + .get("feedback") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + ContractToolExecutionResult { success: true, message: format!("Advancing to '{}' phase...", new_phase), data: None, request: Some(ContractToolRequest::AdvancePhase { new_phase: new_phase.to_string(), + confirmed, + feedback, }), pending_questions: None, } diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs index 101b257..29ec620 100644 --- a/makima/src/server/handlers/contract_chat.rs +++ b/makima/src/server/handlers/contract_chat.rs @@ -1689,7 +1689,7 @@ async fn handle_contract_request( } } - ContractToolRequest::AdvancePhase { new_phase } => { + ContractToolRequest::AdvancePhase { new_phase, confirmed, feedback } => { let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { Ok(Some(c)) => c, Ok(None) => { @@ -1723,7 +1723,88 @@ async fn handle_contract_request( }; } - // Update phase + // Check if phase_guard is enabled + if contract.phase_guard { + // If user provided feedback, return it for the task to address + if let Some(ref user_feedback) = feedback { + return ContractRequestResult { + success: true, + message: format!( + "Phase transition to '{}' requires changes. User feedback: {}", + new_phase, user_feedback + ), + data: Some(json!({ + "status": "changes_requested", + "currentPhase": current_phase, + "requestedPhase": new_phase, + "feedback": user_feedback, + "action": "Address the user feedback and try again when ready" + })), + }; + } + + // If not confirmed, return pending confirmation with phase deliverables + if !confirmed { + // Get files created in this phase + let phase_files = match repository::list_files_in_contract(pool, contract_id, owner_id).await { + Ok(files) => files + .into_iter() + .filter(|f| f.contract_phase.as_deref() == Some(current_phase)) + .map(|f| json!({ + "id": f.id, + "name": f.name, + "description": f.description + })) + .collect::<Vec<_>>(), + Err(_) => Vec::new(), + }; + + // Get tasks completed in this contract + let phase_tasks = match repository::list_tasks_in_contract(pool, contract_id, owner_id).await { + Ok(tasks) => tasks + .into_iter() + .filter(|t| t.status == "done" || t.status == "completed") + .map(|t| json!({ + "id": t.id, + "name": t.name, + "status": t.status + })) + .collect::<Vec<_>>(), + Err(_) => Vec::new(), + }; + + // Build deliverables summary + let deliverables_summary = format!( + "Phase '{}' deliverables: {} files created, {} tasks completed.", + current_phase, + phase_files.len(), + phase_tasks.len() + ); + + let transition_id = uuid::Uuid::new_v4().to_string(); + + return ContractRequestResult { + success: true, + message: format!( + "Phase transition to '{}' requires user confirmation. Review the deliverables and call advance_phase again with confirmed=true to proceed, or provide feedback to request changes.", + new_phase + ), + data: Some(json!({ + "status": "pending_confirmation", + "transitionId": transition_id, + "currentPhase": current_phase, + "nextPhase": new_phase, + "deliverablesSummary": deliverables_summary, + "phaseFiles": phase_files, + "phaseTasks": phase_tasks, + "requiresConfirmation": true, + "instructions": "To proceed: call advance_phase with confirmed=true. To request changes: call advance_phase with feedback='your feedback here'" + })), + }; + } + } + + // Update phase (either phase_guard is disabled, or user confirmed) match repository::change_contract_phase_for_owner(pool, contract_id, owner_id, &new_phase).await { Ok(Some(updated)) => { // Get deliverables for the new phase @@ -1748,6 +1829,7 @@ async fn handle_contract_request( current_phase, new_phase, deliverables.guidance ), data: Some(json!({ + "status": "advanced", "previousPhase": current_phase, "newPhase": updated.phase, "phaseGuidance": deliverables.guidance, @@ -2377,6 +2459,7 @@ async fn handle_contract_request( contract_type: Some("specification".to_string()), initial_phase: Some("research".to_string()), autonomous_loop: None, + phase_guard: None, }; let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await { diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs index 684ab2b..4f4a94b 100644 --- a/makima/src/server/handlers/contracts.rs +++ b/makima/src/server/handlers/contracts.rs @@ -1267,14 +1267,100 @@ pub async fn change_phase( .into_response(); }; + // First, get the contract to check phase_guard + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // If phase_guard is enabled and not confirmed, return phase deliverables for review + if contract.phase_guard && !req.confirmed.unwrap_or(false) { + // If user provided feedback, return it + if let Some(ref feedback) = req.feedback { + return Json(serde_json::json!({ + "status": "changes_requested", + "currentPhase": contract.phase, + "requestedPhase": req.phase, + "feedback": feedback, + "message": "Feedback has been noted. Address the changes and try again." + })) + .into_response(); + } + + // Get files created in this phase + let phase_files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { + Ok(files) => files + .into_iter() + .filter(|f| f.contract_phase.as_deref() == Some(&contract.phase)) + .map(|f| serde_json::json!({ + "id": f.id, + "name": f.name, + "description": f.description + })) + .collect::<Vec<_>>(), + Err(_) => Vec::new(), + }; + + // Get tasks completed in this contract + let phase_tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { + Ok(tasks) => tasks + .into_iter() + .filter(|t| t.status == "done" || t.status == "completed") + .map(|t| serde_json::json!({ + "id": t.id, + "name": t.name, + "status": t.status + })) + .collect::<Vec<_>>(), + Err(_) => Vec::new(), + }; + + let deliverables_summary = format!( + "Phase '{}' deliverables: {} files created, {} tasks completed.", + contract.phase, + phase_files.len(), + phase_tasks.len() + ); + + let transition_id = uuid::Uuid::new_v4().to_string(); + + return Json(serde_json::json!({ + "status": "pending_confirmation", + "transitionId": transition_id, + "currentPhase": contract.phase, + "nextPhase": req.phase, + "deliverablesSummary": deliverables_summary, + "phaseFiles": phase_files, + "phaseTasks": phase_tasks, + "requiresConfirmation": true, + "message": "Phase transition requires confirmation. Set confirmed=true in the request to proceed." + })) + .into_response(); + } + + // Phase guard is disabled or user confirmed - proceed with phase change match repository::change_contract_phase_for_owner(pool, id, auth.owner_id, &req.phase).await { - Ok(Some(contract)) => { + Ok(Some(updated_contract)) => { // Notify supervisor of phase change - if let Some(supervisor_task_id) = contract.supervisor_task_id { + if let Some(supervisor_task_id) = updated_contract.supervisor_task_id { if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { let state_clone = state.clone(); - let contract_id = contract.id; - let new_phase = contract.phase.clone(); + let contract_id = updated_contract.id; + let new_phase = updated_contract.phase.clone(); tokio::spawn(async move { state_clone.notify_supervisor_of_phase_change( supervisor.id, @@ -1302,21 +1388,21 @@ pub async fn change_phase( ).await; // Get summary with counts - match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await + match repository::get_contract_summary_for_owner(pool, updated_contract.id, auth.owner_id).await { Ok(Some(summary)) => Json(summary).into_response(), _ => Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, + id: updated_contract.id, + name: updated_contract.name, + description: updated_contract.description, + contract_type: updated_contract.contract_type, + phase: updated_contract.phase, + status: updated_contract.status, file_count: 0, task_count: 0, repository_count: 0, - version: contract.version, - created_at: contract.created_at, + version: updated_contract.version, + created_at: updated_contract.created_at, }) .into_response(), } diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index 5a08a49..f8df69f 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -3239,6 +3239,7 @@ pub async fn create_adhoc_task( contract_type: Some(CONTRACT_TYPE_TASK.to_string()), initial_phase: Some("execute".to_string()), // Skip planning autonomous_loop: Some(false), + phase_guard: None, }; let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await { diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs index 275905e..99f9ea7 100644 --- a/makima/src/server/handlers/transcript_analysis.rs +++ b/makima/src/server/handlers/transcript_analysis.rs @@ -277,6 +277,7 @@ pub async fn create_contract_from_analysis( contract_type: Some("specification".to_string()), initial_phase: Some("research".to_string()), autonomous_loop: None, + phase_guard: None, }; let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await { |
