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

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

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

View File

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

View File

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

View File

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

View File

@@ -426,7 +426,7 @@ export class OutputHandler {
let resultMessage = summary?.body ?? 'Task completed';
switch (mode) {
case 'breakdown': {
case 'plan': {
const phases = readPhaseFiles(agentWorkdir);
if (canWriteChangeSets && this.phaseRepository && phases.length > 0) {
const entries: CreateChangeSetEntryData[] = [];
@@ -485,13 +485,13 @@ export class OutputHandler {
agentId,
agentName: agent.name,
initiativeId,
mode: 'breakdown',
mode: 'plan',
summary: summary?.body ?? `Created ${phases.length} phases`,
}, entries);
this.eventBus?.emit({
type: 'changeset:created' as const,
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) {
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;
}
case 'decompose': {
case 'detail': {
const tasks = readTaskFiles(agentWorkdir);
if (canWriteChangeSets && this.taskRepository && tasks.length > 0) {
const phaseInput = readFrontmatterFile(join(agentWorkdir, '.cw', 'input', 'phase.md'));
const phaseId = (phaseInput?.data?.id as string) ?? null;
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()) {
if (existingNames.has(t.title)) {
log.info({ agentId, task: t.title, phaseId }, 'skipped duplicate task');
continue;
}
try {
const created = await this.taskRepository.create({
initiativeId,
@@ -521,6 +529,7 @@ export class OutputHandler {
category: (t.category as any) ?? 'execute',
type: (t.type as any) ?? 'auto',
});
existingNames.add(t.title); // prevent dupes within same agent output
entries.push({
entityType: 'task',
entityId: created.id,
@@ -531,7 +540,7 @@ export class OutputHandler {
this.eventBus?.emit({
type: 'task:completed' as const,
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) {
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,
agentName: agent.name,
initiativeId,
mode: 'decompose',
mode: 'detail',
summary: summary?.body ?? `Created ${tasks.length} tasks`,
}, entries);
this.eventBus?.emit({
type: 'changeset:created' as const,
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) {
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'] {
switch (mode) {
case 'discuss': return 'context_complete';
case 'breakdown': return 'breakdown_complete';
case 'decompose': return 'decompose_complete';
case 'plan': return 'plan_complete';
case 'detail': return 'detail_complete';
case 'refine': return 'refine_complete';
default: return 'task_complete';
}

View File

@@ -138,7 +138,7 @@ describe('ProcessManager', () => {
// Mock project repository
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

View File

@@ -115,14 +115,14 @@ ${ID_GENERATION}
}
/**
* Build prompt for breakdown mode.
* Agent decomposes initiative into executable phases.
* Build prompt for plan mode.
* Agent plans initiative into executable phases.
*/
export function buildBreakdownPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in BREAKDOWN mode.
export function buildPlanPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in PLAN mode.
## 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}
${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.
*/
export function buildDecomposePrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in DECOMPOSE mode.
export function buildDetailPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in DETAIL mode.
## 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}
${SIGNAL_FORMAT}
@@ -165,7 +165,7 @@ ${SIGNAL_FORMAT}
Write one file per task to \`.cw/output/tasks/{id}.md\`:
- Frontmatter:
- \`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
- \`dependencies\`: List of other task IDs this depends on
- Body: Detailed description of what the task requires

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
/**
* Breakdown mode prompt decompose initiative into phases.
* Plan mode prompt plan initiative into phases.
*/
import { ID_GENERATION, INPUT_FILES, SIGNAL_FORMAT } from './shared.js';
export function buildBreakdownPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in BREAKDOWN mode.
export function buildPlanPrompt(): string {
return `You are an Architect agent in the Codewalk multi-agent system operating in PLAN mode.
## 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}
${SIGNAL_FORMAT}

View File

@@ -12,10 +12,10 @@ export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' |
*
* - execute: Standard task execution (default)
* - discuss: Gather context through questions, output decisions
* - breakdown: Decompose initiative into phases
* - decompose: Decompose phase into individual tasks
* - plan: Plan initiative into phases
* - 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.

View File

@@ -847,52 +847,52 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
}
});
// cw architect breakdown <initiative-id>
// cw architect plan <initiative-id>
architectCommand
.command('breakdown <initiativeId>')
.description('Start breakdown phase for an initiative')
.command('plan <initiativeId>')
.description('Plan phases for an initiative')
.option('--name <name>', 'Agent name (auto-generated if omitted)')
.option('-s, --summary <summary>', 'Context summary from discuss phase')
.action(async (initiativeId: string, options: { name?: string; summary?: string }) => {
try {
const client = createDefaultTrpcClient();
const agent = await client.spawnArchitectBreakdown.mutate({
const agent = await client.spawnArchitectPlan.mutate({
name: options.name,
initiativeId,
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(` Mode: ${agent.mode}`);
console.log(` Initiative: ${initiativeId}`);
} 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);
}
});
// cw architect decompose <phase-id>
// cw architect detail <phase-id>
architectCommand
.command('decompose <phaseId>')
.description('Decompose a phase into tasks')
.command('detail <phaseId>')
.description('Detail a phase into tasks')
.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')
.action(async (phaseId: string, options: { name?: string; taskName?: string; context?: string }) => {
try {
const client = createDefaultTrpcClient();
const agent = await client.spawnArchitectDecompose.mutate({
const agent = await client.spawnArchitectDetail.mutate({
name: options.name,
phaseId,
taskName: options.taskName,
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(` Mode: ${agent.mode}`);
console.log(` Phase: ${phaseId}`);
} 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);
}
});

View File

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

View File

@@ -27,7 +27,7 @@ describe('Cascade Deletes', () => {
/**
* 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() {
const initiative = await initiativeRepo.create({
@@ -44,12 +44,12 @@ describe('Cascade Deletes', () => {
name: 'Phase 2',
});
// Create parent (decompose) tasks that group child tasks
// Create parent (detail) tasks that group child tasks
const parentTask1 = await taskRepo.create({
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Parent Task 1-1',
category: 'decompose',
category: 'detail',
order: 1,
});
@@ -57,7 +57,7 @@ describe('Cascade Deletes', () => {
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Parent Task 1-2',
category: 'decompose',
category: 'detail',
order: 2,
});
@@ -65,7 +65,7 @@ describe('Cascade Deletes', () => {
phaseId: phase2.id,
initiativeId: initiative.id,
name: 'Parent Task 2-1',
category: 'decompose',
category: 'detail',
order: 1,
});

View File

@@ -25,7 +25,7 @@ export const initiatives = sqliteTable('initiatives', {
mergeRequiresApproval: integer('merge_requires_approval', { mode: 'boolean' })
.notNull()
.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'] })
.notNull()
.default('review_per_phase'),
@@ -120,8 +120,8 @@ export const TASK_CATEGORIES = [
'execute', // Standard execution task
'research', // Research/exploration task
'discuss', // Discussion/context gathering
'breakdown', // Break initiative into phases
'decompose', // Decompose plan into tasks
'plan', // Plan initiative into phases
'detail', // Detail phase into tasks
'refine', // Refine/edit content
'verify', // Verification task
'merge', // Merge task
@@ -135,7 +135,7 @@ export const tasks = sqliteTable('tasks', {
// Parent context - at least one should be set
phaseId: text('phase_id').references(() => phases.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' }),
name: text('name').notNull(),
description: text('description'),
@@ -172,7 +172,7 @@ export const tasksRelations = relations(tasks, ({ one, many }) => ({
fields: [tasks.initiativeId],
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, {
fields: [tasks.parentTaskId],
references: [tasks.id],
@@ -263,7 +263,7 @@ export const agents = sqliteTable('agents', {
})
.notNull()
.default('idle'),
mode: text('mode', { enum: ['execute', 'discuss', 'breakdown', 'decompose', 'refine'] })
mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine'] })
.notNull()
.default('execute'),
pid: integer('pid'),
@@ -307,7 +307,7 @@ export const changeSets = sqliteTable('change_sets', {
initiativeId: text('initiative_id')
.notNull()
.references(() => initiatives.id, { onDelete: 'cascade' }),
mode: text('mode', { enum: ['breakdown', 'decompose', 'refine'] }).notNull(),
mode: text('mode', { enum: ['plan', 'detail', 'refine'] }).notNull(),
summary: text('summary'),
status: text('status', { enum: ['applied', 'reverted'] })
.notNull()
@@ -451,6 +451,7 @@ export const projects = sqliteTable('projects', {
id: text('id').primaryKey(),
name: text('name').notNull().unique(),
url: text('url').notNull().unique(),
defaultBranch: text('default_branch').notNull().default('main'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
/**
* E2E Tests for Decompose Workflow
* E2E Tests for Detail Workflow
*
* Tests the complete decomposition workflow from phase through task creation:
* - Decompose mode: Break phase into executable tasks
* - Q&A flow: Handle clarifying questions during decomposition
* - Task persistence: Save child tasks from decomposition output
* Tests the complete detail workflow from phase through task creation:
* - Detail mode: Break phase into executable tasks
* - Q&A flow: Handle clarifying questions during detailing
* - Task persistence: Save child tasks from detail output
*
* 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 type { AgentStoppedEvent, AgentWaitingEvent } from '../../events/types.js';
describe('Decompose Workflow E2E', () => {
describe('Detail Workflow E2E', () => {
let harness: TestHarness;
beforeEach(() => {
@@ -25,30 +25,30 @@ describe('Decompose Workflow E2E', () => {
vi.useRealTimers();
});
describe('spawn decompose agent', () => {
it('should spawn agent in decompose mode and complete with tasks', async () => {
describe('spawn detail agent', () => {
it('should spawn agent in detail mode and complete with tasks', async () => {
vi.useFakeTimers();
// Setup: Create initiative -> phase -> plan
const initiative = await harness.createInitiative('Test Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
const phases = await harness.createPhasesFromPlan(initiative.id, [
{ 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
harness.setArchitectDecomposeComplete('decomposer', [
// Set detail scenario
harness.setArchitectDetailComplete('detailer', [
{ number: 1, name: 'Create schema', content: 'User table', type: 'auto', dependencies: [] },
{ number: 2, name: 'Create endpoint', content: 'Login API', type: 'auto', dependencies: [1] },
]);
// Spawn decompose agent
const agent = await harness.caller.spawnArchitectDecompose({
name: 'decomposer',
// Spawn detail agent
const agent = await harness.caller.spawnArchitectDetail({
name: 'detailer',
phaseId: phases[0].id,
});
expect(agent.mode).toBe('decompose');
expect(agent.mode).toBe('detail');
// Advance timers for async completion
await harness.advanceTimers();
@@ -56,33 +56,33 @@ describe('Decompose Workflow E2E', () => {
// Verify agent completed
const events = harness.getEmittedEvents('agent:stopped') as AgentStoppedEvent[];
expect(events).toHaveLength(1);
expect(events[0].payload.name).toBe('decomposer');
expect(events[0].payload.reason).toBe('decompose_complete');
expect(events[0].payload.name).toBe('detailer');
expect(events[0].payload.reason).toBe('detail_complete');
});
it('should pause on questions and resume', async () => {
vi.useFakeTimers();
const initiative = await harness.createInitiative('Test Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
const phases = await harness.createPhasesFromPlan(initiative.id, [
{ 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
harness.setArchitectDecomposeQuestions('decomposer', [
harness.setArchitectDetailQuestions('detailer', [
{ id: 'q1', question: 'How granular should tasks be?' },
]);
const agent = await harness.caller.spawnArchitectDecompose({
name: 'decomposer',
const agent = await harness.caller.spawnArchitectDetail({
name: 'detailer',
phaseId: phases[0].id,
});
await harness.advanceTimers();
// 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');
// 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?');
// Set completion scenario for resume
harness.setArchitectDecomposeComplete('decomposer', [
harness.setArchitectDetailComplete('detailer', [
{ number: 1, name: 'Task 1', content: 'Single task', type: 'auto', dependencies: [] },
]);
// Resume with answer
await harness.caller.resumeAgent({
name: 'decomposer',
name: 'detailer',
answers: { q1: 'Very granular' },
});
await harness.advanceTimers();
// 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');
});
@@ -116,20 +116,20 @@ describe('Decompose Workflow E2E', () => {
vi.useFakeTimers();
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' },
]);
const decomposeTask = await harness.createDecomposeTask(phases[0].id, 'Complex Plan');
const detailTask = await harness.createDetailTask(phases[0].id, 'Complex Plan');
// Set multiple questions scenario
harness.setArchitectDecomposeQuestions('decomposer', [
harness.setArchitectDetailQuestions('detailer', [
{ id: 'q1', question: 'What task granularity?', options: [{ label: 'Fine' }, { label: 'Coarse' }] },
{ id: 'q2', question: 'Include checkpoints?' },
{ id: 'q3', question: 'Any blocking dependencies?' },
]);
const agent = await harness.caller.spawnArchitectDecompose({
name: 'decomposer',
const agent = await harness.caller.spawnArchitectDetail({
name: 'detailer',
phaseId: phases[0].id,
});
@@ -140,7 +140,7 @@ describe('Decompose Workflow E2E', () => {
expect(pending?.questions).toHaveLength(3);
// Set completion scenario for resume
harness.setArchitectDecomposeComplete('decomposer', [
harness.setArchitectDetailComplete('detailer', [
{ number: 1, name: 'Task 1', content: 'First task', type: 'auto', dependencies: [] },
{ 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] },
@@ -148,7 +148,7 @@ describe('Decompose Workflow E2E', () => {
// Resume with all answers
await harness.caller.resumeAgent({
name: 'decomposer',
name: 'detailer',
answers: {
q1: 'Fine',
q2: 'Yes, add human verification',
@@ -158,106 +158,106 @@ describe('Decompose Workflow E2E', () => {
await harness.advanceTimers();
// Verify completed
const finalAgent = await harness.caller.getAgent({ name: 'decomposer' });
const finalAgent = await harness.caller.getAgent({ name: 'detailer' });
expect(finalAgent?.status).toBe('idle');
});
});
describe('decompose conflict detection', () => {
it('should reject if a decompose agent is already running for the same phase', async () => {
describe('detail conflict detection', () => {
it('should reject if a detail agent is already running for the same phase', async () => {
vi.useFakeTimers();
const initiative = await harness.createInitiative('Test Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' },
]);
// Long-running decompose agent
harness.setAgentScenario('decomposer-1', { status: 'done', delay: 999999 });
// Long-running detail agent
harness.setAgentScenario('detailer-1', { status: 'done', delay: 999999 });
await harness.caller.spawnArchitectDecompose({
name: 'decomposer-1',
await harness.caller.spawnArchitectDetail({
name: 'detailer-1',
phaseId: phases[0].id,
});
// Second decompose for same phase should be rejected
// Second detail for same phase should be rejected
await expect(
harness.caller.spawnArchitectDecompose({
name: 'decomposer-2',
harness.caller.spawnArchitectDetail({
name: 'detailer-2',
phaseId: phases[0].id,
}),
).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();
const initiative = await harness.createInitiative('Test Project');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Phase 1' },
]);
// Decompose agent that crashes immediately
harness.setAgentScenario('stale-decomposer', { status: 'error', error: 'crashed' });
// Detail agent that crashes immediately
harness.setAgentScenario('stale-detailer', { status: 'error', error: 'crashed' });
await harness.caller.spawnArchitectDecompose({
name: 'stale-decomposer',
await harness.caller.spawnArchitectDetail({
name: 'stale-detailer',
phaseId: phases[0].id,
});
await harness.advanceTimers();
// New decompose should succeed
harness.setArchitectDecomposeComplete('new-decomposer', [
// New detail should succeed
harness.setArchitectDetailComplete('new-detailer', [
{ number: 1, name: 'Task 1', content: 'Do it', type: 'auto', dependencies: [] },
]);
const agent = await harness.caller.spawnArchitectDecompose({
name: 'new-decomposer',
const agent = await harness.caller.spawnArchitectDetail({
name: 'new-detailer',
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();
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 2' },
]);
// Long-running agent on phase 1
harness.setAgentScenario('decomposer-p1', { status: 'done', delay: 999999 });
await harness.caller.spawnArchitectDecompose({
name: 'decomposer-p1',
harness.setAgentScenario('detailer-p1', { status: 'done', delay: 999999 });
await harness.caller.spawnArchitectDetail({
name: 'detailer-p1',
phaseId: phases[0].id,
});
// Decompose on phase 2 should succeed
harness.setArchitectDecomposeComplete('decomposer-p2', [
// Detail on phase 2 should succeed
harness.setArchitectDetailComplete('detailer-p2', [
{ number: 1, name: 'Task 1', content: 'Do it', type: 'auto', dependencies: [] },
]);
const agent = await harness.caller.spawnArchitectDecompose({
name: 'decomposer-p2',
const agent = await harness.caller.spawnArchitectDetail({
name: 'detailer-p2',
phaseId: phases[1].id,
});
expect(agent.mode).toBe('decompose');
expect(agent.mode).toBe('detail');
});
});
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 phases = await harness.createPhasesFromBreakdown(initiative.id, [
const phases = await harness.createPhasesFromPlan(initiative.id, [
{ 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({
parentTaskId: decomposeTask.id,
parentTaskId: detailTask.id,
tasks: [
{ number: 1, name: 'Schema', description: 'Create tables', type: 'auto', dependencies: [] },
{ number: 2, name: 'API', description: 'Create endpoints', type: 'auto', dependencies: [1] },
@@ -266,7 +266,7 @@ describe('Decompose Workflow E2E', () => {
});
// Verify tasks created
const tasks = await harness.getChildTasks(decomposeTask.id);
const tasks = await harness.getChildTasks(detailTask.id);
expect(tasks).toHaveLength(3);
expect(tasks[0].name).toBe('Schema');
expect(tasks[1].name).toBe('API');
@@ -276,14 +276,14 @@ describe('Decompose Workflow E2E', () => {
it('should handle all task types', async () => {
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' },
]);
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
await harness.caller.createChildTasks({
parentTaskId: decomposeTask.id,
parentTaskId: detailTask.id,
tasks: [
{ number: 1, name: 'Auto Task', description: 'Automated work', type: 'auto' },
{ 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[0].type).toBe('auto');
expect(tasks[1].type).toBe('checkpoint:human-verify');
@@ -302,14 +302,14 @@ describe('Decompose Workflow E2E', () => {
it('should create task dependencies', async () => {
const initiative = await harness.createInitiative('Dependencies Test');
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
const phases = await harness.createPhasesFromPlan(initiative.id, [
{ 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
await harness.caller.createChildTasks({
parentTaskId: decomposeTask.id,
parentTaskId: detailTask.id,
tasks: [
{ number: 1, name: 'Task A', description: 'No deps', type: 'auto' },
{ 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);
// All tasks should be created with correct names
@@ -326,31 +326,31 @@ describe('Decompose Workflow E2E', () => {
});
});
describe('full decompose workflow', () => {
it('should complete initiative -> phase -> plan -> decompose -> tasks workflow', async () => {
describe('full detail workflow', () => {
it('should complete initiative -> phase -> plan -> detail -> tasks workflow', async () => {
vi.useFakeTimers();
// 1. Create initiative
const initiative = await harness.createInitiative('Full Workflow Test');
// 2. Create phase
const phases = await harness.createPhasesFromBreakdown(initiative.id, [
const phases = await harness.createPhasesFromPlan(initiative.id, [
{ name: 'Auth Phase' },
]);
// 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
harness.setArchitectDecomposeComplete('decomposer', [
// 4. Spawn detail agent
harness.setArchitectDetailComplete('detailer', [
{ 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: 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] },
]);
await harness.caller.spawnArchitectDecompose({
name: 'decomposer',
await harness.caller.spawnArchitectDetail({
name: 'detailer',
phaseId: phases[0].id,
});
await harness.advanceTimers();
@@ -358,11 +358,11 @@ describe('Decompose Workflow E2E', () => {
// 5. Verify agent completed
const events = harness.getEmittedEvents('agent:stopped') as AgentStoppedEvent[];
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({
parentTaskId: decomposeTask.id,
parentTaskId: detailTask.id,
tasks: [
{ 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] },
@@ -372,13 +372,13 @@ describe('Decompose Workflow E2E', () => {
});
// 7. Verify final state
const tasks = await harness.getChildTasks(decomposeTask.id);
const tasks = await harness.getChildTasks(detailTask.id);
expect(tasks).toHaveLength(4);
expect(tasks[0].name).toBe('Create user schema');
expect(tasks[3].type).toBe('checkpoint:human-verify');
// 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');
});
});

View File

@@ -29,7 +29,7 @@ export interface TaskFixture {
/** Task priority */
priority?: 'low' | 'medium' | 'high';
/** 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 */
dependsOn?: string[];
}
@@ -39,7 +39,7 @@ export interface TaskFixture {
* Tasks are grouped by parent task in the new model.
*/
export interface TaskGroupFixture {
/** Group name (becomes a decompose task) */
/** Group name (becomes a detail task) */
name: string;
/** Tasks in this group */
tasks: TaskFixture[];
@@ -51,7 +51,7 @@ export interface TaskGroupFixture {
export interface PhaseFixture {
/** Phase name */
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[];
}
@@ -87,7 +87,7 @@ export interface SeededFixture {
/**
* 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.
*
* @param db - Drizzle database instance
@@ -126,19 +126,19 @@ export async function seedFixture(
});
phasesMap.set(phaseFixture.name, phase.id);
// Create task groups as parent decompose tasks
// Create task groups as parent detail tasks
let taskOrder = 0;
for (const groupFixture of phaseFixture.taskGroups) {
// Create parent decompose task
// Create parent detail task
const parentTask = await taskRepo.create({
phaseId: phase.id,
initiativeId: initiative.id,
name: groupFixture.name,
description: `Test task group: ${groupFixture.name}`,
category: 'decompose',
category: 'detail',
type: 'auto',
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++,
});
taskGroupsMap.set(groupFixture.name, parentTask.id);

View File

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

View File

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

View File

@@ -82,17 +82,17 @@ Now complete the task by outputting:
{"status":"done"}`,
/**
* ~$0.02 - Breakdown complete
* Tests: breakdown mode output handling (now uses universal done signal)
* ~$0.02 - Plan complete
* 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"}`,
/**
* ~$0.02 - Decompose complete
* Tests: decompose mode output handling (now uses universal done signal)
* ~$0.02 - Detail complete
* 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"}`,
} as const;

View File

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

View File

@@ -18,7 +18,7 @@ export const spawnAgentInputSchema = z.object({
taskId: z.string().min(1),
prompt: z.string().min(1),
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(),
initiativeId: z.string().min(1).optional(),
});

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import { z } from 'zod';
import type { Phase } from '../../db/schema.js';
import type { ProcedureBuilder } from '../trpc.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';
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 workTasks = phaseTasks.filter((t) => t.category !== 'decompose');
const workTasks = phaseTasks.filter((t) => t.category !== 'detail');
if (workTasks.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -101,7 +101,7 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return { success: true };
}),
createPhasesFromBreakdown: publicProcedure
createPhasesFromPlan: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
phases: z.array(z.object({
@@ -201,11 +201,11 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
}
const initiative = await initiativeRepo.findById(phase.initiativeId);
if (!initiative?.mergeTarget) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no merge target' });
if (!initiative?.branch) {
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 projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);

View File

@@ -57,7 +57,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
initiativeId: z.string().min(1),
name: z.string().min(1),
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(),
requiresApproval: z.boolean().nullable().optional(),
}))
@@ -89,7 +89,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
phaseId: z.string().min(1),
name: z.string().min(1),
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(),
requiresApproval: z.boolean().nullable().optional(),
}))
@@ -120,7 +120,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
.input(z.object({
initiativeId: 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())
.query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
@@ -132,7 +132,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
.query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
const tasks = await taskRepository.findByInitiativeId(input.initiativeId);
return tasks.filter((t) => t.category !== 'decompose');
return tasks.filter((t) => t.category !== 'detail');
}),
listPhaseTasks: publicProcedure
@@ -140,7 +140,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
.query(async ({ ctx, input }) => {
const taskRepository = requireTaskRepository(ctx);
const tasks = await taskRepository.findByPhaseId(input.phaseId);
return tasks.filter((t) => t.category !== 'decompose');
return tasks.filter((t) => t.category !== 'detail');
}),
approveTask: publicProcedure