/** * 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 }, }); } 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 detail agents once for all initiatives const allAgents = ctx.agentManager ? await ctx.agentManager.list() : []; const activeDetailAgents = allAgents .filter(a => a.mode === 'detail' && (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, activeDetailAgents) }; })); } return initiatives.map(init => ({ ...init, activity: deriveInitiativeActivity(init, [], activeDetailAgents), })); }), 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); }), }; }