/** * 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, requireBranchManager, requireExecutionOrchestrator } from './_helpers.js'; import { deriveInitiativeActivity } from './initiative-activity.js'; import { buildRefinePrompt, buildConflictResolutionPrompt, buildConflictResolutionDescription } from '../../agent/prompts/index.js'; import type { PageForSerialization } from '../../agent/content-serializer.js'; import { ensureProjectClone } from '../../git/project-clones.js'; const ACTIVITY_STATE_PRIORITY: Record = { executing: 0, pending_review: 0, discussing: 0, detailing: 0, refining: 0, resolving_conflict: 0, ready: 1, planning: 1, blocked: 2, complete: 3, archived: 4, }; function activityPriority(state: string): number { return ACTIVITY_STATE_PRIORITY[state] ?? 1; } function sortInitiatives(enriched: T[]): T[] { return enriched.sort((a, b) => { const pa = activityPriority(a.activity.state); const pb = activityPriority(b.activity.state); if (pa !== pb) return pa - pb; const ta = new Date(a.updatedAt).getTime(); const tb = new Date(b.updatedAt).getTime(); if (tb !== ta) return tb - ta; return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }); } 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', 'pending_review']).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 agents once for all initiatives (architect + conflict) 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.name?.startsWith('conflict-')) && (a.status === 'running' || a.status === 'waiting_for_input') && !a.userDismissedAt, ) .map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status, name: a.name })); // Batch-fetch projects for all initiatives const projectRepo = ctx.projectRepository; const projectsByInitiativeId = new Map>(); if (projectRepo) { await Promise.all(initiatives.map(async (init) => { const projects = await projectRepo.findProjectsByInitiativeId(init.id); projectsByInitiativeId.set(init.id, projects.map(p => ({ id: p.id, name: p.name }))); })); } const addProjects = (init: typeof initiatives[0]) => ({ projects: projectsByInitiativeId.get(init.id) ?? [], }); if (ctx.phaseRepository) { const phaseRepo = ctx.phaseRepository; const enriched = await Promise.all(initiatives.map(async (init) => { const phases = await phaseRepo.findByInitiativeId(init.id); return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) }; })); return sortInitiatives(enriched); } return sortInitiatives(initiatives.map(init => ({ ...init, ...addProjects(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', 'pending_review']).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), executionMode: z.enum(['yolo', 'review_per_phase']).optional(), branch: z.string().nullable().optional(), qualityReview: z.boolean().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); }), getInitiativeReviewDiff: publicProcedure .input(z.object({ initiativeId: z.string().min(1) })) .query(async ({ ctx, input }) => { const initiativeRepo = requireInitiativeRepository(ctx); const projectRepo = requireProjectRepository(ctx); const branchManager = requireBranchManager(ctx); const initiative = await initiativeRepo.findById(input.initiativeId); if (!initiative) { throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` }); } if (initiative.status !== 'pending_review') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Initiative is not pending review (status: ${initiative.status})` }); } if (!initiative.branch) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' }); } const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId); let rawDiff = ''; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); const diff = await branchManager.diffBranches(clonePath, project.defaultBranch, initiative.branch); if (diff) rawDiff += diff + '\n'; } return { initiativeName: initiative.name, sourceBranch: initiative.branch, targetBranch: projects[0]?.defaultBranch ?? 'main', rawDiff, }; }), getInitiativeReviewCommits: publicProcedure .input(z.object({ initiativeId: z.string().min(1) })) .query(async ({ ctx, input }) => { const initiativeRepo = requireInitiativeRepository(ctx); const projectRepo = requireProjectRepository(ctx); const branchManager = requireBranchManager(ctx); const initiative = await initiativeRepo.findById(input.initiativeId); if (!initiative) { throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` }); } if (initiative.status !== 'pending_review') { throw new TRPCError({ code: 'BAD_REQUEST', message: `Initiative is not pending review (status: ${initiative.status})` }); } if (!initiative.branch) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' }); } const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId); const allCommits: Array<{ hash: string; shortHash: string; message: string; author: string; date: string; filesChanged: number; insertions: number; deletions: number }> = []; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); const commits = await branchManager.listCommits(clonePath, project.defaultBranch, initiative.branch); allCommits.push(...commits); } return { commits: allCommits, sourceBranch: initiative.branch, targetBranch: projects[0]?.defaultBranch ?? 'main', }; }), getInitiativeCommitDiff: publicProcedure .input(z.object({ initiativeId: z.string().min(1), commitHash: z.string().min(1) })) .query(async ({ ctx, input }) => { const projectRepo = requireProjectRepository(ctx); const branchManager = requireBranchManager(ctx); const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId); let rawDiff = ''; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); try { const diff = await branchManager.diffCommit(clonePath, input.commitHash); if (diff) rawDiff += diff + '\n'; } catch { // commit not in this project clone } } return { rawDiff }; }), approveInitiativeReview: publicProcedure .input(z.object({ initiativeId: z.string().min(1), strategy: z.enum(['push_branch', 'merge_and_push']), })) .mutation(async ({ ctx, input }) => { const orchestrator = requireExecutionOrchestrator(ctx); await orchestrator.approveInitiative(input.initiativeId, input.strategy); return { success: true }; }), requestInitiativeChanges: publicProcedure .input(z.object({ initiativeId: z.string().min(1), summary: z.string().trim().min(1), })) .mutation(async ({ ctx, input }) => { const orchestrator = requireExecutionOrchestrator(ctx); const result = await orchestrator.requestChangesOnInitiative( input.initiativeId, input.summary, ); return { success: true, taskId: result.taskId }; }), checkInitiativeMergeability: publicProcedure .input(z.object({ initiativeId: z.string().min(1) })) .query(async ({ ctx, input }) => { const initiativeRepo = requireInitiativeRepository(ctx); const projectRepo = requireProjectRepository(ctx); const branchManager = requireBranchManager(ctx); const initiative = await initiativeRepo.findById(input.initiativeId); if (!initiative) { throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` }); } if (!initiative.branch) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' }); } const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId); const allConflicts: string[] = []; let mergeable = true; for (const project of projects) { const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); const result = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch); if (!result.mergeable) { mergeable = false; if (result.conflicts) allConflicts.push(...result.conflicts); } } return { mergeable, conflictFiles: allConflicts, targetBranch: projects[0]?.defaultBranch ?? 'main', }; }), spawnConflictResolutionAgent: publicProcedure .input(z.object({ initiativeId: z.string().min(1), provider: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const agentManager = requireAgentManager(ctx); const initiativeRepo = requireInitiativeRepository(ctx); const projectRepo = requireProjectRepository(ctx); const taskRepo = requireTaskRepository(ctx); const branchManager = requireBranchManager(ctx); const initiative = await initiativeRepo.findById(input.initiativeId); if (!initiative) { throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` }); } if (!initiative.branch) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' }); } const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId); if (projects.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no linked projects' }); } // Auto-dismiss stale conflict agents const allAgents = await agentManager.list(); const staleAgents = allAgents.filter( (a) => a.mode === 'execute' && a.initiativeId === input.initiativeId && a.name?.startsWith('conflict-') && ['crashed', 'idle'].includes(a.status) && !a.userDismissedAt, ); for (const stale of staleAgents) { await agentManager.dismiss(stale.id); } // Reject if active conflict agent already running const activeConflictAgents = allAgents.filter( (a) => a.mode === 'execute' && a.initiativeId === input.initiativeId && a.name?.startsWith('conflict-') && ['running', 'waiting_for_input'].includes(a.status), ); if (activeConflictAgents.length > 0) { throw new TRPCError({ code: 'CONFLICT', message: 'A conflict resolution agent is already running for this initiative', }); } // Re-check mergeability to get current conflict list const project = projects[0]; const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!); const mergeCheck = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch); if (mergeCheck.mergeable) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No merge conflicts detected — merge is clean' }); } const conflicts = mergeCheck.conflicts ?? []; const targetBranch = project.defaultBranch; // Create task const task = await taskRepo.create({ initiativeId: input.initiativeId, name: `Resolve conflicts: ${initiative.name}`, description: buildConflictResolutionDescription(initiative.branch, targetBranch, conflicts), category: 'merge', status: 'in_progress', }); // Spawn agent on a unique temp branch based off the initiative branch. // Using initiative.branch directly as branchName would cause SimpleGitWorktreeManager.create() // to run `git branch -f `, force-resetting the initiative branch. const tempBranch = `${initiative.branch}-conflict-${Date.now()}`; const prompt = buildConflictResolutionPrompt(initiative.branch, targetBranch, conflicts); return agentManager.spawn({ name: `conflict-${Date.now()}`, taskId: task.id, prompt, mode: 'execute', provider: input.provider, initiativeId: input.initiativeId, baseBranch: initiative.branch, branchName: tempBranch, skipPromptExtras: true, }); }), }; }