Compare commits

...

3 Commits

Author SHA1 Message Date
Jon Chery 8ec7748ccb docs(P05): v0.11 hardening — persona md files, version bump, docs update
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
---ci---
project: ci
phase: 5
milestone: v0.11
status: complete
requirements:
  covered: [PERSONA-02, INTEG-03, INTEG-04, INTEG-05]
---/ci---

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