refactor: Rename agent modes breakdown→plan, decompose→detail

Full rename across the codebase for clarity:
- breakdown (initiative→phases) is now "plan"
- decompose (phase→tasks) is now "detail"

Updates schema enums, TypeScript types, events, prompts, output handler,
tRPC procedures, CLI commands, frontend components, tests, and docs.
Also fixes 0022 migration multi-statement issue (adds statement-breakpoint markers).
This commit is contained in:
Lukas May
2026-02-10 10:51:42 +01:00
parent f9f8b4c185
commit 0407f05332
51 changed files with 551 additions and 483 deletions

View File

@@ -10,8 +10,8 @@ interface ChangeSetBannerProps {
}
const MODE_LABELS: Record<string, string> = {
breakdown: "phases",
decompose: "tasks",
plan: "phases",
detail: "tasks",
refine: "pages",
};

View File

@@ -5,7 +5,7 @@ import { topologicalSortPhases, type DependencyEdge } from "@codewalk-district/s
import {
ExecutionProvider,
PhaseActions,
BreakdownSection,
PlanSection,
TaskModal,
type PhaseData,
} from "@/components/execution";
@@ -22,7 +22,7 @@ interface ExecutionTabProps {
phasesLoading: boolean;
phasesLoaded: boolean;
dependencyEdges: DependencyEdge[];
mergeTarget?: string | null;
branch?: string | null;
}
export function ExecutionTab({
@@ -31,7 +31,7 @@ export function ExecutionTab({
phasesLoading,
phasesLoaded,
dependencyEdges,
mergeTarget,
branch,
}: ExecutionTabProps) {
// Topological sort
const sortedPhases = useMemo(
@@ -53,7 +53,7 @@ export function ExecutionTab({
return map;
}, [dependencyEdges, sortedPhases]);
// Decompose agent tracking: map phaseId → most recent active decompose agent
// Detail agent tracking: map phaseId → most recent active detail agent
const agentsQuery = trpc.listAgents.useQuery();
const allAgents = agentsQuery.data ?? [];
@@ -133,8 +133,8 @@ export function ExecutionTab({
return { taskCountsByPhase: counts, tasksByPhase: grouped };
}, [allTasks]);
// Map phaseId → most recent active decompose agent
const decomposeAgentByPhase = useMemo(() => {
// Map phaseId → most recent active detail agent
const detailAgentByPhase = useMemo(() => {
const map = new Map<string, (typeof allAgents)[number]>();
// Build taskId → phaseId lookup from allTasks
const taskPhaseMap = new Map<string, string>();
@@ -143,7 +143,7 @@ export function ExecutionTab({
}
const candidates = allAgents.filter(
(a) =>
a.mode === "decompose" &&
a.mode === "detail" &&
a.initiativeId === initiativeId &&
["running", "waiting_for_input", "idle"].includes(a.status) &&
!a.userDismissedAt,
@@ -163,7 +163,7 @@ export function ExecutionTab({
return map;
}, [allAgents, allTasks, initiativeId]);
// Phase IDs that have zero tasks (eligible for breakdown)
// Phase IDs that have zero tasks (eligible for detailing)
const phasesWithoutTasks = useMemo(
() =>
sortedPhases
@@ -178,11 +178,11 @@ export function ExecutionTab({
[sortedPhases],
);
// No phases yet and not adding — show breakdown section
// No phases yet and not adding — show plan section
if (phasesLoaded && sortedPhases.length === 0 && !isAddingPhase) {
return (
<ExecutionProvider>
<BreakdownSection
<PlanSection
initiativeId={initiativeId}
phasesLoaded={phasesLoaded}
phases={sortedPhases}
@@ -209,7 +209,7 @@ export function ExecutionTab({
phases={sortedPhases}
onAddPhase={handleStartAdd}
phasesWithoutTasks={phasesWithoutTasks}
decomposeAgentByPhase={decomposeAgentByPhase}
detailAgentByPhase={detailAgentByPhase}
/>
</div>
@@ -258,8 +258,8 @@ export function ExecutionTab({
tasks={tasksByPhase[activePhase.id] ?? []}
tasksLoading={allTasksQuery.isLoading}
onDelete={() => deletePhase.mutate({ id: activePhase.id })}
decomposeAgent={decomposeAgentByPhase.get(activePhase.id) ?? null}
mergeTarget={mergeTarget}
detailAgent={detailAgentByPhase.get(activePhase.id) ?? null}
branch={branch}
/>
) : (
<PhaseDetailEmpty />

View File

@@ -18,7 +18,7 @@ export interface SerializedInitiative {
name: string;
status: "active" | "completed" | "archived";
mergeRequiresApproval: boolean;
mergeTarget: string | null;
branch: string | null;
createdAt: string;
updatedAt: string;
}
@@ -26,7 +26,7 @@ export interface SerializedInitiative {
interface InitiativeCardProps {
initiative: SerializedInitiative;
onView: () => void;
onSpawnArchitect: (mode: "discuss" | "breakdown") => void;
onSpawnArchitect: (mode: "discuss" | "plan") => void;
onDelete: () => void;
}
@@ -94,9 +94,9 @@ export function InitiativeCard({
Discuss
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSpawnArchitect("breakdown")}
onClick={() => onSpawnArchitect("plan")}
>
Breakdown
Plan
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -11,7 +11,7 @@ interface InitiativeListProps {
onViewInitiative: (id: string) => void;
onSpawnArchitect: (
initiativeId: string,
mode: "discuss" | "breakdown",
mode: "discuss" | "plan",
) => void;
onDeleteInitiative: (id: string) => void;
}

View File

@@ -31,18 +31,18 @@ export function SpawnArchitectDropdown({
onSuccess: handleSuccess,
});
const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, {
const planSpawn = useSpawnMutation(trpc.spawnArchitectPlan.useMutation, {
onSuccess: handleSuccess,
});
const isPending = discussSpawn.isSpawning || breakdownSpawn.isSpawning;
const isPending = discussSpawn.isSpawning || planSpawn.isSpawning;
function handleDiscuss() {
discussSpawn.spawn({ initiativeId });
}
function handleBreakdown() {
breakdownSpawn.spawn({ initiativeId });
function handlePlan() {
planSpawn.spawn({ initiativeId });
}
return (
@@ -57,8 +57,8 @@ export function SpawnArchitectDropdown({
<DropdownMenuItem onClick={handleDiscuss} disabled={isPending}>
Discuss
</DropdownMenuItem>
<DropdownMenuItem onClick={handleBreakdown} disabled={isPending}>
Breakdown
<DropdownMenuItem onClick={handlePlan} disabled={isPending}>
Plan
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { Loader2, Plus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
@@ -8,45 +8,55 @@ interface PhaseActionsProps {
phases: Array<{ id: string; status: string }>;
onAddPhase: () => void;
phasesWithoutTasks: string[];
decomposeAgentByPhase: Map<string, { id: string; status: string }>;
detailAgentByPhase: Map<string, { id: string; status: string }>;
}
export function PhaseActions({
onAddPhase,
phasesWithoutTasks,
decomposeAgentByPhase,
detailAgentByPhase,
}: PhaseActionsProps) {
const decomposeMutation = trpc.spawnArchitectDecompose.useMutation();
const detailMutation = trpc.spawnArchitectDetail.useMutation();
const [isDetailingAll, setIsDetailingAll] = useState(false);
// Phases eligible for breakdown: no tasks AND no active decompose agent
// Phases eligible for detailing: no tasks AND no active detail agent
const eligiblePhaseIds = useMemo(
() => phasesWithoutTasks.filter((id) => !decomposeAgentByPhase.has(id)),
[phasesWithoutTasks, decomposeAgentByPhase],
() => phasesWithoutTasks.filter((id) => !detailAgentByPhase.has(id)),
[phasesWithoutTasks, detailAgentByPhase],
);
// Count of phases currently being decomposed
const activeDecomposeCount = useMemo(() => {
// Count of phases currently being detailed
const activeDetailCount = useMemo(() => {
let count = 0;
for (const [, agent] of decomposeAgentByPhase) {
for (const [, agent] of detailAgentByPhase) {
if (agent.status === "running" || agent.status === "waiting_for_input") {
count++;
}
}
return count;
}, [decomposeAgentByPhase]);
}, [detailAgentByPhase]);
const handleBreakdownAll = useCallback(() => {
for (const phaseId of eligiblePhaseIds) {
decomposeMutation.mutate({ phaseId });
const handleDetailAll = useCallback(async () => {
setIsDetailingAll(true);
try {
for (const phaseId of eligiblePhaseIds) {
try {
await detailMutation.mutateAsync({ phaseId });
} catch {
// CONFLICT errors expected if agent already exists — continue
}
}
} finally {
setIsDetailingAll(false);
}
}, [eligiblePhaseIds, decomposeMutation]);
}, [eligiblePhaseIds, detailMutation]);
return (
<div className="flex items-center gap-2">
{activeDecomposeCount > 0 && (
{activeDetailCount > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Decomposing ({activeDecomposeCount})
Detailing ({activeDetailCount})
</div>
)}
<Button
@@ -61,12 +71,16 @@ export function PhaseActions({
<Button
variant="outline"
size="sm"
disabled={eligiblePhaseIds.length === 0}
onClick={handleBreakdownAll}
disabled={eligiblePhaseIds.length === 0 || isDetailingAll}
onClick={handleDetailAll}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
Breakdown All
{isDetailingAll ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
Detail All
</Button>
</div>
);

View File

@@ -36,8 +36,8 @@ interface PhaseDetailPanelProps {
tasks: SerializedTask[];
tasksLoading: boolean;
onDelete?: () => void;
mergeTarget?: string | null;
decomposeAgent: {
branch?: string | null;
detailAgent: {
id: string;
status: string;
createdAt: string | Date;
@@ -53,8 +53,8 @@ export function PhaseDetailPanel({
tasks,
tasksLoading,
onDelete,
mergeTarget,
decomposeAgent,
branch,
detailAgent,
}: PhaseDetailPanelProps) {
const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } =
useExecutionContext();
@@ -137,46 +137,46 @@ export function PhaseDetailPanel({
handleRegisterTasks(phase.id, entries);
}, [tasks, phase.id, displayIndex, phase.name, handleTaskCounts, handleRegisterTasks]);
// --- Change sets for decompose agent ---
// --- Change sets for detail agent ---
const changeSetsQuery = trpc.listChangeSets.useQuery(
{ agentId: decomposeAgent?.id ?? "" },
{ enabled: !!decomposeAgent && decomposeAgent.status === "idle" },
{ agentId: detailAgent?.id ?? "" },
{ enabled: !!detailAgent && detailAgent.status === "idle" },
);
const latestChangeSet = useMemo(
() => (changeSetsQuery.data ?? []).find((cs) => cs.status === "applied") ?? null,
[changeSetsQuery.data],
);
// --- Decompose spawn ---
const decomposeMutation = trpc.spawnArchitectDecompose.useMutation();
// --- Detail spawn ---
const detailMutation = trpc.spawnArchitectDetail.useMutation();
const handleDecompose = useCallback(() => {
decomposeMutation.mutate({ phaseId: phase.id });
}, [phase.id, decomposeMutation]);
const handleDetail = useCallback(() => {
detailMutation.mutate({ phaseId: phase.id });
}, [phase.id, detailMutation]);
// --- Dismiss handler for decompose agent ---
// --- Dismiss handler for detail agent ---
const dismissMutation = trpc.dismissAgent.useMutation();
const handleDismissDecompose = useCallback(() => {
if (!decomposeAgent) return;
dismissMutation.mutate({ id: decomposeAgent.id });
}, [decomposeAgent, dismissMutation]);
const handleDismissDetail = useCallback(() => {
if (!detailAgent) return;
dismissMutation.mutate({ id: detailAgent.id });
}, [detailAgent, dismissMutation]);
// Compute phase branch name if initiative has a merge target
const phaseBranch = mergeTarget
? `${mergeTarget}-phase-${phase.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`
const phaseBranch = branch
? `${branch}-phase-${phase.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`
: null;
const isPendingReview = phase.status === "pending_review";
const sortedTasks = sortByPriorityAndQueueTime(tasks);
const hasTasks = tasks.length > 0;
const isDecomposeRunning =
decomposeAgent?.status === "running" ||
decomposeAgent?.status === "waiting_for_input";
const showBreakdownButton =
!decomposeAgent && !hasTasks;
const isDetailRunning =
detailAgent?.status === "running" ||
detailAgent?.status === "waiting_for_input";
const showDetailButton =
!detailAgent && !hasTasks;
const showChangeSet =
decomposeAgent?.status === "idle" && !!latestChangeSet;
detailAgent?.status === "idle" && !!latestChangeSet;
return (
<div className="space-y-6">
@@ -214,25 +214,25 @@ export function PhaseDetailPanel({
</span>
)}
{/* Breakdown button in header */}
{showBreakdownButton && (
{/* Detail button in header */}
{showDetailButton && (
<Button
variant="outline"
size="sm"
onClick={handleDecompose}
disabled={decomposeMutation.isPending}
onClick={handleDetail}
disabled={detailMutation.isPending}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
{decomposeMutation.isPending ? "Starting..." : "Breakdown"}
{detailMutation.isPending ? "Starting..." : "Detail Tasks"}
</Button>
)}
{/* Running indicator in header */}
{isDecomposeRunning && (
{isDetailRunning && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Breaking down...
Detailing...
</div>
)}
@@ -342,11 +342,11 @@ export function PhaseDetailPanel({
)}
</div>
{/* Decompose change set */}
{/* Detail change set */}
{showChangeSet && (
<ChangeSetBanner
changeSet={latestChangeSet!}
onDismiss={handleDismissDecompose}
onDismiss={handleDismissDetail}
/>
)}

View File

@@ -1,7 +1,7 @@
import { Skeleton } from "@/components/Skeleton";
import { useExecutionContext, type PhaseData } from "./ExecutionContext";
import { PhaseWithTasks } from "./PhaseWithTasks";
import { BreakdownSection } from "./BreakdownSection";
import { PlanSection } from "./PlanSection";
interface PhasesListProps {
initiativeId: string;
@@ -35,7 +35,7 @@ export function PhasesList({
if (phasesLoaded && phases.length === 0) {
return (
<BreakdownSection
<PlanSection
initiativeId={initiativeId}
phasesLoaded={phasesLoaded}
phases={phases}

View File

@@ -5,27 +5,27 @@ import { trpc } from "@/lib/trpc";
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
interface BreakdownSectionProps {
interface PlanSectionProps {
initiativeId: string;
phasesLoaded: boolean;
phases: Array<{ status: string }>;
onAddPhase?: () => void;
}
export function BreakdownSection({
export function PlanSection({
initiativeId,
phasesLoaded,
phases,
onAddPhase,
}: BreakdownSectionProps) {
// Breakdown agent tracking
}: PlanSectionProps) {
// Plan agent tracking
const agentsQuery = trpc.listAgents.useQuery();
const allAgents = agentsQuery.data ?? [];
const breakdownAgent = useMemo(() => {
const planAgent = useMemo(() => {
const candidates = allAgents
.filter(
(a) =>
a.mode === "breakdown" &&
a.mode === "plan" &&
a.initiativeId === initiativeId &&
["running", "waiting_for_input", "idle"].includes(a.status),
)
@@ -36,12 +36,12 @@ export function BreakdownSection({
return candidates[0] ?? null;
}, [allAgents, initiativeId]);
const isBreakdownRunning = breakdownAgent?.status === "running";
const isPlanRunning = planAgent?.status === "running";
// Query change sets when we have a completed breakdown agent
// Query change sets when we have a completed plan agent
const changeSetsQuery = trpc.listChangeSets.useQuery(
{ agentId: breakdownAgent?.id ?? "" },
{ enabled: !!breakdownAgent && breakdownAgent.status === "idle" },
{ agentId: planAgent?.id ?? "" },
{ enabled: !!planAgent && planAgent.status === "idle" },
);
const latestChangeSet = useMemo(
() => (changeSetsQuery.data ?? []).find((cs) => cs.status === "applied") ?? null,
@@ -50,18 +50,18 @@ export function BreakdownSection({
const dismissMutation = trpc.dismissAgent.useMutation();
const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, {
const planSpawn = useSpawnMutation(trpc.spawnArchitectPlan.useMutation, {
showToast: false,
});
const handleBreakdown = useCallback(() => {
breakdownSpawn.spawn({ initiativeId });
}, [initiativeId, breakdownSpawn]);
const handlePlan = useCallback(() => {
planSpawn.spawn({ initiativeId });
}, [initiativeId, planSpawn]);
const handleDismiss = useCallback(() => {
if (!breakdownAgent) return;
dismissMutation.mutate({ id: breakdownAgent.id });
}, [breakdownAgent, dismissMutation]);
if (!planAgent) return;
dismissMutation.mutate({ id: planAgent.id });
}, [planAgent, dismissMutation]);
// Don't render during loading
if (!phasesLoaded) {
@@ -73,8 +73,8 @@ export function BreakdownSection({
return null;
}
// Show change set banner when breakdown agent completed
if (breakdownAgent?.status === "idle" && latestChangeSet) {
// Show change set banner when plan agent completed
if (planAgent?.status === "idle" && latestChangeSet) {
return (
<div className="py-4">
<ChangeSetBanner
@@ -88,24 +88,24 @@ export function BreakdownSection({
return (
<div className="py-8 text-center space-y-3">
<p className="text-muted-foreground">No phases yet</p>
{isBreakdownRunning ? (
{isPlanRunning ? (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Breaking down initiative...
Planning phases...
</div>
) : (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleBreakdown}
disabled={breakdownSpawn.isSpawning}
onClick={handlePlan}
disabled={planSpawn.isSpawning}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
{breakdownSpawn.isSpawning
{planSpawn.isSpawning
? "Starting..."
: "Break Down Initiative"}
: "Plan Phases"}
</Button>
{onAddPhase && (
<>
@@ -123,9 +123,9 @@ export function BreakdownSection({
)}
</div>
)}
{breakdownSpawn.isError && (
{planSpawn.isError && (
<p className="text-xs text-destructive">
{breakdownSpawn.error}
{planSpawn.error}
</p>
)}
</div>

View File

@@ -1,5 +1,5 @@
export { ExecutionProvider, useExecutionContext } from "./ExecutionContext";
export { BreakdownSection } from "./BreakdownSection";
export { PlanSection } from "./PlanSection";
export { PhaseActions } from "./PhaseActions";
export { PhaseSidebarItem } from "./PhaseSidebarItem";
export { PhaseDetailPanel, PhaseDetailEmpty } from "./PhaseDetailPanel";

View File

@@ -42,8 +42,8 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
// --- Architect spawns ---
spawnArchitectRefine: ["listAgents"],
spawnArchitectDiscuss: ["listAgents"],
spawnArchitectBreakdown: ["listAgents"],
spawnArchitectDecompose: ["listAgents", "listInitiativeTasks"],
spawnArchitectPlan: ["listAgents"],
spawnArchitectDetail: ["listAgents", "listInitiativeTasks"],
// --- Initiatives ---
createInitiative: ["listInitiatives"],

View File

@@ -0,0 +1,11 @@
const MODE_LABELS: Record<string, string> = {
plan: 'Plan',
detail: 'Detail',
discuss: 'Discuss',
refine: 'Refine',
execute: 'Execute',
};
export function modeLabel(mode: string): string {
return MODE_LABELS[mode] ?? mode;
}

View File

@@ -11,6 +11,7 @@ import { AgentOutputViewer } from "@/components/AgentOutputViewer";
import { AgentActions } from "@/components/AgentActions";
import { formatRelativeTime } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { modeLabel } from "@/lib/labels";
import { StatusDot } from "@/components/StatusDot";
import { useLiveUpdates } from "@/hooks";
@@ -247,7 +248,7 @@ function AgentsPage() {
{agent.provider}
</Badge>
<Badge variant="secondary" className="text-xs">
{agent.mode}
{modeLabel(agent.mode)}
</Badge>
{/* Action dropdown */}
<div onClick={(e) => e.stopPropagation()}>

View File

@@ -10,8 +10,8 @@ import { ReviewTab } from "@/components/review";
import { PipelineTab } from "@/components/pipeline";
import { useLiveUpdates } from "@/hooks";
type Tab = "content" | "breakdown" | "execution" | "review";
const TABS: Tab[] = ["content", "breakdown", "execution", "review"];
type Tab = "content" | "plan" | "execution" | "review";
const TABS: Tab[] = ["content", "plan", "execution", "review"];
export const Route = createFileRoute("/initiatives/$id")({
component: InitiativeDetailPage,
@@ -90,7 +90,7 @@ function InitiativeDetailPage() {
name: initiative.name,
status: initiative.status,
executionMode: (initiative as any).executionMode as string | undefined,
mergeTarget: (initiative as any).mergeTarget as string | null | undefined,
branch: (initiative as any).branch as string | null | undefined,
};
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
@@ -130,14 +130,14 @@ function InitiativeDetailPage() {
{/* Tab content */}
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
{activeTab === "breakdown" && (
{activeTab === "plan" && (
<ExecutionTab
initiativeId={id}
phases={phases}
phasesLoading={phasesQuery.isLoading}
phasesLoaded={phasesQuery.isSuccess}
dependencyEdges={depsQuery.data ?? []}
mergeTarget={serializedInitiative.mergeTarget}
branch={serializedInitiative.branch}
/>
)}
{activeTab === "execution" && (