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

@@ -9,7 +9,7 @@
| `types.ts` | Core types: `AgentInfo`, `AgentManager` interface, `SpawnOptions`, `StreamEvent` | | `types.ts` | Core types: `AgentInfo`, `AgentManager` interface, `SpawnOptions`, `StreamEvent` |
| `manager.ts` | `MultiProviderAgentManager` — main orchestrator class | | `manager.ts` | `MultiProviderAgentManager` — main orchestrator class |
| `process-manager.ts` | `AgentProcessManager` — worktree creation, command building, detached spawn | | `process-manager.ts` | `AgentProcessManager` — worktree creation, command building, detached spawn |
| `output-handler.ts` | `OutputHandler` — JSONL stream parsing, completion detection, proposal creation | | `output-handler.ts` | `OutputHandler` — JSONL stream parsing, completion detection, proposal creation, task dedup |
| `file-tailer.ts` | `FileTailer` — watches output files, emits line events | | `file-tailer.ts` | `FileTailer` — watches output files, emits line events |
| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion | | `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion |
| `markdown-to-tiptap.ts` | Markdown to Tiptap JSON conversion using MarkdownManager | | `markdown-to-tiptap.ts` | Markdown to Tiptap JSON conversion using MarkdownManager |
@@ -24,7 +24,7 @@
| `accounts/` | Account discovery, config dir setup, credential management, usage API | | `accounts/` | Account discovery, config dir setup, credential management, usage API |
| `credentials/` | `AccountCredentialManager` — credential injection per account | | `credentials/` | `AccountCredentialManager` — credential injection per account |
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions | | `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
| `prompts/` | Mode-specific prompt builders (execute, discuss, breakdown, decompose, refine) | | `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine) |
## Key Flows ## Key Flows

View File

@@ -65,8 +65,8 @@ Uses **Commander.js** for command parsing.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `discuss <initiativeId> [-c context]` | Start discussion agent | | `discuss <initiativeId> [-c context]` | Start discussion agent |
| `breakdown <initiativeId> [-s summary]` | Start breakdown agent | | `plan <initiativeId> [-s summary]` | Start plan agent |
| `decompose <phaseId> [-t taskName] [-c context]` | Decompose phase into tasks | | `detail <phaseId> [-t taskName] [-c context]` | Detail phase into tasks |
### Phase (`cw phase`) ### Phase (`cw phase`)
| Command | Description | | Command | Description |

View File

@@ -20,7 +20,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
| name | text NOT NULL | | | name | text NOT NULL | |
| status | text enum | 'active' \| 'completed' \| 'archived', default 'active' | | status | text enum | 'active' \| 'completed' \| 'archived', default 'active' |
| mergeRequiresApproval | integer/boolean | default true | | mergeRequiresApproval | integer/boolean | default true |
| mergeTarget | text nullable | target branch for merges | | branch | text nullable | auto-generated initiative branch (e.g., 'cw/user-auth') |
| createdAt, updatedAt | integer/timestamp | | | createdAt, updatedAt | integer/timestamp | |
### phases ### phases
@@ -46,7 +46,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
| name | text NOT NULL | | | name | text NOT NULL | |
| description | text nullable | | | description | text nullable | |
| type | text enum | 'auto' \| 'checkpoint:human-verify' \| 'checkpoint:decision' \| 'checkpoint:human-action' | | type | text enum | 'auto' \| 'checkpoint:human-verify' \| 'checkpoint:decision' \| 'checkpoint:human-action' |
| category | text enum | 'execute' \| 'research' \| 'discuss' \| 'breakdown' \| 'decompose' \| 'refine' \| 'verify' \| 'merge' \| 'review' | | category | text enum | 'execute' \| 'research' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' \| 'verify' \| 'merge' \| 'review' |
| priority | text enum | 'low' \| 'medium' \| 'high' | | priority | text enum | 'low' \| 'medium' \| 'high' |
| status | text enum | 'pending_approval' \| 'pending' \| 'in_progress' \| 'completed' \| 'blocked' | | status | text enum | 'pending_approval' \| 'pending' \| 'in_progress' \| 'completed' \| 'blocked' |
| requiresApproval | integer/boolean nullable | null = inherit from initiative | | requiresApproval | integer/boolean nullable | null = inherit from initiative |
@@ -68,7 +68,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
| provider | text NOT NULL | default 'claude' | | provider | text NOT NULL | default 'claude' |
| accountId | text nullable FK → accounts (set null) | | | accountId | text nullable FK → accounts (set null) | |
| status | text enum | 'idle' \| 'running' \| 'waiting_for_input' \| 'stopped' \| 'crashed' | | status | text enum | 'idle' \| 'running' \| 'waiting_for_input' \| 'stopped' \| 'crashed' |
| mode | text enum | 'execute' \| 'discuss' \| 'breakdown' \| 'decompose' \| 'refine' | | mode | text enum | 'execute' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' |
| pid | integer nullable | OS process ID | | pid | integer nullable | OS process ID |
| exitCode | integer nullable | | | exitCode | integer nullable | |
| outputFilePath | text nullable | | | outputFilePath | text nullable | |
@@ -122,6 +122,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
| id | text PK | | | id | text PK | |
| name | text NOT NULL UNIQUE | | | name | text NOT NULL UNIQUE | |
| url | text NOT NULL UNIQUE | git repo URL | | url | text NOT NULL UNIQUE | git repo URL |
| defaultBranch | text NOT NULL | default 'main' |
| createdAt, updatedAt | integer/timestamp | | | createdAt, updatedAt | integer/timestamp | |
### initiative_projects (junction) ### initiative_projects (junction)

View File

@@ -32,7 +32,7 @@
AgentSpawnedEvent { agentId, name, taskId, worktreeId, provider } AgentSpawnedEvent { agentId, name, taskId, worktreeId, provider }
AgentStoppedEvent { agentId, name, taskId, reason } AgentStoppedEvent { agentId, name, taskId, reason }
// reason: 'user_requested'|'task_complete'|'error'|'waiting_for_input'| // reason: 'user_requested'|'task_complete'|'error'|'waiting_for_input'|
// 'context_complete'|'breakdown_complete'|'decompose_complete'|'refine_complete' // 'context_complete'|'plan_complete'|'detail_complete'|'refine_complete'
AgentWaitingEvent { agentId, name, taskId, sessionId, questions[] } AgentWaitingEvent { agentId, name, taskId, sessionId, questions[] }
AgentOutputEvent { agentId, stream, data } AgentOutputEvent { agentId, stream, data }
TaskCompletedEvent { taskId, agentId, success, message } TaskCompletedEvent { taskId, agentId, success, message }

View File

@@ -63,7 +63,7 @@ The initiative detail page has three tabs managed via local state (not URL param
|-----------|---------| |-----------|---------|
| `ExecutionTab` | Main execution view container | | `ExecutionTab` | Main execution view container |
| `ExecutionContext` | React context for execution state | | `ExecutionContext` | React context for execution state |
| `PhaseDetailPanel` | Phase detail with tasks, dependencies, breakdown | | `PhaseDetailPanel` | Phase detail with tasks, dependencies, plan |
| `PhaseSidebar` | Phase list sidebar | | `PhaseSidebar` | Phase list sidebar |
| `TaskDetailPanel` | Task detail with agent status, output | | `TaskDetailPanel` | Task detail with agent status, output |
@@ -88,7 +88,7 @@ shadcn/ui components: badge, button, card, dialog, dropdown-menu, input, label,
| Hook | Purpose | | Hook | Purpose |
|------|---------| |------|---------|
| `useRefineAgent` | Manages refine agent lifecycle for initiative | | `useRefineAgent` | Manages refine agent lifecycle for initiative |
| `useDecomposeAgent` | Manages decompose agent for phase breakdown | | `useDetailAgent` | Manages detail agent for phase planning |
| `useAgentOutput` | Subscribes to live agent output stream | | `useAgentOutput` | Subscribes to live agent output stream |
## tRPC Client ## tRPC Client
@@ -120,9 +120,9 @@ Configured in `src/lib/trpc.ts`. Uses `@trpc/react-query` with TanStack Query fo
3. Approve phases → queue for dispatch 3. Approve phases → queue for dispatch
4. Tasks auto-queued when phase starts 4. Tasks auto-queued when phase starts
### Decomposing Phases ### Detailing Phases
1. Select phase → "Breakdown" button 1. Select phase → "Detail" button
2. `spawnArchitectDecompose` mutation → agent creates task proposals 2. `spawnArchitectDetail` mutation → agent creates task proposals
3. Accept proposals → tasks created under phase 3. Accept proposals → tasks created under phase
4. View tasks in phase detail panel 4. View tasks in phase detail panel

View File

@@ -87,7 +87,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| listInitiatives | query | Filter by status | | listInitiatives | query | Filter by status |
| getInitiative | query | With projects array | | getInitiative | query | With projects array |
| updateInitiative | mutation | Name, status | | updateInitiative | mutation | Name, status |
| updateInitiativeMergeConfig | mutation | mergeRequiresApproval, mergeTarget | | updateInitiativeConfig | mutation | mergeRequiresApproval, executionMode |
### Phases ### Phases
| Procedure | Type | Description | | Procedure | Type | Description |
@@ -98,7 +98,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| updatePhase | mutation | Name, content, status | | updatePhase | mutation | Name, content, status |
| approvePhase | mutation | Validate and approve | | approvePhase | mutation | Validate and approve |
| deletePhase | mutation | Cascade delete | | deletePhase | mutation | Cascade delete |
| createPhasesFromBreakdown | mutation | Bulk create from agent output | | createPhasesFromPlan | mutation | Bulk create from agent output |
| createPhaseDependency | mutation | Add dependency edge | | createPhaseDependency | mutation | Add dependency edge |
| removePhaseDependency | mutation | Remove dependency edge | | removePhaseDependency | mutation | Remove dependency edge |
| listInitiativePhaseDependencies | query | All dependency edges | | listInitiativePhaseDependencies | query | All dependency edges |
@@ -111,15 +111,15 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| queuePhase | mutation | Queue approved phase | | queuePhase | mutation | Queue approved phase |
| dispatchNextPhase | mutation | Start next ready phase | | dispatchNextPhase | mutation | Start next ready phase |
| getPhaseQueueState | query | Queue state | | getPhaseQueueState | query | Queue state |
| createChildTasks | mutation | Create tasks from decompose parent | | createChildTasks | mutation | Create tasks from detail parent |
### Architect (High-Level Agent Spawning) ### Architect (High-Level Agent Spawning)
| Procedure | Type | Description | | Procedure | Type | Description |
|-----------|------|-------------| |-----------|------|-------------|
| spawnArchitectDiscuss | mutation | Discussion agent | | spawnArchitectDiscuss | mutation | Discussion agent |
| spawnArchitectBreakdown | mutation | Breakdown agent (generates phases). Passes full initiative context (existing phases, tasks, pages) | | spawnArchitectPlan | mutation | Plan agent (generates phases). Passes full initiative context (existing phases, tasks, pages) |
| spawnArchitectRefine | mutation | Refine agent (generates proposals) | | spawnArchitectRefine | mutation | Refine agent (generates proposals) |
| spawnArchitectDecompose | mutation | Decompose agent (generates tasks). Passes full initiative context (sibling phases, tasks, pages) | | spawnArchitectDetail | mutation | Detail agent (generates tasks). Passes full initiative context (sibling phases, tasks, pages) |
### Dispatch ### Dispatch
| Procedure | Type | Description | | Procedure | Type | Description |

View File

@@ -22,8 +22,8 @@ Located alongside source files (`*.test.ts`):
| File | Scenarios | | File | Scenarios |
|------|-----------| |------|-----------|
| `happy-path.test.ts` | Single task, parallel, complex flows | | `happy-path.test.ts` | Single task, parallel, complex flows |
| `architect-workflow.test.ts` | Discussion + breakdown agent workflows | | `architect-workflow.test.ts` | Discussion + plan agent workflows |
| `decompose-workflow.test.ts` | Task decomposition with child tasks | | `detail-workflow.test.ts` | Task detail with child tasks |
| `phase-dispatch.test.ts` | Phase-level dispatch with dependencies | | `phase-dispatch.test.ts` | Phase-level dispatch with dependencies |
| `recovery-scenarios.test.ts` | Crash recovery, agent resume | | `recovery-scenarios.test.ts` | Crash recovery, agent resume |
| `edge-cases.test.ts` | Boundary conditions | | `edge-cases.test.ts` | Boundary conditions |

View File

@@ -0,0 +1,4 @@
-- Rename merge_target → branch on initiatives, add default_branch to projects
ALTER TABLE `initiatives` RENAME COLUMN `merge_target` TO `branch`;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `default_branch` TEXT NOT NULL DEFAULT 'main';

View File

@@ -0,0 +1,12 @@
-- Rename agent modes: breakdown → plan, decompose → detail
UPDATE tasks SET category = 'plan' WHERE category = 'breakdown';
--> statement-breakpoint
UPDATE tasks SET category = 'detail' WHERE category = 'decompose';
--> statement-breakpoint
UPDATE agents SET mode = 'plan' WHERE mode = 'breakdown';
--> statement-breakpoint
UPDATE agents SET mode = 'detail' WHERE mode = 'decompose';
--> statement-breakpoint
UPDATE change_sets SET mode = 'plan' WHERE mode = 'breakdown';
--> statement-breakpoint
UPDATE change_sets SET mode = 'detail' WHERE mode = 'decompose';

View File

@@ -155,6 +155,20 @@
"when": 1771372800000, "when": 1771372800000,
"tag": "0021_drop_proposals", "tag": "0021_drop_proposals",
"breakpoints": true "breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1771459200000,
"tag": "0022_branch_refactor",
"breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1771545600000,
"tag": "0023_rename_breakdown_decompose",
"breakpoints": true
} }
] ]
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,18 +31,18 @@ export function SpawnArchitectDropdown({
onSuccess: handleSuccess, onSuccess: handleSuccess,
}); });
const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, { const planSpawn = useSpawnMutation(trpc.spawnArchitectPlan.useMutation, {
onSuccess: handleSuccess, onSuccess: handleSuccess,
}); });
const isPending = discussSpawn.isSpawning || breakdownSpawn.isSpawning; const isPending = discussSpawn.isSpawning || planSpawn.isSpawning;
function handleDiscuss() { function handleDiscuss() {
discussSpawn.spawn({ initiativeId }); discussSpawn.spawn({ initiativeId });
} }
function handleBreakdown() { function handlePlan() {
breakdownSpawn.spawn({ initiativeId }); planSpawn.spawn({ initiativeId });
} }
return ( return (
@@ -57,8 +57,8 @@ export function SpawnArchitectDropdown({
<DropdownMenuItem onClick={handleDiscuss} disabled={isPending}> <DropdownMenuItem onClick={handleDiscuss} disabled={isPending}>
Discuss Discuss
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={handleBreakdown} disabled={isPending}> <DropdownMenuItem onClick={handlePlan} disabled={isPending}>
Breakdown Plan
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 { Loader2, Plus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
@@ -8,45 +8,55 @@ interface PhaseActionsProps {
phases: Array<{ id: string; status: string }>; phases: Array<{ id: string; status: string }>;
onAddPhase: () => void; onAddPhase: () => void;
phasesWithoutTasks: string[]; phasesWithoutTasks: string[];
decomposeAgentByPhase: Map<string, { id: string; status: string }>; detailAgentByPhase: Map<string, { id: string; status: string }>;
} }
export function PhaseActions({ export function PhaseActions({
onAddPhase, onAddPhase,
phasesWithoutTasks, phasesWithoutTasks,
decomposeAgentByPhase, detailAgentByPhase,
}: PhaseActionsProps) { }: 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( const eligiblePhaseIds = useMemo(
() => phasesWithoutTasks.filter((id) => !decomposeAgentByPhase.has(id)), () => phasesWithoutTasks.filter((id) => !detailAgentByPhase.has(id)),
[phasesWithoutTasks, decomposeAgentByPhase], [phasesWithoutTasks, detailAgentByPhase],
); );
// Count of phases currently being decomposed // Count of phases currently being detailed
const activeDecomposeCount = useMemo(() => { const activeDetailCount = useMemo(() => {
let count = 0; let count = 0;
for (const [, agent] of decomposeAgentByPhase) { for (const [, agent] of detailAgentByPhase) {
if (agent.status === "running" || agent.status === "waiting_for_input") { if (agent.status === "running" || agent.status === "waiting_for_input") {
count++; count++;
} }
} }
return count; return count;
}, [decomposeAgentByPhase]); }, [detailAgentByPhase]);
const handleBreakdownAll = useCallback(() => { const handleDetailAll = useCallback(async () => {
for (const phaseId of eligiblePhaseIds) { setIsDetailingAll(true);
decomposeMutation.mutate({ phaseId }); 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 ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{activeDecomposeCount > 0 && ( {activeDetailCount > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
Decomposing ({activeDecomposeCount}) Detailing ({activeDetailCount})
</div> </div>
)} )}
<Button <Button
@@ -61,12 +71,16 @@ export function PhaseActions({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={eligiblePhaseIds.length === 0} disabled={eligiblePhaseIds.length === 0 || isDetailingAll}
onClick={handleBreakdownAll} onClick={handleDetailAll}
className="gap-1.5" className="gap-1.5"
> >
<Sparkles className="h-3.5 w-3.5" /> {isDetailingAll ? (
Breakdown All <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
Detail All
</Button> </Button>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,8 +42,8 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
// --- Architect spawns --- // --- Architect spawns ---
spawnArchitectRefine: ["listAgents"], spawnArchitectRefine: ["listAgents"],
spawnArchitectDiscuss: ["listAgents"], spawnArchitectDiscuss: ["listAgents"],
spawnArchitectBreakdown: ["listAgents"], spawnArchitectPlan: ["listAgents"],
spawnArchitectDecompose: ["listAgents", "listInitiativeTasks"], spawnArchitectDetail: ["listAgents", "listInitiativeTasks"],
// --- Initiatives --- // --- Initiatives ---
createInitiative: ["listInitiatives"], 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 { AgentActions } from "@/components/AgentActions";
import { formatRelativeTime } from "@/lib/utils"; import { formatRelativeTime } from "@/lib/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { modeLabel } from "@/lib/labels";
import { StatusDot } from "@/components/StatusDot"; import { StatusDot } from "@/components/StatusDot";
import { useLiveUpdates } from "@/hooks"; import { useLiveUpdates } from "@/hooks";
@@ -247,7 +248,7 @@ function AgentsPage() {
{agent.provider} {agent.provider}
</Badge> </Badge>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{agent.mode} {modeLabel(agent.mode)}
</Badge> </Badge>
{/* Action dropdown */} {/* Action dropdown */}
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>

View File

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

View File

@@ -34,10 +34,10 @@ export type { AgentProviderConfig } from './providers/index.js';
// Agent prompts // Agent prompts
export { export {
buildDiscussPrompt, buildDiscussPrompt,
buildBreakdownPrompt, buildPlanPrompt,
buildExecutePrompt, buildExecutePrompt,
buildRefinePrompt, buildRefinePrompt,
buildDecomposePrompt, buildDetailPrompt,
} from './prompts/index.js'; } from './prompts/index.js';
// Schema // Schema

View File

@@ -596,7 +596,7 @@ describe('MockAgentManager', () => {
}); });
// =========================================================================== // ===========================================================================
// Agent modes (execute, discuss, breakdown) // Agent modes (execute, discuss, plan)
// =========================================================================== // ===========================================================================
describe('agent modes', () => { describe('agent modes', () => {
@@ -626,21 +626,21 @@ describe('MockAgentManager', () => {
expect(agent.mode).toBe('discuss'); expect(agent.mode).toBe('discuss');
}); });
it('should spawn agent in breakdown mode', async () => { it('should spawn agent in plan mode', async () => {
manager.setScenario('breakdown-agent', { manager.setScenario('plan-agent', {
status: 'done', status: 'done',
delay: 0, delay: 0,
result: 'Breakdown complete', result: 'Plan complete',
}); });
const agent = await manager.spawn({ const agent = await manager.spawn({
name: 'breakdown-agent', name: 'plan-agent',
taskId: 't1', taskId: 't1',
prompt: 'breakdown work', prompt: 'plan work',
mode: 'breakdown', mode: 'plan',
}); });
expect(agent.mode).toBe('breakdown'); expect(agent.mode).toBe('plan');
}); });
it('should emit stopped event with context_complete reason for discuss mode', async () => { it('should emit stopped event with context_complete reason for discuss mode', async () => {
@@ -662,63 +662,63 @@ describe('MockAgentManager', () => {
expect(stopped?.payload.reason).toBe('context_complete'); expect(stopped?.payload.reason).toBe('context_complete');
}); });
it('should emit stopped event with breakdown_complete reason for breakdown mode', async () => { it('should emit stopped event with plan_complete reason for plan mode', async () => {
manager.setScenario('breakdown-done', { manager.setScenario('plan-done', {
status: 'done', status: 'done',
delay: 0, delay: 0,
result: 'Breakdown complete', result: 'Plan complete',
}); });
await manager.spawn({ await manager.spawn({
name: 'breakdown-done', name: 'plan-done',
taskId: 't1', taskId: 't1',
prompt: 'test', prompt: 'test',
mode: 'breakdown', mode: 'plan',
}); });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
const stopped = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined; const stopped = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined;
expect(stopped?.payload.reason).toBe('breakdown_complete'); expect(stopped?.payload.reason).toBe('plan_complete');
}); });
}); });
// =========================================================================== // ===========================================================================
// Decompose mode (plan to tasks) // Detail mode (phase to tasks)
// =========================================================================== // ===========================================================================
describe('decompose mode', () => { describe('detail mode', () => {
it('should spawn agent in decompose mode', async () => { it('should spawn agent in detail mode', async () => {
const agent = await manager.spawn({ const agent = await manager.spawn({
name: 'decomposer', name: 'detailer',
taskId: 'plan-1', taskId: 'plan-1',
prompt: 'Decompose this plan', prompt: 'Detail this phase',
mode: 'decompose', mode: 'detail',
}); });
expect(agent.mode).toBe('decompose'); expect(agent.mode).toBe('detail');
}); });
it('should complete with decompose_complete reason in decompose mode', async () => { it('should complete with detail_complete reason in detail mode', async () => {
manager.setScenario('decomposer', { manager.setScenario('detailer', {
status: 'done', status: 'done',
result: 'Decompose complete', result: 'Detail complete',
}); });
await manager.spawn({ name: 'decomposer', taskId: 'plan-1', prompt: 'test', mode: 'decompose' }); await manager.spawn({ name: 'detailer', taskId: 'plan-1', prompt: 'test', mode: 'detail' });
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
// Verify agent:stopped event with decompose_complete reason (derived from mode) // Verify agent:stopped event with detail_complete reason (derived from mode)
const stoppedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined; const stoppedEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined;
expect(stoppedEvent).toBeDefined(); expect(stoppedEvent).toBeDefined();
expect(stoppedEvent?.payload.reason).toBe('decompose_complete'); expect(stoppedEvent?.payload.reason).toBe('detail_complete');
}); });
it('should pause on questions in decompose mode', async () => { it('should pause on questions in detail mode', async () => {
manager.setScenario('decomposer', { manager.setScenario('detailer', {
status: 'questions', status: 'questions',
questions: [{ id: 'q1', question: 'How many tasks?' }], questions: [{ id: 'q1', question: 'How many tasks?' }],
}); });
await manager.spawn({ name: 'decomposer', taskId: 'plan-1', prompt: 'test', mode: 'decompose' }); await manager.spawn({ name: 'detailer', taskId: 'plan-1', prompt: 'test', mode: 'detail' });
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
// Verify agent pauses for questions // Verify agent pauses for questions
@@ -726,41 +726,41 @@ describe('MockAgentManager', () => {
expect(stoppedEvent).toBeDefined(); expect(stoppedEvent).toBeDefined();
// Check agent status // Check agent status
const agent = await manager.getByName('decomposer'); const agent = await manager.getByName('detailer');
expect(agent?.status).toBe('waiting_for_input'); expect(agent?.status).toBe('waiting_for_input');
}); });
it('should emit stopped event with decompose_complete reason (second test)', async () => { it('should emit stopped event with detail_complete reason (second test)', async () => {
manager.setScenario('decompose-done', { manager.setScenario('detail-done', {
status: 'done', status: 'done',
delay: 0, delay: 0,
result: 'Decompose complete', result: 'Detail complete',
}); });
await manager.spawn({ await manager.spawn({
name: 'decompose-done', name: 'detail-done',
taskId: 'plan-1', taskId: 'plan-1',
prompt: 'test', prompt: 'test',
mode: 'decompose', mode: 'detail',
}); });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
const stopped = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined; const stopped = eventBus.emittedEvents.find((e) => e.type === 'agent:stopped') as AgentStoppedEvent | undefined;
expect(stopped?.payload.reason).toBe('decompose_complete'); expect(stopped?.payload.reason).toBe('detail_complete');
}); });
it('should set result message for decompose mode', async () => { it('should set result message for detail mode', async () => {
manager.setScenario('decomposer', { manager.setScenario('detailer', {
status: 'done', status: 'done',
result: 'Decompose complete', result: 'Detail complete',
}); });
const agent = await manager.spawn({ name: 'decomposer', taskId: 'plan-1', prompt: 'test', mode: 'decompose' }); const agent = await manager.spawn({ name: 'detailer', taskId: 'plan-1', prompt: 'test', mode: 'detail' });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
const result = await manager.getResult(agent.id); const result = await manager.getResult(agent.id);
expect(result?.success).toBe(true); expect(result?.success).toBe(true);
expect(result?.message).toBe('Decompose complete'); expect(result?.message).toBe('Detail complete');
}); });
}); });

View File

@@ -195,8 +195,8 @@ export class MockAgentManager implements AgentManager {
private getStoppedReason(mode: AgentMode): AgentStoppedEvent['payload']['reason'] { private getStoppedReason(mode: AgentMode): AgentStoppedEvent['payload']['reason'] {
switch (mode) { switch (mode) {
case 'discuss': return 'context_complete'; case 'discuss': return 'context_complete';
case 'breakdown': return 'breakdown_complete'; case 'plan': return 'plan_complete';
case 'decompose': return 'decompose_complete'; case 'detail': return 'detail_complete';
case 'refine': return 'refine_complete'; case 'refine': return 'refine_complete';
default: return 'task_complete'; default: return 'task_complete';
} }

View File

@@ -426,7 +426,7 @@ export class OutputHandler {
let resultMessage = summary?.body ?? 'Task completed'; let resultMessage = summary?.body ?? 'Task completed';
switch (mode) { switch (mode) {
case 'breakdown': { case 'plan': {
const phases = readPhaseFiles(agentWorkdir); const phases = readPhaseFiles(agentWorkdir);
if (canWriteChangeSets && this.phaseRepository && phases.length > 0) { if (canWriteChangeSets && this.phaseRepository && phases.length > 0) {
const entries: CreateChangeSetEntryData[] = []; const entries: CreateChangeSetEntryData[] = [];
@@ -485,13 +485,13 @@ export class OutputHandler {
agentId, agentId,
agentName: agent.name, agentName: agent.name,
initiativeId, initiativeId,
mode: 'breakdown', mode: 'plan',
summary: summary?.body ?? `Created ${phases.length} phases`, summary: summary?.body ?? `Created ${phases.length} phases`,
}, entries); }, entries);
this.eventBus?.emit({ this.eventBus?.emit({
type: 'changeset:created' as const, type: 'changeset:created' as const,
timestamp: new Date(), timestamp: new Date(),
payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'breakdown', entryCount: entries.length }, payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'plan', entryCount: entries.length },
}); });
} catch (err) { } catch (err) {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record change set after successful writes'); log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record change set after successful writes');
@@ -503,14 +503,22 @@ export class OutputHandler {
} }
break; break;
} }
case 'decompose': { case 'detail': {
const tasks = readTaskFiles(agentWorkdir); const tasks = readTaskFiles(agentWorkdir);
if (canWriteChangeSets && this.taskRepository && tasks.length > 0) { if (canWriteChangeSets && this.taskRepository && tasks.length > 0) {
const phaseInput = readFrontmatterFile(join(agentWorkdir, '.cw', 'input', 'phase.md')); const phaseInput = readFrontmatterFile(join(agentWorkdir, '.cw', 'input', 'phase.md'));
const phaseId = (phaseInput?.data?.id as string) ?? null; const phaseId = (phaseInput?.data?.id as string) ?? null;
const entries: CreateChangeSetEntryData[] = []; const entries: CreateChangeSetEntryData[] = [];
// Load existing tasks for dedup — prevents duplicates when multiple agents finish concurrently
const existingTasks = phaseId ? await this.taskRepository.findByPhaseId(phaseId) : [];
const existingNames = new Set(existingTasks.map(t => t.name));
for (const [i, t] of tasks.entries()) { for (const [i, t] of tasks.entries()) {
if (existingNames.has(t.title)) {
log.info({ agentId, task: t.title, phaseId }, 'skipped duplicate task');
continue;
}
try { try {
const created = await this.taskRepository.create({ const created = await this.taskRepository.create({
initiativeId, initiativeId,
@@ -521,6 +529,7 @@ export class OutputHandler {
category: (t.category as any) ?? 'execute', category: (t.category as any) ?? 'execute',
type: (t.type as any) ?? 'auto', type: (t.type as any) ?? 'auto',
}); });
existingNames.add(t.title); // prevent dupes within same agent output
entries.push({ entries.push({
entityType: 'task', entityType: 'task',
entityId: created.id, entityId: created.id,
@@ -531,7 +540,7 @@ export class OutputHandler {
this.eventBus?.emit({ this.eventBus?.emit({
type: 'task:completed' as const, type: 'task:completed' as const,
timestamp: new Date(), timestamp: new Date(),
payload: { taskId: created.id, agentId, success: true, message: 'Task created by decompose' }, payload: { taskId: created.id, agentId, success: true, message: 'Task created by detail' },
}); });
} catch (err) { } catch (err) {
log.warn({ agentId, task: t.title, err: err instanceof Error ? err.message : String(err) }, 'failed to create task'); log.warn({ agentId, task: t.title, err: err instanceof Error ? err.message : String(err) }, 'failed to create task');
@@ -544,13 +553,13 @@ export class OutputHandler {
agentId, agentId,
agentName: agent.name, agentName: agent.name,
initiativeId, initiativeId,
mode: 'decompose', mode: 'detail',
summary: summary?.body ?? `Created ${tasks.length} tasks`, summary: summary?.body ?? `Created ${tasks.length} tasks`,
}, entries); }, entries);
this.eventBus?.emit({ this.eventBus?.emit({
type: 'changeset:created' as const, type: 'changeset:created' as const,
timestamp: new Date(), timestamp: new Date(),
payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'decompose', entryCount: entries.length }, payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'detail', entryCount: entries.length },
}); });
} catch (err) { } catch (err) {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record change set after successful writes'); log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record change set after successful writes');
@@ -709,8 +718,8 @@ export class OutputHandler {
getStoppedReason(mode: AgentMode): AgentStoppedEvent['payload']['reason'] { getStoppedReason(mode: AgentMode): AgentStoppedEvent['payload']['reason'] {
switch (mode) { switch (mode) {
case 'discuss': return 'context_complete'; case 'discuss': return 'context_complete';
case 'breakdown': return 'breakdown_complete'; case 'plan': return 'plan_complete';
case 'decompose': return 'decompose_complete'; case 'detail': return 'detail_complete';
case 'refine': return 'refine_complete'; case 'refine': return 'refine_complete';
default: return 'task_complete'; default: return 'task_complete';
} }

View File

@@ -138,7 +138,7 @@ describe('ProcessManager', () => {
// Mock project repository // Mock project repository
vi.mocked(mockProjectRepository.findProjectsByInitiativeId).mockResolvedValue([ vi.mocked(mockProjectRepository.findProjectsByInitiativeId).mockResolvedValue([
{ id: '1', name: 'project1', url: 'https://github.com/user/project1.git', createdAt: new Date(), updatedAt: new Date() } { id: '1', name: 'project1', url: 'https://github.com/user/project1.git', defaultBranch: 'main', createdAt: new Date(), updatedAt: new Date() }
]); ]);
// Mock existsSync to return true for worktree paths // Mock existsSync to return true for worktree paths

View File

@@ -115,14 +115,14 @@ ${ID_GENERATION}
} }
/** /**
* Build prompt for breakdown mode. * Build prompt for plan mode.
* Agent decomposes initiative into executable phases. * Agent plans initiative into executable phases.
*/ */
export function buildBreakdownPrompt(): string { export function buildPlanPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in BREAKDOWN mode. return `You are an Architect agent in the Codewalk multi-agent system operating in PLAN mode.
## Your Role ## Your Role
Decompose the initiative into executable phases. You do NOT write code — you plan it. Plan the initiative into executable phases. You do NOT write code — you plan it.
${INPUT_FILES} ${INPUT_FILES}
${SIGNAL_FORMAT} ${SIGNAL_FORMAT}
@@ -149,14 +149,14 @@ ${ID_GENERATION}
} }
/** /**
* Build prompt for decompose mode. * Build prompt for detail mode.
* Agent breaks a phase into executable tasks. * Agent breaks a phase into executable tasks.
*/ */
export function buildDecomposePrompt(): string { export function buildDetailPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in DECOMPOSE mode. return `You are an Architect agent in the Codewalk multi-agent system operating in DETAIL mode.
## Your Role ## Your Role
Decompose the phase into individual executable tasks. You do NOT write code — you define work items. Detail the phase into individual executable tasks. You do NOT write code — you define work items.
${INPUT_FILES} ${INPUT_FILES}
${SIGNAL_FORMAT} ${SIGNAL_FORMAT}
@@ -165,7 +165,7 @@ ${SIGNAL_FORMAT}
Write one file per task to \`.cw/output/tasks/{id}.md\`: Write one file per task to \`.cw/output/tasks/{id}.md\`:
- Frontmatter: - Frontmatter:
- \`title\`: Clear task name - \`title\`: Clear task name
- \`category\`: One of: execute, research, discuss, breakdown, decompose, refine, verify, merge, review - \`category\`: One of: execute, research, discuss, plan, detail, refine, verify, merge, review
- \`type\`: One of: auto, checkpoint:human-verify, checkpoint:decision, checkpoint:human-action - \`type\`: One of: auto, checkpoint:human-verify, checkpoint:decision, checkpoint:human-action
- \`dependencies\`: List of other task IDs this depends on - \`dependencies\`: List of other task IDs this depends on
- Body: Detailed description of what the task requires - Body: Detailed description of what the task requires

View File

@@ -1,14 +1,14 @@
/** /**
* Decompose mode prompt break a phase into executable tasks. * Detail mode prompt break a phase into executable tasks.
*/ */
import { ID_GENERATION, INPUT_FILES, SIGNAL_FORMAT } from './shared.js'; import { ID_GENERATION, INPUT_FILES, SIGNAL_FORMAT } from './shared.js';
export function buildDecomposePrompt(): string { export function buildDetailPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in DECOMPOSE mode. return `You are an Architect agent in the Codewalk multi-agent system operating in DETAIL mode.
## Your Role ## Your Role
Decompose the phase into individual executable tasks. You do NOT write code you define work items. Detail the phase into individual executable tasks. You do NOT write code you define work items.
${INPUT_FILES} ${INPUT_FILES}
${SIGNAL_FORMAT} ${SIGNAL_FORMAT}
@@ -17,7 +17,7 @@ ${SIGNAL_FORMAT}
Write one file per task to \`.cw/output/tasks/{id}.md\`: Write one file per task to \`.cw/output/tasks/{id}.md\`:
- Frontmatter: - Frontmatter:
- \`title\`: Clear task name - \`title\`: Clear task name
- \`category\`: One of: execute, research, discuss, breakdown, decompose, refine, verify, merge, review - \`category\`: One of: execute, research, discuss, plan, detail, refine, verify, merge, review
- \`type\`: One of: auto, checkpoint:human-verify, checkpoint:decision, checkpoint:human-action - \`type\`: One of: auto, checkpoint:human-verify, checkpoint:decision, checkpoint:human-action
- \`dependencies\`: List of other task IDs this depends on - \`dependencies\`: List of other task IDs this depends on
- Body: Detailed description of what the task requires - Body: Detailed description of what the task requires
@@ -31,10 +31,11 @@ ${ID_GENERATION}
- Dependencies should be minimal and explicit - Dependencies should be minimal and explicit
## Existing Context ## Existing Context
- Read context files to see sibling phases and their tasks - FIRST: Read ALL files in \`context/tasks/\` before generating any output
- Your target is \`phase.md\` — only create tasks for THIS phase - Your target phase is \`phase.md\` — only create tasks for THIS phase
- Pages contain requirements and specifications reference them for task descriptions - If a task in context/tasks/ already covers the same work (even under a different name), do NOT create a duplicate
- Avoid duplicating work that is already covered by other phases or their tasks - Pages contain requirements reference them for detailed task descriptions
- DO NOT create tasks that overlap with existing tasks in other phases
## Rules ## Rules
- Break work into 3-8 tasks per phase - Break work into 3-8 tasks per phase

View File

@@ -8,7 +8,7 @@
export { SIGNAL_FORMAT, INPUT_FILES, ID_GENERATION } from './shared.js'; export { SIGNAL_FORMAT, INPUT_FILES, ID_GENERATION } from './shared.js';
export { buildExecutePrompt } from './execute.js'; export { buildExecutePrompt } from './execute.js';
export { buildDiscussPrompt } from './discuss.js'; export { buildDiscussPrompt } from './discuss.js';
export { buildBreakdownPrompt } from './breakdown.js'; export { buildPlanPrompt } from './plan.js';
export { buildDecomposePrompt } from './decompose.js'; export { buildDetailPrompt } from './detail.js';
export { buildRefinePrompt } from './refine.js'; export { buildRefinePrompt } from './refine.js';
export { buildWorkspaceLayout } from './workspace.js'; export { buildWorkspaceLayout } from './workspace.js';

View File

@@ -1,14 +1,14 @@
/** /**
* Breakdown mode prompt decompose initiative into phases. * Plan mode prompt plan initiative into phases.
*/ */
import { ID_GENERATION, INPUT_FILES, SIGNAL_FORMAT } from './shared.js'; import { ID_GENERATION, INPUT_FILES, SIGNAL_FORMAT } from './shared.js';
export function buildBreakdownPrompt(): string { export function buildPlanPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in BREAKDOWN mode. return `You are an Architect agent in the Codewalk multi-agent system operating in PLAN mode.
## Your Role ## Your Role
Decompose the initiative into executable phases. You do NOT write code you plan it. Plan the initiative into executable phases. You do NOT write code you plan it.
${INPUT_FILES} ${INPUT_FILES}
${SIGNAL_FORMAT} ${SIGNAL_FORMAT}

View File

@@ -12,10 +12,10 @@ export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' |
* *
* - execute: Standard task execution (default) * - execute: Standard task execution (default)
* - discuss: Gather context through questions, output decisions * - discuss: Gather context through questions, output decisions
* - breakdown: Decompose initiative into phases * - plan: Plan initiative into phases
* - decompose: Decompose phase into individual tasks * - detail: Detail phase into individual tasks
*/ */
export type AgentMode = 'execute' | 'discuss' | 'breakdown' | 'decompose' | 'refine'; export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine';
/** /**
* Context data written as input files in agent workdir before spawn. * Context data written as input files in agent workdir before spawn.

View File

@@ -847,52 +847,52 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
} }
}); });
// cw architect breakdown <initiative-id> // cw architect plan <initiative-id>
architectCommand architectCommand
.command('breakdown <initiativeId>') .command('plan <initiativeId>')
.description('Start breakdown phase for an initiative') .description('Plan phases for an initiative')
.option('--name <name>', 'Agent name (auto-generated if omitted)') .option('--name <name>', 'Agent name (auto-generated if omitted)')
.option('-s, --summary <summary>', 'Context summary from discuss phase') .option('-s, --summary <summary>', 'Context summary from discuss phase')
.action(async (initiativeId: string, options: { name?: string; summary?: string }) => { .action(async (initiativeId: string, options: { name?: string; summary?: string }) => {
try { try {
const client = createDefaultTrpcClient(); const client = createDefaultTrpcClient();
const agent = await client.spawnArchitectBreakdown.mutate({ const agent = await client.spawnArchitectPlan.mutate({
name: options.name, name: options.name,
initiativeId, initiativeId,
contextSummary: options.summary, contextSummary: options.summary,
}); });
console.log(`Started architect agent in breakdown mode`); console.log(`Started architect agent in plan mode`);
console.log(` Agent: ${agent.name} (${agent.id})`); console.log(` Agent: ${agent.name} (${agent.id})`);
console.log(` Mode: ${agent.mode}`); console.log(` Mode: ${agent.mode}`);
console.log(` Initiative: ${initiativeId}`); console.log(` Initiative: ${initiativeId}`);
} catch (error) { } catch (error) {
console.error('Failed to start breakdown:', (error as Error).message); console.error('Failed to start plan:', (error as Error).message);
process.exit(1); process.exit(1);
} }
}); });
// cw architect decompose <phase-id> // cw architect detail <phase-id>
architectCommand architectCommand
.command('decompose <phaseId>') .command('detail <phaseId>')
.description('Decompose a phase into tasks') .description('Detail a phase into tasks')
.option('--name <name>', 'Agent name (auto-generated if omitted)') .option('--name <name>', 'Agent name (auto-generated if omitted)')
.option('-t, --task-name <taskName>', 'Name for the decompose task') .option('-t, --task-name <taskName>', 'Name for the detail task')
.option('-c, --context <context>', 'Additional context') .option('-c, --context <context>', 'Additional context')
.action(async (phaseId: string, options: { name?: string; taskName?: string; context?: string }) => { .action(async (phaseId: string, options: { name?: string; taskName?: string; context?: string }) => {
try { try {
const client = createDefaultTrpcClient(); const client = createDefaultTrpcClient();
const agent = await client.spawnArchitectDecompose.mutate({ const agent = await client.spawnArchitectDetail.mutate({
name: options.name, name: options.name,
phaseId, phaseId,
taskName: options.taskName, taskName: options.taskName,
context: options.context, context: options.context,
}); });
console.log(`Started architect agent in decompose mode`); console.log(`Started architect agent in detail mode`);
console.log(` Agent: ${agent.name} (${agent.id})`); console.log(` Agent: ${agent.name} (${agent.id})`);
console.log(` Mode: ${agent.mode}`); console.log(` Mode: ${agent.mode}`);
console.log(` Phase: ${phaseId}`); console.log(` Phase: ${phaseId}`);
} catch (error) { } catch (error) {
console.error('Failed to start decompose:', (error as Error).message); console.error('Failed to start detail:', (error as Error).message);
process.exit(1); process.exit(1);
} }
}); });

View File

@@ -11,7 +11,7 @@ export type CreateChangeSetData = {
agentId: string | null; agentId: string | null;
agentName: string; agentName: string;
initiativeId: string; initiativeId: string;
mode: 'breakdown' | 'decompose' | 'refine'; mode: 'plan' | 'detail' | 'refine';
summary?: string | null; summary?: string | null;
}; };

View File

@@ -27,7 +27,7 @@ describe('Cascade Deletes', () => {
/** /**
* Helper to create a full hierarchy for testing. * Helper to create a full hierarchy for testing.
* Uses parent tasks (decompose category) to group child tasks. * Uses parent tasks (detail category) to group child tasks.
*/ */
async function createFullHierarchy() { async function createFullHierarchy() {
const initiative = await initiativeRepo.create({ const initiative = await initiativeRepo.create({
@@ -44,12 +44,12 @@ describe('Cascade Deletes', () => {
name: 'Phase 2', name: 'Phase 2',
}); });
// Create parent (decompose) tasks that group child tasks // Create parent (detail) tasks that group child tasks
const parentTask1 = await taskRepo.create({ const parentTask1 = await taskRepo.create({
phaseId: phase1.id, phaseId: phase1.id,
initiativeId: initiative.id, initiativeId: initiative.id,
name: 'Parent Task 1-1', name: 'Parent Task 1-1',
category: 'decompose', category: 'detail',
order: 1, order: 1,
}); });
@@ -57,7 +57,7 @@ describe('Cascade Deletes', () => {
phaseId: phase1.id, phaseId: phase1.id,
initiativeId: initiative.id, initiativeId: initiative.id,
name: 'Parent Task 1-2', name: 'Parent Task 1-2',
category: 'decompose', category: 'detail',
order: 2, order: 2,
}); });
@@ -65,7 +65,7 @@ describe('Cascade Deletes', () => {
phaseId: phase2.id, phaseId: phase2.id,
initiativeId: initiative.id, initiativeId: initiative.id,
name: 'Parent Task 2-1', name: 'Parent Task 2-1',
category: 'decompose', category: 'detail',
order: 1, order: 1,
}); });

View File

@@ -25,7 +25,7 @@ export const initiatives = sqliteTable('initiatives', {
mergeRequiresApproval: integer('merge_requires_approval', { mode: 'boolean' }) mergeRequiresApproval: integer('merge_requires_approval', { mode: 'boolean' })
.notNull() .notNull()
.default(true), .default(true),
mergeTarget: text('merge_target'), // Target branch for merges (e.g., 'feature/xyz') branch: text('branch'), // Auto-generated initiative branch (e.g., 'cw/user-auth')
executionMode: text('execution_mode', { enum: ['yolo', 'review_per_phase'] }) executionMode: text('execution_mode', { enum: ['yolo', 'review_per_phase'] })
.notNull() .notNull()
.default('review_per_phase'), .default('review_per_phase'),
@@ -120,8 +120,8 @@ export const TASK_CATEGORIES = [
'execute', // Standard execution task 'execute', // Standard execution task
'research', // Research/exploration task 'research', // Research/exploration task
'discuss', // Discussion/context gathering 'discuss', // Discussion/context gathering
'breakdown', // Break initiative into phases 'plan', // Plan initiative into phases
'decompose', // Decompose plan into tasks 'detail', // Detail phase into tasks
'refine', // Refine/edit content 'refine', // Refine/edit content
'verify', // Verification task 'verify', // Verification task
'merge', // Merge task 'merge', // Merge task
@@ -135,7 +135,7 @@ export const tasks = sqliteTable('tasks', {
// Parent context - at least one should be set // Parent context - at least one should be set
phaseId: text('phase_id').references(() => phases.id, { onDelete: 'cascade' }), phaseId: text('phase_id').references(() => phases.id, { onDelete: 'cascade' }),
initiativeId: text('initiative_id').references(() => initiatives.id, { onDelete: 'cascade' }), initiativeId: text('initiative_id').references(() => initiatives.id, { onDelete: 'cascade' }),
// Parent task for decomposition hierarchy (child tasks link to parent decompose task) // Parent task for detail hierarchy (child tasks link to parent detail task)
parentTaskId: text('parent_task_id').references((): ReturnType<typeof text> => tasks.id, { onDelete: 'cascade' }), parentTaskId: text('parent_task_id').references((): ReturnType<typeof text> => tasks.id, { onDelete: 'cascade' }),
name: text('name').notNull(), name: text('name').notNull(),
description: text('description'), description: text('description'),
@@ -172,7 +172,7 @@ export const tasksRelations = relations(tasks, ({ one, many }) => ({
fields: [tasks.initiativeId], fields: [tasks.initiativeId],
references: [initiatives.id], references: [initiatives.id],
}), }),
// Parent task (for decomposition hierarchy - child links to parent decompose task) // Parent task (for detail hierarchy - child links to parent detail task)
parentTask: one(tasks, { parentTask: one(tasks, {
fields: [tasks.parentTaskId], fields: [tasks.parentTaskId],
references: [tasks.id], references: [tasks.id],
@@ -263,7 +263,7 @@ export const agents = sqliteTable('agents', {
}) })
.notNull() .notNull()
.default('idle'), .default('idle'),
mode: text('mode', { enum: ['execute', 'discuss', 'breakdown', 'decompose', 'refine'] }) mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine'] })
.notNull() .notNull()
.default('execute'), .default('execute'),
pid: integer('pid'), pid: integer('pid'),
@@ -307,7 +307,7 @@ export const changeSets = sqliteTable('change_sets', {
initiativeId: text('initiative_id') initiativeId: text('initiative_id')
.notNull() .notNull()
.references(() => initiatives.id, { onDelete: 'cascade' }), .references(() => initiatives.id, { onDelete: 'cascade' }),
mode: text('mode', { enum: ['breakdown', 'decompose', 'refine'] }).notNull(), mode: text('mode', { enum: ['plan', 'detail', 'refine'] }).notNull(),
summary: text('summary'), summary: text('summary'),
status: text('status', { enum: ['applied', 'reverted'] }) status: text('status', { enum: ['applied', 'reverted'] })
.notNull() .notNull()
@@ -451,6 +451,7 @@ export const projects = sqliteTable('projects', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
name: text('name').notNull().unique(), name: text('name').notNull().unique(),
url: text('url').notNull().unique(), url: text('url').notNull().unique(),
defaultBranch: text('default_branch').notNull().default('main'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}); });

View File

@@ -160,8 +160,8 @@ export interface AgentStoppedEvent extends DomainEvent {
| 'error' | 'error'
| 'waiting_for_input' | 'waiting_for_input'
| 'context_complete' | 'context_complete'
| 'breakdown_complete' | 'plan_complete'
| 'decompose_complete' | 'detail_complete'
| 'refine_complete'; | 'refine_complete';
}; };
} }

View File

@@ -3,8 +3,8 @@
* *
* Tests the complete architect workflow from discussion through phase creation: * Tests the complete architect workflow from discussion through phase creation:
* - Discuss mode: Gather context, answer questions, capture decisions * - Discuss mode: Gather context, answer questions, capture decisions
* - Breakdown mode: Decompose initiative into phases * - Plan mode: Break initiative into phases
* - Full workflow: Discuss -> Breakdown -> Phase persistence * - Full workflow: Discuss -> Plan -> Phase persistence
* *
* Uses TestHarness from src/test/ for full system wiring. * Uses TestHarness from src/test/ for full system wiring.
*/ */
@@ -100,35 +100,35 @@ describe('Architect Workflow E2E', () => {
}); });
}); });
describe('breakdown mode', () => { describe('plan mode', () => {
it('should spawn architect in breakdown mode and create phases', async () => { it('should spawn architect in plan mode and create phases', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const initiative = await harness.createInitiative('Auth System'); const initiative = await harness.createInitiative('Auth System');
// Set up breakdown completion // Set up plan completion
harness.setArchitectBreakdownComplete('auth-breakdown', [ harness.setArchitectPlanComplete('auth-plan', [
{ number: 1, name: 'Database Setup', description: 'User table and auth schema', dependencies: [] }, { number: 1, name: 'Database Setup', description: 'User table and auth schema', dependencies: [] },
{ number: 2, name: 'JWT Implementation', description: 'Token generation and validation', dependencies: [1] }, { number: 2, name: 'JWT Implementation', description: 'Token generation and validation', dependencies: [1] },
{ number: 3, name: 'Protected Routes', description: 'Middleware and route guards', dependencies: [2] }, { number: 3, name: 'Protected Routes', description: 'Middleware and route guards', dependencies: [2] },
]); ]);
const agent = await harness.caller.spawnArchitectBreakdown({ const agent = await harness.caller.spawnArchitectPlan({
name: 'auth-breakdown', name: 'auth-plan',
initiativeId: initiative.id, initiativeId: initiative.id,
}); });
expect(agent.mode).toBe('breakdown'); expect(agent.mode).toBe('plan');
await harness.advanceTimers(); await harness.advanceTimers();
// Verify stopped with breakdown_complete // Verify stopped with plan_complete
const events = harness.getEmittedEvents('agent:stopped') as AgentStoppedEvent[]; const events = harness.getEmittedEvents('agent:stopped') as AgentStoppedEvent[];
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
expect(events[0].payload.reason).toBe('breakdown_complete'); expect(events[0].payload.reason).toBe('plan_complete');
}); });
it('should persist phases from breakdown output', async () => { it('should persist phases from plan output', async () => {
const initiative = await harness.createInitiative('Auth System'); const initiative = await harness.createInitiative('Auth System');
const phasesData = [ const phasesData = [
@@ -136,8 +136,8 @@ describe('Architect Workflow E2E', () => {
{ name: 'Features' }, { name: 'Features' },
]; ];
// Persist phases (simulating what would happen after breakdown) // Persist phases (simulating what would happen after plan)
const created = await harness.createPhasesFromBreakdown(initiative.id, phasesData); const created = await harness.createPhasesFromPlan(initiative.id, phasesData);
expect(created).toHaveLength(2); expect(created).toHaveLength(2);
@@ -149,95 +149,95 @@ describe('Architect Workflow E2E', () => {
}); });
}); });
describe('breakdown conflict detection', () => { describe('plan conflict detection', () => {
it('should reject if a breakdown agent is already running', async () => { it('should reject if a plan agent is already running', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const initiative = await harness.createInitiative('Auth System'); const initiative = await harness.createInitiative('Auth System');
// Set up a long-running breakdown agent (never completes during this test) // Set up a long-running plan agent (never completes during this test)
harness.setArchitectBreakdownComplete('first-breakdown', [ harness.setArchitectPlanComplete('first-plan', [
{ number: 1, name: 'Phase 1', description: 'First', dependencies: [] }, { number: 1, name: 'Phase 1', description: 'First', dependencies: [] },
]); ]);
// Use a delay so it stays running // Use a delay so it stays running
harness.setAgentScenario('first-breakdown', { status: 'done', delay: 999999 }); harness.setAgentScenario('first-plan', { status: 'done', delay: 999999 });
await harness.caller.spawnArchitectBreakdown({ await harness.caller.spawnArchitectPlan({
name: 'first-breakdown', name: 'first-plan',
initiativeId: initiative.id, initiativeId: initiative.id,
}); });
// Agent should be running // Agent should be running
const agents = await harness.caller.listAgents(); const agents = await harness.caller.listAgents();
expect(agents.find(a => a.name === 'first-breakdown')?.status).toBe('running'); expect(agents.find(a => a.name === 'first-plan')?.status).toBe('running');
// Second breakdown should be rejected // Second plan should be rejected
await expect( await expect(
harness.caller.spawnArchitectBreakdown({ harness.caller.spawnArchitectPlan({
name: 'second-breakdown', name: 'second-plan',
initiativeId: initiative.id, initiativeId: initiative.id,
}), }),
).rejects.toThrow(/already running/); ).rejects.toThrow(/already running/);
}); });
it('should auto-dismiss stale breakdown agents before checking', async () => { it('should auto-dismiss stale plan agents before checking', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const initiative = await harness.createInitiative('Auth System'); const initiative = await harness.createInitiative('Auth System');
// Set up a breakdown agent that crashes immediately // Set up a plan agent that crashes immediately
harness.setAgentScenario('stale-breakdown', { status: 'error', error: 'crashed' }); harness.setAgentScenario('stale-plan', { status: 'error', error: 'crashed' });
await harness.caller.spawnArchitectBreakdown({ await harness.caller.spawnArchitectPlan({
name: 'stale-breakdown', name: 'stale-plan',
initiativeId: initiative.id, initiativeId: initiative.id,
}); });
await harness.advanceTimers(); await harness.advanceTimers();
// Should be crashed // Should be crashed
const agents = await harness.caller.listAgents(); const agents = await harness.caller.listAgents();
expect(agents.find(a => a.name === 'stale-breakdown')?.status).toBe('crashed'); expect(agents.find(a => a.name === 'stale-plan')?.status).toBe('crashed');
// New breakdown should succeed (stale one gets auto-dismissed) // New plan should succeed (stale one gets auto-dismissed)
harness.setArchitectBreakdownComplete('new-breakdown', [ harness.setArchitectPlanComplete('new-plan', [
{ number: 1, name: 'Phase 1', description: 'First', dependencies: [] }, { number: 1, name: 'Phase 1', description: 'First', dependencies: [] },
]); ]);
const agent = await harness.caller.spawnArchitectBreakdown({ const agent = await harness.caller.spawnArchitectPlan({
name: 'new-breakdown', name: 'new-plan',
initiativeId: initiative.id, initiativeId: initiative.id,
}); });
expect(agent.mode).toBe('breakdown'); expect(agent.mode).toBe('plan');
}); });
it('should allow breakdown for different initiatives', async () => { it('should allow plan for different initiatives', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const init1 = await harness.createInitiative('Initiative 1'); const init1 = await harness.createInitiative('Initiative 1');
const init2 = await harness.createInitiative('Initiative 2'); const init2 = await harness.createInitiative('Initiative 2');
// Long-running agent on initiative 1 // Long-running agent on initiative 1
harness.setAgentScenario('breakdown-1', { status: 'done', delay: 999999 }); harness.setAgentScenario('plan-1', { status: 'done', delay: 999999 });
await harness.caller.spawnArchitectBreakdown({ await harness.caller.spawnArchitectPlan({
name: 'breakdown-1', name: 'plan-1',
initiativeId: init1.id, initiativeId: init1.id,
}); });
// Breakdown on initiative 2 should succeed // Plan on initiative 2 should succeed
harness.setArchitectBreakdownComplete('breakdown-2', [ harness.setArchitectPlanComplete('plan-2', [
{ number: 1, name: 'Phase 1', description: 'First', dependencies: [] }, { number: 1, name: 'Phase 1', description: 'First', dependencies: [] },
]); ]);
const agent = await harness.caller.spawnArchitectBreakdown({ const agent = await harness.caller.spawnArchitectPlan({
name: 'breakdown-2', name: 'plan-2',
initiativeId: init2.id, initiativeId: init2.id,
}); });
expect(agent.mode).toBe('breakdown'); expect(agent.mode).toBe('plan');
}); });
}); });
describe('full workflow', () => { describe('full workflow', () => {
it('should complete discuss -> breakdown -> phases workflow', async () => { it('should complete discuss -> plan -> phases workflow', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
// 1. Create initiative // 1. Create initiative
@@ -254,21 +254,21 @@ describe('Architect Workflow E2E', () => {
}); });
await harness.advanceTimers(); await harness.advanceTimers();
// 3. Breakdown phase // 3. Plan phase
harness.setArchitectBreakdownComplete('breakdown-agent', [ harness.setArchitectPlanComplete('plan-agent', [
{ number: 1, name: 'Core', description: 'Core functionality', dependencies: [] }, { number: 1, name: 'Core', description: 'Core functionality', dependencies: [] },
{ number: 2, name: 'Polish', description: 'UI and UX', dependencies: [1] }, { number: 2, name: 'Polish', description: 'UI and UX', dependencies: [1] },
]); ]);
await harness.caller.spawnArchitectBreakdown({ await harness.caller.spawnArchitectPlan({
name: 'breakdown-agent', name: 'plan-agent',
initiativeId: initiative.id, initiativeId: initiative.id,
contextSummary: 'MVP scope defined', contextSummary: 'MVP scope defined',
}); });
await harness.advanceTimers(); await harness.advanceTimers();
// 4. Persist phases // 4. Persist phases
await harness.createPhasesFromBreakdown(initiative.id, [ await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Core' }, { name: 'Core' },
{ name: 'Polish' }, { name: 'Polish' },
]); ]);

View File

@@ -1,10 +1,10 @@
/** /**
* E2E Tests for Decompose Workflow * E2E Tests for Detail Workflow
* *
* Tests the complete decomposition workflow from phase through task creation: * Tests the complete detail workflow from phase through task creation:
* - Decompose mode: Break phase into executable tasks * - Detail mode: Break phase into executable tasks
* - Q&A flow: Handle clarifying questions during decomposition * - Q&A flow: Handle clarifying questions during detailing
* - Task persistence: Save child tasks from decomposition output * - Task persistence: Save child tasks from detail output
* *
* Uses TestHarness from src/test/ for full system wiring. * Uses TestHarness from src/test/ for full system wiring.
*/ */
@@ -13,7 +13,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createTestHarness, type TestHarness } from '../index.js'; import { createTestHarness, type TestHarness } from '../index.js';
import type { AgentStoppedEvent, AgentWaitingEvent } from '../../events/types.js'; import type { AgentStoppedEvent, AgentWaitingEvent } from '../../events/types.js';
describe('Decompose Workflow E2E', () => { describe('Detail Workflow E2E', () => {
let harness: TestHarness; let harness: TestHarness;
beforeEach(() => { beforeEach(() => {
@@ -25,30 +25,30 @@ describe('Decompose Workflow E2E', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
describe('spawn decompose agent', () => { describe('spawn detail agent', () => {
it('should spawn agent in decompose mode and complete with tasks', async () => { it('should spawn agent in detail mode and complete with tasks', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
// Setup: Create initiative -> phase -> plan // Setup: Create initiative -> phase -> plan
const initiative = await harness.createInitiative('Test Project'); const initiative = await harness.createInitiative('Test Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [ const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' }, { name: 'Phase 1' },
]); ]);
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Auth Plan', 'Implement authentication'); const detailTask = await harness.createDetailTask(phases[0].id, 'Auth Plan', 'Implement authentication');
// Set decompose scenario // Set detail scenario
harness.setArchitectDecomposeComplete('decomposer', [ harness.setArchitectDetailComplete('detailer', [
{ number: 1, name: 'Create schema', content: 'User table', type: 'auto', dependencies: [] }, { number: 1, name: 'Create schema', content: 'User table', type: 'auto', dependencies: [] },
{ number: 2, name: 'Create endpoint', content: 'Login API', type: 'auto', dependencies: [1] }, { number: 2, name: 'Create endpoint', content: 'Login API', type: 'auto', dependencies: [1] },
]); ]);
// Spawn decompose agent // Spawn detail agent
const agent = await harness.caller.spawnArchitectDecompose({ const agent = await harness.caller.spawnArchitectDetail({
name: 'decomposer', name: 'detailer',
phaseId: phases[0].id, phaseId: phases[0].id,
}); });
expect(agent.mode).toBe('decompose'); expect(agent.mode).toBe('detail');
// Advance timers for async completion // Advance timers for async completion
await harness.advanceTimers(); await harness.advanceTimers();
@@ -56,33 +56,33 @@ describe('Decompose Workflow E2E', () => {
// Verify agent completed // Verify agent completed
const events = harness.getEmittedEvents('agent:stopped') as AgentStoppedEvent[]; const events = harness.getEmittedEvents('agent:stopped') as AgentStoppedEvent[];
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
expect(events[0].payload.name).toBe('decomposer'); expect(events[0].payload.name).toBe('detailer');
expect(events[0].payload.reason).toBe('decompose_complete'); expect(events[0].payload.reason).toBe('detail_complete');
}); });
it('should pause on questions and resume', async () => { it('should pause on questions and resume', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const initiative = await harness.createInitiative('Test Project'); const initiative = await harness.createInitiative('Test Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [ const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' }, { name: 'Phase 1' },
]); ]);
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Complex Plan'); const detailTask = await harness.createDetailTask(phases[0].id, 'Complex Plan');
// Set questions scenario // Set questions scenario
harness.setArchitectDecomposeQuestions('decomposer', [ harness.setArchitectDetailQuestions('detailer', [
{ id: 'q1', question: 'How granular should tasks be?' }, { id: 'q1', question: 'How granular should tasks be?' },
]); ]);
const agent = await harness.caller.spawnArchitectDecompose({ const agent = await harness.caller.spawnArchitectDetail({
name: 'decomposer', name: 'detailer',
phaseId: phases[0].id, phaseId: phases[0].id,
}); });
await harness.advanceTimers(); await harness.advanceTimers();
// Verify agent is waiting for input // Verify agent is waiting for input
const waitingAgent = await harness.caller.getAgent({ name: 'decomposer' }); const waitingAgent = await harness.caller.getAgent({ name: 'detailer' });
expect(waitingAgent?.status).toBe('waiting_for_input'); expect(waitingAgent?.status).toBe('waiting_for_input');
// Verify paused on questions (emits agent:waiting, not agent:stopped) // Verify paused on questions (emits agent:waiting, not agent:stopped)
@@ -96,19 +96,19 @@ describe('Decompose Workflow E2E', () => {
expect(pending?.questions[0].question).toBe('How granular should tasks be?'); expect(pending?.questions[0].question).toBe('How granular should tasks be?');
// Set completion scenario for resume // Set completion scenario for resume
harness.setArchitectDecomposeComplete('decomposer', [ harness.setArchitectDetailComplete('detailer', [
{ number: 1, name: 'Task 1', content: 'Single task', type: 'auto', dependencies: [] }, { number: 1, name: 'Task 1', content: 'Single task', type: 'auto', dependencies: [] },
]); ]);
// Resume with answer // Resume with answer
await harness.caller.resumeAgent({ await harness.caller.resumeAgent({
name: 'decomposer', name: 'detailer',
answers: { q1: 'Very granular' }, answers: { q1: 'Very granular' },
}); });
await harness.advanceTimers(); await harness.advanceTimers();
// Verify completed after resume // Verify completed after resume
const finalAgent = await harness.caller.getAgent({ name: 'decomposer' }); const finalAgent = await harness.caller.getAgent({ name: 'detailer' });
expect(finalAgent?.status).toBe('idle'); expect(finalAgent?.status).toBe('idle');
}); });
@@ -116,20 +116,20 @@ describe('Decompose Workflow E2E', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const initiative = await harness.createInitiative('Multi-Q Project'); const initiative = await harness.createInitiative('Multi-Q Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [ const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' }, { name: 'Phase 1' },
]); ]);
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Complex Plan'); const detailTask = await harness.createDetailTask(phases[0].id, 'Complex Plan');
// Set multiple questions scenario // Set multiple questions scenario
harness.setArchitectDecomposeQuestions('decomposer', [ harness.setArchitectDetailQuestions('detailer', [
{ id: 'q1', question: 'What task granularity?', options: [{ label: 'Fine' }, { label: 'Coarse' }] }, { id: 'q1', question: 'What task granularity?', options: [{ label: 'Fine' }, { label: 'Coarse' }] },
{ id: 'q2', question: 'Include checkpoints?' }, { id: 'q2', question: 'Include checkpoints?' },
{ id: 'q3', question: 'Any blocking dependencies?' }, { id: 'q3', question: 'Any blocking dependencies?' },
]); ]);
const agent = await harness.caller.spawnArchitectDecompose({ const agent = await harness.caller.spawnArchitectDetail({
name: 'decomposer', name: 'detailer',
phaseId: phases[0].id, phaseId: phases[0].id,
}); });
@@ -140,7 +140,7 @@ describe('Decompose Workflow E2E', () => {
expect(pending?.questions).toHaveLength(3); expect(pending?.questions).toHaveLength(3);
// Set completion scenario for resume // Set completion scenario for resume
harness.setArchitectDecomposeComplete('decomposer', [ harness.setArchitectDetailComplete('detailer', [
{ number: 1, name: 'Task 1', content: 'First task', type: 'auto', dependencies: [] }, { number: 1, name: 'Task 1', content: 'First task', type: 'auto', dependencies: [] },
{ number: 2, name: 'Task 2', content: 'Second task', type: 'auto', dependencies: [1] }, { number: 2, name: 'Task 2', content: 'Second task', type: 'auto', dependencies: [1] },
{ number: 3, name: 'Verify', content: 'Verify all', type: 'checkpoint:human-verify', dependencies: [2] }, { number: 3, name: 'Verify', content: 'Verify all', type: 'checkpoint:human-verify', dependencies: [2] },
@@ -148,7 +148,7 @@ describe('Decompose Workflow E2E', () => {
// Resume with all answers // Resume with all answers
await harness.caller.resumeAgent({ await harness.caller.resumeAgent({
name: 'decomposer', name: 'detailer',
answers: { answers: {
q1: 'Fine', q1: 'Fine',
q2: 'Yes, add human verification', q2: 'Yes, add human verification',
@@ -158,106 +158,106 @@ describe('Decompose Workflow E2E', () => {
await harness.advanceTimers(); await harness.advanceTimers();
// Verify completed // Verify completed
const finalAgent = await harness.caller.getAgent({ name: 'decomposer' }); const finalAgent = await harness.caller.getAgent({ name: 'detailer' });
expect(finalAgent?.status).toBe('idle'); expect(finalAgent?.status).toBe('idle');
}); });
}); });
describe('decompose conflict detection', () => { describe('detail conflict detection', () => {
it('should reject if a decompose agent is already running for the same phase', async () => { it('should reject if a detail agent is already running for the same phase', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const initiative = await harness.createInitiative('Test Project'); const initiative = await harness.createInitiative('Test Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [ const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' }, { name: 'Phase 1' },
]); ]);
// Long-running decompose agent // Long-running detail agent
harness.setAgentScenario('decomposer-1', { status: 'done', delay: 999999 }); harness.setAgentScenario('detailer-1', { status: 'done', delay: 999999 });
await harness.caller.spawnArchitectDecompose({ await harness.caller.spawnArchitectDetail({
name: 'decomposer-1', name: 'detailer-1',
phaseId: phases[0].id, phaseId: phases[0].id,
}); });
// Second decompose for same phase should be rejected // Second detail for same phase should be rejected
await expect( await expect(
harness.caller.spawnArchitectDecompose({ harness.caller.spawnArchitectDetail({
name: 'decomposer-2', name: 'detailer-2',
phaseId: phases[0].id, phaseId: phases[0].id,
}), }),
).rejects.toThrow(/already running/); ).rejects.toThrow(/already running/);
}); });
it('should auto-dismiss stale decompose agents before checking', async () => { it('should auto-dismiss stale detail agents before checking', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const initiative = await harness.createInitiative('Test Project'); const initiative = await harness.createInitiative('Test Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [ const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' }, { name: 'Phase 1' },
]); ]);
// Decompose agent that crashes immediately // Detail agent that crashes immediately
harness.setAgentScenario('stale-decomposer', { status: 'error', error: 'crashed' }); harness.setAgentScenario('stale-detailer', { status: 'error', error: 'crashed' });
await harness.caller.spawnArchitectDecompose({ await harness.caller.spawnArchitectDetail({
name: 'stale-decomposer', name: 'stale-detailer',
phaseId: phases[0].id, phaseId: phases[0].id,
}); });
await harness.advanceTimers(); await harness.advanceTimers();
// New decompose should succeed // New detail should succeed
harness.setArchitectDecomposeComplete('new-decomposer', [ harness.setArchitectDetailComplete('new-detailer', [
{ number: 1, name: 'Task 1', content: 'Do it', type: 'auto', dependencies: [] }, { number: 1, name: 'Task 1', content: 'Do it', type: 'auto', dependencies: [] },
]); ]);
const agent = await harness.caller.spawnArchitectDecompose({ const agent = await harness.caller.spawnArchitectDetail({
name: 'new-decomposer', name: 'new-detailer',
phaseId: phases[0].id, phaseId: phases[0].id,
}); });
expect(agent.mode).toBe('decompose'); expect(agent.mode).toBe('detail');
}); });
it('should allow decompose for different phases simultaneously', async () => { it('should allow detail for different phases simultaneously', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const initiative = await harness.createInitiative('Test Project'); const initiative = await harness.createInitiative('Test Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [ const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' }, { name: 'Phase 1' },
{ name: 'Phase 2' }, { name: 'Phase 2' },
]); ]);
// Long-running agent on phase 1 // Long-running agent on phase 1
harness.setAgentScenario('decomposer-p1', { status: 'done', delay: 999999 }); harness.setAgentScenario('detailer-p1', { status: 'done', delay: 999999 });
await harness.caller.spawnArchitectDecompose({ await harness.caller.spawnArchitectDetail({
name: 'decomposer-p1', name: 'detailer-p1',
phaseId: phases[0].id, phaseId: phases[0].id,
}); });
// Decompose on phase 2 should succeed // Detail on phase 2 should succeed
harness.setArchitectDecomposeComplete('decomposer-p2', [ harness.setArchitectDetailComplete('detailer-p2', [
{ number: 1, name: 'Task 1', content: 'Do it', type: 'auto', dependencies: [] }, { number: 1, name: 'Task 1', content: 'Do it', type: 'auto', dependencies: [] },
]); ]);
const agent = await harness.caller.spawnArchitectDecompose({ const agent = await harness.caller.spawnArchitectDetail({
name: 'decomposer-p2', name: 'detailer-p2',
phaseId: phases[1].id, phaseId: phases[1].id,
}); });
expect(agent.mode).toBe('decompose'); expect(agent.mode).toBe('detail');
}); });
}); });
describe('task persistence', () => { describe('task persistence', () => {
it('should create tasks from decomposition output', async () => { it('should create tasks from detail output', async () => {
const initiative = await harness.createInitiative('Test Project'); const initiative = await harness.createInitiative('Test Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [ const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' }, { name: 'Phase 1' },
]); ]);
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Auth Plan'); const detailTask = await harness.createDetailTask(phases[0].id, 'Auth Plan');
// Create tasks from decomposition // Create tasks from detail output
await harness.caller.createChildTasks({ await harness.caller.createChildTasks({
parentTaskId: decomposeTask.id, parentTaskId: detailTask.id,
tasks: [ tasks: [
{ number: 1, name: 'Schema', description: 'Create tables', type: 'auto', dependencies: [] }, { number: 1, name: 'Schema', description: 'Create tables', type: 'auto', dependencies: [] },
{ number: 2, name: 'API', description: 'Create endpoints', type: 'auto', dependencies: [1] }, { number: 2, name: 'API', description: 'Create endpoints', type: 'auto', dependencies: [1] },
@@ -266,7 +266,7 @@ describe('Decompose Workflow E2E', () => {
}); });
// Verify tasks created // Verify tasks created
const tasks = await harness.getChildTasks(decomposeTask.id); const tasks = await harness.getChildTasks(detailTask.id);
expect(tasks).toHaveLength(3); expect(tasks).toHaveLength(3);
expect(tasks[0].name).toBe('Schema'); expect(tasks[0].name).toBe('Schema');
expect(tasks[1].name).toBe('API'); expect(tasks[1].name).toBe('API');
@@ -276,14 +276,14 @@ describe('Decompose Workflow E2E', () => {
it('should handle all task types', async () => { it('should handle all task types', async () => {
const initiative = await harness.createInitiative('Task Types Test'); const initiative = await harness.createInitiative('Task Types Test');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [ const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' }, { name: 'Phase 1' },
]); ]);
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Mixed Tasks'); const detailTask = await harness.createDetailTask(phases[0].id, 'Mixed Tasks');
// Create tasks with all types // Create tasks with all types
await harness.caller.createChildTasks({ await harness.caller.createChildTasks({
parentTaskId: decomposeTask.id, parentTaskId: detailTask.id,
tasks: [ tasks: [
{ number: 1, name: 'Auto Task', description: 'Automated work', type: 'auto' }, { number: 1, name: 'Auto Task', description: 'Automated work', type: 'auto' },
{ number: 2, name: 'Human Verify', description: 'Visual check', type: 'checkpoint:human-verify', dependencies: [1] }, { number: 2, name: 'Human Verify', description: 'Visual check', type: 'checkpoint:human-verify', dependencies: [1] },
@@ -292,7 +292,7 @@ describe('Decompose Workflow E2E', () => {
], ],
}); });
const tasks = await harness.getChildTasks(decomposeTask.id); const tasks = await harness.getChildTasks(detailTask.id);
expect(tasks).toHaveLength(4); expect(tasks).toHaveLength(4);
expect(tasks[0].type).toBe('auto'); expect(tasks[0].type).toBe('auto');
expect(tasks[1].type).toBe('checkpoint:human-verify'); expect(tasks[1].type).toBe('checkpoint:human-verify');
@@ -302,14 +302,14 @@ describe('Decompose Workflow E2E', () => {
it('should create task dependencies', async () => { it('should create task dependencies', async () => {
const initiative = await harness.createInitiative('Dependencies Test'); const initiative = await harness.createInitiative('Dependencies Test');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [ const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' }, { name: 'Phase 1' },
]); ]);
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Dependent Tasks'); const detailTask = await harness.createDetailTask(phases[0].id, 'Dependent Tasks');
// Create tasks with complex dependencies // Create tasks with complex dependencies
await harness.caller.createChildTasks({ await harness.caller.createChildTasks({
parentTaskId: decomposeTask.id, parentTaskId: detailTask.id,
tasks: [ tasks: [
{ number: 1, name: 'Task A', description: 'No deps', type: 'auto' }, { number: 1, name: 'Task A', description: 'No deps', type: 'auto' },
{ number: 2, name: 'Task B', description: 'Depends on A', type: 'auto', dependencies: [1] }, { number: 2, name: 'Task B', description: 'Depends on A', type: 'auto', dependencies: [1] },
@@ -318,7 +318,7 @@ describe('Decompose Workflow E2E', () => {
], ],
}); });
const tasks = await harness.getChildTasks(decomposeTask.id); const tasks = await harness.getChildTasks(detailTask.id);
expect(tasks).toHaveLength(4); expect(tasks).toHaveLength(4);
// All tasks should be created with correct names // All tasks should be created with correct names
@@ -326,31 +326,31 @@ describe('Decompose Workflow E2E', () => {
}); });
}); });
describe('full decompose workflow', () => { describe('full detail workflow', () => {
it('should complete initiative -> phase -> plan -> decompose -> tasks workflow', async () => { it('should complete initiative -> phase -> plan -> detail -> tasks workflow', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
// 1. Create initiative // 1. Create initiative
const initiative = await harness.createInitiative('Full Workflow Test'); const initiative = await harness.createInitiative('Full Workflow Test');
// 2. Create phase // 2. Create phase
const phases = await harness.createPhasesFromBreakdown(initiative.id, [ const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Auth Phase' }, { name: 'Auth Phase' },
]); ]);
// 3. Create plan // 3. Create plan
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Auth Plan', 'Implement JWT auth'); const detailTask = await harness.createDetailTask(phases[0].id, 'Auth Plan', 'Implement JWT auth');
// 4. Spawn decompose agent // 4. Spawn detail agent
harness.setArchitectDecomposeComplete('decomposer', [ harness.setArchitectDetailComplete('detailer', [
{ number: 1, name: 'Create user schema', content: 'Define User model', type: 'auto', dependencies: [] }, { number: 1, name: 'Create user schema', content: 'Define User model', type: 'auto', dependencies: [] },
{ number: 2, name: 'Implement JWT', content: 'Token generation', type: 'auto', dependencies: [1] }, { number: 2, name: 'Implement JWT', content: 'Token generation', type: 'auto', dependencies: [1] },
{ number: 3, name: 'Protected routes', content: 'Middleware', type: 'auto', dependencies: [2] }, { number: 3, name: 'Protected routes', content: 'Middleware', type: 'auto', dependencies: [2] },
{ number: 4, name: 'Verify auth', content: 'Test login flow', type: 'checkpoint:human-verify', dependencies: [3] }, { number: 4, name: 'Verify auth', content: 'Test login flow', type: 'checkpoint:human-verify', dependencies: [3] },
]); ]);
await harness.caller.spawnArchitectDecompose({ await harness.caller.spawnArchitectDetail({
name: 'decomposer', name: 'detailer',
phaseId: phases[0].id, phaseId: phases[0].id,
}); });
await harness.advanceTimers(); await harness.advanceTimers();
@@ -358,11 +358,11 @@ describe('Decompose Workflow E2E', () => {
// 5. Verify agent completed // 5. Verify agent completed
const events = harness.getEmittedEvents('agent:stopped') as AgentStoppedEvent[]; const events = harness.getEmittedEvents('agent:stopped') as AgentStoppedEvent[];
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
expect(events[0].payload.reason).toBe('decompose_complete'); expect(events[0].payload.reason).toBe('detail_complete');
// 6. Persist tasks (simulating what orchestrator would do after decompose) // 6. Persist tasks (simulating what orchestrator would do after detail)
await harness.caller.createChildTasks({ await harness.caller.createChildTasks({
parentTaskId: decomposeTask.id, parentTaskId: detailTask.id,
tasks: [ tasks: [
{ number: 1, name: 'Create user schema', description: 'Define User model', type: 'auto', dependencies: [] }, { number: 1, name: 'Create user schema', description: 'Define User model', type: 'auto', dependencies: [] },
{ number: 2, name: 'Implement JWT', description: 'Token generation', type: 'auto', dependencies: [1] }, { number: 2, name: 'Implement JWT', description: 'Token generation', type: 'auto', dependencies: [1] },
@@ -372,13 +372,13 @@ describe('Decompose Workflow E2E', () => {
}); });
// 7. Verify final state // 7. Verify final state
const tasks = await harness.getChildTasks(decomposeTask.id); const tasks = await harness.getChildTasks(detailTask.id);
expect(tasks).toHaveLength(4); expect(tasks).toHaveLength(4);
expect(tasks[0].name).toBe('Create user schema'); expect(tasks[0].name).toBe('Create user schema');
expect(tasks[3].type).toBe('checkpoint:human-verify'); expect(tasks[3].type).toBe('checkpoint:human-verify');
// Agent should be idle // Agent should be idle
const finalAgent = await harness.caller.getAgent({ name: 'decomposer' }); const finalAgent = await harness.caller.getAgent({ name: 'detailer' });
expect(finalAgent?.status).toBe('idle'); expect(finalAgent?.status).toBe('idle');
}); });
}); });

View File

@@ -29,7 +29,7 @@ export interface TaskFixture {
/** Task priority */ /** Task priority */
priority?: 'low' | 'medium' | 'high'; priority?: 'low' | 'medium' | 'high';
/** Task category */ /** Task category */
category?: 'execute' | 'research' | 'discuss' | 'breakdown' | 'decompose' | 'refine' | 'verify' | 'merge' | 'review'; category?: 'execute' | 'research' | 'discuss' | 'plan' | 'detail' | 'refine' | 'verify' | 'merge' | 'review';
/** Names of other tasks in same fixture this task depends on */ /** Names of other tasks in same fixture this task depends on */
dependsOn?: string[]; dependsOn?: string[];
} }
@@ -39,7 +39,7 @@ export interface TaskFixture {
* Tasks are grouped by parent task in the new model. * Tasks are grouped by parent task in the new model.
*/ */
export interface TaskGroupFixture { export interface TaskGroupFixture {
/** Group name (becomes a decompose task) */ /** Group name (becomes a detail task) */
name: string; name: string;
/** Tasks in this group */ /** Tasks in this group */
tasks: TaskFixture[]; tasks: TaskFixture[];
@@ -51,7 +51,7 @@ export interface TaskGroupFixture {
export interface PhaseFixture { export interface PhaseFixture {
/** Phase name */ /** Phase name */
name: string; name: string;
/** Task groups in this phase (each group becomes a parent decompose task) */ /** Task groups in this phase (each group becomes a parent detail task) */
taskGroups: TaskGroupFixture[]; taskGroups: TaskGroupFixture[];
} }
@@ -87,7 +87,7 @@ export interface SeededFixture {
/** /**
* Seed a complete task hierarchy from a fixture definition. * Seed a complete task hierarchy from a fixture definition.
* *
* Creates initiative, phases, decompose tasks (as parent), and child tasks. * Creates initiative, phases, detail tasks (as parent), and child tasks.
* Resolves task dependencies by name to actual task IDs. * Resolves task dependencies by name to actual task IDs.
* *
* @param db - Drizzle database instance * @param db - Drizzle database instance
@@ -126,19 +126,19 @@ export async function seedFixture(
}); });
phasesMap.set(phaseFixture.name, phase.id); phasesMap.set(phaseFixture.name, phase.id);
// Create task groups as parent decompose tasks // Create task groups as parent detail tasks
let taskOrder = 0; let taskOrder = 0;
for (const groupFixture of phaseFixture.taskGroups) { for (const groupFixture of phaseFixture.taskGroups) {
// Create parent decompose task // Create parent detail task
const parentTask = await taskRepo.create({ const parentTask = await taskRepo.create({
phaseId: phase.id, phaseId: phase.id,
initiativeId: initiative.id, initiativeId: initiative.id,
name: groupFixture.name, name: groupFixture.name,
description: `Test task group: ${groupFixture.name}`, description: `Test task group: ${groupFixture.name}`,
category: 'decompose', category: 'detail',
type: 'auto', type: 'auto',
priority: 'medium', priority: 'medium',
status: 'completed', // Decompose tasks are completed once child tasks are created status: 'completed', // Detail tasks are completed once child tasks are created
order: taskOrder++, order: taskOrder++,
}); });
taskGroupsMap.set(groupFixture.name, parentTask.id); taskGroupsMap.set(groupFixture.name, parentTask.id);

View File

@@ -301,25 +301,25 @@ export interface TestHarness {
): void; ): void;
/** /**
* Set up scenario where architect completes breakdown. * Set up scenario where architect completes plan.
*/ */
setArchitectBreakdownComplete( setArchitectPlanComplete(
agentName: string, agentName: string,
_phases: unknown[] _phases: unknown[]
): void; ): void;
/** /**
* Set up scenario where architect completes decomposition. * Set up scenario where architect completes detail.
*/ */
setArchitectDecomposeComplete( setArchitectDetailComplete(
agentName: string, agentName: string,
_tasks: unknown[] _tasks: unknown[]
): void; ): void;
/** /**
* Set up scenario where architect needs questions in decompose mode. * Set up scenario where architect needs questions in detail mode.
*/ */
setArchitectDecomposeQuestions( setArchitectDetailQuestions(
agentName: string, agentName: string,
questions: QuestionItem[] questions: QuestionItem[]
): void; ): void;
@@ -344,17 +344,17 @@ export interface TestHarness {
createInitiative(name: string): Promise<Initiative>; createInitiative(name: string): Promise<Initiative>;
/** /**
* Create phases from breakdown output through tRPC. * Create phases from plan output through tRPC.
*/ */
createPhasesFromBreakdown( createPhasesFromPlan(
initiativeId: string, initiativeId: string,
phases: Array<{ name: string }> phases: Array<{ name: string }>
): Promise<Phase[]>; ): Promise<Phase[]>;
/** /**
* Create a decompose task through tRPC (replaces createPlan). * Create a detail task through tRPC (replaces createPlan).
*/ */
createDecomposeTask( createDetailTask(
phaseId: string, phaseId: string,
name: string, name: string,
description?: string description?: string
@@ -543,29 +543,29 @@ export function createTestHarness(): TestHarness {
}); });
}, },
setArchitectBreakdownComplete: ( setArchitectPlanComplete: (
agentName: string, agentName: string,
_phases: unknown[] _phases: unknown[]
) => { ) => {
agentManager.setScenario(agentName, { agentManager.setScenario(agentName, {
status: 'done', status: 'done',
result: 'Breakdown complete', result: 'Plan complete',
delay: 0, delay: 0,
}); });
}, },
setArchitectDecomposeComplete: ( setArchitectDetailComplete: (
agentName: string, agentName: string,
_tasks: unknown[] _tasks: unknown[]
) => { ) => {
agentManager.setScenario(agentName, { agentManager.setScenario(agentName, {
status: 'done', status: 'done',
result: 'Decompose complete', result: 'Detail complete',
delay: 0, delay: 0,
}); });
}, },
setArchitectDecomposeQuestions: ( setArchitectDetailQuestions: (
agentName: string, agentName: string,
questions: QuestionItem[] questions: QuestionItem[]
) => { ) => {
@@ -596,19 +596,19 @@ export function createTestHarness(): TestHarness {
return caller.createInitiative({ name }); return caller.createInitiative({ name });
}, },
createPhasesFromBreakdown: ( createPhasesFromPlan: (
initiativeId: string, initiativeId: string,
phases: Array<{ name: string }> phases: Array<{ name: string }>
) => { ) => {
return caller.createPhasesFromBreakdown({ initiativeId, phases }); return caller.createPhasesFromPlan({ initiativeId, phases });
}, },
createDecomposeTask: async (phaseId: string, name: string, description?: string) => { createDetailTask: async (phaseId: string, name: string, description?: string) => {
return caller.createPhaseTask({ return caller.createPhaseTask({
phaseId, phaseId,
name, name,
description, description,
category: 'decompose', category: 'detail',
type: 'auto', type: 'auto',
requiresApproval: true, requiresApproval: true,
}); });

View File

@@ -17,7 +17,7 @@ interface TestAgent {
id: string; id: string;
name: string; name: string;
status: 'idle' | 'running' | 'waiting_for_input' | 'stopped' | 'crashed'; status: 'idle' | 'running' | 'waiting_for_input' | 'stopped' | 'crashed';
mode: 'execute' | 'discuss' | 'breakdown' | 'decompose' | 'refine'; mode: 'execute' | 'discuss' | 'plan' | 'detail' | 'refine';
taskId: string | null; taskId: string | null;
sessionId: string | null; sessionId: string | null;
worktreeId: string; worktreeId: string;

View File

@@ -82,17 +82,17 @@ Now complete the task by outputting:
{"status":"done"}`, {"status":"done"}`,
/** /**
* ~$0.02 - Breakdown complete * ~$0.02 - Plan complete
* Tests: breakdown mode output handling (now uses universal done signal) * Tests: plan mode output handling (now uses universal done signal)
*/ */
breakdownComplete: `Output exactly this JSON with no other text: planComplete: `Output exactly this JSON with no other text:
{"status":"done"}`, {"status":"done"}`,
/** /**
* ~$0.02 - Decompose complete * ~$0.02 - Detail complete
* Tests: decompose mode output handling (now uses universal done signal) * Tests: detail mode output handling (now uses universal done signal)
*/ */
decomposeComplete: `Output exactly this JSON with no other text: detailComplete: `Output exactly this JSON with no other text:
{"status":"done"}`, {"status":"done"}`,
} as const; } as const;

View File

@@ -262,12 +262,12 @@ describeRealClaude('Schema Validation & Retry', () => {
); );
it( it(
'validates breakdown mode output', 'validates plan mode output',
async () => { async () => {
const agent = await harness.agentManager.spawn({ const agent = await harness.agentManager.spawn({
taskId: null, taskId: null,
prompt: MINIMAL_PROMPTS.breakdownComplete, prompt: MINIMAL_PROMPTS.planComplete,
mode: 'breakdown', mode: 'plan',
provider: 'claude', provider: 'claude',
}); });
@@ -277,18 +277,18 @@ describeRealClaude('Schema Validation & Retry', () => {
expect(dbAgent?.status).toBe('idle'); expect(dbAgent?.status).toBe('idle');
expect(result?.success).toBe(true); expect(result?.success).toBe(true);
console.log(' Breakdown mode result:', result?.message); console.log(' Plan mode result:', result?.message);
}, },
REAL_TEST_TIMEOUT REAL_TEST_TIMEOUT
); );
it( it(
'validates decompose mode output', 'validates detail mode output',
async () => { async () => {
const agent = await harness.agentManager.spawn({ const agent = await harness.agentManager.spawn({
taskId: null, taskId: null,
prompt: MINIMAL_PROMPTS.decomposeComplete, prompt: MINIMAL_PROMPTS.detailComplete,
mode: 'decompose', mode: 'detail',
provider: 'claude', provider: 'claude',
}); });
@@ -298,7 +298,7 @@ describeRealClaude('Schema Validation & Retry', () => {
expect(dbAgent?.status).toBe('idle'); expect(dbAgent?.status).toBe('idle');
expect(result?.success).toBe(true); expect(result?.success).toBe(true);
console.log(' Decompose mode result:', result?.message); console.log(' Detail mode result:', result?.message);
}, },
REAL_TEST_TIMEOUT REAL_TEST_TIMEOUT
); );

View File

@@ -18,7 +18,7 @@ export const spawnAgentInputSchema = z.object({
taskId: z.string().min(1), taskId: z.string().min(1),
prompt: z.string().min(1), prompt: z.string().min(1),
cwd: z.string().optional(), cwd: z.string().optional(),
mode: z.enum(['execute', 'discuss', 'breakdown', 'decompose', 'refine']).optional(), mode: z.enum(['execute', 'discuss', 'plan', 'detail', 'refine']).optional(),
provider: z.string().optional(), provider: z.string().optional(),
initiativeId: z.string().min(1).optional(), initiativeId: z.string().min(1).optional(),
}); });

View File

@@ -1,5 +1,5 @@
/** /**
* Architect Router — discuss, breakdown, refine, decompose spawn procedures * Architect Router — discuss, plan, refine, detail spawn procedures
*/ */
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
@@ -14,9 +14,9 @@ import {
} from './_helpers.js'; } from './_helpers.js';
import { import {
buildDiscussPrompt, buildDiscussPrompt,
buildBreakdownPrompt, buildPlanPrompt,
buildRefinePrompt, buildRefinePrompt,
buildDecomposePrompt, buildDetailPrompt,
} from '../../agent/prompts/index.js'; } from '../../agent/prompts/index.js';
import type { PhaseRepository } from '../../db/repositories/phase-repository.js'; import type { PhaseRepository } from '../../db/repositories/phase-repository.js';
import type { TaskRepository } from '../../db/repositories/task-repository.js'; import type { TaskRepository } from '../../db/repositories/task-repository.js';
@@ -114,7 +114,7 @@ export function architectProcedures(publicProcedure: ProcedureBuilder) {
}); });
}), }),
spawnArchitectBreakdown: publicProcedure spawnArchitectPlan: publicProcedure
.input(z.object({ .input(z.object({
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
initiativeId: z.string().min(1), initiativeId: z.string().min(1),
@@ -134,11 +134,11 @@ export function architectProcedures(publicProcedure: ProcedureBuilder) {
}); });
} }
// Auto-dismiss stale breakdown agents // Auto-dismiss stale plan agents
const allAgents = await agentManager.list(); const allAgents = await agentManager.list();
const staleAgents = allAgents.filter( const staleAgents = allAgents.filter(
(a) => (a) =>
a.mode === 'breakdown' && a.mode === 'plan' &&
a.initiativeId === input.initiativeId && a.initiativeId === input.initiativeId &&
['crashed', 'idle'].includes(a.status) && ['crashed', 'idle'].includes(a.status) &&
!a.userDismissedAt, !a.userDismissedAt,
@@ -147,37 +147,37 @@ export function architectProcedures(publicProcedure: ProcedureBuilder) {
await agentManager.dismiss(stale.id); await agentManager.dismiss(stale.id);
} }
// Reject if a breakdown agent is already active for this initiative // Reject if a plan agent is already active for this initiative
const activeBreakdownAgents = allAgents.filter( const activePlanAgents = allAgents.filter(
(a) => (a) =>
a.mode === 'breakdown' && a.mode === 'plan' &&
a.initiativeId === input.initiativeId && a.initiativeId === input.initiativeId &&
['running', 'waiting_for_input'].includes(a.status), ['running', 'waiting_for_input'].includes(a.status),
); );
if (activeBreakdownAgents.length > 0) { if (activePlanAgents.length > 0) {
throw new TRPCError({ throw new TRPCError({
code: 'CONFLICT', code: 'CONFLICT',
message: 'A breakdown agent is already running for this initiative', message: 'A plan agent is already running for this initiative',
}); });
} }
const task = await taskRepo.create({ const task = await taskRepo.create({
initiativeId: input.initiativeId, initiativeId: input.initiativeId,
name: `Breakdown: ${initiative.name}`, name: `Plan: ${initiative.name}`,
description: 'Break initiative into phases', description: 'Plan initiative into phases',
category: 'breakdown', category: 'plan',
status: 'in_progress', status: 'in_progress',
}); });
const context = await gatherInitiativeContext(ctx.phaseRepository, ctx.taskRepository, ctx.pageRepository, input.initiativeId); const context = await gatherInitiativeContext(ctx.phaseRepository, ctx.taskRepository, ctx.pageRepository, input.initiativeId);
const prompt = buildBreakdownPrompt(); const prompt = buildPlanPrompt();
return agentManager.spawn({ return agentManager.spawn({
name: input.name, name: input.name,
taskId: task.id, taskId: task.id,
prompt, prompt,
mode: 'breakdown', mode: 'plan',
provider: input.provider, provider: input.provider,
initiativeId: input.initiativeId, initiativeId: input.initiativeId,
inputContext: { inputContext: {
@@ -267,7 +267,7 @@ export function architectProcedures(publicProcedure: ProcedureBuilder) {
}); });
}), }),
spawnArchitectDecompose: publicProcedure spawnArchitectDetail: publicProcedure
.input(z.object({ .input(z.object({
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
phaseId: z.string().min(1), phaseId: z.string().min(1),
@@ -296,16 +296,16 @@ export function architectProcedures(publicProcedure: ProcedureBuilder) {
}); });
} }
// Auto-dismiss stale decompose agents for this phase // Auto-dismiss stale detail agents for this phase
const allAgents = await agentManager.list(); const allAgents = await agentManager.list();
const decomposeAgents = allAgents.filter( const detailAgents = allAgents.filter(
(a) => a.mode === 'decompose' && !a.userDismissedAt, (a) => a.mode === 'detail' && !a.userDismissedAt,
); );
// Look up tasks to find which phase each decompose agent targets // Look up tasks to find which phase each detail agent targets
const activeForPhase: typeof decomposeAgents = []; const activeForPhase: typeof detailAgents = [];
const staleForPhase: typeof decomposeAgents = []; const staleForPhase: typeof detailAgents = [];
for (const agent of decomposeAgents) { for (const agent of detailAgents) {
if (!agent.taskId) continue; if (!agent.taskId) continue;
const agentTask = await taskRepo.findById(agent.taskId); const agentTask = await taskRepo.findById(agent.taskId);
if (agentTask?.phaseId !== input.phaseId) continue; if (agentTask?.phaseId !== input.phaseId) continue;
@@ -322,29 +322,29 @@ export function architectProcedures(publicProcedure: ProcedureBuilder) {
if (activeForPhase.length > 0) { if (activeForPhase.length > 0) {
throw new TRPCError({ throw new TRPCError({
code: 'CONFLICT', code: 'CONFLICT',
message: `A decompose agent is already running for phase "${phase.name}"`, message: `A detail agent is already running for phase "${phase.name}"`,
}); });
} }
const decomposeTaskName = input.taskName ?? `Decompose: ${phase.name}`; const detailTaskName = input.taskName ?? `Detail: ${phase.name}`;
const task = await taskRepo.create({ const task = await taskRepo.create({
phaseId: phase.id, phaseId: phase.id,
initiativeId: phase.initiativeId, initiativeId: phase.initiativeId,
name: decomposeTaskName, name: detailTaskName,
description: input.context ?? `Break phase "${phase.name}" into executable tasks`, description: input.context ?? `Detail phase "${phase.name}" into executable tasks`,
category: 'decompose', category: 'detail',
status: 'in_progress', status: 'in_progress',
}); });
const context = await gatherInitiativeContext(ctx.phaseRepository, ctx.taskRepository, ctx.pageRepository, phase.initiativeId); const context = await gatherInitiativeContext(ctx.phaseRepository, ctx.taskRepository, ctx.pageRepository, phase.initiativeId);
const prompt = buildDecomposePrompt(); const prompt = buildDetailPrompt();
return agentManager.spawn({ return agentManager.spawn({
name: input.name, name: input.name,
taskId: task.id, taskId: task.id,
prompt, prompt,
mode: 'decompose', mode: 'detail',
provider: input.provider, provider: input.provider,
initiativeId: phase.initiativeId, initiativeId: phase.initiativeId,
inputContext: { inputContext: {

View File

@@ -51,10 +51,10 @@ export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
message: `Parent task '${input.parentTaskId}' not found`, message: `Parent task '${input.parentTaskId}' not found`,
}); });
} }
if (parentTask.category !== 'decompose') { if (parentTask.category !== 'detail') {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: `Parent task must have category 'decompose', got '${parentTask.category}'`, message: `Parent task must have category 'detail', got '${parentTask.category}'`,
}); });
} }

View File

@@ -7,7 +7,7 @@ import { z } from 'zod';
import type { Phase } from '../../db/schema.js'; import type { Phase } from '../../db/schema.js';
import type { ProcedureBuilder } from '../trpc.js'; import type { ProcedureBuilder } from '../trpc.js';
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator } from './_helpers.js'; import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator } from './_helpers.js';
import { initiativeBranchName, phaseBranchName } from '../../git/branch-naming.js'; import { phaseBranchName } from '../../git/branch-naming.js';
import { ensureProjectClone } from '../../git/project-clones.js'; import { ensureProjectClone } from '../../git/project-clones.js';
export function phaseProcedures(publicProcedure: ProcedureBuilder) { export function phaseProcedures(publicProcedure: ProcedureBuilder) {
@@ -80,9 +80,9 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
}); });
} }
// Validate phase has work tasks (filter out decompose tasks) // Validate phase has work tasks (filter out detail tasks)
const phaseTasks = await taskRepo.findByPhaseId(input.phaseId); const phaseTasks = await taskRepo.findByPhaseId(input.phaseId);
const workTasks = phaseTasks.filter((t) => t.category !== 'decompose'); const workTasks = phaseTasks.filter((t) => t.category !== 'detail');
if (workTasks.length === 0) { if (workTasks.length === 0) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
@@ -101,7 +101,7 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return { success: true }; return { success: true };
}), }),
createPhasesFromBreakdown: publicProcedure createPhasesFromPlan: publicProcedure
.input(z.object({ .input(z.object({
initiativeId: z.string().min(1), initiativeId: z.string().min(1),
phases: z.array(z.object({ phases: z.array(z.object({
@@ -201,11 +201,11 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
} }
const initiative = await initiativeRepo.findById(phase.initiativeId); const initiative = await initiativeRepo.findById(phase.initiativeId);
if (!initiative?.mergeTarget) { if (!initiative?.branch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no merge target' }); throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
} }
const initBranch = initiativeBranchName(initiative.mergeTarget); const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name); const phBranch = phaseBranchName(initBranch, phase.name);
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId); const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);

View File

@@ -57,7 +57,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
initiativeId: z.string().min(1), initiativeId: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
category: z.enum(['execute', 'research', 'discuss', 'breakdown', 'decompose', 'refine', 'verify', 'merge', 'review']).optional(), category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(), type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(),
requiresApproval: z.boolean().nullable().optional(), requiresApproval: z.boolean().nullable().optional(),
})) }))
@@ -89,7 +89,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
phaseId: z.string().min(1), phaseId: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
category: z.enum(['execute', 'research', 'discuss', 'breakdown', 'decompose', 'refine', 'verify', 'merge', 'review']).optional(), category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(), type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(),
requiresApproval: z.boolean().nullable().optional(), requiresApproval: z.boolean().nullable().optional(),
})) }))
@@ -120,7 +120,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
.input(z.object({ .input(z.object({
initiativeId: z.string().optional(), initiativeId: z.string().optional(),
phaseId: z.string().optional(), phaseId: z.string().optional(),
category: z.enum(['execute', 'research', 'discuss', 'breakdown', 'decompose', 'refine', 'verify', 'merge', 'review']).optional(), category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
}).optional()) }).optional())
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx); const taskRepository = requireTaskRepository(ctx);
@@ -132,7 +132,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx); const taskRepository = requireTaskRepository(ctx);
const tasks = await taskRepository.findByInitiativeId(input.initiativeId); const tasks = await taskRepository.findByInitiativeId(input.initiativeId);
return tasks.filter((t) => t.category !== 'decompose'); return tasks.filter((t) => t.category !== 'detail');
}), }),
listPhaseTasks: publicProcedure listPhaseTasks: publicProcedure
@@ -140,7 +140,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx); const taskRepository = requireTaskRepository(ctx);
const tasks = await taskRepository.findByPhaseId(input.phaseId); const tasks = await taskRepository.findByPhaseId(input.phaseId);
return tasks.filter((t) => t.category !== 'decompose'); return tasks.filter((t) => t.category !== 'detail');
}), }),
approveTask: publicProcedure approveTask: publicProcedure