The discuss agent spawned on initiative creation received only the initiative in its inputContext, missing the task that carries the user's description. The agent started without knowing what to discuss.
208 lines
7.4 KiB
TypeScript
208 lines
7.4 KiB
TypeScript
/**
|
|
* Initiative Router — create, list, get, update, merge config
|
|
*/
|
|
|
|
import { TRPCError } from '@trpc/server';
|
|
import { z } from 'zod';
|
|
import type { ProcedureBuilder } from '../trpc.js';
|
|
import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository } from './_helpers.js';
|
|
import { deriveInitiativeActivity } from './initiative-activity.js';
|
|
import { buildDiscussPrompt } from '../../agent/prompts/index.js';
|
|
|
|
export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
|
return {
|
|
createInitiative: publicProcedure
|
|
.input(z.object({
|
|
name: z.string().min(1),
|
|
description: z.string().optional(),
|
|
branch: z.string().nullable().optional(),
|
|
projectIds: z.array(z.string().min(1)).min(1).optional(),
|
|
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const repo = requireInitiativeRepository(ctx);
|
|
|
|
if (input.projectIds && input.projectIds.length > 0) {
|
|
const projectRepo = requireProjectRepository(ctx);
|
|
for (const pid of input.projectIds) {
|
|
const project = await projectRepo.findById(pid);
|
|
if (!project) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: `Project '${pid}' not found`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const initiative = await repo.create({
|
|
name: input.name,
|
|
status: 'active',
|
|
...(input.executionMode && { executionMode: input.executionMode }),
|
|
...(input.branch && { branch: input.branch }),
|
|
});
|
|
|
|
if (input.projectIds && input.projectIds.length > 0) {
|
|
const projectRepo = requireProjectRepository(ctx);
|
|
await projectRepo.setInitiativeProjects(initiative.id, input.projectIds);
|
|
}
|
|
|
|
if (ctx.pageRepository) {
|
|
await ctx.pageRepository.create({
|
|
initiativeId: initiative.id,
|
|
parentPageId: null,
|
|
title: input.name,
|
|
content: null,
|
|
sortOrder: 0,
|
|
});
|
|
}
|
|
|
|
// Auto-spawn discuss agent when description is provided
|
|
if (input.description?.trim() && ctx.agentManager && ctx.taskRepository) {
|
|
try {
|
|
const taskRepo = requireTaskRepository(ctx);
|
|
const agentManager = requireAgentManager(ctx);
|
|
|
|
const task = await taskRepo.create({
|
|
initiativeId: initiative.id,
|
|
name: `Discuss: ${initiative.name}`,
|
|
description: input.description.trim(),
|
|
category: 'discuss',
|
|
status: 'in_progress',
|
|
});
|
|
|
|
const prompt = buildDiscussPrompt();
|
|
|
|
agentManager.spawn({
|
|
taskId: task.id,
|
|
prompt,
|
|
mode: 'discuss',
|
|
initiativeId: initiative.id,
|
|
inputContext: { initiative, task },
|
|
});
|
|
} catch {
|
|
// Fire-and-forget — don't fail initiative creation if agent spawn fails
|
|
}
|
|
}
|
|
|
|
return initiative;
|
|
}),
|
|
|
|
listInitiatives: publicProcedure
|
|
.input(z.object({
|
|
status: z.enum(['active', 'completed', 'archived']).optional(),
|
|
}).optional())
|
|
.query(async ({ ctx, input }) => {
|
|
const repo = requireInitiativeRepository(ctx);
|
|
const initiatives = input?.status
|
|
? await repo.findByStatus(input.status)
|
|
: await repo.findAll();
|
|
|
|
// Fetch active architect agents once for all initiatives
|
|
const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine'];
|
|
const allAgents = ctx.agentManager ? await ctx.agentManager.list() : [];
|
|
const activeArchitectAgents = allAgents
|
|
.filter(a =>
|
|
ARCHITECT_MODES.includes(a.mode ?? '')
|
|
&& (a.status === 'running' || a.status === 'waiting_for_input')
|
|
&& !a.userDismissedAt,
|
|
)
|
|
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status }));
|
|
|
|
if (ctx.phaseRepository) {
|
|
const phaseRepo = ctx.phaseRepository;
|
|
return Promise.all(initiatives.map(async (init) => {
|
|
const phases = await phaseRepo.findByInitiativeId(init.id);
|
|
return { ...init, activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
|
}));
|
|
}
|
|
|
|
return initiatives.map(init => ({
|
|
...init,
|
|
activity: deriveInitiativeActivity(init, [], activeArchitectAgents),
|
|
}));
|
|
}),
|
|
|
|
getInitiative: publicProcedure
|
|
.input(z.object({ id: z.string().min(1) }))
|
|
.query(async ({ ctx, input }) => {
|
|
const repo = requireInitiativeRepository(ctx);
|
|
const initiative = await repo.findById(input.id);
|
|
if (!initiative) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: `Initiative '${input.id}' not found`,
|
|
});
|
|
}
|
|
|
|
let projects: Array<{ id: string; name: string; url: string }> = [];
|
|
if (ctx.projectRepository) {
|
|
const fullProjects = await ctx.projectRepository.findProjectsByInitiativeId(input.id);
|
|
projects = fullProjects.map((p) => ({ id: p.id, name: p.name, url: p.url }));
|
|
}
|
|
|
|
let branchLocked = false;
|
|
if (ctx.taskRepository) {
|
|
const tasks = await ctx.taskRepository.findByInitiativeId(input.id);
|
|
branchLocked = tasks.some((t) => t.status !== 'pending');
|
|
}
|
|
|
|
return { ...initiative, projects, branchLocked };
|
|
}),
|
|
|
|
updateInitiative: publicProcedure
|
|
.input(z.object({
|
|
id: z.string().min(1),
|
|
name: z.string().min(1).optional(),
|
|
status: z.enum(['active', 'completed', 'archived']).optional(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const repo = requireInitiativeRepository(ctx);
|
|
const { id, ...data } = input;
|
|
return repo.update(id, data);
|
|
}),
|
|
|
|
deleteInitiative: publicProcedure
|
|
.input(z.object({ id: z.string().min(1) }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const repo = requireInitiativeRepository(ctx);
|
|
await repo.delete(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
updateInitiativeConfig: publicProcedure
|
|
.input(z.object({
|
|
initiativeId: z.string().min(1),
|
|
mergeRequiresApproval: z.boolean().optional(),
|
|
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
|
|
branch: z.string().nullable().optional(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const repo = requireInitiativeRepository(ctx);
|
|
const { initiativeId, ...data } = input;
|
|
|
|
const existing = await repo.findById(initiativeId);
|
|
if (!existing) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: `Initiative '${initiativeId}' not found`,
|
|
});
|
|
}
|
|
|
|
// Prevent branch changes once work has started
|
|
if (data.branch !== undefined && ctx.taskRepository) {
|
|
const tasks = await ctx.taskRepository.findByInitiativeId(initiativeId);
|
|
const hasStarted = tasks.some((t) => t.status !== 'pending');
|
|
if (hasStarted) {
|
|
throw new TRPCError({
|
|
code: 'PRECONDITION_FAILED',
|
|
message: 'Cannot change branch after work has started',
|
|
});
|
|
}
|
|
}
|
|
|
|
return repo.update(initiativeId, data);
|
|
}),
|
|
};
|
|
}
|