summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/src/components/NavStrip.tsx9
-rw-r--r--makima/frontend/src/components/contracts/CommandModePanel.tsx272
-rw-r--r--makima/frontend/src/components/contracts/ContractCliInput.tsx974
-rw-r--r--makima/frontend/src/components/contracts/ContractContextMenu.tsx160
-rw-r--r--makima/frontend/src/components/contracts/ContractDetail.tsx810
-rw-r--r--makima/frontend/src/components/contracts/ContractList.tsx223
-rw-r--r--makima/frontend/src/components/contracts/PhaseBadge.tsx54
-rw-r--r--makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx339
-rw-r--r--makima/frontend/src/components/contracts/PhaseHint.tsx90
-rw-r--r--makima/frontend/src/components/contracts/PhaseProgressBar.tsx149
-rw-r--r--makima/frontend/src/components/contracts/QuickActionButtons.tsx217
-rw-r--r--makima/frontend/src/components/contracts/RepositoryPanel.tsx325
-rw-r--r--makima/frontend/src/components/contracts/TaskDerivationPreview.tsx221
-rw-r--r--makima/frontend/src/main.tsx26
-rw-r--r--makima/frontend/src/routes/contract-file.tsx659
-rw-r--r--makima/frontend/src/routes/contracts.tsx885
-rw-r--r--makima/frontend/src/routes/document-directives.tsx16
-rw-r--r--makima/frontend/src/routes/tmp.tsx9
-rw-r--r--makima/migrations/20260501100000_tmp_directive_and_clear_orphans.sql39
-rw-r--r--makima/src/bin/makima.rs454
-rw-r--r--makima/src/daemon/cli/contract.rs87
-rw-r--r--makima/src/daemon/cli/mod.rs140
-rw-r--r--makima/src/daemon/cli/supervisor.rs448
-rw-r--r--makima/src/daemon/mod.rs2
-rw-r--r--makima/src/db/models.rs6
-rw-r--r--makima/src/db/repository.rs99
-rw-r--r--makima/src/orchestration/directive.rs229
-rw-r--r--makima/src/server/handlers/contract_chat.rs3183
-rw-r--r--makima/src/server/handlers/contract_daemon.rs936
-rw-r--r--makima/src/server/handlers/contract_discuss.rs592
-rw-r--r--makima/src/server/handlers/contracts.rs2376
-rw-r--r--makima/src/server/handlers/mesh.rs34
-rw-r--r--makima/src/server/handlers/mod.rs8
-rw-r--r--makima/src/server/handlers/transcript_analysis.rs690
-rw-r--r--makima/src/server/mod.rs73
-rw-r--r--makima/src/server/openapi.rs34
36 files changed, 275 insertions, 14593 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index 6fe4ba9..7c5dad1 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -20,12 +20,9 @@ const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
{ label: "Directives", href: "/directives", requiresAuth: true },
{ label: "Orders", href: "/orders", requiresAuth: true },
- {
- label: "Contracts",
- href: "/contracts",
- requiresAuth: true,
- hideInDocumentMode: true,
- },
+ // /contracts has been removed in Phase 5; the legacy nav entry is gone.
+ // /exec is still reachable for the standalone task page but hidden when
+ // document mode is on (the unified surface routes through /directives).
{
label: "Exec",
href: "/exec",
diff --git a/makima/frontend/src/components/contracts/CommandModePanel.tsx b/makima/frontend/src/components/contracts/CommandModePanel.tsx
deleted file mode 100644
index b39b309..0000000
--- a/makima/frontend/src/components/contracts/CommandModePanel.tsx
+++ /dev/null
@@ -1,272 +0,0 @@
-import { useState, useCallback } from "react";
-import { useNavigate } from "react-router";
-import type { ContractWithRelations } from "../../lib/api";
-import {
- getSupervisorStatus,
- startSupervisor,
- stopSupervisor,
- resumeSupervisor,
- updateContract,
- type SupervisorStatus,
-} from "../../lib/api";
-
-interface CommandModePanelProps {
- contract: ContractWithRelations;
- onUpdate: () => void;
-}
-
-const statusConfig: Record<
- SupervisorStatus["status"],
- { label: string; color: string; bgColor: string }
-> = {
- not_configured: {
- label: "Not Configured",
- color: "text-[#555]",
- bgColor: "bg-[#555]/10",
- },
- pending: {
- label: "Ready",
- color: "text-yellow-400",
- bgColor: "bg-yellow-400/10",
- },
- starting: {
- label: "Starting...",
- color: "text-blue-400",
- bgColor: "bg-blue-400/10",
- },
- running: {
- label: "Running",
- color: "text-green-400",
- bgColor: "bg-green-400/10",
- },
- paused: {
- label: "Paused",
- color: "text-orange-400",
- bgColor: "bg-orange-400/10",
- },
- done: {
- label: "Completed",
- color: "text-blue-400",
- bgColor: "bg-blue-400/10",
- },
- failed: {
- label: "Failed",
- color: "text-red-400",
- bgColor: "bg-red-400/10",
- },
-};
-
-export function CommandModePanel({ contract, onUpdate }: CommandModePanelProps) {
- const navigate = useNavigate();
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState<string | null>(null);
-
- const supervisorStatus = getSupervisorStatus(contract);
-
- const handleGoToSupervisor = useCallback(() => {
- if (supervisorStatus.supervisorTaskId) {
- navigate(`/exec/${supervisorStatus.supervisorTaskId}`);
- }
- }, [supervisorStatus.supervisorTaskId, navigate]);
- const config = statusConfig[supervisorStatus.status];
-
- const handleStart = useCallback(async () => {
- if (!supervisorStatus.supervisorTaskId) return;
-
- setLoading(true);
- setError(null);
-
- try {
- await startSupervisor(supervisorStatus.supervisorTaskId);
- onUpdate();
- } catch (e) {
- setError(e instanceof Error ? e.message : "Failed to start command mode");
- } finally {
- setLoading(false);
- }
- }, [supervisorStatus.supervisorTaskId, onUpdate]);
-
- const handleStop = useCallback(async () => {
- if (!supervisorStatus.supervisorTaskId) return;
-
- setLoading(true);
- setError(null);
-
- try {
- await stopSupervisor(supervisorStatus.supervisorTaskId);
- onUpdate();
- } catch (e) {
- setError(e instanceof Error ? e.message : "Failed to stop command mode");
- } finally {
- setLoading(false);
- }
- }, [supervisorStatus.supervisorTaskId, onUpdate]);
-
- const handleResume = useCallback(async () => {
- setLoading(true);
- setError(null);
-
- try {
- await resumeSupervisor(contract.id, { resumeMode: "continue" });
- // After resuming, we need to start the task
- if (supervisorStatus.supervisorTaskId) {
- await startSupervisor(supervisorStatus.supervisorTaskId);
- }
- onUpdate();
- } catch (e) {
- setError(e instanceof Error ? e.message : "Failed to resume command mode");
- } finally {
- setLoading(false);
- }
- }, [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]);
-
- return (
- <div className="space-y-3">
- <div className="flex items-center justify-between">
- <h3 className="font-mono text-xs text-[#75aafc] uppercase">
- Command Mode
- </h3>
- <div className="flex items-center gap-2">
- {supervisorStatus.supervisorTaskId && (
- <button
- onClick={handleGoToSupervisor}
- className="px-2 py-1 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] hover:bg-[rgba(117,170,252,0.1)] transition-colors flex items-center gap-1"
- >
- <span className="text-[#75aafc]">▶</span>
- Supervisor
- </button>
- )}
- <div
- className={`px-2 py-1 rounded font-mono text-xs ${config.color} ${config.bgColor}`}
- >
- {config.label}
- </div>
- </div>
- </div>
-
- <p className="font-mono text-xs text-[#555]">
- {supervisorStatus.status === "not_configured" ? (
- "This contract does not have a Command Mode supervisor configured."
- ) : supervisorStatus.status === "running" ? (
- "Command Mode is actively working on this contract, spawning tasks and managing progress."
- ) : supervisorStatus.status === "pending" ? (
- "Command Mode is ready to start. Click 'Enable' to begin autonomous work."
- ) : supervisorStatus.status === "paused" ? (
- "Command Mode is paused. Click 'Resume' to continue work."
- ) : supervisorStatus.status === "failed" ? (
- "Command Mode encountered an error. You can resume to retry."
- ) : supervisorStatus.status === "done" ? (
- "Command Mode has completed its work on this contract."
- ) : (
- "Command Mode is initializing..."
- )}
- </p>
-
- {error && (
- <div className="px-3 py-2 bg-red-500/10 border border-red-400/30 font-mono text-xs text-red-400">
- {error}
- </div>
- )}
-
- <div className="flex gap-2">
- {supervisorStatus.canStart && (
- <button
- onClick={handleStart}
- disabled={loading}
- className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-green-600/20 border border-green-400/50 hover:bg-green-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
- >
- {loading ? "Starting..." : "Enable Command Mode"}
- </button>
- )}
-
- {supervisorStatus.canResume && (
- <button
- onClick={handleResume}
- disabled={loading}
- className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-blue-600/20 border border-blue-400/50 hover:bg-blue-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
- >
- {loading ? "Resuming..." : "Resume Command Mode"}
- </button>
- )}
-
- {supervisorStatus.canStop && (
- <button
- onClick={handleStop}
- disabled={loading}
- className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-orange-600/20 border border-orange-400/50 hover:bg-orange-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
- >
- {loading ? "Stopping..." : "Pause Command Mode"}
- </button>
- )}
- </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)]">
- <div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
- <span className="font-mono text-xs text-green-400">
- Command Mode is actively working
- </span>
- </div>
- )}
-
- {supervisorStatus.status === "starting" && (
- <div className="flex items-center gap-2 pt-2 border-t border-dashed border-[rgba(117,170,252,0.2)]">
- <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
- <span className="font-mono text-xs text-blue-400">
- Initializing command mode...
- </span>
- </div>
- )}
- </div>
- );
-}
diff --git a/makima/frontend/src/components/contracts/ContractCliInput.tsx b/makima/frontend/src/components/contracts/ContractCliInput.tsx
deleted file mode 100644
index 54d9f3a..0000000
--- a/makima/frontend/src/components/contracts/ContractCliInput.tsx
+++ /dev/null
@@ -1,974 +0,0 @@
-import { useState, useCallback, useRef, useEffect, useMemo } from "react";
-import {
- getContractChatHistory,
- clearContractChatHistory,
- startTask,
- sendTaskMessage,
- type UserQuestion,
- type ContractWithRelations,
- type TaskStatus,
-} from "../../lib/api";
-import { SimpleMarkdown } from "../SimpleMarkdown";
-import {
- QuickActionButtons,
- type QuickAction,
-} from "./QuickActionButtons";
-import { TaskDerivationPreview, type ParsedTask } from "./TaskDerivationPreview";
-import { useTaskSubscription, type TaskOutputEvent } from "../../hooks/useTaskSubscription";
-
-interface ContractCliInputProps {
- contractId: string;
- contract: ContractWithRelations;
- onUpdate: () => void;
-}
-
-interface Message {
- id: string;
- type: "user" | "assistant" | "error" | "question";
- content: string;
- toolCalls?: { name: string; success: boolean; message: string }[];
- questions?: UserQuestion[];
- quickActions?: QuickAction[];
-}
-
-export function ContractCliInput({ contractId, contract, onUpdate }: ContractCliInputProps) {
- const [input, setInput] = useState("");
- const [loading, setLoading] = useState(false);
- const [historyLoading, setHistoryLoading] = useState(true);
- const [messages, setMessages] = useState<Message[]>([]);
- const [expanded, setExpanded] = useState(false);
- const [fullscreen, setFullscreen] = useState(false);
- const [pendingQuestions, setPendingQuestions] = useState<UserQuestion[] | null>(null);
- const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(new Map());
- const [customInputs, setCustomInputs] = useState<Map<string, string>>(new Map());
-
- // Task derivation state
- const [parsedTasks, setParsedTasks] = useState<ParsedTask[] | null>(null);
- const [parsedTaskGroups, setParsedTaskGroups] = useState<string[]>([]);
- const [parsedTasksFileName, setParsedTasksFileName] = useState<string>("");
- const [creatingTasks, setCreatingTasks] = useState(false);
-
- // Supervisor state
- const [supervisorStarting, setSupervisorStarting] = useState(false);
- const [supervisorOutput, setSupervisorOutput] = useState<TaskOutputEvent[]>([]);
- const [supervisorQuestion, setSupervisorQuestion] = useState<{
- id: string;
- question: string;
- options: string[];
- allowMultiple?: boolean;
- allowCustom?: boolean;
- } | null>(null);
-
- const inputRef = useRef<HTMLInputElement>(null);
- const messagesRef = useRef<HTMLDivElement>(null);
-
- // Find the supervisor task for this contract
- // First try by supervisorTaskId on the contract, then fall back to isSupervisor flag
- const supervisorTask = useMemo(() => {
- // Use contract.supervisorTaskId if available (most reliable)
- if (contract.supervisorTaskId) {
- const taskById = contract.tasks.find((t) => t.id === contract.supervisorTaskId);
- if (taskById) return taskById;
- }
- // Fallback to finding by isSupervisor flag
- return contract.tasks.find((t) => t.isSupervisor);
- }, [contract.tasks, contract.supervisorTaskId]);
-
- // Log for debugging
- useEffect(() => {
- console.log("Supervisor lookup:", {
- contractId: contract.id,
- supervisorTaskId: contract.supervisorTaskId,
- tasksCount: contract.tasks.length,
- foundSupervisor: supervisorTask ? { id: supervisorTask.id, status: supervisorTask.status, isSupervisor: supervisorTask.isSupervisor } : null,
- allTasks: contract.tasks.map(t => ({ id: t.id, name: t.name, isSupervisor: t.isSupervisor }))
- });
- }, [contract.id, contract.supervisorTaskId, contract.tasks, supervisorTask]);
-
- const supervisorTaskId = supervisorTask?.id ?? null;
- const supervisorStatus = supervisorTask?.status as TaskStatus | undefined;
- const isSupervisorRunning = supervisorStatus === "running";
- const isSupervisorPending = supervisorStatus === "pending";
-
- // Subscribe to supervisor output when it's running
- const handleSupervisorOutput = useCallback((event: TaskOutputEvent) => {
- // Check for question pattern in output
- // Pattern: {"__supervisor_question__": {"id": "...", "question": "...", "options": [...]}}
- if (!event.isPartial && event.content) {
- const questionMatch = event.content.match(/\{"__supervisor_question__":\s*(\{[^}]+\})\}/);
- if (questionMatch) {
- try {
- const questionData = JSON.parse(questionMatch[1]);
- if (questionData.id && questionData.question && questionData.options) {
- setSupervisorQuestion({
- id: questionData.id,
- question: questionData.question,
- options: questionData.options,
- allowMultiple: questionData.allowMultiple ?? false,
- allowCustom: questionData.allowCustom ?? true,
- });
- // Don't add this to output since it's a control message
- return;
- }
- } catch {
- // Not valid JSON, continue as normal output
- }
- }
- }
-
- setSupervisorOutput((prev) => {
- // If it's a partial message, update the last message
- if (event.isPartial && prev.length > 0) {
- const lastEvent = prev[prev.length - 1];
- if (lastEvent.messageType === event.messageType && lastEvent.isPartial) {
- return [...prev.slice(0, -1), { ...event, content: lastEvent.content + event.content }];
- }
- }
- return [...prev, event];
- });
- }, []);
-
- useTaskSubscription({
- taskId: supervisorTaskId,
- subscribeOutput: isSupervisorRunning,
- onOutput: handleSupervisorOutput,
- });
-
- // Auto-start supervisor function - starts and waits for it to be running
- const ensureSupervisorStarted = useCallback(async (): Promise<boolean> => {
- if (!supervisorTask) {
- console.warn("No supervisor task found for contract");
- return false;
- }
-
- if (isSupervisorRunning) {
- return true; // Already running
- }
-
- if (isSupervisorPending) {
- try {
- setSupervisorStarting(true);
- await startTask(supervisorTask.id);
-
- // Poll for the task to be running (up to 10 seconds)
- for (let i = 0; i < 20; i++) {
- await new Promise(resolve => setTimeout(resolve, 500));
- onUpdate(); // Refresh contract to get updated task status
- // Note: We can't check the new status here directly since state updates are async
- // The UI will update when onUpdate triggers a re-render
- }
-
- // Return true - the caller should check if supervisor is running after this
- return true;
- } catch (err) {
- console.error("Failed to start supervisor:", err);
- return false;
- } finally {
- setSupervisorStarting(false);
- }
- }
-
- // Supervisor exists but is in some other state (paused, done, failed, etc.)
- // Can still send messages to paused tasks
- return supervisorStatus === "paused";
- }, [supervisorTask, isSupervisorRunning, isSupervisorPending, supervisorStatus, onUpdate]);
-
- // Handle answering supervisor questions
- const [supervisorAnswers, setSupervisorAnswers] = useState<string[]>([]);
- const [supervisorCustomInput, setSupervisorCustomInput] = useState("");
-
- const handleSupervisorOptionToggle = useCallback((option: string) => {
- setSupervisorAnswers((prev) => {
- if (supervisorQuestion?.allowMultiple) {
- if (prev.includes(option)) {
- return prev.filter((a) => a !== option);
- }
- return [...prev, option];
- }
- return [option];
- });
- }, [supervisorQuestion?.allowMultiple]);
-
- const handleSubmitSupervisorAnswer = useCallback(async () => {
- if (!supervisorQuestion || !supervisorTask) return;
-
- const customAnswer = supervisorCustomInput.trim();
- const allAnswers = customAnswer
- ? [...supervisorAnswers, customAnswer]
- : supervisorAnswers;
-
- if (allAnswers.length === 0) return;
-
- // Format answer message for supervisor
- const answerMessage = `__supervisor_answer__ ${JSON.stringify({
- id: supervisorQuestion.id,
- answers: allAnswers,
- })}`;
-
- try {
- await sendTaskMessage(supervisorTask.id, answerMessage);
-
- // Add user message to chat
- const userMsgId = Date.now().toString();
- setMessages((prev) => [
- ...prev,
- {
- id: userMsgId,
- type: "user",
- content: `[Answer to: ${supervisorQuestion.question}]\n${allAnswers.join(", ")}`,
- },
- ]);
- } catch (err) {
- console.error("Failed to send supervisor answer:", err);
- } finally {
- setSupervisorQuestion(null);
- setSupervisorAnswers([]);
- setSupervisorCustomInput("");
- }
- }, [supervisorQuestion, supervisorTask, supervisorAnswers, supervisorCustomInput]);
-
- const handleCancelSupervisorQuestion = useCallback(() => {
- setSupervisorQuestion(null);
- setSupervisorAnswers([]);
- setSupervisorCustomInput("");
- }, []);
-
- // Load chat history on mount
- useEffect(() => {
- let mounted = true;
-
- async function loadHistory() {
- try {
- const history = await getContractChatHistory(contractId);
- if (!mounted) return;
-
- // Convert saved messages to display messages
- const displayMessages: Message[] = history.messages.map((msg) => ({
- id: msg.id,
- type: msg.role as "user" | "assistant" | "error",
- content: msg.content,
- toolCalls: msg.toolCalls as { name: string; success: boolean; message: string }[] | undefined,
- }));
-
- setMessages(displayMessages);
-
- // Auto-expand if there's history
- if (displayMessages.length > 0) {
- setExpanded(true);
- }
- } catch (err) {
- console.error("Failed to load contract chat history:", err);
- } finally {
- if (mounted) {
- setHistoryLoading(false);
- }
- }
- }
-
- loadHistory();
-
- return () => {
- mounted = false;
- };
- }, [contractId]);
-
- // Auto-scroll to bottom when messages change
- useEffect(() => {
- if (messagesRef.current) {
- messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
- }
- }, [messages]);
-
- // Auto-start supervisor when component mounts if it's pending (but not for completed contracts)
- useEffect(() => {
- if (supervisorTask && isSupervisorPending && !supervisorStarting && contract.status !== 'completed') {
- console.log("Auto-starting supervisor task on mount...");
- ensureSupervisorStarted().then((started) => {
- if (started) {
- console.log("Supervisor started successfully");
- }
- });
- }
- }, [supervisorTask?.id, contract.status]); // Only run when task ID or contract status changes
-
- // Convert supervisor output events to messages
- useEffect(() => {
- if (supervisorOutput.length === 0) return;
-
- // Get the latest event
- const latestEvent = supervisorOutput[supervisorOutput.length - 1];
-
- // Only add complete messages (not partials) to the message history
- if (!latestEvent.isPartial && latestEvent.content.trim()) {
- const msgId = `supervisor-${Date.now()}`;
- let msgType: "assistant" | "error" = "assistant";
- let content = latestEvent.content;
-
- // Format based on message type
- switch (latestEvent.messageType) {
- case "assistant":
- content = latestEvent.content;
- break;
- case "tool_use":
- content = `_Using tool: ${latestEvent.toolName}_`;
- break;
- case "tool_result":
- content = latestEvent.isError
- ? `Tool error: ${latestEvent.content}`
- : `Tool result: ${latestEvent.content.slice(0, 200)}${latestEvent.content.length > 200 ? "..." : ""}`;
- msgType = latestEvent.isError ? "error" : "assistant";
- break;
- case "error":
- msgType = "error";
- break;
- case "result":
- // Final result - show cost info if available
- if (latestEvent.costUsd) {
- content = `${latestEvent.content}\n\n_Cost: $${latestEvent.costUsd.toFixed(4)}_`;
- }
- break;
- default:
- // system, raw, etc.
- break;
- }
-
- setMessages((prev) => {
- // Don't add duplicate messages
- if (prev.some((m) => m.content === content)) return prev;
- return [
- ...prev,
- { id: msgId, type: msgType, content },
- ];
- });
- }
- }, [supervisorOutput]);
-
- const handleSubmit = useCallback(
- async (e: React.FormEvent) => {
- e.preventDefault();
- if (!input.trim() || loading) return;
-
- const userMessage = input.trim();
- setInput("");
- setExpanded(true);
-
- const userMsgId = Date.now().toString();
- setMessages((prev) => [
- ...prev,
- { id: userMsgId, type: "user", content: userMessage },
- ]);
-
- setLoading(true);
-
- try {
- // Supervisor is the ONLY way to interact with contracts
- if (!supervisorTask) {
- throw new Error("No supervisor task found. Please create a contract with a supervisor.");
- }
-
- // Ensure supervisor is started (this will start it if pending)
- await ensureSupervisorStarted();
-
- // Send message to supervisor task stdin
- await sendTaskMessage(supervisorTask.id, userMessage);
-
- // Response will come through WebSocket subscription
- // No need for a placeholder message - output will stream in
- } catch (err) {
- const errorMsgId = (Date.now() + 1).toString();
- setMessages((prev) => [
- ...prev,
- {
- id: errorMsgId,
- type: "error",
- content: err instanceof Error ? err.message : "An error occurred",
- },
- ]);
- } finally {
- setLoading(false);
- inputRef.current?.focus();
- }
- },
- [input, loading, supervisorTask, ensureSupervisorStarted]
- );
-
- const handleOptionToggle = useCallback((questionId: string, option: string, allowMultiple: boolean) => {
- setUserAnswers((prev) => {
- const newMap = new Map(prev);
- const currentAnswers = newMap.get(questionId) || [];
-
- if (allowMultiple) {
- if (currentAnswers.includes(option)) {
- newMap.set(questionId, currentAnswers.filter((a) => a !== option));
- } else {
- newMap.set(questionId, [...currentAnswers, option]);
- }
- } else {
- newMap.set(questionId, [option]);
- }
-
- return newMap;
- });
- }, []);
-
- const handleCustomInputChange = useCallback((questionId: string, value: string) => {
- setCustomInputs((prev) => {
- const newMap = new Map(prev);
- newMap.set(questionId, value);
- return newMap;
- });
- }, []);
-
- const handleSubmitAnswers = useCallback(async () => {
- if (!pendingQuestions || loading) return;
-
- const answers = pendingQuestions.map((q) => {
- const selectedOptions = userAnswers.get(q.id) || [];
- const customInput = customInputs.get(q.id)?.trim();
- const finalAnswers = customInput
- ? [...selectedOptions, customInput]
- : selectedOptions;
-
- return {
- id: q.id,
- answers: finalAnswers,
- };
- });
-
- const answerText = answers
- .map((a) => {
- const question = pendingQuestions.find((q) => q.id === a.id);
- return `${question?.question || a.id}: ${a.answers.join(", ")}`;
- })
- .join("\n");
-
- setPendingQuestions(null);
- setUserAnswers(new Map());
- setCustomInputs(new Map());
-
- const userMsgId = Date.now().toString();
- setMessages((prev) => [
- ...prev,
- { id: userMsgId, type: "user", content: `[Answers]\n${answerText}` },
- ]);
-
- setLoading(true);
-
- try {
- if (!supervisorTask) {
- throw new Error("No supervisor task found");
- }
- await ensureSupervisorStarted();
- await sendTaskMessage(supervisorTask.id, answerText);
- // Response will come through WebSocket
- } catch (err) {
- const errorMsgId = (Date.now() + 1).toString();
- setMessages((prev) => [
- ...prev,
- {
- id: errorMsgId,
- type: "error",
- content: err instanceof Error ? err.message : "An error occurred",
- },
- ]);
- } finally {
- setLoading(false);
- }
- }, [pendingQuestions, userAnswers, customInputs, loading, supervisorTask, ensureSupervisorStarted]);
-
- const handleCancelQuestions = useCallback(() => {
- setPendingQuestions(null);
- setUserAnswers(new Map());
- setCustomInputs(new Map());
- }, []);
-
- const clearMessages = useCallback(() => {
- setMessages([]);
- setPendingQuestions(null);
- setUserAnswers(new Map());
- setCustomInputs(new Map());
- setParsedTasks(null);
- setParsedTaskGroups([]);
- setParsedTasksFileName("");
- setSupervisorOutput([]);
- setSupervisorQuestion(null);
- setSupervisorAnswers([]);
- setSupervisorCustomInput("");
- }, []);
-
- // Handle creating tasks from the preview
- const handleCreateDerivedTasks = useCallback(
- async (selectedTasks: ParsedTask[]) => {
- if (selectedTasks.length === 0) {
- setParsedTasks(null);
- return;
- }
-
- setCreatingTasks(true);
-
- // Build a message asking the supervisor to create these tasks
- const taskList = selectedTasks
- .map((t, i) => `${i + 1}. ${t.name}${t.description ? `: ${t.description}` : ""}`)
- .join("\n");
-
- const message = `Create these ${selectedTasks.length} tasks as chained tasks:\n${taskList}`;
-
- // Add user message
- const userMsgId = Date.now().toString();
- setMessages((prev) => [
- ...prev,
- { id: userMsgId, type: "user", content: message },
- ]);
-
- try {
- if (!supervisorTask) {
- throw new Error("No supervisor task found");
- }
- await ensureSupervisorStarted();
- await sendTaskMessage(supervisorTask.id, message);
- // Response will come through WebSocket
- } catch (err) {
- const errorMsgId = (Date.now() + 1).toString();
- setMessages((prev) => [
- ...prev,
- {
- id: errorMsgId,
- type: "error",
- content: err instanceof Error ? err.message : "An error occurred",
- },
- ]);
- } finally {
- setCreatingTasks(false);
- setParsedTasks(null);
- setParsedTaskGroups([]);
- setParsedTasksFileName("");
- }
- },
- [supervisorTask, ensureSupervisorStarted]
- );
-
- const handleCancelTaskDerivation = useCallback(() => {
- setParsedTasks(null);
- setParsedTaskGroups([]);
- setParsedTasksFileName("");
- }, []);
-
- const handleQuickAction = useCallback(
- async (action: QuickAction) => {
- // Convert the action into a chat message that triggers the appropriate behavior
- let message = "";
- switch (action.type) {
- case "create_file":
- message = "Create the suggested file from the template.";
- break;
- case "create_task":
- message = "Yes, create the tasks.";
- break;
- case "derive_tasks":
- message = "Show me the tasks to review and create them.";
- break;
- case "run_task":
- message = "Run the next task.";
- break;
- case "advance_phase":
- if (action.data?.phase) {
- message = `Advance to the ${action.data.phase} phase.`;
- } else {
- message = "Advance to the next phase.";
- }
- break;
- case "update_file":
- message = "Update the file with the task output.";
- break;
- default:
- return;
- }
-
- setExpanded(true);
-
- // Submit the message
- const userMsgId = Date.now().toString();
- setMessages((prev) => [
- ...prev,
- { id: userMsgId, type: "user", content: message },
- ]);
-
- setLoading(true);
- try {
- if (!supervisorTask) {
- throw new Error("No supervisor task found");
- }
- await ensureSupervisorStarted();
- await sendTaskMessage(supervisorTask.id, message);
- // Response will come through WebSocket
- } catch (err) {
- const errorMsgId = (Date.now() + 1).toString();
- setMessages((prev) => [
- ...prev,
- {
- id: errorMsgId,
- type: "error",
- content: err instanceof Error ? err.message : "An error occurred",
- },
- ]);
- } finally {
- setLoading(false);
- }
- },
- [supervisorTask, ensureSupervisorStarted]
- );
-
- return (
- <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
- {/* Header bar with supervisor status and toggle */}
- <div className="px-3 py-2 flex items-center justify-between border-b border-[rgba(117,170,252,0.2)]">
- <div className="flex items-center gap-3">
- <span className="font-mono text-[10px] text-[#555] uppercase tracking-wide">
- Supervisor
- </span>
- {supervisorTask && (
- <span className={`font-mono text-[10px] px-2 py-0.5 border ${
- isSupervisorRunning
- ? "text-green-400 border-green-400/30 bg-green-400/10"
- : isSupervisorPending || supervisorStarting
- ? "text-yellow-400 border-yellow-400/30 bg-yellow-400/10"
- : "text-[#555] border-[rgba(117,170,252,0.2)]"
- }`}>
- {supervisorStarting ? "Starting..." : isSupervisorRunning ? "Running" : supervisorStatus || "Unknown"}
- </span>
- )}
- {!supervisorTask && (
- <span className="font-mono text-[10px] text-red-400">
- No supervisor
- </span>
- )}
- </div>
- {messages.length > 0 && (
- <button
- type="button"
- onClick={() => setExpanded(!expanded)}
- className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors"
- >
- {expanded ? "Hide Messages" : `Show Messages (${messages.length})`}
- </button>
- )}
- </div>
-
- {/* History loading indicator */}
- {historyLoading && (
- <div className="px-3 py-2 text-[10px] font-mono text-[#555] flex items-center gap-2 border-b border-[rgba(117,170,252,0.2)]">
- <span className="animate-pulse">Loading history...</span>
- </div>
- )}
-
- {/* Messages Panel (expandable) */}
- {expanded && messages.length > 0 && !historyLoading && (
- <div className="relative border-b border-[rgba(117,170,252,0.2)]">
- {/* Expand/Collapse button */}
- <div className="absolute top-2 right-2 z-10 flex gap-1">
- <button
- type="button"
- onClick={() => setFullscreen(!fullscreen)}
- className="px-2 py-1 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors"
- title={fullscreen ? "Collapse" : "Expand"}
- >
- {fullscreen ? "▼ Collapse" : "▲ Expand"}
- </button>
- </div>
- <div
- ref={messagesRef}
- className={`overflow-y-auto p-3 pr-24 space-y-2 transition-all duration-200 ${
- fullscreen ? "max-h-[60vh]" : "max-h-48"
- }`}
- >
- {messages.map((msg) => (
- <div key={msg.id} className="font-mono text-xs">
- {msg.type === "user" && (
- <div className="flex gap-2">
- <span className="text-[#9bc3ff]">&gt;</span>
- <span className="text-white/80 whitespace-pre-wrap">{msg.content}</span>
- </div>
- )}
- {(msg.type === "assistant" || msg.type === "question") && (
- <div className="pl-4 space-y-1">
- <SimpleMarkdown content={msg.content} className="text-[#75aafc]" />
- {msg.toolCalls && msg.toolCalls.length > 0 && (
- <div className="text-[#555] text-[10px] space-y-0.5">
- {msg.toolCalls.map((tc, i) => (
- <div key={i}>
- <span
- className={
- tc.success ? "text-green-500" : "text-red-400"
- }
- >
- {tc.success ? "+" : "x"}
- </span>{" "}
- {tc.name}: {tc.message}
- </div>
- ))}
- </div>
- )}
- {msg.quickActions && msg.quickActions.length > 0 && (
- <QuickActionButtons
- actions={msg.quickActions}
- onAction={handleQuickAction}
- loading={loading}
- />
- )}
- </div>
- )}
- {msg.type === "error" && (
- <div className="pl-4 text-red-400">{msg.content}</div>
- )}
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* Pending Questions UI */}
- {pendingQuestions && pendingQuestions.length > 0 && (
- <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3">
- <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide">
- Questions from AI
- </div>
- {pendingQuestions.map((q) => (
- <div key={q.id} className="space-y-2">
- <div className="text-white/90 font-mono text-sm">{q.question}</div>
- <div className="flex flex-wrap gap-2">
- {q.options.map((option) => {
- const isSelected = (userAnswers.get(q.id) || []).includes(option);
- return (
- <button
- key={option}
- type="button"
- onClick={() => handleOptionToggle(q.id, option, q.allowMultiple)}
- className={`px-2 py-1 font-mono text-xs border transition-colors ${
- isSelected
- ? "bg-[#3f6fb3] border-[#75aafc] text-white"
- : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]"
- }`}
- >
- {q.allowMultiple && (
- <span className="mr-1">{isSelected ? "[x]" : "[ ]"}</span>
- )}
- {option}
- </button>
- );
- })}
- </div>
- {q.allowCustom && (
- <input
- type="text"
- value={customInputs.get(q.id) || ""}
- onChange={(e) => handleCustomInputChange(q.id, e.target.value)}
- placeholder="Or type a custom answer..."
- className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]"
- />
- )}
- </div>
- ))}
- <div className="flex gap-2 pt-2">
- <button
- type="button"
- onClick={handleSubmitAnswers}
- disabled={loading}
- className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
- >
- {loading ? "..." : "Submit Answers"}
- </button>
- <button
- type="button"
- onClick={handleCancelQuestions}
- disabled={loading}
- className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
- >
- Cancel
- </button>
- </div>
- </div>
- )}
-
- {/* Supervisor Question UI */}
- {supervisorQuestion && (
- <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3 bg-[rgba(117,170,252,0.05)]">
- <div className="text-green-400 font-mono text-xs uppercase tracking-wide flex items-center gap-2">
- <span className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
- Question from Supervisor
- </div>
- <div className="text-white/90 font-mono text-sm">{supervisorQuestion.question}</div>
- <div className="flex flex-wrap gap-2">
- {supervisorQuestion.options.map((option) => {
- const isSelected = supervisorAnswers.includes(option);
- return (
- <button
- key={option}
- type="button"
- onClick={() => handleSupervisorOptionToggle(option)}
- className={`px-2 py-1 font-mono text-xs border transition-colors ${
- isSelected
- ? "bg-green-500/30 border-green-400 text-white"
- : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-green-400"
- }`}
- >
- {supervisorQuestion.allowMultiple && (
- <span className="mr-1">{isSelected ? "[x]" : "[ ]"}</span>
- )}
- {option}
- </button>
- );
- })}
- </div>
- {supervisorQuestion.allowCustom && (
- <input
- type="text"
- value={supervisorCustomInput}
- onChange={(e) => setSupervisorCustomInput(e.target.value)}
- placeholder="Or type a custom answer..."
- className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-green-400 placeholder-[#555]"
- />
- )}
- <div className="flex gap-2 pt-2">
- <button
- type="button"
- onClick={handleSubmitSupervisorAnswer}
- disabled={supervisorAnswers.length === 0 && !supervisorCustomInput.trim()}
- className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/50 hover:border-green-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
- >
- Send Answer
- </button>
- <button
- type="button"
- onClick={handleCancelSupervisorQuestion}
- className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
- >
- Dismiss
- </button>
- </div>
- </div>
- )}
-
- {/* Contract Context Badge */}
- <div className="px-3 pt-2 pb-1 flex items-center gap-2 text-[10px] font-mono text-[#555]">
- <span className="text-[#75aafc]">{contract.phase}</span>
- <span>|</span>
- {supervisorTask && (
- <>
- <span
- className={
- isSupervisorRunning
- ? "text-green-400"
- : isSupervisorPending
- ? "text-yellow-400"
- : supervisorStarting
- ? "text-cyan-400 animate-pulse"
- : "text-[#555]"
- }
- >
- Supervisor: {supervisorStarting ? "starting..." : supervisorTask.status}
- </span>
- <span>|</span>
- </>
- )}
- <span>{contract.files.length} files</span>
- <span>|</span>
- <span>{contract.tasks.length} tasks</span>
- <span>|</span>
- <span>{contract.repositories.length} repos</span>
- <span>|</span>
- <button
- type="button"
- onClick={() => {
- const prompt = "Guide me to complete this phase and advance to the next. Analyze my current deliverables, identify what's missing, and suggest specific next steps.";
- setInput(prompt);
- // Auto-submit the prompt
- setTimeout(() => {
- const form = document.querySelector('form');
- if (form) form.requestSubmit();
- }, 0);
- }}
- disabled={loading || !!pendingQuestions}
- className="text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50 transition-colors cursor-pointer"
- >
- Progress →
- </button>
- {messages.length > 0 && (
- <>
- <span>|</span>
- <button
- type="button"
- onClick={async () => {
- if (window.confirm("Clear all chat history for this contract?")) {
- try {
- await clearContractChatHistory(contractId);
- setMessages([]);
- } catch (err) {
- console.error("Failed to clear history:", err);
- }
- }
- }}
- disabled={loading}
- className="text-[#555] hover:text-red-400 disabled:opacity-50 transition-colors cursor-pointer"
- >
- Clear
- </button>
- </>
- )}
- </div>
-
- {/* Input Bar */}
- <form onSubmit={handleSubmit} className="flex items-center gap-2 px-3 pb-3">
- <span className="text-[#9bc3ff] font-mono text-sm">&gt;</span>
- <input
- ref={inputRef}
- type="text"
- value={input}
- onChange={(e) => setInput(e.target.value)}
- placeholder={
- loading
- ? "Processing..."
- : supervisorStarting
- ? "Starting supervisor..."
- : supervisorQuestion
- ? "Answer supervisor question above..."
- : pendingQuestions
- ? "Answer questions above first..."
- : isSupervisorRunning
- ? "Message supervisor..."
- : "Create a task, add a file, or ask about the contract..."
- }
- disabled={loading || !!pendingQuestions || !!supervisorQuestion}
- className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
- />
- {messages.length > 0 && (
- <button
- type="button"
- onClick={clearMessages}
- className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors"
- >
- clear
- </button>
- )}
- <button
- type="submit"
- disabled={loading || !input.trim() || !!pendingQuestions || !!supervisorQuestion}
- className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
- >
- {loading ? "..." : "Send"}
- </button>
- </form>
-
- {/* Task Derivation Preview Modal */}
- {parsedTasks && parsedTasks.length > 0 && (
- <TaskDerivationPreview
- tasks={parsedTasks}
- groups={parsedTaskGroups}
- fileName={parsedTasksFileName}
- onCreateTasks={handleCreateDerivedTasks}
- onCancel={handleCancelTaskDerivation}
- loading={creatingTasks}
- />
- )}
- </div>
- );
-}
diff --git a/makima/frontend/src/components/contracts/ContractContextMenu.tsx b/makima/frontend/src/components/contracts/ContractContextMenu.tsx
deleted file mode 100644
index f31beb5..0000000
--- a/makima/frontend/src/components/contracts/ContractContextMenu.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-import { useEffect, useRef } from "react";
-import type { ContractSummary } from "../../lib/api";
-
-interface ContractContextMenuProps {
- x: number;
- y: number;
- contract: ContractSummary;
- onClose: () => void;
- onMarkComplete: () => void;
- onMarkActive: () => void;
- onArchive: () => void;
- onDelete: () => void;
- onGoToSupervisor: () => void;
-}
-
-export function ContractContextMenu({
- x,
- y,
- contract,
- onClose,
- onMarkComplete,
- onMarkActive,
- onArchive,
- onDelete,
- onGoToSupervisor,
-}: ContractContextMenuProps) {
- const menuRef = useRef<HTMLDivElement>(null);
-
- // Close on click outside
- useEffect(() => {
- const handleClickOutside = (e: MouseEvent) => {
- if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
- onClose();
- }
- };
-
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
- onClose();
- }
- };
-
- document.addEventListener("mousedown", handleClickOutside);
- document.addEventListener("keydown", handleKeyDown);
-
- return () => {
- document.removeEventListener("mousedown", handleClickOutside);
- document.removeEventListener("keydown", handleKeyDown);
- };
- }, [onClose]);
-
- // Adjust position if menu would overflow viewport
- useEffect(() => {
- if (menuRef.current) {
- const rect = menuRef.current.getBoundingClientRect();
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
-
- if (rect.right > viewportWidth) {
- menuRef.current.style.left = `${x - rect.width}px`;
- }
- if (rect.bottom > viewportHeight) {
- menuRef.current.style.top = `${y - rect.height}px`;
- }
- }
- }, [x, y]);
-
- const menuItemClass =
- "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";
- const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1";
-
- const showMarkComplete = contract.status !== "completed";
- const showMarkActive = contract.status !== "active";
- const showArchive = contract.status !== "archived";
- const showGoToSupervisor = !!contract.supervisorTaskId;
-
- return (
- <div
- ref={menuRef}
- className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
- style={{ left: x, top: y }}
- >
- {/* Header showing contract name */}
- <div className="px-3 py-1.5 text-[10px] font-mono text-[#555] uppercase border-b border-[rgba(117,170,252,0.2)] truncate max-w-[200px]">
- {contract.name}
- </div>
-
- {/* Status actions */}
- {showMarkComplete && (
- <button
- className={menuItemClass}
- onClick={() => {
- onMarkComplete();
- onClose();
- }}
- >
- <span className="text-[#75aafc]">✓</span>
- Mark as Complete
- </button>
- )}
-
- {showMarkActive && (
- <button
- className={menuItemClass}
- onClick={() => {
- onMarkActive();
- onClose();
- }}
- >
- <span className="text-[#75aafc]">●</span>
- Mark as Active
- </button>
- )}
-
- {showArchive && (
- <button
- className={menuItemClass}
- onClick={() => {
- onArchive();
- onClose();
- }}
- >
- <span className="text-[#75aafc]">▣</span>
- Archive
- </button>
- )}
-
- {/* Supervisor link */}
- {showGoToSupervisor && (
- <>
- <div className={dividerClass} />
- <button
- className={menuItemClass}
- onClick={() => {
- onGoToSupervisor();
- onClose();
- }}
- >
- <span className="text-[#75aafc]">▶</span>
- Go to Supervisor Task
- </button>
- </>
- )}
-
- <div className={dividerClass} />
-
- {/* Delete action */}
- <button
- className={`${menuItemClass} text-red-400 hover:bg-red-400/10`}
- onClick={() => {
- onDelete();
- onClose();
- }}
- >
- <span className="text-red-400">✕</span>
- Delete
- </button>
- </div>
- );
-}
diff --git a/makima/frontend/src/components/contracts/ContractDetail.tsx b/makima/frontend/src/components/contracts/ContractDetail.tsx
deleted file mode 100644
index 02c129e..0000000
--- a/makima/frontend/src/components/contracts/ContractDetail.tsx
+++ /dev/null
@@ -1,810 +0,0 @@
-import { useState, useEffect, useCallback } from "react";
-import type {
- ContractWithRelations,
- ContractPhase,
- ContractStatus,
- ContractRepository,
- FileSummary,
- TaskSummary,
- TemplateSummary,
-} from "../../lib/api";
-import {
- listTemplates,
- getTemplate,
- createFile,
-} from "../../lib/api";
-import { PhaseProgressBar } from "./PhaseProgressBar";
-import { PhaseHint } from "./PhaseHint";
-import { RepositoryPanel } from "./RepositoryPanel";
-import { ContractCliInput } from "./ContractCliInput";
-import { PhaseDeliverablesPanel } from "./PhaseDeliverablesPanel";
-import { CommandModePanel } from "./CommandModePanel";
-import { TaskTree } from "../mesh/TaskTree";
-
-type Tab = "overview" | "repos" | "files" | "tasks";
-
-interface ContractDetailProps {
- contract: ContractWithRelations;
- loading: boolean;
- onBack: () => void;
- onUpdate: (name: string, description: string) => void;
- onDelete: () => void;
- onPhaseChange: (phase: ContractPhase) => void;
- onStatusChange: (status: ContractStatus) => void;
- onFileSelect: (id: string) => void;
- onTaskSelect: (id: string) => void;
- onTaskCreate: (name: string, plan: string, repositoryUrl?: string) => void;
- onRefresh: () => void;
- // Repository callbacks
- onAddRemoteRepo: (name: string, url: string, isPrimary: boolean) => void;
- onAddLocalRepo: (name: string, path: string, isPrimary: boolean) => void;
- onCreateManagedRepo: (name: string, isPrimary: boolean) => void;
- onDeleteRepo: (repoId: string) => void;
- onSetRepoPrimary: (repoId: string) => void;
- // File creation callback for phase deliverables
- onCreateFileFromTemplate?: (templateId: string, suggestedName: string) => void;
-}
-
-const statusConfig: Record<ContractStatus, { label: string; color: string }> = {
- active: { label: "Active", color: "text-green-400" },
- completed: { label: "Completed", color: "text-blue-400" },
- archived: { label: "Archived", color: "text-[#555]" },
-};
-
-export function ContractDetail({
- contract,
- loading,
- onBack,
- onUpdate,
- onDelete,
- onPhaseChange,
- onStatusChange,
- onFileSelect,
- onTaskSelect,
- onTaskCreate,
- onRefresh,
- onAddRemoteRepo,
- onAddLocalRepo,
- onCreateManagedRepo,
- onDeleteRepo,
- onSetRepoPrimary,
- onCreateFileFromTemplate,
-}: ContractDetailProps) {
- const [activeTab, setActiveTab] = useState<Tab>("overview");
- const [isEditing, setIsEditing] = useState(false);
- const [name, setName] = useState(contract.name);
- const [description, setDescription] = useState(contract.description || "");
-
- const handleSave = () => {
- onUpdate(name, description);
- setIsEditing(false);
- };
-
- const handleCancel = () => {
- setName(contract.name);
- setDescription(contract.description || "");
- setIsEditing(false);
- };
-
- if (loading) {
- return (
- <div className="panel h-full flex items-center justify-center">
- <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
- </div>
- );
- }
-
- const tabs: { key: Tab; label: string; count?: number }[] = [
- { key: "overview", label: "Overview" },
- { key: "repos", label: "Repositories", count: contract.repositories.length },
- { key: "files", label: "Files", count: contract.files.length },
- { key: "tasks", label: "Tasks", count: contract.tasks.length },
- ];
-
- return (
- <div className="panel h-full flex flex-col min-h-0">
- {/* Header */}
- <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]">
- <div className="flex items-center justify-between mb-3">
- <button
- onClick={onBack}
- className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
- >
- &larr; Back to list
- </button>
- <div className="flex items-center gap-2">
- {isEditing ? (
- <>
- <button
- onClick={handleCancel}
- className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
- >
- Cancel
- </button>
- <button
- onClick={handleSave}
- className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
- >
- Save
- </button>
- </>
- ) : (
- <>
- <button
- onClick={() => setIsEditing(true)}
- className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase"
- >
- Edit
- </button>
- <button
- onClick={onDelete}
- className="px-3 py-1.5 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
- >
- Delete
- </button>
- </>
- )}
- </div>
- </div>
-
- {isEditing ? (
- <div className="space-y-3">
- <input
- type="text"
- value={name}
- onChange={(e) => setName(e.target.value)}
- className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
- placeholder="Contract name"
- />
- <textarea
- value={description}
- onChange={(e) => setDescription(e.target.value)}
- 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"
- rows={2}
- placeholder="Description (optional)"
- />
- </div>
- ) : (
- <>
- <div className="flex items-center gap-3 mb-2">
- <h2 className="font-mono text-lg text-[#dbe7ff]">
- {contract.name}
- </h2>
- <span
- className={`font-mono text-xs uppercase ${
- statusConfig[contract.status].color
- }`}
- >
- {statusConfig[contract.status].label}
- </span>
- {contract.localOnly && (
- <span className="px-2 py-0.5 font-mono text-[10px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10">
- Local-Only
- </span>
- )}
- </div>
- {contract.description && (
- <p className="font-mono text-sm text-[#9bc3ff] mb-3">
- {contract.description}
- </p>
- )}
- </>
- )}
-
- {/* Phase progress */}
- <div className="mt-4 pt-4 border-t border-dashed border-[rgba(117,170,252,0.2)]">
- <PhaseProgressBar
- currentPhase={contract.phase}
- contractType={contract.contractType}
- onPhaseClick={onPhaseChange}
- />
- </div>
- </div>
-
- {/* Tabs */}
- <div className="flex border-b border-[rgba(117,170,252,0.2)]">
- {tabs.map((tab) => (
- <button
- key={tab.key}
- onClick={() => setActiveTab(tab.key)}
- className={`
- px-4 py-2 font-mono text-xs uppercase tracking-wider transition-colors
- ${
- activeTab === tab.key
- ? "text-[#dbe7ff] border-b-2 border-[#75aafc]"
- : "text-[#555] hover:text-[#9bc3ff]"
- }
- `}
- >
- {tab.label}
- {tab.count !== undefined && tab.count > 0 && (
- <span className="ml-1 text-[10px]">({tab.count})</span>
- )}
- </button>
- ))}
- </div>
-
- {/* Tab content */}
- <div className="flex-1 overflow-y-auto p-4 min-h-0">
- {activeTab === "overview" && (
- <OverviewTab
- contract={contract}
- onStatusChange={onStatusChange}
- onPhaseChange={onPhaseChange}
- onCreateFile={onCreateFileFromTemplate}
- onRefresh={onRefresh}
- />
- )}
-
- {activeTab === "repos" && (
- <RepositoryPanel
- repositories={contract.repositories}
- onAddRemote={onAddRemoteRepo}
- onAddLocal={onAddLocalRepo}
- onCreateManaged={onCreateManagedRepo}
- onDelete={onDeleteRepo}
- onSetPrimary={onSetRepoPrimary}
- />
- )}
-
- {activeTab === "files" && (
- <FilesTab
- files={contract.files}
- contractId={contract.id}
- contractPhase={contract.phase}
- onSelect={onFileSelect}
- onRefresh={onRefresh}
- />
- )}
-
- {activeTab === "tasks" && (
- <TasksTab
- tasks={contract.tasks}
- repositories={contract.repositories}
- supervisorTaskId={contract.supervisorTaskId}
- contractType={contract.contractType}
- onSelect={onTaskSelect}
- onCreate={onTaskCreate}
- />
- )}
- </div>
-
- {/* Chat Input */}
- <ContractCliInput
- contractId={contract.id}
- contract={contract}
- onUpdate={onRefresh}
- />
- </div>
- );
-}
-
-// Overview tab
-function OverviewTab({
- contract,
- onStatusChange,
- onPhaseChange,
- onCreateFile,
- onRefresh,
-}: {
- contract: ContractWithRelations;
- onStatusChange: (status: ContractStatus) => void;
- onPhaseChange: (phase: ContractPhase) => void;
- onCreateFile?: (templateId: string, suggestedName: string) => void;
- onRefresh: () => void;
-}) {
- return (
- <div className="space-y-6">
- {/* Command Mode controls */}
- <CommandModePanel contract={contract} onUpdate={onRefresh} />
-
- {/* Phase deliverables checklist */}
- <PhaseDeliverablesPanel
- contract={contract}
- onCreateFile={onCreateFile}
- />
-
- {/* Phase hint */}
- <PhaseHint contract={contract} onAdvancePhase={onPhaseChange} />
-
- {/* Task progress summary */}
- <TaskStatusSummary tasks={contract.tasks} />
-
- {/* Stats */}
- <div className="grid grid-cols-3 gap-4">
- <StatCard label="Repositories" value={contract.repositories.length} />
- <StatCard label="Files" value={contract.files.length} />
- <StatCard label="Tasks" value={contract.tasks.length} />
- </div>
-
- {/* Status change */}
- <div>
- <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">
- Status
- </h3>
- <div className="flex gap-2">
- {(["active", "completed", "archived"] as ContractStatus[]).map(
- (status) => (
- <button
- key={status}
- onClick={() => onStatusChange(status)}
- className={`
- px-3 py-1.5 font-mono text-xs uppercase transition-colors
- ${
- contract.status === status
- ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]"
- : "text-[#555] border border-transparent hover:text-[#75aafc]"
- }
- `}
- >
- {status}
- </button>
- )
- )}
- </div>
- </div>
-
- {/* Metadata */}
- <div>
- <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">
- Details
- </h3>
- <div className="space-y-1 font-mono text-xs text-[#555]">
- <p>Created: {new Date(contract.createdAt).toLocaleString()}</p>
- <p>Updated: {new Date(contract.updatedAt).toLocaleString()}</p>
- <p>Version: {contract.version}</p>
- </div>
- </div>
- </div>
- );
-}
-
-function StatCard({ label, value }: { label: string; value: number }) {
- return (
- <div className="p-3 border border-[rgba(117,170,252,0.2)]">
- <div className="font-mono text-2xl text-[#dbe7ff]">{value}</div>
- <div className="font-mono text-[10px] text-[#555] uppercase">{label}</div>
- </div>
- );
-}
-
-// Task status summary with progress bar
-function TaskStatusSummary({ tasks }: { tasks: TaskSummary[] }) {
- if (tasks.length === 0) return null;
-
- // Count tasks by status
- const statusCounts = {
- done: 0,
- merged: 0,
- running: 0,
- pending: 0,
- failed: 0,
- other: 0,
- };
-
- for (const task of tasks) {
- switch (task.status) {
- case "done":
- statusCounts.done++;
- break;
- case "merged":
- statusCounts.merged++;
- break;
- case "running":
- case "initializing":
- case "starting":
- statusCounts.running++;
- break;
- case "pending":
- statusCounts.pending++;
- break;
- case "failed":
- statusCounts.failed++;
- break;
- default:
- statusCounts.other++;
- }
- }
-
- const completedCount = statusCounts.done + statusCounts.merged;
- const progressPercent = (completedCount / tasks.length) * 100;
-
- // Build summary parts
- const parts: string[] = [];
- if (completedCount > 0) parts.push(`${completedCount} done`);
- if (statusCounts.running > 0) parts.push(`${statusCounts.running} running`);
- if (statusCounts.pending > 0) parts.push(`${statusCounts.pending} pending`);
- if (statusCounts.failed > 0) parts.push(`${statusCounts.failed} failed`);
-
- return (
- <div className="space-y-2">
- <h3 className="font-mono text-xs text-[#75aafc] uppercase">
- Task Progress
- </h3>
-
- {/* Progress bar */}
- <div className="h-2 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden">
- <div
- className="h-full bg-green-400 transition-all duration-300"
- style={{ width: `${progressPercent}%` }}
- />
- </div>
-
- {/* Summary text */}
- <div className="flex items-center justify-between">
- <span className="font-mono text-xs text-[#9bc3ff]">
- {parts.join(", ")}
- </span>
- <span className="font-mono text-xs text-[#555]">
- {completedCount}/{tasks.length} completed
- </span>
- </div>
- </div>
- );
-}
-
-// Phase color mapping for badges
-const phaseColors: Record<ContractPhase, string> = {
- research: "bg-purple-500/20 text-purple-400 border-purple-400/30",
- specify: "bg-blue-500/20 text-blue-400 border-blue-400/30",
- plan: "bg-cyan-500/20 text-cyan-400 border-cyan-400/30",
- execute: "bg-green-500/20 text-green-400 border-green-400/30",
- review: "bg-yellow-500/20 text-yellow-400 border-yellow-400/30",
-};
-
-// Files tab with template creation
-function FilesTab({
- files,
- contractId,
- contractPhase,
- onSelect,
- onRefresh,
-}: {
- files: FileSummary[];
- contractId: string;
- contractPhase: ContractPhase;
- onSelect: (id: string) => void;
- onRefresh: () => void;
-}) {
- const [showTemplateModal, setShowTemplateModal] = useState(false);
- const [templates, setTemplates] = useState<TemplateSummary[]>([]);
- const [loadingTemplates, setLoadingTemplates] = useState(false);
- const [creating, setCreating] = useState(false);
- const [fileName, setFileName] = useState("");
- const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
-
- // Load templates when modal opens
- useEffect(() => {
- if (showTemplateModal) {
- setLoadingTemplates(true);
- listTemplates(contractPhase)
- .then((res) => setTemplates(res.templates))
- .catch((err) => console.error("Failed to load templates:", err))
- .finally(() => setLoadingTemplates(false));
- }
- }, [showTemplateModal, contractPhase]);
-
- const handleCreateFromTemplate = useCallback(async () => {
- if (!fileName.trim() || !selectedTemplateId) return;
-
- setCreating(true);
- try {
- // Get the full template with body
- const template = await getTemplate(selectedTemplateId);
-
- // Create the file with contract (files must belong to contracts)
- await createFile({
- contractId,
- name: fileName.trim(),
- description: template.description,
- body: template.suggestedBody,
- });
-
- // Reset and close
- setShowTemplateModal(false);
- setFileName("");
- setSelectedTemplateId(null);
- onRefresh();
- } catch (err) {
- console.error("Failed to create file from template:", err);
- } finally {
- setCreating(false);
- }
- }, [fileName, selectedTemplateId, contractId, onRefresh]);
-
- const handleCloseModal = () => {
- setShowTemplateModal(false);
- setFileName("");
- setSelectedTemplateId(null);
- };
-
- return (
- <div className="space-y-4">
- {/* Create from template button */}
- <button
- onClick={() => setShowTemplateModal(true)}
- className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
- >
- + Create from Template
- </button>
-
- {/* Template Selection Modal */}
- {showTemplateModal && (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
- <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[80vh] flex flex-col">
- <div className="flex items-center justify-between mb-4">
- <h3 className="font-mono text-sm text-[#75aafc] uppercase">
- Create File from Template
- </h3>
- <span className={`px-2 py-0.5 text-[10px] font-mono uppercase border rounded ${phaseColors[contractPhase]}`}>
- {contractPhase} phase
- </span>
- </div>
-
- <div className="space-y-4 flex-1 overflow-y-auto">
- {/* File name input */}
- <div>
- <label className="block font-mono text-xs text-[#555] uppercase mb-1">
- File Name
- </label>
- <input
- type="text"
- value={fileName}
- onChange={(e) => setFileName(e.target.value)}
- placeholder="e.g., Project Requirements"
- className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
- autoFocus
- />
- </div>
-
- {/* Template selection */}
- <div>
- <label className="block font-mono text-xs text-[#555] uppercase mb-2">
- Select Template
- </label>
- {loadingTemplates ? (
- <p className="font-mono text-xs text-[#555]">Loading templates...</p>
- ) : templates.length === 0 ? (
- <p className="font-mono text-xs text-[#555]">No templates available for {contractPhase} phase</p>
- ) : (
- <div className="space-y-2 max-h-60 overflow-y-auto">
- {templates.map((template) => (
- <button
- key={template.id}
- onClick={() => setSelectedTemplateId(template.id)}
- className={`w-full text-left p-3 border transition-colors ${
- selectedTemplateId === template.id
- ? "border-[#75aafc] bg-[rgba(117,170,252,0.1)]"
- : "border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)]"
- }`}
- >
- <div className="flex items-center justify-between mb-1">
- <span className="font-mono text-sm text-[#dbe7ff]">
- {template.name}
- </span>
- <span className="font-mono text-[10px] text-[#555]">
- {template.elementCount} elements
- </span>
- </div>
- <p className="font-mono text-xs text-[#555]">
- {template.description}
- </p>
- </button>
- ))}
- </div>
- )}
- </div>
- </div>
-
- <div className="flex gap-2 justify-end mt-4 pt-4 border-t border-[rgba(117,170,252,0.2)]">
- <button
- onClick={handleCloseModal}
- className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
- >
- Cancel
- </button>
- <button
- onClick={handleCreateFromTemplate}
- disabled={!fileName.trim() || !selectedTemplateId || creating}
- className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
- >
- {creating ? "Creating..." : "Create File"}
- </button>
- </div>
- </div>
- </div>
- )}
-
- {/* File list */}
- {files.length === 0 ? (
- <p className="font-mono text-xs text-[#555]">
- No files in this contract. Create one from a template above.
- </p>
- ) : (
- <div className="space-y-2">
- {files.map((file) => (
- <button
- key={file.id}
- onClick={() => onSelect(file.id)}
- className="w-full text-left p-3 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors"
- >
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <span className="font-mono text-sm text-[#dbe7ff]">
- {file.name}
- </span>
- {file.contractPhase && (
- <span
- className={`px-1.5 py-0.5 text-[9px] font-mono uppercase border rounded ${
- phaseColors[file.contractPhase]
- }`}
- title={`Added during ${file.contractPhase} phase`}
- >
- {file.contractPhase}
- </span>
- )}
- </div>
- <span className="font-mono text-[10px] text-[#555]">
- v{file.version}
- </span>
- </div>
- {file.description && (
- <p className="font-mono text-xs text-[#555] mt-1 truncate">
- {file.description}
- </p>
- )}
- </button>
- ))}
- </div>
- )}
- </div>
- );
-}
-
-// Tasks tab - now using TaskTree for supervisor view
-function TasksTab({
- tasks,
- repositories,
- supervisorTaskId,
- contractType,
- onSelect,
- onCreate,
-}: {
- tasks: TaskSummary[];
- repositories: ContractRepository[];
- supervisorTaskId: string | null;
- contractType: string;
- onSelect: (id: string) => void;
- onCreate: (name: string, plan: string, repositoryUrl?: string) => void;
-}) {
- const [isCreating, setIsCreating] = useState(false);
- const [taskName, setTaskName] = useState("");
- const [taskPlan, setTaskPlan] = useState("# Plan\n\nDescribe what this task should accomplish...");
-
- // Find primary repository or first ready one
- const readyRepos = repositories.filter((r) => r.status === "ready");
- const primaryRepo = readyRepos.find((r) => r.isPrimary) || readyRepos[0];
- const [selectedRepoId, setSelectedRepoId] = useState<string>(primaryRepo?.id || "");
-
- const handleCreate = () => {
- if (!taskName.trim()) return;
- const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
- // Get the URL - for remote repos it's repositoryUrl, for local it's the local path
- const repoUrl = selectedRepo?.repositoryUrl || selectedRepo?.localPath;
- onCreate(taskName.trim(), taskPlan, repoUrl || undefined);
- setIsCreating(false);
- setTaskName("");
- setTaskPlan("# Plan\n\nDescribe what this task should accomplish...");
- setSelectedRepoId(primaryRepo?.id || "");
- };
-
- const handleCancel = () => {
- setIsCreating(false);
- setTaskName("");
- setTaskPlan("# Plan\n\nDescribe what this task should accomplish...");
- setSelectedRepoId(primaryRepo?.id || "");
- };
-
- return (
- <div className="space-y-4">
- {/* TaskTree with supervisor view */}
- <TaskTree
- tasks={tasks}
- supervisorTaskId={supervisorTaskId}
- onSelect={onSelect}
- />
-
- {/* Manual task creation - show for task-type contracts or contracts without supervisors */}
- {(contractType === "task" || !supervisorTaskId) && (
- <>
- <div className="border-t border-[rgba(117,170,252,0.2)] pt-4">
- <button
- onClick={() => setIsCreating(true)}
- className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
- >
- + Create Task Manually
- </button>
- </div>
-
- {/* Create Task Modal */}
- {isCreating && (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
- <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]">
- <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">
- Create Task
- </h3>
- <div className="space-y-4">
- <div>
- <label className="block font-mono text-xs text-[#555] uppercase mb-1">
- Name
- </label>
- <input
- type="text"
- value={taskName}
- onChange={(e) => setTaskName(e.target.value)}
- placeholder="Task name"
- className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
- autoFocus
- />
- </div>
-
- {/* Repository selection */}
- {readyRepos.length > 0 && (
- <div>
- <label className="block font-mono text-xs text-[#555] uppercase mb-1">
- Repository
- </label>
- <select
- value={selectedRepoId}
- onChange={(e) => setSelectedRepoId(e.target.value)}
- className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
- >
- <option value="">No repository</option>
- {readyRepos.map((repo) => (
- <option key={repo.id} value={repo.id}>
- {repo.name}
- {repo.isPrimary ? " (Primary)" : ""}
- {" - "}
- {repo.sourceType}
- </option>
- ))}
- </select>
- </div>
- )}
-
- <div>
- <label className="block font-mono text-xs text-[#555] uppercase mb-1">
- Plan
- </label>
- <textarea
- value={taskPlan}
- onChange={(e) => setTaskPlan(e.target.value)}
- rows={6}
- 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"
- />
- </div>
-
- <div className="flex gap-2 justify-end">
- <button
- onClick={handleCancel}
- className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
- >
- Cancel
- </button>
- <button
- onClick={handleCreate}
- disabled={!taskName.trim()}
- className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
- >
- Create
- </button>
- </div>
- </div>
- </div>
- </div>
- )}
- </>
- )}
- </div>
- );
-}
diff --git a/makima/frontend/src/components/contracts/ContractList.tsx b/makima/frontend/src/components/contracts/ContractList.tsx
deleted file mode 100644
index 1eee6a3..0000000
--- a/makima/frontend/src/components/contracts/ContractList.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-import { useState } from "react";
-import type { ContractSummary, ContractStatus } from "../../lib/api";
-import { PhaseBadge } from "./PhaseBadge";
-import { PhaseProgressBarCompact } from "./PhaseProgressBar";
-import { ContractContextMenu } from "./ContractContextMenu";
-
-interface ContractListProps {
- contracts: ContractSummary[];
- loading: boolean;
- onSelect: (id: string) => void;
- onCreate: () => void;
- selectedId?: string;
- onMarkComplete?: (contract: ContractSummary) => void;
- onMarkActive?: (contract: ContractSummary) => void;
- onArchive?: (contract: ContractSummary) => void;
- onDelete?: (contract: ContractSummary) => void;
- onGoToSupervisor?: (contract: ContractSummary) => void;
-}
-
-const statusColors: Record<ContractStatus, string> = {
- active: "text-green-400",
- completed: "text-blue-400",
- archived: "text-[#555]",
-};
-
-export function ContractList({
- contracts,
- loading,
- onSelect,
- onCreate,
- selectedId,
- onMarkComplete,
- onMarkActive,
- onArchive,
- onDelete,
- onGoToSupervisor,
-}: ContractListProps) {
- const [filter, setFilter] = useState<ContractStatus | "all">("all");
- const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
- const [contextMenuContract, setContextMenuContract] = useState<ContractSummary | null>(null);
-
- const handleContextMenu = (e: React.MouseEvent, contract: ContractSummary) => {
- e.preventDefault();
- setContextMenuPosition({ x: e.clientX, y: e.clientY });
- setContextMenuContract(contract);
- };
-
- const closeContextMenu = () => {
- setContextMenuPosition(null);
- setContextMenuContract(null);
- };
-
- const filteredContracts =
- filter === "all"
- ? contracts
- : contracts.filter((c) => c.status === filter);
-
- if (loading) {
- return (
- <div className="panel h-full flex items-center justify-center">
- <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
- </div>
- );
- }
-
- return (
- <div className="panel h-full flex flex-col min-h-0">
- {/* Header */}
- <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]">
- <div className="flex items-center justify-between mb-3">
- <h2 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider">
- Contracts
- </h2>
- <button
- onClick={onCreate}
- className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
- >
- + New
- </button>
- </div>
-
- {/* Filter tabs */}
- <div className="flex gap-1">
- {(["all", "active", "completed", "archived"] as const).map((status) => (
- <button
- key={status}
- onClick={() => setFilter(status)}
- className={`
- px-2 py-1 font-mono text-[10px] uppercase tracking-wider transition-colors
- ${
- filter === status
- ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]"
- : "text-[#555] hover:text-[#75aafc]"
- }
- `}
- >
- {status}
- </button>
- ))}
- </div>
- </div>
-
- {/* Contract list */}
- <div className="flex-1 min-h-0 overflow-y-auto">
- {filteredContracts.length === 0 ? (
- <div className="p-4 text-center">
- <p className="font-mono text-sm text-[#555]">
- {filter === "all"
- ? "No contracts yet"
- : `No ${filter} contracts`}
- </p>
- </div>
- ) : (
- <div className="divide-y divide-[rgba(117,170,252,0.15)]">
- {filteredContracts.map((contract) => (
- <button
- key={contract.id}
- onClick={() => onSelect(contract.id)}
- onContextMenu={(e) => handleContextMenu(e, contract)}
- className={`
- w-full text-left p-4 transition-colors
- ${
- selectedId === contract.id
- ? "bg-[rgba(117,170,252,0.1)]"
- : "hover:bg-[rgba(117,170,252,0.05)]"
- }
- `}
- >
- <div className="flex items-start justify-between gap-2 mb-2">
- <div className="flex items-center gap-2 min-w-0">
- <h3 className="font-mono text-sm text-[#dbe7ff] truncate">
- {contract.name}
- </h3>
- {contract.localOnly && (
- <span className="px-1.5 py-0.5 font-mono text-[9px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10 shrink-0">
- Local
- </span>
- )}
- </div>
- <span
- className={`text-[10px] font-mono uppercase shrink-0 ${
- statusColors[contract.status]
- }`}
- >
- {contract.status}
- </span>
- </div>
-
- {contract.description && (
- <p className="font-mono text-xs text-[#555] mb-2 line-clamp-2">
- {contract.description}
- </p>
- )}
-
- <div className="flex items-center justify-between">
- <PhaseProgressBarCompact currentPhase={contract.phase} contractType={contract.contractType} />
- <div className="flex items-center gap-3 text-[10px] font-mono text-[#555]">
- {contract.fileCount > 0 && (
- <span>{contract.fileCount} files</span>
- )}
- {contract.taskCount > 0 && (
- <span>{contract.taskCount} tasks</span>
- )}
- {contract.repositoryCount > 0 && (
- <span>{contract.repositoryCount} repos</span>
- )}
- </div>
- </div>
- </button>
- ))}
- </div>
- )}
- </div>
-
- {/* Context Menu */}
- {contextMenuPosition && contextMenuContract && (
- <ContractContextMenu
- x={contextMenuPosition.x}
- y={contextMenuPosition.y}
- contract={contextMenuContract}
- onClose={closeContextMenu}
- onMarkComplete={() => onMarkComplete?.(contextMenuContract)}
- onMarkActive={() => onMarkActive?.(contextMenuContract)}
- onArchive={() => onArchive?.(contextMenuContract)}
- onDelete={() => onDelete?.(contextMenuContract)}
- onGoToSupervisor={() => onGoToSupervisor?.(contextMenuContract)}
- />
- )}
- </div>
- );
-}
-
-export function ContractCard({
- contract,
- onClick,
-}: {
- contract: ContractSummary;
- onClick: () => void;
-}) {
- return (
- <button
- onClick={onClick}
- className="w-full text-left p-4 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors"
- >
- <div className="flex items-start justify-between gap-2 mb-2">
- <h3 className="font-mono text-sm text-[#dbe7ff]">{contract.name}</h3>
- <PhaseBadge phase={contract.phase} />
- </div>
-
- {contract.description && (
- <p className="font-mono text-xs text-[#555] mb-3 line-clamp-2">
- {contract.description}
- </p>
- )}
-
- <div className="flex items-center gap-4 text-[10px] font-mono text-[#555]">
- <span>{contract.fileCount} files</span>
- <span>{contract.taskCount} tasks</span>
- <span>{contract.repositoryCount} repos</span>
- </div>
- </button>
- );
-}
diff --git a/makima/frontend/src/components/contracts/PhaseBadge.tsx b/makima/frontend/src/components/contracts/PhaseBadge.tsx
deleted file mode 100644
index 0f46b9b..0000000
--- a/makima/frontend/src/components/contracts/PhaseBadge.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { ContractPhase } from "../../lib/api";
-
-interface PhaseBadgeProps {
- phase: ContractPhase;
- size?: "sm" | "md";
-}
-
-const phaseConfig: Record<
- ContractPhase,
- { label: string; color: string; bgColor: string }
-> = {
- research: {
- label: "Research",
- color: "text-purple-400",
- bgColor: "bg-purple-400/10 border-purple-400/30",
- },
- specify: {
- label: "Specify",
- color: "text-blue-400",
- bgColor: "bg-blue-400/10 border-blue-400/30",
- },
- plan: {
- label: "Plan",
- color: "text-cyan-400",
- bgColor: "bg-cyan-400/10 border-cyan-400/30",
- },
- execute: {
- label: "Execute",
- color: "text-yellow-400",
- bgColor: "bg-yellow-400/10 border-yellow-400/30",
- },
- review: {
- label: "Review",
- color: "text-green-400",
- bgColor: "bg-green-400/10 border-green-400/30",
- },
-};
-
-export function PhaseBadge({ phase, size = "sm" }: PhaseBadgeProps) {
- const config = phaseConfig[phase];
- const sizeClasses = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs";
-
- return (
- <span
- className={`${sizeClasses} ${config.color} ${config.bgColor} border font-mono uppercase tracking-wider`}
- >
- {config.label}
- </span>
- );
-}
-
-export function getPhaseLabel(phase: ContractPhase): string {
- return phaseConfig[phase].label;
-}
diff --git a/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx b/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx
deleted file mode 100644
index b2c2e58..0000000
--- a/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx
+++ /dev/null
@@ -1,339 +0,0 @@
-import { useMemo } from "react";
-import type { ContractWithRelations, ContractPhase, ContractType } from "../../lib/api";
-
-// Phase deliverables configuration (mirrors backend phase_guidance.rs)
-// IDs must match backend phase_guidance.rs exactly for mark_deliverable_complete
-interface PhaseDeliverable {
- id: string; // Must match backend deliverable ID
- name: string;
- priority: "required" | "recommended" | "optional";
- description: string;
-}
-
-interface PhaseConfig {
- deliverables: PhaseDeliverable[];
- requiresRepository: boolean;
- requiresTasks: boolean;
- guidance: string;
-}
-
-// Contract type specific deliverables (must match backend phase_guidance.rs)
-type ContractTypeDeliverables = Partial<Record<ContractPhase, PhaseConfig>>;
-
-const CONTRACT_TYPE_DELIVERABLES: Record<ContractType, ContractTypeDeliverables> = {
- simple: {
- plan: {
- deliverables: [
- { id: "plan-document", name: "Plan", priority: "required", description: "Implementation plan detailing the approach and tasks" },
- ],
- requiresRepository: true,
- requiresTasks: false,
- guidance: "Create a plan document that outlines the implementation approach. A repository must be configured before moving to Execute phase.",
- },
- execute: {
- deliverables: [
- { id: "pull-request", name: "Pull Request", priority: "required", description: "Pull request with the implemented changes" },
- ],
- requiresRepository: true,
- requiresTasks: true,
- guidance: "Execute the plan and create a PR with the implemented changes. Complete all tasks to finish the contract.",
- },
- },
- specification: {
- research: {
- deliverables: [
- { id: "research-notes", name: "Research Notes", priority: "required", description: "Document findings and insights during research" },
- ],
- requiresRepository: false,
- requiresTasks: false,
- guidance: "Focus on understanding the problem space and document your findings in the Research Notes before moving to Specify phase.",
- },
- specify: {
- deliverables: [
- { id: "requirements-document", name: "Requirements Document", priority: "required", description: "Define functional and non-functional requirements" },
- ],
- requiresRepository: false,
- requiresTasks: false,
- guidance: "Define what needs to be built with clear requirements in the Requirements Document. Ensure specifications are detailed enough for planning.",
- },
- plan: {
- deliverables: [
- { id: "plan-document", name: "Plan", priority: "required", description: "Implementation plan detailing the approach and tasks" },
- ],
- requiresRepository: true,
- requiresTasks: false,
- guidance: "Create a plan document that outlines the implementation approach. A repository must be configured before moving to Execute phase.",
- },
- execute: {
- deliverables: [
- { id: "pull-request", name: "Pull Request", priority: "required", description: "Pull request with the implemented changes" },
- ],
- requiresRepository: true,
- requiresTasks: true,
- guidance: "Execute the plan and create a PR with the implemented changes. Complete all tasks before moving to Review phase.",
- },
- review: {
- deliverables: [
- { id: "release-notes", name: "Release Notes", priority: "required", description: "Document changes for release communication" },
- ],
- requiresRepository: false,
- requiresTasks: false,
- guidance: "Review completed work and document the release in the Release Notes. The contract can be completed after review.",
- },
- },
- execute: {
- execute: {
- deliverables: [], // No deliverables for execute-only contract type
- requiresRepository: true,
- requiresTasks: true,
- guidance: "Execute the tasks directly. No deliverable documents are required for this contract type.",
- },
- },
-};
-
-// Get phase config for a specific contract type and phase
-function getPhaseConfig(contractType: ContractType, phase: ContractPhase): PhaseConfig {
- const typeConfig = CONTRACT_TYPE_DELIVERABLES[contractType];
- const phaseConfig = typeConfig?.[phase];
-
- if (phaseConfig) {
- return phaseConfig;
- }
-
- // Fallback for unknown phase/type combinations
- return {
- deliverables: [],
- requiresRepository: false,
- requiresTasks: false,
- guidance: `Unknown phase "${phase}" for contract type "${contractType}"`,
- };
-}
-
-interface DeliverableStatus {
- id: string;
- name: string;
- priority: "required" | "recommended" | "optional";
- description: string;
- completed: boolean;
- fileId?: string;
- actualName?: string;
-}
-
-interface PhaseDeliverablesProps {
- contract: ContractWithRelations;
- onCreateFile?: (templateId: string, suggestedName: string) => void;
-}
-
-export function PhaseDeliverablesPanel({ contract, onCreateFile }: PhaseDeliverablesProps) {
- // Get phase config based on contract type AND phase
- const phaseConfig = useMemo(
- () => getPhaseConfig(contract.contractType, contract.phase),
- [contract.contractType, contract.phase]
- );
-
- // Calculate deliverable status
- const deliverableStatuses = useMemo((): DeliverableStatus[] => {
- return phaseConfig.deliverables.map((deliverable) => {
- // Find matching file by name similarity
- const matchedFile = contract.files.find((f) => {
- const nameLower = f.name.toLowerCase();
- const deliverableLower = deliverable.name.toLowerCase();
- return (
- f.contractPhase === contract.phase &&
- (nameLower.includes(deliverableLower) || deliverableLower.includes(nameLower) || nameLower.includes(deliverable.id.replace("-", " ")))
- );
- });
-
- return {
- ...deliverable,
- completed: !!matchedFile,
- fileId: matchedFile?.id,
- actualName: matchedFile?.name,
- };
- });
- }, [contract.files, contract.phase, phaseConfig.deliverables]);
-
- // Check repository status
- const hasRepository = contract.repositories.length > 0;
-
- // Check task status
- const taskStats = useMemo(() => {
- const total = contract.tasks.length;
- const done = contract.tasks.filter((t) => t.status === "done" || t.status === "merged").length;
- const pending = contract.tasks.filter((t) => t.status === "pending").length;
- const running = contract.tasks.filter((t) => ["running", "initializing", "starting"].includes(t.status)).length;
- const failed = contract.tasks.filter((t) => t.status === "failed").length;
- return { total, done, pending, running, failed };
- }, [contract.tasks]);
-
- // Calculate completion percentage
- const completionPercent = useMemo(() => {
- let completed = 0;
- let total = 0;
-
- // Count required and recommended deliverables
- deliverableStatuses.forEach((s) => {
- if (s.priority !== "optional") {
- total++;
- if (s.completed) completed++;
- }
- });
-
- // Count repository if required
- if (phaseConfig.requiresRepository) {
- total++;
- if (hasRepository) completed++;
- }
-
- // Count tasks if required
- if (phaseConfig.requiresTasks && taskStats.total > 0) {
- total++;
- if (taskStats.done === taskStats.total) completed++;
- }
-
- return total > 0 ? Math.round((completed / total) * 100) : 100;
- }, [deliverableStatuses, hasRepository, phaseConfig, taskStats]);
-
- const priorityColors = {
- required: "text-red-400",
- recommended: "text-yellow-400",
- optional: "text-[#555]",
- };
-
- return (
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <h3 className="font-mono text-xs text-[#75aafc] uppercase">
- Phase Deliverables
- </h3>
- <div className="flex items-center gap-2">
- <div className="w-24 h-1.5 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden">
- <div
- className={`h-full transition-all duration-300 ${
- completionPercent === 100 ? "bg-green-400" : "bg-[#75aafc]"
- }`}
- style={{ width: `${completionPercent}%` }}
- />
- </div>
- <span className="font-mono text-[10px] text-[#555]">{completionPercent}%</span>
- </div>
- </div>
-
- {/* Guidance text */}
- <p className="font-mono text-xs text-[#555] italic">{phaseConfig.guidance}</p>
-
- {/* Deliverables checklist */}
- <div className="space-y-2">
- {deliverableStatuses.map((status) => (
- <div
- key={status.id}
- className={`flex items-center justify-between p-2 border ${
- status.completed
- ? "border-green-400/20 bg-green-400/5"
- : "border-[rgba(117,170,252,0.15)]"
- }`}
- >
- <div className="flex items-center gap-2">
- <span
- className={`font-mono text-xs ${
- status.completed ? "text-green-400" : "text-[#555]"
- }`}
- >
- {status.completed ? "[+]" : "[ ]"}
- </span>
- <div>
- <div className="flex items-center gap-2">
- <span className="font-mono text-xs text-[#dbe7ff]">
- {status.completed ? status.actualName : status.name}
- </span>
- {!status.completed && (
- <span className={`font-mono text-[9px] uppercase ${priorityColors[status.priority]}`}>
- {status.priority}
- </span>
- )}
- </div>
- <span className="font-mono text-[10px] text-[#555]">
- {status.description}
- </span>
- </div>
- </div>
- {!status.completed && onCreateFile && (
- <button
- onClick={() => onCreateFile(status.id, status.name)}
- className="px-2 py-1 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
- >
- Create
- </button>
- )}
- </div>
- ))}
- </div>
-
- {/* Repository status */}
- {phaseConfig.requiresRepository && (
- <div
- className={`flex items-center gap-2 p-2 border ${
- hasRepository
- ? "border-green-400/20 bg-green-400/5"
- : "border-[rgba(117,170,252,0.15)]"
- }`}
- >
- <span
- className={`font-mono text-xs ${
- hasRepository ? "text-green-400" : "text-[#555]"
- }`}
- >
- {hasRepository ? "[+]" : "[ ]"}
- </span>
- <div>
- <span className="font-mono text-xs text-[#dbe7ff]">
- Repository Configured
- </span>
- {!hasRepository && (
- <span className="font-mono text-[9px] uppercase text-red-400 ml-2">
- required
- </span>
- )}
- </div>
- </div>
- )}
-
- {/* Task status */}
- {phaseConfig.requiresTasks && (
- <div
- className={`flex items-center justify-between p-2 border ${
- taskStats.total > 0 && taskStats.done === taskStats.total
- ? "border-green-400/20 bg-green-400/5"
- : "border-[rgba(117,170,252,0.15)]"
- }`}
- >
- <div className="flex items-center gap-2">
- <span
- className={`font-mono text-xs ${
- taskStats.total > 0 && taskStats.done === taskStats.total
- ? "text-green-400"
- : "text-[#555]"
- }`}
- >
- {taskStats.total > 0 && taskStats.done === taskStats.total ? "[+]" : "[ ]"}
- </span>
- <span className="font-mono text-xs text-[#dbe7ff]">
- Tasks Completed
- </span>
- </div>
- {taskStats.total > 0 ? (
- <span className="font-mono text-[10px] text-[#9bc3ff]">
- {taskStats.done}/{taskStats.total}
- {taskStats.running > 0 && ` (${taskStats.running} running)`}
- {taskStats.failed > 0 && (
- <span className="text-red-400"> ({taskStats.failed} failed)</span>
- )}
- </span>
- ) : (
- <span className="font-mono text-[10px] text-[#555]">No tasks yet</span>
- )}
- </div>
- )}
- </div>
- );
-}
diff --git a/makima/frontend/src/components/contracts/PhaseHint.tsx b/makima/frontend/src/components/contracts/PhaseHint.tsx
deleted file mode 100644
index 95573ed..0000000
--- a/makima/frontend/src/components/contracts/PhaseHint.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import type { ContractPhase, ContractWithRelations } from "../../lib/api";
-
-interface PhaseHintProps {
- contract: ContractWithRelations;
- onAdvancePhase: (phase: ContractPhase) => void;
-}
-
-const phaseOrder: ContractPhase[] = ["research", "specify", "plan", "execute", "review"];
-
-interface HintConfig {
- condition: (contract: ContractWithRelations) => boolean;
- message: (contract: ContractWithRelations) => string;
- nextPhase: ContractPhase;
-}
-
-const phaseHints: Record<ContractPhase, HintConfig | null> = {
- research: {
- condition: (c) => c.files.length >= 1,
- message: (c) =>
- `You have ${c.files.length} file${c.files.length === 1 ? "" : "s"}. Ready to specify requirements?`,
- nextPhase: "specify",
- },
- specify: {
- condition: (c) => c.files.length >= 2,
- message: () => "Spec files ready. Create implementation plan?",
- nextPhase: "plan",
- },
- plan: {
- condition: (c) => c.files.length >= 1 && c.repositories.length >= 1,
- message: () => "Plan documented. Ready to create tasks?",
- nextPhase: "execute",
- },
- execute: {
- condition: (c) => {
- // Show hint only when all tasks are complete
- const doneTasks = c.tasks.filter(
- (t) => t.status === "done" || t.status === "merged"
- );
- return c.tasks.length > 0 && doneTasks.length === c.tasks.length;
- },
- message: (c) => {
- const doneTasks = c.tasks.filter(
- (t) => t.status === "done" || t.status === "merged"
- );
- return `All ${doneTasks.length} task${doneTasks.length === 1 ? "" : "s"} complete. Ready for review?`;
- },
- nextPhase: "review",
- },
- review: null, // No hint for review phase - it's the final phase
-};
-
-export function PhaseHint({ contract, onAdvancePhase }: PhaseHintProps) {
- const hintConfig = phaseHints[contract.phase];
-
- // No hint for this phase
- if (!hintConfig) return null;
-
- // Condition not met
- if (!hintConfig.condition(contract)) return null;
-
- return (
- <div className="flex items-center gap-3 p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.2)]">
- <span className="font-mono text-xs text-[#9bc3ff]">
- {hintConfig.message(contract)}
- </span>
- <button
- onClick={() => onAdvancePhase(hintConfig.nextPhase)}
- className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase whitespace-nowrap"
- >
- Advance to {hintConfig.nextPhase}
- </button>
- </div>
- );
-}
-
-export function getNextPhase(currentPhase: ContractPhase): ContractPhase | null {
- const currentIndex = phaseOrder.indexOf(currentPhase);
- if (currentIndex === -1 || currentIndex === phaseOrder.length - 1) {
- return null;
- }
- return phaseOrder[currentIndex + 1];
-}
-
-export function getPreviousPhase(currentPhase: ContractPhase): ContractPhase | null {
- const currentIndex = phaseOrder.indexOf(currentPhase);
- if (currentIndex <= 0) {
- return null;
- }
- return phaseOrder[currentIndex - 1];
-}
diff --git a/makima/frontend/src/components/contracts/PhaseProgressBar.tsx b/makima/frontend/src/components/contracts/PhaseProgressBar.tsx
deleted file mode 100644
index 9589db9..0000000
--- a/makima/frontend/src/components/contracts/PhaseProgressBar.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import type { ContractPhase, ContractType } from "../../lib/api";
-import { getValidPhases } from "../../lib/api";
-
-interface PhaseProgressBarProps {
- currentPhase: ContractPhase;
- contractType?: ContractType;
- onPhaseClick?: (phase: ContractPhase) => void;
- readonly?: boolean;
-}
-
-const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"];
-
-const phaseLabels: Record<ContractPhase, string> = {
- research: "Research",
- specify: "Specify",
- plan: "Plan",
- execute: "Execute",
- review: "Review",
-};
-
-const phaseColors: Record<ContractPhase, { active: string; inactive: string; completed: string }> = {
- research: {
- active: "bg-purple-400 border-purple-400",
- inactive: "bg-transparent border-purple-400/30",
- completed: "bg-purple-400/50 border-purple-400/50",
- },
- specify: {
- active: "bg-blue-400 border-blue-400",
- inactive: "bg-transparent border-blue-400/30",
- completed: "bg-blue-400/50 border-blue-400/50",
- },
- plan: {
- active: "bg-cyan-400 border-cyan-400",
- inactive: "bg-transparent border-cyan-400/30",
- completed: "bg-cyan-400/50 border-cyan-400/50",
- },
- execute: {
- active: "bg-yellow-400 border-yellow-400",
- inactive: "bg-transparent border-yellow-400/30",
- completed: "bg-yellow-400/50 border-yellow-400/50",
- },
- review: {
- active: "bg-green-400 border-green-400",
- inactive: "bg-transparent border-green-400/30",
- completed: "bg-green-400/50 border-green-400/50",
- },
-};
-
-export function PhaseProgressBar({
- currentPhase,
- contractType,
- onPhaseClick,
- readonly = false,
-}: PhaseProgressBarProps) {
- const visiblePhases = contractType ? getValidPhases(contractType) : phases;
- const currentIndex = visiblePhases.indexOf(currentPhase);
-
- return (
- <div className="flex items-center gap-1">
- {visiblePhases.map((phase, index) => {
- const isActive = phase === currentPhase;
- const isCompleted = index < currentIndex;
- const colors = phaseColors[phase];
- const colorClass = isActive
- ? colors.active
- : isCompleted
- ? colors.completed
- : colors.inactive;
-
- const canClick = !readonly && onPhaseClick;
-
- return (
- <div key={phase} className="flex items-center">
- {/* Phase node */}
- <button
- onClick={() => canClick && onPhaseClick(phase)}
- disabled={readonly}
- className={`
- relative group flex flex-col items-center
- ${canClick ? "cursor-pointer" : "cursor-default"}
- `}
- >
- {/* Circle */}
- <div
- className={`
- w-3 h-3 rounded-full border-2 transition-all
- ${colorClass}
- ${canClick && !isActive ? "hover:scale-110" : ""}
- `}
- />
- {/* Label */}
- <span
- className={`
- absolute top-4 font-mono text-[9px] uppercase tracking-wide whitespace-nowrap
- ${isActive ? "text-[#dbe7ff]" : "text-[#555]"}
- ${canClick && !isActive ? "group-hover:text-[#75aafc]" : ""}
- `}
- >
- {phaseLabels[phase]}
- </span>
- </button>
-
- {/* Connector line */}
- {index < visiblePhases.length - 1 && (
- <div
- className={`
- w-8 h-0.5 mx-1
- ${index < currentIndex ? "bg-[#3f6fb3]" : "bg-[rgba(117,170,252,0.15)]"}
- `}
- />
- )}
- </div>
- );
- })}
- </div>
- );
-}
-
-export function PhaseProgressBarCompact({
- currentPhase,
- contractType,
-}: {
- currentPhase: ContractPhase;
- contractType?: ContractType;
-}) {
- const visiblePhases = contractType ? getValidPhases(contractType) : phases;
- const currentIndex = visiblePhases.indexOf(currentPhase);
-
- return (
- <div className="flex items-center gap-0.5">
- {visiblePhases.map((phase, index) => {
- const isActive = phase === currentPhase;
- const isCompleted = index < currentIndex;
- const colors = phaseColors[phase];
-
- return (
- <div
- key={phase}
- className={`
- w-2 h-2 rounded-full border
- ${isActive ? colors.active : isCompleted ? colors.completed : colors.inactive}
- `}
- title={phaseLabels[phase]}
- />
- );
- })}
- </div>
- );
-}
diff --git a/makima/frontend/src/components/contracts/QuickActionButtons.tsx b/makima/frontend/src/components/contracts/QuickActionButtons.tsx
deleted file mode 100644
index 4dbb90c..0000000
--- a/makima/frontend/src/components/contracts/QuickActionButtons.tsx
+++ /dev/null
@@ -1,217 +0,0 @@
-import { useCallback } from "react";
-
-export type QuickActionType =
- | "create_file"
- | "create_task"
- | "run_task"
- | "advance_phase"
- | "derive_tasks"
- | "update_file";
-
-export interface QuickAction {
- type: QuickActionType;
- label: string;
- description?: string;
- data?: Record<string, unknown>;
-}
-
-interface QuickActionButtonsProps {
- actions: QuickAction[];
- onAction: (action: QuickAction) => void;
- loading?: boolean;
-}
-
-const ACTION_ICONS: Record<QuickActionType, string> = {
- create_file: "[+]",
- create_task: "[T]",
- run_task: "[>]",
- advance_phase: "[→]",
- derive_tasks: "[≡]",
- update_file: "[*]",
-};
-
-const ACTION_COLORS: Record<QuickActionType, string> = {
- create_file: "border-blue-400/30 hover:border-blue-400/60 text-blue-400",
- create_task: "border-green-400/30 hover:border-green-400/60 text-green-400",
- run_task: "border-yellow-400/30 hover:border-yellow-400/60 text-yellow-400",
- advance_phase: "border-purple-400/30 hover:border-purple-400/60 text-purple-400",
- derive_tasks: "border-cyan-400/30 hover:border-cyan-400/60 text-cyan-400",
- update_file: "border-orange-400/30 hover:border-orange-400/60 text-orange-400",
-};
-
-export function QuickActionButtons({
- actions,
- onAction,
- loading = false,
-}: QuickActionButtonsProps) {
- const handleClick = useCallback(
- (action: QuickAction) => {
- if (!loading) {
- onAction(action);
- }
- },
- [onAction, loading]
- );
-
- if (actions.length === 0) return null;
-
- return (
- <div className="flex flex-wrap gap-2 mt-2">
- {actions.map((action, index) => (
- <button
- key={`${action.type}-${index}`}
- onClick={() => handleClick(action)}
- disabled={loading}
- className={`
- flex items-center gap-1.5 px-2 py-1
- font-mono text-[10px] uppercase
- border transition-colors
- disabled:opacity-50 disabled:cursor-not-allowed
- ${ACTION_COLORS[action.type]}
- `}
- title={action.description}
- >
- <span>{ACTION_ICONS[action.type]}</span>
- <span>{action.label}</span>
- </button>
- ))}
- </div>
- );
-}
-
-/**
- * Parse tool call results to extract suggested quick actions.
- * This is used by ContractCliInput to detect actionable results.
- */
-export function parseActionsFromToolCalls(
- toolCalls: { name: string; success: boolean; message: string }[]
-): QuickAction[] {
- const actions: QuickAction[] = [];
-
- for (const tc of toolCalls) {
- if (!tc.success) continue;
-
- switch (tc.name) {
- case "derive_tasks_from_file":
- // When tasks are parsed, offer to create them
- if (tc.message.includes("task") || tc.message.includes("Found")) {
- actions.push({
- type: "derive_tasks",
- label: "Review & Create Tasks",
- description: "Review parsed tasks and create them with chaining",
- });
- }
- break;
-
- case "process_task_completion":
- // Check for suggested actions in the result
- if (tc.message.includes("next task")) {
- actions.push({
- type: "run_task",
- label: "Run Next Task",
- description: "Continue with the next chained task",
- });
- }
- if (tc.message.includes("advance") || tc.message.includes("phase")) {
- actions.push({
- type: "advance_phase",
- label: "Advance Phase",
- description: "Move to the next contract phase",
- });
- }
- break;
-
- case "get_phase_checklist":
- // When checklist shows missing items, offer to create them
- if (tc.message.includes("missing") || tc.message.includes("not created")) {
- actions.push({
- type: "create_file",
- label: "Create Missing Files",
- description: "Create files from recommended templates",
- });
- }
- break;
-
- case "advance_phase":
- // After phase transition, suggest creating files
- actions.push({
- type: "create_file",
- label: "Create Phase Files",
- description: "Create recommended files for this phase",
- });
- break;
- }
- }
-
- return actions;
-}
-
-/**
- * Parse LLM response text to detect suggested actions.
- * Used as a fallback when structured action data isn't available.
- */
-export function parseActionsFromText(text: string): QuickAction[] {
- const actions: QuickAction[] = [];
- const lower = text.toLowerCase();
-
- // Detect file creation suggestions
- if (
- lower.includes("create a file") ||
- lower.includes("create the file") ||
- lower.includes("should i create")
- ) {
- actions.push({
- type: "create_file",
- label: "Create File",
- description: "Create the suggested file",
- });
- }
-
- // Detect task creation suggestions
- if (
- lower.includes("create tasks") ||
- lower.includes("create these tasks") ||
- lower.includes("create chained tasks")
- ) {
- actions.push({
- type: "create_task",
- label: "Create Tasks",
- description: "Create the suggested tasks",
- });
- }
-
- // Detect phase advancement suggestions
- if (
- lower.includes("advance to") ||
- lower.includes("ready to move to") ||
- lower.includes("transition to")
- ) {
- const phases = ["specify", "plan", "execute", "review"];
- for (const phase of phases) {
- if (lower.includes(phase)) {
- actions.push({
- type: "advance_phase",
- label: `Advance to ${phase.charAt(0).toUpperCase() + phase.slice(1)}`,
- description: `Move to the ${phase} phase`,
- data: { phase },
- });
- break;
- }
- }
- }
-
- // Detect run task suggestions
- if (
- lower.includes("run the task") ||
- lower.includes("start the task") ||
- lower.includes("run task")
- ) {
- actions.push({
- type: "run_task",
- label: "Run Task",
- description: "Start the suggested task",
- });
- }
-
- return actions;
-}
diff --git a/makima/frontend/src/components/contracts/RepositoryPanel.tsx b/makima/frontend/src/components/contracts/RepositoryPanel.tsx
deleted file mode 100644
index 15741a8..0000000
--- a/makima/frontend/src/components/contracts/RepositoryPanel.tsx
+++ /dev/null
@@ -1,325 +0,0 @@
-import { useState, useEffect } from "react";
-import type {
- ContractRepository,
- RepositorySourceType,
- RepositoryStatus,
- DaemonDirectory,
- RepositoryHistoryEntry,
-} from "../../lib/api";
-import { getDaemonDirectories, getRepositorySuggestions } from "../../lib/api";
-import { DirectoryInput } from "../mesh/DirectoryInput";
-
-interface RepositoryPanelProps {
- repositories: ContractRepository[];
- onAddRemote: (name: string, url: string, isPrimary: boolean) => void;
- onAddLocal: (name: string, path: string, isPrimary: boolean) => void;
- onCreateManaged: (name: string, isPrimary: boolean) => void;
- onDelete: (repoId: string) => void;
- onSetPrimary: (repoId: string) => void;
-}
-
-type AddMode = "remote" | "local" | "managed" | null;
-
-const sourceTypeLabels: Record<RepositorySourceType, string> = {
- remote: "Remote",
- local: "Local",
- managed: "Managed",
-};
-
-const sourceTypeIcons: Record<RepositorySourceType, string> = {
- remote: "GH",
- local: "FS",
- managed: "MK",
-};
-
-const statusColors: Record<RepositoryStatus, string> = {
- ready: "text-green-400",
- pending: "text-yellow-400",
- creating: "text-cyan-400",
- failed: "text-red-400",
-};
-
-export function RepositoryPanel({
- repositories,
- onAddRemote,
- onAddLocal,
- onCreateManaged,
- onDelete,
- onSetPrimary,
-}: RepositoryPanelProps) {
- const [addMode, setAddMode] = useState<AddMode>(null);
- const [name, setName] = useState("");
- const [url, setUrl] = useState("");
- const [path, setPath] = useState("");
- const [isPrimary, setIsPrimary] = useState(false);
- // Daemon directory suggestions for local repositories
- const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]);
- // Repository history suggestions
- const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]);
- const [showSuggestions, setShowSuggestions] = useState(false);
-
- // Fetch daemon directories when "local" mode is selected
- useEffect(() => {
- if (addMode === "local") {
- getDaemonDirectories()
- .then((res) => setSuggestedDirectories(res.directories))
- .catch(() => setSuggestedDirectories([]));
- }
- }, [addMode]);
-
- // Fetch repository suggestions when mode changes to remote or local
- useEffect(() => {
- if (addMode === "remote" || addMode === "local") {
- getRepositorySuggestions(addMode, undefined, 10)
- .then((res) => {
- setRepoSuggestions(res.entries);
- setShowSuggestions(res.entries.length > 0);
- })
- .catch(() => {
- setRepoSuggestions([]);
- setShowSuggestions(false);
- });
- } else {
- setRepoSuggestions([]);
- setShowSuggestions(false);
- }
- }, [addMode]);
-
- // Apply a suggestion to the form
- const applySuggestion = (suggestion: RepositoryHistoryEntry) => {
- setName(suggestion.name);
- if (suggestion.repositoryUrl) {
- setUrl(suggestion.repositoryUrl);
- }
- if (suggestion.localPath) {
- setPath(suggestion.localPath);
- }
- setShowSuggestions(false);
- };
-
- const handleAdd = () => {
- if (!name.trim()) return;
-
- if (addMode === "remote" && url.trim()) {
- onAddRemote(name.trim(), url.trim(), isPrimary);
- } else if (addMode === "local" && path.trim()) {
- onAddLocal(name.trim(), path.trim(), isPrimary);
- } else if (addMode === "managed") {
- onCreateManaged(name.trim(), isPrimary);
- }
-
- // Reset form
- setAddMode(null);
- setName("");
- setUrl("");
- setPath("");
- setIsPrimary(false);
- };
-
- const handleCancel = () => {
- setAddMode(null);
- setName("");
- setUrl("");
- setPath("");
- setIsPrimary(false);
- };
-
- return (
- <div className="space-y-4">
- {/* Repository list */}
- {repositories.length === 0 ? (
- <p className="font-mono text-xs text-[#555]">
- No repositories configured
- </p>
- ) : (
- <div className="space-y-2">
- {repositories.map((repo) => (
- <div
- key={repo.id}
- className="flex items-center gap-3 p-3 border border-[rgba(117,170,252,0.2)]"
- >
- {/* Type icon */}
- <span className="font-mono text-[10px] text-[#555] uppercase w-6">
- {sourceTypeIcons[repo.sourceType]}
- </span>
-
- {/* Name and details */}
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2">
- <span className="font-mono text-sm text-[#dbe7ff] truncate">
- {repo.name}
- </span>
- {repo.isPrimary && (
- <span className="px-1 py-0.5 text-[8px] font-mono uppercase bg-[rgba(117,170,252,0.1)] text-[#75aafc] border border-[rgba(117,170,252,0.3)]">
- Primary
- </span>
- )}
- </div>
- <div className="font-mono text-[10px] text-[#555] truncate">
- {repo.repositoryUrl || repo.localPath || "(pending creation)"}
- </div>
- </div>
-
- {/* Status */}
- <span
- className={`font-mono text-[10px] uppercase ${
- statusColors[repo.status]
- }`}
- >
- {repo.status}
- </span>
-
- {/* Actions */}
- <div className="flex items-center gap-1">
- {!repo.isPrimary && repo.status === "ready" && (
- <button
- onClick={() => onSetPrimary(repo.id)}
- className="p-1 font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors"
- title="Set as primary"
- >
- *
- </button>
- )}
- <button
- onClick={() => onDelete(repo.id)}
- className="p-1 font-mono text-[10px] text-[#555] hover:text-red-400 transition-colors"
- title="Remove"
- >
- x
- </button>
- </div>
- </div>
- ))}
- </div>
- )}
-
- {/* Add repository form */}
- {addMode ? (
- <div className="p-3 border border-[rgba(117,170,252,0.3)] space-y-3">
- <div className="flex items-center justify-between mb-2">
- <span className="font-mono text-xs text-[#75aafc] uppercase">
- Add {sourceTypeLabels[addMode]} Repository
- </span>
- {repoSuggestions.length > 0 && (
- <button
- onClick={() => setShowSuggestions(!showSuggestions)}
- className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors"
- >
- {showSuggestions ? "Hide suggestions" : `${repoSuggestions.length} suggestions`}
- </button>
- )}
- </div>
-
- {/* Suggestions dropdown */}
- {showSuggestions && repoSuggestions.length > 0 && (
- <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto">
- {repoSuggestions.map((suggestion) => (
- <button
- key={suggestion.id}
- onClick={() => applySuggestion(suggestion)}
- className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] transition-colors border-b border-[rgba(117,170,252,0.1)] last:border-b-0"
- >
- <div className="flex items-center justify-between">
- <span className="text-[#9bc3ff] truncate">{suggestion.name}</span>
- <span className="text-[10px] text-[#556677]">
- {suggestion.useCount}×
- </span>
- </div>
- <div className="text-[10px] text-[#556677] truncate">
- {addMode === "local" ? suggestion.localPath : suggestion.repositoryUrl}
- </div>
- </button>
- ))}
- </div>
- )}
-
- <input
- type="text"
- value={name}
- onChange={(e) => setName(e.target.value)}
- placeholder="Repository name"
- className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]"
- />
-
- {addMode === "remote" && (
- <input
- type="text"
- value={url}
- onChange={(e) => setUrl(e.target.value)}
- placeholder="https://github.com/owner/repo"
- className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]"
- />
- )}
-
- {addMode === "local" && (
- <DirectoryInput
- value={path}
- onChange={setPath}
- suggestions={suggestedDirectories}
- placeholder="/path/to/repository"
- />
- )}
-
- {addMode === "managed" && (
- <p className="font-mono text-xs text-[#555]">
- Makima will create this repository via the daemon.
- </p>
- )}
-
- <label className="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- checked={isPrimary}
- onChange={(e) => setIsPrimary(e.target.checked)}
- className="w-3 h-3"
- />
- <span className="font-mono text-xs text-[#9bc3ff]">
- Set as primary repository
- </span>
- </label>
-
- <div className="flex gap-2">
- <button
- onClick={handleCancel}
- className="px-3 py-1.5 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
- >
- Cancel
- </button>
- <button
- onClick={handleAdd}
- disabled={
- !name.trim() ||
- (addMode === "remote" && !url.trim()) ||
- (addMode === "local" && !path.trim())
- }
- className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
- >
- Add Repository
- </button>
- </div>
- </div>
- ) : (
- <div className="flex gap-2">
- <button
- onClick={() => setAddMode("remote")}
- className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
- >
- + Remote
- </button>
- <button
- onClick={() => setAddMode("local")}
- className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
- >
- + Local
- </button>
- <button
- onClick={() => setAddMode("managed")}
- className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
- >
- + Managed
- </button>
- </div>
- )}
- </div>
- );
-}
diff --git a/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx b/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx
deleted file mode 100644
index 07421ef..0000000
--- a/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx
+++ /dev/null
@@ -1,221 +0,0 @@
-import { useState, useCallback } from "react";
-
-export interface ParsedTask {
- name: string;
- description?: string;
- group?: string;
- order: number;
- completed: boolean;
- dependencies: string[];
-}
-
-interface TaskDerivationPreviewProps {
- tasks: ParsedTask[];
- groups: string[];
- fileName: string;
- onCreateTasks: (selectedTasks: ParsedTask[]) => void;
- onCancel: () => void;
- loading?: boolean;
-}
-
-export function TaskDerivationPreview({
- tasks,
- groups,
- fileName,
- onCreateTasks,
- onCancel,
- loading = false,
-}: TaskDerivationPreviewProps) {
- const [selectedIndices, setSelectedIndices] = useState<Set<number>>(
- new Set(tasks.map((_, i) => i)) // Select all by default
- );
-
- const toggleTask = useCallback((index: number) => {
- setSelectedIndices((prev) => {
- const newSet = new Set(prev);
- if (newSet.has(index)) {
- newSet.delete(index);
- } else {
- newSet.add(index);
- }
- return newSet;
- });
- }, []);
-
- const selectAll = useCallback(() => {
- setSelectedIndices(new Set(tasks.map((_, i) => i)));
- }, [tasks]);
-
- const selectNone = useCallback(() => {
- setSelectedIndices(new Set());
- }, []);
-
- const handleCreate = useCallback(() => {
- const selectedTasks = tasks.filter((_, i) => selectedIndices.has(i));
- onCreateTasks(selectedTasks);
- }, [tasks, selectedIndices, onCreateTasks]);
-
- // Group tasks by their group property
- const tasksByGroup = tasks.reduce((acc, task, index) => {
- const groupKey = task.group || "Ungrouped";
- if (!acc[groupKey]) {
- acc[groupKey] = [];
- }
- acc[groupKey].push({ task, index });
- return acc;
- }, {} as Record<string, { task: ParsedTask; index: number }[]>);
-
- const selectedCount = selectedIndices.size;
- const totalCount = tasks.length;
-
- return (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
- <div className="w-full max-w-2xl p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[80vh] flex flex-col">
- {/* Header */}
- <div className="flex items-center justify-between mb-4">
- <div>
- <h3 className="font-mono text-sm text-[#75aafc] uppercase">
- Create Tasks from Document
- </h3>
- <p className="font-mono text-xs text-[#555] mt-1">
- Source: {fileName}
- </p>
- </div>
- <div className="flex items-center gap-2">
- <button
- onClick={selectAll}
- className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
- >
- Select All
- </button>
- <span className="text-[#555]">|</span>
- <button
- onClick={selectNone}
- className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
- >
- Select None
- </button>
- </div>
- </div>
-
- {/* Task List */}
- <div className="flex-1 overflow-y-auto space-y-4 mb-4">
- {groups.length > 0 ? (
- // Grouped view
- Object.entries(tasksByGroup).map(([groupName, groupTasks]) => (
- <div key={groupName} className="space-y-2">
- <h4 className="font-mono text-xs text-[#9bc3ff] uppercase border-b border-[rgba(117,170,252,0.2)] pb-1">
- {groupName}
- </h4>
- {groupTasks.map(({ task, index }) => (
- <TaskItem
- key={index}
- task={task}
- index={index}
- selected={selectedIndices.has(index)}
- onToggle={() => toggleTask(index)}
- />
- ))}
- </div>
- ))
- ) : (
- // Flat view
- tasks.map((task, index) => (
- <TaskItem
- key={index}
- task={task}
- index={index}
- selected={selectedIndices.has(index)}
- onToggle={() => toggleTask(index)}
- />
- ))
- )}
- </div>
-
- {/* Footer */}
- <div className="flex items-center justify-between pt-4 border-t border-[rgba(117,170,252,0.2)]">
- <span className="font-mono text-xs text-[#555]">
- {selectedCount} of {totalCount} tasks selected
- </span>
- <div className="flex gap-2">
- <button
- onClick={onCancel}
- disabled={loading}
- className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors disabled:opacity-50"
- >
- Cancel
- </button>
- <button
- onClick={handleCreate}
- disabled={loading || selectedCount === 0}
- className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
- >
- {loading ? "Creating..." : `Create ${selectedCount} Task${selectedCount !== 1 ? "s" : ""}`}
- </button>
- </div>
- </div>
-
- {/* Chaining info */}
- {selectedCount > 1 && (
- <p className="font-mono text-[10px] text-[#555] mt-2 text-center">
- Tasks will be chained: each task continues from the previous one's work
- </p>
- )}
- </div>
- </div>
- );
-}
-
-function TaskItem({
- task,
- index,
- selected,
- onToggle,
-}: {
- task: ParsedTask;
- index: number;
- selected: boolean;
- onToggle: () => void;
-}) {
- return (
- <button
- onClick={onToggle}
- className={`w-full text-left p-3 border transition-colors ${
- selected
- ? "border-[#75aafc] bg-[rgba(117,170,252,0.1)]"
- : "border-[rgba(117,170,252,0.15)] hover:border-[rgba(117,170,252,0.3)]"
- }`}
- >
- <div className="flex items-start gap-2">
- <span
- className={`font-mono text-xs mt-0.5 ${
- selected ? "text-[#75aafc]" : "text-[#555]"
- }`}
- >
- {selected ? "[x]" : "[ ]"}
- </span>
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2">
- <span className="font-mono text-[10px] text-[#555]">#{index + 1}</span>
- <span className="font-mono text-sm text-[#dbe7ff]">{task.name}</span>
- {task.completed && (
- <span className="font-mono text-[9px] text-green-400 uppercase">
- done in source
- </span>
- )}
- </div>
- {task.description && (
- <p className="font-mono text-xs text-[#555] mt-1 truncate">
- {task.description}
- </p>
- )}
- {task.dependencies.length > 0 && (
- <p className="font-mono text-[10px] text-[#75aafc] mt-1">
- Depends on: {task.dependencies.join(", ")}
- </p>
- )}
- </div>
- </div>
- </button>
- );
-}
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index bbb72f3..c1c6c35 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -12,14 +12,12 @@ import { ProtectedRoute } from "./components/ProtectedRoute";
import HomePage from "./routes/_index";
import ListenPage from "./routes/listen";
import FilesPage from "./routes/files";
-import ContractsPage from "./routes/contracts";
import OrdersPage from "./routes/orders";
import MeshPage from "./routes/mesh";
import DaemonsPage from "./routes/daemons";
import HistoryPage from "./routes/history";
import LoginPage from "./routes/login";
import SettingsPage from "./routes/settings";
-import ContractFilePage from "./routes/contract-file";
import SpeakPage from "./routes/speak";
import DirectivesPage from "./routes/directives";
import ExecRedirect from "./routes/exec-redirect";
@@ -62,30 +60,6 @@ createRoot(document.getElementById("root")!).render(
}
/>
<Route
- path="/contracts"
- element={
- <ProtectedRoute>
- <ContractsPage />
- </ProtectedRoute>
- }
- />
- <Route
- path="/contracts/:id"
- element={
- <ProtectedRoute>
- <ContractsPage />
- </ProtectedRoute>
- }
- />
- <Route
- path="/contracts/:id/files/:fileId"
- element={
- <ProtectedRoute>
- <ContractFilePage />
- </ProtectedRoute>
- }
- />
- <Route
path="/orders"
element={
<ProtectedRoute>
diff --git a/makima/frontend/src/routes/contract-file.tsx b/makima/frontend/src/routes/contract-file.tsx
deleted file mode 100644
index 9ed25ed..0000000
--- a/makima/frontend/src/routes/contract-file.tsx
+++ /dev/null
@@ -1,659 +0,0 @@
-import { useEffect, useState, useCallback, useRef } from "react";
-import { useParams, useNavigate } from "react-router";
-import { useAuth } from "../contexts/AuthContext";
-import { Masthead } from "../components/Masthead";
-import { FileDetail, type FocusedElement } from "../components/files/FileDetail";
-import { CliInput } from "../components/files/CliInput";
-import { ConflictNotification } from "../components/files/ConflictNotification";
-import { UpdateNotification } from "../components/files/UpdateNotification";
-import { useFiles } from "../hooks/useFiles";
-import { useVersionHistory } from "../hooks/useVersionHistory";
-import {
- useFileSubscription,
- type FileUpdateEvent,
-} from "../hooks/useFileSubscription";
-import type { FileDetail as FileDetailType, BodyElement } from "../lib/api";
-
-/**
- * ContractFilePage - Wrapper for viewing files within a contract context
- *
- * This component handles the /contracts/:contractId/files/:fileId route,
- * providing navigation back to the contract and rendering the file detail view.
- */
-export default function ContractFilePage() {
- const { id: contractId, fileId } = useParams<{ id: string; fileId: string }>();
- const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
- const navigate = useNavigate();
-
- // Redirect to login if not authenticated (when auth is configured)
- useEffect(() => {
- if (!authLoading && isAuthConfigured && !isAuthenticated) {
- navigate("/login");
- }
- }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
-
- // Show loading while checking auth
- if (authLoading) {
- return (
- <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
- <Masthead showNav />
- <main className="flex-1 flex items-center justify-center">
- <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
- </main>
- </div>
- );
- }
-
- // Don't render if not authenticated (will redirect)
- if (isAuthConfigured && !isAuthenticated) {
- return null;
- }
-
- // Render the file page with contract context
- return <ContractAwareFilesPage contractId={contractId} fileId={fileId} />;
-}
-
-// A version of the files page aware of contract context
-function ContractAwareFilesPage({
- contractId,
- fileId,
-}: {
- contractId?: string;
- fileId?: string;
-}) {
- const navigate = useNavigate();
- const { error, conflict, clearConflict, fetchFile, editFile, removeFile } = useFiles();
- const [fileDetail, setFileDetail] = useState<FileDetailType | null>(null);
- const [detailLoading, setDetailLoading] = useState(false);
- const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null);
- const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null);
- const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null);
- const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null);
- const pendingUpdateRef = useRef(false);
- const lastSentVersionRef = useRef<number | null>(null);
- const lastSavedVersionRef = useRef<number | null>(null);
- const hasLocalChangesRef = useRef(false);
- const isActivelyEditingRef = useRef(false);
- const currentVersionRef = useRef<number | null>(null);
-
- // Handle back navigation - go to contract detail instead of /files
- const handleBack = useCallback(() => {
- if (contractId) {
- navigate(`/contracts/${contractId}`);
- } else {
- navigate("/contracts");
- }
- }, [contractId, navigate]);
-
- const updateHasLocalChanges = useCallback((value: boolean) => {
- hasLocalChangesRef.current = value;
- }, []);
-
- const updateIsActivelyEditing = useCallback((value: boolean) => {
- isActivelyEditingRef.current = value;
- }, []);
-
- // Version history
- const {
- versions,
- loading: versionsLoading,
- selectedVersion,
- loadingVersion,
- restoring,
- fetchVersion,
- restoreToVersion,
- clearSelectedVersion,
- fetchVersions,
- } = useVersionHistory({
- fileId: fileId || null,
- currentVersion: fileDetail?.version || 0,
- });
-
- const handleRestoreVersion = useCallback(
- async (targetVersion: number) => {
- const result = await restoreToVersion(targetVersion);
- if (result) {
- currentVersionRef.current = result.version;
- setFileDetail(result);
- updateHasLocalChanges(false);
- fetchVersions();
- }
- },
- [restoreToVersion, fetchVersions, updateHasLocalChanges]
- );
-
- // Load file detail when fileId is provided
- useEffect(() => {
- if (fileId) {
- setDetailLoading(true);
- updateHasLocalChanges(false);
- pendingUpdateRef.current = false;
- lastSentVersionRef.current = null;
- lastSavedVersionRef.current = null;
- currentVersionRef.current = null;
- setRemoteUpdate(null);
- setRemoteFileData(null);
- setFocusedElement(null);
- fetchFile(fileId).then((detail) => {
- if (detail) {
- currentVersionRef.current = detail.version;
- }
- setFileDetail(detail);
- setDetailLoading(false);
- });
- } else {
- setFileDetail(null);
- currentVersionRef.current = null;
- updateHasLocalChanges(false);
- }
- }, [fileId, fetchFile, updateHasLocalChanges]);
-
- // Handle file update events from WebSocket
- const handleFileUpdate = useCallback(
- async (event: FileUpdateEvent) => {
- if (lastSavedVersionRef.current !== null && event.version === lastSavedVersionRef.current) {
- lastSavedVersionRef.current = null;
- return;
- }
-
- if (pendingUpdateRef.current) {
- if (lastSentVersionRef.current !== null) {
- const expectedNewVersion = lastSentVersionRef.current + 1;
- if (event.version === expectedNewVersion) {
- pendingUpdateRef.current = false;
- lastSentVersionRef.current = null;
- return;
- }
- }
- return;
- }
-
- if (currentVersionRef.current !== null && event.version === currentVersionRef.current) {
- return;
- }
-
- if (!hasLocalChangesRef.current && !isActivelyEditingRef.current) {
- const detail = await fetchFile(event.fileId);
- if (detail) {
- currentVersionRef.current = detail.version;
- }
- setFileDetail(detail);
- } else {
- const remoteData = await fetchFile(event.fileId);
- setRemoteFileData(remoteData);
- setRemoteUpdate(event);
- }
- },
- [fetchFile]
- );
-
- useFileSubscription({
- fileId: fileId || null,
- onUpdate: handleFileUpdate,
- });
-
- const handleDelete = useCallback(
- async (id: string) => {
- if (confirm("Are you sure you want to delete this file?")) {
- const success = await removeFile(id);
- if (success && fileId === id) {
- handleBack();
- }
- }
- },
- [removeFile, fileId, handleBack]
- );
-
- const handleSave = useCallback(
- async (id: string, name: string, description: string) => {
- if (!fileDetail) return;
- pendingUpdateRef.current = true;
- lastSentVersionRef.current = fileDetail.version;
- try {
- const result = await editFile(id, { name, description, version: fileDetail.version });
- if (result) {
- lastSavedVersionRef.current = result.version;
- currentVersionRef.current = result.version;
- setFileDetail(result);
- updateHasLocalChanges(false);
- }
- } finally {
- pendingUpdateRef.current = false;
- lastSentVersionRef.current = null;
- }
- },
- [editFile, fileDetail, updateHasLocalChanges]
- );
-
- const handleBodyUpdate = useCallback(
- (body: BodyElement[], summary: string | null) => {
- if (fileDetail) {
- setFileDetail({
- ...fileDetail,
- body,
- summary,
- });
- }
- },
- [fileDetail]
- );
-
- const handleBodyElementUpdate = useCallback(
- async (index: number, element: BodyElement) => {
- if (fileDetail && fileId) {
- const newBody = [...fileDetail.body];
- newBody[index] = element;
-
- setFileDetail({
- ...fileDetail,
- body: newBody,
- });
- updateHasLocalChanges(true);
-
- pendingUpdateRef.current = true;
- lastSentVersionRef.current = fileDetail.version;
- try {
- const result = await editFile(fileId, { body: newBody, version: fileDetail.version });
- if (result) {
- lastSavedVersionRef.current = result.version;
- currentVersionRef.current = result.version;
- setFileDetail(result);
- updateHasLocalChanges(false);
- }
- } finally {
- pendingUpdateRef.current = false;
- lastSentVersionRef.current = null;
- }
- }
- },
- [fileDetail, fileId, editFile, updateHasLocalChanges]
- );
-
- const handleBodyReorder = useCallback(
- async (fromIndex: number, toIndex: number) => {
- if (fileDetail && fileId) {
- const newBody = [...fileDetail.body];
- const [movedElement] = newBody.splice(fromIndex, 1);
- newBody.splice(toIndex, 0, movedElement);
-
- setFileDetail({
- ...fileDetail,
- body: newBody,
- });
- updateHasLocalChanges(true);
-
- pendingUpdateRef.current = true;
- lastSentVersionRef.current = fileDetail.version;
- try {
- const result = await editFile(fileId, { body: newBody, version: fileDetail.version });
- if (result) {
- lastSavedVersionRef.current = result.version;
- currentVersionRef.current = result.version;
- setFileDetail(result);
- updateHasLocalChanges(false);
- }
- } finally {
- pendingUpdateRef.current = false;
- lastSentVersionRef.current = null;
- }
- }
- },
- [fileDetail, fileId, editFile, updateHasLocalChanges]
- );
-
- const handleBodyElementDelete = useCallback(
- async (index: number) => {
- if (fileDetail && fileId) {
- const newBody = fileDetail.body.filter((_, i) => i !== index);
-
- setFileDetail({
- ...fileDetail,
- body: newBody,
- });
- updateHasLocalChanges(true);
-
- if (focusedElement?.index === index) {
- setFocusedElement(null);
- } else if (focusedElement && focusedElement.index > index) {
- setFocusedElement({
- ...focusedElement,
- index: focusedElement.index - 1,
- });
- }
-
- pendingUpdateRef.current = true;
- lastSentVersionRef.current = fileDetail.version;
- try {
- const result = await editFile(fileId, { body: newBody, version: fileDetail.version });
- if (result) {
- lastSavedVersionRef.current = result.version;
- currentVersionRef.current = result.version;
- setFileDetail(result);
- updateHasLocalChanges(false);
- }
- } finally {
- pendingUpdateRef.current = false;
- lastSentVersionRef.current = null;
- }
- }
- },
- [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement]
- );
-
- const handleBodyElementDuplicate = useCallback(
- async (index: number) => {
- if (fileDetail && fileId) {
- const elementToDuplicate = fileDetail.body[index];
- if (!elementToDuplicate) return;
-
- const newBody = [...fileDetail.body];
- newBody.splice(index + 1, 0, { ...elementToDuplicate });
-
- setFileDetail({
- ...fileDetail,
- body: newBody,
- });
- updateHasLocalChanges(true);
-
- if (focusedElement && focusedElement.index > index) {
- setFocusedElement({
- ...focusedElement,
- index: focusedElement.index + 1,
- });
- }
-
- pendingUpdateRef.current = true;
- lastSentVersionRef.current = fileDetail.version;
- try {
- const result = await editFile(fileId, { body: newBody, version: fileDetail.version });
- if (result) {
- lastSavedVersionRef.current = result.version;
- currentVersionRef.current = result.version;
- setFileDetail(result);
- updateHasLocalChanges(false);
- }
- } finally {
- pendingUpdateRef.current = false;
- lastSentVersionRef.current = null;
- }
- }
- },
- [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement]
- );
-
- const handleFocusElement = useCallback((element: FocusedElement | null) => {
- setFocusedElement(element);
- }, []);
-
- const handleClearFocus = useCallback(() => {
- setFocusedElement(null);
- }, []);
-
- const handleConvertElement = useCallback(
- async (index: number, toType: string) => {
- if (!fileDetail || !fileId) return;
-
- const element = fileDetail.body[index];
- if (!element) return;
-
- let textContent = "";
- switch (element.type) {
- case "heading":
- case "paragraph":
- textContent = element.text;
- break;
- case "code":
- textContent = element.content;
- break;
- case "list":
- textContent = element.items.join("\n");
- break;
- default:
- return;
- }
-
- let newElement: BodyElement;
- if (toType === "paragraph") {
- newElement = { type: "paragraph", text: textContent };
- } else if (toType === "list_unordered") {
- const items = textContent.split("\n").filter(line => line.trim());
- newElement = { type: "list", ordered: false, items };
- } else if (toType === "list_ordered") {
- const items = textContent.split("\n").filter(line => line.trim());
- newElement = { type: "list", ordered: true, items };
- } else if (toType === "code") {
- newElement = { type: "code", content: textContent };
- } else if (toType.startsWith("heading_")) {
- const level = parseInt(toType.replace("heading_", ""), 10) as 1 | 2 | 3 | 4 | 5 | 6;
- newElement = { type: "heading", level, text: textContent };
- } else {
- return;
- }
-
- const newBody = [...fileDetail.body];
- newBody[index] = newElement;
-
- setFileDetail({ ...fileDetail, body: newBody });
- updateHasLocalChanges(true);
-
- if (focusedElement?.index === index) {
- setFocusedElement({
- index,
- type: newElement.type,
- preview: textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""),
- });
- }
-
- pendingUpdateRef.current = true;
- lastSentVersionRef.current = fileDetail.version;
- try {
- const result = await editFile(fileId, { body: newBody, version: fileDetail.version });
- if (result) {
- lastSavedVersionRef.current = result.version;
- currentVersionRef.current = result.version;
- setFileDetail(result);
- updateHasLocalChanges(false);
- }
- } finally {
- pendingUpdateRef.current = false;
- lastSentVersionRef.current = null;
- }
- },
- [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement]
- );
-
- const handleGenerateFromElement = useCallback(
- (index: number, action: string) => {
- if (!fileDetail) return;
-
- const element = fileDetail.body[index];
- if (!element) return;
-
- let preview = "";
- switch (element.type) {
- case "heading":
- case "paragraph":
- preview = element.text.slice(0, 50);
- break;
- case "code":
- preview = element.content.slice(0, 50);
- break;
- case "list":
- preview = element.items[0]?.slice(0, 40) || "";
- break;
- default:
- preview = "Element";
- }
-
- setFocusedElement({
- index,
- type: element.type,
- preview: preview + (preview.length >= 50 ? "..." : ""),
- });
-
- let prompt = "";
- switch (action) {
- case "elaborate":
- prompt = "Elaborate and expand on this content";
- break;
- case "summarize":
- prompt = "Summarize this content";
- break;
- case "extract_actions":
- prompt = "Extract action items from this content";
- break;
- }
- setSuggestedPrompt(prompt);
- },
- [fileDetail]
- );
-
- // Conflict resolution handlers
- const handleConflictReload = useCallback(async () => {
- if (fileId) {
- clearConflict();
- const detail = await fetchFile(fileId);
- if (detail) {
- currentVersionRef.current = detail.version;
- }
- setFileDetail(detail);
- updateHasLocalChanges(false);
- }
- }, [fileId, clearConflict, fetchFile, updateHasLocalChanges]);
-
- const handleConflictForceOverwrite = useCallback(async () => {
- if (fileId && fileDetail) {
- clearConflict();
- const latest = await fetchFile(fileId);
- if (latest) {
- pendingUpdateRef.current = true;
- lastSentVersionRef.current = latest.version;
- try {
- const result = await editFile(fileId, { body: fileDetail.body, version: latest.version });
- if (result) {
- lastSavedVersionRef.current = result.version;
- currentVersionRef.current = result.version;
- setFileDetail(result);
- updateHasLocalChanges(false);
- }
- } finally {
- pendingUpdateRef.current = false;
- lastSentVersionRef.current = null;
- }
- }
- }
- }, [fileId, fileDetail, clearConflict, fetchFile, editFile, updateHasLocalChanges]);
-
- const handleRemoteUpdateRefresh = useCallback(async () => {
- if (fileId) {
- const detail = await fetchFile(fileId);
- if (detail) {
- currentVersionRef.current = detail.version;
- }
- setFileDetail(detail);
- setRemoteUpdate(null);
- setRemoteFileData(null);
- updateHasLocalChanges(false);
- }
- }, [fileId, fetchFile, updateHasLocalChanges]);
-
- const handleRemoteUpdateDismiss = useCallback(() => {
- setRemoteUpdate(null);
- setRemoteFileData(null);
- }, []);
-
- return (
- <div className="relative z-10 h-screen flex flex-col overflow-hidden">
- <Masthead showTicker={false} showNav />
-
- <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col">
- {error && (
- <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0">
- {error}
- </div>
- )}
-
- {fileId && fileDetail ? (
- <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
- <div className="flex-1 min-h-0 overflow-hidden">
- <FileDetail
- file={fileDetail}
- loading={detailLoading}
- onBack={handleBack}
- onSave={handleSave}
- onDelete={handleDelete}
- onBodyElementUpdate={handleBodyElementUpdate}
- onBodyReorder={handleBodyReorder}
- onBodyElementDelete={handleBodyElementDelete}
- onBodyElementDuplicate={handleBodyElementDuplicate}
- onConvertElement={handleConvertElement}
- onGenerateFromElement={handleGenerateFromElement}
- onEditingChange={updateIsActivelyEditing}
- hasPendingRemoteUpdate={!!remoteUpdate}
- onOverwrite={handleRemoteUpdateDismiss}
- focusedElement={focusedElement}
- onFocusElement={handleFocusElement}
- versions={versions}
- versionsLoading={versionsLoading}
- selectedVersion={selectedVersion}
- loadingVersion={loadingVersion}
- restoring={restoring}
- onSelectVersion={fetchVersion}
- onRestoreVersion={handleRestoreVersion}
- onClearVersionSelection={clearSelectedVersion}
- />
- </div>
- <div className="shrink-0">
- <CliInput
- fileId={fileId}
- onUpdate={handleBodyUpdate}
- focusedElement={focusedElement}
- onClearFocus={handleClearFocus}
- suggestedPrompt={suggestedPrompt}
- onClearSuggestedPrompt={() => setSuggestedPrompt(null)}
- />
- </div>
- </div>
- ) : fileId && detailLoading ? (
- <div className="panel h-full flex items-center justify-center">
- <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
- </div>
- ) : (
- <div className="panel h-full flex items-center justify-center">
- <div className="text-center">
- <p className="font-mono text-sm text-[#555] mb-4">
- File not found
- </p>
- <button
- onClick={handleBack}
- className="px-4 py-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
- >
- &larr; Back to Contract
- </button>
- </div>
- </div>
- )}
- </main>
-
- {/* Conflict notification */}
- {conflict?.hasConflict && (
- <ConflictNotification
- onReload={handleConflictReload}
- onForceOverwrite={handleConflictForceOverwrite}
- onDismiss={clearConflict}
- />
- )}
-
- {/* Remote update notification */}
- {remoteUpdate && (
- <UpdateNotification
- updatedBy={remoteUpdate.updatedBy}
- localBody={fileDetail?.body || []}
- remoteBody={remoteFileData?.body || []}
- onRefresh={handleRemoteUpdateRefresh}
- onDismiss={handleRemoteUpdateDismiss}
- />
- )}
- </div>
- );
-}
diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx
deleted file mode 100644
index ce9ceca..0000000
--- a/makima/frontend/src/routes/contracts.tsx
+++ /dev/null
@@ -1,885 +0,0 @@
-import { useState, useCallback, useEffect } from "react";
-import { useParams, useNavigate } from "react-router";
-import { Masthead } from "../components/Masthead";
-import { ContractList } from "../components/contracts/ContractList";
-import { ContractDetail } from "../components/contracts/ContractDetail";
-import { DirectoryInput } from "../components/mesh/DirectoryInput";
-import { useContracts } from "../hooks/useContracts";
-import { useAuth } from "../contexts/AuthContext";
-import {
- createTask,
- getDaemonDirectories,
- getRepositorySuggestions,
- listContractTypes,
-} from "../lib/api";
-import type {
- ContractWithRelations,
- ContractSummary,
- ContractPhase,
- ContractStatus,
- ContractType,
- CreateContractRequest,
- RepositorySourceType,
- DaemonDirectory,
- RepositoryHistoryEntry,
- ContractTypeTemplate,
-} from "../lib/api";
-
-export default function ContractsPage() {
- const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
- const navigate = useNavigate();
-
- // Redirect to login if not authenticated (when auth is configured)
- useEffect(() => {
- if (!authLoading && isAuthConfigured && !isAuthenticated) {
- navigate("/login");
- }
- }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
-
- // Show loading while checking auth
- if (authLoading) {
- return (
- <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
- <Masthead showNav />
- <main className="flex-1 flex items-center justify-center">
- <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
- </main>
- </div>
- );
- }
-
- // Don't render if not authenticated (will redirect)
- if (isAuthConfigured && !isAuthenticated) {
- return null;
- }
-
- return <ContractsPageContent />;
-}
-
-function ContractsPageContent() {
- const { id } = useParams<{ id: string }>();
- const navigate = useNavigate();
- const {
- contracts,
- loading,
- error,
- fetchContract,
- saveContract,
- editContract,
- removeContract,
- changePhase,
- addRemoteRepo,
- addLocalRepo,
- createManagedRepo,
- removeRepo,
- setRepoPrimary,
- } = useContracts();
-
- const [contractDetail, setContractDetail] = useState<ContractWithRelations | null>(null);
- const [detailLoading, setDetailLoading] = useState(false);
- const [isCreating, setIsCreating] = useState(false);
- const [newContractName, setNewContractName] = useState("");
- const [newContractDescription, setNewContractDescription] = useState("");
- const [contractType, setContractType] = useState<ContractType>("simple");
- const [initialPhase, setInitialPhase] = useState<ContractPhase>("plan");
- const [repoType, setRepoType] = useState<RepositorySourceType>("remote");
- const [repoName, setRepoName] = useState("");
- const [repoUrl, setRepoUrl] = useState("");
- const [repoPath, setRepoPath] = useState("");
- const [createError, setCreateError] = useState<string | null>(null);
- const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]);
- const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]);
- const [showRepoSuggestions, setShowRepoSuggestions] = useState(false);
- const [contractTypes, setContractTypes] = useState<ContractTypeTemplate[]>([]);
- const [contractTypesLoading, setContractTypesLoading] = useState(false);
- const [localOnly, setLocalOnly] = useState(false);
-
- // Fetch contract types when modal opens - API returns both built-in and custom templates
- useEffect(() => {
- if (isCreating) {
- setContractTypesLoading(true);
-
- listContractTypes()
- .then((res) => {
- setContractTypes(res.contractTypes);
- setContractTypesLoading(false);
- })
- .catch((err) => {
- console.error("Failed to fetch contract types:", err);
- // Fall back to built-in types
- const builtinTypes: ContractTypeTemplate[] = [
- {
- id: "simple",
- name: "Simple",
- description: "Plan \u2192 Execute: Simple workflow with a plan document",
- phases: ["plan", "execute"],
- defaultPhase: "plan",
- isBuiltin: true,
- },
- {
- id: "specification",
- name: "Specification",
- description: "Research \u2192 Specify \u2192 Plan \u2192 Execute \u2192 Review: Full specification-driven development with TDD",
- phases: ["research", "specify", "plan", "execute", "review"],
- defaultPhase: "research",
- isBuiltin: true,
- },
- {
- id: "execute",
- name: "Execute",
- description: "Execute only: Minimal workflow for immediate task execution",
- phases: ["execute"],
- defaultPhase: "execute",
- isBuiltin: true,
- },
- ];
- setContractTypes(builtinTypes);
- setContractTypesLoading(false);
- });
- }
- }, [isCreating]);
-
- // Fetch repository suggestions when modal opens and repo type changes
- useEffect(() => {
- if (isCreating && (repoType === "remote" || repoType === "local")) {
- getRepositorySuggestions(repoType, undefined, 10)
- .then((res) => {
- setRepoSuggestions(res.entries);
- setShowRepoSuggestions(res.entries.length > 0);
- })
- .catch(() => {
- setRepoSuggestions([]);
- setShowRepoSuggestions(false);
- });
- } else {
- setRepoSuggestions([]);
- setShowRepoSuggestions(false);
- }
- }, [isCreating, repoType]);
-
- // Apply a repository suggestion
- const applyRepoSuggestion = useCallback((suggestion: RepositoryHistoryEntry) => {
- setRepoName(suggestion.name);
- if (suggestion.repositoryUrl) {
- setRepoUrl(suggestion.repositoryUrl);
- }
- if (suggestion.localPath) {
- setRepoPath(suggestion.localPath);
- }
- setShowRepoSuggestions(false);
- }, []);
-
- // Fetch daemon directories when "local" repo type is selected
- useEffect(() => {
- if (repoType === "local" && isCreating) {
- getDaemonDirectories()
- .then((res) => setSuggestedDirectories(res.directories))
- .catch(() => setSuggestedDirectories([]));
- }
- }, [repoType, isCreating]);
-
- // Load contract detail when ID changes
- useEffect(() => {
- if (id) {
- setDetailLoading(true);
- fetchContract(id).then((contract) => {
- setContractDetail(contract);
- setDetailLoading(false);
- });
- } else {
- setContractDetail(null);
- }
- }, [id, fetchContract]);
-
- const handleSelect = useCallback(
- (contractId: string) => {
- navigate(`/contracts/${contractId}`);
- },
- [navigate]
- );
-
- const handleBack = useCallback(() => {
- navigate("/contracts");
- }, [navigate]);
-
- const handleCreate = useCallback(() => {
- setIsCreating(true);
- }, []);
-
- // Validate repository configuration
- const isRepoValid = useCallback(() => {
- if (!repoName.trim()) return false;
- if (repoType === "remote" && !repoUrl.trim()) return false;
- if (repoType === "local" && !repoPath.trim()) return false;
- return true;
- }, [repoType, repoName, repoUrl, repoPath]);
-
- const handleCreateSubmit = useCallback(async () => {
- if (!newContractName.trim()) return;
- if (!isRepoValid()) {
- setCreateError("Repository configuration is required");
- return;
- }
-
- setCreateError(null);
-
- // Get default phase from contract types or fall back to static function
- const selectedType = contractTypes.find((t) => t.id === contractType);
- const defaultPhaseForType = selectedType?.defaultPhase || (contractType === "simple" ? "plan" : "research");
- const isCustomTemplate = selectedType && !selectedType.isBuiltin;
-
- const data: CreateContractRequest = {
- name: newContractName.trim(),
- description: newContractDescription.trim() || undefined,
- // For custom templates, send templateId instead of contractType
- contractType: isCustomTemplate ? undefined : contractType,
- templateId: isCustomTemplate ? contractType : undefined,
- initialPhase: initialPhase !== defaultPhaseForType ? initialPhase : undefined,
- localOnly: localOnly || undefined,
- };
-
- try {
- const contract = await saveContract(data);
- if (contract) {
- // Add the repository after contract creation
- try {
- if (repoType === "remote") {
- await addRemoteRepo(contract.id, {
- name: repoName.trim(),
- repositoryUrl: repoUrl.trim(),
- isPrimary: true,
- });
- } else if (repoType === "local") {
- await addLocalRepo(contract.id, {
- name: repoName.trim(),
- localPath: repoPath.trim(),
- isPrimary: true,
- });
- } else if (repoType === "managed") {
- await createManagedRepo(contract.id, {
- name: repoName.trim(),
- isPrimary: true,
- });
- }
- } catch (repoError) {
- console.error("Failed to add repository:", repoError);
- // Still navigate to the contract - repo can be added later
- }
-
- // Clear form state
- setIsCreating(false);
- setNewContractName("");
- setNewContractDescription("");
- setContractType("simple");
- setInitialPhase("plan");
- setRepoType("remote");
- setRepoName("");
- setRepoUrl("");
- setRepoPath("");
- setLocalOnly(false);
- navigate(`/contracts/${contract.id}`);
- }
- } catch (err) {
- setCreateError(err instanceof Error ? err.message : "Failed to create contract");
- }
- }, [
- newContractName,
- newContractDescription,
- contractType,
- contractTypes,
- initialPhase,
- repoType,
- repoName,
- repoUrl,
- repoPath,
- isRepoValid,
- saveContract,
- addRemoteRepo,
- addLocalRepo,
- createManagedRepo,
- navigate,
- ]);
-
- const handleCreateCancel = useCallback(() => {
- setIsCreating(false);
- setNewContractName("");
- setNewContractDescription("");
- setContractType("simple");
- setInitialPhase("plan");
- setRepoType("remote");
- setRepoName("");
- setRepoUrl("");
- setRepoPath("");
- setLocalOnly(false);
- setCreateError(null);
- }, []);
-
- const handleUpdate = useCallback(
- async (name: string, description: string) => {
- if (contractDetail) {
- const updated = await editContract(contractDetail.id, {
- name,
- description: description || undefined,
- version: contractDetail.version,
- });
- if (updated) {
- // Refresh detail
- const refreshed = await fetchContract(contractDetail.id);
- setContractDetail(refreshed);
- }
- }
- },
- [contractDetail, editContract, fetchContract]
- );
-
- const handleDelete = useCallback(async () => {
- if (contractDetail && confirm("Are you sure you want to delete this contract?")) {
- const success = await removeContract(contractDetail.id);
- if (success) {
- navigate("/contracts");
- }
- }
- }, [contractDetail, removeContract, navigate]);
-
- const handlePhaseChange = useCallback(
- async (phase: ContractPhase) => {
- if (contractDetail) {
- const updated = await changePhase(contractDetail.id, phase);
- if (updated) {
- // Refresh detail
- const refreshed = await fetchContract(contractDetail.id);
- setContractDetail(refreshed);
- }
- }
- },
- [contractDetail, changePhase, fetchContract]
- );
-
- const handleStatusChange = useCallback(
- async (status: ContractStatus) => {
- if (contractDetail) {
- const updated = await editContract(contractDetail.id, {
- status,
- version: contractDetail.version,
- });
- if (updated) {
- // Refresh detail
- const refreshed = await fetchContract(contractDetail.id);
- setContractDetail(refreshed);
- }
- }
- },
- [contractDetail, editContract, fetchContract]
- );
-
- // Repository handlers
- const handleAddRemoteRepo = useCallback(
- async (name: string, url: string, isPrimary: boolean) => {
- if (contractDetail) {
- await addRemoteRepo(contractDetail.id, { name, repositoryUrl: url, isPrimary });
- const refreshed = await fetchContract(contractDetail.id);
- setContractDetail(refreshed);
- }
- },
- [contractDetail, addRemoteRepo, fetchContract]
- );
-
- const handleAddLocalRepo = useCallback(
- async (name: string, path: string, isPrimary: boolean) => {
- if (contractDetail) {
- await addLocalRepo(contractDetail.id, { name, localPath: path, isPrimary });
- const refreshed = await fetchContract(contractDetail.id);
- setContractDetail(refreshed);
- }
- },
- [contractDetail, addLocalRepo, fetchContract]
- );
-
- const handleCreateManagedRepo = useCallback(
- async (name: string, isPrimary: boolean) => {
- if (contractDetail) {
- await createManagedRepo(contractDetail.id, { name, isPrimary });
- const refreshed = await fetchContract(contractDetail.id);
- setContractDetail(refreshed);
- }
- },
- [contractDetail, createManagedRepo, fetchContract]
- );
-
- const handleDeleteRepo = useCallback(
- async (repoId: string) => {
- if (contractDetail) {
- await removeRepo(contractDetail.id, repoId);
- const refreshed = await fetchContract(contractDetail.id);
- setContractDetail(refreshed);
- }
- },
- [contractDetail, removeRepo, fetchContract]
- );
-
- const handleSetRepoPrimary = useCallback(
- async (repoId: string) => {
- if (contractDetail) {
- await setRepoPrimary(contractDetail.id, repoId);
- const refreshed = await fetchContract(contractDetail.id);
- setContractDetail(refreshed);
- }
- },
- [contractDetail, setRepoPrimary, fetchContract]
- );
-
- // Refresh contract detail (used after file/task operations)
- const handleRefresh = useCallback(async () => {
- if (contractDetail) {
- const refreshed = await fetchContract(contractDetail.id);
- setContractDetail(refreshed);
- }
- }, [contractDetail, fetchContract]);
-
- // File/task navigation handlers
- const handleFileSelect = useCallback(
- (fileId: string) => {
- if (contractDetail) {
- navigate(`/contracts/${contractDetail.id}/files/${fileId}`);
- }
- },
- [navigate, contractDetail]
- );
-
- const handleTaskSelect = useCallback(
- (taskId: string) => {
- navigate(`/exec/${taskId}`);
- },
- [navigate]
- );
-
- // Create task within contract context
- const handleTaskCreate = useCallback(
- async (name: string, plan: string, repositoryUrl?: string) => {
- if (!contractDetail) return;
- try {
- // Create the task with contract_id (task is automatically associated)
- const task = await createTask({
- contractId: contractDetail.id,
- name,
- plan,
- repositoryUrl,
- });
- // Refresh contract detail to show new task
- const refreshed = await fetchContract(contractDetail.id);
- setContractDetail(refreshed);
- // Navigate to the new task
- navigate(`/exec/${task.id}`);
- } catch (e) {
- console.error("Failed to create task:", e);
- alert(e instanceof Error ? e.message : "Failed to create task");
- }
- },
- [contractDetail, fetchContract, navigate]
- );
-
- // Context menu handlers for ContractList
- const handleContextMarkComplete = useCallback(
- async (contract: ContractSummary) => {
- await editContract(contract.id, { status: "completed", version: contract.version });
- },
- [editContract]
- );
-
- const handleContextMarkActive = useCallback(
- async (contract: ContractSummary) => {
- await editContract(contract.id, { status: "active", version: contract.version });
- },
- [editContract]
- );
-
- const handleContextArchive = useCallback(
- async (contract: ContractSummary) => {
- await editContract(contract.id, { status: "archived", version: contract.version });
- },
- [editContract]
- );
-
- const handleContextDelete = useCallback(
- async (contract: ContractSummary) => {
- if (confirm(`Are you sure you want to delete "${contract.name}"?`)) {
- const success = await removeContract(contract.id);
- if (success && contract.id === id) {
- navigate("/contracts");
- }
- }
- },
- [removeContract, id, navigate]
- );
-
- const handleContextGoToSupervisor = useCallback(
- (contract: ContractSummary) => {
- if (contract.supervisorTaskId) {
- navigate(`/exec/${contract.supervisorTaskId}`);
- }
- },
- [navigate]
- );
-
- return (
- <div className="relative z-10 h-screen flex flex-col overflow-hidden bg-[#0a1628]">
- <Masthead showNav />
- <main className="flex-1 flex overflow-hidden" style={{ height: "calc(100vh - 80px)" }}>
- {/* Left: Contract list */}
- <div className="w-[350px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col">
- <ContractList
- contracts={contracts}
- loading={loading}
- onSelect={handleSelect}
- onCreate={handleCreate}
- selectedId={id}
- onMarkComplete={handleContextMarkComplete}
- onMarkActive={handleContextMarkActive}
- onArchive={handleContextArchive}
- onDelete={handleContextDelete}
- onGoToSupervisor={handleContextGoToSupervisor}
- />
- </div>
-
- {/* Right: Detail or Create */}
- <div className="flex-1 overflow-hidden flex flex-col min-h-0">
- {error && (
- <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm">
- {error}
- </div>
- )}
-
- {/* Contract detail, creation form, or empty state */}
- <div className="flex-1 min-h-0 overflow-hidden">
- {isCreating ? (
- <div className="p-4 max-w-lg overflow-y-auto h-full bg-[#0a1628]">
- <h3 className="font-mono text-[10px] text-[#9bc3ff] uppercase tracking-wide mb-4">
- Create Contract
- </h3>
-
- {createError && (
- <div className="mb-4 p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs">
- {createError}
- </div>
- )}
-
- <div className="space-y-4">
- {/* Contract name */}
- <div>
- <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1">
- Contract Name
- </label>
- <input
- type="text"
- value={newContractName}
- onChange={(e) => setNewContractName(e.target.value)}
- placeholder="Contract name"
- className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]"
- autoFocus
- />
- </div>
-
- {/* Description */}
- <div>
- <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1">
- Description (optional)
- </label>
- <textarea
- value={newContractDescription}
- onChange={(e) => setNewContractDescription(e.target.value)}
- placeholder="Describe what this contract is for..."
- rows={2}
- className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc] resize-none"
- />
- </div>
-
- {/* Contract Type */}
- <div>
- <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1">
- Contract Type
- </label>
- {contractTypesLoading ? (
- <div className="flex items-center justify-center py-4">
- <span className="font-mono text-xs text-[#8b949e]">Loading contract types...</span>
- </div>
- ) : (
- <>
- <div className="flex gap-2">
- {contractTypes.map((type) => (
- <button
- key={type.id}
- type="button"
- onClick={() => {
- setContractType(type.id as ContractType);
- setInitialPhase(type.defaultPhase as ContractPhase);
- }}
- className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
- contractType === type.id
- ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
- : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]"
- }`}
- >
- {type.name}
- </button>
- ))}
- </div>
- <p className="mt-1 font-mono text-xs text-[#8b949e]">
- {contractTypes.find((t) => t.id === contractType)?.description ||
- "Select a contract type"}
- </p>
- </>
- )}
- </div>
-
- {/* Starting Phase */}
- <div>
- <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1">
- Starting Phase
- </label>
- <select
- value={initialPhase}
- onChange={(e) => setInitialPhase(e.target.value as ContractPhase)}
- className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]"
- >
- {(() => {
- const template = contractTypes.find((t) => t.id === contractType);
- return (template?.phases || []).map((phase) => {
- const displayName = template?.phaseNames?.[phase] || (phase.charAt(0).toUpperCase() + phase.slice(1));
- return (
- <option key={phase} value={phase}>
- {displayName}
- </option>
- );
- });
- })()}
- </select>
- <p className="mt-1 font-mono text-xs text-[#8b949e]">
- {contractType === "simple"
- ? "Start in Plan to define what to build, or Execute if already planned"
- : "Skip earlier phases if you already have requirements defined"}
- </p>
- </div>
-
- {/* Local-Only Mode */}
- <div className="space-y-2">
- <div className="flex items-center space-x-3">
- <button
- type="button"
- onClick={() => setLocalOnly(!localOnly)}
- className={`w-5 h-5 flex items-center justify-center border transition-colors ${
- localOnly
- ? "bg-[#0f3c78] border-[#75aafc] text-[#dbe7ff]"
- : "bg-[#0d1b2d] border-[rgba(117,170,252,0.2)] text-transparent"
- }`}
- >
- {localOnly && (
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="3"
- strokeLinecap="round"
- strokeLinejoin="round"
- className="w-3 h-3"
- >
- <polyline points="20 6 9 17 4 12" />
- </svg>
- )}
- </button>
- <label
- className="font-mono text-sm text-[#dbe7ff] cursor-pointer select-none"
- onClick={() => setLocalOnly(!localOnly)}
- >
- Local-Only Mode
- </label>
- </div>
- <p className="font-mono text-xs text-[#8b949e] pl-8">
- When enabled, tasks won't automatically push to remote or create PRs.
- Use patch files to export changes.
- </p>
- </div>
-
- {/* Repository Configuration */}
- <div className="border-t border-[rgba(117,170,252,0.2)] pt-4">
- <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-3">
- Repository Configuration (Required)
- </label>
-
- {/* Repository type selector */}
- <div className="flex gap-2 mb-3">
- <button
- type="button"
- onClick={() => setRepoType("remote")}
- className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
- repoType === "remote"
- ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
- : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]"
- }`}
- >
- Remote
- </button>
- <button
- type="button"
- onClick={() => setRepoType("local")}
- className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
- repoType === "local"
- ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
- : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]"
- }`}
- >
- Local
- </button>
- <button
- type="button"
- onClick={() => setRepoType("managed")}
- className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
- repoType === "managed"
- ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
- : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]"
- }`}
- >
- Managed
- </button>
- </div>
-
- {/* Repository suggestions */}
- {showRepoSuggestions && repoSuggestions.length > 0 && (
- <div className="mb-3">
- <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1">
- Recent Repositories
- </label>
- <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto">
- {repoSuggestions.map((suggestion) => (
- <button
- key={suggestion.id}
- type="button"
- onClick={() => applyRepoSuggestion(suggestion)}
- className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0"
- >
- <div className="flex items-center justify-between">
- <span className="text-[#9bc3ff] truncate">{suggestion.name}</span>
- <span className="text-[10px] text-[#556677] ml-2">
- {suggestion.useCount}×
- </span>
- </div>
- <div className="text-[10px] text-[#556677] truncate">
- {repoType === "local" ? suggestion.localPath : suggestion.repositoryUrl}
- </div>
- </button>
- ))}
- </div>
- </div>
- )}
-
- {/* Repository name */}
- <div className="mb-3">
- <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1">
- Repository Name
- </label>
- <input
- type="text"
- value={repoName}
- onChange={(e) => setRepoName(e.target.value)}
- placeholder="e.g., my-project"
- className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]"
- />
- </div>
-
- {/* Repository URL (for remote) */}
- {repoType === "remote" && (
- <div>
- <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1">
- Repository URL
- </label>
- <input
- type="text"
- value={repoUrl}
- onChange={(e) => setRepoUrl(e.target.value)}
- placeholder="https://github.com/user/repo.git"
- className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]"
- />
- </div>
- )}
-
- {/* Repository path (for local) */}
- {repoType === "local" && (
- <div>
- <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1">
- Local Path
- </label>
- <DirectoryInput
- value={repoPath}
- onChange={setRepoPath}
- suggestions={suggestedDirectories}
- placeholder="/path/to/repository"
- />
- </div>
- )}
-
- {/* Managed description */}
- {repoType === "managed" && (
- <p className="font-mono text-xs text-[#8b949e]">
- A managed repository will be created automatically by the daemon.
- </p>
- )}
- </div>
-
- {/* Actions */}
- <div className="flex gap-2 justify-end pt-2">
- <button
- onClick={handleCreateCancel}
- className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
- >
- Cancel
- </button>
- <button
- onClick={handleCreateSubmit}
- disabled={!newContractName.trim() || !isRepoValid()}
- className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[rgba(117,170,252,0.2)] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
- >
- Create
- </button>
- </div>
- </div>
- </div>
- ) : contractDetail ? (
- <ContractDetail
- contract={contractDetail}
- loading={detailLoading}
- onBack={handleBack}
- onUpdate={handleUpdate}
- onDelete={handleDelete}
- onPhaseChange={handlePhaseChange}
- onStatusChange={handleStatusChange}
- onFileSelect={handleFileSelect}
- onTaskSelect={handleTaskSelect}
- onTaskCreate={handleTaskCreate}
- onRefresh={handleRefresh}
- onAddRemoteRepo={handleAddRemoteRepo}
- onAddLocalRepo={handleAddLocalRepo}
- onCreateManagedRepo={handleCreateManagedRepo}
- onDeleteRepo={handleDeleteRepo}
- onSetRepoPrimary={handleSetRepoPrimary}
- />
- ) : (
- <div className="panel h-full flex items-center justify-center">
- <div className="text-center">
- <p className="font-mono text-sm text-[#555] mb-4">
- Select a contract or create a new one
- </p>
- <button
- onClick={handleCreate}
- className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
- >
- + New Contract
- </button>
- </div>
- </div>
- )}
- </div>
- </div>
- </main>
- </div>
- );
-}
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index 7b0a89b..a3ea969 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -1530,13 +1530,17 @@ export default function DocumentDirectivesPage() {
: null;
return (
- <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ // h-screen + overflow-hidden so the page itself never scrolls; the
+ // sidebar and editor pane each manage their own scroll via flex-1
+ // children with overflow-y-auto. Previously we set
+ // height: calc(100vh - 80px) on <main>, which assumed an 80px masthead
+ // and quietly clipped content when the masthead was taller (or pushed
+ // the page below the viewport on shorter screens, which made the
+ // whole page scroll instead of the sidebar/editor independently).
+ <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden">
<Masthead showNav />
- <main
- className="flex-1 flex overflow-hidden"
- style={{ height: "calc(100vh - 80px)" }}
- >
- {/* Left: file-tree sidebar */}
+ <main className="flex-1 flex min-h-0 overflow-hidden">
+ {/* Left: file-tree sidebar — independent scroll. */}
<div className="w-[260px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]">
<DocumentSidebar
directives={directives}
diff --git a/makima/frontend/src/routes/tmp.tsx b/makima/frontend/src/routes/tmp.tsx
index 69f13a2..c0c7365 100644
--- a/makima/frontend/src/routes/tmp.tsx
+++ b/makima/frontend/src/routes/tmp.tsx
@@ -53,7 +53,7 @@ export default function TmpTaskPage() {
if (authLoading) {
return (
- <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden">
<Masthead showNav />
<main className="flex-1 flex items-center justify-center">
<p className="text-[#7788aa] font-mono text-sm">Loading...</p>
@@ -63,12 +63,9 @@ export default function TmpTaskPage() {
}
return (
- <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden">
<Masthead showNav />
- <main
- className="flex-1 flex flex-col overflow-hidden"
- style={{ height: "calc(100vh - 80px)" }}
- >
+ <main className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Breadcrumb echoing the document-mode header style. */}
<div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
<div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
diff --git a/makima/migrations/20260501100000_tmp_directive_and_clear_orphans.sql b/makima/migrations/20260501100000_tmp_directive_and_clear_orphans.sql
new file mode 100644
index 0000000..a587288
--- /dev/null
+++ b/makima/migrations/20260501100000_tmp_directive_and_clear_orphans.sql
@@ -0,0 +1,39 @@
+-- Tmp directive system: every task must belong to a directive going forward.
+--
+-- Background: the unified-surface UI previously surfaced "orphan" tasks
+-- (tasks with directive_id NULL) under a synthetic /tmp/ folder. That
+-- accumulated stale junk over time and made the UI noisy. The new model:
+--
+-- * Add `is_tmp` to directives — at most one per owner, marks the
+-- special "scratchpad" directive that holds otherwise-orphan tasks.
+-- * Delete every existing orphan task. The user explicitly asked for
+-- a clean slate: "ALSO there are TOO MANY old tasks in tmp, we need
+-- to remove all of them as well."
+-- * Going forward, ephemeral / standalone task creation paths attach
+-- to the caller's tmp directive (auto-created on first use by the
+-- repository helper, not by this migration — owners may not exist
+-- yet at migration time, but every owner gets one as soon as a
+-- standalone task is requested).
+-- * A 30-day expiry sweep in the directive reconciler deletes tasks
+-- in tmp directives once they age out.
+
+-- 1. New flag column on directives. Default false; only set true on the
+-- auto-created scratchpad directive.
+ALTER TABLE directives
+ ADD COLUMN is_tmp BOOLEAN NOT NULL DEFAULT false;
+
+-- Partial unique index — at most ONE tmp directive per owner.
+CREATE UNIQUE INDEX idx_directives_owner_tmp_unique
+ ON directives(owner_id)
+ WHERE is_tmp;
+
+-- 2. Clear out every existing orphan task. Per the user's spec these are
+-- discardable scratch work; pre-existing valuable tasks are already
+-- attached to a directive and will not be touched.
+--
+-- Cascades: task_events delete via FK; daemon links go to NULL; nothing
+-- in the contracts/directive_steps tables references orphan tasks (a
+-- contract-backed step always has a directive_id by construction).
+DELETE FROM tasks
+ WHERE directive_id IS NULL
+ AND parent_task_id IS NULL;
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs
index df3e8e7..338d8f9 100644
--- a/makima/src/bin/makima.rs
+++ b/makima/src/bin/makima.rs
@@ -4,10 +4,9 @@ use std::io::{self, Read};
use std::path::Path;
use std::sync::Arc;
-use makima::daemon::api::{ApiClient, CreateContractRequest};
+use makima::daemon::api::ApiClient;
use makima::daemon::cli::{
- Cli, CliConfig, Commands, ConfigCommand, ContractCommand,
- DirectiveCommand, SupervisorCommand, ViewArgs,
+ Cli, CliConfig, Commands, ConfigCommand, DirectiveCommand, ViewArgs,
};
use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion};
use makima::daemon::config::{DaemonConfig, RepoEntry};
@@ -27,8 +26,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match cli.command {
Commands::Server(args) => run_server(args).await,
Commands::Daemon(args) => run_daemon(args).await,
- Commands::Supervisor(cmd) => run_supervisor(cmd).await,
- Commands::Contract(cmd) => run_contract(cmd).await,
Commands::Directive(cmd) => run_directive(cmd).await,
Commands::View(args) => run_view(args).await,
Commands::Config(cmd) => run_config(cmd).await,
@@ -309,383 +306,6 @@ async fn run_daemon(
Ok(())
}
-/// Run supervisor commands.
-async fn run_supervisor(
- cmd: SupervisorCommand,
-) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
- use makima::daemon::api::supervisor::*;
-
- match cmd {
- SupervisorCommand::Tasks(args) => {
- let client = ApiClient::new(args.api_url, args.api_key)?;
- let result = client.supervisor_tasks(args.contract_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Tree(args) => {
- let client = ApiClient::new(args.api_url, args.api_key)?;
- let result = client.supervisor_tree(args.contract_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Spawn(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- eprintln!("Creating task: {}...", args.name);
- let req = SpawnTaskRequest {
- name: args.name,
- plan: args.plan,
- contract_id: args.common.contract_id,
- parent_task_id: args.parent,
- checkpoint_sha: args.checkpoint,
- };
- let result = client.supervisor_spawn(req).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Wait(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- eprintln!(
- "Waiting for task {} (timeout: {}s, poll interval: {}s)...",
- args.task_id, args.timeout, args.poll_interval
- );
-
- let start_time = std::time::Instant::now();
- let timeout_duration = std::time::Duration::from_secs(args.timeout as u64);
- let poll_interval = std::time::Duration::from_secs(args.poll_interval);
- let server_wait_timeout = 30i32; // Short timeout for server-side wait
-
- loop {
- // Check if we've exceeded the total timeout
- let remaining = timeout_duration.saturating_sub(start_time.elapsed());
- if remaining.is_zero() {
- eprintln!("Timeout reached after {}s", args.timeout);
- let result = client.supervisor_get_task(args.task_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- break;
- }
-
- // Try server-side wait with short timeout
- let wait_timeout = std::cmp::min(server_wait_timeout, remaining.as_secs() as i32);
-
- match client.supervisor_wait(args.task_id, wait_timeout).await {
- Ok(result) => {
- if let Some(completed) = result.0.get("completed").and_then(|c| c.as_bool()) {
- if completed {
- println!("{}", serde_json::to_string(&result.0)?);
- break;
- }
- }
- // Not completed yet, continue loop
- eprintln!("Task still running (elapsed: {:?})", start_time.elapsed());
- }
- Err(e) => {
- eprintln!("Warning: Server wait failed: {}. Falling back to polling...", e);
- // Fall back to simple status poll
- if let Ok(result) = client.supervisor_get_task(args.task_id).await {
- if let Some(status) = result.0.get("status").and_then(|s| s.as_str()) {
- if status == "done" || status == "failed" || status == "merged" {
- let wait_response = serde_json::json!({
- "taskId": args.task_id,
- "status": status,
- "completed": true,
- "outputSummary": result.0.get("progressSummary")
- });
- println!("{}", serde_json::to_string(&wait_response)?);
- break;
- }
- }
- }
- }
- }
-
- // Small delay before retrying
- tokio::time::sleep(poll_interval).await;
- }
- }
- SupervisorCommand::ReadFile(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- let result = client
- .supervisor_read_file(args.task_id, &args.file_path)
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Branch(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- eprintln!("Creating branch: {}...", args.name);
- let result = client.supervisor_branch(&args.name, args.from).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Merge(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- eprintln!("Merging task {}...", args.task_id);
- let result = client
- .supervisor_merge(args.task_id, args.to, args.squash)
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Pr(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- eprintln!("Creating PR for branch {}...", args.branch);
- let body = args.body.as_deref().unwrap_or("");
- let result = client
- .supervisor_pr(&args.branch, &args.title, body)
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Diff(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- let result = client.supervisor_diff(args.task_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Checkpoint(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- let task_id = args
- .common
- .self_task_id
- .ok_or("MAKIMA_TASK_ID is required for checkpoint")?;
- let result = client
- .supervisor_checkpoint(task_id, &args.message)
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Checkpoints(args) => {
- let client = ApiClient::new(args.api_url, args.api_key)?;
- let task_id = args.self_task_id.ok_or("MAKIMA_TASK_ID is required")?;
- let result = client.supervisor_checkpoints(task_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Status(args) => {
- let client = ApiClient::new(args.api_url, args.api_key)?;
- let result = client.supervisor_status(args.contract_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Ask(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- eprintln!("Asking user: {}...", args.question);
- let choices = args
- .choices
- .map(|c| c.split(',').map(|s| s.trim().to_string()).collect())
- .unwrap_or_default();
- let result = client
- .supervisor_ask(&args.question, choices, args.context, args.timeout, args.phaseguard, args.multi_select, args.non_blocking, args.question_type)
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::AdvancePhase(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- if args.confirmed {
- eprintln!("Advancing contract to phase: {} (confirmed)...", args.phase);
- } else {
- eprintln!("Requesting phase advance to: {} (use --confirmed to proceed)...", args.phase);
- }
- let result = client
- .supervisor_advance_phase(args.common.contract_id, &args.phase, args.confirmed)
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Task(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- let result = client.supervisor_get_task(args.target_task_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::Output(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- let result = client.supervisor_get_task_output(args.target_task_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- SupervisorCommand::TaskHistory(args) => {
- eprintln!(
- "Task history for {} (limit: {:?}, format: {})",
- args.task_id, args.limit, args.format
- );
- eprintln!("CLI integration not yet implemented. Use the API directly:");
- eprintln!(" GET /api/v1/mesh/tasks/{}/conversation", args.task_id);
- }
- SupervisorCommand::TaskCheckpoints(args) => {
- eprintln!(
- "Task checkpoints for {} (with_diff: {})",
- args.task_id, args.with_diff
- );
- eprintln!("CLI integration not yet implemented. Use the API directly:");
- eprintln!(" GET /api/v1/mesh/tasks/{}/checkpoints", args.task_id);
- }
- SupervisorCommand::Resume(args) => {
- eprintln!(
- "Resume supervisor for contract {} (mode: {}, checkpoint: {:?})",
- args.common.contract_id, args.mode, args.checkpoint
- );
- eprintln!("CLI integration not yet implemented. Use the API directly:");
- eprintln!(
- " POST /api/v1/contracts/{}/supervisor/resume",
- args.common.contract_id
- );
- }
- SupervisorCommand::TaskResumeFrom(args) => {
- eprintln!(
- "Resume task {} from checkpoint {} with plan: {}",
- args.task_id, args.checkpoint, args.plan
- );
- eprintln!("CLI integration not yet implemented. Use the API directly:");
- eprintln!(
- " POST /api/v1/mesh/tasks/{}/checkpoints/{}/resume",
- args.task_id, args.checkpoint
- );
- }
- SupervisorCommand::TaskRewind(args) => {
- eprintln!(
- "Rewind task {} to checkpoint {} (preserve: {}, branch: {:?})",
- args.task_id, args.checkpoint, args.preserve, args.branch_name
- );
- eprintln!("CLI integration not yet implemented. Use the API directly:");
- eprintln!(" POST /api/v1/mesh/tasks/{}/rewind", args.task_id);
- }
- SupervisorCommand::TaskFork(args) => {
- eprintln!(
- "Fork task {} from checkpoint {} as '{}' with plan: {}",
- args.task_id, args.checkpoint, args.name, args.plan
- );
- eprintln!("CLI integration not yet implemented. Use the API directly:");
- eprintln!(" POST /api/v1/mesh/tasks/{}/fork", args.task_id);
- }
- SupervisorCommand::RewindConversation(args) => {
- eprintln!(
- "Rewind conversation for contract {} (by: {:?}, to: {:?}, rewind_code: {})",
- args.common.contract_id, args.by_messages, args.to_message, args.rewind_code
- );
- eprintln!("CLI integration not yet implemented. Use the API directly:");
- eprintln!(
- " POST /api/v1/contracts/{}/supervisor/conversation/rewind",
- args.common.contract_id
- );
- }
- SupervisorCommand::Complete(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- eprintln!("Marking contract {} as complete...", args.common.contract_id);
- match client.supervisor_complete(args.common.contract_id).await {
- Ok(_) => {
- println!(r#"{{"success": true, "message": "Contract marked as complete"}}"#);
- }
- Err(e) => {
- eprintln!("Error: {}", e);
- println!(r#"{{"success": false, "error": "{}"}}"#, e);
- std::process::exit(1);
- }
- }
- }
- SupervisorCommand::ResumeContract(args) => {
- let client = ApiClient::new(args.api_url, args.api_key)?;
- eprintln!("Resuming contract {}...", args.contract_id);
- let result = client.supervisor_resume_contract(args.contract_id).await?;
- println!("{}", serde_json::to_string(&serde_json::json!({
- "success": true,
- "message": "Contract resumed",
- "contract": result.0
- }))?);
- }
- SupervisorCommand::MarkDeliverable(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- eprintln!(
- "Marking deliverable '{}' as complete for contract {}...",
- args.deliverable_id, args.common.contract_id
- );
- let result = client
- .supervisor_mark_deliverable(
- args.common.contract_id,
- &args.deliverable_id,
- args.phase.as_deref(),
- )
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- }
-
- Ok(())
-}
-
-/// Run contract commands.
-async fn run_contract(
- cmd: ContractCommand,
-) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
- match cmd {
- ContractCommand::Status(args) => {
- let client = ApiClient::new(args.api_url, args.api_key)?;
- let result = client.contract_status(args.contract_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- ContractCommand::Checklist(args) => {
- let client = ApiClient::new(args.api_url, args.api_key)?;
- let result = client.contract_checklist(args.contract_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- ContractCommand::Goals(args) => {
- let client = ApiClient::new(args.api_url, args.api_key)?;
- let result = client.contract_goals(args.contract_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- ContractCommand::Files(args) => {
- let client = ApiClient::new(args.api_url, args.api_key)?;
- let result = client.contract_files(args.contract_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- ContractCommand::File(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- let result = client
- .contract_file(args.common.contract_id, args.file_id)
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- ContractCommand::Report(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- let result = client
- .contract_report(args.common.contract_id, &args.message, args.common.task_id)
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- ContractCommand::SuggestAction(args) => {
- let client = ApiClient::new(args.api_url, args.api_key)?;
- let result = client.contract_suggest_action(args.contract_id).await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- ContractCommand::CompletionAction(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- let files = args.files.map(|f| {
- f.split(',')
- .map(|s| s.trim().to_string())
- .collect::<Vec<_>>()
- });
- let result = client
- .contract_completion_action(
- args.common.contract_id,
- args.common.task_id,
- files,
- args.lines_added,
- args.lines_removed,
- args.code,
- )
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- ContractCommand::UpdateFile(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- // Read content from stdin
- let mut content = String::new();
- io::stdin().read_to_string(&mut content)?;
- let result = client
- .contract_update_file(args.common.contract_id, args.file_id, &content)
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- ContractCommand::CreateFile(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- // Read content from stdin
- let mut content = String::new();
- io::stdin().read_to_string(&mut content)?;
- let result = client
- .contract_create_file(args.common.contract_id, &args.name, &content)
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
- }
-
- Ok(())
-}
/// Run directive commands.
async fn run_directive(
@@ -1380,68 +1000,14 @@ async fn run_tui_loop(
app.ws_state = WsConnectionState::Disconnected;
}
}
- Action::PerformCreateContract { name, description, contract_type, repository_url } => {
- // Create the contract via API
- let req = CreateContractRequest {
- name: name.clone(),
- description: if description.is_empty() { None } else { Some(description) },
- contract_type: Some(contract_type),
- initial_phase: None,
- autonomous_loop: None,
- phase_guard: None,
- local_only: None,
- auto_merge_local: None,
- };
-
- match client.create_contract(req).await {
- Ok(result) => {
- let contract_name = result.0.get("name")
- .and_then(|v| v.as_str())
- .unwrap_or(&name)
- .to_string();
- let contract_id = result.0.get("id")
- .and_then(|v| v.as_str())
- .and_then(|s| uuid::Uuid::parse_str(s).ok());
-
- // Add repository if provided
- if let (Some(repo_url), Some(cid)) = (repository_url.as_ref(), contract_id) {
- if !repo_url.is_empty() {
- // Extract repo name from URL (e.g., "owner/repo" from GitHub URL)
- let repo_name = extract_repo_name(repo_url);
- match client.add_remote_repository(cid, &repo_name, repo_url, true).await {
- Ok(_) => {
- app.status_message = Some(format!(
- "Created contract '{}' with repository",
- contract_name
- ));
- }
- Err(e) => {
- app.status_message = Some(format!(
- "Created contract but failed to add repository: {}",
- e
- ));
- }
- }
- } else {
- app.status_message = Some(format!("Created contract: {}", contract_name));
- }
- } else {
- app.status_message = Some(format!("Created contract: {}", contract_name));
- }
-
- // Refresh the contracts list
- match load_contracts(client).await {
- Ok(items) => app.set_items(items),
- Err(e) => {
- let msg = app.status_message.take().unwrap_or_default();
- app.status_message = Some(format!("{} (refresh failed: {})", msg, e));
- }
- }
- }
- Err(e) => {
- app.status_message = Some(format!("Create failed: {}", e));
- }
- }
+ Action::PerformCreateContract { name: _, description: _, contract_type: _, repository_url: _ } => {
+ // Contracts removed in Phase 5 — directives are
+ // the only way to organise multi-task work now.
+ // The TUI's contract create form is dead code
+ // pending a wider TUI refresh.
+ app.status_message = Some(
+ "Contracts have been removed. Use directives instead.".to_string()
+ );
}
Action::LoadRepoSuggestions => {
// Load repository suggestions for the create form
diff --git a/makima/src/daemon/cli/contract.rs b/makima/src/daemon/cli/contract.rs
deleted file mode 100644
index a443b85..0000000
--- a/makima/src/daemon/cli/contract.rs
+++ /dev/null
@@ -1,87 +0,0 @@
-//! Contract subcommand - task-contract interaction commands.
-
-use clap::Args;
-use uuid::Uuid;
-
-/// Common arguments for contract commands.
-#[derive(Args, Debug, Clone)]
-pub struct ContractArgs {
- /// API URL
- #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp", global = true)]
- pub api_url: String,
-
- /// API key for authentication
- #[arg(long, env = "MAKIMA_API_KEY", global = true)]
- pub api_key: String,
-
- /// Current task ID (optional)
- #[arg(long, env = "MAKIMA_TASK_ID", global = true)]
- pub task_id: Option<Uuid>,
-
- /// Contract ID
- #[arg(long, env = "MAKIMA_CONTRACT_ID", global = true)]
- pub contract_id: Uuid,
-}
-
-/// Arguments for file command (get specific file).
-#[derive(Args, Debug)]
-pub struct FileArgs {
- #[command(flatten)]
- pub common: ContractArgs,
-
- /// File ID to retrieve
- pub file_id: Uuid,
-}
-
-/// Arguments for report command.
-#[derive(Args, Debug)]
-pub struct ReportArgs {
- #[command(flatten)]
- pub common: ContractArgs,
-
- /// Progress message
- pub message: String,
-}
-
-/// Arguments for completion-action command.
-#[derive(Args, Debug)]
-pub struct CompletionActionArgs {
- #[command(flatten)]
- pub common: ContractArgs,
-
- /// Comma-separated list of modified files
- #[arg(long)]
- pub files: Option<String>,
-
- /// Number of lines added
- #[arg(long, default_value = "0")]
- pub lines_added: i32,
-
- /// Number of lines removed
- #[arg(long, default_value = "0")]
- pub lines_removed: i32,
-
- /// Whether there are code changes
- #[arg(long)]
- pub code: bool,
-}
-
-/// Arguments for update-file command.
-#[derive(Args, Debug)]
-pub struct UpdateFileArgs {
- #[command(flatten)]
- pub common: ContractArgs,
-
- /// File ID to update
- pub file_id: Uuid,
-}
-
-/// Arguments for create-file command.
-#[derive(Args, Debug)]
-pub struct CreateFileArgs {
- #[command(flatten)]
- pub common: ContractArgs,
-
- /// Name of the new file
- pub name: String,
-}
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs
index 7affc55..b01c161 100644
--- a/makima/src/daemon/cli/mod.rs
+++ b/makima/src/daemon/cli/mod.rs
@@ -1,21 +1,17 @@
//! Command-line interface for the makima CLI.
pub mod config;
-pub mod contract;
pub mod daemon;
pub mod directive;
pub mod server;
-pub mod supervisor;
pub mod view;
use clap::{Parser, Subcommand};
pub use config::CliConfig;
-pub use contract::ContractArgs;
pub use daemon::DaemonArgs;
pub use directive::DirectiveArgs;
pub use server::ServerArgs;
-pub use supervisor::SupervisorArgs;
pub use view::ViewArgs;
/// Makima - unified CLI for server, daemon, and task management.
@@ -35,28 +31,11 @@ pub enum Commands {
/// Run the daemon (connect to server, manage tasks)
Daemon(DaemonArgs),
- /// Supervisor commands for contract orchestration
- #[command(subcommand)]
- Supervisor(SupervisorCommand),
-
- /// Contract commands for task-contract interaction
- #[command(subcommand)]
- Contract(ContractCommand),
-
/// Directive commands for DAG-based project management
#[command(subcommand)]
Directive(DirectiveCommand),
- /// Interactive TUI browser for contracts and tasks
- ///
- /// Provides a drill-down interface for browsing contracts, viewing their
- /// tasks, and streaming real-time task output.
- ///
- /// Keyboard shortcuts:
- /// ↑/k: Move up ↓/j: Move down Enter/l: Drill in
- /// Esc/h: Go back /: Search q: Quit
- /// e: Edit d: Delete c: cd to worktree
- /// n: New contract
+ /// Interactive TUI browser for directives and tasks
View(ViewArgs),
/// Configure CLI settings (API key, server URL)
@@ -86,121 +65,8 @@ pub enum ConfigCommand {
Path,
}
-/// Supervisor subcommands for contract orchestration.
-#[derive(Subcommand, Debug)]
-pub enum SupervisorCommand {
- /// List all tasks in the contract
- Tasks(SupervisorArgs),
-
- /// Get the task tree structure
- Tree(SupervisorArgs),
-
- /// Create and start a new task
- Spawn(supervisor::SpawnArgs),
-
- /// Wait for a task to complete
- Wait(supervisor::WaitArgs),
-
- /// Read a file from a task's worktree
- ReadFile(supervisor::ReadFileArgs),
-
- /// Create a git branch
- Branch(supervisor::BranchArgs),
-
- /// Merge a task's changes to a branch
- Merge(supervisor::MergeArgs),
-
- /// Create a pull request
- Pr(supervisor::PrArgs),
-
- /// View task diff
- Diff(supervisor::DiffArgs),
-
- /// Create a checkpoint
- Checkpoint(supervisor::CheckpointArgs),
-
- /// List checkpoints
- Checkpoints(SupervisorArgs),
-
- /// Get contract status
- Status(SupervisorArgs),
-
- /// Advance the contract to the next phase
- AdvancePhase(supervisor::AdvancePhaseArgs),
-
- /// Ask a question and wait for user feedback
- Ask(supervisor::AskArgs),
-
- /// Get individual task details
- Task(supervisor::GetTaskArgs),
-
- /// Get task output/claude log
- Output(supervisor::GetTaskOutputArgs),
-
- /// View task conversation history
- TaskHistory(supervisor::TaskHistoryArgs),
-
- /// List task checkpoints (with optional diff)
- TaskCheckpoints(supervisor::TaskCheckpointsArgs),
-
- /// Resume supervisor after interruption
- Resume(supervisor::ResumeArgs),
-
- /// Resume task from checkpoint
- TaskResumeFrom(supervisor::TaskResumeFromArgs),
-
- /// Rewind task code to checkpoint
- TaskRewind(supervisor::TaskRewindArgs),
-
- /// Fork task from historical point
- TaskFork(supervisor::TaskForkArgs),
-
- /// Rewind supervisor conversation
- RewindConversation(supervisor::ConversationRewindArgs),
-
- /// Mark the contract as complete and stop the supervisor
- Complete(supervisor::CompleteArgs),
-
- /// Resume a completed contract (reactivate it)
- ResumeContract(supervisor::ResumeContractArgs),
-
- /// Mark a deliverable as complete
- MarkDeliverable(supervisor::MarkDeliverableArgs),
-}
-
-/// Contract subcommands for task-contract interaction.
-#[derive(Subcommand, Debug)]
-pub enum ContractCommand {
- /// Get contract status
- Status(ContractArgs),
-
- /// Get the phase checklist
- Checklist(ContractArgs),
-
- /// Get contract goals
- Goals(ContractArgs),
-
- /// List contract files
- Files(ContractArgs),
-
- /// Get a specific file's content
- File(contract::FileArgs),
-
- /// Report progress on the contract
- Report(contract::ReportArgs),
-
- /// Get suggested next action
- SuggestAction(ContractArgs),
-
- /// Get completion recommendation
- CompletionAction(contract::CompletionActionArgs),
-
- /// Update a file (reads content from stdin)
- UpdateFile(contract::UpdateFileArgs),
-
- /// Create a new file (reads content from stdin)
- CreateFile(contract::CreateFileArgs),
-}
+// SupervisorCommand and ContractCommand removed in Phase 5 — contracts
+// subsystem is gone. See cli/contract.rs and cli/supervisor.rs deletion.
/// Directive subcommands for DAG-based project management.
#[derive(Subcommand, Debug)]
diff --git a/makima/src/daemon/cli/supervisor.rs b/makima/src/daemon/cli/supervisor.rs
deleted file mode 100644
index 82d3900..0000000
--- a/makima/src/daemon/cli/supervisor.rs
+++ /dev/null
@@ -1,448 +0,0 @@
-//! Supervisor subcommand - contract orchestration commands.
-
-use clap::Args;
-use uuid::Uuid;
-
-/// Common arguments for supervisor commands.
-#[derive(Args, Debug, Clone)]
-pub struct SupervisorArgs {
- /// API URL
- #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp")]
- pub api_url: String,
-
- /// API key for authentication
- #[arg(long, env = "MAKIMA_API_KEY")]
- pub api_key: String,
-
- /// Current task ID (optional) - the supervisor's own task ID
- #[arg(long, env = "MAKIMA_TASK_ID")]
- pub self_task_id: Option<Uuid>,
-
- /// Contract ID
- #[arg(long, env = "MAKIMA_CONTRACT_ID")]
- pub contract_id: Uuid,
-}
-
-/// Arguments for spawn command.
-#[derive(Args, Debug)]
-pub struct SpawnArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Name of the task
- #[arg(index = 1)]
- pub name: String,
-
- /// Plan/description for the task
- #[arg(index = 2)]
- pub plan: String,
-
- /// Parent task ID to branch from
- #[arg(long)]
- pub parent: Option<Uuid>,
-
- /// Checkpoint SHA to start from
- #[arg(long)]
- pub checkpoint: Option<String>,
-
- /// Repository URL (local path or remote URL). If not provided, will try to detect from current directory.
- #[arg(long)]
- pub repo: Option<String>,
-}
-
-/// Arguments for wait command.
-#[derive(Args, Debug)]
-pub struct WaitArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Task ID to wait for
- #[arg(index = 1)]
- pub task_id: Uuid,
-
- /// Timeout in seconds (total wait time)
- #[arg(index = 2, default_value = "300")]
- pub timeout: i32,
-
- /// Polling interval in seconds (how often to check task status via client-side polling)
- #[arg(long, default_value = "5")]
- pub poll_interval: u64,
-}
-
-/// Arguments for read-file command.
-#[derive(Args, Debug)]
-pub struct ReadFileArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Task ID to read from
- #[arg(index = 1)]
- pub task_id: Uuid,
-
- /// File path to read
- #[arg(index = 2)]
- pub file_path: String,
-}
-
-/// Arguments for branch command.
-#[derive(Args, Debug)]
-pub struct BranchArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Branch name to create
- #[arg(index = 1)]
- pub name: String,
-
- /// Reference (task ID or SHA) to branch from
- #[arg(long)]
- pub from: Option<String>,
-}
-
-/// Arguments for merge command.
-#[derive(Args, Debug)]
-pub struct MergeArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Task ID to merge
- #[arg(index = 1)]
- pub task_id: Uuid,
-
- /// Target branch to merge into
- #[arg(long)]
- pub to: Option<String>,
-
- /// Squash commits on merge
- #[arg(long)]
- pub squash: bool,
-}
-
-/// Arguments for pr command.
-#[derive(Args, Debug)]
-pub struct PrArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Branch name to create PR from (e.g., "makima/feature-name")
- #[arg(index = 1)]
- pub branch: String,
-
- /// PR title
- #[arg(long)]
- pub title: String,
-
- /// PR body/description
- #[arg(long)]
- pub body: Option<String>,
-}
-
-/// Arguments for diff command.
-#[derive(Args, Debug)]
-pub struct DiffArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Task ID to get diff for
- #[arg(index = 1)]
- pub task_id: Uuid,
-}
-
-/// Arguments for checkpoint command.
-#[derive(Args, Debug)]
-pub struct CheckpointArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Checkpoint message
- #[arg(index = 1)]
- pub message: String,
-}
-
-/// Arguments for ask command (ask user a question).
-#[derive(Args, Debug)]
-pub struct AskArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// The question to ask
- #[arg(index = 1)]
- pub question: String,
-
- /// Optional choices (comma-separated)
- #[arg(long)]
- pub choices: Option<String>,
-
- /// Context about what this relates to
- #[arg(long)]
- pub context: Option<String>,
-
- /// Timeout in seconds (default: 3600 = 1 hour)
- #[arg(long, default_value = "3600")]
- pub timeout: i32,
-
- /// Block indefinitely until user responds (no timeout)
- #[arg(long, default_value = "false")]
- pub phaseguard: bool,
-
- /// Allow selecting multiple choices (response will be comma-separated)
- #[arg(long, default_value = "false")]
- pub multi_select: bool,
-
- /// Non-blocking mode - returns immediately without waiting for response
- #[arg(long, default_value = "false")]
- pub non_blocking: bool,
-
- /// Question type (general, phase_confirmation, contract_complete)
- #[arg(long, default_value = "general")]
- pub question_type: String,
-}
-
-/// Arguments for status command (get contract status including phase).
-#[derive(Args, Debug)]
-pub struct StatusArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-}
-
-/// Arguments for advance-phase command.
-#[derive(Args, Debug)]
-pub struct AdvancePhaseArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// The phase to advance to (specify, plan, execute, review)
- #[arg(index = 1)]
- pub phase: String,
-
- /// Confirm the phase transition (required when phase_guard is enabled).
- /// Without this flag, the command will return deliverables for review.
- #[arg(long, short = 'y')]
- pub confirmed: bool,
-}
-
-/// Arguments for mark-deliverable command.
-#[derive(Args, Debug)]
-pub struct MarkDeliverableArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// The deliverable ID to mark as complete (e.g., 'plan-document', 'pull-request', 'research-notes')
- #[arg(index = 1)]
- pub deliverable_id: String,
-
- /// Phase the deliverable belongs to. Defaults to current contract phase if not specified.
- #[arg(long)]
- pub phase: Option<String>,
-}
-
-/// Arguments for task command (get individual task details).
-#[derive(Args, Debug)]
-pub struct GetTaskArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Task ID to get details for
- #[arg(index = 1, id = "target_task_id")]
- pub target_task_id: Uuid,
-}
-
-/// Arguments for output command (get task output/claude log).
-#[derive(Args, Debug)]
-pub struct GetTaskOutputArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Task ID to get output for
- #[arg(index = 1, id = "target_task_id")]
- pub target_task_id: Uuid,
-}
-
-// ============================================================================
-// History Command Args
-// ============================================================================
-
-/// Arguments for task-history command.
-#[derive(Args, Debug)]
-pub struct TaskHistoryArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Task ID to view history for
- #[arg(index = 1)]
- pub task_id: Uuid,
-
- /// Include tool calls in output
- #[arg(long, default_value = "true")]
- pub tool_calls: bool,
-
- /// Maximum messages to return
- #[arg(long)]
- pub limit: Option<i32>,
-
- /// Output format (table, json, chat)
- #[arg(long, default_value = "chat")]
- pub format: String,
-}
-
-/// Arguments for task-checkpoints command (with optional diff).
-#[derive(Args, Debug)]
-pub struct TaskCheckpointsArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Task ID to list checkpoints for
- #[arg(index = 1)]
- pub task_id: Uuid,
-
- /// Include diff summary
- #[arg(long)]
- pub with_diff: bool,
-}
-
-// ============================================================================
-// Resume Command Args
-// ============================================================================
-
-/// Arguments for resume command.
-#[derive(Args, Debug)]
-pub struct ResumeArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Resume mode: continue, restart_phase, from_checkpoint
- #[arg(long, default_value = "continue")]
- pub mode: String,
-
- /// Checkpoint ID (required for from_checkpoint mode)
- #[arg(long)]
- pub checkpoint: Option<Uuid>,
-
- /// Additional context to inject
- #[arg(long)]
- pub context: Option<String>,
-}
-
-/// Arguments for task-resume-from command.
-#[derive(Args, Debug)]
-pub struct TaskResumeFromArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Source task ID
- #[arg(index = 1)]
- pub task_id: Uuid,
-
- /// Checkpoint number to resume from
- #[arg(long)]
- pub checkpoint: i32,
-
- /// Plan for the new task
- #[arg(long)]
- pub plan: String,
-
- /// Name for the new task
- #[arg(long)]
- pub name: Option<String>,
-}
-
-// ============================================================================
-// Rewind Command Args
-// ============================================================================
-
-/// Arguments for task-rewind command.
-#[derive(Args, Debug)]
-pub struct TaskRewindArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Task ID to rewind
- #[arg(index = 1)]
- pub task_id: Uuid,
-
- /// Checkpoint number to rewind to
- #[arg(long)]
- pub checkpoint: i32,
-
- /// Preserve mode: discard, create_branch, stash
- #[arg(long, default_value = "create_branch")]
- pub preserve: String,
-
- /// Branch name (for create_branch mode)
- #[arg(long)]
- pub branch_name: Option<String>,
-}
-
-/// Arguments for task-fork command.
-#[derive(Args, Debug)]
-pub struct TaskForkArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Source task ID
- #[arg(index = 1)]
- pub task_id: Uuid,
-
- /// Checkpoint number to fork from
- #[arg(long)]
- pub checkpoint: i32,
-
- /// Name for the new task
- #[arg(long)]
- pub name: String,
-
- /// Plan for the new task
- #[arg(long)]
- pub plan: String,
-
- /// Include conversation history
- #[arg(long, default_value = "true")]
- pub include_conversation: bool,
-}
-
-/// Arguments for rewind-conversation command.
-#[derive(Args, Debug)]
-pub struct ConversationRewindArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-
- /// Number of messages to rewind
- #[arg(long)]
- pub by_messages: Option<i32>,
-
- /// Message ID to rewind to
- #[arg(long)]
- pub to_message: Option<String>,
-
- /// Also rewind code to matching checkpoint
- #[arg(long)]
- pub rewind_code: bool,
-}
-
-/// Arguments for complete command (mark contract as complete).
-#[derive(Args, Debug)]
-pub struct CompleteArgs {
- #[command(flatten)]
- pub common: SupervisorArgs,
-}
-
-// ============================================================================
-// Resume Contract Command Args
-// ============================================================================
-
-/// Arguments for resume-contract command (reactivate a completed contract).
-#[derive(Args, Debug)]
-pub struct ResumeContractArgs {
- /// API URL
- #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp")]
- pub api_url: String,
-
- /// API key for authentication
- #[arg(long, env = "MAKIMA_API_KEY")]
- pub api_key: String,
-
- /// Contract ID to resume
- #[arg(index = 1)]
- pub contract_id: Uuid,
-}
-
diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs
index 13f0862..e15608b 100644
--- a/makima/src/daemon/mod.rs
+++ b/makima/src/daemon/mod.rs
@@ -23,6 +23,6 @@ pub mod tui;
pub mod worktree;
pub mod ws;
-pub use cli::{Cli, Commands, ContractCommand, SupervisorCommand, ViewArgs};
+pub use cli::{Cli, Commands, ViewArgs};
pub use config::DaemonConfig;
pub use error::{DaemonError, Result};
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 1fe6e35..44af939 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -2721,6 +2721,12 @@ pub struct Directive {
pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
+ /// True for the per-owner scratchpad directive. Auto-created on first
+ /// orphan-task creation. Hidden from the directive list; surfaced to
+ /// users via the sidebar's `tmp/` folder. Tasks attached to a tmp
+ /// directive are auto-deleted after 30 days.
+ #[serde(default)]
+ pub is_tmp: bool,
}
/// A historical record of a directive goal change.
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index b41c74c..f91bfaa 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -1189,6 +1189,86 @@ pub async fn list_tasks_for_owner(
.await
}
+// =============================================================================
+// Tmp directive — per-owner scratchpad
+// =============================================================================
+
+/// Get the owner's tmp directive, creating it on the fly if absent. Idempotent
+/// thanks to the partial unique index on (owner_id) WHERE is_tmp.
+///
+/// We try an INSERT first with ON CONFLICT DO NOTHING; if a row was inserted
+/// it's returned, otherwise we fall back to a SELECT for the row some other
+/// request just created (or one that already existed).
+pub async fn get_or_create_tmp_directive(
+ pool: &PgPool,
+ owner_id: Uuid,
+) -> Result<Directive, sqlx::Error> {
+ // Try insert first. RETURNING fires only if a row was actually written;
+ // if the partial unique index trips (a tmp directive already exists)
+ // we get None and fall through to the SELECT.
+ let inserted = sqlx::query_as::<_, Directive>(
+ r#"
+ INSERT INTO directives
+ (owner_id, title, goal, status, reconcile_mode, is_tmp)
+ VALUES
+ ($1, 'tmp', '', 'idle', 'auto', true)
+ ON CONFLICT DO NOTHING
+ RETURNING *
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await?;
+
+ if let Some(d) = inserted {
+ return Ok(d);
+ }
+
+ // Pre-existing or just-created-by-someone-else: fetch.
+ sqlx::query_as::<_, Directive>(
+ r#"SELECT * FROM directives WHERE owner_id = $1 AND is_tmp = true LIMIT 1"#,
+ )
+ .bind(owner_id)
+ .fetch_one(pool)
+ .await
+}
+
+/// Find every tmp directive (across owners). Used by the 30-day expiry
+/// sweep — we need to know which directives are scratchpads so we know
+/// which tasks to age out.
+pub async fn list_all_tmp_directives(
+ pool: &PgPool,
+) -> Result<Vec<Directive>, sqlx::Error> {
+ sqlx::query_as::<_, Directive>(
+ r#"SELECT * FROM directives WHERE is_tmp = true"#,
+ )
+ .fetch_all(pool)
+ .await
+}
+
+/// Delete tasks attached to a tmp directive that are older than 30 days.
+/// Returns the number of rows deleted (informational; we log it).
+///
+/// We only sweep top-level tasks (parent_task_id IS NULL) — subtasks die
+/// when their parent dies via the FK cascade.
+pub async fn delete_expired_tmp_tasks(
+ pool: &PgPool,
+ tmp_directive_id: Uuid,
+) -> Result<u64, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM tasks
+ WHERE directive_id = $1
+ AND parent_task_id IS NULL
+ AND created_at < NOW() - INTERVAL '30 days'
+ "#,
+ )
+ .bind(tmp_directive_id)
+ .execute(pool)
+ .await?;
+ Ok(result.rows_affected())
+}
+
/// List ephemeral tasks attached to a directive — tasks with `directive_id`
/// set but no `directive_step_id`. These are the "spinoff" tasks the user
/// created via the directive folder context menu, distinct from
@@ -1223,14 +1303,15 @@ pub async fn list_ephemeral_directive_tasks_for_owner(
.await
}
-/// List "orphan" top-level tasks for an owner — tasks that are NOT attached
-/// to a directive and NOT a subtask of another task. These surface in the
-/// document-mode sidebar under a top-level `tmp/` folder. Hidden tasks
-/// excluded.
-pub async fn list_orphan_tasks_for_owner(
+/// List top-level tasks attached to the owner's tmp directive. These are
+/// the scratchpad / orphan tasks surfaced under the sidebar's `tmp/`
+/// folder. Auto-creates the tmp directive if it doesn't exist yet so the
+/// caller never has to handle "no tmp directive".
+pub async fn list_tmp_tasks_for_owner(
pool: &PgPool,
owner_id: Uuid,
) -> Result<Vec<TaskSummary>, sqlx::Error> {
+ let tmp = get_or_create_tmp_directive(pool, owner_id).await?;
sqlx::query_as::<_, TaskSummary>(
r#"
SELECT
@@ -1243,13 +1324,14 @@ pub async fn list_orphan_tasks_for_owner(
FROM tasks t
LEFT JOIN contracts c ON t.contract_id = c.id
WHERE t.owner_id = $1
+ AND t.directive_id = $2
AND t.parent_task_id IS NULL
- AND t.directive_id IS NULL
AND COALESCE(t.hidden, false) = false
ORDER BY t.priority DESC, t.created_at DESC
"#,
)
.bind(owner_id)
+ .bind(tmp.id)
.fetch_all(pool)
.await
}
@@ -5066,7 +5148,9 @@ pub async fn get_directive_with_steps_for_owner(
}
}
-/// List all directives for an owner with step counts.
+/// List all directives for an owner with step counts. Excludes the per-owner
+/// tmp directive (the scratchpad surface; surfaced via the sidebar's
+/// dedicated `tmp/` folder, not the regular directive list).
pub async fn list_directives_for_owner(
pool: &PgPool,
owner_id: Uuid,
@@ -5093,6 +5177,7 @@ pub async fn list_directives_for_owner(
WHERE directive_id = d.id
) s ON true
WHERE d.owner_id = $1
+ AND d.is_tmp = false
ORDER BY d.created_at DESC
"#,
)
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs
index 1e004bf..80d8172 100644
--- a/makima/src/orchestration/directive.rs
+++ b/makima/src/orchestration/directive.rs
@@ -18,11 +18,22 @@ use crate::server::state::{DaemonCommand, SharedState};
pub struct DirectiveOrchestrator {
pool: PgPool,
state: SharedState,
+ /// Last time we ran the tmp-task expiry sweep. Throttled to once an
+ /// hour so the deletion query doesn't run on every 15-second tick.
+ last_tmp_sweep: std::time::Instant,
}
impl DirectiveOrchestrator {
pub fn new(pool: PgPool, state: SharedState) -> Self {
- Self { pool, state }
+ Self {
+ pool,
+ state,
+ // Initialise to 1 hour ago so the first tick after startup runs
+ // the sweep immediately — clears any tasks that aged out while
+ // the server was down.
+ last_tmp_sweep: std::time::Instant::now()
+ - std::time::Duration::from_secs(3600),
+ }
}
/// Run one orchestration tick — called every 15s.
@@ -42,6 +53,14 @@ impl DirectiveOrchestrator {
if let Err(e) = self.phase_completion().await {
tracing::warn!(error = %e, "Directive phase_completion failed");
}
+ // Throttled to hourly — the actual delete is cheap (indexed
+ // partial scan) but we don't want to log a sweep every 15s.
+ if self.last_tmp_sweep.elapsed() >= std::time::Duration::from_secs(3600) {
+ self.last_tmp_sweep = std::time::Instant::now();
+ if let Err(e) = self.phase_tmp_expiry().await {
+ tracing::warn!(error = %e, "Directive phase_tmp_expiry failed");
+ }
+ }
Ok(())
}
@@ -100,40 +119,18 @@ impl DirectiveOrchestrator {
let steps = repository::get_ready_steps_for_dispatch(&self.pool).await?;
for step in steps {
- // If the step has a contract_type, create a contract instead of a standalone task
+ // contract_type used to spawn a heavyweight contract+supervisor
+ // for a step. The contracts subsystem has been removed (Phase 5);
+ // we now treat any contract-backed step as a plain standalone
+ // task. The column itself is left in place for one more release
+ // so old data still reads cleanly, but it has no effect.
if step.contract_type.is_some() {
- tracing::info!(
+ tracing::warn!(
step_id = %step.step_id,
directive_id = %step.directive_id,
- step_name = %step.step_name,
contract_type = ?step.contract_type,
- "Spawning contract for contract-backed step"
+ "Step has legacy contract_type; falling back to standalone task spawn"
);
-
- match self
- .spawn_step_contract(
- step.step_id,
- step.directive_id,
- step.owner_id,
- &step.step_name,
- step.step_description.as_deref(),
- step.task_plan.as_deref(),
- step.contract_type.as_deref().unwrap_or("simple"),
- step.repository_url.as_deref(),
- step.base_branch.as_deref(),
- )
- .await
- {
- Ok(()) => {}
- Err(e) => {
- tracing::warn!(
- step_id = %step.step_id,
- error = %e,
- "Failed to spawn contract for step"
- );
- }
- }
- continue;
}
tracing::info!(
@@ -647,141 +644,9 @@ impl DirectiveOrchestrator {
Ok(())
}
- /// Spawn a contract for a contract-backed step.
- /// Creates a contract, adds the directive's repository to it, links it to the step,
- /// creates a supervisor task, and marks the step as running.
- async fn spawn_step_contract(
- &self,
- step_id: Uuid,
- directive_id: Uuid,
- owner_id: Uuid,
- step_name: &str,
- step_description: Option<&str>,
- task_plan: Option<&str>,
- contract_type: &str,
- repo_url: Option<&str>,
- base_branch: Option<&str>,
- ) -> Result<(), anyhow::Error> {
- // Build contract description from step info
- let description = match (step_description, task_plan) {
- (Some(desc), Some(plan)) => Some(format!("{}\n\n{}", desc, plan)),
- (Some(desc), None) => Some(desc.to_string()),
- (None, Some(plan)) => Some(plan.to_string()),
- (None, None) => None,
- };
-
- // Create the contract
- let contract_req = CreateContractRequest {
- name: step_name.to_string(),
- description,
- contract_type: Some(contract_type.to_string()),
- template_id: None,
- initial_phase: None,
- autonomous_loop: Some(true),
- phase_guard: None,
- local_only: None,
- auto_merge_local: None,
- };
-
- let contract = repository::create_contract_for_owner(&self.pool, owner_id, contract_req).await?;
-
- tracing::info!(
- step_id = %step_id,
- contract_id = %contract.id,
- contract_type = %contract.contract_type,
- "Created contract for directive step"
- );
-
- // Link the contract to the step
- repository::link_contract_to_step(&self.pool, step_id, contract.id).await?;
-
- // Add the directive's repository to the contract (if available)
- if let Some(url) = repo_url {
- if let Err(e) = repository::add_remote_repository(
- &self.pool,
- contract.id,
- step_name,
- url,
- true, // is_primary
- )
- .await
- {
- tracing::warn!(
- contract_id = %contract.id,
- error = %e,
- "Failed to add repository to contract — continuing without it"
- );
- }
- }
-
- // Create supervisor task for the contract (following the pattern from contract handlers)
- let supervisor_name = format!("{} Supervisor", step_name);
- let supervisor_plan = format!(
- "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}",
- step_name,
- contract.description.as_deref().unwrap_or("No description provided.")
- );
-
- let supervisor_req = CreateTaskRequest {
- name: supervisor_name.clone(),
- description: None,
- plan: supervisor_plan.clone(),
- repository_url: repo_url.map(|s| s.to_string()),
- base_branch: base_branch.map(|s| s.to_string()),
- target_branch: None,
- parent_task_id: None,
- contract_id: Some(contract.id),
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: None,
- copy_files: None,
- is_supervisor: true,
- checkpoint_sha: None,
- priority: 0,
- merge_mode: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None,
- directive_id: Some(directive_id),
- directive_step_id: Some(step_id),
- };
-
- let supervisor_task = repository::create_task_for_owner(&self.pool, owner_id, supervisor_req).await?;
-
- tracing::info!(
- contract_id = %contract.id,
- supervisor_task_id = %supervisor_task.id,
- "Created supervisor task for contract-backed step"
- );
-
- // Link supervisor task to contract
- let update_req = UpdateContractRequest {
- supervisor_task_id: Some(supervisor_task.id),
- version: Some(contract.version),
- ..Default::default()
- };
- if let Err(e) = repository::update_contract_for_owner(&self.pool, contract.id, owner_id, update_req).await {
- tracing::warn!(
- contract_id = %contract.id,
- error = %e,
- "Failed to link supervisor task to contract"
- );
- }
-
- // Try to dispatch the supervisor task to a daemon
- if self
- .try_dispatch_task(supervisor_task.id, owner_id, &supervisor_task.name, &supervisor_task.plan, supervisor_task.version)
- .await
- {
- repository::set_step_running(&self.pool, step_id).await?;
- } else {
- // Even if dispatch fails, mark step as running since contract is created.
- // The supervisor task will be retried by the pending task retry logic.
- repository::set_step_running(&self.pool, step_id).await?;
- }
-
- Ok(())
- }
+ // spawn_step_contract was removed in Phase 5 — the contracts subsystem
+ // is gone. Step rows with `contract_type` set are now silently treated
+ // as standalone tasks (see the warn! in phase_execution).
/// Try to dispatch a task to an available daemon. Returns true if dispatched.
async fn try_dispatch_task(
@@ -877,6 +742,40 @@ impl DirectiveOrchestrator {
false
}
+ /// Hourly sweep — delete top-level tasks attached to any tmp directive
+ /// that are older than 30 days. Per-owner; no global cap. Subtasks die
+ /// via the FK cascade.
+ async fn phase_tmp_expiry(&self) -> Result<(), anyhow::Error> {
+ let tmps = repository::list_all_tmp_directives(&self.pool).await?;
+ let mut total_deleted: u64 = 0;
+ for d in tmps {
+ match repository::delete_expired_tmp_tasks(&self.pool, d.id).await {
+ Ok(n) => {
+ if n > 0 {
+ tracing::info!(
+ directive_id = %d.id,
+ owner_id = %d.owner_id,
+ deleted = n,
+ "Expired tmp tasks deleted (>30 days old)"
+ );
+ total_deleted += n;
+ }
+ }
+ Err(e) => {
+ tracing::warn!(
+ directive_id = %d.id,
+ error = %e,
+ "Failed to expire tmp tasks for owner"
+ );
+ }
+ }
+ }
+ if total_deleted > 0 {
+ tracing::info!(total = total_deleted, "Tmp expiry sweep completed");
+ }
+ Ok(())
+ }
+
/// Phase 5: Completion — spawn PR-creation tasks for idle directives.
async fn phase_completion(&self) -> Result<(), anyhow::Error> {
// Part 1: Spawn completion tasks for idle directives
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs
deleted file mode 100644
index 5d8ab3e..0000000
--- a/makima/src/server/handlers/contract_chat.rs
+++ /dev/null
@@ -1,3183 +0,0 @@
-//! Chat endpoint for LLM-powered contract management.
-//!
-//! This handler provides an agentic loop for managing contracts: creating tasks,
-//! adding files, managing repositories, and handling phase transitions.
-
-use axum::{
- extract::{Path, State},
- http::StatusCode,
- response::IntoResponse,
- Json,
-};
-use serde::{Deserialize, Serialize};
-use serde_json::json;
-use utoipa::ToSchema;
-use uuid::Uuid;
-
-use crate::db::{
- models::{
- ContractChatHistoryResponse, ContractWithRelations, CreateTaskRequest, UpdateFileRequest,
- },
- repository,
-};
-use crate::llm::{
- analyze_task_output, body_to_markdown, format_checklist_markdown,
- format_parsed_tasks, parse_tasks_from_breakdown,
- claude::{self, ClaudeClient, ClaudeError, ClaudeModel},
- groq::{GroqClient, GroqError, Message, ToolCallResponse},
- parse_contract_tool_call, ContractToolRequest,
- LlmModel, TaskInfo, ToolCall, ToolResult, UserQuestion, CONTRACT_TOOLS,
- format_transcript_for_analysis, calculate_speaker_stats,
- build_analysis_prompt, parse_analysis_response,
-};
-use crate::server::auth::Authenticated;
-use crate::server::state::{DaemonCommand, SharedState};
-
-/// Maximum number of tool-calling rounds to prevent infinite loops
-const MAX_TOOL_ROUNDS: usize = 30;
-
-#[derive(Debug, Clone, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ContractChatHistoryMessage {
- /// Role: "user" or "assistant"
- pub role: String,
- /// Message content
- pub content: String,
-}
-
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ContractChatRequest {
- /// The user's message/instruction
- pub message: String,
- /// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq"
- #[serde(default)]
- pub model: Option<String>,
- /// Optional conversation history for context continuity
- #[serde(default)]
- pub history: Option<Vec<ContractChatHistoryMessage>>,
-}
-
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ContractChatResponse {
- /// The LLM's response message
- pub response: String,
- /// Tool calls that were executed
- pub tool_calls: Vec<ContractToolCallInfo>,
- /// Questions pending user answers (pauses conversation)
- #[serde(skip_serializing_if = "Option::is_none")]
- pub pending_questions: Option<Vec<UserQuestion>>,
-}
-
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ContractToolCallInfo {
- pub name: String,
- pub result: ToolResult,
-}
-
-/// Enum to hold LLM clients
-enum LlmClient {
- Groq(GroqClient),
- Claude(ClaudeClient),
-}
-
-/// Unified result from LLM call
-struct LlmResult {
- content: Option<String>,
- tool_calls: Vec<ToolCall>,
- raw_tool_calls: Vec<ToolCallResponse>,
- finish_reason: String,
-}
-
-/// Helper to get contract with all relations
-async fn get_contract_with_relations(
- pool: &sqlx::PgPool,
- contract_id: Uuid,
- owner_id: Uuid,
-) -> Result<Option<ContractWithRelations>, sqlx::Error> {
- let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await? {
- Some(c) => c,
- None => return Ok(None),
- };
-
- let repositories = repository::list_contract_repositories(pool, contract_id)
- .await
- .unwrap_or_default();
-
- let files = repository::list_files_in_contract(pool, contract_id, owner_id)
- .await
- .unwrap_or_default();
-
- let tasks = repository::list_tasks_in_contract(pool, contract_id, owner_id)
- .await
- .unwrap_or_default();
-
- Ok(Some(ContractWithRelations {
- contract,
- repositories,
- files,
- tasks,
- }))
-}
-
-/// Chat with a contract using LLM tool calling for management
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/chat",
- request_body = ContractChatRequest,
- responses(
- (status = 200, description = "Chat completed successfully", body = ContractChatResponse),
- (status = 401, description = "Unauthorized"),
- (status = 404, description = "Contract not found"),
- (status = 500, description = "Internal server error")
- ),
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn contract_chat_handler(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(contract_id): Path<Uuid>,
- Json(request): Json<ContractChatRequest>,
-) -> impl IntoResponse {
- // Check if database is configured
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "Database not configured" })),
- )
- .into_response();
- };
-
- // Get the contract (scoped by owner)
- let contract = match get_contract_with_relations(pool, contract_id, auth.owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(json!({ "error": "Contract not found" })),
- )
- .into_response();
- }
- Err(e) => {
- tracing::error!("Database error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Database error: {}", e) })),
- )
- .into_response();
- }
- };
-
- // Parse model selection (default to Claude Sonnet)
- let model = request
- .model
- .as_ref()
- .and_then(|m| LlmModel::from_str(m))
- .unwrap_or(LlmModel::ClaudeSonnet);
-
- tracing::info!("Contract chat using LLM model: {:?}", model);
-
- // Initialize the appropriate LLM client
- let llm_client = match model {
- LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) {
- Ok(client) => LlmClient::Claude(client),
- Err(ClaudeError::MissingApiKey) => {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
- )
- .into_response();
- }
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Claude client error: {}", e) })),
- )
- .into_response();
- }
- },
- LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) {
- Ok(client) => LlmClient::Claude(client),
- Err(ClaudeError::MissingApiKey) => {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
- )
- .into_response();
- }
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Claude client error: {}", e) })),
- )
- .into_response();
- }
- },
- LlmModel::GroqKimi => match GroqClient::from_env() {
- Ok(client) => LlmClient::Groq(client),
- Err(GroqError::MissingApiKey) => {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "GROQ_API_KEY not configured" })),
- )
- .into_response();
- }
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Groq client error: {}", e) })),
- )
- .into_response();
- }
- },
- };
-
- // Build contract context
- let contract_context = build_contract_context(&contract);
-
- // Build system prompt for contract management
- let system_prompt = format!(
- r#"You are an intelligent contract management agent. You guide users through the contract lifecycle from research to completion, helping them organize work, create documentation, set up repositories, and execute tasks.
-
-## Your Capabilities
-You have access to tools for:
-- **Query**: get_contract_status, list_contract_files, list_contract_tasks, list_contract_repositories, read_file
-- **File Management**: create_file_from_template, create_empty_file, list_available_templates
-- **Task Management**: create_contract_task, delegate_content_generation, start_task
-- **Phase Management**: get_phase_info, suggest_phase_transition, advance_phase
-- **Repository Management**: list_daemon_directories, add_repository, set_primary_repository
-- **Interactive**: ask_user
-
-## Content Generation Deferral
-When asked to write substantial content, fill templates, or generate documentation:
-- **Use delegate_content_generation** to create a task for the content generation
-- This delegates the work to a task agent that can do more thorough research and writing
-
-**Use delegation for:**
-- Filling in template content with real data
-- Writing documentation based on requirements
-- Generating user stories or specifications
-- Creating detailed design documents
-- Any substantial writing that requires research or analysis
-
-**Direct actions (no delegation needed):**
-- Listing files/tasks/repos
-- Reading files
-- Phase transitions
-- Creating empty files or templates
-- Simple queries and status checks
-- Asking user questions
-
-## Contract Lifecycle Phases
-
-### 1. RESEARCH Phase
-**Purpose**: Gather information and understand the problem space
-**Key Activities**:
-- Conduct user research and interviews
-- Analyze competitors and existing solutions
-- Document findings and insights
-- Identify opportunities and constraints
-**Suggested Actions**:
-- Create a "Research Notes" document to capture findings
-- Create a "Competitor Analysis" document
-- When research is complete, suggest transitioning to Specify phase
-
-### 2. SPECIFY Phase
-**Purpose**: Define what needs to be built
-**Key Activities**:
-- Write clear requirements
-- Create user stories with acceptance criteria
-- Define scope and constraints
-- Document technical constraints
-**Suggested Actions**:
-- Create a "Requirements" document
-- Create "User Stories" with acceptance criteria
-- When specifications are clear, suggest transitioning to Plan phase
-
-### 3. PLAN Phase
-**Purpose**: Design the solution and break down the work
-**Key Activities**:
-- Design system architecture
-- Create technical specifications
-- Break work into implementable tasks
-- Set up repositories for development
-**Suggested Actions**:
-- Create an "Architecture" document
-- Create a "Task Breakdown" document
-- **IMPORTANT**: Help set up a repository if not already configured
-- When planning is complete and a repository is set, suggest transitioning to Execute phase
-
-### 4. EXECUTE Phase
-**Purpose**: Implement the solution
-**Key Activities**:
-- Create and run tasks to implement features
-- Write and run tests
-- Track progress
-- Document implementation decisions
-**Suggested Actions**:
-- Create tasks based on the task breakdown
-- Monitor task progress and help resolve blockers
-- When all tasks are complete, suggest transitioning to Review phase
-
-### 5. REVIEW Phase
-**Purpose**: Validate and document the completed work
-**Key Activities**:
-- Review completed work
-- Create release notes
-- Conduct retrospective
-- Document learnings
-**Suggested Actions**:
-- Create a "Release Notes" document
-- Create a "Retrospective" document
-- Help mark the contract as complete when review is done
-
-## Current Contract
-{contract_context}
-
-## Proactive Guidance
-
-### Repository Setup (Critical for Plan/Execute phases)
-When the user wants to add a local repository or set up for execution:
-1. **First call list_daemon_directories** to get available paths from connected agents
-2. Present the suggested directories to the user
-3. Ask which path they want to use, or let them specify a custom path
-4. Then call add_repository with the chosen path
-
-Example flow:
-```
-User: "Set up a repository for this contract"
-You: Call list_daemon_directories first
-You: "I found these directories from your connected agent:
- - /Users/alice/projects (Working Directory)
- - /Users/alice/.makima/home (Makima Home)
- Which would you like to use, or provide a custom path?"
-```
-
-### Phase Transitions
-- Phases progress in order: research -> specify -> plan -> execute -> review
-- You can ONLY advance forward one step at a time to the NEXT phase
-- ALWAYS use suggest_phase_transition FIRST to get the exact nextPhase value
-- Then use advance_phase with that exact nextPhase value
-- Example: If currentPhase is "specify", nextPhase will be "plan" - use advance_phase with new_phase="plan"
-- NEVER suggest advancing to the same phase the contract is already in
-
-### New Users
-When a new contract is created or the user seems unsure:
-1. Explain the current phase and what should be done
-2. Suggest creating appropriate documents
-3. Guide them toward the next milestone
-
-## Agentic Behavior Guidelines
-
-### 1. Understand Before Acting
-- For complex requests, first gather information about the contract's current state
-- Use get_contract_status or list_contract_files to understand what exists
-- Consider the current phase when suggesting actions
-
-### 2. Phase-Appropriate Suggestions
-- Suggest templates and actions appropriate for the current phase
-- When creating files, prefer templates that match the contract's phase
-- Advise when the contract might be ready for the next phase
-
-### 3. Help Plan Work
-- When asked to plan work, read existing files to understand context
-- Suggest creating tasks based on requirements or plans in files
-- Offer to create task breakdowns from design documents
-
-### 4. Repository Management
-- When adding local repositories, ALWAYS use list_daemon_directories first to get suggestions
-- This provides the user with valid paths from their connected agents
-- Don't ask users to manually type paths when suggestions are available
-
-### 5. Task Creation and Execution
-- When creating tasks, derive plans from existing contract files when possible
-- Use the contract's primary repository for tasks by default
-- Create clear, actionable task plans
-- After creating a task, you can use **start_task** to immediately begin execution
-- A daemon must be connected for start_task to work
-
-### 6. Be Proactive but Efficient
-- Guide users through the contract flow
-- Don't over-analyze simple requests
-- Use the minimum number of tool calls needed
-- Provide clear summaries of actions taken
-
-## Important Notes
-- This contract's ID is: {contract_id}
-- All operations are scoped to this contract
-- When creating tasks or files, they are automatically associated with this contract"#,
- contract_context = contract_context,
- contract_id = contract_id
- );
-
- // Run the agentic loop
- run_contract_agentic_loop(
- pool,
- &state,
- &llm_client,
- system_prompt,
- &request,
- contract_id,
- auth.owner_id,
- )
- .await
-}
-
-fn build_contract_context(contract: &crate::db::models::ContractWithRelations) -> String {
- let c = &contract.contract;
- let mut context = format!(
- "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 {
- context.push_str(&format!("Description: {}\n", desc));
- }
-
- // Get completed deliverables for the current phase
- let completed_deliverables = c.get_completed_deliverables(&c.phase);
-
- // Build task infos for checklist
- let task_infos: Vec<TaskInfo> = contract.tasks.iter().map(|t| TaskInfo {
- name: t.name.clone(),
- status: t.status.clone(),
- }).collect();
-
- let has_repository = !contract.repositories.is_empty();
- let phase_checklist = crate::llm::get_phase_checklist_for_type(&c.phase, &completed_deliverables, &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
- let deliverable_check = crate::llm::check_deliverables_met(
- &c.phase,
- &c.contract_type,
- &completed_deliverables,
- &task_infos,
- has_repository,
- );
-
- // 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() {
- for file in contract.files.iter().take(5) {
- let phase_label = file.contract_phase.as_deref().unwrap_or("none");
- context.push_str(&format!("- {} [{}] (ID: {})\n", file.name, phase_label, file.id));
- }
- if contract.files.len() > 5 {
- context.push_str(&format!("... and {} more\n", contract.files.len() - 5));
- }
- }
-
- // Tasks summary
- context.push_str(&format!("\n### Tasks ({} total)\n", contract.tasks.len()));
- if !contract.tasks.is_empty() {
- let pending = contract.tasks.iter().filter(|t| t.status == "pending").count();
- let running = contract.tasks.iter().filter(|t| t.status == "running").count();
- let done = contract.tasks.iter().filter(|t| t.status == "done").count();
- context.push_str(&format!("{} pending, {} running, {} done\n", pending, running, done));
- for task in contract.tasks.iter().take(5) {
- context.push_str(&format!("- {} ({}) - ID: {}\n", task.name, task.status, task.id));
- }
- if contract.tasks.len() > 5 {
- context.push_str(&format!("... and {} more\n", contract.tasks.len() - 5));
- }
- }
-
- // Repositories summary
- context.push_str(&format!("\n### Repositories ({} total)\n", contract.repositories.len()));
- if !contract.repositories.is_empty() {
- for repo in &contract.repositories {
- let primary = if repo.is_primary { " (primary)" } else { "" };
- let url_or_path = repo.repository_url.as_deref()
- .or(repo.local_path.as_deref())
- .unwrap_or("managed");
- context.push_str(&format!("- {}: {}{}\n", repo.name, url_or_path, primary));
- }
- }
-
- context
-}
-
-/// Summarize older conversation history to reduce token usage
-async fn summarize_conversation_history(
- llm_client: &LlmClient,
- messages: &[&crate::db::models::ContractChatMessageRecord],
-) -> String {
- // Build conversation text for summarization
- let mut conversation_text = String::new();
- for msg in messages {
- let role_label = if msg.role == "user" { "User" } else { "Assistant" };
- // Limit each message to avoid overwhelming the summarizer
- let content = if msg.content.len() > 500 {
- format!("{}...", &msg.content[..500])
- } else {
- msg.content.clone()
- };
- conversation_text.push_str(&format!("{}: {}\n", role_label, content));
- }
-
- // Limit total text to summarize
- if conversation_text.len() > 8000 {
- conversation_text = format!("{}...", &conversation_text[..8000]);
- }
-
- let summary_prompt = format!(
- "Summarize this conversation history in 2-3 sentences, focusing on key decisions, actions taken, and current state:\n\n{}",
- conversation_text
- );
-
- // Use a simple chat call without tools for summarization
- let summary = match llm_client {
- LlmClient::Claude(client) => {
- let claude_messages = vec![claude::Message {
- role: "user".to_string(),
- content: claude::MessageContent::Text(summary_prompt.clone()),
- }];
- match client.chat_with_tools(claude_messages, &[]).await {
- Ok(response) => response.content.unwrap_or_default(),
- Err(e) => {
- tracing::warn!("Failed to summarize conversation: {}", e);
- "Previous conversation covered contract management tasks.".to_string()
- }
- }
- }
- LlmClient::Groq(client) => {
- let groq_messages = vec![Message {
- role: "user".to_string(),
- content: Some(summary_prompt.clone()),
- tool_calls: None,
- tool_call_id: None,
- }];
- match client.chat_with_tools(groq_messages, &[]).await {
- Ok(response) => response.content.unwrap_or_default(),
- Err(e) => {
- tracing::warn!("Failed to summarize conversation: {}", e);
- "Previous conversation covered contract management tasks.".to_string()
- }
- }
- }
- };
-
- // Limit summary length
- if summary.len() > 500 {
- format!("{}...", &summary[..500])
- } else {
- summary
- }
-}
-
-/// Run the agentic loop for contract chat
-async fn run_contract_agentic_loop(
- pool: &sqlx::PgPool,
- state: &SharedState,
- llm_client: &LlmClient,
- system_prompt: String,
- request: &ContractChatRequest,
- contract_id: Uuid,
- owner_id: Uuid,
-) -> axum::response::Response {
- // Get or create the conversation for persistent history
- let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, owner_id).await {
- Ok(conv) => conv,
- Err(e) => {
- tracing::error!("Failed to get/create contract conversation: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Failed to initialize conversation: {}", e) })),
- )
- .into_response();
- }
- };
-
- // Load ALL existing messages from database
- let saved_messages = match repository::list_contract_chat_messages(pool, conversation.id, None).await {
- Ok(msgs) => msgs,
- Err(e) => {
- tracing::warn!("Failed to load contract chat history: {}", e);
- Vec::new()
- }
- };
-
- // Build initial messages
- let mut messages = vec![Message {
- role: "system".to_string(),
- content: Some(system_prompt),
- tool_calls: None,
- tool_call_id: None,
- }];
-
- // Add saved conversation history, summarizing older messages if needed
- // to stay under rate limits (~25k chars ≈ ~6k tokens for history)
- const MAX_HISTORY_CHARS: usize = 25000;
- const RECENT_MESSAGES_TO_KEEP: usize = 6; // Keep last 3 turns intact
-
- // Filter to user/assistant messages only
- let history_messages: Vec<_> = saved_messages
- .iter()
- .filter(|m| m.role == "user" || m.role == "assistant")
- .collect();
-
- // Calculate total character count
- let total_chars: usize = history_messages.iter().map(|m| m.content.len()).sum();
-
- if total_chars > MAX_HISTORY_CHARS && history_messages.len() > RECENT_MESSAGES_TO_KEEP {
- // Need to summarize older messages
- let split_point = history_messages.len().saturating_sub(RECENT_MESSAGES_TO_KEEP);
- let older_messages = &history_messages[..split_point];
- let recent_messages = &history_messages[split_point..];
-
- // Generate summary of older conversation
- let summary = summarize_conversation_history(&llm_client, older_messages).await;
-
- // Add summary as context
- messages.push(Message {
- role: "user".to_string(),
- content: Some(format!("[Previous conversation summary: {}]", summary)),
- tool_calls: None,
- tool_call_id: None,
- });
- messages.push(Message {
- role: "assistant".to_string(),
- content: Some("I understand the previous context. Let's continue.".to_string()),
- tool_calls: None,
- tool_call_id: None,
- });
-
- // Add recent messages in full
- for saved_msg in recent_messages {
- messages.push(Message {
- role: saved_msg.role.clone(),
- content: Some(saved_msg.content.clone()),
- tool_calls: None,
- tool_call_id: None,
- });
- }
-
- tracing::info!(
- total_messages = history_messages.len(),
- summarized = older_messages.len(),
- kept_recent = recent_messages.len(),
- "Summarized older conversation history"
- );
- } else {
- // Add all messages directly
- for saved_msg in history_messages {
- messages.push(Message {
- role: saved_msg.role.clone(),
- content: Some(saved_msg.content.clone()),
- tool_calls: None,
- tool_call_id: None,
- });
- }
- }
-
- // Add current user message
- messages.push(Message {
- role: "user".to_string(),
- content: Some(request.message.clone()),
- tool_calls: None,
- tool_call_id: None,
- });
-
- // Save the user message to database
- if let Err(e) = repository::add_contract_chat_message(
- pool,
- conversation.id,
- "user",
- &request.message,
- None,
- None,
- ).await {
- tracing::warn!("Failed to save user message to contract chat history: {}", e);
- }
-
- // State for tracking
- let mut all_tool_call_infos: Vec<ContractToolCallInfo> = Vec::new();
- let mut final_response: Option<String> = None;
- let mut consecutive_failures = 0;
- const MAX_CONSECUTIVE_FAILURES: usize = 3;
- let mut pending_questions: Option<Vec<UserQuestion>> = None;
-
- // Multi-turn agentic tool calling loop
- for round in 0..MAX_TOOL_ROUNDS {
- tracing::info!(
- round = round,
- total_tool_calls = all_tool_call_infos.len(),
- "Contract agentic loop iteration"
- );
-
- // Check consecutive failures
- if consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
- tracing::warn!(
- "Breaking contract loop due to {} consecutive failures",
- consecutive_failures
- );
- final_response = Some(
- "I encountered multiple consecutive errors and stopped. \
- Please check the contract state and try again."
- .to_string(),
- );
- break;
- }
-
- // Call the appropriate LLM API
- let result = match llm_client {
- LlmClient::Groq(groq) => {
- match groq.chat_with_tools(messages.clone(), &CONTRACT_TOOLS).await {
- Ok(r) => LlmResult {
- content: r.content,
- tool_calls: r.tool_calls,
- raw_tool_calls: r.raw_tool_calls,
- finish_reason: r.finish_reason,
- },
- Err(e) => {
- tracing::error!("Groq API error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("LLM API error: {}", e) })),
- )
- .into_response();
- }
- }
- }
- LlmClient::Claude(claude_client) => {
- let claude_messages = claude::groq_messages_to_claude(&messages);
- match claude_client
- .chat_with_tools(claude_messages, &CONTRACT_TOOLS)
- .await
- {
- Ok(r) => {
- let raw_tool_calls: Vec<ToolCallResponse> = r
- .tool_calls
- .iter()
- .map(|tc| ToolCallResponse {
- id: tc.id.clone(),
- call_type: "function".to_string(),
- function: crate::llm::groq::FunctionCall {
- name: tc.name.clone(),
- arguments: tc.arguments.to_string(),
- },
- })
- .collect();
-
- LlmResult {
- content: r.content,
- tool_calls: r.tool_calls,
- raw_tool_calls,
- finish_reason: r.stop_reason,
- }
- }
- Err(e) => {
- tracing::error!("Claude API error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("LLM API error: {}", e) })),
- )
- .into_response();
- }
- }
- }
- };
-
- // Check if there are tool calls to execute
- if result.tool_calls.is_empty() {
- final_response = result.content;
- break;
- }
-
- // Add assistant message with tool calls to conversation
- messages.push(Message {
- role: "assistant".to_string(),
- content: result.content.clone(),
- tool_calls: Some(result.raw_tool_calls.clone()),
- tool_call_id: None,
- });
-
- // Execute each tool call
- for (i, tool_call) in result.tool_calls.iter().enumerate() {
- tracing::info!(tool = %tool_call.name, round = round, "Executing contract tool call");
-
- // Parse the tool call
- let mut execution_result = parse_contract_tool_call(tool_call);
-
- // Handle async contract tool requests
- if let Some(contract_request) = execution_result.request.take() {
- let async_result =
- handle_contract_request(pool, &state.daemon_connections, contract_request, contract_id, owner_id).await;
- execution_result.success = async_result.success;
- execution_result.message = async_result.message;
- execution_result.data = async_result.data;
- }
-
- // Track consecutive failures
- if execution_result.success {
- consecutive_failures = 0;
- } else {
- consecutive_failures += 1;
- tracing::warn!(
- tool = %tool_call.name,
- consecutive_failures = consecutive_failures,
- "Contract tool call failed"
- );
- }
-
- // Check for pending user questions
- if let Some(questions) = execution_result.pending_questions {
- tracing::info!(
- question_count = questions.len(),
- "Contract LLM requesting user input"
- );
- pending_questions = Some(questions);
- all_tool_call_infos.push(ContractToolCallInfo {
- name: tool_call.name.clone(),
- result: ToolResult {
- success: execution_result.success,
- message: execution_result.message.clone(),
- },
- });
- break;
- }
-
- // Build tool result message
- let result_content = if let Some(data) = &execution_result.data {
- json!({
- "success": execution_result.success,
- "message": execution_result.message,
- "data": data
- })
- .to_string()
- } else {
- json!({
- "success": execution_result.success,
- "message": execution_result.message
- })
- .to_string()
- };
-
- // Add tool result message
- let tool_call_id = match llm_client {
- LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(),
- LlmClient::Claude(_) => tool_call.id.clone(),
- };
-
- messages.push(Message {
- role: "tool".to_string(),
- content: Some(result_content),
- tool_calls: None,
- tool_call_id: Some(tool_call_id),
- });
-
- // Track for response
- all_tool_call_infos.push(ContractToolCallInfo {
- name: tool_call.name.clone(),
- result: ToolResult {
- success: execution_result.success,
- message: execution_result.message,
- },
- });
- }
-
- // If user questions are pending, pause
- if pending_questions.is_some() {
- final_response = result.content;
- break;
- }
-
- // If finish reason indicates completion, exit loop
- let finish_lower = result.finish_reason.to_lowercase();
- if finish_lower == "stop" || finish_lower == "end_turn" {
- final_response = result.content;
- break;
- }
- }
-
- // Build response
- let response_text = final_response.unwrap_or_else(|| {
- if all_tool_call_infos.is_empty() {
- "I couldn't understand your request. Please try rephrasing.".to_string()
- } else {
- format!(
- "Done! Executed {} tool{}.",
- all_tool_call_infos.len(),
- if all_tool_call_infos.len() == 1 { "" } else { "s" }
- )
- }
- });
-
- // Save assistant response to database
- let tool_calls_json = if all_tool_call_infos.is_empty() {
- None
- } else {
- serde_json::to_value(&all_tool_call_infos).ok()
- };
-
- let pending_questions_json = pending_questions.as_ref().and_then(|q| serde_json::to_value(q).ok());
-
- if let Err(e) = repository::add_contract_chat_message(
- pool,
- conversation.id,
- "assistant",
- &response_text,
- tool_calls_json,
- pending_questions_json,
- ).await {
- tracing::warn!("Failed to save assistant response to contract chat history: {}", e);
- }
-
- (
- StatusCode::OK,
- Json(ContractChatResponse {
- response: response_text,
- tool_calls: all_tool_call_infos,
- pending_questions,
- }),
- )
- .into_response()
-}
-
-/// Result from handling an async contract tool request
-struct ContractRequestResult {
- success: bool,
- message: String,
- data: Option<serde_json::Value>,
-}
-
-/// Handle async contract tool requests that require database access
-async fn handle_contract_request(
- pool: &sqlx::PgPool,
- daemon_connections: &dashmap::DashMap<String, crate::server::state::DaemonConnectionInfo>,
- request: ContractToolRequest,
- contract_id: Uuid,
- owner_id: Uuid,
-) -> ContractRequestResult {
- match request {
- ContractToolRequest::ListDaemonDirectories => {
- let mut directories = Vec::new();
-
- // Iterate over connected daemons belonging to this owner
- for entry in daemon_connections.iter() {
- let daemon = entry.value();
-
- // Only include daemons belonging to this owner
- if daemon.owner_id != owner_id {
- continue;
- }
-
- // Add working directory if available
- if let Some(ref working_dir) = daemon.working_directory {
- directories.push(json!({
- "path": working_dir,
- "label": "Working Directory",
- "type": "working",
- "hostname": daemon.hostname,
- }));
- }
-
- // Add home directory if available
- if let Some(ref home_dir) = daemon.home_directory {
- directories.push(json!({
- "path": home_dir,
- "label": "Makima Home",
- "type": "home",
- "hostname": daemon.hostname,
- }));
- }
- }
-
- if directories.is_empty() {
- ContractRequestResult {
- success: true,
- message: "No daemon directories available. Connect a daemon to get directory suggestions.".to_string(),
- data: Some(json!({ "directories": [] })),
- }
- } else {
- ContractRequestResult {
- success: true,
- message: format!("Found {} suggested directories from connected daemons", directories.len()),
- data: Some(json!({ "directories": directories })),
- }
- }
- }
-
- ContractToolRequest::GetContractStatus => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let c = &cwr.contract;
- ContractRequestResult {
- success: true,
- message: format!(
- "Contract '{}' is in '{}' phase with status '{}'",
- c.name, c.phase, c.status
- ),
- data: Some(json!({
- "name": c.name,
- "phase": c.phase,
- "status": c.status,
- "description": c.description,
- "fileCount": cwr.files.len(),
- "taskCount": cwr.tasks.len(),
- "repositoryCount": cwr.repositories.len(),
- })),
- }
- }
- 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,
- },
- }
- }
-
- ContractToolRequest::ListContractFiles => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let files: Vec<serde_json::Value> = cwr
- .files
- .iter()
- .map(|f| {
- json!({
- "fileId": f.id,
- "name": f.name,
- "description": f.description,
- "phase": f.contract_phase,
- })
- })
- .collect();
-
- ContractRequestResult {
- success: true,
- message: format!("Found {} files", files.len()),
- data: Some(json!({ "files": files })),
- }
- }
- 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,
- },
- }
- }
-
- ContractToolRequest::ListContractTasks => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let tasks: Vec<serde_json::Value> = cwr
- .tasks
- .iter()
- .map(|t| {
- json!({
- "taskId": t.id,
- "name": t.name,
- "status": t.status,
- "priority": t.priority,
- })
- })
- .collect();
-
- ContractRequestResult {
- success: true,
- message: format!("Found {} tasks", tasks.len()),
- data: Some(json!({ "tasks": tasks })),
- }
- }
- 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,
- },
- }
- }
-
- ContractToolRequest::ListContractRepositories => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let repos: Vec<serde_json::Value> = cwr
- .repositories
- .iter()
- .map(|r| {
- json!({
- "repositoryId": r.id,
- "name": r.name,
- "repositoryUrl": r.repository_url,
- "localPath": r.local_path,
- "isPrimary": r.is_primary,
- })
- })
- .collect();
-
- ContractRequestResult {
- success: true,
- message: format!("Found {} repositories", repos.len()),
- data: Some(json!({ "repositories": repos })),
- }
- }
- 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,
- },
- }
- }
-
- ContractToolRequest::ReadFile { file_id } => {
- match repository::get_file_for_owner(pool, file_id, owner_id).await {
- Ok(Some(file)) => {
- // Verify file belongs to this contract
- if file.contract_id != Some(contract_id) {
- return ContractRequestResult {
- success: false,
- message: "File does not belong to this contract".to_string(),
- data: None,
- };
- }
-
- // Convert body to markdown for LLM consumption
- let markdown = body_to_markdown(&file.body);
-
- ContractRequestResult {
- success: true,
- message: format!("Read file '{}'", file.name),
- data: Some(json!({
- "fileId": file.id,
- "name": file.name,
- "description": file.description,
- "summary": file.summary,
- "plainText": markdown,
- "phase": file.contract_phase,
- })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "File not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::CreateEmptyFile { name, description } => {
- // Verify contract exists and get current phase
- let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- }
- }
- };
-
- // Create the file with current contract phase
- let create_req = crate::db::models::CreateFileRequest {
- contract_id,
- name: Some(name.clone()),
- description,
- body: Vec::new(),
- transcript: Vec::new(),
- location: None,
- repo_file_path: None,
- contract_phase: Some(contract.phase.clone()),
- };
-
- match repository::create_file_for_owner(pool, owner_id, create_req).await {
- Ok(file) => ContractRequestResult {
- success: true,
- message: format!("Created empty file '{}'", name),
- data: Some(json!({
- "fileId": file.id,
- "name": file.name,
- })),
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to create file: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::MarkDeliverableComplete {
- deliverable_id,
- phase,
- } => {
- // Get the contract to determine current phase and contract type
- let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- }
- }
- };
-
- // Use specified phase or default to current contract phase
- let target_phase = phase.unwrap_or_else(|| contract.phase.clone());
-
- // Validate the deliverable ID exists for this phase/contract type
- let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&target_phase, &contract.contract_type);
- let deliverable_exists = phase_deliverables.deliverables.iter().any(|d| d.id == deliverable_id);
-
- if !deliverable_exists {
- let valid_ids: Vec<&str> = phase_deliverables.deliverables.iter().map(|d| d.id.as_str()).collect();
- return ContractRequestResult {
- success: false,
- message: format!(
- "Invalid deliverable_id '{}' for {} phase. Valid IDs: {:?}",
- deliverable_id, target_phase, valid_ids
- ),
- data: None,
- };
- }
-
- // Check if already completed
- if contract.is_deliverable_complete(&target_phase, &deliverable_id) {
- return ContractRequestResult {
- success: true,
- message: format!("Deliverable '{}' is already marked complete for {} phase", deliverable_id, target_phase),
- data: Some(json!({
- "deliverableId": deliverable_id,
- "phase": target_phase,
- "alreadyComplete": true,
- })),
- };
- }
-
- // Mark the deliverable as complete
- match repository::mark_deliverable_complete(pool, contract_id, &target_phase, &deliverable_id).await {
- Ok(updated_contract) => {
- let completed = updated_contract.get_completed_deliverables(&target_phase);
- ContractRequestResult {
- success: true,
- message: format!("Marked deliverable '{}' as complete for {} phase", deliverable_id, target_phase),
- data: Some(json!({
- "deliverableId": deliverable_id,
- "phase": target_phase,
- "completedDeliverables": completed,
- })),
- }
- }
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to mark deliverable complete: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::CreateContractTask {
- name,
- plan,
- repository_url,
- base_branch,
- } => {
- // Get primary repository if not specified
- let repo_url = if repository_url.is_some() {
- repository_url
- } else {
- // Find primary repository
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(contract)) => {
- contract
- .repositories
- .iter()
- .find(|r| r.is_primary)
- .and_then(|r| r.repository_url.clone().or(r.local_path.clone()))
- }
- _ => None,
- }
- };
-
- let create_req = CreateTaskRequest {
- contract_id: Some(contract_id),
- name: name.clone(),
- description: None,
- plan,
- parent_task_id: None,
- repository_url: repo_url,
- base_branch,
- target_branch: None,
- merge_mode: None,
- priority: 0,
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: None,
- copy_files: None,
- is_supervisor: false,
- checkpoint_sha: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: None,
- directive_step_id: None,
- };
-
- match repository::create_task_for_owner(pool, owner_id, create_req).await {
- Ok(task) => ContractRequestResult {
- success: true,
- message: format!("Created task '{}' in contract", name),
- data: Some(json!({
- "taskId": task.id,
- "name": task.name,
- "status": task.status,
- })),
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to create task: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::DelegateContentGeneration {
- file_id,
- instruction,
- context,
- } => {
- // Build a task plan that includes the content generation instruction
- let mut plan = format!(
- "Content Generation Task\n\n\
- ## Instruction\n{}\n\n",
- instruction
- );
-
- if let Some(ctx) = context {
- plan.push_str(&format!("## Context\n{}\n\n", ctx));
- }
-
- // If file_id is provided, get file details and include them
- let (file_name, file_info) = if let Some(fid) = file_id {
- match repository::get_file_for_owner(pool, fid, owner_id).await {
- Ok(Some(file)) => {
- let info = format!(
- "## Target File\n\
- - File ID: {}\n\
- - Name: {}\n\
- - Description: {}\n\n\
- The generated content should be structured to update this file.\n",
- fid,
- file.name,
- file.description.as_deref().unwrap_or("(no description)")
- );
- (Some(file.name.clone()), Some(info))
- }
- _ => (None, None),
- }
- } else {
- (None, None)
- };
-
- if let Some(info) = file_info {
- plan.push_str(&info);
- }
-
- // Get primary repository
- let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(contract)) => contract
- .repositories
- .iter()
- .find(|r| r.is_primary)
- .and_then(|r| r.repository_url.clone().or(r.local_path.clone())),
- _ => None,
- };
-
- let task_name = format!(
- "Generate content{}",
- file_name.map(|n| format!(": {}", n)).unwrap_or_default()
- );
-
- let create_req = CreateTaskRequest {
- contract_id: Some(contract_id),
- name: task_name.clone(),
- description: Some(instruction.clone()),
- plan,
- parent_task_id: None,
- repository_url: repo_url,
- base_branch: None,
- target_branch: None,
- merge_mode: None,
- priority: 0,
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: None,
- copy_files: None,
- is_supervisor: false,
- checkpoint_sha: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: None,
- directive_step_id: None,
- };
-
- match repository::create_task_for_owner(pool, owner_id, create_req).await {
- Ok(task) => ContractRequestResult {
- success: true,
- message: format!(
- "Created content generation task '{}'. Start the task to generate the content.",
- task_name
- ),
- data: Some(json!({
- "taskId": task.id,
- "name": task.name,
- "status": task.status,
- "targetFileId": file_id,
- })),
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to create content generation task: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::StartTask { task_id } => {
- // Get the task
- let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
- Ok(Some(t)) => t,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Task not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to get task: {}", e),
- data: None,
- }
- }
- };
-
- // Check if task can be started
- let startable_statuses = ["pending", "failed", "interrupted", "done", "merged"];
- if !startable_statuses.contains(&task.status.as_str()) {
- return ContractRequestResult {
- success: false,
- message: format!("Task cannot be started from status: {}", task.status),
- data: None,
- };
- }
-
- // Find a connected daemon for this owner
- let daemon_entry = daemon_connections
- .iter()
- .find(|d| d.value().owner_id == owner_id);
-
- let (target_daemon_id, command_sender) = match daemon_entry {
- Some(entry) => {
- let daemon = entry.value();
- (daemon.id, daemon.command_sender.clone())
- }
- None => {
- return ContractRequestResult {
- success: false,
- message: "No daemon connected. Start a daemon to run tasks.".to_string(),
- data: None,
- };
- }
- };
-
- // Check if this is an orchestrator
- let subtask_count = match repository::list_subtasks_for_owner(pool, task_id, owner_id).await {
- Ok(subtasks) => subtasks.len(),
- Err(_) => 0,
- };
- let is_orchestrator = task.depth == 0 && subtask_count > 0;
-
- // Update task status to 'starting' and assign daemon_id
- let update_req = crate::db::models::UpdateTaskRequest {
- status: Some("starting".to_string()),
- daemon_id: Some(target_daemon_id),
- version: Some(task.version),
- ..Default::default()
- };
-
- let _updated_task = match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await {
- Ok(Some(t)) => t,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Task not found".to_string(),
- data: None,
- };
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to update task: {}", e),
- data: None,
- };
- }
- };
-
- // Get local_only and auto_merge_local from contract if task has one
- let (local_only, auto_merge_local) = if let Some(contract_id) = task.contract_id {
- match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
- Ok(Some(contract)) => (contract.local_only, contract.auto_merge_local),
- _ => (false, false),
- }
- } else {
- (false, false)
- };
-
- // Send SpawnTask command to daemon
- let command = DaemonCommand::SpawnTask {
- task_id,
- task_name: task.name.clone(),
- plan: task.plan.clone(),
- repo_url: task.repository_url.clone(),
- base_branch: task.base_branch.clone(),
- target_branch: task.target_branch.clone(),
- parent_task_id: task.parent_task_id,
- depth: task.depth,
- is_orchestrator,
- target_repo_path: task.target_repo_path.clone(),
- completion_action: task.completion_action.clone(),
- continue_from_task_id: task.continue_from_task_id,
- copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()),
- contract_id: task.contract_id,
- is_supervisor: task.is_supervisor,
- autonomous_loop: false,
- resume_session: false,
- conversation_history: None,
- patch_data: None,
- patch_base_sha: None,
- local_only,
- auto_merge_local,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: task.directive_id,
- };
-
- if let Err(e) = command_sender.send(command).await {
- // Rollback: reset status since command failed
- let rollback_req = crate::db::models::UpdateTaskRequest {
- status: Some("pending".to_string()),
- clear_daemon_id: true,
- ..Default::default()
- };
- let _ = repository::update_task_for_owner(pool, task_id, owner_id, rollback_req).await;
- return ContractRequestResult {
- success: false,
- message: format!("Failed to send task to daemon: {}", e),
- data: None,
- };
- }
-
- // Note: TaskUpdateNotification broadcast is handled by the mesh handler when daemon reports status
- ContractRequestResult {
- success: true,
- message: format!("Started task '{}'. The task is now running on a connected daemon.", task.name),
- data: Some(json!({
- "taskId": task_id,
- "name": task.name,
- "status": "starting",
- })),
- }
- }
-
- ContractToolRequest::GetPhaseInfo => {
- let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- }
- }
- };
-
- let phase_info = get_phase_description(&contract.phase);
- let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type);
- let deliverable_names: Vec<String> = phase_deliverables.deliverables.iter().map(|d| d.name.clone()).collect();
-
- ContractRequestResult {
- success: true,
- message: format!("Contract is in '{}' phase", contract.phase),
- data: Some(json!({
- "phase": contract.phase,
- "description": phase_info.0,
- "activities": phase_info.1,
- "deliverables": deliverable_names,
- "guidance": phase_deliverables.guidance,
- "nextPhase": get_next_phase(&contract.phase),
- })),
- }
- }
-
- ContractToolRequest::SuggestPhaseTransition => {
- let contract = match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- }
- }
- };
-
- let analysis = analyze_phase_readiness(&contract);
-
- ContractRequestResult {
- success: true,
- message: analysis.summary.clone(),
- data: Some(json!({
- "currentPhase": contract.contract.phase,
- "nextPhase": get_next_phase(&contract.contract.phase),
- "ready": analysis.ready,
- "summary": analysis.summary,
- "reasons": analysis.reasons,
- "suggestions": analysis.suggestions,
- })),
- }
- }
-
- 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) => {
- return ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- }
- }
- };
-
- // Validate phase transition
- let current_phase = &contract.phase;
- let valid_next = get_next_phase(current_phase);
-
- if valid_next.as_deref() != Some(&new_phase) {
- return ContractRequestResult {
- success: false,
- message: format!(
- "Cannot transition from '{}' to '{}'. Next valid phase is: {:?}",
- current_phase, new_phase, valid_next
- ),
- data: None,
- };
- }
-
- // 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,
- };
- }
- };
-
- // Get completed deliverables for the current phase
- let completed_deliverables = cwr.contract.get_completed_deliverables(current_phase);
-
- let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo {
- name: t.name.clone(),
- status: t.status.clone(),
- }).collect();
-
- let has_repository = !cwr.repositories.is_empty();
-
- let check_result = crate::llm::check_deliverables_met(
- current_phase,
- &contract.contract_type,
- &completed_deliverables,
- &task_infos,
- has_repository,
- );
-
- // 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
- 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 requires_confirmation with phase deliverables
- // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level
- 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(),
- };
-
- // Get phase deliverables with completion status
- let phase_deliverables = crate::llm::get_phase_deliverables_for_type(current_phase, &contract.contract_type);
- let completed_deliverables = contract.get_completed_deliverables(current_phase);
-
- let deliverables: Vec<serde_json::Value> = phase_deliverables
- .deliverables
- .iter()
- .map(|d| json!({
- "id": d.id,
- "name": d.name,
- "completed": completed_deliverables.contains(&d.id)
- }))
- .collect();
-
- // 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": "requires_confirmation",
- "transitionId": transition_id,
- "currentPhase": current_phase,
- "nextPhase": new_phase,
- "deliverablesSummary": deliverables_summary,
- "deliverables": deliverables,
- "phaseFiles": phase_files,
- "phaseTasks": phase_tasks,
- "requiresConfirmation": true,
- "message": "Phase guard is enabled. User confirmation required.",
- "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 (using contract type)
- let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&new_phase, &contract.contract_type);
-
- // Build deliverables list
- let deliverables_list: Vec<serde_json::Value> = phase_deliverables
- .deliverables
- .iter()
- .map(|d| json!({
- "id": d.id,
- "name": d.name,
- "priority": format!("{:?}", d.priority).to_lowercase(),
- "description": d.description,
- }))
- .collect();
-
- ContractRequestResult {
- success: true,
- message: format!(
- "Advanced contract from '{}' to '{}' phase. {}",
- current_phase, new_phase, phase_deliverables.guidance
- ),
- data: Some(json!({
- "status": "advanced",
- "previousPhase": current_phase,
- "newPhase": updated.phase,
- "phaseGuidance": phase_deliverables.guidance,
- "deliverables": deliverables_list,
- "requiresRepository": phase_deliverables.requires_repository,
- "requiresTasks": phase_deliverables.requires_tasks,
- })),
- }
- },
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Failed to update phase".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to update phase: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::AddRepository {
- repo_type,
- name,
- url,
- is_primary,
- } => {
- let add_result = match repo_type.as_str() {
- "remote" => {
- let url = url.unwrap_or_default();
- repository::add_remote_repository(
- pool,
- contract_id,
- &name,
- &url,
- is_primary,
- )
- .await
- }
- "local" => {
- let path = url.unwrap_or_default();
- repository::add_local_repository(
- pool,
- contract_id,
- &name,
- &path,
- is_primary,
- )
- .await
- }
- "managed" => {
- repository::create_managed_repository(pool, contract_id, &name, is_primary)
- .await
- }
- _ => {
- return ContractRequestResult {
- success: false,
- message: format!("Invalid repository type: {}", repo_type),
- data: None,
- }
- }
- };
-
- match add_result {
- Ok(repo) => ContractRequestResult {
- success: true,
- message: format!("Added {} repository '{}'", repo_type, name),
- data: Some(json!({
- "repositoryId": repo.id,
- "name": repo.name,
- "isPrimary": repo.is_primary,
- })),
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to add repository: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::SetPrimaryRepository { repository_id } => {
- match repository::set_repository_primary(pool, repository_id, contract_id).await {
- Ok(true) => ContractRequestResult {
- success: true,
- message: "Set repository as primary".to_string(),
- data: Some(json!({
- "repositoryId": repository_id,
- })),
- },
- Ok(false) => ContractRequestResult {
- success: false,
- message: "Repository not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to set primary repository: {}", e),
- data: None,
- },
- }
- }
-
- // =============================================================================
- // Phase Guidance Handlers
- // =============================================================================
-
- ContractToolRequest::GetPhaseChecklist => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase);
-
- let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo {
- name: t.name.clone(),
- status: t.status.clone(),
- }).collect();
-
- let has_repository = !cwr.repositories.is_empty();
- let checklist = crate::llm::get_phase_checklist_for_type(&cwr.contract.phase, &completed_deliverables, &task_infos, has_repository, &cwr.contract.contract_type);
-
- ContractRequestResult {
- success: true,
- message: checklist.summary.clone(),
- data: Some(json!({
- "phase": checklist.phase,
- "completionPercentage": checklist.completion_percentage,
- "deliverables": checklist.deliverables,
- "hasRepository": checklist.has_repository,
- "repositoryRequired": checklist.repository_required,
- "taskStats": checklist.task_stats,
- "suggestions": checklist.suggestions,
- "summary": checklist.summary,
- })),
- }
- }
- 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,
- },
- }
- }
-
- ContractToolRequest::CheckDeliverablesMet => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase);
-
- let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo {
- name: t.name.clone(),
- status: t.status.clone(),
- }).collect();
-
- let has_repository = !cwr.repositories.is_empty();
-
- let check_result = crate::llm::check_deliverables_met(
- &cwr.contract.phase,
- &cwr.contract.contract_type,
- &completed_deliverables,
- &task_infos,
- has_repository,
- );
-
- // Check if we should auto-progress
- let auto_progress = crate::llm::should_auto_progress(
- &cwr.contract.phase,
- &cwr.contract.contract_type,
- &completed_deliverables,
- &task_infos,
- has_repository,
- 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
- // =============================================================================
-
- ContractToolRequest::DeriveTasksFromFile { file_id } => {
- // First get the file
- match repository::get_file_for_owner(pool, file_id, owner_id).await {
- Ok(Some(file)) => {
- // Verify file belongs to this contract
- if file.contract_id != Some(contract_id) {
- return ContractRequestResult {
- success: false,
- message: "File does not belong to this contract".to_string(),
- data: None,
- };
- }
-
- // Convert body to markdown for task parsing
- let markdown = body_to_markdown(&file.body);
-
- // Parse tasks from the content
- let parse_result = parse_tasks_from_breakdown(&markdown);
-
- ContractRequestResult {
- success: true,
- message: format!("Found {} tasks in file '{}'", parse_result.total, file.name),
- data: Some(json!({
- "fileId": file_id,
- "fileName": file.name,
- "tasks": parse_result.tasks,
- "groups": parse_result.groups,
- "total": parse_result.total,
- "warnings": parse_result.warnings,
- "formatted": format_parsed_tasks(&parse_result),
- })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "File not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::CreateChainedTasks { tasks } => {
- // Get primary repository for tasks
- let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(contract)) => {
- contract
- .repositories
- .iter()
- .find(|r| r.is_primary)
- .and_then(|r| r.repository_url.clone().or(r.local_path.clone()))
- }
- _ => None,
- };
-
- let mut created_tasks = Vec::new();
- let mut previous_task_id: Option<Uuid> = None;
-
- for task_def in &tasks {
- let create_req = CreateTaskRequest {
- contract_id: Some(contract_id),
- name: task_def.name.clone(),
- description: None,
- plan: task_def.plan.clone(),
- parent_task_id: None,
- repository_url: repo_url.clone(),
- base_branch: None,
- target_branch: None,
- merge_mode: None,
- priority: 0,
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: previous_task_id,
- copy_files: None,
- is_supervisor: false,
- checkpoint_sha: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: None,
- directive_step_id: None,
- };
-
- match repository::create_task_for_owner(pool, owner_id, create_req).await {
- Ok(task) => {
- created_tasks.push(json!({
- "taskId": task.id,
- "name": task.name,
- "status": task.status,
- "chainedFrom": previous_task_id,
- }));
- previous_task_id = Some(task.id);
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to create task '{}': {}", task_def.name, e),
- data: Some(json!({
- "createdSoFar": created_tasks,
- })),
- };
- }
- }
- }
-
- ContractRequestResult {
- success: true,
- message: format!("Created {} chained tasks", created_tasks.len()),
- data: Some(json!({
- "tasks": created_tasks,
- "total": created_tasks.len(),
- })),
- }
- }
-
- // =============================================================================
- // Task Completion Processing Handlers
- // =============================================================================
-
- ContractToolRequest::ProcessTaskCompletion { task_id } => {
- // Get the task
- match repository::get_task_for_owner(pool, task_id, owner_id).await {
- Ok(Some(task)) => {
- // Verify task belongs to this contract
- if task.contract_id != Some(contract_id) {
- return ContractRequestResult {
- success: false,
- message: "Task does not belong to this contract".to_string(),
- data: None,
- };
- }
-
- // Get contract for context
- let contract = get_contract_with_relations(pool, contract_id, owner_id).await.ok().flatten();
-
- let total_tasks = contract.as_ref().map(|c| c.tasks.len()).unwrap_or(0);
- let completed_tasks = contract.as_ref()
- .map(|c| c.tasks.iter().filter(|t| t.status == "done").count())
- .unwrap_or(0);
-
- // Note: Finding next chained task would require querying full Task objects
- // Since TaskSummary doesn't have continue_from_task_id, we skip this for now
- let next_task: Option<(Uuid, String)> = None;
-
- // Find Dev Notes file if exists
- let dev_notes = if let Some(ref c) = contract {
- c.files.iter()
- .find(|f| f.name.to_lowercase().contains("dev") && f.name.to_lowercase().contains("notes"))
- .map(|f| (f.id, f.name.clone()))
- } else {
- None
- };
-
- let contract_phase = contract.as_ref()
- .map(|c| c.contract.phase.clone())
- .unwrap_or_else(|| "execute".to_string());
-
- // Analyze the task output
- let analysis = analyze_task_output(
- task_id,
- &task.name,
- task.last_output.as_deref(),
- task.progress_summary.as_deref(),
- &contract_phase,
- total_tasks,
- completed_tasks,
- next_task,
- dev_notes,
- );
-
- ContractRequestResult {
- success: true,
- message: format!("Analyzed completion of task '{}'", task.name),
- data: Some(json!({
- "taskId": task_id,
- "taskName": task.name,
- "taskStatus": task.status,
- "summary": analysis.summary,
- "filesAffected": analysis.files_affected,
- "nextSteps": analysis.next_steps,
- "phaseImpact": analysis.phase_impact,
- })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Task not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::UpdateFileFromTask { file_id, task_id, section_title } => {
- // Get the task
- let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
- Ok(Some(t)) => t,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Task not found".to_string(),
- data: None,
- };
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- };
- }
- };
-
- // Get the file
- let file = match repository::get_file_for_owner(pool, file_id, owner_id).await {
- Ok(Some(f)) => f,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "File not found".to_string(),
- data: None,
- };
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- };
- }
- };
-
- // Verify file belongs to this contract
- if file.contract_id != Some(contract_id) {
- return ContractRequestResult {
- success: false,
- message: "File does not belong to this contract".to_string(),
- data: None,
- };
- }
-
- // Build the section to add
- let title = section_title.unwrap_or_else(|| format!("Task: {}", task.name));
- let result_text = task.last_output.as_deref().unwrap_or("Task completed");
-
- // Create new body elements to append
- let mut new_body = file.body.clone();
- new_body.push(crate::db::models::BodyElement::Heading {
- level: 2,
- text: title,
- });
- new_body.push(crate::db::models::BodyElement::Paragraph {
- text: format!("Status: {}", task.status),
- });
- new_body.push(crate::db::models::BodyElement::Paragraph {
- text: result_text.to_string(),
- });
-
- // Update the file using UpdateFileRequest
- let update_req = UpdateFileRequest {
- name: None,
- description: None,
- transcript: None,
- summary: None,
- body: Some(new_body),
- version: None, // Don't require version for this update
- repo_file_path: None,
- };
-
- match repository::update_file_for_owner(pool, file_id, owner_id, update_req).await {
- Ok(Some(updated_file)) => {
- ContractRequestResult {
- success: true,
- message: format!("Updated file '{}' with task summary", file.name),
- data: Some(json!({
- "fileId": file_id,
- "fileName": updated_file.name,
- "taskId": task_id,
- "taskName": task.name,
- })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Failed to update file".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- // =============================================================================
- // Transcript Analysis Handlers
- // =============================================================================
-
- ContractToolRequest::AnalyzeTranscript { file_id } => {
- // Get the file
- let file = match repository::get_file_for_owner(pool, file_id, owner_id).await {
- Ok(Some(f)) => f,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "File not found".to_string(),
- data: None,
- };
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- };
- }
- };
-
- if file.transcript.is_empty() {
- return ContractRequestResult {
- success: false,
- message: "File has no transcript to analyze".to_string(),
- data: None,
- };
- }
-
- // Format and analyze
- let transcript_text = format_transcript_for_analysis(&file.transcript);
- let speaker_stats = calculate_speaker_stats(&file.transcript);
- let prompt = build_analysis_prompt(&transcript_text);
-
- // Call Claude for analysis
- let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) {
- Ok(c) => c,
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to create Claude client: {}", e),
- data: None,
- };
- }
- };
-
- let claude_messages = vec![claude::Message {
- role: "user".to_string(),
- content: claude::MessageContent::Text(prompt),
- }];
-
- match client.chat_with_tools(claude_messages, &[]).await {
- Ok(result) => {
- let response_content = result.content.unwrap_or_default();
- match parse_analysis_response(&response_content, speaker_stats) {
- Ok(analysis) => {
- ContractRequestResult {
- success: true,
- message: format!(
- "Analysis complete: {} requirements, {} decisions, {} action items",
- analysis.requirements.len(),
- analysis.decisions.len(),
- analysis.action_items.len()
- ),
- data: Some(json!({
- "analysis": analysis
- })),
- }
- }
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to parse analysis: {}", e),
- data: None,
- }
- }
- }
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Claude API error: {}", e),
- data: None,
- }
- }
- }
-
- ContractToolRequest::CreateContractFromTranscript {
- file_id, name, description, include_requirements, include_decisions, include_action_items
- } => {
- // Get file
- let file = match repository::get_file_for_owner(pool, file_id, owner_id).await {
- Ok(Some(f)) => f,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "File not found".to_string(),
- data: None,
- };
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- };
- }
- };
-
- if file.transcript.is_empty() {
- return ContractRequestResult {
- success: false,
- message: "File has no transcript".to_string(),
- data: None,
- };
- }
-
- // Analyze transcript
- let transcript_text = format_transcript_for_analysis(&file.transcript);
- let speaker_stats = calculate_speaker_stats(&file.transcript);
- let prompt = build_analysis_prompt(&transcript_text);
-
- let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) {
- Ok(c) => c,
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to create Claude client: {}", e),
- data: None,
- };
- }
- };
-
- let claude_messages = vec![claude::Message {
- role: "user".to_string(),
- content: claude::MessageContent::Text(prompt),
- }];
-
- let analysis = match client.chat_with_tools(claude_messages, &[]).await {
- Ok(result) => {
- let response_content = result.content.unwrap_or_default();
- match parse_analysis_response(&response_content, speaker_stats) {
- Ok(a) => a,
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to parse analysis: {}", e),
- data: None,
- };
- }
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Claude API error: {}", e),
- data: None,
- };
- }
- };
-
- // Create contract
- let contract_name = name
- .or(analysis.suggested_contract_name.clone())
- .unwrap_or_else(|| format!("Contract from {}", file.name));
- let contract_description = description.or(analysis.suggested_description.clone());
-
- let contract_req = crate::db::models::CreateContractRequest {
- name: contract_name.clone(),
- description: contract_description,
- contract_type: Some("specification".to_string()),
- initial_phase: Some("research".to_string()),
- autonomous_loop: None,
- phase_guard: None,
- local_only: None,
- auto_merge_local: None,
- template_id: None,
- };
-
- let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await {
- Ok(c) => c,
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to create contract: {}", e),
- data: None,
- };
- }
- };
-
- let mut files_created = 0;
- let mut tasks_created = 0;
-
- // Create requirements file if requested and there are requirements
- if include_requirements && !analysis.requirements.is_empty() {
- let requirements_items: Vec<String> = analysis.requirements
- .iter()
- .map(|req| format!("[{}] {}", req.speaker, req.text))
- .collect();
-
- let body: Vec<crate::db::models::BodyElement> = vec![
- crate::db::models::BodyElement::Heading {
- level: 1,
- text: "Requirements".to_string(),
- },
- crate::db::models::BodyElement::Paragraph {
- text: format!("Extracted {} requirements from transcript analysis.", analysis.requirements.len()),
- },
- crate::db::models::BodyElement::Heading {
- level: 2,
- text: "Extracted Requirements".to_string(),
- },
- crate::db::models::BodyElement::List {
- ordered: false,
- items: requirements_items,
- },
- ];
-
- let create_req = crate::db::models::CreateFileRequest {
- contract_id: contract.id,
- name: Some("Requirements".to_string()),
- description: Some("Requirements extracted from transcript analysis".to_string()),
- body,
- transcript: Vec::new(),
- location: None,
- repo_file_path: None,
- contract_phase: Some("specify".to_string()),
- };
-
- if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() {
- files_created += 1;
- }
- }
-
- // Create decisions file if requested and there are decisions
- if include_decisions && !analysis.decisions.is_empty() {
- let decisions_items: Vec<String> = analysis.decisions
- .iter()
- .map(|dec| format!("[{}] {}", dec.speaker, dec.text))
- .collect();
-
- let body: Vec<crate::db::models::BodyElement> = vec![
- crate::db::models::BodyElement::Heading {
- level: 1,
- text: "Decisions".to_string(),
- },
- crate::db::models::BodyElement::Paragraph {
- text: format!("Extracted {} decisions from transcript analysis.", analysis.decisions.len()),
- },
- crate::db::models::BodyElement::Heading {
- level: 2,
- text: "Recorded Decisions".to_string(),
- },
- crate::db::models::BodyElement::List {
- ordered: false,
- items: decisions_items,
- },
- ];
-
- let create_req = crate::db::models::CreateFileRequest {
- contract_id: contract.id,
- name: Some("Decisions".to_string()),
- description: Some("Decisions extracted from transcript analysis".to_string()),
- body,
- transcript: Vec::new(),
- location: None,
- repo_file_path: None,
- contract_phase: Some("research".to_string()),
- };
-
- if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() {
- files_created += 1;
- }
- }
-
- // Create tasks from action items if requested
- if include_action_items && !analysis.action_items.is_empty() {
- for item in &analysis.action_items {
- let task_req = CreateTaskRequest {
- contract_id: Some(contract.id),
- name: item.text.chars().take(100).collect(),
- description: Some(format!("Action item from: {}", item.speaker)),
- plan: item.text.clone(),
- parent_task_id: None,
- repository_url: None,
- base_branch: None,
- target_branch: None,
- merge_mode: None,
- priority: match item.priority.as_deref() {
- Some("high") => 10,
- Some("medium") => 5,
- _ => 0,
- },
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: None,
- copy_files: None,
- is_supervisor: false,
- checkpoint_sha: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: None,
- directive_step_id: None,
- };
-
- if repository::create_task_for_owner(pool, owner_id, task_req).await.is_ok() {
- tasks_created += 1;
- }
- }
- }
-
- ContractRequestResult {
- success: true,
- message: format!(
- "Created contract '{}' with {} files and {} tasks from transcript analysis",
- contract_name, files_created, tasks_created
- ),
- data: Some(json!({
- "contractId": contract.id,
- "contractName": contract_name,
- "filesCreated": files_created,
- "tasksCreated": tasks_created,
- "analysis": {
- "requirementsCount": analysis.requirements.len(),
- "decisionsCount": analysis.decisions.len(),
- "actionItemsCount": analysis.action_items.len()
- }
- })),
- }
- }
-
-
- }
-}
-
-/// Get description and activities for a phase
-fn get_phase_description(phase: &str) -> (String, Vec<String>) {
- match phase {
- "research" => (
- "Gather information, analyze competitors, and understand user needs".to_string(),
- vec![
- "Conduct user research".to_string(),
- "Analyze competitors".to_string(),
- "Document findings".to_string(),
- "Identify opportunities".to_string(),
- ],
- ),
- "specify" => (
- "Define requirements, user stories, and acceptance criteria".to_string(),
- vec![
- "Write requirements".to_string(),
- "Create user stories".to_string(),
- "Define acceptance criteria".to_string(),
- "Document constraints".to_string(),
- ],
- ),
- "plan" => (
- "Design architecture, create task breakdowns, and technical designs".to_string(),
- vec![
- "Design system architecture".to_string(),
- "Create technical specifications".to_string(),
- "Break down into tasks".to_string(),
- "Plan implementation order".to_string(),
- ],
- ),
- "execute" => (
- "Implement features, write code, and run tasks".to_string(),
- vec![
- "Implement features".to_string(),
- "Write tests".to_string(),
- "Track progress".to_string(),
- "Document implementation details".to_string(),
- ],
- ),
- "review" => (
- "Review work, create release notes, and conduct retrospectives".to_string(),
- vec![
- "Review code and features".to_string(),
- "Create release notes".to_string(),
- "Conduct retrospective".to_string(),
- "Document learnings".to_string(),
- ],
- ),
- _ => (
- "Unknown phase".to_string(),
- vec![],
- ),
- }
-}
-
-/// Get the next phase in the lifecycle
-fn get_next_phase(current: &str) -> Option<String> {
- match current {
- "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,
- }
-}
-
-/// Phase readiness analysis result
-struct PhaseReadinessAnalysis {
- ready: bool,
- summary: String,
- reasons: Vec<String>,
- suggestions: Vec<String>,
-}
-
-/// Analyze if the contract is ready to transition to the next phase
-fn analyze_phase_readiness(contract: &crate::db::models::ContractWithRelations) -> PhaseReadinessAnalysis {
- let mut reasons = Vec::new();
- let mut suggestions = Vec::new();
-
- match contract.contract.phase.as_str() {
- "research" => {
- // Check for research files
- let research_files = contract.files.iter()
- .filter(|f| f.contract_phase.as_deref() == Some("research"))
- .count();
-
- if research_files == 0 {
- reasons.push("No research documents created yet".to_string());
- suggestions.push("Create research notes or competitor analysis documents".to_string());
- } else {
- reasons.push(format!("{} research document(s) created", research_files));
- }
-
- let ready = research_files > 0;
- PhaseReadinessAnalysis {
- ready,
- summary: if ready {
- "Research phase has documentation. Consider transitioning to Specify phase.".to_string()
- } else {
- "Research phase needs more documentation before transitioning.".to_string()
- },
- reasons,
- suggestions,
- }
- }
- "specify" => {
- let spec_files = contract.files.iter()
- .filter(|f| f.contract_phase.as_deref() == Some("specify"))
- .count();
-
- if spec_files == 0 {
- reasons.push("No specification documents created yet".to_string());
- suggestions.push("Create requirements or user stories documents".to_string());
- } else {
- reasons.push(format!("{} specification document(s) created", spec_files));
- }
-
- let ready = spec_files > 0;
- PhaseReadinessAnalysis {
- ready,
- summary: if ready {
- "Specification phase has documentation. Consider transitioning to Plan phase.".to_string()
- } else {
- "Specification phase needs requirements or user stories.".to_string()
- },
- reasons,
- suggestions,
- }
- }
- "plan" => {
- let plan_files = contract.files.iter()
- .filter(|f| f.contract_phase.as_deref() == Some("plan"))
- .count();
-
- let has_repos = !contract.repositories.is_empty();
-
- if plan_files == 0 {
- reasons.push("No planning documents created yet".to_string());
- suggestions.push("Create architecture or task breakdown documents".to_string());
- } else {
- reasons.push(format!("{} planning document(s) created", plan_files));
- }
-
- if !has_repos {
- reasons.push("No repositories configured".to_string());
- suggestions.push("Add a repository for task execution".to_string());
- } else {
- reasons.push(format!("{} repository(ies) configured", contract.repositories.len()));
- }
-
- let ready = plan_files > 0 && has_repos;
- PhaseReadinessAnalysis {
- ready,
- summary: if ready {
- "Planning phase complete with documents and repositories. Ready for Execute phase.".to_string()
- } else {
- "Planning phase needs documentation and/or repository configuration.".to_string()
- },
- reasons,
- suggestions,
- }
- }
- "execute" => {
- let total_tasks = contract.tasks.len();
- let done_tasks = contract.tasks.iter().filter(|t| t.status == "done").count();
- let running_tasks = contract.tasks.iter().filter(|t| t.status == "running").count();
-
- if total_tasks == 0 {
- reasons.push("No tasks created yet".to_string());
- suggestions.push("Create tasks to implement the planned work".to_string());
- } else {
- reasons.push(format!("{} of {} tasks completed", done_tasks, total_tasks));
- }
-
- if running_tasks > 0 {
- reasons.push(format!("{} task(s) still running", running_tasks));
- suggestions.push("Wait for running tasks to complete".to_string());
- }
-
- let ready = total_tasks > 0 && done_tasks == total_tasks;
-
- // For simple contracts, execute is the terminal phase - suggest completion
- if ready && contract.contract.contract_type == "simple" {
- suggestions.push("Mark the contract as completed".to_string());
- }
-
- PhaseReadinessAnalysis {
- ready,
- summary: if ready {
- if contract.contract.contract_type == "simple" {
- "All tasks completed. Contract can be marked as completed.".to_string()
- } else {
- "All tasks completed. Ready for Review phase.".to_string()
- }
- } else if total_tasks == 0 {
- "No tasks created yet. Create and complete tasks before reviewing.".to_string()
- } else {
- format!("{}/{} tasks complete. Finish remaining tasks before review.", done_tasks, total_tasks)
- },
- reasons,
- suggestions,
- }
- }
- "review" => {
- let review_files = contract.files.iter()
- .filter(|f| f.contract_phase.as_deref() == Some("review"))
- .count();
-
- if review_files == 0 {
- suggestions.push("Create review checklist or release notes".to_string());
- } else {
- // Review documentation exists - suggest completion
- suggestions.push("Mark the contract as completed".to_string());
- }
-
- PhaseReadinessAnalysis {
- ready: review_files > 0,
- summary: if review_files > 0 {
- "Review documentation complete. Contract can be marked as completed.".to_string()
- } else {
- "Review phase needs documentation before completion.".to_string()
- },
- reasons: vec!["Review is the final phase".to_string()],
- suggestions,
- }
- }
- _ => PhaseReadinessAnalysis {
- ready: false,
- summary: "Unknown phase".to_string(),
- reasons: vec!["Phase not recognized".to_string()],
- suggestions: vec![],
- },
- }
-}
-
-// =============================================================================
-// Contract Chat History Endpoints
-// =============================================================================
-
-/// Get contract chat history
-#[utoipa::path(
- get,
- path = "/api/v1/contracts/{id}/chat/history",
- responses(
- (status = 200, description = "Chat history retrieved successfully", body = ContractChatHistoryResponse),
- (status = 401, description = "Unauthorized"),
- (status = 404, description = "Contract not found"),
- (status = 500, description = "Internal server error")
- ),
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn get_contract_chat_history(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(contract_id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "Database not configured" })),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await {
- Ok(Some(_)) => {}
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(json!({ "error": "Contract not found" })),
- )
- .into_response();
- }
- Err(e) => {
- tracing::error!("Database error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Database error: {}", e) })),
- )
- .into_response();
- }
- }
-
- // Get or create conversation
- let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, auth.owner_id).await {
- Ok(conv) => conv,
- Err(e) => {
- tracing::error!("Failed to get contract conversation: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Failed to get conversation: {}", e) })),
- )
- .into_response();
- }
- };
-
- // Get messages
- let messages = match repository::list_contract_chat_messages(pool, conversation.id, Some(100)).await {
- Ok(msgs) => msgs,
- Err(e) => {
- tracing::error!("Failed to list contract chat messages: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Failed to list messages: {}", e) })),
- )
- .into_response();
- }
- };
-
- (
- StatusCode::OK,
- Json(ContractChatHistoryResponse {
- contract_id,
- conversation_id: conversation.id,
- messages,
- }),
- )
- .into_response()
-}
-
-/// Clear contract chat history (creates a new conversation)
-#[utoipa::path(
- delete,
- path = "/api/v1/contracts/{id}/chat/history",
- responses(
- (status = 200, description = "Chat history cleared successfully"),
- (status = 401, description = "Unauthorized"),
- (status = 404, description = "Contract not found"),
- (status = 500, description = "Internal server error")
- ),
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn clear_contract_chat_history(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(contract_id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "Database not configured" })),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await {
- Ok(Some(_)) => {}
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(json!({ "error": "Contract not found" })),
- )
- .into_response();
- }
- Err(e) => {
- tracing::error!("Database error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Database error: {}", e) })),
- )
- .into_response();
- }
- }
-
- // Clear conversation (archives existing and creates new)
- match repository::clear_contract_conversation(pool, contract_id, auth.owner_id).await {
- Ok(new_conversation) => {
- (
- StatusCode::OK,
- Json(json!({
- "message": "Chat history cleared",
- "newConversationId": new_conversation.id
- })),
- )
- .into_response()
- }
- Err(e) => {
- tracing::error!("Failed to clear contract conversation: {}", e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Failed to clear history: {}", e) })),
- )
- .into_response()
- }
- }
-}
diff --git a/makima/src/server/handlers/contract_daemon.rs b/makima/src/server/handlers/contract_daemon.rs
deleted file mode 100644
index 5f56f06..0000000
--- a/makima/src/server/handlers/contract_daemon.rs
+++ /dev/null
@@ -1,936 +0,0 @@
-//! HTTP handlers for daemon-to-contract interaction.
-//!
-//! These endpoints allow tasks running in daemons to interact with their
-//! associated contracts via the contract.sh script. Authentication is via
-//! tool keys registered by the daemon when starting a task.
-
-use axum::{
- extract::{Path, State},
- http::StatusCode,
- response::IntoResponse,
- Json,
-};
-use serde::{Deserialize, Serialize};
-use utoipa::ToSchema;
-use uuid::Uuid;
-
-use crate::db::{models::FileSummary, repository};
-use crate::llm::phase_guidance::{self, PhaseChecklist, TaskInfo};
-use crate::server::auth::Authenticated;
-use crate::server::messages::ApiError;
-use crate::server::state::SharedState;
-
-// =============================================================================
-// Request/Response Types
-// =============================================================================
-
-/// Contract status response for daemon.
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ContractStatusResponse {
- pub id: Uuid,
- pub name: String,
- pub phase: String,
- pub status: String,
- pub description: Option<String>,
-}
-
-/// Contract goals response.
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ContractGoalsResponse {
- /// Description serves as goals for the contract
- pub description: Option<String>,
- pub phase: String,
- pub phase_guidance: String,
-}
-
-/// Progress report request from daemon.
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ProgressReportRequest {
- pub message: String,
- #[serde(default)]
- pub task_id: Option<Uuid>,
-}
-
-/// Suggested action from server.
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct SuggestedActionResponse {
- pub action: String,
- pub description: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub data: Option<serde_json::Value>,
-}
-
-/// Completion action request.
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct CompletionActionRequest {
- #[serde(default)]
- pub task_id: Option<Uuid>,
- #[serde(default)]
- pub files_modified: Vec<String>,
- #[serde(default)]
- pub lines_added: i32,
- #[serde(default)]
- pub lines_removed: i32,
- #[serde(default)]
- pub has_code_changes: bool,
-}
-
-/// Recommended completion action.
-#[derive(Debug, Clone, Serialize, ToSchema)]
-#[serde(rename_all = "lowercase")]
-pub enum CompletionAction {
- Branch,
- Merge,
- Pr,
- None,
-}
-
-impl std::fmt::Display for CompletionAction {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- CompletionAction::Branch => write!(f, "branch"),
- CompletionAction::Merge => write!(f, "merge"),
- CompletionAction::Pr => write!(f, "pr"),
- CompletionAction::None => write!(f, "none"),
- }
- }
-}
-
-/// Completion action response.
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct CompletionActionResponse {
- pub action: String,
- pub reason: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub branch_name: Option<String>,
-}
-
-/// Create file request from daemon.
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct CreateFileRequest {
- pub name: String,
- pub content: String,
- #[serde(default)]
- pub template_id: Option<String>,
-}
-
-/// Update file request from daemon.
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct DaemonUpdateFileRequest {
- /// Content to update in the file (as markdown body element)
- pub content: String,
-}
-
-// =============================================================================
-// Handlers
-// =============================================================================
-
-/// Get contract status for daemon.
-#[utoipa::path(
- get,
- path = "/api/v1/contracts/{id}/daemon/status",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- responses(
- (status = 200, description = "Contract status", body = ContractStatusResponse),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- ),
- security(
- ("tool_key" = []),
- ("api_key" = [])
- ),
- tag = "Contract Daemon"
-)]
-pub async fn get_contract_status(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(contract)) => Json(ContractStatusResponse {
- id: contract.id,
- name: contract.name,
- phase: contract.phase,
- status: contract.status,
- description: contract.description,
- })
- .into_response(),
- Ok(None) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "Contract not found")),
- )
- .into_response(),
- Err(e) => {
- tracing::error!("Failed to get contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Get phase deliverables checklist.
-#[utoipa::path(
- get,
- path = "/api/v1/contracts/{id}/daemon/checklist",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- responses(
- (status = 200, description = "Phase checklist", body = PhaseChecklist),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- ),
- security(
- ("tool_key" = []),
- ("api_key" = [])
- ),
- tag = "Contract Daemon"
-)]
-pub async fn get_contract_checklist(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Get contract
- 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();
- }
- };
-
- // Get completed deliverables for the current phase
- let completed_deliverables = contract.get_completed_deliverables(&contract.phase);
-
- // Get tasks for this contract
- let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await {
- Ok(t) => t
- .into_iter()
- .map(|t| TaskInfo {
- name: t.name,
- status: t.status,
- })
- .collect::<Vec<_>>(),
- Err(e) => {
- tracing::warn!("Failed to get tasks for contract {}: {}", id, e);
- Vec::new()
- }
- };
-
- // Check if repository is configured
- let has_repository = match repository::list_contract_repositories(pool, id).await {
- Ok(repos) => !repos.is_empty(),
- Err(_) => false,
- };
-
- let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type);
-
- Json(checklist).into_response()
-}
-
-/// Get contract goals.
-#[utoipa::path(
- get,
- path = "/api/v1/contracts/{id}/daemon/goals",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- responses(
- (status = 200, description = "Contract goals", body = ContractGoalsResponse),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- ),
- security(
- ("tool_key" = []),
- ("api_key" = [])
- ),
- tag = "Contract Daemon"
-)]
-pub async fn get_contract_goals(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(contract)) => {
- let deliverables = phase_guidance::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type);
- Json(ContractGoalsResponse {
- description: contract.description,
- phase: contract.phase,
- phase_guidance: deliverables.guidance,
- })
- .into_response()
- }
- Ok(None) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "Contract not found")),
- )
- .into_response(),
- Err(e) => {
- tracing::error!("Failed to get contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Post progress report to contract.
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/daemon/report",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- request_body = ProgressReportRequest,
- responses(
- (status = 200, description = "Report received"),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- ),
- security(
- ("tool_key" = []),
- ("api_key" = [])
- ),
- tag = "Contract Daemon"
-)]
-pub async fn post_progress_report(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- Json(req): Json<ProgressReportRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- // Log the report as a contract event
- let event_type = "progress_report";
- let payload = serde_json::json!({
- "message": req.message,
- "task_id": req.task_id,
- });
-
- if let Err(e) = repository::record_contract_event(pool, id, event_type, Some(payload)).await {
- tracing::warn!("Failed to create contract event: {}", e);
- }
-
- Json(serde_json::json!({"status": "received"})).into_response()
-}
-
-/// Get suggested action based on contract state.
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/daemon/suggest-action",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- responses(
- (status = 200, description = "Suggested action", body = SuggestedActionResponse),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- ),
- security(
- ("tool_key" = []),
- ("api_key" = [])
- ),
- tag = "Contract Daemon"
-)]
-pub async fn get_suggest_action(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Get contract
- 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();
- }
- };
-
- // Get completed deliverables and tasks for checklist
- let completed_deliverables = contract.get_completed_deliverables(&contract.phase);
-
- let tasks = repository::list_tasks_in_contract(pool, id, auth.owner_id)
- .await
- .unwrap_or_default()
- .into_iter()
- .map(|t| TaskInfo {
- name: t.name,
- status: t.status,
- })
- .collect::<Vec<_>>();
-
- let has_repository = repository::list_contract_repositories(pool, id)
- .await
- .map(|r| !r.is_empty())
- .unwrap_or(false);
-
- let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type);
-
- // Determine suggested action based on checklist
- let (action, description) = if !checklist.suggestions.is_empty() {
- ("follow_suggestion", checklist.suggestions.first().unwrap().clone())
- } else if checklist.completion_percentage >= 100 {
- ("advance_phase", format!("Phase {} is complete, consider advancing to next phase", contract.phase))
- } else {
- ("continue", format!("Continue working on {} phase ({}% complete)", contract.phase, checklist.completion_percentage))
- };
-
- Json(SuggestedActionResponse {
- action: action.to_string(),
- description,
- data: None,
- })
- .into_response()
-}
-
-/// Get recommended completion action.
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/daemon/completion-action",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- request_body = CompletionActionRequest,
- responses(
- (status = 200, description = "Recommended completion action", body = CompletionActionResponse),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- ),
- security(
- ("tool_key" = []),
- ("api_key" = [])
- ),
- tag = "Contract Daemon"
-)]
-pub async fn get_completion_action(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- Json(req): Json<CompletionActionRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Get contract
- 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();
- }
- };
-
- // Determine completion action based on phase and changes
- let has_changes = !req.files_modified.is_empty() || req.lines_added > 0 || req.lines_removed > 0;
- let has_significant_changes = req.lines_added + req.lines_removed > 50;
-
- let (action, reason) = match contract.phase.as_str() {
- "research" | "specify" => {
- if has_changes {
- (CompletionAction::Merge, "Early phase changes can be merged directly".to_string())
- } else {
- (CompletionAction::None, "No changes to commit".to_string())
- }
- }
- "plan" => {
- if has_significant_changes {
- (CompletionAction::Pr, "Significant planning changes require review".to_string())
- } else if has_changes {
- (CompletionAction::Merge, "Minor planning changes can be merged".to_string())
- } else {
- (CompletionAction::None, "No changes to commit".to_string())
- }
- }
- "execute" => {
- if req.has_code_changes {
- (CompletionAction::Pr, "Code changes in execute phase require review".to_string())
- } else if has_changes {
- (CompletionAction::Branch, "Documentation changes can be branched".to_string())
- } else {
- (CompletionAction::None, "No changes to commit".to_string())
- }
- }
- "review" => {
- if has_changes {
- (CompletionAction::Pr, "Review phase changes should be reviewed".to_string())
- } else {
- (CompletionAction::None, "No changes to commit".to_string())
- }
- }
- _ => (CompletionAction::None, "Unknown phase".to_string()),
- };
-
- // Generate branch name based on contract
- let branch_name = if matches!(action, CompletionAction::Branch | CompletionAction::Pr) {
- let slug = contract.name.to_lowercase().replace(' ', "-");
- Some(format!("contract/{}", slug))
- } else {
- None
- };
-
- Json(CompletionActionResponse {
- action: action.to_string(),
- reason,
- branch_name,
- })
- .into_response()
-}
-
-/// List contract files for daemon.
-#[utoipa::path(
- get,
- path = "/api/v1/contracts/{id}/daemon/files",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- responses(
- (status = 200, description = "List of contract files", body = Vec<FileSummary>),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- ),
- security(
- ("tool_key" = []),
- ("api_key" = [])
- ),
- tag = "Contract Daemon"
-)]
-pub async fn list_contract_files(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- match repository::list_files_in_contract(pool, id, auth.owner_id).await {
- Ok(files) => Json(files).into_response(),
- Err(e) => {
- tracing::error!("Failed to list files for contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Get a specific contract file.
-#[utoipa::path(
- get,
- path = "/api/v1/contracts/{id}/daemon/files/{file_id}",
- params(
- ("id" = Uuid, Path, description = "Contract ID"),
- ("file_id" = Uuid, Path, description = "File ID")
- ),
- responses(
- (status = 200, description = "File content"),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract or file not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- ),
- security(
- ("tool_key" = []),
- ("api_key" = [])
- ),
- tag = "Contract Daemon"
-)]
-pub async fn get_contract_file(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path((id, file_id)): Path<(Uuid, Uuid)>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- // Get file and verify it belongs to this contract
- match repository::get_file_for_owner(pool, file_id, auth.owner_id).await {
- Ok(Some(file)) => {
- if file.contract_id != Some(id) {
- return (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "File not found in this contract")),
- )
- .into_response();
- }
- Json(file).into_response()
- }
- Ok(None) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "File not found")),
- )
- .into_response(),
- Err(e) => {
- tracing::error!("Failed to get file {}: {}", file_id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Update a contract file.
-#[utoipa::path(
- put,
- path = "/api/v1/contracts/{id}/daemon/files/{file_id}",
- params(
- ("id" = Uuid, Path, description = "Contract ID"),
- ("file_id" = Uuid, Path, description = "File ID")
- ),
- request_body = DaemonUpdateFileRequest,
- responses(
- (status = 200, description = "File updated"),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract or file not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- ),
- security(
- ("tool_key" = []),
- ("api_key" = [])
- ),
- tag = "Contract Daemon"
-)]
-pub async fn update_contract_file(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path((id, file_id)): Path<(Uuid, Uuid)>,
- Json(req): Json<DaemonUpdateFileRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- // Get file and verify it belongs to this contract
- let file = match repository::get_file_for_owner(pool, file_id, auth.owner_id).await {
- Ok(Some(f)) => f,
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "File not found")),
- )
- .into_response();
- }
- Err(e) => {
- tracing::error!("Failed to get file {}: {}", file_id, e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response();
- }
- };
-
- if file.contract_id != Some(id) {
- return (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "File not found in this contract")),
- )
- .into_response();
- }
-
- // Update the file with content parsed as markdown
- let body = crate::llm::markdown_to_body(&req.content);
- let update_req = crate::db::models::UpdateFileRequest {
- name: None,
- description: None,
- transcript: None,
- summary: None,
- body: Some(body),
- version: None,
- repo_file_path: None,
- };
-
- match repository::update_file_for_owner(pool, file_id, auth.owner_id, update_req).await {
- Ok(Some(updated)) => Json(updated).into_response(),
- Ok(None) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "File not found")),
- )
- .into_response(),
- Err(e) => {
- tracing::error!("Failed to update file {}: {}", file_id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", format!("{}", e))),
- )
- .into_response()
- }
- }
-}
-
-/// Create a new contract file.
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/daemon/files",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- request_body = CreateFileRequest,
- responses(
- (status = 201, description = "File created"),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- ),
- security(
- ("tool_key" = []),
- ("api_key" = [])
- ),
- tag = "Contract Daemon"
-)]
-pub async fn create_contract_file(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- Json(req): Json<CreateFileRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- };
-
- // Create the file with content parsed as markdown
- let body = crate::llm::markdown_to_body(&req.content);
- let create_req = crate::db::models::CreateFileRequest {
- contract_id: id,
- name: Some(req.name),
- description: None,
- transcript: vec![],
- location: None,
- body,
- repo_file_path: None,
- contract_phase: None, // Will be looked up from contract's current phase
- };
-
- match repository::create_file_for_owner(pool, auth.owner_id, create_req).await {
- Ok(file) => (StatusCode::CREATED, Json(file)).into_response(),
- Err(e) => {
- tracing::error!("Failed to create file for contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
diff --git a/makima/src/server/handlers/contract_discuss.rs b/makima/src/server/handlers/contract_discuss.rs
deleted file mode 100644
index 1f98f53..0000000
--- a/makima/src/server/handlers/contract_discuss.rs
+++ /dev/null
@@ -1,592 +0,0 @@
-//! Discussion endpoint for LLM-powered contract creation.
-//!
-//! This handler provides an ephemeral conversation with Makima to help users
-//! define and create contracts through natural dialogue.
-
-use axum::{
- extract::State,
- http::StatusCode,
- response::IntoResponse,
- Json,
-};
-use serde::{Deserialize, Serialize};
-use serde_json::json;
-use utoipa::ToSchema;
-use uuid::Uuid;
-
-use crate::db::{models::CreateContractRequest, repository};
-use crate::llm::{
- claude::{self, ClaudeClient, ClaudeError, ClaudeModel},
- groq::{GroqClient, GroqError, Message, ToolCallResponse},
- discuss_tools::{parse_discuss_tool_call, DiscussToolRequest, DISCUSS_TOOLS},
- LlmModel, ToolCall, ToolResult, UserQuestion,
-};
-use crate::server::auth::Authenticated;
-use crate::server::state::SharedState;
-
-/// Maximum number of tool-calling rounds to prevent infinite loops
-const MAX_TOOL_ROUNDS: usize = 10;
-
-/// System prompt for Makima character in contract discussions
-const DISCUSS_SYSTEM_PROMPT: &str = r#"
-You are Makima, an AI assistant on the makima.jp platform. You help users define and create contracts for their projects through natural conversation.
-
-## Your Personality
-- Professional yet personable
-- Focused on understanding the user's actual needs
-- Ask clarifying questions when requirements are vague
-- Guide the conversation toward actionable outcomes
-- Comfortable making recommendations based on experience
-
-## Your Goal
-Help the user flesh out their project idea into a well-defined contract. A contract on makima.jp includes:
-- A clear name and description
-- The right contract type (simple, specification, or execute)
-- Understanding of the scope and requirements
-
-## Contract Types
-- **simple**: Quick tasks with minimal planning (plan -> execute phases only)
-- **specification**: Full lifecycle projects (research -> specify -> plan -> execute -> review)
-- **execute**: Direct implementation when requirements are already clear (execute phase only)
-
-## Guidelines
-1. **Start by understanding**: Ask about what they want to build
-2. **Clarify scope**: Is this a quick fix, a new feature, or a full project?
-3. **Gather requirements**: What are the must-haves vs nice-to-haves?
-4. **Identify context**: Is there existing code? Which repository?
-5. **Recommend type**: Suggest the appropriate contract type
-6. **Confirm and create**: When the user is satisfied, create the contract
-
-## When to Create the Contract
-Create the contract when:
-- You have a clear understanding of what the user wants
-- The user has confirmed they're ready to proceed
-- You've gathered enough information for a meaningful contract
-
-Do NOT create the contract if:
-- The user is still exploring ideas
-- Key information is missing
-- The user hasn't indicated readiness
-
-{transcript_context}
-"#;
-
-/// Chat message in history
-#[derive(Debug, Clone, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ChatMessage {
- /// Role: "user" or "assistant"
- pub role: String,
- /// Message content
- pub content: String,
-}
-
-/// Request to discuss a potential contract
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct DiscussContractRequest {
- /// The user's message
- pub message: String,
- /// Optional model selection (default: claude-sonnet)
- #[serde(default)]
- pub model: Option<String>,
- /// Conversation history for context continuity
- #[serde(default)]
- pub history: Option<Vec<ChatMessage>>,
- /// Optional transcript context from current session
- #[serde(default)]
- pub transcript_context: Option<String>,
-}
-
-/// Response from the discussion endpoint
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct DiscussContractResponse {
- /// Makima's response message
- pub response: String,
- /// Tool calls that were executed (e.g., create_contract)
- pub tool_calls: Vec<ToolCallInfo>,
- /// If a contract was created, its details
- #[serde(skip_serializing_if = "Option::is_none")]
- pub created_contract: Option<CreatedContractInfo>,
- /// Pending questions (if LLM needs clarification)
- #[serde(skip_serializing_if = "Option::is_none")]
- pub pending_questions: Option<Vec<UserQuestion>>,
-}
-
-/// Information about a tool call that was executed
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ToolCallInfo {
- pub name: String,
- pub result: ToolResult,
-}
-
-/// Information about a created contract
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct CreatedContractInfo {
- pub id: String,
- pub name: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub description: Option<String>,
- pub contract_type: String,
- pub initial_phase: String,
-}
-
-/// Enum to hold LLM clients
-enum LlmClient {
- Groq(GroqClient),
- Claude(ClaudeClient),
-}
-
-/// Unified result from LLM call
-struct LlmResult {
- content: Option<String>,
- tool_calls: Vec<ToolCall>,
- raw_tool_calls: Vec<ToolCallResponse>,
- finish_reason: String,
-}
-
-/// Discuss a potential contract with Makima
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/discuss",
- request_body = DiscussContractRequest,
- responses(
- (status = 200, description = "Discussion completed successfully", body = DiscussContractResponse),
- (status = 401, description = "Unauthorized"),
- (status = 500, description = "Internal server error")
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn discuss_contract_handler(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Json(request): Json<DiscussContractRequest>,
-) -> impl IntoResponse {
- // Check if database is configured
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "Database not configured" })),
- )
- .into_response();
- };
-
- // Parse model selection (default to Claude Sonnet)
- let model = request
- .model
- .as_ref()
- .and_then(|m| LlmModel::from_str(m))
- .unwrap_or(LlmModel::ClaudeSonnet);
-
- tracing::info!("Contract discussion using LLM model: {:?}", model);
-
- // Initialize the appropriate LLM client
- let llm_client = match model {
- LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) {
- Ok(client) => LlmClient::Claude(client),
- Err(ClaudeError::MissingApiKey) => {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
- )
- .into_response();
- }
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Claude client error: {}", e) })),
- )
- .into_response();
- }
- },
- LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) {
- Ok(client) => LlmClient::Claude(client),
- Err(ClaudeError::MissingApiKey) => {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
- )
- .into_response();
- }
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Claude client error: {}", e) })),
- )
- .into_response();
- }
- },
- LlmModel::GroqKimi => match GroqClient::from_env() {
- Ok(client) => LlmClient::Groq(client),
- Err(GroqError::MissingApiKey) => {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "GROQ_API_KEY not configured" })),
- )
- .into_response();
- }
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Groq client error: {}", e) })),
- )
- .into_response();
- }
- },
- };
-
- // Build system prompt with optional transcript context
- let transcript_section = match &request.transcript_context {
- Some(ctx) => format!(
- "\n## Current Session Context\nThe user has been recording a session. Here's the transcript:\n\n{}\n",
- ctx
- ),
- None => String::new(),
- };
-
- let system_prompt = DISCUSS_SYSTEM_PROMPT.replace("{transcript_context}", &transcript_section);
-
- // Run the discussion agentic loop
- run_discuss_agentic_loop(
- pool,
- &llm_client,
- system_prompt,
- &request,
- auth.owner_id,
- )
- .await
-}
-
-/// Run the agentic loop for contract discussion
-async fn run_discuss_agentic_loop(
- pool: &sqlx::PgPool,
- llm_client: &LlmClient,
- system_prompt: String,
- request: &DiscussContractRequest,
- owner_id: Uuid,
-) -> axum::response::Response {
- // Build initial messages
- let mut messages = vec![Message {
- role: "system".to_string(),
- content: Some(system_prompt),
- tool_calls: None,
- tool_call_id: None,
- }];
-
- // Add conversation history if provided
- if let Some(history) = &request.history {
- for msg in history {
- messages.push(Message {
- role: msg.role.clone(),
- content: Some(msg.content.clone()),
- tool_calls: None,
- tool_call_id: None,
- });
- }
- }
-
- // Add current user message
- messages.push(Message {
- role: "user".to_string(),
- content: Some(request.message.clone()),
- tool_calls: None,
- tool_call_id: None,
- });
-
- // State for tracking
- let mut all_tool_call_infos: Vec<ToolCallInfo> = Vec::new();
- let mut final_response: Option<String> = None;
- let mut created_contract: Option<CreatedContractInfo> = None;
- let mut pending_questions: Option<Vec<UserQuestion>> = None;
-
- // Multi-turn agentic tool calling loop
- for round in 0..MAX_TOOL_ROUNDS {
- tracing::info!(
- round = round,
- total_tool_calls = all_tool_call_infos.len(),
- "Contract discussion loop iteration"
- );
-
- // Call the appropriate LLM API
- let result = match llm_client {
- LlmClient::Groq(groq) => {
- match groq.chat_with_tools(messages.clone(), &DISCUSS_TOOLS).await {
- Ok(r) => LlmResult {
- content: r.content,
- tool_calls: r.tool_calls,
- raw_tool_calls: r.raw_tool_calls,
- finish_reason: r.finish_reason,
- },
- Err(e) => {
- tracing::error!("Groq API error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("LLM API error: {}", e) })),
- )
- .into_response();
- }
- }
- }
- LlmClient::Claude(claude_client) => {
- let claude_messages = claude::groq_messages_to_claude(&messages);
- match claude_client
- .chat_with_tools(claude_messages, &DISCUSS_TOOLS)
- .await
- {
- Ok(r) => {
- let raw_tool_calls: Vec<ToolCallResponse> = r
- .tool_calls
- .iter()
- .map(|tc| ToolCallResponse {
- id: tc.id.clone(),
- call_type: "function".to_string(),
- function: crate::llm::groq::FunctionCall {
- name: tc.name.clone(),
- arguments: tc.arguments.to_string(),
- },
- })
- .collect();
-
- LlmResult {
- content: r.content,
- tool_calls: r.tool_calls,
- raw_tool_calls,
- finish_reason: r.stop_reason,
- }
- }
- Err(e) => {
- tracing::error!("Claude API error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("LLM API error: {}", e) })),
- )
- .into_response();
- }
- }
- }
- };
-
- // Check if there are tool calls to execute
- if result.tool_calls.is_empty() {
- final_response = result.content;
- break;
- }
-
- // Add assistant message with tool calls to conversation
- messages.push(Message {
- role: "assistant".to_string(),
- content: result.content.clone(),
- tool_calls: Some(result.raw_tool_calls.clone()),
- tool_call_id: None,
- });
-
- // Execute each tool call
- for (i, tool_call) in result.tool_calls.iter().enumerate() {
- tracing::info!(tool = %tool_call.name, round = round, "Executing discussion tool call");
-
- // Parse the tool call
- let mut execution_result = parse_discuss_tool_call(tool_call);
-
- // Handle async discussion tool requests
- if let Some(discuss_request) = execution_result.request.take() {
- let async_result =
- handle_discuss_request(pool, discuss_request, owner_id).await;
- execution_result.success = async_result.success;
- execution_result.message = async_result.message;
- execution_result.data = async_result.data;
-
- // Check if a contract was created
- if let Some(ref data) = execution_result.data {
- if let Some(contract_info) = data.get("createdContract") {
- created_contract = Some(CreatedContractInfo {
- id: contract_info["id"].as_str().unwrap_or("").to_string(),
- name: contract_info["name"].as_str().unwrap_or("").to_string(),
- description: contract_info["description"].as_str().map(|s| s.to_string()),
- contract_type: contract_info["contractType"].as_str().unwrap_or("").to_string(),
- initial_phase: contract_info["initialPhase"].as_str().unwrap_or("").to_string(),
- });
- }
- }
- }
-
- // Check for pending user questions
- if let Some(questions) = execution_result.pending_questions {
- tracing::info!(
- question_count = questions.len(),
- "Discussion LLM requesting user input"
- );
- pending_questions = Some(questions);
- all_tool_call_infos.push(ToolCallInfo {
- name: tool_call.name.clone(),
- result: ToolResult {
- success: execution_result.success,
- message: execution_result.message.clone(),
- },
- });
- break;
- }
-
- // Build tool result message
- let result_content = if let Some(data) = &execution_result.data {
- json!({
- "success": execution_result.success,
- "message": execution_result.message,
- "data": data
- })
- .to_string()
- } else {
- json!({
- "success": execution_result.success,
- "message": execution_result.message
- })
- .to_string()
- };
-
- // Add tool result message
- let tool_call_id = match llm_client {
- LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(),
- LlmClient::Claude(_) => tool_call.id.clone(),
- };
-
- messages.push(Message {
- role: "tool".to_string(),
- content: Some(result_content),
- tool_calls: None,
- tool_call_id: Some(tool_call_id),
- });
-
- // Track for response
- all_tool_call_infos.push(ToolCallInfo {
- name: tool_call.name.clone(),
- result: ToolResult {
- success: execution_result.success,
- message: execution_result.message,
- },
- });
- }
-
- // If user questions are pending, pause
- if pending_questions.is_some() {
- final_response = result.content;
- break;
- }
-
- // If finish reason indicates completion, exit loop
- let finish_lower = result.finish_reason.to_lowercase();
- if finish_lower == "stop" || finish_lower == "end_turn" {
- final_response = result.content;
- break;
- }
- }
-
- // Build response
- let response_text = final_response.unwrap_or_else(|| {
- if all_tool_call_infos.is_empty() {
- "I couldn't understand your request. Please try rephrasing.".to_string()
- } else {
- "Done!".to_string()
- }
- });
-
- (
- StatusCode::OK,
- Json(DiscussContractResponse {
- response: response_text,
- tool_calls: all_tool_call_infos,
- created_contract,
- pending_questions,
- }),
- )
- .into_response()
-}
-
-/// Result from handling an async discussion tool request
-struct DiscussRequestResult {
- success: bool,
- message: String,
- data: Option<serde_json::Value>,
-}
-
-/// Handle async discussion tool requests that require database access
-async fn handle_discuss_request(
- pool: &sqlx::PgPool,
- request: DiscussToolRequest,
- owner_id: Uuid,
-) -> DiscussRequestResult {
- match request {
- DiscussToolRequest::CreateContract {
- name,
- description,
- contract_type,
- repository_url,
- local_only,
- } => {
- // Create the contract request
- let create_req = CreateContractRequest {
- name: name.clone(),
- description: Some(description.clone()),
- contract_type: Some(contract_type.clone()),
- template_id: None,
- initial_phase: None,
- autonomous_loop: None,
- phase_guard: None,
- local_only: Some(local_only),
- auto_merge_local: None,
- };
-
- match repository::create_contract_for_owner(pool, owner_id, create_req).await {
- Ok(contract) => {
- // If repository URL was provided, try to add it
- if let Some(repo_url) = repository_url {
- // Try to add as remote repository
- let add_result = repository::add_remote_repository(
- pool,
- contract.id,
- &format!("{} Repository", name),
- &repo_url,
- true, // is_primary
- )
- .await;
-
- if let Err(e) = add_result {
- tracing::warn!(
- "Failed to add repository to contract {}: {}",
- contract.id,
- e
- );
- }
- }
-
- DiscussRequestResult {
- success: true,
- message: format!("Contract '{}' created successfully!", contract.name),
- data: Some(json!({
- "createdContract": {
- "id": contract.id.to_string(),
- "name": contract.name,
- "description": contract.description,
- "contractType": contract.contract_type,
- "initialPhase": contract.phase,
- }
- })),
- }
- }
- Err(e) => {
- tracing::error!("Failed to create contract: {}", e);
- DiscussRequestResult {
- success: false,
- message: format!("Failed to create contract: {}", e),
- data: None,
- }
- }
- }
- }
- }
-}
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs
deleted file mode 100644
index bdd4d40..0000000
--- a/makima/src/server/handlers/contracts.rs
+++ /dev/null
@@ -1,2376 +0,0 @@
-//! HTTP handlers for contract CRUD operations.
-
-use axum::{
- extract::{Path, State},
- http::StatusCode,
- response::IntoResponse,
- Json,
-};
-use serde::Deserialize;
-use utoipa::ToSchema;
-use uuid::Uuid;
-
-use crate::db::models::{
- AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest,
- ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations,
- CreateContractRequest, CreateManagedRepositoryRequest, PhaseChangeResult,
- UpdateContractRequest, UpdateTaskRequest,
-};
-use crate::db::repository::{self, RepositoryError};
-use crate::llm::PhaseDeliverables;
-use crate::server::auth::Authenticated;
-use crate::server::messages::ApiError;
-use crate::server::state::SharedState;
-
-// =============================================================================
-// Deliverable Validation
-// =============================================================================
-
-/// Error type for deliverable validation failures
-#[derive(Debug, Clone)]
-pub struct DeliverableValidationError {
- /// The error message with details about valid deliverables
- pub message: String,
-}
-
-impl DeliverableValidationError {
- pub fn new(message: impl Into<String>) -> Self {
- Self {
- message: message.into(),
- }
- }
-}
-
-impl std::fmt::Display for DeliverableValidationError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{}", self.message)
- }
-}
-
-impl std::error::Error for DeliverableValidationError {}
-
-/// Validates that a deliverable ID is valid for the given phase deliverables.
-///
-/// # Arguments
-/// * `deliverable_id` - The deliverable ID to validate
-/// * `phase_deliverables` - The phase deliverables configuration to validate against
-///
-/// # Returns
-/// * `Ok(())` if the deliverable is valid
-/// * `Err(DeliverableValidationError)` if the deliverable is not valid
-pub fn validate_deliverable(
- deliverable_id: &str,
- phase_deliverables: &PhaseDeliverables,
-) -> Result<(), DeliverableValidationError> {
- let valid_deliverable = phase_deliverables
- .deliverables
- .iter()
- .any(|d| d.id == deliverable_id);
-
- if valid_deliverable {
- Ok(())
- } else {
- let valid_ids: Vec<&str> = phase_deliverables
- .deliverables
- .iter()
- .map(|d| d.id.as_str())
- .collect();
-
- Err(DeliverableValidationError::new(format!(
- "Invalid deliverable '{}' for {} phase. Valid IDs: [{}]",
- deliverable_id,
- phase_deliverables.phase,
- valid_ids.join(", ")
- )))
- }
-}
-
-// =============================================================================
-// Supervisor Repository Update Helper
-// =============================================================================
-
-/// Helper function to update the supervisor task with repository info when a primary repo is added.
-/// This ensures the supervisor has access to the repository when it starts.
-async fn update_supervisor_with_repo_if_needed(
- pool: &sqlx::PgPool,
- contract_id: uuid::Uuid,
- owner_id: uuid::Uuid,
- repo: &ContractRepository,
-) {
- // Only update for primary repositories
- if !repo.is_primary {
- return;
- }
-
- // Get the supervisor task
- let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await {
- Ok(Some(s)) => s,
- Ok(None) => {
- tracing::debug!(contract_id = %contract_id, "No supervisor task found");
- return;
- }
- Err(e) => {
- tracing::warn!(contract_id = %contract_id, error = %e, "Failed to get supervisor task");
- return;
- }
- };
-
- // Only update if supervisor doesn't have a repository URL yet
- if supervisor.repository_url.is_some() {
- tracing::debug!(
- supervisor_id = %supervisor.id,
- "Supervisor already has repository URL"
- );
- return;
- }
-
- // Get repository URL (for remote repos) or local path (for local repos)
- let repo_url = repo.repository_url.clone().or_else(|| repo.local_path.clone());
-
- if repo_url.is_none() && repo.source_type != "managed" {
- tracing::debug!(
- supervisor_id = %supervisor.id,
- "Repository has no URL or path to assign"
- );
- return;
- }
-
- // Update supervisor task with repository info
- let update_req = UpdateTaskRequest {
- repository_url: repo_url,
- version: Some(supervisor.version),
- ..Default::default()
- };
-
- match repository::update_task_for_owner(pool, supervisor.id, owner_id, update_req).await {
- Ok(Some(updated)) => {
- tracing::info!(
- supervisor_id = %updated.id,
- repository_url = ?updated.repository_url,
- "Updated supervisor task with repository URL"
- );
- }
- Ok(None) => {
- tracing::warn!(supervisor_id = %supervisor.id, "Supervisor task not found during update");
- }
- Err(e) => {
- tracing::warn!(
- supervisor_id = %supervisor.id,
- error = %e,
- "Failed to update supervisor with repository URL"
- );
- }
- }
-}
-
-/// List all root contracts (no parent) for the authenticated user's owner.
-#[utoipa::path(
- get,
- path = "/api/v1/contracts",
- responses(
- (status = 200, description = "List of root contracts", body = ContractListResponse),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn list_contracts(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- match repository::list_contracts_for_owner(pool, auth.owner_id).await {
- Ok(contracts) => {
- let total = contracts.len() as i64;
- Json(ContractListResponse { contracts, total }).into_response()
- }
- Err(e) => {
- tracing::error!("Failed to list contracts: {}", e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Get a contract by ID with all its relations (repositories, files, tasks).
-#[utoipa::path(
- get,
- path = "/api/v1/contracts/{id}",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- responses(
- (status = 200, description = "Contract details with relations", body = ContractWithRelations),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn get_contract(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Get the contract
- 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();
- }
- };
-
- // Get repositories
- let repositories = match repository::list_contract_repositories(pool, id).await {
- Ok(r) => r,
- Err(e) => {
- tracing::warn!("Failed to get repositories for {}: {}", id, e);
- Vec::new()
- }
- };
-
- // Get files
- let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await {
- Ok(f) => f,
- Err(e) => {
- tracing::warn!("Failed to get files for contract {}: {}", id, e);
- Vec::new()
- }
- };
-
- // Get tasks
- let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await {
- Ok(t) => t,
- Err(e) => {
- tracing::warn!("Failed to get tasks for contract {}: {}", id, e);
- Vec::new()
- }
- };
-
- Json(ContractWithRelations {
- contract,
- repositories,
- files,
- tasks,
- })
- .into_response()
-}
-
-/// Create a new contract.
-#[utoipa::path(
- post,
- path = "/api/v1/contracts",
- request_body = CreateContractRequest,
- responses(
- (status = 201, description = "Contract created", body = ContractSummary),
- (status = 400, description = "Invalid request", body = ApiError),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn create_contract(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Json(req): Json<CreateContractRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- match repository::create_contract_for_owner(pool, auth.owner_id, req.clone()).await {
- Ok(contract) => {
- // Create supervisor task for this contract
- let supervisor_name = format!("{} Supervisor", contract.name);
- let supervisor_plan = format!(
- "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}",
- contract.name,
- contract.description.as_deref().unwrap_or("No description provided.")
- );
-
- // Get repository info from contract if available
- let repo_url = {
- // Try to get the first repository associated with this contract
- match repository::list_contract_repositories(pool, contract.id).await {
- Ok(repos) if !repos.is_empty() => {
- let repo = &repos[0];
- repo.repository_url.clone()
- }
- _ => None,
- }
- };
-
- let supervisor_req = crate::db::models::CreateTaskRequest {
- name: supervisor_name,
- description: None,
- plan: supervisor_plan,
- repository_url: repo_url,
- base_branch: None,
- target_branch: None,
- parent_task_id: None,
- contract_id: Some(contract.id),
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: None,
- copy_files: None,
- is_supervisor: true,
- checkpoint_sha: None,
- priority: 0,
- merge_mode: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Supervisor uses its own worktree
- directive_id: None,
- directive_step_id: None,
- };
-
- match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await {
- Ok(supervisor_task) => {
- tracing::info!(
- contract_id = %contract.id,
- supervisor_task_id = %supervisor_task.id,
- is_supervisor = supervisor_task.is_supervisor,
- "Created supervisor task for contract"
- );
-
- // Update contract with supervisor_task_id
- let update_req = crate::db::models::UpdateContractRequest {
- supervisor_task_id: Some(supervisor_task.id),
- version: Some(contract.version),
- ..Default::default()
- };
- if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await {
- tracing::warn!(
- contract_id = %contract.id,
- error = %e,
- "Failed to link supervisor task to contract"
- );
- }
- }
- Err(e) => {
- tracing::warn!(
- contract_id = %contract.id,
- error = %e,
- "Failed to create supervisor task for contract"
- );
- }
- }
-
- // Record history event for contract creation
- let _ = repository::record_history_event(
- pool,
- auth.owner_id,
- Some(contract.id),
- None,
- "contract",
- Some("created"),
- Some(&contract.phase),
- serde_json::json!({
- "name": &contract.name,
- "type": &contract.contract_type,
- "description": &contract.description,
- }),
- ).await;
-
- // Get the summary version with counts
- match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await
- {
- Ok(Some(summary)) => (StatusCode::CREATED, Json(summary)).into_response(),
- Ok(None) => {
- // Shouldn't happen, but return basic info if it does
- (
- StatusCode::CREATED,
- Json(ContractSummary {
- id: contract.id,
- name: contract.name,
- description: contract.description,
- contract_type: contract.contract_type,
- phase: contract.phase,
- status: contract.status,
- supervisor_task_id: contract.supervisor_task_id,
- local_only: contract.local_only,
- auto_merge_local: contract.auto_merge_local,
- file_count: 0,
- task_count: 0,
- repository_count: 0,
- version: contract.version,
- created_at: contract.created_at,
- }),
- )
- .into_response()
- }
- Err(e) => {
- tracing::warn!("Failed to get contract summary: {}", e);
- (
- StatusCode::CREATED,
- Json(ContractSummary {
- id: contract.id,
- name: contract.name,
- description: contract.description,
- contract_type: contract.contract_type,
- phase: contract.phase,
- status: contract.status,
- supervisor_task_id: contract.supervisor_task_id,
- local_only: contract.local_only,
- auto_merge_local: contract.auto_merge_local,
- file_count: 0,
- task_count: 0,
- repository_count: 0,
- version: contract.version,
- created_at: contract.created_at,
- }),
- )
- .into_response()
- }
- }
- }
- Err(e) => {
- tracing::error!("Failed to create contract: {}", e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Update a contract.
-#[utoipa::path(
- put,
- path = "/api/v1/contracts/{id}",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- request_body = UpdateContractRequest,
- responses(
- (status = 200, description = "Contract updated", body = ContractSummary),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 409, description = "Version conflict", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn update_contract(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- Json(req): Json<UpdateContractRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- match repository::update_contract_for_owner(pool, id, auth.owner_id, req).await {
- Ok(Some(contract)) => {
- // If contract is completed, stop the supervisor task and clean up worktrees
- if contract.status == "completed" {
- if let Some(supervisor_task_id) = contract.supervisor_task_id {
- // Get the supervisor task to find its daemon
- if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await {
- if let Some(daemon_id) = supervisor.daemon_id {
- let state_clone = state.clone();
- tokio::spawn(async move {
- // Gracefully interrupt the supervisor
- let cmd = crate::server::state::DaemonCommand::InterruptTask {
- task_id: supervisor_task_id,
- graceful: true,
- };
- if let Err(e) = state_clone.send_daemon_command(daemon_id, cmd).await {
- tracing::warn!(
- supervisor_task_id = %supervisor_task_id,
- daemon_id = %daemon_id,
- error = %e,
- "Failed to stop supervisor task on contract completion"
- );
- } else {
- tracing::info!(
- supervisor_task_id = %supervisor_task_id,
- contract_id = %id,
- "Stopped supervisor task on contract completion"
- );
- }
- });
- }
- }
- }
-
- // Clean up all task worktrees for this contract
- let pool_clone = pool.clone();
- let state_clone = state.clone();
- let contract_id = id;
- tokio::spawn(async move {
- cleanup_contract_worktrees(&pool_clone, &state_clone, contract_id).await;
- });
-
- // Record history event for contract completion
- let _ = repository::record_history_event(
- pool,
- auth.owner_id,
- Some(contract.id),
- None,
- "contract",
- Some("completed"),
- Some(&contract.phase),
- serde_json::json!({
- "name": &contract.name,
- "status": &contract.status,
- }),
- ).await;
-
- }
-
- // Get summary with counts
- match repository::get_contract_summary_for_owner(pool, 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,
- supervisor_task_id: contract.supervisor_task_id,
- local_only: contract.local_only,
- auto_merge_local: contract.auto_merge_local,
- file_count: 0,
- task_count: 0,
- repository_count: 0,
- version: contract.version,
- created_at: contract.created_at,
- })
- .into_response(),
- }
- }
- Ok(None) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "Contract not found")),
- )
- .into_response(),
- Err(RepositoryError::VersionConflict { expected, actual }) => {
- tracing::info!(
- "Version conflict on contract {}: expected {}, actual {}",
- id,
- expected,
- actual
- );
- (
- StatusCode::CONFLICT,
- Json(serde_json::json!({
- "code": "VERSION_CONFLICT",
- "message": format!(
- "Contract was modified. Expected version {}, actual version {}",
- expected, actual
- ),
- "expectedVersion": expected,
- "actualVersion": actual,
- })),
- )
- .into_response()
- }
- Err(RepositoryError::Database(e)) => {
- tracing::error!("Failed to update contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Delete a contract.
-#[utoipa::path(
- delete,
- path = "/api/v1/contracts/{id}",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- responses(
- (status = 204, description = "Contract deleted"),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn delete_contract(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // First, verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- // 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;
-
- match repository::delete_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(true) => StatusCode::NO_CONTENT.into_response(),
- Ok(false) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "Contract not found")),
- )
- .into_response(),
- Err(e) => {
- tracing::error!("Failed to delete contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-// =============================================================================
-// Repository Management
-// =============================================================================
-
-/// Add a remote repository to a contract.
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/repositories/remote",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- request_body = AddRemoteRepositoryRequest,
- responses(
- (status = 201, description = "Repository added", body = ContractRepository),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn add_remote_repository(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- Json(req): Json<AddRemoteRepositoryRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- match repository::add_remote_repository(pool, id, &req.name, &req.repository_url, req.is_primary)
- .await
- {
- Ok(repo) => {
- // Update supervisor task with repository info if this is a primary repo
- update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await;
-
- // Track repository in history for future suggestions
- if let Err(e) = repository::add_or_update_repository_history(
- pool,
- auth.owner_id,
- &req.name,
- Some(&req.repository_url),
- None,
- "remote",
- )
- .await
- {
- // Log but don't fail the request if history tracking fails
- tracing::warn!("Failed to track repository in history: {}", e);
- }
-
- (StatusCode::CREATED, Json(repo)).into_response()
- }
- Err(e) => {
- tracing::error!("Failed to add remote repository to contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Add a local repository to a contract.
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/repositories/local",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- request_body = AddLocalRepositoryRequest,
- responses(
- (status = 201, description = "Repository added", body = ContractRepository),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn add_local_repository(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- Json(req): Json<AddLocalRepositoryRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- match repository::add_local_repository(pool, id, &req.name, &req.local_path, req.is_primary)
- .await
- {
- Ok(repo) => {
- // Update supervisor task with repository info if this is a primary repo
- update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await;
-
- // Track repository in history for future suggestions
- if let Err(e) = repository::add_or_update_repository_history(
- pool,
- auth.owner_id,
- &req.name,
- None,
- Some(&req.local_path),
- "local",
- )
- .await
- {
- // Log but don't fail the request if history tracking fails
- tracing::warn!("Failed to track repository in history: {}", e);
- }
-
- (StatusCode::CREATED, Json(repo)).into_response()
- }
- Err(e) => {
- tracing::error!("Failed to add local repository to contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Create a managed repository (daemon will create it).
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/repositories/managed",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- request_body = CreateManagedRepositoryRequest,
- responses(
- (status = 201, description = "Repository creation requested", body = ContractRepository),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn create_managed_repository(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- Json(req): Json<CreateManagedRepositoryRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- match repository::create_managed_repository(pool, id, &req.name, req.is_primary).await {
- Ok(repo) => {
- // For managed repos, the daemon will create the repo and we'll update later
- // For now, just mark that this is a managed repo configuration
- // The helper handles the case where repo has no URL yet
- update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await;
- (StatusCode::CREATED, Json(repo)).into_response()
- }
- Err(e) => {
- tracing::error!(
- "Failed to create managed repository for contract {}: {}",
- id,
- e
- );
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Delete a repository from a contract.
-#[utoipa::path(
- delete,
- path = "/api/v1/contracts/{id}/repositories/{repo_id}",
- params(
- ("id" = Uuid, Path, description = "Contract ID"),
- ("repo_id" = Uuid, Path, description = "Repository ID")
- ),
- responses(
- (status = 204, description = "Repository removed"),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract or repository not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn delete_repository(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path((id, repo_id)): Path<(Uuid, Uuid)>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- match repository::delete_contract_repository(pool, repo_id, id).await {
- Ok(true) => StatusCode::NO_CONTENT.into_response(),
- Ok(false) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "Repository not found")),
- )
- .into_response(),
- Err(e) => {
- tracing::error!(
- "Failed to delete repository {} from contract {}: {}",
- repo_id,
- id,
- e
- );
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Set a repository as primary for a contract.
-#[utoipa::path(
- put,
- path = "/api/v1/contracts/{id}/repositories/{repo_id}/primary",
- params(
- ("id" = Uuid, Path, description = "Contract ID"),
- ("repo_id" = Uuid, Path, description = "Repository ID")
- ),
- responses(
- (status = 204, description = "Repository set as primary"),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract or repository not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn set_repository_primary(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path((id, repo_id)): Path<(Uuid, Uuid)>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- match repository::set_repository_primary(pool, repo_id, id).await {
- Ok(true) => StatusCode::NO_CONTENT.into_response(),
- Ok(false) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "Repository not found")),
- )
- .into_response(),
- Err(e) => {
- tracing::error!(
- "Failed to set repository {} as primary for contract {}: {}",
- repo_id,
- id,
- e
- );
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-// =============================================================================
-// Task Association
-// =============================================================================
-
-/// Add a task to a contract.
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/tasks/{task_id}",
- params(
- ("id" = Uuid, Path, description = "Contract ID"),
- ("task_id" = Uuid, Path, description = "Task ID")
- ),
- responses(
- (status = 204, description = "Task added to contract"),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract or task not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn add_task_to_contract(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path((id, task_id)): Path<(Uuid, Uuid)>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- // Verify task exists and belongs to owner
- match repository::get_task_for_owner(pool, task_id, auth.owner_id).await {
- Ok(Some(_)) => {}
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "Task not found")),
- )
- .into_response();
- }
- Err(e) => {
- tracing::error!("Failed to get task {}: {}", task_id, e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response();
- }
- }
-
- match repository::add_task_to_contract(pool, id, task_id, auth.owner_id).await {
- Ok(true) => StatusCode::NO_CONTENT.into_response(),
- Ok(false) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "Task not found")),
- )
- .into_response(),
- Err(e) => {
- tracing::error!("Failed to add task {} to contract {}: {}", task_id, id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Remove a task from a contract.
-#[utoipa::path(
- delete,
- path = "/api/v1/contracts/{id}/tasks/{task_id}",
- params(
- ("id" = Uuid, Path, description = "Contract ID"),
- ("task_id" = Uuid, Path, description = "Task ID")
- ),
- responses(
- (status = 204, description = "Task removed from contract"),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract or task not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn remove_task_from_contract(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path((id, task_id)): Path<(Uuid, Uuid)>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- match repository::remove_task_from_contract(pool, id, task_id, auth.owner_id).await {
- Ok(true) => StatusCode::NO_CONTENT.into_response(),
- Ok(false) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "Task not found in this contract")),
- )
- .into_response(),
- Err(e) => {
- tracing::error!(
- "Failed to remove task {} from contract {}: {}",
- task_id,
- id,
- e
- );
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-// =============================================================================
-// Phase Management
-// =============================================================================
-
-/// Change contract phase.
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/phase",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- request_body = ChangePhaseRequest,
- responses(
- (status = 200, description = "Phase changed", body = ContractSummary),
- (status = 400, description = "Validation failed", body = ApiError),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 409, description = "Version conflict", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn change_phase(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- Json(req): Json<ChangePhaseRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .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
- // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level
- 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(),
- };
-
- // Get phase deliverables with completion status
- let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type);
- let completed_deliverables = contract.get_completed_deliverables(&contract.phase);
-
- let deliverables: Vec<serde_json::Value> = phase_deliverables
- .deliverables
- .iter()
- .map(|d| serde_json::json!({
- "id": d.id,
- "name": d.name,
- "completed": completed_deliverables.contains(&d.id)
- }))
- .collect();
-
- 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": "requires_confirmation",
- "transitionId": transition_id,
- "currentPhase": contract.phase,
- "nextPhase": req.phase,
- "deliverablesSummary": deliverables_summary,
- "deliverables": deliverables,
- "phaseFiles": phase_files,
- "phaseTasks": phase_tasks,
- "requiresConfirmation": true,
- "message": "Phase guard is enabled. User confirmation required."
- }))
- .into_response();
- }
-
- // Phase guard is disabled or user confirmed - proceed with phase change
- // Use the version-checking function for explicit conflict detection
- match repository::change_contract_phase_with_version(
- pool,
- id,
- auth.owner_id,
- &req.phase,
- req.expected_version,
- )
- .await
- {
- Ok(PhaseChangeResult::Success(updated_contract)) => {
- // Save supervisor state on phase change (Task 3.3)
- // This is a key save point for restoration
- let new_phase_for_state = updated_contract.phase.clone();
- let contract_id_for_state = updated_contract.id;
- let pool_for_state = pool.clone();
- tokio::spawn(async move {
- if let Err(e) = repository::update_supervisor_phase(&pool_for_state, contract_id_for_state, &new_phase_for_state).await {
- tracing::warn!(
- contract_id = %contract_id_for_state,
- new_phase = %new_phase_for_state,
- error = %e,
- "Failed to save supervisor state on phase change"
- );
- }
- });
-
- // Notify supervisor of phase change
- 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 = updated_contract.id;
- let new_phase = updated_contract.phase.clone();
- tokio::spawn(async move {
- state_clone.notify_supervisor_of_phase_change(
- supervisor.id,
- supervisor.daemon_id,
- contract_id,
- &new_phase,
- ).await;
- });
- }
- }
-
- // Record history event for phase change
- let _ = repository::record_history_event(
- pool,
- auth.owner_id,
- Some(contract.id),
- None,
- "phase",
- Some("changed"),
- Some(&contract.phase),
- serde_json::json!({
- "contractName": &contract.name,
- "newPhase": &updated_contract.phase,
- }),
- ).await;
-
- // Get summary with counts
- 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: 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,
- supervisor_task_id: updated_contract.supervisor_task_id,
- local_only: updated_contract.local_only,
- auto_merge_local: updated_contract.auto_merge_local,
- file_count: 0,
- task_count: 0,
- repository_count: 0,
- version: updated_contract.version,
- created_at: updated_contract.created_at,
- })
- .into_response(),
- }
- }
- Ok(PhaseChangeResult::VersionConflict { expected, actual, current_phase }) => {
- tracing::info!(
- contract_id = %id,
- expected_version = expected,
- actual_version = actual,
- current_phase = %current_phase,
- "Phase change failed due to version conflict"
- );
- (
- StatusCode::CONFLICT,
- Json(serde_json::json!({
- "code": "VERSION_CONFLICT",
- "message": "Phase change failed due to concurrent modification",
- "details": {
- "expected_version": expected,
- "actual_version": actual,
- "current_phase": current_phase
- }
- })),
- )
- .into_response()
- }
- Ok(PhaseChangeResult::ValidationFailed { reason, missing_requirements }) => {
- tracing::warn!(
- contract_id = %id,
- reason = %reason,
- "Phase change validation failed"
- );
- (
- StatusCode::BAD_REQUEST,
- Json(serde_json::json!({
- "code": "VALIDATION_FAILED",
- "message": reason,
- "details": {
- "missing_requirements": missing_requirements
- }
- })),
- )
- .into_response()
- }
- Ok(PhaseChangeResult::NotFound) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "Contract not found")),
- )
- .into_response(),
- Ok(PhaseChangeResult::Unauthorized) => (
- StatusCode::UNAUTHORIZED,
- Json(ApiError::new("UNAUTHORIZED", "Not authorized to change this contract's phase")),
- )
- .into_response(),
- Err(e) => {
- tracing::error!("Failed to change phase for contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-// =============================================================================
-// Deliverables
-// =============================================================================
-
-/// Request body for marking a deliverable complete
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct MarkDeliverableRequest {
- /// The deliverable ID to mark as complete (e.g., 'plan-document', 'pull-request')
- pub deliverable_id: String,
- /// Phase the deliverable belongs to. Defaults to current contract phase if not specified.
- pub phase: Option<String>,
-}
-
-/// Mark a deliverable as complete for a contract phase.
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/deliverables/complete",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- request_body = MarkDeliverableRequest,
- responses(
- (status = 200, description = "Deliverable marked complete", body = serde_json::Value),
- (status = 400, description = "Invalid deliverable ID", body = ApiError),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn mark_deliverable_complete(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- Json(req): Json<MarkDeliverableRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Get contract
- 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();
- }
- };
-
- // Use specified phase or default to current contract phase
- let target_phase = req.phase.unwrap_or_else(|| contract.phase.clone());
-
- // Validate the deliverable ID exists for this phase/contract type
- // Use custom phase_config if present, otherwise fall back to built-in contract types
- let phase_config = contract.get_phase_config();
- let phase_deliverables = crate::llm::get_phase_deliverables_with_config(
- &target_phase,
- &contract.contract_type,
- phase_config.as_ref(),
- );
-
- // Validate deliverable exists
- if let Err(validation_error) = validate_deliverable(&req.deliverable_id, &phase_deliverables) {
- return (
- StatusCode::BAD_REQUEST,
- Json(serde_json::json!({
- "code": "INVALID_DELIVERABLE",
- "message": validation_error.message,
- })),
- )
- .into_response();
- }
-
- // Check if already completed
- if contract.is_deliverable_complete(&target_phase, &req.deliverable_id) {
- return Json(serde_json::json!({
- "success": true,
- "message": format!("Deliverable '{}' is already marked complete for {} phase", req.deliverable_id, target_phase),
- "deliverableId": req.deliverable_id,
- "phase": target_phase,
- "alreadyComplete": true,
- }))
- .into_response();
- }
-
- // Mark the deliverable as complete
- match repository::mark_deliverable_complete(pool, id, &target_phase, &req.deliverable_id).await {
- Ok(updated_contract) => {
- let completed = updated_contract.get_completed_deliverables(&target_phase);
- Json(serde_json::json!({
- "success": true,
- "message": format!("Marked deliverable '{}' as complete for {} phase", req.deliverable_id, target_phase),
- "deliverableId": req.deliverable_id,
- "phase": target_phase,
- "completedDeliverables": completed,
- }))
- .into_response()
- }
- Err(e) => {
- tracing::error!("Failed to mark deliverable complete for contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-// =============================================================================
-// Events
-// =============================================================================
-
-/// Get contract event history.
-#[utoipa::path(
- get,
- path = "/api/v1/contracts/{id}/events",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- responses(
- (status = 200, description = "Event history", body = Vec<crate::db::models::ContractEvent>),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn get_events(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- 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();
- }
- }
-
- match repository::list_contract_events(pool, id).await {
- Ok(events) => Json(events).into_response(),
- Err(e) => {
- tracing::error!("Failed to get events for contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-// =============================================================================
-// Internal Helper Functions
-// =============================================================================
-
-/// Clean up all worktrees for tasks in a contract.
-///
-/// This is called when a contract is completed or deleted to remove
-/// all associated task worktrees from connected daemons.
-async fn cleanup_contract_worktrees(
- pool: &sqlx::PgPool,
- state: &SharedState,
- contract_id: Uuid,
-) {
- tracing::info!(
- contract_id = %contract_id,
- "Cleaning up worktrees for contract tasks"
- );
-
- // Get all tasks with worktree info for this contract
- let tasks = match repository::list_contract_tasks_with_worktree_info(pool, contract_id).await {
- Ok(tasks) => tasks,
- Err(e) => {
- tracing::error!(
- contract_id = %contract_id,
- error = %e,
- "Failed to list tasks for worktree cleanup"
- );
- return;
- }
- };
-
- if tasks.is_empty() {
- tracing::debug!(
- contract_id = %contract_id,
- "No tasks with worktrees to clean up"
- );
- return;
- }
-
- tracing::info!(
- contract_id = %contract_id,
- task_count = tasks.len(),
- "Found tasks with worktrees to clean up"
- );
-
- // Send cleanup command to each task's daemon
- // Skip tasks that share a supervisor's worktree (they don't own the worktree)
- for task in tasks {
- // Skip tasks that reuse the supervisor's worktree - the supervisor owns it
- if task.supervisor_worktree_task_id.is_some() {
- tracing::debug!(
- task_id = %task.id,
- supervisor_worktree_task_id = ?task.supervisor_worktree_task_id,
- contract_id = %contract_id,
- "Task shares supervisor worktree, skipping worktree cleanup"
- );
- continue;
- }
-
- if let Some(daemon_id) = task.daemon_id {
- let cmd = crate::server::state::DaemonCommand::CleanupWorktree {
- task_id: task.id,
- delete_branch: true, // Delete the branch when contract is done
- };
-
- match state.send_daemon_command(daemon_id, cmd).await {
- Ok(()) => {
- tracing::info!(
- task_id = %task.id,
- daemon_id = %daemon_id,
- contract_id = %contract_id,
- "Sent worktree cleanup command"
- );
- }
- Err(e) => {
- tracing::warn!(
- task_id = %task.id,
- daemon_id = %daemon_id,
- contract_id = %contract_id,
- error = %e,
- "Failed to send worktree cleanup command (daemon may be offline)"
- );
- }
- }
- } else {
- tracing::debug!(
- task_id = %task.id,
- contract_id = %contract_id,
- "Task has no daemon assigned, skipping worktree cleanup"
- );
- }
- }
-}
-
-// =============================================================================
-// Supervisor Status API
-// =============================================================================
-
-/// Query parameters for supervisor heartbeat history
-#[derive(Debug, Deserialize)]
-pub struct HeartbeatHistoryQuery {
- /// Maximum number of heartbeats to return (default: 10)
- pub limit: Option<i32>,
- /// Offset for pagination (default: 0)
- pub offset: Option<i32>,
-}
-
-/// Get supervisor status for a contract.
-#[utoipa::path(
- get,
- path = "/api/v1/contracts/{id}/supervisor/status",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- responses(
- (status = 200, description = "Supervisor status", body = crate::db::models::SupervisorStatusResponse),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract or supervisor not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn get_supervisor_status(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- 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("CONTRACT_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();
- }
- };
-
- // Check if contract has a supervisor
- let supervisor_task_id = match contract.supervisor_task_id {
- Some(task_id) => task_id,
- None => {
- return (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")),
- )
- .into_response();
- }
- };
-
- // Get supervisor status from supervisor_states table
- match repository::get_supervisor_status(pool, id, auth.owner_id).await {
- Ok(Some(status_info)) => {
- // Determine if supervisor is actively running
- let is_running = status_info.is_running && status_info.task_status == "running";
-
- let response = crate::db::models::SupervisorStatusResponse {
- task_id: status_info.task_id,
- state: status_info.supervisor_state,
- phase: status_info.phase,
- current_activity: status_info.current_activity,
- progress: None, // We don't track progress percentage yet
- last_heartbeat: status_info.last_heartbeat,
- pending_task_ids: status_info.pending_task_ids,
- is_running,
- };
- Json(response).into_response()
- }
- Ok(None) => {
- // No supervisor state record exists, but supervisor task might exist
- // Try to get info from the task itself
- match repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await {
- Ok(Some(task)) => {
- let is_running = task.daemon_id.is_some() && task.status == "running";
- let response = crate::db::models::SupervisorStatusResponse {
- task_id: task.id,
- state: task.status.clone(),
- phase: contract.phase.clone(),
- current_activity: task.progress_summary.clone(),
- progress: None,
- last_heartbeat: task.updated_at,
- pending_task_ids: Vec::new(),
- is_running,
- };
- Json(response).into_response()
- }
- Ok(None) => (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("SUPERVISOR_NOT_FOUND", "Supervisor task not found")),
- )
- .into_response(),
- Err(e) => {
- tracing::error!("Failed to get supervisor task {}: {}", supervisor_task_id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
- }
- Err(e) => {
- tracing::error!("Failed to get supervisor status for contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-/// Get supervisor heartbeat history for a contract.
-#[utoipa::path(
- get,
- path = "/api/v1/contracts/{id}/supervisor/heartbeats",
- params(
- ("id" = Uuid, Path, description = "Contract ID"),
- ("limit" = Option<i32>, Query, description = "Maximum number of heartbeats to return (default: 10)"),
- ("offset" = Option<i32>, Query, description = "Offset for pagination (default: 0)")
- ),
- responses(
- (status = 200, description = "Supervisor heartbeat history", body = crate::db::models::SupervisorHeartbeatHistoryResponse),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn get_supervisor_heartbeats(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- axum::extract::Query(query): axum::extract::Query<HeartbeatHistoryQuery>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
- Ok(Some(_)) => {}
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("CONTRACT_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();
- }
- }
-
- let limit = query.limit.unwrap_or(10).min(100); // Cap at 100
- let offset = query.offset.unwrap_or(0);
-
- // Get activity history as heartbeats
- let activities = match repository::get_supervisor_activity_history(pool, id, limit, offset).await {
- Ok(activities) => activities,
- Err(e) => {
- tracing::error!("Failed to get supervisor heartbeats for contract {}: {}", id, e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response();
- }
- };
-
- // Get total count for pagination
- let total = match repository::count_supervisor_activity_history(pool, id).await {
- Ok(count) => count,
- Err(e) => {
- tracing::warn!("Failed to count supervisor heartbeats: {}", e);
- activities.len() as i64
- }
- };
-
- // Convert to heartbeat entries
- let heartbeats: Vec<crate::db::models::SupervisorHeartbeatEntry> = activities
- .into_iter()
- .map(|a| crate::db::models::SupervisorHeartbeatEntry {
- timestamp: a.timestamp,
- state: a.state,
- activity: a.activity,
- progress: a.progress.map(|p| p as u8),
- phase: a.phase,
- pending_task_ids: a.pending_task_ids,
- })
- .collect();
-
- Json(crate::db::models::SupervisorHeartbeatHistoryResponse {
- heartbeats,
- total,
- })
- .into_response()
-}
-
-/// Sync supervisor state (refresh last_activity timestamp).
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/supervisor/sync",
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- responses(
- (status = 200, description = "Supervisor synced", body = crate::db::models::SupervisorSyncResponse),
- (status = 401, description = "Unauthorized", body = ApiError),
- (status = 404, description = "Contract or supervisor not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- (status = 500, description = "Internal server error", body = ApiError),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn sync_supervisor(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- 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("CONTRACT_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();
- }
- };
-
- // Check if contract has a supervisor
- if contract.supervisor_task_id.is_none() {
- return (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")),
- )
- .into_response();
- }
-
- // Sync supervisor state (update last_activity)
- match repository::sync_supervisor_state(pool, id).await {
- Ok(Some(_state)) => {
- // Get task status to determine current state
- let task_status = if let Some(task_id) = contract.supervisor_task_id {
- match repository::get_task_for_owner(pool, task_id, auth.owner_id).await {
- Ok(Some(task)) => task.status,
- _ => "unknown".to_string(),
- }
- } else {
- "unknown".to_string()
- };
-
- Json(crate::db::models::SupervisorSyncResponse {
- synced: true,
- state: task_status,
- message: Some("Supervisor state synced successfully".to_string()),
- })
- .into_response()
- }
- Ok(None) => {
- // No supervisor state exists, return not found
- (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor state found for this contract")),
- )
- .into_response()
- }
- Err(e) => {
- tracing::error!("Failed to sync supervisor state for contract {}: {}", id, e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- )
- .into_response()
- }
- }
-}
-
-// =============================================================================
-// Tests
-// =============================================================================
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::db::models::{DeliverableDefinition, PhaseConfig, PhaseDefinition};
- use crate::llm::{get_phase_deliverables_for_type, get_phase_deliverables_with_config};
- use std::collections::HashMap;
-
- #[test]
- fn test_validate_deliverable_valid_simple_plan() {
- let phase_deliverables = get_phase_deliverables_for_type("plan", "simple");
- let result = validate_deliverable("plan-document", &phase_deliverables);
- assert!(result.is_ok());
- }
-
- #[test]
- fn test_validate_deliverable_valid_simple_execute() {
- let phase_deliverables = get_phase_deliverables_for_type("execute", "simple");
- let result = validate_deliverable("pull-request", &phase_deliverables);
- assert!(result.is_ok());
- }
-
- #[test]
- fn test_validate_deliverable_invalid_id() {
- let phase_deliverables = get_phase_deliverables_for_type("plan", "simple");
- let result = validate_deliverable("nonexistent-deliverable", &phase_deliverables);
- assert!(result.is_err());
- let err = result.unwrap_err();
- assert!(err.message.contains("Invalid deliverable"));
- assert!(err.message.contains("nonexistent-deliverable"));
- assert!(err.message.contains("plan-document"));
- }
-
- #[test]
- fn test_validate_deliverable_specification_phases() {
- // Research phase
- let phase_deliverables = get_phase_deliverables_for_type("research", "specification");
- assert!(validate_deliverable("research-notes", &phase_deliverables).is_ok());
- assert!(validate_deliverable("invalid", &phase_deliverables).is_err());
-
- // Specify phase
- let phase_deliverables = get_phase_deliverables_for_type("specify", "specification");
- assert!(validate_deliverable("requirements-document", &phase_deliverables).is_ok());
- assert!(validate_deliverable("plan-document", &phase_deliverables).is_err());
-
- // Review phase
- let phase_deliverables = get_phase_deliverables_for_type("review", "specification");
- assert!(validate_deliverable("release-notes", &phase_deliverables).is_ok());
- }
-
- #[test]
- fn test_validate_deliverable_execute_type_no_deliverables() {
- // Execute-only contracts have no deliverables
- let phase_deliverables = get_phase_deliverables_for_type("execute", "execute");
- // Any deliverable should fail since there are none
- let result = validate_deliverable("pull-request", &phase_deliverables);
- assert!(result.is_err());
- let err = result.unwrap_err();
- assert!(err.message.contains("Valid IDs: []"));
- }
-
- #[test]
- fn test_validate_deliverable_with_custom_phase_config() {
- // Create a custom phase config
- let mut deliverables = HashMap::new();
- deliverables.insert(
- "design".to_string(),
- vec![
- DeliverableDefinition {
- id: "architecture-doc".to_string(),
- name: "Architecture Document".to_string(),
- priority: "required".to_string(),
- },
- DeliverableDefinition {
- id: "api-spec".to_string(),
- name: "API Specification".to_string(),
- priority: "recommended".to_string(),
- },
- ],
- );
-
- let phase_config = PhaseConfig {
- phases: vec![
- PhaseDefinition {
- id: "design".to_string(),
- name: "Design".to_string(),
- order: 0,
- },
- PhaseDefinition {
- id: "build".to_string(),
- name: "Build".to_string(),
- order: 1,
- },
- ],
- default_phase: "design".to_string(),
- deliverables,
- };
-
- // Validate against custom config
- let phase_deliverables =
- get_phase_deliverables_with_config("design", "custom", Some(&phase_config));
-
- // Valid custom deliverables
- assert!(validate_deliverable("architecture-doc", &phase_deliverables).is_ok());
- assert!(validate_deliverable("api-spec", &phase_deliverables).is_ok());
-
- // Invalid deliverable for custom config
- let result = validate_deliverable("plan-document", &phase_deliverables);
- assert!(result.is_err());
- let err = result.unwrap_err();
- assert!(err.message.contains("Invalid deliverable"));
- assert!(err.message.contains("plan-document"));
- assert!(err.message.contains("architecture-doc"));
- assert!(err.message.contains("api-spec"));
- }
-
- #[test]
- fn test_validate_deliverable_error_message_format() {
- let phase_deliverables = get_phase_deliverables_for_type("plan", "simple");
- let result = validate_deliverable("xyz", &phase_deliverables);
- let err = result.unwrap_err();
-
- // Check error message format matches the specification
- assert!(err.message.contains("Invalid deliverable 'xyz'"));
- assert!(err.message.contains("plan phase"));
- assert!(err.message.contains("Valid IDs:"));
- assert!(err.message.contains("plan-document"));
- }
-
- #[test]
- fn test_deliverable_validation_error_display() {
- let err = DeliverableValidationError::new("Test error message");
- assert_eq!(format!("{}", err), "Test error message");
- }
-
- #[test]
- fn test_validate_deliverable_unknown_phase() {
- // Unknown phase should return empty deliverables
- let phase_deliverables = get_phase_deliverables_for_type("unknown", "simple");
- let result = validate_deliverable("any-id", &phase_deliverables);
- assert!(result.is_err());
- }
-}
diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs
index ac5652a..63b1827 100644
--- a/makima/src/server/handlers/mesh.rs
+++ b/makima/src/server/handlers/mesh.rs
@@ -122,7 +122,11 @@ pub async fn list_tasks(
};
let result = if query.orphan {
- repository::list_orphan_tasks_for_owner(pool, auth.owner_id).await
+ // Backed by the per-owner tmp directive going forward — see
+ // `list_tmp_tasks_for_owner` for the semantics. The query parameter
+ // name (`?orphan=true`) is preserved for backwards compatibility
+ // with existing frontend callers.
+ repository::list_tmp_tasks_for_owner(pool, auth.owner_id).await
} else {
repository::list_tasks_for_owner(pool, auth.owner_id).await
};
@@ -228,7 +232,7 @@ pub async fn get_task(
pub async fn create_task(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
- Json(req): Json<CreateTaskRequest>,
+ Json(mut req): Json<CreateTaskRequest>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
@@ -238,6 +242,32 @@ pub async fn create_task(
.into_response();
};
+ // Every top-level task must live under SOME directive going forward —
+ // the unified directive surface is the only way users see tasks. If a
+ // caller doesn't supply directive_id, attach to the owner's tmp
+ // (scratchpad) directive, auto-creating it if needed. Subtasks
+ // (parent_task_id set) inherit their parent's directive linkage and
+ // are fine without an explicit directive_id.
+ if req.directive_id.is_none() && req.parent_task_id.is_none() {
+ match repository::get_or_create_tmp_directive(pool, auth.owner_id).await {
+ Ok(tmp) => {
+ req.directive_id = Some(tmp.id);
+ }
+ Err(e) => {
+ tracing::error!(
+ owner_id = %auth.owner_id,
+ error = %e,
+ "Failed to provision tmp directive for orphan task"
+ );
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("TMP_PROVISION_FAILED", &e.to_string())),
+ )
+ .into_response();
+ }
+ }
+ }
+
match repository::create_task_for_owner(pool, auth.owner_id, req).await {
Ok(task) => {
// Record history event for task creation
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index 4bdb424..c761dcc 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -1,12 +1,11 @@
//! HTTP and WebSocket request handlers.
+//!
+//! Phase 5 removed: contract_chat, contract_daemon, contract_discuss,
+//! contracts, transcript_analysis. Contracts subsystem is gone.
pub mod api_keys;
pub mod chat;
-pub mod contract_chat;
-pub mod contract_daemon;
-pub mod contract_discuss;
pub mod daemon_download;
-pub mod contracts;
pub mod directives;
pub mod file_ws;
pub mod files;
@@ -23,6 +22,5 @@ pub mod repository_history;
pub mod speak;
pub mod templates;
pub mod voice;
-pub mod transcript_analysis;
pub mod users;
pub mod versions;
diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs
deleted file mode 100644
index 9261c0c..0000000
--- a/makima/src/server/handlers/transcript_analysis.rs
+++ /dev/null
@@ -1,690 +0,0 @@
-//! HTTP handlers for transcript analysis and contract integration.
-
-use axum::{
- extract::State,
- http::StatusCode,
- response::IntoResponse,
- Json,
-};
-use serde::{Deserialize, Serialize};
-use utoipa::ToSchema;
-use uuid::Uuid;
-
-use crate::db::{models, repository};
-use crate::llm::transcript_analyzer::{
- TranscriptAnalysisResult, build_analysis_prompt, calculate_speaker_stats,
- format_transcript_for_analysis, parse_analysis_response,
-};
-use crate::llm::claude::{ClaudeClient, ClaudeModel, Message, MessageContent};
-use crate::server::auth::Authenticated;
-use crate::server::messages::ApiError;
-use crate::server::state::SharedState;
-
-// =============================================================================
-// Request/Response Types
-// =============================================================================
-
-/// Request to analyze a file's transcript
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct AnalyzeTranscriptRequest {
- /// File ID containing the transcript to analyze
- pub file_id: Uuid,
-}
-
-/// Response from transcript analysis
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct AnalyzeTranscriptResponse {
- pub file_id: Uuid,
- pub analysis: TranscriptAnalysisResult,
-}
-
-/// Request to create a contract from analysis
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct CreateContractFromAnalysisRequest {
- /// File ID containing the analyzed transcript
- pub file_id: Uuid,
- /// Override the suggested name (optional)
- pub name: Option<String>,
- /// Override the suggested description (optional)
- pub description: Option<String>,
- /// Include requirements as file content (default: true)
- #[serde(default = "default_true")]
- pub include_requirements: bool,
- /// Include decisions as file content (default: true)
- #[serde(default = "default_true")]
- pub include_decisions: bool,
- /// Include action items as tasks (default: true)
- #[serde(default = "default_true")]
- pub include_action_items: bool,
-}
-
-fn default_true() -> bool {
- true
-}
-
-/// Response from creating contract from analysis
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct CreateContractFromAnalysisResponse {
- pub contract_id: Uuid,
- pub contract_name: String,
- pub files_created: Vec<FileCreatedInfo>,
- pub tasks_created: Vec<TaskCreatedInfo>,
-}
-
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct FileCreatedInfo {
- pub id: Uuid,
- pub name: String,
- pub file_type: String,
-}
-
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct TaskCreatedInfo {
- pub id: Uuid,
- pub name: String,
-}
-
-/// Request to update an existing contract from analysis
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct UpdateContractFromAnalysisRequest {
- /// File ID containing the transcript
- pub file_id: Uuid,
- /// Contract ID to update
- pub contract_id: Uuid,
- /// Add requirements to contract files
- #[serde(default = "default_true")]
- pub add_requirements: bool,
- /// Add decisions to contract files
- #[serde(default = "default_true")]
- pub add_decisions: bool,
- /// Create tasks from action items
- #[serde(default = "default_true")]
- pub create_tasks: bool,
-}
-
-/// Response from updating contract
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct UpdateContractFromAnalysisResponse {
- pub contract_id: Uuid,
- pub files_updated: Vec<Uuid>,
- pub tasks_created: Vec<TaskCreatedInfo>,
- pub analysis_summary: String,
-}
-
-// =============================================================================
-// Handlers
-// =============================================================================
-
-/// Analyze a file's transcript to extract requirements, decisions, and action items.
-#[utoipa::path(
- post,
- path = "/api/v1/listen/analyze",
- request_body = AnalyzeTranscriptRequest,
- responses(
- (status = 200, description = "Transcript analyzed", body = AnalyzeTranscriptResponse),
- (status = 400, description = "Invalid request"),
- (status = 401, description = "Unauthorized"),
- (status = 404, description = "File not found"),
- (status = 500, description = "Internal server error"),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Listen"
-)]
-pub async fn analyze_transcript(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Json(request): Json<AnalyzeTranscriptRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- ).into_response();
- };
-
- // Get the file
- let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await {
- Ok(Some(f)) => f,
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "File not found")),
- ).into_response();
- }
- Err(e) => {
- tracing::error!(error = %e, "Failed to get file");
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- ).into_response();
- }
- };
-
- // Check if transcript is empty
- if file.transcript.is_empty() {
- return (
- StatusCode::BAD_REQUEST,
- Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript to analyze")),
- ).into_response();
- }
-
- // Analyze the transcript
- match analyze_transcript_internal(&file.transcript).await {
- Ok(analysis) => {
- Json(AnalyzeTranscriptResponse {
- file_id: request.file_id,
- analysis,
- }).into_response()
- }
- Err(e) => {
- tracing::error!(error = %e, "Failed to analyze transcript");
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("ANALYSIS_ERROR", e)),
- ).into_response()
- }
- }
-}
-
-/// Create a new contract from an analyzed transcript.
-#[utoipa::path(
- post,
- path = "/api/v1/listen/create-contract",
- request_body = CreateContractFromAnalysisRequest,
- responses(
- (status = 201, description = "Contract created", body = CreateContractFromAnalysisResponse),
- (status = 400, description = "Invalid request"),
- (status = 401, description = "Unauthorized"),
- (status = 404, description = "File not found"),
- (status = 500, description = "Internal server error"),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Listen"
-)]
-pub async fn create_contract_from_analysis(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Json(request): Json<CreateContractFromAnalysisRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- ).into_response();
- };
-
- // Get the file with transcript
- let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await {
- Ok(Some(f)) => f,
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "File not found")),
- ).into_response();
- }
- Err(e) => {
- tracing::error!(error = %e, "Failed to get file");
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- ).into_response();
- }
- };
-
- if file.transcript.is_empty() {
- return (
- StatusCode::BAD_REQUEST,
- Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript")),
- ).into_response();
- }
-
- // Analyze transcript
- let analysis = match analyze_transcript_internal(&file.transcript).await {
- Ok(a) => a,
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("ANALYSIS_ERROR", e)),
- ).into_response();
- }
- };
-
- // Determine contract name and description
- let contract_name = request.name
- .or(analysis.suggested_contract_name.clone())
- .unwrap_or_else(|| format!("Contract from {}", file.name));
- let contract_description = request.description
- .or(analysis.suggested_description.clone());
-
- // Create the contract
- let contract_req = models::CreateContractRequest {
- name: contract_name.clone(),
- description: contract_description,
- contract_type: Some("specification".to_string()),
- initial_phase: Some("research".to_string()),
- autonomous_loop: None,
- phase_guard: None,
- local_only: None,
- auto_merge_local: None,
- template_id: None,
- };
-
- let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await {
- Ok(c) => c,
- Err(e) => {
- tracing::error!(error = %e, "Failed to create contract");
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- ).into_response();
- }
- };
-
- let mut files_created: Vec<FileCreatedInfo> = Vec::new();
- let mut tasks_created: Vec<TaskCreatedInfo> = Vec::new();
-
- // Create requirements file if we have requirements
- if request.include_requirements && !analysis.requirements.is_empty() {
- let body = build_requirements_body(&analysis.requirements);
- let file_req = models::CreateFileRequest {
- contract_id: contract.id,
- name: Some("Requirements from Transcript".to_string()),
- description: Some("Requirements extracted from voice transcript".to_string()),
- transcript: vec![],
- location: None,
- body,
- repo_file_path: None,
- contract_phase: Some("research".to_string()),
- };
-
- if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await {
- files_created.push(FileCreatedInfo {
- id: f.id,
- name: f.name,
- file_type: "requirements".to_string(),
- });
- }
- }
-
- // Create decisions file if we have decisions
- if request.include_decisions && !analysis.decisions.is_empty() {
- let body = build_decisions_body(&analysis.decisions);
- let file_req = models::CreateFileRequest {
- contract_id: contract.id,
- name: Some("Decisions from Transcript".to_string()),
- description: Some("Decisions extracted from voice transcript".to_string()),
- transcript: vec![],
- location: None,
- body,
- repo_file_path: None,
- contract_phase: Some("research".to_string()),
- };
-
- if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await {
- files_created.push(FileCreatedInfo {
- id: f.id,
- name: f.name,
- file_type: "decisions".to_string(),
- });
- }
- }
-
- // Create tasks from action items
- if request.include_action_items && !analysis.action_items.is_empty() {
- for item in &analysis.action_items {
- let task_req = models::CreateTaskRequest {
- contract_id: Some(contract.id),
- name: truncate_for_name(&item.text, 100),
- description: Some(format!("Action item from transcript (Speaker: {})", item.speaker)),
- plan: item.text.clone(),
- repository_url: None,
- base_branch: None,
- target_branch: None,
- parent_task_id: None,
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: None,
- copy_files: None,
- is_supervisor: false,
- checkpoint_sha: None,
- priority: match item.priority.as_deref() {
- Some("high") => 10,
- Some("medium") => 5,
- _ => 0,
- },
- merge_mode: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: None,
- directive_step_id: None,
- };
-
- if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await {
- tasks_created.push(TaskCreatedInfo {
- id: t.id,
- name: t.name,
- });
- }
- }
- }
-
- (
- StatusCode::CREATED,
- Json(CreateContractFromAnalysisResponse {
- contract_id: contract.id,
- contract_name,
- files_created,
- tasks_created,
- }),
- ).into_response()
-}
-
-/// Update an existing contract with information from transcript analysis.
-#[utoipa::path(
- post,
- path = "/api/v1/listen/update-contract",
- request_body = UpdateContractFromAnalysisRequest,
- responses(
- (status = 200, description = "Contract updated", body = UpdateContractFromAnalysisResponse),
- (status = 400, description = "Invalid request"),
- (status = 401, description = "Unauthorized"),
- (status = 404, description = "File or contract not found"),
- (status = 500, description = "Internal server error"),
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Listen"
-)]
-pub async fn update_contract_from_analysis(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Json(request): Json<UpdateContractFromAnalysisRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- ).into_response();
- };
-
- // Get the file
- let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await {
- Ok(Some(f)) => f,
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "File not found")),
- ).into_response();
- }
- Err(e) => {
- tracing::error!(error = %e, "Failed to get file");
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- ).into_response();
- }
- };
-
- // Verify contract exists
- let _contract = match repository::get_contract_for_owner(pool, request.contract_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!(error = %e, "Failed to get contract");
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("DB_ERROR", e.to_string())),
- ).into_response();
- }
- };
-
- if file.transcript.is_empty() {
- return (
- StatusCode::BAD_REQUEST,
- Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript")),
- ).into_response();
- }
-
- // Analyze transcript
- let analysis = match analyze_transcript_internal(&file.transcript).await {
- Ok(a) => a,
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("ANALYSIS_ERROR", e)),
- ).into_response();
- }
- };
-
- let mut files_updated: Vec<Uuid> = Vec::new();
- let mut tasks_created: Vec<TaskCreatedInfo> = Vec::new();
-
- // Create or update requirements file
- if request.add_requirements && !analysis.requirements.is_empty() {
- let body = build_requirements_body(&analysis.requirements);
- let file_req = models::CreateFileRequest {
- contract_id: request.contract_id,
- name: Some(format!("Requirements from {}", file.name)),
- description: Some("Requirements extracted from voice transcript".to_string()),
- transcript: vec![],
- location: None,
- body,
- repo_file_path: None,
- contract_phase: None,
- };
-
- if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await {
- files_updated.push(f.id);
- }
- }
-
- // Create or update decisions file
- if request.add_decisions && !analysis.decisions.is_empty() {
- let body = build_decisions_body(&analysis.decisions);
- let file_req = models::CreateFileRequest {
- contract_id: request.contract_id,
- name: Some(format!("Decisions from {}", file.name)),
- description: Some("Decisions extracted from voice transcript".to_string()),
- transcript: vec![],
- location: None,
- body,
- repo_file_path: None,
- contract_phase: None,
- };
-
- if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await {
- files_updated.push(f.id);
- }
- }
-
- // Create tasks from action items
- if request.create_tasks && !analysis.action_items.is_empty() {
- for item in &analysis.action_items {
- let task_req = models::CreateTaskRequest {
- contract_id: Some(request.contract_id),
- name: truncate_for_name(&item.text, 100),
- description: Some(format!("Action item from {} (Speaker: {})", file.name, item.speaker)),
- plan: item.text.clone(),
- repository_url: None,
- base_branch: None,
- target_branch: None,
- parent_task_id: None,
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: None,
- copy_files: None,
- is_supervisor: false,
- checkpoint_sha: None,
- priority: 0,
- merge_mode: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: None,
- directive_step_id: None,
- };
-
- if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await {
- tasks_created.push(TaskCreatedInfo {
- id: t.id,
- name: t.name,
- });
- }
- }
- }
-
- let summary = format!(
- "Extracted {} requirements, {} decisions, {} action items from transcript",
- analysis.requirements.len(),
- analysis.decisions.len(),
- analysis.action_items.len()
- );
-
- Json(UpdateContractFromAnalysisResponse {
- contract_id: request.contract_id,
- files_updated,
- tasks_created,
- analysis_summary: summary,
- }).into_response()
-}
-
-// =============================================================================
-// Helper Functions
-// =============================================================================
-
-/// Analyze transcript using Claude
-async fn analyze_transcript_internal(
- transcript: &[models::TranscriptEntry],
-) -> Result<TranscriptAnalysisResult, String> {
- let transcript_text = format_transcript_for_analysis(transcript);
- let speaker_stats = calculate_speaker_stats(transcript);
- let prompt = build_analysis_prompt(&transcript_text);
-
- // Create Claude client
- let client = ClaudeClient::from_env(ClaudeModel::Sonnet)
- .map_err(|e| format!("Failed to create Claude client: {}", e))?;
-
- // Call Claude API with empty tools to make a simple chat call
- let messages = vec![Message {
- role: "user".to_string(),
- content: MessageContent::Text(prompt),
- }];
-
- let result = client.chat_with_tools(messages, &[]).await
- .map_err(|e| format!("Claude API error: {}", e))?;
-
- // Parse the response
- let content = result.content.ok_or_else(|| "No response content from Claude".to_string())?;
- parse_analysis_response(&content, speaker_stats)
-}
-
-/// Build file body elements from requirements
-fn build_requirements_body(requirements: &[crate::llm::transcript_analyzer::ExtractedRequirement]) -> Vec<models::BodyElement> {
- let mut body = vec![
- models::BodyElement::Heading {
- level: 1,
- text: "Requirements".to_string(),
- },
- ];
-
- // Group by category if available
- let mut functional = Vec::new();
- let mut technical = Vec::new();
- let mut other = Vec::new();
-
- for req in requirements {
- match req.category.as_deref() {
- Some("functional") => functional.push(req),
- Some("technical") => technical.push(req),
- _ => other.push(req),
- }
- }
-
- if !functional.is_empty() {
- body.push(models::BodyElement::Heading {
- level: 2,
- text: "Functional Requirements".to_string(),
- });
- body.push(models::BodyElement::List {
- ordered: false,
- items: functional.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(),
- });
- }
-
- if !technical.is_empty() {
- body.push(models::BodyElement::Heading {
- level: 2,
- text: "Technical Requirements".to_string(),
- });
- body.push(models::BodyElement::List {
- ordered: false,
- items: technical.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(),
- });
- }
-
- if !other.is_empty() {
- body.push(models::BodyElement::Heading {
- level: 2,
- text: "Other Requirements".to_string(),
- });
- body.push(models::BodyElement::List {
- ordered: false,
- items: other.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(),
- });
- }
-
- body
-}
-
-/// Build file body elements from decisions
-fn build_decisions_body(decisions: &[crate::llm::transcript_analyzer::ExtractedDecision]) -> Vec<models::BodyElement> {
- let mut body = vec![
- models::BodyElement::Heading {
- level: 1,
- text: "Decisions".to_string(),
- },
- ];
-
- let items: Vec<String> = decisions.iter().map(|d| {
- let context = d.context.as_ref().map(|c| format!(" (Context: {})", c)).unwrap_or_default();
- format!("**{}**: {}{}", d.speaker, d.text, context)
- }).collect();
-
- body.push(models::BodyElement::List {
- ordered: true,
- items,
- });
-
- body
-}
-
-/// Truncate text to fit as a task name
-fn truncate_for_name(text: &str, max_len: usize) -> String {
- if text.len() <= max_len {
- text.to_string()
- } else {
- format!("{}...", &text[..max_len - 3])
- }
-}
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index efae901..59eff2e 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
-use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions};
+use crate::server::handlers::{api_keys, chat, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, users, versions};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -45,10 +45,8 @@ pub fn make_router(state: SharedState) -> Router {
let api_v1 = Router::new()
.route("/listen", get(listen::websocket_handler))
.route("/speak", get(speak::websocket_handler))
- // Listen/transcript analysis endpoints
- .route("/listen/analyze", post(transcript_analysis::analyze_transcript))
- .route("/listen/create-contract", post(transcript_analysis::create_contract_from_analysis))
- .route("/listen/update-contract", post(transcript_analysis::update_contract_from_analysis))
+ // Listen/transcript-analysis endpoints removed in Phase 5 with the
+ // contracts subsystem.
.route("/files/subscribe", get(file_ws::file_subscription_handler))
.route("/files", get(files::list_files).post(files::create_file))
.route(
@@ -167,68 +165,9 @@ pub fn make_router(state: SharedState) -> Router {
get(users::get_user_settings_handler)
.put(users::update_user_settings_handler),
)
- // Contract endpoints
- .route("/contracts/discuss", post(contract_discuss::discuss_contract_handler))
- .route(
- "/contracts",
- get(contracts::list_contracts).post(contracts::create_contract),
- )
- .route(
- "/contracts/{id}",
- get(contracts::get_contract)
- .put(contracts::update_contract)
- .delete(contracts::delete_contract),
- )
- .route("/contracts/{id}/phase", post(contracts::change_phase))
- .route("/contracts/{id}/deliverables/complete", post(contracts::mark_deliverable_complete))
- .route("/contracts/{id}/events", get(contracts::get_events))
- .route("/contracts/{id}/chat", post(contract_chat::contract_chat_handler))
- .route(
- "/contracts/{id}/chat/history",
- get(contract_chat::get_contract_chat_history).delete(contract_chat::clear_contract_chat_history),
- )
- // Contract supervisor resume endpoints
- .route("/contracts/{id}/supervisor/resume", post(mesh_supervisor::resume_supervisor))
- .route("/contracts/{id}/supervisor/conversation/rewind", post(mesh_supervisor::rewind_conversation))
- // Contract supervisor status endpoints
- .route("/contracts/{id}/supervisor/status", get(contracts::get_supervisor_status))
- .route("/contracts/{id}/supervisor/heartbeats", get(contracts::get_supervisor_heartbeats))
- .route("/contracts/{id}/supervisor/sync", post(contracts::sync_supervisor))
- // History endpoints
- .route("/contracts/{id}/history", get(history::get_contract_history))
- .route("/contracts/{id}/supervisor/conversation", get(history::get_supervisor_conversation))
- // Contract daemon endpoints (for tasks to interact with contracts)
- .route("/contracts/{id}/daemon/status", get(contract_daemon::get_contract_status))
- .route("/contracts/{id}/daemon/checklist", get(contract_daemon::get_contract_checklist))
- .route("/contracts/{id}/daemon/goals", get(contract_daemon::get_contract_goals))
- .route("/contracts/{id}/daemon/report", post(contract_daemon::post_progress_report))
- .route("/contracts/{id}/daemon/suggest-action", post(contract_daemon::get_suggest_action))
- .route("/contracts/{id}/daemon/completion-action", post(contract_daemon::get_completion_action))
- .route(
- "/contracts/{id}/daemon/files",
- get(contract_daemon::list_contract_files).post(contract_daemon::create_contract_file),
- )
- .route(
- "/contracts/{id}/daemon/files/{file_id}",
- get(contract_daemon::get_contract_file).put(contract_daemon::update_contract_file),
- )
- // Contract repository endpoints
- .route("/contracts/{id}/repositories/remote", post(contracts::add_remote_repository))
- .route("/contracts/{id}/repositories/local", post(contracts::add_local_repository))
- .route("/contracts/{id}/repositories/managed", post(contracts::create_managed_repository))
- .route(
- "/contracts/{id}/repositories/{repo_id}",
- axum::routing::delete(contracts::delete_repository),
- )
- .route(
- "/contracts/{id}/repositories/{repo_id}/primary",
- axum::routing::put(contracts::set_repository_primary),
- )
- // Contract task association endpoints
- .route(
- "/contracts/{id}/tasks/{task_id}",
- post(contracts::add_task_to_contract).delete(contracts::remove_task_from_contract),
- )
+ // Contract endpoints removed in Phase 5. The contracts subsystem
+ // has been folded into directives — see Phase 5 in the unified
+ // surface plan. Routes are gone; handler files were deleted.
// Directive endpoints
.route(
"/directives",
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index 7a4b004..51a1c0d 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -31,7 +31,7 @@ use crate::server::auth::{
ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse,
RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse,
};
-use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users};
+use crate::server::handlers::{api_keys, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users};
use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage};
#[derive(OpenApi)]
@@ -92,27 +92,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
users::delete_account_handler,
users::get_user_settings_handler,
users::update_user_settings_handler,
- // Contract endpoints
- contracts::list_contracts,
- contracts::get_contract,
- contracts::create_contract,
- contracts::update_contract,
- contracts::delete_contract,
- contracts::change_phase,
- contracts::get_events,
- contracts::add_remote_repository,
- contracts::add_local_repository,
- contracts::create_managed_repository,
- contracts::delete_repository,
- contracts::set_repository_primary,
- contracts::add_task_to_contract,
- contracts::remove_task_from_contract,
- // Contract chat endpoints
- contract_chat::contract_chat_handler,
- contract_chat::get_contract_chat_history,
- contract_chat::clear_contract_chat_history,
- // Contract discuss endpoint
- contract_discuss::discuss_contract_handler,
+ // Contract endpoints removed in Phase 5.
// Directive endpoints
directives::list_directives,
directives::create_directive,
@@ -182,15 +162,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
MeshChatConversation,
MeshChatMessageRecord,
MeshChatHistoryResponse,
- // Contract chat schemas
- ContractChatMessageRecord,
- ContractChatHistoryResponse,
- // Contract discuss schemas
- contract_discuss::ChatMessage,
- contract_discuss::DiscussContractRequest,
- contract_discuss::DiscussContractResponse,
- contract_discuss::ToolCallInfo,
- contract_discuss::CreatedContractInfo,
+ // Contract chat / discuss schemas removed in Phase 5.
// Merge schemas
BranchInfo,
BranchListResponse,