/** * 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 { buildRefinePrompt } from '../../agent/prompts/index.js'; import type { PageForSerialization } from '../../agent/content-serializer.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); } // Create root page — seed with description as tiptap content if provided const descriptionText = input.description?.trim(); let rootPage: { id: string; parentPageId: string | null; title: string; content: string | null; sortOrder: number } | null = null; if (ctx.pageRepository) { const tiptapContent = descriptionText ? JSON.stringify({ type: 'doc', content: descriptionText.split(/\n{2,}/).map(para => ({ type: 'paragraph', content: [{ type: 'text', text: para.trim() }], })).filter(p => p.content[0].text), }) : null; rootPage = await ctx.pageRepository.create({ initiativeId: initiative.id, parentPageId: null, title: input.name, content: tiptapContent, sortOrder: 0, }); } // Auto-spawn refine agent when description is provided if (descriptionText && rootPage && ctx.agentManager && ctx.taskRepository) { try { const taskRepo = requireTaskRepository(ctx); const agentManager = requireAgentManager(ctx); const task = await taskRepo.create({ initiativeId: initiative.id, name: `Refine: ${initiative.name}`, description: descriptionText, category: 'refine', status: 'in_progress', }); const pages: PageForSerialization[] = [{ id: rootPage.id, parentPageId: null, title: rootPage.title, content: rootPage.content, sortOrder: 0, }]; agentManager.spawn({ taskId: task.id, prompt: buildRefinePrompt(), mode: 'refine', initiativeId: initiative.id, inputContext: { initiative, pages }, }); } 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(), projectId: z.string().min(1).optional(), }).optional()) .query(async ({ ctx, input }) => { const repo = requireInitiativeRepository(ctx); let initiatives; if (input?.projectId) { const all = await repo.findByProjectId(input.projectId); initiatives = input.status ? all.filter(i => i.status === input.status) : all; } else { 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); }), }; }