feat(P01-P05): multi-session support & execute-phase persona specialization — SESSION-01..05, PERSONA-01..11, CLI-01..04, INTEG-01..05
---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---
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user