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
+10 -3
View File
@@ -30,6 +30,8 @@ src/
core/ # Core engine components core/ # Core engine components
artifacts.ts # Legacy .ciagent/ artifact management (retained for backward compat) artifacts.ts # Legacy .ciagent/ artifact management (retained for backward compat)
audit.ts # Git-native audit trail — reads decisions/escalations from git log audit.ts # Git-native audit trail — reads decisions/escalations from git log
agent-session.ts # Multi-session support: AgentSession, file-based git locking
session-manager.ts # SessionManager: concurrent session lifecycle management
ciagent-files.ts # .ciagent/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.) ciagent-files.ts # .ciagent/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.)
clarify.ts # Clarify phase: question generation, default acceptance clarify.ts # Clarify phase: question generation, default acceptance
commit-builder.ts # Structured commit message generation (---ci--- YAML blocks) commit-builder.ts # Structured commit message generation (---ci--- YAML blocks)
@@ -40,14 +42,18 @@ src/
escalation.ts # Escalation protocol: commits escalations as git artifacts escalation.ts # Escalation protocol: commits escalations as git artifacts
git-branch.ts # Branch lifecycle: phase/NN-slug, milestone/vX.X-slug git-branch.ts # Branch lifecycle: phase/NN-slug, milestone/vX.X-slug
git-context.ts # Project state reconstruction from git log + branches git-context.ts # Project state reconstruction from git log + branches
persona-loader.ts # Execute-time persona resolution from .config/opencode/agents/*.md
task-decomposer.ts # Plan decomposition into data/backend/frontend task groups
types/ # Type definitions types/ # Type definitions
commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit (includes session field)
config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, DEFAULT_CIAGENT_CONFIG (includes backend) config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, SessionConfig, PersonaConfigSection, DEFAULT_CIAGENT_CONFIG (includes backend)
decisions.ts # Decision, ConfidenceLevel, DecisionCategory decisions.ts # Decision, ConfidenceLevel, DecisionCategory
escalation.ts # Escalation, EscalationType, EscalationResolution escalation.ts # Escalation, EscalationType, EscalationResolution
clarify.ts # ClarifyQuestion, ClarifyResult clarify.ts # ClarifyQuestion, ClarifyResult
specification.ts # Specification parser (objective, requirements, constraints, out_of_scope) specification.ts # Specification parser (objective, requirements, constraints, out_of_scope)
pipeline.ts # PipelineStage, PipelineState, PhaseResult, STAGE_ORDER pipeline.ts # PipelineStage, PipelineState, PhaseResult, STAGE_ORDER
persona.ts # ExecutePersonaConfig, PersonaDomain, TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS
session.ts # SessionInfo, SessionStatus, SessionConfig, DEFAULT_SESSION_CONFIG
utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON) utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON)
verification/ # 4-layer verification pipeline verification/ # 4-layer verification pipeline
structural.ts # Layer 1: file existence, imports wired, no stubs structural.ts # Layer 1: file existence, imports wired, no stubs
@@ -197,7 +203,8 @@ IntelligenceBackend (unified interface)
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests - **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
- **v0.9.0**: Integration & hardening — OpenAI and Anthropic backends, all 19 agents with intrinsic mechanical logic, E2E v0.9 integration tests, parallel agent execution - **v0.9.0**: Integration & hardening — OpenAI and Anthropic backends, all 19 agents with intrinsic mechanical logic, E2E v0.9 integration tests, parallel agent execution
- **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows - **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows
- **New in v0.10**: IdeationEngine with mechanical/backend-enriched/cross-project tiers, `ciagent ideate` command with --category/--affected/--spec/--external/--cross-project/--project/--output flags, `IDEATE` pipeline stage between RESEARCH and PLAN, multi-project support with `active_projects` config and `--project all` flag, `---ci--- project: <slug>` commit blocks, `max_concurrent_projects` parallelization config - **New in v0.11**: Multi-session support with `SessionManager` and `AgentSession` for independent project pipelines running concurrently, execute-phase persona specialization (`lead-developer`, `data-engineer`, `backend-engineer`, `frontend-engineer`) with territory enforcement and task decomposition, `ciagent sessions` CLI command with list/status/cancel/cleanup subcommands, `--session <id>` flag on `ciagent run`, `---ci--- session:` commit metadata field, `sessions` and `personas` config sections
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
- **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation) - **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation)
- **Config expansion (v0.10)**: `ideation` section in config with categories, thresholds, external signals, cross-project, chaos; `active_projects` array; `max_concurrent_projects` in parallelization - **Config expansion (v0.10)**: `ideation` section in config with categories, thresholds, external signals, cross-project, chaos; `active_projects` array; `max_concurrent_projects` in parallelization
- **Auto-detection order**: opencode → openai → ollama-local → ollama-cloud → anthropic - **Auto-detection order**: opencode → openai → ollama-local → ollama-cloud → anthropic
+190 -4
View File
@@ -2,6 +2,11 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { TaskDecomposer } from "../core/task-decomposer.js";
import { PersonaLoader } from "../core/persona-loader.js";
import { TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS } from "../types/persona.js";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { loadConfig } from "../core/config.js";
export interface ExecutorResult { export interface ExecutorResult {
success: boolean; success: boolean;
@@ -17,6 +22,17 @@ interface MustHaveItem {
passed: boolean; passed: boolean;
} }
interface PersonaTaskGroup {
persona: string;
domain: string;
tasks: Array<{
id: string;
description: string;
files: string[];
}>;
conflicts: TerritoryConflict[];
}
export class ExecutorAgent extends BaseAgent { export class ExecutorAgent extends BaseAgent {
readonly name = "executor"; readonly name = "executor";
readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints."; readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints.";
@@ -27,6 +43,14 @@ export class ExecutorAgent extends BaseAgent {
this.log("Executing tasks..."); this.log("Executing tasks...");
if (context.backend) { if (context.backend) {
const config = this.loadProjectConfig(context);
const personasEnabled = config.personas?.enabled !== false;
if (personasEnabled) {
this.log("Persona-based execution enabled — decomposing plan and assigning to personas");
return this.executeWithPersonas(context, config);
}
const taskPrompt = await this.buildBackendTaskPrompt(context); const taskPrompt = await this.buildBackendTaskPrompt(context);
const backendResult = await this.executeViaBackend(context, taskPrompt); const backendResult = await this.executeViaBackend(context, taskPrompt);
@@ -50,6 +74,156 @@ export class ExecutorAgent extends BaseAgent {
}; };
} }
private async executeWithPersonas(
context: AgentContext,
config: CIAgentConfig
): Promise<AgentResult> {
const start = Date.now();
const planContent = this.readPlanFile(context);
if (!planContent) {
this.log("No plan file found — falling back to standard execution");
const taskPrompt = await this.buildBackendTaskPrompt(context);
return this.executeViaBackend(context, taskPrompt);
}
const decomposer = new TaskDecomposer(context.project_path, config, context.project_slug);
const plan = decomposer.decompose(planContent);
const resolvedPlan = decomposer.resolveConflicts(plan);
this.log(`Decomposed plan into ${resolvedPlan.tasks.length} tasks across domains: data=${resolvedPlan.dataTasks.length}, backend=${resolvedPlan.backendTasks.length}, frontend=${resolvedPlan.frontendTasks.length}, coordination=${resolvedPlan.coordinationTasks.length}`);
if (resolvedPlan.conflicts.length > 0) {
this.log(`Resolved ${resolvedPlan.conflicts.length} territory conflicts`);
for (const conflict of resolvedPlan.conflicts) {
this.log(` Conflict: ${conflict.description}${conflict.resolution || "unresolved"}`);
}
}
const personaGroups = this.groupTasksByPersona(resolvedPlan);
const personaLoader = new PersonaLoader(context.project_path, config);
const enforcement = config.personas?.territory_enforcement || "warn";
let totalDecisions = 0;
let totalEscalations = 0;
const allArtifacts: string[] = [];
let lastError: string | undefined;
const domainOrder: string[] = ["data", "backend", "frontend", "coordination"];
const sortedGroups = domainOrder
.flatMap((domain) => personaGroups.filter((g) => g.domain === domain))
.concat(personaGroups.filter((g) => !domainOrder.includes(g.domain)));
for (const group of sortedGroups) {
this.log(`Executing group: persona=${group.persona}, domain=${group.domain}, tasks=${group.tasks.length}`);
for (const conflict of group.conflicts) {
if (enforcement === "strict") {
this.warn(`Territory conflict (strict): ${conflict.description}`);
totalEscalations++;
} else {
this.log(`Territory conflict (warn): ${conflict.description}${conflict.resolution || "auto-resolved"}`);
}
}
const persona = personaLoader.getPersona(group.persona);
const personaContext = this.buildPersonaContext(context, persona, group);
try {
const result = await this.executeViaBackend(personaContext, personaContext.specification);
if (Array.isArray(result.artifacts_created)) {
allArtifacts.push(...result.artifacts_created);
}
totalDecisions += result.decisions;
totalEscalations += result.escalations;
if (!result.success) {
this.warn(`Persona ${group.persona} reported issues: ${result.error || "unspecified"}`);
lastError = result.error;
}
} catch (err) {
this.warn(`Persona ${group.persona} failed: ${err instanceof Error ? err.message : String(err)}`);
lastError = err instanceof Error ? err.message : String(err);
}
}
const verification = await this.verifyExecution(context);
return {
success: verification.testsPassing || lastError === undefined,
output: `Executed ${resolvedPlan.tasks.length} tasks across ${personaGroups.length} persona groups. Verification: tests=${verification.testsPassing ? "passing" : "failing"}, must-haves=${verification.mustHavesChecked.length}`,
artifacts_created: allArtifacts,
decisions: totalDecisions,
escalations: totalEscalations,
duration_ms: Date.now() - start,
error: lastError,
};
}
private groupTasksByPersona(plan: DecomposedPlan): PersonaTaskGroup[] {
const groupMap = new Map<string, PersonaTaskGroup>();
for (const task of plan.tasks) {
const key = task.persona;
if (!groupMap.has(key)) {
groupMap.set(key, {
persona: task.persona,
domain: task.domain,
tasks: [],
conflicts: plan.conflicts.filter((c) => c.personas.includes(task.persona)),
});
}
groupMap.get(key)!.tasks.push({
id: task.taskId,
description: task.description,
files: task.files,
});
}
return Array.from(groupMap.values());
}
private buildPersonaContext(
context: AgentContext,
persona: ReturnType<PersonaLoader["getPersona"]>,
group: PersonaTaskGroup
): AgentContext {
const personaPrompt = persona
? `You are the ${persona.name} (${persona.domain} domain). ${persona.systemPromptAdditions || persona.description}.\n\nPreferred frameworks: ${persona.frameworks.join(", ")}.\nDesign constraints: ${persona.constraints.join(", ")}.\nTerritory files: ${persona.territory.join(", ")}.\n\n`
: "";
const taskDescriptions = group.tasks
.map((t) => `- [${t.id}] ${t.description} (files: ${t.files.join(", ") || "TBD"})`)
.join("\n");
const conflictNotes = group.conflicts.length > 0
? `\n\n## Territory Conflicts (resolved by lead developer)\n${group.conflicts.map((c) => `- ${c.description} → Resolution: ${c.resolution || "pending"}`).join("\n")}`
: "";
const specification = [
personaPrompt,
"## Assigned Tasks\n",
taskDescriptions,
conflictNotes,
"\n\n## Specification\n",
context.specification || "No specification provided",
].join("\n");
return {
...context,
specification,
};
}
private loadProjectConfig(context: AgentContext): CIAgentConfig {
try {
return loadConfig(context.project_path);
} catch {
return DEFAULT_CIAGENT_CONFIG as CIAgentConfig;
}
}
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> { private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
const parts: string[] = [ const parts: string[] = [
`Execute implementation for stage ${context.stage}, phase ${context.phase}.`, `Execute implementation for stage ${context.stage}, phase ${context.phase}.`,
@@ -64,8 +238,12 @@ export class ExecutorAgent extends BaseAgent {
} }
const ciDir = path.join(context.project_path, ".ciagent"); const ciDir = path.join(context.project_path, ".ciagent");
const roadmapPath = path.join(ciDir, "ROADMAP.md"); const roadmapPath = context.project_slug
const archPath = path.join(ciDir, "ARCHITECTURE.md"); ? path.join(ciDir, context.project_slug, "ROADMAP.md")
: path.join(ciDir, "ROADMAP.md");
const archPath = context.project_slug
? path.join(ciDir, context.project_slug, "ARCHITECTURE.md")
: path.join(ciDir, "ARCHITECTURE.md");
if (fs.existsSync(roadmapPath)) { if (fs.existsSync(roadmapPath)) {
try { try {
@@ -91,11 +269,17 @@ export class ExecutorAgent extends BaseAgent {
} }
private readPlanFile(context: AgentContext): string | null { private readPlanFile(context: AgentContext): string | null {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md"); const planPath = context.project_slug
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
: path.join(context.project_path, ".ciagent", "PLAN.md");
try { try {
if (fs.existsSync(planPath)) { if (fs.existsSync(planPath)) {
return fs.readFileSync(planPath, "utf-8"); return fs.readFileSync(planPath, "utf-8");
} }
const defaultPlanPath = path.join(context.project_path, ".ciagent", "PLAN.md");
if (fs.existsSync(defaultPlanPath)) {
return fs.readFileSync(defaultPlanPath, "utf-8");
}
} catch {} } catch {}
return null; return null;
} }
@@ -139,7 +323,9 @@ export class ExecutorAgent extends BaseAgent {
} }
private checkMustHaves(context: AgentContext): MustHaveItem[] { private checkMustHaves(context: AgentContext): MustHaveItem[] {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md"); const planPath = context.project_slug
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
: path.join(context.project_path, ".ciagent", "PLAN.md");
const results: MustHaveItem[] = []; const results: MustHaveItem[] = [];
try { try {
+31
View File
@@ -20,6 +20,7 @@ import { loadConfig, saveConfig, isCIAgentInitialized, initCIAgent } from "../co
import { getAgent } from "./index.js"; import { getAgent } from "./index.js";
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js"; import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
import { registerEscalationProtocol } from "../cli/index.js"; import { registerEscalationProtocol } from "../cli/index.js";
import { SessionManager } from "../core/session-manager.js";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
export interface GitAgentContext extends AgentContext { export interface GitAgentContext extends AgentContext {
@@ -894,6 +895,36 @@ export class OrchestratorAgent extends BaseAgent {
this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`); this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`);
const useSessions = config.sessions?.max_concurrent_sessions !== undefined;
if (useSessions) {
return this.runWithSessionManager(context, activeProjects, config);
}
return this.runWithLegacyParallel(context, activeProjects, config);
}
private async runWithSessionManager(
context: AgentContext,
activeProjects: string[],
config: CIAgentConfig
): Promise<Record<string, AgentResult>> {
const sessionManager = new SessionManager(context.project_path, config);
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
const contextFactory = (slug: string): AgentContext => ({
...context,
project_slug: slug,
});
return sessionManager.runAllSessions(activeProjects, contextFactory, parallel);
}
private async runWithLegacyParallel(
context: AgentContext,
activeProjects: string[],
config: CIAgentConfig
): Promise<Record<string, AgentResult>> {
const results: Record<string, AgentResult> = {}; const results: Record<string, AgentResult> = {};
const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3; const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3;
const parallel = config.parallelization?.enabled && activeProjects.length > 1; const parallel = config.parallelization?.enabled && activeProjects.length > 1;
+144
View File
@@ -18,6 +18,8 @@ import { BackendUnavailableError } from "../backends/types.js";
import { getAgent } from "../agents/index.js"; import { getAgent } from "../agents/index.js";
import { CIAgentFiles } from "../core/ciagent-files.js"; import { CIAgentFiles } from "../core/ciagent-files.js";
import { GiteaClient, generateReleaseNotes } from "../core/gitea.js"; import { GiteaClient, generateReleaseNotes } from "../core/gitea.js";
import { SessionManager } from "../core/session-manager.js";
import { AgentSession } from "../core/agent-session.js";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as readline from "node:readline"; import * as readline from "node:readline";
@@ -172,6 +174,7 @@ export function createRunCommand(): Command {
.option("--backend <provider>", "Override intelligence backend for this run") .option("--backend <provider>", "Override intelligence backend for this run")
.option("--ideate", "Insert ideation stage between research and plan") .option("--ideate", "Insert ideation stage between research and plan")
.option("--project <slug>", "Target project slug (comma-separated or 'all')") .option("--project <slug>", "Target project slug (comma-separated or 'all')")
.option("--session <id>", "Resume a specific session by ID")
.action(async (phase, options) => { .action(async (phase, options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
@@ -1372,4 +1375,145 @@ export function createIdeateCommand(): Command {
console.log(` ${cat}: ${count}`); console.log(` ${cat}: ${count}`);
} }
}); });
}
export function createSessionsCommand(): Command {
return new Command("sessions")
.description("Manage CIAgent agent sessions")
.addCommand(
new Command("list")
.description("List all sessions")
.option("--project <slug>", "Filter by project slug")
.action(async (options) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const persisted = sessionManager.loadPersistedSessions();
const active = sessionManager.listSessions();
const allSessions = [...persisted];
for (const activeSession of active) {
if (!allSessions.find((s) => s.id === activeSession.id)) {
allSessions.push(activeSession);
}
}
if (options.project) {
const filtered = allSessions.filter((s) => s.project_slug === options.project);
displaySessions(filtered);
} else {
displaySessions(allSessions);
}
})
)
.addCommand(
new Command("status")
.description("Show status of a specific session")
.argument("<session-id>", "Session ID")
.action(async (sessionId) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const persisted = sessionManager.loadPersistedSessions();
const sessionInfo = persisted.find((s) => s.id === sessionId);
if (!sessionInfo) {
const session = sessionManager.getSession(sessionId);
if (!session) {
console.error(`Session ${sessionId} not found.`);
process.exit(1);
}
displaySessionDetail(session.getSessionInfo());
return;
}
displaySessionDetail(sessionInfo);
})
)
.addCommand(
new Command("cancel")
.description("Cancel a running session")
.argument("<session-id>", "Session ID")
.action(async (sessionId) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const success = sessionManager.cancelSession(sessionId);
if (success) {
console.log(`Session ${sessionId} cancelled.`);
} else {
console.error(`Failed to cancel session ${sessionId}. Session may not be running.`);
process.exit(1);
}
})
)
.addCommand(
new Command("cleanup")
.description("Clean up stale sessions")
.action(async () => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const cleaned = sessionManager.cleanupStaleSessions();
console.log(`Cleaned up ${cleaned} stale session(s).`);
})
);
}
function displaySessions(sessions: Array<import("../types/session.js").SessionInfo>): void {
if (sessions.length === 0) {
console.log("No sessions found.");
return;
}
console.log("\n─── CIAgent Sessions ───\n");
console.log("ID Project Phase Stage Status");
console.log("-------- ---------------- ----- ---------- ---------");
for (const s of sessions) {
const id = s.id.padEnd(8);
const slug = (s.project_slug || "default").padEnd(16);
const phase = String(s.phase).padEnd(5);
const stage = s.stage.padEnd(10);
const statusIcon = s.status === "running" ? "●" : s.status === "completed" ? "✓" : s.status === "failed" ? "✗" : s.status === "paused" ? "⏸" : "○";
console.log(`${id} ${slug} ${phase} ${stage} ${statusIcon} ${s.status}`);
}
console.log(`\n${sessions.length} session(s) total.`);
}
function displaySessionDetail(s: import("../types/session.js").SessionInfo): void {
console.log("\n─── Session Detail ───\n");
console.log(` ID: ${s.id}`);
console.log(` Project: ${s.project_slug || "default"}`);
console.log(` Phase: ${s.phase}`);
console.log(` Stage: ${s.stage}`);
console.log(` Status: ${s.status}`);
console.log(` Started: ${s.started_at}`);
console.log(` Last Updated: ${s.last_updated}`);
if (s.error) {
console.log(` Error: ${s.error}`);
}
} }
+3 -1
View File
@@ -18,6 +18,7 @@ import {
createShipCommand, createShipCommand,
createProjectsCommand, createProjectsCommand,
createIdeateCommand, createIdeateCommand,
createSessionsCommand,
} from "./commands.js"; } from "./commands.js";
let activeEscalationProtocol: { dispose(): void } | null = null; let activeEscalationProtocol: { dispose(): void } | null = null;
@@ -68,6 +69,7 @@ program
.addCommand(createRollbackCommand()) .addCommand(createRollbackCommand())
.addCommand(createShipCommand()) .addCommand(createShipCommand())
.addCommand(createProjectsCommand()) .addCommand(createProjectsCommand())
.addCommand(createIdeateCommand()); .addCommand(createIdeateCommand())
.addCommand(createSessionsCommand());
program.parse(); program.parse();
+284
View File
@@ -0,0 +1,284 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { execSync } from "node:child_process";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { SessionConfig, SessionInfo, SessionStatus, DEFAULT_SESSION_CONFIG } from "../types/session.js";
import { PipelineStage } from "../types/pipeline.js";
import { AgentContext, AgentResult } from "../agents/base.js";
import { loadConfig } from "../core/config.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { GitContext } from "../core/git-context.js";
import { CommitBuilder } from "../core/commit-builder.js";
import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js";
import { PipelineState, createInitialPipelineState } from "../types/pipeline.js";
export class AgentSession {
private id: string;
private projectSlug: string;
private projectPath: string;
private config: CIAgentConfig;
private sessionConfig: SessionConfig;
private status: SessionStatus;
private pipelineState: PipelineState | null;
private error: string | undefined;
private startedAt: string;
private lastUpdated: string;
private lockAcquired: boolean;
constructor(projectPath: string, projectSlug: string, config?: CIAgentConfig) {
this.id = crypto.randomUUID().slice(0, 8);
this.projectSlug = projectSlug;
this.projectPath = projectPath;
this.config = config || loadConfig(projectPath);
this.sessionConfig = this.config.sessions || DEFAULT_SESSION_CONFIG;
this.status = "pending";
this.pipelineState = null;
this.error = undefined;
this.startedAt = new Date().toISOString();
this.lastUpdated = this.startedAt;
this.lockAcquired = false;
}
getId(): string {
return this.id;
}
getProjectSlug(): string {
return this.projectSlug;
}
getStatus(): SessionStatus {
return this.status;
}
getSessionInfo(): SessionInfo {
return {
id: this.id,
project_slug: this.projectSlug,
project_path: this.projectPath,
phase: this.pipelineState?.current_phase ?? 0,
stage: this.pipelineState?.current_stage ?? "specify",
status: this.status,
started_at: this.startedAt,
last_updated: this.lastUpdated,
error: this.error,
};
}
acquireLock(): boolean {
const lockPath = this.getLockPath();
ensureDir(path.dirname(lockPath));
if (fileExists(lockPath)) {
const lockData = JSON.parse(readFile(lockPath) || "{}") as { sessionId: string; timestamp: string; projectSlug: string };
if (lockData.sessionId && lockData.sessionId !== this.id) {
const lockAge = Date.now() - new Date(lockData.timestamp).getTime();
if (lockAge < (this.sessionConfig.session_timeout_ms || 3600000)) {
return false;
}
}
}
writeFile(lockPath, JSON.stringify({
sessionId: this.id,
timestamp: new Date().toISOString(),
projectSlug: this.projectSlug,
}));
this.lockAcquired = true;
return true;
}
releaseLock(): void {
if (!this.lockAcquired) return;
const lockPath = this.getLockPath();
try {
if (fileExists(lockPath)) {
const lockData = JSON.parse(readFile(lockPath) || "{}") as { sessionId: string };
if (lockData.sessionId === this.id) {
fs.unlinkSync(lockPath);
}
}
} catch {}
this.lockAcquired = false;
}
async run(context: AgentContext): Promise<AgentResult> {
if (this.status === "running") {
return {
success: false,
output: `Session ${this.id} is already running`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: "Session already running",
};
}
const locked = this.acquireLock();
if (!locked) {
return {
success: false,
output: `Failed to acquire lock for session ${this.id}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: "Lock acquisition failed — another session is active for this project",
};
}
this.status = "running";
this.lastUpdated = new Date().toISOString();
this.pipelineState = createInitialPipelineState(this.projectPath);
const gitContext = new GitContext(this.projectPath, this.projectSlug || undefined);
const projectState = gitContext.reconstructState();
if (projectState.currentPhase > 0) {
this.pipelineState.current_phase = projectState.currentPhase;
this.pipelineState.current_stage = projectState.currentStage;
}
this.persistState();
let result: AgentResult;
try {
const { OrchestratorAgent } = await import("../agents/orchestrator.js");
const orchestrator = new OrchestratorAgent(this.config);
result = await orchestrator.runForProject(this.projectSlug, context);
this.status = result.success ? "completed" : "failed";
this.error = result.error;
} catch (err) {
this.status = "failed";
this.error = err instanceof Error ? err.message : String(err);
result = {
success: false,
output: `Session ${this.id} failed: ${this.error}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: this.error,
};
} finally {
this.lastUpdated = new Date().toISOString();
this.releaseLock();
this.persistState();
}
if (this.config.git?.auto_commit && result.success) {
const ciFiles = new CIAgentFiles(this.projectPath, this.projectSlug || undefined);
try {
const sessionCommit = CommitBuilder.buildTaskCommit({
type: "chore",
phase: this.pipelineState?.current_phase ?? 0,
milestone: "session",
project: this.projectSlug || undefined,
plan: "session",
task: this.id,
subject: `session ${this.id} ${this.status}`,
status: "complete" as PipelineStage,
});
if (gitContext.isGitRepo()) {
execSync(`git add -A && git commit -m "${sessionCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: this.projectPath,
stdio: "pipe",
});
}
} catch {}
}
return {
...result,
output: `[session:${this.id}] ${result.output}`,
};
}
cancel(): boolean {
if (this.status !== "running") return false;
this.status = "cancelled";
this.lastUpdated = new Date().toISOString();
this.releaseLock();
this.persistState();
return true;
}
pause(): boolean {
if (this.status !== "running") return false;
this.status = "paused";
this.lastUpdated = new Date().toISOString();
this.persistState();
return true;
}
resume(): boolean {
if (this.status !== "paused") return false;
this.status = "running";
this.lastUpdated = new Date().toISOString();
return true;
}
private getLockPath(): string {
const ciDir = path.join(this.projectPath, ".ciagent");
const slugDir = this.projectSlug ? path.join(ciDir, this.projectSlug) : ciDir;
return path.join(slugDir, ".session-lock");
}
private getStatePath(): string {
const ciDir = path.join(this.projectPath, ".ciagent");
const slugDir = this.projectSlug ? path.join(ciDir, this.projectSlug) : ciDir;
return path.join(slugDir, `.session-${this.id}.json`);
}
persistState(): void {
const statePath = this.getStatePath();
const stateData = {
id: this.id,
projectSlug: this.projectSlug,
projectPath: this.projectPath,
status: this.status,
startedAt: this.startedAt,
lastUpdated: this.lastUpdated,
error: this.error,
pipelineState: this.pipelineState,
};
ensureDir(path.dirname(statePath));
writeFile(statePath, JSON.stringify(stateData, null, 2));
}
static loadState(projectPath: string, sessionId: string, projectSlug?: string): AgentSession | null {
const ciDir = path.join(projectPath, ".ciagent");
const slugDir = projectSlug ? path.join(ciDir, projectSlug) : ciDir;
const statePath = path.join(slugDir, `.session-${sessionId}.json`);
if (!fileExists(statePath)) return null;
try {
const data = JSON.parse(readFile(statePath) || "{}") as {
id: string;
projectSlug: string;
projectPath: string;
status: SessionStatus;
startedAt: string;
lastUpdated: string;
error?: string;
};
const session = new AgentSession(data.projectPath, data.projectSlug);
(session as any).id = data.id;
(session as any).status = data.status;
(session as any).startedAt = data.startedAt;
(session as any).lastUpdated = data.lastUpdated;
(session as any).error = data.error;
return session;
} catch {
return null;
}
}
}
+1
View File
@@ -98,6 +98,7 @@ export class CommitBuilder {
lines.push(`milestone: ${ci.milestone}`); lines.push(`milestone: ${ci.milestone}`);
if (ci.project) lines.push(`project: ${ci.project}`); if (ci.project) lines.push(`project: ${ci.project}`);
if (ci.session) lines.push(`session: ${ci.session}`);
if (ci.plan) lines.push(`plan: ${ci.plan}`); if (ci.plan) lines.push(`plan: ${ci.plan}`);
if (ci.task) lines.push(`task: ${ci.task}`); if (ci.task) lines.push(`task: ${ci.task}`);
+3
View File
@@ -43,6 +43,9 @@ export function parseCIAgentBlock(yaml: string): CIAgentMetadata | null {
const projectMatch = yaml.match(/^project:\s*(.+)$/m); const projectMatch = yaml.match(/^project:\s*(.+)$/m);
if (projectMatch) result.project = projectMatch[1].trim(); if (projectMatch) result.project = projectMatch[1].trim();
const sessionMatch = yaml.match(/^session:\s*(.+)$/m);
if (sessionMatch) result.session = sessionMatch[1].trim();
result.decisions = parseDecisionsFromYaml(yaml); result.decisions = parseDecisionsFromYaml(yaml);
result.escalations = parseEscalationsFromYaml(yaml); result.escalations = parseEscalationsFromYaml(yaml);
result.requirements = parseRequirementsFromYaml(yaml); result.requirements = parseRequirementsFromYaml(yaml);
+4 -1
View File
@@ -9,6 +9,9 @@ export { GitBranch } from "./git-branch.js";
export { CommitBuilder } from "./commit-builder.js"; export { CommitBuilder } from "./commit-builder.js";
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js"; export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js";
export { GiteaClient, generateReleaseNotes } from "./gitea.js"; export { GiteaClient, generateReleaseNotes } from "./gitea.js";
export type { GiteaReleaseConfig, GiteaRelease } from "./gitea.js"; export { AgentSession } from "./agent-session.js";
export { SessionManager } from "./session-manager.js";
export { PersonaLoader } from "./persona-loader.js";
export { TaskDecomposer } from "./task-decomposer.js";
export type { CIAgentConfig } from "../types/config.js"; export type { CIAgentConfig } from "../types/config.js";
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js"; export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
+227
View File
@@ -0,0 +1,227 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { ExecutePersonaConfig, PersonaDomain, DEFAULT_PERSONAS, TerritoryEnforcement } from "../types/persona.js";
import { CIAgentConfig } from "../types/config.js";
export interface PersonaDefinition {
name: string;
domain: PersonaDomain;
frameworks: string[];
constraints: string[];
territory: string[];
description: string;
systemPromptAdditions: string;
}
const PERSONA_SEARCH_PATHS = [
".config/opencode/agents",
"opencode/agents",
];
const PERSONA_FILE_PATTERN = /^ci-(.+)\.md$/;
export class PersonaLoader {
private projectPath: string;
private config: CIAgentConfig;
private cachedPersonas: Map<string, PersonaDefinition> = new Map();
private loaded: boolean = false;
constructor(projectPath: string, config: CIAgentConfig) {
this.projectPath = projectPath;
this.config = config;
}
loadPersonas(): PersonaDefinition[] {
if (this.loaded) {
return Array.from(this.cachedPersonas.values());
}
const configPersonas = this.config.personas?.personas || DEFAULT_PERSONAS;
const configEnabled = this.config.personas?.enabled ?? true;
if (!configEnabled) {
this.loaded = true;
return [];
}
for (const configPersona of configPersonas) {
const filePersona = this.loadPersonaFromFile(configPersona.name);
if (filePersona) {
const merged: PersonaDefinition = {
name: configPersona.name,
domain: configPersona.domain,
frameworks: filePersona.frameworks.length > 0 ? filePersona.frameworks : configPersona.frameworks,
constraints: filePersona.constraints.length > 0 ? filePersona.constraints : configPersona.constraints,
territory: filePersona.territory.length > 0 ? filePersona.territory : configPersona.territory,
description: filePersona.description,
systemPromptAdditions: filePersona.systemPromptAdditions,
};
this.cachedPersonas.set(configPersona.name, merged);
} else {
const definition: PersonaDefinition = {
name: configPersona.name,
domain: configPersona.domain,
frameworks: configPersona.frameworks,
constraints: configPersona.constraints,
territory: configPersona.territory,
description: `${configPersona.name} persona (domain: ${configPersona.domain})`,
systemPromptAdditions: this.buildDefaultPromptAdditions(configPersona),
};
this.cachedPersonas.set(configPersona.name, definition);
}
}
this.loaded = true;
return Array.from(this.cachedPersonas.values());
}
getPersona(name: string): PersonaDefinition | undefined {
if (!this.loaded) this.loadPersonas();
return this.cachedPersonas.get(name);
}
getPersonaForDomain(domain: PersonaDomain): PersonaDefinition | undefined {
if (!this.loaded) this.loadPersonas();
for (const persona of this.cachedPersonas.values()) {
if (persona.domain === domain) return persona;
}
return undefined;
}
getLeadDeveloper(): PersonaDefinition {
return this.getPersona("lead-developer") || {
name: "lead-developer",
domain: "coordination",
frameworks: [],
constraints: ["pragmatic", "battle-tested defaults"],
territory: [],
description: "Lead developer — coordinates task decomposition and resolves conflicts",
systemPromptAdditions: "",
};
}
getEngineerPersonas(): PersonaDefinition[] {
if (!this.loaded) this.loadPersonas();
return Array.from(this.cachedPersonas.values()).filter(
(p) => p.domain !== "coordination"
);
}
getTerritoryEnforcement(): TerritoryEnforcement {
return this.config.personas?.territory_enforcement || "warn";
}
private loadPersonaFromFile(name: string): PersonaDefinition | null {
const filename = `ci-${name}.md`;
for (const searchPath of PERSONA_SEARCH_PATHS) {
const filePath = path.join(this.projectPath, searchPath, filename);
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, "utf-8");
return this.parsePersonaMd(name, content);
} catch {
continue;
}
}
}
return null;
}
private parsePersonaMd(name: string, content: string): PersonaDefinition {
const frontmatter = this.parseFrontmatter(content);
const body = this.stripFrontmatter(content);
return {
name: (frontmatter.name as string) || name,
domain: (frontmatter.domain as PersonaDomain) || this.inferDomainFromName(name),
frameworks: (frontmatter.frameworks as string[]) || [],
constraints: (frontmatter.constraints as string[]) || [],
territory: (frontmatter.territory as string[]) || [],
description: (frontmatter.description as string) || body.slice(0, 200),
systemPromptAdditions: body,
};
}
private parseFrontmatter(content: string): Record<string, unknown> {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const yaml = match[1];
const result: Record<string, unknown> = {};
const lines = yaml.split("\n");
let currentKey = "";
let inArray = false;
let arrayItems: string[] = [];
for (const line of lines) {
const arrMatch = line.match(/^(\w+):\s*$/);
if (arrMatch) {
if (inArray && currentKey) {
result[currentKey] = arrayItems;
}
currentKey = arrMatch[1];
inArray = true;
arrayItems = [];
continue;
}
const itemMatch = line.match(/^\s+-\s+(.+)$/);
if (itemMatch && inArray) {
arrayItems.push(itemMatch[1].trim());
continue;
}
const kvMatch = line.match(/^(\w+):\s*(.+)$/);
if (kvMatch) {
if (inArray && currentKey) {
result[currentKey] = arrayItems;
inArray = false;
}
currentKey = kvMatch[1];
result[currentKey] = kvMatch[2].trim();
}
}
if (inArray && currentKey) {
result[currentKey] = arrayItems;
}
return result;
}
private stripFrontmatter(content: string): string {
return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
}
private inferDomainFromName(name: string): PersonaDomain {
if (name.includes("data") || name.includes("db") || name.includes("schema")) return "data";
if (name.includes("backend") || name.includes("api") || name.includes("server")) return "backend";
if (name.includes("frontend") || name.includes("ui") || name.includes("client")) return "frontend";
return "coordination";
}
private buildDefaultPromptAdditions(config: ExecutePersonaConfig): string {
const parts: string[] = [];
parts.push(`You are a ${config.name} persona in the CIAgent execution pipeline.`);
parts.push(`Domain: ${config.domain}.`);
if (config.frameworks.length > 0) {
parts.push(`Preferred frameworks: ${config.frameworks.join(", ")}.`);
}
if (config.constraints.length > 0) {
parts.push(`Design constraints: ${config.constraints.join(", ")}.`);
}
if (config.territory.length > 0) {
parts.push(`You own the following file patterns: ${config.territory.join(", ")}.`);
parts.push(`Do not modify files outside your territory without explicit lead developer approval.`);
}
return parts.join(" ");
}
}
+475
View File
@@ -0,0 +1,475 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
ExecutePersonaConfig,
PersonaDomain,
TerritoryConflict,
DecomposedTask,
DecomposedPlan,
DEFAULT_PERSONAS,
matchFileToPersona,
globMatch,
detectConflicts,
} from "../types/persona.js";
import { TaskDecomposer } from "../core/task-decomposer.js";
import { PersonaLoader } from "../core/persona-loader.js";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { initCIAgent } from "../core/config.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-persona-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
const samplePlan = `# Phase 1 Plan — Core API
## Phase Goal
Build core API routes and database schema.
### Wave 1 (foundational)
#### Task 1.1: Create user schema
| **ID** | P1-T1 |
| **REQs** | DATA-01 |
| **Description** | Create the users table schema with Drizzle ORM |
| **Files to create** | \`src/db/schema/users.ts\`, \`src/db/migrations/001_create_users.sql\` |
#### Task 1.2: Create auth routes
| **ID** | P1-T2 |
| **REQs** | API-01 |
| **Description** | Create /api/auth/login and /api/auth/register routes |
| **Files to create** | \`src/api/routes/auth.ts\`, \`src/api/middleware/auth.ts\` |
#### Task 1.3: Create login page
| **ID** | P1-T3 |
| **REQs** | UI-01 |
| **Description** | Create React login page component |
| **Files to create** | \`src/components/LoginForm.tsx\`, \`src/pages/login.tsx\` |
### Wave 2
#### Task 1.4: Create data repository
| **ID** | P1-T4 |
| **REQs** | DATA-02 |
| **Description** | Create UserRepository with typed query methods |
| **Files to create** | \`src/repository/userRepository.ts\` |
`;
describe("ExecutePersona type", () => {
it("DEFAULT_PERSONAS has 4 personas", () => {
expect(DEFAULT_PERSONAS).toHaveLength(4);
});
it("DEFAULT_PERSONAS includes lead-developer", () => {
const lead = DEFAULT_PERSONAS.find((p) => p.name === "lead-developer");
expect(lead).toBeTruthy();
expect(lead!.domain).toBe("coordination");
expect(lead!.territory).toHaveLength(0);
});
it("DEFAULT_PERSONAS includes data-engineer", () => {
const data = DEFAULT_PERSONAS.find((p) => p.name === "data-engineer");
expect(data).toBeTruthy();
expect(data!.domain).toBe("data");
expect(data!.frameworks).toContain("drizzle");
expect(data!.territory.length).toBeGreaterThan(0);
});
it("DEFAULT_PERSONAS includes backend-engineer", () => {
const backend = DEFAULT_PERSONAS.find((p) => p.name === "backend-engineer");
expect(backend).toBeTruthy();
expect(backend!.domain).toBe("backend");
expect(backend!.frameworks).toContain("fastify");
expect(backend!.territory.length).toBeGreaterThan(0);
});
it("DEFAULT_PERSONAS includes frontend-engineer", () => {
const frontend = DEFAULT_PERSONAS.find((p) => p.name === "frontend-engineer");
expect(frontend).toBeTruthy();
expect(frontend!.domain).toBe("frontend");
expect(frontend!.frameworks).toContain("react");
expect(frontend!.territory.length).toBeGreaterThan(0);
});
it("each domain persona has territory patterns", () => {
for (const persona of DEFAULT_PERSONAS) {
if (persona.domain === "coordination") continue;
expect(persona.territory.length).toBeGreaterThan(0);
}
});
it("each domain persona has constraints", () => {
for (const persona of DEFAULT_PERSONAS) {
if (persona.domain === "coordination") continue;
expect(persona.constraints.length).toBeGreaterThan(0);
}
});
});
describe("matchFileToPersona", () => {
const personas = DEFAULT_PERSONAS;
it("matches data files to data engineer", () => {
const matches = [
"src/db/schema/users.ts",
"src/migrations/001_create_users.sql",
"drizzle/config.ts",
"src/models/User.ts",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("data-engineer");
}
});
it("matches API files to backend engineer", () => {
const matches = [
"src/api/routes/auth.ts",
"src/services/UserService.ts",
"src/middleware/auth.ts",
"src/controllers/userController.ts",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("backend-engineer");
}
});
it("matches component files to frontend engineer", () => {
const matches = [
"src/components/LoginForm.tsx",
"src/pages/login.tsx",
"src/hooks/useAuth.ts",
"src/styles/global.css",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("frontend-engineer");
}
});
it("returns null for files outside any territory", () => {
const result = matchFileToPersona("src/utils/helpers.ts", personas);
expect(result).toBeNull();
});
it("handles glob patterns correctly", () => {
expect(globMatch("**/db/**", "src/db/schema/users.ts")).toBe(true);
expect(globMatch("**/db/**", "src/api/routes/auth.ts")).toBe(false);
expect(globMatch("**/*.tsx", "src/components/Button.tsx")).toBe(true);
expect(globMatch("**/*.tsx", "src/utils/helpers.ts")).toBe(false);
});
});
describe("detectConflicts", () => {
it("detects data-backend conflicts", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "data-engineer",
domain: "data",
description: "Create schema",
files: ["src/db/schema/users.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "backend-engineer",
domain: "backend",
description: "Create API routes",
files: ["src/db/schema/users.ts"],
dependencies: ["T1"],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(1);
expect(conflicts[0].type).toBe("data-backend");
expect(conflicts[0].personas).toContain("data-engineer");
expect(conflicts[0].personas).toContain("backend-engineer");
});
it("detects backend-frontend conflicts", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "backend-engineer",
domain: "backend",
description: "Create API types",
files: ["src/api/types/UserTypes.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "frontend-engineer",
domain: "frontend",
description: "Create user component",
files: ["src/api/types/UserTypes.ts"],
dependencies: ["T1"],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(1);
expect(conflicts[0].type).toBe("backend-frontend");
});
it("returns no conflicts for non-overlapping tasks", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "data-engineer",
domain: "data",
description: "Create schema",
files: ["src/db/schema/users.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "backend-engineer",
domain: "backend",
description: "Create API routes",
files: ["src/api/routes/auth.ts"],
dependencies: [],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(0);
});
});
describe("TaskDecomposer", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initCIAgent(dir);
});
afterEach(() => {
cleanup(dir);
});
it("decomposes a plan into persona-specific task groups", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config, "test-project");
const plan = decomposer.decompose(samplePlan);
expect(plan.tasks.length).toBeGreaterThan(0);
expect(plan.dataTasks).toBeDefined();
expect(plan.backendTasks).toBeDefined();
expect(plan.frontendTasks).toBeDefined();
expect(plan.coordinationTasks).toBeDefined();
});
it("resolves territory conflicts", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const resolved = decomposer.resolveConflicts(plan);
for (const conflict of resolved.conflicts) {
if (conflict.resolution) {
expect(conflict.resolution.length).toBeGreaterThan(0);
}
}
});
it("assigns data tasks to data-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const dataTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("schema") || f.includes("migration"))
);
if (dataTask) {
expect(dataTask.domain).toBe("data");
}
});
it("assigns API tasks to backend-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const apiTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("api") || f.includes("routes"))
);
if (apiTask) {
expect(apiTask.domain).toBe("backend");
}
});
it("assigns component tasks to frontend-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const frontendTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("components") || f.endsWith(".tsx"))
);
if (frontendTask) {
expect(frontendTask.domain).toBe("frontend");
}
});
});
describe("PersonaLoader", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initCIAgent(dir);
});
afterEach(() => {
cleanup(dir);
});
it("returns default personas when no files exist", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const personas = loader.loadPersonas();
expect(personas.length).toBeGreaterThan(0);
expect(personas.some((p) => p.domain === "data")).toBe(true);
expect(personas.some((p) => p.domain === "backend")).toBe(true);
expect(personas.some((p) => p.domain === "frontend")).toBe(true);
});
it("getLeadDeveloper returns lead developer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
loader.loadPersonas();
const lead = loader.getLeadDeveloper();
expect(lead).toBeTruthy();
expect(lead.domain).toBe("coordination");
expect(lead.name).toBe("lead-developer");
});
it("getEngineerPersonas returns non-coordination personas", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const engineers = loader.getEngineerPersonas();
expect(engineers.length).toBe(3);
expect(engineers.every((p) => p.domain !== "coordination")).toBe(true);
});
it("returns empty personas when personas disabled", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: false,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const personas = loader.loadPersonas();
expect(personas.length).toBe(0);
});
it("getTerritoryEnforcement returns configured value", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "strict" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
expect(loader.getTerritoryEnforcement()).toBe("strict");
});
it("defaults to warn territory enforcement", () => {
const config = { ...DEFAULT_CIAGENT_CONFIG };
const loader = new PersonaLoader(dir, config);
expect(loader.getTerritoryEnforcement()).toBe("warn");
});
});
+327
View File
@@ -0,0 +1,327 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { initCIAgent, loadConfig } from "../core/config.js";
import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { SessionConfig, SessionInfo, DEFAULT_SESSION_CONFIG } from "../types/session.js";
import { AgentSession } from "../core/agent-session.js";
import { SessionManager } from "../core/session-manager.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-session-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function initProjectWithConfig(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
const config = {
...DEFAULT_CIAGENT_CONFIG,
projects: [{ slug: "test-project", name: "Test Project", default: true }],
active_project: "test-project",
active_projects: ["test-project"],
sessions: {
max_concurrent_sessions: 3,
session_timeout_ms: 3600000,
session_isolation: "branch",
},
};
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify(config, null, 2));
const projectDir = path.join(ciDir, "test-project");
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(path.join(projectDir, "PROJECT.md"), [
"# Test Project",
"",
"## What This Is",
"",
"A test project for session testing",
"",
"## Requirements",
"",
"### Active",
"",
"- [ ] Build session management",
"",
"## Constraints",
"",
"- TypeScript",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "ROADMAP.md"), [
"# Roadmap",
"",
"## Overview",
"",
"Test project roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Sessions** - Build session management",
"",
"## Phase Details",
"",
"### Phase 1: Sessions",
"**Goal**.: Build session management",
"**Depends on**: Nothing",
"**Requirements**: SESSION-01",
"**Success Criteria**:",
"1. Sessions work",
"**Status**: not_started",
"",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "REQUIREMENTS.md"), [
"# Requirements",
"",
"| REQ-ID | Requirement | Priority | Phase | Status |",
"|--------|-------------|----------|-------|--------|",
"| SESSION-01 | Session management | P0 | 1 | pending |",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| SESSION-01 | Phase 1 | pending |",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "ARCHITECTURE.md"), [
"# Architecture",
"",
"## Overview",
"",
"Test architecture",
"",
"## Components",
"",
"### test-api",
"- **Description**: API",
"- **Boundaries**: HTTP only",
"- **Depends on**: None",
"",
"## Data Flow",
"",
"Client -> API",
"",
"## Build Order",
"",
"1. API",
"",
].join("\n"));
}
describe("Session types", () => {
it("DEFAULT_SESSION_CONFIG has expected values", () => {
expect(DEFAULT_SESSION_CONFIG.max_concurrent_sessions).toBe(3);
expect(DEFAULT_SESSION_CONFIG.session_timeout_ms).toBe(3600000);
expect(DEFAULT_SESSION_CONFIG.session_isolation).toBe("branch");
});
it("SessionInfo interface is constructable", () => {
const info: SessionInfo = {
id: "abc12345",
project_slug: "test-project",
project_path: "/tmp/test",
phase: 1,
stage: "execute",
status: "running",
started_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
};
expect(info.id).toBe("abc12345");
expect(info.status).toBe("running");
expect(info.project_slug).toBe("test-project");
});
it("SessionConfig supports all status values", () => {
const statuses: SessionInfo["status"][] = [
"pending", "running", "paused", "completed", "failed", "cancelled",
];
expect(statuses).toHaveLength(6);
});
});
describe("AgentSession", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initProjectWithConfig(dir);
});
afterEach(() => {
cleanup(dir);
});
it("creates a session with a unique ID", () => {
const session = new AgentSession(dir, "test-project");
expect(session.getId()).toBeTruthy();
expect(session.getId().length).toBeGreaterThan(0);
expect(session.getStatus()).toBe("pending");
});
it("getSessionInfo returns valid SessionInfo", () => {
const session = new AgentSession(dir, "test-project");
const info = session.getSessionInfo();
expect(info.id).toBe(session.getId());
expect(info.project_slug).toBe("test-project");
expect(info.project_path).toBe(dir);
expect(info.status).toBe("pending");
expect(info.phase).toBe(0);
});
it("persists session state", () => {
const session = new AgentSession(dir, "test-project");
session.persistState();
const slugDir = path.join(dir, ".ciagent", "test-project");
const files = fs.readdirSync(slugDir);
const stateFile = files.find((f) => f.startsWith(".session-") && f.endsWith(".json"));
expect(stateFile).toBeTruthy();
});
it("loads persisted session state", () => {
const session = new AgentSession(dir, "test-project");
session.persistState();
const loaded = AgentSession.loadState(dir, session.getId(), "test-project");
expect(loaded).not.toBeNull();
expect(loaded!.getId()).toBe(session.getId());
});
it("returns null for non-existent session", () => {
const loaded = AgentSession.loadState(dir, "nonexistent", "test-project");
expect(loaded).toBeNull();
});
it("acquireLock creates a lock file", () => {
const session = new AgentSession(dir, "test-project");
const acquired = session.acquireLock();
expect(acquired).toBe(true);
const lockPath = path.join(dir, ".ciagent", "test-project", ".session-lock");
expect(fs.existsSync(lockPath)).toBe(true);
session.releaseLock();
});
it("releaseLock removes the lock file", () => {
const session = new AgentSession(dir, "test-project");
session.acquireLock();
session.releaseLock();
const lockPath = path.join(dir, ".ciagent", "test-project", ".session-lock");
expect(fs.existsSync(lockPath)).toBe(false);
});
it("cancel changes status to cancelled when running", () => {
const session = new AgentSession(dir, "test-project");
session.acquireLock();
(session as any).status = "running";
const cancelled = session.cancel();
expect(cancelled).toBe(true);
expect(session.getStatus()).toBe("cancelled");
session.releaseLock();
});
it("cancel returns false for non-running session", () => {
const session = new AgentSession(dir, "test-project");
const cancelled = session.cancel();
expect(cancelled).toBe(false);
});
it("pause and resume work correctly for non-running session", () => {
const session = new AgentSession(dir, "test-project");
expect(session.pause()).toBe(false);
expect(session.resume()).toBe(false);
});
});
describe("SessionManager", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initProjectWithConfig(dir);
});
afterEach(() => {
cleanup(dir);
});
it("creates sessions for projects", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
expect(session).toBeTruthy();
expect(session.getProjectSlug()).toBe("test-project");
});
it("lists sessions", () => {
const manager = new SessionManager(dir);
manager.createSession("test-project");
const sessions = manager.listSessions();
expect(sessions.length).toBe(1);
expect(sessions[0].project_slug).toBe("test-project");
});
it("lists active sessions as empty when none running", () => {
const manager = new SessionManager(dir);
manager.createSession("test-project");
const active = manager.listActiveSessions();
expect(active.length).toBe(0);
});
it("cancels a session that is not running returns false", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
const cancelled = manager.cancelSession(session.getId());
expect(cancelled).toBe(false);
});
it("cleans up stale sessions returns 0", () => {
const manager = new SessionManager(dir);
const cleaned = manager.cleanupStaleSessions();
expect(cleaned).toBe(0);
});
it("loads persisted sessions as empty initially", () => {
const manager = new SessionManager(dir);
const persisted = manager.loadPersistedSessions();
expect(Array.isArray(persisted)).toBe(true);
});
it("gets a session by id", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
const retrieved = manager.getSession(session.getId());
expect(retrieved).toBeTruthy();
expect(retrieved!.getId()).toBe(session.getId());
});
it("returns undefined for non-existent session", () => {
const manager = new SessionManager(dir);
const retrieved = manager.getSession("nonexistent");
expect(retrieved).toBeUndefined();
});
});
+183
View File
@@ -0,0 +1,183 @@
import { CIAgentConfig } from "../types/config.js";
import { SessionInfo, SessionStatus } from "../types/session.js";
import { AgentSession } from "./agent-session.js";
import { AgentContext, AgentResult } from "../agents/base.js";
import { loadConfig } from "./config.js";
import * as path from "node:path";
import * as fs from "node:fs";
import * as os from "node:os";
export class SessionManager {
private sessions: Map<string, AgentSession> = new Map();
private config: CIAgentConfig;
private projectPath: string;
constructor(projectPath: string, config?: CIAgentConfig) {
this.projectPath = projectPath;
this.config = config || loadConfig(projectPath);
}
createSession(projectSlug: string): AgentSession {
const session = new AgentSession(this.projectPath, projectSlug, this.config);
this.sessions.set(session.getId(), session);
return session;
}
async runSession(sessionId: string, context: AgentContext): Promise<AgentResult> {
const session = this.sessions.get(sessionId);
if (!session) {
return {
success: false,
output: `Session ${sessionId} not found`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: `Session ${sessionId} not found`,
};
}
return session.run(context);
}
async runAllSessions(
projectSlugs: string[],
contextFactory: (slug: string) => AgentContext,
parallel: boolean = false
): Promise<Record<string, AgentResult>> {
const results: Record<string, AgentResult> = {};
const maxConcurrent = this.config.sessions?.max_concurrent_sessions || 3;
if (parallel && projectSlugs.length > 1) {
const batches: string[][] = [];
const concurrency = Math.min(maxConcurrent, projectSlugs.length);
for (let i = 0; i < projectSlugs.length; i += concurrency) {
batches.push(projectSlugs.slice(i, i + concurrency));
}
for (const batch of batches) {
const batchResults = await Promise.allSettled(
batch.map(async (slug): Promise<[string, AgentResult]> => {
const session = this.createSession(slug);
const context = contextFactory(slug);
const result = await session.run(context);
return [slug, result];
})
);
for (const settled of batchResults) {
if (settled.status === "fulfilled") {
const [slug, result] = settled.value;
results[slug] = result;
} else {
const slug = batch[batchResults.indexOf(settled)];
results[slug] = {
success: false,
output: `Session failed for ${slug}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: settled.reason instanceof Error ? settled.reason.message : String(settled.reason),
};
}
}
}
} else {
for (const slug of projectSlugs) {
const session = this.createSession(slug);
const context = contextFactory(slug);
const result = await session.run(context);
results[slug] = result;
}
}
return results;
}
cancelSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.cancel();
}
pauseSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.pause();
}
resumeSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.resume();
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
}
listSessions(): SessionInfo[] {
return Array.from(this.sessions.values()).map((s) => s.getSessionInfo());
}
listActiveSessions(): SessionInfo[] {
return this.listSessions().filter(
(s) => s.status === "running" || s.status === "paused"
);
}
loadPersistedSessions(): SessionInfo[] {
const ciDir = path.join(this.projectPath, ".ciagent");
if (!fs.existsSync(ciDir)) return [];
const sessions: SessionInfo[] = [];
const dirs = [ciDir];
try {
const config = loadConfig(this.projectPath);
if (config.projects && config.projects.length > 0) {
for (const project of config.projects) {
dirs.push(path.join(ciDir, project.slug));
}
}
} catch {}
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith(".session-") && file.endsWith(".json")) {
const sessionId = file.replace(".session-", "").replace(".json", "");
const slug = dir === ciDir ? "" : path.basename(dir);
const session = AgentSession.loadState(this.projectPath, sessionId, slug || undefined);
if (session) {
sessions.push(session.getSessionInfo());
}
}
}
}
return sessions;
}
cleanupStaleSessions(): number {
const timeout = this.config.sessions?.session_timeout_ms || 3600000;
const now = Date.now();
let cleaned = 0;
for (const [id, session] of this.sessions.entries()) {
const info = session.getSessionInfo();
const age = now - new Date(info.last_updated).getTime();
if ((info.status === "running" || info.status === "paused") && age > timeout) {
session.cancel();
this.sessions.delete(id);
cleaned++;
}
}
return cleaned;
}
}
+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.`;
}
}
}
+9 -1
View File
@@ -9,6 +9,10 @@ export { GitBranch } from "./core/git-branch.js";
export { CommitBuilder } from "./core/commit-builder.js"; export { CommitBuilder } from "./core/commit-builder.js";
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js"; export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js"; export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
export { AgentSession } from "./core/agent-session.js";
export { SessionManager } from "./core/session-manager.js";
export { PersonaLoader } from "./core/persona-loader.js";
export { TaskDecomposer } from "./core/task-decomposer.js";
export { VerificationPipeline } from "./verification/index.js"; export { VerificationPipeline } from "./verification/index.js";
export { StructuralVerification } from "./verification/structural.js"; export { StructuralVerification } from "./verification/structural.js";
export { BehavioralVerification } from "./verification/behavioral.js"; export { BehavioralVerification } from "./verification/behavioral.js";
@@ -24,6 +28,8 @@ export { ESCALATION_TYPES } from "./types/escalation.js";
export { createClarifyQuestion } from "./types/clarify.js"; export { createClarifyQuestion } from "./types/clarify.js";
export { parseSpecification } from "./types/specification.js"; export { parseSpecification } from "./types/specification.js";
export { getNextStage, createInitialPipelineState } from "./types/pipeline.js"; export { getNextStage, createInitialPipelineState } from "./types/pipeline.js";
export { matchFileToPersona, detectConflicts, DEFAULT_PERSONAS } from "./types/persona.js";
export { DEFAULT_SESSION_CONFIG } from "./types/session.js";
export * as fileUtils from "./utils/file.js"; export * as fileUtils from "./utils/file.js";
export { resolveBackend, createBackend } from "./backends/index.js"; export { resolveBackend, createBackend } from "./backends/index.js";
export { OpencodeBackend } from "./backends/opencode.js"; export { OpencodeBackend } from "./backends/opencode.js";
@@ -47,4 +53,6 @@ export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMe
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js"; export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js";
export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js"; export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js";
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js"; export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js"; export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
export type { SessionInfo, SessionStatus, SessionConfig } from "./types/session.js";
export type { ExecutePersonaConfig, TerritoryEnforcement, PersonaDomain, DecomposedTask, DecomposedPlan, TerritoryConflict } from "./types/persona.js";
+1
View File
@@ -55,6 +55,7 @@ export interface CIAgentMetadata {
phase: number; phase: number;
milestone: string; milestone: string;
project?: string; project?: string;
session?: string;
plan?: string; plan?: string;
task?: string; task?: string;
status: PipelineStage; status: PipelineStage;
+33 -2
View File
@@ -1,5 +1,4 @@
import { BackendConfigSection } from "../backends/types.js"; import { TerritoryEnforcement, ExecutePersonaConfig } from "./persona.js";
import { IdeationConfig, IdeationCategory } from "./ideation.js";
export type AutonomyLevel = "full" | "supervised" | "guided"; export type AutonomyLevel = "full" | "supervised" | "guided";
@@ -94,8 +93,25 @@ export interface CIAgentConfig {
backend: BackendConfigSection; backend: BackendConfigSection;
gitea?: GiteaConfig; gitea?: GiteaConfig;
ideation?: IdeationConfig; ideation?: IdeationConfig;
sessions?: SessionConfig;
personas?: PersonaConfigSection;
} }
export interface SessionConfig {
max_concurrent_sessions: number;
session_timeout_ms: number;
session_isolation: "branch";
}
export interface PersonaConfigSection {
enabled: boolean;
territory_enforcement: TerritoryEnforcement;
personas: ExecutePersonaConfig[];
}
import { BackendConfigSection } from "../backends/types.js";
import { IdeationConfig, IdeationCategory } from "./ideation.js";
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = { export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
projects: [], projects: [],
active_project: "", active_project: "",
@@ -190,4 +206,19 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
scenarios: ["backend_unavailable", "requirement_change", "test_coverage_drop"], scenarios: ["backend_unavailable", "requirement_change", "test_coverage_drop"],
}, },
}, },
sessions: {
max_concurrent_sessions: 3,
session_timeout_ms: 3600000,
session_isolation: "branch",
},
personas: {
enabled: true,
territory_enforcement: "warn",
personas: [
{ name: "lead-developer", domain: "coordination", frameworks: [], constraints: ["pragmatic", "battle-tested defaults"], territory: [] },
{ name: "data-engineer", domain: "data", frameworks: ["drizzle", "postgresql"], constraints: ["schema-first", "type-safe ORM", "migration-driven"], territory: ["**/migrations/**", "**/schema/**", "**/models/**", "**/db/**", "prisma/schema.prisma", "drizzle/**", "**/*.sql"] },
{ name: "backend-engineer", domain: "backend", frameworks: ["fastify", "hono"], constraints: ["api-first", "strict-typing", "dependency-injection"], territory: ["**/api/**", "**/routes/**", "**/services/**", "**/middleware/**", "**/controllers/**", "**/auth/**"] },
{ name: "frontend-engineer", domain: "frontend", frameworks: ["react", "next.js"], constraints: ["component-first", "server-components", "minimal-client-js"], territory: ["**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**", "**/*.tsx", "**/*.css", "**/*.vue"] },
],
},
}; };
+168
View File
@@ -0,0 +1,168 @@
export type PersonaDomain = "data" | "backend" | "frontend" | "coordination";
export type TerritoryEnforcement = "warn" | "strict";
export interface ExecutePersonaConfig {
name: string;
domain: PersonaDomain;
frameworks: string[];
constraints: string[];
territory: string[];
}
export interface DecomposedTask {
taskId: string;
persona: string;
domain: PersonaDomain;
description: string;
files: string[];
dependencies: string[];
}
export interface DecomposedPlan {
tasks: DecomposedTask[];
dataTasks: DecomposedTask[];
backendTasks: DecomposedTask[];
frontendTasks: DecomposedTask[];
coordinationTasks: DecomposedTask[];
conflicts: TerritoryConflict[];
}
export interface TerritoryConflict {
type: "data-backend" | "backend-frontend" | "data-frontend";
file: string;
personas: string[];
description: string;
resolution?: string;
}
export const DEFAULT_PERSONAS: ExecutePersonaConfig[] = [
{
name: "lead-developer",
domain: "coordination",
frameworks: [],
constraints: ["pragmatic", "battle-tested defaults"],
territory: [],
},
{
name: "data-engineer",
domain: "data",
frameworks: ["drizzle", "postgresql"],
constraints: ["schema-first", "type-safe ORM", "migration-driven"],
territory: [
"**/migrations/**",
"**/schema/**",
"**/models/**",
"**/db/**",
"prisma/schema.prisma",
"drizzle/**",
"**/*.sql",
],
},
{
name: "backend-engineer",
domain: "backend",
frameworks: ["fastify", "hono"],
constraints: ["api-first", "strict-typing", "dependency-injection"],
territory: [
"**/api/**",
"**/routes/**",
"**/services/**",
"**/middleware/**",
"**/controllers/**",
"**/auth/**",
],
},
{
name: "frontend-engineer",
domain: "frontend",
frameworks: ["react", "next.js"],
constraints: ["component-first", "server-components", "minimal-client-js"],
territory: [
"**/components/**",
"**/pages/**",
"**/hooks/**",
"**/styles/**",
"**/*.tsx",
"**/*.css",
"**/*.vue",
],
},
];
export function matchFileToPersona(
filePath: string,
personas: ExecutePersonaConfig[]
): ExecutePersonaConfig | null {
const normalizedPath = filePath.replace(/\\/g, "/");
for (const persona of personas) {
if (persona.domain === "coordination") continue;
for (const pattern of persona.territory) {
const normalizedPattern = pattern.replace(/\\/g, "/");
if (globMatch(normalizedPattern, normalizedPath)) {
return persona;
}
}
}
return null;
}
export function globMatch(pattern: string, path: string): boolean {
const regexStr = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "§§")
.replace(/\*/g, "[^/]*")
.replace(/§§/g, ".*")
.replace(/\?/g, "[^/]");
const regex = new RegExp(`^${regexStr}$`);
return regex.test(path);
}
export function detectConflicts(
tasks: DecomposedTask[],
personas: ExecutePersonaConfig[]
): TerritoryConflict[] {
const conflicts: TerritoryConflict[] = [];
const filePersonaMap = new Map<string, string[]>();
for (const task of tasks) {
for (const file of task.files) {
if (!filePersonaMap.has(file)) {
filePersonaMap.set(file, []);
}
const personas_list = filePersonaMap.get(file)!;
if (!personas_list.includes(task.persona)) {
personas_list.push(task.persona);
}
}
}
for (const [file, claimingPersonas] of filePersonaMap) {
if (claimingPersonas.length > 1) {
const domains = claimingPersonas
.map((p) => personas.find((pe) => pe.name === p)?.domain)
.filter((d): d is PersonaDomain => d !== undefined);
let conflictType: TerritoryConflict["type"];
if (domains.includes("data") && domains.includes("backend")) {
conflictType = "data-backend";
} else if (domains.includes("backend") && domains.includes("frontend")) {
conflictType = "backend-frontend";
} else {
conflictType = "data-frontend";
}
conflicts.push({
type: conflictType,
file,
personas: claimingPersonas,
description: `File ${file} claimed by multiple personas: ${claimingPersonas.join(", ")}`,
});
}
}
return conflicts;
}
+29
View File
@@ -0,0 +1,29 @@
import { PipelineStage } from "./pipeline.js";
export type SessionStatus = "pending" | "running" | "paused" | "completed" | "failed" | "cancelled";
export type SessionIsolation = "branch";
export interface SessionConfig {
max_concurrent_sessions: number;
session_timeout_ms: number;
session_isolation: SessionIsolation;
}
export interface SessionInfo {
id: string;
project_slug: string;
project_path: string;
phase: number;
stage: PipelineStage;
status: SessionStatus;
started_at: string;
last_updated: string;
error?: string;
}
export const DEFAULT_SESSION_CONFIG: SessionConfig = {
max_concurrent_sessions: 3,
session_timeout_ms: 3600000,
session_isolation: "branch",
};
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "0.10.0"; export const VERSION = "0.11.0";