Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ab3b56b96 | |||
| 8c975352b8 |
@@ -30,6 +30,8 @@ src/
|
|||||||
core/ # Core engine components
|
core/ # Core engine components
|
||||||
artifacts.ts # Legacy .ciagent/ artifact management (retained for backward compat)
|
artifacts.ts # Legacy .ciagent/ artifact management (retained for backward compat)
|
||||||
audit.ts # Git-native audit trail — reads decisions/escalations from git log
|
audit.ts # Git-native audit trail — reads decisions/escalations from git log
|
||||||
|
agent-session.ts # Multi-session support: AgentSession, file-based git locking
|
||||||
|
session-manager.ts # SessionManager: concurrent session lifecycle management
|
||||||
ciagent-files.ts # .ciagent/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.)
|
ciagent-files.ts # .ciagent/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.)
|
||||||
clarify.ts # Clarify phase: question generation, default acceptance
|
clarify.ts # Clarify phase: question generation, default acceptance
|
||||||
commit-builder.ts # Structured commit message generation (---ci--- YAML blocks)
|
commit-builder.ts # Structured commit message generation (---ci--- YAML blocks)
|
||||||
@@ -40,14 +42,18 @@ src/
|
|||||||
escalation.ts # Escalation protocol: commits escalations as git artifacts
|
escalation.ts # Escalation protocol: commits escalations as git artifacts
|
||||||
git-branch.ts # Branch lifecycle: phase/NN-slug, milestone/vX.X-slug
|
git-branch.ts # Branch lifecycle: phase/NN-slug, milestone/vX.X-slug
|
||||||
git-context.ts # Project state reconstruction from git log + branches
|
git-context.ts # Project state reconstruction from git log + branches
|
||||||
|
persona-loader.ts # Execute-time persona resolution from .config/opencode/agents/*.md
|
||||||
|
task-decomposer.ts # Plan decomposition into data/backend/frontend task groups
|
||||||
types/ # Type definitions
|
types/ # Type definitions
|
||||||
commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit
|
commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit (includes session field)
|
||||||
config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, DEFAULT_CIAGENT_CONFIG (includes backend)
|
config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, SessionConfig, PersonaConfigSection, DEFAULT_CIAGENT_CONFIG (includes backend)
|
||||||
decisions.ts # Decision, ConfidenceLevel, DecisionCategory
|
decisions.ts # Decision, ConfidenceLevel, DecisionCategory
|
||||||
escalation.ts # Escalation, EscalationType, EscalationResolution
|
escalation.ts # Escalation, EscalationType, EscalationResolution
|
||||||
clarify.ts # ClarifyQuestion, ClarifyResult
|
clarify.ts # ClarifyQuestion, ClarifyResult
|
||||||
specification.ts # Specification parser (objective, requirements, constraints, out_of_scope)
|
specification.ts # Specification parser (objective, requirements, constraints, out_of_scope)
|
||||||
pipeline.ts # PipelineStage, PipelineState, PhaseResult, STAGE_ORDER
|
pipeline.ts # PipelineStage, PipelineState, PhaseResult, STAGE_ORDER
|
||||||
|
persona.ts # ExecutePersonaConfig, PersonaDomain, TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS
|
||||||
|
session.ts # SessionInfo, SessionStatus, SessionConfig, DEFAULT_SESSION_CONFIG
|
||||||
utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON)
|
utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON)
|
||||||
verification/ # 4-layer verification pipeline
|
verification/ # 4-layer verification pipeline
|
||||||
structural.ts # Layer 1: file existence, imports wired, no stubs
|
structural.ts # Layer 1: file existence, imports wired, no stubs
|
||||||
@@ -197,7 +203,8 @@ IntelligenceBackend (unified interface)
|
|||||||
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
|
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
|
||||||
- **v0.9.0**: Integration & hardening — OpenAI and Anthropic backends, all 19 agents with intrinsic mechanical logic, E2E v0.9 integration tests, parallel agent execution
|
- **v0.9.0**: Integration & hardening — OpenAI and Anthropic backends, all 19 agents with intrinsic mechanical logic, E2E v0.9 integration tests, parallel agent execution
|
||||||
- **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows
|
- **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows
|
||||||
- **New in v0.10**: IdeationEngine with mechanical/backend-enriched/cross-project tiers, `ciagent ideate` command with --category/--affected/--spec/--external/--cross-project/--project/--output flags, `IDEATE` pipeline stage between RESEARCH and PLAN, multi-project support with `active_projects` config and `--project all` flag, `---ci--- project: <slug>` commit blocks, `max_concurrent_projects` parallelization config
|
- **New in v0.11**: Multi-session support with `SessionManager` and `AgentSession` for independent project pipelines running concurrently, execute-phase persona specialization (`lead-developer`, `data-engineer`, `backend-engineer`, `frontend-engineer`) with territory enforcement and task decomposition, `ciagent sessions` CLI command with list/status/cancel/cleanup subcommands, `--session <id>` flag on `ciagent run`, `---ci--- session:` commit metadata field, `sessions` and `personas` config sections
|
||||||
|
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
|
||||||
- **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation)
|
- **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation)
|
||||||
- **Config expansion (v0.10)**: `ideation` section in config with categories, thresholds, external signals, cross-project, chaos; `active_projects` array; `max_concurrent_projects` in parallelization
|
- **Config expansion (v0.10)**: `ideation` section in config with categories, thresholds, external signals, cross-project, chaos; `active_projects` array; `max_concurrent_projects` in parallelization
|
||||||
- **Auto-detection order**: opencode → openai → ollama-local → ollama-cloud → anthropic
|
- **Auto-detection order**: opencode → openai → ollama-local → ollama-cloud → anthropic
|
||||||
|
|||||||
+148
-33
@@ -1,16 +1,16 @@
|
|||||||
---
|
---
|
||||||
description: Execute the full CIAgent pipeline — research → plan → execute → verify → complete for the current or specified phase
|
description: Execute the full CIAgent pipeline — specify → clarify → research → ideate → plan → execute → ship → verify → complete for the current or specified phase
|
||||||
---
|
---
|
||||||
|
|
||||||
# CIAgent Run
|
# CIAgent Run
|
||||||
|
|
||||||
Execute the full CIAgent pipeline from the current stage to completion. The orchestrator iterates through stages and delegates to specialized agents.
|
Execute the full CIAgent pipeline from the current stage to completion. The orchestrator iterates through stages and delegates to specialized agents and sub-workflows.
|
||||||
|
|
||||||
**Usage:** `ciagent-run [phase_number]`
|
**Usage:** `ciagent-run [phase_number]`
|
||||||
|
|
||||||
If no phase number specified, continues from the current phase (detected from git log).
|
If no phase number specified, continues from the current phase (detected from git log).
|
||||||
|
|
||||||
## Step 0: Confirm Active Project
|
## Step 0: Confirm Active Project and Session
|
||||||
|
|
||||||
Check `ci listProjects()` or read `.ciagent/config.json` to determine if multi-project mode is active.
|
Check `ci listProjects()` or read `.ciagent/config.json` to determine if multi-project mode is active.
|
||||||
|
|
||||||
@@ -20,13 +20,21 @@ If `.ciagent/config.json` has `projects[]` with length > 0, or `active_projects`
|
|||||||
- If `--project <slug>` is specified: run for that project only
|
- If `--project <slug>` is specified: run for that project only
|
||||||
- If no `--project` flag: use first project in `active_projects`
|
- If no `--project` flag: use first project in `active_projects`
|
||||||
- All commit messages must include `project: <slug>` in `---ci---` block
|
- All commit messages must include `project: <slug>` in `---ci---` block
|
||||||
|
- All `.ciagent/` file reads use `.ciagent/<slug>/` subdirectory paths
|
||||||
|
- Branch names are prefixed with `<slug>/` (e.g., `<slug>/phase/01-auth`, `<slug>/milestone/v0.2-auth`)
|
||||||
|
|
||||||
For multi-project execution (`--project all`):
|
For multi-project execution (`--project all`):
|
||||||
- Execute pipeline for each project sequentially by default
|
- Execute pipeline for each project sequentially by default
|
||||||
- When `parallelization.enabled=true`: execute projects concurrently up to `max_concurrent_agents`
|
- When `parallelization.enabled=true`: execute projects concurrently up to `max_concurrent_agents`
|
||||||
- Each project has independent phase branches and milestone tracking
|
- Each project has independent phase branches and milestone tracking
|
||||||
|
- Sessions (if configured): each project gets its own `AgentSession` with branch isolation per `config.json sessions.session_isolation`
|
||||||
|
|
||||||
If single-project mode: proceed with existing conventions.
|
For multi-persona execution (when `config.json personas.enabled=true`):
|
||||||
|
- Lead-developer persona decomposes tasks by territory file patterns and requirement IDs
|
||||||
|
- Each persona group executes tasks within their territory
|
||||||
|
- Territory enforcement runs in `warn` or `strict` mode per `config.json personas.territory_enforcement`
|
||||||
|
|
||||||
|
If single-project mode: proceed with existing conventions (flat `.ciagent/` paths, no project prefix on branches).
|
||||||
|
|
||||||
## Step 1: Load Git Context
|
## Step 1: Load Git Context
|
||||||
|
|
||||||
@@ -40,66 +48,173 @@ Determine current state:
|
|||||||
- Current milestone from latest `---ci---` block or active milestone branch
|
- Current milestone from latest `---ci---` block or active milestone branch
|
||||||
- Current pipeline stage from latest `---ci---` status field
|
- Current pipeline stage from latest `---ci---` status field
|
||||||
- Completed phases from merged `phase/NN-*` branches
|
- Completed phases from merged `phase/NN-*` branches
|
||||||
|
- Active project from `---ci---` project field (multi-project mode)
|
||||||
|
|
||||||
## Step 2: Pre-Flight Check
|
## Step 2: Pre-Flight Check
|
||||||
|
|
||||||
Verify `.ciagent/config.json` exists. If missing: stop, run `ciagent-init` first.
|
Verify `.ciagent/config.json` exists. If missing: stop, run `ciagent-init` first.
|
||||||
|
|
||||||
Read `.ciagent/PROJECT.md` and `.ciagent/ROADMAP.md` for phase goals.
|
Resolve project paths based on mode:
|
||||||
|
- **Multi-project**: read `.ciagent/<slug>/PROJECT.md` and `.ciagent/<slug>/ROADMAP.md` for the active project
|
||||||
|
- **Single-project**: read `.ciagent/PROJECT.md` and `.ciagent/ROADMAP.md`
|
||||||
|
|
||||||
|
Read phase goals and milestone context from the resolved files.
|
||||||
|
|
||||||
## Step 3: Execute Pipeline Stages
|
## Step 3: Execute Pipeline Stages
|
||||||
|
|
||||||
For each stage in order (starting from current or from `specify`):
|
For each stage in order (starting from current or from `specify`):
|
||||||
|
|
||||||
### SPECIFY
|
### SPECIFY
|
||||||
- Parse specification from `.ciagent/PROJECT.md`
|
|
||||||
- Validate requirements exist in `.ciagent/REQUIREMENTS.md`
|
- Resolve active project from `config.json`
|
||||||
|
- Parse specification from `.ciagent/<slug>/PROJECT.md` (multi-project) or `.ciagent/PROJECT.md` (single-project)
|
||||||
|
- Validate requirements exist in `.ciagent/<slug>/REQUIREMENTS.md` (multi-project) or `.ciagent/REQUIREMENTS.md` (single-project)
|
||||||
- Commit: `docs(init): validate specification`
|
- Commit: `docs(init): validate specification`
|
||||||
|
|
||||||
|
```
|
||||||
|
---ci---
|
||||||
|
project: <slug>
|
||||||
|
phase: 0
|
||||||
|
milestone: v0.X
|
||||||
|
status: specify
|
||||||
|
---/ci---
|
||||||
|
```
|
||||||
|
|
||||||
### CLARIFY
|
### CLARIFY
|
||||||
- Generate clarify questions for ambiguities
|
|
||||||
- Default-accept at `full` autonomy, present at `supervised`/`guided`
|
**Delegate to `ciagent-clarify` workflow.** Do not reimplement inline.
|
||||||
- Commit: `decision(P##): clarification decisions`
|
|
||||||
|
The clarify workflow handles:
|
||||||
|
- Multi-project active project confirmation
|
||||||
|
- Git context loading
|
||||||
|
- Ambiguity identification and question generation
|
||||||
|
- Autonomy-based resolution (full/supervised/guided)
|
||||||
|
- Clarification commits with `---ci---` blocks
|
||||||
|
- `.ciagent/<slug>/PROJECT.md` and `.ciagent/<slug>/REQUIREMENTS.md` updates
|
||||||
|
|
||||||
|
Pass the current phase number and active project slug. Collect the result and proceed.
|
||||||
|
|
||||||
### RESEARCH
|
### RESEARCH
|
||||||
|
|
||||||
|
- Resolve active project from `config.json`; use `.ciagent/<slug>/` paths
|
||||||
- Delegate to ci-researcher
|
- Delegate to ci-researcher
|
||||||
- Research domain, ecosystem, prior art
|
- Research domain, ecosystem, prior art
|
||||||
- Update `.ciagent/` static files with conclusions
|
- Update `.ciagent/<slug>/` static files with conclusions (ARCHITECTURE.md, PROJECT.md, etc.)
|
||||||
- Commit: `docs(P##): research findings`
|
- Commit: `docs(P##): research findings`
|
||||||
|
|
||||||
|
```
|
||||||
|
---ci---
|
||||||
|
project: <slug>
|
||||||
|
phase: [N]
|
||||||
|
milestone: [vX.X]
|
||||||
|
status: research
|
||||||
|
---/ci---
|
||||||
|
```
|
||||||
|
|
||||||
### IDEATE (when --ideate flag is passed)
|
### IDEATE (when --ideate flag is passed)
|
||||||
- Delegate to ci-ideation-agent
|
|
||||||
- Mine git history for patterns, analyze coverage gaps, detect drift
|
**Delegate to `ciagent-ideate` workflow.** Do not reimplement inline.
|
||||||
- If backend available: enrich with LLM suggestions
|
|
||||||
- If --cross-project: mine patterns from other projects
|
The ideate workflow handles:
|
||||||
- Present recommendations interactively (accept/skip/modify)
|
- Multi-project context and `--project` flags
|
||||||
- Accepted ideas update ROADMAP.md and REQUIREMENTS.md
|
- All three tiers (mechanical, backend-enriched, cross-project)
|
||||||
- Commit: `decision(P##): ideation results — [N] accepted, [M] skipped`
|
- Interactive validation (accept/skip/modify)
|
||||||
|
- Updates to `.ciagent/<slug>/REQUIREMENTS.md`, `.ciagent/<slug>/ROADMAP.md`, `.ciagent/<slug>/ARCHITECTURE.md`, `.ciagent/<slug>/PROJECT.md`
|
||||||
|
- Ideation commit with `---ci---` block
|
||||||
|
|
||||||
|
Pass the active project slug and any `--ideate` flags. Collect accepted ideas and proceed.
|
||||||
|
|
||||||
### PLAN
|
### PLAN
|
||||||
- Delegate to ci-planner
|
|
||||||
|
- Resolve active project from `config.json`; use `.ciagent/<slug>/` paths
|
||||||
|
- Delegate to ci-planner with full project context
|
||||||
- Create vertical-slice plans with wave ordering
|
- Create vertical-slice plans with wave ordering
|
||||||
|
- Plans reference requirement IDs from `.ciagent/<slug>/REQUIREMENTS.md`
|
||||||
- Commit: `docs(P##): create [N] phase plans`
|
- Commit: `docs(P##): create [N] phase plans`
|
||||||
|
|
||||||
|
```
|
||||||
|
---ci---
|
||||||
|
project: <slug>
|
||||||
|
phase: [N]
|
||||||
|
milestone: [vX.X]
|
||||||
|
status: plan
|
||||||
|
---/ci---
|
||||||
|
```
|
||||||
|
|
||||||
### EXECUTE
|
### EXECUTE
|
||||||
- Create phase branch: `phase/NN-slug`
|
|
||||||
|
- Create phase branch: `<slug>/phase/NN-slug` (multi-project) or `phase/NN-slug` (single-project)
|
||||||
- Delegate to ci-executor per plan per wave
|
- Delegate to ci-executor per plan per wave
|
||||||
|
- **Multi-persona development**: if `config.json personas.enabled=true`:
|
||||||
|
- Lead-developer decomposes tasks by territory file patterns and requirement IDs
|
||||||
|
- Each persona executes tasks within their declared territory (config.json `personas[].territory`)
|
||||||
|
- Territory enforcement runs in configured mode (`warn` or `strict`)
|
||||||
|
- Primary persona (i=0) executes sequentially; review personas (i>0) execute in parallel
|
||||||
|
- Persona constraints (frameworks, constraints arrays) guide implementation choices
|
||||||
- Commit each task with `---ci---` block
|
- Commit each task with `---ci---` block
|
||||||
- After all waves: commit phase completion
|
- After all waves complete: **ship the phase** by delegating to `ciagent-ship` workflow
|
||||||
|
|
||||||
|
**Ship gate**: a phase MUST be shipped before advancing to the next phase. The ship workflow handles:
|
||||||
|
- Pre-flight validation (milestone type, branch hierarchy, tag sequence, autonomy)
|
||||||
|
- Test execution (test, typecheck, build)
|
||||||
|
- PR creation and auto-merge
|
||||||
|
- Version computation and tagging
|
||||||
|
- Branch merging (phase → milestone or phase → main)
|
||||||
|
- Gitea release creation
|
||||||
|
|
||||||
|
If the ship fails: do NOT advance to VERIFY. Iterate until the phase ships successfully.
|
||||||
|
|
||||||
### VERIFY
|
### VERIFY
|
||||||
- Delegate to ci-verifier
|
|
||||||
- Check must_haves, requirement coverage, integration links
|
|
||||||
- Auto-generate tests for unverifiable items
|
|
||||||
- Commit: `verify(P##): verification result`
|
|
||||||
|
|
||||||
### COMPLETE
|
**Delegate to `ciagent-verify` workflow.** Do not reimplement inline.
|
||||||
- Merge phase branch into main (squash)
|
|
||||||
- Tag with patch version (e.g., `v0.2.3` — 3rd phase in milestone v0.2)
|
The verify workflow handles:
|
||||||
- Create Gitea release for the tag
|
- Multi-project scoping and active project confirmation
|
||||||
- Update `.ciagent/REQUIREMENTS.md` requirement statuses
|
- Four verification layers (structural, behavioral, security, quality)
|
||||||
- Update `.ciagent/ROADMAP.md` phase status
|
- Auto-generated tests for unverifiable items
|
||||||
- Commit: `docs(P##): complete [phase-name] phase`
|
- Verification commit with `---ci---` block
|
||||||
|
|
||||||
|
Pass the current phase number and active project slug. Collect the verification result and proceed.
|
||||||
|
|
||||||
|
### COMPLETE (milestone completion gate)
|
||||||
|
|
||||||
|
The COMPLETE stage is reached only after ALL phases in the milestone have been shipped and verified. It orchestrates milestone-level finalization through three sub-workflows with a feedback loop:
|
||||||
|
|
||||||
|
1. **Trigger `ciagent-review`** — multi-persona code review across all phases in the milestone
|
||||||
|
- Reviews all changes in the milestone branch
|
||||||
|
- Auto-applies P0 fixes, flags P1+ for post-hoc review
|
||||||
|
- If P1+ issues found: send them back to the EXECUTE stage for remediation
|
||||||
|
|
||||||
|
2. **Trigger `ciagent-ship` (milestone)** — ship the entire milestone
|
||||||
|
- Merge milestone branch into main
|
||||||
|
- Tag with milestone version (minor for feature, major for major milestone)
|
||||||
|
- Create Gitea release for the milestone with full phase summary
|
||||||
|
- Build and upload distribution packages
|
||||||
|
|
||||||
|
3. **Trigger `ciagent-audit`** — verify project health
|
||||||
|
- Reconstruction test: verify git log matches `.ciagent/` files
|
||||||
|
- Check `.ciagent/` file discipline and branch hygiene
|
||||||
|
- Check commit discipline
|
||||||
|
- If audit finds issues: document them, send critical issues back to EXECUTE
|
||||||
|
|
||||||
|
4. **Feedback loop**: if review or audit produces pending issues that require code changes, loop back to EXECUTE → SHIP → VERIFY for those fixes before re-attempting COMPLETE.
|
||||||
|
|
||||||
|
5. **If no pending issues from review/audit and audit is clean**: complete the milestone:
|
||||||
|
- Update `.ciagent/<slug>/REQUIREMENTS.md` — mark all milestone requirements as complete
|
||||||
|
- Update `.ciagent/<slug>/ROADMAP.md` — mark milestone as complete
|
||||||
|
- Commit: `docs(milestone): complete [milestone-name]`
|
||||||
|
|
||||||
|
```
|
||||||
|
---ci---
|
||||||
|
project: <slug>
|
||||||
|
phase: 0
|
||||||
|
milestone: [vX.Y]
|
||||||
|
status: complete
|
||||||
|
requirements:
|
||||||
|
covered: [REQ-01, REQ-02, ...]
|
||||||
|
partial: []
|
||||||
|
---/ci---
|
||||||
|
```
|
||||||
|
|
||||||
Versioning: Major milestone = breaking schema changes, Feature milestone = milestone completion (minor), Patch = every phase.
|
Versioning: Major milestone = breaking schema changes, Feature milestone = milestone completion (minor), Patch = every phase.
|
||||||
|
|
||||||
@@ -108,7 +223,7 @@ Versioning: Major milestone = breaking schema changes, Feature milestone = miles
|
|||||||
Between phases, perform a context reset:
|
Between phases, perform a context reset:
|
||||||
|
|
||||||
1. Commit all work from the current phase
|
1. Commit all work from the current phase
|
||||||
2. Update `.ciagent/` files (phase status, requirement statuses)
|
2. Update `.ciagent/<slug>/` files (phase status, requirement statuses)
|
||||||
3. Verify `GitContext.reconstructState()` matches expected state
|
3. Verify `GitContext.reconstructState()` matches expected state
|
||||||
4. Reset context: spawn fresh agent (opencode) or re-read git context (platforms without subagents)
|
4. Reset context: spawn fresh agent (opencode) or re-read git context (platforms without subagents)
|
||||||
5. Next phase begins with fresh context from git log only
|
5. Next phase begins with fresh context from git log only
|
||||||
|
|||||||
+190
-4
@@ -2,6 +2,11 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import { TaskDecomposer } from "../core/task-decomposer.js";
|
||||||
|
import { PersonaLoader } from "../core/persona-loader.js";
|
||||||
|
import { TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS } from "../types/persona.js";
|
||||||
|
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||||
|
import { loadConfig } from "../core/config.js";
|
||||||
|
|
||||||
export interface ExecutorResult {
|
export interface ExecutorResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -17,6 +22,17 @@ interface MustHaveItem {
|
|||||||
passed: boolean;
|
passed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PersonaTaskGroup {
|
||||||
|
persona: string;
|
||||||
|
domain: string;
|
||||||
|
tasks: Array<{
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
files: string[];
|
||||||
|
}>;
|
||||||
|
conflicts: TerritoryConflict[];
|
||||||
|
}
|
||||||
|
|
||||||
export class ExecutorAgent extends BaseAgent {
|
export class ExecutorAgent extends BaseAgent {
|
||||||
readonly name = "executor";
|
readonly name = "executor";
|
||||||
readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints.";
|
readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints.";
|
||||||
@@ -27,6 +43,14 @@ export class ExecutorAgent extends BaseAgent {
|
|||||||
this.log("Executing tasks...");
|
this.log("Executing tasks...");
|
||||||
|
|
||||||
if (context.backend) {
|
if (context.backend) {
|
||||||
|
const config = this.loadProjectConfig(context);
|
||||||
|
const personasEnabled = config.personas?.enabled !== false;
|
||||||
|
|
||||||
|
if (personasEnabled) {
|
||||||
|
this.log("Persona-based execution enabled — decomposing plan and assigning to personas");
|
||||||
|
return this.executeWithPersonas(context, config);
|
||||||
|
}
|
||||||
|
|
||||||
const taskPrompt = await this.buildBackendTaskPrompt(context);
|
const taskPrompt = await this.buildBackendTaskPrompt(context);
|
||||||
const backendResult = await this.executeViaBackend(context, taskPrompt);
|
const backendResult = await this.executeViaBackend(context, taskPrompt);
|
||||||
|
|
||||||
@@ -50,6 +74,156 @@ export class ExecutorAgent extends BaseAgent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async executeWithPersonas(
|
||||||
|
context: AgentContext,
|
||||||
|
config: CIAgentConfig
|
||||||
|
): Promise<AgentResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
const planContent = this.readPlanFile(context);
|
||||||
|
if (!planContent) {
|
||||||
|
this.log("No plan file found — falling back to standard execution");
|
||||||
|
const taskPrompt = await this.buildBackendTaskPrompt(context);
|
||||||
|
return this.executeViaBackend(context, taskPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decomposer = new TaskDecomposer(context.project_path, config, context.project_slug);
|
||||||
|
const plan = decomposer.decompose(planContent);
|
||||||
|
const resolvedPlan = decomposer.resolveConflicts(plan);
|
||||||
|
|
||||||
|
this.log(`Decomposed plan into ${resolvedPlan.tasks.length} tasks across domains: data=${resolvedPlan.dataTasks.length}, backend=${resolvedPlan.backendTasks.length}, frontend=${resolvedPlan.frontendTasks.length}, coordination=${resolvedPlan.coordinationTasks.length}`);
|
||||||
|
|
||||||
|
if (resolvedPlan.conflicts.length > 0) {
|
||||||
|
this.log(`Resolved ${resolvedPlan.conflicts.length} territory conflicts`);
|
||||||
|
for (const conflict of resolvedPlan.conflicts) {
|
||||||
|
this.log(` Conflict: ${conflict.description} → ${conflict.resolution || "unresolved"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const personaGroups = this.groupTasksByPersona(resolvedPlan);
|
||||||
|
const personaLoader = new PersonaLoader(context.project_path, config);
|
||||||
|
const enforcement = config.personas?.territory_enforcement || "warn";
|
||||||
|
|
||||||
|
let totalDecisions = 0;
|
||||||
|
let totalEscalations = 0;
|
||||||
|
const allArtifacts: string[] = [];
|
||||||
|
let lastError: string | undefined;
|
||||||
|
|
||||||
|
const domainOrder: string[] = ["data", "backend", "frontend", "coordination"];
|
||||||
|
const sortedGroups = domainOrder
|
||||||
|
.flatMap((domain) => personaGroups.filter((g) => g.domain === domain))
|
||||||
|
.concat(personaGroups.filter((g) => !domainOrder.includes(g.domain)));
|
||||||
|
|
||||||
|
for (const group of sortedGroups) {
|
||||||
|
this.log(`Executing group: persona=${group.persona}, domain=${group.domain}, tasks=${group.tasks.length}`);
|
||||||
|
|
||||||
|
for (const conflict of group.conflicts) {
|
||||||
|
if (enforcement === "strict") {
|
||||||
|
this.warn(`Territory conflict (strict): ${conflict.description}`);
|
||||||
|
totalEscalations++;
|
||||||
|
} else {
|
||||||
|
this.log(`Territory conflict (warn): ${conflict.description} — ${conflict.resolution || "auto-resolved"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const persona = personaLoader.getPersona(group.persona);
|
||||||
|
const personaContext = this.buildPersonaContext(context, persona, group);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.executeViaBackend(personaContext, personaContext.specification);
|
||||||
|
|
||||||
|
if (Array.isArray(result.artifacts_created)) {
|
||||||
|
allArtifacts.push(...result.artifacts_created);
|
||||||
|
}
|
||||||
|
totalDecisions += result.decisions;
|
||||||
|
totalEscalations += result.escalations;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
this.warn(`Persona ${group.persona} reported issues: ${result.error || "unspecified"}`);
|
||||||
|
lastError = result.error;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.warn(`Persona ${group.persona} failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
lastError = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await this.verifyExecution(context);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: verification.testsPassing || lastError === undefined,
|
||||||
|
output: `Executed ${resolvedPlan.tasks.length} tasks across ${personaGroups.length} persona groups. Verification: tests=${verification.testsPassing ? "passing" : "failing"}, must-haves=${verification.mustHavesChecked.length}`,
|
||||||
|
artifacts_created: allArtifacts,
|
||||||
|
decisions: totalDecisions,
|
||||||
|
escalations: totalEscalations,
|
||||||
|
duration_ms: Date.now() - start,
|
||||||
|
error: lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupTasksByPersona(plan: DecomposedPlan): PersonaTaskGroup[] {
|
||||||
|
const groupMap = new Map<string, PersonaTaskGroup>();
|
||||||
|
|
||||||
|
for (const task of plan.tasks) {
|
||||||
|
const key = task.persona;
|
||||||
|
if (!groupMap.has(key)) {
|
||||||
|
groupMap.set(key, {
|
||||||
|
persona: task.persona,
|
||||||
|
domain: task.domain,
|
||||||
|
tasks: [],
|
||||||
|
conflicts: plan.conflicts.filter((c) => c.personas.includes(task.persona)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
groupMap.get(key)!.tasks.push({
|
||||||
|
id: task.taskId,
|
||||||
|
description: task.description,
|
||||||
|
files: task.files,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groupMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPersonaContext(
|
||||||
|
context: AgentContext,
|
||||||
|
persona: ReturnType<PersonaLoader["getPersona"]>,
|
||||||
|
group: PersonaTaskGroup
|
||||||
|
): AgentContext {
|
||||||
|
const personaPrompt = persona
|
||||||
|
? `You are the ${persona.name} (${persona.domain} domain). ${persona.systemPromptAdditions || persona.description}.\n\nPreferred frameworks: ${persona.frameworks.join(", ")}.\nDesign constraints: ${persona.constraints.join(", ")}.\nTerritory files: ${persona.territory.join(", ")}.\n\n`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const taskDescriptions = group.tasks
|
||||||
|
.map((t) => `- [${t.id}] ${t.description} (files: ${t.files.join(", ") || "TBD"})`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const conflictNotes = group.conflicts.length > 0
|
||||||
|
? `\n\n## Territory Conflicts (resolved by lead developer)\n${group.conflicts.map((c) => `- ${c.description} → Resolution: ${c.resolution || "pending"}`).join("\n")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const specification = [
|
||||||
|
personaPrompt,
|
||||||
|
"## Assigned Tasks\n",
|
||||||
|
taskDescriptions,
|
||||||
|
conflictNotes,
|
||||||
|
"\n\n## Specification\n",
|
||||||
|
context.specification || "No specification provided",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...context,
|
||||||
|
specification,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadProjectConfig(context: AgentContext): CIAgentConfig {
|
||||||
|
try {
|
||||||
|
return loadConfig(context.project_path);
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_CIAGENT_CONFIG as CIAgentConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
|
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
|
||||||
const parts: string[] = [
|
const parts: string[] = [
|
||||||
`Execute implementation for stage ${context.stage}, phase ${context.phase}.`,
|
`Execute implementation for stage ${context.stage}, phase ${context.phase}.`,
|
||||||
@@ -64,8 +238,12 @@ export class ExecutorAgent extends BaseAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ciDir = path.join(context.project_path, ".ciagent");
|
const ciDir = path.join(context.project_path, ".ciagent");
|
||||||
const roadmapPath = path.join(ciDir, "ROADMAP.md");
|
const roadmapPath = context.project_slug
|
||||||
const archPath = path.join(ciDir, "ARCHITECTURE.md");
|
? path.join(ciDir, context.project_slug, "ROADMAP.md")
|
||||||
|
: path.join(ciDir, "ROADMAP.md");
|
||||||
|
const archPath = context.project_slug
|
||||||
|
? path.join(ciDir, context.project_slug, "ARCHITECTURE.md")
|
||||||
|
: path.join(ciDir, "ARCHITECTURE.md");
|
||||||
|
|
||||||
if (fs.existsSync(roadmapPath)) {
|
if (fs.existsSync(roadmapPath)) {
|
||||||
try {
|
try {
|
||||||
@@ -91,11 +269,17 @@ export class ExecutorAgent extends BaseAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readPlanFile(context: AgentContext): string | null {
|
private readPlanFile(context: AgentContext): string | null {
|
||||||
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
|
const planPath = context.project_slug
|
||||||
|
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
|
||||||
|
: path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(planPath)) {
|
if (fs.existsSync(planPath)) {
|
||||||
return fs.readFileSync(planPath, "utf-8");
|
return fs.readFileSync(planPath, "utf-8");
|
||||||
}
|
}
|
||||||
|
const defaultPlanPath = path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||||
|
if (fs.existsSync(defaultPlanPath)) {
|
||||||
|
return fs.readFileSync(defaultPlanPath, "utf-8");
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -139,7 +323,9 @@ export class ExecutorAgent extends BaseAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private checkMustHaves(context: AgentContext): MustHaveItem[] {
|
private checkMustHaves(context: AgentContext): MustHaveItem[] {
|
||||||
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
|
const planPath = context.project_slug
|
||||||
|
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
|
||||||
|
: path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||||
const results: MustHaveItem[] = [];
|
const results: MustHaveItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { loadConfig, saveConfig, isCIAgentInitialized, initCIAgent } from "../co
|
|||||||
import { getAgent } from "./index.js";
|
import { getAgent } from "./index.js";
|
||||||
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
|
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
|
||||||
import { registerEscalationProtocol } from "../cli/index.js";
|
import { registerEscalationProtocol } from "../cli/index.js";
|
||||||
|
import { SessionManager } from "../core/session-manager.js";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
export interface GitAgentContext extends AgentContext {
|
export interface GitAgentContext extends AgentContext {
|
||||||
@@ -894,6 +895,36 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
|
|
||||||
this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`);
|
this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`);
|
||||||
|
|
||||||
|
const useSessions = config.sessions?.max_concurrent_sessions !== undefined;
|
||||||
|
|
||||||
|
if (useSessions) {
|
||||||
|
return this.runWithSessionManager(context, activeProjects, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.runWithLegacyParallel(context, activeProjects, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runWithSessionManager(
|
||||||
|
context: AgentContext,
|
||||||
|
activeProjects: string[],
|
||||||
|
config: CIAgentConfig
|
||||||
|
): Promise<Record<string, AgentResult>> {
|
||||||
|
const sessionManager = new SessionManager(context.project_path, config);
|
||||||
|
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
|
||||||
|
|
||||||
|
const contextFactory = (slug: string): AgentContext => ({
|
||||||
|
...context,
|
||||||
|
project_slug: slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sessionManager.runAllSessions(activeProjects, contextFactory, parallel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runWithLegacyParallel(
|
||||||
|
context: AgentContext,
|
||||||
|
activeProjects: string[],
|
||||||
|
config: CIAgentConfig
|
||||||
|
): Promise<Record<string, AgentResult>> {
|
||||||
const results: Record<string, AgentResult> = {};
|
const results: Record<string, AgentResult> = {};
|
||||||
const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3;
|
const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3;
|
||||||
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
|
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { BackendUnavailableError } from "../backends/types.js";
|
|||||||
import { getAgent } from "../agents/index.js";
|
import { getAgent } from "../agents/index.js";
|
||||||
import { CIAgentFiles } from "../core/ciagent-files.js";
|
import { CIAgentFiles } from "../core/ciagent-files.js";
|
||||||
import { GiteaClient, generateReleaseNotes } from "../core/gitea.js";
|
import { GiteaClient, generateReleaseNotes } from "../core/gitea.js";
|
||||||
|
import { SessionManager } from "../core/session-manager.js";
|
||||||
|
import { AgentSession } from "../core/agent-session.js";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import * as readline from "node:readline";
|
import * as readline from "node:readline";
|
||||||
@@ -172,6 +174,7 @@ export function createRunCommand(): Command {
|
|||||||
.option("--backend <provider>", "Override intelligence backend for this run")
|
.option("--backend <provider>", "Override intelligence backend for this run")
|
||||||
.option("--ideate", "Insert ideation stage between research and plan")
|
.option("--ideate", "Insert ideation stage between research and plan")
|
||||||
.option("--project <slug>", "Target project slug (comma-separated or 'all')")
|
.option("--project <slug>", "Target project slug (comma-separated or 'all')")
|
||||||
|
.option("--session <id>", "Resume a specific session by ID")
|
||||||
.action(async (phase, options) => {
|
.action(async (phase, options) => {
|
||||||
const projectPath = process.cwd();
|
const projectPath = process.cwd();
|
||||||
|
|
||||||
@@ -1372,4 +1375,145 @@ export function createIdeateCommand(): Command {
|
|||||||
console.log(` ${cat}: ${count}`);
|
console.log(` ${cat}: ${count}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionsCommand(): Command {
|
||||||
|
return new Command("sessions")
|
||||||
|
.description("Manage CIAgent agent sessions")
|
||||||
|
.addCommand(
|
||||||
|
new Command("list")
|
||||||
|
.description("List all sessions")
|
||||||
|
.option("--project <slug>", "Filter by project slug")
|
||||||
|
.action(async (options) => {
|
||||||
|
const projectPath = process.cwd();
|
||||||
|
if (!isCIAgentInitialized(projectPath)) {
|
||||||
|
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig(projectPath);
|
||||||
|
const sessionManager = new SessionManager(projectPath, config);
|
||||||
|
const persisted = sessionManager.loadPersistedSessions();
|
||||||
|
const active = sessionManager.listSessions();
|
||||||
|
const allSessions = [...persisted];
|
||||||
|
|
||||||
|
for (const activeSession of active) {
|
||||||
|
if (!allSessions.find((s) => s.id === activeSession.id)) {
|
||||||
|
allSessions.push(activeSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.project) {
|
||||||
|
const filtered = allSessions.filter((s) => s.project_slug === options.project);
|
||||||
|
displaySessions(filtered);
|
||||||
|
} else {
|
||||||
|
displaySessions(allSessions);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.addCommand(
|
||||||
|
new Command("status")
|
||||||
|
.description("Show status of a specific session")
|
||||||
|
.argument("<session-id>", "Session ID")
|
||||||
|
.action(async (sessionId) => {
|
||||||
|
const projectPath = process.cwd();
|
||||||
|
if (!isCIAgentInitialized(projectPath)) {
|
||||||
|
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig(projectPath);
|
||||||
|
const sessionManager = new SessionManager(projectPath, config);
|
||||||
|
|
||||||
|
const persisted = sessionManager.loadPersistedSessions();
|
||||||
|
const sessionInfo = persisted.find((s) => s.id === sessionId);
|
||||||
|
|
||||||
|
if (!sessionInfo) {
|
||||||
|
const session = sessionManager.getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
console.error(`Session ${sessionId} not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
displaySessionDetail(session.getSessionInfo());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
displaySessionDetail(sessionInfo);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.addCommand(
|
||||||
|
new Command("cancel")
|
||||||
|
.description("Cancel a running session")
|
||||||
|
.argument("<session-id>", "Session ID")
|
||||||
|
.action(async (sessionId) => {
|
||||||
|
const projectPath = process.cwd();
|
||||||
|
if (!isCIAgentInitialized(projectPath)) {
|
||||||
|
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig(projectPath);
|
||||||
|
const sessionManager = new SessionManager(projectPath, config);
|
||||||
|
|
||||||
|
const success = sessionManager.cancelSession(sessionId);
|
||||||
|
if (success) {
|
||||||
|
console.log(`Session ${sessionId} cancelled.`);
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to cancel session ${sessionId}. Session may not be running.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.addCommand(
|
||||||
|
new Command("cleanup")
|
||||||
|
.description("Clean up stale sessions")
|
||||||
|
.action(async () => {
|
||||||
|
const projectPath = process.cwd();
|
||||||
|
if (!isCIAgentInitialized(projectPath)) {
|
||||||
|
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig(projectPath);
|
||||||
|
const sessionManager = new SessionManager(projectPath, config);
|
||||||
|
const cleaned = sessionManager.cleanupStaleSessions();
|
||||||
|
console.log(`Cleaned up ${cleaned} stale session(s).`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displaySessions(sessions: Array<import("../types/session.js").SessionInfo>): void {
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
console.log("No sessions found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n─── CIAgent Sessions ───\n");
|
||||||
|
console.log("ID Project Phase Stage Status");
|
||||||
|
console.log("-------- ---------------- ----- ---------- ---------");
|
||||||
|
|
||||||
|
for (const s of sessions) {
|
||||||
|
const id = s.id.padEnd(8);
|
||||||
|
const slug = (s.project_slug || "default").padEnd(16);
|
||||||
|
const phase = String(s.phase).padEnd(5);
|
||||||
|
const stage = s.stage.padEnd(10);
|
||||||
|
const statusIcon = s.status === "running" ? "●" : s.status === "completed" ? "✓" : s.status === "failed" ? "✗" : s.status === "paused" ? "⏸" : "○";
|
||||||
|
console.log(`${id} ${slug} ${phase} ${stage} ${statusIcon} ${s.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${sessions.length} session(s) total.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displaySessionDetail(s: import("../types/session.js").SessionInfo): void {
|
||||||
|
console.log("\n─── Session Detail ───\n");
|
||||||
|
console.log(` ID: ${s.id}`);
|
||||||
|
console.log(` Project: ${s.project_slug || "default"}`);
|
||||||
|
console.log(` Phase: ${s.phase}`);
|
||||||
|
console.log(` Stage: ${s.stage}`);
|
||||||
|
console.log(` Status: ${s.status}`);
|
||||||
|
console.log(` Started: ${s.started_at}`);
|
||||||
|
console.log(` Last Updated: ${s.last_updated}`);
|
||||||
|
if (s.error) {
|
||||||
|
console.log(` Error: ${s.error}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+3
-1
@@ -18,6 +18,7 @@ import {
|
|||||||
createShipCommand,
|
createShipCommand,
|
||||||
createProjectsCommand,
|
createProjectsCommand,
|
||||||
createIdeateCommand,
|
createIdeateCommand,
|
||||||
|
createSessionsCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
|
|
||||||
let activeEscalationProtocol: { dispose(): void } | null = null;
|
let activeEscalationProtocol: { dispose(): void } | null = null;
|
||||||
@@ -68,6 +69,7 @@ program
|
|||||||
.addCommand(createRollbackCommand())
|
.addCommand(createRollbackCommand())
|
||||||
.addCommand(createShipCommand())
|
.addCommand(createShipCommand())
|
||||||
.addCommand(createProjectsCommand())
|
.addCommand(createProjectsCommand())
|
||||||
.addCommand(createIdeateCommand());
|
.addCommand(createIdeateCommand())
|
||||||
|
.addCommand(createSessionsCommand());
|
||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
@@ -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}`);
|
lines.push(`milestone: ${ci.milestone}`);
|
||||||
|
|
||||||
if (ci.project) lines.push(`project: ${ci.project}`);
|
if (ci.project) lines.push(`project: ${ci.project}`);
|
||||||
|
if (ci.session) lines.push(`session: ${ci.session}`);
|
||||||
if (ci.plan) lines.push(`plan: ${ci.plan}`);
|
if (ci.plan) lines.push(`plan: ${ci.plan}`);
|
||||||
if (ci.task) lines.push(`task: ${ci.task}`);
|
if (ci.task) lines.push(`task: ${ci.task}`);
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export function parseCIAgentBlock(yaml: string): CIAgentMetadata | null {
|
|||||||
const projectMatch = yaml.match(/^project:\s*(.+)$/m);
|
const projectMatch = yaml.match(/^project:\s*(.+)$/m);
|
||||||
if (projectMatch) result.project = projectMatch[1].trim();
|
if (projectMatch) result.project = projectMatch[1].trim();
|
||||||
|
|
||||||
|
const sessionMatch = yaml.match(/^session:\s*(.+)$/m);
|
||||||
|
if (sessionMatch) result.session = sessionMatch[1].trim();
|
||||||
|
|
||||||
result.decisions = parseDecisionsFromYaml(yaml);
|
result.decisions = parseDecisionsFromYaml(yaml);
|
||||||
result.escalations = parseEscalationsFromYaml(yaml);
|
result.escalations = parseEscalationsFromYaml(yaml);
|
||||||
result.requirements = parseRequirementsFromYaml(yaml);
|
result.requirements = parseRequirementsFromYaml(yaml);
|
||||||
|
|||||||
+4
-1
@@ -9,6 +9,9 @@ export { GitBranch } from "./git-branch.js";
|
|||||||
export { CommitBuilder } from "./commit-builder.js";
|
export { CommitBuilder } from "./commit-builder.js";
|
||||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js";
|
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js";
|
||||||
export { GiteaClient, generateReleaseNotes } from "./gitea.js";
|
export { GiteaClient, generateReleaseNotes } from "./gitea.js";
|
||||||
export type { GiteaReleaseConfig, GiteaRelease } from "./gitea.js";
|
export { AgentSession } from "./agent-session.js";
|
||||||
|
export { SessionManager } from "./session-manager.js";
|
||||||
|
export { PersonaLoader } from "./persona-loader.js";
|
||||||
|
export { TaskDecomposer } from "./task-decomposer.js";
|
||||||
export type { CIAgentConfig } from "../types/config.js";
|
export type { CIAgentConfig } from "../types/config.js";
|
||||||
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||||
@@ -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 { CommitBuilder } from "./core/commit-builder.js";
|
||||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
|
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
|
||||||
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
|
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
|
||||||
|
export { AgentSession } from "./core/agent-session.js";
|
||||||
|
export { SessionManager } from "./core/session-manager.js";
|
||||||
|
export { PersonaLoader } from "./core/persona-loader.js";
|
||||||
|
export { TaskDecomposer } from "./core/task-decomposer.js";
|
||||||
export { VerificationPipeline } from "./verification/index.js";
|
export { VerificationPipeline } from "./verification/index.js";
|
||||||
export { StructuralVerification } from "./verification/structural.js";
|
export { StructuralVerification } from "./verification/structural.js";
|
||||||
export { BehavioralVerification } from "./verification/behavioral.js";
|
export { BehavioralVerification } from "./verification/behavioral.js";
|
||||||
@@ -24,6 +28,8 @@ export { ESCALATION_TYPES } from "./types/escalation.js";
|
|||||||
export { createClarifyQuestion } from "./types/clarify.js";
|
export { createClarifyQuestion } from "./types/clarify.js";
|
||||||
export { parseSpecification } from "./types/specification.js";
|
export { parseSpecification } from "./types/specification.js";
|
||||||
export { getNextStage, createInitialPipelineState } from "./types/pipeline.js";
|
export { getNextStage, createInitialPipelineState } from "./types/pipeline.js";
|
||||||
|
export { matchFileToPersona, detectConflicts, DEFAULT_PERSONAS } from "./types/persona.js";
|
||||||
|
export { DEFAULT_SESSION_CONFIG } from "./types/session.js";
|
||||||
export * as fileUtils from "./utils/file.js";
|
export * as fileUtils from "./utils/file.js";
|
||||||
export { resolveBackend, createBackend } from "./backends/index.js";
|
export { resolveBackend, createBackend } from "./backends/index.js";
|
||||||
export { OpencodeBackend } from "./backends/opencode.js";
|
export { OpencodeBackend } from "./backends/opencode.js";
|
||||||
@@ -47,4 +53,6 @@ export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMe
|
|||||||
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js";
|
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js";
|
||||||
export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js";
|
export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js";
|
||||||
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
|
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
|
||||||
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
|
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
|
||||||
|
export type { SessionInfo, SessionStatus, SessionConfig } from "./types/session.js";
|
||||||
|
export type { ExecutePersonaConfig, TerritoryEnforcement, PersonaDomain, DecomposedTask, DecomposedPlan, TerritoryConflict } from "./types/persona.js";
|
||||||
@@ -55,6 +55,7 @@ export interface CIAgentMetadata {
|
|||||||
phase: number;
|
phase: number;
|
||||||
milestone: string;
|
milestone: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
|
session?: string;
|
||||||
plan?: string;
|
plan?: string;
|
||||||
task?: string;
|
task?: string;
|
||||||
status: PipelineStage;
|
status: PipelineStage;
|
||||||
|
|||||||
+33
-2
@@ -1,5 +1,4 @@
|
|||||||
import { BackendConfigSection } from "../backends/types.js";
|
import { TerritoryEnforcement, ExecutePersonaConfig } from "./persona.js";
|
||||||
import { IdeationConfig, IdeationCategory } from "./ideation.js";
|
|
||||||
|
|
||||||
export type AutonomyLevel = "full" | "supervised" | "guided";
|
export type AutonomyLevel = "full" | "supervised" | "guided";
|
||||||
|
|
||||||
@@ -94,8 +93,25 @@ export interface CIAgentConfig {
|
|||||||
backend: BackendConfigSection;
|
backend: BackendConfigSection;
|
||||||
gitea?: GiteaConfig;
|
gitea?: GiteaConfig;
|
||||||
ideation?: IdeationConfig;
|
ideation?: IdeationConfig;
|
||||||
|
sessions?: SessionConfig;
|
||||||
|
personas?: PersonaConfigSection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionConfig {
|
||||||
|
max_concurrent_sessions: number;
|
||||||
|
session_timeout_ms: number;
|
||||||
|
session_isolation: "branch";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonaConfigSection {
|
||||||
|
enabled: boolean;
|
||||||
|
territory_enforcement: TerritoryEnforcement;
|
||||||
|
personas: ExecutePersonaConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
import { BackendConfigSection } from "../backends/types.js";
|
||||||
|
import { IdeationConfig, IdeationCategory } from "./ideation.js";
|
||||||
|
|
||||||
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
||||||
projects: [],
|
projects: [],
|
||||||
active_project: "",
|
active_project: "",
|
||||||
@@ -190,4 +206,19 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
|||||||
scenarios: ["backend_unavailable", "requirement_change", "test_coverage_drop"],
|
scenarios: ["backend_unavailable", "requirement_change", "test_coverage_drop"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sessions: {
|
||||||
|
max_concurrent_sessions: 3,
|
||||||
|
session_timeout_ms: 3600000,
|
||||||
|
session_isolation: "branch",
|
||||||
|
},
|
||||||
|
personas: {
|
||||||
|
enabled: true,
|
||||||
|
territory_enforcement: "warn",
|
||||||
|
personas: [
|
||||||
|
{ name: "lead-developer", domain: "coordination", frameworks: [], constraints: ["pragmatic", "battle-tested defaults"], territory: [] },
|
||||||
|
{ name: "data-engineer", domain: "data", frameworks: ["drizzle", "postgresql"], constraints: ["schema-first", "type-safe ORM", "migration-driven"], territory: ["**/migrations/**", "**/schema/**", "**/models/**", "**/db/**", "prisma/schema.prisma", "drizzle/**", "**/*.sql"] },
|
||||||
|
{ name: "backend-engineer", domain: "backend", frameworks: ["fastify", "hono"], constraints: ["api-first", "strict-typing", "dependency-injection"], territory: ["**/api/**", "**/routes/**", "**/services/**", "**/middleware/**", "**/controllers/**", "**/auth/**"] },
|
||||||
|
{ name: "frontend-engineer", domain: "frontend", frameworks: ["react", "next.js"], constraints: ["component-first", "server-components", "minimal-client-js"], territory: ["**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**", "**/*.tsx", "**/*.css", "**/*.vue"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -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