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