From 8c975352b8f84cfa31790605fafc018e687ca162 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Mon, 1 Jun 2026 17:43:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(P01-P05):=20multi-session=20support=20&=20?= =?UTF-8?q?execute-phase=20persona=20specialization=20=E2=80=94=20SESSION-?= =?UTF-8?q?01..05,=20PERSONA-01..11,=20CLI-01..04,=20INTEG-01..05?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---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--- --- AGENTS.md | 13 +- src/agents/executor.ts | 194 ++++++++++++- src/agents/orchestrator.ts | 31 ++ src/cli/commands.ts | 144 ++++++++++ src/cli/index.ts | 4 +- src/core/agent-session.ts | 284 ++++++++++++++++++ src/core/commit-builder.ts | 1 + src/core/commit-parser.ts | 3 + src/core/index.ts | 5 +- src/core/persona-loader.ts | 227 +++++++++++++++ src/core/persona-system.test.ts | 475 +++++++++++++++++++++++++++++++ src/core/session-manager.test.ts | 327 +++++++++++++++++++++ src/core/session-manager.ts | 183 ++++++++++++ src/core/task-decomposer.ts | 275 ++++++++++++++++++ src/index.ts | 10 +- src/types/commit-meta.ts | 1 + src/types/config.ts | 35 ++- src/types/persona.ts | 168 +++++++++++ src/types/session.ts | 29 ++ src/version.ts | 2 +- 20 files changed, 2398 insertions(+), 13 deletions(-) create mode 100644 src/core/agent-session.ts create mode 100644 src/core/persona-loader.ts create mode 100644 src/core/persona-system.test.ts create mode 100644 src/core/session-manager.test.ts create mode 100644 src/core/session-manager.ts create mode 100644 src/core/task-decomposer.ts create mode 100644 src/types/persona.ts create mode 100644 src/types/session.ts diff --git a/AGENTS.md b/AGENTS.md index f1b0c54..26cfe8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: ` 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 ` 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 diff --git a/src/agents/executor.ts b/src/agents/executor.ts index c45de79..4bb3f14 100644 --- a/src/agents/executor.ts +++ b/src/agents/executor.ts @@ -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 { + 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(); + + 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, + 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 { 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 { diff --git a/src/agents/orchestrator.ts b/src/agents/orchestrator.ts index fb33573..e87fddb 100644 --- a/src/agents/orchestrator.ts +++ b/src/agents/orchestrator.ts @@ -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> { + 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> { const results: Record = {}; const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3; const parallel = config.parallelization?.enabled && activeProjects.length > 1; diff --git a/src/cli/commands.ts b/src/cli/commands.ts index ea03ed8..28d5a7b 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -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 ", "Override intelligence backend for this run") .option("--ideate", "Insert ideation stage between research and plan") .option("--project ", "Target project slug (comma-separated or 'all')") + .option("--session ", "Resume a specific session by ID") .action(async (phase, options) => { const projectPath = process.cwd(); @@ -1372,4 +1375,145 @@ export function createIdeateCommand(): Command { console.log(` ${cat}: ${count}`); } }); +} + +export function createSessionsCommand(): Command { + return new Command("sessions") + .description("Manage CIAgent agent sessions") + .addCommand( + new Command("list") + .description("List all sessions") + .option("--project ", "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") + .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") + .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): 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}`); + } } \ No newline at end of file diff --git a/src/cli/index.ts b/src/cli/index.ts index 54f141c..1e96b69 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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(); \ No newline at end of file diff --git a/src/core/agent-session.ts b/src/core/agent-session.ts new file mode 100644 index 0000000..3fec054 --- /dev/null +++ b/src/core/agent-session.ts @@ -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 { + 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; + } + } +} \ No newline at end of file diff --git a/src/core/commit-builder.ts b/src/core/commit-builder.ts index de0584c..8edc299 100644 --- a/src/core/commit-builder.ts +++ b/src/core/commit-builder.ts @@ -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}`); diff --git a/src/core/commit-parser.ts b/src/core/commit-parser.ts index 9975d10..f60e716 100644 --- a/src/core/commit-parser.ts +++ b/src/core/commit-parser.ts @@ -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); diff --git a/src/core/index.ts b/src/core/index.ts index 8231f3f..2657249 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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"; \ No newline at end of file diff --git a/src/core/persona-loader.ts b/src/core/persona-loader.ts new file mode 100644 index 0000000..1483f4c --- /dev/null +++ b/src/core/persona-loader.ts @@ -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 = 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 { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + + const yaml = match[1]; + const result: Record = {}; + + 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(" "); + } +} \ No newline at end of file diff --git a/src/core/persona-system.test.ts b/src/core/persona-system.test.ts new file mode 100644 index 0000000..eb2d983 --- /dev/null +++ b/src/core/persona-system.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/src/core/session-manager.test.ts b/src/core/session-manager.test.ts new file mode 100644 index 0000000..1b94cd2 --- /dev/null +++ b/src/core/session-manager.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts new file mode 100644 index 0000000..d630437 --- /dev/null +++ b/src/core/session-manager.ts @@ -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 = 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 { + 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> { + const results: Record = {}; + 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; + } +} \ No newline at end of file diff --git a/src/core/task-decomposer.ts b/src/core/task-decomposer.ts new file mode 100644 index 0000000..2da8cc3 --- /dev/null +++ b/src/core/task-decomposer.ts @@ -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 = { + 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 = { + 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.`; + } + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 2b9f785..9f24f4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -47,4 +53,6 @@ export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMe export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js"; 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"; \ No newline at end of file +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"; \ No newline at end of file diff --git a/src/types/commit-meta.ts b/src/types/commit-meta.ts index 3a9db96..8a509f0 100644 --- a/src/types/commit-meta.ts +++ b/src/types/commit-meta.ts @@ -55,6 +55,7 @@ export interface CIAgentMetadata { phase: number; milestone: string; project?: string; + session?: string; plan?: string; task?: string; status: PipelineStage; diff --git a/src/types/config.ts b/src/types/config.ts index 96bb66c..d7b9c3e 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -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"] }, + ], + }, }; \ No newline at end of file diff --git a/src/types/persona.ts b/src/types/persona.ts new file mode 100644 index 0000000..22b1d59 --- /dev/null +++ b/src/types/persona.ts @@ -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(); + + 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; +} \ No newline at end of file diff --git a/src/types/session.ts b/src/types/session.ts new file mode 100644 index 0000000..6a3360a --- /dev/null +++ b/src/types/session.ts @@ -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", +}; \ No newline at end of file diff --git a/src/version.ts b/src/version.ts index 98e7400..267d4aa 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.10.0"; \ No newline at end of file +export const VERSION = "0.11.0"; \ No newline at end of file