8c975352b8
---ci---
phase: 1-5
milestone: v0.11
project: ci
status: execute
decisions:
- id: D-092
decision: Independent sessions via AgentSession (not shared state)
rationale: Aligns with git-native model; sessions communicate through commits and .ciagent/ files
confidence: 0.90
- id: D-093
decision: Personas as runtime configs (not new Agent classes)
rationale: Less code, more flexible. Persona md files define domain knowledge and framework opinions.
confidence: 0.88
- id: D-094
decision: Lead developer as task decomposer (not separate pipeline stage)
rationale: EXECUTE stays one stage. Lead decomposes before execution, each persona group runs.
confidence: 0.85
- id: D-095
decision: File-based git locking (not DB or IPC)
rationale: Git-native. .session-lock files are simple JSON with session ID, timestamp, project slug.
confidence: 0.87
- id: D-096
decision: Territory enforcement with warn/strict modes
rationale: Warn for teams learning boundaries. Strict for mature projects. Configurable per-project.
confidence: 0.82
- id: D-097
decision: Task decomposition by file patterns + requirement IDs
rationale: File patterns are deterministic; no LLM needed. Requirement IDs in PLAN.md already map to domains.
confidence: 0.88
requirements:
covered: [SESSION-01, SESSION-02, SESSION-03, SESSION-04, SESSION-05, PERSONA-01, PERSONA-02, PERSONA-03, PERSONA-04, PERSONA-05, PERSONA-06, PERSONA-07, PERSONA-08, PERSONA-09, PERSONA-10, PERSONA-11, CLI-01, CLI-02, CLI-03, CLI-04, INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05]
---/ci---
275 lines
9.3 KiB
TypeScript
275 lines
9.3 KiB
TypeScript
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<string, string[]> = {
|
||
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<string, string[]> = {
|
||
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.`;
|
||
}
|
||
}
|
||
} |