summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-08 16:33:36 +0100
committersoryu <soryu@soryu.co>2026-05-08 16:33:36 +0100
commit7af816032fbc54d5e0a8e94d4a000f307cd3b370 (patch)
tree50b6aad1aa47e56b61f0700e224028bb7578cb91
parente4f1622a0f0ac74707cc1c9810e0b99e948d1319 (diff)
downloadsoryu-drop-directive-goal.tar.gz
soryu-drop-directive-goal.zip
feat(directives): drop directives.goal — orchestration reads contract bodydrop-directive-goal
Hard cut. The unified contracts surface owns spec text now; the directive itself is just a folder. The orchestrator daemon reads the active contract's body when it spawns, replans, or runs completion. Schema (migration 20260510000000): - DROP TABLE directive_goal_history - ALTER TABLE directives DROP COLUMN goal - ALTER TABLE directives DROP COLUMN goal_updated_at New repo helper: - get_active_contract_body(directive_id) — picks the active|queued|draft contract (in that order), most-recent first. Backend cuts: - Directive / DirectiveSummary / CreateDirectiveRequest / UpdateDirectiveRequest lose goal & goalUpdatedAt. - CreateDirectiveRequest gains optional `contractBody` — when provided, create_directive_for_owner auto-creates a first contract with that body in the same transaction. - Removed: update_directive_goal, update_directive_goal_keep_orchestrator, save_directive_goal_history, get_directive_goal_history, DirectiveGoalHistory model, UpdateGoalRequest. - Removed handlers::directives::update_goal + the /directives/{id}/goal route. - orchestration::directive::build_planning_prompt / build_completion_prompt / build_order_pickup_prompt now take a `contract_body: &str` instead of `goal_history`. classify_goal_change + try_interrupt_planner_with_goal_edit + GoalChangeKind + GoalEditInterruptResult removed (they were only useful for the small-vs-large goal-edit interrupt cycle). CLI: - `makima directive update-goal` removed (UpdateGoalArgs deleted, Commands enum trimmed, ApiClient::directive_update_goal + UpdateGoalRequest deleted). Frontend: - Directive / DirectiveSummary / CreateDirectiveRequest types lose goal & goalUpdatedAt; CreateDirectiveRequest gains `contractBody`. - useDirective drops updateGoal helper. - api.ts updateDirectiveGoal removed. - Legacy DirectiveList + DirectiveDetail components deleted; the /directives route now always renders the document-mode page. The user-settings documentModeEnabled flag is no longer consulted at the route level. - NewContractModal passes body via contractBody. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx603
-rw-r--r--makima/frontend/src/components/directives/DirectiveList.tsx140
-rw-r--r--makima/frontend/src/hooks/useDirectives.ts9
-rw-r--r--makima/frontend/src/lib/api.ts19
-rw-r--r--makima/frontend/src/routes/directives.tsx324
-rw-r--r--makima/frontend/src/routes/document-directives.tsx4
-rw-r--r--makima/migrations/20260510000000_drop_directive_goal.sql16
-rw-r--r--makima/src/bin/makima.rs7
-rw-r--r--makima/src/daemon/api/directive.rs16
-rw-r--r--makima/src/daemon/cli/directive.rs10
-rw-r--r--makima/src/daemon/cli/mod.rs3
-rw-r--r--makima/src/db/models.rs40
-rw-r--r--makima/src/db/repository.rs200
-rw-r--r--makima/src/orchestration/directive.rs365
-rw-r--r--makima/src/server/handlers/directives.rs203
-rw-r--r--makima/src/server/mod.rs1
-rw-r--r--makima/src/server/openapi.rs4
17 files changed, 198 insertions, 1766 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx
deleted file mode 100644
index 4931afa..0000000
--- a/makima/frontend/src/components/directives/DirectiveDetail.tsx
+++ /dev/null
@@ -1,603 +0,0 @@
-import { useState, useMemo, useEffect, useRef } from "react";
-import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest, DirectiveOrderGroup, CreateDOGRequest, UpdateDOGRequest } from "../../lib/api";
-import { DirectiveDAG } from "./DirectiveDAG";
-import type { SpecializedStep } from "./DirectiveDAG";
-import { DirectiveLogStream } from "./DirectiveLogStream";
-import { TaskSlideOutPanel } from "./TaskSlideOutPanel";
-import { DOGList } from "./DOGList";
-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" },
- active: { color: "text-green-400 border-green-800", label: "ACTIVE" },
- idle: { color: "text-yellow-400 border-yellow-800", label: "IDLE" },
- paused: { color: "text-orange-400 border-orange-800", label: "PAUSED" },
- inactive: { color: "text-[#9bc3ff] border-[#3f6fb3]", label: "INACTIVE" },
- archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" },
-};
-
-interface DirectiveDetailProps {
- directive: DirectiveWithSteps;
- onStart: () => void;
- onPause: () => void;
- onAdvance: () => void;
- onCompleteStep: (stepId: string) => void;
- onFailStep: (stepId: string) => void;
- onSkipStep: (stepId: string) => void;
- onUpdateGoal: (goal: string) => void;
- onUpdate: (req: UpdateDirectiveRequest) => void;
- onDelete: () => void;
- onRefresh: () => void;
- onCleanup: () => void;
- onPickUpOrders: () => Promise<{ message: string; orderCount: number; taskId: string | null } | null>;
- onCreatePR: () => Promise<void>;
- dogs: DirectiveOrderGroup[];
- dogsLoading: boolean;
- onCreateDog: (req: CreateDOGRequest) => Promise<DirectiveOrderGroup | null>;
- onUpdateDog: (dogId: string, req: UpdateDOGRequest) => Promise<void>;
- onDeleteDog: (dogId: string) => Promise<void>;
- onPickUpDogOrders: (dogId: string) => Promise<any>;
-}
-
-export function DirectiveDetail({
- directive,
- onStart,
- onPause,
- onAdvance,
- onCompleteStep,
- onFailStep,
- onSkipStep,
- onUpdateGoal,
- onUpdate,
- onDelete,
- onRefresh,
- onCleanup,
- onPickUpOrders,
- onCreatePR,
- dogs,
- dogsLoading,
- onCreateDog,
- onUpdateDog,
- onDeleteDog,
- onPickUpDogOrders,
-}: DirectiveDetailProps) {
- const [activeTab, setActiveTab] = useState<"steps" | "dogs">("steps");
- const [editingGoal, setEditingGoal] = useState(false);
- const [goalText, setGoalText] = useState(directive.goal);
- const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null);
- const [pickingUpOrders, setPickingUpOrders] = useState(false);
- const [pickUpResult, setPickUpResult] = useState<string | null>(null);
- const [creatingPR, setCreatingPR] = useState(false);
- const [slideOutTaskId, setSlideOutTaskId] = useState<string | null>(null);
-
- const handleViewTask = (taskId: string) => {
- setSlideOutTaskId(taskId);
- };
-
- // 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);
- const badge = STATUS_BADGE[directive.status] || STATUS_BADGE.draft;
-
- const completedSteps = directive.steps.filter((s) => s.status === "completed").length;
- const totalSteps = directive.steps.length;
- const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
- // 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(() => {
- const parts: string[] = [];
- if (directive.orchestratorTaskId) parts.push(`o:${directive.orchestratorTaskId}`);
- for (const step of directive.steps) {
- if (step.taskId) parts.push(`${step.id}:${step.taskId}`);
- }
- return parts.join(",");
- }, [directive.orchestratorTaskId, directive.steps]);
-
- const taskMap = useMemo(() => {
- const map = new Map<string, string>();
- if (directive.orchestratorTaskId) {
- map.set(directive.orchestratorTaskId, "Orchestrator");
- }
- for (const step of directive.steps) {
- if (step.taskId) {
- map.set(step.taskId, step.name);
- }
- }
- return map;
- }, [taskMapKey]); // eslint-disable-line react-hooks/exhaustive-deps
-
- // Build specialized steps for DAG visualization
- const specializedSteps = useMemo(() => {
- const steps: SpecializedStep[] = [];
-
- if (directive.orchestratorTaskId) {
- steps.push({
- id: `orchestrator-${directive.orchestratorTaskId}`,
- name: taskMap.get(directive.orchestratorTaskId) || "Planning",
- type: "orchestrator",
- taskId: directive.orchestratorTaskId,
- status: "running",
- });
- }
-
- if (directive.completionTaskId) {
- steps.push({
- id: `completion-${directive.completionTaskId}`,
- name: directive.prUrl ? "Updating PR" : "Creating PR",
- type: "completion",
- taskId: directive.completionTaskId,
- status: "running",
- });
- }
-
- return steps;
- }, [directive.orchestratorTaskId, directive.completionTaskId, directive.prUrl, taskMap]);
-
- // Subscribe to all task outputs
- const { connected, entries, clearEntries } = useMultiTaskSubscription({
- taskMap,
- enabled: taskMap.size > 0,
- });
-
- // Auto-expand log panel when tasks start running
- const hasRunningTasks = directive.steps.some((s) => s.status === "running") ||
- !!directive.orchestratorTaskId;
-
- useEffect(() => {
- if (hasRunningTasks && !prevHadRunningRef.current) {
- setIsLogCollapsed(false);
- }
- prevHadRunningRef.current = hasRunningTasks;
- }, [hasRunningTasks]);
-
- const handlePickUpOrders = async () => {
- setPickingUpOrders(true);
- setPickUpResult(null);
- try {
- const result = await onPickUpOrders();
- if (result) {
- setPickUpResult(result.message);
- setTimeout(() => setPickUpResult(null), 5000);
- }
- } catch (e) {
- setPickUpResult(e instanceof Error ? e.message : "Failed to pick up orders");
- setTimeout(() => setPickUpResult(null), 5000);
- } finally {
- setPickingUpOrders(false);
- }
- };
-
- const handleGoalSave = () => {
- if (goalText.trim() && goalText !== directive.goal) {
- onUpdateGoal(goalText.trim());
- }
- setEditingGoal(false);
- };
-
-
- // Find the task name for the slide-out panel
- const slideOutTaskName = slideOutTaskId
- ? (directive.steps.find((s) => s.taskId === slideOutTaskId)?.name ??
- taskMap.get(slideOutTaskId) ??
- undefined)
- : undefined;
-
- 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">
- <h2 className="text-[14px] font-mono text-white font-medium truncate pr-2">
- {directive.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>
-
- {/* Progress bar */}
- {totalSteps > 0 && (
- <div className="flex items-center gap-2 mb-2">
- <div className="flex-1 h-1.5 bg-[#1a2540] rounded overflow-hidden">
- <div
- className="h-full bg-emerald-600 rounded transition-all"
- style={{ width: `${progress}%` }}
- />
- </div>
- <span className="text-[10px] font-mono text-[#7788aa] shrink-0">
- {completedSteps}/{totalSteps} steps
- </span>
- </div>
- )}
-
- {/* Repo info */}
- {(directive.repositoryUrl || directive.localPath) && (
- <div className="text-[10px] font-mono text-[#556677] mb-2 truncate">
- {directive.repositoryUrl || directive.localPath}
- {directive.baseBranch && ` @ ${directive.baseBranch}`}
- </div>
- )}
-
- {/* Reconcile mode toggle */}
- <div className="flex items-center gap-2 mb-2">
- <div className="flex items-center border border-[#2a3a5a] rounded overflow-hidden">
- {(["auto", "semi-auto", "manual"] as const).map((mode) => {
- const isActive = directive.reconcileMode === mode;
- const modeStyles: Record<string, string> = {
- auto: isActive ? "text-[#9bc3ff] bg-[#1a2540]" : "text-[#445566] hover:text-[#7788aa]",
- "semi-auto": isActive ? "text-amber-400 bg-amber-900/20" : "text-[#445566] hover:text-[#7788aa]",
- manual: isActive ? "text-orange-400 bg-orange-900/20" : "text-[#445566] hover:text-[#7788aa]",
- };
- const labels: Record<string, string> = { auto: "Auto", "semi-auto": "Semi", manual: "Manual" };
- return (
- <button
- key={mode}
- type="button"
- onClick={() => onUpdate({ reconcileMode: mode })}
- className={`text-[10px] font-mono px-2 py-0.5 transition-colors border-r border-[#2a3a5a] last:border-r-0 ${modeStyles[mode]}`}
- >
- {labels[mode]}
- </button>
- );
- })}
- </div>
- <span className="text-[9px] font-mono text-[#445566]">
- {directive.reconcileMode === "auto" && "Questions timeout after 30s"}
- {directive.reconcileMode === "semi-auto" && "Questions pause execution"}
- {directive.reconcileMode === "manual" && "Tasks ask clarifying questions"}
- </span>
- </div>
-
- {/* PR link */}
- {directive.prUrl && (
- <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#0a1a10] border border-emerald-900 rounded">
- <span className="inline-block w-2 h-2 rounded-full bg-emerald-400" />
- <span className="text-[10px] font-mono text-emerald-400">
- PR created
- </span>
- <a
- href={directive.prUrl}
- target="_blank"
- rel="noopener noreferrer"
- className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 underline ml-auto truncate max-w-[200px]"
- >
- {directive.prUrl}
- </a>
- </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") && (
- <button
- type="button"
- onClick={onStart}
- className="text-[10px] font-mono text-green-400 hover:text-green-300 border border-green-800 rounded px-2 py-1"
- >
- Start
- </button>
- )}
- {directive.status === "active" && (
- <>
- <button
- type="button"
- onClick={onPause}
- className="text-[10px] font-mono text-orange-400 hover:text-orange-300 border border-orange-800 rounded px-2 py-1"
- >
- Pause
- </button>
- <button
- type="button"
- onClick={onAdvance}
- className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1"
- >
- Advance
- </button>
- </>
- )}
- {directive.status === "idle" && (
- <div className="flex items-center gap-2">
- <span className="text-[10px] font-mono text-yellow-400">
- All steps done. Update goal to add new work.
- </span>
- <button
- type="button"
- 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
- </button>
- <button
- type="button"
- onClick={onCleanup}
- className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-1"
- >
- Clean up
- </button>
- </div>
- )}
- {completedSteps > 0 && !directive.completionTaskId && (
- <button
- type="button"
- onClick={async () => {
- setCreatingPR(true);
- try { await onCreatePR(); } catch (e) { console.error("Failed to create PR:", e); } finally { setCreatingPR(false); }
- }}
- disabled={creatingPR}
- className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-1 disabled:opacity-50"
- >
- {creatingPR ? "Creating..." : directive.prUrl ? "Update PR" : "Create PR"}
- </button>
- )}
- <button
- type="button"
- onClick={handlePickUpOrders}
- disabled={pickingUpOrders}
- className="text-[10px] font-mono text-[#c084fc] hover:text-[#d8b4fe] border border-[rgba(192,132,252,0.3)] rounded px-2 py-1 disabled:opacity-50"
- >
- {pickingUpOrders ? "Planning..." : "Plan Orders"}
- </button>
- <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>
-
- {pickUpResult && (
- <div className="mt-2 px-2 py-1.5 bg-[#1a1030] border border-[rgba(192,132,252,0.2)] rounded">
- <span className="text-[10px] font-mono text-[#c084fc]">{pickUpResult}</span>
- </div>
- )}
- </div>
-
- {/* Goal */}
- <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">
- Goal
- </span>
- {!editingGoal && (
- <button
- type="button"
- onClick={() => { setGoalText(directive.goal); setEditingGoal(true); }}
- className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
- >
- [edit]
- </button>
- )}
- </div>
- {editingGoal ? (
- <div className="flex flex-col gap-1.5">
- <textarea
- value={goalText}
- onChange={(e) => setGoalText(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-[60px]"
- rows={3}
- />
- <div className="flex gap-1.5">
- <button
- type="button"
- onClick={handleGoalSave}
- 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={() => setEditingGoal(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">
- {directive.goal}
- </p>
- )}
- </div>
-
- {/* Tab bar */}
- <div className="flex items-center gap-0 border-b border-[rgba(117,170,252,0.1)] px-4">
- <button
- type="button"
- onClick={() => setActiveTab("steps")}
- className={`px-3 py-2 text-[10px] font-mono uppercase tracking-wide transition-colors
- ${activeTab === "steps" ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" : "text-[#556677] hover:text-[#9bc3ff]"}
- `}
- >
- Steps ({totalSteps})
- </button>
- <button
- type="button"
- onClick={() => setActiveTab("dogs")}
- className={`px-3 py-2 text-[10px] font-mono uppercase tracking-wide transition-colors
- ${activeTab === "dogs" ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" : "text-[#556677] hover:text-[#9bc3ff]"}
- `}
- >
- DOGs ({dogs.length})
- </button>
- </div>
-
- {/* Tab content */}
- {activeTab === "steps" ? (
- <div className="px-4 py-3 flex-1">
- <DirectiveDAG
- steps={directive.steps}
- specializedSteps={specializedSteps}
- onComplete={onCompleteStep}
- onFail={onFailStep}
- onSkip={onSkipStep}
- onViewTask={handleViewTask}
- />
- </div>
- ) : (
- <div className="px-4 py-3 flex-1">
- <DOGList
- dogs={dogs}
- loading={dogsLoading}
- onCreateDog={onCreateDog}
- onUpdateDog={onUpdateDog}
- onDeleteDog={onDeleteDog}
- onPickUpOrders={onPickUpDogOrders}
- />
- </div>
- )}
-
- {/* Log Stream */}
- {taskMap.size > 0 && (
- <div className="px-4 py-3 border-t border-[rgba(117,170,252,0.1)]">
- <DirectiveLogStream
- entries={entries}
- taskMap={taskMap}
- connected={connected}
- visibleTaskIds={visibleTaskIds}
- searchQuery={searchQuery}
- isCollapsed={isLogCollapsed}
- onToggleCollapse={() => setIsLogCollapsed((prev) => !prev)}
- onSetVisibleTaskIds={setVisibleTaskIds}
- onSetSearchQuery={setSearchQuery}
- onClear={clearEntries}
- />
- </div>
- )}
- </div>
-
- <TaskSlideOutPanel
- taskId={slideOutTaskId || ""}
- taskName={slideOutTaskName}
- isOpen={!!slideOutTaskId}
- onClose={() => setSlideOutTaskId(null)}
- />
- </>
- );
-}
-
-/** 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/directives/DirectiveList.tsx b/makima/frontend/src/components/directives/DirectiveList.tsx
deleted file mode 100644
index a35c8b1..0000000
--- a/makima/frontend/src/components/directives/DirectiveList.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import { useState, useMemo } from "react";
-import type { DirectiveSummary, DirectiveStatus } from "../../lib/api";
-import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext";
-import { DirectiveContextMenu } from "./DirectiveContextMenu";
-
-const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = {
- draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" },
- active: { color: "text-green-400 border-green-800", label: "ACTIVE" },
- idle: { color: "text-yellow-400 border-yellow-800", label: "IDLE" },
- paused: { color: "text-orange-400 border-orange-800", label: "PAUSED" },
- inactive: { color: "text-[#9bc3ff] border-[#3f6fb3]", label: "INACTIVE" },
- archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" },
-};
-
-interface DirectiveListProps {
- directives: DirectiveSummary[];
- selectedId: string | null;
- onSelect: (id: string) => void;
- onCreate: () => void;
- onStart?: (directive: DirectiveSummary) => void;
- onPause?: (directive: DirectiveSummary) => void;
- onArchive?: (directive: DirectiveSummary) => void;
- onDelete?: (directive: DirectiveSummary) => void;
- onGoToPR?: (directive: DirectiveSummary) => void;
-}
-
-export function DirectiveList({ directives, selectedId, onSelect, onCreate, onStart, onPause, onArchive, onDelete, onGoToPR }: DirectiveListProps) {
- const { pendingQuestions } = useSupervisorQuestions();
- const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
- const [contextMenuDirective, setContextMenuDirective] = useState<DirectiveSummary | null>(null);
-
- const handleContextMenu = (e: React.MouseEvent, directive: DirectiveSummary) => {
- e.preventDefault();
- setContextMenuPosition({ x: e.clientX, y: e.clientY });
- setContextMenuDirective(directive);
- };
-
- const closeContextMenu = () => {
- setContextMenuPosition(null);
- setContextMenuDirective(null);
- };
-
- const questionsPerDirective = useMemo(() => {
- const counts = new Map<string, number>();
- for (const q of pendingQuestions) {
- if (q.directiveId) {
- counts.set(q.directiveId, (counts.get(q.directiveId) || 0) + 1);
- }
- }
- return counts;
- }, [pendingQuestions]);
-
- return (
- <div className="flex flex-col h-full">
- <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">
- Directives
- </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>
- <div className="flex-1 overflow-y-auto">
- {directives.length === 0 ? (
- <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
- No directives yet
- </div>
- ) : (
- directives.map((d) => {
- const badge = STATUS_BADGE[d.status] || STATUS_BADGE.draft;
- const progress = d.totalSteps > 0
- ? Math.round((d.completedSteps / d.totalSteps) * 100)
- : 0;
-
- return (
- <button
- key={d.id}
- type="button"
- onClick={() => onSelect(d.id)}
- onContextMenu={(e) => handleContextMenu(e, d)}
- 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 === d.id ? "bg-[rgba(117,170,252,0.1)]" : ""
- }`}
- >
- <div className="flex items-center justify-between mb-1">
- <span className="text-[12px] font-mono text-white truncate pr-2">
- {d.title}
- </span>
- {questionsPerDirective.has(d.id) && (
- <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-400 animate-pulse shrink-0" title={`${questionsPerDirective.get(d.id)} pending question(s)`} />
- )}
- <span
- className={`text-[9px] font-mono ${badge.color} border rounded px-1.5 py-0.5 shrink-0`}
- >
- {badge.label}
- </span>
- </div>
- <p className="text-[10px] text-[#7788aa] font-mono truncate mb-1.5">
- {d.goal}
- </p>
- {d.totalSteps > 0 && (
- <div className="flex items-center gap-2">
- <div className="flex-1 h-1 bg-[#1a2540] rounded overflow-hidden">
- <div
- className="h-full bg-emerald-600 rounded transition-all"
- style={{ width: `${progress}%` }}
- />
- </div>
- <span className="text-[9px] font-mono text-[#556677] shrink-0">
- {d.completedSteps}/{d.totalSteps}
- </span>
- </div>
- )}
- </button>
- );
- })
- )}
- </div>
-
- {/* Context Menu */}
- {contextMenuPosition && contextMenuDirective && (
- <DirectiveContextMenu
- x={contextMenuPosition.x}
- y={contextMenuPosition.y}
- directive={contextMenuDirective}
- onClose={closeContextMenu}
- onStart={() => onStart?.(contextMenuDirective)}
- onPause={() => onPause?.(contextMenuDirective)}
- onArchive={() => onArchive?.(contextMenuDirective)}
- onDelete={() => onDelete?.(contextMenuDirective)}
- onGoToPR={() => onGoToPR?.(contextMenuDirective)}
- />
- )}
- </div>
- );
-}
diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts
index 8104de0..e7b2b80 100644
--- a/makima/frontend/src/hooks/useDirectives.ts
+++ b/makima/frontend/src/hooks/useDirectives.ts
@@ -18,7 +18,6 @@ import {
completeDirectiveStep,
failDirectiveStep,
skipDirectiveStep,
- updateDirectiveGoal,
cleanupDirective,
pickUpOrders as pickUpOrdersApi,
createDirectivePR,
@@ -293,12 +292,6 @@ export function useDirective(id: string | undefined) {
await refresh();
}, [id, refresh]);
- const updateGoal = useCallback(async (goal: string) => {
- if (!id) return;
- await updateDirectiveGoal(id, goal);
- await refresh();
- }, [id, refresh]);
-
const cleanup = useCallback(async () => {
if (!id) return;
await cleanupDirective(id);
@@ -323,7 +316,7 @@ export function useDirective(id: string | undefined) {
update, addStep, removeStep,
start, pause, advance,
completeStep, failStep, skipStep,
- updateGoal, cleanup,
+ cleanup,
pickUpOrders: pickUpOrdersFn,
createPR,
};
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index f777ba0..a4ec4db 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -3207,7 +3207,6 @@ export interface Directive {
id: string;
ownerId: string;
title: string;
- goal: string;
status: DirectiveStatus;
repositoryUrl: string | null;
localPath: string | null;
@@ -3220,7 +3219,6 @@ export interface Directive {
memoryEnabled: boolean;
/** Reconcile mode: auto (timeout), semi-auto (pause), or manual (ask questions) */
reconcileMode: string;
- goalUpdatedAt: string;
startedAt: string | null;
version: number;
createdAt: string;
@@ -3255,7 +3253,6 @@ export interface DirectiveSummary {
id: string;
ownerId: string;
title: string;
- goal: string;
status: DirectiveStatus;
repositoryUrl: string | null;
orchestratorTaskId: string | null;
@@ -3281,7 +3278,9 @@ export interface DirectiveListResponse {
export interface CreateDirectiveRequest {
title: string;
- goal: string;
+ /** Optional. When provided, a first contract is auto-created with
+ * this body so the directive is immediately ready to start. */
+ contractBody?: string;
repositoryUrl?: string;
localPath?: string;
baseBranch?: string;
@@ -3293,7 +3292,6 @@ export interface CreateDirectiveRequest {
export interface UpdateDirectiveRequest {
title?: string;
- goal?: string;
status?: string;
repositoryUrl?: string;
localPath?: string;
@@ -3433,15 +3431,8 @@ export async function skipDirectiveStep(directiveId: string, stepId: string): Pr
return res.json();
}
-export async function updateDirectiveGoal(id: string, goal: string): Promise<Directive> {
- const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/goal`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ goal }),
- });
- if (!res.ok) throw new Error(`Failed to update goal: ${res.statusText}`);
- return res.json();
-}
+// (updateDirectiveGoal removed — spec edits go through the contracts API.
+// Use updateDirectiveContract(activeContractId, { body }) instead.)
export async function cleanupDirective(id: string): Promise<{ message: string; taskId: string | null }> {
const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/cleanup`, {
diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx
index 895c86a..f397e54 100644
--- a/makima/frontend/src/routes/directives.tsx
+++ b/makima/frontend/src/routes/directives.tsx
@@ -1,317 +1,15 @@
-import { useState, useEffect, useCallback } 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, useDirective } from "../hooks/useDirectives";
-import { useDogs } from "../hooks/useDogs";
-import { useUserSettings } from "../hooks/useUserSettings";
-import { useAuth } from "../contexts/AuthContext";
+// Top-level /directives route — now always renders the document-mode UI.
+//
+// The legacy tabular UI (DirectiveList + DirectiveDetail) was retired
+// when directives.goal was dropped: contracts own the spec text now,
+// and the document-mode page is the only surface that knows how to
+// edit them. The user-settings `documentModeEnabled` toggle is no
+// longer consulted here (kept around in settings for future flag use).
+//
+// /directives/:id is delegated to DocumentDirectivesPage which reads
+// the param itself.
import DocumentDirectivesPage from "./document-directives";
-import { getRepositorySuggestions, startDirective, pauseDirective, updateDirective, type RepositoryHistoryEntry, type DirectiveSummary } from "../lib/api";
-/**
- * Top-level /directives route. Gates between the legacy tabular UI and the
- * Document Mode (POC) UI based on the user's settings flag.
- *
- * Both code paths support /directives/:id deep links — the param is read by
- * each branch independently via useParams.
- */
export default function DirectivesPage() {
- const { settings, loading: settingsLoading } = useUserSettings();
-
- // While settings are loading for the very first time, render nothing inside
- // a Masthead-wrapped shell so we don't briefly flash the legacy UI just to
- // swap to document mode a moment later.
- if (settingsLoading && !settings) {
- 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>
- );
- }
-
- if (settings?.documentModeEnabled) {
- return <DocumentDirectivesPage />;
- }
-
- return <LegacyDirectivesPage />;
-}
-
-function LegacyDirectivesPage() {
- const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
- const navigate = useNavigate();
- const { id: selectedId } = useParams<{ id: string }>();
- const { directives, loading: listLoading, create, remove, refresh: refreshList } = useDirectives();
- const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanup, pickUpOrders, createPR } = useDirective(selectedId);
- const { dogs, loading: dogsLoading, create: createDog, update: updateDog, remove: removeDog, pickUpOrders: pickUpDogOrders } = useDogs(selectedId);
-
- const [showCreate, setShowCreate] = useState(false);
- const [newTitle, setNewTitle] = useState("");
- const [newGoal, setNewGoal] = useState("");
- const [newRepoUrl, setNewRepoUrl] = useState("");
- const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]);
- const [showRepoSuggestions, setShowRepoSuggestions] = useState(false);
-
- // Fetch repository suggestions when create form opens
- useEffect(() => {
- if (showCreate) {
- getRepositorySuggestions("remote", undefined, 10)
- .then((res) => {
- setRepoSuggestions(res.entries);
- setShowRepoSuggestions(res.entries.length > 0);
- })
- .catch(() => {
- setRepoSuggestions([]);
- setShowRepoSuggestions(false);
- });
- } else {
- setRepoSuggestions([]);
- setShowRepoSuggestions(false);
- }
- }, [showCreate]);
-
- const applyRepoSuggestion = useCallback((suggestion: RepositoryHistoryEntry) => {
- if (suggestion.repositoryUrl) {
- setNewRepoUrl(suggestion.repositoryUrl);
- }
- if (!newTitle.trim() && suggestion.name) {
- setNewTitle(suggestion.name);
- }
- setShowRepoSuggestions(false);
- }, [newTitle]);
-
- 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 handleContextStart = async (directive: DirectiveSummary) => {
- try {
- await startDirective(directive.id);
- await refreshList();
- } catch (e) {
- console.error("Failed to start directive:", e);
- }
- };
-
- const handleContextPause = async (directive: DirectiveSummary) => {
- try {
- await pauseDirective(directive.id);
- await refreshList();
- } catch (e) {
- console.error("Failed to pause directive:", e);
- }
- };
-
- const handleContextArchive = async (directive: DirectiveSummary) => {
- try {
- await updateDirective(directive.id, { status: "archived" });
- await refreshList();
- } catch (e) {
- console.error("Failed to archive directive:", e);
- }
- };
-
- const handleContextDelete = async (directive: DirectiveSummary) => {
- if (!window.confirm("Delete this directive?")) return;
- try {
- await remove(directive.id);
- if (directive.id === selectedId) navigate("/directives");
- } catch (e) {
- console.error("Failed to delete:", e);
- }
- };
-
- const handleContextGoToPR = (directive: DirectiveSummary) => {
- if (directive.prUrl) window.open(directive.prUrl, "_blank");
- };
-
- const handleCreate = async () => {
- if (!newTitle.trim() || !newGoal.trim()) return;
- try {
- const d = await create({
- title: newTitle.trim(),
- goal: newGoal.trim(),
- repositoryUrl: newRepoUrl.trim() || undefined,
- });
- setShowCreate(false);
- setNewTitle("");
- setNewGoal("");
- setNewRepoUrl("");
- navigate(`/directives/${d.id}`);
- } catch (e) {
- console.error("Failed to create directive:", e);
- }
- };
-
- const handleDelete = async () => {
- if (!selectedId) return;
- if (!window.confirm("Delete this directive?")) return;
- try {
- await remove(selectedId);
- navigate("/directives");
- } catch (e) {
- console.error("Failed to delete:", e);
- }
- };
-
- 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">
- <DirectiveList
- directives={directives}
- selectedId={selectedId ?? null}
- onSelect={(id) => navigate(`/directives/${id}`)}
- onCreate={() => setShowCreate(true)}
- onStart={handleContextStart}
- onPause={handleContextPause}
- onArchive={handleContextArchive}
- onDelete={handleContextDelete}
- onGoToPR={handleContextGoToPR}
- />
- </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 Directive
- </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="Project 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"
- />
- </div>
- <div>
- <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1">
- Goal
- </label>
- <textarea
- value={newGoal}
- onChange={(e) => setNewGoal(e.target.value)}
- placeholder="What should this directive accomplish?"
- 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>
- {showRepoSuggestions && repoSuggestions.length > 0 && (
- <div>
- <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
- Recent Repositories
- </label>
- <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto">
- {repoSuggestions.map((suggestion) => (
- <button
- key={suggestion.id}
- type="button"
- onClick={() => applyRepoSuggestion(suggestion)}
- className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0"
- >
- <div className="flex items-center justify-between">
- <span className="text-[#9bc3ff] truncate">{suggestion.name}</span>
- <span className="text-[10px] text-[#556677] ml-2">
- {suggestion.useCount}×
- </span>
- </div>
- <div className="text-[10px] text-[#556677] truncate">
- {suggestion.repositoryUrl}
- </div>
- </button>
- ))}
- </div>
- </div>
- )}
- <div>
- <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1">
- Repository URL (optional)
- </label>
- <input
- value={newRepoUrl}
- onChange={(e) => setNewRepoUrl(e.target.value)}
- placeholder="https://github.com/..."
- 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"
- />
- </div>
- <div className="flex gap-2">
- <button
- type="button"
- onClick={handleCreate}
- disabled={!newTitle.trim() || !newGoal.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 && directive ? (
- <DirectiveDetail
- directive={directive}
- onStart={start}
- onPause={pause}
- onAdvance={advance}
- onCompleteStep={completeStep}
- onFailStep={failStep}
- onSkipStep={skipStep}
- onUpdateGoal={updateGoal}
- onUpdate={update}
- onDelete={handleDelete}
- onRefresh={refreshDetail}
- onCleanup={cleanup}
- onPickUpOrders={pickUpOrders}
- onCreatePR={createPR}
- dogs={dogs}
- dogsLoading={dogsLoading}
- onCreateDog={createDog}
- onUpdateDog={updateDog}
- onDeleteDog={removeDog}
- onPickUpDogOrders={pickUpDogOrders}
- />
- ) : (
- <div className="flex-1 flex items-center justify-center h-full">
- <p className="text-[#556677] font-mono text-[12px]">
- {listLoading
- ? "Loading..."
- : "Select a directive or create a new one"}
- </p>
- </div>
- )}
- </div>
- </main>
- </div>
- );
+ return <DocumentDirectivesPage />;
}
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index 63d0b96..06e427a 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -1595,10 +1595,10 @@ export default function DocumentDirectivesPage() {
const [newEphemeralFor, setNewEphemeralFor] = useState<DirectiveSummary | null>(null);
const handleSubmitNewContract = useCallback(
- async (title: string, goal: string, repositoryUrl: string) => {
+ async (title: string, body: string, repositoryUrl: string) => {
const d = await createDirective({
title,
- goal,
+ contractBody: body,
repositoryUrl: repositoryUrl.length > 0 ? repositoryUrl : undefined,
});
setShowNewContract(false);
diff --git a/makima/migrations/20260510000000_drop_directive_goal.sql b/makima/migrations/20260510000000_drop_directive_goal.sql
new file mode 100644
index 0000000..0848239
--- /dev/null
+++ b/makima/migrations/20260510000000_drop_directive_goal.sql
@@ -0,0 +1,16 @@
+-- Drop directives.goal and directive_goal_history.
+--
+-- The unified contract surface makes contracts the source of truth for
+-- spec text. A directive is now a folder of contracts; the orchestrator
+-- daemon reads the currently-active contract's body when it spawns,
+-- replanned, or runs completion. directives.goal was the pre-contract
+-- spec field — superseded.
+--
+-- directive_goal_history tracked edits to the goal field. Contract
+-- bodies have their own version field on the contracts table; the
+-- amend/replan flow now queries that instead.
+
+DROP TABLE IF EXISTS directive_goal_history;
+
+ALTER TABLE directives DROP COLUMN IF EXISTS goal;
+ALTER TABLE directives DROP COLUMN IF EXISTS goal_updated_at;
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs
index a84c581..7b8cdb6 100644
--- a/makima/src/bin/makima.rs
+++ b/makima/src/bin/makima.rs
@@ -417,13 +417,6 @@ async fn run_directive(
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
- DirectiveCommand::UpdateGoal(args) => {
- let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
- let result = client
- .directive_update_goal(args.common.directive_id, &args.goal)
- .await?;
- println!("{}", serde_json::to_string(&result.0)?);
- }
DirectiveCommand::BatchAddSteps(args) => {
let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
let steps: serde_json::Value = serde_json::from_str(&args.json)
diff --git a/makima/src/daemon/api/directive.rs b/makima/src/daemon/api/directive.rs
index 1088eb7..bf5db09 100644
--- a/makima/src/daemon/api/directive.rs
+++ b/makima/src/daemon/api/directive.rs
@@ -20,12 +20,6 @@ pub struct CreateStepRequest {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
-pub struct UpdateGoalRequest {
- pub goal: String,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
pub struct UpdateStepDepsRequest {
pub depends_on: Vec<Uuid>,
}
@@ -125,16 +119,6 @@ impl ApiClient {
.await
}
- /// Update the directive's goal.
- pub async fn directive_update_goal(
- &self,
- directive_id: Uuid,
- goal: &str,
- ) -> Result<JsonValue, ApiError> {
- let req = UpdateGoalRequest { goal: goal.to_string() };
- self.put(&format!("/api/v1/directives/{}/goal", directive_id), &req).await
- }
-
/// Update directive metadata (PR URL, PR branch, status, etc.)
pub async fn directive_update(
&self,
diff --git a/makima/src/daemon/cli/directive.rs b/makima/src/daemon/cli/directive.rs
index 0f04720..a27b94e 100644
--- a/makima/src/daemon/cli/directive.rs
+++ b/makima/src/daemon/cli/directive.rs
@@ -90,15 +90,7 @@ pub struct StepActionArgs {
pub step_id: Uuid,
}
-/// Arguments for update-goal command.
-#[derive(Args, Debug)]
-pub struct UpdateGoalArgs {
- #[command(flatten)]
- pub common: DirectiveArgs,
-
- /// New goal text
- pub goal: String,
-}
+// (UpdateGoalArgs removed — spec edits flow through the contracts API now.)
/// Arguments for batch-add-steps command.
#[derive(Args, Debug)]
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs
index b01c161..acad9ad 100644
--- a/makima/src/daemon/cli/mod.rs
+++ b/makima/src/daemon/cli/mod.rs
@@ -107,9 +107,6 @@ pub enum DirectiveCommand {
/// Mark a step as skipped
SkipStep(directive::StepActionArgs),
- /// Update the directive's goal (triggers re-planning)
- UpdateGoal(directive::UpdateGoalArgs),
-
/// Batch add multiple steps from JSON
BatchAddSteps(directive::BatchAddStepsArgs),
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index fcccd05..3fb9667 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -2704,14 +2704,16 @@ mod tests {
// Directive Types
// =============================================================================
-/// A directive — a long-lived top-level entity for managing projects via a DAG of steps.
+/// A directive — a long-lived top-level entity that owns a sequence of
+/// contracts (see `directive_documents`). The directive itself is a
+/// folder; the active contract's body is the spec the orchestrator
+/// daemon reads when planning.
#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct Directive {
pub id: Uuid,
pub owner_id: Uuid,
pub title: String,
- pub goal: String,
/// Status: draft, active, idle, paused, archived
pub status: String,
pub repository_url: Option<String>,
@@ -2723,7 +2725,6 @@ pub struct Directive {
pub completion_task_id: Option<Uuid>,
/// Question timeout mode: "auto" (30s timeout), "semi-auto" (block indefinitely), "manual" (block + ask many questions)
pub reconcile_mode: String,
- pub goal_updated_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub version: i32,
pub created_at: DateTime<Utc>,
@@ -2736,16 +2737,6 @@ pub struct Directive {
pub is_tmp: bool,
}
-/// A historical record of a directive goal change.
-#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct DirectiveGoalHistory {
- pub id: Uuid,
- pub directive_id: Uuid,
- pub goal: String,
- pub created_at: DateTime<Utc>,
-}
-
/// Per-PR snapshot of a directive's goal — the immutable record of what the
/// contract said at the moment a PR was raised. Frozen at PR-creation time;
/// `pr_state` mirrors the PR's GitHub lifecycle ('open' | 'merged' | 'closed').
@@ -2808,7 +2799,6 @@ pub struct DirectiveSummary {
pub id: Uuid,
pub owner_id: Uuid,
pub title: String,
- pub goal: String,
pub status: String,
pub repository_url: Option<String>,
pub orchestrator_task_id: Option<Uuid>,
@@ -2833,12 +2823,18 @@ pub struct DirectiveListResponse {
pub total: i64,
}
-/// Request to create a new directive.
+/// Request to create a new directive. The directive itself has no spec
+/// text — pass `contractBody` to auto-create a first contract whose
+/// body is the spec; if omitted, the directive is created empty and
+/// the user will create a contract from the UI.
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectiveRequest {
pub title: String,
- pub goal: String,
+ /// Optional. When provided, a first contract is auto-created with
+ /// this body so the directive is immediately ready to start.
+ #[serde(default)]
+ pub contract_body: Option<String>,
pub repository_url: Option<String>,
pub local_path: Option<String>,
pub base_branch: Option<String>,
@@ -2846,12 +2842,13 @@ pub struct CreateDirectiveRequest {
pub reconcile_mode: Option<String>,
}
-/// Request to update a directive.
+/// Request to update a directive's metadata. Spec edits go through the
+/// contracts API now — this endpoint only mutates directive-level
+/// fields (title, repo, status, etc.).
#[derive(Debug, Default, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateDirectiveRequest {
pub title: Option<String>,
- pub goal: Option<String>,
pub status: Option<String>,
pub repository_url: Option<String>,
pub local_path: Option<String>,
@@ -2864,13 +2861,6 @@ pub struct UpdateDirectiveRequest {
pub version: Option<i32>,
}
-/// Request to update a directive's goal (triggers re-planning).
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct UpdateGoalRequest {
- pub goal: String,
-}
-
/// Response for cleanup_directive_tasks (legacy).
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index e58f58c..20f3268 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -12,7 +12,7 @@ use super::models::{
CreateContractRequest, CreateFileRequest, CreateTaskRequest,
CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity,
DeliverableDefinition, Directive, DirectiveDocument, DirectiveStep, DirectiveSummary,
- CreateDirectiveRequest, CreateDirectiveStepRequest, DirectiveGoalHistory,
+ CreateDirectiveRequest, CreateDirectiveStepRequest,
UpdateDirectiveRequest, UpdateDirectiveStepRequest,
CreateOrderRequest, Order, UpdateOrderRequest,
CreateDirectiveOrderGroupRequest, DirectiveOrderGroup, UpdateDirectiveOrderGroupRequest,
@@ -5125,27 +5125,78 @@ fn truncate_string(s: &str, max_len: usize) -> String {
// =============================================================================
/// Create a new directive for an owner.
+///
+/// If `req.contract_body` is set, also auto-creates a first contract
+/// with that body so the directive is immediately ready to start. Both
+/// inserts run in the same transaction.
pub async fn create_directive_for_owner(
pool: &PgPool,
owner_id: Uuid,
req: CreateDirectiveRequest,
) -> Result<Directive, sqlx::Error> {
- sqlx::query_as::<_, Directive>(
+ let mut tx = pool.begin().await?;
+
+ let directive = sqlx::query_as::<_, Directive>(
r#"
- INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch, reconcile_mode)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
+ INSERT INTO directives (owner_id, title, repository_url, local_path, base_branch, reconcile_mode)
+ VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
"#,
)
.bind(owner_id)
.bind(&req.title)
- .bind(&req.goal)
.bind(&req.repository_url)
.bind(&req.local_path)
.bind(&req.base_branch)
.bind(req.reconcile_mode.as_deref().unwrap_or("auto"))
- .fetch_one(pool)
- .await
+ .fetch_one(&mut *tx)
+ .await?;
+
+ if let Some(body) = &req.contract_body {
+ sqlx::query(
+ r#"
+ INSERT INTO directive_documents (directive_id, title, body, status, position)
+ VALUES ($1, '', $2, 'draft', 0)
+ "#,
+ )
+ .bind(directive.id)
+ .bind(body)
+ .execute(&mut *tx)
+ .await?;
+ }
+
+ tx.commit().await?;
+ Ok(directive)
+}
+
+/// Resolve the body of the directive's "current spec" — the active
+/// contract's body, falling back to the most-recently-updated draft if
+/// none is active. Returns empty string when the directive has no
+/// usable contracts (orchestrator should refuse to spawn in that case).
+pub async fn get_active_contract_body(
+ pool: &PgPool,
+ directive_id: Uuid,
+) -> Result<String, sqlx::Error> {
+ let row: Option<(String,)> = sqlx::query_as(
+ r#"
+ SELECT body FROM directive_documents
+ WHERE directive_id = $1
+ AND status IN ('active', 'queued', 'draft')
+ ORDER BY
+ CASE status
+ WHEN 'active' THEN 0
+ WHEN 'queued' THEN 1
+ WHEN 'draft' THEN 2
+ ELSE 3
+ END,
+ updated_at DESC
+ LIMIT 1
+ "#,
+ )
+ .bind(directive_id)
+ .fetch_optional(pool)
+ .await?;
+ Ok(row.map(|r| r.0).unwrap_or_default())
}
/// Get a single directive for an owner.
@@ -5212,7 +5263,7 @@ pub async fn list_directives_for_owner(
sqlx::query_as::<_, DirectiveSummary>(
r#"
SELECT
- d.id, d.owner_id, d.title, d.goal, d.status, d.repository_url,
+ d.id, d.owner_id, d.title, 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,
@@ -5271,8 +5322,6 @@ pub async fn update_directive_for_owner(
}
let title = req.title.as_deref().unwrap_or(&current.title);
- let goal = req.goal.as_deref().unwrap_or(&current.goal);
- let goal_changed = goal != current.goal;
let status = req.status.as_deref().unwrap_or(&current.status);
let repository_url = req.repository_url.as_deref().or(current.repository_url.as_deref());
let local_path = req.local_path.as_deref().or(current.local_path.as_deref());
@@ -5285,10 +5334,9 @@ pub async fn update_directive_for_owner(
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,
- goal_updated_at = CASE WHEN $13 THEN NOW() ELSE goal_updated_at END,
+ SET title = $3, status = $4, repository_url = $5, local_path = $6,
+ base_branch = $7, orchestrator_task_id = $8, pr_url = $9, pr_branch = $10,
+ reconcile_mode = $11,
version = version + 1, updated_at = NOW()
WHERE id = $1 AND owner_id = $2
RETURNING *
@@ -5297,7 +5345,6 @@ pub async fn update_directive_for_owner(
.bind(id)
.bind(owner_id)
.bind(title)
- .bind(goal)
.bind(status)
.bind(repository_url)
.bind(local_path)
@@ -5306,7 +5353,6 @@ pub async fn update_directive_for_owner(
.bind(pr_url)
.bind(pr_branch)
.bind(reconcile_mode)
- .bind(goal_changed)
.fetch_optional(pool)
.await
.map_err(RepositoryError::Database)?;
@@ -6454,45 +6500,6 @@ pub async fn check_directive_idle(
Ok(result.rows_affected() > 0)
}
-/// Update a directive's goal and bump goal_updated_at.
-/// Reactivates draft/idle/paused/inactive directives and clears any stale
-/// orchestrator task so that planning/replanning triggers on the next
-/// reconciler tick.
-///
-/// `draft` flips because the document-mode UI treats the first goal save as
-/// the implicit "start". `inactive` flips because editing a contract whose
-/// last revision was already shipped is the way the user kicks off an
-/// amendment — the planner picks it up via phase_planning/replanning and
-/// uses get_latest_merged_revision to learn the BEFORE→AFTER diff.
-pub async fn update_directive_goal(
- pool: &PgPool,
- owner_id: Uuid,
- directive_id: Uuid,
- goal: &str,
-) -> Result<Option<Directive>, sqlx::Error> {
- sqlx::query_as::<_, Directive>(
- r#"
- UPDATE directives
- SET goal = $3,
- goal_updated_at = NOW(),
- status = CASE
- WHEN status IN ('draft', 'idle', 'paused', 'inactive') THEN 'active'
- ELSE status
- END,
- orchestrator_task_id = NULL,
- updated_at = NOW(),
- version = version + 1
- WHERE id = $1 AND owner_id = $2
- RETURNING *
- "#,
- )
- .bind(directive_id)
- .bind(owner_id)
- .bind(goal)
- .fetch_optional(pool)
- .await
-}
-
/// Mark a directive 'inactive'. Used at the moment a PR is raised — at that
/// point the contract's current iteration is "shipped" and editing the goal
/// (Stage 4) starts an amendment cycle. Idempotent: no-op if status is
@@ -6517,11 +6524,10 @@ pub async fn set_directive_inactive(
Ok(())
}
-/// Reset a directive for a "new draft" cycle: clear the goal back to empty,
-/// flip status to 'draft', and detach the current pr_url / pr_branch /
-/// orchestrator linkage so the next goal save starts fresh. Prior revisions
-/// remain in `directive_revisions` as the historical record. Used by the
-/// sidebar's "New draft" right-click on inactive contracts.
+/// Reset a directive for a "new draft" cycle: flip status to 'draft' and
+/// detach the current pr_url / pr_branch / orchestrator linkage so the
+/// next contract activation starts fresh. Prior revisions remain in
+/// `directive_revisions` as the historical record.
pub async fn reset_directive_for_new_draft(
pool: &PgPool,
owner_id: Uuid,
@@ -6530,9 +6536,7 @@ pub async fn reset_directive_for_new_draft(
sqlx::query_as::<_, Directive>(
r#"
UPDATE directives
- SET goal = '',
- goal_updated_at = NOW(),
- status = 'draft',
+ SET status = 'draft',
pr_url = NULL,
pr_branch = NULL,
orchestrator_task_id = NULL,
@@ -6549,40 +6553,6 @@ pub async fn reset_directive_for_new_draft(
.await
}
-/// Update a directive's goal WITHOUT clearing the orchestrator task id.
-///
-/// This is the path used by the goal-edit interrupt cycle: when a small goal
-/// edit arrives while a planner is already running, we want to keep the
-/// planner attached so a `SendMessage` can summarise the change in-flight
-/// instead of cancelling and respawning. We still bump `goal_updated_at` so
-/// the timestamp reflects the edit, but we do NOT trigger replanning by
-/// clearing the orchestrator task. We also do not flip status from
-/// idle/paused → active here, since by definition a planner is already
-/// running.
-pub async fn update_directive_goal_keep_orchestrator(
- pool: &PgPool,
- owner_id: Uuid,
- directive_id: Uuid,
- goal: &str,
-) -> Result<Option<Directive>, sqlx::Error> {
- sqlx::query_as::<_, Directive>(
- r#"
- UPDATE directives
- SET goal = $3,
- goal_updated_at = NOW(),
- updated_at = NOW(),
- version = version + 1
- WHERE id = $1 AND owner_id = $2
- RETURNING *
- "#,
- )
- .bind(directive_id)
- .bind(owner_id)
- .bind(goal)
- .fetch_optional(pool)
- .await
-}
-
// =============================================================================
// Directive Revisions — per-PR snapshots of the contract content.
// =============================================================================
@@ -6701,42 +6671,6 @@ pub async fn get_latest_merged_revision(
.await
}
-/// Save a goal to the directive goal history.
-pub async fn save_directive_goal_history(
- pool: &PgPool,
- directive_id: Uuid,
- goal: &str,
-) -> Result<(), sqlx::Error> {
- sqlx::query(
- r#"INSERT INTO directive_goal_history (directive_id, goal)
- VALUES ($1, $2)"#,
- )
- .bind(directive_id)
- .bind(goal)
- .execute(pool)
- .await?;
- Ok(())
-}
-
-/// Get recent goal history for a directive (most recent first), limited to limit entries.
-pub async fn get_directive_goal_history(
- pool: &PgPool,
- directive_id: Uuid,
- limit: i64,
-) -> Result<Vec<DirectiveGoalHistory>, sqlx::Error> {
- sqlx::query_as::<_, DirectiveGoalHistory>(
- r#"SELECT id, directive_id, goal, created_at
- FROM directive_goal_history
- WHERE directive_id = $1
- ORDER BY created_at DESC
- LIMIT $2"#,
- )
- .bind(directive_id)
- .bind(limit)
- .fetch_all(pool)
- .await
-}
-
/// Set a directive's status (used for start/pause/archive transitions).
pub async fn set_directive_status(
pool: &PgPool,
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs
index 7897c2c..384fa23 100644
--- a/makima/src/orchestration/directive.rs
+++ b/makima/src/orchestration/directive.rs
@@ -81,12 +81,14 @@ impl DirectiveOrchestrator {
let prev_merged = repository::get_latest_merged_revision(&self.pool, directive.id)
.await
.unwrap_or(None);
+ let contract_body =
+ repository::get_active_contract_body(&self.pool, directive.id).await?;
let plan = build_planning_prompt(
&directive,
&[],
1,
- &[],
+ &contract_body,
None,
prev_merged.as_ref(),
);
@@ -485,8 +487,8 @@ impl DirectiveOrchestrator {
repository::list_directive_steps(&self.pool, directive.id).await?;
let generation =
repository::get_directive_max_generation(&self.pool, directive.id).await? + 1;
- let goal_history =
- repository::get_directive_goal_history(&self.pool, directive.id, 3).await?;
+ let contract_body =
+ repository::get_active_contract_body(&self.pool, directive.id).await?;
// If steps are currently running (or recently completed), build a
// WORK IN PROGRESS summary for the planner so it doesn't re-issue
@@ -506,7 +508,7 @@ impl DirectiveOrchestrator {
&directive,
&existing_steps,
generation,
- &goal_history,
+ &contract_body,
progress_summary.as_deref(),
prev_merged.as_ref(),
);
@@ -825,8 +827,12 @@ impl DirectiveOrchestrator {
})
.collect();
+ let contract_body = repository::get_active_contract_body(&self.pool, directive.id)
+ .await
+ .unwrap_or_default();
let prompt = build_completion_prompt(
&directive,
+ &contract_body,
&step_tasks,
&step_branches,
&directive_branch,
@@ -1355,7 +1361,10 @@ pub async fn trigger_completion_task(
})
.collect();
- let prompt = build_completion_prompt(&directive, &step_tasks, &step_branches, &directive_branch, base_branch);
+ let contract_body = repository::get_active_contract_body(pool, directive_id)
+ .await
+ .unwrap_or_default();
+ let prompt = build_completion_prompt(&directive, &contract_body, &step_tasks, &step_branches, &directive_branch, base_branch);
let task_name = if directive.pr_url.is_some() {
format!("Update PR: {}", directive.title)
@@ -1543,7 +1552,7 @@ fn build_planning_prompt(
directive: &crate::db::models::Directive,
existing_steps: &[crate::db::models::DirectiveStep],
generation: i32,
- goal_history: &[crate::db::models::DirectiveGoalHistory],
+ contract_body: &str,
progress_summary: Option<&str>,
previous_merged_revision: Option<&crate::db::models::DirectiveRevision>,
) -> String {
@@ -1566,7 +1575,7 @@ fn build_planning_prompt(
prompt.push_str("PREVIOUSLY-MERGED CONTRACT (frozen content):\n");
prompt.push_str(&prev.content);
prompt.push_str("\n\nAMENDED CONTRACT (what the user wants now):\n");
- prompt.push_str(&directive.goal);
+ prompt.push_str(contract_body);
prompt.push_str(
"\n\nIMPORTANT:\n\
- Identify what CHANGED between the previously-merged contract and the amended one.\n\
@@ -1591,6 +1600,17 @@ fn build_planning_prompt(
}
}
+ // Always include the current contract body so the planner has the
+ // up-to-date spec, regardless of whether there are existing steps.
+ prompt.push_str("CURRENT GOAL (active contract body):\n");
+ prompt.push_str(contract_body);
+ prompt.push_str("\n\n");
+
+ // Suppress unused warning for `directive` — kept in the signature so
+ // callers don't have to plumb the contract body separately when we
+ // expand the prompt later.
+ let _ = directive;
+
if !existing_steps.is_empty() {
// ── RE-PLANNING header ──────────────────────────────────────
prompt.push_str(&format!(
@@ -1599,37 +1619,6 @@ fn build_planning_prompt(
relevant. Review each step below and act according to the instructions per status category.\n\n",
));
- // ── Goal changes section ──────────────────────────────────
- if !goal_history.is_empty() {
- prompt.push_str("-- GOAL CHANGES --\n");
- prompt.push_str("The goal has been updated. Compare the previous and current goals to understand what changed:\n\n");
- for (i, entry) in goal_history.iter().enumerate() {
- if i == 0 {
- prompt.push_str(&format!(
- "PREVIOUS GOAL (replaced at {}):\n{}\n\n",
- entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
- entry.goal
- ));
- } else {
- prompt.push_str(&format!(
- "OLDER GOAL (version from {}):\n{}\n\n",
- entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
- entry.goal
- ));
- }
- }
- prompt.push_str(&format!(
- "CURRENT GOAL (what you must plan for):\n{}\n\n",
- directive.goal
- ));
- prompt.push_str(
- "IMPORTANT: Analyze what CHANGED between the previous goal and the current goal.\n\
- - If the change is minor (e.g., clarification, small addition), try to KEEP existing pending steps and only add/modify what is needed for the delta.\n\
- - If the change is major (e.g., completely different objective), you may need to remove most pending steps and create a fresh plan.\n\
- - Always preserve completed and running steps - they represent work already done.\n\n",
- );
- }
-
prompt.push_str(&format!(
"EXISTING STEPS (generation {}):\n",
generation - 1
@@ -1763,7 +1752,18 @@ Your job:
1. Explore the repository to understand the codebase
2. Decompose the goal into concrete, ordered steps
3. Each step = one task for a Claude Code instance to execute
-4. Submit ALL steps using the batch command or individual add-step commands
+4. Submit ALL steps using the batch command or individual add-step commands"#,
+ title = directive.title,
+ goal = contract_body,
+ repo_section = match &directive.repository_url {
+ Some(url) => format!("REPOSITORY: {}\n", url),
+ None => String::new(),
+ },
+ ));
+
+ // The original tail (orders, dependency rules, etc.) follows below;
+ // re-attached intact so the prompt structure is unchanged.
+ prompt.push_str(r#"
For each step, define:
- name: Short imperative title (e.g., "Add user authentication middleware")
@@ -1854,14 +1854,7 @@ When to create orders:
Do NOT create orders for:
- Work that should be a step in the current plan
- Tasks that are part of the current goal
-"#,
- title = directive.title,
- goal = directive.goal,
- repo_section = match &directive.repository_url {
- Some(url) => format!("REPOSITORY: {}\n", url),
- None => String::new(),
- },
- ));
+"#);
prompt
}
@@ -1869,6 +1862,7 @@ Do NOT create orders for:
/// Build the prompt for a completion task that creates or updates a PR.
fn build_completion_prompt(
directive: &crate::db::models::Directive,
+ contract_body: &str,
step_tasks: &[crate::db::repository::CompletedStepTask],
step_branches: &[String],
directive_branch: &str,
@@ -2050,7 +2044,7 @@ If you encounter issues you cannot resolve (e.g., persistent merge conflicts, PR
makima directive ask "Your question" --phaseguard
"#,
title = directive.title,
- goal = directive.goal,
+ goal = contract_body,
pr_url = pr_url,
directive_branch = directive_branch,
base_branch = base_branch,
@@ -2058,7 +2052,7 @@ If you encounter issues you cannot resolve (e.g., persistent merge conflicts, PR
merge_commands = merge_commands,
pr_body = format!(
"## Directive\\n\\n{}\\n\\n## Steps\\n\\n{}",
- directive.goal.replace('\n', "\\n").replace('"', "\\\""),
+ contract_body.replace('\n', "\\n").replace('"', "\\\""),
step_summary.replace('\n', "\\n").replace('"', "\\\""),
),
)
@@ -2156,14 +2150,14 @@ If you encounter issues you cannot resolve (e.g., persistent merge conflicts, PR
makima directive ask "Your question" --phaseguard
"#,
title = directive.title,
- goal = directive.goal,
+ goal = contract_body,
directive_branch = directive_branch,
base_branch = base_branch,
step_summary = step_summary,
merge_commands = merge_commands,
pr_body = format!(
"## Directive\\n\\n{}\\n\\n## Steps\\n\\n{}",
- directive.goal.replace('\n', "\\n").replace('"', "\\\""),
+ contract_body.replace('\n', "\\n").replace('"', "\\\""),
step_summary.replace('\n', "\\n").replace('"', "\\\""),
),
)
@@ -2316,7 +2310,7 @@ pub fn build_order_pickup_prompt(
existing_steps: &[crate::db::models::DirectiveStep],
orders: &[crate::db::models::Order],
generation: i32,
- goal_history: &[crate::db::models::DirectiveGoalHistory],
+ contract_body: &str,
) -> String {
let mut prompt = String::new();
@@ -2326,33 +2320,13 @@ pub fn build_order_pickup_prompt(
GOAL: {goal}\n\
{repo_section}\n",
title = directive.title,
- goal = directive.goal,
+ goal = contract_body,
repo_section = match &directive.repository_url {
Some(url) => format!("REPOSITORY: {}\n", url),
None => String::new(),
},
));
- // ── Goal history (if any) ─────────────────────────────────────
- if !goal_history.is_empty() {
- prompt.push_str("-- GOAL CHANGES --\n");
- for (i, entry) in goal_history.iter().enumerate() {
- if i == 0 {
- prompt.push_str(&format!(
- "PREVIOUS GOAL (replaced at {}):\n{}\n\n",
- entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
- entry.goal
- ));
- } else {
- prompt.push_str(&format!(
- "OLDER GOAL (version from {}):\n{}\n\n",
- entry.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
- entry.goal
- ));
- }
- }
- }
-
// ── Orders being picked up ───────────────────────────────────
prompt.push_str("== ORDERS AVAILABLE FOR PLANNING ==\n");
prompt.push_str("The following open orders have been linked to this directive. \
@@ -2558,93 +2532,9 @@ Do NOT ask questions for trivial decisions — use your best judgment.
}
// =============================================================================
-// Goal-edit classification (small vs large) and interrupt helpers
+// Planner cancellation helper
// =============================================================================
-/// Classification of a goal change for the goal-edit interrupt cycle.
-///
-/// When a user edits a directive's goal while a planning/replanning task is
-/// already running, we want to differentiate between:
-/// • Small edits (typo fixes, clarifications, small additions) → interrupt
-/// the current planner with a `SendMessage` so it can adjust its in-flight
-/// plan rather than throwing away its work.
-/// • Large edits (substantial rewrites, completely different objective) →
-/// fall back to the existing replan path (cancel + spawn a new planner).
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum GoalChangeKind {
- /// Small change — interrupt the running planner with the diff.
- Small,
- /// Large change — proceed with full replan.
- Large,
-}
-
-/// Heuristic: classify a goal edit as small or large.
-///
-/// Rules (POC heuristic, kept deliberately simple):
-/// 1. Empty old goal or empty new goal → Large (treat as a fresh start).
-/// 2. If one goal is a prefix of the other → Small (pure addition / truncation).
-/// 3. If the absolute length difference relative to the longer goal is < 0.3,
-/// classify as Small. Otherwise Large.
-pub fn classify_goal_change(old: &str, new: &str) -> GoalChangeKind {
- let old = old.trim();
- let new = new.trim();
-
- if old.is_empty() || new.is_empty() {
- return GoalChangeKind::Large;
- }
-
- if old == new {
- // No content change — treat as small (no-op for the planner).
- return GoalChangeKind::Small;
- }
-
- // Pure prefix changes (added a sentence at the end, or removed a trailing
- // clause) are almost always small.
- if old.starts_with(new) || new.starts_with(old) {
- return GoalChangeKind::Small;
- }
-
- let old_len = old.chars().count();
- let new_len = new.chars().count();
- let longer = old_len.max(new_len) as f64;
- let diff = (old_len as i64 - new_len as i64).unsigned_abs() as f64;
- if longer == 0.0 {
- return GoalChangeKind::Large;
- }
- let length_ratio = diff / longer;
-
- if length_ratio < 0.3 {
- GoalChangeKind::Small
- } else {
- GoalChangeKind::Large
- }
-}
-
-/// Format the goal-edit interrupt message sent to a running planner task
-/// when the user edits the directive goal mid-flight.
-pub fn build_goal_edit_interrupt_message(old_goal: &str, new_goal: &str) -> String {
- format!(
- "GOAL_UPDATED: The user has edited the directive goal. Summary of changes follows. \
- Adjust your current plan in-flight rather than starting over.\n\
- --- OLD GOAL ---\n\
- {old}\n\
- --- NEW GOAL ---\n\
- {new}\n",
- old = old_goal,
- new = new_goal,
- )
-}
-
-/// Result of attempting to send a goal-edit interrupt to a running planner.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum GoalEditInterruptResult {
- /// A `SendMessage` daemon command was dispatched to the running planner.
- Sent,
- /// No suitable planner task was running, or the change was classified as
- /// large — caller should fall through to the regular replanning path.
- Skipped,
-}
-
/// Best-effort cancellation of a directive's currently-running orchestrator
/// (planner) task. Used by the goal-update path: when we are about to clear
/// `orchestrator_task_id` from the directive, the still-running task would
@@ -2727,160 +2617,5 @@ pub async fn try_cancel_running_planner(
Ok(true)
}
-/// Attempt to interrupt a directive's currently-running planner with a goal
-/// edit summary instead of replanning from scratch.
-///
-/// Returns `Ok(GoalEditInterruptResult::Sent)` when a `SendMessage` was
-/// dispatched. Returns `Ok(GoalEditInterruptResult::Skipped)` when the change
-/// was large, no orchestrator task exists, the task has already finished, or
-/// no daemon is currently assigned.
-///
-/// This function is best-effort: errors talking to the daemon are logged and
-/// translated into `Skipped` so the caller can fall through to the normal
-/// replan path.
-pub async fn try_interrupt_planner_with_goal_edit(
- pool: &PgPool,
- state: &SharedState,
- directive_id: Uuid,
- old_goal: &str,
- new_goal: &str,
-) -> Result<GoalEditInterruptResult, anyhow::Error> {
- // Only fire if the change classifies as small.
- if classify_goal_change(old_goal, new_goal) != GoalChangeKind::Small {
- tracing::debug!(
- directive_id = %directive_id,
- "Goal change classified as large — skipping planner interrupt"
- );
- return Ok(GoalEditInterruptResult::Skipped);
- }
-
- // Look up the directive's current orchestrator task (planner).
- let directive = match repository::get_directive(pool, directive_id).await? {
- Some(d) => d,
- None => return Ok(GoalEditInterruptResult::Skipped),
- };
- let Some(orchestrator_task_id) = directive.orchestrator_task_id else {
- return Ok(GoalEditInterruptResult::Skipped);
- };
-
- // Fetch the planner task to confirm it's still queued/running.
- let task = match repository::get_task(pool, orchestrator_task_id).await? {
- Some(t) => t,
- None => return Ok(GoalEditInterruptResult::Skipped),
- };
-
- let interruptible = matches!(
- task.status.as_str(),
- "queued" | "pending" | "starting" | "running"
- );
- if !interruptible {
- tracing::debug!(
- directive_id = %directive_id,
- task_id = %orchestrator_task_id,
- task_status = %task.status,
- "Planner task is not in an interruptible state — skipping interrupt"
- );
- return Ok(GoalEditInterruptResult::Skipped);
- }
-
- let Some(daemon_id) = task.daemon_id else {
- tracing::debug!(
- directive_id = %directive_id,
- task_id = %orchestrator_task_id,
- "Planner task has no assigned daemon — skipping interrupt"
- );
- return Ok(GoalEditInterruptResult::Skipped);
- };
-
- let message = build_goal_edit_interrupt_message(old_goal, new_goal);
- let command = DaemonCommand::SendMessage {
- task_id: orchestrator_task_id,
- message,
- };
-
- match state.send_daemon_command(daemon_id, command).await {
- Ok(()) => {
- tracing::info!(
- directive_id = %directive_id,
- task_id = %orchestrator_task_id,
- daemon_id = %daemon_id,
- "Sent goal-edit interrupt to running planner"
- );
- Ok(GoalEditInterruptResult::Sent)
- }
- Err(e) => {
- tracing::warn!(
- directive_id = %directive_id,
- task_id = %orchestrator_task_id,
- daemon_id = %daemon_id,
- error = %e,
- "Failed to send goal-edit interrupt — falling back to replan"
- );
- Ok(GoalEditInterruptResult::Skipped)
- }
- }
-}
-
-// =============================================================================
-// Tests
-// =============================================================================
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn classifier_identical_goal_is_small() {
- assert_eq!(
- classify_goal_change("Build a todo app", "Build a todo app"),
- GoalChangeKind::Small
- );
- }
-
- #[test]
- fn classifier_pure_addition_is_small() {
- let old = "Build a todo app";
- let new = "Build a todo app with authentication";
- assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
- }
-
- #[test]
- fn classifier_pure_truncation_is_small() {
- let old = "Build a todo app with authentication and tests";
- let new = "Build a todo app";
- assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
- }
-
- #[test]
- fn classifier_typo_fix_is_small() {
- // Same length, single character diff — well below 0.3 length ratio.
- let old = "Build a todo aap with authentication and tests today";
- let new = "Build a todo app with authentication and tests today";
- assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
- }
-
- #[test]
- fn classifier_completely_different_is_large() {
- // Wildly different lengths and content.
- let old = "Build a todo app";
- let new = "Migrate the entire backend to Rust, port the frontend to Svelte, \
- and add a new realtime collaboration feature with operational transforms";
- assert_eq!(classify_goal_change(old, new), GoalChangeKind::Large);
- }
-
- #[test]
- fn classifier_empty_goals_are_large() {
- assert_eq!(classify_goal_change("", "Anything"), GoalChangeKind::Large);
- assert_eq!(classify_goal_change("Anything", ""), GoalChangeKind::Large);
- }
-
- #[test]
- fn interrupt_message_contains_old_and_new() {
- let msg = build_goal_edit_interrupt_message("OLD", "NEW");
- assert!(msg.contains("GOAL_UPDATED"));
- assert!(msg.contains("OLD"));
- assert!(msg.contains("NEW"));
- assert!(msg.contains("--- OLD GOAL ---"));
- assert!(msg.contains("--- NEW GOAL ---"));
- }
-}
+// (Goal-edit classification + interrupt helpers were tied to directive.goal,
+// which has been dropped. Their unit tests went with them.)
diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs
index 7b13f1c..6d99179 100644
--- a/makima/src/server/handlers/directives.rs
+++ b/makima/src/server/handlers/directives.rs
@@ -13,7 +13,7 @@ use crate::db::models::{
CreateDirectiveStepRequest, Directive, DirectiveListResponse,
DirectiveRevision, DirectiveStep, DirectiveWithSteps, PickUpOrdersResponse,
Task,
- UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest,
+ UpdateDirectiveRequest, UpdateDirectiveStepRequest,
CreateDirectiveOrderGroupRequest, DirectiveOrderGroup,
DirectiveOrderGroupListResponse, UpdateDirectiveOrderGroupRequest,
OrderListResponse,
@@ -22,9 +22,8 @@ use serde::Serialize;
use utoipa::ToSchema;
use crate::db::repository;
use crate::orchestration::directive::{
- build_cleanup_prompt, build_order_pickup_prompt, classify_goal_change,
- try_cancel_running_planner, try_interrupt_planner_with_goal_edit,
- GoalChangeKind, GoalEditInterruptResult,
+ build_cleanup_prompt, build_order_pickup_prompt,
+ try_cancel_running_planner,
};
use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
@@ -200,15 +199,19 @@ pub async fn update_directive(
match repository::update_directive_for_owner(pool, auth.owner_id, id, req).await {
Ok(Some(directive)) => {
// Detect "PR was just raised" — pr_url went from None to Some.
- // Snapshot the current goal as a revision tied to this PR.
- // Best-effort: a snapshot failure should not fail the update,
- // because the directive's pr_url has already been written.
+ // Snapshot the active contract's body as a revision tied to
+ // this PR. Best-effort: a snapshot failure should not fail
+ // the update, because the directive's pr_url has already
+ // been written.
if before_pr_url.is_none() {
if let Some(ref new_pr_url) = directive.pr_url {
+ let snapshot_body = repository::get_active_contract_body(pool, directive.id)
+ .await
+ .unwrap_or_default();
if let Err(e) = repository::create_directive_revision(
pool,
directive.id,
- &directive.goal,
+ &snapshot_body,
new_pr_url,
directive.pr_branch.as_deref(),
)
@@ -859,152 +862,10 @@ async fn step_status_change(
}
}
-/// Update a directive's goal (triggers re-planning).
-#[utoipa::path(
- put,
- path = "/api/v1/directives/{id}/goal",
- params(("id" = Uuid, Path, description = "Directive ID")),
- request_body = UpdateGoalRequest,
- responses(
- (status = 200, description = "Goal updated", body = Directive),
- (status = 404, description = "Not found", body = ApiError),
- (status = 503, description = "Database not configured", body = ApiError),
- ),
- security(("bearer_auth" = []), ("api_key" = [])),
- tag = "Directives"
-)]
-pub async fn update_goal(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- Json(req): Json<UpdateGoalRequest>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
- )
- .into_response();
- };
-
- // Fetch the current directive so we can:
- // 1. Save the old goal to history (best-effort).
- // 2. Decide whether to fire a goal-edit interrupt at a running planner.
- let current = match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
- Ok(Some(d)) => Some(d),
- Ok(None) => None,
- Err(e) => {
- tracing::warn!(
- directive_id = %id,
- error = %e,
- "Failed to fetch current directive for goal history — continuing with goal update"
- );
- None
- }
- };
-
- // Save old goal to history before overwriting (best-effort).
- if let Some(ref current) = current {
- if let Err(e) = repository::save_directive_goal_history(pool, id, &current.goal).await {
- tracing::warn!(
- directive_id = %id,
- error = %e,
- "Failed to save goal history before update — continuing with goal update"
- );
- }
- }
-
- // Goal-edit interrupt cycle: if a planner task is currently running for
- // this directive AND the goal change classifies as 'small', interrupt the
- // running planner via SendMessage instead of clearing it (which would
- // trigger a fresh replan on the next orchestrator tick).
- let mut interrupted = false;
- if let Some(ref current) = current {
- if current.orchestrator_task_id.is_some()
- && classify_goal_change(&current.goal, &req.goal) == GoalChangeKind::Small
- {
- match try_interrupt_planner_with_goal_edit(
- pool,
- &state,
- id,
- &current.goal,
- &req.goal,
- )
- .await
- {
- Ok(GoalEditInterruptResult::Sent) => {
- interrupted = true;
- }
- Ok(GoalEditInterruptResult::Skipped) => {}
- Err(e) => {
- tracing::warn!(
- directive_id = %id,
- error = %e,
- "Goal-edit interrupt attempt errored — falling back to replan"
- );
- }
- }
- }
- }
-
- // If we successfully interrupted a running planner, persist the new goal
- // WITHOUT clearing the orchestrator task — the planner will react to the
- // SendMessage and adjust in-flight. Otherwise, fall through to the normal
- // path which clears orchestrator_task_id and lets phase_replanning kick
- // in on the next tick.
- //
- // CRITICAL: when going down the "clear" path, we must also CANCEL the
- // running planner. Otherwise the orphaned task keeps producing add-step
- // calls based on the old goal, racing the freshly-spawned replanner.
- if !interrupted {
- if let Some(ref current) = current {
- if let Some(orch_task_id) = current.orchestrator_task_id {
- if let Err(e) = try_cancel_running_planner(pool, &state, id, orch_task_id).await {
- tracing::warn!(
- directive_id = %id,
- task_id = %orch_task_id,
- error = %e,
- "Failed to cancel orphaned planner — proceeding with clear anyway"
- );
- }
- }
- }
- }
-
- let update_result = if interrupted {
- repository::update_directive_goal_keep_orchestrator(pool, auth.owner_id, id, &req.goal)
- .await
- } else {
- repository::update_directive_goal(pool, auth.owner_id, id, &req.goal).await
- };
-
- let response = match update_result {
- Ok(Some(directive)) => Json(directive).into_response(),
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(ApiError::new("NOT_FOUND", "Directive not found")),
- )
- .into_response();
- }
- Err(e) => {
- tracing::error!("Failed to update goal: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(ApiError::new("UPDATE_FAILED", &e.to_string())),
- )
- .into_response();
- }
- };
-
- // Nudge the directive reconciler so the user does not wait up to 15s for
- // the next interval tick before the new planner is spawned (clear path) or
- // the small-edit interrupt is consumed (keep path). Best-effort: if the
- // channel is full or closed we just rely on the normal interval.
- state.kick_directive_reconciler();
-
- response
-}
+// (Goal updates now flow through the contracts API. The directive's
+// orchestrator reads the active contract's body when it spawns or
+// replans — see repository::get_active_contract_body and the
+// orchestration module.)
// =============================================================================
// Task Cleanup
@@ -1404,16 +1265,13 @@ pub async fn pick_up_orders(
}
};
- let goal_history = match repository::get_directive_goal_history(pool, id, 3).await {
- Ok(h) => h,
- Err(e) => {
- tracing::warn!("Failed to get goal history: {}", e);
- vec![]
- }
- };
-
- // Build the specialized planning prompt
- let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &goal_history);
+ // Build the specialized planning prompt. The orchestrator reads the
+ // active contract's body itself when it picks up the task; we just
+ // pass the directive shape + steps + orders + generation here.
+ let contract_body = repository::get_active_contract_body(pool, id)
+ .await
+ .unwrap_or_default();
+ let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &contract_body);
// Link orders to the directive
if let Err(e) =
@@ -1984,16 +1842,13 @@ pub async fn pick_up_dog_orders(
}
};
- let goal_history = match repository::get_directive_goal_history(pool, id, 3).await {
- Ok(h) => h,
- Err(e) => {
- tracing::warn!("Failed to get goal history: {}", e);
- vec![]
- }
- };
-
- // Build the specialized planning prompt
- let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &goal_history);
+ // Build the specialized planning prompt. The orchestrator reads the
+ // active contract's body itself when it picks up the task; we just
+ // pass the directive shape + steps + orders + generation here.
+ let contract_body = repository::get_active_contract_body(pool, id)
+ .await
+ .unwrap_or_default();
+ let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &contract_body);
// Link orders to the directive
if let Err(e) =
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index a3a1886..604caea 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -191,7 +191,6 @@ pub fn make_router(state: SharedState) -> Router {
.route("/directives/{id}/steps/{step_id}/complete", post(directives::complete_step))
.route("/directives/{id}/steps/{step_id}/fail", post(directives::fail_step))
.route("/directives/{id}/steps/{step_id}/skip", post(directives::skip_step))
- .route("/directives/{id}/goal", put(directives::update_goal))
.route("/directives/{id}/revisions", get(directives::list_directive_revisions))
.route("/directives/{id}/new-draft", post(directives::new_directive_draft))
.route(
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index 184d12a..437285f 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -25,7 +25,7 @@ use crate::db::models::{
Task,
TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry,
UpdateContractRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest,
- UpdateFileRequest, UpdateGoalRequest, UpdateOrderRequest, UpdateTaskRequest,
+ UpdateFileRequest, UpdateOrderRequest, UpdateTaskRequest,
};
use crate::server::auth::{
ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse,
@@ -109,7 +109,6 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
directives::complete_step,
directives::fail_step,
directives::skip_step,
- directives::update_goal,
directives::list_directive_revisions,
directives::new_directive_draft,
directives::create_directive_task,
@@ -227,7 +226,6 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
crate::server::handlers::directives::CreateDirectiveTaskRequest,
CreateDirectiveRequest,
UpdateDirectiveRequest,
- UpdateGoalRequest,
CreateDirectiveStepRequest,
UpdateDirectiveStepRequest,
CleanupResponse,