import * as fs from "node:fs"; import * as path from "node:path"; import { matchFileToPersona, detectConflicts, DecomposedTask, DecomposedPlan, TerritoryConflict, ExecutePersonaConfig, PersonaDomain, DEFAULT_PERSONAS } from "../types/persona.js"; import { CIAgentConfig } from "../types/config.js"; import { PersonaLoader, PersonaDefinition } from "./persona-loader.js"; import { CIAgentFiles } from "./ciagent-files.js"; import { readFile } from "../utils/file.js"; const DOMAIN_FILE_PATTERNS: Record = { data: [ "**/migrations/**", "**/schema/**", "**/models/**", "**/db/**", "prisma/schema.prisma", "drizzle/**", "**/*.sql", "**/seed*", "**/repository/**", "**/dao/**", ], backend: [ "**/api/**", "**/routes/**", "**/services/**", "**/middleware/**", "**/controllers/**", "**/auth/**", "**/handlers/**", "**/grpc/**", "**/server.ts", "**/app.ts", ], frontend: [ "**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**", "**/*.tsx", "**/*.css", "**/*.vue", "**/*.svelte", "**/layouts/**", "**/views/**", "**/client/**", ], }; const DOMAIN_KEYWORDS: Record = { data: [ "schema", "migration", "database", "model", "query", "table", "column", "index", "seed", "orm", "sql", "repository", "dao", "entity", ], backend: [ "api", "route", "endpoint", "middleware", "controller", "service", "handler", "server", "auth", "grpc", "rest", "websocket", "request", "response", "cors", "rate-limit", ], frontend: [ "component", "page", "layout", "style", "css", "hook", "view", "client", "ui", "render", "state", "interactive", "accessible", "responsive", "animation", ], }; interface PlanTask { id: string; description: string; files: string[]; requirements: string[]; dependencies: string[]; wave: number; } export class TaskDecomposer { private projectPath: string; private personaLoader: PersonaLoader; private config: CIAgentConfig; private ciFiles: CIAgentFiles; constructor(projectPath: string, config: CIAgentConfig, projectSlug?: string) { this.projectPath = projectPath; this.config = config; this.personaLoader = new PersonaLoader(projectPath, config); this.ciFiles = new CIAgentFiles(projectPath, projectSlug || undefined); } decompose(planContent: string): DecomposedPlan { const tasks = this.parsePlanTasks(planContent); const personas = this.config.personas?.enabled !== false ? this.config.personas?.personas || DEFAULT_PERSONAS : DEFAULT_PERSONAS; const decomposedTasks = this.assignTasksToPersonas(tasks, personas); const conflicts = detectConflicts(decomposedTasks, personas); return { tasks: decomposedTasks, dataTasks: decomposedTasks.filter((t) => t.domain === "data"), backendTasks: decomposedTasks.filter((t) => t.domain === "backend"), frontendTasks: decomposedTasks.filter((t) => t.domain === "frontend"), coordinationTasks: decomposedTasks.filter((t) => t.domain === "coordination"), conflicts, }; } resolveConflicts(plan: DecomposedPlan): DecomposedPlan { const resolved = { ...plan, conflicts: [...plan.conflicts] }; for (let i = 0; i < resolved.conflicts.length; i++) { const conflict = resolved.conflicts[i]; const resolution = this.leadDeveloperResolve(conflict); resolved.conflicts[i] = { ...conflict, resolution }; } return resolved; } private parsePlanTasks(planContent: string): PlanTask[] { const tasks: PlanTask[] = []; const taskRegex = /####\s+Task\s+(\d+[\.\d]*)[\s:]+(.+)/g; const idRegex = /\*\*ID\*\*\s*\|\s*([A-Z]+-\d+(?:-\d+)*)/g; const filesRegex = /\*\*Files\s+to\s+(?:create|modify)\*\*\s*\|\s*(.+)/g; const reqRegex = /\*\*REQs\*\*\s*\|\s*(.+)/g; const depRegex = /\*\*Dependencies\*\*\s*\|\s*(.+)/g; const waveRegex = /###\s+Wave\s+(\d+)/g; const sections = planContent.split(/####\s+Task/); let currentWave = 1; const waveMatches = [...planContent.matchAll(/###\s+Wave\s+(\d+)/g)]; const wavePositions = waveMatches.map((m) => ({ wave: parseInt(m[1], 10), position: m.index || 0, })); let taskCounter = 0; for (let i = 1; i < sections.length; i++) { const section = sections[i]; const taskPosition = planContent.indexOf(section); currentWave = 1; for (const wp of wavePositions) { if (wp.position <= taskPosition) { currentWave = wp.wave; } } const taskIdMatch = section.match(/([A-Z]+-\d+(?:-\d+)*)/); const taskId = taskIdMatch ? taskIdMatch[1] : `T${++taskCounter}`; const descriptionMatch = section.match(/^\s*\d*[\.\d]*\s*[::]?\s*(.+)/); const description = descriptionMatch ? descriptionMatch[1].split("\n")[0].trim() : `Task ${taskId}`; const files: string[] = []; const filesMatch = section.match(/\*\*Files?\s+to\s+(?:create|modify)\*\*\s*\|?\s*(.+)/i); if (filesMatch) { const fileList = filesMatch[1].split(/[`,]/).map((f: string) => f.trim()).filter(Boolean); files.push(...fileList); } const blockFiles = section.match(/`([^`]+\.(ts|js|json|sql|md|tsx|jsx|vue|svelte|css))`/g); if (blockFiles) { for (const bf of blockFiles) { const cleaned = bf.replace(/`/g, ""); if (!files.includes(cleaned)) files.push(cleaned); } } const requirements: string[] = []; const reqMatch = section.match(/\*\*REQs?\*\*\s*\|?\s*(.+)/i); if (reqMatch) { const reqs = reqMatch[1].split(",").map((r: string) => r.trim()).filter(Boolean); requirements.push(...reqs); } const dependencies: string[] = []; const depMatch = section.match(/\*\*Dependencies?\*\*\s*\|?\s*(.+)/i); if (depMatch) { const deps = depMatch[1].split(",").map((d: string) => d.trim()).filter((d: string) => d && d !== "None"); dependencies.push(...deps); } tasks.push({ id: taskId, description, files, requirements, dependencies, wave: currentWave, }); } return tasks; } private assignTasksToPersonas( tasks: PlanTask[], personas: ExecutePersonaConfig[] ): DecomposedTask[] { const leadConfig = personas.find((p) => p.domain === "coordination") || personas[0]; const engineerConfigs = personas.filter((p) => p.domain !== "coordination"); return tasks.map((task) => { const assignedPersona = this.assignPersona(task, personas); const domain = this.determineDomain(task, assignedPersona); return { taskId: task.id, persona: assignedPersona.name, domain, description: task.description, files: task.files, dependencies: task.dependencies, }; }); } private assignPersona( task: PlanTask, personas: ExecutePersonaConfig[] ): ExecutePersonaConfig { if (task.files.length === 0 && task.description.length === 0) { return personas.find((p) => p.domain === "coordination") || personas[0]; } let bestPersona: ExecutePersonaConfig | null = null; let bestScore = 0; for (const persona of personas) { if (persona.domain === "coordination") continue; let score = 0; for (const file of task.files) { const matched = matchFileToPersona(file, personas); if (matched && matched.name === persona.name) { score += 3; } } const domainKeywords = DOMAIN_KEYWORDS[persona.domain] || []; const descLower = task.description.toLowerCase(); for (const keyword of domainKeywords) { if (descLower.includes(keyword)) { score += 1; } } for (const req of task.requirements) { const reqLower = req.toLowerCase(); for (const keyword of domainKeywords) { if (reqLower.includes(keyword)) { score += 1; } } } if (score > bestScore) { bestScore = score; bestPersona = persona; } } if (bestPersona && bestScore > 0) { return bestPersona; } if (task.files.length > 0) { const firstFile = task.files[0]; const matched = matchFileToPersona(firstFile, personas); if (matched) return matched; } return personas.find((p) => p.domain === "coordination") || personas[0]; } private determineDomain( task: PlanTask, persona: ExecutePersonaConfig ): PersonaDomain { return persona.domain as PersonaDomain; } private leadDeveloperResolve(conflict: TerritoryConflict): string { switch (conflict.type) { case "data-backend": return `Lead developer assigns ${conflict.file} to backend engineer. Data engineer provides schema contract; backend implements API contract. Data changes should be in a separate migration.`; case "backend-frontend": return `Lead developer assigns ${conflict.file} to backend engineer. Frontend engineer adapts to backend API contract. If the file is primarily a type definition, create a shared types module.`; case "data-frontend": return `Lead developer assigns ${conflict.file} to data engineer for schema definition. Frontend engineer consumes through a backend API endpoint. Direct database access from frontend is prohibited.`; default: return `Lead developer arbitrates: ${conflict.file} assigned to ${conflict.personas[0]}. Other persona uses the public interface.`; } } }