feat(P01-P05): multi-session support & execute-phase persona specialization — SESSION-01..05, PERSONA-01..11, CLI-01..04, INTEG-01..05
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled

---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:
Jon Chery
2026-06-01 17:43:06 +00:00
parent 6d0034dc88
commit 8c975352b8
20 changed files with 2398 additions and 13 deletions
+275
View File
@@ -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.`;
}
}
}