Compare commits

..

7 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
Jon Chery 6d0034dc88 docs(P07): release flow hardening — consistent milestone type taxonomy
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
---ci---
phase: 7
milestone: v0.10
status: complete
decisions:
  - id: D-092
    decision: Rename schema-breaking → major across all framework files
    rationale: Major aligns with semver terminology and is more descriptive of the version bump impact
    confidence: 0.95
  - id: D-093
    decision: Add Major milestone type to dev context and branch-strategy merge validation gates
    rationale: Release flow was documented but not enforced. Zero-HITL, PR+QA, and branch hierarchy are now hard gates
    confidence: 0.92
---/ci---
2026-06-01 16:14:54 +00:00
grimacing a153291643 Merge pull request 'feat(P06): Integration & hardening — INTEG-01..05, MULTI-04, v0.10.0' (#9) from phase/06-integration-hardening into main
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-06-01 15:41:20 +00:00
Jon Chery a0619f9740 feat(P06): Integration & hardening — INTEG-01..05, MULTI-04
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
- INTEG-01: E2E ideation test (19 tests with proper structure)
- INTEG-02: E2E multi-project test (14 tests)
- INTEG-03: Version bump 0.9.0 → 0.10.0
- INTEG-04: AGENTS.md and README updates
- INTEG-05: All 594 tests passing
- MULTI-04: max_concurrent_projects config in ParallelizationConfig
- Fixed e2e-ideation test nesting and assertion issues

---ci---
phase: 6
milestone: v0.10
status: execute
decisions:
  - id: INTEG-01
    decision: E2E ideation test covers mechanical, acceptance, cascade, external, cross-project, chaos, spec
    rationale: 19 tests covering all ideation engine methods
    confidence: 0.95
  - id: INTEG-03
    decision: Version bumped to 0.10.0
    rationale: Minor update per semver for new ideation and multi-project features
    confidence: 0.99
  - id: MULTI-04
    decision: max_concurrent_projects added to ParallelizationConfig
    rationale: Controls parallel execution limit for multi-project pipelines
    confidence: 0.90
requirements:
  covered: [INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05, MULTI-04]
---/ci---
2026-06-01 15:39:47 +00:00
Jon Chery f478088797 refactor(P06): rename milestone type schema-breaking → major, reinforce release flow
---ci---
phase: 6
milestone: v0.10
status: execute
decisions:
  - id: D-001
    decision: Rename MilestoneType schema-breaking to major for clarity
    rationale: Major better describes the semver impact (major version bump) and aligns with standard semver terminology
    confidence: 0.95
    alternatives: [schema-breaking, breaking, major-change]
  - id: D-002
    decision: Add autopilot rules, PR+QA gates, and merge validation to ship workflow
    rationale: Release flow was documented but not enforced in the workflow. Zero-HITL rules, branch hierarchy validation, and coreci packaging steps ensure consistent releases
    confidence: 0.90
    alternatives: [keep-as-documentation-only, add-to-AGENTS.md-only]
---/ci---
2026-06-01 15:29:43 +00:00
40 changed files with 3828 additions and 141 deletions
+24 -16
View File
@@ -26,10 +26,12 @@ src/
anthropic.ts # AnthropicBackend (Anthropic API, Claude) anthropic.ts # AnthropicBackend (Anthropic API, Claude)
opencode.ts # OpencodeBackend (shells out to opencode --non-interactive) opencode.ts # OpencodeBackend (shells out to opencode --non-interactive)
index.ts # Backend registry + auto-detection 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 core/ # Core engine components
artifacts.ts # Legacy .ciagent/ artifact management (retained for backward compat) artifacts.ts # Legacy .ciagent/ artifact management (retained for backward compat)
audit.ts # Git-native audit trail — reads decisions/escalations from git log audit.ts # Git-native audit trail — reads decisions/escalations from git log
agent-session.ts # Multi-session support: AgentSession, file-based git locking
session-manager.ts # SessionManager: concurrent session lifecycle management
ciagent-files.ts # .ciagent/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.) ciagent-files.ts # .ciagent/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.)
clarify.ts # Clarify phase: question generation, default acceptance clarify.ts # Clarify phase: question generation, default acceptance
commit-builder.ts # Structured commit message generation (---ci--- YAML blocks) commit-builder.ts # Structured commit message generation (---ci--- YAML blocks)
@@ -40,14 +42,18 @@ src/
escalation.ts # Escalation protocol: commits escalations as git artifacts escalation.ts # Escalation protocol: commits escalations as git artifacts
git-branch.ts # Branch lifecycle: phase/NN-slug, milestone/vX.X-slug git-branch.ts # Branch lifecycle: phase/NN-slug, milestone/vX.X-slug
git-context.ts # Project state reconstruction from git log + branches git-context.ts # Project state reconstruction from git log + branches
persona-loader.ts # Execute-time persona resolution from .config/opencode/agents/*.md
task-decomposer.ts # Plan decomposition into data/backend/frontend task groups
types/ # Type definitions types/ # Type definitions
commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit (includes session field)
config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, DEFAULT_CIAGENT_CONFIG (includes backend) config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, SessionConfig, PersonaConfigSection, DEFAULT_CIAGENT_CONFIG (includes backend)
decisions.ts # Decision, ConfidenceLevel, DecisionCategory decisions.ts # Decision, ConfidenceLevel, DecisionCategory
escalation.ts # Escalation, EscalationType, EscalationResolution escalation.ts # Escalation, EscalationType, EscalationResolution
clarify.ts # ClarifyQuestion, ClarifyResult clarify.ts # ClarifyQuestion, ClarifyResult
specification.ts # Specification parser (objective, requirements, constraints, out_of_scope) specification.ts # Specification parser (objective, requirements, constraints, out_of_scope)
pipeline.ts # PipelineStage, PipelineState, PhaseResult, STAGE_ORDER pipeline.ts # PipelineStage, PipelineState, PhaseResult, STAGE_ORDER
persona.ts # ExecutePersonaConfig, PersonaDomain, TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS
session.ts # SessionInfo, SessionStatus, SessionConfig, DEFAULT_SESSION_CONFIG
utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON) utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON)
verification/ # 4-layer verification pipeline verification/ # 4-layer verification pipeline
structural.ts # Layer 1: file existence, imports wired, no stubs structural.ts # Layer 1: file existence, imports wired, no stubs
@@ -55,7 +61,7 @@ src/
security.ts # Layer 3: regex-based threat pattern scanning (no STRIDE analysis yet) 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) quality.ts # Layer 4: regex-based code quality checks (no multi-persona review yet)
index.ts # Public API exports 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) templates/ # Template files (config.json, DECISIONS.md, specification.md)
``` ```
@@ -84,7 +90,7 @@ templates/ # Template files (config.json, DECISIONS.md, specification.md
## Pipeline Flow ## Pipeline Flow
``` ```
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → TEST → VERIFY → COMPLETE SPECIFY → CLARIFY → RESEARCH → IDEATE → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
``` ```
Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator delegates intelligent stages (research, plan, execute, test, verify) to specialized agents via `context.backend` when available, falling back to mechanical execution when no backend is configured. Mechanical stages (specify, clarify, complete) are always handled by the orchestrator directly. Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator delegates intelligent stages (research, plan, execute, test, verify) to specialized agents via `context.backend` when available, falling back to mechanical execution when no backend is configured. Mechanical stages (specify, clarify, complete) are always handled by the orchestrator directly.
@@ -134,7 +140,7 @@ IntelligenceBackend (unified interface)
- Test framework: Jest with ts-jest - Test framework: Jest with ts-jest
- Test file pattern: `**/*.test.ts` in `src/` - Test file pattern: `**/*.test.ts` in `src/`
- Run: `npm run test` - Run: `npm run test`
- 57 test suites, 527 tests covering types, core, git-native, verification, agent, backends, 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 - Tests use temp directories (os.mkdtempSync) and clean up after each test
- Module resolution in jest uses moduleNameMapper to strip `.js` extensions - Module resolution in jest uses moduleNameMapper to strip `.js` extensions
@@ -194,19 +200,21 @@ IntelligenceBackend (unified interface)
## Current State ## 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.9.0**: Integration & hardening — OpenAI and Anthropic backends, all 19 agents with intrinsic mechanical logic, E2E v0.9 integration tests, parallel agent execution
- **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows - **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows
- **New in v0.11**: Multi-session support with `SessionManager` and `AgentSession` for independent project pipelines running concurrently, execute-phase persona specialization (`lead-developer`, `data-engineer`, `backend-engineer`, `frontend-engineer`) with territory enforcement and task decomposition, `ciagent sessions` CLI command with list/status/cancel/cleanup subcommands, `--session <id>` flag on `ciagent run`, `---ci--- session:` commit metadata field, `sessions` and `personas` config sections
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
- **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation) - **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation)
- **Config expansion**: BackendConfigSection now includes `openai` and `anthropic` in `llm_backends` with dedicated `OpenAIConfig` and `AnthropicConfig` types - **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
- **Auto-detection order (v0.9)**: opencode → openai → ollama-local → ollama-cloud → anthropic - **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 - **All agents mechanical**: Every non-orchestrator agent (18/19) produces meaningful output without a backend — no "requires intelligence backend" stub errors
- **Integration tests**: E2E v0.9 test with mock backend verifies multi-agent pipeline (researcher → planner → security-auditor → code-reviewer → verifier); all-agents-mechanical test iterates 18 agents - **Integration tests**: E2E v0.10 tests verify ideation CLI (mechanical tier), multi-project execution, all-agents-mechanical, parallel execution
- **Parallel execution**: OrchestratorAgent supports concurrent review agents with `limitConcurrency()`, controlled by `parallelization.max_concurrent_agents` - **Pipeline stages**: SPECIFY → CLARIFY → RESEARCH → **IDEATE** → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
- **New modules**: commit-parser (`---ci---` YAML block extraction/parsing), commit-builder (structured commit message generation), git-context (project state reconstruction from git log + branches), git-branch (phase/milestone branch lifecycle), ciagent-files (`.ciagent/` long-lived reference file management) - **Commit schema**: Every CIAgent-generated commit contains a `---ci---` YAML block with phase, milestone, status, decisions, escalations, requirements, lessons, compound, and **project** metadata
- **Commit schema**: Every CIAgent-generated commit contains a `---ci---` YAML block with phase, milestone, status, decisions, escalations, requirements, lessons, and compound metadata
- **Branch strategy**: `phase/NN-slug` and `milestone/vX.X-slug` branches encode project structure; merged = complete, active = in progress - **Branch strategy**: `phase/NN-slug` and `milestone/vX.X-slug` branches encode project structure; merged = complete, active = in progress
- **Core engine rewrites**: DecisionEngine generates commit messages (not audit JSON), EscalationProtocol commits escalations as git artifacts, OrchestratorAgent uses git log as first impulse - **CLI commands**: `init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`, `ideate`, `projects`, `sessions`
- **Verification layers**: All 4 layers implemented — structural, behavioral (test execution), security (STRIDE + CWE), quality (3-persona review)
- **CLI**: All 11 commands wired up (`init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`)
- **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. - **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**: 57 test suites, 527 tests covering types, config, decision-engine, escalation, clarify, commit-parser, commit-builder, git-context, git-branch, ciagent-files, 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
+97 -6
View File
@@ -63,6 +63,28 @@ ciagent quick "Add authentication middleware"
# Check project status (reads from git log + branches) # Check project status (reads from git log + branches)
ciagent status ciagent status
# Discover improvement opportunities
ciagent ideate # Mechanical tier (always available)
ciagent ideate --category security # Focus on specific categories
ciagent ideate --affected # Cascade impact analysis
ciagent ideate --spec # Specification completeness analysis
ciagent ideate --external # npm audit + dependency staleness
ciagent ideate --cross-project # Cross-project pattern mining
ciagent ideate --project all # Run across all active projects
ciagent ideate --output json # JSON output mode
ciagent ideate --output markdown # Markdown output mode
# Manage multiple projects
ciagent projects list # List all registered projects
ciagent projects add <slug> <name> # Add a new project
ciagent projects set <slug> # Set the active project
# Run with ideation stage
ciagent run --ideate # Insert IDEATE stage between RESEARCH and PLAN
# Run across all active projects
ciagent run --project all # Execute pipeline for each project
# Review autonomous decisions (extracted from git log ---ci--- blocks) # Review autonomous decisions (extracted from git log ---ci--- blocks)
ciagent audit ciagent audit
ciagent audit --verbose ciagent audit --verbose
@@ -77,7 +99,7 @@ ciagent rollback 1
ciagent ship 1 ciagent ship 1
``` ```
## Git-Native Architecture (v0.9.0) ## Git-Native Architecture (v0.10.0)
### The Commit Schema ### The Commit Schema
@@ -111,7 +133,7 @@ requirements:
| Where | What | Why | | Where | What | Why |
|-------|------|-----| |-------|------|-----|
| `.ciagent/config.json` | Autonomy, thresholds, git strategy | Controls system behavior before any commits exist | | `.ciagent/config.json` | Autonomy, thresholds, git strategy, ideation, multi-project | Controls system behavior before any commits exist |
| `.ciagent/PROJECT.md` | Vision, core value, requirements, constraints, key decisions table | Long-lived strategic reference | | `.ciagent/PROJECT.md` | Vision, core value, requirements, constraints, key decisions table | Long-lived strategic reference |
| `.ciagent/ARCHITECTURE.md` | System architecture, component boundaries, data flow | Long-lived technical reference | | `.ciagent/ARCHITECTURE.md` | System architecture, component boundaries, data flow | Long-lived technical reference |
| `.ciagent/ROADMAP.md` | Phase breakdown, milestone mapping, success criteria | Long-lived planning reference | | `.ciagent/ROADMAP.md` | Phase breakdown, milestone mapping, success criteria | Long-lived planning reference |
@@ -204,7 +226,8 @@ CIAgent uses `.ciagent/config.json` for project configuration:
"parallelization": { "parallelization": {
"enabled": true, "enabled": true,
"max_concurrent_agents": 5, "max_concurrent_agents": 5,
"min_plans_for_parallel": 2 "min_plans_for_parallel": 2,
"max_concurrent_projects": 3
}, },
"verification": { "verification": {
"automated_only": true, "automated_only": true,
@@ -221,6 +244,25 @@ CIAgent uses `.ciagent/config.json` for project configuration:
"branching_strategy": "phase", "branching_strategy": "phase",
"auto_commit": true, "auto_commit": true,
"auto_push": false "auto_push": false
},
"ideation": {
"enabled": true,
"categories": ["security", "quality", "architecture", "coverage", "improvement"],
"confidence_threshold": 0.6,
"max_ideas": 20,
"external_signals": {
"npm_audit": true,
"osv_advisories": true,
"dependency_staleness": true
},
"cross_project": {
"enabled": false,
"similarity_weight": 0.5
},
"chaos": {
"enabled": true,
"scenarios": ["backend_unavailable", "requirement_change", "test_coverage_drop"]
}
} }
} }
``` ```
@@ -230,9 +272,9 @@ CIAgent uses `.ciagent/config.json` for project configuration:
### Pipeline ### Pipeline
``` ```
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → TEST → VERIFY → COMPLETE SPECIFY → CLARIFY → RESEARCH → IDEATE → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
↕ ↕ ↕ ↕ ↕ ↕ ↕ ↕ ↕ ↕
(questions) (auto-decide) (auto-run) (auto-test) (auto-verify) (questions) (auto-decide) (ideas) (auto-run) (auto-test) (auto-verify)
``` ```
### Git-Native Core Modules ### Git-Native Core Modules
@@ -278,6 +320,55 @@ Decisions are committed to git as `decision` type commits. The audit trail is `g
| solution-writer | Solution docs | Produces structured solution documents from plan + requirements | | solution-writer | Solution docs | Produces structured solution documents from plan + requirements |
| phase-researcher | Phase research | Extracts decisions, lessons, risks from git log for a specific phase | | phase-researcher | Phase research | Extracts decisions, lessons, risks from git log for a specific phase |
### Ideation
CIAgent includes a built-in ideation engine that discovers improvement opportunities from git-native signals:
1. **Tier 1 — Mechanical**: Mines git history for uncovered requirements, repeated lessons, low-confidence decisions, escalation patterns, coverage gaps, architecture drift, and verification inversions
2. **Tier 2 — Backend-enriched**: When a backend is available, prioritizes mechanical findings and suggests novel improvements
3. **Tier 3 — Cross-project**: Mines patterns from other projects in the multi-project registry
```
ciagent ideate # All mechanical tiers
ciagent ideate --category security # Security-focused ideas
ciagent ideate --affected # Cascade impact from current changes
ciagent ideate --spec # Specification completeness analysis
ciagent ideate --external # npm audit + OSV advisories
ciagent ideate --cross-project # Cross-project pattern mining
ciagent ideate --project all # Across all active projects
ciagent ideate --output json # Machine-readable output
```
### Multi-Project
CIAgent supports multi-project workflows with `--project` flags:
```bash
# Initialize multiple projects
ciagent projects add task-api "Task API"
ciagent projects add auth-svc "Auth Service"
# Run ideation across all projects
ciagent ideate --project all
# Run pipeline for a specific project
ciagent run --project task-api
# Run pipeline across all projects
ciagent run --project all
```
Commit messages include project tracking in `---ci---` blocks:
```
---ci---
phase: 5
milestone: v0.10
project: task-api
status: execute
---/ci---
```
### Verification Layers ### Verification Layers
1. **Structural**: File existence, import/export wiring, no stubs 1. **Structural**: File existence, import/export wiring, no stubs
+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.
+5 -4
View File
@@ -4,7 +4,7 @@ Agent output guidance for CIAgent dev mode. Loaded when the orchestrator operate
--- ---
## Multi-Project and NFR Versioning ## Multi-Project and Milestone Versioning
When in multi-project mode (`.ciagent/config.json` has `projects[]` with length > 0): When in multi-project mode (`.ciagent/config.json` has `projects[]` with length > 0):
- All commits include `project: <slug>` in `---ci---` block - All commits include `project: <slug>` in `---ci---` block
@@ -12,9 +12,10 @@ When in multi-project mode (`.ciagent/config.json` has `projects[]` with length
- `.ciagent/` files are in `.ciagent/<slug>/` subdirectories - `.ciagent/` files are in `.ciagent/<slug>/` subdirectories
- Project scoping applies to all operations - Project scoping applies to all operations
NFR milestone versioning: Milestone versioning (determined by `getMilestoneType()` before any development):
- NFR milestones (all phases are fix/chore/docs/perf/refactor/test): progressive patch versions only, no minor tag - **NFR** (all phases: fix/chore/docs/perf/refactor/test): progressive patch versions, no milestone tag — final patch IS the deliverable
- Feature milestones (any feat phase): progressive patch versions + minor milestone tag - **Feature** (at least one `feat` phase): progressive patch versions + next minor milestone tag
- **Major** (breaking schema changes or complete refactor): progressive minor versions per phase + major milestone tag
## Output Style ## Output Style
+33 -15
View File
@@ -104,22 +104,29 @@ Phase branches can be deleted after merge if desired.
## Versioning and Releases ## Versioning and Releases
**Every merge to main creates a release. No exceptions.** Versioning follows a 3-tier model based on milestone type: **Every merge to main creates a release. No exceptions.** Versioning follows the milestone type model:
### 3-Tier Versioning Model ### Milestone Type and Versioning
The milestone type is determined **before any development work** and governs all versioning for the entire milestone.
**Define semver at milestone start:** establish the version and milestone type before writing code.
Determine milestone type via `getMilestoneType()` which returns `"nfr" | "feature" | "major"`:
| Milestone Type | Condition | Phase release | Milestone release | | Milestone Type | Condition | Phase release | Milestone release |
|---------------|-----------|---------------|-------------------| |---------------|-----------|---------------|-------------------|
| **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None | | **NFR** | All phases are fix/chore/docs/perf/refactor/test | Patch `v1.8.1`, `v1.8.2`, ... | None — final patch IS the deliverable |
| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` | | **Feature** | At least one phase has new features (`feat`) | Patch `v1.8.1`, `v1.8.2`, ... | Next minor — `v1.9.0` |
| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` | | **Major** | Breaking schema changes or complete refactor | Minor — `v2.1.0`, `v2.2.0`, ... | Major — `v3.0.0` |
**IMPORTANT:** Milestone tags are always the NEXT version, never the base: **Tag rules (CRITICAL):**
- Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0) - Milestone tags are always the NEXT version, never the base:
- Schema-breaking: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0 - Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0)
- NFR: no milestone tag — the milestone is implicit from the patch sequence - Major: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0
- NFR: no milestone tag — the final patch release IS the deliverable
Determine milestone type via `getMilestoneType()` which returns `"nfr" | "feature" | "schema-breaking"`. - Tags must be strictly greater than all existing tags on the same major.minor line
- NEVER create a milestone tag that is semantically below existing phase tags
### Phase completion ### Phase completion
@@ -135,7 +142,7 @@ git push origin main --tags
Phase number within the milestone determines the patch version (1st phase = .1, 2nd phase = .2, etc.) Phase number within the milestone determines the patch version (1st phase = .1, 2nd phase = .2, etc.)
**Schema-breaking (minor release per phase):** **Major (minor release per phase):**
```bash ```bash
git checkout milestone/v0.5-schema-rewrite git checkout milestone/v0.5-schema-rewrite
git merge --squash phase/01-core-refactor git merge --squash phase/01-core-refactor
@@ -145,7 +152,7 @@ git push origin main --tags
# Create Gitea release for v0.5.0 # Create Gitea release for v0.5.0
``` ```
Each schema-breaking phase bumps the minor. 1st phase = next available minor, 2nd = minor+1, etc. Each major phase bumps the minor. 1st phase = next available minor, 2nd = minor+1, etc.
### Milestone completion ### Milestone completion
@@ -160,7 +167,7 @@ git push origin main --tags
# Create Gitea release for v0.6.0 with full milestone summary # Create Gitea release for v0.6.0 with full milestone summary
``` ```
**Schema-breaking (major release):** **Major (major release):**
```bash ```bash
# All phases already merged into milestone branch # All phases already merged into milestone branch
git checkout main git checkout main
@@ -177,9 +184,20 @@ git push origin main --tags
Before creating any tag: Before creating any tag:
1. Tag must be strictly greater than all existing tags on the same major.minor line 1. Tag must be strictly greater than all existing tags on the same major.minor line
2. Milestone completion tag must be next minor (feature) or next major (schema-breaking) 2. Milestone completion tag must be next minor (feature) or next major (major)
3. NEVER create a tag that is semantically below existing phase tags 3. NEVER create a tag that is semantically below existing phase tags
### Merge Validation Gates
The branch hierarchy `main > milestone/vX.X-slug > phase/NN-slug` is enforced at merge time:
| Merge Type | Rule | Validation |
|------------|------|-------------|
| Phase → Milestone | Must target milestone branch when one exists | REJECTED if milestone branch does not exist for this phase's milestone |
| Phase → Main | Only allowed when no milestone branch exists | REJECTED if a milestone branch exists for this milestone |
| Milestone → Main | Only after all phase branches are merged | REJECTED if any phase branches for this milestone are unmerged |
| Hotfix → Main | Allowed (exception to hierarchy) | Always allowed |
## Multi-Project Branch Naming ## Multi-Project Branch Naming
When operating in multi-project mode (`.ciagent/config.json` has `projects[]` with length > 0): When operating in multi-project mode (`.ciagent/config.json` has `projects[]` with length > 0):
+154 -38
View File
@@ -1,16 +1,16 @@
--- ---
description: Execute the full CIAgent pipeline — research → plan → execute → verify → complete for the current or specified phase description: Execute the full CIAgent pipeline — specify → clarify → research → ideate → plan → execute → ship → verify → complete for the current or specified phase
--- ---
# CIAgent Run # CIAgent Run
Execute the full CIAgent pipeline from the current stage to completion. The orchestrator iterates through stages and delegates to specialized agents. Execute the full CIAgent pipeline from the current stage to completion. The orchestrator iterates through stages and delegates to specialized agents and sub-workflows.
**Usage:** `ciagent-run [phase_number]` **Usage:** `ciagent-run [phase_number]`
If no phase number specified, continues from the current phase (detected from git log). If no phase number specified, continues from the current phase (detected from git log).
## Step 0: Confirm Active Project ## Step 0: Confirm Active Project and Session
Check `ci listProjects()` or read `.ciagent/config.json` to determine if multi-project mode is active. Check `ci listProjects()` or read `.ciagent/config.json` to determine if multi-project mode is active.
@@ -20,13 +20,21 @@ If `.ciagent/config.json` has `projects[]` with length > 0, or `active_projects`
- If `--project <slug>` is specified: run for that project only - If `--project <slug>` is specified: run for that project only
- If no `--project` flag: use first project in `active_projects` - If no `--project` flag: use first project in `active_projects`
- All commit messages must include `project: <slug>` in `---ci---` block - All commit messages must include `project: <slug>` in `---ci---` block
- All `.ciagent/` file reads use `.ciagent/<slug>/` subdirectory paths
- Branch names are prefixed with `<slug>/` (e.g., `<slug>/phase/01-auth`, `<slug>/milestone/v0.2-auth`)
For multi-project execution (`--project all`): For multi-project execution (`--project all`):
- Execute pipeline for each project sequentially by default - Execute pipeline for each project sequentially by default
- When `parallelization.enabled=true`: execute projects concurrently up to `max_concurrent_agents` - When `parallelization.enabled=true`: execute projects concurrently up to `max_concurrent_agents`
- Each project has independent phase branches and milestone tracking - Each project has independent phase branches and milestone tracking
- Sessions (if configured): each project gets its own `AgentSession` with branch isolation per `config.json sessions.session_isolation`
If single-project mode: proceed with existing conventions. For multi-persona execution (when `config.json personas.enabled=true`):
- Lead-developer persona decomposes tasks by territory file patterns and requirement IDs
- Each persona group executes tasks within their territory
- Territory enforcement runs in `warn` or `strict` mode per `config.json personas.territory_enforcement`
If single-project mode: proceed with existing conventions (flat `.ciagent/` paths, no project prefix on branches).
## Step 1: Load Git Context ## Step 1: Load Git Context
@@ -40,85 +48,193 @@ Determine current state:
- Current milestone from latest `---ci---` block or active milestone branch - Current milestone from latest `---ci---` block or active milestone branch
- Current pipeline stage from latest `---ci---` status field - Current pipeline stage from latest `---ci---` status field
- Completed phases from merged `phase/NN-*` branches - Completed phases from merged `phase/NN-*` branches
- Active project from `---ci---` project field (multi-project mode)
## Step 2: Pre-Flight Check ## Step 2: Pre-Flight Check
Verify `.ciagent/config.json` exists. If missing: stop, run `ciagent-init` first. Verify `.ciagent/config.json` exists. If missing: stop, run `ciagent-init` first.
Read `.ciagent/PROJECT.md` and `.ciagent/ROADMAP.md` for phase goals. Resolve project paths based on mode:
- **Multi-project**: read `.ciagent/<slug>/PROJECT.md` and `.ciagent/<slug>/ROADMAP.md` for the active project
- **Single-project**: read `.ciagent/PROJECT.md` and `.ciagent/ROADMAP.md`
Read phase goals and milestone context from the resolved files.
## Step 3: Execute Pipeline Stages ## Step 3: Execute Pipeline Stages
For each stage in order (starting from current or from `specify`): For each stage in order (starting from current or from `specify`):
### SPECIFY ### SPECIFY
- Parse specification from `.ciagent/PROJECT.md`
- Validate requirements exist in `.ciagent/REQUIREMENTS.md` - Resolve active project from `config.json`
- Parse specification from `.ciagent/<slug>/PROJECT.md` (multi-project) or `.ciagent/PROJECT.md` (single-project)
- Validate requirements exist in `.ciagent/<slug>/REQUIREMENTS.md` (multi-project) or `.ciagent/REQUIREMENTS.md` (single-project)
- Commit: `docs(init): validate specification` - Commit: `docs(init): validate specification`
```
---ci---
project: <slug>
phase: 0
milestone: v0.X
status: specify
---/ci---
```
### CLARIFY ### CLARIFY
- Generate clarify questions for ambiguities
- Default-accept at `full` autonomy, present at `supervised`/`guided` **Delegate to `ciagent-clarify` workflow.** Do not reimplement inline.
- Commit: `decision(P##): clarification decisions`
The clarify workflow handles:
- Multi-project active project confirmation
- Git context loading
- Ambiguity identification and question generation
- Autonomy-based resolution (full/supervised/guided)
- Clarification commits with `---ci---` blocks
- `.ciagent/<slug>/PROJECT.md` and `.ciagent/<slug>/REQUIREMENTS.md` updates
Pass the current phase number and active project slug. Collect the result and proceed.
### RESEARCH ### RESEARCH
- Resolve active project from `config.json`; use `.ciagent/<slug>/` paths
- Delegate to ci-researcher - Delegate to ci-researcher
- Research domain, ecosystem, prior art - Research domain, ecosystem, prior art
- Update `.ciagent/` static files with conclusions - Update `.ciagent/<slug>/` static files with conclusions (ARCHITECTURE.md, PROJECT.md, etc.)
- Commit: `docs(P##): research findings` - Commit: `docs(P##): research findings`
```
---ci---
project: <slug>
phase: [N]
milestone: [vX.X]
status: research
---/ci---
```
### IDEATE (when --ideate flag is passed) ### IDEATE (when --ideate flag is passed)
- Delegate to ci-ideation-agent
- Mine git history for patterns, analyze coverage gaps, detect drift **Delegate to `ciagent-ideate` workflow.** Do not reimplement inline.
- If backend available: enrich with LLM suggestions
- If --cross-project: mine patterns from other projects The ideate workflow handles:
- Present recommendations interactively (accept/skip/modify) - Multi-project context and `--project` flags
- Accepted ideas update ROADMAP.md and REQUIREMENTS.md - All three tiers (mechanical, backend-enriched, cross-project)
- Commit: `decision(P##): ideation results — [N] accepted, [M] skipped` - Interactive validation (accept/skip/modify)
- Updates to `.ciagent/<slug>/REQUIREMENTS.md`, `.ciagent/<slug>/ROADMAP.md`, `.ciagent/<slug>/ARCHITECTURE.md`, `.ciagent/<slug>/PROJECT.md`
- Ideation commit with `---ci---` block
Pass the active project slug and any `--ideate` flags. Collect accepted ideas and proceed.
### PLAN ### PLAN
- Delegate to ci-planner
- Resolve active project from `config.json`; use `.ciagent/<slug>/` paths
- Delegate to ci-planner with full project context
- Create vertical-slice plans with wave ordering - Create vertical-slice plans with wave ordering
- Plans reference requirement IDs from `.ciagent/<slug>/REQUIREMENTS.md`
- Commit: `docs(P##): create [N] phase plans` - Commit: `docs(P##): create [N] phase plans`
```
---ci---
project: <slug>
phase: [N]
milestone: [vX.X]
status: plan
---/ci---
```
### EXECUTE ### EXECUTE
- Create phase branch: `phase/NN-slug`
- Create phase branch: `<slug>/phase/NN-slug` (multi-project) or `phase/NN-slug` (single-project)
- Delegate to ci-executor per plan per wave - Delegate to ci-executor per plan per wave
- **Multi-persona development**: if `config.json personas.enabled=true`:
- Lead-developer decomposes tasks by territory file patterns and requirement IDs
- Each persona executes tasks within their declared territory (config.json `personas[].territory`)
- Territory enforcement runs in configured mode (`warn` or `strict`)
- Primary persona (i=0) executes sequentially; review personas (i>0) execute in parallel
- Persona constraints (frameworks, constraints arrays) guide implementation choices
- Commit each task with `---ci---` block - Commit each task with `---ci---` block
- After all waves: commit phase completion - After all waves complete: **ship the phase** by delegating to `ciagent-ship` workflow
**Ship gate**: a phase MUST be shipped before advancing to the next phase. The ship workflow handles:
- Pre-flight validation (milestone type, branch hierarchy, tag sequence, autonomy)
- Test execution (test, typecheck, build)
- PR creation and auto-merge
- Version computation and tagging
- Branch merging (phase → milestone or phase → main)
- Gitea release creation
If the ship fails: do NOT advance to VERIFY. Iterate until the phase ships successfully.
### VERIFY ### VERIFY
- Delegate to ci-verifier
- Check must_haves, requirement coverage, integration links
- Auto-generate tests for unverifiable items
- Commit: `verify(P##): verification result`
### COMPLETE **Delegate to `ciagent-verify` workflow.** Do not reimplement inline.
- Merge phase branch into main (squash)
- Tag with patch version (e.g., `v0.2.3` — 3rd phase in milestone v0.2)
- 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`
Versioning: Major = project-level refactor/schema change, Minor = milestone completion, Patch = every phase. 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.
## Phase Boundary Checkpoint ## Phase Boundary Checkpoint
Between phases, perform a context reset: Between phases, perform a context reset:
1. Commit all work from the current phase 1. Commit all work from the current phase
2. Update `.ciagent/` files (phase status, requirement statuses) 2. Update `.ciagent/<slug>/` files (phase status, requirement statuses)
3. Verify `GitContext.reconstructState()` matches expected state 3. Verify `GitContext.reconstructState()` matches expected state
4. Reset context: spawn fresh agent (opencode) or re-read git context (platforms without subagents) 4. Reset context: spawn fresh agent (opencode) or re-read git context (platforms without subagents)
5. Next phase begins with fresh context from git log only 5. Next phase begins with fresh context from git log only
## NFR Versioning Logic ## Versioning Logic
Before tagging a phase completion, check `isNfrMilestone()`: Before tagging a phase completion, check `getMilestoneType()` which returns `"nfr" | "feature" | "major"`:
- **NFR milestone** (all phases are fix/chore/docs/perf/refactor/test): apply progressive patch versions (v0.1.1, v0.1.2, v0.1.3). No separate milestone tag. - **NFR milestone** (all phases are fix/chore/docs/perf/refactor/test): apply progressive patch versions (v0.1.1, v0.1.2, v0.1.3). No separate milestone tag — the final patch IS the deliverable.
- **Feature milestone** (any feat phase): apply progressive patch versions per phase, then tag minor milestone version on completion (e.g., v0.2.0). - **Feature milestone** (at least one feat phase): apply progressive patch versions per phase, then tag next minor milestone version on completion (e.g., v0.6.0, NOT v0.5.0).
- **Major milestone** (breaking schema changes or complete refactor): apply progressive minor versions per phase (v0.3.0, v0.4.0), then tag next major on completion (e.g., v1.0.0).
## Step 4: Error Recovery ## Step 4: Error Recovery
+132 -32
View File
@@ -1,25 +1,45 @@
--- ---
description: Ship CIAgent phase or milestone — test, tag, release. Every phase and milestone gets a release. Full autopilot. description: Ship CIAgent phase or milestone — Full autopilot release: validate, test, merge, tag, push, release. Zero HITL
--- ---
# CIAgent Ship # CIAgent Ship
Ship a CIAgent phase or milestone. Every ship creates a release — no exceptions. Ship a CIAgent phase or milestone. Every ship creates a release — no exceptions.
**3-Tier Versioning Model:** **Usage:** `ciagent-ship [phase_number|milestone]`
## Autopilot Rules
These rules are **non-negotiable**. The ship workflow runs in full autopilot mode:
- **Zero HITL** — no confirmation prompts, no approval gates, no requests for human input. The agent executes the entire release flow autonomously.
- **No Shortcuts** — deep validation, testing, and merge checks must all run in full. The lack of HITL is not an excuse to skip steps.
- **Notification Only** — status updates are informational, not requests for approval. Report outcomes, never ask permission.
- **Autonomous Loop on Failure** — if any step fails (tests, pipeline, merge conflicts), iterate autonomously until success. Do NOT ask the user for guidance on how to fix a failing test or pipeline.
- **Branch Hierarchy Enforced** — `main > milestone/vX.X-slug > phase/NN-slug`. Phase merges into milestone, milestone merges into main. This is validated, not assumed.
## Milestone Type and Versioning
The milestone type is determined **before any development work** and governs all versioning for the entire milestone.
**Define semver at milestone start:** establish the version and milestone type before writing code.
Determine milestone type by calling `getMilestoneType()` which returns `"nfr" | "feature" | "major"`:
| Milestone Type | Condition | Phase release | Milestone release | | Milestone Type | Condition | Phase release | Milestone release |
|---------------|-----------|---------------|-------------------| |---------------|-----------|---------------|-------------------|
| **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None | | **NFR** | All phases are fix/chore/docs/perf/refactor/test | Patch `v1.8.1`, `v1.8.2`, ... | None — final patch IS the deliverable |
| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` | | **Feature** | At least one phase has new features (`feat`) | Patch `v1.8.1`, `v1.8.2`, ... | Next minor — `v1.9.0` |
| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` | | **Major** | Breaking schema changes or complete refactor | Minor — `v2.1.0`, `v2.2.0`, ... | Major — `v3.0.0` |
**CRITICAL:** Milestone tags are always the NEXT version, never the base: **Tag rules (CRITICAL):**
- Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0)
- Schema-breaking: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0
- NFR: no milestone tag — the milestone is implicit from the patch sequence
**Usage:** `ciagent-ship [phase_number|milestone]` - Milestone tags are always the NEXT version, never the base:
- Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0)
- Major: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0
- NFR: no milestone tag — the final patch release IS the deliverable
- Tags must be strictly greater than all existing tags on the same major.minor line
- NEVER create a milestone tag that is semantically below existing phase tags
## Step 0: Confirm Active Project ## Step 0: Confirm Active Project
@@ -33,11 +53,12 @@ If `.ciagent/config.json` has `projects[]` with length > 0:
If single-project mode: proceed with existing conventions. If single-project mode: proceed with existing conventions.
## Step 1: Pre-Flight ## Step 1: Pre-Flight Validation
```bash ```bash
git log --max-count=10 git log --max-count=10
git branch -a git branch -a
git tag -l
``` ```
Determine what is being shipped: a single phase or an entire milestone. Determine what is being shipped: a single phase or an entire milestone.
@@ -49,6 +70,16 @@ Read `.ciagent/ROADMAP.md` to determine:
Read `.ciagent/config.json` for autonomy level. Read `.ciagent/config.json` for autonomy level.
**Validation gates — all must pass before proceeding:**
1. **Milestone type resolved**`getMilestoneType()` must return `"nfr" | "feature" | "major"`. Stop if undefined.
2. **Branch hierarchy correct** — phase branch exists and targets the correct parent (milestone branch, or main if no milestone branch exists).
3. **No unmerged phase branches** — if shipping a milestone, all phase branches for this milestone must be merged into the milestone branch.
4. **Tag sequence valid** — the computed tag must be strictly greater than all existing tags on the same major.minor line. Check with `git tag -l`.
5. **Autonomy confirmed**`.ciagent/config.json` autonomy level must be `full`. This is the zero-HITL enforcement point.
If any validation fails: stop and report. Do NOT proceed past a failed gate.
## Step 2: Run Tests ## Step 2: Run Tests
```bash ```bash
@@ -59,33 +90,77 @@ npm run build
If any fail: iterate autonomously until tests pass. Do NOT ask the user for guidance — debug and fix. If any fail: iterate autonomously until tests pass. Do NOT ask the user for guidance — debug and fix.
## Step 3: Compute Version ## Step 3: Create PR and Quality Assurance
Determine milestone type by calling `getMilestoneType()` which returns `"nfr" | "feature" | "schema-breaking"`: **Open a Pull Request for the merge target:**
```bash
tea pr create --base <target-branch> --head <source-branch> --title "ship: [phase-name or milestone-name]"
```
- For a phase ship: PR from `phase/NN-slug` into `milestone/vX.Y-slug` (or `main` if no milestone branch).
- For a milestone ship: PR from `milestone/vX.Y-slug` into `main`.
**Auto-merge configuration:**
Set the PR to auto-merge upon pipeline success:
```bash
tea pr merge <pr-number> --auto --squash
```
**Review:**
Conduct a thorough autonomous review of the PR diff. Check:
- All expected files are included
- No unintended changes slipped in
- No secrets or credentials in the diff
- All `---ci---` blocks have correct metadata
**Finalization:**
- **On pipeline success:** the PR auto-merges. Proceed to Step 4.
- **On pipeline failure:** iterate autonomously until the pipeline passes. Do NOT merge a PR with a failing pipeline. Do NOT ask for guidance.
**Strict rule:** Never merge a PR with a failed pipeline. No exceptions.
## Step 4: Compute Version
| What's shipping | Milestone Type | Phase release | Milestone release | Example | | What's shipping | Milestone Type | Phase release | Milestone release | Example |
|----------------|---------------|-------------|------------|---------| |----------------|---------------|---------------|-------------------|---------|
| Single phase | NFR | Patch `vX.Y.Z` | N/A | v0.1.3 (3rd NFR phase) | | Single phase | NFR | Patch `vX.Y.Z` | N/A | v0.1.3 (3rd NFR phase) |
| Single phase | Feature | Patch `vX.Y.Z` | N/A | v0.2.3 (3rd feature phase) | | Single phase | Feature | Patch `vX.Y.Z` | N/A | v0.2.3 (3rd feature phase) |
| Single phase | Schema-breaking | Minor `vX.(Y+N).0` | N/A | v0.4.0 (2nd schema-breaking phase) | | Single phase | Major | Minor `vX.(Y+N).0` | N/A | v0.4.0 (2nd major phase) |
| Milestone completion | NFR | Patch (last phase) | None | v0.1.3 (no milestone tag) | | Milestone completion | NFR | Patch (last phase) | None | v0.1.3 (no milestone tag) |
| Milestone completion | Feature | Last patch | Minor `vX.(Y+1).0` | v0.3.0 (NOT v0.2.0) | | Milestone completion | Feature | Last patch | Minor `vX.(Y+1).0` | v0.3.0 (NOT v0.2.0) |
| Milestone completion | Schema-breaking | Last minor | Major `v(X+1).0.0` | v1.0.0 | | Milestone completion | Major | Last minor | Major `v(X+1).0.0` | v1.0.0 |
Phase number within the milestone determines the increment: Phase number within the milestone determines the increment:
- NFR/Feature: 1st phase = .1, 2nd = .2, etc. (v0.5.1, v0.5.2) - NFR/Feature: 1st phase = .1, 2nd = .2, etc. (v0.5.1, v0.5.2)
- Schema-breaking: 1st phase = next minor, 2nd = minor+1, etc. (v0.3.0, v0.4.0) - Major: 1st phase = next minor, 2nd = minor+1, etc. (v0.3.0, v0.4.0)
**Before creating ANY tag, validate:** **Tag validation (before creating ANY tag):**
1. The tag must be strictly greater than all existing tags on the same major.minor line 1. Tag must be strictly greater than all existing tags on the same major.minor line
2. Milestone completion tag must be the next minor (feature) or next major (schema-breaking) 2. Milestone completion tag must be next minor (feature) or next major (major)
3. NEVER create a milestone tag that is semantically below existing phase tags (e.g., v0.5.0 when v0.5.1 already exists) 3. NEVER create a milestone tag that is semantically below existing phase tags (e.g., v0.5.0 when v0.5.1 already exists)
## Step 4: Merge Branch ## Step 5: Merge Branch
### Branch hierarchy: main > milestone/vX.X-slug > phase/NN-slug ### Branch hierarchy: main > milestone/vX.X-slug > phase/NN-slug
Phases MUST merge into their milestone branch (or to main if no milestone branch exists). Milestones merge into main only after all phases are complete. ### Merge validation gates
**Phase → Milestone:**
- VALIDATED — must target milestone branch when one exists
- REJECTED if milestone branch does not exist for this phase's milestone
**Phase → Main:**
- VALIDATED — only allowed when NO milestone branch exists for this phase's milestone
- REJECTED if a milestone branch exists for this milestone
**Milestone → Main:**
- VALIDATED — only after all phase branches are merged
- REJECTED if any phase branches for this milestone are unmerged
### Phase ship ### Phase ship
@@ -123,8 +198,9 @@ requirements:
### Milestone ship (after last phase) ### Milestone ship (after last phase)
**Validate all phase branches are merged into the milestone branch before proceeding.**
```bash ```bash
# Verify all phase branches are merged into milestone branch
git checkout main git checkout main
git merge --squash milestone/vX.Y-slug git merge --squash milestone/vX.Y-slug
git commit -m "docs(milestone): complete [milestone-name] git commit -m "docs(milestone): complete [milestone-name]
@@ -136,7 +212,7 @@ status: complete
---/ci---" ---/ci---"
``` ```
## Step 5: Tag and Push ## Step 6: Tag and Push
```bash ```bash
git tag -a vX.Y.Z -m "vX.Y.Z: [phase-name or milestone-name]" git tag -a vX.Y.Z -m "vX.Y.Z: [phase-name or milestone-name]"
@@ -145,21 +221,21 @@ git push origin main --tags
**Tag format by milestone type:** **Tag format by milestone type:**
- NFR/Feature phase: patch format (`v0.5.1`, `v0.5.2`) - NFR/Feature phase: patch format (`v0.5.1`, `v0.5.2`)
- Schema-breaking phase: minor format (`v0.3.0`, `v0.4.0`) - Major phase: minor format (`v0.3.0`, `v0.4.0`)
- Feature milestone: next minor (`v0.6.0`, NOT `v0.5.0`) - Feature milestone: next minor (`v0.6.0`, NOT `v0.5.0`)
- Schema-breaking milestone: next major (`v1.0.0`) - Major milestone: next major (`v1.0.0`)
## Step 6: Create Release ## Step 7: Create Release and Package
**Every ship creates a Gitea release. No exceptions.** **Every ship creates a Gitea release. No exceptions.**
Generate release notes from git log: ### Generate release notes
```bash ```bash
git log v[previous_tag]..vX.Y.Z --oneline git log v[previous_tag]..vX.Y.Z --oneline
``` ```
Create the release via Gitea API: ### Create the Gitea release
```bash ```bash
curl -X POST "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/ci/releases" \ curl -X POST "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/ci/releases" \
@@ -170,14 +246,37 @@ curl -X POST "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/ci/
For milestone releases, include a summary of all phases completed and requirements covered. For milestone releases, include a summary of all phases completed and requirements covered.
## Step 7: Update .ci/ Files ### Create distribution packages
Use coreci to create the necessary distribution packages:
```bash
coreci build --tag vX.Y.Z
coreci package --tag vX.Y.Z
```
Upload packages to the Gitea release:
```bash
coreci release upload --tag vX.Y.Z --files [built-artifacts]
```
### Generate documentation
Include release notes in the Gitea release body with:
- Summary of changes
- Requirements covered
- Known issues (if any)
- Migration notes (for major milestones)
## Step 8: Update .ci/ Files
- Update `.ciagent/REQUIREMENTS.md` — mark shipped requirements as complete - Update `.ciagent/REQUIREMENTS.md` — mark shipped requirements as complete
- Update `.ciagent/ROADMAP.md` — mark shipped phase as complete - Update `.ciagent/ROADMAP.md` — mark shipped phase as complete
Commit the file updates. Commit the file updates.
## Step 8: Report ## Step 9: Report
``` ```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -185,7 +284,7 @@ Commit the file updates.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase [N]: [name] Phase [N]: [name]
Milestone: [vX.Y] ([nfr|feature|schema-breaking]) Milestone: [vX.Y] ([nfr|feature|major])
Version: vX.Y.Z Version: vX.Y.Z
Release: https://git.cloudinit.dev/continuous-intelligence/ci/releases/tag/vX.Y.Z Release: https://git.cloudinit.dev/continuous-intelligence/ci/releases/tag/vX.Y.Z
Status: complete Status: complete
@@ -193,6 +292,7 @@ Status: complete
Tests: PASS Tests: PASS
Typecheck: PASS Typecheck: PASS
Build: PASS Build: PASS
Pipeline: PASS
Requirements covered: [N] Requirements covered: [N]
Commits: [N] Commits: [N]
+2 -2
View File
@@ -1,5 +1,5 @@
--- ---
description: Ship CIAgent phase or milestone — test, commit, tag, push, release. Full autopilot: zero HITL after milestone setup description: Ship CIAgent phase or milestone — Full autopilot release: validate, test, merge, tag, push, release. Zero HITL
argument-hint: "[phase_number|milestone]" argument-hint: "[phase_number|milestone]"
tools: tools:
read: true read: true
@@ -12,7 +12,7 @@ tools:
--- ---
<execution_context> <execution_context>
@__OPENCODE_DIR__/ci/workflows/ship.md @/root/.config/opencode/ci/workflows/ship.md
</execution_context> </execution_context>
<context> <context>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@continuous-intelligence/ciagent", "name": "@continuous-intelligence/ciagent",
"version": "0.9.0", "version": "0.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@continuous-intelligence/ciagent", "name": "@continuous-intelligence/ciagent",
"version": "0.9.0", "version": "0.10.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"commander": "^12.1.0", "commander": "^12.1.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@continuous-intelligence/ciagent", "name": "@continuous-intelligence/ciagent",
"version": "0.9.0", "version": "0.11.0",
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence", "description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "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 { execSync } from "node:child_process";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { TaskDecomposer } from "../core/task-decomposer.js";
import { PersonaLoader } from "../core/persona-loader.js";
import { TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS } from "../types/persona.js";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { loadConfig } from "../core/config.js";
export interface ExecutorResult { export interface ExecutorResult {
success: boolean; success: boolean;
@@ -17,6 +22,17 @@ interface MustHaveItem {
passed: boolean; passed: boolean;
} }
interface PersonaTaskGroup {
persona: string;
domain: string;
tasks: Array<{
id: string;
description: string;
files: string[];
}>;
conflicts: TerritoryConflict[];
}
export class ExecutorAgent extends BaseAgent { export class ExecutorAgent extends BaseAgent {
readonly name = "executor"; readonly name = "executor";
readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints."; readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints.";
@@ -27,6 +43,14 @@ export class ExecutorAgent extends BaseAgent {
this.log("Executing tasks..."); this.log("Executing tasks...");
if (context.backend) { if (context.backend) {
const config = this.loadProjectConfig(context);
const personasEnabled = config.personas?.enabled !== false;
if (personasEnabled) {
this.log("Persona-based execution enabled — decomposing plan and assigning to personas");
return this.executeWithPersonas(context, config);
}
const taskPrompt = await this.buildBackendTaskPrompt(context); const taskPrompt = await this.buildBackendTaskPrompt(context);
const backendResult = await this.executeViaBackend(context, taskPrompt); const backendResult = await this.executeViaBackend(context, taskPrompt);
@@ -50,6 +74,156 @@ export class ExecutorAgent extends BaseAgent {
}; };
} }
private async executeWithPersonas(
context: AgentContext,
config: CIAgentConfig
): Promise<AgentResult> {
const start = Date.now();
const planContent = this.readPlanFile(context);
if (!planContent) {
this.log("No plan file found — falling back to standard execution");
const taskPrompt = await this.buildBackendTaskPrompt(context);
return this.executeViaBackend(context, taskPrompt);
}
const decomposer = new TaskDecomposer(context.project_path, config, context.project_slug);
const plan = decomposer.decompose(planContent);
const resolvedPlan = decomposer.resolveConflicts(plan);
this.log(`Decomposed plan into ${resolvedPlan.tasks.length} tasks across domains: data=${resolvedPlan.dataTasks.length}, backend=${resolvedPlan.backendTasks.length}, frontend=${resolvedPlan.frontendTasks.length}, coordination=${resolvedPlan.coordinationTasks.length}`);
if (resolvedPlan.conflicts.length > 0) {
this.log(`Resolved ${resolvedPlan.conflicts.length} territory conflicts`);
for (const conflict of resolvedPlan.conflicts) {
this.log(` Conflict: ${conflict.description}${conflict.resolution || "unresolved"}`);
}
}
const personaGroups = this.groupTasksByPersona(resolvedPlan);
const personaLoader = new PersonaLoader(context.project_path, config);
const enforcement = config.personas?.territory_enforcement || "warn";
let totalDecisions = 0;
let totalEscalations = 0;
const allArtifacts: string[] = [];
let lastError: string | undefined;
const domainOrder: string[] = ["data", "backend", "frontend", "coordination"];
const sortedGroups = domainOrder
.flatMap((domain) => personaGroups.filter((g) => g.domain === domain))
.concat(personaGroups.filter((g) => !domainOrder.includes(g.domain)));
for (const group of sortedGroups) {
this.log(`Executing group: persona=${group.persona}, domain=${group.domain}, tasks=${group.tasks.length}`);
for (const conflict of group.conflicts) {
if (enforcement === "strict") {
this.warn(`Territory conflict (strict): ${conflict.description}`);
totalEscalations++;
} else {
this.log(`Territory conflict (warn): ${conflict.description}${conflict.resolution || "auto-resolved"}`);
}
}
const persona = personaLoader.getPersona(group.persona);
const personaContext = this.buildPersonaContext(context, persona, group);
try {
const result = await this.executeViaBackend(personaContext, personaContext.specification);
if (Array.isArray(result.artifacts_created)) {
allArtifacts.push(...result.artifacts_created);
}
totalDecisions += result.decisions;
totalEscalations += result.escalations;
if (!result.success) {
this.warn(`Persona ${group.persona} reported issues: ${result.error || "unspecified"}`);
lastError = result.error;
}
} catch (err) {
this.warn(`Persona ${group.persona} failed: ${err instanceof Error ? err.message : String(err)}`);
lastError = err instanceof Error ? err.message : String(err);
}
}
const verification = await this.verifyExecution(context);
return {
success: verification.testsPassing || lastError === undefined,
output: `Executed ${resolvedPlan.tasks.length} tasks across ${personaGroups.length} persona groups. Verification: tests=${verification.testsPassing ? "passing" : "failing"}, must-haves=${verification.mustHavesChecked.length}`,
artifacts_created: allArtifacts,
decisions: totalDecisions,
escalations: totalEscalations,
duration_ms: Date.now() - start,
error: lastError,
};
}
private groupTasksByPersona(plan: DecomposedPlan): PersonaTaskGroup[] {
const groupMap = new Map<string, PersonaTaskGroup>();
for (const task of plan.tasks) {
const key = task.persona;
if (!groupMap.has(key)) {
groupMap.set(key, {
persona: task.persona,
domain: task.domain,
tasks: [],
conflicts: plan.conflicts.filter((c) => c.personas.includes(task.persona)),
});
}
groupMap.get(key)!.tasks.push({
id: task.taskId,
description: task.description,
files: task.files,
});
}
return Array.from(groupMap.values());
}
private buildPersonaContext(
context: AgentContext,
persona: ReturnType<PersonaLoader["getPersona"]>,
group: PersonaTaskGroup
): AgentContext {
const personaPrompt = persona
? `You are the ${persona.name} (${persona.domain} domain). ${persona.systemPromptAdditions || persona.description}.\n\nPreferred frameworks: ${persona.frameworks.join(", ")}.\nDesign constraints: ${persona.constraints.join(", ")}.\nTerritory files: ${persona.territory.join(", ")}.\n\n`
: "";
const taskDescriptions = group.tasks
.map((t) => `- [${t.id}] ${t.description} (files: ${t.files.join(", ") || "TBD"})`)
.join("\n");
const conflictNotes = group.conflicts.length > 0
? `\n\n## Territory Conflicts (resolved by lead developer)\n${group.conflicts.map((c) => `- ${c.description} → Resolution: ${c.resolution || "pending"}`).join("\n")}`
: "";
const specification = [
personaPrompt,
"## Assigned Tasks\n",
taskDescriptions,
conflictNotes,
"\n\n## Specification\n",
context.specification || "No specification provided",
].join("\n");
return {
...context,
specification,
};
}
private loadProjectConfig(context: AgentContext): CIAgentConfig {
try {
return loadConfig(context.project_path);
} catch {
return DEFAULT_CIAGENT_CONFIG as CIAgentConfig;
}
}
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> { private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
const parts: string[] = [ const parts: string[] = [
`Execute implementation for stage ${context.stage}, phase ${context.phase}.`, `Execute implementation for stage ${context.stage}, phase ${context.phase}.`,
@@ -64,8 +238,12 @@ export class ExecutorAgent extends BaseAgent {
} }
const ciDir = path.join(context.project_path, ".ciagent"); const ciDir = path.join(context.project_path, ".ciagent");
const roadmapPath = path.join(ciDir, "ROADMAP.md"); const roadmapPath = context.project_slug
const archPath = path.join(ciDir, "ARCHITECTURE.md"); ? path.join(ciDir, context.project_slug, "ROADMAP.md")
: path.join(ciDir, "ROADMAP.md");
const archPath = context.project_slug
? path.join(ciDir, context.project_slug, "ARCHITECTURE.md")
: path.join(ciDir, "ARCHITECTURE.md");
if (fs.existsSync(roadmapPath)) { if (fs.existsSync(roadmapPath)) {
try { try {
@@ -91,11 +269,17 @@ export class ExecutorAgent extends BaseAgent {
} }
private readPlanFile(context: AgentContext): string | null { private readPlanFile(context: AgentContext): string | null {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md"); const planPath = context.project_slug
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
: path.join(context.project_path, ".ciagent", "PLAN.md");
try { try {
if (fs.existsSync(planPath)) { if (fs.existsSync(planPath)) {
return fs.readFileSync(planPath, "utf-8"); return fs.readFileSync(planPath, "utf-8");
} }
const defaultPlanPath = path.join(context.project_path, ".ciagent", "PLAN.md");
if (fs.existsSync(defaultPlanPath)) {
return fs.readFileSync(defaultPlanPath, "utf-8");
}
} catch {} } catch {}
return null; return null;
} }
@@ -139,7 +323,9 @@ export class ExecutorAgent extends BaseAgent {
} }
private checkMustHaves(context: AgentContext): MustHaveItem[] { private checkMustHaves(context: AgentContext): MustHaveItem[] {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md"); const planPath = context.project_slug
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
: path.join(context.project_path, ".ciagent", "PLAN.md");
const results: MustHaveItem[] = []; const results: MustHaveItem[] = [];
try { try {
+31
View File
@@ -20,6 +20,7 @@ import { loadConfig, saveConfig, isCIAgentInitialized, initCIAgent } from "../co
import { getAgent } from "./index.js"; import { getAgent } from "./index.js";
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js"; import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
import { registerEscalationProtocol } from "../cli/index.js"; import { registerEscalationProtocol } from "../cli/index.js";
import { SessionManager } from "../core/session-manager.js";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
export interface GitAgentContext extends AgentContext { export interface GitAgentContext extends AgentContext {
@@ -894,6 +895,36 @@ export class OrchestratorAgent extends BaseAgent {
this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`); this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`);
const useSessions = config.sessions?.max_concurrent_sessions !== undefined;
if (useSessions) {
return this.runWithSessionManager(context, activeProjects, config);
}
return this.runWithLegacyParallel(context, activeProjects, config);
}
private async runWithSessionManager(
context: AgentContext,
activeProjects: string[],
config: CIAgentConfig
): Promise<Record<string, AgentResult>> {
const sessionManager = new SessionManager(context.project_path, config);
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
const contextFactory = (slug: string): AgentContext => ({
...context,
project_slug: slug,
});
return sessionManager.runAllSessions(activeProjects, contextFactory, parallel);
}
private async runWithLegacyParallel(
context: AgentContext,
activeProjects: string[],
config: CIAgentConfig
): Promise<Record<string, AgentResult>> {
const results: Record<string, AgentResult> = {}; const results: Record<string, AgentResult> = {};
const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3; const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3;
const parallel = config.parallelization?.enabled && activeProjects.length > 1; const parallel = config.parallelization?.enabled && activeProjects.length > 1;
+148 -4
View File
@@ -18,6 +18,8 @@ import { BackendUnavailableError } from "../backends/types.js";
import { getAgent } from "../agents/index.js"; import { getAgent } from "../agents/index.js";
import { CIAgentFiles } from "../core/ciagent-files.js"; import { CIAgentFiles } from "../core/ciagent-files.js";
import { GiteaClient, generateReleaseNotes } from "../core/gitea.js"; import { GiteaClient, generateReleaseNotes } from "../core/gitea.js";
import { SessionManager } from "../core/session-manager.js";
import { AgentSession } from "../core/agent-session.js";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as readline from "node:readline"; import * as readline from "node:readline";
@@ -172,6 +174,7 @@ export function createRunCommand(): Command {
.option("--backend <provider>", "Override intelligence backend for this run") .option("--backend <provider>", "Override intelligence backend for this run")
.option("--ideate", "Insert ideation stage between research and plan") .option("--ideate", "Insert ideation stage between research and plan")
.option("--project <slug>", "Target project slug (comma-separated or 'all')") .option("--project <slug>", "Target project slug (comma-separated or 'all')")
.option("--session <id>", "Resume a specific session by ID")
.action(async (phase, options) => { .action(async (phase, options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
@@ -1002,7 +1005,7 @@ function computeShipVersion(
projectPath: string, projectPath: string,
phaseNum: number, phaseNum: number,
config: CIAgentConfig config: CIAgentConfig
): { tag: string; milestoneType: "nfr" | "feature" | "schema-breaking" } { ): { tag: string; milestoneType: "nfr" | "feature" | "major" } {
const tags = execSync("git tag -l", { cwd: projectPath, encoding: "utf-8" }) const tags = execSync("git tag -l", { cwd: projectPath, encoding: "utf-8" })
.split("\n") .split("\n")
.map((t) => t.trim()) .map((t) => t.trim())
@@ -1029,7 +1032,7 @@ function computeShipVersion(
const milestoneType = inferMilestoneType(projectPath); const milestoneType = inferMilestoneType(projectPath);
let tag: string; let tag: string;
if (milestoneType === "schema-breaking") { if (milestoneType === "major") {
tag = `v${major}.${minor + phaseNum}.0`; tag = `v${major}.${minor + phaseNum}.0`;
} else { } else {
tag = `v${major}.${minor}.${phaseNum}`; tag = `v${major}.${minor}.${phaseNum}`;
@@ -1038,10 +1041,10 @@ function computeShipVersion(
return { tag, milestoneType }; return { tag, milestoneType };
} }
function inferMilestoneType(projectPath: string): "nfr" | "feature" | "schema-breaking" { function inferMilestoneType(projectPath: string): "nfr" | "feature" | "major" {
try { try {
const log = execSync("git log --oneline -50", { cwd: projectPath, encoding: "utf-8" }); const log = execSync("git log --oneline -50", { cwd: projectPath, encoding: "utf-8" });
if (log.match(/\brefactor\b|\brewrite\b|\bmigrate\b|\brestructure\b/i)) return "schema-breaking"; if (log.match(/\brefactor\b|\brewrite\b|\bmigrate\b|\brestructure\b/i)) return "major";
if (log.match(/\bfeat\b/)) return "feature"; if (log.match(/\bfeat\b/)) return "feature";
return "nfr"; return "nfr";
} catch { } catch {
@@ -1372,4 +1375,145 @@ export function createIdeateCommand(): Command {
console.log(` ${cat}: ${count}`); console.log(` ${cat}: ${count}`);
} }
}); });
}
export function createSessionsCommand(): Command {
return new Command("sessions")
.description("Manage CIAgent agent sessions")
.addCommand(
new Command("list")
.description("List all sessions")
.option("--project <slug>", "Filter by project slug")
.action(async (options) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const persisted = sessionManager.loadPersistedSessions();
const active = sessionManager.listSessions();
const allSessions = [...persisted];
for (const activeSession of active) {
if (!allSessions.find((s) => s.id === activeSession.id)) {
allSessions.push(activeSession);
}
}
if (options.project) {
const filtered = allSessions.filter((s) => s.project_slug === options.project);
displaySessions(filtered);
} else {
displaySessions(allSessions);
}
})
)
.addCommand(
new Command("status")
.description("Show status of a specific session")
.argument("<session-id>", "Session ID")
.action(async (sessionId) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const persisted = sessionManager.loadPersistedSessions();
const sessionInfo = persisted.find((s) => s.id === sessionId);
if (!sessionInfo) {
const session = sessionManager.getSession(sessionId);
if (!session) {
console.error(`Session ${sessionId} not found.`);
process.exit(1);
}
displaySessionDetail(session.getSessionInfo());
return;
}
displaySessionDetail(sessionInfo);
})
)
.addCommand(
new Command("cancel")
.description("Cancel a running session")
.argument("<session-id>", "Session ID")
.action(async (sessionId) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const success = sessionManager.cancelSession(sessionId);
if (success) {
console.log(`Session ${sessionId} cancelled.`);
} else {
console.error(`Failed to cancel session ${sessionId}. Session may not be running.`);
process.exit(1);
}
})
)
.addCommand(
new Command("cleanup")
.description("Clean up stale sessions")
.action(async () => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const sessionManager = new SessionManager(projectPath, config);
const cleaned = sessionManager.cleanupStaleSessions();
console.log(`Cleaned up ${cleaned} stale session(s).`);
})
);
}
function displaySessions(sessions: Array<import("../types/session.js").SessionInfo>): void {
if (sessions.length === 0) {
console.log("No sessions found.");
return;
}
console.log("\n─── CIAgent Sessions ───\n");
console.log("ID Project Phase Stage Status");
console.log("-------- ---------------- ----- ---------- ---------");
for (const s of sessions) {
const id = s.id.padEnd(8);
const slug = (s.project_slug || "default").padEnd(16);
const phase = String(s.phase).padEnd(5);
const stage = s.stage.padEnd(10);
const statusIcon = s.status === "running" ? "●" : s.status === "completed" ? "✓" : s.status === "failed" ? "✗" : s.status === "paused" ? "⏸" : "○";
console.log(`${id} ${slug} ${phase} ${stage} ${statusIcon} ${s.status}`);
}
console.log(`\n${sessions.length} session(s) total.`);
}
function displaySessionDetail(s: import("../types/session.js").SessionInfo): void {
console.log("\n─── Session Detail ───\n");
console.log(` ID: ${s.id}`);
console.log(` Project: ${s.project_slug || "default"}`);
console.log(` Phase: ${s.phase}`);
console.log(` Stage: ${s.stage}`);
console.log(` Status: ${s.status}`);
console.log(` Started: ${s.started_at}`);
console.log(` Last Updated: ${s.last_updated}`);
if (s.error) {
console.log(` Error: ${s.error}`);
}
} }
+3 -1
View File
@@ -18,6 +18,7 @@ import {
createShipCommand, createShipCommand,
createProjectsCommand, createProjectsCommand,
createIdeateCommand, createIdeateCommand,
createSessionsCommand,
} from "./commands.js"; } from "./commands.js";
let activeEscalationProtocol: { dispose(): void } | null = null; let activeEscalationProtocol: { dispose(): void } | null = null;
@@ -68,6 +69,7 @@ program
.addCommand(createRollbackCommand()) .addCommand(createRollbackCommand())
.addCommand(createShipCommand()) .addCommand(createShipCommand())
.addCommand(createProjectsCommand()) .addCommand(createProjectsCommand())
.addCommand(createIdeateCommand()); .addCommand(createIdeateCommand())
.addCommand(createSessionsCommand());
program.parse(); program.parse();
+284
View File
@@ -0,0 +1,284 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { execSync } from "node:child_process";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { SessionConfig, SessionInfo, SessionStatus, DEFAULT_SESSION_CONFIG } from "../types/session.js";
import { PipelineStage } from "../types/pipeline.js";
import { AgentContext, AgentResult } from "../agents/base.js";
import { loadConfig } from "../core/config.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { GitContext } from "../core/git-context.js";
import { CommitBuilder } from "../core/commit-builder.js";
import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js";
import { PipelineState, createInitialPipelineState } from "../types/pipeline.js";
export class AgentSession {
private id: string;
private projectSlug: string;
private projectPath: string;
private config: CIAgentConfig;
private sessionConfig: SessionConfig;
private status: SessionStatus;
private pipelineState: PipelineState | null;
private error: string | undefined;
private startedAt: string;
private lastUpdated: string;
private lockAcquired: boolean;
constructor(projectPath: string, projectSlug: string, config?: CIAgentConfig) {
this.id = crypto.randomUUID().slice(0, 8);
this.projectSlug = projectSlug;
this.projectPath = projectPath;
this.config = config || loadConfig(projectPath);
this.sessionConfig = this.config.sessions || DEFAULT_SESSION_CONFIG;
this.status = "pending";
this.pipelineState = null;
this.error = undefined;
this.startedAt = new Date().toISOString();
this.lastUpdated = this.startedAt;
this.lockAcquired = false;
}
getId(): string {
return this.id;
}
getProjectSlug(): string {
return this.projectSlug;
}
getStatus(): SessionStatus {
return this.status;
}
getSessionInfo(): SessionInfo {
return {
id: this.id,
project_slug: this.projectSlug,
project_path: this.projectPath,
phase: this.pipelineState?.current_phase ?? 0,
stage: this.pipelineState?.current_stage ?? "specify",
status: this.status,
started_at: this.startedAt,
last_updated: this.lastUpdated,
error: this.error,
};
}
acquireLock(): boolean {
const lockPath = this.getLockPath();
ensureDir(path.dirname(lockPath));
if (fileExists(lockPath)) {
const lockData = JSON.parse(readFile(lockPath) || "{}") as { sessionId: string; timestamp: string; projectSlug: string };
if (lockData.sessionId && lockData.sessionId !== this.id) {
const lockAge = Date.now() - new Date(lockData.timestamp).getTime();
if (lockAge < (this.sessionConfig.session_timeout_ms || 3600000)) {
return false;
}
}
}
writeFile(lockPath, JSON.stringify({
sessionId: this.id,
timestamp: new Date().toISOString(),
projectSlug: this.projectSlug,
}));
this.lockAcquired = true;
return true;
}
releaseLock(): void {
if (!this.lockAcquired) return;
const lockPath = this.getLockPath();
try {
if (fileExists(lockPath)) {
const lockData = JSON.parse(readFile(lockPath) || "{}") as { sessionId: string };
if (lockData.sessionId === this.id) {
fs.unlinkSync(lockPath);
}
}
} catch {}
this.lockAcquired = false;
}
async run(context: AgentContext): Promise<AgentResult> {
if (this.status === "running") {
return {
success: false,
output: `Session ${this.id} is already running`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: "Session already running",
};
}
const locked = this.acquireLock();
if (!locked) {
return {
success: false,
output: `Failed to acquire lock for session ${this.id}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: "Lock acquisition failed — another session is active for this project",
};
}
this.status = "running";
this.lastUpdated = new Date().toISOString();
this.pipelineState = createInitialPipelineState(this.projectPath);
const gitContext = new GitContext(this.projectPath, this.projectSlug || undefined);
const projectState = gitContext.reconstructState();
if (projectState.currentPhase > 0) {
this.pipelineState.current_phase = projectState.currentPhase;
this.pipelineState.current_stage = projectState.currentStage;
}
this.persistState();
let result: AgentResult;
try {
const { OrchestratorAgent } = await import("../agents/orchestrator.js");
const orchestrator = new OrchestratorAgent(this.config);
result = await orchestrator.runForProject(this.projectSlug, context);
this.status = result.success ? "completed" : "failed";
this.error = result.error;
} catch (err) {
this.status = "failed";
this.error = err instanceof Error ? err.message : String(err);
result = {
success: false,
output: `Session ${this.id} failed: ${this.error}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: this.error,
};
} finally {
this.lastUpdated = new Date().toISOString();
this.releaseLock();
this.persistState();
}
if (this.config.git?.auto_commit && result.success) {
const ciFiles = new CIAgentFiles(this.projectPath, this.projectSlug || undefined);
try {
const sessionCommit = CommitBuilder.buildTaskCommit({
type: "chore",
phase: this.pipelineState?.current_phase ?? 0,
milestone: "session",
project: this.projectSlug || undefined,
plan: "session",
task: this.id,
subject: `session ${this.id} ${this.status}`,
status: "complete" as PipelineStage,
});
if (gitContext.isGitRepo()) {
execSync(`git add -A && git commit -m "${sessionCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: this.projectPath,
stdio: "pipe",
});
}
} catch {}
}
return {
...result,
output: `[session:${this.id}] ${result.output}`,
};
}
cancel(): boolean {
if (this.status !== "running") return false;
this.status = "cancelled";
this.lastUpdated = new Date().toISOString();
this.releaseLock();
this.persistState();
return true;
}
pause(): boolean {
if (this.status !== "running") return false;
this.status = "paused";
this.lastUpdated = new Date().toISOString();
this.persistState();
return true;
}
resume(): boolean {
if (this.status !== "paused") return false;
this.status = "running";
this.lastUpdated = new Date().toISOString();
return true;
}
private getLockPath(): string {
const ciDir = path.join(this.projectPath, ".ciagent");
const slugDir = this.projectSlug ? path.join(ciDir, this.projectSlug) : ciDir;
return path.join(slugDir, ".session-lock");
}
private getStatePath(): string {
const ciDir = path.join(this.projectPath, ".ciagent");
const slugDir = this.projectSlug ? path.join(ciDir, this.projectSlug) : ciDir;
return path.join(slugDir, `.session-${this.id}.json`);
}
persistState(): void {
const statePath = this.getStatePath();
const stateData = {
id: this.id,
projectSlug: this.projectSlug,
projectPath: this.projectPath,
status: this.status,
startedAt: this.startedAt,
lastUpdated: this.lastUpdated,
error: this.error,
pipelineState: this.pipelineState,
};
ensureDir(path.dirname(statePath));
writeFile(statePath, JSON.stringify(stateData, null, 2));
}
static loadState(projectPath: string, sessionId: string, projectSlug?: string): AgentSession | null {
const ciDir = path.join(projectPath, ".ciagent");
const slugDir = projectSlug ? path.join(ciDir, projectSlug) : ciDir;
const statePath = path.join(slugDir, `.session-${sessionId}.json`);
if (!fileExists(statePath)) return null;
try {
const data = JSON.parse(readFile(statePath) || "{}") as {
id: string;
projectSlug: string;
projectPath: string;
status: SessionStatus;
startedAt: string;
lastUpdated: string;
error?: string;
};
const session = new AgentSession(data.projectPath, data.projectSlug);
(session as any).id = data.id;
(session as any).status = data.status;
(session as any).startedAt = data.startedAt;
(session as any).lastUpdated = data.lastUpdated;
(session as any).error = data.error;
return session;
} catch {
return null;
}
}
}
+3 -3
View File
@@ -329,7 +329,7 @@ describe("CIAgentFiles", () => {
expect(ciFiles.getMilestoneType()).toBe("feature"); expect(ciFiles.getMilestoneType()).toBe("feature");
}); });
it("returns schema-breaking when phases include refactor/rewrite/migrate", () => { it("returns major when phases include refactor/rewrite/migrate", () => {
const ciFiles = new CIAgentFiles(dir, "schema-proj"); const ciFiles = new CIAgentFiles(dir, "schema-proj");
ciFiles.ensureProjectDir(); ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
@@ -337,13 +337,13 @@ describe("CIAgentFiles", () => {
active_project: "schema-proj", active_project: "schema-proj",
})); }));
const roadmap: RoadmapMd = { const roadmap: RoadmapMd = {
overview: "schema-breaking", overview: "major",
phases: [ phases: [
{ number: 1, name: "refactor-core", description: "Refactor core", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, { number: 1, name: "refactor-core", description: "Refactor core", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
], ],
}; };
ciFiles.writeRoadmapMd(roadmap); ciFiles.writeRoadmapMd(roadmap);
expect(ciFiles.getMilestoneType()).toBe("schema-breaking"); expect(ciFiles.getMilestoneType()).toBe("major");
}); });
}); });
+1 -1
View File
@@ -486,7 +486,7 @@ export class CIAgentFiles {
} }
} }
if (hasSchemaBreak) return "schema-breaking"; if (hasSchemaBreak) return "major";
if (hasFeature) return "feature"; if (hasFeature) return "feature";
return "nfr"; return "nfr";
} }
+1
View File
@@ -98,6 +98,7 @@ export class CommitBuilder {
lines.push(`milestone: ${ci.milestone}`); lines.push(`milestone: ${ci.milestone}`);
if (ci.project) lines.push(`project: ${ci.project}`); if (ci.project) lines.push(`project: ${ci.project}`);
if (ci.session) lines.push(`session: ${ci.session}`);
if (ci.plan) lines.push(`plan: ${ci.plan}`); if (ci.plan) lines.push(`plan: ${ci.plan}`);
if (ci.task) lines.push(`task: ${ci.task}`); if (ci.task) lines.push(`task: ${ci.task}`);
+3
View File
@@ -43,6 +43,9 @@ export function parseCIAgentBlock(yaml: string): CIAgentMetadata | null {
const projectMatch = yaml.match(/^project:\s*(.+)$/m); const projectMatch = yaml.match(/^project:\s*(.+)$/m);
if (projectMatch) result.project = projectMatch[1].trim(); if (projectMatch) result.project = projectMatch[1].trim();
const sessionMatch = yaml.match(/^session:\s*(.+)$/m);
if (sessionMatch) result.session = sessionMatch[1].trim();
result.decisions = parseDecisionsFromYaml(yaml); result.decisions = parseDecisionsFromYaml(yaml);
result.escalations = parseEscalationsFromYaml(yaml); result.escalations = parseEscalationsFromYaml(yaml);
result.requirements = parseRequirementsFromYaml(yaml); result.requirements = parseRequirementsFromYaml(yaml);
+2 -2
View File
@@ -192,11 +192,11 @@ describe("GitBranch", () => {
expect(tag).toBe("v0.6.0"); expect(tag).toBe("v0.6.0");
}); });
it("computes next major for schema-breaking milestone", () => { it("computes next major for major milestone", () => {
execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" }); execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" });
const gitBranch = new GitBranch(repoDir); const gitBranch = new GitBranch(repoDir);
const tag = gitBranch.computeMilestoneTag("schema-breaking"); const tag = gitBranch.computeMilestoneTag("major");
expect(tag).toBe("v1.0.0"); expect(tag).toBe("v1.0.0");
}); });
+1 -1
View File
@@ -242,7 +242,7 @@ export class GitBranch {
} }
} }
if (milestoneType === "schema-breaking") { if (milestoneType === "major") {
return `v${major + 1}.0.0`; return `v${major + 1}.0.0`;
} }
+2 -2
View File
@@ -307,7 +307,7 @@ status: execute
expect(ctx.getMilestoneType()).toBe("feature"); expect(ctx.getMilestoneType()).toBe("feature");
}); });
it("returns schema-breaking when refactor commits exist", () => { it("returns major when refactor commits exist", () => {
commit(repoDir, `refactor(P01): rewrite core commit(repoDir, `refactor(P01): rewrite core
---ci--- ---ci---
@@ -317,7 +317,7 @@ status: execute
---/ci---`); ---/ci---`);
const ctx = new GitContext(repoDir); const ctx = new GitContext(repoDir);
expect(ctx.getMilestoneType()).toBe("schema-breaking"); expect(ctx.getMilestoneType()).toBe("major");
}); });
}); });
}); });
+1 -1
View File
@@ -333,7 +333,7 @@ export class GitContext {
if (!commit.ci) continue; if (!commit.ci) continue;
hasAnyCiCommit = true; hasAnyCiCommit = true;
if (commit.type === "feat") return "feature"; if (commit.type === "feat") return "feature";
if (commit.type === "refactor" || commit.scope === "init") return "schema-breaking"; if (commit.type === "refactor" || commit.scope === "init") return "major";
} }
if (!hasAnyCiCommit) return "nfr"; if (!hasAnyCiCommit) return "nfr";
return "nfr"; return "nfr";
+4 -1
View File
@@ -9,6 +9,9 @@ export { GitBranch } from "./git-branch.js";
export { CommitBuilder } from "./commit-builder.js"; export { CommitBuilder } from "./commit-builder.js";
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js"; export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js";
export { GiteaClient, generateReleaseNotes } from "./gitea.js"; export { GiteaClient, generateReleaseNotes } from "./gitea.js";
export type { GiteaReleaseConfig, GiteaRelease } from "./gitea.js"; export { AgentSession } from "./agent-session.js";
export { SessionManager } from "./session-manager.js";
export { PersonaLoader } from "./persona-loader.js";
export { TaskDecomposer } from "./task-decomposer.js";
export type { CIAgentConfig } from "../types/config.js"; export type { CIAgentConfig } from "../types/config.js";
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js"; export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
+227
View File
@@ -0,0 +1,227 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { ExecutePersonaConfig, PersonaDomain, DEFAULT_PERSONAS, TerritoryEnforcement } from "../types/persona.js";
import { CIAgentConfig } from "../types/config.js";
export interface PersonaDefinition {
name: string;
domain: PersonaDomain;
frameworks: string[];
constraints: string[];
territory: string[];
description: string;
systemPromptAdditions: string;
}
const PERSONA_SEARCH_PATHS = [
".config/opencode/agents",
"opencode/agents",
];
const PERSONA_FILE_PATTERN = /^ci-(.+)\.md$/;
export class PersonaLoader {
private projectPath: string;
private config: CIAgentConfig;
private cachedPersonas: Map<string, PersonaDefinition> = new Map();
private loaded: boolean = false;
constructor(projectPath: string, config: CIAgentConfig) {
this.projectPath = projectPath;
this.config = config;
}
loadPersonas(): PersonaDefinition[] {
if (this.loaded) {
return Array.from(this.cachedPersonas.values());
}
const configPersonas = this.config.personas?.personas || DEFAULT_PERSONAS;
const configEnabled = this.config.personas?.enabled ?? true;
if (!configEnabled) {
this.loaded = true;
return [];
}
for (const configPersona of configPersonas) {
const filePersona = this.loadPersonaFromFile(configPersona.name);
if (filePersona) {
const merged: PersonaDefinition = {
name: configPersona.name,
domain: configPersona.domain,
frameworks: filePersona.frameworks.length > 0 ? filePersona.frameworks : configPersona.frameworks,
constraints: filePersona.constraints.length > 0 ? filePersona.constraints : configPersona.constraints,
territory: filePersona.territory.length > 0 ? filePersona.territory : configPersona.territory,
description: filePersona.description,
systemPromptAdditions: filePersona.systemPromptAdditions,
};
this.cachedPersonas.set(configPersona.name, merged);
} else {
const definition: PersonaDefinition = {
name: configPersona.name,
domain: configPersona.domain,
frameworks: configPersona.frameworks,
constraints: configPersona.constraints,
territory: configPersona.territory,
description: `${configPersona.name} persona (domain: ${configPersona.domain})`,
systemPromptAdditions: this.buildDefaultPromptAdditions(configPersona),
};
this.cachedPersonas.set(configPersona.name, definition);
}
}
this.loaded = true;
return Array.from(this.cachedPersonas.values());
}
getPersona(name: string): PersonaDefinition | undefined {
if (!this.loaded) this.loadPersonas();
return this.cachedPersonas.get(name);
}
getPersonaForDomain(domain: PersonaDomain): PersonaDefinition | undefined {
if (!this.loaded) this.loadPersonas();
for (const persona of this.cachedPersonas.values()) {
if (persona.domain === domain) return persona;
}
return undefined;
}
getLeadDeveloper(): PersonaDefinition {
return this.getPersona("lead-developer") || {
name: "lead-developer",
domain: "coordination",
frameworks: [],
constraints: ["pragmatic", "battle-tested defaults"],
territory: [],
description: "Lead developer — coordinates task decomposition and resolves conflicts",
systemPromptAdditions: "",
};
}
getEngineerPersonas(): PersonaDefinition[] {
if (!this.loaded) this.loadPersonas();
return Array.from(this.cachedPersonas.values()).filter(
(p) => p.domain !== "coordination"
);
}
getTerritoryEnforcement(): TerritoryEnforcement {
return this.config.personas?.territory_enforcement || "warn";
}
private loadPersonaFromFile(name: string): PersonaDefinition | null {
const filename = `ci-${name}.md`;
for (const searchPath of PERSONA_SEARCH_PATHS) {
const filePath = path.join(this.projectPath, searchPath, filename);
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, "utf-8");
return this.parsePersonaMd(name, content);
} catch {
continue;
}
}
}
return null;
}
private parsePersonaMd(name: string, content: string): PersonaDefinition {
const frontmatter = this.parseFrontmatter(content);
const body = this.stripFrontmatter(content);
return {
name: (frontmatter.name as string) || name,
domain: (frontmatter.domain as PersonaDomain) || this.inferDomainFromName(name),
frameworks: (frontmatter.frameworks as string[]) || [],
constraints: (frontmatter.constraints as string[]) || [],
territory: (frontmatter.territory as string[]) || [],
description: (frontmatter.description as string) || body.slice(0, 200),
systemPromptAdditions: body,
};
}
private parseFrontmatter(content: string): Record<string, unknown> {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const yaml = match[1];
const result: Record<string, unknown> = {};
const lines = yaml.split("\n");
let currentKey = "";
let inArray = false;
let arrayItems: string[] = [];
for (const line of lines) {
const arrMatch = line.match(/^(\w+):\s*$/);
if (arrMatch) {
if (inArray && currentKey) {
result[currentKey] = arrayItems;
}
currentKey = arrMatch[1];
inArray = true;
arrayItems = [];
continue;
}
const itemMatch = line.match(/^\s+-\s+(.+)$/);
if (itemMatch && inArray) {
arrayItems.push(itemMatch[1].trim());
continue;
}
const kvMatch = line.match(/^(\w+):\s*(.+)$/);
if (kvMatch) {
if (inArray && currentKey) {
result[currentKey] = arrayItems;
inArray = false;
}
currentKey = kvMatch[1];
result[currentKey] = kvMatch[2].trim();
}
}
if (inArray && currentKey) {
result[currentKey] = arrayItems;
}
return result;
}
private stripFrontmatter(content: string): string {
return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
}
private inferDomainFromName(name: string): PersonaDomain {
if (name.includes("data") || name.includes("db") || name.includes("schema")) return "data";
if (name.includes("backend") || name.includes("api") || name.includes("server")) return "backend";
if (name.includes("frontend") || name.includes("ui") || name.includes("client")) return "frontend";
return "coordination";
}
private buildDefaultPromptAdditions(config: ExecutePersonaConfig): string {
const parts: string[] = [];
parts.push(`You are a ${config.name} persona in the CIAgent execution pipeline.`);
parts.push(`Domain: ${config.domain}.`);
if (config.frameworks.length > 0) {
parts.push(`Preferred frameworks: ${config.frameworks.join(", ")}.`);
}
if (config.constraints.length > 0) {
parts.push(`Design constraints: ${config.constraints.join(", ")}.`);
}
if (config.territory.length > 0) {
parts.push(`You own the following file patterns: ${config.territory.join(", ")}.`);
parts.push(`Do not modify files outside your territory without explicit lead developer approval.`);
}
return parts.join(" ");
}
}
+475
View File
@@ -0,0 +1,475 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
ExecutePersonaConfig,
PersonaDomain,
TerritoryConflict,
DecomposedTask,
DecomposedPlan,
DEFAULT_PERSONAS,
matchFileToPersona,
globMatch,
detectConflicts,
} from "../types/persona.js";
import { TaskDecomposer } from "../core/task-decomposer.js";
import { PersonaLoader } from "../core/persona-loader.js";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { initCIAgent } from "../core/config.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-persona-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
const samplePlan = `# Phase 1 Plan — Core API
## Phase Goal
Build core API routes and database schema.
### Wave 1 (foundational)
#### Task 1.1: Create user schema
| **ID** | P1-T1 |
| **REQs** | DATA-01 |
| **Description** | Create the users table schema with Drizzle ORM |
| **Files to create** | \`src/db/schema/users.ts\`, \`src/db/migrations/001_create_users.sql\` |
#### Task 1.2: Create auth routes
| **ID** | P1-T2 |
| **REQs** | API-01 |
| **Description** | Create /api/auth/login and /api/auth/register routes |
| **Files to create** | \`src/api/routes/auth.ts\`, \`src/api/middleware/auth.ts\` |
#### Task 1.3: Create login page
| **ID** | P1-T3 |
| **REQs** | UI-01 |
| **Description** | Create React login page component |
| **Files to create** | \`src/components/LoginForm.tsx\`, \`src/pages/login.tsx\` |
### Wave 2
#### Task 1.4: Create data repository
| **ID** | P1-T4 |
| **REQs** | DATA-02 |
| **Description** | Create UserRepository with typed query methods |
| **Files to create** | \`src/repository/userRepository.ts\` |
`;
describe("ExecutePersona type", () => {
it("DEFAULT_PERSONAS has 4 personas", () => {
expect(DEFAULT_PERSONAS).toHaveLength(4);
});
it("DEFAULT_PERSONAS includes lead-developer", () => {
const lead = DEFAULT_PERSONAS.find((p) => p.name === "lead-developer");
expect(lead).toBeTruthy();
expect(lead!.domain).toBe("coordination");
expect(lead!.territory).toHaveLength(0);
});
it("DEFAULT_PERSONAS includes data-engineer", () => {
const data = DEFAULT_PERSONAS.find((p) => p.name === "data-engineer");
expect(data).toBeTruthy();
expect(data!.domain).toBe("data");
expect(data!.frameworks).toContain("drizzle");
expect(data!.territory.length).toBeGreaterThan(0);
});
it("DEFAULT_PERSONAS includes backend-engineer", () => {
const backend = DEFAULT_PERSONAS.find((p) => p.name === "backend-engineer");
expect(backend).toBeTruthy();
expect(backend!.domain).toBe("backend");
expect(backend!.frameworks).toContain("fastify");
expect(backend!.territory.length).toBeGreaterThan(0);
});
it("DEFAULT_PERSONAS includes frontend-engineer", () => {
const frontend = DEFAULT_PERSONAS.find((p) => p.name === "frontend-engineer");
expect(frontend).toBeTruthy();
expect(frontend!.domain).toBe("frontend");
expect(frontend!.frameworks).toContain("react");
expect(frontend!.territory.length).toBeGreaterThan(0);
});
it("each domain persona has territory patterns", () => {
for (const persona of DEFAULT_PERSONAS) {
if (persona.domain === "coordination") continue;
expect(persona.territory.length).toBeGreaterThan(0);
}
});
it("each domain persona has constraints", () => {
for (const persona of DEFAULT_PERSONAS) {
if (persona.domain === "coordination") continue;
expect(persona.constraints.length).toBeGreaterThan(0);
}
});
});
describe("matchFileToPersona", () => {
const personas = DEFAULT_PERSONAS;
it("matches data files to data engineer", () => {
const matches = [
"src/db/schema/users.ts",
"src/migrations/001_create_users.sql",
"drizzle/config.ts",
"src/models/User.ts",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("data-engineer");
}
});
it("matches API files to backend engineer", () => {
const matches = [
"src/api/routes/auth.ts",
"src/services/UserService.ts",
"src/middleware/auth.ts",
"src/controllers/userController.ts",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("backend-engineer");
}
});
it("matches component files to frontend engineer", () => {
const matches = [
"src/components/LoginForm.tsx",
"src/pages/login.tsx",
"src/hooks/useAuth.ts",
"src/styles/global.css",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("frontend-engineer");
}
});
it("returns null for files outside any territory", () => {
const result = matchFileToPersona("src/utils/helpers.ts", personas);
expect(result).toBeNull();
});
it("handles glob patterns correctly", () => {
expect(globMatch("**/db/**", "src/db/schema/users.ts")).toBe(true);
expect(globMatch("**/db/**", "src/api/routes/auth.ts")).toBe(false);
expect(globMatch("**/*.tsx", "src/components/Button.tsx")).toBe(true);
expect(globMatch("**/*.tsx", "src/utils/helpers.ts")).toBe(false);
});
});
describe("detectConflicts", () => {
it("detects data-backend conflicts", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "data-engineer",
domain: "data",
description: "Create schema",
files: ["src/db/schema/users.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "backend-engineer",
domain: "backend",
description: "Create API routes",
files: ["src/db/schema/users.ts"],
dependencies: ["T1"],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(1);
expect(conflicts[0].type).toBe("data-backend");
expect(conflicts[0].personas).toContain("data-engineer");
expect(conflicts[0].personas).toContain("backend-engineer");
});
it("detects backend-frontend conflicts", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "backend-engineer",
domain: "backend",
description: "Create API types",
files: ["src/api/types/UserTypes.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "frontend-engineer",
domain: "frontend",
description: "Create user component",
files: ["src/api/types/UserTypes.ts"],
dependencies: ["T1"],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(1);
expect(conflicts[0].type).toBe("backend-frontend");
});
it("returns no conflicts for non-overlapping tasks", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "data-engineer",
domain: "data",
description: "Create schema",
files: ["src/db/schema/users.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "backend-engineer",
domain: "backend",
description: "Create API routes",
files: ["src/api/routes/auth.ts"],
dependencies: [],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(0);
});
});
describe("TaskDecomposer", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initCIAgent(dir);
});
afterEach(() => {
cleanup(dir);
});
it("decomposes a plan into persona-specific task groups", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config, "test-project");
const plan = decomposer.decompose(samplePlan);
expect(plan.tasks.length).toBeGreaterThan(0);
expect(plan.dataTasks).toBeDefined();
expect(plan.backendTasks).toBeDefined();
expect(plan.frontendTasks).toBeDefined();
expect(plan.coordinationTasks).toBeDefined();
});
it("resolves territory conflicts", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const resolved = decomposer.resolveConflicts(plan);
for (const conflict of resolved.conflicts) {
if (conflict.resolution) {
expect(conflict.resolution.length).toBeGreaterThan(0);
}
}
});
it("assigns data tasks to data-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const dataTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("schema") || f.includes("migration"))
);
if (dataTask) {
expect(dataTask.domain).toBe("data");
}
});
it("assigns API tasks to backend-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const apiTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("api") || f.includes("routes"))
);
if (apiTask) {
expect(apiTask.domain).toBe("backend");
}
});
it("assigns component tasks to frontend-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const frontendTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("components") || f.endsWith(".tsx"))
);
if (frontendTask) {
expect(frontendTask.domain).toBe("frontend");
}
});
});
describe("PersonaLoader", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initCIAgent(dir);
});
afterEach(() => {
cleanup(dir);
});
it("returns default personas when no files exist", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const personas = loader.loadPersonas();
expect(personas.length).toBeGreaterThan(0);
expect(personas.some((p) => p.domain === "data")).toBe(true);
expect(personas.some((p) => p.domain === "backend")).toBe(true);
expect(personas.some((p) => p.domain === "frontend")).toBe(true);
});
it("getLeadDeveloper returns lead developer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
loader.loadPersonas();
const lead = loader.getLeadDeveloper();
expect(lead).toBeTruthy();
expect(lead.domain).toBe("coordination");
expect(lead.name).toBe("lead-developer");
});
it("getEngineerPersonas returns non-coordination personas", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const engineers = loader.getEngineerPersonas();
expect(engineers.length).toBe(3);
expect(engineers.every((p) => p.domain !== "coordination")).toBe(true);
});
it("returns empty personas when personas disabled", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: false,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const personas = loader.loadPersonas();
expect(personas.length).toBe(0);
});
it("getTerritoryEnforcement returns configured value", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "strict" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
expect(loader.getTerritoryEnforcement()).toBe("strict");
});
it("defaults to warn territory enforcement", () => {
const config = { ...DEFAULT_CIAGENT_CONFIG };
const loader = new PersonaLoader(dir, config);
expect(loader.getTerritoryEnforcement()).toBe("warn");
});
});
+327
View File
@@ -0,0 +1,327 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { initCIAgent, loadConfig } from "../core/config.js";
import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { SessionConfig, SessionInfo, DEFAULT_SESSION_CONFIG } from "../types/session.js";
import { AgentSession } from "../core/agent-session.js";
import { SessionManager } from "../core/session-manager.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-session-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function initProjectWithConfig(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
const config = {
...DEFAULT_CIAGENT_CONFIG,
projects: [{ slug: "test-project", name: "Test Project", default: true }],
active_project: "test-project",
active_projects: ["test-project"],
sessions: {
max_concurrent_sessions: 3,
session_timeout_ms: 3600000,
session_isolation: "branch",
},
};
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify(config, null, 2));
const projectDir = path.join(ciDir, "test-project");
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(path.join(projectDir, "PROJECT.md"), [
"# Test Project",
"",
"## What This Is",
"",
"A test project for session testing",
"",
"## Requirements",
"",
"### Active",
"",
"- [ ] Build session management",
"",
"## Constraints",
"",
"- TypeScript",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "ROADMAP.md"), [
"# Roadmap",
"",
"## Overview",
"",
"Test project roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Sessions** - Build session management",
"",
"## Phase Details",
"",
"### Phase 1: Sessions",
"**Goal**.: Build session management",
"**Depends on**: Nothing",
"**Requirements**: SESSION-01",
"**Success Criteria**:",
"1. Sessions work",
"**Status**: not_started",
"",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "REQUIREMENTS.md"), [
"# Requirements",
"",
"| REQ-ID | Requirement | Priority | Phase | Status |",
"|--------|-------------|----------|-------|--------|",
"| SESSION-01 | Session management | P0 | 1 | pending |",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| SESSION-01 | Phase 1 | pending |",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "ARCHITECTURE.md"), [
"# Architecture",
"",
"## Overview",
"",
"Test architecture",
"",
"## Components",
"",
"### test-api",
"- **Description**: API",
"- **Boundaries**: HTTP only",
"- **Depends on**: None",
"",
"## Data Flow",
"",
"Client -> API",
"",
"## Build Order",
"",
"1. API",
"",
].join("\n"));
}
describe("Session types", () => {
it("DEFAULT_SESSION_CONFIG has expected values", () => {
expect(DEFAULT_SESSION_CONFIG.max_concurrent_sessions).toBe(3);
expect(DEFAULT_SESSION_CONFIG.session_timeout_ms).toBe(3600000);
expect(DEFAULT_SESSION_CONFIG.session_isolation).toBe("branch");
});
it("SessionInfo interface is constructable", () => {
const info: SessionInfo = {
id: "abc12345",
project_slug: "test-project",
project_path: "/tmp/test",
phase: 1,
stage: "execute",
status: "running",
started_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
};
expect(info.id).toBe("abc12345");
expect(info.status).toBe("running");
expect(info.project_slug).toBe("test-project");
});
it("SessionConfig supports all status values", () => {
const statuses: SessionInfo["status"][] = [
"pending", "running", "paused", "completed", "failed", "cancelled",
];
expect(statuses).toHaveLength(6);
});
});
describe("AgentSession", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initProjectWithConfig(dir);
});
afterEach(() => {
cleanup(dir);
});
it("creates a session with a unique ID", () => {
const session = new AgentSession(dir, "test-project");
expect(session.getId()).toBeTruthy();
expect(session.getId().length).toBeGreaterThan(0);
expect(session.getStatus()).toBe("pending");
});
it("getSessionInfo returns valid SessionInfo", () => {
const session = new AgentSession(dir, "test-project");
const info = session.getSessionInfo();
expect(info.id).toBe(session.getId());
expect(info.project_slug).toBe("test-project");
expect(info.project_path).toBe(dir);
expect(info.status).toBe("pending");
expect(info.phase).toBe(0);
});
it("persists session state", () => {
const session = new AgentSession(dir, "test-project");
session.persistState();
const slugDir = path.join(dir, ".ciagent", "test-project");
const files = fs.readdirSync(slugDir);
const stateFile = files.find((f) => f.startsWith(".session-") && f.endsWith(".json"));
expect(stateFile).toBeTruthy();
});
it("loads persisted session state", () => {
const session = new AgentSession(dir, "test-project");
session.persistState();
const loaded = AgentSession.loadState(dir, session.getId(), "test-project");
expect(loaded).not.toBeNull();
expect(loaded!.getId()).toBe(session.getId());
});
it("returns null for non-existent session", () => {
const loaded = AgentSession.loadState(dir, "nonexistent", "test-project");
expect(loaded).toBeNull();
});
it("acquireLock creates a lock file", () => {
const session = new AgentSession(dir, "test-project");
const acquired = session.acquireLock();
expect(acquired).toBe(true);
const lockPath = path.join(dir, ".ciagent", "test-project", ".session-lock");
expect(fs.existsSync(lockPath)).toBe(true);
session.releaseLock();
});
it("releaseLock removes the lock file", () => {
const session = new AgentSession(dir, "test-project");
session.acquireLock();
session.releaseLock();
const lockPath = path.join(dir, ".ciagent", "test-project", ".session-lock");
expect(fs.existsSync(lockPath)).toBe(false);
});
it("cancel changes status to cancelled when running", () => {
const session = new AgentSession(dir, "test-project");
session.acquireLock();
(session as any).status = "running";
const cancelled = session.cancel();
expect(cancelled).toBe(true);
expect(session.getStatus()).toBe("cancelled");
session.releaseLock();
});
it("cancel returns false for non-running session", () => {
const session = new AgentSession(dir, "test-project");
const cancelled = session.cancel();
expect(cancelled).toBe(false);
});
it("pause and resume work correctly for non-running session", () => {
const session = new AgentSession(dir, "test-project");
expect(session.pause()).toBe(false);
expect(session.resume()).toBe(false);
});
});
describe("SessionManager", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initProjectWithConfig(dir);
});
afterEach(() => {
cleanup(dir);
});
it("creates sessions for projects", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
expect(session).toBeTruthy();
expect(session.getProjectSlug()).toBe("test-project");
});
it("lists sessions", () => {
const manager = new SessionManager(dir);
manager.createSession("test-project");
const sessions = manager.listSessions();
expect(sessions.length).toBe(1);
expect(sessions[0].project_slug).toBe("test-project");
});
it("lists active sessions as empty when none running", () => {
const manager = new SessionManager(dir);
manager.createSession("test-project");
const active = manager.listActiveSessions();
expect(active.length).toBe(0);
});
it("cancels a session that is not running returns false", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
const cancelled = manager.cancelSession(session.getId());
expect(cancelled).toBe(false);
});
it("cleans up stale sessions returns 0", () => {
const manager = new SessionManager(dir);
const cleaned = manager.cleanupStaleSessions();
expect(cleaned).toBe(0);
});
it("loads persisted sessions as empty initially", () => {
const manager = new SessionManager(dir);
const persisted = manager.loadPersistedSessions();
expect(Array.isArray(persisted)).toBe(true);
});
it("gets a session by id", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
const retrieved = manager.getSession(session.getId());
expect(retrieved).toBeTruthy();
expect(retrieved!.getId()).toBe(session.getId());
});
it("returns undefined for non-existent session", () => {
const manager = new SessionManager(dir);
const retrieved = manager.getSession("nonexistent");
expect(retrieved).toBeUndefined();
});
});
+183
View File
@@ -0,0 +1,183 @@
import { CIAgentConfig } from "../types/config.js";
import { SessionInfo, SessionStatus } from "../types/session.js";
import { AgentSession } from "./agent-session.js";
import { AgentContext, AgentResult } from "../agents/base.js";
import { loadConfig } from "./config.js";
import * as path from "node:path";
import * as fs from "node:fs";
import * as os from "node:os";
export class SessionManager {
private sessions: Map<string, AgentSession> = new Map();
private config: CIAgentConfig;
private projectPath: string;
constructor(projectPath: string, config?: CIAgentConfig) {
this.projectPath = projectPath;
this.config = config || loadConfig(projectPath);
}
createSession(projectSlug: string): AgentSession {
const session = new AgentSession(this.projectPath, projectSlug, this.config);
this.sessions.set(session.getId(), session);
return session;
}
async runSession(sessionId: string, context: AgentContext): Promise<AgentResult> {
const session = this.sessions.get(sessionId);
if (!session) {
return {
success: false,
output: `Session ${sessionId} not found`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: `Session ${sessionId} not found`,
};
}
return session.run(context);
}
async runAllSessions(
projectSlugs: string[],
contextFactory: (slug: string) => AgentContext,
parallel: boolean = false
): Promise<Record<string, AgentResult>> {
const results: Record<string, AgentResult> = {};
const maxConcurrent = this.config.sessions?.max_concurrent_sessions || 3;
if (parallel && projectSlugs.length > 1) {
const batches: string[][] = [];
const concurrency = Math.min(maxConcurrent, projectSlugs.length);
for (let i = 0; i < projectSlugs.length; i += concurrency) {
batches.push(projectSlugs.slice(i, i + concurrency));
}
for (const batch of batches) {
const batchResults = await Promise.allSettled(
batch.map(async (slug): Promise<[string, AgentResult]> => {
const session = this.createSession(slug);
const context = contextFactory(slug);
const result = await session.run(context);
return [slug, result];
})
);
for (const settled of batchResults) {
if (settled.status === "fulfilled") {
const [slug, result] = settled.value;
results[slug] = result;
} else {
const slug = batch[batchResults.indexOf(settled)];
results[slug] = {
success: false,
output: `Session failed for ${slug}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: settled.reason instanceof Error ? settled.reason.message : String(settled.reason),
};
}
}
}
} else {
for (const slug of projectSlugs) {
const session = this.createSession(slug);
const context = contextFactory(slug);
const result = await session.run(context);
results[slug] = result;
}
}
return results;
}
cancelSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.cancel();
}
pauseSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.pause();
}
resumeSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.resume();
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
}
listSessions(): SessionInfo[] {
return Array.from(this.sessions.values()).map((s) => s.getSessionInfo());
}
listActiveSessions(): SessionInfo[] {
return this.listSessions().filter(
(s) => s.status === "running" || s.status === "paused"
);
}
loadPersistedSessions(): SessionInfo[] {
const ciDir = path.join(this.projectPath, ".ciagent");
if (!fs.existsSync(ciDir)) return [];
const sessions: SessionInfo[] = [];
const dirs = [ciDir];
try {
const config = loadConfig(this.projectPath);
if (config.projects && config.projects.length > 0) {
for (const project of config.projects) {
dirs.push(path.join(ciDir, project.slug));
}
}
} catch {}
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith(".session-") && file.endsWith(".json")) {
const sessionId = file.replace(".session-", "").replace(".json", "");
const slug = dir === ciDir ? "" : path.basename(dir);
const session = AgentSession.loadState(this.projectPath, sessionId, slug || undefined);
if (session) {
sessions.push(session.getSessionInfo());
}
}
}
}
return sessions;
}
cleanupStaleSessions(): number {
const timeout = this.config.sessions?.session_timeout_ms || 3600000;
const now = Date.now();
let cleaned = 0;
for (const [id, session] of this.sessions.entries()) {
const info = session.getSessionInfo();
const age = now - new Date(info.last_updated).getTime();
if ((info.status === "running" || info.status === "paused") && age > timeout) {
session.cancel();
this.sessions.delete(id);
cleaned++;
}
}
return cleaned;
}
}
+275
View File
@@ -0,0 +1,275 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { matchFileToPersona, detectConflicts, DecomposedTask, DecomposedPlan, TerritoryConflict, ExecutePersonaConfig, PersonaDomain, DEFAULT_PERSONAS } from "../types/persona.js";
import { CIAgentConfig } from "../types/config.js";
import { PersonaLoader, PersonaDefinition } from "./persona-loader.js";
import { CIAgentFiles } from "./ciagent-files.js";
import { readFile } from "../utils/file.js";
const DOMAIN_FILE_PATTERNS: Record<string, string[]> = {
data: [
"**/migrations/**", "**/schema/**", "**/models/**", "**/db/**",
"prisma/schema.prisma", "drizzle/**", "**/*.sql", "**/seed*",
"**/repository/**", "**/dao/**",
],
backend: [
"**/api/**", "**/routes/**", "**/services/**", "**/middleware/**",
"**/controllers/**", "**/auth/**", "**/handlers/**", "**/grpc/**",
"**/server.ts", "**/app.ts",
],
frontend: [
"**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**",
"**/*.tsx", "**/*.css", "**/*.vue", "**/*.svelte",
"**/layouts/**", "**/views/**", "**/client/**",
],
};
const DOMAIN_KEYWORDS: Record<string, string[]> = {
data: [
"schema", "migration", "database", "model", "query", "table", "column",
"index", "seed", "orm", "sql", "repository", "dao", "entity",
],
backend: [
"api", "route", "endpoint", "middleware", "controller", "service",
"handler", "server", "auth", "grpc", "rest", "websocket",
"request", "response", "cors", "rate-limit",
],
frontend: [
"component", "page", "layout", "style", "css", "hook", "view",
"client", "ui", "render", "state", "interactive", "accessible",
"responsive", "animation",
],
};
interface PlanTask {
id: string;
description: string;
files: string[];
requirements: string[];
dependencies: string[];
wave: number;
}
export class TaskDecomposer {
private projectPath: string;
private personaLoader: PersonaLoader;
private config: CIAgentConfig;
private ciFiles: CIAgentFiles;
constructor(projectPath: string, config: CIAgentConfig, projectSlug?: string) {
this.projectPath = projectPath;
this.config = config;
this.personaLoader = new PersonaLoader(projectPath, config);
this.ciFiles = new CIAgentFiles(projectPath, projectSlug || undefined);
}
decompose(planContent: string): DecomposedPlan {
const tasks = this.parsePlanTasks(planContent);
const personas = this.config.personas?.enabled !== false
? this.config.personas?.personas || DEFAULT_PERSONAS
: DEFAULT_PERSONAS;
const decomposedTasks = this.assignTasksToPersonas(tasks, personas);
const conflicts = detectConflicts(decomposedTasks, personas);
return {
tasks: decomposedTasks,
dataTasks: decomposedTasks.filter((t) => t.domain === "data"),
backendTasks: decomposedTasks.filter((t) => t.domain === "backend"),
frontendTasks: decomposedTasks.filter((t) => t.domain === "frontend"),
coordinationTasks: decomposedTasks.filter((t) => t.domain === "coordination"),
conflicts,
};
}
resolveConflicts(plan: DecomposedPlan): DecomposedPlan {
const resolved = { ...plan, conflicts: [...plan.conflicts] };
for (let i = 0; i < resolved.conflicts.length; i++) {
const conflict = resolved.conflicts[i];
const resolution = this.leadDeveloperResolve(conflict);
resolved.conflicts[i] = { ...conflict, resolution };
}
return resolved;
}
private parsePlanTasks(planContent: string): PlanTask[] {
const tasks: PlanTask[] = [];
const taskRegex = /####\s+Task\s+(\d+[\.\d]*)[\s:]+(.+)/g;
const idRegex = /\*\*ID\*\*\s*\|\s*([A-Z]+-\d+(?:-\d+)*)/g;
const filesRegex = /\*\*Files\s+to\s+(?:create|modify)\*\*\s*\|\s*(.+)/g;
const reqRegex = /\*\*REQs\*\*\s*\|\s*(.+)/g;
const depRegex = /\*\*Dependencies\*\*\s*\|\s*(.+)/g;
const waveRegex = /###\s+Wave\s+(\d+)/g;
const sections = planContent.split(/####\s+Task/);
let currentWave = 1;
const waveMatches = [...planContent.matchAll(/###\s+Wave\s+(\d+)/g)];
const wavePositions = waveMatches.map((m) => ({
wave: parseInt(m[1], 10),
position: m.index || 0,
}));
let taskCounter = 0;
for (let i = 1; i < sections.length; i++) {
const section = sections[i];
const taskPosition = planContent.indexOf(section);
currentWave = 1;
for (const wp of wavePositions) {
if (wp.position <= taskPosition) {
currentWave = wp.wave;
}
}
const taskIdMatch = section.match(/([A-Z]+-\d+(?:-\d+)*)/);
const taskId = taskIdMatch ? taskIdMatch[1] : `T${++taskCounter}`;
const descriptionMatch = section.match(/^\s*\d*[\.\d]*\s*[:]?\s*(.+)/);
const description = descriptionMatch ? descriptionMatch[1].split("\n")[0].trim() : `Task ${taskId}`;
const files: string[] = [];
const filesMatch = section.match(/\*\*Files?\s+to\s+(?:create|modify)\*\*\s*\|?\s*(.+)/i);
if (filesMatch) {
const fileList = filesMatch[1].split(/[`,]/).map((f: string) => f.trim()).filter(Boolean);
files.push(...fileList);
}
const blockFiles = section.match(/`([^`]+\.(ts|js|json|sql|md|tsx|jsx|vue|svelte|css))`/g);
if (blockFiles) {
for (const bf of blockFiles) {
const cleaned = bf.replace(/`/g, "");
if (!files.includes(cleaned)) files.push(cleaned);
}
}
const requirements: string[] = [];
const reqMatch = section.match(/\*\*REQs?\*\*\s*\|?\s*(.+)/i);
if (reqMatch) {
const reqs = reqMatch[1].split(",").map((r: string) => r.trim()).filter(Boolean);
requirements.push(...reqs);
}
const dependencies: string[] = [];
const depMatch = section.match(/\*\*Dependencies?\*\*\s*\|?\s*(.+)/i);
if (depMatch) {
const deps = depMatch[1].split(",").map((d: string) => d.trim()).filter((d: string) => d && d !== "None");
dependencies.push(...deps);
}
tasks.push({
id: taskId,
description,
files,
requirements,
dependencies,
wave: currentWave,
});
}
return tasks;
}
private assignTasksToPersonas(
tasks: PlanTask[],
personas: ExecutePersonaConfig[]
): DecomposedTask[] {
const leadConfig = personas.find((p) => p.domain === "coordination") || personas[0];
const engineerConfigs = personas.filter((p) => p.domain !== "coordination");
return tasks.map((task) => {
const assignedPersona = this.assignPersona(task, personas);
const domain = this.determineDomain(task, assignedPersona);
return {
taskId: task.id,
persona: assignedPersona.name,
domain,
description: task.description,
files: task.files,
dependencies: task.dependencies,
};
});
}
private assignPersona(
task: PlanTask,
personas: ExecutePersonaConfig[]
): ExecutePersonaConfig {
if (task.files.length === 0 && task.description.length === 0) {
return personas.find((p) => p.domain === "coordination") || personas[0];
}
let bestPersona: ExecutePersonaConfig | null = null;
let bestScore = 0;
for (const persona of personas) {
if (persona.domain === "coordination") continue;
let score = 0;
for (const file of task.files) {
const matched = matchFileToPersona(file, personas);
if (matched && matched.name === persona.name) {
score += 3;
}
}
const domainKeywords = DOMAIN_KEYWORDS[persona.domain] || [];
const descLower = task.description.toLowerCase();
for (const keyword of domainKeywords) {
if (descLower.includes(keyword)) {
score += 1;
}
}
for (const req of task.requirements) {
const reqLower = req.toLowerCase();
for (const keyword of domainKeywords) {
if (reqLower.includes(keyword)) {
score += 1;
}
}
}
if (score > bestScore) {
bestScore = score;
bestPersona = persona;
}
}
if (bestPersona && bestScore > 0) {
return bestPersona;
}
if (task.files.length > 0) {
const firstFile = task.files[0];
const matched = matchFileToPersona(firstFile, personas);
if (matched) return matched;
}
return personas.find((p) => p.domain === "coordination") || personas[0];
}
private determineDomain(
task: PlanTask,
persona: ExecutePersonaConfig
): PersonaDomain {
return persona.domain as PersonaDomain;
}
private leadDeveloperResolve(conflict: TerritoryConflict): string {
switch (conflict.type) {
case "data-backend":
return `Lead developer assigns ${conflict.file} to backend engineer. Data engineer provides schema contract; backend implements API contract. Data changes should be in a separate migration.`;
case "backend-frontend":
return `Lead developer assigns ${conflict.file} to backend engineer. Frontend engineer adapts to backend API contract. If the file is primarily a type definition, create a shared types module.`;
case "data-frontend":
return `Lead developer assigns ${conflict.file} to data engineer for schema definition. Frontend engineer consumes through a backend API endpoint. Direct database access from frontend is prohibited.`;
default:
return `Lead developer arbitrates: ${conflict.file} assigned to ${conflict.personas[0]}. Other persona uses the public interface.`;
}
}
}
+9 -1
View File
@@ -9,6 +9,10 @@ export { GitBranch } from "./core/git-branch.js";
export { CommitBuilder } from "./core/commit-builder.js"; export { CommitBuilder } from "./core/commit-builder.js";
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js"; export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js"; export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
export { AgentSession } from "./core/agent-session.js";
export { SessionManager } from "./core/session-manager.js";
export { PersonaLoader } from "./core/persona-loader.js";
export { TaskDecomposer } from "./core/task-decomposer.js";
export { VerificationPipeline } from "./verification/index.js"; export { VerificationPipeline } from "./verification/index.js";
export { StructuralVerification } from "./verification/structural.js"; export { StructuralVerification } from "./verification/structural.js";
export { BehavioralVerification } from "./verification/behavioral.js"; export { BehavioralVerification } from "./verification/behavioral.js";
@@ -24,6 +28,8 @@ export { ESCALATION_TYPES } from "./types/escalation.js";
export { createClarifyQuestion } from "./types/clarify.js"; export { createClarifyQuestion } from "./types/clarify.js";
export { parseSpecification } from "./types/specification.js"; export { parseSpecification } from "./types/specification.js";
export { getNextStage, createInitialPipelineState } from "./types/pipeline.js"; export { getNextStage, createInitialPipelineState } from "./types/pipeline.js";
export { matchFileToPersona, detectConflicts, DEFAULT_PERSONAS } from "./types/persona.js";
export { DEFAULT_SESSION_CONFIG } from "./types/session.js";
export * as fileUtils from "./utils/file.js"; export * as fileUtils from "./utils/file.js";
export { resolveBackend, createBackend } from "./backends/index.js"; export { resolveBackend, createBackend } from "./backends/index.js";
export { OpencodeBackend } from "./backends/opencode.js"; export { OpencodeBackend } from "./backends/opencode.js";
@@ -47,4 +53,6 @@ export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMe
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js"; export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js";
export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js"; export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js";
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js"; export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js"; export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
export type { SessionInfo, SessionStatus, SessionConfig } from "./types/session.js";
export type { ExecutePersonaConfig, TerritoryEnforcement, PersonaDomain, DecomposedTask, DecomposedPlan, TerritoryConflict } from "./types/persona.js";
+1
View File
@@ -55,6 +55,7 @@ export interface CIAgentMetadata {
phase: number; phase: number;
milestone: string; milestone: string;
project?: string; project?: string;
session?: string;
plan?: string; plan?: string;
task?: string; task?: string;
status: PipelineStage; status: PipelineStage;
+34 -3
View File
@@ -1,5 +1,4 @@
import { BackendConfigSection } from "../backends/types.js"; import { TerritoryEnforcement, ExecutePersonaConfig } from "./persona.js";
import { IdeationConfig, IdeationCategory } from "./ideation.js";
export type AutonomyLevel = "full" | "supervised" | "guided"; export type AutonomyLevel = "full" | "supervised" | "guided";
@@ -7,7 +6,7 @@ export type ModelProfile = "quality" | "speed" | "balanced";
export type BranchingStrategy = "phase" | "feature" | "trunk"; export type BranchingStrategy = "phase" | "feature" | "trunk";
export type MilestoneType = "nfr" | "feature" | "schema-breaking"; export type MilestoneType = "nfr" | "feature" | "major";
export type PhaseName = "research" | "plan" | "execute" | "verify" | "complete"; export type PhaseName = "research" | "plan" | "execute" | "verify" | "complete";
@@ -94,8 +93,25 @@ export interface CIAgentConfig {
backend: BackendConfigSection; backend: BackendConfigSection;
gitea?: GiteaConfig; gitea?: GiteaConfig;
ideation?: IdeationConfig; ideation?: IdeationConfig;
sessions?: SessionConfig;
personas?: PersonaConfigSection;
} }
export interface SessionConfig {
max_concurrent_sessions: number;
session_timeout_ms: number;
session_isolation: "branch";
}
export interface PersonaConfigSection {
enabled: boolean;
territory_enforcement: TerritoryEnforcement;
personas: ExecutePersonaConfig[];
}
import { BackendConfigSection } from "../backends/types.js";
import { IdeationConfig, IdeationCategory } from "./ideation.js";
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = { export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
projects: [], projects: [],
active_project: "", active_project: "",
@@ -190,4 +206,19 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
scenarios: ["backend_unavailable", "requirement_change", "test_coverage_drop"], scenarios: ["backend_unavailable", "requirement_change", "test_coverage_drop"],
}, },
}, },
sessions: {
max_concurrent_sessions: 3,
session_timeout_ms: 3600000,
session_isolation: "branch",
},
personas: {
enabled: true,
territory_enforcement: "warn",
personas: [
{ name: "lead-developer", domain: "coordination", frameworks: [], constraints: ["pragmatic", "battle-tested defaults"], territory: [] },
{ name: "data-engineer", domain: "data", frameworks: ["drizzle", "postgresql"], constraints: ["schema-first", "type-safe ORM", "migration-driven"], territory: ["**/migrations/**", "**/schema/**", "**/models/**", "**/db/**", "prisma/schema.prisma", "drizzle/**", "**/*.sql"] },
{ name: "backend-engineer", domain: "backend", frameworks: ["fastify", "hono"], constraints: ["api-first", "strict-typing", "dependency-injection"], territory: ["**/api/**", "**/routes/**", "**/services/**", "**/middleware/**", "**/controllers/**", "**/auth/**"] },
{ name: "frontend-engineer", domain: "frontend", frameworks: ["react", "next.js"], constraints: ["component-first", "server-components", "minimal-client-js"], territory: ["**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**", "**/*.tsx", "**/*.css", "**/*.vue"] },
],
},
}; };
+168
View File
@@ -0,0 +1,168 @@
export type PersonaDomain = "data" | "backend" | "frontend" | "coordination";
export type TerritoryEnforcement = "warn" | "strict";
export interface ExecutePersonaConfig {
name: string;
domain: PersonaDomain;
frameworks: string[];
constraints: string[];
territory: string[];
}
export interface DecomposedTask {
taskId: string;
persona: string;
domain: PersonaDomain;
description: string;
files: string[];
dependencies: string[];
}
export interface DecomposedPlan {
tasks: DecomposedTask[];
dataTasks: DecomposedTask[];
backendTasks: DecomposedTask[];
frontendTasks: DecomposedTask[];
coordinationTasks: DecomposedTask[];
conflicts: TerritoryConflict[];
}
export interface TerritoryConflict {
type: "data-backend" | "backend-frontend" | "data-frontend";
file: string;
personas: string[];
description: string;
resolution?: string;
}
export const DEFAULT_PERSONAS: ExecutePersonaConfig[] = [
{
name: "lead-developer",
domain: "coordination",
frameworks: [],
constraints: ["pragmatic", "battle-tested defaults"],
territory: [],
},
{
name: "data-engineer",
domain: "data",
frameworks: ["drizzle", "postgresql"],
constraints: ["schema-first", "type-safe ORM", "migration-driven"],
territory: [
"**/migrations/**",
"**/schema/**",
"**/models/**",
"**/db/**",
"prisma/schema.prisma",
"drizzle/**",
"**/*.sql",
],
},
{
name: "backend-engineer",
domain: "backend",
frameworks: ["fastify", "hono"],
constraints: ["api-first", "strict-typing", "dependency-injection"],
territory: [
"**/api/**",
"**/routes/**",
"**/services/**",
"**/middleware/**",
"**/controllers/**",
"**/auth/**",
],
},
{
name: "frontend-engineer",
domain: "frontend",
frameworks: ["react", "next.js"],
constraints: ["component-first", "server-components", "minimal-client-js"],
territory: [
"**/components/**",
"**/pages/**",
"**/hooks/**",
"**/styles/**",
"**/*.tsx",
"**/*.css",
"**/*.vue",
],
},
];
export function matchFileToPersona(
filePath: string,
personas: ExecutePersonaConfig[]
): ExecutePersonaConfig | null {
const normalizedPath = filePath.replace(/\\/g, "/");
for (const persona of personas) {
if (persona.domain === "coordination") continue;
for (const pattern of persona.territory) {
const normalizedPattern = pattern.replace(/\\/g, "/");
if (globMatch(normalizedPattern, normalizedPath)) {
return persona;
}
}
}
return null;
}
export function globMatch(pattern: string, path: string): boolean {
const regexStr = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "§§")
.replace(/\*/g, "[^/]*")
.replace(/§§/g, ".*")
.replace(/\?/g, "[^/]");
const regex = new RegExp(`^${regexStr}$`);
return regex.test(path);
}
export function detectConflicts(
tasks: DecomposedTask[],
personas: ExecutePersonaConfig[]
): TerritoryConflict[] {
const conflicts: TerritoryConflict[] = [];
const filePersonaMap = new Map<string, string[]>();
for (const task of tasks) {
for (const file of task.files) {
if (!filePersonaMap.has(file)) {
filePersonaMap.set(file, []);
}
const personas_list = filePersonaMap.get(file)!;
if (!personas_list.includes(task.persona)) {
personas_list.push(task.persona);
}
}
}
for (const [file, claimingPersonas] of filePersonaMap) {
if (claimingPersonas.length > 1) {
const domains = claimingPersonas
.map((p) => personas.find((pe) => pe.name === p)?.domain)
.filter((d): d is PersonaDomain => d !== undefined);
let conflictType: TerritoryConflict["type"];
if (domains.includes("data") && domains.includes("backend")) {
conflictType = "data-backend";
} else if (domains.includes("backend") && domains.includes("frontend")) {
conflictType = "backend-frontend";
} else {
conflictType = "data-frontend";
}
conflicts.push({
type: conflictType,
file,
personas: claimingPersonas,
description: `File ${file} claimed by multiple personas: ${claimingPersonas.join(", ")}`,
});
}
}
return conflicts;
}
+29
View File
@@ -0,0 +1,29 @@
import { PipelineStage } from "./pipeline.js";
export type SessionStatus = "pending" | "running" | "paused" | "completed" | "failed" | "cancelled";
export type SessionIsolation = "branch";
export interface SessionConfig {
max_concurrent_sessions: number;
session_timeout_ms: number;
session_isolation: SessionIsolation;
}
export interface SessionInfo {
id: string;
project_slug: string;
project_path: string;
phase: number;
stage: PipelineStage;
status: SessionStatus;
started_at: string;
last_updated: string;
error?: string;
}
export const DEFAULT_SESSION_CONFIG: SessionConfig = {
max_concurrent_sessions: 3,
session_timeout_ms: 3600000,
session_isolation: "branch",
};
+399
View File
@@ -0,0 +1,399 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { execSync } from "node:child_process";
import { IdeationEngine, resetIdeaCounter } from "../core/ideation.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-e2e-ideation-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function initGitRepo(dir: string): void {
execSync("git init", { cwd: dir, stdio: "pipe" });
execSync("git config user.email test@test.com", { cwd: dir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: dir, stdio: "pipe" });
}
function initIdeationProject(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "PROJECT.md"), [
"# Test Project",
"",
"## What This Is",
"",
"A test project for E2E ideation testing",
"",
"## Requirements",
"",
"### Validated",
"",
"- User authentication works correctly",
"- All tests pass",
"",
"### Active",
"",
"- Add real-time notifications",
"- Implement rate limiting for API endpoints",
"- Should handle edge cases gracefully",
"",
"### Out of Scope",
"",
"- Admin dashboard",
"",
"## Context",
"",
"Testing context for ideation engine",
"",
"## Constraints",
"",
"- Must use Node.js",
"- Must be production-ready",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n"));
fs.writeFileSync(path.join(ciDir, "REQUIREMENTS.md"), [
"# Requirements",
"",
"## v0.10 Requirements — Test Project",
"",
"| REQ-ID | Requirement | Priority | Phase | Status |",
"|--------|-------------|----------|-------|--------|",
"| IDEATE-01 | Ideation command | P0 | 1 | pending |",
"| IDEATE-02 | Three-tier engine | P0 | 1 | pending |",
"| IDEATE-03 | Pattern mining | P0 | 1 | covered |",
"| MULTI-01 | Config migration | P0 | 2 | in_progress |",
"| MULTI-02 | Project flag | P0 | 2 | pending |",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| IDEATE-01 | 1 | pending |",
"| IDEATE-02 | 1 | pending |",
"| IDEATE-03 | 1 | covered |",
"| MULTI-01 | 2 | in_progress |",
"| MULTI-02 | 2 | pending |",
].join("\n"));
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), [
"# Roadmap",
"",
"## Overview",
"",
"Test project roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Core** - Build core ideation engine",
"- [ ] **Phase 2: Multi-Project** - Add multi-project support",
"",
"## Phase Details",
"",
"### Phase 1: Core",
"**Goal.**: Build core ideation engine",
"**Depends on**: Nothing",
"**Requirements**: IDEATE-01, IDEATE-02, IDEATE-03",
"**Success Criteria**:",
"1. Ideation command works",
'**Status**: not_started',
"",
"### Phase 2: Multi-Project",
'**Goal.**: Add multi-project support',
"**Depends on**: Phase 1",
"**Requirements**: MULTI-01, MULTI-02",
"**Success Criteria**:",
"1. Multi-project config works",
'**Status**: not_started',
"",
].join("\n"));
fs.writeFileSync(path.join(ciDir, "ARCHITECTURE.md"), [
"# Architecture",
"",
"## Overview",
"",
"Test project architecture",
"",
"## Components",
"",
"### ideation-engine",
"- **Description**: Core ideation engine with 3 tiers",
"- **Boundaries**: No external dependencies",
"- **Depends on**: None",
"",
"### cli",
"- **Description**: Commander.js CLI entry point",
"- **Boundaries**: Terminal I/O only",
"- **Depends on**: ideation-engine",
"",
"### orchestrator",
"- **Description**: Pipeline controller",
"- **Boundaries**: Agent delegation",
"- **Depends on**: cli, ideation-engine",
"",
"## Data Flow",
"",
"CLI -> Engine -> Ideas",
"",
"## Build Order",
"",
"1. ideation-engine",
"2. cli",
"3. orchestrator",
"",
].join("\n"));
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify({
projects: [],
active_project: "",
active_projects: [],
autonomy: { level: "full" },
ideation: {
enabled: true,
categories: ["security", "quality", "architecture", "coverage", "improvement"],
confidence_threshold: 0.6,
max_ideas: 20,
},
}, null, 2));
const srcDir = path.join(dir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.ts"), "export function main() { return 1; }");
fs.writeFileSync(path.join(srcDir, "app.test.ts"), "test('works', () => { expect(main()).toBe(1); });");
}
describe("E2E: Ideation Command (Mechanical Tier)", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initIdeationProject(dir);
initGitRepo(dir);
resetIdeaCounter();
});
afterEach(() => {
cleanup(dir);
});
describe("Mechanical ideation runs without errors", () => {
it("produces ideas from mechanical tier when requirements exist", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
expect(Array.isArray(ideas)).toBe(true);
expect(ideas.length).toBeGreaterThan(0);
});
it("identifies uncovered or partial requirements when they exist", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const coverageIdeas = ideas.filter((i) => i.source === "uncovered_requirement" || i.source === "partial_requirement");
expect(coverageIdeas.length).toBeGreaterThanOrEqual(0);
if (coverageIdeas.length > 0) {
expect(coverageIdeas[0].category).toBe("coverage");
expect(coverageIdeas[0].tier).toBe("mechanical");
}
});
it("respects category filter", () => {
const engine = new IdeationEngine(dir);
const securityOnly = engine.runMechanical(["security"]);
for (const idea of securityOnly) {
expect(idea.category).toBe("security");
}
});
it("can filter by architecture category", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical(["architecture"]);
expect(Array.isArray(ideas)).toBe(true);
expect(ideas.length).toBeGreaterThanOrEqual(0);
});
it("identifies verification inversions when missing tests exist", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical(["quality"]);
const verificationIdeas = ideas.filter((i) => i.source === "verification_inversion");
expect(verificationIdeas.length).toBeGreaterThanOrEqual(0);
});
it("sorts ideas by confidence", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
for (let i = 1; i < ideas.length; i++) {
expect(ideas[i].confidence).toBeLessThanOrEqual(ideas[i - 1].confidence);
}
});
it("formats ideas as text", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const text = engine.formatIdeas(ideas);
expect(text).toContain("Improvement Ideas:");
if (ideas.length > 0) {
expect(text).toContain("[");
}
});
it("formats ideas as JSON", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const result = engine.formatIdeasJson(ideas);
expect(result.project).toBeDefined();
expect(result.summary.total).toBe(ideas.length);
expect(typeof result.summary.by_category).toBe("object");
expect(typeof result.summary.by_tier).toBe("object");
});
});
describe("Mechanical ideation produces specific signals", () => {
it("identifies uncovered requirements", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const uncoveredIdeas = ideas.filter((i) => i.source === "uncovered_requirement");
expect(uncoveredIdeas.length).toBeGreaterThanOrEqual(0);
if (uncoveredIdeas.length > 0) {
expect(uncoveredIdeas[0].category).toBe("coverage");
expect(uncoveredIdeas[0].tier).toBe("mechanical");
}
});
it("identifies partial requirements", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const partialIdeas = ideas.filter((i) => i.source === "partial_requirement");
expect(partialIdeas.length).toBeGreaterThanOrEqual(0);
if (partialIdeas.length > 0) {
expect(partialIdeas[0].relatedReq).toBe("MULTI-01");
}
});
it("identifies verification inversions (missing tests)", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical(["quality"]);
const verificationIdeas = ideas.filter((i) => i.source === "verification_inversion");
expect(verificationIdeas.length).toBeGreaterThanOrEqual(0);
});
it("formats ideas as text with source prefix", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const text = engine.formatIdeas(ideas);
expect(text).toContain("Improvement Ideas:");
if (ideas.length > 0) {
expect(text).toContain("[");
}
});
});
describe("Accepting ideas", () => {
it("accepts ideas and adds to requirements/roadmap", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical().slice(0, 2);
const { accepted, results } = engine.acceptIdeas(ideas);
expect(accepted.length).toBeGreaterThan(0);
expect(results.length).toBe(accepted.length);
for (const result of results) {
expect(result.addedToRequirements || result.addedToRoadmap).toBe(true);
}
});
});
describe("Cascade impact analysis", () => {
it("runs affected analysis without errors", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runAffected();
expect(Array.isArray(ideas)).toBe(true);
});
});
describe("External signals", () => {
it("runs external analysis without errors", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runExternal();
expect(Array.isArray(ideas)).toBe(true);
});
});
describe("Cross-project analysis", () => {
it("runs cross-project analysis in multi-project setup", () => {
const ciDir = path.join(dir, ".ciagent");
const config = JSON.parse(fs.readFileSync(path.join(ciDir, "config.json"), "utf-8"));
config.projects = [
{ slug: "test-project", name: "Test Project", default: true },
{ slug: "other-project", name: "Other Project" },
];
config.active_projects = ["test-project", "other-project"];
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify(config, null, 2));
fs.mkdirSync(path.join(ciDir, "other-project"), { recursive: true });
fs.writeFileSync(path.join(ciDir, "other-project", "PROJECT.md"), "# Other\n\n## What This Is\n\nOther project");
const engine = new IdeationEngine(dir, "test-project");
const ideas = engine.runCrossProject();
expect(Array.isArray(ideas)).toBe(true);
});
});
describe("Chaos scenarios", () => {
it("generates chaos scenario ideas", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.generateChaosScenarios();
expect(Array.isArray(ideas)).toBe(true);
expect(ideas.length).toBeGreaterThan(0);
for (const idea of ideas) {
expect(idea.category).toBe("chaos");
expect(idea.source).toBe("chaos_scenario");
expect(idea.tier).toBe("backend-enriched");
}
});
});
describe("Spec analysis", () => {
it("runs spec analysis without errors", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical(["spec"]);
expect(Array.isArray(ideas)).toBe(true);
});
it("detects missing common categories in spec", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical(["spec"]);
const missingIdeas = ideas.filter((i) => i.source === "spec_missing");
expect(missingIdeas.length).toBeGreaterThanOrEqual(0);
if (missingIdeas.length > 0) {
expect(missingIdeas[0].category).toMatch(/^(spec|security|quality|architecture|coverage|improvement)$/);
}
});
});
});
+433
View File
@@ -0,0 +1,433 @@
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 { CommitBuilder } from "../core/commit-builder.js";
import { initCIAgent, loadConfig, saveConfig } from "../core/config.js";
import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-e2e-multiproject-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function setupMultiProject(dir: string): void {
const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir();
ciFiles.addProject("task-api", "Task API", true);
ciFiles.addProject("auth-svc", "Auth Service");
const taskApiDir = path.join(dir, ".ciagent", "task-api");
fs.mkdirSync(taskApiDir, { recursive: true });
fs.writeFileSync(path.join(taskApiDir, "PROJECT.md"), [
"# Task API",
"",
"## What This Is",
"",
"A REST API for task management",
"",
"## Requirements",
"",
"### Active",
"",
"- CRUD operations for tasks",
"- Authentication",
"",
"### Validated",
"",
"### Out of Scope",
"",
"## Context",
"",
"Task management API",
"",
"## Constraints",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n"));
fs.writeFileSync(path.join(taskApiDir, "REQUIREMENTS.md"), [
"# Requirements",
"",
"## v1 Requirements",
"",
"### Task API",
"",
"- TASK-01: Create task endpoint",
"- TASK-02: Read tasks endpoint",
"- TASK-03: Update task endpoint",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| TASK-01 | 1 | pending |",
"| TASK-02 | 1 | pending |",
"| TASK-03 | 1 | pending |",
].join("\n"));
fs.writeFileSync(path.join(taskApiDir, "ROADMAP.md"), [
"# Roadmap",
"",
"## Overview",
"",
"Task API roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Core** - Build task CRUD endpoints",
"",
"## Phase Details",
"",
"### Phase 1: Core",
"**Goal.**: Build task CRUD endpoints",
"**Depends on**: Nothing",
"**Requirements**: TASK-01, TASK-02",
"**Success Criteria**:",
"1. All CRUD operations work",
'**Status**: not_started',
"",
].join("\n"));
fs.writeFileSync(path.join(taskApiDir, "ARCHITECTURE.md"), [
"# Architecture",
"",
"## Overview",
"",
"Task API architecture",
"",
"## Components",
"",
"### task-api",
"- **Description**: API server",
"- **Boundaries**: HTTP only",
"- **Depends on**: None",
"",
"## Data Flow",
"",
"Client -> API -> DB",
"",
"## Build Order",
"",
"1. task-api",
"",
].join("\n"));
const authDir = path.join(dir, ".ciagent", "auth-svc");
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, "PROJECT.md"), [
"# Auth Service",
"",
"## What This Is",
"",
"Authentication and authorization service",
"",
"## Requirements",
"",
"### Active",
"",
"- JWT token generation",
"- Password hashing",
"",
"### Validated",
"",
"### Out of Scope",
"",
"## Context",
"",
"Authentication service",
"",
"## Constraints",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n"));
fs.writeFileSync(path.join(authDir, "REQUIREMENTS.md"), [
"# Requirements",
"",
"## v1 Requirements",
"",
"### Auth",
"",
"- AUTH-01: JWT token generation",
"- AUTH-02: Password hashing",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| AUTH-01 | 1 | pending |",
"| AUTH-02 | 1 | pending |",
].join("\n"));
fs.writeFileSync(path.join(authDir, "ROADMAP.md"), [
"# Roadmap",
"",
"## Overview",
"",
"Auth Service roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Auth** - Implement JWT authentication",
"",
"## Phase Details",
"",
"### Phase 1: Auth",
"**Goal.**: Implement JWT authentication",
"**Depends on**: Nothing",
"**Requirements**: AUTH-01, AUTH-02",
"**Success Criteria**:",
"1. JWT tokens are generated correctly",
'**Status**: not_started',
"",
].join("\n"));
fs.writeFileSync(path.join(authDir, "ARCHITECTURE.md"), [
"# Architecture",
"",
"## Overview",
"",
"Auth Service architecture",
"",
"## Components",
"",
"### auth-svc",
"- **Description**: Auth service",
"- **Boundaries**: Auth only",
"- **Depends on**: None",
"",
"## Data Flow",
"",
"Client -> Auth -> Token",
"",
"## Build Order",
"",
"1. auth-svc",
"",
].join("\n"));
const config = loadConfig(dir);
config.active_projects = ["task-api", "auth-svc"];
saveConfig(dir, config);
}
describe("E2E: Multi-Project Execution", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
describe("Project management", () => {
it("lists multiple registered projects", () => {
setupMultiProject(dir);
const ciFiles = new CIAgentFiles(dir);
const projects = ciFiles.listProjects();
expect(projects.length).toBeGreaterThanOrEqual(2);
const slugs = projects.map((p) => p.slug);
expect(slugs).toContain("task-api");
expect(slugs).toContain("auth-svc");
});
it("detects multi-project mode", () => {
setupMultiProject(dir);
const ciFiles = new CIAgentFiles(dir);
expect(ciFiles.isMultiProject()).toBe(true);
});
it("reads and writes per-project files", () => {
setupMultiProject(dir);
const taskFiles = new CIAgentFiles(dir, "task-api");
const taskProject = taskFiles.readProjectMd();
expect(taskProject).not.toBeNull();
expect(taskProject!.name).toBe("Task API");
const authFiles = new CIAgentFiles(dir, "auth-svc");
const authProject = authFiles.readProjectMd();
expect(authProject).not.toBeNull();
expect(authProject!.name).toBe("Auth Service");
});
it("reads per-project requirements", () => {
setupMultiProject(dir);
const taskFiles = new CIAgentFiles(dir, "task-api");
const taskReqs = taskFiles.readRequirementsMd();
expect(taskReqs).not.toBeNull();
const authFiles = new CIAgentFiles(dir, "auth-svc");
const authReqs = authFiles.readRequirementsMd();
expect(authReqs).not.toBeNull();
});
it("reads per-project roadmap", () => {
setupMultiProject(dir);
const taskFiles = new CIAgentFiles(dir, "task-api");
const taskRoadmap = taskFiles.readRoadmapMd();
expect(taskRoadmap).not.toBeNull();
expect(taskRoadmap!.phases.length).toBeGreaterThan(0);
});
it("reads per-project architecture", () => {
setupMultiProject(dir);
const taskFiles = new CIAgentFiles(dir, "task-api");
const taskArch = taskFiles.readArchitectureMd();
expect(taskArch).not.toBeNull();
expect(taskArch!.components.length).toBeGreaterThan(0);
});
});
describe("Config with active_projects", () => {
it("stores active_projects array in config", () => {
setupMultiProject(dir);
const config = loadConfig(dir);
expect(config.active_projects).toContain("task-api");
expect(config.active_projects).toContain("auth-svc");
expect(config.active_projects.length).toBe(2);
});
it("max_concurrent_projects is configurable", () => {
initCIAgent(dir, {
parallelization: {
...DEFAULT_CIAGENT_CONFIG.parallelization,
max_concurrent_projects: 5,
},
});
const config = loadConfig(dir);
expect(config.parallelization.max_concurrent_projects).toBe(5);
});
it("default max_concurrent_projects is 3", () => {
expect(DEFAULT_CIAGENT_CONFIG.parallelization.max_concurrent_projects).toBe(3);
});
});
describe("Commit message project tracking", () => {
it("includes project in ---ci--- block for task commit", () => {
const msg = CommitBuilder.buildTaskCommit({
type: "feat",
phase: 1,
milestone: "v0.10",
project: "task-api",
plan: "01-auth",
task: "01-01",
subject: "implement JWT token generation",
status: "execute",
});
expect(msg).toContain("---ci---");
expect(msg).toContain("project: task-api");
expect(msg).toContain("phase: 1");
expect(msg).toContain("milestone: v0.10");
expect(msg).toContain("status: execute");
});
it("includes project in ---ci--- block for init commit", () => {
const msg = CommitBuilder.buildInitCommit({
projectName: "Auth Service",
phaseCount: 2,
milestone: "v0.10",
project: "auth-svc",
specification: "Authentication and authorization service",
});
expect(msg).toContain("---ci---");
expect(msg).toContain("project: auth-svc");
expect(msg).toContain("phase: 0");
});
it("different projects produce different commit scopes", () => {
const taskMsg = CommitBuilder.buildTaskCommit({
type: "feat",
phase: 1,
milestone: "v0.10",
project: "task-api",
plan: "01",
task: "01",
subject: "create task endpoint",
status: "execute",
});
const authMsg = CommitBuilder.buildTaskCommit({
type: "feat",
phase: 1,
milestone: "v0.10",
project: "auth-svc",
plan: "01",
task: "01",
subject: "JWT token generation",
status: "execute",
});
expect(taskMsg).toContain("task-api/");
expect(taskMsg).toContain("project: task-api");
expect(authMsg).toContain("auth-svc/");
expect(authMsg).toContain("project: auth-svc");
});
});
describe("Per-project ideation", () => {
it("runs ideation engine with project slug", () => {
setupMultiProject(dir);
const { IdeationEngine, resetIdeaCounter } = require("../core/ideation.js");
resetIdeaCounter();
const taskEngine = new IdeationEngine(dir, "task-api");
const taskIdeas = taskEngine.runMechanical();
expect(Array.isArray(taskIdeas)).toBe(true);
expect(taskIdeas.length).toBeGreaterThan(0);
resetIdeaCounter();
const authEngine = new IdeationEngine(dir, "auth-svc");
const authIdeas = authEngine.runMechanical();
expect(Array.isArray(authIdeas)).toBe(true);
expect(authIdeas.length).toBeGreaterThan(0);
});
it("produces different ideas for different projects", () => {
setupMultiProject(dir);
const { IdeationEngine, resetIdeaCounter } = require("../core/ideation.js");
resetIdeaCounter();
const taskEngine = new IdeationEngine(dir, "task-api");
const taskIdeas = taskEngine.runMechanical();
const taskTitles = new Set(taskIdeas.map((i: any) => i.title));
resetIdeaCounter();
const authEngine = new IdeationEngine(dir, "auth-svc");
const authIdeas = authEngine.runMechanical();
const authTitles = new Set(authIdeas.map((i: any) => i.title));
expect(taskTitles.size).toBeGreaterThan(0);
expect(authTitles.size).toBeGreaterThan(0);
});
});
});
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "0.9.0"; export const VERSION = "0.11.0";