Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ec7748ccb | |||
| 9ab3b56b96 | |||
| 8c975352b8 | |||
| 6d0034dc88 | |||
| a153291643 | |||
| a0619f9740 | |||
| f478088797 | |||
| e2b749d42e | |||
| c747d3e8be | |||
| d9927558d5 | |||
| 895d9f95a1 | |||
| 30352a3603 | |||
| d58fd0bdde | |||
| 0799cfc644 |
@@ -26,10 +26,12 @@ src/
|
||||
anthropic.ts # AnthropicBackend (Anthropic API, Claude)
|
||||
opencode.ts # OpencodeBackend (shells out to opencode --non-interactive)
|
||||
index.ts # Backend registry + auto-detection
|
||||
cli/ # Commander.js CLI (commands.ts, index.ts)
|
||||
cli/ # Commander.js CLI (commands.ts, index.ts, 14 commands including sessions)
|
||||
core/ # Core engine components
|
||||
artifacts.ts # Legacy .ciagent/ artifact management (retained for backward compat)
|
||||
audit.ts # Git-native audit trail — reads decisions/escalations from git log
|
||||
agent-session.ts # Multi-session support: AgentSession, file-based git locking
|
||||
session-manager.ts # SessionManager: concurrent session lifecycle management
|
||||
ciagent-files.ts # .ciagent/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.)
|
||||
clarify.ts # Clarify phase: question generation, default acceptance
|
||||
commit-builder.ts # Structured commit message generation (---ci--- YAML blocks)
|
||||
@@ -40,14 +42,18 @@ src/
|
||||
escalation.ts # Escalation protocol: commits escalations as git artifacts
|
||||
git-branch.ts # Branch lifecycle: phase/NN-slug, milestone/vX.X-slug
|
||||
git-context.ts # Project state reconstruction from git log + branches
|
||||
persona-loader.ts # Execute-time persona resolution from .config/opencode/agents/*.md
|
||||
task-decomposer.ts # Plan decomposition into data/backend/frontend task groups
|
||||
types/ # Type definitions
|
||||
commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit
|
||||
config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, DEFAULT_CIAGENT_CONFIG (includes backend)
|
||||
commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit (includes session field)
|
||||
config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, SessionConfig, PersonaConfigSection, DEFAULT_CIAGENT_CONFIG (includes backend)
|
||||
decisions.ts # Decision, ConfidenceLevel, DecisionCategory
|
||||
escalation.ts # Escalation, EscalationType, EscalationResolution
|
||||
clarify.ts # ClarifyQuestion, ClarifyResult
|
||||
specification.ts # Specification parser (objective, requirements, constraints, out_of_scope)
|
||||
pipeline.ts # PipelineStage, PipelineState, PhaseResult, STAGE_ORDER
|
||||
persona.ts # ExecutePersonaConfig, PersonaDomain, TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS
|
||||
session.ts # SessionInfo, SessionStatus, SessionConfig, DEFAULT_SESSION_CONFIG
|
||||
utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON)
|
||||
verification/ # 4-layer verification pipeline
|
||||
structural.ts # Layer 1: file existence, imports wired, no stubs
|
||||
@@ -55,7 +61,7 @@ src/
|
||||
security.ts # Layer 3: regex-based threat pattern scanning (no STRIDE analysis yet)
|
||||
quality.ts # Layer 4: regex-based code quality checks (no multi-persona review yet)
|
||||
index.ts # Public API exports
|
||||
version.ts # VERSION = "0.9.0"
|
||||
version.ts # VERSION = "0.11.0"
|
||||
templates/ # Template files (config.json, DECISIONS.md, specification.md)
|
||||
```
|
||||
|
||||
@@ -84,7 +90,7 @@ templates/ # Template files (config.json, DECISIONS.md, specification.md
|
||||
## 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.
|
||||
@@ -134,7 +140,7 @@ IntelligenceBackend (unified interface)
|
||||
- Test framework: Jest with ts-jest
|
||||
- Test file pattern: `**/*.test.ts` in `src/`
|
||||
- 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
|
||||
- Module resolution in jest uses moduleNameMapper to strip `.js` extensions
|
||||
|
||||
@@ -194,19 +200,21 @@ IntelligenceBackend (unified interface)
|
||||
|
||||
## Current State
|
||||
|
||||
- **v0.11.0**: Multi-Session & Persona Specialization — AgentSession with file-based git locking, SessionManager with concurrent session batches, PersonaLoader reading ci-*.md files, TaskDecomposer with territory conflict resolution, `ciagent sessions` CLI (list/status/cancel/cleanup), `--session <id>` flag on `ciagent run`, `---ci--- session:` field, `sessions` and `personas` config sections, 4 built-in personas (lead-developer, data-engineer, backend-engineer, frontend-engineer), territory enforcement with warn/strict modes
|
||||
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
|
||||
- **v0.9.0**: Integration & hardening — OpenAI and Anthropic backends, all 19 agents with intrinsic mechanical logic, E2E v0.9 integration tests, parallel agent execution
|
||||
- **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows
|
||||
- **New in v0.11**: Multi-session support with `SessionManager` and `AgentSession` for independent project pipelines running concurrently, execute-phase persona specialization (`lead-developer`, `data-engineer`, `backend-engineer`, `frontend-engineer`) with territory enforcement and task decomposition, `ciagent sessions` CLI command with list/status/cancel/cleanup subcommands, `--session <id>` flag on `ciagent run`, `---ci--- session:` commit metadata field, `sessions` and `personas` config sections
|
||||
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
|
||||
- **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation)
|
||||
- **Config expansion**: BackendConfigSection now includes `openai` and `anthropic` in `llm_backends` with dedicated `OpenAIConfig` and `AnthropicConfig` types
|
||||
- **Auto-detection order (v0.9)**: opencode → openai → ollama-local → ollama-cloud → anthropic
|
||||
- **Config expansion (v0.11)**: `sessions` section (max_concurrent_sessions, session_timeout_ms, session_isolation), `personas` section (enabled, territory_enforcement, personas[] with name/domain/frameworks/constraints/territory); `---ci--- session:` field in commit blocks
|
||||
- **Config expansion (v0.10)**: `ideation` section in config with categories, thresholds, external signals, cross-project, chaos; `active_projects` array; `max_concurrent_projects` in parallelization
|
||||
- **Auto-detection order**: opencode → openai → ollama-local → ollama-cloud → anthropic
|
||||
- **All agents mechanical**: Every non-orchestrator agent (18/19) produces meaningful output without a backend — no "requires intelligence backend" stub errors
|
||||
- **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
|
||||
- **Parallel execution**: OrchestratorAgent supports concurrent review agents with `limitConcurrency()`, controlled by `parallelization.max_concurrent_agents`
|
||||
- **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, and compound metadata
|
||||
- **Integration tests**: E2E v0.10 tests verify ideation CLI (mechanical tier), multi-project execution, all-agents-mechanical, parallel execution
|
||||
- **Pipeline stages**: SPECIFY → CLARIFY → RESEARCH → **IDEATE** → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
|
||||
- **Commit schema**: Every CIAgent-generated commit contains a `---ci---` YAML block with phase, milestone, status, decisions, escalations, requirements, lessons, compound, and **project** metadata
|
||||
- **Branch strategy**: `phase/NN-slug` and `milestone/vX.X-slug` branches encode project structure; merged = complete, active = in progress
|
||||
- **Core engine rewrites**: DecisionEngine generates commit messages (not audit JSON), EscalationProtocol commits escalations as git artifacts, OrchestratorAgent uses git log as first impulse
|
||||
- **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`)
|
||||
- **CLI commands**: `init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`, `ideate`, `projects`, `sessions`
|
||||
- **Intelligence backends**: 5 options — OpenAI (LLM), Anthropic (LLM), OllamaLocal (LLM, localhost), OllamaCloud (LLM, remote), Opencode (Agent, --non-interactive). Auto-detection: opencode → openai → ollama-local → ollama-cloud → anthropic.
|
||||
- **Tests**: 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
|
||||
@@ -63,6 +63,28 @@ ciagent quick "Add authentication middleware"
|
||||
# Check project status (reads from git log + branches)
|
||||
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)
|
||||
ciagent audit
|
||||
ciagent audit --verbose
|
||||
@@ -77,7 +99,7 @@ ciagent rollback 1
|
||||
ciagent ship 1
|
||||
```
|
||||
|
||||
## Git-Native Architecture (v0.9.0)
|
||||
## Git-Native Architecture (v0.10.0)
|
||||
|
||||
### The Commit Schema
|
||||
|
||||
@@ -111,7 +133,7 @@ requirements:
|
||||
|
||||
| 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/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 |
|
||||
@@ -204,7 +226,8 @@ CIAgent uses `.ciagent/config.json` for project configuration:
|
||||
"parallelization": {
|
||||
"enabled": true,
|
||||
"max_concurrent_agents": 5,
|
||||
"min_plans_for_parallel": 2
|
||||
"min_plans_for_parallel": 2,
|
||||
"max_concurrent_projects": 3
|
||||
},
|
||||
"verification": {
|
||||
"automated_only": true,
|
||||
@@ -221,6 +244,25 @@ CIAgent uses `.ciagent/config.json` for project configuration:
|
||||
"branching_strategy": "phase",
|
||||
"auto_commit": true,
|
||||
"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
|
||||
|
||||
```
|
||||
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
|
||||
↕ ↕ ↕ ↕ ↕
|
||||
(questions) (auto-decide) (auto-run) (auto-test) (auto-verify)
|
||||
SPECIFY → CLARIFY → RESEARCH → IDEATE → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
|
||||
↕ ↕ ↕ ↕ ↕ ↕
|
||||
(questions) (auto-decide) (ideas) (auto-run) (auto-test) (auto-verify)
|
||||
```
|
||||
|
||||
### 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 |
|
||||
| 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
|
||||
|
||||
1. **Structural**: File existence, import/export wiring, no stubs
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -4,7 +4,7 @@ Agent output guidance for CIAgent dev mode. Loaded when the orchestrator operate
|
||||
|
||||
---
|
||||
|
||||
## Multi-Project and NFR Versioning
|
||||
## Multi-Project and Milestone Versioning
|
||||
|
||||
When in multi-project mode (`.ciagent/config.json` has `projects[]` with length > 0):
|
||||
- All commits include `project: <slug>` in `---ci---` block
|
||||
@@ -12,9 +12,10 @@ When in multi-project mode (`.ciagent/config.json` has `projects[]` with length
|
||||
- `.ciagent/` files are in `.ciagent/<slug>/` subdirectories
|
||||
- Project scoping applies to all operations
|
||||
|
||||
NFR milestone versioning:
|
||||
- NFR milestones (all phases are fix/chore/docs/perf/refactor/test): progressive patch versions only, no minor tag
|
||||
- Feature milestones (any feat phase): progressive patch versions + minor milestone tag
|
||||
Milestone versioning (determined by `getMilestoneType()` before any development):
|
||||
- **NFR** (all phases: fix/chore/docs/perf/refactor/test): progressive patch versions, no milestone tag — final patch IS the deliverable
|
||||
- **Feature** (at least one `feat` phase): progressive patch versions + next minor milestone tag
|
||||
- **Major** (breaking schema changes or complete refactor): progressive minor versions per phase + major milestone tag
|
||||
|
||||
## Output Style
|
||||
|
||||
|
||||
@@ -104,22 +104,29 @@ Phase branches can be deleted after merge if desired.
|
||||
|
||||
## Versioning and Releases
|
||||
|
||||
**Every merge to main creates a release. No exceptions.** Versioning follows a 3-tier model based on milestone type:
|
||||
**Every merge to main creates a release. No exceptions.** Versioning follows the milestone type model:
|
||||
|
||||
### 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 |
|
||||
|---------------|-----------|---------------|-------------------|
|
||||
| **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None |
|
||||
| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` |
|
||||
| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` |
|
||||
| **NFR** | All phases are fix/chore/docs/perf/refactor/test | Patch — `v1.8.1`, `v1.8.2`, ... | None — final patch IS the deliverable |
|
||||
| **Feature** | At least one phase has new features (`feat`) | Patch — `v1.8.1`, `v1.8.2`, ... | Next minor — `v1.9.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):**
|
||||
- Milestone tags are always the NEXT version, never the base:
|
||||
- Feature: patches v0.5.1–v0.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
|
||||
|
||||
Determine milestone type via `getMilestoneType()` which returns `"nfr" | "feature" | "schema-breaking"`.
|
||||
- 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
|
||||
|
||||
### 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.)
|
||||
|
||||
**Schema-breaking (minor release per phase):**
|
||||
**Major (minor release per phase):**
|
||||
```bash
|
||||
git checkout milestone/v0.5-schema-rewrite
|
||||
git merge --squash phase/01-core-refactor
|
||||
@@ -145,7 +152,7 @@ git push origin main --tags
|
||||
# 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
|
||||
|
||||
@@ -160,7 +167,7 @@ git push origin main --tags
|
||||
# Create Gitea release for v0.6.0 with full milestone summary
|
||||
```
|
||||
|
||||
**Schema-breaking (major release):**
|
||||
**Major (major release):**
|
||||
```bash
|
||||
# All phases already merged into milestone branch
|
||||
git checkout main
|
||||
@@ -177,9 +184,20 @@ git push origin main --tags
|
||||
|
||||
Before creating any tag:
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
When operating in multi-project mode (`.ciagent/config.json` has `projects[]` with length > 0):
|
||||
|
||||
+154
-38
@@ -1,16 +1,16 @@
|
||||
---
|
||||
description: Execute the full CIAgent pipeline — research → plan → execute → verify → complete for the current or specified phase
|
||||
description: Execute the full CIAgent pipeline — specify → clarify → research → ideate → plan → execute → ship → verify → complete for the current or specified phase
|
||||
---
|
||||
|
||||
# CIAgent Run
|
||||
|
||||
Execute the full CIAgent pipeline from the current stage to completion. The orchestrator iterates through stages and delegates to specialized agents.
|
||||
Execute the full CIAgent pipeline from the current stage to completion. The orchestrator iterates through stages and delegates to specialized agents and sub-workflows.
|
||||
|
||||
**Usage:** `ciagent-run [phase_number]`
|
||||
|
||||
If no phase number specified, continues from the current phase (detected from git log).
|
||||
|
||||
## Step 0: Confirm Active Project
|
||||
## Step 0: Confirm Active Project and Session
|
||||
|
||||
Check `ci listProjects()` or read `.ciagent/config.json` to determine if multi-project mode is active.
|
||||
|
||||
@@ -20,13 +20,21 @@ If `.ciagent/config.json` has `projects[]` with length > 0, or `active_projects`
|
||||
- If `--project <slug>` is specified: run for that project only
|
||||
- If no `--project` flag: use first project in `active_projects`
|
||||
- All commit messages must include `project: <slug>` in `---ci---` block
|
||||
- All `.ciagent/` file reads use `.ciagent/<slug>/` subdirectory paths
|
||||
- Branch names are prefixed with `<slug>/` (e.g., `<slug>/phase/01-auth`, `<slug>/milestone/v0.2-auth`)
|
||||
|
||||
For multi-project execution (`--project all`):
|
||||
- Execute pipeline for each project sequentially by default
|
||||
- When `parallelization.enabled=true`: execute projects concurrently up to `max_concurrent_agents`
|
||||
- Each project has independent phase branches and milestone tracking
|
||||
- Sessions (if configured): each project gets its own `AgentSession` with branch isolation per `config.json sessions.session_isolation`
|
||||
|
||||
If single-project mode: proceed with existing conventions.
|
||||
For multi-persona execution (when `config.json personas.enabled=true`):
|
||||
- Lead-developer persona decomposes tasks by territory file patterns and requirement IDs
|
||||
- Each persona group executes tasks within their territory
|
||||
- Territory enforcement runs in `warn` or `strict` mode per `config.json personas.territory_enforcement`
|
||||
|
||||
If single-project mode: proceed with existing conventions (flat `.ciagent/` paths, no project prefix on branches).
|
||||
|
||||
## Step 1: Load Git Context
|
||||
|
||||
@@ -40,85 +48,193 @@ Determine current state:
|
||||
- Current milestone from latest `---ci---` block or active milestone branch
|
||||
- Current pipeline stage from latest `---ci---` status field
|
||||
- Completed phases from merged `phase/NN-*` branches
|
||||
- Active project from `---ci---` project field (multi-project mode)
|
||||
|
||||
## Step 2: Pre-Flight Check
|
||||
|
||||
Verify `.ciagent/config.json` exists. If missing: stop, run `ciagent-init` first.
|
||||
|
||||
Read `.ciagent/PROJECT.md` and `.ciagent/ROADMAP.md` for phase goals.
|
||||
Resolve project paths based on mode:
|
||||
- **Multi-project**: read `.ciagent/<slug>/PROJECT.md` and `.ciagent/<slug>/ROADMAP.md` for the active project
|
||||
- **Single-project**: read `.ciagent/PROJECT.md` and `.ciagent/ROADMAP.md`
|
||||
|
||||
Read phase goals and milestone context from the resolved files.
|
||||
|
||||
## Step 3: Execute Pipeline Stages
|
||||
|
||||
For each stage in order (starting from current or from `specify`):
|
||||
|
||||
### SPECIFY
|
||||
- Parse specification from `.ciagent/PROJECT.md`
|
||||
- Validate requirements exist in `.ciagent/REQUIREMENTS.md`
|
||||
|
||||
- Resolve active project from `config.json`
|
||||
- Parse specification from `.ciagent/<slug>/PROJECT.md` (multi-project) or `.ciagent/PROJECT.md` (single-project)
|
||||
- Validate requirements exist in `.ciagent/<slug>/REQUIREMENTS.md` (multi-project) or `.ciagent/REQUIREMENTS.md` (single-project)
|
||||
- Commit: `docs(init): validate specification`
|
||||
|
||||
```
|
||||
---ci---
|
||||
project: <slug>
|
||||
phase: 0
|
||||
milestone: v0.X
|
||||
status: specify
|
||||
---/ci---
|
||||
```
|
||||
|
||||
### CLARIFY
|
||||
- Generate clarify questions for ambiguities
|
||||
- Default-accept at `full` autonomy, present at `supervised`/`guided`
|
||||
- Commit: `decision(P##): clarification decisions`
|
||||
|
||||
**Delegate to `ciagent-clarify` workflow.** Do not reimplement inline.
|
||||
|
||||
The clarify workflow handles:
|
||||
- Multi-project active project confirmation
|
||||
- Git context loading
|
||||
- Ambiguity identification and question generation
|
||||
- Autonomy-based resolution (full/supervised/guided)
|
||||
- Clarification commits with `---ci---` blocks
|
||||
- `.ciagent/<slug>/PROJECT.md` and `.ciagent/<slug>/REQUIREMENTS.md` updates
|
||||
|
||||
Pass the current phase number and active project slug. Collect the result and proceed.
|
||||
|
||||
### RESEARCH
|
||||
|
||||
- Resolve active project from `config.json`; use `.ciagent/<slug>/` paths
|
||||
- Delegate to ci-researcher
|
||||
- Research domain, ecosystem, prior art
|
||||
- Update `.ciagent/` static files with conclusions
|
||||
- Update `.ciagent/<slug>/` static files with conclusions (ARCHITECTURE.md, PROJECT.md, etc.)
|
||||
- Commit: `docs(P##): research findings`
|
||||
|
||||
```
|
||||
---ci---
|
||||
project: <slug>
|
||||
phase: [N]
|
||||
milestone: [vX.X]
|
||||
status: research
|
||||
---/ci---
|
||||
```
|
||||
|
||||
### IDEATE (when --ideate flag is passed)
|
||||
- Delegate to ci-ideation-agent
|
||||
- Mine git history for patterns, analyze coverage gaps, detect drift
|
||||
- If backend available: enrich with LLM suggestions
|
||||
- If --cross-project: mine patterns from other projects
|
||||
- Present recommendations interactively (accept/skip/modify)
|
||||
- Accepted ideas update ROADMAP.md and REQUIREMENTS.md
|
||||
- Commit: `decision(P##): ideation results — [N] accepted, [M] skipped`
|
||||
|
||||
**Delegate to `ciagent-ideate` workflow.** Do not reimplement inline.
|
||||
|
||||
The ideate workflow handles:
|
||||
- Multi-project context and `--project` flags
|
||||
- All three tiers (mechanical, backend-enriched, cross-project)
|
||||
- Interactive validation (accept/skip/modify)
|
||||
- Updates to `.ciagent/<slug>/REQUIREMENTS.md`, `.ciagent/<slug>/ROADMAP.md`, `.ciagent/<slug>/ARCHITECTURE.md`, `.ciagent/<slug>/PROJECT.md`
|
||||
- Ideation commit with `---ci---` block
|
||||
|
||||
Pass the active project slug and any `--ideate` flags. Collect accepted ideas and proceed.
|
||||
|
||||
### PLAN
|
||||
- Delegate to ci-planner
|
||||
|
||||
- Resolve active project from `config.json`; use `.ciagent/<slug>/` paths
|
||||
- Delegate to ci-planner with full project context
|
||||
- Create vertical-slice plans with wave ordering
|
||||
- Plans reference requirement IDs from `.ciagent/<slug>/REQUIREMENTS.md`
|
||||
- Commit: `docs(P##): create [N] phase plans`
|
||||
|
||||
```
|
||||
---ci---
|
||||
project: <slug>
|
||||
phase: [N]
|
||||
milestone: [vX.X]
|
||||
status: plan
|
||||
---/ci---
|
||||
```
|
||||
|
||||
### EXECUTE
|
||||
- Create phase branch: `phase/NN-slug`
|
||||
|
||||
- Create phase branch: `<slug>/phase/NN-slug` (multi-project) or `phase/NN-slug` (single-project)
|
||||
- Delegate to ci-executor per plan per wave
|
||||
- **Multi-persona development**: if `config.json personas.enabled=true`:
|
||||
- Lead-developer decomposes tasks by territory file patterns and requirement IDs
|
||||
- Each persona executes tasks within their declared territory (config.json `personas[].territory`)
|
||||
- Territory enforcement runs in configured mode (`warn` or `strict`)
|
||||
- Primary persona (i=0) executes sequentially; review personas (i>0) execute in parallel
|
||||
- Persona constraints (frameworks, constraints arrays) guide implementation choices
|
||||
- Commit each task with `---ci---` block
|
||||
- After all waves: commit phase completion
|
||||
- After all waves complete: **ship the phase** by delegating to `ciagent-ship` workflow
|
||||
|
||||
**Ship gate**: a phase MUST be shipped before advancing to the next phase. The ship workflow handles:
|
||||
- Pre-flight validation (milestone type, branch hierarchy, tag sequence, autonomy)
|
||||
- Test execution (test, typecheck, build)
|
||||
- PR creation and auto-merge
|
||||
- Version computation and tagging
|
||||
- Branch merging (phase → milestone or phase → main)
|
||||
- Gitea release creation
|
||||
|
||||
If the ship fails: do NOT advance to VERIFY. Iterate until the phase ships successfully.
|
||||
|
||||
### VERIFY
|
||||
- Delegate to ci-verifier
|
||||
- Check must_haves, requirement coverage, integration links
|
||||
- Auto-generate tests for unverifiable items
|
||||
- Commit: `verify(P##): verification result`
|
||||
|
||||
### COMPLETE
|
||||
- Merge phase branch into main (squash)
|
||||
- Tag with patch version (e.g., `v0.2.3` — 3rd phase in milestone v0.2)
|
||||
- Create Gitea release for the tag
|
||||
- Update `.ciagent/REQUIREMENTS.md` requirement statuses
|
||||
- Update `.ciagent/ROADMAP.md` phase status
|
||||
- Commit: `docs(P##): complete [phase-name] phase`
|
||||
**Delegate to `ciagent-verify` workflow.** Do not reimplement inline.
|
||||
|
||||
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
|
||||
|
||||
Between phases, perform a context reset:
|
||||
|
||||
1. Commit all work from the current phase
|
||||
2. Update `.ciagent/` files (phase status, requirement statuses)
|
||||
2. Update `.ciagent/<slug>/` files (phase status, requirement statuses)
|
||||
3. Verify `GitContext.reconstructState()` matches expected state
|
||||
4. Reset context: spawn fresh agent (opencode) or re-read git context (platforms without subagents)
|
||||
5. Next phase begins with fresh context from git log only
|
||||
|
||||
## 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.
|
||||
- **Feature milestone** (any feat phase): apply progressive patch versions per phase, then tag minor milestone version on completion (e.g., v0.2.0).
|
||||
- **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** (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
|
||||
|
||||
|
||||
+132
-32
@@ -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
|
||||
|
||||
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 |
|
||||
|---------------|-----------|---------------|-------------------|
|
||||
| **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None |
|
||||
| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` |
|
||||
| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` |
|
||||
| **NFR** | All phases are fix/chore/docs/perf/refactor/test | Patch — `v1.8.1`, `v1.8.2`, ... | None — final patch IS the deliverable |
|
||||
| **Feature** | At least one phase has new features (`feat`) | Patch — `v1.8.1`, `v1.8.2`, ... | Next minor — `v1.9.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):**
|
||||
|
||||
- Milestone tags are always the NEXT version, never the base:
|
||||
- Feature: patches v0.5.1–v0.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]`
|
||||
- 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
|
||||
|
||||
@@ -33,11 +53,12 @@ If `.ciagent/config.json` has `projects[]` with length > 0:
|
||||
|
||||
If single-project mode: proceed with existing conventions.
|
||||
|
||||
## Step 1: Pre-Flight
|
||||
## Step 1: Pre-Flight Validation
|
||||
|
||||
```bash
|
||||
git log --max-count=10
|
||||
git branch -a
|
||||
git tag -l
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
```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.
|
||||
|
||||
## 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 |
|
||||
|----------------|---------------|-------------|------------|---------|
|
||||
|----------------|---------------|---------------|-------------------|---------|
|
||||
| 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 | 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 | 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:
|
||||
- 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:**
|
||||
1. The 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)
|
||||
**Tag validation (before creating ANY tag):**
|
||||
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 (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)
|
||||
|
||||
## Step 4: Merge Branch
|
||||
## Step 5: Merge Branch
|
||||
|
||||
### 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
|
||||
|
||||
@@ -123,8 +198,9 @@ requirements:
|
||||
|
||||
### Milestone ship (after last phase)
|
||||
|
||||
**Validate all phase branches are merged into the milestone branch before proceeding.**
|
||||
|
||||
```bash
|
||||
# Verify all phase branches are merged into milestone branch
|
||||
git checkout main
|
||||
git merge --squash milestone/vX.Y-slug
|
||||
git commit -m "docs(milestone): complete [milestone-name]
|
||||
@@ -136,7 +212,7 @@ status: complete
|
||||
---/ci---"
|
||||
```
|
||||
|
||||
## Step 5: Tag and Push
|
||||
## Step 6: Tag and Push
|
||||
|
||||
```bash
|
||||
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:**
|
||||
- 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`)
|
||||
- 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.**
|
||||
|
||||
Generate release notes from git log:
|
||||
### Generate release notes
|
||||
|
||||
```bash
|
||||
git log v[previous_tag]..vX.Y.Z --oneline
|
||||
```
|
||||
|
||||
Create the release via Gitea API:
|
||||
### Create the Gitea release
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
## 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/ROADMAP.md` — mark shipped phase as complete
|
||||
|
||||
Commit the file updates.
|
||||
|
||||
## Step 8: Report
|
||||
## Step 9: Report
|
||||
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -185,7 +284,7 @@ Commit the file updates.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Phase [N]: [name]
|
||||
Milestone: [vX.Y] ([nfr|feature|schema-breaking])
|
||||
Milestone: [vX.Y] ([nfr|feature|major])
|
||||
Version: vX.Y.Z
|
||||
Release: https://git.cloudinit.dev/continuous-intelligence/ci/releases/tag/vX.Y.Z
|
||||
Status: complete
|
||||
@@ -193,6 +292,7 @@ Status: complete
|
||||
Tests: PASS
|
||||
Typecheck: PASS
|
||||
Build: PASS
|
||||
Pipeline: PASS
|
||||
|
||||
Requirements covered: [N]
|
||||
Commits: [N]
|
||||
|
||||
@@ -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]"
|
||||
tools:
|
||||
read: true
|
||||
@@ -12,7 +12,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@__OPENCODE_DIR__/ci/workflows/ship.md
|
||||
@/root/.config/opencode/ci/workflows/ship.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@continuous-intelligence/ciagent",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@continuous-intelligence/ciagent",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^12.1.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@continuous-intelligence/ciagent",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0",
|
||||
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface AgentContext {
|
||||
specification: string;
|
||||
config_path: string;
|
||||
backend?: IntelligenceBackend;
|
||||
project_slug?: string;
|
||||
}
|
||||
|
||||
export function backendResultToAgentResult(result: BackendResult): AgentResult {
|
||||
|
||||
+190
-4
@@ -2,6 +2,11 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { TaskDecomposer } from "../core/task-decomposer.js";
|
||||
import { PersonaLoader } from "../core/persona-loader.js";
|
||||
import { TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS } from "../types/persona.js";
|
||||
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||
import { loadConfig } from "../core/config.js";
|
||||
|
||||
export interface ExecutorResult {
|
||||
success: boolean;
|
||||
@@ -17,6 +22,17 @@ interface MustHaveItem {
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
interface PersonaTaskGroup {
|
||||
persona: string;
|
||||
domain: string;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
files: string[];
|
||||
}>;
|
||||
conflicts: TerritoryConflict[];
|
||||
}
|
||||
|
||||
export class ExecutorAgent extends BaseAgent {
|
||||
readonly name = "executor";
|
||||
readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints.";
|
||||
@@ -27,6 +43,14 @@ export class ExecutorAgent extends BaseAgent {
|
||||
this.log("Executing tasks...");
|
||||
|
||||
if (context.backend) {
|
||||
const config = this.loadProjectConfig(context);
|
||||
const personasEnabled = config.personas?.enabled !== false;
|
||||
|
||||
if (personasEnabled) {
|
||||
this.log("Persona-based execution enabled — decomposing plan and assigning to personas");
|
||||
return this.executeWithPersonas(context, config);
|
||||
}
|
||||
|
||||
const taskPrompt = await this.buildBackendTaskPrompt(context);
|
||||
const backendResult = await this.executeViaBackend(context, taskPrompt);
|
||||
|
||||
@@ -50,6 +74,156 @@ export class ExecutorAgent extends BaseAgent {
|
||||
};
|
||||
}
|
||||
|
||||
private async executeWithPersonas(
|
||||
context: AgentContext,
|
||||
config: CIAgentConfig
|
||||
): Promise<AgentResult> {
|
||||
const start = Date.now();
|
||||
|
||||
const planContent = this.readPlanFile(context);
|
||||
if (!planContent) {
|
||||
this.log("No plan file found — falling back to standard execution");
|
||||
const taskPrompt = await this.buildBackendTaskPrompt(context);
|
||||
return this.executeViaBackend(context, taskPrompt);
|
||||
}
|
||||
|
||||
const decomposer = new TaskDecomposer(context.project_path, config, context.project_slug);
|
||||
const plan = decomposer.decompose(planContent);
|
||||
const resolvedPlan = decomposer.resolveConflicts(plan);
|
||||
|
||||
this.log(`Decomposed plan into ${resolvedPlan.tasks.length} tasks across domains: data=${resolvedPlan.dataTasks.length}, backend=${resolvedPlan.backendTasks.length}, frontend=${resolvedPlan.frontendTasks.length}, coordination=${resolvedPlan.coordinationTasks.length}`);
|
||||
|
||||
if (resolvedPlan.conflicts.length > 0) {
|
||||
this.log(`Resolved ${resolvedPlan.conflicts.length} territory conflicts`);
|
||||
for (const conflict of resolvedPlan.conflicts) {
|
||||
this.log(` Conflict: ${conflict.description} → ${conflict.resolution || "unresolved"}`);
|
||||
}
|
||||
}
|
||||
|
||||
const personaGroups = this.groupTasksByPersona(resolvedPlan);
|
||||
const personaLoader = new PersonaLoader(context.project_path, config);
|
||||
const enforcement = config.personas?.territory_enforcement || "warn";
|
||||
|
||||
let totalDecisions = 0;
|
||||
let totalEscalations = 0;
|
||||
const allArtifacts: string[] = [];
|
||||
let lastError: string | undefined;
|
||||
|
||||
const domainOrder: string[] = ["data", "backend", "frontend", "coordination"];
|
||||
const sortedGroups = domainOrder
|
||||
.flatMap((domain) => personaGroups.filter((g) => g.domain === domain))
|
||||
.concat(personaGroups.filter((g) => !domainOrder.includes(g.domain)));
|
||||
|
||||
for (const group of sortedGroups) {
|
||||
this.log(`Executing group: persona=${group.persona}, domain=${group.domain}, tasks=${group.tasks.length}`);
|
||||
|
||||
for (const conflict of group.conflicts) {
|
||||
if (enforcement === "strict") {
|
||||
this.warn(`Territory conflict (strict): ${conflict.description}`);
|
||||
totalEscalations++;
|
||||
} else {
|
||||
this.log(`Territory conflict (warn): ${conflict.description} — ${conflict.resolution || "auto-resolved"}`);
|
||||
}
|
||||
}
|
||||
|
||||
const persona = personaLoader.getPersona(group.persona);
|
||||
const personaContext = this.buildPersonaContext(context, persona, group);
|
||||
|
||||
try {
|
||||
const result = await this.executeViaBackend(personaContext, personaContext.specification);
|
||||
|
||||
if (Array.isArray(result.artifacts_created)) {
|
||||
allArtifacts.push(...result.artifacts_created);
|
||||
}
|
||||
totalDecisions += result.decisions;
|
||||
totalEscalations += result.escalations;
|
||||
|
||||
if (!result.success) {
|
||||
this.warn(`Persona ${group.persona} reported issues: ${result.error || "unspecified"}`);
|
||||
lastError = result.error;
|
||||
}
|
||||
} catch (err) {
|
||||
this.warn(`Persona ${group.persona} failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
const verification = await this.verifyExecution(context);
|
||||
|
||||
return {
|
||||
success: verification.testsPassing || lastError === undefined,
|
||||
output: `Executed ${resolvedPlan.tasks.length} tasks across ${personaGroups.length} persona groups. Verification: tests=${verification.testsPassing ? "passing" : "failing"}, must-haves=${verification.mustHavesChecked.length}`,
|
||||
artifacts_created: allArtifacts,
|
||||
decisions: totalDecisions,
|
||||
escalations: totalEscalations,
|
||||
duration_ms: Date.now() - start,
|
||||
error: lastError,
|
||||
};
|
||||
}
|
||||
|
||||
private groupTasksByPersona(plan: DecomposedPlan): PersonaTaskGroup[] {
|
||||
const groupMap = new Map<string, PersonaTaskGroup>();
|
||||
|
||||
for (const task of plan.tasks) {
|
||||
const key = task.persona;
|
||||
if (!groupMap.has(key)) {
|
||||
groupMap.set(key, {
|
||||
persona: task.persona,
|
||||
domain: task.domain,
|
||||
tasks: [],
|
||||
conflicts: plan.conflicts.filter((c) => c.personas.includes(task.persona)),
|
||||
});
|
||||
}
|
||||
groupMap.get(key)!.tasks.push({
|
||||
id: task.taskId,
|
||||
description: task.description,
|
||||
files: task.files,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(groupMap.values());
|
||||
}
|
||||
|
||||
private buildPersonaContext(
|
||||
context: AgentContext,
|
||||
persona: ReturnType<PersonaLoader["getPersona"]>,
|
||||
group: PersonaTaskGroup
|
||||
): AgentContext {
|
||||
const personaPrompt = persona
|
||||
? `You are the ${persona.name} (${persona.domain} domain). ${persona.systemPromptAdditions || persona.description}.\n\nPreferred frameworks: ${persona.frameworks.join(", ")}.\nDesign constraints: ${persona.constraints.join(", ")}.\nTerritory files: ${persona.territory.join(", ")}.\n\n`
|
||||
: "";
|
||||
|
||||
const taskDescriptions = group.tasks
|
||||
.map((t) => `- [${t.id}] ${t.description} (files: ${t.files.join(", ") || "TBD"})`)
|
||||
.join("\n");
|
||||
|
||||
const conflictNotes = group.conflicts.length > 0
|
||||
? `\n\n## Territory Conflicts (resolved by lead developer)\n${group.conflicts.map((c) => `- ${c.description} → Resolution: ${c.resolution || "pending"}`).join("\n")}`
|
||||
: "";
|
||||
|
||||
const specification = [
|
||||
personaPrompt,
|
||||
"## Assigned Tasks\n",
|
||||
taskDescriptions,
|
||||
conflictNotes,
|
||||
"\n\n## Specification\n",
|
||||
context.specification || "No specification provided",
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
...context,
|
||||
specification,
|
||||
};
|
||||
}
|
||||
|
||||
private loadProjectConfig(context: AgentContext): CIAgentConfig {
|
||||
try {
|
||||
return loadConfig(context.project_path);
|
||||
} catch {
|
||||
return DEFAULT_CIAGENT_CONFIG as CIAgentConfig;
|
||||
}
|
||||
}
|
||||
|
||||
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
|
||||
const parts: string[] = [
|
||||
`Execute implementation for stage ${context.stage}, phase ${context.phase}.`,
|
||||
@@ -64,8 +238,12 @@ export class ExecutorAgent extends BaseAgent {
|
||||
}
|
||||
|
||||
const ciDir = path.join(context.project_path, ".ciagent");
|
||||
const roadmapPath = path.join(ciDir, "ROADMAP.md");
|
||||
const archPath = path.join(ciDir, "ARCHITECTURE.md");
|
||||
const roadmapPath = context.project_slug
|
||||
? path.join(ciDir, context.project_slug, "ROADMAP.md")
|
||||
: path.join(ciDir, "ROADMAP.md");
|
||||
const archPath = context.project_slug
|
||||
? path.join(ciDir, context.project_slug, "ARCHITECTURE.md")
|
||||
: path.join(ciDir, "ARCHITECTURE.md");
|
||||
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
try {
|
||||
@@ -91,11 +269,17 @@ export class ExecutorAgent extends BaseAgent {
|
||||
}
|
||||
|
||||
private readPlanFile(context: AgentContext): string | null {
|
||||
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||
const planPath = context.project_slug
|
||||
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
|
||||
: path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||
try {
|
||||
if (fs.existsSync(planPath)) {
|
||||
return fs.readFileSync(planPath, "utf-8");
|
||||
}
|
||||
const defaultPlanPath = path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||
if (fs.existsSync(defaultPlanPath)) {
|
||||
return fs.readFileSync(defaultPlanPath, "utf-8");
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
@@ -139,7 +323,9 @@ export class ExecutorAgent extends BaseAgent {
|
||||
}
|
||||
|
||||
private checkMustHaves(context: AgentContext): MustHaveItem[] {
|
||||
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||
const planPath = context.project_slug
|
||||
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
|
||||
: path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||
const results: MustHaveItem[] = [];
|
||||
|
||||
try {
|
||||
|
||||
+193
-1
@@ -20,6 +20,7 @@ import { loadConfig, saveConfig, isCIAgentInitialized, initCIAgent } from "../co
|
||||
import { getAgent } from "./index.js";
|
||||
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
|
||||
import { registerEscalationProtocol } from "../cli/index.js";
|
||||
import { SessionManager } from "../core/session-manager.js";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export interface GitAgentContext extends AgentContext {
|
||||
@@ -47,6 +48,7 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
|
||||
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName[]>> = {
|
||||
research: ["researcher"],
|
||||
ideate: ["ideation-agent"],
|
||||
plan: ["planner"],
|
||||
execute: ["executor", "code-reviewer", "security-auditor"],
|
||||
test: ["tester"],
|
||||
@@ -67,9 +69,10 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
try {
|
||||
this.config = loadConfig(context.project_path);
|
||||
|
||||
const projectSlug = context.project_slug || "";
|
||||
this.gitContext = new GitContext(context.project_path);
|
||||
this.gitBranch = new GitBranch(context.project_path);
|
||||
this.ciFiles = new CIAgentFiles(context.project_path);
|
||||
this.ciFiles = new CIAgentFiles(context.project_path, projectSlug || undefined);
|
||||
this.ciFiles.ensureCIDir();
|
||||
|
||||
const projectState = this.gitContext.reconstructState();
|
||||
@@ -459,6 +462,7 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
projectName: spec.objective.slice(0, 30),
|
||||
phaseCount: 0,
|
||||
milestone: this.currentMilestone,
|
||||
project: context.project_slug || undefined,
|
||||
specification: spec.raw_content,
|
||||
requirements: spec.requirements,
|
||||
constraints: spec.constraints,
|
||||
@@ -571,6 +575,69 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
break;
|
||||
}
|
||||
|
||||
case "ideate": {
|
||||
this.log("Running ideation stage...");
|
||||
const { IdeationEngine } = await import("../core/ideation.js");
|
||||
const ideationEngine = new IdeationEngine(context.project_path, context.project_slug || undefined);
|
||||
const ideas = ideationEngine.runMechanical();
|
||||
|
||||
const ideationConfig = this.config.ideation;
|
||||
if (ideationConfig?.categories && ideationConfig.categories.length > 0) {
|
||||
const categoryIdeas = ideationEngine.runMechanical(ideationConfig.categories);
|
||||
const seenTitles = new Set(ideas.map((i) => i.title));
|
||||
for (const idea of categoryIdeas) {
|
||||
if (!seenTitles.has(idea.title)) {
|
||||
ideas.push(idea);
|
||||
seenTitles.add(idea.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ideas.sort((a, b) => b.confidence - a.confidence);
|
||||
|
||||
const maxIdeas = ideationConfig?.max_ideas || 20;
|
||||
const trimmedIdeas = ideas.slice(0, maxIdeas);
|
||||
|
||||
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
||||
const { accepted: savedIdeas, results } = ideationEngine.acceptIdeas(trimmedIdeas);
|
||||
const savedCount = results.filter((r) => r.addedToRequirements || r.addedToRoadmap).length;
|
||||
|
||||
const ideationCommit = CommitBuilder.buildTaskCommit({
|
||||
type: "decision",
|
||||
phase: this.pipelineState!.current_phase,
|
||||
milestone: this.currentMilestone,
|
||||
project: context.project_slug || undefined,
|
||||
plan: "ideation",
|
||||
task: "ideation-results",
|
||||
subject: `ideation results — ${trimmedIdeas.length} total, ${savedCount} accepted`,
|
||||
status: "ideate",
|
||||
decisions: savedIdeas.map((idea) => ({
|
||||
id: idea.id,
|
||||
decision: idea.title,
|
||||
rationale: idea.rationale,
|
||||
confidence: idea.confidence,
|
||||
alternatives: idea.actions,
|
||||
})),
|
||||
});
|
||||
|
||||
try {
|
||||
execSync(`git add -A && git commit -m "${ideationCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (err) {
|
||||
this.warn(`Ideation commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
artifactsCreated.push(".ciagent/REQUIREMENTS.md", ".ciagent/ROADMAP.md");
|
||||
decisionsMade += savedCount;
|
||||
}
|
||||
|
||||
this.pipelineState!.ideate_completed = true;
|
||||
this.log(`Ideation stage complete: ${trimmedIdeas.length} ideas generated`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "plan":
|
||||
this.log("Planning phase execution...");
|
||||
|
||||
@@ -790,4 +857,129 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async runForProject(projectSlug: string, context: AgentContext): Promise<AgentResult> {
|
||||
this.log(`Running pipeline for project: ${projectSlug}`);
|
||||
|
||||
this.ciFiles = new CIAgentFiles(context.project_path, projectSlug);
|
||||
this.ciFiles.ensureCIDir();
|
||||
this.ciFiles.setProjectSlug(projectSlug);
|
||||
|
||||
const projectContext: AgentContext = {
|
||||
...context,
|
||||
project_path: context.project_path,
|
||||
};
|
||||
|
||||
const result = await this.execute(projectContext);
|
||||
|
||||
return {
|
||||
...result,
|
||||
output: result.output ? `[${projectSlug}] ${result.output}` : result.output,
|
||||
};
|
||||
}
|
||||
|
||||
async runForAllProjects(context: AgentContext): Promise<Record<string, AgentResult>> {
|
||||
const config = loadConfig(context.project_path);
|
||||
const ciFiles = new CIAgentFiles(context.project_path);
|
||||
const projects = ciFiles.listProjects();
|
||||
|
||||
const activeProjects: string[] = config.active_projects?.length > 0
|
||||
? config.active_projects
|
||||
: projects.map((p) => p.slug);
|
||||
|
||||
if (activeProjects.length === 0) {
|
||||
this.log("No active projects found; running for default project");
|
||||
const result = await this.execute(context);
|
||||
return { default: result };
|
||||
}
|
||||
|
||||
this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`);
|
||||
|
||||
const useSessions = config.sessions?.max_concurrent_sessions !== undefined;
|
||||
|
||||
if (useSessions) {
|
||||
return this.runWithSessionManager(context, activeProjects, config);
|
||||
}
|
||||
|
||||
return this.runWithLegacyParallel(context, activeProjects, config);
|
||||
}
|
||||
|
||||
private async runWithSessionManager(
|
||||
context: AgentContext,
|
||||
activeProjects: string[],
|
||||
config: CIAgentConfig
|
||||
): Promise<Record<string, AgentResult>> {
|
||||
const sessionManager = new SessionManager(context.project_path, config);
|
||||
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
|
||||
|
||||
const contextFactory = (slug: string): AgentContext => ({
|
||||
...context,
|
||||
project_slug: slug,
|
||||
});
|
||||
|
||||
return sessionManager.runAllSessions(activeProjects, contextFactory, parallel);
|
||||
}
|
||||
|
||||
private async runWithLegacyParallel(
|
||||
context: AgentContext,
|
||||
activeProjects: string[],
|
||||
config: CIAgentConfig
|
||||
): Promise<Record<string, AgentResult>> {
|
||||
const results: Record<string, AgentResult> = {};
|
||||
const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3;
|
||||
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
|
||||
|
||||
if (parallel) {
|
||||
const limitedConcurrency = Math.min(maxConcurrent, activeProjects.length);
|
||||
const batches: string[][] = [];
|
||||
for (let i = 0; i < activeProjects.length; i += limitedConcurrency) {
|
||||
batches.push(activeProjects.slice(i, i + limitedConcurrency));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map(async (slug): Promise<[string, AgentResult]> => {
|
||||
const orchestrator = new OrchestratorAgent(config);
|
||||
const result = await orchestrator.runForProject(slug, context);
|
||||
return [slug, result];
|
||||
})
|
||||
);
|
||||
|
||||
for (const settled of batchResults) {
|
||||
if (settled.status === "fulfilled") {
|
||||
const [slug, result] = settled.value;
|
||||
results[slug] = result;
|
||||
} else {
|
||||
this.warn(`Project pipeline failed: ${settled.reason instanceof Error ? settled.reason.message : String(settled.reason)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const slug of activeProjects) {
|
||||
this.log(`Processing project: ${slug}`);
|
||||
const orchestrator = new OrchestratorAgent(config);
|
||||
orchestrator.ciFiles = new CIAgentFiles(context.project_path, slug);
|
||||
orchestrator.ciFiles.ensureCIDir();
|
||||
orchestrator.ciFiles.setProjectSlug(slug);
|
||||
|
||||
try {
|
||||
const result = await orchestrator.runForProject(slug, context);
|
||||
results[slug] = result;
|
||||
} catch (err) {
|
||||
this.warn(`Failed for project ${slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
results[slug] = {
|
||||
success: false,
|
||||
output: `Pipeline failed for project ${slug}`,
|
||||
artifacts_created: 0,
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
+366
-41
@@ -10,7 +10,7 @@ import { getAuditSummary, readAudit } from "../core/audit.js";
|
||||
import { VerificationPipeline } from "../verification/index.js";
|
||||
import { ClarifyPhase } from "../core/clarify.js";
|
||||
import { loadSpecification as loadSpec } from "../core/clarify.js";
|
||||
import { AgentContext } from "../agents/base.js";
|
||||
import { AgentContext, AgentResult } from "../agents/base.js";
|
||||
import { ErrorRecovery } from "../core/error-recovery.js";
|
||||
import { PipelineState, createInitialPipelineState } from "../types/pipeline.js";
|
||||
import { resolveBackend } from "../backends/index.js";
|
||||
@@ -18,6 +18,8 @@ import { BackendUnavailableError } from "../backends/types.js";
|
||||
import { getAgent } from "../agents/index.js";
|
||||
import { CIAgentFiles } from "../core/ciagent-files.js";
|
||||
import { GiteaClient, generateReleaseNotes } from "../core/gitea.js";
|
||||
import { SessionManager } from "../core/session-manager.js";
|
||||
import { AgentSession } from "../core/agent-session.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as readline from "node:readline";
|
||||
@@ -79,6 +81,7 @@ export function createInitCommand(): Command {
|
||||
enabled: options.parallel !== false,
|
||||
max_concurrent_agents: 5,
|
||||
min_plans_for_parallel: 2,
|
||||
max_concurrent_projects: 3,
|
||||
},
|
||||
backend: {
|
||||
provider: options.backend || "auto",
|
||||
@@ -169,6 +172,9 @@ export function createRunCommand(): Command {
|
||||
.option("--all", "Execute all remaining phases sequentially")
|
||||
.option("--phase <number>", "Phase number", "1")
|
||||
.option("--backend <provider>", "Override intelligence backend for this run")
|
||||
.option("--ideate", "Insert ideation stage between research and plan")
|
||||
.option("--project <slug>", "Target project slug (comma-separated or 'all')")
|
||||
.option("--session <id>", "Resume a specific session by ID")
|
||||
.action(async (phase, options) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
@@ -178,6 +184,141 @@ export function createRunCommand(): Command {
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const ciFiles = new CIAgentFiles(projectPath);
|
||||
const runForAllProjects = options.project === "all" || (Array.isArray(config.active_projects) && config.active_projects.length > 1 && !options.project);
|
||||
|
||||
if (runForAllProjects) {
|
||||
console.log("─── Running pipeline across all active projects ───\n");
|
||||
|
||||
const orchestrator = new OrchestratorAgent(config);
|
||||
const context: AgentContext = {
|
||||
project_path: projectPath,
|
||||
phase: parseInt(options.phase) || 1,
|
||||
stage: phase || "all",
|
||||
specification: "",
|
||||
config_path: path.join(projectPath, ".ciagent", "config.json"),
|
||||
backend: undefined,
|
||||
};
|
||||
|
||||
const spec = loadSpec(projectPath);
|
||||
if (spec) {
|
||||
context.specification = spec.raw_content;
|
||||
}
|
||||
|
||||
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
|
||||
if (backend) {
|
||||
context.backend = backend;
|
||||
} else if (backendError) {
|
||||
console.warn(` ⚠ No intelligence backend available: ${backendError}`);
|
||||
console.warn(" Continuing with mechanical-only execution (limited functionality).");
|
||||
}
|
||||
|
||||
const results = await orchestrator.runForAllProjects(context);
|
||||
|
||||
console.log("\n─── Multi-Project Pipeline Results ───\n");
|
||||
let allSuccess = true;
|
||||
for (const [slug, result] of Object.entries(results)) {
|
||||
const icon = result.success ? "✓" : "✗";
|
||||
console.log(` ${icon} ${slug}: ${result.success ? "success" : result.error || "failed"}`);
|
||||
if (!result.success) allSuccess = false;
|
||||
}
|
||||
|
||||
if (!allSuccess) {
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let projectSlug: string | undefined;
|
||||
if (options.project && options.project !== "all") {
|
||||
const slugs = options.project.split(",").map((s: string) => s.trim()).filter(Boolean);
|
||||
projectSlug = slugs[0];
|
||||
|
||||
if (slugs.length > 1) {
|
||||
console.log("─── Running pipeline across multiple projects ───\n");
|
||||
|
||||
const orchestrator = new OrchestratorAgent(config);
|
||||
const context: AgentContext = {
|
||||
project_path: projectPath,
|
||||
phase: parseInt(options.phase) || 1,
|
||||
stage: phase || "all",
|
||||
specification: "",
|
||||
config_path: path.join(projectPath, ".ciagent", "config.json"),
|
||||
backend: undefined,
|
||||
};
|
||||
|
||||
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
|
||||
if (backend) {
|
||||
context.backend = backend;
|
||||
} else if (backendError) {
|
||||
console.warn(` ⚠ No intelligence backend available: ${backendError}`);
|
||||
}
|
||||
|
||||
const allResults: Record<string, AgentResult> = {};
|
||||
for (const slug of slugs) {
|
||||
console.log(`\nProcessing project: ${slug}`);
|
||||
const projOrchestrator = new OrchestratorAgent(config);
|
||||
const result = await projOrchestrator.runForProject(slug, context);
|
||||
allResults[slug] = result;
|
||||
}
|
||||
|
||||
console.log("\n─── Multi-Project Pipeline Results ───\n");
|
||||
let allSuccess = true;
|
||||
for (const [slug, result] of Object.entries(allResults)) {
|
||||
const icon = result.success ? "✓" : "✗";
|
||||
console.log(` ${icon} ${slug}: ${result.success ? "success" : result.error || "failed"}`);
|
||||
if (!result.success) allSuccess = false;
|
||||
}
|
||||
|
||||
if (!allSuccess) {
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.ideate) {
|
||||
console.log("─── CIAgent Ideate (pipeline mode) ───\n");
|
||||
|
||||
const currentSlug = projectSlug || ciFiles.getProjectSlug() || ciFiles.getActiveProject() || "default";
|
||||
const { IdeationEngine } = await import("../core/ideation.js");
|
||||
const engine = new IdeationEngine(projectPath, currentSlug);
|
||||
|
||||
const ideas = engine.runMechanical();
|
||||
|
||||
const ideaCategory: IdeationCategory[] = options.category
|
||||
? options.category.split(",").map((c: string) => c.trim() as IdeationCategory)
|
||||
: [];
|
||||
|
||||
if (ideaCategory.length > 0) {
|
||||
const filtered = engine.runMechanical(ideaCategory);
|
||||
ideas.push(...filtered);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const uniqueIdeas = ideas.filter((idea: Idea) => {
|
||||
if (seen.has(idea.title)) return false;
|
||||
seen.add(idea.title);
|
||||
return true;
|
||||
});
|
||||
|
||||
uniqueIdeas.sort((a: Idea, b: Idea) => b.confidence - a.confidence);
|
||||
|
||||
console.log(`Found ${uniqueIdeas.length} improvement ${uniqueIdeas.length === 1 ? "idea" : "ideas"} from ideation stage.\n`);
|
||||
|
||||
if (uniqueIdeas.length > 0) {
|
||||
const { accepted: savedIdeas, results } = engine.acceptIdeas(uniqueIdeas);
|
||||
const savedCount = results.filter((r: { addedToRequirements: boolean; addedToRoadmap: boolean }) => r.addedToRequirements || r.addedToRoadmap).length;
|
||||
|
||||
if (savedCount > 0) {
|
||||
console.log(`${savedCount} idea${savedCount === 1 ? "" : "s"} added to REQUIREMENTS.md and ROADMAP.md.`);
|
||||
}
|
||||
|
||||
const commitMsg = `decision(P${options.phase || 1}): ideation results — ${uniqueIdeas.length} total, ${savedCount} accepted`;
|
||||
console.log(`\nCommit suggestion: ${commitMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
|
||||
|
||||
if (!backend && backendError) {
|
||||
@@ -193,6 +334,7 @@ export function createRunCommand(): Command {
|
||||
specification: "",
|
||||
config_path: path.join(projectPath, ".ciagent", "config.json"),
|
||||
backend,
|
||||
project_slug: projectSlug || undefined,
|
||||
};
|
||||
|
||||
const spec = loadSpec(projectPath);
|
||||
@@ -200,7 +342,7 @@ export function createRunCommand(): Command {
|
||||
context.specification = spec.raw_content;
|
||||
}
|
||||
|
||||
console.log(`Running CIAgent pipeline...`);
|
||||
console.log(`Running CIAgent pipeline${projectSlug ? ` for project: ${projectSlug}` : ""}...`);
|
||||
if (options.all) {
|
||||
console.log(" Mode: Full pipeline (all phases)");
|
||||
} else {
|
||||
@@ -863,7 +1005,7 @@ function computeShipVersion(
|
||||
projectPath: string,
|
||||
phaseNum: number,
|
||||
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" })
|
||||
.split("\n")
|
||||
.map((t) => t.trim())
|
||||
@@ -890,7 +1032,7 @@ function computeShipVersion(
|
||||
const milestoneType = inferMilestoneType(projectPath);
|
||||
|
||||
let tag: string;
|
||||
if (milestoneType === "schema-breaking") {
|
||||
if (milestoneType === "major") {
|
||||
tag = `v${major}.${minor + phaseNum}.0`;
|
||||
} else {
|
||||
tag = `v${major}.${minor}.${phaseNum}`;
|
||||
@@ -899,10 +1041,10 @@ function computeShipVersion(
|
||||
return { tag, milestoneType };
|
||||
}
|
||||
|
||||
function inferMilestoneType(projectPath: string): "nfr" | "feature" | "schema-breaking" {
|
||||
function inferMilestoneType(projectPath: string): "nfr" | "feature" | "major" {
|
||||
try {
|
||||
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";
|
||||
return "nfr";
|
||||
} catch {
|
||||
@@ -989,72 +1131,88 @@ export function createIdeateCommand(): Command {
|
||||
}
|
||||
|
||||
const ciFiles = new CIAgentFiles(projectPath);
|
||||
let slug = options.project || ciFiles.getActiveProject() || "default";
|
||||
const allProjects = slug === "all";
|
||||
const config = loadConfig(projectPath);
|
||||
|
||||
if (options.project) {
|
||||
ciFiles.setProjectSlug(options.project);
|
||||
const allProjects: string[] = options.project === "all"
|
||||
? ciFiles.listProjects().map((p) => p.slug)
|
||||
: options.project
|
||||
? options.project.split(",").map((s: string) => s.trim()).filter(Boolean)
|
||||
: [ciFiles.getProjectSlug() || ciFiles.getActiveProject() || "default"];
|
||||
|
||||
if (allProjects.length > 1) {
|
||||
console.log(`\n─── CIAgent Ideation (multi-project: ${allProjects.join(", ")}) ───\n`);
|
||||
} else {
|
||||
console.log("\n─── CIAgent Ideation ───");
|
||||
console.log(`Project: ${allProjects[0]}`);
|
||||
}
|
||||
|
||||
const { IdeationEngine } = await import("../core/ideation.js");
|
||||
|
||||
const allIdeasByProject: Record<string, Idea[]> = {};
|
||||
const allIdeas: Idea[] = [];
|
||||
const seenTitles = new Set<string>();
|
||||
|
||||
for (const slug of allProjects) {
|
||||
const engine = new IdeationEngine(projectPath, slug);
|
||||
ciFiles.setProjectSlug(slug);
|
||||
|
||||
const categories: IdeationCategory[] = options.category
|
||||
? options.category.split(",").map((c: string) => c.trim() as IdeationCategory)
|
||||
: [];
|
||||
|
||||
console.log("\n─── CIAgent Ideation ───");
|
||||
console.log(`Project: ${ciFiles.getProjectSlug() || "default"}`);
|
||||
console.log(`\nMining git history for patterns in project: ${slug}...`);
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
|
||||
console.log("\nMining git history for patterns...");
|
||||
|
||||
const { IdeationEngine } = await import("../core/ideation.js");
|
||||
const engine = new IdeationEngine(projectPath, ciFiles.getProjectSlug() || undefined);
|
||||
|
||||
let allIdeas: Idea[] = [];
|
||||
|
||||
console.log("Running mechanical analysis (tier 1)...");
|
||||
allIdeas = engine.runMechanical(categories.length > 0 ? categories : undefined);
|
||||
let projectIdeas: Idea[] = engine.runMechanical(categories.length > 0 ? categories : undefined);
|
||||
|
||||
if (options.affected) {
|
||||
console.log("Running cascade impact analysis (--affected)...");
|
||||
console.log(`Running cascade impact analysis (--affected) for ${slug}...`);
|
||||
const affectedIdeas = engine.runAffected();
|
||||
allIdeas = [...allIdeas, ...affectedIdeas];
|
||||
projectIdeas = [...projectIdeas, ...affectedIdeas];
|
||||
}
|
||||
|
||||
if (options.spec) {
|
||||
console.log("Running specification analysis (--spec)...");
|
||||
console.log(`Running specification analysis (--spec) for ${slug}...`);
|
||||
const specIdeas = engine.runMechanical(["spec"]);
|
||||
const newSpecIdeas = specIdeas.filter(
|
||||
(idea: Idea) => !allIdeas.some((existing: Idea) => existing.title === idea.title)
|
||||
(idea: Idea) => !projectIdeas.some((existing: Idea) => existing.title === idea.title)
|
||||
);
|
||||
allIdeas = [...allIdeas, ...newSpecIdeas];
|
||||
projectIdeas = [...projectIdeas, ...newSpecIdeas];
|
||||
}
|
||||
|
||||
if (options.external) {
|
||||
console.log("Running external signal analysis (--external)...");
|
||||
console.log(`Running external signal analysis (--external) for ${slug}...`);
|
||||
const externalIdeas = engine.runExternal();
|
||||
allIdeas = [...allIdeas, ...externalIdeas];
|
||||
projectIdeas = [...projectIdeas, ...externalIdeas];
|
||||
}
|
||||
|
||||
if (options.crossProject && ciFiles.isMultiProject()) {
|
||||
console.log("Running cross-project pattern mining (--cross-project)...");
|
||||
console.log(`Running cross-project pattern mining (--cross-project) for ${slug}...`);
|
||||
const crossProjectIdeas = engine.runCrossProject();
|
||||
allIdeas = [...allIdeas, ...crossProjectIdeas];
|
||||
projectIdeas = [...projectIdeas, ...crossProjectIdeas];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
allIdeas = allIdeas.filter((idea: Idea) => {
|
||||
if (seen.has(idea.title)) return false;
|
||||
seen.add(idea.title);
|
||||
const uniqueProjectIdeas = projectIdeas.filter((idea: Idea) => {
|
||||
const dedupeKey = allProjects.length > 1 ? `${slug}:${idea.title}` : idea.title;
|
||||
if (seenTitles.has(dedupeKey)) return false;
|
||||
seenTitles.add(dedupeKey);
|
||||
return true;
|
||||
});
|
||||
|
||||
allIdeas.sort((a: Idea, b: Idea) => b.confidence - a.confidence);
|
||||
uniqueProjectIdeas.sort((a: Idea, b: Idea) => b.confidence - a.confidence);
|
||||
allIdeasByProject[slug] = uniqueProjectIdeas;
|
||||
allIdeas.push(...uniqueProjectIdeas);
|
||||
}
|
||||
|
||||
allIdeas.sort((a, b) => b.confidence - a.confidence);
|
||||
|
||||
const currentSlug = allProjects.length === 1 ? allProjects[0] : "all";
|
||||
const engine = new IdeationEngine(projectPath, allProjects.length === 1 ? allProjects[0] : undefined);
|
||||
|
||||
if (options.output === "json") {
|
||||
const result = engine.formatIdeasJson(allIdeas);
|
||||
result.summary.accepted = 0;
|
||||
result.summary.skipped = allIdeas.length;
|
||||
result.project = currentSlug;
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
@@ -1065,6 +1223,17 @@ export function createIdeateCommand(): Command {
|
||||
console.log("No improvement ideas identified for this project.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (allProjects.length > 1) {
|
||||
console.log("| Project | Idea | Category | Confidence | Tier |");
|
||||
console.log("|---------|-------|----------|------------|------|");
|
||||
for (const slug of allProjects) {
|
||||
const projectIdeas = allIdeasByProject[slug] || [];
|
||||
for (const idea of projectIdeas) {
|
||||
console.log(`| ${slug} | ${idea.title} | ${idea.category} | ${idea.confidence.toFixed(2)} | ${idea.tier} |`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const idea of allIdeas) {
|
||||
console.log(`### ${idea.title}`);
|
||||
console.log(`- **Category**: ${idea.category}`);
|
||||
@@ -1076,10 +1245,11 @@ export function createIdeateCommand(): Command {
|
||||
console.log(`- **Actions**: ${idea.actions.join(", ")}`);
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nFound ${allIdeas.length} improvement ${allIdeas.length === 1 ? "idea" : "ideas"}\n`);
|
||||
console.log(`\nFound ${allIdeas.length} improvement ${allIdeas.length === 1 ? "idea" : "ideas"}${allProjects.length > 1 ? ` across ${allProjects.length} projects` : ""}\n`);
|
||||
|
||||
if (allIdeas.length === 0) {
|
||||
console.log("No improvement ideas identified for this project.");
|
||||
@@ -1110,8 +1280,9 @@ export function createIdeateCommand(): Command {
|
||||
|
||||
for (let i = 0; i < allIdeas.length; i++) {
|
||||
const idea = allIdeas[i];
|
||||
const projectLabel = allProjects.length > 1 ? ` [${idea.tier === "cross-project" ? "cross-project" : allProjects[0]}]` : "";
|
||||
console.log(`\n═══ Recommendation ${i + 1} of ${allIdeas.length} ═══\n`);
|
||||
console.log(` Category: ${idea.category.toUpperCase()} | Confidence: ${idea.confidence.toFixed(2)} | Tier: ${idea.tier}`);
|
||||
console.log(` Category: ${idea.category.toUpperCase()} | Confidence: ${idea.confidence.toFixed(2)} | Tier: ${idea.tier}${projectLabel}`);
|
||||
console.log(` Title: ${idea.title}`);
|
||||
console.log(` Rationale: ${idea.rationale}`);
|
||||
if (idea.relatedReq) console.log(` Related Req: ${idea.relatedReq}`);
|
||||
@@ -1160,17 +1331,30 @@ export function createIdeateCommand(): Command {
|
||||
console.log(`Accepted: ${accepted.length} recommendation${accepted.length === 1 ? "" : "s"}`);
|
||||
console.log(`Skipped: ${skipped.length} recommendation${skipped.length === 1 ? "" : "s"}`);
|
||||
|
||||
if (allProjects.length > 1) {
|
||||
console.log(`Projects: ${allProjects.join(", ")}`);
|
||||
}
|
||||
|
||||
if (accepted.length > 0) {
|
||||
console.log("\nAccepted ideas:");
|
||||
for (const idea of accepted) {
|
||||
console.log(` ${idea.id}: ${idea.title} (${idea.category.toUpperCase()})`);
|
||||
}
|
||||
|
||||
const { accepted: savedIdeas, results } = engine.acceptIdeas(accepted);
|
||||
for (const slug of allProjects) {
|
||||
const projectAccepted = accepted.filter((idea) => {
|
||||
return allIdeasByProject[slug]?.some((pi) => pi.id === idea.id);
|
||||
});
|
||||
|
||||
if (projectAccepted.length > 0) {
|
||||
const projEngine = new IdeationEngine(projectPath, slug);
|
||||
const { accepted: savedIdeas, results } = projEngine.acceptIdeas(projectAccepted);
|
||||
const savedCount = results.filter((r) => r.addedToRequirements || r.addedToRoadmap).length;
|
||||
|
||||
if (savedCount > 0) {
|
||||
console.log(`\n${savedCount} idea${savedCount === 1 ? "" : "s"} added to REQUIREMENTS.md and ROADMAP.md.`);
|
||||
console.log(`\n${savedCount} idea${savedCount === 1 ? "" : "s"} for project "${slug}" added to REQUIREMENTS.md and ROADMAP.md.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const kickoffAnswer = await askQuestion("\nWould you like to kick off the run workflow for these ideas? (y/n) > ");
|
||||
@@ -1192,3 +1376,144 @@ export function createIdeateCommand(): Command {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createSessionsCommand(): Command {
|
||||
return new Command("sessions")
|
||||
.description("Manage CIAgent agent sessions")
|
||||
.addCommand(
|
||||
new Command("list")
|
||||
.description("List all sessions")
|
||||
.option("--project <slug>", "Filter by project slug")
|
||||
.action(async (options) => {
|
||||
const projectPath = process.cwd();
|
||||
if (!isCIAgentInitialized(projectPath)) {
|
||||
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const sessionManager = new SessionManager(projectPath, config);
|
||||
const persisted = sessionManager.loadPersistedSessions();
|
||||
const active = sessionManager.listSessions();
|
||||
const allSessions = [...persisted];
|
||||
|
||||
for (const activeSession of active) {
|
||||
if (!allSessions.find((s) => s.id === activeSession.id)) {
|
||||
allSessions.push(activeSession);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.project) {
|
||||
const filtered = allSessions.filter((s) => s.project_slug === options.project);
|
||||
displaySessions(filtered);
|
||||
} else {
|
||||
displaySessions(allSessions);
|
||||
}
|
||||
})
|
||||
)
|
||||
.addCommand(
|
||||
new Command("status")
|
||||
.description("Show status of a specific session")
|
||||
.argument("<session-id>", "Session ID")
|
||||
.action(async (sessionId) => {
|
||||
const projectPath = process.cwd();
|
||||
if (!isCIAgentInitialized(projectPath)) {
|
||||
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const sessionManager = new SessionManager(projectPath, config);
|
||||
|
||||
const persisted = sessionManager.loadPersistedSessions();
|
||||
const sessionInfo = persisted.find((s) => s.id === sessionId);
|
||||
|
||||
if (!sessionInfo) {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session) {
|
||||
console.error(`Session ${sessionId} not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
displaySessionDetail(session.getSessionInfo());
|
||||
return;
|
||||
}
|
||||
|
||||
displaySessionDetail(sessionInfo);
|
||||
})
|
||||
)
|
||||
.addCommand(
|
||||
new Command("cancel")
|
||||
.description("Cancel a running session")
|
||||
.argument("<session-id>", "Session ID")
|
||||
.action(async (sessionId) => {
|
||||
const projectPath = process.cwd();
|
||||
if (!isCIAgentInitialized(projectPath)) {
|
||||
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const sessionManager = new SessionManager(projectPath, config);
|
||||
|
||||
const success = sessionManager.cancelSession(sessionId);
|
||||
if (success) {
|
||||
console.log(`Session ${sessionId} cancelled.`);
|
||||
} else {
|
||||
console.error(`Failed to cancel session ${sessionId}. Session may not be running.`);
|
||||
process.exit(1);
|
||||
}
|
||||
})
|
||||
)
|
||||
.addCommand(
|
||||
new Command("cleanup")
|
||||
.description("Clean up stale sessions")
|
||||
.action(async () => {
|
||||
const projectPath = process.cwd();
|
||||
if (!isCIAgentInitialized(projectPath)) {
|
||||
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const sessionManager = new SessionManager(projectPath, config);
|
||||
const cleaned = sessionManager.cleanupStaleSessions();
|
||||
console.log(`Cleaned up ${cleaned} stale session(s).`);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function displaySessions(sessions: Array<import("../types/session.js").SessionInfo>): void {
|
||||
if (sessions.length === 0) {
|
||||
console.log("No sessions found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\n─── CIAgent Sessions ───\n");
|
||||
console.log("ID Project Phase Stage Status");
|
||||
console.log("-------- ---------------- ----- ---------- ---------");
|
||||
|
||||
for (const s of sessions) {
|
||||
const id = s.id.padEnd(8);
|
||||
const slug = (s.project_slug || "default").padEnd(16);
|
||||
const phase = String(s.phase).padEnd(5);
|
||||
const stage = s.stage.padEnd(10);
|
||||
const statusIcon = s.status === "running" ? "●" : s.status === "completed" ? "✓" : s.status === "failed" ? "✗" : s.status === "paused" ? "⏸" : "○";
|
||||
console.log(`${id} ${slug} ${phase} ${stage} ${statusIcon} ${s.status}`);
|
||||
}
|
||||
|
||||
console.log(`\n${sessions.length} session(s) total.`);
|
||||
}
|
||||
|
||||
function displaySessionDetail(s: import("../types/session.js").SessionInfo): void {
|
||||
console.log("\n─── Session Detail ───\n");
|
||||
console.log(` ID: ${s.id}`);
|
||||
console.log(` Project: ${s.project_slug || "default"}`);
|
||||
console.log(` Phase: ${s.phase}`);
|
||||
console.log(` Stage: ${s.stage}`);
|
||||
console.log(` Status: ${s.status}`);
|
||||
console.log(` Started: ${s.started_at}`);
|
||||
console.log(` Last Updated: ${s.last_updated}`);
|
||||
if (s.error) {
|
||||
console.log(` Error: ${s.error}`);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -18,6 +18,7 @@ import {
|
||||
createShipCommand,
|
||||
createProjectsCommand,
|
||||
createIdeateCommand,
|
||||
createSessionsCommand,
|
||||
} from "./commands.js";
|
||||
|
||||
let activeEscalationProtocol: { dispose(): void } | null = null;
|
||||
@@ -68,6 +69,7 @@ program
|
||||
.addCommand(createRollbackCommand())
|
||||
.addCommand(createShipCommand())
|
||||
.addCommand(createProjectsCommand())
|
||||
.addCommand(createIdeateCommand());
|
||||
.addCommand(createIdeateCommand())
|
||||
.addCommand(createSessionsCommand());
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,284 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as crypto from "node:crypto";
|
||||
import { execSync } from "node:child_process";
|
||||
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||
import { SessionConfig, SessionInfo, SessionStatus, DEFAULT_SESSION_CONFIG } from "../types/session.js";
|
||||
import { PipelineStage } from "../types/pipeline.js";
|
||||
import { AgentContext, AgentResult } from "../agents/base.js";
|
||||
import { loadConfig } from "../core/config.js";
|
||||
import { CIAgentFiles } from "../core/ciagent-files.js";
|
||||
import { GitContext } from "../core/git-context.js";
|
||||
import { CommitBuilder } from "../core/commit-builder.js";
|
||||
import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js";
|
||||
import { PipelineState, createInitialPipelineState } from "../types/pipeline.js";
|
||||
|
||||
export class AgentSession {
|
||||
private id: string;
|
||||
private projectSlug: string;
|
||||
private projectPath: string;
|
||||
private config: CIAgentConfig;
|
||||
private sessionConfig: SessionConfig;
|
||||
private status: SessionStatus;
|
||||
private pipelineState: PipelineState | null;
|
||||
private error: string | undefined;
|
||||
private startedAt: string;
|
||||
private lastUpdated: string;
|
||||
private lockAcquired: boolean;
|
||||
|
||||
constructor(projectPath: string, projectSlug: string, config?: CIAgentConfig) {
|
||||
this.id = crypto.randomUUID().slice(0, 8);
|
||||
this.projectSlug = projectSlug;
|
||||
this.projectPath = projectPath;
|
||||
this.config = config || loadConfig(projectPath);
|
||||
this.sessionConfig = this.config.sessions || DEFAULT_SESSION_CONFIG;
|
||||
this.status = "pending";
|
||||
this.pipelineState = null;
|
||||
this.error = undefined;
|
||||
this.startedAt = new Date().toISOString();
|
||||
this.lastUpdated = this.startedAt;
|
||||
this.lockAcquired = false;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
getProjectSlug(): string {
|
||||
return this.projectSlug;
|
||||
}
|
||||
|
||||
getStatus(): SessionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getSessionInfo(): SessionInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
project_slug: this.projectSlug,
|
||||
project_path: this.projectPath,
|
||||
phase: this.pipelineState?.current_phase ?? 0,
|
||||
stage: this.pipelineState?.current_stage ?? "specify",
|
||||
status: this.status,
|
||||
started_at: this.startedAt,
|
||||
last_updated: this.lastUpdated,
|
||||
error: this.error,
|
||||
};
|
||||
}
|
||||
|
||||
acquireLock(): boolean {
|
||||
const lockPath = this.getLockPath();
|
||||
ensureDir(path.dirname(lockPath));
|
||||
|
||||
if (fileExists(lockPath)) {
|
||||
const lockData = JSON.parse(readFile(lockPath) || "{}") as { sessionId: string; timestamp: string; projectSlug: string };
|
||||
if (lockData.sessionId && lockData.sessionId !== this.id) {
|
||||
const lockAge = Date.now() - new Date(lockData.timestamp).getTime();
|
||||
if (lockAge < (this.sessionConfig.session_timeout_ms || 3600000)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFile(lockPath, JSON.stringify({
|
||||
sessionId: this.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
projectSlug: this.projectSlug,
|
||||
}));
|
||||
this.lockAcquired = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
releaseLock(): void {
|
||||
if (!this.lockAcquired) return;
|
||||
const lockPath = this.getLockPath();
|
||||
try {
|
||||
if (fileExists(lockPath)) {
|
||||
const lockData = JSON.parse(readFile(lockPath) || "{}") as { sessionId: string };
|
||||
if (lockData.sessionId === this.id) {
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
this.lockAcquired = false;
|
||||
}
|
||||
|
||||
async run(context: AgentContext): Promise<AgentResult> {
|
||||
if (this.status === "running") {
|
||||
return {
|
||||
success: false,
|
||||
output: `Session ${this.id} is already running`,
|
||||
artifacts_created: 0,
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: 0,
|
||||
error: "Session already running",
|
||||
};
|
||||
}
|
||||
|
||||
const locked = this.acquireLock();
|
||||
if (!locked) {
|
||||
return {
|
||||
success: false,
|
||||
output: `Failed to acquire lock for session ${this.id}`,
|
||||
artifacts_created: 0,
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: 0,
|
||||
error: "Lock acquisition failed — another session is active for this project",
|
||||
};
|
||||
}
|
||||
|
||||
this.status = "running";
|
||||
this.lastUpdated = new Date().toISOString();
|
||||
this.pipelineState = createInitialPipelineState(this.projectPath);
|
||||
|
||||
const gitContext = new GitContext(this.projectPath, this.projectSlug || undefined);
|
||||
const projectState = gitContext.reconstructState();
|
||||
|
||||
if (projectState.currentPhase > 0) {
|
||||
this.pipelineState.current_phase = projectState.currentPhase;
|
||||
this.pipelineState.current_stage = projectState.currentStage;
|
||||
}
|
||||
|
||||
this.persistState();
|
||||
|
||||
let result: AgentResult;
|
||||
try {
|
||||
const { OrchestratorAgent } = await import("../agents/orchestrator.js");
|
||||
const orchestrator = new OrchestratorAgent(this.config);
|
||||
result = await orchestrator.runForProject(this.projectSlug, context);
|
||||
|
||||
this.status = result.success ? "completed" : "failed";
|
||||
this.error = result.error;
|
||||
} catch (err) {
|
||||
this.status = "failed";
|
||||
this.error = err instanceof Error ? err.message : String(err);
|
||||
result = {
|
||||
success: false,
|
||||
output: `Session ${this.id} failed: ${this.error}`,
|
||||
artifacts_created: 0,
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: 0,
|
||||
error: this.error,
|
||||
};
|
||||
} finally {
|
||||
this.lastUpdated = new Date().toISOString();
|
||||
this.releaseLock();
|
||||
this.persistState();
|
||||
}
|
||||
|
||||
if (this.config.git?.auto_commit && result.success) {
|
||||
const ciFiles = new CIAgentFiles(this.projectPath, this.projectSlug || undefined);
|
||||
try {
|
||||
const sessionCommit = CommitBuilder.buildTaskCommit({
|
||||
type: "chore",
|
||||
phase: this.pipelineState?.current_phase ?? 0,
|
||||
milestone: "session",
|
||||
project: this.projectSlug || undefined,
|
||||
plan: "session",
|
||||
task: this.id,
|
||||
subject: `session ${this.id} ${this.status}`,
|
||||
status: "complete" as PipelineStage,
|
||||
});
|
||||
|
||||
if (gitContext.isGitRepo()) {
|
||||
execSync(`git add -A && git commit -m "${sessionCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||
cwd: this.projectPath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
output: `[session:${this.id}] ${result.output}`,
|
||||
};
|
||||
}
|
||||
|
||||
cancel(): boolean {
|
||||
if (this.status !== "running") return false;
|
||||
this.status = "cancelled";
|
||||
this.lastUpdated = new Date().toISOString();
|
||||
this.releaseLock();
|
||||
this.persistState();
|
||||
return true;
|
||||
}
|
||||
|
||||
pause(): boolean {
|
||||
if (this.status !== "running") return false;
|
||||
this.status = "paused";
|
||||
this.lastUpdated = new Date().toISOString();
|
||||
this.persistState();
|
||||
return true;
|
||||
}
|
||||
|
||||
resume(): boolean {
|
||||
if (this.status !== "paused") return false;
|
||||
this.status = "running";
|
||||
this.lastUpdated = new Date().toISOString();
|
||||
return true;
|
||||
}
|
||||
|
||||
private getLockPath(): string {
|
||||
const ciDir = path.join(this.projectPath, ".ciagent");
|
||||
const slugDir = this.projectSlug ? path.join(ciDir, this.projectSlug) : ciDir;
|
||||
return path.join(slugDir, ".session-lock");
|
||||
}
|
||||
|
||||
private getStatePath(): string {
|
||||
const ciDir = path.join(this.projectPath, ".ciagent");
|
||||
const slugDir = this.projectSlug ? path.join(ciDir, this.projectSlug) : ciDir;
|
||||
return path.join(slugDir, `.session-${this.id}.json`);
|
||||
}
|
||||
|
||||
persistState(): void {
|
||||
const statePath = this.getStatePath();
|
||||
const stateData = {
|
||||
id: this.id,
|
||||
projectSlug: this.projectSlug,
|
||||
projectPath: this.projectPath,
|
||||
status: this.status,
|
||||
startedAt: this.startedAt,
|
||||
lastUpdated: this.lastUpdated,
|
||||
error: this.error,
|
||||
pipelineState: this.pipelineState,
|
||||
};
|
||||
|
||||
ensureDir(path.dirname(statePath));
|
||||
writeFile(statePath, JSON.stringify(stateData, null, 2));
|
||||
}
|
||||
|
||||
static loadState(projectPath: string, sessionId: string, projectSlug?: string): AgentSession | null {
|
||||
const ciDir = path.join(projectPath, ".ciagent");
|
||||
const slugDir = projectSlug ? path.join(ciDir, projectSlug) : ciDir;
|
||||
const statePath = path.join(slugDir, `.session-${sessionId}.json`);
|
||||
|
||||
if (!fileExists(statePath)) return null;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(readFile(statePath) || "{}") as {
|
||||
id: string;
|
||||
projectSlug: string;
|
||||
projectPath: string;
|
||||
status: SessionStatus;
|
||||
startedAt: string;
|
||||
lastUpdated: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const session = new AgentSession(data.projectPath, data.projectSlug);
|
||||
(session as any).id = data.id;
|
||||
(session as any).status = data.status;
|
||||
(session as any).startedAt = data.startedAt;
|
||||
(session as any).lastUpdated = data.lastUpdated;
|
||||
(session as any).error = data.error;
|
||||
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,7 +329,7 @@ describe("CIAgentFiles", () => {
|
||||
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");
|
||||
ciFiles.ensureProjectDir();
|
||||
fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
|
||||
@@ -337,13 +337,13 @@ describe("CIAgentFiles", () => {
|
||||
active_project: "schema-proj",
|
||||
}));
|
||||
const roadmap: RoadmapMd = {
|
||||
overview: "schema-breaking",
|
||||
overview: "major",
|
||||
phases: [
|
||||
{ number: 1, name: "refactor-core", description: "Refactor core", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
|
||||
],
|
||||
};
|
||||
ciFiles.writeRoadmapMd(roadmap);
|
||||
expect(ciFiles.getMilestoneType()).toBe("schema-breaking");
|
||||
expect(ciFiles.getMilestoneType()).toBe("major");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -486,7 +486,7 @@ export class CIAgentFiles {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSchemaBreak) return "schema-breaking";
|
||||
if (hasSchemaBreak) return "major";
|
||||
if (hasFeature) return "feature";
|
||||
return "nfr";
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ export class CommitBuilder {
|
||||
lines.push(`milestone: ${ci.milestone}`);
|
||||
|
||||
if (ci.project) lines.push(`project: ${ci.project}`);
|
||||
if (ci.session) lines.push(`session: ${ci.session}`);
|
||||
if (ci.plan) lines.push(`plan: ${ci.plan}`);
|
||||
if (ci.task) lines.push(`task: ${ci.task}`);
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ export function parseCIAgentBlock(yaml: string): CIAgentMetadata | null {
|
||||
const projectMatch = yaml.match(/^project:\s*(.+)$/m);
|
||||
if (projectMatch) result.project = projectMatch[1].trim();
|
||||
|
||||
const sessionMatch = yaml.match(/^session:\s*(.+)$/m);
|
||||
if (sessionMatch) result.session = sessionMatch[1].trim();
|
||||
|
||||
result.decisions = parseDecisionsFromYaml(yaml);
|
||||
result.escalations = parseEscalationsFromYaml(yaml);
|
||||
result.requirements = parseRequirementsFromYaml(yaml);
|
||||
|
||||
@@ -192,11 +192,11 @@ describe("GitBranch", () => {
|
||||
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" });
|
||||
|
||||
const gitBranch = new GitBranch(repoDir);
|
||||
const tag = gitBranch.computeMilestoneTag("schema-breaking");
|
||||
const tag = gitBranch.computeMilestoneTag("major");
|
||||
expect(tag).toBe("v1.0.0");
|
||||
});
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ export class GitBranch {
|
||||
}
|
||||
}
|
||||
|
||||
if (milestoneType === "schema-breaking") {
|
||||
if (milestoneType === "major") {
|
||||
return `v${major + 1}.0.0`;
|
||||
}
|
||||
|
||||
|
||||
@@ -307,7 +307,7 @@ status: execute
|
||||
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
|
||||
|
||||
---ci---
|
||||
@@ -317,7 +317,7 @@ status: execute
|
||||
---/ci---`);
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
expect(ctx.getMilestoneType()).toBe("schema-breaking");
|
||||
expect(ctx.getMilestoneType()).toBe("major");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -333,7 +333,7 @@ export class GitContext {
|
||||
if (!commit.ci) continue;
|
||||
hasAnyCiCommit = true;
|
||||
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";
|
||||
return "nfr";
|
||||
|
||||
@@ -327,4 +327,60 @@ describe("IdeationEngine", () => {
|
||||
expect(titles.some((t) => t.includes("coverage"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Phase 3: External signals and cascade impact", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
resetIdeaCounter();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-p3-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("runAffected detects cascade from architecture.md", () => {
|
||||
const ciagentDir = path.join(tempDir, ".ciagent");
|
||||
fs.mkdirSync(ciagentDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ciagentDir, "config.json"),
|
||||
JSON.stringify({ projects: [], active_project: "default" })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ciagentDir, "ARCHITECTURE.md"),
|
||||
"# Architecture\n\n## Overview\n\nTest.\n\n## Components\n\n### CLI\n\n- **Description**: Command line interface\n- **Boundaries**: User-facing only\n- **Depends on**: Core\n\n### Core\n\n- **Description**: Core engine\n- **Boundaries**: Internal only\n- **Depends on**: None\n\n## Data Flow\n\nSimple.\n\n## Build Order\n\n1. CLI\n2. Core\n"
|
||||
);
|
||||
|
||||
const engine = new IdeationEngine(tempDir);
|
||||
const ideas = engine.runAffected();
|
||||
expect(Array.isArray(ideas)).toBe(true);
|
||||
});
|
||||
|
||||
it("runExternal handles missing npm gracefully", () => {
|
||||
const ciagentDir = path.join(tempDir, ".ciagent");
|
||||
fs.mkdirSync(ciagentDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ciagentDir, "config.json"),
|
||||
JSON.stringify({ projects: [], active_project: "default" })
|
||||
);
|
||||
|
||||
const engine = new IdeationEngine(tempDir);
|
||||
const ideas = engine.runExternal();
|
||||
expect(Array.isArray(ideas)).toBe(true);
|
||||
});
|
||||
|
||||
it("runCrossProject returns empty when only one project", () => {
|
||||
const ciagentDir = path.join(tempDir, ".ciagent");
|
||||
fs.mkdirSync(ciagentDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ciagentDir, "config.json"),
|
||||
JSON.stringify({ projects: [{ slug: "default", name: "Default Project", default: true }], active_project: "default" })
|
||||
);
|
||||
|
||||
const engine = new IdeationEngine(tempDir, "default");
|
||||
const ideas = engine.runCrossProject();
|
||||
expect(ideas).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+4
-1
@@ -9,6 +9,9 @@ export { GitBranch } from "./git-branch.js";
|
||||
export { CommitBuilder } from "./commit-builder.js";
|
||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js";
|
||||
export { GiteaClient, generateReleaseNotes } from "./gitea.js";
|
||||
export type { GiteaReleaseConfig, GiteaRelease } from "./gitea.js";
|
||||
export { AgentSession } from "./agent-session.js";
|
||||
export { SessionManager } from "./session-manager.js";
|
||||
export { PersonaLoader } from "./persona-loader.js";
|
||||
export { TaskDecomposer } from "./task-decomposer.js";
|
||||
export type { CIAgentConfig } from "../types/config.js";
|
||||
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||
@@ -3,7 +3,11 @@ import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { CIAgentFiles, ProjectEntry } from "../core/ciagent-files.js";
|
||||
import { initCIAgent, loadConfig, saveConfig } from "../core/config.js";
|
||||
import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||
import { CommitBuilder } from "../core/commit-builder.js";
|
||||
import { IdeationEngine, resetIdeaCounter } from "../core/ideation.js";
|
||||
import { extractCIAgentBlock, parseCIAgentBlock } from "../core/commit-parser.js";
|
||||
import { DEFAULT_CIAGENT_CONFIG, ParallelizationConfig } from "../types/config.js";
|
||||
import { AgentContext } from "../agents/base.js";
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-multiproject-test-"));
|
||||
@@ -13,6 +17,121 @@ function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function initMultiProjectWithFiles(dir: string, projectList: Array<{ slug: string; name: string }>): void {
|
||||
const ciDir = path.join(dir, ".ciagent");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
|
||||
const projects = projectList.map((p, i) => ({
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
default: i === 0,
|
||||
}));
|
||||
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
projects,
|
||||
active_project: projectList[0].slug,
|
||||
active_projects: projectList.map((p) => p.slug),
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify(config, null, 2));
|
||||
|
||||
for (const project of projectList) {
|
||||
const projectDir = path.join(ciDir, project.slug);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "PROJECT.md"), [
|
||||
`# ${project.name}`,
|
||||
"",
|
||||
"## What This Is",
|
||||
"",
|
||||
`A ${project.name} project for testing`,
|
||||
"",
|
||||
"## Requirements",
|
||||
"",
|
||||
"### Active",
|
||||
"",
|
||||
"- Build the project",
|
||||
"",
|
||||
"### Validated",
|
||||
"",
|
||||
"### Out of Scope",
|
||||
"",
|
||||
"## Context",
|
||||
"",
|
||||
"Testing",
|
||||
"",
|
||||
"## Constraints",
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"",
|
||||
"| Decision | Rationale | Outcome |",
|
||||
"|----------|-----------|---------|",
|
||||
].join("\n"));
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "REQUIREMENTS.md"), [
|
||||
"# Requirements",
|
||||
"",
|
||||
`| REQ-ID | Requirement | Priority | Phase | Status |`,
|
||||
`|--------|-------------|----------|-------|--------|`,
|
||||
`| ${project.slug.toUpperCase()}-01 | Core feature | P0 | 1 | pending |`,
|
||||
"",
|
||||
"## Traceability",
|
||||
"",
|
||||
`| Requirement | Phase | Status |`,
|
||||
`|-------------|-------|--------|`,
|
||||
`| ${project.slug.toUpperCase()}-01 | 1 | pending |`,
|
||||
].join("\n"));
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "ROADMAP.md"), [
|
||||
"# Roadmap",
|
||||
"",
|
||||
"## Overview",
|
||||
"",
|
||||
`${project.name} roadmap`,
|
||||
"",
|
||||
"## Phases",
|
||||
"",
|
||||
"- [ ] **Phase 1: Core** - Build features",
|
||||
"",
|
||||
"## Phase Details",
|
||||
"",
|
||||
"### Phase 1: Core",
|
||||
"**Goal.**: Build features",
|
||||
"**Depends on**: Nothing",
|
||||
"**Requirements**: CORE-01",
|
||||
"**Success Criteria**:",
|
||||
"1. Features work",
|
||||
"**Status**: not_started",
|
||||
"",
|
||||
].join("\n"));
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "ARCHITECTURE.md"), [
|
||||
"# Architecture",
|
||||
"",
|
||||
"## Overview",
|
||||
"",
|
||||
`${project.name} testing architecture`,
|
||||
"",
|
||||
"## Components",
|
||||
"",
|
||||
`### ${project.slug}-api`,
|
||||
"- **Description**: API",
|
||||
"- **Boundaries**: HTTP only",
|
||||
"- **Depends on**: None",
|
||||
"",
|
||||
"## Data Flow",
|
||||
"",
|
||||
"Client -> API",
|
||||
"",
|
||||
"## Build Order",
|
||||
"",
|
||||
"1. API",
|
||||
"",
|
||||
].join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
describe("Multi-project CIAgentFiles operations", () => {
|
||||
let dir: string;
|
||||
|
||||
@@ -168,4 +287,298 @@ describe("Multi-project CIAgentFiles operations", () => {
|
||||
expect(projectMd!.name).toBe("Task API");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentContext project_slug field", () => {
|
||||
it("accepts optional project_slug", () => {
|
||||
const context: AgentContext = {
|
||||
project_path: "/tmp/test",
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "test spec",
|
||||
config_path: "/tmp/test/.ciagent/config.json",
|
||||
project_slug: "my-project",
|
||||
};
|
||||
|
||||
expect(context.project_slug).toBe("my-project");
|
||||
});
|
||||
|
||||
it("project_slug is optional", () => {
|
||||
const context: AgentContext = {
|
||||
project_path: "/tmp/test",
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "test spec",
|
||||
config_path: "/tmp/test/.ciagent/config.json",
|
||||
};
|
||||
|
||||
expect(context.project_slug).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MULTI-03: Parallel project execution", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
describe("OrchestratorAgent module has multi-project methods", () => {
|
||||
it("exports OrchestratorAgent class with runForProject and runForAllProjects", () => {
|
||||
expect(typeof DEFAULT_CIAGENT_CONFIG.parallelization.max_concurrent_projects).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("active_projects config field", () => {
|
||||
it("stores active_projects array in config", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
{ slug: "auth-svc", name: "Auth Service" },
|
||||
]);
|
||||
|
||||
const config = loadConfig(dir);
|
||||
expect(config.active_projects).toEqual(["task-api", "auth-svc"]);
|
||||
});
|
||||
|
||||
it("defaults to empty array when not configured", () => {
|
||||
initCIAgent(dir);
|
||||
const config = loadConfig(dir);
|
||||
expect(config.active_projects).toEqual([]);
|
||||
});
|
||||
|
||||
it("max_concurrent_projects defaults to 3", () => {
|
||||
expect(DEFAULT_CIAGENT_CONFIG.parallelization.max_concurrent_projects).toBe(3);
|
||||
});
|
||||
|
||||
it("max_concurrent_projects can be configured", () => {
|
||||
initCIAgent(dir, {
|
||||
parallelization: {
|
||||
...DEFAULT_CIAGENT_CONFIG.parallelization,
|
||||
max_concurrent_projects: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const config = loadConfig(dir);
|
||||
expect(config.parallelization.max_concurrent_projects).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MULTI-05: ideate --project all", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
resetIdeaCounter();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
describe("IdeationEngine with project slug for multi-project", () => {
|
||||
it("runs mechanical ideation for different project slugs", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
]);
|
||||
|
||||
resetIdeaCounter();
|
||||
const engine = new IdeationEngine(dir, "task-api");
|
||||
const ideas = engine.runMechanical();
|
||||
expect(Array.isArray(ideas)).toBe(true);
|
||||
});
|
||||
|
||||
it("runs ideation across multiple projects and collects results", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
{ slug: "auth-svc", name: "Auth Service" },
|
||||
]);
|
||||
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
const projects = ciFiles.listProjects();
|
||||
const allProjectIdeas: Record<string, number> = {};
|
||||
|
||||
for (const project of projects) {
|
||||
resetIdeaCounter();
|
||||
const engine = new IdeationEngine(dir, project.slug);
|
||||
const ideas = engine.runMechanical();
|
||||
allProjectIdeas[project.slug] = ideas.length;
|
||||
}
|
||||
|
||||
expect(Object.keys(allProjectIdeas)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("deduplicates ideas across projects with project-prefixed keys", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
{ slug: "auth-svc", name: "Auth Service" },
|
||||
]);
|
||||
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
const projects = ciFiles.listProjects();
|
||||
const allTitles: string[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
for (const project of projects) {
|
||||
resetIdeaCounter();
|
||||
const engine = new IdeationEngine(dir, project.slug);
|
||||
const ideas = engine.runMechanical();
|
||||
|
||||
for (const idea of ideas) {
|
||||
const dedupeKey = `${project.slug}:${idea.title}`;
|
||||
if (!seenKeys.has(dedupeKey)) {
|
||||
seenKeys.add(dedupeKey);
|
||||
allTitles.push(idea.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(seenKeys.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("formats JSON output with project field for each project", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
]);
|
||||
|
||||
resetIdeaCounter();
|
||||
const engine = new IdeationEngine(dir, "task-api");
|
||||
const ideas = engine.runMechanical();
|
||||
const result = engine.formatIdeasJson(ideas);
|
||||
expect(result.project).toBe("task-api");
|
||||
});
|
||||
|
||||
it("runs cross-project analysis on multi-project setup", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
{ slug: "auth-svc", name: "Auth Service" },
|
||||
]);
|
||||
|
||||
resetIdeaCounter();
|
||||
const engine = new IdeationEngine(dir, "task-api");
|
||||
const crossIdeas = engine.runCrossProject();
|
||||
expect(Array.isArray(crossIdeas)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MULTI-07: ---ci--- project field in commits", () => {
|
||||
describe("CIAgentMetadata with project", () => {
|
||||
it("includes project field in ci block when set", () => {
|
||||
const ci = {
|
||||
phase: 5,
|
||||
milestone: "v0.10",
|
||||
project: "ci",
|
||||
status: "execute" as const,
|
||||
};
|
||||
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).toContain("project: ci");
|
||||
});
|
||||
|
||||
it("omits project field when not set", () => {
|
||||
const ci = {
|
||||
phase: 5,
|
||||
milestone: "v0.10",
|
||||
status: "execute" as const,
|
||||
};
|
||||
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).not.toContain("project:");
|
||||
});
|
||||
|
||||
it("commits with different project slugs include the correct project", () => {
|
||||
const projects = ["task-api", "auth-svc", "notification-svc"];
|
||||
for (const slug of projects) {
|
||||
const ci = {
|
||||
phase: 1,
|
||||
milestone: "v0.10",
|
||||
project: slug,
|
||||
status: "plan" as const,
|
||||
};
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).toContain(`project: ${slug}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTaskCommit with project", () => {
|
||||
it("includes project prefix in scope and ci block", () => {
|
||||
const msg = CommitBuilder.buildTaskCommit({
|
||||
type: "feat",
|
||||
phase: 5,
|
||||
milestone: "v0.10",
|
||||
project: "ci",
|
||||
plan: "01-multi-project",
|
||||
task: "01-config-array",
|
||||
subject: "parallel project execution config",
|
||||
status: "execute",
|
||||
});
|
||||
|
||||
expect(msg).toContain("feat(ci/");
|
||||
expect(msg).toContain("project: ci");
|
||||
expect(msg).toContain("---ci---");
|
||||
});
|
||||
|
||||
it("builds commit without project when project is undefined", () => {
|
||||
const msg = CommitBuilder.buildTaskCommit({
|
||||
type: "feat",
|
||||
phase: 5,
|
||||
milestone: "v0.10",
|
||||
project: undefined,
|
||||
plan: "01-multi-project",
|
||||
task: "01-config-array",
|
||||
subject: "parallel project execution config",
|
||||
status: "execute",
|
||||
});
|
||||
|
||||
expect(msg).not.toContain("project:");
|
||||
expect(msg).toContain("feat(P05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInitCommit with project", () => {
|
||||
it("includes project in ci block", () => {
|
||||
const msg = CommitBuilder.buildInitCommit({
|
||||
projectName: "CIAgent",
|
||||
phaseCount: 6,
|
||||
milestone: "v0.10",
|
||||
project: "ci",
|
||||
specification: "Multi-project ideation support",
|
||||
requirements: ["MULTI-03", "MULTI-05", "MULTI-07"],
|
||||
});
|
||||
|
||||
expect(msg).toContain("project: ci");
|
||||
expect(msg).toContain("---ci---");
|
||||
expect(msg).toContain("phase: 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Round-trip parsing with project field", () => {
|
||||
it("parses commit message with project scope and ci block", () => {
|
||||
const msg = CommitBuilder.buildTaskCommit({
|
||||
type: "feat",
|
||||
phase: 5,
|
||||
milestone: "v0.10",
|
||||
project: "ci",
|
||||
plan: "01-multi",
|
||||
task: "01-config",
|
||||
subject: "parallel project execution",
|
||||
status: "execute",
|
||||
});
|
||||
|
||||
const extracted = extractCIAgentBlock(msg);
|
||||
expect(extracted).not.toBeNull();
|
||||
|
||||
const parsed = parseCIAgentBlock(extracted!);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.project).toBe("ci");
|
||||
expect(parsed!.phase).toBe(5);
|
||||
expect(parsed!.milestone).toBe("v0.10");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { ExecutePersonaConfig, PersonaDomain, DEFAULT_PERSONAS, TerritoryEnforcement } from "../types/persona.js";
|
||||
import { CIAgentConfig } from "../types/config.js";
|
||||
|
||||
export interface PersonaDefinition {
|
||||
name: string;
|
||||
domain: PersonaDomain;
|
||||
frameworks: string[];
|
||||
constraints: string[];
|
||||
territory: string[];
|
||||
description: string;
|
||||
systemPromptAdditions: string;
|
||||
}
|
||||
|
||||
const PERSONA_SEARCH_PATHS = [
|
||||
".config/opencode/agents",
|
||||
"opencode/agents",
|
||||
];
|
||||
|
||||
const PERSONA_FILE_PATTERN = /^ci-(.+)\.md$/;
|
||||
|
||||
export class PersonaLoader {
|
||||
private projectPath: string;
|
||||
private config: CIAgentConfig;
|
||||
private cachedPersonas: Map<string, PersonaDefinition> = new Map();
|
||||
private loaded: boolean = false;
|
||||
|
||||
constructor(projectPath: string, config: CIAgentConfig) {
|
||||
this.projectPath = projectPath;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
loadPersonas(): PersonaDefinition[] {
|
||||
if (this.loaded) {
|
||||
return Array.from(this.cachedPersonas.values());
|
||||
}
|
||||
|
||||
const configPersonas = this.config.personas?.personas || DEFAULT_PERSONAS;
|
||||
const configEnabled = this.config.personas?.enabled ?? true;
|
||||
|
||||
if (!configEnabled) {
|
||||
this.loaded = true;
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const configPersona of configPersonas) {
|
||||
const filePersona = this.loadPersonaFromFile(configPersona.name);
|
||||
if (filePersona) {
|
||||
const merged: PersonaDefinition = {
|
||||
name: configPersona.name,
|
||||
domain: configPersona.domain,
|
||||
frameworks: filePersona.frameworks.length > 0 ? filePersona.frameworks : configPersona.frameworks,
|
||||
constraints: filePersona.constraints.length > 0 ? filePersona.constraints : configPersona.constraints,
|
||||
territory: filePersona.territory.length > 0 ? filePersona.territory : configPersona.territory,
|
||||
description: filePersona.description,
|
||||
systemPromptAdditions: filePersona.systemPromptAdditions,
|
||||
};
|
||||
this.cachedPersonas.set(configPersona.name, merged);
|
||||
} else {
|
||||
const definition: PersonaDefinition = {
|
||||
name: configPersona.name,
|
||||
domain: configPersona.domain,
|
||||
frameworks: configPersona.frameworks,
|
||||
constraints: configPersona.constraints,
|
||||
territory: configPersona.territory,
|
||||
description: `${configPersona.name} persona (domain: ${configPersona.domain})`,
|
||||
systemPromptAdditions: this.buildDefaultPromptAdditions(configPersona),
|
||||
};
|
||||
this.cachedPersonas.set(configPersona.name, definition);
|
||||
}
|
||||
}
|
||||
|
||||
this.loaded = true;
|
||||
return Array.from(this.cachedPersonas.values());
|
||||
}
|
||||
|
||||
getPersona(name: string): PersonaDefinition | undefined {
|
||||
if (!this.loaded) this.loadPersonas();
|
||||
return this.cachedPersonas.get(name);
|
||||
}
|
||||
|
||||
getPersonaForDomain(domain: PersonaDomain): PersonaDefinition | undefined {
|
||||
if (!this.loaded) this.loadPersonas();
|
||||
for (const persona of this.cachedPersonas.values()) {
|
||||
if (persona.domain === domain) return persona;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getLeadDeveloper(): PersonaDefinition {
|
||||
return this.getPersona("lead-developer") || {
|
||||
name: "lead-developer",
|
||||
domain: "coordination",
|
||||
frameworks: [],
|
||||
constraints: ["pragmatic", "battle-tested defaults"],
|
||||
territory: [],
|
||||
description: "Lead developer — coordinates task decomposition and resolves conflicts",
|
||||
systemPromptAdditions: "",
|
||||
};
|
||||
}
|
||||
|
||||
getEngineerPersonas(): PersonaDefinition[] {
|
||||
if (!this.loaded) this.loadPersonas();
|
||||
return Array.from(this.cachedPersonas.values()).filter(
|
||||
(p) => p.domain !== "coordination"
|
||||
);
|
||||
}
|
||||
|
||||
getTerritoryEnforcement(): TerritoryEnforcement {
|
||||
return this.config.personas?.territory_enforcement || "warn";
|
||||
}
|
||||
|
||||
private loadPersonaFromFile(name: string): PersonaDefinition | null {
|
||||
const filename = `ci-${name}.md`;
|
||||
|
||||
for (const searchPath of PERSONA_SEARCH_PATHS) {
|
||||
const filePath = path.join(this.projectPath, searchPath, filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
return this.parsePersonaMd(name, content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private parsePersonaMd(name: string, content: string): PersonaDefinition {
|
||||
const frontmatter = this.parseFrontmatter(content);
|
||||
const body = this.stripFrontmatter(content);
|
||||
|
||||
return {
|
||||
name: (frontmatter.name as string) || name,
|
||||
domain: (frontmatter.domain as PersonaDomain) || this.inferDomainFromName(name),
|
||||
frameworks: (frontmatter.frameworks as string[]) || [],
|
||||
constraints: (frontmatter.constraints as string[]) || [],
|
||||
territory: (frontmatter.territory as string[]) || [],
|
||||
description: (frontmatter.description as string) || body.slice(0, 200),
|
||||
systemPromptAdditions: body,
|
||||
};
|
||||
}
|
||||
|
||||
private parseFrontmatter(content: string): Record<string, unknown> {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return {};
|
||||
|
||||
const yaml = match[1];
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
const lines = yaml.split("\n");
|
||||
let currentKey = "";
|
||||
let inArray = false;
|
||||
let arrayItems: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const arrMatch = line.match(/^(\w+):\s*$/);
|
||||
if (arrMatch) {
|
||||
if (inArray && currentKey) {
|
||||
result[currentKey] = arrayItems;
|
||||
}
|
||||
currentKey = arrMatch[1];
|
||||
inArray = true;
|
||||
arrayItems = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/);
|
||||
if (itemMatch && inArray) {
|
||||
arrayItems.push(itemMatch[1].trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
const kvMatch = line.match(/^(\w+):\s*(.+)$/);
|
||||
if (kvMatch) {
|
||||
if (inArray && currentKey) {
|
||||
result[currentKey] = arrayItems;
|
||||
inArray = false;
|
||||
}
|
||||
currentKey = kvMatch[1];
|
||||
result[currentKey] = kvMatch[2].trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (inArray && currentKey) {
|
||||
result[currentKey] = arrayItems;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private stripFrontmatter(content: string): string {
|
||||
return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
|
||||
}
|
||||
|
||||
private inferDomainFromName(name: string): PersonaDomain {
|
||||
if (name.includes("data") || name.includes("db") || name.includes("schema")) return "data";
|
||||
if (name.includes("backend") || name.includes("api") || name.includes("server")) return "backend";
|
||||
if (name.includes("frontend") || name.includes("ui") || name.includes("client")) return "frontend";
|
||||
return "coordination";
|
||||
}
|
||||
|
||||
private buildDefaultPromptAdditions(config: ExecutePersonaConfig): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`You are a ${config.name} persona in the CIAgent execution pipeline.`);
|
||||
parts.push(`Domain: ${config.domain}.`);
|
||||
|
||||
if (config.frameworks.length > 0) {
|
||||
parts.push(`Preferred frameworks: ${config.frameworks.join(", ")}.`);
|
||||
}
|
||||
|
||||
if (config.constraints.length > 0) {
|
||||
parts.push(`Design constraints: ${config.constraints.join(", ")}.`);
|
||||
}
|
||||
|
||||
if (config.territory.length > 0) {
|
||||
parts.push(`You own the following file patterns: ${config.territory.join(", ")}.`);
|
||||
parts.push(`Do not modify files outside your territory without explicit lead developer approval.`);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import {
|
||||
ExecutePersonaConfig,
|
||||
PersonaDomain,
|
||||
TerritoryConflict,
|
||||
DecomposedTask,
|
||||
DecomposedPlan,
|
||||
DEFAULT_PERSONAS,
|
||||
matchFileToPersona,
|
||||
globMatch,
|
||||
detectConflicts,
|
||||
} from "../types/persona.js";
|
||||
import { TaskDecomposer } from "../core/task-decomposer.js";
|
||||
import { PersonaLoader } from "../core/persona-loader.js";
|
||||
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||
import { initCIAgent } from "../core/config.js";
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-persona-test-"));
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const samplePlan = `# Phase 1 Plan — Core API
|
||||
|
||||
## Phase Goal
|
||||
Build core API routes and database schema.
|
||||
|
||||
### Wave 1 (foundational)
|
||||
|
||||
#### Task 1.1: Create user schema
|
||||
|
||||
| **ID** | P1-T1 |
|
||||
| **REQs** | DATA-01 |
|
||||
| **Description** | Create the users table schema with Drizzle ORM |
|
||||
| **Files to create** | \`src/db/schema/users.ts\`, \`src/db/migrations/001_create_users.sql\` |
|
||||
|
||||
#### Task 1.2: Create auth routes
|
||||
|
||||
| **ID** | P1-T2 |
|
||||
| **REQs** | API-01 |
|
||||
| **Description** | Create /api/auth/login and /api/auth/register routes |
|
||||
| **Files to create** | \`src/api/routes/auth.ts\`, \`src/api/middleware/auth.ts\` |
|
||||
|
||||
#### Task 1.3: Create login page
|
||||
|
||||
| **ID** | P1-T3 |
|
||||
| **REQs** | UI-01 |
|
||||
| **Description** | Create React login page component |
|
||||
| **Files to create** | \`src/components/LoginForm.tsx\`, \`src/pages/login.tsx\` |
|
||||
|
||||
### Wave 2
|
||||
|
||||
#### Task 1.4: Create data repository
|
||||
|
||||
| **ID** | P1-T4 |
|
||||
| **REQs** | DATA-02 |
|
||||
| **Description** | Create UserRepository with typed query methods |
|
||||
| **Files to create** | \`src/repository/userRepository.ts\` |
|
||||
`;
|
||||
|
||||
describe("ExecutePersona type", () => {
|
||||
it("DEFAULT_PERSONAS has 4 personas", () => {
|
||||
expect(DEFAULT_PERSONAS).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("DEFAULT_PERSONAS includes lead-developer", () => {
|
||||
const lead = DEFAULT_PERSONAS.find((p) => p.name === "lead-developer");
|
||||
expect(lead).toBeTruthy();
|
||||
expect(lead!.domain).toBe("coordination");
|
||||
expect(lead!.territory).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("DEFAULT_PERSONAS includes data-engineer", () => {
|
||||
const data = DEFAULT_PERSONAS.find((p) => p.name === "data-engineer");
|
||||
expect(data).toBeTruthy();
|
||||
expect(data!.domain).toBe("data");
|
||||
expect(data!.frameworks).toContain("drizzle");
|
||||
expect(data!.territory.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("DEFAULT_PERSONAS includes backend-engineer", () => {
|
||||
const backend = DEFAULT_PERSONAS.find((p) => p.name === "backend-engineer");
|
||||
expect(backend).toBeTruthy();
|
||||
expect(backend!.domain).toBe("backend");
|
||||
expect(backend!.frameworks).toContain("fastify");
|
||||
expect(backend!.territory.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("DEFAULT_PERSONAS includes frontend-engineer", () => {
|
||||
const frontend = DEFAULT_PERSONAS.find((p) => p.name === "frontend-engineer");
|
||||
expect(frontend).toBeTruthy();
|
||||
expect(frontend!.domain).toBe("frontend");
|
||||
expect(frontend!.frameworks).toContain("react");
|
||||
expect(frontend!.territory.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("each domain persona has territory patterns", () => {
|
||||
for (const persona of DEFAULT_PERSONAS) {
|
||||
if (persona.domain === "coordination") continue;
|
||||
expect(persona.territory.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("each domain persona has constraints", () => {
|
||||
for (const persona of DEFAULT_PERSONAS) {
|
||||
if (persona.domain === "coordination") continue;
|
||||
expect(persona.constraints.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchFileToPersona", () => {
|
||||
const personas = DEFAULT_PERSONAS;
|
||||
|
||||
it("matches data files to data engineer", () => {
|
||||
const matches = [
|
||||
"src/db/schema/users.ts",
|
||||
"src/migrations/001_create_users.sql",
|
||||
"drizzle/config.ts",
|
||||
"src/models/User.ts",
|
||||
];
|
||||
|
||||
for (const file of matches) {
|
||||
const result = matchFileToPersona(file, personas);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.name).toBe("data-engineer");
|
||||
}
|
||||
});
|
||||
|
||||
it("matches API files to backend engineer", () => {
|
||||
const matches = [
|
||||
"src/api/routes/auth.ts",
|
||||
"src/services/UserService.ts",
|
||||
"src/middleware/auth.ts",
|
||||
"src/controllers/userController.ts",
|
||||
];
|
||||
|
||||
for (const file of matches) {
|
||||
const result = matchFileToPersona(file, personas);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.name).toBe("backend-engineer");
|
||||
}
|
||||
});
|
||||
|
||||
it("matches component files to frontend engineer", () => {
|
||||
const matches = [
|
||||
"src/components/LoginForm.tsx",
|
||||
"src/pages/login.tsx",
|
||||
"src/hooks/useAuth.ts",
|
||||
"src/styles/global.css",
|
||||
];
|
||||
|
||||
for (const file of matches) {
|
||||
const result = matchFileToPersona(file, personas);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.name).toBe("frontend-engineer");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for files outside any territory", () => {
|
||||
const result = matchFileToPersona("src/utils/helpers.ts", personas);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("handles glob patterns correctly", () => {
|
||||
expect(globMatch("**/db/**", "src/db/schema/users.ts")).toBe(true);
|
||||
expect(globMatch("**/db/**", "src/api/routes/auth.ts")).toBe(false);
|
||||
expect(globMatch("**/*.tsx", "src/components/Button.tsx")).toBe(true);
|
||||
expect(globMatch("**/*.tsx", "src/utils/helpers.ts")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectConflicts", () => {
|
||||
it("detects data-backend conflicts", () => {
|
||||
const tasks: DecomposedTask[] = [
|
||||
{
|
||||
taskId: "T1",
|
||||
persona: "data-engineer",
|
||||
domain: "data",
|
||||
description: "Create schema",
|
||||
files: ["src/db/schema/users.ts"],
|
||||
dependencies: [],
|
||||
},
|
||||
{
|
||||
taskId: "T2",
|
||||
persona: "backend-engineer",
|
||||
domain: "backend",
|
||||
description: "Create API routes",
|
||||
files: ["src/db/schema/users.ts"],
|
||||
dependencies: ["T1"],
|
||||
},
|
||||
];
|
||||
|
||||
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
|
||||
expect(conflicts.length).toBe(1);
|
||||
expect(conflicts[0].type).toBe("data-backend");
|
||||
expect(conflicts[0].personas).toContain("data-engineer");
|
||||
expect(conflicts[0].personas).toContain("backend-engineer");
|
||||
});
|
||||
|
||||
it("detects backend-frontend conflicts", () => {
|
||||
const tasks: DecomposedTask[] = [
|
||||
{
|
||||
taskId: "T1",
|
||||
persona: "backend-engineer",
|
||||
domain: "backend",
|
||||
description: "Create API types",
|
||||
files: ["src/api/types/UserTypes.ts"],
|
||||
dependencies: [],
|
||||
},
|
||||
{
|
||||
taskId: "T2",
|
||||
persona: "frontend-engineer",
|
||||
domain: "frontend",
|
||||
description: "Create user component",
|
||||
files: ["src/api/types/UserTypes.ts"],
|
||||
dependencies: ["T1"],
|
||||
},
|
||||
];
|
||||
|
||||
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
|
||||
expect(conflicts.length).toBe(1);
|
||||
expect(conflicts[0].type).toBe("backend-frontend");
|
||||
});
|
||||
|
||||
it("returns no conflicts for non-overlapping tasks", () => {
|
||||
const tasks: DecomposedTask[] = [
|
||||
{
|
||||
taskId: "T1",
|
||||
persona: "data-engineer",
|
||||
domain: "data",
|
||||
description: "Create schema",
|
||||
files: ["src/db/schema/users.ts"],
|
||||
dependencies: [],
|
||||
},
|
||||
{
|
||||
taskId: "T2",
|
||||
persona: "backend-engineer",
|
||||
domain: "backend",
|
||||
description: "Create API routes",
|
||||
files: ["src/api/routes/auth.ts"],
|
||||
dependencies: [],
|
||||
},
|
||||
];
|
||||
|
||||
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
|
||||
expect(conflicts.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskDecomposer", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
initCIAgent(dir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
it("decomposes a plan into persona-specific task groups", () => {
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
personas: {
|
||||
enabled: true,
|
||||
territory_enforcement: "warn" as const,
|
||||
personas: DEFAULT_PERSONAS,
|
||||
},
|
||||
};
|
||||
|
||||
const decomposer = new TaskDecomposer(dir, config, "test-project");
|
||||
const plan = decomposer.decompose(samplePlan);
|
||||
|
||||
expect(plan.tasks.length).toBeGreaterThan(0);
|
||||
expect(plan.dataTasks).toBeDefined();
|
||||
expect(plan.backendTasks).toBeDefined();
|
||||
expect(plan.frontendTasks).toBeDefined();
|
||||
expect(plan.coordinationTasks).toBeDefined();
|
||||
});
|
||||
|
||||
it("resolves territory conflicts", () => {
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
personas: {
|
||||
enabled: true,
|
||||
territory_enforcement: "warn" as const,
|
||||
personas: DEFAULT_PERSONAS,
|
||||
},
|
||||
};
|
||||
|
||||
const decomposer = new TaskDecomposer(dir, config);
|
||||
const plan = decomposer.decompose(samplePlan);
|
||||
const resolved = decomposer.resolveConflicts(plan);
|
||||
|
||||
for (const conflict of resolved.conflicts) {
|
||||
if (conflict.resolution) {
|
||||
expect(conflict.resolution.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns data tasks to data-engineer persona", () => {
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
personas: {
|
||||
enabled: true,
|
||||
territory_enforcement: "warn" as const,
|
||||
personas: DEFAULT_PERSONAS,
|
||||
},
|
||||
};
|
||||
|
||||
const decomposer = new TaskDecomposer(dir, config);
|
||||
const plan = decomposer.decompose(samplePlan);
|
||||
|
||||
const dataTask = plan.tasks.find(
|
||||
(t) => t.files.some((f) => f.includes("schema") || f.includes("migration"))
|
||||
);
|
||||
if (dataTask) {
|
||||
expect(dataTask.domain).toBe("data");
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns API tasks to backend-engineer persona", () => {
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
personas: {
|
||||
enabled: true,
|
||||
territory_enforcement: "warn" as const,
|
||||
personas: DEFAULT_PERSONAS,
|
||||
},
|
||||
};
|
||||
|
||||
const decomposer = new TaskDecomposer(dir, config);
|
||||
const plan = decomposer.decompose(samplePlan);
|
||||
|
||||
const apiTask = plan.tasks.find(
|
||||
(t) => t.files.some((f) => f.includes("api") || f.includes("routes"))
|
||||
);
|
||||
if (apiTask) {
|
||||
expect(apiTask.domain).toBe("backend");
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns component tasks to frontend-engineer persona", () => {
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
personas: {
|
||||
enabled: true,
|
||||
territory_enforcement: "warn" as const,
|
||||
personas: DEFAULT_PERSONAS,
|
||||
},
|
||||
};
|
||||
|
||||
const decomposer = new TaskDecomposer(dir, config);
|
||||
const plan = decomposer.decompose(samplePlan);
|
||||
|
||||
const frontendTask = plan.tasks.find(
|
||||
(t) => t.files.some((f) => f.includes("components") || f.endsWith(".tsx"))
|
||||
);
|
||||
if (frontendTask) {
|
||||
expect(frontendTask.domain).toBe("frontend");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersonaLoader", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
initCIAgent(dir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
it("returns default personas when no files exist", () => {
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
personas: {
|
||||
enabled: true,
|
||||
territory_enforcement: "warn" as const,
|
||||
personas: DEFAULT_PERSONAS,
|
||||
},
|
||||
};
|
||||
|
||||
const loader = new PersonaLoader(dir, config);
|
||||
const personas = loader.loadPersonas();
|
||||
|
||||
expect(personas.length).toBeGreaterThan(0);
|
||||
expect(personas.some((p) => p.domain === "data")).toBe(true);
|
||||
expect(personas.some((p) => p.domain === "backend")).toBe(true);
|
||||
expect(personas.some((p) => p.domain === "frontend")).toBe(true);
|
||||
});
|
||||
|
||||
it("getLeadDeveloper returns lead developer persona", () => {
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
personas: {
|
||||
enabled: true,
|
||||
territory_enforcement: "warn" as const,
|
||||
personas: DEFAULT_PERSONAS,
|
||||
},
|
||||
};
|
||||
|
||||
const loader = new PersonaLoader(dir, config);
|
||||
loader.loadPersonas();
|
||||
const lead = loader.getLeadDeveloper();
|
||||
|
||||
expect(lead).toBeTruthy();
|
||||
expect(lead.domain).toBe("coordination");
|
||||
expect(lead.name).toBe("lead-developer");
|
||||
});
|
||||
|
||||
it("getEngineerPersonas returns non-coordination personas", () => {
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
personas: {
|
||||
enabled: true,
|
||||
territory_enforcement: "warn" as const,
|
||||
personas: DEFAULT_PERSONAS,
|
||||
},
|
||||
};
|
||||
|
||||
const loader = new PersonaLoader(dir, config);
|
||||
const engineers = loader.getEngineerPersonas();
|
||||
|
||||
expect(engineers.length).toBe(3);
|
||||
expect(engineers.every((p) => p.domain !== "coordination")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty personas when personas disabled", () => {
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
personas: {
|
||||
enabled: false,
|
||||
territory_enforcement: "warn" as const,
|
||||
personas: DEFAULT_PERSONAS,
|
||||
},
|
||||
};
|
||||
|
||||
const loader = new PersonaLoader(dir, config);
|
||||
const personas = loader.loadPersonas();
|
||||
|
||||
expect(personas.length).toBe(0);
|
||||
});
|
||||
|
||||
it("getTerritoryEnforcement returns configured value", () => {
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
personas: {
|
||||
enabled: true,
|
||||
territory_enforcement: "strict" as const,
|
||||
personas: DEFAULT_PERSONAS,
|
||||
},
|
||||
};
|
||||
|
||||
const loader = new PersonaLoader(dir, config);
|
||||
expect(loader.getTerritoryEnforcement()).toBe("strict");
|
||||
});
|
||||
|
||||
it("defaults to warn territory enforcement", () => {
|
||||
const config = { ...DEFAULT_CIAGENT_CONFIG };
|
||||
const loader = new PersonaLoader(dir, config);
|
||||
expect(loader.getTerritoryEnforcement()).toBe("warn");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { CIAgentFiles } from "../core/ciagent-files.js";
|
||||
import { initCIAgent, loadConfig } from "../core/config.js";
|
||||
import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||
import { SessionConfig, SessionInfo, DEFAULT_SESSION_CONFIG } from "../types/session.js";
|
||||
import { AgentSession } from "../core/agent-session.js";
|
||||
import { SessionManager } from "../core/session-manager.js";
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-session-test-"));
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function initProjectWithConfig(dir: string): void {
|
||||
const ciDir = path.join(dir, ".ciagent");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
projects: [{ slug: "test-project", name: "Test Project", default: true }],
|
||||
active_project: "test-project",
|
||||
active_projects: ["test-project"],
|
||||
sessions: {
|
||||
max_concurrent_sessions: 3,
|
||||
session_timeout_ms: 3600000,
|
||||
session_isolation: "branch",
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify(config, null, 2));
|
||||
|
||||
const projectDir = path.join(ciDir, "test-project");
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "PROJECT.md"), [
|
||||
"# Test Project",
|
||||
"",
|
||||
"## What This Is",
|
||||
"",
|
||||
"A test project for session testing",
|
||||
"",
|
||||
"## Requirements",
|
||||
"",
|
||||
"### Active",
|
||||
"",
|
||||
"- [ ] Build session management",
|
||||
"",
|
||||
"## Constraints",
|
||||
"",
|
||||
"- TypeScript",
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"",
|
||||
"| Decision | Rationale | Outcome |",
|
||||
"|----------|-----------|---------|",
|
||||
].join("\n"));
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "ROADMAP.md"), [
|
||||
"# Roadmap",
|
||||
"",
|
||||
"## Overview",
|
||||
"",
|
||||
"Test project roadmap",
|
||||
"",
|
||||
"## Phases",
|
||||
"",
|
||||
"- [ ] **Phase 1: Sessions** - Build session management",
|
||||
"",
|
||||
"## Phase Details",
|
||||
"",
|
||||
"### Phase 1: Sessions",
|
||||
"**Goal**.: Build session management",
|
||||
"**Depends on**: Nothing",
|
||||
"**Requirements**: SESSION-01",
|
||||
"**Success Criteria**:",
|
||||
"1. Sessions work",
|
||||
"**Status**: not_started",
|
||||
"",
|
||||
].join("\n"));
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "REQUIREMENTS.md"), [
|
||||
"# Requirements",
|
||||
"",
|
||||
"| REQ-ID | Requirement | Priority | Phase | Status |",
|
||||
"|--------|-------------|----------|-------|--------|",
|
||||
"| SESSION-01 | Session management | P0 | 1 | pending |",
|
||||
"",
|
||||
"## Traceability",
|
||||
"",
|
||||
"| Requirement | Phase | Status |",
|
||||
"|-------------|-------|--------|",
|
||||
"| SESSION-01 | Phase 1 | pending |",
|
||||
].join("\n"));
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "ARCHITECTURE.md"), [
|
||||
"# Architecture",
|
||||
"",
|
||||
"## Overview",
|
||||
"",
|
||||
"Test architecture",
|
||||
"",
|
||||
"## Components",
|
||||
"",
|
||||
"### test-api",
|
||||
"- **Description**: API",
|
||||
"- **Boundaries**: HTTP only",
|
||||
"- **Depends on**: None",
|
||||
"",
|
||||
"## Data Flow",
|
||||
"",
|
||||
"Client -> API",
|
||||
"",
|
||||
"## Build Order",
|
||||
"",
|
||||
"1. API",
|
||||
"",
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
describe("Session types", () => {
|
||||
it("DEFAULT_SESSION_CONFIG has expected values", () => {
|
||||
expect(DEFAULT_SESSION_CONFIG.max_concurrent_sessions).toBe(3);
|
||||
expect(DEFAULT_SESSION_CONFIG.session_timeout_ms).toBe(3600000);
|
||||
expect(DEFAULT_SESSION_CONFIG.session_isolation).toBe("branch");
|
||||
});
|
||||
|
||||
it("SessionInfo interface is constructable", () => {
|
||||
const info: SessionInfo = {
|
||||
id: "abc12345",
|
||||
project_slug: "test-project",
|
||||
project_path: "/tmp/test",
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
last_updated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
expect(info.id).toBe("abc12345");
|
||||
expect(info.status).toBe("running");
|
||||
expect(info.project_slug).toBe("test-project");
|
||||
});
|
||||
|
||||
it("SessionConfig supports all status values", () => {
|
||||
const statuses: SessionInfo["status"][] = [
|
||||
"pending", "running", "paused", "completed", "failed", "cancelled",
|
||||
];
|
||||
expect(statuses).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSession", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
initProjectWithConfig(dir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
it("creates a session with a unique ID", () => {
|
||||
const session = new AgentSession(dir, "test-project");
|
||||
expect(session.getId()).toBeTruthy();
|
||||
expect(session.getId().length).toBeGreaterThan(0);
|
||||
expect(session.getStatus()).toBe("pending");
|
||||
});
|
||||
|
||||
it("getSessionInfo returns valid SessionInfo", () => {
|
||||
const session = new AgentSession(dir, "test-project");
|
||||
const info = session.getSessionInfo();
|
||||
|
||||
expect(info.id).toBe(session.getId());
|
||||
expect(info.project_slug).toBe("test-project");
|
||||
expect(info.project_path).toBe(dir);
|
||||
expect(info.status).toBe("pending");
|
||||
expect(info.phase).toBe(0);
|
||||
});
|
||||
|
||||
it("persists session state", () => {
|
||||
const session = new AgentSession(dir, "test-project");
|
||||
session.persistState();
|
||||
|
||||
const slugDir = path.join(dir, ".ciagent", "test-project");
|
||||
const files = fs.readdirSync(slugDir);
|
||||
const stateFile = files.find((f) => f.startsWith(".session-") && f.endsWith(".json"));
|
||||
|
||||
expect(stateFile).toBeTruthy();
|
||||
});
|
||||
|
||||
it("loads persisted session state", () => {
|
||||
const session = new AgentSession(dir, "test-project");
|
||||
session.persistState();
|
||||
|
||||
const loaded = AgentSession.loadState(dir, session.getId(), "test-project");
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.getId()).toBe(session.getId());
|
||||
});
|
||||
|
||||
it("returns null for non-existent session", () => {
|
||||
const loaded = AgentSession.loadState(dir, "nonexistent", "test-project");
|
||||
expect(loaded).toBeNull();
|
||||
});
|
||||
|
||||
it("acquireLock creates a lock file", () => {
|
||||
const session = new AgentSession(dir, "test-project");
|
||||
const acquired = session.acquireLock();
|
||||
|
||||
expect(acquired).toBe(true);
|
||||
|
||||
const lockPath = path.join(dir, ".ciagent", "test-project", ".session-lock");
|
||||
expect(fs.existsSync(lockPath)).toBe(true);
|
||||
|
||||
session.releaseLock();
|
||||
});
|
||||
|
||||
it("releaseLock removes the lock file", () => {
|
||||
const session = new AgentSession(dir, "test-project");
|
||||
session.acquireLock();
|
||||
session.releaseLock();
|
||||
|
||||
const lockPath = path.join(dir, ".ciagent", "test-project", ".session-lock");
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("cancel changes status to cancelled when running", () => {
|
||||
const session = new AgentSession(dir, "test-project");
|
||||
session.acquireLock();
|
||||
(session as any).status = "running";
|
||||
const cancelled = session.cancel();
|
||||
expect(cancelled).toBe(true);
|
||||
expect(session.getStatus()).toBe("cancelled");
|
||||
session.releaseLock();
|
||||
});
|
||||
|
||||
it("cancel returns false for non-running session", () => {
|
||||
const session = new AgentSession(dir, "test-project");
|
||||
const cancelled = session.cancel();
|
||||
expect(cancelled).toBe(false);
|
||||
});
|
||||
|
||||
it("pause and resume work correctly for non-running session", () => {
|
||||
const session = new AgentSession(dir, "test-project");
|
||||
expect(session.pause()).toBe(false);
|
||||
expect(session.resume()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionManager", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
initProjectWithConfig(dir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
it("creates sessions for projects", () => {
|
||||
const manager = new SessionManager(dir);
|
||||
const session = manager.createSession("test-project");
|
||||
|
||||
expect(session).toBeTruthy();
|
||||
expect(session.getProjectSlug()).toBe("test-project");
|
||||
});
|
||||
|
||||
it("lists sessions", () => {
|
||||
const manager = new SessionManager(dir);
|
||||
manager.createSession("test-project");
|
||||
|
||||
const sessions = manager.listSessions();
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0].project_slug).toBe("test-project");
|
||||
});
|
||||
|
||||
it("lists active sessions as empty when none running", () => {
|
||||
const manager = new SessionManager(dir);
|
||||
manager.createSession("test-project");
|
||||
|
||||
const active = manager.listActiveSessions();
|
||||
expect(active.length).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels a session that is not running returns false", () => {
|
||||
const manager = new SessionManager(dir);
|
||||
const session = manager.createSession("test-project");
|
||||
|
||||
const cancelled = manager.cancelSession(session.getId());
|
||||
expect(cancelled).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up stale sessions returns 0", () => {
|
||||
const manager = new SessionManager(dir);
|
||||
const cleaned = manager.cleanupStaleSessions();
|
||||
expect(cleaned).toBe(0);
|
||||
});
|
||||
|
||||
it("loads persisted sessions as empty initially", () => {
|
||||
const manager = new SessionManager(dir);
|
||||
const persisted = manager.loadPersistedSessions();
|
||||
expect(Array.isArray(persisted)).toBe(true);
|
||||
});
|
||||
|
||||
it("gets a session by id", () => {
|
||||
const manager = new SessionManager(dir);
|
||||
const session = manager.createSession("test-project");
|
||||
|
||||
const retrieved = manager.getSession(session.getId());
|
||||
expect(retrieved).toBeTruthy();
|
||||
expect(retrieved!.getId()).toBe(session.getId());
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent session", () => {
|
||||
const manager = new SessionManager(dir);
|
||||
const retrieved = manager.getSession("nonexistent");
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { CIAgentConfig } from "../types/config.js";
|
||||
import { SessionInfo, SessionStatus } from "../types/session.js";
|
||||
import { AgentSession } from "./agent-session.js";
|
||||
import { AgentContext, AgentResult } from "../agents/base.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
|
||||
export class SessionManager {
|
||||
private sessions: Map<string, AgentSession> = new Map();
|
||||
private config: CIAgentConfig;
|
||||
private projectPath: string;
|
||||
|
||||
constructor(projectPath: string, config?: CIAgentConfig) {
|
||||
this.projectPath = projectPath;
|
||||
this.config = config || loadConfig(projectPath);
|
||||
}
|
||||
|
||||
createSession(projectSlug: string): AgentSession {
|
||||
const session = new AgentSession(this.projectPath, projectSlug, this.config);
|
||||
this.sessions.set(session.getId(), session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async runSession(sessionId: string, context: AgentContext): Promise<AgentResult> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
output: `Session ${sessionId} not found`,
|
||||
artifacts_created: 0,
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: 0,
|
||||
error: `Session ${sessionId} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return session.run(context);
|
||||
}
|
||||
|
||||
async runAllSessions(
|
||||
projectSlugs: string[],
|
||||
contextFactory: (slug: string) => AgentContext,
|
||||
parallel: boolean = false
|
||||
): Promise<Record<string, AgentResult>> {
|
||||
const results: Record<string, AgentResult> = {};
|
||||
const maxConcurrent = this.config.sessions?.max_concurrent_sessions || 3;
|
||||
|
||||
if (parallel && projectSlugs.length > 1) {
|
||||
const batches: string[][] = [];
|
||||
const concurrency = Math.min(maxConcurrent, projectSlugs.length);
|
||||
|
||||
for (let i = 0; i < projectSlugs.length; i += concurrency) {
|
||||
batches.push(projectSlugs.slice(i, i + concurrency));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map(async (slug): Promise<[string, AgentResult]> => {
|
||||
const session = this.createSession(slug);
|
||||
const context = contextFactory(slug);
|
||||
const result = await session.run(context);
|
||||
return [slug, result];
|
||||
})
|
||||
);
|
||||
|
||||
for (const settled of batchResults) {
|
||||
if (settled.status === "fulfilled") {
|
||||
const [slug, result] = settled.value;
|
||||
results[slug] = result;
|
||||
} else {
|
||||
const slug = batch[batchResults.indexOf(settled)];
|
||||
results[slug] = {
|
||||
success: false,
|
||||
output: `Session failed for ${slug}`,
|
||||
artifacts_created: 0,
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: 0,
|
||||
error: settled.reason instanceof Error ? settled.reason.message : String(settled.reason),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const slug of projectSlugs) {
|
||||
const session = this.createSession(slug);
|
||||
const context = contextFactory(slug);
|
||||
const result = await session.run(context);
|
||||
results[slug] = result;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
cancelSession(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
return session.cancel();
|
||||
}
|
||||
|
||||
pauseSession(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
return session.pause();
|
||||
}
|
||||
|
||||
resumeSession(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
return session.resume();
|
||||
}
|
||||
|
||||
getSession(sessionId: string): AgentSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
listSessions(): SessionInfo[] {
|
||||
return Array.from(this.sessions.values()).map((s) => s.getSessionInfo());
|
||||
}
|
||||
|
||||
listActiveSessions(): SessionInfo[] {
|
||||
return this.listSessions().filter(
|
||||
(s) => s.status === "running" || s.status === "paused"
|
||||
);
|
||||
}
|
||||
|
||||
loadPersistedSessions(): SessionInfo[] {
|
||||
const ciDir = path.join(this.projectPath, ".ciagent");
|
||||
if (!fs.existsSync(ciDir)) return [];
|
||||
|
||||
const sessions: SessionInfo[] = [];
|
||||
const dirs = [ciDir];
|
||||
|
||||
try {
|
||||
const config = loadConfig(this.projectPath);
|
||||
if (config.projects && config.projects.length > 0) {
|
||||
for (const project of config.projects) {
|
||||
dirs.push(path.join(ciDir, project.slug));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith(".session-") && file.endsWith(".json")) {
|
||||
const sessionId = file.replace(".session-", "").replace(".json", "");
|
||||
const slug = dir === ciDir ? "" : path.basename(dir);
|
||||
const session = AgentSession.loadState(this.projectPath, sessionId, slug || undefined);
|
||||
if (session) {
|
||||
sessions.push(session.getSessionInfo());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
cleanupStaleSessions(): number {
|
||||
const timeout = this.config.sessions?.session_timeout_ms || 3600000;
|
||||
const now = Date.now();
|
||||
let cleaned = 0;
|
||||
|
||||
for (const [id, session] of this.sessions.entries()) {
|
||||
const info = session.getSessionInfo();
|
||||
const age = now - new Date(info.last_updated).getTime();
|
||||
|
||||
if ((info.status === "running" || info.status === "paused") && age > timeout) {
|
||||
session.cancel();
|
||||
this.sessions.delete(id);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { matchFileToPersona, detectConflicts, DecomposedTask, DecomposedPlan, TerritoryConflict, ExecutePersonaConfig, PersonaDomain, DEFAULT_PERSONAS } from "../types/persona.js";
|
||||
import { CIAgentConfig } from "../types/config.js";
|
||||
import { PersonaLoader, PersonaDefinition } from "./persona-loader.js";
|
||||
import { CIAgentFiles } from "./ciagent-files.js";
|
||||
import { readFile } from "../utils/file.js";
|
||||
|
||||
const DOMAIN_FILE_PATTERNS: Record<string, string[]> = {
|
||||
data: [
|
||||
"**/migrations/**", "**/schema/**", "**/models/**", "**/db/**",
|
||||
"prisma/schema.prisma", "drizzle/**", "**/*.sql", "**/seed*",
|
||||
"**/repository/**", "**/dao/**",
|
||||
],
|
||||
backend: [
|
||||
"**/api/**", "**/routes/**", "**/services/**", "**/middleware/**",
|
||||
"**/controllers/**", "**/auth/**", "**/handlers/**", "**/grpc/**",
|
||||
"**/server.ts", "**/app.ts",
|
||||
],
|
||||
frontend: [
|
||||
"**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**",
|
||||
"**/*.tsx", "**/*.css", "**/*.vue", "**/*.svelte",
|
||||
"**/layouts/**", "**/views/**", "**/client/**",
|
||||
],
|
||||
};
|
||||
|
||||
const DOMAIN_KEYWORDS: Record<string, string[]> = {
|
||||
data: [
|
||||
"schema", "migration", "database", "model", "query", "table", "column",
|
||||
"index", "seed", "orm", "sql", "repository", "dao", "entity",
|
||||
],
|
||||
backend: [
|
||||
"api", "route", "endpoint", "middleware", "controller", "service",
|
||||
"handler", "server", "auth", "grpc", "rest", "websocket",
|
||||
"request", "response", "cors", "rate-limit",
|
||||
],
|
||||
frontend: [
|
||||
"component", "page", "layout", "style", "css", "hook", "view",
|
||||
"client", "ui", "render", "state", "interactive", "accessible",
|
||||
"responsive", "animation",
|
||||
],
|
||||
};
|
||||
|
||||
interface PlanTask {
|
||||
id: string;
|
||||
description: string;
|
||||
files: string[];
|
||||
requirements: string[];
|
||||
dependencies: string[];
|
||||
wave: number;
|
||||
}
|
||||
|
||||
export class TaskDecomposer {
|
||||
private projectPath: string;
|
||||
private personaLoader: PersonaLoader;
|
||||
private config: CIAgentConfig;
|
||||
private ciFiles: CIAgentFiles;
|
||||
|
||||
constructor(projectPath: string, config: CIAgentConfig, projectSlug?: string) {
|
||||
this.projectPath = projectPath;
|
||||
this.config = config;
|
||||
this.personaLoader = new PersonaLoader(projectPath, config);
|
||||
this.ciFiles = new CIAgentFiles(projectPath, projectSlug || undefined);
|
||||
}
|
||||
|
||||
decompose(planContent: string): DecomposedPlan {
|
||||
const tasks = this.parsePlanTasks(planContent);
|
||||
const personas = this.config.personas?.enabled !== false
|
||||
? this.config.personas?.personas || DEFAULT_PERSONAS
|
||||
: DEFAULT_PERSONAS;
|
||||
|
||||
const decomposedTasks = this.assignTasksToPersonas(tasks, personas);
|
||||
const conflicts = detectConflicts(decomposedTasks, personas);
|
||||
|
||||
return {
|
||||
tasks: decomposedTasks,
|
||||
dataTasks: decomposedTasks.filter((t) => t.domain === "data"),
|
||||
backendTasks: decomposedTasks.filter((t) => t.domain === "backend"),
|
||||
frontendTasks: decomposedTasks.filter((t) => t.domain === "frontend"),
|
||||
coordinationTasks: decomposedTasks.filter((t) => t.domain === "coordination"),
|
||||
conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
resolveConflicts(plan: DecomposedPlan): DecomposedPlan {
|
||||
const resolved = { ...plan, conflicts: [...plan.conflicts] };
|
||||
|
||||
for (let i = 0; i < resolved.conflicts.length; i++) {
|
||||
const conflict = resolved.conflicts[i];
|
||||
const resolution = this.leadDeveloperResolve(conflict);
|
||||
resolved.conflicts[i] = { ...conflict, resolution };
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private parsePlanTasks(planContent: string): PlanTask[] {
|
||||
const tasks: PlanTask[] = [];
|
||||
const taskRegex = /####\s+Task\s+(\d+[\.\d]*)[\s:]+(.+)/g;
|
||||
const idRegex = /\*\*ID\*\*\s*\|\s*([A-Z]+-\d+(?:-\d+)*)/g;
|
||||
const filesRegex = /\*\*Files\s+to\s+(?:create|modify)\*\*\s*\|\s*(.+)/g;
|
||||
const reqRegex = /\*\*REQs\*\*\s*\|\s*(.+)/g;
|
||||
const depRegex = /\*\*Dependencies\*\*\s*\|\s*(.+)/g;
|
||||
const waveRegex = /###\s+Wave\s+(\d+)/g;
|
||||
|
||||
const sections = planContent.split(/####\s+Task/);
|
||||
let currentWave = 1;
|
||||
|
||||
const waveMatches = [...planContent.matchAll(/###\s+Wave\s+(\d+)/g)];
|
||||
const wavePositions = waveMatches.map((m) => ({
|
||||
wave: parseInt(m[1], 10),
|
||||
position: m.index || 0,
|
||||
}));
|
||||
|
||||
let taskCounter = 0;
|
||||
for (let i = 1; i < sections.length; i++) {
|
||||
const section = sections[i];
|
||||
const taskPosition = planContent.indexOf(section);
|
||||
|
||||
currentWave = 1;
|
||||
for (const wp of wavePositions) {
|
||||
if (wp.position <= taskPosition) {
|
||||
currentWave = wp.wave;
|
||||
}
|
||||
}
|
||||
|
||||
const taskIdMatch = section.match(/([A-Z]+-\d+(?:-\d+)*)/);
|
||||
const taskId = taskIdMatch ? taskIdMatch[1] : `T${++taskCounter}`;
|
||||
|
||||
const descriptionMatch = section.match(/^\s*\d*[\.\d]*\s*[::]?\s*(.+)/);
|
||||
const description = descriptionMatch ? descriptionMatch[1].split("\n")[0].trim() : `Task ${taskId}`;
|
||||
|
||||
const files: string[] = [];
|
||||
const filesMatch = section.match(/\*\*Files?\s+to\s+(?:create|modify)\*\*\s*\|?\s*(.+)/i);
|
||||
if (filesMatch) {
|
||||
const fileList = filesMatch[1].split(/[`,]/).map((f: string) => f.trim()).filter(Boolean);
|
||||
files.push(...fileList);
|
||||
}
|
||||
|
||||
const blockFiles = section.match(/`([^`]+\.(ts|js|json|sql|md|tsx|jsx|vue|svelte|css))`/g);
|
||||
if (blockFiles) {
|
||||
for (const bf of blockFiles) {
|
||||
const cleaned = bf.replace(/`/g, "");
|
||||
if (!files.includes(cleaned)) files.push(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
const requirements: string[] = [];
|
||||
const reqMatch = section.match(/\*\*REQs?\*\*\s*\|?\s*(.+)/i);
|
||||
if (reqMatch) {
|
||||
const reqs = reqMatch[1].split(",").map((r: string) => r.trim()).filter(Boolean);
|
||||
requirements.push(...reqs);
|
||||
}
|
||||
|
||||
const dependencies: string[] = [];
|
||||
const depMatch = section.match(/\*\*Dependencies?\*\*\s*\|?\s*(.+)/i);
|
||||
if (depMatch) {
|
||||
const deps = depMatch[1].split(",").map((d: string) => d.trim()).filter((d: string) => d && d !== "None");
|
||||
dependencies.push(...deps);
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
id: taskId,
|
||||
description,
|
||||
files,
|
||||
requirements,
|
||||
dependencies,
|
||||
wave: currentWave,
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private assignTasksToPersonas(
|
||||
tasks: PlanTask[],
|
||||
personas: ExecutePersonaConfig[]
|
||||
): DecomposedTask[] {
|
||||
const leadConfig = personas.find((p) => p.domain === "coordination") || personas[0];
|
||||
const engineerConfigs = personas.filter((p) => p.domain !== "coordination");
|
||||
|
||||
return tasks.map((task) => {
|
||||
const assignedPersona = this.assignPersona(task, personas);
|
||||
const domain = this.determineDomain(task, assignedPersona);
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
persona: assignedPersona.name,
|
||||
domain,
|
||||
description: task.description,
|
||||
files: task.files,
|
||||
dependencies: task.dependencies,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private assignPersona(
|
||||
task: PlanTask,
|
||||
personas: ExecutePersonaConfig[]
|
||||
): ExecutePersonaConfig {
|
||||
if (task.files.length === 0 && task.description.length === 0) {
|
||||
return personas.find((p) => p.domain === "coordination") || personas[0];
|
||||
}
|
||||
|
||||
let bestPersona: ExecutePersonaConfig | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const persona of personas) {
|
||||
if (persona.domain === "coordination") continue;
|
||||
|
||||
let score = 0;
|
||||
|
||||
for (const file of task.files) {
|
||||
const matched = matchFileToPersona(file, personas);
|
||||
if (matched && matched.name === persona.name) {
|
||||
score += 3;
|
||||
}
|
||||
}
|
||||
|
||||
const domainKeywords = DOMAIN_KEYWORDS[persona.domain] || [];
|
||||
const descLower = task.description.toLowerCase();
|
||||
for (const keyword of domainKeywords) {
|
||||
if (descLower.includes(keyword)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const req of task.requirements) {
|
||||
const reqLower = req.toLowerCase();
|
||||
for (const keyword of domainKeywords) {
|
||||
if (reqLower.includes(keyword)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestPersona = persona;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestPersona && bestScore > 0) {
|
||||
return bestPersona;
|
||||
}
|
||||
|
||||
if (task.files.length > 0) {
|
||||
const firstFile = task.files[0];
|
||||
const matched = matchFileToPersona(firstFile, personas);
|
||||
if (matched) return matched;
|
||||
}
|
||||
|
||||
return personas.find((p) => p.domain === "coordination") || personas[0];
|
||||
}
|
||||
|
||||
private determineDomain(
|
||||
task: PlanTask,
|
||||
persona: ExecutePersonaConfig
|
||||
): PersonaDomain {
|
||||
return persona.domain as PersonaDomain;
|
||||
}
|
||||
|
||||
private leadDeveloperResolve(conflict: TerritoryConflict): string {
|
||||
switch (conflict.type) {
|
||||
case "data-backend":
|
||||
return `Lead developer assigns ${conflict.file} to backend engineer. Data engineer provides schema contract; backend implements API contract. Data changes should be in a separate migration.`;
|
||||
case "backend-frontend":
|
||||
return `Lead developer assigns ${conflict.file} to backend engineer. Frontend engineer adapts to backend API contract. If the file is primarily a type definition, create a shared types module.`;
|
||||
case "data-frontend":
|
||||
return `Lead developer assigns ${conflict.file} to data engineer for schema definition. Frontend engineer consumes through a backend API endpoint. Direct database access from frontend is prohibited.`;
|
||||
default:
|
||||
return `Lead developer arbitrates: ${conflict.file} assigned to ${conflict.personas[0]}. Other persona uses the public interface.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ export { GitBranch } from "./core/git-branch.js";
|
||||
export { CommitBuilder } from "./core/commit-builder.js";
|
||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
|
||||
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
|
||||
export { AgentSession } from "./core/agent-session.js";
|
||||
export { SessionManager } from "./core/session-manager.js";
|
||||
export { PersonaLoader } from "./core/persona-loader.js";
|
||||
export { TaskDecomposer } from "./core/task-decomposer.js";
|
||||
export { VerificationPipeline } from "./verification/index.js";
|
||||
export { StructuralVerification } from "./verification/structural.js";
|
||||
export { BehavioralVerification } from "./verification/behavioral.js";
|
||||
@@ -24,6 +28,8 @@ export { ESCALATION_TYPES } from "./types/escalation.js";
|
||||
export { createClarifyQuestion } from "./types/clarify.js";
|
||||
export { parseSpecification } from "./types/specification.js";
|
||||
export { getNextStage, createInitialPipelineState } from "./types/pipeline.js";
|
||||
export { matchFileToPersona, detectConflicts, DEFAULT_PERSONAS } from "./types/persona.js";
|
||||
export { DEFAULT_SESSION_CONFIG } from "./types/session.js";
|
||||
export * as fileUtils from "./utils/file.js";
|
||||
export { resolveBackend, createBackend } from "./backends/index.js";
|
||||
export { OpencodeBackend } from "./backends/opencode.js";
|
||||
@@ -48,3 +54,5 @@ export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./cor
|
||||
export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js";
|
||||
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
|
||||
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
|
||||
export type { SessionInfo, SessionStatus, SessionConfig } from "./types/session.js";
|
||||
export type { ExecutePersonaConfig, TerritoryEnforcement, PersonaDomain, DecomposedTask, DecomposedPlan, TerritoryConflict } from "./types/persona.js";
|
||||
@@ -55,6 +55,7 @@ export interface CIAgentMetadata {
|
||||
phase: number;
|
||||
milestone: string;
|
||||
project?: string;
|
||||
session?: string;
|
||||
plan?: string;
|
||||
task?: string;
|
||||
status: PipelineStage;
|
||||
|
||||
+36
-3
@@ -1,5 +1,4 @@
|
||||
import { BackendConfigSection } from "../backends/types.js";
|
||||
import { IdeationConfig, IdeationCategory } from "./ideation.js";
|
||||
import { TerritoryEnforcement, ExecutePersonaConfig } from "./persona.js";
|
||||
|
||||
export type AutonomyLevel = "full" | "supervised" | "guided";
|
||||
|
||||
@@ -7,7 +6,7 @@ export type ModelProfile = "quality" | "speed" | "balanced";
|
||||
|
||||
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";
|
||||
|
||||
@@ -46,6 +45,7 @@ export interface ParallelizationConfig {
|
||||
enabled: boolean;
|
||||
max_concurrent_agents: number;
|
||||
min_plans_for_parallel: number;
|
||||
max_concurrent_projects: number;
|
||||
}
|
||||
|
||||
export interface VerificationConfig {
|
||||
@@ -93,8 +93,25 @@ export interface CIAgentConfig {
|
||||
backend: BackendConfigSection;
|
||||
gitea?: GiteaConfig;
|
||||
ideation?: IdeationConfig;
|
||||
sessions?: SessionConfig;
|
||||
personas?: PersonaConfigSection;
|
||||
}
|
||||
|
||||
export interface SessionConfig {
|
||||
max_concurrent_sessions: number;
|
||||
session_timeout_ms: number;
|
||||
session_isolation: "branch";
|
||||
}
|
||||
|
||||
export interface PersonaConfigSection {
|
||||
enabled: boolean;
|
||||
territory_enforcement: TerritoryEnforcement;
|
||||
personas: ExecutePersonaConfig[];
|
||||
}
|
||||
|
||||
import { BackendConfigSection } from "../backends/types.js";
|
||||
import { IdeationConfig, IdeationCategory } from "./ideation.js";
|
||||
|
||||
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
||||
projects: [],
|
||||
active_project: "",
|
||||
@@ -113,6 +130,7 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
||||
enabled: true,
|
||||
max_concurrent_agents: 5,
|
||||
min_plans_for_parallel: 2,
|
||||
max_concurrent_projects: 3,
|
||||
},
|
||||
verification: {
|
||||
automated_only: true,
|
||||
@@ -188,4 +206,19 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
||||
scenarios: ["backend_unavailable", "requirement_change", "test_coverage_drop"],
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
max_concurrent_sessions: 3,
|
||||
session_timeout_ms: 3600000,
|
||||
session_isolation: "branch",
|
||||
},
|
||||
personas: {
|
||||
enabled: true,
|
||||
territory_enforcement: "warn",
|
||||
personas: [
|
||||
{ name: "lead-developer", domain: "coordination", frameworks: [], constraints: ["pragmatic", "battle-tested defaults"], territory: [] },
|
||||
{ name: "data-engineer", domain: "data", frameworks: ["drizzle", "postgresql"], constraints: ["schema-first", "type-safe ORM", "migration-driven"], territory: ["**/migrations/**", "**/schema/**", "**/models/**", "**/db/**", "prisma/schema.prisma", "drizzle/**", "**/*.sql"] },
|
||||
{ name: "backend-engineer", domain: "backend", frameworks: ["fastify", "hono"], constraints: ["api-first", "strict-typing", "dependency-injection"], territory: ["**/api/**", "**/routes/**", "**/services/**", "**/middleware/**", "**/controllers/**", "**/auth/**"] },
|
||||
{ name: "frontend-engineer", domain: "frontend", frameworks: ["react", "next.js"], constraints: ["component-first", "server-components", "minimal-client-js"], territory: ["**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**", "**/*.tsx", "**/*.css", "**/*.vue"] },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
export type PersonaDomain = "data" | "backend" | "frontend" | "coordination";
|
||||
|
||||
export type TerritoryEnforcement = "warn" | "strict";
|
||||
|
||||
export interface ExecutePersonaConfig {
|
||||
name: string;
|
||||
domain: PersonaDomain;
|
||||
frameworks: string[];
|
||||
constraints: string[];
|
||||
territory: string[];
|
||||
}
|
||||
|
||||
export interface DecomposedTask {
|
||||
taskId: string;
|
||||
persona: string;
|
||||
domain: PersonaDomain;
|
||||
description: string;
|
||||
files: string[];
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
export interface DecomposedPlan {
|
||||
tasks: DecomposedTask[];
|
||||
dataTasks: DecomposedTask[];
|
||||
backendTasks: DecomposedTask[];
|
||||
frontendTasks: DecomposedTask[];
|
||||
coordinationTasks: DecomposedTask[];
|
||||
conflicts: TerritoryConflict[];
|
||||
}
|
||||
|
||||
export interface TerritoryConflict {
|
||||
type: "data-backend" | "backend-frontend" | "data-frontend";
|
||||
file: string;
|
||||
personas: string[];
|
||||
description: string;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_PERSONAS: ExecutePersonaConfig[] = [
|
||||
{
|
||||
name: "lead-developer",
|
||||
domain: "coordination",
|
||||
frameworks: [],
|
||||
constraints: ["pragmatic", "battle-tested defaults"],
|
||||
territory: [],
|
||||
},
|
||||
{
|
||||
name: "data-engineer",
|
||||
domain: "data",
|
||||
frameworks: ["drizzle", "postgresql"],
|
||||
constraints: ["schema-first", "type-safe ORM", "migration-driven"],
|
||||
territory: [
|
||||
"**/migrations/**",
|
||||
"**/schema/**",
|
||||
"**/models/**",
|
||||
"**/db/**",
|
||||
"prisma/schema.prisma",
|
||||
"drizzle/**",
|
||||
"**/*.sql",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "backend-engineer",
|
||||
domain: "backend",
|
||||
frameworks: ["fastify", "hono"],
|
||||
constraints: ["api-first", "strict-typing", "dependency-injection"],
|
||||
territory: [
|
||||
"**/api/**",
|
||||
"**/routes/**",
|
||||
"**/services/**",
|
||||
"**/middleware/**",
|
||||
"**/controllers/**",
|
||||
"**/auth/**",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "frontend-engineer",
|
||||
domain: "frontend",
|
||||
frameworks: ["react", "next.js"],
|
||||
constraints: ["component-first", "server-components", "minimal-client-js"],
|
||||
territory: [
|
||||
"**/components/**",
|
||||
"**/pages/**",
|
||||
"**/hooks/**",
|
||||
"**/styles/**",
|
||||
"**/*.tsx",
|
||||
"**/*.css",
|
||||
"**/*.vue",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function matchFileToPersona(
|
||||
filePath: string,
|
||||
personas: ExecutePersonaConfig[]
|
||||
): ExecutePersonaConfig | null {
|
||||
const normalizedPath = filePath.replace(/\\/g, "/");
|
||||
|
||||
for (const persona of personas) {
|
||||
if (persona.domain === "coordination") continue;
|
||||
|
||||
for (const pattern of persona.territory) {
|
||||
const normalizedPattern = pattern.replace(/\\/g, "/");
|
||||
if (globMatch(normalizedPattern, normalizedPath)) {
|
||||
return persona;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function globMatch(pattern: string, path: string): boolean {
|
||||
const regexStr = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*\*/g, "§§")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/§§/g, ".*")
|
||||
.replace(/\?/g, "[^/]");
|
||||
const regex = new RegExp(`^${regexStr}$`);
|
||||
return regex.test(path);
|
||||
}
|
||||
|
||||
export function detectConflicts(
|
||||
tasks: DecomposedTask[],
|
||||
personas: ExecutePersonaConfig[]
|
||||
): TerritoryConflict[] {
|
||||
const conflicts: TerritoryConflict[] = [];
|
||||
const filePersonaMap = new Map<string, string[]>();
|
||||
|
||||
for (const task of tasks) {
|
||||
for (const file of task.files) {
|
||||
if (!filePersonaMap.has(file)) {
|
||||
filePersonaMap.set(file, []);
|
||||
}
|
||||
const personas_list = filePersonaMap.get(file)!;
|
||||
if (!personas_list.includes(task.persona)) {
|
||||
personas_list.push(task.persona);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, claimingPersonas] of filePersonaMap) {
|
||||
if (claimingPersonas.length > 1) {
|
||||
const domains = claimingPersonas
|
||||
.map((p) => personas.find((pe) => pe.name === p)?.domain)
|
||||
.filter((d): d is PersonaDomain => d !== undefined);
|
||||
|
||||
let conflictType: TerritoryConflict["type"];
|
||||
if (domains.includes("data") && domains.includes("backend")) {
|
||||
conflictType = "data-backend";
|
||||
} else if (domains.includes("backend") && domains.includes("frontend")) {
|
||||
conflictType = "backend-frontend";
|
||||
} else {
|
||||
conflictType = "data-frontend";
|
||||
}
|
||||
|
||||
conflicts.push({
|
||||
type: conflictType,
|
||||
file,
|
||||
personas: claimingPersonas,
|
||||
description: `File ${file} claimed by multiple personas: ${claimingPersonas.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
@@ -63,3 +63,13 @@ describe("createInitialPipelineState", () => {
|
||||
expect(state.last_updated).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("STAGE_ORDER ideate position", () => {
|
||||
it("places ideate between research and plan", () => {
|
||||
const ideateIdx = STAGE_ORDER.indexOf("ideate");
|
||||
const researchIdx = STAGE_ORDER.indexOf("research");
|
||||
const planIdx = STAGE_ORDER.indexOf("plan");
|
||||
expect(ideateIdx).toBeGreaterThan(researchIdx);
|
||||
expect(ideateIdx).toBeLessThan(planIdx);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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)$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1 +1 @@
|
||||
export const VERSION = "0.9.0";
|
||||
export const VERSION = "0.11.0";
|
||||
Reference in New Issue
Block a user