Files
Codewalkers/apps/server/trpc/routers/architect.ts
Lukas May c8f370583a feat: Add codebase exploration to architect agent prompts
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
2026-03-03 12:45:14 +01:00

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,
},
});
}),
};
}