Architect agents (discuss, plan, detail, refine) were producing generic analysis disconnected from the actual codebase. They had full tool access in their worktrees but were never instructed to explore the code. - Add CODEBASE_EXPLORATION shared constant: read project docs, explore structure, check existing patterns, use subagents for parallel exploration - Inject into all 4 architect prompts after INPUT_FILES - Strengthen discuss prompt: analysis method references codebase, examples cite specific paths, definition_of_done requires codebase references - Fix spawnArchitectDiscuss to pass full context (pages/phases/tasks) via gatherInitiativeContext() — was only passing bare initiative metadata - Update docs/agent.md with new tag ordering and shared block table
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
/**
|
|
* 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';
|
|
|
|
async function gatherInitiativeContext(
|
|
phaseRepo: PhaseRepository | undefined,
|
|
taskRepo: TaskRepository | undefined,
|
|
pageRepo: PageRepository | undefined,
|
|
initiativeId: string,
|
|
): Promise<{
|
|
phases: Array<Phase & { dependsOn?: string[] }>;
|
|
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<string, string[]>();
|
|
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();
|
|
|
|
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,
|
|
},
|
|
});
|
|
}),
|
|
};
|
|
}
|