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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
4
drizzle/0022_branch_refactor.sql
Normal file
4
drizzle/0022_branch_refactor.sql
Normal 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';
|
||||||
12
drizzle/0023_rename_breakdown_decompose.sql
Normal file
12
drizzle/0023_rename_breakdown_decompose.sql
Normal 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';
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
11
packages/web/src/lib/labels.ts
Normal file
11
packages/web/src/lib/labels.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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" && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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}'`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user