diff options
| author | soryu <soryu@soryu.co> | 2026-05-08 16:34:11 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-08 16:34:11 +0100 |
| commit | dce7f50e503dc374aaf879df33e725af16c4cc78 (patch) | |
| tree | 50b6aad1aa47e56b61f0700e224028bb7578cb91 | |
| parent | e4f1622a0f0ac74707cc1c9810e0b99e948d1319 (diff) | |
| download | soryu-dce7f50e503dc374aaf879df33e725af16c4cc78.tar.gz soryu-dce7f50e503dc374aaf879df33e725af16c4cc78.zip | |
feat(directives): drop directives.goal — orchestration reads contract body (#132)
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.tsx | 603 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveList.tsx | 140 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useDirectives.ts | 9 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 19 | ||||
| -rw-r--r-- | makima/frontend/src/routes/directives.tsx | 324 | ||||
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 4 | ||||
| -rw-r--r-- | makima/migrations/20260510000000_drop_directive_goal.sql | 16 | ||||
| -rw-r--r-- | makima/src/bin/makima.rs | 7 | ||||
| -rw-r--r-- | makima/src/daemon/api/directive.rs | 16 | ||||
| -rw-r--r-- | makima/src/daemon/cli/directive.rs | 10 | ||||
| -rw-r--r-- | makima/src/daemon/cli/mod.rs | 3 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 40 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 200 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 365 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 203 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 4 |
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(¤t.title); - let goal = req.goal.as_deref().unwrap_or(¤t.goal); - let goal_changed = goal != current.goal; let status = req.status.as_deref().unwrap_or(¤t.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, ¤t.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(¤t.goal, &req.goal) == GoalChangeKind::Small - { - match try_interrupt_planner_with_goal_edit( - pool, - &state, - id, - ¤t.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, |
