summaryrefslogtreecommitdiff
path: root/makima
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-07 01:11:26 +0000
committersoryu <soryu@soryu.co>2026-02-07 01:11:26 +0000
commit9e9f18884c78c21f5785908fb7ccd00e2fa5436b (patch)
treef2ca7c2a3db5350186282ae0be0e539aa77c0320 /makima
parentb8d563d45f14a2b1db1f684aa0a8dcd7e5b6ad56 (diff)
downloadsoryu-9e9f18884c78c21f5785908fb7ccd00e2fa5436b.tar.gz
soryu-9e9f18884c78c21f5785908fb7ccd00e2fa5436b.zip
Add new directive initial implementation
Diffstat (limited to 'makima')
-rw-r--r--makima/frontend/src/components/NavStrip.tsx1
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx200
-rw-r--r--makima/frontend/src/components/directives/DirectiveList.tsx135
-rw-r--r--makima/frontend/src/hooks/useDirectives.ts108
-rw-r--r--makima/frontend/src/lib/api.ts158
-rw-r--r--makima/frontend/src/main.tsx17
-rw-r--r--makima/frontend/src/routes/directives.tsx209
-rw-r--r--makima/src/bin/makima.rs64
-rw-r--r--makima/src/daemon/api/directive.rs54
-rw-r--r--makima/src/daemon/api/mod.rs1
-rw-r--r--makima/src/daemon/cli/directive.rs40
-rw-r--r--makima/src/daemon/cli/mod.rs28
-rw-r--r--makima/src/daemon/skills/directive.md68
-rw-r--r--makima/src/daemon/skills/mod.rs4
-rw-r--r--makima/src/db/models.rs234
-rw-r--r--makima/src/db/repository.rs283
-rw-r--r--makima/src/server/handlers/directives.rs440
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/mod.rs15
-rw-r--r--makima/src/server/openapi.rs41
20 files changed, 2084 insertions, 17 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index fb95c7f..f7e67db 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -11,6 +11,7 @@ interface NavLink {
const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
{ label: "Contracts", href: "/contracts", requiresAuth: true },
+ { label: "Directives", href: "/directives", requiresAuth: true },
{ label: "Board", href: "/workflow", 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
new file mode 100644
index 0000000..3634a79
--- /dev/null
+++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx
@@ -0,0 +1,200 @@
+import type {
+ DirectiveWithChains,
+ DirectiveStatus,
+ DirectiveChain,
+} from "../../lib/api";
+
+interface DirectiveDetailProps {
+ directive: DirectiveWithChains;
+ onBack: () => void;
+ onDelete?: (id: string) => void;
+}
+
+const statusColors: Record<DirectiveStatus, string> = {
+ draft: "text-[#888]",
+ planning: "text-yellow-400",
+ active: "text-green-400",
+ paused: "text-orange-400",
+ completed: "text-blue-400",
+ archived: "text-[#555]",
+ failed: "text-red-400",
+};
+
+function ChainCard({ chain }: { chain: DirectiveChain }) {
+ return (
+ <div className="p-3 border border-dashed border-[rgba(117,170,252,0.25)] bg-[rgba(117,170,252,0.03)]">
+ <div className="flex items-center justify-between mb-1">
+ <span className="font-mono text-xs text-[#dbe7ff]">{chain.name}</span>
+ <span className="font-mono text-[10px] text-[#7788aa] uppercase">
+ gen {chain.generation} &middot; {chain.status}
+ </span>
+ </div>
+ {chain.description && (
+ <p className="font-mono text-[11px] text-[#7788aa] mb-1">
+ {chain.description}
+ </p>
+ )}
+ <div className="flex gap-3 font-mono text-[10px] text-[#7788aa]">
+ <span>
+ {chain.completedSteps}/{chain.totalSteps} steps
+ </span>
+ {chain.failedSteps > 0 && (
+ <span className="text-red-400">{chain.failedSteps} failed</span>
+ )}
+ {chain.currentConfidence != null && (
+ <span>confidence: {(chain.currentConfidence * 100).toFixed(0)}%</span>
+ )}
+ </div>
+ </div>
+ );
+}
+
+function JsonSection({
+ label,
+ data,
+}: {
+ label: string;
+ data: unknown[] | unknown;
+}) {
+ const items = Array.isArray(data) ? data : [];
+ if (items.length === 0) return null;
+
+ return (
+ <div>
+ <h4 className="font-mono text-[10px] text-[#75aafc] uppercase tracking-wider mb-1">
+ {label}
+ </h4>
+ <div className="font-mono text-xs text-[#9bb8d8] bg-[rgba(0,0,0,0.2)] p-2 max-h-32 overflow-y-auto">
+ {items.map((item, i) => (
+ <div key={i} className="mb-0.5">
+ {typeof item === "string" ? item : JSON.stringify(item)}
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
+
+export function DirectiveDetail({
+ directive,
+ onBack,
+ onDelete,
+}: DirectiveDetailProps) {
+ return (
+ <div className="panel h-full flex flex-col">
+ {/* Header */}
+ <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]">
+ <div className="flex items-center gap-2 mb-2">
+ <button
+ onClick={onBack}
+ className="font-mono text-xs text-[#75aafc] hover:text-white transition-colors"
+ >
+ &larr; Back
+ </button>
+ <span
+ className={`font-mono text-[10px] uppercase ${
+ statusColors[directive.status as DirectiveStatus] || "text-[#888]"
+ }`}
+ >
+ {directive.status}
+ </span>
+ <span className="font-mono text-[10px] text-[#7788aa]">
+ v{directive.version}
+ </span>
+ {onDelete && (
+ <button
+ onClick={() => onDelete(directive.id)}
+ className="ml-auto font-mono text-[10px] text-red-400 hover:text-red-300 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ )}
+ </div>
+ <h2 className="font-mono text-sm text-[#dbe7ff]">
+ {directive.title}
+ </h2>
+ </div>
+
+ {/* Content */}
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
+ {/* Goal */}
+ <div>
+ <h4 className="font-mono text-[10px] text-[#75aafc] uppercase tracking-wider mb-1">
+ Goal
+ </h4>
+ <p className="font-mono text-xs text-[#9bb8d8] whitespace-pre-wrap">
+ {directive.goal}
+ </p>
+ </div>
+
+ {/* Config */}
+ <div className="grid grid-cols-2 gap-2">
+ <div>
+ <span className="font-mono text-[10px] text-[#7788aa] uppercase">
+ Autonomy
+ </span>
+ <div className="font-mono text-xs text-[#dbe7ff]">
+ {directive.autonomyLevel}
+ </div>
+ </div>
+ <div>
+ <span className="font-mono text-[10px] text-[#7788aa] uppercase">
+ Chains
+ </span>
+ <div className="font-mono text-xs text-[#dbe7ff]">
+ {directive.chainGenerationCount} generated
+ </div>
+ </div>
+ <div>
+ <span className="font-mono text-[10px] text-[#7788aa] uppercase">
+ Cost
+ </span>
+ <div className="font-mono text-xs text-[#dbe7ff]">
+ ${directive.totalCostUsd.toFixed(2)}
+ </div>
+ </div>
+ {directive.repositoryUrl && (
+ <div>
+ <span className="font-mono text-[10px] text-[#7788aa] uppercase">
+ Repository
+ </span>
+ <div className="font-mono text-xs text-[#dbe7ff] truncate">
+ {directive.repositoryUrl}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* Structured sections */}
+ <JsonSection label="Requirements" data={directive.requirements} />
+ <JsonSection
+ label="Acceptance Criteria"
+ data={directive.acceptanceCriteria}
+ />
+ <JsonSection label="Constraints" data={directive.constraints} />
+ <JsonSection
+ label="External Dependencies"
+ data={directive.externalDependencies}
+ />
+
+ {/* Chains */}
+ <div>
+ <h4 className="font-mono text-[10px] text-[#75aafc] uppercase tracking-wider mb-2">
+ Chains ({directive.chains.length})
+ </h4>
+ {directive.chains.length === 0 ? (
+ <p className="font-mono text-xs text-[#7788aa]">
+ No chains yet. Chains are created during planning.
+ </p>
+ ) : (
+ <div className="space-y-2">
+ {directive.chains.map((chain) => (
+ <ChainCard key={chain.id} chain={chain} />
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/directives/DirectiveList.tsx b/makima/frontend/src/components/directives/DirectiveList.tsx
new file mode 100644
index 0000000..a900b7b
--- /dev/null
+++ b/makima/frontend/src/components/directives/DirectiveList.tsx
@@ -0,0 +1,135 @@
+import { useState } from "react";
+import type { DirectiveSummary, DirectiveStatus } from "../../lib/api";
+
+interface DirectiveListProps {
+ directives: DirectiveSummary[];
+ loading: boolean;
+ onSelect: (id: string) => void;
+ onCreate: () => void;
+ onDelete?: (directive: DirectiveSummary) => void;
+ selectedId?: string;
+}
+
+const statusColors: Record<DirectiveStatus, string> = {
+ draft: "text-[#888]",
+ planning: "text-yellow-400",
+ active: "text-green-400",
+ paused: "text-orange-400",
+ completed: "text-blue-400",
+ archived: "text-[#555]",
+ failed: "text-red-400",
+};
+
+export function DirectiveList({
+ directives,
+ loading,
+ onSelect,
+ onCreate,
+ onDelete,
+ selectedId,
+}: DirectiveListProps) {
+ const [filter, setFilter] = useState<DirectiveStatus | "all">("all");
+
+ const filteredDirectives =
+ filter === "all"
+ ? directives
+ : directives.filter((d) => d.status === filter);
+
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel h-full flex flex-col">
+ {/* Header */}
+ <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]">
+ <div className="flex items-center justify-between mb-3">
+ <h2 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider">
+ Directives
+ </h2>
+ <button
+ onClick={onCreate}
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ + New
+ </button>
+ </div>
+ {/* Filter tabs */}
+ <div className="flex gap-1 flex-wrap">
+ {(["all", "draft", "planning", "active", "paused", "completed", "failed"] as const).map(
+ (status) => (
+ <button
+ key={status}
+ onClick={() => setFilter(status)}
+ className={`px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider border transition-colors ${
+ filter === status
+ ? "bg-[#0f3c78] border-[#3f6fb3] text-[#dbe7ff]"
+ : "bg-transparent border-[rgba(117,170,252,0.2)] text-[#7788aa] hover:border-[rgba(117,170,252,0.4)]"
+ }`}
+ >
+ {status}
+ </button>
+ )
+ )}
+ </div>
+ </div>
+
+ {/* List */}
+ <div className="flex-1 overflow-y-auto">
+ {filteredDirectives.length === 0 ? (
+ <div className="p-4 text-center">
+ <p className="font-mono text-sm text-[#7788aa]">
+ {directives.length === 0
+ ? "No directives yet"
+ : "No matching directives"}
+ </p>
+ </div>
+ ) : (
+ filteredDirectives.map((directive) => (
+ <div
+ key={directive.id}
+ onClick={() => onSelect(directive.id)}
+ onContextMenu={(e) => {
+ if (onDelete) {
+ e.preventDefault();
+ }
+ }}
+ className={`p-3 border-b border-dashed border-[rgba(117,170,252,0.15)] cursor-pointer transition-colors hover:bg-[rgba(117,170,252,0.05)] ${
+ selectedId === directive.id
+ ? "bg-[rgba(117,170,252,0.1)]"
+ : ""
+ }`}
+ >
+ <div className="flex items-start justify-between gap-2">
+ <div className="flex-1 min-w-0">
+ <div className="font-mono text-sm text-[#dbe7ff] truncate">
+ {directive.title}
+ </div>
+ <div className="font-mono text-xs text-[#7788aa] mt-0.5 line-clamp-2">
+ {directive.goal}
+ </div>
+ </div>
+ <div className="flex flex-col items-end gap-1 shrink-0">
+ <span
+ className={`font-mono text-[10px] uppercase ${
+ statusColors[directive.status] || "text-[#888]"
+ }`}
+ >
+ {directive.status}
+ </span>
+ <span className="font-mono text-[10px] text-[#7788aa]">
+ {directive.chainCount}ch / {directive.stepCount}st
+ </span>
+ </div>
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts
new file mode 100644
index 0000000..001cf89
--- /dev/null
+++ b/makima/frontend/src/hooks/useDirectives.ts
@@ -0,0 +1,108 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ listDirectives,
+ getDirective,
+ createDirective,
+ updateDirective,
+ deleteDirective,
+ type DirectiveSummary,
+ type DirectiveWithChains,
+ type CreateDirectiveRequest,
+ type UpdateDirectiveRequest,
+} from "../lib/api";
+
+export function useDirectives() {
+ const [directives, setDirectives] = useState<DirectiveSummary[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const fetchDirectives = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await listDirectives();
+ setDirectives(response.directives);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch directives");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const fetchDirective = useCallback(
+ async (id: string): Promise<DirectiveWithChains | null> => {
+ setError(null);
+ try {
+ return await getDirective(id);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch directive");
+ return null;
+ }
+ },
+ []
+ );
+
+ const saveDirective = useCallback(
+ async (data: CreateDirectiveRequest): Promise<DirectiveSummary | null> => {
+ setError(null);
+ try {
+ const directive = await createDirective(data);
+ await fetchDirectives();
+ return directive as unknown as DirectiveSummary;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to create directive");
+ return null;
+ }
+ },
+ [fetchDirectives]
+ );
+
+ const editDirective = useCallback(
+ async (
+ id: string,
+ data: UpdateDirectiveRequest
+ ): Promise<DirectiveSummary | null> => {
+ setError(null);
+ try {
+ const directive = await updateDirective(id, data);
+ await fetchDirectives();
+ return directive as unknown as DirectiveSummary;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to update directive");
+ return null;
+ }
+ },
+ [fetchDirectives]
+ );
+
+ const removeDirective = useCallback(
+ async (id: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await deleteDirective(id);
+ await fetchDirectives();
+ return true;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to delete directive");
+ return false;
+ }
+ },
+ [fetchDirectives]
+ );
+
+ // Initial fetch
+ useEffect(() => {
+ fetchDirectives();
+ }, [fetchDirectives]);
+
+ return {
+ directives,
+ loading,
+ error,
+ fetchDirectives,
+ fetchDirective,
+ saveDirective,
+ editDirective,
+ removeDirective,
+ };
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index 9f5ff88..6c450eb 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -3003,3 +3003,161 @@ export async function listTaskPatches(taskId: string, contractId: string): Promi
return res.json();
}
+// =============================================================================
+// Directive Types & API
+// =============================================================================
+
+export type DirectiveStatus = "draft" | "planning" | "active" | "paused" | "completed" | "archived" | "failed";
+export type AutonomyLevel = "full_auto" | "guardrails" | "manual";
+
+export interface DirectiveSummary {
+ id: string;
+ title: string;
+ goal: string;
+ status: DirectiveStatus;
+ autonomyLevel: AutonomyLevel;
+ chainCount: number;
+ stepCount: number;
+ totalCostUsd: number;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface Directive {
+ id: string;
+ ownerId: string;
+ title: string;
+ goal: string;
+ requirements: unknown[];
+ acceptanceCriteria: unknown[];
+ constraints: unknown[];
+ externalDependencies: unknown[];
+ status: DirectiveStatus;
+ autonomyLevel: AutonomyLevel;
+ confidenceThresholdGreen: number;
+ confidenceThresholdYellow: number;
+ maxTotalCostUsd: number | null;
+ maxWallTimeMinutes: number | null;
+ maxReworkCycles: number | null;
+ maxChainRegenerations: number | null;
+ repositoryUrl: string | null;
+ localPath: string | null;
+ baseBranch: string | null;
+ orchestratorContractId: string | null;
+ currentChainId: string | null;
+ chainGenerationCount: number;
+ totalCostUsd: number;
+ startedAt: string | null;
+ completedAt: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface DirectiveChain {
+ id: string;
+ directiveId: string;
+ generation: number;
+ name: string;
+ description: string | null;
+ rationale: string | null;
+ planningModel: string | null;
+ status: string;
+ totalSteps: number;
+ completedSteps: number;
+ failedSteps: number;
+ currentConfidence: number | null;
+ startedAt: string | null;
+ completedAt: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface DirectiveWithChains extends Directive {
+ chains: DirectiveChain[];
+}
+
+export interface DirectiveListResponse {
+ directives: DirectiveSummary[];
+ total: number;
+}
+
+export interface CreateDirectiveRequest {
+ title: string;
+ goal: string;
+ requirements?: unknown[];
+ acceptanceCriteria?: unknown[];
+ constraints?: unknown[];
+ externalDependencies?: unknown[];
+ autonomyLevel?: AutonomyLevel;
+ repositoryUrl?: string;
+ localPath?: string;
+ baseBranch?: string;
+}
+
+export interface UpdateDirectiveRequest {
+ title?: string;
+ goal?: string;
+ status?: DirectiveStatus;
+ autonomyLevel?: AutonomyLevel;
+ version?: number;
+}
+
+export async function listDirectives(): Promise<DirectiveListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives`);
+ if (!res.ok) {
+ throw new Error(`Failed to list directives: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function getDirective(id: string): Promise<DirectiveWithChains> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get directive: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function createDirective(
+ data: CreateDirectiveRequest
+): Promise<Directive> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to create directive: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function updateDirective(
+ id: string,
+ data: UpdateDirectiveRequest
+): Promise<Directive> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(data),
+ });
+ if (res.status === 409) {
+ const conflict = (await res.json()) as ConflictErrorResponse;
+ throw new VersionConflictError(conflict);
+ }
+ if (!res.ok) {
+ throw new Error(`Failed to update directive: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function deleteDirective(id: string): Promise<void> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${id}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to delete directive: ${res.statusText}`);
+ }
+}
+
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index 50fffe4..f07a143 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -18,6 +18,7 @@ import HistoryPage from "./routes/history";
import LoginPage from "./routes/login";
import SettingsPage from "./routes/settings";
import ContractFilePage from "./routes/contract-file";
+import DirectivesPage from "./routes/directives";
import SpeakPage from "./routes/speak";
createRoot(document.getElementById("root")!).render(
@@ -80,6 +81,22 @@ createRoot(document.getElementById("root")!).render(
}
/>
<Route
+ path="/directives"
+ element={
+ <ProtectedRoute>
+ <DirectivesPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/directives/:id"
+ element={
+ <ProtectedRoute>
+ <DirectivesPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
path="/workflow"
element={
<ProtectedRoute>
diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx
new file mode 100644
index 0000000..89535e2
--- /dev/null
+++ b/makima/frontend/src/routes/directives.tsx
@@ -0,0 +1,209 @@
+import { useState, useCallback, useEffect } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { DirectiveList } from "../components/directives/DirectiveList";
+import { DirectiveDetail } from "../components/directives/DirectiveDetail";
+import { useDirectives } from "../hooks/useDirectives";
+import { useAuth } from "../contexts/AuthContext";
+import type {
+ DirectiveWithChains,
+ CreateDirectiveRequest,
+} from "../lib/api";
+
+export default function DirectivesPage() {
+ 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]);
+
+ 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>
+ );
+ }
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <DirectivesContent />
+ </div>
+ );
+}
+
+function DirectivesContent() {
+ const { id } = useParams<{ id?: string }>();
+ const navigate = useNavigate();
+ const {
+ directives,
+ loading,
+ error,
+ fetchDirective,
+ saveDirective,
+ removeDirective,
+ } = useDirectives();
+
+ const [selectedDirective, setSelectedDirective] =
+ useState<DirectiveWithChains | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [createTitle, setCreateTitle] = useState("");
+ const [createGoal, setCreateGoal] = useState("");
+ const [createRepoUrl, setCreateRepoUrl] = useState("");
+
+ // Load directive when ID changes
+ useEffect(() => {
+ if (id) {
+ setDetailLoading(true);
+ fetchDirective(id).then((d) => {
+ setSelectedDirective(d);
+ setDetailLoading(false);
+ });
+ } else {
+ setSelectedDirective(null);
+ }
+ }, [id, fetchDirective]);
+
+ const handleSelect = useCallback(
+ (directiveId: string) => {
+ navigate(`/directives/${directiveId}`);
+ },
+ [navigate]
+ );
+
+ const handleBack = useCallback(() => {
+ navigate("/directives");
+ }, [navigate]);
+
+ const handleCreate = useCallback(async () => {
+ if (!createTitle.trim() || !createGoal.trim()) return;
+
+ const data: CreateDirectiveRequest = {
+ title: createTitle.trim(),
+ goal: createGoal.trim(),
+ };
+ if (createRepoUrl.trim()) {
+ data.repositoryUrl = createRepoUrl.trim();
+ }
+
+ const result = await saveDirective(data);
+ if (result) {
+ setShowCreateForm(false);
+ setCreateTitle("");
+ setCreateGoal("");
+ setCreateRepoUrl("");
+ }
+ }, [createTitle, createGoal, createRepoUrl, saveDirective]);
+
+ const handleDelete = useCallback(
+ async (directiveId: string) => {
+ const ok = await removeDirective(directiveId);
+ if (ok && id === directiveId) {
+ navigate("/directives");
+ }
+ },
+ [removeDirective, id, navigate]
+ );
+
+ // Detail view
+ if (id) {
+ if (detailLoading) {
+ return (
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading directive...</p>
+ </main>
+ );
+ }
+ if (!selectedDirective) {
+ return (
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Directive not found</p>
+ </main>
+ );
+ }
+ return (
+ <main className="flex-1 p-4 max-w-4xl mx-auto w-full">
+ <DirectiveDetail
+ directive={selectedDirective}
+ onBack={handleBack}
+ onDelete={handleDelete}
+ />
+ </main>
+ );
+ }
+
+ // List view
+ return (
+ <main className="flex-1 p-4 max-w-4xl mx-auto w-full">
+ {error && (
+ <div className="mb-4 p-2 border border-red-400/30 bg-red-400/5 font-mono text-xs text-red-400">
+ {error}
+ </div>
+ )}
+
+ {showCreateForm && (
+ <div className="mb-4 p-4 border border-dashed border-[rgba(117,170,252,0.35)] bg-[rgba(117,170,252,0.03)]">
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase tracking-wider mb-3">
+ New Directive
+ </h3>
+ <div className="space-y-2">
+ <input
+ type="text"
+ placeholder="Title"
+ value={createTitle}
+ onChange={(e) => setCreateTitle(e.target.value)}
+ className="w-full px-2 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0a1628] border border-[rgba(117,170,252,0.3)] focus:border-[#75aafc] outline-none"
+ />
+ <textarea
+ placeholder="Goal - what should be accomplished?"
+ value={createGoal}
+ onChange={(e) => setCreateGoal(e.target.value)}
+ rows={3}
+ className="w-full px-2 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0a1628] border border-[rgba(117,170,252,0.3)] focus:border-[#75aafc] outline-none resize-none"
+ />
+ <input
+ type="text"
+ placeholder="Repository URL (optional)"
+ value={createRepoUrl}
+ onChange={(e) => setCreateRepoUrl(e.target.value)}
+ className="w-full px-2 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0a1628] border border-[rgba(117,170,252,0.3)] focus:border-[#75aafc] outline-none"
+ />
+ <div className="flex gap-2">
+ <button
+ onClick={handleCreate}
+ disabled={!createTitle.trim() || !createGoal.trim()}
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ Create
+ </button>
+ <button
+ onClick={() => setShowCreateForm(false)}
+ className="px-3 py-1.5 font-mono text-xs text-[#7788aa] hover:text-[#dbe7ff] transition-colors uppercase"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ <DirectiveList
+ directives={directives}
+ loading={loading}
+ onSelect={handleSelect}
+ onCreate={() => setShowCreateForm(true)}
+ onDelete={(d) => handleDelete(d.id)}
+ selectedId={id}
+ />
+ </main>
+ );
+}
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs
index ee5895c..308d689 100644
--- a/makima/src/bin/makima.rs
+++ b/makima/src/bin/makima.rs
@@ -7,7 +7,7 @@ use std::sync::Arc;
use makima::daemon::api::{ApiClient, CreateContractRequest};
use makima::daemon::cli::{
Cli, CliConfig, Commands, ConfigCommand, ContractCommand,
- SupervisorCommand, ViewArgs,
+ DirectiveCommand, SupervisorCommand, ViewArgs,
};
use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion};
use makima::daemon::config::{DaemonConfig, RepoEntry};
@@ -29,6 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Commands::Daemon(args) => run_daemon(args).await,
Commands::Supervisor(cmd) => run_supervisor(cmd).await,
Commands::Contract(cmd) => run_contract(cmd).await,
+ Commands::Directive(cmd) => run_directive(cmd).await,
Commands::View(args) => run_view(args).await,
Commands::Config(cmd) => run_config(cmd).await,
}
@@ -711,6 +712,67 @@ async fn run_contract(
Ok(())
}
+/// Run a directive subcommand.
+async fn run_directive(
+ cmd: DirectiveCommand,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ match cmd {
+ DirectiveCommand::Status(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let result = client.directive_status(args.directive_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ DirectiveCommand::Goals(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let result = client.directive_status(args.directive_id).await?;
+ // Extract goal-related fields from directive
+ let directive = &result.0;
+ let goals = serde_json::json!({
+ "goal": directive.get("goal"),
+ "requirements": directive.get("requirements"),
+ "acceptanceCriteria": directive.get("acceptanceCriteria"),
+ "constraints": directive.get("constraints"),
+ "externalDependencies": directive.get("externalDependencies"),
+ });
+ println!("{}", serde_json::to_string(&goals)?);
+ }
+ DirectiveCommand::Chains(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let result = client.directive_chains(args.directive_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ DirectiveCommand::Chain(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client
+ .directive_chain(args.common.directive_id, args.chain_id)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ DirectiveCommand::Steps(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client
+ .directive_chain(args.common.directive_id, args.chain_id)
+ .await?;
+ // Extract steps from chain response
+ let steps = result.0.get("steps").cloned().unwrap_or(serde_json::json!([]));
+ println!("{}", serde_json::to_string(&steps)?);
+ }
+ DirectiveCommand::UpdateStatus(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let req = makima::daemon::api::directive::UpdateDirectiveRequest {
+ status: Some(args.status),
+ version: None,
+ };
+ let result = client
+ .directive_update(args.common.directive_id, req)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ }
+
+ Ok(())
+}
+
/// Run the TUI view command.
async fn run_view(args: ViewArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Load CLI config for defaults
diff --git a/makima/src/daemon/api/directive.rs b/makima/src/daemon/api/directive.rs
new file mode 100644
index 0000000..0c8115a
--- /dev/null
+++ b/makima/src/daemon/api/directive.rs
@@ -0,0 +1,54 @@
+//! Directive API methods.
+
+use serde::Serialize;
+use uuid::Uuid;
+
+use super::client::{ApiClient, ApiError};
+use super::supervisor::JsonValue;
+
+/// Request to update a directive.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateDirectiveRequest {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub status: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub version: Option<i32>,
+}
+
+impl ApiClient {
+ /// Get directive status and details.
+ pub async fn directive_status(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/directives/{}", directive_id))
+ .await
+ }
+
+ /// List chains for a directive.
+ pub async fn directive_chains(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/directives/{}/chains", directive_id))
+ .await
+ }
+
+ /// Get a chain with its steps.
+ pub async fn directive_chain(
+ &self,
+ directive_id: Uuid,
+ chain_id: Uuid,
+ ) -> Result<JsonValue, ApiError> {
+ self.get(&format!(
+ "/api/v1/directives/{}/chains/{}",
+ directive_id, chain_id
+ ))
+ .await
+ }
+
+ /// Update a directive.
+ pub async fn directive_update(
+ &self,
+ directive_id: Uuid,
+ req: UpdateDirectiveRequest,
+ ) -> Result<JsonValue, ApiError> {
+ self.put(&format!("/api/v1/directives/{}", directive_id), &req)
+ .await
+ }
+}
diff --git a/makima/src/daemon/api/mod.rs b/makima/src/daemon/api/mod.rs
index 49d80e0..2d1efbf 100644
--- a/makima/src/daemon/api/mod.rs
+++ b/makima/src/daemon/api/mod.rs
@@ -2,6 +2,7 @@
pub mod client;
pub mod contract;
+pub mod directive;
pub mod supervisor;
pub use client::ApiClient;
diff --git a/makima/src/daemon/cli/directive.rs b/makima/src/daemon/cli/directive.rs
new file mode 100644
index 0000000..5ce88c5
--- /dev/null
+++ b/makima/src/daemon/cli/directive.rs
@@ -0,0 +1,40 @@
+//! Directive subcommand - directive orchestration commands.
+
+use clap::Args;
+use uuid::Uuid;
+
+/// Common arguments for directive commands.
+#[derive(Args, Debug, Clone)]
+pub struct DirectiveArgs {
+ /// API URL
+ #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp", global = true)]
+ pub api_url: String,
+
+ /// API key for authentication
+ #[arg(long, env = "MAKIMA_API_KEY", global = true)]
+ pub api_key: String,
+
+ /// Directive ID
+ #[arg(long, env = "MAKIMA_DIRECTIVE_ID", global = true)]
+ pub directive_id: Uuid,
+}
+
+/// Arguments for chain command (get specific chain).
+#[derive(Args, Debug)]
+pub struct ChainArgs {
+ #[command(flatten)]
+ pub common: DirectiveArgs,
+
+ /// Chain ID to retrieve
+ pub chain_id: Uuid,
+}
+
+/// Arguments for update-status command.
+#[derive(Args, Debug)]
+pub struct UpdateStatusArgs {
+ #[command(flatten)]
+ pub common: DirectiveArgs,
+
+ /// New status (draft, planning, active, paused, completed, archived, failed)
+ pub status: String,
+}
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs
index 0805edd..9fba216 100644
--- a/makima/src/daemon/cli/mod.rs
+++ b/makima/src/daemon/cli/mod.rs
@@ -3,6 +3,7 @@
pub mod config;
pub mod contract;
pub mod daemon;
+pub mod directive;
pub mod server;
pub mod supervisor;
pub mod view;
@@ -12,6 +13,7 @@ use clap::{Parser, Subcommand};
pub use config::CliConfig;
pub use contract::ContractArgs;
pub use daemon::DaemonArgs;
+pub use directive::DirectiveArgs;
pub use server::ServerArgs;
pub use supervisor::SupervisorArgs;
pub use view::ViewArgs;
@@ -41,6 +43,10 @@ pub enum Commands {
#[command(subcommand)]
Contract(ContractCommand),
+ /// Directive commands for autonomous goal-driven execution
+ #[command(subcommand)]
+ Directive(DirectiveCommand),
+
/// Interactive TUI browser for contracts and tasks
///
/// Provides a drill-down interface for browsing contracts, viewing their
@@ -196,6 +202,28 @@ pub enum ContractCommand {
CreateFile(contract::CreateFileArgs),
}
+/// Directive subcommands for autonomous goal-driven execution.
+#[derive(Subcommand, Debug)]
+pub enum DirectiveCommand {
+ /// Get directive status and details
+ Status(DirectiveArgs),
+
+ /// Get goal, requirements, acceptance criteria
+ Goals(DirectiveArgs),
+
+ /// List chains for the directive
+ Chains(DirectiveArgs),
+
+ /// Get a chain with its steps
+ Chain(directive::ChainArgs),
+
+ /// List steps in a chain
+ Steps(directive::ChainArgs),
+
+ /// Update directive status
+ UpdateStatus(directive::UpdateStatusArgs),
+}
+
impl Cli {
/// Parse command-line arguments
pub fn parse_args() -> Self {
diff --git a/makima/src/daemon/skills/directive.md b/makima/src/daemon/skills/directive.md
new file mode 100644
index 0000000..cdfdaa2
--- /dev/null
+++ b/makima/src/daemon/skills/directive.md
@@ -0,0 +1,68 @@
+---
+name: makima-directive
+description: Directive orchestration tools for autonomous goal-driven execution. Use when working with directives, chains, steps, verifiers, and approvals.
+---
+
+# Makima Directive Commands
+
+These commands let orchestrators interact with directive state. Environment variables (`MAKIMA_API_URL`, `MAKIMA_API_KEY`, `MAKIMA_DIRECTIVE_ID`) are pre-configured by the daemon.
+
+## Status and Information
+
+### Get directive status
+```bash
+makima directive status
+```
+Returns full directive details including status, autonomy level, thresholds, and tracking info.
+
+### Get directive goals
+```bash
+makima directive goals
+```
+Returns the goal, requirements, acceptance criteria, constraints, and external dependencies.
+
+### List chains
+```bash
+makima directive chains
+```
+Returns all chains (plan generations) for the directive, ordered by generation.
+
+### Get chain with steps
+```bash
+makima directive chain <chain_id>
+```
+Returns a chain and all its steps with status, dependencies, and evaluation info.
+
+### List steps in a chain
+```bash
+makima directive steps <chain_id>
+```
+Returns just the steps array from a chain.
+
+## Status Updates
+
+### Update directive status
+```bash
+makima directive update-status <status>
+```
+Updates the directive status. Valid statuses: `draft`, `planning`, `active`, `paused`, `completed`, `archived`, `failed`.
+
+## Output Format
+
+All commands output JSON to stdout.
+
+Example workflow:
+```bash
+# Check directive details and goals
+makima directive status
+makima directive goals
+
+# List execution chains
+makima directive chains
+
+# Get details of a specific chain
+makima directive chain <chain_id>
+
+# Update status to active
+makima directive update-status active
+```
diff --git a/makima/src/daemon/skills/mod.rs b/makima/src/daemon/skills/mod.rs
index 0b05f3a..6e5d0a8 100644
--- a/makima/src/daemon/skills/mod.rs
+++ b/makima/src/daemon/skills/mod.rs
@@ -9,8 +9,12 @@ pub const SUPERVISOR_SKILL: &str = include_str!("supervisor.md");
/// Contract skill content - task-contract interaction commands
pub const CONTRACT_SKILL: &str = include_str!("contract.md");
+/// Directive skill content - directive orchestration commands
+pub const DIRECTIVE_SKILL: &str = include_str!("directive.md");
+
/// All skills as (name, content) pairs for installation
pub const ALL_SKILLS: &[(&str, &str)] = &[
("makima-supervisor", SUPERVISOR_SKILL),
("makima-contract", CONTRACT_SKILL),
+ ("makima-directive", DIRECTIVE_SKILL),
];
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 3b10cb5..ec4ee15 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -2690,3 +2690,237 @@ mod tests {
assert_eq!(deserialized.progress, 50);
}
}
+
+// =============================================================================
+// Directive Types
+// =============================================================================
+
+/// Default autonomy level for directives
+fn default_autonomy_level() -> String {
+ "guardrails".to_string()
+}
+
+/// Default empty JSON array
+fn default_json_array() -> serde_json::Value {
+ serde_json::json!([])
+}
+
+/// Default empty JSON object
+fn default_json_object() -> serde_json::Value {
+ serde_json::json!({})
+}
+
+/// Default confidence threshold (green)
+fn default_confidence_green() -> f64 {
+ 0.85
+}
+
+/// Default confidence threshold (yellow)
+fn default_confidence_yellow() -> f64 {
+ 0.60
+}
+
+/// Default max rework cycles
+fn default_max_rework_cycles() -> Option<i32> {
+ Some(3)
+}
+
+/// Default max chain regenerations
+fn default_max_chain_regenerations() -> Option<i32> {
+ Some(2)
+}
+
+/// Full directive row from the database.
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct Directive {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub title: String,
+ pub goal: String,
+ #[sqlx(json)]
+ pub requirements: serde_json::Value,
+ #[sqlx(json)]
+ pub acceptance_criteria: serde_json::Value,
+ #[sqlx(json)]
+ pub constraints: serde_json::Value,
+ #[sqlx(json)]
+ pub external_dependencies: serde_json::Value,
+ pub status: String,
+ pub autonomy_level: String,
+ pub confidence_threshold_green: f64,
+ pub confidence_threshold_yellow: f64,
+ pub max_total_cost_usd: Option<f64>,
+ pub max_wall_time_minutes: Option<i32>,
+ pub max_rework_cycles: Option<i32>,
+ pub max_chain_regenerations: Option<i32>,
+ pub repository_url: Option<String>,
+ pub local_path: Option<String>,
+ pub base_branch: Option<String>,
+ pub orchestrator_contract_id: Option<Uuid>,
+ pub current_chain_id: Option<Uuid>,
+ pub chain_generation_count: i32,
+ pub total_cost_usd: f64,
+ pub started_at: Option<DateTime<Utc>>,
+ pub completed_at: Option<DateTime<Utc>>,
+ pub version: i32,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Summary of a directive for list views.
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DirectiveSummary {
+ pub id: Uuid,
+ pub title: String,
+ pub goal: String,
+ pub status: String,
+ pub autonomy_level: String,
+ pub chain_count: i64,
+ pub step_count: i64,
+ pub total_cost_usd: f64,
+ pub version: i32,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Response for directive list endpoint.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DirectiveListResponse {
+ pub directives: Vec<DirectiveSummary>,
+ pub total: i64,
+}
+
+/// Request to create a new directive.
+#[derive(Debug, Clone, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateDirectiveRequest {
+ pub title: String,
+ pub goal: String,
+ #[serde(default = "default_json_array")]
+ pub requirements: serde_json::Value,
+ #[serde(default = "default_json_array")]
+ pub acceptance_criteria: serde_json::Value,
+ #[serde(default = "default_json_array")]
+ pub constraints: serde_json::Value,
+ #[serde(default = "default_json_array")]
+ pub external_dependencies: serde_json::Value,
+ #[serde(default = "default_autonomy_level")]
+ pub autonomy_level: String,
+ #[serde(default = "default_confidence_green")]
+ pub confidence_threshold_green: f64,
+ #[serde(default = "default_confidence_yellow")]
+ pub confidence_threshold_yellow: f64,
+ pub max_total_cost_usd: Option<f64>,
+ pub max_wall_time_minutes: Option<i32>,
+ #[serde(default = "default_max_rework_cycles")]
+ pub max_rework_cycles: Option<i32>,
+ #[serde(default = "default_max_chain_regenerations")]
+ pub max_chain_regenerations: Option<i32>,
+ pub repository_url: Option<String>,
+ pub local_path: Option<String>,
+ pub base_branch: Option<String>,
+}
+
+/// Request to update an existing directive.
+#[derive(Debug, Clone, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateDirectiveRequest {
+ pub title: Option<String>,
+ pub goal: Option<String>,
+ pub requirements: Option<serde_json::Value>,
+ pub acceptance_criteria: Option<serde_json::Value>,
+ pub constraints: Option<serde_json::Value>,
+ pub external_dependencies: Option<serde_json::Value>,
+ pub status: Option<String>,
+ pub autonomy_level: Option<String>,
+ pub confidence_threshold_green: Option<f64>,
+ pub confidence_threshold_yellow: Option<f64>,
+ pub max_total_cost_usd: Option<f64>,
+ pub max_wall_time_minutes: Option<i32>,
+ pub max_rework_cycles: Option<i32>,
+ pub max_chain_regenerations: Option<i32>,
+ pub repository_url: Option<String>,
+ pub local_path: Option<String>,
+ pub base_branch: Option<String>,
+ /// Version for optimistic locking
+ pub version: Option<i32>,
+}
+
+/// Directive with its chains for detail view.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DirectiveWithChains {
+ #[serde(flatten)]
+ pub directive: Directive,
+ pub chains: Vec<DirectiveChain>,
+}
+
+/// Full row from directive_chains table.
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DirectiveChain {
+ pub id: Uuid,
+ pub directive_id: Uuid,
+ pub generation: i32,
+ pub name: String,
+ pub description: Option<String>,
+ pub rationale: Option<String>,
+ pub planning_model: Option<String>,
+ pub status: String,
+ pub total_steps: i32,
+ pub completed_steps: i32,
+ pub failed_steps: i32,
+ pub current_confidence: Option<f64>,
+ pub started_at: Option<DateTime<Utc>>,
+ pub completed_at: Option<DateTime<Utc>>,
+ pub version: i32,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Full row from chain_steps table.
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainStep {
+ pub id: Uuid,
+ pub chain_id: Uuid,
+ pub name: String,
+ pub description: Option<String>,
+ pub step_type: String,
+ pub contract_type: String,
+ pub initial_phase: Option<String>,
+ pub task_plan: Option<String>,
+ pub phases: Option<Vec<String>>,
+ pub depends_on: Option<Vec<Uuid>>,
+ pub parallel_group: Option<String>,
+ pub requirement_ids: Option<Vec<String>>,
+ pub acceptance_criteria_ids: Option<Vec<String>>,
+ #[sqlx(json)]
+ pub verifier_config: serde_json::Value,
+ pub status: String,
+ pub contract_id: Option<Uuid>,
+ pub supervisor_task_id: Option<Uuid>,
+ pub confidence_score: Option<f64>,
+ pub confidence_level: Option<String>,
+ pub evaluation_count: i32,
+ pub rework_count: i32,
+ pub last_evaluation_id: Option<Uuid>,
+ pub editor_x: Option<f64>,
+ pub editor_y: Option<f64>,
+ pub order_index: i32,
+ pub started_at: Option<DateTime<Utc>>,
+ pub completed_at: Option<DateTime<Utc>>,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Chain with its steps for detail view.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChainWithSteps {
+ #[serde(flatten)]
+ pub chain: DirectiveChain,
+ pub steps: Vec<ChainStep>,
+}
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 863d927..5949079 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -6,16 +6,17 @@ use sqlx::PgPool;
use uuid::Uuid;
use super::models::{
- CheckpointPatch, CheckpointPatchInfo, Contract, ContractChatConversation,
+ ChainStep, CheckpointPatch, CheckpointPatchInfo, Contract, ContractChatConversation,
ContractChatMessageRecord, ContractEvent, ContractRepository, ContractSummary,
ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot,
- CreateContractRequest, CreateFileRequest, CreateTaskRequest, CreateTemplateRequest,
- Daemon, DaemonTaskAssignment, DaemonWithCapacity, DeliverableDefinition,
+ CreateContractRequest, CreateDirectiveRequest, CreateFileRequest, CreateTaskRequest,
+ CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity,
+ DeliverableDefinition, Directive, DirectiveChain, DirectiveSummary,
File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters,
MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig,
PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint,
- TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest,
- UpdateTemplateRequest,
+ TaskEvent, TaskSummary, UpdateContractRequest, UpdateDirectiveRequest,
+ UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest,
};
/// Repository error types.
@@ -4910,3 +4911,275 @@ fn truncate_string(s: &str, max_len: usize) -> String {
format!("{}...", &s[..max_len - 3])
}
}
+
+// =============================================================================
+// Directive CRUD
+// =============================================================================
+
+/// Create a new directive, scoped to owner.
+pub async fn create_directive_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+ req: CreateDirectiveRequest,
+) -> Result<Directive, sqlx::Error> {
+ sqlx::query_as::<_, Directive>(
+ r#"
+ INSERT INTO directives (
+ owner_id, title, goal,
+ requirements, acceptance_criteria, constraints, external_dependencies,
+ autonomy_level, confidence_threshold_green, confidence_threshold_yellow,
+ max_total_cost_usd, max_wall_time_minutes, max_rework_cycles, max_chain_regenerations,
+ repository_url, local_path, base_branch
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
+ RETURNING *
+ "#,
+ )
+ .bind(owner_id)
+ .bind(&req.title)
+ .bind(&req.goal)
+ .bind(&req.requirements)
+ .bind(&req.acceptance_criteria)
+ .bind(&req.constraints)
+ .bind(&req.external_dependencies)
+ .bind(&req.autonomy_level)
+ .bind(req.confidence_threshold_green)
+ .bind(req.confidence_threshold_yellow)
+ .bind(req.max_total_cost_usd)
+ .bind(req.max_wall_time_minutes)
+ .bind(req.max_rework_cycles)
+ .bind(req.max_chain_regenerations)
+ .bind(&req.repository_url)
+ .bind(&req.local_path)
+ .bind(&req.base_branch)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get a directive by ID, scoped to owner.
+pub async fn get_directive_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+) -> Result<Option<Directive>, sqlx::Error> {
+ sqlx::query_as::<_, Directive>(
+ r#"
+ SELECT *
+ FROM directives
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// List all directives for an owner, ordered by created_at DESC.
+pub async fn list_directives_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+) -> Result<Vec<DirectiveSummary>, sqlx::Error> {
+ sqlx::query_as::<_, DirectiveSummary>(
+ r#"
+ SELECT
+ d.id, d.title, d.goal, d.status, d.autonomy_level,
+ (SELECT COUNT(*) FROM directive_chains WHERE directive_id = d.id) as chain_count,
+ (SELECT COUNT(*) FROM chain_steps cs JOIN directive_chains dc ON cs.chain_id = dc.id WHERE dc.directive_id = d.id) as step_count,
+ d.total_cost_usd, d.version, d.created_at, d.updated_at
+ FROM directives d
+ WHERE d.owner_id = $1
+ ORDER BY d.created_at DESC
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Update a directive by ID with optimistic locking, scoped to owner.
+pub async fn update_directive_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+ req: UpdateDirectiveRequest,
+) -> Result<Option<Directive>, RepositoryError> {
+ let existing = get_directive_for_owner(pool, id, owner_id).await?;
+ let Some(existing) = existing else {
+ return Ok(None);
+ };
+
+ // Check version if provided (optimistic locking)
+ if let Some(expected_version) = req.version {
+ if existing.version != expected_version {
+ return Err(RepositoryError::VersionConflict {
+ expected: expected_version,
+ actual: existing.version,
+ });
+ }
+ }
+
+ // Apply updates
+ let title = req.title.unwrap_or(existing.title);
+ let goal = req.goal.unwrap_or(existing.goal);
+ let requirements = req.requirements.unwrap_or(existing.requirements);
+ let acceptance_criteria = req.acceptance_criteria.unwrap_or(existing.acceptance_criteria);
+ let constraints = req.constraints.unwrap_or(existing.constraints);
+ let external_dependencies = req.external_dependencies.unwrap_or(existing.external_dependencies);
+ let status = req.status.unwrap_or(existing.status);
+ let autonomy_level = req.autonomy_level.unwrap_or(existing.autonomy_level);
+ let confidence_threshold_green = req.confidence_threshold_green.unwrap_or(existing.confidence_threshold_green);
+ let confidence_threshold_yellow = req.confidence_threshold_yellow.unwrap_or(existing.confidence_threshold_yellow);
+ let max_total_cost_usd = req.max_total_cost_usd.or(existing.max_total_cost_usd);
+ let max_wall_time_minutes = req.max_wall_time_minutes.or(existing.max_wall_time_minutes);
+ let max_rework_cycles = req.max_rework_cycles.or(existing.max_rework_cycles);
+ let max_chain_regenerations = req.max_chain_regenerations.or(existing.max_chain_regenerations);
+ let repository_url = req.repository_url.or(existing.repository_url);
+ let local_path = req.local_path.or(existing.local_path);
+ let base_branch = req.base_branch.or(existing.base_branch);
+
+ let result = if req.version.is_some() {
+ sqlx::query_as::<_, Directive>(
+ r#"
+ UPDATE directives
+ SET title = $3, goal = $4,
+ requirements = $5, acceptance_criteria = $6, constraints = $7, external_dependencies = $8,
+ status = $9, autonomy_level = $10,
+ confidence_threshold_green = $11, confidence_threshold_yellow = $12,
+ max_total_cost_usd = $13, max_wall_time_minutes = $14,
+ max_rework_cycles = $15, max_chain_regenerations = $16,
+ repository_url = $17, local_path = $18, base_branch = $19,
+ version = version + 1, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2 AND version = $20
+ RETURNING *
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .bind(&title)
+ .bind(&goal)
+ .bind(&requirements)
+ .bind(&acceptance_criteria)
+ .bind(&constraints)
+ .bind(&external_dependencies)
+ .bind(&status)
+ .bind(&autonomy_level)
+ .bind(confidence_threshold_green)
+ .bind(confidence_threshold_yellow)
+ .bind(max_total_cost_usd)
+ .bind(max_wall_time_minutes)
+ .bind(max_rework_cycles)
+ .bind(max_chain_regenerations)
+ .bind(&repository_url)
+ .bind(&local_path)
+ .bind(&base_branch)
+ .bind(req.version.unwrap())
+ .fetch_optional(pool)
+ .await?
+ } else {
+ sqlx::query_as::<_, Directive>(
+ r#"
+ UPDATE directives
+ SET title = $3, goal = $4,
+ requirements = $5, acceptance_criteria = $6, constraints = $7, external_dependencies = $8,
+ status = $9, autonomy_level = $10,
+ confidence_threshold_green = $11, confidence_threshold_yellow = $12,
+ max_total_cost_usd = $13, max_wall_time_minutes = $14,
+ max_rework_cycles = $15, max_chain_regenerations = $16,
+ repository_url = $17, local_path = $18, base_branch = $19,
+ version = version + 1, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .bind(&title)
+ .bind(&goal)
+ .bind(&requirements)
+ .bind(&acceptance_criteria)
+ .bind(&constraints)
+ .bind(&external_dependencies)
+ .bind(&status)
+ .bind(&autonomy_level)
+ .bind(confidence_threshold_green)
+ .bind(confidence_threshold_yellow)
+ .bind(max_total_cost_usd)
+ .bind(max_wall_time_minutes)
+ .bind(max_rework_cycles)
+ .bind(max_chain_regenerations)
+ .bind(&repository_url)
+ .bind(&local_path)
+ .bind(&base_branch)
+ .fetch_optional(pool)
+ .await?
+ };
+
+ // If versioned update returned None, there was a race condition
+ if result.is_none() && req.version.is_some() {
+ if let Some(current) = get_directive_for_owner(pool, id, owner_id).await? {
+ return Err(RepositoryError::VersionConflict {
+ expected: req.version.unwrap(),
+ actual: current.version,
+ });
+ }
+ }
+
+ Ok(result)
+}
+
+/// Delete a directive by ID, scoped to owner.
+pub async fn delete_directive_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM directives
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// List chains for a directive (read-only).
+pub async fn list_chains_for_directive(
+ pool: &PgPool,
+ directive_id: Uuid,
+) -> Result<Vec<DirectiveChain>, sqlx::Error> {
+ sqlx::query_as::<_, DirectiveChain>(
+ r#"
+ SELECT *
+ FROM directive_chains
+ WHERE directive_id = $1
+ ORDER BY generation DESC, created_at DESC
+ "#,
+ )
+ .bind(directive_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// List steps for a chain (read-only).
+pub async fn list_steps_for_chain(
+ pool: &PgPool,
+ chain_id: Uuid,
+) -> Result<Vec<ChainStep>, sqlx::Error> {
+ sqlx::query_as::<_, ChainStep>(
+ r#"
+ SELECT *
+ FROM chain_steps
+ WHERE chain_id = $1
+ ORDER BY order_index ASC, created_at ASC
+ "#,
+ )
+ .bind(chain_id)
+ .fetch_all(pool)
+ .await
+}
diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs
new file mode 100644
index 0000000..a74f8ff
--- /dev/null
+++ b/makima/src/server/handlers/directives.rs
@@ -0,0 +1,440 @@
+//! HTTP handlers for directive CRUD operations.
+
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use uuid::Uuid;
+
+use crate::db::models::{
+ ChainWithSteps, CreateDirectiveRequest, Directive, DirectiveChain,
+ DirectiveListResponse, DirectiveWithChains, UpdateDirectiveRequest,
+};
+use crate::db::repository::{self, RepositoryError};
+use crate::server::auth::Authenticated;
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+/// List all directives for the authenticated user's owner.
+#[utoipa::path(
+ get,
+ path = "/api/v1/directives",
+ responses(
+ (status = 200, description = "List of directives", body = DirectiveListResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Directives"
+)]
+pub async fn list_directives(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::list_directives_for_owner(pool, auth.owner_id).await {
+ Ok(directives) => {
+ let total = directives.len() as i64;
+ Json(DirectiveListResponse { directives, total }).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list directives: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get a directive by ID with its chains.
+#[utoipa::path(
+ get,
+ path = "/api/v1/directives/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Directive ID")
+ ),
+ responses(
+ (status = 200, description = "Directive details with chains", body = DirectiveWithChains),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Directive not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Directives"
+)]
+pub async fn get_directive(
+ 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();
+ };
+
+ let directive = match repository::get_directive_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(d)) => d,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Directive not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get directive {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ let chains = match repository::list_chains_for_directive(pool, id).await {
+ Ok(c) => c,
+ Err(e) => {
+ tracing::warn!("Failed to get chains for directive {}: {}", id, e);
+ Vec::new()
+ }
+ };
+
+ Json(DirectiveWithChains { directive, chains }).into_response()
+}
+
+/// Create a new directive.
+#[utoipa::path(
+ post,
+ path = "/api/v1/directives",
+ request_body = CreateDirectiveRequest,
+ responses(
+ (status = 201, description = "Directive created", body = Directive),
+ (status = 400, description = "Invalid request", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Directives"
+)]
+pub async fn create_directive(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Json(req): Json<CreateDirectiveRequest>,
+) -> 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_directive_for_owner(pool, auth.owner_id, req).await {
+ Ok(directive) => (StatusCode::CREATED, Json(directive)).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to create directive: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update an existing directive.
+#[utoipa::path(
+ put,
+ path = "/api/v1/directives/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Directive ID")
+ ),
+ request_body = UpdateDirectiveRequest,
+ responses(
+ (status = 200, description = "Directive updated", body = Directive),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Directive not found", body = ApiError),
+ (status = 409, description = "Version conflict", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Directives"
+)]
+pub async fn update_directive(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<UpdateDirectiveRequest>,
+) -> 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_directive_for_owner(pool, id, auth.owner_id, req).await {
+ Ok(Some(directive)) => Json(directive).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Directive not found")),
+ )
+ .into_response(),
+ Err(RepositoryError::VersionConflict { expected, actual }) => (
+ StatusCode::CONFLICT,
+ Json(ApiError::new(
+ "VERSION_CONFLICT",
+ format!(
+ "Version conflict: expected {}, actual {}",
+ expected, actual
+ ),
+ )),
+ )
+ .into_response(),
+ Err(RepositoryError::Database(e)) => {
+ tracing::error!("Failed to update directive {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Delete a directive.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/directives/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Directive ID")
+ ),
+ responses(
+ (status = 204, description = "Directive deleted"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Directive not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Directives"
+)]
+pub async fn delete_directive(
+ 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_directive_for_owner(pool, id, auth.owner_id).await {
+ Ok(true) => StatusCode::NO_CONTENT.into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Directive not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to delete directive {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// List chains for a directive.
+#[utoipa::path(
+ get,
+ path = "/api/v1/directives/{id}/chains",
+ params(
+ ("id" = Uuid, Path, description = "Directive ID")
+ ),
+ responses(
+ (status = 200, description = "List of chains", body = Vec<DirectiveChain>),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Directive not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Directives"
+)]
+pub async fn list_chains(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify directive exists and belongs to owner
+ match repository::get_directive_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Directive not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get directive {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::list_chains_for_directive(pool, id).await {
+ Ok(chains) => Json(chains).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to list chains for directive {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get a chain with its steps.
+#[utoipa::path(
+ get,
+ path = "/api/v1/directives/{id}/chains/{chain_id}",
+ params(
+ ("id" = Uuid, Path, description = "Directive ID"),
+ ("chain_id" = Uuid, Path, description = "Chain ID")
+ ),
+ responses(
+ (status = 200, description = "Chain with steps", body = ChainWithSteps),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Directives"
+)]
+pub async fn get_chain(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path((id, chain_id)): Path<(Uuid, Uuid)>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify directive exists and belongs to owner
+ match repository::get_directive_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Directive not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get directive {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ // Get the chain and verify it belongs to this directive
+ let chains = match repository::list_chains_for_directive(pool, id).await {
+ Ok(c) => c,
+ Err(e) => {
+ tracing::error!("Failed to list chains: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ let chain = match chains.into_iter().find(|c| c.id == chain_id) {
+ Some(c) => c,
+ None => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response();
+ }
+ };
+
+ let steps = match repository::list_steps_for_chain(pool, chain_id).await {
+ Ok(s) => s,
+ Err(e) => {
+ tracing::warn!("Failed to get steps for chain {}: {}", chain_id, e);
+ Vec::new()
+ }
+ };
+
+ Json(ChainWithSteps { chain, steps }).into_response()
+}
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index ae370c9..29cd09f 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -6,6 +6,7 @@ pub mod contract_chat;
pub mod contract_daemon;
pub mod contract_discuss;
pub mod contracts;
+pub mod directives;
pub mod file_ws;
pub mod files;
pub mod history;
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index b7a4156..a429612 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, 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, repository_history, speak, templates, transcript_analysis, users, versions};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -170,6 +170,19 @@ pub fn make_router(state: SharedState) -> Router {
"/contracts/{id}/chat/history",
get(contract_chat::get_contract_chat_history).delete(contract_chat::clear_contract_chat_history),
)
+ // Directive endpoints
+ .route(
+ "/directives",
+ get(directives::list_directives).post(directives::create_directive),
+ )
+ .route(
+ "/directives/{id}",
+ get(directives::get_directive)
+ .put(directives::update_directive)
+ .delete(directives::delete_directive),
+ )
+ .route("/directives/{id}/chains", get(directives::list_chains))
+ .route("/directives/{id}/chains/{chain_id}", get(directives::get_chain))
// Contract supervisor resume endpoints
.route("/contracts/{id}/supervisor/resume", post(mesh_supervisor::resume_supervisor))
.route("/contracts/{id}/supervisor/conversation/rewind", post(mesh_supervisor::rewind_conversation))
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index a70342b..0e6912a 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -4,23 +4,25 @@ use utoipa::OpenApi;
use crate::db::models::{
AddLocalRepositoryRequest, AddRemoteRepositoryRequest, BranchInfo, BranchListResponse,
- BranchTaskRequest, BranchTaskResponse, ChangePhaseRequest, Contract,
- ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, ContractListResponse,
- ContractRepository, ContractSummary, ContractWithRelations, CreateContractRequest,
- CreateFileRequest, CreateManagedRepositoryRequest, CreateTaskRequest, Daemon,
- DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, File, FileListResponse,
- FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest,
- MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse,
- MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, RepositoryHistoryEntry,
+ BranchTaskRequest, BranchTaskResponse, ChainStep, ChainWithSteps, ChangePhaseRequest,
+ Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent,
+ ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations,
+ CreateContractRequest, CreateDirectiveRequest, CreateFileRequest,
+ CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse,
+ DaemonDirectory, DaemonListResponse, Directive, DirectiveChain, DirectiveListResponse,
+ DirectiveSummary, DirectiveWithChains, File, FileListResponse, FileSummary,
+ MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse,
+ MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation,
+ MeshChatHistoryResponse, MeshChatMessageRecord, RepositoryHistoryEntry,
RepositoryHistoryListResponse, RepositorySuggestionsQuery, SendMessageRequest, Task,
TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry,
- UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest,
+ UpdateContractRequest, UpdateDirectiveRequest, UpdateFileRequest, UpdateTaskRequest,
};
use crate::server::auth::{
ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse,
RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse,
};
-use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, 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, repository_history, users};
use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage};
#[derive(OpenApi)]
@@ -103,6 +105,14 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
repository_history::list_repository_history,
repository_history::get_repository_suggestions,
repository_history::delete_repository_history,
+ // Directive endpoints
+ directives::list_directives,
+ directives::get_directive,
+ directives::create_directive,
+ directives::update_directive,
+ directives::delete_directive,
+ directives::list_chains,
+ directives::get_chain,
),
components(
schemas(
@@ -187,6 +197,16 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
RepositoryHistoryEntry,
RepositoryHistoryListResponse,
RepositorySuggestionsQuery,
+ // Directive schemas
+ Directive,
+ DirectiveSummary,
+ DirectiveListResponse,
+ DirectiveWithChains,
+ DirectiveChain,
+ ChainStep,
+ ChainWithSteps,
+ CreateDirectiveRequest,
+ UpdateDirectiveRequest,
)
),
tags(
@@ -197,6 +217,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 = "Settings", description = "User settings including repository history"),
+ (name = "Directives", description = "Directive management for autonomous goal-driven execution"),
)
)]
pub struct ApiDoc;