feat(P01-P05): multi-session support & execute-phase persona specialization — SESSION-01..05, PERSONA-01..11, CLI-01..04, INTEG-01..05
---ci---
phase: 1-5
milestone: v0.11
project: ci
status: execute
decisions:
- id: D-092
decision: Independent sessions via AgentSession (not shared state)
rationale: Aligns with git-native model; sessions communicate through commits and .ciagent/ files
confidence: 0.90
- id: D-093
decision: Personas as runtime configs (not new Agent classes)
rationale: Less code, more flexible. Persona md files define domain knowledge and framework opinions.
confidence: 0.88
- id: D-094
decision: Lead developer as task decomposer (not separate pipeline stage)
rationale: EXECUTE stays one stage. Lead decomposes before execution, each persona group runs.
confidence: 0.85
- id: D-095
decision: File-based git locking (not DB or IPC)
rationale: Git-native. .session-lock files are simple JSON with session ID, timestamp, project slug.
confidence: 0.87
- id: D-096
decision: Territory enforcement with warn/strict modes
rationale: Warn for teams learning boundaries. Strict for mature projects. Configurable per-project.
confidence: 0.82
- id: D-097
decision: Task decomposition by file patterns + requirement IDs
rationale: File patterns are deterministic; no LLM needed. Requirement IDs in PLAN.md already map to domains.
confidence: 0.88
requirements:
covered: [SESSION-01, SESSION-02, SESSION-03, SESSION-04, SESSION-05, PERSONA-01, PERSONA-02, PERSONA-03, PERSONA-04, PERSONA-05, PERSONA-06, PERSONA-07, PERSONA-08, PERSONA-09, PERSONA-10, PERSONA-11, CLI-01, CLI-02, CLI-03, CLI-04, INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05]
---/ci---
This commit is contained in:
@@ -30,6 +30,8 @@ src/
|
||||
core/ # Core engine components
|
||||
artifacts.ts # Legacy .ciagent/ artifact management (retained for backward compat)
|
||||
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.)
|
||||
clarify.ts # Clarify phase: question generation, default acceptance
|
||||
commit-builder.ts # Structured commit message generation (---ci--- YAML blocks)
|
||||
@@ -40,14 +42,18 @@ src/
|
||||
escalation.ts # Escalation protocol: commits escalations as git artifacts
|
||||
git-branch.ts # Branch lifecycle: phase/NN-slug, milestone/vX.X-slug
|
||||
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
|
||||
commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit
|
||||
config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, DEFAULT_CIAGENT_CONFIG (includes backend)
|
||||
commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit (includes session field)
|
||||
config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, SessionConfig, PersonaConfigSection, DEFAULT_CIAGENT_CONFIG (includes backend)
|
||||
decisions.ts # Decision, ConfidenceLevel, DecisionCategory
|
||||
escalation.ts # Escalation, EscalationType, EscalationResolution
|
||||
clarify.ts # ClarifyQuestion, ClarifyResult
|
||||
specification.ts # Specification parser (objective, requirements, constraints, out_of_scope)
|
||||
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)
|
||||
verification/ # 4-layer verification pipeline
|
||||
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.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
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
+190
-4
@@ -2,6 +2,11 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
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 {
|
||||
success: boolean;
|
||||
@@ -17,6 +22,17 @@ interface MustHaveItem {
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
interface PersonaTaskGroup {
|
||||
persona: string;
|
||||
domain: string;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
files: string[];
|
||||
}>;
|
||||
conflicts: TerritoryConflict[];
|
||||
}
|
||||
|
||||
export class ExecutorAgent extends BaseAgent {
|
||||
readonly name = "executor";
|
||||
readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints.";
|
||||
@@ -27,6 +43,14 @@ export class ExecutorAgent extends BaseAgent {
|
||||
this.log("Executing tasks...");
|
||||
|
||||
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 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> {
|
||||
const parts: string[] = [
|
||||
`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 roadmapPath = path.join(ciDir, "ROADMAP.md");
|
||||
const archPath = path.join(ciDir, "ARCHITECTURE.md");
|
||||
const roadmapPath = context.project_slug
|
||||
? 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)) {
|
||||
try {
|
||||
@@ -91,11 +269,17 @@ export class ExecutorAgent extends BaseAgent {
|
||||
}
|
||||
|
||||
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 {
|
||||
if (fs.existsSync(planPath)) {
|
||||
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 {}
|
||||
return null;
|
||||
}
|
||||
@@ -139,7 +323,9 @@ export class ExecutorAgent extends BaseAgent {
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { loadConfig, saveConfig, isCIAgentInitialized, initCIAgent } from "../co
|
||||
import { getAgent } from "./index.js";
|
||||
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
|
||||
import { registerEscalationProtocol } from "../cli/index.js";
|
||||
import { SessionManager } from "../core/session-manager.js";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
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(", ")}`);
|
||||
|
||||
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 maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3;
|
||||
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
|
||||
|
||||
@@ -18,6 +18,8 @@ import { BackendUnavailableError } from "../backends/types.js";
|
||||
import { getAgent } from "../agents/index.js";
|
||||
import { CIAgentFiles } from "../core/ciagent-files.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 path from "node:path";
|
||||
import * as readline from "node:readline";
|
||||
@@ -172,6 +174,7 @@ export function createRunCommand(): Command {
|
||||
.option("--backend <provider>", "Override intelligence backend for this run")
|
||||
.option("--ideate", "Insert ideation stage between research and plan")
|
||||
.option("--project <slug>", "Target project slug (comma-separated or 'all')")
|
||||
.option("--session <id>", "Resume a specific session by ID")
|
||||
.action(async (phase, options) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
@@ -1373,3 +1376,144 @@ export function createIdeateCommand(): Command {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
@@ -18,6 +18,7 @@ import {
|
||||
createShipCommand,
|
||||
createProjectsCommand,
|
||||
createIdeateCommand,
|
||||
createSessionsCommand,
|
||||
} from "./commands.js";
|
||||
|
||||
let activeEscalationProtocol: { dispose(): void } | null = null;
|
||||
@@ -68,6 +69,7 @@ program
|
||||
.addCommand(createRollbackCommand())
|
||||
.addCommand(createShipCommand())
|
||||
.addCommand(createProjectsCommand())
|
||||
.addCommand(createIdeateCommand());
|
||||
.addCommand(createIdeateCommand())
|
||||
.addCommand(createSessionsCommand());
|
||||
|
||||
program.parse();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ export class CommitBuilder {
|
||||
lines.push(`milestone: ${ci.milestone}`);
|
||||
|
||||
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.task) lines.push(`task: ${ci.task}`);
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ export function parseCIAgentBlock(yaml: string): CIAgentMetadata | null {
|
||||
const projectMatch = yaml.match(/^project:\s*(.+)$/m);
|
||||
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.escalations = parseEscalationsFromYaml(yaml);
|
||||
result.requirements = parseRequirementsFromYaml(yaml);
|
||||
|
||||
+4
-1
@@ -9,6 +9,9 @@ export { GitBranch } from "./git-branch.js";
|
||||
export { CommitBuilder } from "./commit-builder.js";
|
||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.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 { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||
@@ -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(" ");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,6 +9,10 @@ export { GitBranch } from "./core/git-branch.js";
|
||||
export { CommitBuilder } from "./core/commit-builder.js";
|
||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.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 { StructuralVerification } from "./verification/structural.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 { parseSpecification } from "./types/specification.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 { resolveBackend, createBackend } from "./backends/index.js";
|
||||
export { OpencodeBackend } from "./backends/opencode.js";
|
||||
@@ -48,3 +54,5 @@ export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./cor
|
||||
export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.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 { SessionInfo, SessionStatus, SessionConfig } from "./types/session.js";
|
||||
export type { ExecutePersonaConfig, TerritoryEnforcement, PersonaDomain, DecomposedTask, DecomposedPlan, TerritoryConflict } from "./types/persona.js";
|
||||
@@ -55,6 +55,7 @@ export interface CIAgentMetadata {
|
||||
phase: number;
|
||||
milestone: string;
|
||||
project?: string;
|
||||
session?: string;
|
||||
plan?: string;
|
||||
task?: string;
|
||||
status: PipelineStage;
|
||||
|
||||
+33
-2
@@ -1,5 +1,4 @@
|
||||
import { BackendConfigSection } from "../backends/types.js";
|
||||
import { IdeationConfig, IdeationCategory } from "./ideation.js";
|
||||
import { TerritoryEnforcement, ExecutePersonaConfig } from "./persona.js";
|
||||
|
||||
export type AutonomyLevel = "full" | "supervised" | "guided";
|
||||
|
||||
@@ -94,8 +93,25 @@ export interface CIAgentConfig {
|
||||
backend: BackendConfigSection;
|
||||
gitea?: GiteaConfig;
|
||||
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 = {
|
||||
projects: [],
|
||||
active_project: "",
|
||||
@@ -190,4 +206,19 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
||||
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"] },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -1 +1 @@
|
||||
export const VERSION = "0.10.0";
|
||||
export const VERSION = "0.11.0";
|
||||
Reference in New Issue
Block a user