Compare commits

...

2 Commits

Author SHA1 Message Date
Jon Chery 9ab3b56b96 docs(P08): run workflow pipeline restructure — multi-project paths, sub-workflow delegation, ship gate, milestone completion gate
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
---ci---
project: ci
phase: 8
milestone: v0.11
status: complete
requirements:
  covered: [PIPELINE-01, PIPELINE-02, PIPELINE-03, PIPELINE-04, PIPELINE-05, PIPELINE-06, PIPELINE-07]
decisions:
  - id: D-098
    decision: Run pipeline stages delegate to sub-workflows instead of reimplementing inline
    rationale: 'Clarify, ideate, verify are fully defined workflows — duplicating them in run.md causes drift and missing details'
    confidence: 0.95
  - id: D-099
    decision: 'EXECUTE includes ship gate — phase must be shipped via ciagent-ship before advancing'
    rationale: 'Prevents advancing to VERIFY/next phase on unshipped code; ship.md has proper validation gates'
    confidence: 0.93
  - id: D-100
    decision: 'COMPLETE orchestrates review → ship(milestone) → audit with feedback loop'
    rationale: 'Replaces inline merge+tag+release with proper sub-workflow delegation; audit catches stale docs and branch hygiene issues'
    confidence: 0.92
  - id: D-101
    decision: 'Multi-project paths use .ciagent/<slug>/ subdirectories throughout'
    rationale: 'Consistent with ci-files-discipline reference and existing multi-project convention in other workflows'
    confidence: 0.97
  - id: D-102
    decision: 'Multi-persona execution integrated into EXECUTE stage'
    rationale: 'config.json personas section already defines territories; lead-developer decomposition and parallel review personas belong in execution'
    confidence: 0.90
---/ci---
2026-06-01 18:27:35 +00:00
Jon Chery 8c975352b8 feat(P01-P05): multi-session support & execute-phase persona specialization — SESSION-01..05, PERSONA-01..11, CLI-01..04, INTEG-01..05
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
---ci---
phase: 1-5
milestone: v0.11
project: ci
status: execute
decisions:
  - id: D-092
    decision: Independent sessions via AgentSession (not shared state)
    rationale: Aligns with git-native model; sessions communicate through commits and .ciagent/ files
    confidence: 0.90
  - id: D-093
    decision: Personas as runtime configs (not new Agent classes)
    rationale: Less code, more flexible. Persona md files define domain knowledge and framework opinions.
    confidence: 0.88
  - id: D-094
    decision: Lead developer as task decomposer (not separate pipeline stage)
    rationale: EXECUTE stays one stage. Lead decomposes before execution, each persona group runs.
    confidence: 0.85
  - id: D-095
    decision: File-based git locking (not DB or IPC)
    rationale: Git-native. .session-lock files are simple JSON with session ID, timestamp, project slug.
    confidence: 0.87
  - id: D-096
    decision: Territory enforcement with warn/strict modes
    rationale: Warn for teams learning boundaries. Strict for mature projects. Configurable per-project.
    confidence: 0.82
  - id: D-097
    decision: Task decomposition by file patterns + requirement IDs
    rationale: File patterns are deterministic; no LLM needed. Requirement IDs in PLAN.md already map to domains.
    confidence: 0.88
requirements:
  covered: [SESSION-01, SESSION-02, SESSION-03, SESSION-04, SESSION-05, PERSONA-01, PERSONA-02, PERSONA-03, PERSONA-04, PERSONA-05, PERSONA-06, PERSONA-07, PERSONA-08, PERSONA-09, PERSONA-10, PERSONA-11, CLI-01, CLI-02, CLI-03, CLI-04, INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05]
---/ci---
2026-06-01 17:43:06 +00:00
21 changed files with 2546 additions and 46 deletions
+10 -3
View File
@@ -30,6 +30,8 @@ src/
core/ # Core engine components core/ # Core engine components
artifacts.ts # Legacy .ciagent/ artifact management (retained for backward compat) artifacts.ts # Legacy .ciagent/ artifact management (retained for backward compat)
audit.ts # Git-native audit trail — reads decisions/escalations from git log audit.ts # Git-native audit trail — reads decisions/escalations from git log
agent-session.ts # Multi-session support: AgentSession, file-based git locking
session-manager.ts # SessionManager: concurrent session lifecycle management
ciagent-files.ts # .ciagent/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.) ciagent-files.ts # .ciagent/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.)
clarify.ts # Clarify phase: question generation, default acceptance clarify.ts # Clarify phase: question generation, default acceptance
commit-builder.ts # Structured commit message generation (---ci--- YAML blocks) commit-builder.ts # Structured commit message generation (---ci--- YAML blocks)
@@ -40,14 +42,18 @@ src/
escalation.ts # Escalation protocol: commits escalations as git artifacts escalation.ts # Escalation protocol: commits escalations as git artifacts
git-branch.ts # Branch lifecycle: phase/NN-slug, milestone/vX.X-slug git-branch.ts # Branch lifecycle: phase/NN-slug, milestone/vX.X-slug
git-context.ts # Project state reconstruction from git log + branches git-context.ts # Project state reconstruction from git log + branches
persona-loader.ts # Execute-time persona resolution from .config/opencode/agents/*.md
task-decomposer.ts # Plan decomposition into data/backend/frontend task groups
types/ # Type definitions types/ # Type definitions
commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit (includes session field)
config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, DEFAULT_CIAGENT_CONFIG (includes backend) config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, SessionConfig, PersonaConfigSection, DEFAULT_CIAGENT_CONFIG (includes backend)
decisions.ts # Decision, ConfidenceLevel, DecisionCategory decisions.ts # Decision, ConfidenceLevel, DecisionCategory
escalation.ts # Escalation, EscalationType, EscalationResolution escalation.ts # Escalation, EscalationType, EscalationResolution
clarify.ts # ClarifyQuestion, ClarifyResult clarify.ts # ClarifyQuestion, ClarifyResult
specification.ts # Specification parser (objective, requirements, constraints, out_of_scope) specification.ts # Specification parser (objective, requirements, constraints, out_of_scope)
pipeline.ts # PipelineStage, PipelineState, PhaseResult, STAGE_ORDER pipeline.ts # PipelineStage, PipelineState, PhaseResult, STAGE_ORDER
persona.ts # ExecutePersonaConfig, PersonaDomain, TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS
session.ts # SessionInfo, SessionStatus, SessionConfig, DEFAULT_SESSION_CONFIG
utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON) utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON)
verification/ # 4-layer verification pipeline verification/ # 4-layer verification pipeline
structural.ts # Layer 1: file existence, imports wired, no stubs structural.ts # Layer 1: file existence, imports wired, no stubs
@@ -197,7 +203,8 @@ IntelligenceBackend (unified interface)
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests - **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
- **v0.9.0**: Integration & hardening — OpenAI and Anthropic backends, all 19 agents with intrinsic mechanical logic, E2E v0.9 integration tests, parallel agent execution - **v0.9.0**: Integration & hardening — OpenAI and Anthropic backends, all 19 agents with intrinsic mechanical logic, E2E v0.9 integration tests, parallel agent execution
- **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows - **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows
- **New in v0.10**: IdeationEngine with mechanical/backend-enriched/cross-project tiers, `ciagent ideate` command with --category/--affected/--spec/--external/--cross-project/--project/--output flags, `IDEATE` pipeline stage between RESEARCH and PLAN, multi-project support with `active_projects` config and `--project all` flag, `---ci--- project: <slug>` commit blocks, `max_concurrent_projects` parallelization config - **New in v0.11**: Multi-session support with `SessionManager` and `AgentSession` for independent project pipelines running concurrently, execute-phase persona specialization (`lead-developer`, `data-engineer`, `backend-engineer`, `frontend-engineer`) with territory enforcement and task decomposition, `ciagent sessions` CLI command with list/status/cancel/cleanup subcommands, `--session <id>` flag on `ciagent run`, `---ci--- session:` commit metadata field, `sessions` and `personas` config sections
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
- **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation) - **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation)
- **Config expansion (v0.10)**: `ideation` section in config with categories, thresholds, external signals, cross-project, chaos; `active_projects` array; `max_concurrent_projects` in parallelization - **Config expansion (v0.10)**: `ideation` section in config with categories, thresholds, external signals, cross-project, chaos; `active_projects` array; `max_concurrent_projects` in parallelization
- **Auto-detection order**: opencode → openai → ollama-local → ollama-cloud → anthropic - **Auto-detection order**: opencode → openai → ollama-local → ollama-cloud → anthropic
+148 -33
View File
@@ -1,16 +1,16 @@
--- ---
description: Execute the full CIAgent pipeline — research → plan → execute → verify → complete for the current or specified phase description: Execute the full CIAgent pipeline — specify → clarify → research → ideate → plan → execute → ship → verify → complete for the current or specified phase
--- ---
# CIAgent Run # CIAgent Run
Execute the full CIAgent pipeline from the current stage to completion. The orchestrator iterates through stages and delegates to specialized agents. Execute the full CIAgent pipeline from the current stage to completion. The orchestrator iterates through stages and delegates to specialized agents and sub-workflows.
**Usage:** `ciagent-run [phase_number]` **Usage:** `ciagent-run [phase_number]`
If no phase number specified, continues from the current phase (detected from git log). If no phase number specified, continues from the current phase (detected from git log).
## Step 0: Confirm Active Project ## Step 0: Confirm Active Project and Session
Check `ci listProjects()` or read `.ciagent/config.json` to determine if multi-project mode is active. Check `ci listProjects()` or read `.ciagent/config.json` to determine if multi-project mode is active.
@@ -20,13 +20,21 @@ If `.ciagent/config.json` has `projects[]` with length > 0, or `active_projects`
- If `--project <slug>` is specified: run for that project only - If `--project <slug>` is specified: run for that project only
- If no `--project` flag: use first project in `active_projects` - If no `--project` flag: use first project in `active_projects`
- All commit messages must include `project: <slug>` in `---ci---` block - All commit messages must include `project: <slug>` in `---ci---` block
- All `.ciagent/` file reads use `.ciagent/<slug>/` subdirectory paths
- Branch names are prefixed with `<slug>/` (e.g., `<slug>/phase/01-auth`, `<slug>/milestone/v0.2-auth`)
For multi-project execution (`--project all`): For multi-project execution (`--project all`):
- Execute pipeline for each project sequentially by default - Execute pipeline for each project sequentially by default
- When `parallelization.enabled=true`: execute projects concurrently up to `max_concurrent_agents` - When `parallelization.enabled=true`: execute projects concurrently up to `max_concurrent_agents`
- Each project has independent phase branches and milestone tracking - Each project has independent phase branches and milestone tracking
- Sessions (if configured): each project gets its own `AgentSession` with branch isolation per `config.json sessions.session_isolation`
If single-project mode: proceed with existing conventions. For multi-persona execution (when `config.json personas.enabled=true`):
- Lead-developer persona decomposes tasks by territory file patterns and requirement IDs
- Each persona group executes tasks within their territory
- Territory enforcement runs in `warn` or `strict` mode per `config.json personas.territory_enforcement`
If single-project mode: proceed with existing conventions (flat `.ciagent/` paths, no project prefix on branches).
## Step 1: Load Git Context ## Step 1: Load Git Context
@@ -40,66 +48,173 @@ Determine current state:
- Current milestone from latest `---ci---` block or active milestone branch - Current milestone from latest `---ci---` block or active milestone branch
- Current pipeline stage from latest `---ci---` status field - Current pipeline stage from latest `---ci---` status field
- Completed phases from merged `phase/NN-*` branches - Completed phases from merged `phase/NN-*` branches
- Active project from `---ci---` project field (multi-project mode)
## Step 2: Pre-Flight Check ## Step 2: Pre-Flight Check
Verify `.ciagent/config.json` exists. If missing: stop, run `ciagent-init` first. Verify `.ciagent/config.json` exists. If missing: stop, run `ciagent-init` first.
Read `.ciagent/PROJECT.md` and `.ciagent/ROADMAP.md` for phase goals. Resolve project paths based on mode:
- **Multi-project**: read `.ciagent/<slug>/PROJECT.md` and `.ciagent/<slug>/ROADMAP.md` for the active project
- **Single-project**: read `.ciagent/PROJECT.md` and `.ciagent/ROADMAP.md`
Read phase goals and milestone context from the resolved files.
## Step 3: Execute Pipeline Stages ## Step 3: Execute Pipeline Stages
For each stage in order (starting from current or from `specify`): For each stage in order (starting from current or from `specify`):
### SPECIFY ### SPECIFY
- Parse specification from `.ciagent/PROJECT.md`
- Validate requirements exist in `.ciagent/REQUIREMENTS.md` - Resolve active project from `config.json`
- Parse specification from `.ciagent/<slug>/PROJECT.md` (multi-project) or `.ciagent/PROJECT.md` (single-project)
- Validate requirements exist in `.ciagent/<slug>/REQUIREMENTS.md` (multi-project) or `.ciagent/REQUIREMENTS.md` (single-project)
- Commit: `docs(init): validate specification` - Commit: `docs(init): validate specification`
```
---ci---
project: <slug>
phase: 0
milestone: v0.X
status: specify
---/ci---
```
### CLARIFY ### CLARIFY
- Generate clarify questions for ambiguities
- Default-accept at `full` autonomy, present at `supervised`/`guided` **Delegate to `ciagent-clarify` workflow.** Do not reimplement inline.
- Commit: `decision(P##): clarification decisions`
The clarify workflow handles:
- Multi-project active project confirmation
- Git context loading
- Ambiguity identification and question generation
- Autonomy-based resolution (full/supervised/guided)
- Clarification commits with `---ci---` blocks
- `.ciagent/<slug>/PROJECT.md` and `.ciagent/<slug>/REQUIREMENTS.md` updates
Pass the current phase number and active project slug. Collect the result and proceed.
### RESEARCH ### RESEARCH
- Resolve active project from `config.json`; use `.ciagent/<slug>/` paths
- Delegate to ci-researcher - Delegate to ci-researcher
- Research domain, ecosystem, prior art - Research domain, ecosystem, prior art
- Update `.ciagent/` static files with conclusions - Update `.ciagent/<slug>/` static files with conclusions (ARCHITECTURE.md, PROJECT.md, etc.)
- Commit: `docs(P##): research findings` - Commit: `docs(P##): research findings`
```
---ci---
project: <slug>
phase: [N]
milestone: [vX.X]
status: research
---/ci---
```
### IDEATE (when --ideate flag is passed) ### IDEATE (when --ideate flag is passed)
- Delegate to ci-ideation-agent
- Mine git history for patterns, analyze coverage gaps, detect drift **Delegate to `ciagent-ideate` workflow.** Do not reimplement inline.
- If backend available: enrich with LLM suggestions
- If --cross-project: mine patterns from other projects The ideate workflow handles:
- Present recommendations interactively (accept/skip/modify) - Multi-project context and `--project` flags
- Accepted ideas update ROADMAP.md and REQUIREMENTS.md - All three tiers (mechanical, backend-enriched, cross-project)
- Commit: `decision(P##): ideation results — [N] accepted, [M] skipped` - Interactive validation (accept/skip/modify)
- Updates to `.ciagent/<slug>/REQUIREMENTS.md`, `.ciagent/<slug>/ROADMAP.md`, `.ciagent/<slug>/ARCHITECTURE.md`, `.ciagent/<slug>/PROJECT.md`
- Ideation commit with `---ci---` block
Pass the active project slug and any `--ideate` flags. Collect accepted ideas and proceed.
### PLAN ### PLAN
- Delegate to ci-planner
- Resolve active project from `config.json`; use `.ciagent/<slug>/` paths
- Delegate to ci-planner with full project context
- Create vertical-slice plans with wave ordering - Create vertical-slice plans with wave ordering
- Plans reference requirement IDs from `.ciagent/<slug>/REQUIREMENTS.md`
- Commit: `docs(P##): create [N] phase plans` - Commit: `docs(P##): create [N] phase plans`
```
---ci---
project: <slug>
phase: [N]
milestone: [vX.X]
status: plan
---/ci---
```
### EXECUTE ### EXECUTE
- Create phase branch: `phase/NN-slug`
- Create phase branch: `<slug>/phase/NN-slug` (multi-project) or `phase/NN-slug` (single-project)
- Delegate to ci-executor per plan per wave - Delegate to ci-executor per plan per wave
- **Multi-persona development**: if `config.json personas.enabled=true`:
- Lead-developer decomposes tasks by territory file patterns and requirement IDs
- Each persona executes tasks within their declared territory (config.json `personas[].territory`)
- Territory enforcement runs in configured mode (`warn` or `strict`)
- Primary persona (i=0) executes sequentially; review personas (i>0) execute in parallel
- Persona constraints (frameworks, constraints arrays) guide implementation choices
- Commit each task with `---ci---` block - Commit each task with `---ci---` block
- After all waves: commit phase completion - After all waves complete: **ship the phase** by delegating to `ciagent-ship` workflow
**Ship gate**: a phase MUST be shipped before advancing to the next phase. The ship workflow handles:
- Pre-flight validation (milestone type, branch hierarchy, tag sequence, autonomy)
- Test execution (test, typecheck, build)
- PR creation and auto-merge
- Version computation and tagging
- Branch merging (phase → milestone or phase → main)
- Gitea release creation
If the ship fails: do NOT advance to VERIFY. Iterate until the phase ships successfully.
### VERIFY ### VERIFY
- Delegate to ci-verifier
- Check must_haves, requirement coverage, integration links
- Auto-generate tests for unverifiable items
- Commit: `verify(P##): verification result`
### COMPLETE **Delegate to `ciagent-verify` workflow.** Do not reimplement inline.
- Merge phase branch into main (squash)
- Tag with patch version (e.g., `v0.2.3` — 3rd phase in milestone v0.2) The verify workflow handles:
- Create Gitea release for the tag - Multi-project scoping and active project confirmation
- Update `.ciagent/REQUIREMENTS.md` requirement statuses - Four verification layers (structural, behavioral, security, quality)
- Update `.ciagent/ROADMAP.md` phase status - Auto-generated tests for unverifiable items
- Commit: `docs(P##): complete [phase-name] phase` - Verification commit with `---ci---` block
Pass the current phase number and active project slug. Collect the verification result and proceed.
### COMPLETE (milestone completion gate)
The COMPLETE stage is reached only after ALL phases in the milestone have been shipped and verified. It orchestrates milestone-level finalization through three sub-workflows with a feedback loop:
1. **Trigger `ciagent-review`** — multi-persona code review across all phases in the milestone
- Reviews all changes in the milestone branch
- Auto-applies P0 fixes, flags P1+ for post-hoc review
- If P1+ issues found: send them back to the EXECUTE stage for remediation
2. **Trigger `ciagent-ship` (milestone)** — ship the entire milestone
- Merge milestone branch into main
- Tag with milestone version (minor for feature, major for major milestone)
- Create Gitea release for the milestone with full phase summary
- Build and upload distribution packages
3. **Trigger `ciagent-audit`** — verify project health
- Reconstruction test: verify git log matches `.ciagent/` files
- Check `.ciagent/` file discipline and branch hygiene
- Check commit discipline
- If audit finds issues: document them, send critical issues back to EXECUTE
4. **Feedback loop**: if review or audit produces pending issues that require code changes, loop back to EXECUTE → SHIP → VERIFY for those fixes before re-attempting COMPLETE.
5. **If no pending issues from review/audit and audit is clean**: complete the milestone:
- Update `.ciagent/<slug>/REQUIREMENTS.md` — mark all milestone requirements as complete
- Update `.ciagent/<slug>/ROADMAP.md` — mark milestone as complete
- Commit: `docs(milestone): complete [milestone-name]`
```
---ci---
project: <slug>
phase: 0
milestone: [vX.Y]
status: complete
requirements:
covered: [REQ-01, REQ-02, ...]
partial: []
---/ci---
```
Versioning: Major milestone = breaking schema changes, Feature milestone = milestone completion (minor), Patch = every phase. Versioning: Major milestone = breaking schema changes, Feature milestone = milestone completion (minor), Patch = every phase.
@@ -108,7 +223,7 @@ Versioning: Major milestone = breaking schema changes, Feature milestone = miles
Between phases, perform a context reset: Between phases, perform a context reset:
1. Commit all work from the current phase 1. Commit all work from the current phase
2. Update `.ciagent/` files (phase status, requirement statuses) 2. Update `.ciagent/<slug>/` files (phase status, requirement statuses)
3. Verify `GitContext.reconstructState()` matches expected state 3. Verify `GitContext.reconstructState()` matches expected state
4. Reset context: spawn fresh agent (opencode) or re-read git context (platforms without subagents) 4. Reset context: spawn fresh agent (opencode) or re-read git context (platforms without subagents)
5. Next phase begins with fresh context from git log only 5. Next phase begins with fresh context from git log only
+190 -4
View File
@@ -2,6 +2,11 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { TaskDecomposer } from "../core/task-decomposer.js";
import { PersonaLoader } from "../core/persona-loader.js";
import { TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS } from "../types/persona.js";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { loadConfig } from "../core/config.js";
export interface ExecutorResult { export interface ExecutorResult {
success: boolean; success: boolean;
@@ -17,6 +22,17 @@ interface MustHaveItem {
passed: boolean; passed: boolean;
} }
interface PersonaTaskGroup {
persona: string;
domain: string;
tasks: Array<{
id: string;
description: string;
files: string[];
}>;
conflicts: TerritoryConflict[];
}
export class ExecutorAgent extends BaseAgent { export class ExecutorAgent extends BaseAgent {
readonly name = "executor"; readonly name = "executor";
readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints."; readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints.";
@@ -27,6 +43,14 @@ export class ExecutorAgent extends BaseAgent {
this.log("Executing tasks..."); this.log("Executing tasks...");
if (context.backend) { if (context.backend) {
const config = this.loadProjectConfig(context);
const personasEnabled = config.personas?.enabled !== false;
if (personasEnabled) {
this.log("Persona-based execution enabled — decomposing plan and assigning to personas");
return this.executeWithPersonas(context, config);
}
const taskPrompt = await this.buildBackendTaskPrompt(context); const taskPrompt = await this.buildBackendTaskPrompt(context);
const backendResult = await this.executeViaBackend(context, taskPrompt); const backendResult = await this.executeViaBackend(context, taskPrompt);
@@ -50,6 +74,156 @@ export class ExecutorAgent extends BaseAgent {
}; };
} }
private async executeWithPersonas(
context: AgentContext,
config: CIAgentConfig
): Promise<AgentResult> {
const start = Date.now();
const planContent = this.readPlanFile(context);
if (!planContent) {
this.log("No plan file found — falling back to standard execution");
const taskPrompt = await this.buildBackendTaskPrompt(context);
return this.executeViaBackend(context, taskPrompt);
}
const decomposer = new TaskDecomposer(context.project_path, config, context.project_slug);
const plan = decomposer.decompose(planContent);
const resolvedPlan = decomposer.resolveConflicts(plan);
this.log(`Decomposed plan into ${resolvedPlan.tasks.length} tasks across domains: data=${resolvedPlan.dataTasks.length}, backend=${resolvedPlan.backendTasks.length}, frontend=${resolvedPlan.frontendTasks.length}, coordination=${resolvedPlan.coordinationTasks.length}`);
if (resolvedPlan.conflicts.length > 0) {
this.log(`Resolved ${resolvedPlan.conflicts.length} territory conflicts`);
for (const conflict of resolvedPlan.conflicts) {
this.log(` Conflict: ${conflict.description}${conflict.resolution || "unresolved"}`);
}
}
const personaGroups = this.groupTasksByPersona(resolvedPlan);
const personaLoader = new PersonaLoader(context.project_path, config);
const enforcement = config.personas?.territory_enforcement || "warn";
let totalDecisions = 0;
let totalEscalations = 0;
const allArtifacts: string[] = [];
let lastError: string | undefined;
const domainOrder: string[] = ["data", "backend", "frontend", "coordination"];
const sortedGroups = domainOrder
.flatMap((domain) => personaGroups.filter((g) => g.domain === domain))
.concat(personaGroups.filter((g) => !domainOrder.includes(g.domain)));
for (const group of sortedGroups) {
this.log(`Executing group: persona=${group.persona}, domain=${group.domain}, tasks=${group.tasks.length}`);
for (const conflict of group.conflicts) {
if (enforcement === "strict") {
this.warn(`Territory conflict (strict): ${conflict.description}`);
totalEscalations++;
} else {
this.log(`Territory conflict (warn): ${conflict.description}${conflict.resolution || "auto-resolved"}`);
}
}
const persona = personaLoader.getPersona(group.persona);
const personaContext = this.buildPersonaContext(context, persona, group);
try {
const result = await this.executeViaBackend(personaContext, personaContext.specification);
if (Array.isArray(result.artifacts_created)) {
allArtifacts.push(...result.artifacts_created);
}
totalDecisions += result.decisions;
totalEscalations += result.escalations;
if (!result.success) {
this.warn(`Persona ${group.persona} reported issues: ${result.error || "unspecified"}`);
lastError = result.error;
}
} catch (err) {
this.warn(`Persona ${group.persona} failed: ${err instanceof Error ? err.message : String(err)}`);
lastError = err instanceof Error ? err.message : String(err);
}
}
const verification = await this.verifyExecution(context);
return {
success: verification.testsPassing || lastError === undefined,
output: `Executed ${resolvedPlan.tasks.length} tasks across ${personaGroups.length} persona groups. Verification: tests=${verification.testsPassing ? "passing" : "failing"}, must-haves=${verification.mustHavesChecked.length}`,
artifacts_created: allArtifacts,
decisions: totalDecisions,
escalations: totalEscalations,
duration_ms: Date.now() - start,
error: lastError,
};
}
private groupTasksByPersona(plan: DecomposedPlan): PersonaTaskGroup[] {
const groupMap = new Map<string, PersonaTaskGroup>();
for (const task of plan.tasks) {
const key = task.persona;
if (!groupMap.has(key)) {
groupMap.set(key, {
persona: task.persona,
domain: task.domain,
tasks: [],
conflicts: plan.conflicts.filter((c) => c.personas.includes(task.persona)),
});
}
groupMap.get(key)!.tasks.push({
id: task.taskId,
description: task.description,
files: task.files,
});
}
return Array.from(groupMap.values());
}
private buildPersonaContext(
context: AgentContext,
persona: ReturnType<PersonaLoader["getPersona"]>,
group: PersonaTaskGroup
): AgentContext {
const personaPrompt = persona
? `You are the ${persona.name} (${persona.domain} domain). ${persona.systemPromptAdditions || persona.description}.\n\nPreferred frameworks: ${persona.frameworks.join(", ")}.\nDesign constraints: ${persona.constraints.join(", ")}.\nTerritory files: ${persona.territory.join(", ")}.\n\n`
: "";
const taskDescriptions = group.tasks
.map((t) => `- [${t.id}] ${t.description} (files: ${t.files.join(", ") || "TBD"})`)
.join("\n");
const conflictNotes = group.conflicts.length > 0
? `\n\n## Territory Conflicts (resolved by lead developer)\n${group.conflicts.map((c) => `- ${c.description} → Resolution: ${c.resolution || "pending"}`).join("\n")}`
: "";
const specification = [
personaPrompt,
"## Assigned Tasks\n",
taskDescriptions,
conflictNotes,
"\n\n## Specification\n",
context.specification || "No specification provided",
].join("\n");
return {
...context,
specification,
};
}
private loadProjectConfig(context: AgentContext): CIAgentConfig {
try {
return loadConfig(context.project_path);
} catch {
return DEFAULT_CIAGENT_CONFIG as CIAgentConfig;
}
}
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> { private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
const parts: string[] = [ const parts: string[] = [
`Execute implementation for stage ${context.stage}, phase ${context.phase}.`, `Execute implementation for stage ${context.stage}, phase ${context.phase}.`,
@@ -64,8 +238,12 @@ export class ExecutorAgent extends BaseAgent {
} }
const ciDir = path.join(context.project_path, ".ciagent"); const ciDir = path.join(context.project_path, ".ciagent");
const roadmapPath = path.join(ciDir, "ROADMAP.md"); const roadmapPath = context.project_slug
const archPath = path.join(ciDir, "ARCHITECTURE.md"); ? path.join(ciDir, context.project_slug, "ROADMAP.md")
: path.join(ciDir, "ROADMAP.md");
const archPath = context.project_slug
? path.join(ciDir, context.project_slug, "ARCHITECTURE.md")
: path.join(ciDir, "ARCHITECTURE.md");
if (fs.existsSync(roadmapPath)) { if (fs.existsSync(roadmapPath)) {
try { try {
@@ -91,11 +269,17 @@ export class ExecutorAgent extends BaseAgent {
} }
private readPlanFile(context: AgentContext): string | null { private readPlanFile(context: AgentContext): string | null {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md"); const planPath = context.project_slug
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
: path.join(context.project_path, ".ciagent", "PLAN.md");
try { try {
if (fs.existsSync(planPath)) { if (fs.existsSync(planPath)) {
return fs.readFileSync(planPath, "utf-8"); return fs.readFileSync(planPath, "utf-8");
} }
const defaultPlanPath = path.join(context.project_path, ".ciagent", "PLAN.md");
if (fs.existsSync(defaultPlanPath)) {
return fs.readFileSync(defaultPlanPath, "utf-8");
}
} catch {} } catch {}
return null; return null;
} }
@@ -139,7 +323,9 @@ export class ExecutorAgent extends BaseAgent {
} }
private checkMustHaves(context: AgentContext): MustHaveItem[] { private checkMustHaves(context: AgentContext): MustHaveItem[] {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md"); const planPath = context.project_slug
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
: path.join(context.project_path, ".ciagent", "PLAN.md");
const results: MustHaveItem[] = []; const results: MustHaveItem[] = [];
try { try {
+31
View File
@@ -20,6 +20,7 @@ import { loadConfig, saveConfig, isCIAgentInitialized, initCIAgent } from "../co
import { getAgent } from "./index.js"; import { getAgent } from "./index.js";
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js"; import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
import { registerEscalationProtocol } from "../cli/index.js"; import { registerEscalationProtocol } from "../cli/index.js";
import { SessionManager } from "../core/session-manager.js";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
export interface GitAgentContext extends AgentContext { export interface GitAgentContext extends AgentContext {
@@ -894,6 +895,36 @@ export class OrchestratorAgent extends BaseAgent {
this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`); this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`);
const useSessions = config.sessions?.max_concurrent_sessions !== undefined;
if (useSessions) {
return this.runWithSessionManager(context, activeProjects, config);
}
return this.runWithLegacyParallel(context, activeProjects, config);
}
private async runWithSessionManager(
context: AgentContext,
activeProjects: string[],
config: CIAgentConfig
): Promise<Record<string, AgentResult>> {
const sessionManager = new SessionManager(context.project_path, config);
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
const contextFactory = (slug: string): AgentContext => ({
...context,
project_slug: slug,
});
return sessionManager.runAllSessions(activeProjects, contextFactory, parallel);
}
private async runWithLegacyParallel(
context: AgentContext,
activeProjects: string[],
config: CIAgentConfig
): Promise<Record<string, AgentResult>> {
const results: Record<string, AgentResult> = {}; const results: Record<string, AgentResult> = {};
const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3; const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3;
const parallel = config.parallelization?.enabled && activeProjects.length > 1; const parallel = config.parallelization?.enabled && activeProjects.length > 1;
+144
View File
@@ -18,6 +18,8 @@ import { BackendUnavailableError } from "../backends/types.js";
import { getAgent } from "../agents/index.js"; import { getAgent } from "../agents/index.js";
import { CIAgentFiles } from "../core/ciagent-files.js"; import { CIAgentFiles } from "../core/ciagent-files.js";
import { GiteaClient, generateReleaseNotes } from "../core/gitea.js"; import { GiteaClient, generateReleaseNotes } from "../core/gitea.js";
import { SessionManager } from "../core/session-manager.js";
import { AgentSession } from "../core/agent-session.js";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as readline from "node:readline"; import * as readline from "node:readline";
@@ -172,6 +174,7 @@ export function createRunCommand(): Command {
.option("--backend <provider>", "Override intelligence backend for this run") .option("--backend <provider>", "Override intelligence backend for this run")
.option("--ideate", "Insert ideation stage between research and plan") .option("--ideate", "Insert ideation stage between research and plan")
.option("--project <slug>", "Target project slug (comma-separated or 'all')") .option("--project <slug>", "Target project slug (comma-separated or 'all')")
.option("--session <id>", "Resume a specific session by ID")
.action(async (phase, options) => { .action(async (phase, options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
@@ -1372,4 +1375,145 @@ export function createIdeateCommand(): Command {
console.log(` ${cat}: ${count}`); console.log(` ${cat}: ${count}`);
} }
}); });
}
export function createSessionsCommand(): Command {
return new Command("sessions")
.description("Manage CIAgent agent sessions")
.addCommand(
new Command("list")
.description("List all sessions")
.option("--project <slug>", "Filter by project slug")
.action(async (options) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const persisted = sessionManager.loadPersistedSessions();
const active = sessionManager.listSessions();
const allSessions = [...persisted];
for (const activeSession of active) {
if (!allSessions.find((s) => s.id === activeSession.id)) {
allSessions.push(activeSession);
}
}
if (options.project) {
const filtered = allSessions.filter((s) => s.project_slug === options.project);
displaySessions(filtered);
} else {
displaySessions(allSessions);
}
})
)
.addCommand(
new Command("status")
.description("Show status of a specific session")
.argument("<session-id>", "Session ID")
.action(async (sessionId) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const persisted = sessionManager.loadPersistedSessions();
const sessionInfo = persisted.find((s) => s.id === sessionId);
if (!sessionInfo) {
const session = sessionManager.getSession(sessionId);
if (!session) {
console.error(`Session ${sessionId} not found.`);
process.exit(1);
}
displaySessionDetail(session.getSessionInfo());
return;
}
displaySessionDetail(sessionInfo);
})
)
.addCommand(
new Command("cancel")
.description("Cancel a running session")
.argument("<session-id>", "Session ID")
.action(async (sessionId) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const success = sessionManager.cancelSession(sessionId);
if (success) {
console.log(`Session ${sessionId} cancelled.`);
} else {
console.error(`Failed to cancel session ${sessionId}. Session may not be running.`);
process.exit(1);
}
})
)
.addCommand(
new Command("cleanup")
.description("Clean up stale sessions")
.action(async () => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const cleaned = sessionManager.cleanupStaleSessions();
console.log(`Cleaned up ${cleaned} stale session(s).`);
})
);
}
function displaySessions(sessions: Array<import("../types/session.js").SessionInfo>): void {
if (sessions.length === 0) {
console.log("No sessions found.");
return;
}
console.log("\n─── CIAgent Sessions ───\n");
console.log("ID Project Phase Stage Status");
console.log("-------- ---------------- ----- ---------- ---------");
for (const s of sessions) {
const id = s.id.padEnd(8);
const slug = (s.project_slug || "default").padEnd(16);
const phase = String(s.phase).padEnd(5);
const stage = s.stage.padEnd(10);
const statusIcon = s.status === "running" ? "●" : s.status === "completed" ? "✓" : s.status === "failed" ? "✗" : s.status === "paused" ? "⏸" : "○";
console.log(`${id} ${slug} ${phase} ${stage} ${statusIcon} ${s.status}`);
}
console.log(`\n${sessions.length} session(s) total.`);
}
function displaySessionDetail(s: import("../types/session.js").SessionInfo): void {
console.log("\n─── Session Detail ───\n");
console.log(` ID: ${s.id}`);
console.log(` Project: ${s.project_slug || "default"}`);
console.log(` Phase: ${s.phase}`);
console.log(` Stage: ${s.stage}`);
console.log(` Status: ${s.status}`);
console.log(` Started: ${s.started_at}`);
console.log(` Last Updated: ${s.last_updated}`);
if (s.error) {
console.log(` Error: ${s.error}`);
}
} }
+3 -1
View File
@@ -18,6 +18,7 @@ import {
createShipCommand, createShipCommand,
createProjectsCommand, createProjectsCommand,
createIdeateCommand, createIdeateCommand,
createSessionsCommand,
} from "./commands.js"; } from "./commands.js";
let activeEscalationProtocol: { dispose(): void } | null = null; let activeEscalationProtocol: { dispose(): void } | null = null;
@@ -68,6 +69,7 @@ program
.addCommand(createRollbackCommand()) .addCommand(createRollbackCommand())
.addCommand(createShipCommand()) .addCommand(createShipCommand())
.addCommand(createProjectsCommand()) .addCommand(createProjectsCommand())
.addCommand(createIdeateCommand()); .addCommand(createIdeateCommand())
.addCommand(createSessionsCommand());
program.parse(); program.parse();
+284
View File
@@ -0,0 +1,284 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { execSync } from "node:child_process";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { SessionConfig, SessionInfo, SessionStatus, DEFAULT_SESSION_CONFIG } from "../types/session.js";
import { PipelineStage } from "../types/pipeline.js";
import { AgentContext, AgentResult } from "../agents/base.js";
import { loadConfig } from "../core/config.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { GitContext } from "../core/git-context.js";
import { CommitBuilder } from "../core/commit-builder.js";
import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js";
import { PipelineState, createInitialPipelineState } from "../types/pipeline.js";
export class AgentSession {
private id: string;
private projectSlug: string;
private projectPath: string;
private config: CIAgentConfig;
private sessionConfig: SessionConfig;
private status: SessionStatus;
private pipelineState: PipelineState | null;
private error: string | undefined;
private startedAt: string;
private lastUpdated: string;
private lockAcquired: boolean;
constructor(projectPath: string, projectSlug: string, config?: CIAgentConfig) {
this.id = crypto.randomUUID().slice(0, 8);
this.projectSlug = projectSlug;
this.projectPath = projectPath;
this.config = config || loadConfig(projectPath);
this.sessionConfig = this.config.sessions || DEFAULT_SESSION_CONFIG;
this.status = "pending";
this.pipelineState = null;
this.error = undefined;
this.startedAt = new Date().toISOString();
this.lastUpdated = this.startedAt;
this.lockAcquired = false;
}
getId(): string {
return this.id;
}
getProjectSlug(): string {
return this.projectSlug;
}
getStatus(): SessionStatus {
return this.status;
}
getSessionInfo(): SessionInfo {
return {
id: this.id,
project_slug: this.projectSlug,
project_path: this.projectPath,
phase: this.pipelineState?.current_phase ?? 0,
stage: this.pipelineState?.current_stage ?? "specify",
status: this.status,
started_at: this.startedAt,
last_updated: this.lastUpdated,
error: this.error,
};
}
acquireLock(): boolean {
const lockPath = this.getLockPath();
ensureDir(path.dirname(lockPath));
if (fileExists(lockPath)) {
const lockData = JSON.parse(readFile(lockPath) || "{}") as { sessionId: string; timestamp: string; projectSlug: string };
if (lockData.sessionId && lockData.sessionId !== this.id) {
const lockAge = Date.now() - new Date(lockData.timestamp).getTime();
if (lockAge < (this.sessionConfig.session_timeout_ms || 3600000)) {
return false;
}
}
}
writeFile(lockPath, JSON.stringify({
sessionId: this.id,
timestamp: new Date().toISOString(),
projectSlug: this.projectSlug,
}));
this.lockAcquired = true;
return true;
}
releaseLock(): void {
if (!this.lockAcquired) return;
const lockPath = this.getLockPath();
try {
if (fileExists(lockPath)) {
const lockData = JSON.parse(readFile(lockPath) || "{}") as { sessionId: string };
if (lockData.sessionId === this.id) {
fs.unlinkSync(lockPath);
}
}
} catch {}
this.lockAcquired = false;
}
async run(context: AgentContext): Promise<AgentResult> {
if (this.status === "running") {
return {
success: false,
output: `Session ${this.id} is already running`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: "Session already running",
};
}
const locked = this.acquireLock();
if (!locked) {
return {
success: false,
output: `Failed to acquire lock for session ${this.id}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: "Lock acquisition failed — another session is active for this project",
};
}
this.status = "running";
this.lastUpdated = new Date().toISOString();
this.pipelineState = createInitialPipelineState(this.projectPath);
const gitContext = new GitContext(this.projectPath, this.projectSlug || undefined);
const projectState = gitContext.reconstructState();
if (projectState.currentPhase > 0) {
this.pipelineState.current_phase = projectState.currentPhase;
this.pipelineState.current_stage = projectState.currentStage;
}
this.persistState();
let result: AgentResult;
try {
const { OrchestratorAgent } = await import("../agents/orchestrator.js");
const orchestrator = new OrchestratorAgent(this.config);
result = await orchestrator.runForProject(this.projectSlug, context);
this.status = result.success ? "completed" : "failed";
this.error = result.error;
} catch (err) {
this.status = "failed";
this.error = err instanceof Error ? err.message : String(err);
result = {
success: false,
output: `Session ${this.id} failed: ${this.error}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: this.error,
};
} finally {
this.lastUpdated = new Date().toISOString();
this.releaseLock();
this.persistState();
}
if (this.config.git?.auto_commit && result.success) {
const ciFiles = new CIAgentFiles(this.projectPath, this.projectSlug || undefined);
try {
const sessionCommit = CommitBuilder.buildTaskCommit({
type: "chore",
phase: this.pipelineState?.current_phase ?? 0,
milestone: "session",
project: this.projectSlug || undefined,
plan: "session",
task: this.id,
subject: `session ${this.id} ${this.status}`,
status: "complete" as PipelineStage,
});
if (gitContext.isGitRepo()) {
execSync(`git add -A && git commit -m "${sessionCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: this.projectPath,
stdio: "pipe",
});
}
} catch {}
}
return {
...result,
output: `[session:${this.id}] ${result.output}`,
};
}
cancel(): boolean {
if (this.status !== "running") return false;
this.status = "cancelled";
this.lastUpdated = new Date().toISOString();
this.releaseLock();
this.persistState();
return true;
}
pause(): boolean {
if (this.status !== "running") return false;
this.status = "paused";
this.lastUpdated = new Date().toISOString();
this.persistState();
return true;
}
resume(): boolean {
if (this.status !== "paused") return false;
this.status = "running";
this.lastUpdated = new Date().toISOString();
return true;
}
private getLockPath(): string {
const ciDir = path.join(this.projectPath, ".ciagent");
const slugDir = this.projectSlug ? path.join(ciDir, this.projectSlug) : ciDir;
return path.join(slugDir, ".session-lock");
}
private getStatePath(): string {
const ciDir = path.join(this.projectPath, ".ciagent");
const slugDir = this.projectSlug ? path.join(ciDir, this.projectSlug) : ciDir;
return path.join(slugDir, `.session-${this.id}.json`);
}
persistState(): void {
const statePath = this.getStatePath();
const stateData = {
id: this.id,
projectSlug: this.projectSlug,
projectPath: this.projectPath,
status: this.status,
startedAt: this.startedAt,
lastUpdated: this.lastUpdated,
error: this.error,
pipelineState: this.pipelineState,
};
ensureDir(path.dirname(statePath));
writeFile(statePath, JSON.stringify(stateData, null, 2));
}
static loadState(projectPath: string, sessionId: string, projectSlug?: string): AgentSession | null {
const ciDir = path.join(projectPath, ".ciagent");
const slugDir = projectSlug ? path.join(ciDir, projectSlug) : ciDir;
const statePath = path.join(slugDir, `.session-${sessionId}.json`);
if (!fileExists(statePath)) return null;
try {
const data = JSON.parse(readFile(statePath) || "{}") as {
id: string;
projectSlug: string;
projectPath: string;
status: SessionStatus;
startedAt: string;
lastUpdated: string;
error?: string;
};
const session = new AgentSession(data.projectPath, data.projectSlug);
(session as any).id = data.id;
(session as any).status = data.status;
(session as any).startedAt = data.startedAt;
(session as any).lastUpdated = data.lastUpdated;
(session as any).error = data.error;
return session;
} catch {
return null;
}
}
}
+1
View File
@@ -98,6 +98,7 @@ export class CommitBuilder {
lines.push(`milestone: ${ci.milestone}`); lines.push(`milestone: ${ci.milestone}`);
if (ci.project) lines.push(`project: ${ci.project}`); if (ci.project) lines.push(`project: ${ci.project}`);
if (ci.session) lines.push(`session: ${ci.session}`);
if (ci.plan) lines.push(`plan: ${ci.plan}`); if (ci.plan) lines.push(`plan: ${ci.plan}`);
if (ci.task) lines.push(`task: ${ci.task}`); if (ci.task) lines.push(`task: ${ci.task}`);
+3
View File
@@ -43,6 +43,9 @@ export function parseCIAgentBlock(yaml: string): CIAgentMetadata | null {
const projectMatch = yaml.match(/^project:\s*(.+)$/m); const projectMatch = yaml.match(/^project:\s*(.+)$/m);
if (projectMatch) result.project = projectMatch[1].trim(); if (projectMatch) result.project = projectMatch[1].trim();
const sessionMatch = yaml.match(/^session:\s*(.+)$/m);
if (sessionMatch) result.session = sessionMatch[1].trim();
result.decisions = parseDecisionsFromYaml(yaml); result.decisions = parseDecisionsFromYaml(yaml);
result.escalations = parseEscalationsFromYaml(yaml); result.escalations = parseEscalationsFromYaml(yaml);
result.requirements = parseRequirementsFromYaml(yaml); result.requirements = parseRequirementsFromYaml(yaml);
+4 -1
View File
@@ -9,6 +9,9 @@ export { GitBranch } from "./git-branch.js";
export { CommitBuilder } from "./commit-builder.js"; export { CommitBuilder } from "./commit-builder.js";
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js"; export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js";
export { GiteaClient, generateReleaseNotes } from "./gitea.js"; export { GiteaClient, generateReleaseNotes } from "./gitea.js";
export type { GiteaReleaseConfig, GiteaRelease } from "./gitea.js"; export { AgentSession } from "./agent-session.js";
export { SessionManager } from "./session-manager.js";
export { PersonaLoader } from "./persona-loader.js";
export { TaskDecomposer } from "./task-decomposer.js";
export type { CIAgentConfig } from "../types/config.js"; export type { CIAgentConfig } from "../types/config.js";
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js"; export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
+227
View File
@@ -0,0 +1,227 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { ExecutePersonaConfig, PersonaDomain, DEFAULT_PERSONAS, TerritoryEnforcement } from "../types/persona.js";
import { CIAgentConfig } from "../types/config.js";
export interface PersonaDefinition {
name: string;
domain: PersonaDomain;
frameworks: string[];
constraints: string[];
territory: string[];
description: string;
systemPromptAdditions: string;
}
const PERSONA_SEARCH_PATHS = [
".config/opencode/agents",
"opencode/agents",
];
const PERSONA_FILE_PATTERN = /^ci-(.+)\.md$/;
export class PersonaLoader {
private projectPath: string;
private config: CIAgentConfig;
private cachedPersonas: Map<string, PersonaDefinition> = new Map();
private loaded: boolean = false;
constructor(projectPath: string, config: CIAgentConfig) {
this.projectPath = projectPath;
this.config = config;
}
loadPersonas(): PersonaDefinition[] {
if (this.loaded) {
return Array.from(this.cachedPersonas.values());
}
const configPersonas = this.config.personas?.personas || DEFAULT_PERSONAS;
const configEnabled = this.config.personas?.enabled ?? true;
if (!configEnabled) {
this.loaded = true;
return [];
}
for (const configPersona of configPersonas) {
const filePersona = this.loadPersonaFromFile(configPersona.name);
if (filePersona) {
const merged: PersonaDefinition = {
name: configPersona.name,
domain: configPersona.domain,
frameworks: filePersona.frameworks.length > 0 ? filePersona.frameworks : configPersona.frameworks,
constraints: filePersona.constraints.length > 0 ? filePersona.constraints : configPersona.constraints,
territory: filePersona.territory.length > 0 ? filePersona.territory : configPersona.territory,
description: filePersona.description,
systemPromptAdditions: filePersona.systemPromptAdditions,
};
this.cachedPersonas.set(configPersona.name, merged);
} else {
const definition: PersonaDefinition = {
name: configPersona.name,
domain: configPersona.domain,
frameworks: configPersona.frameworks,
constraints: configPersona.constraints,
territory: configPersona.territory,
description: `${configPersona.name} persona (domain: ${configPersona.domain})`,
systemPromptAdditions: this.buildDefaultPromptAdditions(configPersona),
};
this.cachedPersonas.set(configPersona.name, definition);
}
}
this.loaded = true;
return Array.from(this.cachedPersonas.values());
}
getPersona(name: string): PersonaDefinition | undefined {
if (!this.loaded) this.loadPersonas();
return this.cachedPersonas.get(name);
}
getPersonaForDomain(domain: PersonaDomain): PersonaDefinition | undefined {
if (!this.loaded) this.loadPersonas();
for (const persona of this.cachedPersonas.values()) {
if (persona.domain === domain) return persona;
}
return undefined;
}
getLeadDeveloper(): PersonaDefinition {
return this.getPersona("lead-developer") || {
name: "lead-developer",
domain: "coordination",
frameworks: [],
constraints: ["pragmatic", "battle-tested defaults"],
territory: [],
description: "Lead developer — coordinates task decomposition and resolves conflicts",
systemPromptAdditions: "",
};
}
getEngineerPersonas(): PersonaDefinition[] {
if (!this.loaded) this.loadPersonas();
return Array.from(this.cachedPersonas.values()).filter(
(p) => p.domain !== "coordination"
);
}
getTerritoryEnforcement(): TerritoryEnforcement {
return this.config.personas?.territory_enforcement || "warn";
}
private loadPersonaFromFile(name: string): PersonaDefinition | null {
const filename = `ci-${name}.md`;
for (const searchPath of PERSONA_SEARCH_PATHS) {
const filePath = path.join(this.projectPath, searchPath, filename);
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, "utf-8");
return this.parsePersonaMd(name, content);
} catch {
continue;
}
}
}
return null;
}
private parsePersonaMd(name: string, content: string): PersonaDefinition {
const frontmatter = this.parseFrontmatter(content);
const body = this.stripFrontmatter(content);
return {
name: (frontmatter.name as string) || name,
domain: (frontmatter.domain as PersonaDomain) || this.inferDomainFromName(name),
frameworks: (frontmatter.frameworks as string[]) || [],
constraints: (frontmatter.constraints as string[]) || [],
territory: (frontmatter.territory as string[]) || [],
description: (frontmatter.description as string) || body.slice(0, 200),
systemPromptAdditions: body,
};
}
private parseFrontmatter(content: string): Record<string, unknown> {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const yaml = match[1];
const result: Record<string, unknown> = {};
const lines = yaml.split("\n");
let currentKey = "";
let inArray = false;
let arrayItems: string[] = [];
for (const line of lines) {
const arrMatch = line.match(/^(\w+):\s*$/);
if (arrMatch) {
if (inArray && currentKey) {
result[currentKey] = arrayItems;
}
currentKey = arrMatch[1];
inArray = true;
arrayItems = [];
continue;
}
const itemMatch = line.match(/^\s+-\s+(.+)$/);
if (itemMatch && inArray) {
arrayItems.push(itemMatch[1].trim());
continue;
}
const kvMatch = line.match(/^(\w+):\s*(.+)$/);
if (kvMatch) {
if (inArray && currentKey) {
result[currentKey] = arrayItems;
inArray = false;
}
currentKey = kvMatch[1];
result[currentKey] = kvMatch[2].trim();
}
}
if (inArray && currentKey) {
result[currentKey] = arrayItems;
}
return result;
}
private stripFrontmatter(content: string): string {
return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
}
private inferDomainFromName(name: string): PersonaDomain {
if (name.includes("data") || name.includes("db") || name.includes("schema")) return "data";
if (name.includes("backend") || name.includes("api") || name.includes("server")) return "backend";
if (name.includes("frontend") || name.includes("ui") || name.includes("client")) return "frontend";
return "coordination";
}
private buildDefaultPromptAdditions(config: ExecutePersonaConfig): string {
const parts: string[] = [];
parts.push(`You are a ${config.name} persona in the CIAgent execution pipeline.`);
parts.push(`Domain: ${config.domain}.`);
if (config.frameworks.length > 0) {
parts.push(`Preferred frameworks: ${config.frameworks.join(", ")}.`);
}
if (config.constraints.length > 0) {
parts.push(`Design constraints: ${config.constraints.join(", ")}.`);
}
if (config.territory.length > 0) {
parts.push(`You own the following file patterns: ${config.territory.join(", ")}.`);
parts.push(`Do not modify files outside your territory without explicit lead developer approval.`);
}
return parts.join(" ");
}
}
+475
View File
@@ -0,0 +1,475 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
ExecutePersonaConfig,
PersonaDomain,
TerritoryConflict,
DecomposedTask,
DecomposedPlan,
DEFAULT_PERSONAS,
matchFileToPersona,
globMatch,
detectConflicts,
} from "../types/persona.js";
import { TaskDecomposer } from "../core/task-decomposer.js";
import { PersonaLoader } from "../core/persona-loader.js";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { initCIAgent } from "../core/config.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-persona-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
const samplePlan = `# Phase 1 Plan — Core API
## Phase Goal
Build core API routes and database schema.
### Wave 1 (foundational)
#### Task 1.1: Create user schema
| **ID** | P1-T1 |
| **REQs** | DATA-01 |
| **Description** | Create the users table schema with Drizzle ORM |
| **Files to create** | \`src/db/schema/users.ts\`, \`src/db/migrations/001_create_users.sql\` |
#### Task 1.2: Create auth routes
| **ID** | P1-T2 |
| **REQs** | API-01 |
| **Description** | Create /api/auth/login and /api/auth/register routes |
| **Files to create** | \`src/api/routes/auth.ts\`, \`src/api/middleware/auth.ts\` |
#### Task 1.3: Create login page
| **ID** | P1-T3 |
| **REQs** | UI-01 |
| **Description** | Create React login page component |
| **Files to create** | \`src/components/LoginForm.tsx\`, \`src/pages/login.tsx\` |
### Wave 2
#### Task 1.4: Create data repository
| **ID** | P1-T4 |
| **REQs** | DATA-02 |
| **Description** | Create UserRepository with typed query methods |
| **Files to create** | \`src/repository/userRepository.ts\` |
`;
describe("ExecutePersona type", () => {
it("DEFAULT_PERSONAS has 4 personas", () => {
expect(DEFAULT_PERSONAS).toHaveLength(4);
});
it("DEFAULT_PERSONAS includes lead-developer", () => {
const lead = DEFAULT_PERSONAS.find((p) => p.name === "lead-developer");
expect(lead).toBeTruthy();
expect(lead!.domain).toBe("coordination");
expect(lead!.territory).toHaveLength(0);
});
it("DEFAULT_PERSONAS includes data-engineer", () => {
const data = DEFAULT_PERSONAS.find((p) => p.name === "data-engineer");
expect(data).toBeTruthy();
expect(data!.domain).toBe("data");
expect(data!.frameworks).toContain("drizzle");
expect(data!.territory.length).toBeGreaterThan(0);
});
it("DEFAULT_PERSONAS includes backend-engineer", () => {
const backend = DEFAULT_PERSONAS.find((p) => p.name === "backend-engineer");
expect(backend).toBeTruthy();
expect(backend!.domain).toBe("backend");
expect(backend!.frameworks).toContain("fastify");
expect(backend!.territory.length).toBeGreaterThan(0);
});
it("DEFAULT_PERSONAS includes frontend-engineer", () => {
const frontend = DEFAULT_PERSONAS.find((p) => p.name === "frontend-engineer");
expect(frontend).toBeTruthy();
expect(frontend!.domain).toBe("frontend");
expect(frontend!.frameworks).toContain("react");
expect(frontend!.territory.length).toBeGreaterThan(0);
});
it("each domain persona has territory patterns", () => {
for (const persona of DEFAULT_PERSONAS) {
if (persona.domain === "coordination") continue;
expect(persona.territory.length).toBeGreaterThan(0);
}
});
it("each domain persona has constraints", () => {
for (const persona of DEFAULT_PERSONAS) {
if (persona.domain === "coordination") continue;
expect(persona.constraints.length).toBeGreaterThan(0);
}
});
});
describe("matchFileToPersona", () => {
const personas = DEFAULT_PERSONAS;
it("matches data files to data engineer", () => {
const matches = [
"src/db/schema/users.ts",
"src/migrations/001_create_users.sql",
"drizzle/config.ts",
"src/models/User.ts",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("data-engineer");
}
});
it("matches API files to backend engineer", () => {
const matches = [
"src/api/routes/auth.ts",
"src/services/UserService.ts",
"src/middleware/auth.ts",
"src/controllers/userController.ts",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("backend-engineer");
}
});
it("matches component files to frontend engineer", () => {
const matches = [
"src/components/LoginForm.tsx",
"src/pages/login.tsx",
"src/hooks/useAuth.ts",
"src/styles/global.css",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("frontend-engineer");
}
});
it("returns null for files outside any territory", () => {
const result = matchFileToPersona("src/utils/helpers.ts", personas);
expect(result).toBeNull();
});
it("handles glob patterns correctly", () => {
expect(globMatch("**/db/**", "src/db/schema/users.ts")).toBe(true);
expect(globMatch("**/db/**", "src/api/routes/auth.ts")).toBe(false);
expect(globMatch("**/*.tsx", "src/components/Button.tsx")).toBe(true);
expect(globMatch("**/*.tsx", "src/utils/helpers.ts")).toBe(false);
});
});
describe("detectConflicts", () => {
it("detects data-backend conflicts", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "data-engineer",
domain: "data",
description: "Create schema",
files: ["src/db/schema/users.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "backend-engineer",
domain: "backend",
description: "Create API routes",
files: ["src/db/schema/users.ts"],
dependencies: ["T1"],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(1);
expect(conflicts[0].type).toBe("data-backend");
expect(conflicts[0].personas).toContain("data-engineer");
expect(conflicts[0].personas).toContain("backend-engineer");
});
it("detects backend-frontend conflicts", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "backend-engineer",
domain: "backend",
description: "Create API types",
files: ["src/api/types/UserTypes.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "frontend-engineer",
domain: "frontend",
description: "Create user component",
files: ["src/api/types/UserTypes.ts"],
dependencies: ["T1"],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(1);
expect(conflicts[0].type).toBe("backend-frontend");
});
it("returns no conflicts for non-overlapping tasks", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "data-engineer",
domain: "data",
description: "Create schema",
files: ["src/db/schema/users.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "backend-engineer",
domain: "backend",
description: "Create API routes",
files: ["src/api/routes/auth.ts"],
dependencies: [],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(0);
});
});
describe("TaskDecomposer", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initCIAgent(dir);
});
afterEach(() => {
cleanup(dir);
});
it("decomposes a plan into persona-specific task groups", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config, "test-project");
const plan = decomposer.decompose(samplePlan);
expect(plan.tasks.length).toBeGreaterThan(0);
expect(plan.dataTasks).toBeDefined();
expect(plan.backendTasks).toBeDefined();
expect(plan.frontendTasks).toBeDefined();
expect(plan.coordinationTasks).toBeDefined();
});
it("resolves territory conflicts", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const resolved = decomposer.resolveConflicts(plan);
for (const conflict of resolved.conflicts) {
if (conflict.resolution) {
expect(conflict.resolution.length).toBeGreaterThan(0);
}
}
});
it("assigns data tasks to data-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const dataTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("schema") || f.includes("migration"))
);
if (dataTask) {
expect(dataTask.domain).toBe("data");
}
});
it("assigns API tasks to backend-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const apiTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("api") || f.includes("routes"))
);
if (apiTask) {
expect(apiTask.domain).toBe("backend");
}
});
it("assigns component tasks to frontend-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const frontendTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("components") || f.endsWith(".tsx"))
);
if (frontendTask) {
expect(frontendTask.domain).toBe("frontend");
}
});
});
describe("PersonaLoader", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initCIAgent(dir);
});
afterEach(() => {
cleanup(dir);
});
it("returns default personas when no files exist", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const personas = loader.loadPersonas();
expect(personas.length).toBeGreaterThan(0);
expect(personas.some((p) => p.domain === "data")).toBe(true);
expect(personas.some((p) => p.domain === "backend")).toBe(true);
expect(personas.some((p) => p.domain === "frontend")).toBe(true);
});
it("getLeadDeveloper returns lead developer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
loader.loadPersonas();
const lead = loader.getLeadDeveloper();
expect(lead).toBeTruthy();
expect(lead.domain).toBe("coordination");
expect(lead.name).toBe("lead-developer");
});
it("getEngineerPersonas returns non-coordination personas", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const engineers = loader.getEngineerPersonas();
expect(engineers.length).toBe(3);
expect(engineers.every((p) => p.domain !== "coordination")).toBe(true);
});
it("returns empty personas when personas disabled", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: false,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const personas = loader.loadPersonas();
expect(personas.length).toBe(0);
});
it("getTerritoryEnforcement returns configured value", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "strict" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
expect(loader.getTerritoryEnforcement()).toBe("strict");
});
it("defaults to warn territory enforcement", () => {
const config = { ...DEFAULT_CIAGENT_CONFIG };
const loader = new PersonaLoader(dir, config);
expect(loader.getTerritoryEnforcement()).toBe("warn");
});
});
+327
View File
@@ -0,0 +1,327 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { initCIAgent, loadConfig } from "../core/config.js";
import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { SessionConfig, SessionInfo, DEFAULT_SESSION_CONFIG } from "../types/session.js";
import { AgentSession } from "../core/agent-session.js";
import { SessionManager } from "../core/session-manager.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-session-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function initProjectWithConfig(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
const config = {
...DEFAULT_CIAGENT_CONFIG,
projects: [{ slug: "test-project", name: "Test Project", default: true }],
active_project: "test-project",
active_projects: ["test-project"],
sessions: {
max_concurrent_sessions: 3,
session_timeout_ms: 3600000,
session_isolation: "branch",
},
};
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify(config, null, 2));
const projectDir = path.join(ciDir, "test-project");
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(path.join(projectDir, "PROJECT.md"), [
"# Test Project",
"",
"## What This Is",
"",
"A test project for session testing",
"",
"## Requirements",
"",
"### Active",
"",
"- [ ] Build session management",
"",
"## Constraints",
"",
"- TypeScript",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "ROADMAP.md"), [
"# Roadmap",
"",
"## Overview",
"",
"Test project roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Sessions** - Build session management",
"",
"## Phase Details",
"",
"### Phase 1: Sessions",
"**Goal**.: Build session management",
"**Depends on**: Nothing",
"**Requirements**: SESSION-01",
"**Success Criteria**:",
"1. Sessions work",
"**Status**: not_started",
"",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "REQUIREMENTS.md"), [
"# Requirements",
"",
"| REQ-ID | Requirement | Priority | Phase | Status |",
"|--------|-------------|----------|-------|--------|",
"| SESSION-01 | Session management | P0 | 1 | pending |",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| SESSION-01 | Phase 1 | pending |",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "ARCHITECTURE.md"), [
"# Architecture",
"",
"## Overview",
"",
"Test architecture",
"",
"## Components",
"",
"### test-api",
"- **Description**: API",
"- **Boundaries**: HTTP only",
"- **Depends on**: None",
"",
"## Data Flow",
"",
"Client -> API",
"",
"## Build Order",
"",
"1. API",
"",
].join("\n"));
}
describe("Session types", () => {
it("DEFAULT_SESSION_CONFIG has expected values", () => {
expect(DEFAULT_SESSION_CONFIG.max_concurrent_sessions).toBe(3);
expect(DEFAULT_SESSION_CONFIG.session_timeout_ms).toBe(3600000);
expect(DEFAULT_SESSION_CONFIG.session_isolation).toBe("branch");
});
it("SessionInfo interface is constructable", () => {
const info: SessionInfo = {
id: "abc12345",
project_slug: "test-project",
project_path: "/tmp/test",
phase: 1,
stage: "execute",
status: "running",
started_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
};
expect(info.id).toBe("abc12345");
expect(info.status).toBe("running");
expect(info.project_slug).toBe("test-project");
});
it("SessionConfig supports all status values", () => {
const statuses: SessionInfo["status"][] = [
"pending", "running", "paused", "completed", "failed", "cancelled",
];
expect(statuses).toHaveLength(6);
});
});
describe("AgentSession", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initProjectWithConfig(dir);
});
afterEach(() => {
cleanup(dir);
});
it("creates a session with a unique ID", () => {
const session = new AgentSession(dir, "test-project");
expect(session.getId()).toBeTruthy();
expect(session.getId().length).toBeGreaterThan(0);
expect(session.getStatus()).toBe("pending");
});
it("getSessionInfo returns valid SessionInfo", () => {
const session = new AgentSession(dir, "test-project");
const info = session.getSessionInfo();
expect(info.id).toBe(session.getId());
expect(info.project_slug).toBe("test-project");
expect(info.project_path).toBe(dir);
expect(info.status).toBe("pending");
expect(info.phase).toBe(0);
});
it("persists session state", () => {
const session = new AgentSession(dir, "test-project");
session.persistState();
const slugDir = path.join(dir, ".ciagent", "test-project");
const files = fs.readdirSync(slugDir);
const stateFile = files.find((f) => f.startsWith(".session-") && f.endsWith(".json"));
expect(stateFile).toBeTruthy();
});
it("loads persisted session state", () => {
const session = new AgentSession(dir, "test-project");
session.persistState();
const loaded = AgentSession.loadState(dir, session.getId(), "test-project");
expect(loaded).not.toBeNull();
expect(loaded!.getId()).toBe(session.getId());
});
it("returns null for non-existent session", () => {
const loaded = AgentSession.loadState(dir, "nonexistent", "test-project");
expect(loaded).toBeNull();
});
it("acquireLock creates a lock file", () => {
const session = new AgentSession(dir, "test-project");
const acquired = session.acquireLock();
expect(acquired).toBe(true);
const lockPath = path.join(dir, ".ciagent", "test-project", ".session-lock");
expect(fs.existsSync(lockPath)).toBe(true);
session.releaseLock();
});
it("releaseLock removes the lock file", () => {
const session = new AgentSession(dir, "test-project");
session.acquireLock();
session.releaseLock();
const lockPath = path.join(dir, ".ciagent", "test-project", ".session-lock");
expect(fs.existsSync(lockPath)).toBe(false);
});
it("cancel changes status to cancelled when running", () => {
const session = new AgentSession(dir, "test-project");
session.acquireLock();
(session as any).status = "running";
const cancelled = session.cancel();
expect(cancelled).toBe(true);
expect(session.getStatus()).toBe("cancelled");
session.releaseLock();
});
it("cancel returns false for non-running session", () => {
const session = new AgentSession(dir, "test-project");
const cancelled = session.cancel();
expect(cancelled).toBe(false);
});
it("pause and resume work correctly for non-running session", () => {
const session = new AgentSession(dir, "test-project");
expect(session.pause()).toBe(false);
expect(session.resume()).toBe(false);
});
});
describe("SessionManager", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initProjectWithConfig(dir);
});
afterEach(() => {
cleanup(dir);
});
it("creates sessions for projects", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
expect(session).toBeTruthy();
expect(session.getProjectSlug()).toBe("test-project");
});
it("lists sessions", () => {
const manager = new SessionManager(dir);
manager.createSession("test-project");
const sessions = manager.listSessions();
expect(sessions.length).toBe(1);
expect(sessions[0].project_slug).toBe("test-project");
});
it("lists active sessions as empty when none running", () => {
const manager = new SessionManager(dir);
manager.createSession("test-project");
const active = manager.listActiveSessions();
expect(active.length).toBe(0);
});
it("cancels a session that is not running returns false", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
const cancelled = manager.cancelSession(session.getId());
expect(cancelled).toBe(false);
});
it("cleans up stale sessions returns 0", () => {
const manager = new SessionManager(dir);
const cleaned = manager.cleanupStaleSessions();
expect(cleaned).toBe(0);
});
it("loads persisted sessions as empty initially", () => {
const manager = new SessionManager(dir);
const persisted = manager.loadPersistedSessions();
expect(Array.isArray(persisted)).toBe(true);
});
it("gets a session by id", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
const retrieved = manager.getSession(session.getId());
expect(retrieved).toBeTruthy();
expect(retrieved!.getId()).toBe(session.getId());
});
it("returns undefined for non-existent session", () => {
const manager = new SessionManager(dir);
const retrieved = manager.getSession("nonexistent");
expect(retrieved).toBeUndefined();
});
});
+183
View File
@@ -0,0 +1,183 @@
import { CIAgentConfig } from "../types/config.js";
import { SessionInfo, SessionStatus } from "../types/session.js";
import { AgentSession } from "./agent-session.js";
import { AgentContext, AgentResult } from "../agents/base.js";
import { loadConfig } from "./config.js";
import * as path from "node:path";
import * as fs from "node:fs";
import * as os from "node:os";
export class SessionManager {
private sessions: Map<string, AgentSession> = new Map();
private config: CIAgentConfig;
private projectPath: string;
constructor(projectPath: string, config?: CIAgentConfig) {
this.projectPath = projectPath;
this.config = config || loadConfig(projectPath);
}
createSession(projectSlug: string): AgentSession {
const session = new AgentSession(this.projectPath, projectSlug, this.config);
this.sessions.set(session.getId(), session);
return session;
}
async runSession(sessionId: string, context: AgentContext): Promise<AgentResult> {
const session = this.sessions.get(sessionId);
if (!session) {
return {
success: false,
output: `Session ${sessionId} not found`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: `Session ${sessionId} not found`,
};
}
return session.run(context);
}
async runAllSessions(
projectSlugs: string[],
contextFactory: (slug: string) => AgentContext,
parallel: boolean = false
): Promise<Record<string, AgentResult>> {
const results: Record<string, AgentResult> = {};
const maxConcurrent = this.config.sessions?.max_concurrent_sessions || 3;
if (parallel && projectSlugs.length > 1) {
const batches: string[][] = [];
const concurrency = Math.min(maxConcurrent, projectSlugs.length);
for (let i = 0; i < projectSlugs.length; i += concurrency) {
batches.push(projectSlugs.slice(i, i + concurrency));
}
for (const batch of batches) {
const batchResults = await Promise.allSettled(
batch.map(async (slug): Promise<[string, AgentResult]> => {
const session = this.createSession(slug);
const context = contextFactory(slug);
const result = await session.run(context);
return [slug, result];
})
);
for (const settled of batchResults) {
if (settled.status === "fulfilled") {
const [slug, result] = settled.value;
results[slug] = result;
} else {
const slug = batch[batchResults.indexOf(settled)];
results[slug] = {
success: false,
output: `Session failed for ${slug}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: settled.reason instanceof Error ? settled.reason.message : String(settled.reason),
};
}
}
}
} else {
for (const slug of projectSlugs) {
const session = this.createSession(slug);
const context = contextFactory(slug);
const result = await session.run(context);
results[slug] = result;
}
}
return results;
}
cancelSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.cancel();
}
pauseSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.pause();
}
resumeSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.resume();
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
}
listSessions(): SessionInfo[] {
return Array.from(this.sessions.values()).map((s) => s.getSessionInfo());
}
listActiveSessions(): SessionInfo[] {
return this.listSessions().filter(
(s) => s.status === "running" || s.status === "paused"
);
}
loadPersistedSessions(): SessionInfo[] {
const ciDir = path.join(this.projectPath, ".ciagent");
if (!fs.existsSync(ciDir)) return [];
const sessions: SessionInfo[] = [];
const dirs = [ciDir];
try {
const config = loadConfig(this.projectPath);
if (config.projects && config.projects.length > 0) {
for (const project of config.projects) {
dirs.push(path.join(ciDir, project.slug));
}
}
} catch {}
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith(".session-") && file.endsWith(".json")) {
const sessionId = file.replace(".session-", "").replace(".json", "");
const slug = dir === ciDir ? "" : path.basename(dir);
const session = AgentSession.loadState(this.projectPath, sessionId, slug || undefined);
if (session) {
sessions.push(session.getSessionInfo());
}
}
}
}
return sessions;
}
cleanupStaleSessions(): number {
const timeout = this.config.sessions?.session_timeout_ms || 3600000;
const now = Date.now();
let cleaned = 0;
for (const [id, session] of this.sessions.entries()) {
const info = session.getSessionInfo();
const age = now - new Date(info.last_updated).getTime();
if ((info.status === "running" || info.status === "paused") && age > timeout) {
session.cancel();
this.sessions.delete(id);
cleaned++;
}
}
return cleaned;
}
}
+275
View File
@@ -0,0 +1,275 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { matchFileToPersona, detectConflicts, DecomposedTask, DecomposedPlan, TerritoryConflict, ExecutePersonaConfig, PersonaDomain, DEFAULT_PERSONAS } from "../types/persona.js";
import { CIAgentConfig } from "../types/config.js";
import { PersonaLoader, PersonaDefinition } from "./persona-loader.js";
import { CIAgentFiles } from "./ciagent-files.js";
import { readFile } from "../utils/file.js";
const DOMAIN_FILE_PATTERNS: Record<string, string[]> = {
data: [
"**/migrations/**", "**/schema/**", "**/models/**", "**/db/**",
"prisma/schema.prisma", "drizzle/**", "**/*.sql", "**/seed*",
"**/repository/**", "**/dao/**",
],
backend: [
"**/api/**", "**/routes/**", "**/services/**", "**/middleware/**",
"**/controllers/**", "**/auth/**", "**/handlers/**", "**/grpc/**",
"**/server.ts", "**/app.ts",
],
frontend: [
"**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**",
"**/*.tsx", "**/*.css", "**/*.vue", "**/*.svelte",
"**/layouts/**", "**/views/**", "**/client/**",
],
};
const DOMAIN_KEYWORDS: Record<string, string[]> = {
data: [
"schema", "migration", "database", "model", "query", "table", "column",
"index", "seed", "orm", "sql", "repository", "dao", "entity",
],
backend: [
"api", "route", "endpoint", "middleware", "controller", "service",
"handler", "server", "auth", "grpc", "rest", "websocket",
"request", "response", "cors", "rate-limit",
],
frontend: [
"component", "page", "layout", "style", "css", "hook", "view",
"client", "ui", "render", "state", "interactive", "accessible",
"responsive", "animation",
],
};
interface PlanTask {
id: string;
description: string;
files: string[];
requirements: string[];
dependencies: string[];
wave: number;
}
export class TaskDecomposer {
private projectPath: string;
private personaLoader: PersonaLoader;
private config: CIAgentConfig;
private ciFiles: CIAgentFiles;
constructor(projectPath: string, config: CIAgentConfig, projectSlug?: string) {
this.projectPath = projectPath;
this.config = config;
this.personaLoader = new PersonaLoader(projectPath, config);
this.ciFiles = new CIAgentFiles(projectPath, projectSlug || undefined);
}
decompose(planContent: string): DecomposedPlan {
const tasks = this.parsePlanTasks(planContent);
const personas = this.config.personas?.enabled !== false
? this.config.personas?.personas || DEFAULT_PERSONAS
: DEFAULT_PERSONAS;
const decomposedTasks = this.assignTasksToPersonas(tasks, personas);
const conflicts = detectConflicts(decomposedTasks, personas);
return {
tasks: decomposedTasks,
dataTasks: decomposedTasks.filter((t) => t.domain === "data"),
backendTasks: decomposedTasks.filter((t) => t.domain === "backend"),
frontendTasks: decomposedTasks.filter((t) => t.domain === "frontend"),
coordinationTasks: decomposedTasks.filter((t) => t.domain === "coordination"),
conflicts,
};
}
resolveConflicts(plan: DecomposedPlan): DecomposedPlan {
const resolved = { ...plan, conflicts: [...plan.conflicts] };
for (let i = 0; i < resolved.conflicts.length; i++) {
const conflict = resolved.conflicts[i];
const resolution = this.leadDeveloperResolve(conflict);
resolved.conflicts[i] = { ...conflict, resolution };
}
return resolved;
}
private parsePlanTasks(planContent: string): PlanTask[] {
const tasks: PlanTask[] = [];
const taskRegex = /####\s+Task\s+(\d+[\.\d]*)[\s:]+(.+)/g;
const idRegex = /\*\*ID\*\*\s*\|\s*([A-Z]+-\d+(?:-\d+)*)/g;
const filesRegex = /\*\*Files\s+to\s+(?:create|modify)\*\*\s*\|\s*(.+)/g;
const reqRegex = /\*\*REQs\*\*\s*\|\s*(.+)/g;
const depRegex = /\*\*Dependencies\*\*\s*\|\s*(.+)/g;
const waveRegex = /###\s+Wave\s+(\d+)/g;
const sections = planContent.split(/####\s+Task/);
let currentWave = 1;
const waveMatches = [...planContent.matchAll(/###\s+Wave\s+(\d+)/g)];
const wavePositions = waveMatches.map((m) => ({
wave: parseInt(m[1], 10),
position: m.index || 0,
}));
let taskCounter = 0;
for (let i = 1; i < sections.length; i++) {
const section = sections[i];
const taskPosition = planContent.indexOf(section);
currentWave = 1;
for (const wp of wavePositions) {
if (wp.position <= taskPosition) {
currentWave = wp.wave;
}
}
const taskIdMatch = section.match(/([A-Z]+-\d+(?:-\d+)*)/);
const taskId = taskIdMatch ? taskIdMatch[1] : `T${++taskCounter}`;
const descriptionMatch = section.match(/^\s*\d*[\.\d]*\s*[:]?\s*(.+)/);
const description = descriptionMatch ? descriptionMatch[1].split("\n")[0].trim() : `Task ${taskId}`;
const files: string[] = [];
const filesMatch = section.match(/\*\*Files?\s+to\s+(?:create|modify)\*\*\s*\|?\s*(.+)/i);
if (filesMatch) {
const fileList = filesMatch[1].split(/[`,]/).map((f: string) => f.trim()).filter(Boolean);
files.push(...fileList);
}
const blockFiles = section.match(/`([^`]+\.(ts|js|json|sql|md|tsx|jsx|vue|svelte|css))`/g);
if (blockFiles) {
for (const bf of blockFiles) {
const cleaned = bf.replace(/`/g, "");
if (!files.includes(cleaned)) files.push(cleaned);
}
}
const requirements: string[] = [];
const reqMatch = section.match(/\*\*REQs?\*\*\s*\|?\s*(.+)/i);
if (reqMatch) {
const reqs = reqMatch[1].split(",").map((r: string) => r.trim()).filter(Boolean);
requirements.push(...reqs);
}
const dependencies: string[] = [];
const depMatch = section.match(/\*\*Dependencies?\*\*\s*\|?\s*(.+)/i);
if (depMatch) {
const deps = depMatch[1].split(",").map((d: string) => d.trim()).filter((d: string) => d && d !== "None");
dependencies.push(...deps);
}
tasks.push({
id: taskId,
description,
files,
requirements,
dependencies,
wave: currentWave,
});
}
return tasks;
}
private assignTasksToPersonas(
tasks: PlanTask[],
personas: ExecutePersonaConfig[]
): DecomposedTask[] {
const leadConfig = personas.find((p) => p.domain === "coordination") || personas[0];
const engineerConfigs = personas.filter((p) => p.domain !== "coordination");
return tasks.map((task) => {
const assignedPersona = this.assignPersona(task, personas);
const domain = this.determineDomain(task, assignedPersona);
return {
taskId: task.id,
persona: assignedPersona.name,
domain,
description: task.description,
files: task.files,
dependencies: task.dependencies,
};
});
}
private assignPersona(
task: PlanTask,
personas: ExecutePersonaConfig[]
): ExecutePersonaConfig {
if (task.files.length === 0 && task.description.length === 0) {
return personas.find((p) => p.domain === "coordination") || personas[0];
}
let bestPersona: ExecutePersonaConfig | null = null;
let bestScore = 0;
for (const persona of personas) {
if (persona.domain === "coordination") continue;
let score = 0;
for (const file of task.files) {
const matched = matchFileToPersona(file, personas);
if (matched && matched.name === persona.name) {
score += 3;
}
}
const domainKeywords = DOMAIN_KEYWORDS[persona.domain] || [];
const descLower = task.description.toLowerCase();
for (const keyword of domainKeywords) {
if (descLower.includes(keyword)) {
score += 1;
}
}
for (const req of task.requirements) {
const reqLower = req.toLowerCase();
for (const keyword of domainKeywords) {
if (reqLower.includes(keyword)) {
score += 1;
}
}
}
if (score > bestScore) {
bestScore = score;
bestPersona = persona;
}
}
if (bestPersona && bestScore > 0) {
return bestPersona;
}
if (task.files.length > 0) {
const firstFile = task.files[0];
const matched = matchFileToPersona(firstFile, personas);
if (matched) return matched;
}
return personas.find((p) => p.domain === "coordination") || personas[0];
}
private determineDomain(
task: PlanTask,
persona: ExecutePersonaConfig
): PersonaDomain {
return persona.domain as PersonaDomain;
}
private leadDeveloperResolve(conflict: TerritoryConflict): string {
switch (conflict.type) {
case "data-backend":
return `Lead developer assigns ${conflict.file} to backend engineer. Data engineer provides schema contract; backend implements API contract. Data changes should be in a separate migration.`;
case "backend-frontend":
return `Lead developer assigns ${conflict.file} to backend engineer. Frontend engineer adapts to backend API contract. If the file is primarily a type definition, create a shared types module.`;
case "data-frontend":
return `Lead developer assigns ${conflict.file} to data engineer for schema definition. Frontend engineer consumes through a backend API endpoint. Direct database access from frontend is prohibited.`;
default:
return `Lead developer arbitrates: ${conflict.file} assigned to ${conflict.personas[0]}. Other persona uses the public interface.`;
}
}
}
+9 -1
View File
@@ -9,6 +9,10 @@ export { GitBranch } from "./core/git-branch.js";
export { CommitBuilder } from "./core/commit-builder.js"; export { CommitBuilder } from "./core/commit-builder.js";
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js"; export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js"; export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
export { AgentSession } from "./core/agent-session.js";
export { SessionManager } from "./core/session-manager.js";
export { PersonaLoader } from "./core/persona-loader.js";
export { TaskDecomposer } from "./core/task-decomposer.js";
export { VerificationPipeline } from "./verification/index.js"; export { VerificationPipeline } from "./verification/index.js";
export { StructuralVerification } from "./verification/structural.js"; export { StructuralVerification } from "./verification/structural.js";
export { BehavioralVerification } from "./verification/behavioral.js"; export { BehavioralVerification } from "./verification/behavioral.js";
@@ -24,6 +28,8 @@ export { ESCALATION_TYPES } from "./types/escalation.js";
export { createClarifyQuestion } from "./types/clarify.js"; export { createClarifyQuestion } from "./types/clarify.js";
export { parseSpecification } from "./types/specification.js"; export { parseSpecification } from "./types/specification.js";
export { getNextStage, createInitialPipelineState } from "./types/pipeline.js"; export { getNextStage, createInitialPipelineState } from "./types/pipeline.js";
export { matchFileToPersona, detectConflicts, DEFAULT_PERSONAS } from "./types/persona.js";
export { DEFAULT_SESSION_CONFIG } from "./types/session.js";
export * as fileUtils from "./utils/file.js"; export * as fileUtils from "./utils/file.js";
export { resolveBackend, createBackend } from "./backends/index.js"; export { resolveBackend, createBackend } from "./backends/index.js";
export { OpencodeBackend } from "./backends/opencode.js"; export { OpencodeBackend } from "./backends/opencode.js";
@@ -47,4 +53,6 @@ export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMe
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js"; export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js";
export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js"; export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js";
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js"; export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js"; export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
export type { SessionInfo, SessionStatus, SessionConfig } from "./types/session.js";
export type { ExecutePersonaConfig, TerritoryEnforcement, PersonaDomain, DecomposedTask, DecomposedPlan, TerritoryConflict } from "./types/persona.js";
+1
View File
@@ -55,6 +55,7 @@ export interface CIAgentMetadata {
phase: number; phase: number;
milestone: string; milestone: string;
project?: string; project?: string;
session?: string;
plan?: string; plan?: string;
task?: string; task?: string;
status: PipelineStage; status: PipelineStage;
+33 -2
View File
@@ -1,5 +1,4 @@
import { BackendConfigSection } from "../backends/types.js"; import { TerritoryEnforcement, ExecutePersonaConfig } from "./persona.js";
import { IdeationConfig, IdeationCategory } from "./ideation.js";
export type AutonomyLevel = "full" | "supervised" | "guided"; export type AutonomyLevel = "full" | "supervised" | "guided";
@@ -94,8 +93,25 @@ export interface CIAgentConfig {
backend: BackendConfigSection; backend: BackendConfigSection;
gitea?: GiteaConfig; gitea?: GiteaConfig;
ideation?: IdeationConfig; ideation?: IdeationConfig;
sessions?: SessionConfig;
personas?: PersonaConfigSection;
} }
export interface SessionConfig {
max_concurrent_sessions: number;
session_timeout_ms: number;
session_isolation: "branch";
}
export interface PersonaConfigSection {
enabled: boolean;
territory_enforcement: TerritoryEnforcement;
personas: ExecutePersonaConfig[];
}
import { BackendConfigSection } from "../backends/types.js";
import { IdeationConfig, IdeationCategory } from "./ideation.js";
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = { export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
projects: [], projects: [],
active_project: "", active_project: "",
@@ -190,4 +206,19 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
scenarios: ["backend_unavailable", "requirement_change", "test_coverage_drop"], scenarios: ["backend_unavailable", "requirement_change", "test_coverage_drop"],
}, },
}, },
sessions: {
max_concurrent_sessions: 3,
session_timeout_ms: 3600000,
session_isolation: "branch",
},
personas: {
enabled: true,
territory_enforcement: "warn",
personas: [
{ name: "lead-developer", domain: "coordination", frameworks: [], constraints: ["pragmatic", "battle-tested defaults"], territory: [] },
{ name: "data-engineer", domain: "data", frameworks: ["drizzle", "postgresql"], constraints: ["schema-first", "type-safe ORM", "migration-driven"], territory: ["**/migrations/**", "**/schema/**", "**/models/**", "**/db/**", "prisma/schema.prisma", "drizzle/**", "**/*.sql"] },
{ name: "backend-engineer", domain: "backend", frameworks: ["fastify", "hono"], constraints: ["api-first", "strict-typing", "dependency-injection"], territory: ["**/api/**", "**/routes/**", "**/services/**", "**/middleware/**", "**/controllers/**", "**/auth/**"] },
{ name: "frontend-engineer", domain: "frontend", frameworks: ["react", "next.js"], constraints: ["component-first", "server-components", "minimal-client-js"], territory: ["**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**", "**/*.tsx", "**/*.css", "**/*.vue"] },
],
},
}; };
+168
View File
@@ -0,0 +1,168 @@
export type PersonaDomain = "data" | "backend" | "frontend" | "coordination";
export type TerritoryEnforcement = "warn" | "strict";
export interface ExecutePersonaConfig {
name: string;
domain: PersonaDomain;
frameworks: string[];
constraints: string[];
territory: string[];
}
export interface DecomposedTask {
taskId: string;
persona: string;
domain: PersonaDomain;
description: string;
files: string[];
dependencies: string[];
}
export interface DecomposedPlan {
tasks: DecomposedTask[];
dataTasks: DecomposedTask[];
backendTasks: DecomposedTask[];
frontendTasks: DecomposedTask[];
coordinationTasks: DecomposedTask[];
conflicts: TerritoryConflict[];
}
export interface TerritoryConflict {
type: "data-backend" | "backend-frontend" | "data-frontend";
file: string;
personas: string[];
description: string;
resolution?: string;
}
export const DEFAULT_PERSONAS: ExecutePersonaConfig[] = [
{
name: "lead-developer",
domain: "coordination",
frameworks: [],
constraints: ["pragmatic", "battle-tested defaults"],
territory: [],
},
{
name: "data-engineer",
domain: "data",
frameworks: ["drizzle", "postgresql"],
constraints: ["schema-first", "type-safe ORM", "migration-driven"],
territory: [
"**/migrations/**",
"**/schema/**",
"**/models/**",
"**/db/**",
"prisma/schema.prisma",
"drizzle/**",
"**/*.sql",
],
},
{
name: "backend-engineer",
domain: "backend",
frameworks: ["fastify", "hono"],
constraints: ["api-first", "strict-typing", "dependency-injection"],
territory: [
"**/api/**",
"**/routes/**",
"**/services/**",
"**/middleware/**",
"**/controllers/**",
"**/auth/**",
],
},
{
name: "frontend-engineer",
domain: "frontend",
frameworks: ["react", "next.js"],
constraints: ["component-first", "server-components", "minimal-client-js"],
territory: [
"**/components/**",
"**/pages/**",
"**/hooks/**",
"**/styles/**",
"**/*.tsx",
"**/*.css",
"**/*.vue",
],
},
];
export function matchFileToPersona(
filePath: string,
personas: ExecutePersonaConfig[]
): ExecutePersonaConfig | null {
const normalizedPath = filePath.replace(/\\/g, "/");
for (const persona of personas) {
if (persona.domain === "coordination") continue;
for (const pattern of persona.territory) {
const normalizedPattern = pattern.replace(/\\/g, "/");
if (globMatch(normalizedPattern, normalizedPath)) {
return persona;
}
}
}
return null;
}
export function globMatch(pattern: string, path: string): boolean {
const regexStr = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "§§")
.replace(/\*/g, "[^/]*")
.replace(/§§/g, ".*")
.replace(/\?/g, "[^/]");
const regex = new RegExp(`^${regexStr}$`);
return regex.test(path);
}
export function detectConflicts(
tasks: DecomposedTask[],
personas: ExecutePersonaConfig[]
): TerritoryConflict[] {
const conflicts: TerritoryConflict[] = [];
const filePersonaMap = new Map<string, string[]>();
for (const task of tasks) {
for (const file of task.files) {
if (!filePersonaMap.has(file)) {
filePersonaMap.set(file, []);
}
const personas_list = filePersonaMap.get(file)!;
if (!personas_list.includes(task.persona)) {
personas_list.push(task.persona);
}
}
}
for (const [file, claimingPersonas] of filePersonaMap) {
if (claimingPersonas.length > 1) {
const domains = claimingPersonas
.map((p) => personas.find((pe) => pe.name === p)?.domain)
.filter((d): d is PersonaDomain => d !== undefined);
let conflictType: TerritoryConflict["type"];
if (domains.includes("data") && domains.includes("backend")) {
conflictType = "data-backend";
} else if (domains.includes("backend") && domains.includes("frontend")) {
conflictType = "backend-frontend";
} else {
conflictType = "data-frontend";
}
conflicts.push({
type: conflictType,
file,
personas: claimingPersonas,
description: `File ${file} claimed by multiple personas: ${claimingPersonas.join(", ")}`,
});
}
}
return conflicts;
}
+29
View File
@@ -0,0 +1,29 @@
import { PipelineStage } from "./pipeline.js";
export type SessionStatus = "pending" | "running" | "paused" | "completed" | "failed" | "cancelled";
export type SessionIsolation = "branch";
export interface SessionConfig {
max_concurrent_sessions: number;
session_timeout_ms: number;
session_isolation: SessionIsolation;
}
export interface SessionInfo {
id: string;
project_slug: string;
project_path: string;
phase: number;
stage: PipelineStage;
status: SessionStatus;
started_at: string;
last_updated: string;
error?: string;
}
export const DEFAULT_SESSION_CONFIG: SessionConfig = {
max_concurrent_sessions: 3,
session_timeout_ms: 3600000,
session_isolation: "branch",
};
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "0.10.0"; export const VERSION = "0.11.0";