summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-14 21:29:26 +0000
committerGitHub <noreply@github.com>2026-02-14 21:29:26 +0000
commit9aadbc7958d39d181c0dd0600e2b7c30bb6c391a (patch)
treeef8bed9718c39041191b58a284ee31f5d8d32521
parentc1e55ce4fec79f9909b957f86bd7fa8b76939746 (diff)
downloadsoryu-9aadbc7958d39d181c0dd0600e2b7c30bb6c391a.tar.gz
soryu-9aadbc7958d39d181c0dd0600e2b7c30bb6c391a.zip
Makima system improvements: Orders, directive questions, PR creation fix, bug fixes (#62)
* feat: soryu-co/soryu - makima: Fix directive goal update bug - stale closure issue * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Create Orders database schema and backend API * feat: soryu-co/soryu - makima: Fix task Claude instance not receiving user inputs from input box * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Build Orders frontend page replacing the Board page * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Fix directive PR creation system
-rw-r--r--makima/frontend/src/components/NavStrip.tsx2
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx139
-rw-r--r--makima/frontend/src/components/mesh/TaskOutput.tsx5
-rw-r--r--makima/frontend/src/components/orders/OrderDetail.tsx530
-rw-r--r--makima/frontend/src/components/orders/OrderList.tsx188
-rw-r--r--makima/frontend/src/components/workflow/PhaseColumn.tsx126
-rw-r--r--makima/frontend/src/components/workflow/WorkflowBoard.tsx98
-rw-r--r--makima/frontend/src/components/workflow/WorkflowContractCard.tsx76
-rw-r--r--makima/frontend/src/hooks/useOrders.ts123
-rw-r--r--makima/frontend/src/lib/api.ts145
-rw-r--r--makima/frontend/src/main.tsx14
-rw-r--r--makima/frontend/src/routes/directives.tsx3
-rw-r--r--makima/frontend/src/routes/mesh.tsx2
-rw-r--r--makima/frontend/src/routes/orders.tsx238
-rw-r--r--makima/frontend/src/routes/workflow.tsx250
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/migrations/20260214000000_create_orders.sql38
-rw-r--r--makima/migrations/20260214100000_directive_reconcile_mode.sql4
-rw-r--r--makima/src/daemon/skills/directive.md6
-rw-r--r--makima/src/daemon/task/manager.rs23
-rw-r--r--makima/src/db/models.rs125
-rw-r--r--makima/src/db/repository.rs316
-rw-r--r--makima/src/orchestration/directive.rs88
-rw-r--r--makima/src/server/handlers/mesh.rs10
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs90
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/handlers/orders.rs443
-rw-r--r--makima/src/server/mod.rs16
-rw-r--r--makima/src/server/openapi.rs33
-rw-r--r--makima/src/server/state.rs30
30 files changed, 2558 insertions, 606 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index 9bb7777..5aba6a3 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -12,7 +12,7 @@ const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
{ label: "Directives", href: "/directives", requiresAuth: true },
{ label: "Contracts", href: "/contracts", requiresAuth: true },
- { label: "Board", href: "/workflow", requiresAuth: true },
+ { label: "Orders", href: "/orders", requiresAuth: true },
{ label: "Mesh", href: "/mesh", requiresAuth: true },
{ label: "History", href: "/history", requiresAuth: true },
];
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx
index b73463d..e278939 100644
--- a/makima/frontend/src/components/directives/DirectiveDetail.tsx
+++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx
@@ -1,8 +1,9 @@
import { useState, useMemo, useEffect, useRef } from "react";
-import type { DirectiveWithSteps, DirectiveStatus } from "../../lib/api";
+import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest } from "../../lib/api";
import { DirectiveDAG } from "./DirectiveDAG";
import { DirectiveLogStream } from "./DirectiveLogStream";
import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription";
+import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext";
const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = {
draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" },
@@ -21,6 +22,7 @@ interface DirectiveDetailProps {
onFailStep: (stepId: string) => void;
onSkipStep: (stepId: string) => void;
onUpdateGoal: (goal: string) => void;
+ onUpdate: (req: UpdateDirectiveRequest) => void;
onDelete: () => void;
onRefresh: () => void;
onCleanupTasks: () => void;
@@ -35,6 +37,7 @@ export function DirectiveDetail({
onFailStep,
onSkipStep,
onUpdateGoal,
+ onUpdate,
onDelete,
onRefresh,
onCleanupTasks,
@@ -42,6 +45,12 @@ export function DirectiveDetail({
const [editingGoal, setEditingGoal] = useState(false);
const [goalText, setGoalText] = useState(directive.goal);
const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null);
+
+ // Sync goalText and reset editing state when directive changes
+ useEffect(() => {
+ setGoalText(directive.goal);
+ setEditingGoal(false);
+ }, [directive.id, directive.goal]);
const [searchQuery, setSearchQuery] = useState("");
const [isLogCollapsed, setIsLogCollapsed] = useState(true);
const prevHadRunningRef = useRef(false);
@@ -53,6 +62,24 @@ export function DirectiveDetail({
const terminalStatuses = new Set(["completed", "failed", "skipped"]);
const hasTerminalTasks = directive.steps.some((s) => s.taskId && terminalStatuses.has(s.status));
+ // Get pending questions for this directive's tasks
+ const { pendingQuestions, submitAnswer } = useSupervisorQuestions();
+ const directiveTaskIds = useMemo(() => {
+ const ids = new Set<string>();
+ if (directive.orchestratorTaskId) ids.add(directive.orchestratorTaskId);
+ for (const step of directive.steps) {
+ if (step.taskId) ids.add(step.taskId);
+ }
+ return ids;
+ }, [directive.orchestratorTaskId, directive.steps]);
+
+ const directiveQuestions = useMemo(
+ () => pendingQuestions.filter((q) =>
+ q.directiveId === directive.id || directiveTaskIds.has(q.taskId)
+ ),
+ [pendingQuestions, directive.id, directiveTaskIds]
+ );
+
// Build task map from directive steps and orchestrator
// Derive a stable key from the actual task IDs to avoid recreating the map on every poll
const taskMapKey = useMemo(() => {
@@ -149,6 +176,26 @@ export function DirectiveDetail({
</div>
)}
+ {/* Reconcile mode toggle */}
+ <div className="flex items-center gap-2 mb-2">
+ <button
+ type="button"
+ onClick={() => onUpdate({ reconcileMode: !directive.reconcileMode })}
+ className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${
+ directive.reconcileMode
+ ? "text-amber-400 border-amber-800 bg-amber-900/20"
+ : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]"
+ }`}
+ >
+ {directive.reconcileMode ? "Reconcile: ON" : "Reconcile: OFF"}
+ </button>
+ <span className="text-[9px] font-mono text-[#445566]">
+ {directive.reconcileMode
+ ? "Questions pause execution"
+ : "Questions timeout after 30s"}
+ </span>
+ </div>
+
{/* Orchestrator planning indicator */}
{directive.orchestratorTaskId && (
<div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#1a1a30] border border-[rgba(117,170,252,0.2)] rounded">
@@ -199,6 +246,20 @@ export function DirectiveDetail({
</div>
)}
+ {/* Pending Questions */}
+ {directiveQuestions.length > 0 && (
+ <div className="mb-2 space-y-2">
+ {directiveQuestions.map((q) => (
+ <DirectiveQuestionCard
+ key={q.questionId}
+ question={q}
+ taskName={taskMap.get(q.taskId) || "Task"}
+ onAnswer={(response) => submitAnswer(q.questionId, response)}
+ />
+ ))}
+ </div>
+ )}
+
{/* Controls */}
<div className="flex flex-wrap gap-2">
{(directive.status === "draft" || directive.status === "paused") && (
@@ -235,7 +296,7 @@ export function DirectiveDetail({
</span>
<button
type="button"
- onClick={() => setEditingGoal(true)}
+ onClick={() => { setGoalText(directive.goal); setEditingGoal(true); }}
className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1"
>
Update Goal
@@ -342,3 +403,77 @@ export function DirectiveDetail({
</div>
);
}
+
+/** Inline question card for directive pending questions */
+function DirectiveQuestionCard({
+ question,
+ taskName,
+ onAnswer,
+}: {
+ question: { questionId: string; question: string; choices: string[]; context: string | null };
+ taskName: string;
+ onAnswer: (response: string) => void;
+}) {
+ const [customResponse, setCustomResponse] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async (response: string) => {
+ setSubmitting(true);
+ await onAnswer(response);
+ setSubmitting(false);
+ };
+
+ return (
+ <div className="px-2 py-2 bg-[#1a1020] border border-purple-900/50 rounded">
+ <div className="flex items-center gap-1.5 mb-1">
+ <span className="inline-block w-2 h-2 rounded-full bg-purple-400 animate-pulse" />
+ <span className="text-[9px] font-mono text-purple-400 uppercase">
+ Question from {taskName}
+ </span>
+ </div>
+ <p className="text-[11px] font-mono text-white mb-1.5">{question.question}</p>
+ {question.context && (
+ <p className="text-[9px] font-mono text-[#556677] mb-1.5">{question.context}</p>
+ )}
+ {question.choices.length > 0 ? (
+ <div className="flex flex-wrap gap-1">
+ {question.choices.map((choice) => (
+ <button
+ key={choice}
+ type="button"
+ disabled={submitting}
+ onClick={() => handleSubmit(choice)}
+ className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 hover:border-purple-600 rounded px-2 py-0.5 disabled:opacity-50"
+ >
+ {choice}
+ </button>
+ ))}
+ </div>
+ ) : (
+ <div className="flex gap-1">
+ <input
+ type="text"
+ value={customResponse}
+ onChange={(e) => setCustomResponse(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && customResponse.trim()) {
+ handleSubmit(customResponse.trim());
+ }
+ }}
+ placeholder="Type your answer..."
+ className="flex-1 bg-[#0a0618] border border-purple-900/50 rounded px-2 py-0.5 text-[10px] font-mono text-white placeholder:text-[#445566]"
+ disabled={submitting}
+ />
+ <button
+ type="button"
+ disabled={submitting || !customResponse.trim()}
+ onClick={() => handleSubmit(customResponse.trim())}
+ className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 rounded px-2 py-0.5 disabled:opacity-50"
+ >
+ Send
+ </button>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx
index f49c366..2db4250 100644
--- a/makima/frontend/src/components/mesh/TaskOutput.tsx
+++ b/makima/frontend/src/components/mesh/TaskOutput.tsx
@@ -77,7 +77,10 @@ export function TaskOutput({
setInputValue("");
inputRef.current?.focus();
} catch (err) {
- setInputError(err instanceof Error ? err.message : "Failed to send input");
+ const errorMsg = err instanceof Error ? err.message : "Failed to send input";
+ setInputError(errorMsg);
+ // Auto-dismiss error after 5 seconds
+ setTimeout(() => setInputError(null), 5000);
} finally {
setSendingInput(false);
}
diff --git a/makima/frontend/src/components/orders/OrderDetail.tsx b/makima/frontend/src/components/orders/OrderDetail.tsx
new file mode 100644
index 0000000..7f8a95d
--- /dev/null
+++ b/makima/frontend/src/components/orders/OrderDetail.tsx
@@ -0,0 +1,530 @@
+import { useState } from "react";
+import type {
+ Order,
+ OrderStatus,
+ OrderPriority,
+ OrderType,
+ UpdateOrderRequest,
+ DirectiveSummary,
+} from "../../lib/api";
+
+const STATUS_BADGE: Record<OrderStatus, { color: string; label: string }> = {
+ open: { color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "OPEN" },
+ in_progress: { color: "text-yellow-400 border-yellow-800", label: "IN PROGRESS" },
+ done: { color: "text-emerald-400 border-emerald-800", label: "DONE" },
+ archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" },
+};
+
+const PRIORITY_OPTIONS: { value: OrderPriority; color: string; label: string }[] = [
+ { value: "critical", color: "text-red-400 border-red-800", label: "Critical" },
+ { value: "high", color: "text-orange-400 border-orange-800", label: "High" },
+ { value: "medium", color: "text-yellow-400 border-yellow-800", label: "Medium" },
+ { value: "low", color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "Low" },
+ { value: "none", color: "text-[#556677] border-[#2a3a5a]", label: "None" },
+];
+
+const TYPE_OPTIONS: { value: OrderType; color: string; label: string }[] = [
+ { value: "feature", color: "text-[#75aafc]", label: "Feature" },
+ { value: "bug", color: "text-red-400", label: "Bug" },
+ { value: "spike", color: "text-yellow-400", label: "Spike" },
+ { value: "chore", color: "text-[#7788aa]", label: "Chore" },
+ { value: "improvement", color: "text-emerald-400", label: "Improvement" },
+];
+
+const STATUS_OPTIONS: OrderStatus[] = ["open", "in_progress", "done", "archived"];
+
+interface OrderDetailProps {
+ order: Order;
+ directives: DirectiveSummary[];
+ onUpdate: (req: UpdateOrderRequest) => Promise<void>;
+ onDelete: () => void;
+ onLinkDirective: (directiveId: string) => Promise<void>;
+ onLinkContract: (contractId: string) => Promise<void>;
+ onConvertToStep: (directiveId: string) => Promise<void>;
+ onRefresh: () => void;
+}
+
+export function OrderDetail({
+ order,
+ directives,
+ onUpdate,
+ onDelete,
+ onLinkDirective,
+ onLinkContract,
+ onConvertToStep,
+ onRefresh,
+}: OrderDetailProps) {
+ const [editingTitle, setEditingTitle] = useState(false);
+ const [titleText, setTitleText] = useState(order.title);
+ const [editingDesc, setEditingDesc] = useState(false);
+ const [descText, setDescText] = useState(order.description || "");
+ const [editingLabels, setEditingLabels] = useState(false);
+ const [labelsText, setLabelsText] = useState(order.labels.join(", "));
+ const [showLinkDirective, setShowLinkDirective] = useState(false);
+ const [showLinkContract, setShowLinkContract] = useState(false);
+ const [contractIdInput, setContractIdInput] = useState("");
+ const [showConvertToStep, setShowConvertToStep] = useState(false);
+
+ const badge = STATUS_BADGE[order.status] || STATUS_BADGE.open;
+ const currentPriority = PRIORITY_OPTIONS.find((p) => p.value === order.priority) || PRIORITY_OPTIONS[4];
+ const currentType = TYPE_OPTIONS.find((t) => t.value === order.orderType) || TYPE_OPTIONS[0];
+
+ const handleTitleSave = async () => {
+ if (titleText.trim() && titleText !== order.title) {
+ await onUpdate({ title: titleText.trim() });
+ }
+ setEditingTitle(false);
+ };
+
+ const handleDescSave = async () => {
+ const newDesc = descText.trim() || null;
+ if (newDesc !== order.description) {
+ await onUpdate({ description: newDesc });
+ }
+ setEditingDesc(false);
+ };
+
+ const handleLabelsSave = async () => {
+ const newLabels = labelsText
+ .split(",")
+ .map((l) => l.trim())
+ .filter((l) => l.length > 0);
+ await onUpdate({ labels: newLabels });
+ setEditingLabels(false);
+ };
+
+ const handleStatusChange = async (status: OrderStatus) => {
+ await onUpdate({ status });
+ };
+
+ const handlePriorityChange = async (priority: OrderPriority) => {
+ await onUpdate({ priority });
+ };
+
+ const handleTypeChange = async (orderType: OrderType) => {
+ await onUpdate({ orderType });
+ };
+
+ const handleLinkDirective = async (directiveId: string) => {
+ await onLinkDirective(directiveId);
+ setShowLinkDirective(false);
+ };
+
+ const handleLinkContract = async () => {
+ if (!contractIdInput.trim()) return;
+ await onLinkContract(contractIdInput.trim());
+ setContractIdInput("");
+ setShowLinkContract(false);
+ };
+
+ const handleConvertToStep = async (directiveId: string) => {
+ await onConvertToStep(directiveId);
+ setShowConvertToStep(false);
+ };
+
+ return (
+ <div className="flex flex-col h-full overflow-y-auto">
+ {/* Header */}
+ <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
+ <div className="flex items-center justify-between mb-2">
+ {editingTitle ? (
+ <div className="flex-1 flex items-center gap-2 pr-2">
+ <input
+ value={titleText}
+ onChange={(e) => setTitleText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleTitleSave();
+ if (e.key === "Escape") setEditingTitle(false);
+ }}
+ autoFocus
+ className="flex-1 bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1 text-[14px] font-mono text-white"
+ />
+ <button
+ type="button"
+ onClick={handleTitleSave}
+ className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300"
+ >
+ [save]
+ </button>
+ <button
+ type="button"
+ onClick={() => setEditingTitle(false)}
+ className="text-[10px] font-mono text-[#556677] hover:text-white"
+ >
+ [cancel]
+ </button>
+ </div>
+ ) : (
+ <h2
+ className="text-[14px] font-mono text-white font-medium truncate pr-2 cursor-pointer hover:text-[#9bc3ff]"
+ onClick={() => {
+ setTitleText(order.title);
+ setEditingTitle(true);
+ }}
+ >
+ {order.title}
+ </h2>
+ )}
+ <div className="flex items-center gap-2 shrink-0">
+ <span
+ className={`text-[10px] font-mono ${badge.color} border rounded px-2 py-0.5`}
+ >
+ {badge.label}
+ </span>
+ <button
+ type="button"
+ onClick={onRefresh}
+ className="text-[10px] font-mono text-[#7788aa] hover:text-white"
+ title="Refresh"
+ >
+ [refresh]
+ </button>
+ </div>
+ </div>
+
+ {/* Type + Priority inline */}
+ <div className="flex items-center gap-3 mb-2">
+ <span className={`text-[10px] font-mono ${currentType.color}`}>
+ {currentType.label}
+ </span>
+ <span className="text-[10px] font-mono text-[#2a3a5a]">/</span>
+ <span className={`text-[10px] font-mono ${currentPriority.color} border rounded px-1.5 py-0.5`}>
+ {currentPriority.label}
+ </span>
+ </div>
+
+ {/* Linked entities */}
+ {order.directiveId && (
+ <div className="text-[10px] font-mono text-[#556677] mb-1 truncate">
+ Directive: <a href={`/directives/${order.directiveId}`} className="text-[#75aafc] hover:text-white underline">{order.directiveId.slice(0, 8)}...</a>
+ </div>
+ )}
+ {order.contractId && (
+ <div className="text-[10px] font-mono text-[#556677] mb-1 truncate">
+ Contract: <a href={`/contracts/${order.contractId}`} className="text-[#75aafc] hover:text-white underline">{order.contractId.slice(0, 8)}...</a>
+ </div>
+ )}
+ {order.directiveStepId && (
+ <div className="text-[10px] font-mono text-[#556677] mb-1 truncate">
+ Step: <span className="text-[#7788aa]">{order.directiveStepId.slice(0, 8)}...</span>
+ </div>
+ )}
+
+ {/* Controls */}
+ <div className="flex flex-wrap gap-2 mt-2">
+ <button
+ type="button"
+ onClick={onDelete}
+ className="text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-1 ml-auto"
+ >
+ Delete
+ </button>
+ </div>
+ </div>
+
+ {/* Status selector */}
+ <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]">
+ <div className="flex items-center justify-between mb-1.5">
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Status
+ </span>
+ </div>
+ <div className="flex gap-1.5 flex-wrap">
+ {STATUS_OPTIONS.map((s) => {
+ const sBadge = STATUS_BADGE[s];
+ return (
+ <button
+ key={s}
+ type="button"
+ onClick={() => handleStatusChange(s)}
+ className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${
+ s === order.status
+ ? `${sBadge.color} bg-[rgba(117,170,252,0.1)]`
+ : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]"
+ }`}
+ >
+ {sBadge.label}
+ </button>
+ );
+ })}
+ </div>
+ </div>
+
+ {/* Priority selector */}
+ <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]">
+ <div className="flex items-center justify-between mb-1.5">
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Priority
+ </span>
+ </div>
+ <div className="flex gap-1.5 flex-wrap">
+ {PRIORITY_OPTIONS.map((p) => (
+ <button
+ key={p.value}
+ type="button"
+ onClick={() => handlePriorityChange(p.value)}
+ className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${
+ p.value === order.priority
+ ? `${p.color} bg-[rgba(117,170,252,0.1)]`
+ : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]"
+ }`}
+ >
+ {p.label}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* Type selector */}
+ <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]">
+ <div className="flex items-center justify-between mb-1.5">
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Type
+ </span>
+ </div>
+ <div className="flex gap-1.5 flex-wrap">
+ {TYPE_OPTIONS.map((t) => (
+ <button
+ key={t.value}
+ type="button"
+ onClick={() => handleTypeChange(t.value)}
+ className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${
+ t.value === order.orderType
+ ? `${t.color} border-current bg-[rgba(117,170,252,0.1)]`
+ : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]"
+ }`}
+ >
+ {t.label}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* Description */}
+ <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]">
+ <div className="flex items-center justify-between mb-1">
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Description
+ </span>
+ {!editingDesc && (
+ <button
+ type="button"
+ onClick={() => {
+ setDescText(order.description || "");
+ setEditingDesc(true);
+ }}
+ className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
+ >
+ [edit]
+ </button>
+ )}
+ </div>
+ {editingDesc ? (
+ <div className="flex flex-col gap-1.5">
+ <textarea
+ value={descText}
+ onChange={(e) => setDescText(e.target.value)}
+ className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[11px] font-mono text-white resize-y min-h-[80px]"
+ rows={4}
+ autoFocus
+ />
+ <div className="flex gap-1.5">
+ <button
+ type="button"
+ onClick={handleDescSave}
+ className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5"
+ >
+ Save
+ </button>
+ <button
+ type="button"
+ onClick={() => setEditingDesc(false)}
+ className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ ) : (
+ <p className="text-[11px] font-mono text-[#c0d0e0] whitespace-pre-wrap">
+ {order.description || <span className="text-[#556677] italic">No description</span>}
+ </p>
+ )}
+ </div>
+
+ {/* Labels */}
+ <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]">
+ <div className="flex items-center justify-between mb-1">
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Labels
+ </span>
+ {!editingLabels && (
+ <button
+ type="button"
+ onClick={() => {
+ setLabelsText(order.labels.join(", "));
+ setEditingLabels(true);
+ }}
+ className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
+ >
+ [edit]
+ </button>
+ )}
+ </div>
+ {editingLabels ? (
+ <div className="flex flex-col gap-1.5">
+ <input
+ value={labelsText}
+ onChange={(e) => setLabelsText(e.target.value)}
+ placeholder="label1, label2, ..."
+ className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[11px] font-mono text-white"
+ autoFocus
+ />
+ <div className="flex gap-1.5">
+ <button
+ type="button"
+ onClick={handleLabelsSave}
+ className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5"
+ >
+ Save
+ </button>
+ <button
+ type="button"
+ onClick={() => setEditingLabels(false)}
+ className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ ) : (
+ <div className="flex gap-1 flex-wrap">
+ {order.labels.length > 0 ? (
+ order.labels.map((l) => (
+ <span
+ key={l}
+ className="text-[10px] font-mono text-[#9bc3ff] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)] rounded px-1.5 py-0.5"
+ >
+ {l}
+ </span>
+ ))
+ ) : (
+ <span className="text-[10px] font-mono text-[#556677] italic">No labels</span>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Actions */}
+ <div className="px-4 py-3 flex-1">
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-2">
+ Actions
+ </span>
+
+ <div className="flex flex-col gap-2">
+ {/* Link to Directive */}
+ <div>
+ <button
+ type="button"
+ onClick={() => setShowLinkDirective(!showLinkDirective)}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1 w-full text-left"
+ >
+ Link to Directive
+ </button>
+ {showLinkDirective && (
+ <div className="mt-1 border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto rounded">
+ {directives.length === 0 ? (
+ <div className="px-3 py-2 text-[10px] font-mono text-[#556677]">
+ No directives available
+ </div>
+ ) : (
+ directives.map((d) => (
+ <button
+ key={d.id}
+ type="button"
+ onClick={() => handleLinkDirective(d.id)}
+ className="w-full text-left px-3 py-1.5 text-[10px] font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0"
+ >
+ {d.title}
+ </button>
+ ))
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Link to Contract */}
+ <div>
+ <button
+ type="button"
+ onClick={() => setShowLinkContract(!showLinkContract)}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1 w-full text-left"
+ >
+ Link to Contract
+ </button>
+ {showLinkContract && (
+ <div className="mt-1 flex gap-1.5">
+ <input
+ value={contractIdInput}
+ onChange={(e) => setContractIdInput(e.target.value)}
+ placeholder="Contract ID..."
+ className="flex-1 bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1 text-[10px] font-mono text-white"
+ autoFocus
+ />
+ <button
+ type="button"
+ onClick={handleLinkContract}
+ disabled={!contractIdInput.trim()}
+ className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-1 disabled:opacity-50"
+ >
+ Link
+ </button>
+ </div>
+ )}
+ </div>
+
+ {/* Convert to Directive Step */}
+ {!order.directiveStepId && (
+ <div>
+ <button
+ type="button"
+ onClick={() => setShowConvertToStep(!showConvertToStep)}
+ className="text-[10px] font-mono text-yellow-400 hover:text-yellow-300 border border-yellow-800 rounded px-2 py-1 w-full text-left"
+ >
+ Convert to Directive Step
+ </button>
+ {showConvertToStep && (
+ <div className="mt-1 border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto rounded">
+ {directives.length === 0 ? (
+ <div className="px-3 py-2 text-[10px] font-mono text-[#556677]">
+ No directives available
+ </div>
+ ) : (
+ directives.map((d) => (
+ <button
+ key={d.id}
+ type="button"
+ onClick={() => handleConvertToStep(d.id)}
+ className="w-full text-left px-3 py-1.5 text-[10px] font-mono text-yellow-400 hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0"
+ >
+ {d.title}
+ </button>
+ ))
+ )}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* Metadata */}
+ <div className="px-4 py-2 border-t border-[rgba(117,170,252,0.1)]">
+ <div className="flex items-center justify-between text-[9px] font-mono text-[#556677]">
+ <span>Created {new Date(order.createdAt).toLocaleDateString()}</span>
+ <span>Updated {new Date(order.updatedAt).toLocaleDateString()}</span>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/orders/OrderList.tsx b/makima/frontend/src/components/orders/OrderList.tsx
new file mode 100644
index 0000000..76ac7a7
--- /dev/null
+++ b/makima/frontend/src/components/orders/OrderList.tsx
@@ -0,0 +1,188 @@
+import { useState, useMemo } from "react";
+import type { Order, OrderStatus, OrderPriority, OrderType } from "../../lib/api";
+
+const STATUS_BADGE: Record<OrderStatus, { color: string; label: string }> = {
+ open: { color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "OPEN" },
+ in_progress: { color: "text-yellow-400 border-yellow-800", label: "IN PROGRESS" },
+ done: { color: "text-emerald-400 border-emerald-800", label: "DONE" },
+ archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" },
+};
+
+const PRIORITY_COLOR: Record<OrderPriority, string> = {
+ critical: "bg-red-400",
+ high: "bg-orange-400",
+ medium: "bg-yellow-400",
+ low: "bg-[#75aafc]",
+ none: "bg-[#556677]",
+};
+
+const TYPE_BADGE: Record<OrderType, { color: string; label: string }> = {
+ feature: { color: "text-[#75aafc] border-[rgba(117,170,252,0.3)]", label: "FEAT" },
+ bug: { color: "text-red-400 border-red-800", label: "BUG" },
+ spike: { color: "text-yellow-400 border-yellow-800", label: "SPIKE" },
+ chore: { color: "text-[#7788aa] border-[#2a3a5a]", label: "CHORE" },
+ improvement: { color: "text-emerald-400 border-emerald-800", label: "IMPROVE" },
+};
+
+interface OrderListProps {
+ orders: Order[];
+ selectedId: string | null;
+ onSelect: (id: string) => void;
+ onCreate: () => void;
+ statusFilter: OrderStatus | undefined;
+ onStatusFilter: (s: OrderStatus | undefined) => void;
+ typeFilter: OrderType | undefined;
+ onTypeFilter: (t: OrderType | undefined) => void;
+}
+
+const STATUS_OPTIONS: (OrderStatus | "all")[] = ["all", "open", "in_progress", "done", "archived"];
+const TYPE_OPTIONS: (OrderType | "all")[] = ["all", "feature", "bug", "spike", "chore", "improvement"];
+
+export function OrderList({
+ orders,
+ selectedId,
+ onSelect,
+ onCreate,
+ statusFilter,
+ onStatusFilter,
+ typeFilter,
+ onTypeFilter,
+}: OrderListProps) {
+ const [search, setSearch] = useState("");
+
+ const filtered = useMemo(() => {
+ if (!search.trim()) return orders;
+ const q = search.toLowerCase();
+ return orders.filter(
+ (o) =>
+ o.title.toLowerCase().includes(q) ||
+ (o.description && o.description.toLowerCase().includes(q)) ||
+ o.labels.some((l) => l.toLowerCase().includes(q)),
+ );
+ }, [orders, search]);
+
+ return (
+ <div className="flex flex-col h-full">
+ {/* Header */}
+ <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]">
+ <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Orders
+ </span>
+ <button
+ type="button"
+ onClick={onCreate}
+ className="text-[11px] font-mono text-[#75aafc] hover:text-white bg-transparent border border-[rgba(117,170,252,0.3)] rounded px-2 py-0.5 hover:border-[rgba(117,170,252,0.6)] transition-colors"
+ >
+ + New
+ </button>
+ </div>
+
+ {/* Search */}
+ <div className="px-3 py-1.5 border-b border-[rgba(117,170,252,0.1)]">
+ <input
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ placeholder="Search orders..."
+ className="w-full bg-transparent border-none outline-none text-[11px] font-mono text-white placeholder:text-[#556677]"
+ />
+ </div>
+
+ {/* Filters */}
+ <div className="px-3 py-1.5 border-b border-[rgba(117,170,252,0.1)] flex flex-col gap-1">
+ <div className="flex items-center gap-1 flex-wrap">
+ <span className="text-[9px] font-mono text-[#556677] uppercase w-[38px] shrink-0">
+ Status
+ </span>
+ {STATUS_OPTIONS.map((s) => (
+ <button
+ key={s}
+ type="button"
+ onClick={() => onStatusFilter(s === "all" ? undefined : s)}
+ className={`text-[9px] font-mono px-1.5 py-0.5 rounded transition-colors ${
+ (s === "all" && !statusFilter) || s === statusFilter
+ ? "text-white bg-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.4)]"
+ : "text-[#556677] hover:text-[#7788aa] border border-transparent"
+ }`}
+ >
+ {s === "all" ? "ALL" : s === "in_progress" ? "WIP" : s.toUpperCase()}
+ </button>
+ ))}
+ </div>
+ <div className="flex items-center gap-1 flex-wrap">
+ <span className="text-[9px] font-mono text-[#556677] uppercase w-[38px] shrink-0">
+ Type
+ </span>
+ {TYPE_OPTIONS.map((t) => (
+ <button
+ key={t}
+ type="button"
+ onClick={() => onTypeFilter(t === "all" ? undefined : t)}
+ className={`text-[9px] font-mono px-1.5 py-0.5 rounded transition-colors ${
+ (t === "all" && !typeFilter) || t === typeFilter
+ ? "text-white bg-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.4)]"
+ : "text-[#556677] hover:text-[#7788aa] border border-transparent"
+ }`}
+ >
+ {t === "all" ? "ALL" : t.toUpperCase()}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* List */}
+ <div className="flex-1 overflow-y-auto">
+ {filtered.length === 0 ? (
+ <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
+ No orders found
+ </div>
+ ) : (
+ filtered.map((o) => {
+ const statusBadge = STATUS_BADGE[o.status] || STATUS_BADGE.open;
+ const typeBadge = TYPE_BADGE[o.orderType] || TYPE_BADGE.feature;
+ const priorityColor = PRIORITY_COLOR[o.priority] || PRIORITY_COLOR.none;
+
+ return (
+ <button
+ key={o.id}
+ type="button"
+ onClick={() => onSelect(o.id)}
+ className={`w-full text-left px-3 py-2.5 border-b border-[rgba(117,170,252,0.1)] hover:bg-[rgba(117,170,252,0.05)] transition-colors ${
+ selectedId === o.id ? "bg-[rgba(117,170,252,0.1)]" : ""
+ }`}
+ >
+ <div className="flex items-start gap-2 mb-1">
+ {/* Priority dot */}
+ <span
+ className={`w-2 h-2 rounded-full ${priorityColor} shrink-0 mt-[3px]`}
+ title={o.priority}
+ />
+ <span className="text-[12px] font-mono text-white truncate flex-1">
+ {o.title}
+ </span>
+ </div>
+ <div className="flex items-center gap-1.5 pl-4">
+ <span
+ className={`text-[9px] font-mono ${statusBadge.color} border rounded px-1.5 py-0.5`}
+ >
+ {statusBadge.label}
+ </span>
+ <span
+ className={`text-[9px] font-mono ${typeBadge.color} border rounded px-1.5 py-0.5`}
+ >
+ {typeBadge.label}
+ </span>
+ {o.labels.length > 0 && (
+ <span className="text-[9px] font-mono text-[#556677] truncate">
+ {o.labels.slice(0, 2).join(", ")}
+ {o.labels.length > 2 && ` +${o.labels.length - 2}`}
+ </span>
+ )}
+ </div>
+ </button>
+ );
+ })
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/workflow/PhaseColumn.tsx b/makima/frontend/src/components/workflow/PhaseColumn.tsx
deleted file mode 100644
index 277b04c..0000000
--- a/makima/frontend/src/components/workflow/PhaseColumn.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import { useState } from "react";
-import type { ContractSummary, ContractPhase } from "../../lib/api";
-import { WorkflowContractCard } from "./WorkflowContractCard";
-
-interface PhaseColumnProps {
- phase: ContractPhase;
- contracts: ContractSummary[];
- onContractClick: (contractId: string) => void;
- onDrop: (contractId: string, phase: ContractPhase) => void;
- onContextMenu?: (e: React.MouseEvent, contract: ContractSummary) => void;
-}
-
-const phaseConfig: Record<
- ContractPhase,
- { label: string; color: string; bgColor: string; borderColor: string }
-> = {
- research: {
- label: "Research",
- color: "text-purple-400",
- bgColor: "bg-purple-400/10",
- borderColor: "border-purple-400/30",
- },
- specify: {
- label: "Specify",
- color: "text-blue-400",
- bgColor: "bg-blue-400/10",
- borderColor: "border-blue-400/30",
- },
- plan: {
- label: "Plan",
- color: "text-cyan-400",
- bgColor: "bg-cyan-400/10",
- borderColor: "border-cyan-400/30",
- },
- execute: {
- label: "Execute",
- color: "text-yellow-400",
- bgColor: "bg-yellow-400/10",
- borderColor: "border-yellow-400/30",
- },
- review: {
- label: "Review",
- color: "text-green-400",
- bgColor: "bg-green-400/10",
- borderColor: "border-green-400/30",
- },
-};
-
-export function PhaseColumn({
- phase,
- contracts,
- onContractClick,
- onDrop,
- onContextMenu,
-}: PhaseColumnProps) {
- const [isDragOver, setIsDragOver] = useState(false);
- const config = phaseConfig[phase];
-
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
- setIsDragOver(true);
- };
-
- const handleDragLeave = () => {
- setIsDragOver(false);
- };
-
- const handleDrop = (e: React.DragEvent) => {
- e.preventDefault();
- setIsDragOver(false);
- const contractId = e.dataTransfer.getData("contractId");
- if (contractId) {
- onDrop(contractId, phase);
- }
- };
-
- return (
- <div
- className={`
- flex flex-col min-w-[220px] flex-1 border border-[rgba(117,170,252,0.15)]
- ${isDragOver ? "bg-[rgba(117,170,252,0.05)]" : "bg-transparent"}
- transition-colors
- `}
- onDragOver={handleDragOver}
- onDragLeave={handleDragLeave}
- onDrop={handleDrop}
- >
- {/* Column header */}
- <div
- className={`
- p-3 border-b ${config.borderColor} ${config.bgColor}
- flex items-center justify-between
- `}
- >
- <span className={`font-mono text-xs uppercase tracking-wider ${config.color}`}>
- {config.label}
- </span>
- <span className="font-mono text-[10px] text-[#555]">
- ({contracts.length})
- </span>
- </div>
-
- {/* Cards container */}
- <div className="flex-1 overflow-y-auto p-2 space-y-2">
- {contracts.length === 0 ? (
- <div className="p-4 text-center font-mono text-[10px] text-[#555]">
- No contracts
- </div>
- ) : (
- contracts.map((contract) => (
- <WorkflowContractCard
- key={contract.id}
- contract={contract}
- onClick={() => onContractClick(contract.id)}
- onDragStart={(e) => {
- e.dataTransfer.setData("contractId", contract.id);
- e.dataTransfer.effectAllowed = "move";
- }}
- onContextMenu={onContextMenu ? (e) => onContextMenu(e, contract) : undefined}
- />
- ))
- )}
- </div>
- </div>
- );
-}
diff --git a/makima/frontend/src/components/workflow/WorkflowBoard.tsx b/makima/frontend/src/components/workflow/WorkflowBoard.tsx
deleted file mode 100644
index e36ca21..0000000
--- a/makima/frontend/src/components/workflow/WorkflowBoard.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { useMemo, useState } from "react";
-import type { ContractSummary, ContractPhase } from "../../lib/api";
-import { PhaseColumn } from "./PhaseColumn";
-import { ContractContextMenu } from "../contracts/ContractContextMenu";
-
-interface WorkflowBoardProps {
- contracts: ContractSummary[];
- onContractClick: (contractId: string) => void;
- onPhaseChange: (contractId: string, newPhase: ContractPhase) => void;
- onMarkComplete?: (contract: ContractSummary) => void;
- onMarkActive?: (contract: ContractSummary) => void;
- onArchive?: (contract: ContractSummary) => void;
- onDelete?: (contract: ContractSummary) => void;
- onGoToSupervisor?: (contract: ContractSummary) => void;
-}
-
-const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"];
-
-export function WorkflowBoard({
- contracts,
- onContractClick,
- onPhaseChange,
- onMarkComplete,
- onMarkActive,
- onArchive,
- onDelete,
- onGoToSupervisor,
-}: WorkflowBoardProps) {
- 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();
- e.stopPropagation(); // Prevent interference with drag-and-drop
- setContextMenuPosition({ x: e.clientX, y: e.clientY });
- setContextMenuContract(contract);
- };
-
- const closeContextMenu = () => {
- setContextMenuPosition(null);
- setContextMenuContract(null);
- };
-
- // Group contracts by phase
- const contractsByPhase = useMemo(() => {
- const grouped: Record<ContractPhase, ContractSummary[]> = {
- research: [],
- specify: [],
- plan: [],
- execute: [],
- review: [],
- };
-
- for (const contract of contracts) {
- const phase = contract.phase as ContractPhase;
- if (grouped[phase]) {
- grouped[phase].push(contract);
- } else {
- // Default to research if unknown phase
- grouped.research.push(contract);
- }
- }
-
- return grouped;
- }, [contracts]);
-
- return (
- <>
- <div className="flex gap-2 h-full overflow-x-auto">
- {phases.map((phase) => (
- <PhaseColumn
- key={phase}
- phase={phase}
- contracts={contractsByPhase[phase]}
- onContractClick={onContractClick}
- onDrop={onPhaseChange}
- onContextMenu={handleContextMenu}
- />
- ))}
- </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)}
- />
- )}
- </>
- );
-}
diff --git a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx
deleted file mode 100644
index 86fcd13..0000000
--- a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { useNavigate } from "react-router";
-import type { ContractSummary, ContractStatus } from "../../lib/api";
-
-interface WorkflowContractCardProps {
- contract: ContractSummary;
- onClick: () => void;
- onDragStart: (e: React.DragEvent) => void;
- onContextMenu?: (e: React.MouseEvent) => void;
-}
-
-const statusConfig: Record<ContractStatus, { label: string; color: string }> = {
- active: { label: "Active", color: "text-green-400" },
- completed: { label: "Done", color: "text-blue-400" },
- archived: { label: "Archived", color: "text-[#555]" },
-};
-
-export function WorkflowContractCard({
- contract,
- onClick,
- onDragStart,
- onContextMenu,
-}: WorkflowContractCardProps) {
- const navigate = useNavigate();
- const status = statusConfig[contract.status] || statusConfig.active;
-
- const handleSupervisorClick = (e: React.MouseEvent) => {
- e.stopPropagation();
- if (contract.supervisorTaskId) {
- navigate(`/mesh/${contract.supervisorTaskId}`);
- }
- };
-
- return (
- <div
- draggable
- onDragStart={onDragStart}
- onClick={onClick}
- onContextMenu={onContextMenu}
- className="p-3 bg-[rgba(9,13,20,0.8)] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] cursor-pointer transition-colors select-none"
- >
- {/* Header row with name and supervisor button */}
- <div className="flex items-center justify-between gap-2 mb-1">
- <div className="font-mono text-sm text-[#dbe7ff] truncate flex-1">
- {contract.name}
- </div>
- {contract.supervisorTaskId && (
- <button
- onClick={handleSupervisorClick}
- title="Open Supervisor Task"
- className="flex-shrink-0 px-1.5 py-0.5 font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.1)] transition-colors"
- >
- ▶
- </button>
- )}
- </div>
-
- {/* Status and counts row */}
- <div className="flex items-center justify-between">
- <span className={`font-mono text-[10px] uppercase ${status.color}`}>
- {status.label}
- </span>
- <div className="flex items-center gap-2 font-mono text-[10px] text-[#555]">
- <span title="Files">{contract.fileCount} files</span>
- <span title="Tasks">{contract.taskCount} tasks</span>
- </div>
- </div>
-
- {/* Description preview if exists */}
- {contract.description && (
- <div className="mt-1 font-mono text-[10px] text-[#555] truncate">
- {contract.description}
- </div>
- )}
- </div>
- );
-}
diff --git a/makima/frontend/src/hooks/useOrders.ts b/makima/frontend/src/hooks/useOrders.ts
new file mode 100644
index 0000000..2dd20bb
--- /dev/null
+++ b/makima/frontend/src/hooks/useOrders.ts
@@ -0,0 +1,123 @@
+import { useState, useEffect, useCallback } from "react";
+import {
+ type Order,
+ type OrderStatus,
+ type OrderType,
+ type OrderPriority,
+ type CreateOrderRequest,
+ type UpdateOrderRequest,
+ listOrders,
+ createOrder,
+ getOrder,
+ updateOrder,
+ deleteOrder,
+ linkOrderToDirective,
+ linkOrderToContract,
+ convertOrderToStep,
+} from "../lib/api";
+
+export function useOrders(
+ statusFilter?: OrderStatus,
+ typeFilter?: OrderType,
+ priorityFilter?: OrderPriority,
+) {
+ const [orders, setOrders] = useState<Order[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const res = await listOrders(statusFilter, typeFilter, priorityFilter);
+ setOrders(res.orders);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to load orders");
+ } finally {
+ setLoading(false);
+ }
+ }, [statusFilter, typeFilter, priorityFilter]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ const create = useCallback(async (req: CreateOrderRequest) => {
+ const o = await createOrder(req);
+ await refresh();
+ return o;
+ }, [refresh]);
+
+ const remove = useCallback(async (id: string) => {
+ await deleteOrder(id);
+ await refresh();
+ }, [refresh]);
+
+ return { orders, loading, error, refresh, create, remove };
+}
+
+export function useOrder(id: string | undefined) {
+ const [order, setOrder] = useState<Order | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ const refresh = useCallback(async () => {
+ if (!id) return;
+ try {
+ setLoading(true);
+ setError(null);
+ const o = await getOrder(id);
+ setOrder(o);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to load order");
+ } finally {
+ setLoading(false);
+ }
+ }, [id]);
+
+ useEffect(() => {
+ setOrder(null);
+ setError(null);
+ setLoading(true);
+ refresh();
+ }, [id]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const update = useCallback(async (req: UpdateOrderRequest) => {
+ if (!id) return;
+ const o = await updateOrder(id, req);
+ setOrder(o);
+ return o;
+ }, [id]);
+
+ const remove = useCallback(async () => {
+ if (!id) return;
+ await deleteOrder(id);
+ }, [id]);
+
+ const linkDirective = useCallback(async (directiveId: string) => {
+ if (!id) return;
+ const o = await linkOrderToDirective(id, directiveId);
+ setOrder(o);
+ return o;
+ }, [id]);
+
+ const linkContract = useCallback(async (contractId: string) => {
+ if (!id) return;
+ const o = await linkOrderToContract(id, contractId);
+ setOrder(o);
+ return o;
+ }, [id]);
+
+ const convertToStep = useCallback(async (directiveId: string) => {
+ if (!id) return;
+ const step = await convertOrderToStep(id, directiveId);
+ await refresh();
+ return step;
+ }, [id, refresh]);
+
+ return {
+ order, loading, error, refresh,
+ update, remove,
+ linkDirective, linkContract, convertToStep,
+ };
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index 480041c..f88176b 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -2241,6 +2241,8 @@ export interface PendingQuestion {
questionId: string;
taskId: string;
contractId: string;
+ /** Directive this question relates to (if from a directive task) */
+ directiveId?: string | null;
question: string;
choices: string[];
context: string | null;
@@ -3025,6 +3027,8 @@ export interface Directive {
completionTaskId: string | null;
/** Whether the memory system is enabled for this directive */
memoryEnabled: boolean;
+ /** Whether questions pause execution indefinitely until answered */
+ reconcileMode: boolean;
goalUpdatedAt: string;
startedAt: string | null;
version: number;
@@ -3064,6 +3068,8 @@ export interface DirectiveSummary {
completionTaskId: string | null;
/** Whether the memory system is enabled for this directive */
memoryEnabled: boolean;
+ /** Whether questions pause execution indefinitely until answered */
+ reconcileMode: boolean;
version: number;
createdAt: string;
updatedAt: string;
@@ -3086,6 +3092,8 @@ export interface CreateDirectiveRequest {
baseBranch?: string;
/** Enable the memory system for this directive (default: false) */
memoryEnabled?: boolean;
+ /** Whether questions pause execution indefinitely until answered (default: false) */
+ reconcileMode?: boolean;
}
export interface UpdateDirectiveRequest {
@@ -3098,6 +3106,8 @@ export interface UpdateDirectiveRequest {
orchestratorTaskId?: string;
/** Enable or disable the memory system for this directive */
memoryEnabled?: boolean;
+ /** Whether questions pause execution indefinitely until answered */
+ reconcileMode?: boolean;
version?: number;
}
@@ -3246,3 +3256,138 @@ export async function cleanupDirectiveTasks(id: string): Promise<{ deleted: numb
return res.json();
}
+// =============================================================================
+// Orders API
+// =============================================================================
+
+export type OrderPriority = "critical" | "high" | "medium" | "low" | "none";
+export type OrderStatus = "open" | "in_progress" | "done" | "archived";
+export type OrderType = "feature" | "bug" | "spike" | "chore" | "improvement";
+
+export interface Order {
+ id: string;
+ ownerId: string;
+ title: string;
+ description: string | null;
+ priority: OrderPriority;
+ status: OrderStatus;
+ orderType: OrderType;
+ labels: string[];
+ directiveId: string | null;
+ directiveStepId: string | null;
+ contractId: string | null;
+ repositoryUrl: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface OrderListResponse {
+ orders: Order[];
+ total: number;
+}
+
+export interface CreateOrderRequest {
+ title: string;
+ description?: string | null;
+ priority?: OrderPriority;
+ status?: OrderStatus;
+ orderType?: OrderType;
+ labels?: string[];
+ directiveId?: string | null;
+ contractId?: string | null;
+ repositoryUrl?: string | null;
+}
+
+export interface UpdateOrderRequest {
+ title?: string;
+ description?: string | null;
+ priority?: OrderPriority;
+ status?: OrderStatus;
+ orderType?: OrderType;
+ labels?: string[];
+ directiveId?: string | null;
+ directiveStepId?: string | null;
+ contractId?: string | null;
+ repositoryUrl?: string | null;
+}
+
+export async function listOrders(
+ status?: OrderStatus,
+ type?: OrderType,
+ priority?: OrderPriority,
+ directiveId?: string,
+ contractId?: string,
+): Promise<OrderListResponse> {
+ const params = new URLSearchParams();
+ if (status) params.set("status", status);
+ if (type) params.set("type", type);
+ if (priority) params.set("priority", priority);
+ if (directiveId) params.set("directiveId", directiveId);
+ if (contractId) params.set("contractId", contractId);
+ const qs = params.toString();
+ const res = await authFetch(`${API_BASE}/api/v1/orders${qs ? `?${qs}` : ""}`);
+ if (!res.ok) throw new Error(`Failed to list orders: ${res.statusText}`);
+ return res.json();
+}
+
+export async function createOrder(req: CreateOrderRequest): Promise<Order> {
+ const res = await authFetch(`${API_BASE}/api/v1/orders`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) throw new Error(`Failed to create order: ${res.statusText}`);
+ return res.json();
+}
+
+export async function getOrder(id: string): Promise<Order> {
+ const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`);
+ if (!res.ok) throw new Error(`Failed to get order: ${res.statusText}`);
+ return res.json();
+}
+
+export async function updateOrder(id: string, req: UpdateOrderRequest): Promise<Order> {
+ const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) throw new Error(`Failed to update order: ${res.statusText}`);
+ return res.json();
+}
+
+export async function deleteOrder(id: string): Promise<void> {
+ const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`, { method: "DELETE" });
+ if (!res.ok) throw new Error(`Failed to delete order: ${res.statusText}`);
+}
+
+export async function linkOrderToDirective(orderId: string, directiveId: string): Promise<Order> {
+ const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/link-directive`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ directiveId }),
+ });
+ if (!res.ok) throw new Error(`Failed to link order to directive: ${res.statusText}`);
+ return res.json();
+}
+
+export async function linkOrderToContract(orderId: string, contractId: string): Promise<Order> {
+ const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/link-contract`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ contractId }),
+ });
+ if (!res.ok) throw new Error(`Failed to link order to contract: ${res.statusText}`);
+ return res.json();
+}
+
+export async function convertOrderToStep(orderId: string, directiveId: string): Promise<DirectiveStep> {
+ const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/convert-to-step`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ directiveId }),
+ });
+ if (!res.ok) throw new Error(`Failed to convert order to step: ${res.statusText}`);
+ return res.json();
+}
+
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index 3dc68f5..acc9afc 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -12,7 +12,7 @@ import HomePage from "./routes/_index";
import ListenPage from "./routes/listen";
import FilesPage from "./routes/files";
import ContractsPage from "./routes/contracts";
-import WorkflowPage from "./routes/workflow";
+import OrdersPage from "./routes/orders";
import MeshPage from "./routes/mesh";
import HistoryPage from "./routes/history";
import LoginPage from "./routes/login";
@@ -81,10 +81,18 @@ createRoot(document.getElementById("root")!).render(
}
/>
<Route
- path="/workflow"
+ path="/orders"
element={
<ProtectedRoute>
- <WorkflowPage />
+ <OrdersPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/orders/:id"
+ element={
+ <ProtectedRoute>
+ <OrdersPage />
</ProtectedRoute>
}
/>
diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx
index ca4437c..643cfee 100644
--- a/makima/frontend/src/routes/directives.tsx
+++ b/makima/frontend/src/routes/directives.tsx
@@ -12,7 +12,7 @@ export default function DirectivesPage() {
const navigate = useNavigate();
const { id: selectedId } = useParams<{ id: string }>();
const { directives, loading: listLoading, create, remove } = useDirectives();
- const { directive, refresh: refreshDetail, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks } = useDirective(selectedId);
+ const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks } = useDirective(selectedId);
const [showCreate, setShowCreate] = useState(false);
const [newTitle, setNewTitle] = useState("");
@@ -207,6 +207,7 @@ export default function DirectivesPage() {
onFailStep={failStep}
onSkipStep={skipStep}
onUpdateGoal={updateGoal}
+ onUpdate={update}
onDelete={handleDelete}
onRefresh={refreshDetail}
onCleanupTasks={cleanupTasks}
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx
index cb4a77c..1d1db84 100644
--- a/makima/frontend/src/routes/mesh.tsx
+++ b/makima/frontend/src/routes/mesh.tsx
@@ -852,7 +852,7 @@ export default function MeshPage() {
<div className="flex-1 min-h-0 overflow-hidden">
<TaskOutput
entries={taskOutputEntries}
- isStreaming={isStreaming || taskDetail.status === "running"}
+ isStreaming={isStreaming || taskDetail.status === "running" || taskDetail.status === "starting"}
viewingSubtaskName={viewingSubtaskName}
onClearSubtaskView={viewingSubtaskId ? () => {
setViewingSubtaskId(null);
diff --git a/makima/frontend/src/routes/orders.tsx b/makima/frontend/src/routes/orders.tsx
new file mode 100644
index 0000000..735c557
--- /dev/null
+++ b/makima/frontend/src/routes/orders.tsx
@@ -0,0 +1,238 @@
+import { useState, useEffect } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { OrderList } from "../components/orders/OrderList";
+import { OrderDetail } from "../components/orders/OrderDetail";
+import { useOrders, useOrder } from "../hooks/useOrders";
+import { useDirectives } from "../hooks/useDirectives";
+import { useAuth } from "../contexts/AuthContext";
+import type { OrderStatus, OrderType, OrderPriority } from "../lib/api";
+
+export default function OrdersPage() {
+ const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const { id: selectedId } = useParams<{ id: string }>();
+
+ const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>(undefined);
+ const [typeFilter, setTypeFilter] = useState<OrderType | undefined>(undefined);
+ const { orders, loading: listLoading, create, refresh: refreshList } = useOrders(statusFilter, typeFilter);
+ const { order, refresh: refreshDetail, update, remove: removeOrder, linkDirective, linkContract, convertToStep } = useOrder(selectedId);
+ const { directives } = useDirectives();
+
+ const [showCreate, setShowCreate] = useState(false);
+ const [newTitle, setNewTitle] = useState("");
+ const [newDesc, setNewDesc] = useState("");
+ const [newPriority, setNewPriority] = useState<OrderPriority>("medium");
+ const [newType, setNewType] = useState<OrderType>("feature");
+
+ useEffect(() => {
+ if (!authLoading && isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+ 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>
+ );
+ }
+
+ const handleCreate = async () => {
+ if (!newTitle.trim()) return;
+ try {
+ const o = await create({
+ title: newTitle.trim(),
+ description: newDesc.trim() || undefined,
+ priority: newPriority,
+ orderType: newType,
+ });
+ setShowCreate(false);
+ setNewTitle("");
+ setNewDesc("");
+ setNewPriority("medium");
+ setNewType("feature");
+ navigate(`/orders/${o.id}`);
+ } catch (e) {
+ console.error("Failed to create order:", e);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!selectedId) return;
+ if (!window.confirm("Delete this order?")) return;
+ try {
+ await removeOrder();
+ await refreshList();
+ navigate("/orders");
+ } catch (e) {
+ console.error("Failed to delete:", e);
+ }
+ };
+
+ const handleUpdate = async (req: Parameters<typeof update>[0]) => {
+ await update(req);
+ await refreshList();
+ };
+
+ const handleLinkDirective = async (directiveId: string) => {
+ await linkDirective(directiveId);
+ await refreshList();
+ };
+
+ const handleLinkContract = async (contractId: string) => {
+ await linkContract(contractId);
+ await refreshList();
+ };
+
+ const handleConvertToStep = async (directiveId: string) => {
+ await convertToStep(directiveId);
+ await refreshList();
+ };
+
+ const priorityOptions: { value: OrderPriority; label: string }[] = [
+ { value: "critical", label: "Critical" },
+ { value: "high", label: "High" },
+ { value: "medium", label: "Medium" },
+ { value: "low", label: "Low" },
+ { value: "none", label: "None" },
+ ];
+
+ const typeOptions: { value: OrderType; label: string }[] = [
+ { value: "feature", label: "Feature" },
+ { value: "bug", label: "Bug" },
+ { value: "spike", label: "Spike" },
+ { value: "chore", label: "Chore" },
+ { value: "improvement", label: "Improvement" },
+ ];
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex overflow-hidden" style={{ height: "calc(100vh - 80px)" }}>
+ {/* Left: List */}
+ <div className="w-[280px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col">
+ <OrderList
+ orders={orders}
+ selectedId={selectedId ?? null}
+ onSelect={(id) => navigate(`/orders/${id}`)}
+ onCreate={() => setShowCreate(true)}
+ statusFilter={statusFilter}
+ onStatusFilter={setStatusFilter}
+ typeFilter={typeFilter}
+ onTypeFilter={setTypeFilter}
+ />
+ </div>
+
+ {/* Right: Detail or Create */}
+ <div className="flex-1 overflow-hidden">
+ {showCreate ? (
+ <div className="p-4 max-w-lg">
+ <h2 className="text-[14px] font-mono text-white font-medium mb-4">
+ New Order
+ </h2>
+ <div className="flex flex-col gap-3">
+ <div>
+ <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1">
+ Title
+ </label>
+ <input
+ value={newTitle}
+ onChange={(e) => setNewTitle(e.target.value)}
+ placeholder="Order title..."
+ className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white"
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && newTitle.trim()) handleCreate();
+ }}
+ />
+ </div>
+ <div>
+ <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1">
+ Description (optional)
+ </label>
+ <textarea
+ value={newDesc}
+ onChange={(e) => setNewDesc(e.target.value)}
+ placeholder="Describe the order..."
+ rows={4}
+ className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white resize-y"
+ />
+ </div>
+ <div className="flex gap-4">
+ <div className="flex-1">
+ <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1">
+ Priority
+ </label>
+ <select
+ value={newPriority}
+ onChange={(e) => setNewPriority(e.target.value as OrderPriority)}
+ className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white"
+ >
+ {priorityOptions.map((p) => (
+ <option key={p.value} value={p.value}>{p.label}</option>
+ ))}
+ </select>
+ </div>
+ <div className="flex-1">
+ <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1">
+ Type
+ </label>
+ <select
+ value={newType}
+ onChange={(e) => setNewType(e.target.value as OrderType)}
+ className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white"
+ >
+ {typeOptions.map((t) => (
+ <option key={t.value} value={t.value}>{t.label}</option>
+ ))}
+ </select>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <button
+ type="button"
+ onClick={handleCreate}
+ disabled={!newTitle.trim()}
+ className="text-[11px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-3 py-1 disabled:opacity-50"
+ >
+ Create
+ </button>
+ <button
+ type="button"
+ onClick={() => setShowCreate(false)}
+ className="text-[11px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-3 py-1"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ </div>
+ ) : selectedId && order ? (
+ <OrderDetail
+ order={order}
+ directives={directives}
+ onUpdate={handleUpdate}
+ onDelete={handleDelete}
+ onLinkDirective={handleLinkDirective}
+ onLinkContract={handleLinkContract}
+ onConvertToStep={handleConvertToStep}
+ onRefresh={refreshDetail}
+ />
+ ) : (
+ <div className="flex-1 flex items-center justify-center h-full">
+ <p className="text-[#556677] font-mono text-[12px]">
+ {listLoading
+ ? "Loading..."
+ : "Select an order or create a new one"}
+ </p>
+ </div>
+ )}
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/workflow.tsx b/makima/frontend/src/routes/workflow.tsx
deleted file mode 100644
index e122092..0000000
--- a/makima/frontend/src/routes/workflow.tsx
+++ /dev/null
@@ -1,250 +0,0 @@
-import { useState, useCallback, useEffect, useMemo } from "react";
-import { useNavigate } from "react-router";
-import { Masthead } from "../components/Masthead";
-import { WorkflowBoard } from "../components/workflow/WorkflowBoard";
-import { useContracts } from "../hooks/useContracts";
-import { useAuth } from "../contexts/AuthContext";
-import type { ContractPhase, ContractStatus, ContractSummary } from "../lib/api";
-
-type StatusFilter = "all" | ContractStatus;
-
-export default function WorkflowPage() {
- 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 <WorkflowPageContent />;
-}
-
-function WorkflowPageContent() {
- const navigate = useNavigate();
- const { contracts, loading, error, changePhase, saveContract, editContract, removeContract } = useContracts();
- const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
- const [isCreating, setIsCreating] = useState(false);
- const [newContractName, setNewContractName] = useState("");
-
- // Filter contracts by status
- const filteredContracts = useMemo(() => {
- if (statusFilter === "all") {
- return contracts;
- }
- return contracts.filter((c) => c.status === statusFilter);
- }, [contracts, statusFilter]);
-
- const handleContractClick = useCallback(
- (contractId: string) => {
- navigate(`/contracts/${contractId}`);
- },
- [navigate]
- );
-
- const handlePhaseChange = useCallback(
- async (contractId: string, newPhase: ContractPhase) => {
- await changePhase(contractId, newPhase);
- },
- [changePhase]
- );
-
- // Context menu handlers
- 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}"?`)) {
- await removeContract(contract.id);
- }
- },
- [removeContract]
- );
-
- const handleContextGoToSupervisor = useCallback(
- (contract: ContractSummary) => {
- if (contract.supervisorTaskId) {
- navigate(`/mesh/${contract.supervisorTaskId}`);
- }
- },
- [navigate]
- );
-
- const handleCreateContract = useCallback(async () => {
- if (!newContractName.trim()) return;
- const contract = await saveContract({
- name: newContractName.trim(),
- });
- if (contract) {
- setNewContractName("");
- setIsCreating(false);
- navigate(`/contracts/${contract.id}`);
- }
- }, [newContractName, saveContract, navigate]);
-
- const handleCancelCreate = useCallback(() => {
- setNewContractName("");
- setIsCreating(false);
- }, []);
-
- return (
- <div className="relative z-10 h-screen flex flex-col bg-[#0a1628]">
- <Masthead showNav />
- <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden">
- {error && (
- <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm shrink-0">
- {error}
- </div>
- )}
-
- {/* Header with filter and create button */}
- <div className="flex items-center justify-between shrink-0">
- <div className="flex items-center gap-4">
- <h1 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider">
- Board
- </h1>
- {/* Status filter */}
- <div className="flex items-center gap-1">
- {(["all", "active", "completed", "archived"] as StatusFilter[]).map(
- (status) => (
- <button
- key={status}
- onClick={() => setStatusFilter(status)}
- className={`
- px-2 py-1 font-mono text-[10px] uppercase transition-colors
- ${
- statusFilter === 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>
- <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"
- >
- + New Contract
- </button>
- </div>
-
- {/* Create contract modal */}
- {isCreating && (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
- <div className="w-full max-w-md p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]">
- <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">
- Create Contract
- </h3>
- <div className="space-y-4">
- <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-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
- autoFocus
- onKeyDown={(e) => {
- if (e.key === "Enter") handleCreateContract();
- if (e.key === "Escape") handleCancelCreate();
- }}
- />
- <div className="flex gap-2 justify-end">
- <button
- onClick={handleCancelCreate}
- className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
- >
- Cancel
- </button>
- <button
- onClick={handleCreateContract}
- disabled={!newContractName.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>
- )}
-
- {/* Board */}
- <div className="flex-1 min-h-0 overflow-hidden">
- {loading ? (
- <div className="h-full flex items-center justify-center">
- <p className="font-mono text-sm text-[#555]">Loading...</p>
- </div>
- ) : filteredContracts.length === 0 && statusFilter === "all" ? (
- <div className="h-full flex items-center justify-center">
- <div className="text-center">
- <p className="font-mono text-sm text-[#555] mb-4">
- No contracts yet
- </p>
- <button
- onClick={() => setIsCreating(true)}
- className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
- >
- + Create First Contract
- </button>
- </div>
- </div>
- ) : (
- <WorkflowBoard
- contracts={filteredContracts}
- onContractClick={handleContractClick}
- onPhaseChange={handlePhaseChange}
- onMarkComplete={handleContextMarkComplete}
- onMarkActive={handleContextMarkActive}
- onArchive={handleContextArchive}
- onDelete={handleContextDelete}
- onGoToSupervisor={handleContextGoToSupervisor}
- />
- )}
- </div>
- </main>
- </div>
- );
-}
diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo
index c2bf573..68f2eac 100644
--- a/makima/frontend/tsconfig.tsbuildinfo
+++ b/makima/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/directives/directivedag.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/directivelogstream.tsx","./src/components/directives/stepnode.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usedirectives.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usemultitasksubscription.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
+{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/directives/directivedag.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/directivelogstream.tsx","./src/components/directives/stepnode.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/orders/orderdetail.tsx","./src/components/orders/orderlist.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usedirectives.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usemultitasksubscription.ts","./src/hooks/useorders.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/orders.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
diff --git a/makima/migrations/20260214000000_create_orders.sql b/makima/migrations/20260214000000_create_orders.sql
new file mode 100644
index 0000000..cb2fbae
--- /dev/null
+++ b/makima/migrations/20260214000000_create_orders.sql
@@ -0,0 +1,38 @@
+-- Orders system: card-based issue tracker (similar to Linear/Jira/GitHub Issues).
+-- Orders represent planned work items (features, bugs, spikes) that can later be
+-- attached to directives (as steps) or contracts for execution.
+
+CREATE TABLE orders (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE,
+ title VARCHAR(500) NOT NULL,
+ description TEXT,
+ -- Priority: critical > high > medium > low > none
+ priority VARCHAR(32) NOT NULL DEFAULT 'medium'
+ CHECK (priority IN ('critical', 'high', 'medium', 'low', 'none')),
+ -- Status lifecycle: open -> in_progress -> done | archived
+ status VARCHAR(32) NOT NULL DEFAULT 'open'
+ CHECK (status IN ('open', 'in_progress', 'done', 'archived')),
+ -- Type of work item
+ order_type VARCHAR(32) NOT NULL DEFAULT 'feature'
+ CHECK (order_type IN ('feature', 'bug', 'spike', 'chore', 'improvement')),
+ -- Flexible labels stored as JSON array of strings
+ labels JSONB NOT NULL DEFAULT '[]',
+ -- Optional links to directives, directive steps, and contracts
+ directive_id UUID REFERENCES directives(id) ON DELETE SET NULL,
+ directive_step_id UUID REFERENCES directive_steps(id) ON DELETE SET NULL,
+ contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL,
+ -- Repository context
+ repository_url VARCHAR(512),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Index for listing orders by owner
+CREATE INDEX idx_orders_owner_id ON orders(owner_id);
+-- Composite index for filtering by owner + status (common query pattern)
+CREATE INDEX idx_orders_owner_status ON orders(owner_id, status);
+-- Index for looking up orders linked to a directive
+CREATE INDEX idx_orders_directive_id ON orders(directive_id);
+-- Index for looking up orders linked to a contract
+CREATE INDEX idx_orders_contract_id ON orders(contract_id);
diff --git a/makima/migrations/20260214100000_directive_reconcile_mode.sql b/makima/migrations/20260214100000_directive_reconcile_mode.sql
new file mode 100644
index 0000000..a06e8f2
--- /dev/null
+++ b/makima/migrations/20260214100000_directive_reconcile_mode.sql
@@ -0,0 +1,4 @@
+-- Add reconcile_mode flag to directives table.
+-- When true, directive task questions pause execution indefinitely until answered.
+-- When false (default), questions timeout after 30 seconds.
+ALTER TABLE directives ADD COLUMN IF NOT EXISTS reconcile_mode BOOLEAN NOT NULL DEFAULT false;
diff --git a/makima/src/daemon/skills/directive.md b/makima/src/daemon/skills/directive.md
index 68d9277..9d2b644 100644
--- a/makima/src/daemon/skills/directive.md
+++ b/makima/src/daemon/skills/directive.md
@@ -76,6 +76,12 @@ Updates the goal and bumps `goalUpdatedAt`. If the directive is `idle`, it react
makima directive pause
```
+### Update Directive Metadata
+```bash
+makima directive update --pr-url "<url>" --pr-branch "<branch>"
+```
+Updates the directive's PR URL and/or PR branch. Used by completion tasks to store the PR URL after creating it.
+
## Memory Commands
Directives have an optional key-value memory system that persists across steps and planning cycles. Use memory to share context, decisions, and learned information between steps — so downstream tasks don't need to re-discover what earlier steps already figured out.
diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs
index ce5a580..76138c1 100644
--- a/makima/src/daemon/task/manager.rs
+++ b/makima/src/daemon/task/manager.rs
@@ -1611,14 +1611,14 @@ impl TaskManager {
}
// Regular message - send to task's stdin
- tracing::info!(task_id = %task_id, message_len = message.len(), "Sending message to task");
+ tracing::info!(task_id = %task_id, message_len = message.len(), "Sending message to task stdin");
// Send message to the task's stdin via the input channel
let inputs = self.task_inputs.read().await;
if let Some(sender) = inputs.get(&task_id) {
if let Err(e) = sender.send(message).await {
- tracing::warn!(task_id = %task_id, error = %e, "Failed to send message to task input channel");
+ tracing::warn!(task_id = %task_id, error = %e, "Failed to send message to task input channel (channel may be closed, stdin forwarder may have exited)");
} else {
- tracing::info!(task_id = %task_id, "Message sent to task successfully");
+ tracing::info!(task_id = %task_id, "Message sent to task input channel successfully, will be forwarded to Claude stdin");
}
} else {
drop(inputs); // Release read lock before checking if we need to respawn
@@ -5192,12 +5192,19 @@ impl TaskManagerInner {
// Check if this is a "result" message indicating task completion
// With --input-format=stream-json, Claude waits for more input after completion
- // We close stdin to signal EOF and let the process exit
if line.json_type.as_deref() == Some("result") {
- tracing::info!(task_id = %task_id, "Received result message, closing stdin to signal completion");
- let mut stdin_guard = stdin_handle_for_completion.lock().await;
- if let Some(mut stdin) = stdin_guard.take() {
- let _ = stdin.shutdown().await;
+ if autonomous_loop {
+ // In autonomous loop mode, close stdin to let the process exit
+ // so we can spawn the next iteration with --continue
+ tracing::info!(task_id = %task_id, "Received result message in autonomous loop, closing stdin to signal completion");
+ let mut stdin_guard = stdin_handle_for_completion.lock().await;
+ if let Some(mut stdin) = stdin_guard.take() {
+ let _ = stdin.shutdown().await;
+ }
+ } else {
+ // In interactive mode, keep stdin open so the user can send
+ // follow-up messages. Claude will stay alive waiting for input.
+ tracing::info!(task_id = %task_id, "Received result message, keeping stdin open for interactive input");
}
}
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 131dffc..6ec6cf4 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -2714,6 +2714,8 @@ pub struct Directive {
pub pr_url: Option<String>,
pub pr_branch: Option<String>,
pub completion_task_id: Option<Uuid>,
+ /// Whether questions pause execution indefinitely until answered
+ pub reconcile_mode: bool,
pub goal_updated_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub version: i32,
@@ -2763,6 +2765,8 @@ pub struct DirectiveSummary {
pub orchestrator_task_id: Option<Uuid>,
pub pr_url: Option<String>,
pub completion_task_id: Option<Uuid>,
+ /// Whether questions pause execution indefinitely until answered
+ pub reconcile_mode: bool,
pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -2789,6 +2793,8 @@ pub struct CreateDirectiveRequest {
pub repository_url: Option<String>,
pub local_path: Option<String>,
pub base_branch: Option<String>,
+ /// Whether questions pause execution indefinitely until answered
+ pub reconcile_mode: Option<bool>,
}
/// Request to update a directive.
@@ -2804,6 +2810,8 @@ pub struct UpdateDirectiveRequest {
pub orchestrator_task_id: Option<Uuid>,
pub pr_url: Option<String>,
pub pr_branch: Option<String>,
+ /// Whether questions pause execution indefinitely until answered
+ pub reconcile_mode: Option<bool>,
pub version: Option<i32>,
}
@@ -2848,3 +2856,120 @@ pub struct UpdateDirectiveStepRequest {
pub order_index: Option<i32>,
}
+// =============================================================================
+// Order Types
+// =============================================================================
+
+/// An order — a card-based work item (feature, bug, spike, chore, improvement)
+/// similar to GitHub Issues or Linear cards. Orders can be linked to directives
+/// or contracts for execution.
+#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct Order {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub title: String,
+ pub description: Option<String>,
+ /// Priority: critical, high, medium, low, none
+ pub priority: String,
+ /// Status: open, in_progress, done, archived
+ pub status: String,
+ /// Type of work: feature, bug, spike, chore, improvement
+ pub order_type: String,
+ /// Flexible labels as JSON array of strings
+ pub labels: serde_json::Value,
+ /// Linked directive (optional)
+ pub directive_id: Option<Uuid>,
+ /// Linked directive step (optional)
+ pub directive_step_id: Option<Uuid>,
+ /// Linked contract (optional)
+ pub contract_id: Option<Uuid>,
+ /// Repository context
+ pub repository_url: Option<String>,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Request to create a new order.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateOrderRequest {
+ pub title: String,
+ pub description: Option<String>,
+ pub priority: Option<String>,
+ pub status: Option<String>,
+ pub order_type: Option<String>,
+ #[serde(default = "default_empty_labels")]
+ pub labels: serde_json::Value,
+ pub directive_id: Option<Uuid>,
+ pub contract_id: Option<Uuid>,
+ pub repository_url: Option<String>,
+}
+
+/// Default empty JSON array for labels.
+fn default_empty_labels() -> serde_json::Value {
+ serde_json::json!([])
+}
+
+/// Request to update an existing order.
+#[derive(Debug, Default, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateOrderRequest {
+ pub title: Option<String>,
+ pub description: Option<String>,
+ pub priority: Option<String>,
+ pub status: Option<String>,
+ pub order_type: Option<String>,
+ pub labels: Option<serde_json::Value>,
+ pub directive_id: Option<Uuid>,
+ pub directive_step_id: Option<Uuid>,
+ pub contract_id: Option<Uuid>,
+ pub repository_url: Option<String>,
+}
+
+/// Response for order list endpoint.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OrderListResponse {
+ pub orders: Vec<Order>,
+ pub total: i64,
+}
+
+/// Query parameters for listing orders.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct OrderListQuery {
+ /// Filter by status (e.g., "open", "in_progress", "done", "archived")
+ pub status: Option<String>,
+ /// Filter by order type (e.g., "feature", "bug", "spike", "chore", "improvement")
+ #[serde(rename = "type")]
+ pub order_type: Option<String>,
+ /// Filter by priority (e.g., "critical", "high", "medium", "low", "none")
+ pub priority: Option<String>,
+ /// Filter by linked directive ID
+ pub directive_id: Option<Uuid>,
+ /// Filter by linked contract ID
+ pub contract_id: Option<Uuid>,
+}
+
+/// Request body for linking an order to a directive.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct LinkDirectiveRequest {
+ pub directive_id: Uuid,
+}
+
+/// Request body for linking an order to a contract.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct LinkContractRequest {
+ pub contract_id: Uuid,
+}
+
+/// Request body for converting an order to a directive step.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ConvertToStepRequest {
+ pub directive_id: Uuid,
+}
+
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index d8168f6..ed4a1fa 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -14,6 +14,7 @@ use super::models::{
DeliverableDefinition, Directive, DirectiveStep, DirectiveSummary,
CreateDirectiveRequest, CreateDirectiveStepRequest, UpdateDirectiveRequest,
UpdateDirectiveStepRequest,
+ CreateOrderRequest, Order, UpdateOrderRequest,
File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters,
MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig,
PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState,
@@ -4929,8 +4930,8 @@ pub async fn create_directive_for_owner(
) -> Result<Directive, sqlx::Error> {
sqlx::query_as::<_, Directive>(
r#"
- INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch)
- VALUES ($1, $2, $3, $4, $5, $6)
+ INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch, reconcile_mode)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
"#,
)
@@ -4940,6 +4941,7 @@ pub async fn create_directive_for_owner(
.bind(&req.repository_url)
.bind(&req.local_path)
.bind(&req.base_branch)
+ .bind(req.reconcile_mode.unwrap_or(false))
.fetch_one(pool)
.await
}
@@ -4992,6 +4994,7 @@ pub async fn list_directives_for_owner(
SELECT
d.id, d.owner_id, d.title, d.goal, d.status, d.repository_url,
d.orchestrator_task_id, d.pr_url, d.completion_task_id,
+ d.reconcile_mode,
d.version, d.created_at, d.updated_at,
COALESCE(s.total_steps, 0) as total_steps,
COALESCE(s.completed_steps, 0) as completed_steps,
@@ -5055,12 +5058,14 @@ pub async fn update_directive_for_owner(
let orchestrator_task_id = req.orchestrator_task_id.or(current.orchestrator_task_id);
let pr_url = req.pr_url.as_deref().or(current.pr_url.as_deref());
let pr_branch = req.pr_branch.as_deref().or(current.pr_branch.as_deref());
+ let reconcile_mode = req.reconcile_mode.unwrap_or(current.reconcile_mode);
let result = sqlx::query_as::<_, Directive>(
r#"
UPDATE directives
SET title = $3, goal = $4, status = $5, repository_url = $6, local_path = $7,
base_branch = $8, orchestrator_task_id = $9, pr_url = $10, pr_branch = $11,
+ reconcile_mode = $12,
version = version + 1, updated_at = NOW()
WHERE id = $1 AND owner_id = $2
RETURNING *
@@ -5077,6 +5082,7 @@ pub async fn update_directive_for_owner(
.bind(orchestrator_task_id)
.bind(pr_url)
.bind(pr_branch)
+ .bind(reconcile_mode)
.fetch_optional(pool)
.await
.map_err(RepositoryError::Database)?;
@@ -5188,6 +5194,7 @@ pub struct CompletedStepTask {
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DirectiveCompletionCheck {
pub directive_id: Uuid,
+ pub owner_id: Uuid,
pub completion_task_id: Uuid,
pub task_status: String,
pub pr_url: Option<String>,
@@ -5224,7 +5231,7 @@ pub async fn get_completion_tasks_to_check(
) -> Result<Vec<DirectiveCompletionCheck>, sqlx::Error> {
sqlx::query_as::<_, DirectiveCompletionCheck>(
r#"
- SELECT d.id as directive_id, d.completion_task_id, t.status as task_status, d.pr_url
+ SELECT d.id as directive_id, d.owner_id, d.completion_task_id, t.status as task_status, d.pr_url
FROM directives d
JOIN tasks t ON t.id = d.completion_task_id
WHERE d.completion_task_id IS NOT NULL
@@ -5917,3 +5924,306 @@ pub async fn get_directive_max_generation(
Ok(row.0.unwrap_or(0))
}
+// =============================================================================
+// Order CRUD
+// =============================================================================
+
+/// Create a new order for the given owner.
+pub async fn create_order(
+ pool: &PgPool,
+ owner_id: Uuid,
+ req: CreateOrderRequest,
+) -> Result<Order, sqlx::Error> {
+ let priority = req.priority.as_deref().unwrap_or("medium");
+ let status = req.status.as_deref().unwrap_or("open");
+ let order_type = req.order_type.as_deref().unwrap_or("feature");
+
+ sqlx::query_as::<_, Order>(
+ r#"
+ INSERT INTO orders (owner_id, title, description, priority, status, order_type, labels, directive_id, contract_id, repository_url)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ RETURNING *
+ "#,
+ )
+ .bind(owner_id)
+ .bind(&req.title)
+ .bind(&req.description)
+ .bind(priority)
+ .bind(status)
+ .bind(order_type)
+ .bind(&req.labels)
+ .bind(req.directive_id)
+ .bind(req.contract_id)
+ .bind(&req.repository_url)
+ .fetch_one(pool)
+ .await
+}
+
+/// List orders for the given owner with optional filters.
+pub async fn list_orders(
+ pool: &PgPool,
+ owner_id: Uuid,
+ status_filter: Option<&str>,
+ type_filter: Option<&str>,
+ priority_filter: Option<&str>,
+ directive_id_filter: Option<Uuid>,
+ contract_id_filter: Option<Uuid>,
+) -> Result<Vec<Order>, sqlx::Error> {
+ // Build dynamic query with optional filters
+ let mut query = String::from("SELECT * FROM orders WHERE owner_id = $1");
+ let mut param_idx = 2u32;
+
+ if status_filter.is_some() {
+ query.push_str(&format!(" AND status = ${}", param_idx));
+ param_idx += 1;
+ }
+ if type_filter.is_some() {
+ query.push_str(&format!(" AND order_type = ${}", param_idx));
+ param_idx += 1;
+ }
+ if priority_filter.is_some() {
+ query.push_str(&format!(" AND priority = ${}", param_idx));
+ param_idx += 1;
+ }
+ if directive_id_filter.is_some() {
+ query.push_str(&format!(" AND directive_id = ${}", param_idx));
+ param_idx += 1;
+ }
+ if contract_id_filter.is_some() {
+ query.push_str(&format!(" AND contract_id = ${}", param_idx));
+ let _ = param_idx; // suppress unused warning
+ }
+ query.push_str(" ORDER BY created_at DESC");
+
+ let mut q = sqlx::query_as::<_, Order>(&query).bind(owner_id);
+
+ if let Some(s) = status_filter {
+ q = q.bind(s);
+ }
+ if let Some(t) = type_filter {
+ q = q.bind(t);
+ }
+ if let Some(p) = priority_filter {
+ q = q.bind(p);
+ }
+ if let Some(d) = directive_id_filter {
+ q = q.bind(d);
+ }
+ if let Some(c) = contract_id_filter {
+ q = q.bind(c);
+ }
+
+ q.fetch_all(pool).await
+}
+
+/// Get a single order by ID (owner-scoped).
+pub async fn get_order(
+ pool: &PgPool,
+ owner_id: Uuid,
+ order_id: Uuid,
+) -> Result<Option<Order>, sqlx::Error> {
+ sqlx::query_as::<_, Order>(
+ r#"SELECT * FROM orders WHERE id = $1 AND owner_id = $2"#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Update an order (owner-scoped). Uses COALESCE pattern to only update provided fields.
+pub async fn update_order(
+ pool: &PgPool,
+ owner_id: Uuid,
+ order_id: Uuid,
+ req: UpdateOrderRequest,
+) -> Result<Option<Order>, sqlx::Error> {
+ let current = sqlx::query_as::<_, Order>(
+ r#"SELECT * FROM orders WHERE id = $1 AND owner_id = $2"#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await?;
+
+ let current = match current {
+ Some(c) => c,
+ None => return Ok(None),
+ };
+
+ let title = req.title.as_deref().unwrap_or(&current.title);
+ let description = req.description.as_deref().or(current.description.as_deref());
+ let priority = req.priority.as_deref().unwrap_or(&current.priority);
+ let status = req.status.as_deref().unwrap_or(&current.status);
+ let order_type = req.order_type.as_deref().unwrap_or(&current.order_type);
+ let labels = req.labels.as_ref().unwrap_or(&current.labels);
+ let directive_id = req.directive_id.or(current.directive_id);
+ let directive_step_id = req.directive_step_id.or(current.directive_step_id);
+ let contract_id = req.contract_id.or(current.contract_id);
+ let repository_url = req.repository_url.as_deref().or(current.repository_url.as_deref());
+
+ sqlx::query_as::<_, Order>(
+ r#"
+ UPDATE orders
+ SET title = $3, description = $4, priority = $5, status = $6,
+ order_type = $7, labels = $8, directive_id = $9, directive_step_id = $10,
+ contract_id = $11, repository_url = $12, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .bind(title)
+ .bind(description)
+ .bind(priority)
+ .bind(status)
+ .bind(order_type)
+ .bind(labels)
+ .bind(directive_id)
+ .bind(directive_step_id)
+ .bind(contract_id)
+ .bind(repository_url)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Delete an order (owner-scoped). Returns true if a row was deleted.
+pub async fn delete_order(
+ pool: &PgPool,
+ owner_id: Uuid,
+ order_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"DELETE FROM orders WHERE id = $1 AND owner_id = $2"#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Link an order to a directive.
+pub async fn link_order_to_directive(
+ pool: &PgPool,
+ owner_id: Uuid,
+ order_id: Uuid,
+ directive_id: Uuid,
+) -> Result<Option<Order>, sqlx::Error> {
+ sqlx::query_as::<_, Order>(
+ r#"
+ UPDATE orders
+ SET directive_id = $3, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .bind(directive_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Link an order to a contract.
+pub async fn link_order_to_contract(
+ pool: &PgPool,
+ owner_id: Uuid,
+ order_id: Uuid,
+ contract_id: Uuid,
+) -> Result<Option<Order>, sqlx::Error> {
+ sqlx::query_as::<_, Order>(
+ r#"
+ UPDATE orders
+ SET contract_id = $3, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .bind(contract_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Convert an order to a directive step. Creates a new DirectiveStep from the order's
+/// title and description, links the order to both the directive and the new step,
+/// and returns the created step.
+pub async fn convert_order_to_step(
+ pool: &PgPool,
+ owner_id: Uuid,
+ order_id: Uuid,
+ directive_id: Uuid,
+) -> Result<Option<DirectiveStep>, sqlx::Error> {
+ // Verify the order exists and belongs to this owner
+ let order = sqlx::query_as::<_, Order>(
+ r#"SELECT * FROM orders WHERE id = $1 AND owner_id = $2"#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await?;
+
+ let order = match order {
+ Some(o) => o,
+ None => return Ok(None),
+ };
+
+ // Verify the directive exists and belongs to this owner
+ let directive = sqlx::query_as::<_, Directive>(
+ r#"SELECT * FROM directives WHERE id = $1 AND owner_id = $2"#,
+ )
+ .bind(directive_id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await?;
+
+ if directive.is_none() {
+ return Ok(None);
+ }
+
+ // Get the next order_index for this directive
+ let max_index: (Option<i32>,) = sqlx::query_as(
+ r#"SELECT MAX(order_index) FROM directive_steps WHERE directive_id = $1"#,
+ )
+ .bind(directive_id)
+ .fetch_one(pool)
+ .await?;
+ let next_index = max_index.0.unwrap_or(-1) + 1;
+
+ // Create the directive step from order data
+ let step = sqlx::query_as::<_, DirectiveStep>(
+ r#"
+ INSERT INTO directive_steps (directive_id, name, description, order_index)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *
+ "#,
+ )
+ .bind(directive_id)
+ .bind(&order.title)
+ .bind(&order.description)
+ .bind(next_index)
+ .fetch_one(pool)
+ .await?;
+
+ // Link the order to the directive and the new step
+ sqlx::query(
+ r#"
+ UPDATE orders
+ SET directive_id = $3, directive_step_id = $4, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(order_id)
+ .bind(owner_id)
+ .bind(directive_id)
+ .bind(step.id)
+ .execute(pool)
+ .await?;
+
+ Ok(Some(step))
+}
+
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs
index ea8009d..0deacca 100644
--- a/makima/src/orchestration/directive.rs
+++ b/makima/src/orchestration/directive.rs
@@ -248,6 +248,16 @@ impl DirectiveOrchestrator {
.await?;
repository::check_directive_idle(&self.pool, step.directive_id).await?;
}
+ "paused" => {
+ // Task is paused (e.g., waiting for user answer in reconcile mode)
+ // Keep step in running status — task will auto-resume when answered
+ tracing::debug!(
+ step_id = %step.step_id,
+ directive_id = %step.directive_id,
+ task_id = %step.task_id,
+ "Step task paused (waiting for user response) — keeping step running"
+ );
+ }
_ => {
// Still running — do nothing
}
@@ -627,6 +637,45 @@ impl DirectiveOrchestrator {
task_id = %check.completion_task_id,
"Completion task finished"
);
+
+ // If directive has no pr_url yet, try to extract from task output
+ if check.pr_url.is_none() {
+ match self.extract_pr_url_from_task(check.completion_task_id).await {
+ Ok(Some(url)) => {
+ tracing::info!(
+ directive_id = %check.directive_id,
+ pr_url = %url,
+ "Extracted PR URL from completion task output"
+ );
+ let update = crate::db::models::UpdateDirectiveRequest {
+ pr_url: Some(url),
+ ..Default::default()
+ };
+ let _ = repository::update_directive_for_owner(
+ &self.pool,
+ check.owner_id,
+ check.directive_id,
+ update,
+ )
+ .await;
+ }
+ Ok(None) => {
+ tracing::warn!(
+ directive_id = %check.directive_id,
+ task_id = %check.completion_task_id,
+ "Completion task finished but no PR URL found in output"
+ );
+ }
+ Err(e) => {
+ tracing::warn!(
+ directive_id = %check.directive_id,
+ error = %e,
+ "Failed to extract PR URL from completion task output"
+ );
+ }
+ }
+ }
+
repository::clear_completion_task(&self.pool, check.directive_id).await?;
}
"failed" | "interrupted" => {
@@ -688,6 +737,36 @@ impl DirectiveOrchestrator {
Ok(task.id)
}
+
+ /// Extract a GitHub PR URL from a completion task's output events.
+ /// Searches task output for patterns like `https://github.com/.../pull/123`.
+ async fn extract_pr_url_from_task(
+ &self,
+ task_id: Uuid,
+ ) -> Result<Option<String>, anyhow::Error> {
+ let events = repository::get_task_output(&self.pool, task_id, Some(500)).await?;
+
+ let pr_url_re = regex::Regex::new(r"https://github\.com/[^/\s]+/[^/\s]+/pull/\d+")?;
+
+ // Search from most recent events backwards for the PR URL
+ for event in events.iter().rev() {
+ if let Some(ref data) = event.event_data {
+ // Check the content field inside event_data JSON
+ if let Some(content) = data.get("content").and_then(|c| c.as_str()) {
+ if let Some(m) = pr_url_re.find(content) {
+ return Ok(Some(m.as_str().to_string()));
+ }
+ }
+ // Also check the raw JSON string representation as fallback
+ let data_str = data.to_string();
+ if let Some(m) = pr_url_re.find(&data_str) {
+ return Ok(Some(m.as_str().to_string()));
+ }
+ }
+ }
+
+ Ok(None)
+ }
}
/// Build the planning prompt for a directive.
@@ -952,10 +1031,15 @@ Then create the PR:
gh pr create --title "{title}" --body "{pr_body}" --head {directive_branch} --base {base_branch}
```
-After creating the PR, store the URL:
+IMPORTANT: After creating the PR, you MUST store the PR URL so the directive system can track it.
+
+1. Run `gh pr create` as shown above and capture its output
+2. The output will contain the PR URL (e.g., https://github.com/owner/repo/pull/123)
+3. Then run this command to store the URL:
```
-makima directive update --pr-url "<the PR URL from gh pr create output>"
+makima directive update --pr-url "https://github.com/..."
```
+Replace the URL with the actual PR URL from the `gh pr create` output. This step is CRITICAL — the PR will not be tracked by the directive system without it.
If there are merge conflicts, resolve them sensibly before pushing.
"#,
diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs
index eb87e17..c840676 100644
--- a/makima/src/server/handlers/mesh.rs
+++ b/makima/src/server/handlers/mesh.rs
@@ -1070,17 +1070,19 @@ pub async fn send_message(
}
};
- // Check if task is running (except for AUTH_CODE messages and supervisor tasks)
- // Supervisor tasks can receive messages even when not running - daemon will respawn Claude
+ // Check if task is in a state that can receive messages
+ // Allow "running" and "starting" (to handle race between status update and message send)
+ // Also allow AUTH_CODE messages and supervisor tasks regardless of status
let is_auth_code = req.message.starts_with("AUTH_CODE:");
let is_supervisor = task.is_supervisor;
- if task.status != "running" && !is_auth_code && !is_supervisor {
+ let can_receive_message = task.status == "running" || task.status == "starting";
+ if !can_receive_message && !is_auth_code && !is_supervisor {
return (
StatusCode::BAD_REQUEST,
Json(ApiError::new(
"INVALID_STATE",
format!(
- "Cannot send message to task in status: {}. Task must be running.",
+ "Cannot send message to task in status: {}. Task must be running or starting.",
task.status
),
)),
diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs
index c9cb849..90c6dc7 100644
--- a/makima/src/server/handlers/mesh_supervisor.rs
+++ b/makima/src/server/handlers/mesh_supervisor.rs
@@ -129,6 +129,9 @@ pub struct PendingQuestionSummary {
pub question_id: Uuid,
pub task_id: Uuid,
pub contract_id: Uuid,
+ /// Directive this question relates to (if from a directive task)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub directive_id: Option<Uuid>,
pub question: String,
pub choices: Vec<String>,
pub context: Option<String>,
@@ -257,11 +260,11 @@ async fn verify_supervisor_auth(
)
})?;
- // Verify task is a supervisor
- if !task.is_supervisor {
+ // Verify task is a supervisor or a directive task
+ if !task.is_supervisor && task.directive_id.is_none() {
return Err((
StatusCode::FORBIDDEN,
- Json(ApiError::new("NOT_SUPERVISOR", "Only supervisor tasks can use these endpoints")),
+ Json(ApiError::new("NOT_SUPERVISOR", "Only supervisor or directive tasks can use these endpoints")),
));
}
@@ -1694,17 +1697,43 @@ pub async fn ask_question(
}
};
- let Some(contract_id) = supervisor.contract_id else {
+ // Determine context: contract or directive
+ let contract_id = supervisor.contract_id;
+ let directive_id = supervisor.directive_id;
+
+ if contract_id.is_none() && directive_id.is_none() {
return (
StatusCode::BAD_REQUEST,
- Json(ApiError::new("NO_CONTRACT", "Supervisor has no associated contract")),
+ Json(ApiError::new("NO_CONTEXT", "Supervisor has no associated contract or directive")),
).into_response();
+ }
+
+ let is_directive_context = directive_id.is_some() && contract_id.is_none();
+
+ // For directive context, check reconcile_mode to determine behavior
+ let directive_reconcile_mode = if let Some(did) = directive_id {
+ if is_directive_context {
+ match repository::get_directive_for_owner(pool, owner_id, did).await {
+ Ok(Some(d)) => d.reconcile_mode,
+ Ok(None) => false,
+ Err(e) => {
+ tracing::warn!(error = %e, "Failed to get directive for reconcile_mode check");
+ false
+ }
+ }
+ } else {
+ false
+ }
+ } else {
+ false
};
- // Add the question
- let question_id = state.add_supervisor_question(
+ // Add the question (use Uuid::nil() for contract_id in directive-only context)
+ let effective_contract_id = contract_id.unwrap_or(Uuid::nil());
+ let question_id = state.add_supervisor_question_with_directive(
supervisor_id,
- contract_id,
+ effective_contract_id,
+ directive_id,
owner_id,
request.question.clone(),
request.choices.clone(),
@@ -1714,15 +1743,18 @@ pub async fn ask_question(
);
// Save state: question asked is a key save point (Task 3.3)
- let pending_question = PendingQuestion {
- id: question_id,
- question: request.question.clone(),
- choices: request.choices.clone(),
- context: request.context.clone(),
- question_type: request.question_type.clone(),
- asked_at: chrono::Utc::now(),
- };
- save_state_on_question_asked(pool, contract_id, pending_question).await;
+ // Only for contract context — directive tasks don't use supervisor_states table
+ if let Some(cid) = contract_id {
+ let pending_question = PendingQuestion {
+ id: question_id,
+ question: request.question.clone(),
+ choices: request.choices.clone(),
+ context: request.context.clone(),
+ question_type: request.question_type.clone(),
+ asked_at: chrono::Utc::now(),
+ };
+ save_state_on_question_asked(pool, cid, pending_question).await;
+ }
// Broadcast question as task output entry for the task's chat
let question_data = serde_json::json!({
@@ -1775,9 +1807,10 @@ pub async fn ask_question(
).into_response();
}
- // If phaseguard is enabled, pause the supervisor task and return
+ // If phaseguard is enabled (or directive reconcile mode), pause the supervisor task and return
// The task will be auto-resumed when a message is sent to it (e.g., when user answers)
- if request.phaseguard {
+ let use_phaseguard = request.phaseguard || (is_directive_context && directive_reconcile_mode);
+ if use_phaseguard {
// Pause the supervisor task
if let Some(daemon_id) = supervisor.daemon_id {
let cmd = DaemonCommand::PauseTask { task_id: supervisor_id };
@@ -1808,7 +1841,13 @@ pub async fn ask_question(
}
// Poll for response with timeout
- let timeout_duration = std::time::Duration::from_secs(request.timeout_seconds.max(1) as u64);
+ // For directive tasks without reconcile mode, use 30s default timeout
+ let timeout_secs = if is_directive_context && !directive_reconcile_mode {
+ 30
+ } else {
+ request.timeout_seconds.max(1) as u64
+ };
+ let timeout_duration = std::time::Duration::from_secs(timeout_secs);
let start = std::time::Instant::now();
let poll_interval = std::time::Duration::from_millis(500);
@@ -1819,7 +1858,10 @@ pub async fn ask_question(
state.cleanup_question_response(question_id);
// Clear pending question from supervisor state (Task 3.3)
- clear_pending_question(pool, contract_id, question_id).await;
+ // Skip for directive context — no supervisor_states for directives
+ if let Some(cid) = contract_id {
+ clear_pending_question(pool, cid, question_id).await;
+ }
return (
StatusCode::OK,
@@ -1837,7 +1879,10 @@ pub async fn ask_question(
state.remove_pending_question(question_id);
// Clear pending question from supervisor state on timeout (Task 3.3)
- clear_pending_question(pool, contract_id, question_id).await;
+ // Skip for directive context — no supervisor_states for directives
+ if let Some(cid) = contract_id {
+ clear_pending_question(pool, cid, question_id).await;
+ }
return (
StatusCode::REQUEST_TIMEOUT,
@@ -1880,6 +1925,7 @@ pub async fn list_pending_questions(
question_id: q.question_id,
task_id: q.task_id,
contract_id: q.contract_id,
+ directive_id: q.directive_id,
question: q.question,
choices: q.choices,
context: q.context,
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index 29cd09f..8b06a28 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -13,6 +13,7 @@ pub mod history;
pub mod listen;
pub mod mesh;
pub mod mesh_chat;
+pub mod orders;
pub mod mesh_daemon;
pub mod mesh_merge;
pub mod mesh_supervisor;
diff --git a/makima/src/server/handlers/orders.rs b/makima/src/server/handlers/orders.rs
new file mode 100644
index 0000000..c43c406
--- /dev/null
+++ b/makima/src/server/handlers/orders.rs
@@ -0,0 +1,443 @@
+//! HTTP handlers for order CRUD operations.
+//! Orders are card-based work items (features, bugs, spikes) similar to
+//! GitHub Issues or Linear cards.
+
+use axum::{
+ extract::{Path, Query, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use uuid::Uuid;
+
+use crate::db::models::{
+ ConvertToStepRequest, CreateOrderRequest, DirectiveStep, LinkContractRequest,
+ LinkDirectiveRequest, Order, OrderListQuery, OrderListResponse, UpdateOrderRequest,
+};
+use crate::db::repository;
+use crate::server::auth::Authenticated;
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+// =============================================================================
+// Order CRUD
+// =============================================================================
+
+/// List all orders for the authenticated user.
+#[utoipa::path(
+ get,
+ path = "/api/v1/orders",
+ params(
+ ("status" = Option<String>, Query, description = "Filter by status"),
+ ("type" = Option<String>, Query, description = "Filter by order type"),
+ ("priority" = Option<String>, Query, description = "Filter by priority"),
+ ("directive_id" = Option<Uuid>, Query, description = "Filter by directive ID"),
+ ("contract_id" = Option<Uuid>, Query, description = "Filter by contract ID"),
+ ),
+ responses(
+ (status = 200, description = "List of orders", body = OrderListResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Orders"
+)]
+pub async fn list_orders(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Query(query): Query<OrderListQuery>,
+) -> 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_orders(
+ pool,
+ auth.owner_id,
+ query.status.as_deref(),
+ query.order_type.as_deref(),
+ query.priority.as_deref(),
+ query.directive_id,
+ query.contract_id,
+ )
+ .await
+ {
+ Ok(orders) => {
+ let total = orders.len() as i64;
+ Json(OrderListResponse { orders, total }).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list orders: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("LIST_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Create a new order.
+#[utoipa::path(
+ post,
+ path = "/api/v1/orders",
+ request_body = CreateOrderRequest,
+ responses(
+ (status = 201, description = "Order created", body = Order),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Orders"
+)]
+pub async fn create_order(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Json(req): Json<CreateOrderRequest>,
+) -> 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_order(pool, auth.owner_id, req).await {
+ Ok(order) => (StatusCode::CREATED, Json(order)).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to create order: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("CREATE_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get an order by ID.
+#[utoipa::path(
+ get,
+ path = "/api/v1/orders/{id}",
+ params(("id" = Uuid, Path, description = "Order ID")),
+ responses(
+ (status = 200, description = "Order details", body = Order),
+ (status = 404, description = "Not found", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Orders"
+)]
+pub async fn get_order(
+ 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_order(pool, auth.owner_id, id).await {
+ Ok(Some(order)) => Json(order).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Order not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get order: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("GET_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update an order.
+#[utoipa::path(
+ patch,
+ path = "/api/v1/orders/{id}",
+ params(("id" = Uuid, Path, description = "Order ID")),
+ request_body = UpdateOrderRequest,
+ responses(
+ (status = 200, description = "Order updated", body = Order),
+ (status = 404, description = "Not found", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Orders"
+)]
+pub async fn update_order(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<UpdateOrderRequest>,
+) -> 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_order(pool, auth.owner_id, id, req).await {
+ Ok(Some(order)) => Json(order).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Order not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to update order: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("UPDATE_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Delete an order.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/orders/{id}",
+ params(("id" = Uuid, Path, description = "Order ID")),
+ responses(
+ (status = 204, description = "Deleted"),
+ (status = 404, description = "Not found", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Orders"
+)]
+pub async fn delete_order(
+ 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::delete_order(pool, auth.owner_id, id).await {
+ Ok(true) => StatusCode::NO_CONTENT.into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Order not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to delete order: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DELETE_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+// =============================================================================
+// Order Linking & Conversion
+// =============================================================================
+
+/// Link an order to a directive.
+#[utoipa::path(
+ post,
+ path = "/api/v1/orders/{id}/link-directive",
+ params(("id" = Uuid, Path, description = "Order ID")),
+ request_body = LinkDirectiveRequest,
+ responses(
+ (status = 200, description = "Order linked to directive", body = Order),
+ (status = 404, description = "Not found", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Orders"
+)]
+pub async fn link_to_directive(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<LinkDirectiveRequest>,
+) -> 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 the directive exists and belongs to this owner
+ match repository::get_directive_for_owner(pool, auth.owner_id, req.directive_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Directive not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("GET_FAILED", &e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::link_order_to_directive(pool, auth.owner_id, id, req.directive_id).await {
+ Ok(Some(order)) => Json(order).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Order not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to link order to directive: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("LINK_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Link an order to a contract.
+#[utoipa::path(
+ post,
+ path = "/api/v1/orders/{id}/link-contract",
+ params(("id" = Uuid, Path, description = "Order ID")),
+ request_body = LinkContractRequest,
+ responses(
+ (status = 200, description = "Order linked to contract", body = Order),
+ (status = 404, description = "Not found", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Orders"
+)]
+pub async fn link_to_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<LinkContractRequest>,
+) -> 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 the contract exists and belongs to this owner
+ match repository::get_contract_for_owner(pool, auth.owner_id, req.contract_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("GET_FAILED", &e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::link_order_to_contract(pool, auth.owner_id, id, req.contract_id).await {
+ Ok(Some(order)) => Json(order).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Order not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to link order to contract: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("LINK_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Convert an order to a directive step.
+/// Creates a new step in the specified directive using the order's title/description,
+/// and links the order to both the directive and the new step.
+#[utoipa::path(
+ post,
+ path = "/api/v1/orders/{id}/convert-to-step",
+ params(("id" = Uuid, Path, description = "Order ID")),
+ request_body = ConvertToStepRequest,
+ responses(
+ (status = 201, description = "Directive step created from order", body = DirectiveStep),
+ (status = 404, description = "Order or directive not found", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Orders"
+)]
+pub async fn convert_to_step(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<ConvertToStepRequest>,
+) -> 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::convert_order_to_step(pool, auth.owner_id, id, req.directive_id).await {
+ Ok(Some(step)) => (StatusCode::CREATED, Json(step)).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Order or directive not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to convert order to step: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("CONVERT_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index c1e1309..29c55c4 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, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions};
+use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, 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::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -238,6 +238,20 @@ pub fn make_router(state: SharedState) -> Router {
.route("/directives/{id}/steps/{step_id}/skip", post(directives::skip_step))
.route("/directives/{id}/goal", put(directives::update_goal))
.route("/directives/{id}/cleanup-tasks", post(directives::cleanup_tasks))
+ // Order endpoints
+ .route(
+ "/orders",
+ get(orders::list_orders).post(orders::create_order),
+ )
+ .route(
+ "/orders/{id}",
+ get(orders::get_order)
+ .patch(orders::update_order)
+ .delete(orders::delete_order),
+ )
+ .route("/orders/{id}/link-directive", post(orders::link_to_directive))
+ .route("/orders/{id}/link-contract", post(orders::link_to_contract))
+ .route("/orders/{id}/convert-to-step", post(orders::convert_to_step))
// Timeline endpoint (unified history for user)
.route("/timeline", get(history::get_timeline))
// Contract type templates (built-in only)
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index e68286e..b21dab9 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -8,26 +8,30 @@ use crate::db::models::{
ChangePhaseRequest,
Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent,
ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations,
- CleanupTasksResponse,
+ CleanupTasksResponse, ConvertToStepRequest,
CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest,
- CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse,
+ CreateManagedRepositoryRequest, CreateOrderRequest, CreateTaskRequest,
+ Daemon, DaemonDirectoriesResponse,
DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse,
DirectiveStep, DirectiveSummary, DirectiveWithSteps,
File, FileListResponse, FileSummary,
+ LinkContractRequest, LinkDirectiveRequest,
MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse,
MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation,
- MeshChatHistoryResponse, MeshChatMessageRecord, RepositoryHistoryEntry,
+ MeshChatHistoryResponse, MeshChatMessageRecord,
+ Order, OrderListResponse, OrderListQuery,
+ RepositoryHistoryEntry,
RepositoryHistoryListResponse, RepositorySuggestionsQuery, SendMessageRequest,
Task,
TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry,
UpdateContractRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest,
- UpdateFileRequest, UpdateGoalRequest, UpdateTaskRequest,
+ UpdateFileRequest, UpdateGoalRequest, UpdateOrderRequest, UpdateTaskRequest,
};
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, repository_history, users};
+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::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage};
#[derive(OpenApi)]
@@ -125,6 +129,15 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
directives::skip_step,
directives::update_goal,
directives::cleanup_tasks,
+ // Order endpoints
+ orders::list_orders,
+ orders::create_order,
+ orders::get_order,
+ orders::update_order,
+ orders::delete_order,
+ orders::link_to_directive,
+ orders::link_to_contract,
+ orders::convert_to_step,
// Repository history/settings endpoints
repository_history::list_repository_history,
repository_history::get_repository_suggestions,
@@ -222,6 +235,15 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
CreateDirectiveStepRequest,
UpdateDirectiveStepRequest,
CleanupTasksResponse,
+ // Order schemas
+ Order,
+ OrderListResponse,
+ OrderListQuery,
+ CreateOrderRequest,
+ UpdateOrderRequest,
+ LinkDirectiveRequest,
+ LinkContractRequest,
+ ConvertToStepRequest,
// Repository history schemas
RepositoryHistoryEntry,
RepositoryHistoryListResponse,
@@ -236,6 +258,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
(name = "API Keys", description = "API key management for programmatic access"),
(name = "Users", description = "User account management"),
(name = "Directives", description = "Directive management with DAG-based step progression"),
+ (name = "Orders", description = "Order management — card-based issue tracking for planned work items"),
(name = "Settings", description = "User settings including repository history"),
)
)]
diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs
index 58e8545..41c336e 100644
--- a/makima/src/server/state.rs
+++ b/makima/src/server/state.rs
@@ -142,8 +142,11 @@ pub struct SupervisorQuestionNotification {
pub question_id: Uuid,
/// Supervisor task that asked the question
pub task_id: Uuid,
- /// Contract this question relates to
+ /// Contract this question relates to (Uuid::nil() for directive context)
pub contract_id: Uuid,
+ /// Directive this question relates to (if from a directive task)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub directive_id: Option<Uuid>,
/// Owner ID for data isolation
#[serde(skip)]
pub owner_id: Option<Uuid>,
@@ -170,6 +173,8 @@ pub struct PendingSupervisorQuestion {
pub question_id: Uuid,
pub task_id: Uuid,
pub contract_id: Uuid,
+ /// Directive this question relates to (if from a directive task)
+ pub directive_id: Option<Uuid>,
pub owner_id: Uuid,
pub question: String,
pub choices: Vec<String>,
@@ -819,6 +824,25 @@ impl AppState {
multi_select: bool,
question_type: String,
) -> Uuid {
+ self.add_supervisor_question_with_directive(
+ task_id, contract_id, None, owner_id,
+ question, choices, context, multi_select, question_type,
+ )
+ }
+
+ /// Add a pending supervisor question with optional directive context and broadcast it.
+ pub fn add_supervisor_question_with_directive(
+ &self,
+ task_id: Uuid,
+ contract_id: Uuid,
+ directive_id: Option<Uuid>,
+ owner_id: Uuid,
+ question: String,
+ choices: Vec<String>,
+ context: Option<String>,
+ multi_select: bool,
+ question_type: String,
+ ) -> Uuid {
let question_id = Uuid::new_v4();
let now = chrono::Utc::now();
@@ -829,6 +853,7 @@ impl AppState {
question_id,
task_id,
contract_id,
+ directive_id,
owner_id,
question: question.clone(),
choices: choices.clone(),
@@ -844,6 +869,7 @@ impl AppState {
question_id,
task_id,
contract_id,
+ directive_id,
owner_id: Some(owner_id),
question,
choices,
@@ -857,6 +883,7 @@ impl AppState {
question_id = %question_id,
task_id = %task_id,
contract_id = %contract_id,
+ directive_id = ?directive_id,
question_type = %question_type,
"Supervisor question added"
);
@@ -904,6 +931,7 @@ impl AppState {
question_id,
task_id: question.1.task_id,
contract_id: question.1.contract_id,
+ directive_id: question.1.directive_id,
owner_id: Some(question.1.owner_id),
question: question.1.question,
choices: question.1.choices,