/** * Architect Router — discuss, plan, refine, detail spawn procedures */ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { ProcedureBuilder } from '../trpc.js'; import { requireAgentManager, requireInitiativeRepository, requirePhaseRepository, requirePageRepository, requireTaskRepository, } from './_helpers.js'; import { buildDiscussPrompt, buildPlanPrompt, buildRefinePrompt, buildDetailPrompt, } from '../../agent/prompts/index.js'; import { isPlanningCategory } from '../../git/branch-naming.js'; import type { PhaseRepository } from '../../db/repositories/phase-repository.js'; import type { TaskRepository } from '../../db/repositories/task-repository.js'; import type { PageRepository } from '../../db/repositories/page-repository.js'; import type { Phase, Task } from '../../db/schema.js'; import type { PageForSerialization } from '../../agent/content-serializer.js'; export async function gatherInitiativeContext( phaseRepo: PhaseRepository | undefined, taskRepo: TaskRepository | undefined, pageRepo: PageRepository | undefined, initiativeId: string, ): Promise<{ phases: Array; tasks: Task[]; pages: PageForSerialization[]; }> { const [rawPhases, deps, initiativeTasks, pages] = await Promise.all([ phaseRepo?.findByInitiativeId(initiativeId) ?? [], phaseRepo?.findDependenciesByInitiativeId(initiativeId) ?? [], taskRepo?.findByInitiativeId(initiativeId) ?? [], pageRepo?.findByInitiativeId(initiativeId) ?? [], ]); // Merge dependencies into each phase as a dependsOn array const depsByPhase = new Map(); for (const dep of deps) { const arr = depsByPhase.get(dep.phaseId) ?? []; arr.push(dep.dependsOnPhaseId); depsByPhase.set(dep.phaseId, arr); } const phases = rawPhases.map((ph) => ({ ...ph, dependsOn: depsByPhase.get(ph.id) ?? [], })); // Collect tasks from all phases (some tasks only have phaseId, not initiativeId) const taskIds = new Set(initiativeTasks.map((t) => t.id)); const allTasks = [...initiativeTasks]; if (taskRepo) { for (const ph of rawPhases) { const phaseTasks = await taskRepo.findByPhaseId(ph.id); for (const t of phaseTasks) { if (!taskIds.has(t.id)) { taskIds.add(t.id); allTasks.push(t); } } } } // Only include implementation tasks in agent context — planning tasks are irrelevant noise const implementationTasks = allTasks.filter(t => !isPlanningCategory(t.category)); return { phases, tasks: implementationTasks, pages }; } export function architectProcedures(publicProcedure: ProcedureBuilder) { return { spawnArchitectDiscuss: publicProcedure .input(z.object({ name: z.string().min(1).optional(), initiativeId: z.string().min(1), context: z.string().optional(), provider: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const agentManager = requireAgentManager(ctx); const initiativeRepo = requireInitiativeRepository(ctx); const taskRepo = requireTaskRepository(ctx); const initiative = await initiativeRepo.findById(input.initiativeId); if (!initiative) { throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found`, }); } const task = await taskRepo.create({ initiativeId: input.initiativeId, name: `Discuss: ${initiative.name}`, description: input.context ?? 'Gather context and requirements for initiative', category: 'discuss', status: 'in_progress', }); const context = await gatherInitiativeContext(ctx.phaseRepository, ctx.taskRepository, ctx.pageRepository, input.initiativeId); const prompt = buildDiscussPrompt(); return agentManager.spawn({ name: input.name, taskId: task.id, prompt, mode: 'discuss', provider: input.provider, initiativeId: input.initiativeId, inputContext: { initiative, pages: context.pages.length > 0 ? context.pages : undefined, phases: context.phases.length > 0 ? context.phases : undefined, tasks: context.tasks.length > 0 ? context.tasks : undefined, }, }); }), spawnArchitectPlan: publicProcedure .input(z.object({ name: z.string().min(1).optional(), initiativeId: z.string().min(1), contextSummary: z.string().optional(), provider: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const agentManager = requireAgentManager(ctx); const initiativeRepo = requireInitiativeRepository(ctx); const taskRepo = requireTaskRepository(ctx); const initiative = await initiativeRepo.findById(input.initiativeId); if (!initiative) { throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found`, }); } // Auto-dismiss stale plan agents const allAgents = await agentManager.list(); const staleAgents = allAgents.filter( (a) => a.mode === 'plan' && a.initiativeId === input.initiativeId && ['crashed', 'idle'].includes(a.status) && !a.userDismissedAt, ); for (const stale of staleAgents) { await agentManager.dismiss(stale.id); } // Reject if a plan agent is already active for this initiative const activePlanAgents = allAgents.filter( (a) => a.mode === 'plan' && a.initiativeId === input.initiativeId && ['running', 'waiting_for_input'].includes(a.status), ); if (activePlanAgents.length > 0) { throw new TRPCError({ code: 'CONFLICT', message: 'A plan agent is already running for this initiative', }); } const task = await taskRepo.create({ initiativeId: input.initiativeId, 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 = buildPlanPrompt(); return agentManager.spawn({ name: input.name, taskId: task.id, prompt, mode: 'plan', provider: input.provider, initiativeId: input.initiativeId, inputContext: { initiative, pages: context.pages.length > 0 ? context.pages : undefined, phases: context.phases.length > 0 ? context.phases : undefined, tasks: context.tasks.length > 0 ? context.tasks : undefined, }, }); }), spawnArchitectRefine: publicProcedure .input(z.object({ name: z.string().min(1).optional(), initiativeId: z.string().min(1), instruction: z.string().optional(), provider: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const agentManager = requireAgentManager(ctx); const initiativeRepo = requireInitiativeRepository(ctx); const pageRepo = requirePageRepository(ctx); const taskRepo = requireTaskRepository(ctx); const initiative = await initiativeRepo.findById(input.initiativeId); if (!initiative) { throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found`, }); } // Bug #10: Auto-dismiss stale (crashed/idle) refine agents before checking for active ones const allAgents = await agentManager.list(); const staleAgents = allAgents.filter( (a) => a.mode === 'refine' && a.initiativeId === input.initiativeId && ['crashed', 'idle'].includes(a.status) && !a.userDismissedAt, ); for (const stale of staleAgents) { await agentManager.dismiss(stale.id); } // Bug #9: Prevent concurrent refine agents on the same initiative const activeRefineAgents = allAgents.filter( (a) => a.mode === 'refine' && a.initiativeId === input.initiativeId && ['running', 'waiting_for_input'].includes(a.status), ); if (activeRefineAgents.length > 0) { throw new TRPCError({ code: 'CONFLICT', message: `A refine agent is already running for this initiative`, }); } const pages = await pageRepo.findByInitiativeId(input.initiativeId); if (pages.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no page content to refine', }); } const task = await taskRepo.create({ initiativeId: input.initiativeId, name: `Refine: ${initiative.name}`, description: input.instruction ?? 'Review and propose edits to initiative content', category: 'refine', status: 'in_progress', }); const prompt = buildRefinePrompt(input.instruction); return agentManager.spawn({ name: input.name, taskId: task.id, prompt, mode: 'refine', provider: input.provider, initiativeId: input.initiativeId, inputContext: { initiative, pages }, }); }), spawnArchitectDetail: publicProcedure .input(z.object({ name: z.string().min(1).optional(), phaseId: z.string().min(1), taskName: z.string().min(1).optional(), context: z.string().optional(), provider: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const agentManager = requireAgentManager(ctx); const phaseRepo = requirePhaseRepository(ctx); const taskRepo = requireTaskRepository(ctx); const initiativeRepo = requireInitiativeRepository(ctx); const phase = await phaseRepo.findById(input.phaseId); if (!phase) { throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found`, }); } const initiative = await initiativeRepo.findById(phase.initiativeId); if (!initiative) { throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${phase.initiativeId}' not found`, }); } // Auto-dismiss stale detail agents for this phase const allAgents = await agentManager.list(); const detailAgents = allAgents.filter( (a) => a.mode === 'detail' && !a.userDismissedAt, ); // 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; if (['crashed', 'idle'].includes(agent.status)) { staleForPhase.push(agent); } else if (['running', 'waiting_for_input'].includes(agent.status)) { activeForPhase.push(agent); } } for (const stale of staleForPhase) { await agentManager.dismiss(stale.id); } if (activeForPhase.length > 0) { throw new TRPCError({ code: 'CONFLICT', message: `A detail agent is already running for phase "${phase.name}"`, }); } const detailTaskName = input.taskName ?? `Detail: ${phase.name}`; const task = await taskRepo.create({ phaseId: phase.id, initiativeId: phase.initiativeId, 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 = buildDetailPrompt(); return agentManager.spawn({ name: input.name, taskId: task.id, prompt, mode: 'detail', provider: input.provider, initiativeId: phase.initiativeId, inputContext: { initiative, phase, task, pages: context.pages.length > 0 ? context.pages : undefined, phases: context.phases.length > 0 ? context.phases : undefined, tasks: context.tasks.length > 0 ? context.tasks : undefined, }, }); }), }; }