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:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}'`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user