summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/src/components/directives/DirectiveDAG.tsx114
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx91
-rw-r--r--makima/frontend/src/components/directives/OrchestratorStepNode.tsx161
-rw-r--r--makima/frontend/src/hooks/useMultiTaskSubscription.ts153
-rw-r--r--makima/src/bin/makima.rs12
-rw-r--r--makima/src/daemon/cli/directive.rs39
-rw-r--r--makima/src/daemon/cli/mod.rs3
-rw-r--r--makima/src/daemon/skills/directive.md27
-rw-r--r--makima/src/orchestration/directive.rs64
9 files changed, 617 insertions, 47 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDAG.tsx b/makima/frontend/src/components/directives/DirectiveDAG.tsx
index 27a80ac..8c7def9 100644
--- a/makima/frontend/src/components/directives/DirectiveDAG.tsx
+++ b/makima/frontend/src/components/directives/DirectiveDAG.tsx
@@ -1,9 +1,31 @@
import { useMemo } from "react";
import type { DirectiveStep } from "../../lib/api";
import { StepNode } from "./StepNode";
+import {
+ OrchestratorStepNode,
+ type OrchestratorStepType,
+ type OrchestratorStepStatus,
+} from "./OrchestratorStepNode";
+
+export interface VirtualStep {
+ type: OrchestratorStepType;
+ taskId: string;
+ status: OrchestratorStepStatus;
+ label: string;
+ hasQuestions?: boolean;
+}
+
+export interface SpecializedStep {
+ id: string;
+ name: string;
+ type: "orchestrator" | "completion";
+ taskId: string;
+ status: "running" | "completed";
+}
interface DirectiveDAGProps {
steps: DirectiveStep[];
+ specializedSteps?: SpecializedStep[];
onComplete?: (stepId: string) => void;
onFail?: (stepId: string) => void;
onSkip?: (stepId: string) => void;
@@ -13,6 +35,13 @@ interface Layer {
steps: DirectiveStep[];
}
+/** Types that should appear before the regular DAG steps */
+const BEFORE_TYPES = new Set<OrchestratorStepType>([
+ "planning",
+ "replanning",
+ "plan-orders",
+]);
+
function topoSort(steps: DirectiveStep[]): Layer[] {
if (steps.length === 0) return [];
@@ -31,10 +60,13 @@ function topoSort(steps: DirectiveStep[]): Layer[] {
}));
}
-export function DirectiveDAG({ steps, onComplete, onFail, onSkip }: DirectiveDAGProps) {
+export function DirectiveDAG({ steps, specializedSteps, onComplete, onFail, onSkip }: DirectiveDAGProps) {
const layers = useMemo(() => topoSort(steps), [steps]);
- if (steps.length === 0) {
+ const orchestratorSteps = specializedSteps?.filter(s => s.type === "orchestrator") ?? [];
+ const completionSteps = specializedSteps?.filter(s => s.type === "completion") ?? [];
+
+ if (steps.length === 0 && orchestratorSteps.length === 0 && completionSteps.length === 0) {
return (
<div className="text-center py-8 text-[#7788aa] font-mono text-sm">
No steps yet. Add steps to build the DAG.
@@ -44,6 +76,19 @@ export function DirectiveDAG({ steps, onComplete, onFail, onSkip }: DirectiveDAG
return (
<div className="flex flex-col gap-4 items-center py-4">
+ {/* Orchestrator steps (Planning/Cleanup/Orders) - rendered above regular steps */}
+ {orchestratorSteps.map(step => (
+ <SpecializedStepNode key={step.id} step={step} />
+ ))}
+
+ {/* Connector line if both orchestrator step and regular steps exist */}
+ {orchestratorSteps.length > 0 && layers.length > 0 && (
+ <div className="flex justify-center py-1">
+ <div className="w-px h-4 bg-[rgba(117,170,252,0.2)]" />
+ </div>
+ )}
+
+ {/* Regular step layers */}
{layers.map((layer, layerIdx) => (
<div key={layerIdx}>
{layerIdx > 0 && (
@@ -52,18 +97,69 @@ export function DirectiveDAG({ steps, onComplete, onFail, onSkip }: DirectiveDAG
</div>
)}
<div className="flex flex-wrap gap-3 justify-center">
- {layer.steps.map((step) => (
- <StepNode
- key={step.id}
- step={step}
- onComplete={onComplete ? () => onComplete(step.id) : undefined}
- onFail={onFail ? () => onFail(step.id) : undefined}
- onSkip={onSkip ? () => onSkip(step.id) : undefined}
+ {afterSteps.map((vs) => (
+ <OrchestratorStepNode
+ key={`${vs.type}-${vs.taskId}`}
+ type={vs.type}
+ taskId={vs.taskId}
+ status={vs.status}
+ label={vs.label}
+ hasQuestions={vs.hasQuestions}
/>
))}
</div>
</div>
))}
+
+ {/* Connector line if both regular steps and completion step exist */}
+ {completionSteps.length > 0 && layers.length > 0 && (
+ <div className="flex justify-center py-1">
+ <div className="w-px h-4 bg-[rgba(117,170,252,0.2)]" />
+ </div>
+ )}
+
+ {/* Completion steps (PR creation) - rendered below regular steps */}
+ {completionSteps.map(step => (
+ <SpecializedStepNode key={step.id} step={step} />
+ ))}
+ </div>
+ );
+}
+
+function SpecializedStepNode({ step }: { step: SpecializedStep }) {
+ const themeColors = step.type === "orchestrator"
+ ? {
+ bg: "bg-[#1a1a30]",
+ border: "border-[rgba(117,170,252,0.3)]",
+ text: "text-[#75aafc]",
+ dot: "bg-[#75aafc]",
+ label: step.name.startsWith("Cleanup") ? "CLEANUP"
+ : step.name.startsWith("Pick up") ? "ORDERS"
+ : "PLANNING",
+ }
+ : {
+ bg: "bg-[#1a1a10]",
+ border: "border-yellow-900/50",
+ text: "text-yellow-400",
+ dot: "bg-yellow-400",
+ label: "PR",
+ };
+
+ return (
+ <div className={`flex items-center gap-2 px-3 py-2 ${themeColors.bg} border ${themeColors.border} rounded-lg mx-2`}>
+ <span className={`inline-block w-2 h-2 rounded-full ${themeColors.dot} animate-pulse`} />
+ <span className={`text-[9px] font-mono uppercase tracking-wide ${themeColors.text} opacity-60`}>
+ {themeColors.label}
+ </span>
+ <span className={`text-[11px] font-mono ${themeColors.text} flex-1 truncate`}>
+ {step.name}
+ </span>
+ <a
+ href={`/mesh/${step.taskId}`}
+ className="text-[9px] font-mono text-[#556677] hover:text-white underline"
+ >
+ View task
+ </a>
</div>
);
}
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx
index 98940d0..171654d 100644
--- a/makima/frontend/src/components/directives/DirectiveDetail.tsx
+++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx
@@ -1,6 +1,7 @@
import { useState, useMemo, useEffect, useRef } from "react";
import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest } from "../../lib/api";
import { DirectiveDAG } from "./DirectiveDAG";
+import type { SpecializedStep } from "./DirectiveDAG";
import { DirectiveLogStream } from "./DirectiveLogStream";
import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription";
import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext";
@@ -108,6 +109,33 @@ export function DirectiveDetail({
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,
@@ -149,6 +177,36 @@ export function DirectiveDetail({
setEditingGoal(false);
};
+ // Build virtual steps for orchestrator tasks to display in the DAG
+ const virtualSteps = useMemo(() => {
+ const steps: VirtualStep[] = [];
+ if (directive.orchestratorTaskId) {
+ const hasOrchestratorQuestions = directiveQuestions.some(
+ (q) => q.taskId === directive.orchestratorTaskId
+ );
+ steps.push({
+ type: "planning",
+ taskId: directive.orchestratorTaskId,
+ status: "running",
+ label: "Planning",
+ hasQuestions: hasOrchestratorQuestions,
+ });
+ }
+ if (directive.completionTaskId) {
+ const hasCompletionQuestions = directiveQuestions.some(
+ (q) => q.taskId === directive.completionTaskId
+ );
+ steps.push({
+ type: directive.prUrl ? "pr-update" : "pr",
+ taskId: directive.completionTaskId,
+ status: "running",
+ label: directive.prUrl ? "Updating PR" : "Creating PR",
+ hasQuestions: hasCompletionQuestions,
+ });
+ }
+ return steps;
+ }, [directive.orchestratorTaskId, directive.completionTaskId, directive.prUrl, directiveQuestions]);
+
return (
<div className="flex flex-col h-full overflow-y-auto">
{/* Header */}
@@ -217,22 +275,6 @@ export function DirectiveDetail({
</span>
</div>
- {/* Orchestrator planning indicator */}
- {directive.orchestratorTaskId && (
- <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#1a1a30] border border-[rgba(117,170,252,0.2)] rounded">
- <span className="inline-block w-2 h-2 rounded-full bg-[#75aafc] animate-pulse" />
- <span className="text-[10px] font-mono text-[#75aafc]">
- Planning in progress...
- </span>
- <a
- href={`/mesh/${directive.orchestratorTaskId}`}
- className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc] underline ml-auto"
- >
- View task
- </a>
- </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">
@@ -251,22 +293,6 @@ export function DirectiveDetail({
</div>
)}
- {/* Completion task indicator */}
- {directive.completionTaskId && (
- <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#1a1a10] border border-yellow-900 rounded">
- <span className="inline-block w-2 h-2 rounded-full bg-yellow-400 animate-pulse" />
- <span className="text-[10px] font-mono text-yellow-400">
- {directive.prUrl ? "Updating PR..." : "Creating PR..."}
- </span>
- <a
- href={`/mesh/${directive.completionTaskId}`}
- className="text-[9px] font-mono text-[#556677] hover:text-yellow-400 underline ml-auto"
- >
- View task
- </a>
- </div>
- )}
-
{/* Pending Questions */}
{directiveQuestions.length > 0 && (
<div className="mb-2 space-y-2">
@@ -423,6 +449,7 @@ export function DirectiveDetail({
</span>
<DirectiveDAG
steps={directive.steps}
+ specializedSteps={specializedSteps}
onComplete={onCompleteStep}
onFail={onFailStep}
onSkip={onSkipStep}
diff --git a/makima/frontend/src/components/directives/OrchestratorStepNode.tsx b/makima/frontend/src/components/directives/OrchestratorStepNode.tsx
new file mode 100644
index 0000000..9c8e95e
--- /dev/null
+++ b/makima/frontend/src/components/directives/OrchestratorStepNode.tsx
@@ -0,0 +1,161 @@
+export type OrchestratorStepType =
+ | "planning"
+ | "replanning"
+ | "plan-orders"
+ | "pr"
+ | "pr-update"
+ | "cleanup"
+ | "verification";
+
+export type OrchestratorStepStatus = "running" | "completed" | "failed" | "pending";
+
+export interface OrchestratorStepNodeProps {
+ type: OrchestratorStepType;
+ taskId: string;
+ status: OrchestratorStepStatus;
+ label: string;
+ hasQuestions?: boolean;
+}
+
+const TYPE_COLORS: Record<
+ OrchestratorStepType,
+ { accent: string; bg: string; border: string; text: string; dot: string }
+> = {
+ planning: {
+ accent: "#75aafc",
+ bg: "bg-[#0d1a30]",
+ border: "border-[#75aafc]",
+ text: "text-[#75aafc]",
+ dot: "bg-[#75aafc]",
+ },
+ replanning: {
+ accent: "#75aafc",
+ bg: "bg-[#0d1a30]",
+ border: "border-[#75aafc]",
+ text: "text-[#75aafc]",
+ dot: "bg-[#75aafc]",
+ },
+ "plan-orders": {
+ accent: "#c084fc",
+ bg: "bg-[#1a0d30]",
+ border: "border-[#c084fc]",
+ text: "text-[#c084fc]",
+ dot: "bg-[#c084fc]",
+ },
+ pr: {
+ accent: "#34d399",
+ bg: "bg-[#0a1a14]",
+ border: "border-[#34d399]",
+ text: "text-[#34d399]",
+ dot: "bg-[#34d399]",
+ },
+ "pr-update": {
+ accent: "#34d399",
+ bg: "bg-[#0a1a14]",
+ border: "border-[#34d399]",
+ text: "text-[#34d399]",
+ dot: "bg-[#34d399]",
+ },
+ cleanup: {
+ accent: "#7788aa",
+ bg: "bg-[#141a24]",
+ border: "border-[#7788aa]",
+ text: "text-[#7788aa]",
+ dot: "bg-[#7788aa]",
+ },
+ verification: {
+ accent: "#7788aa",
+ bg: "bg-[#141a24]",
+ border: "border-[#7788aa]",
+ text: "text-[#7788aa]",
+ dot: "bg-[#7788aa]",
+ },
+};
+
+const TYPE_LABELS: Record<OrchestratorStepType, string> = {
+ planning: "PLANNING",
+ replanning: "REPLANNING",
+ "plan-orders": "PLAN ORDERS",
+ pr: "PR",
+ "pr-update": "PR UPDATE",
+ cleanup: "CLEANUP",
+ verification: "VERIFICATION",
+};
+
+const STATUS_LABELS: Record<OrchestratorStepStatus, string> = {
+ pending: "PENDING",
+ running: "RUNNING",
+ completed: "DONE",
+ failed: "FAILED",
+};
+
+export function OrchestratorStepNode({
+ type,
+ taskId,
+ status,
+ label,
+ hasQuestions,
+}: OrchestratorStepNodeProps) {
+ const colors = TYPE_COLORS[type];
+ const typeLabel = TYPE_LABELS[type];
+ const statusLabel = STATUS_LABELS[status];
+
+ return (
+ <div
+ className={`${colors.bg} ${colors.border} border border-dashed rounded px-3 py-2 min-w-[160px] max-w-[220px] relative`}
+ >
+ {/* Type badge */}
+ <div className="flex items-center justify-between gap-2 mb-1">
+ <div className="flex items-center gap-1.5 min-w-0">
+ {/* Status dot */}
+ {status === "running" && (
+ <span
+ className={`inline-block w-2 h-2 rounded-full ${colors.dot} animate-pulse shrink-0`}
+ />
+ )}
+ {status === "completed" && (
+ <span className="inline-block w-2 h-2 rounded-full bg-emerald-400 shrink-0" />
+ )}
+ {status === "failed" && (
+ <span className="inline-block w-2 h-2 rounded-full bg-red-400 shrink-0" />
+ )}
+ {status === "pending" && (
+ <span className="inline-block w-2 h-2 rounded-full bg-[#556677] shrink-0" />
+ )}
+ <span className={`text-[9px] font-mono ${colors.text} uppercase shrink-0`}>
+ {typeLabel}
+ </span>
+ </div>
+ <div className="flex items-center gap-1 shrink-0">
+ {hasQuestions && (
+ <span className="inline-block w-2 h-2 rounded-full bg-purple-400 animate-pulse" title="Has pending questions" />
+ )}
+ <span
+ className={`text-[9px] font-mono uppercase ${
+ status === "completed"
+ ? "text-emerald-400"
+ : status === "failed"
+ ? "text-red-400"
+ : colors.text
+ }`}
+ >
+ {statusLabel}
+ </span>
+ </div>
+ </div>
+
+ {/* Label */}
+ <span className="text-[11px] font-mono text-white truncate block font-medium mb-1">
+ {label}
+ </span>
+
+ {/* Task link */}
+ <a
+ href={`/mesh/${taskId}`}
+ className={`text-[9px] font-mono text-[#556677] hover:${colors.text} underline block`}
+ >
+ {status === "running" ? "View running task" : "View task"}
+ </a>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/hooks/useMultiTaskSubscription.ts b/makima/frontend/src/hooks/useMultiTaskSubscription.ts
index 4303f1b..41489c7 100644
--- a/makima/frontend/src/hooks/useMultiTaskSubscription.ts
+++ b/makima/frontend/src/hooks/useMultiTaskSubscription.ts
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
-import { TASK_SUBSCRIBE_ENDPOINT } from "../lib/api";
+import { TASK_SUBSCRIBE_ENDPOINT, getTaskOutput } from "../lib/api";
import type { TaskOutputEvent } from "./useTaskSubscription";
export interface MultiTaskOutputEntry extends TaskOutputEvent {
@@ -7,6 +7,8 @@ export interface MultiTaskOutputEntry extends TaskOutputEvent {
taskLabel: string;
/** Timestamp when the entry was received */
receivedAt: number;
+ /** Whether this entry was backfilled from historical data (not live-streamed) */
+ isBackfill?: boolean;
}
interface UseMultiTaskSubscriptionOptions {
@@ -26,8 +28,11 @@ export function useMultiTaskSubscription(options: UseMultiTaskSubscriptionOption
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const subscribedTasksRef = useRef<Set<string>>(new Set());
+ const backfilledTasksRef = useRef<Set<string>>(new Set());
const taskMapRef = useRef(taskMap);
const enabledRef = useRef(enabled);
+ /** Track which task IDs have already been backfilled to avoid re-fetching */
+ const backfilledTasksRef = useRef<Set<string>>(new Set());
// Keep refs in sync
useEffect(() => {
@@ -38,6 +43,88 @@ export function useMultiTaskSubscription(options: UseMultiTaskSubscriptionOption
enabledRef.current = enabled;
}, [enabled]);
+ /** Max number of historical events to backfill per task */
+ const MAX_BACKFILL_PER_TASK = 200;
+
+ /**
+ * Convert a TaskEvent (from the REST API) into a MultiTaskOutputEntry.
+ * Only converts events with event_type === 'output'.
+ */
+ const convertTaskEventToEntry = useCallback(
+ (event: TaskEvent): MultiTaskOutputEntry | null => {
+ if (event.eventType !== "output") return null;
+ const data = event.eventData;
+ if (!data) return null;
+
+ return {
+ taskId: event.taskId,
+ messageType: (data.messageType as string) || "system",
+ content: (data.content as string) || "",
+ toolName: data.toolName as string | undefined,
+ toolInput: data.toolInput as Record<string, unknown> | undefined,
+ isError: data.isError as boolean | undefined,
+ costUsd: data.costUsd as number | undefined,
+ durationMs: data.durationMs as number | undefined,
+ isPartial: false,
+ taskLabel:
+ taskMapRef.current.get(event.taskId) || event.taskId,
+ receivedAt: new Date(event.createdAt).getTime(),
+ isBackfill: true,
+ };
+ },
+ []
+ );
+
+ /**
+ * Backfill historical log entries for a task from the REST API.
+ * Only fetches once per task ID (tracked in backfilledTasksRef).
+ */
+ const backfillTask = useCallback(
+ async (taskId: string) => {
+ if (backfilledTasksRef.current.has(taskId)) return;
+ backfilledTasksRef.current.add(taskId);
+
+ try {
+ const response = await listTaskEvents(taskId);
+ const events = response.events;
+
+ // The API returns events in DESC order; reverse to get chronological ASC
+ const chronologicalEvents = [...events].reverse();
+
+ // Filter to output events and convert, limiting to MAX_BACKFILL_PER_TASK
+ const backfillEntries: MultiTaskOutputEntry[] = [];
+ for (const event of chronologicalEvents) {
+ const entry = convertTaskEventToEntry(event);
+ if (entry) {
+ backfillEntries.push(entry);
+ if (backfillEntries.length >= MAX_BACKFILL_PER_TASK) break;
+ }
+ }
+
+ if (backfillEntries.length === 0) return;
+
+ // Prepend historical entries before any existing live entries for this task,
+ // maintaining overall chronological order across all tasks
+ setEntries((prev) => {
+ // Merge backfill entries with existing entries, maintaining chronological order
+ const merged = [...backfillEntries, ...prev];
+ // Sort by receivedAt to ensure proper chronological ordering
+ merged.sort((a, b) => a.receivedAt - b.receivedAt);
+ // Trim to maxEntries
+ if (merged.length > maxEntries) {
+ return merged.slice(merged.length - maxEntries);
+ }
+ return merged;
+ });
+ } catch (e) {
+ console.error(`Failed to backfill task events for ${taskId}:`, e);
+ // Remove from backfilled set so it can be retried
+ backfilledTasksRef.current.delete(taskId);
+ }
+ },
+ [convertTaskEventToEntry, maxEntries]
+ );
+
// Derive task IDs from the map, stabilized to avoid unnecessary effect triggers
const taskIdsKey = useMemo(() => Array.from(taskMap.keys()).sort().join(","), [taskMap]);
const taskIds = useMemo(() => Array.from(taskMap.keys()), [taskIdsKey]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -56,6 +143,58 @@ export function useMultiTaskSubscription(options: UseMultiTaskSubscriptionOption
}
}, []);
+ const backfillTask = useCallback(
+ async (taskId: string, label: string) => {
+ if (backfilledTasksRef.current.has(taskId)) return;
+ backfilledTasksRef.current.add(taskId);
+
+ try {
+ const response = await getTaskOutput(taskId);
+ if (response.entries.length === 0) return;
+
+ const historicalEntries: MultiTaskOutputEntry[] = response.entries.map(
+ (entry) => ({
+ taskId: entry.taskId,
+ messageType: entry.messageType,
+ content: entry.content,
+ toolName: entry.toolName,
+ toolInput: entry.toolInput,
+ isError: entry.isError,
+ costUsd: entry.costUsd,
+ durationMs: entry.durationMs,
+ isPartial: false,
+ taskLabel: label,
+ receivedAt: new Date(entry.createdAt || Date.now()).getTime(),
+ })
+ );
+
+ setEntries((prev) => {
+ // De-duplicate by checking if content+taskId+messageType already exists
+ const existingKeys = new Set(
+ prev.map(
+ (e) =>
+ `${e.taskId}:${e.messageType}:${e.content.slice(0, 100)}`
+ )
+ );
+ const newHistorical = historicalEntries.filter(
+ (e) =>
+ !existingKeys.has(
+ `${e.taskId}:${e.messageType}:${e.content.slice(0, 100)}`
+ )
+ );
+ const combined = [...newHistorical, ...prev];
+ if (combined.length > maxEntries) {
+ return combined.slice(combined.length - maxEntries);
+ }
+ return combined;
+ });
+ } catch (e) {
+ console.error(`Failed to backfill task ${taskId}:`, e);
+ }
+ },
+ [maxEntries]
+ );
+
const connect = useCallback(() => {
const currentState = wsRef.current?.readyState;
if (currentState === WebSocket.OPEN || currentState === WebSocket.CONNECTING) {
@@ -150,6 +289,11 @@ export function useMultiTaskSubscription(options: UseMultiTaskSubscriptionOption
// Set desired subscriptions and connect
subscribedTasksRef.current = newTaskIds;
connect();
+ // Backfill all initial tasks
+ for (const taskId of newTaskIds) {
+ const label = taskMapRef.current.get(taskId) || taskId;
+ backfillTask(taskId, label);
+ }
return;
}
@@ -160,13 +304,15 @@ export function useMultiTaskSubscription(options: UseMultiTaskSubscriptionOption
}
}
- // Subscribe to new tasks
+ // Subscribe to new tasks and backfill their history
for (const newId of newTaskIds) {
if (!subscribedTasksRef.current.has(newId)) {
subscribeToTask(ws, newId);
+ const label = taskMapRef.current.get(newId) || newId;
+ backfillTask(newId, label);
}
}
- }, [taskIds, enabled, connect, subscribeToTask, unsubscribeFromTask]);
+ }, [taskIds, enabled, connect, subscribeToTask, unsubscribeFromTask, backfillTask]);
// Cleanup on unmount
useEffect(() => {
@@ -182,6 +328,7 @@ export function useMultiTaskSubscription(options: UseMultiTaskSubscriptionOption
const clearEntries = useCallback(() => {
setEntries([]);
+ backfilledTasksRef.current.clear();
}, []);
return {
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs
index 070e28e..aaf5a08 100644
--- a/makima/src/bin/makima.rs
+++ b/makima/src/bin/makima.rs
@@ -800,6 +800,18 @@ async fn run_directive(
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
+ DirectiveCommand::Ask(args) => {
+ let client = ApiClient::new(args.common.api_url.clone(), args.common.api_key.clone())?;
+ eprintln!("Asking user: {}...", args.question);
+ let choices = args
+ .choices
+ .map(|c| c.split(',').map(|s| s.trim().to_string()).collect())
+ .unwrap_or_default();
+ let result = client
+ .supervisor_ask(&args.question, choices, args.context, args.timeout, args.phaseguard, args.multi_select, args.non_blocking, args.question_type)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
}
Ok(())
diff --git a/makima/src/daemon/cli/directive.rs b/makima/src/daemon/cli/directive.rs
index 8a6a9f2..7c8451f 100644
--- a/makima/src/daemon/cli/directive.rs
+++ b/makima/src/daemon/cli/directive.rs
@@ -111,6 +111,45 @@ pub struct BatchAddStepsArgs {
pub json: String,
}
+/// Arguments for ask command (ask user a question from directive context).
+#[derive(Args, Debug)]
+pub struct AskArgs {
+ #[command(flatten)]
+ pub common: DirectiveArgs,
+
+ /// The question to ask
+ #[arg(index = 1)]
+ pub question: String,
+
+ /// Optional choices (comma-separated)
+ #[arg(long)]
+ pub choices: Option<String>,
+
+ /// Context about what this relates to
+ #[arg(long)]
+ pub context: Option<String>,
+
+ /// Timeout in seconds (default: 3600 = 1 hour)
+ #[arg(long, default_value = "3600")]
+ pub timeout: i32,
+
+ /// Block indefinitely until user responds (no timeout)
+ #[arg(long, default_value = "false")]
+ pub phaseguard: bool,
+
+ /// Allow selecting multiple choices (response will be comma-separated)
+ #[arg(long, default_value = "false")]
+ pub multi_select: bool,
+
+ /// Non-blocking mode - returns immediately without waiting for response
+ #[arg(long, default_value = "false")]
+ pub non_blocking: bool,
+
+ /// Question type (general, phase_confirmation, contract_complete)
+ #[arg(long, default_value = "general")]
+ pub question_type: String,
+}
+
/// Arguments for update command.
#[derive(Args, Debug)]
pub struct UpdateArgs {
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs
index bcaaa70..8063541 100644
--- a/makima/src/daemon/cli/mod.rs
+++ b/makima/src/daemon/cli/mod.rs
@@ -249,6 +249,9 @@ pub enum DirectiveCommand {
/// Update directive metadata (PR URL, etc.)
Update(directive::UpdateArgs),
+
+ /// Ask a question and wait for user feedback
+ Ask(directive::AskArgs),
}
impl Cli {
diff --git a/makima/src/daemon/skills/directive.md b/makima/src/daemon/skills/directive.md
index 9d2b644..02de836 100644
--- a/makima/src/daemon/skills/directive.md
+++ b/makima/src/daemon/skills/directive.md
@@ -82,6 +82,32 @@ makima directive update --pr-url "<url>" --pr-branch "<branch>"
```
Updates the directive's PR URL and/or PR branch. Used by completion tasks to store the PR URL after creating it.
+### Ask User a Question
+```bash
+makima directive ask "<question>"
+```
+Asks the user a question and waits for their response. Questions appear on the directive page with a yellow indicator and can be answered inline.
+
+Options:
+- `--choices "opt1,opt2,opt3"` - Provide choices for the user to select from
+- `--context "<context>"` - Additional context to help the user understand the question
+- `--timeout <seconds>` - Wait timeout (default: 3600 = 1 hour)
+- `--phaseguard` - Block indefinitely until the user responds (no timeout). Recommended for critical decisions during planning.
+- `--multi-select` - Allow the user to select multiple choices
+- `--non-blocking` - Return immediately without waiting for a response
+- `--question-type <general|phase_confirmation|contract_complete>` - Question type
+
+**When to use:**
+- During planning, when you need clarification on requirements or approach
+- When there are multiple valid approaches and user preference matters
+- When a decision requires domain knowledge you don't have
+- Always use `--phaseguard` for questions that block progress (the reconcile mode on the directive also controls this)
+
+**Example:**
+```bash
+makima directive ask "Should we use REST or GraphQL for the new API?" --choices "REST,GraphQL" --context "The existing codebase uses REST but the frontend team prefers GraphQL" --phaseguard
+```
+
## Memory Commands
Directives have an optional key-value memory system that persists across steps and planning cycles. Use memory to share context, decisions, and learned information between steps — so downstream tasks don't need to re-discover what earlier steps already figured out.
@@ -167,6 +193,7 @@ makima directive memory-batch-set --json '{"framework": "axum", "orm": "sqlx", "
### Initial Setup
1. Check the directive status to understand the goal
2. Decompose the goal into steps with clear dependencies
+ - If requirements are unclear, use `makima directive ask` to get clarification before finalizing the plan
3. Add steps using `add-step` with appropriate `--depends-on` flags
4. Start the directive with `start`
5. Steps with no dependencies will become `ready` immediately
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs
index 420b3e1..b91781c 100644
--- a/makima/src/orchestration/directive.rs
+++ b/makima/src/orchestration/directive.rs
@@ -165,7 +165,9 @@ impl DirectiveOrchestrator {
{merge_preamble}\
INSTRUCTIONS:\n{task_plan}\n\
When done, the system will automatically mark this step as completed.\n\
- If you cannot complete the task, report the failure clearly.",
+ If you cannot complete the task, report the failure clearly.\n\n\
+ If you need clarification or encounter a decision that requires user input, you can ask:\n\
+ \x20 makima directive ask \"Your question\" --phaseguard",
directive_title = step.directive_title,
step_name = step.step_name,
description = step.step_description.as_deref().unwrap_or("(none)"),
@@ -1308,7 +1310,9 @@ fn build_planning_prompt(
\x20 makima directive remove-step <step_id>\n\
2. Then, add new steps for the updated goal. Use generation {}.\n\
3. New steps that build on completed work MUST use --depends-on to inherit the worktree.\n\
- 4. Ensure the new plan fully addresses the UPDATED goal.\n\n",
+ 4. Ensure the new plan fully addresses the UPDATED goal.\n\
+ 5. If the updated goal is unclear or ambiguous, ask the user for clarification using:\n\
+ \x20 makima directive ask \"<question>\" --phaseguard\n\n",
generation
));
}
@@ -1359,6 +1363,28 @@ Guidelines:
they should BOTH list that prior step in dependsOn.
IMPORTANT: Each step's taskPlan must be self-contained. The executing instance won't have your planning context.
+
+ASKING QUESTIONS:
+If you need clarification from the user before finalizing the plan, you can ask questions:
+ makima directive ask "Your question here"
+ makima directive ask "Which approach?" --choices "Option A,Option B" --phaseguard
+ makima directive ask "Confirm this approach?" --context "Additional context here" --phaseguard
+
+Use --phaseguard for questions that block progress (the question will wait indefinitely for a response).
+Without --phaseguard, questions timeout based on the directive's reconcile mode:
+- Reconcile ON: questions block indefinitely until answered
+- Reconcile OFF: questions timeout after 30 seconds with no response
+
+When to ask:
+- Requirements are ambiguous and multiple interpretations are valid
+- There are multiple equally valid technical approaches
+- You need domain-specific knowledge that cannot be inferred from the codebase
+- A decision has significant downstream impact and user preference matters
+
+Do NOT ask questions for:
+- Implementation details you can determine from the codebase
+- Standard engineering decisions with clear best practices
+- Trivial choices that do not significantly affect the outcome
"#,
title = directive.title,
goal = directive.goal,
@@ -1480,6 +1506,9 @@ Already-merged branches will be a no-op. If a merge fails with conflicts:
1. First try: `git merge --abort` then retry with `git merge <the-failing-branch> -X theirs --no-edit`
2. If that also fails, manually resolve the conflicts, `git add .`, and `git commit --no-edit`
3. Continue with remaining merges
+
+If you encounter issues you cannot resolve (e.g., persistent merge conflicts, PR update failures), you can ask for help:
+ makima directive ask "Your question" --phaseguard
"#,
title = directive.title,
goal = directive.goal,
@@ -1539,6 +1568,9 @@ For each step branch merge, if a merge fails with conflicts:
1. First try: `git merge --abort` then retry with `git merge <the-failing-branch> -X theirs --no-edit`
2. If that also fails, manually resolve the conflicts, `git add .`, and `git commit --no-edit`
3. Continue with remaining merges
+
+If you encounter issues you cannot resolve (e.g., persistent merge conflicts, PR creation failures), you can ask for help:
+ makima directive ask "Your question" --phaseguard
"#,
title = directive.title,
goal = directive.goal,
@@ -1679,7 +1711,10 @@ makima directive remove-step <step_id>
6. After processing all steps, report a summary of what was cleaned up and what was left.
-IMPORTANT: Only remove steps whose task branches have been verified as merged. Never remove unmerged steps."#,
+IMPORTANT: Only remove steps whose task branches have been verified as merged. Never remove unmerged steps.
+
+If you encounter issues you cannot resolve during cleanup, you can ask for help:
+ makima directive ask "Your question" --phaseguard"#,
title = directive.title,
pr_branch = pr_branch,
base_branch = base_branch,
@@ -1900,6 +1935,29 @@ Guidelines:
they should BOTH list that prior step in dependsOn.
IMPORTANT: Each step's taskPlan must be self-contained. The executing instance won't have your planning context.
+
+## Asking Questions
+
+If you need clarification about the goal, requirements, or implementation approach, you can ask the user:
+```bash
+makima directive ask "Your question here"
+```
+
+Options:
+- `--choices "opt1,opt2,opt3"` - Provide choices
+- `--context "<context>"` - Additional context
+- `--phaseguard` - Block until response (recommended for important questions)
+
+The question will appear in the directive UI. Behavior depends on reconcile mode:
+- Reconcile ON: blocks until user responds
+- Reconcile OFF: times out after 30s (use for non-critical questions)
+
+Use this when:
+- The goal is ambiguous and could be interpreted multiple ways
+- You need to choose between significantly different implementation approaches
+- You discover constraints that affect the plan
+
+Do NOT ask questions for trivial decisions — use your best judgment.
"#,
generation = generation,
));