Compare commits

..

15 Commits

Author SHA1 Message Date
grimacing a153291643 Merge pull request 'feat(P06): Integration & hardening — INTEG-01..05, MULTI-04, v0.10.0' (#9) from phase/06-integration-hardening into main
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-06-01 15:41:20 +00:00
Jon Chery a0619f9740 feat(P06): Integration & hardening — INTEG-01..05, MULTI-04
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
- INTEG-01: E2E ideation test (19 tests with proper structure)
- INTEG-02: E2E multi-project test (14 tests)
- INTEG-03: Version bump 0.9.0 → 0.10.0
- INTEG-04: AGENTS.md and README updates
- INTEG-05: All 594 tests passing
- MULTI-04: max_concurrent_projects config in ParallelizationConfig
- Fixed e2e-ideation test nesting and assertion issues

---ci---
phase: 6
milestone: v0.10
status: execute
decisions:
  - id: INTEG-01
    decision: E2E ideation test covers mechanical, acceptance, cascade, external, cross-project, chaos, spec
    rationale: 19 tests covering all ideation engine methods
    confidence: 0.95
  - id: INTEG-03
    decision: Version bumped to 0.10.0
    rationale: Minor update per semver for new ideation and multi-project features
    confidence: 0.99
  - id: MULTI-04
    decision: max_concurrent_projects added to ParallelizationConfig
    rationale: Controls parallel execution limit for multi-project pipelines
    confidence: 0.90
requirements:
  covered: [INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05, MULTI-04]
---/ci---
2026-06-01 15:39:47 +00:00
Jon Chery f478088797 refactor(P06): rename milestone type schema-breaking → major, reinforce release flow
---ci---
phase: 6
milestone: v0.10
status: execute
decisions:
  - id: D-001
    decision: Rename MilestoneType schema-breaking to major for clarity
    rationale: Major better describes the semver impact (major version bump) and aligns with standard semver terminology
    confidence: 0.95
    alternatives: [schema-breaking, breaking, major-change]
  - id: D-002
    decision: Add autopilot rules, PR+QA gates, and merge validation to ship workflow
    rationale: Release flow was documented but not enforced in the workflow. Zero-HITL rules, branch hierarchy validation, and coreci packaging steps ensure consistent releases
    confidence: 0.90
    alternatives: [keep-as-documentation-only, add-to-AGENTS.md-only]
---/ci---
2026-06-01 15:29:43 +00:00
grimacing e2b749d42e Merge pull request 'feat(P05): Multi-project ideation support — MULTI-03, MULTI-05, MULTI-07' (#8) from phase/05-multi-project-ideation into main
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-06-01 13:58:10 +00:00
Jon Chery c747d3e8be feat(P05): Multi-project ideation support — MULTI-03, MULTI-05, MULTI-07
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
---ci---
phase: 5
milestone: v0.10
status: execute
decisions:
  - id: MULTI-03
    decision: Parallel project execution via OrchestratorAgent.runForAllProjects
    rationale: Sequential by default, parallel when parallelization.enabled with max_concurrent_projects limit
    confidence: 0.85
    alternatives: [single-project-only, manual-iteration]
  - id: MULTI-05
    decision: ideate --project all iterates all active_projects with deduplication
    rationale: Each project gets its own IdeationEngine; ideas deduplicated by project:title key
    confidence: 0.90
    alternatives: [single-project-only, merge-all-ideas]
  - id: MULTI-07
    decision: project field in ---ci--- commit blocks and CommitScope for multi-project tracking
    rationale: CIAgentMetadata.project and CommitScope.project fields propagated through all commit builders
    confidence: 0.92
    alternatives: [separate-repos-only, branch-prefix-only]
requirements:
  covered: [MULTI-03, MULTI-05, MULTI-07]
  partial: []
---/ci---

- Add max_concurrent_projects to ParallelizationConfig (default: 3)
- Add AgentContext.project_slug optional field for multi-project pipeline tracking
- Implement OrchestratorAgent.runForProject() for single-project execution
- Implement OrchestratorAgent.runForAllProjects() for multi-project iteration
  - Sequential execution by default
  - Parallel when parallelization.enabled with limitConcurrency batching
- Add --project flag to createRunCommand for targeted project execution
  - --project all triggers multi-project pipeline
  - --project slug1,slug2 for comma-separated projects
- Enhance createIdeateCommand --project all support
  - Iterates all active projects from config
  - Deduplicates findings by project:title key
  - Per-project idea acceptance via separate IdeationEngine instances
  - Markdown table output for multi-project results
- Propagate project slug through orchestrator pipeline commits
  - Specify stage: project field in CIAgentMetadata init commit
  - Ideate stage: project field in task commit via buildTaskCommit
  - Orchestrator sets ciFiles with project slug for per-project .ciagent dirs
- 19 new tests covering MULTI-03, MULTI-05, MULTI-07 functionality
- All 561 tests pass, typecheck clean
2026-06-01 13:56:43 +00:00
grimacing d9927558d5 Merge pull request 'feat(P04): Cross-project pipeline integration — IDEATE-16, IDEATE-11, IDEATE-18' (#7) from phase/04-cross-project-pipeline into main
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-05-30 21:20:34 +00:00
Jon Chery 895d9f95a1 feat(P04): Add IDEATE stage to orchestrator pipeline — IDEATE-16
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
- Add ideation-agent to STAGE_AGENT_MAP for ideate stage
- Implement ideate case in executeStage() with mechanical ideation,
  config-aware category filtering, idea deduplication, auto-accept,
  and ---ci--- commit with decision block
- Add test verifying ideate position between research and plan in
  STAGE_ORDER
- 542 tests passing
2026-05-30 21:17:21 +00:00
Jon Chery 30352a3603 feat(P03): External/cascade tests + --ideate flag on run (#6)
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-05-30 20:59:40 +00:00
Jon Chery d58fd0bdde feat(P03): external/cascade tests + --ideate flag on run — IDEATE-07,08,15
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
---ci---
phase: 3
milestone: v0.10
status: execute
decisions:
  - id: D-084
    decision: Dual integration: standalone ciagent ideate + --ideate flag on run
    confidence: 0.90
requirements:
  covered:
    - IDEATE-07
    - IDEATE-08
    - IDEATE-15
---/ci---

- IDEATE-07: External signal collection (npm audit, dependency staleness) tested
- IDEATE-08: Cascade impact analysis (--affected) tested
- IDEATE-15: --ideate flag on ciagent run inserts IDEATE stage between RESEARCH and PLAN
- Tests for runAffected, runExternal, runCrossProject
- 541 tests passing
2026-05-30 20:58:30 +00:00
Jon Chery 0799cfc644 feat(P02): Backend-enriched tier, chaos engineering, prioritization — IDEATE-04,05,06,09,10 (#5)
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-05-30 20:51:36 +00:00
Jon Chery 70ee21856d feat(P02): backend-enriched tier, chaos engineering, prioritization — IDEATE-04,05,06,09,10
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
---ci---
phase: 2
milestone: v0.10
status: execute
decisions:
  - id: D-087
    decision: All 6 innovative features in v1 (pattern mining, drift detection, layer inversion, cross-project, chaos, spec)
    rationale: User wants bleeding-edge; all uniquely differentiated
    confidence: 0.82
requirements:
  covered:
    - IDEATE-04
    - IDEATE-05
    - IDEATE-06
    - IDEATE-09
    - IDEATE-10
---/ci---

- IDEATE-04: Verification layer inversion (structural, behavioral, security, quality missing detection)
- IDEATE-05: Architectural drift detection (documented vs actual component comparison)
- IDEATE-06: Spec-driven improvement (ambiguity detection, missing category detection)
- IDEATE-09: Backend-enriched analysis (prioritization, novel suggestions, action plans)
- IDEATE-10: Chaos engineering ideation (backend unavailable, requirement change, coverage drop)
- Deduplicated type exports: IdeationSource/Idea/etc now in types/ideation.ts
- 538 tests passing
2026-05-30 20:50:29 +00:00
Jon Chery b7d02ee4a4 feat(P01): interactive validation + doc updates + multi-project CLI — IDEATE-12,13,14 + MULTI-02,06
CI / build-and-test (pull_request) Has been cancelled
Publish to npm / publish (push) Has been cancelled
CI / build-and-test (push) Has been cancelled
---ci---
phase: 1
milestone: v0.10
status: execute
decisions:
  - id: D-083
    decision: Interactive one-at-a-time validation with accept/skip/modify
    rationale: Gives user full control over ideation results
    confidence: 0.87
  - id: D-085
    decision: Ask-after-validation kickoff of run workflow
    rationale: Balances automation with user control
    confidence: 0.85
  - id: D-091
    decision: Full multi-project support with active_projects array + parallel execution
    rationale: User wants complete multi-project capability
    confidence: 0.85
requirements:
  covered:
    - IDEATE-12
    - IDEATE-13
    - IDEATE-14
    - MULTI-02
    - MULTI-06
---/ci---

- IDEATE-12: Interactive accept/skip/modify validation with readline
- IDEATE-13: acceptIdea/acceptIdeas methods update REQUIREMENTS.md and ROADMAP.md
- IDEATE-14: Ask-after-validation kickoff prompt for
- MULTI-02: --project flag accepts comma-separated or 'all' in pre-action hook
- MULTI-06: ciagent status shows active_projects and ideation config
- projects list shows all active projects with multi-marker
- projects set updates both active_project and active_projects
2026-05-30 20:26:36 +00:00
Jon Chery 8e50049ba5 feat(P01): add ideation engine + ciagent ideate command — IDEATE-01,02,03,17 + MULTI-01
---ci---
phase: 1
milestone: v0.10
status: execute
decisions:
  - id: D-080
    decision: Three-tier ideation (mechanical, backend-enriched, cross-project)
    rationale: Mechanical tier always produces output without backend
    confidence: 0.92
  - id: D-089
    decision: No separate codebase map command
    rationale: Git-native + .ciagent/ covers mapping; avoids tree-sitter dep
    confidence: 0.88
requirements:
  covered:
    - IDEATE-01
    - IDEATE-02
    - IDEATE-03
    - IDEATE-17
    - MULTI-01
---/ci---

Add IdeationEngine core module with 15 signal collectors:
- Uncovered/partial requirements from REQUIREMENTS.md
- Coverage gaps (documented but unimplemented agents)
- Repeated lessons from git history
- Low-confidence decisions from ---ci--- blocks
- Escalation patterns from git history
- Compound solution patterns
- Architecture drift (ARCHITECTURE.md vs src/)
- Verification inversion (missing test files)
- Improvement patterns (cross-referencing lessons + requirements)
- Spec ambiguity (should/could/might patterns)
- Spec missing (common requirement categories)
- Cascade impact (--affected from git diff)
- External signals (npm audit, dependency staleness)
- Cross-project lesson mining

Add ciagent ideate CLI command with flags:
--category, --affected, --spec, --external, --cross-project, --output

Add active_projects to CIAgentConfig (backwards compatible with active_project).
Add IDEATE pipeline stage between RESEARCH and PLAN.
Update IdeationAgent to delegate to IdeationEngine.

533 tests passing.
2026-05-30 20:13:43 +00:00
Jon Chery da528cc493 docs: add ideate workflow + update run workflow with IDEATE stage and multi-project
---ci---
phase: 0
milestone: v0.10
status: specify
decisions:
  - id: D-089
    decision: No separate codebase map command — subsumed by ideation
    rationale: Git-native + .ciagent/ covers all mapping needs; avoids tree-sitter dep
    confidence: 0.88
  - id: D-090
    decision: Milestone v0.10 for ideate + multi-project
    rationale: Significant features but not schema-breaking
    confidence: 0.95
---/ci---

- Add opencode/ci/workflows/ideate.md: full ideation pipeline specification
- Update opencode/ci/workflows/run.md: add IDEATE stage, update multi-project Step 0
2026-05-30 19:45:30 +00:00
Jon Chery a8b50f5109 feat(ci): v0.9.0 — Distribution & Expansion milestone complete
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
---ci---
project: ci
phase: 6
milestone: v0.9
status: complete
artifacts:
  tags: [v0.9.0]
decisions:
  - id: D-047
    decision: v0.9 theme = Distribution & Expansion
    rationale: npm publish + OpenAI/Anthropic backends + agent flesh + parallel execution
    confidence: 0.92
  - id: D-049
    decision: Feature milestone — patch tags v0.8.1-v0.8.6 then v0.9.0
    rationale: OpenAI backend, agent flesh, npm publish all feat
    confidence: 0.95
  - id: D-059
    decision: Rename OllamaBaseBackend to LLMBaseBackend + thin OllamaBaseBackend subclass
    rationale: 15 of 17 methods backend-agnostic
    confidence: 0.92
  - id: D-060
    decision: OpenAI/Anthropic backends use native fetch() not SDK packages
    rationale: No dependency bloat; fetch native in Node 18+
    confidence: 0.85
  - id: D-066
    decision: Concurrency limiter internal (no p-limit dependency)
    rationale: 15 lines; avoids dependency for trivial feature
    confidence: 0.90
  - id: D-067
    decision: Promise.allSettled for review agents at orchestrator lines 373-400
    rationale: Current sequential loop replaced with parallel execution
    confidence: 0.88
requirements:
  covered: [PUBLISH-01, PUBLISH-02, PUBLISH-03, PUBLISH-04, OPENAI-01, OPENAI-02, OPENAI-03, OPENAI-04, OPENAI-05, FLESH-01, FLESH-02, FLESH-03, FLESH-04, FLESH-05, ANTHROPIC-01, ANTHROPIC-02, FLESH-06, FLESH-07, NPM-01, NPM-02, PARALLEL-01, PARALLEL-02, PARALLEL-03, INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05]
---/ci---

6 phases, 28 tasks, 4077 net lines added, 57 test suites, 527 tests, zero stub agents
2026-05-30 02:19:44 +00:00
64 changed files with 7872 additions and 554 deletions
+19
View File
@@ -0,0 +1,19 @@
name: CI
on:
push:
branches: [main, "phase/*", "milestone/*"]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run typecheck
- run: npm run build
- run: npm test
- run: npm pack --dry-run
+20
View File
@@ -0,0 +1,20 @@
name: Publish to npm
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm run typecheck
- run: npm run build
- run: npm test
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+24 -19
View File
@@ -19,9 +19,11 @@ src/
backends/ # Intelligence backend layer backends/ # Intelligence backend layer
types.ts # IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection types.ts # IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection
tool-registry.ts # CIAgent-owned tool implementations (readFile, writeFile, editFile, runBash, glob, grep) tool-registry.ts # CIAgent-owned tool implementations (readFile, writeFile, editFile, runBash, glob, grep)
ollama-base.ts # Abstract base for Ollama backends (shared tool loop, prompt construction) llm-base.ts # Abstract base for LLM backends (shared tool loop, prompt construction)
ollama-local.ts # OllamaLocalBackend (localhost:11434) ollama-local.ts # OllamaLocalBackend (localhost:11434)
ollama-cloud.ts # OllamaCloudBackend (remote endpoint, auth, rate limiting) ollama-cloud.ts # OllamaCloudBackend (remote endpoint, auth, rate limiting)
openai.ts # OpenAIBackend (OpenAI API, gpt-4o)
anthropic.ts # AnthropicBackend (Anthropic API, Claude)
opencode.ts # OpencodeBackend (shells out to opencode --non-interactive) opencode.ts # OpencodeBackend (shells out to opencode --non-interactive)
index.ts # Backend registry + auto-detection index.ts # Backend registry + auto-detection
cli/ # Commander.js CLI (commands.ts, index.ts) cli/ # Commander.js CLI (commands.ts, index.ts)
@@ -53,7 +55,7 @@ src/
security.ts # Layer 3: regex-based threat pattern scanning (no STRIDE analysis yet) security.ts # Layer 3: regex-based threat pattern scanning (no STRIDE analysis yet)
quality.ts # Layer 4: regex-based code quality checks (no multi-persona review yet) quality.ts # Layer 4: regex-based code quality checks (no multi-persona review yet)
index.ts # Public API exports index.ts # Public API exports
version.ts # VERSION = "0.7.0" version.ts # VERSION = "0.9.0"
templates/ # Template files (config.json, DECISIONS.md, specification.md) templates/ # Template files (config.json, DECISIONS.md, specification.md)
``` ```
@@ -82,7 +84,7 @@ templates/ # Template files (config.json, DECISIONS.md, specification.md
## Pipeline Flow ## Pipeline Flow
``` ```
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → TEST → VERIFY → COMPLETE SPECIFY → CLARIFY → RESEARCH → IDEATE → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
``` ```
Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator delegates intelligent stages (research, plan, execute, test, verify) to specialized agents via `context.backend` when available, falling back to mechanical execution when no backend is configured. Mechanical stages (specify, clarify, complete) are always handled by the orchestrator directly. Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator delegates intelligent stages (research, plan, execute, test, verify) to specialized agents via `context.backend` when available, falling back to mechanical execution when no backend is configured. Mechanical stages (specify, clarify, complete) are always handled by the orchestrator directly.
@@ -94,7 +96,8 @@ IntelligenceBackend (unified interface)
├── LLMBackend (CIAgent runs tool loop, provides tools, constructs prompts) ├── LLMBackend (CIAgent runs tool loop, provides tools, constructs prompts)
│ ├── OllamaLocalBackend (localhost:11434, no auth) │ ├── OllamaLocalBackend (localhost:11434, no auth)
│ ├── OllamaCloudBackend (remote endpoint, API key, rate limits) │ ├── OllamaCloudBackend (remote endpoint, API key, rate limits)
── (future: OpenAI, Anthropic, Gemini, etc.) ── OpenAIBackend (OpenAI API, gpt-4o, API key auth)
│ └── AnthropicBackend (Anthropic API, Claude, API key auth)
└── AgentBackend (agent runs own tool loop, CIAgent sends request) └── AgentBackend (agent runs own tool loop, CIAgent sends request)
├── OpencodeBackend (opencode --non-interactive) ├── OpencodeBackend (opencode --non-interactive)
└── (future: Codex, Claude Code, Hermes, etc.) └── (future: Codex, Claude Code, Hermes, etc.)
@@ -102,8 +105,8 @@ IntelligenceBackend (unified interface)
- **LLM backends**: CIAgent constructs system prompts from persona.md + workflow.md, defines tool schemas, runs the tool-call loop via `ToolRegistry`, and parses structured JSON output - **LLM backends**: CIAgent constructs system prompts from persona.md + workflow.md, defines tool schemas, runs the tool-call loop via `ToolRegistry`, and parses structured JSON output
- **Agent backends**: CIAgent serializes `BackendRequest`, invokes the agent, and parses JSON `BackendResult` from stdout - **Agent backends**: CIAgent serializes `BackendRequest`, invokes the agent, and parses JSON `BackendResult` from stdout
- **Auto-detection** (provider: "auto"): tries opencode → ollama-local → ollama-cloud → fails with instructions - **Auto-detection** (provider: "auto"): tries opencode → openai → ollama-local → ollama-cloud → anthropic → fails with instructions
- **Per-command override**: `ciagent run --backend ollama-local` forces a specific backend - **Per-command override**: `ciagent run --backend ollama-local` forces a specific backend (options: opencode, openai, anthropic, ollama-local, ollama-cloud)
- **Config**: `backend` section in `.ciagent/config.json` with provider, fallback, agent_backends, llm_backends - **Config**: `backend` section in `.ciagent/config.json` with provider, fallback, agent_backends, llm_backends
## Agent Modification Rules (from PRD) ## Agent Modification Rules (from PRD)
@@ -131,7 +134,7 @@ IntelligenceBackend (unified interface)
- Test framework: Jest with ts-jest - Test framework: Jest with ts-jest
- Test file pattern: `**/*.test.ts` in `src/` - Test file pattern: `**/*.test.ts` in `src/`
- Run: `npm run test` - Run: `npm run test`
- 44 test suites, 454 tests covering types, core, git-native, verification, agent, backends, and utility modules - 58 test suites, 561 tests covering types, core, git-native, verification, agent, backends, ideation, multi-project, and utility modules
- Tests use temp directories (os.mkdtempSync) and clean up after each test - Tests use temp directories (os.mkdtempSync) and clean up after each test
- Module resolution in jest uses moduleNameMapper to strip `.js` extensions - Module resolution in jest uses moduleNameMapper to strip `.js` extensions
@@ -191,16 +194,18 @@ IntelligenceBackend (unified interface)
## Current State ## Current State
- **v0.7.0**: Backends module (OllamaLocal, OllamaCloud, Opencode), learnship references removed, verification layers migrated from .planning/ to .ciagent/ - **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
- **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) - **v0.9.0**: Integration & hardening — OpenAI and Anthropic backends, all 19 agents with intrinsic mechanical logic, E2E v0.9 integration tests, parallel agent execution
- **Commit schema**: Every CIAgent-generated commit contains a `---ci---` YAML block with phase, milestone, status, decisions, escalations, requirements, lessons, and compound metadata - **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows
- **New in v0.10**: IdeationEngine with mechanical/backend-enriched/cross-project tiers, `ciagent ideate` command with --category/--affected/--spec/--external/--cross-project/--project/--output flags, `IDEATE` pipeline stage between RESEARCH and PLAN, multi-project support with `active_projects` config and `--project all` flag, `---ci--- project: <slug>` commit blocks, `max_concurrent_projects` parallelization config
- **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation)
- **Config expansion (v0.10)**: `ideation` section in config with categories, thresholds, external signals, cross-project, chaos; `active_projects` array; `max_concurrent_projects` in parallelization
- **Auto-detection order**: opencode → openai → ollama-local → ollama-cloud → anthropic
- **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.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 - **Branch strategy**: `phase/NN-slug` and `milestone/vX.X-slug` branches encode project structure; merged = complete, active = in progress
- **Core engine rewrites**: DecisionEngine generates commit messages (not audit JSON), EscalationProtocol commits escalations as git artifacts, OrchestratorAgent uses git log as first impulse - **CLI commands**: `init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`, `ideate`, `projects`
- **Removed**: `.ciagent/audit/` directory (audit trail is git log), `.planning/` directory (dynamic state derived from git history) - **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.
- **`.ciagent/` contents**: `config.json`, `PROJECT.md`, `ARCHITECTURE.md`, `ROADMAP.md`, `REQUIREMENTS.md` — long-lived reference docs updated with discipline - **Tests**: 58 test suites, 561 tests covering types, config, decision-engine, escalation, clarify, commit-parser, commit-builder, git-context, git-branch, ciagent-files, ideation, multi-project, all 4 verification layers, file utils, backends (ollama, openai, anthropic, opencode, tool-registry), agents (all 18 non-orchestrator), zod validation, E2E, parallel execution
- **Reconstruction test**: An agent with only commit message access can reconstruct project state (phase, decisions, requirements coverage, lessons, escalations)
- **Verification layers**: All 4 layers implemented — structural, behavioral, security, quality
- **CLI**: All 11 commands wired up (`init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`)
- **Agent implementations**: Persona loaders that delegate to active backend. Fail honestly when no backend is available (no more fake success).
- **Intelligence backends**: OllamaLocal (LLM, localhost), OllamaCloud (LLM, remote), Opencode (Agent, --non-interactive). Auto-detection: opencode → ollama-local → ollama-cloud.
- **Tests**: 44 test suites, 454 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, tool-registry, agents (security-auditor, doc-writer, debugger, challenger, code-reviewer), zod validation, e2e
+131 -13
View File
@@ -8,6 +8,20 @@ CIAgent (Continuous Intelligence) is an autonomous-first software engineering ha
**The git log IS the project memory.** Every decision, escalation, lesson learned, and verification result is encoded in commit messages using structured `---ci---` YAML blocks. An agent's first impulse to gather context is `git log`, not file reads. Another agent with access to only commit messages (no code, no diffs) can reconstruct the project state completely. **The git log IS the project memory.** Every decision, escalation, lesson learned, and verification result is encoded in commit messages using structured `---ci---` YAML blocks. An agent's first impulse to gather context is `git log`, not file reads. Another agent with access to only commit messages (no code, no diffs) can reconstruct the project state completely.
## Intelligence Backends
CIAgent supports 5 intelligence backends. Set the appropriate environment variable and use `--backend` to select:
| Backend | Setup | Usage |
|---------|-------|-------|
| **OpenAI** | `export OPENAI_API_KEY=sk-...` | `ciagent run --backend openai` |
| **Anthropic** | `export ANTHROPIC_API_KEY=sk-ant-...` | `ciagent run --backend anthropic` |
| **Ollama Local** | `ollama serve` (localhost:11434) | `ciagent run --backend ollama-local` |
| **Ollama Cloud** | `export OLLAMA_CLOUD_API_KEY=...` | `ciagent run --backend ollama-cloud` |
| **Opencode** | `npm i -g opencode` | `ciagent run --backend opencode` |
Auto-detection (`--backend auto`, the default) tries: opencode → openai → ollama-local → ollama-cloud → anthropic.
## Installation ## Installation
From source (package not yet published to npm): From source (package not yet published to npm):
@@ -38,12 +52,39 @@ ciagent run plan
ciagent run execute ciagent run execute
ciagent run verify ciagent run verify
# Run with specific backends
ciagent run --all --backend openai
ciagent run --all --backend anthropic
ciagent run --all --backend ollama-local
# Execute an ad-hoc task # Execute an ad-hoc task
ciagent quick "Add authentication middleware" ciagent quick "Add authentication middleware"
# Check project status (reads from git log + branches) # Check project status (reads from git log + branches)
ciagent status ciagent status
# Discover improvement opportunities
ciagent ideate # Mechanical tier (always available)
ciagent ideate --category security # Focus on specific categories
ciagent ideate --affected # Cascade impact analysis
ciagent ideate --spec # Specification completeness analysis
ciagent ideate --external # npm audit + dependency staleness
ciagent ideate --cross-project # Cross-project pattern mining
ciagent ideate --project all # Run across all active projects
ciagent ideate --output json # JSON output mode
ciagent ideate --output markdown # Markdown output mode
# Manage multiple projects
ciagent projects list # List all registered projects
ciagent projects add <slug> <name> # Add a new project
ciagent projects set <slug> # Set the active project
# Run with ideation stage
ciagent run --ideate # Insert IDEATE stage between RESEARCH and PLAN
# Run across all active projects
ciagent run --project all # Execute pipeline for each project
# Review autonomous decisions (extracted from git log ---ci--- blocks) # Review autonomous decisions (extracted from git log ---ci--- blocks)
ciagent audit ciagent audit
ciagent audit --verbose ciagent audit --verbose
@@ -58,7 +99,7 @@ ciagent rollback 1
ciagent ship 1 ciagent ship 1
``` ```
## Git-Native Architecture (v0.2.0) ## Git-Native Architecture (v0.10.0)
### The Commit Schema ### The Commit Schema
@@ -92,7 +133,7 @@ requirements:
| Where | What | Why | | Where | What | Why |
|-------|------|-----| |-------|------|-----|
| `.ciagent/config.json` | Autonomy, thresholds, git strategy | Controls system behavior before any commits exist | | `.ciagent/config.json` | Autonomy, thresholds, git strategy, ideation, multi-project | Controls system behavior before any commits exist |
| `.ciagent/PROJECT.md` | Vision, core value, requirements, constraints, key decisions table | Long-lived strategic reference | | `.ciagent/PROJECT.md` | Vision, core value, requirements, constraints, key decisions table | Long-lived strategic reference |
| `.ciagent/ARCHITECTURE.md` | System architecture, component boundaries, data flow | Long-lived technical reference | | `.ciagent/ARCHITECTURE.md` | System architecture, component boundaries, data flow | Long-lived technical reference |
| `.ciagent/ROADMAP.md` | Phase breakdown, milestone mapping, success criteria | Long-lived planning reference | | `.ciagent/ROADMAP.md` | Phase breakdown, milestone mapping, success criteria | Long-lived planning reference |
@@ -185,7 +226,8 @@ CIAgent uses `.ciagent/config.json` for project configuration:
"parallelization": { "parallelization": {
"enabled": true, "enabled": true,
"max_concurrent_agents": 5, "max_concurrent_agents": 5,
"min_plans_for_parallel": 2 "min_plans_for_parallel": 2,
"max_concurrent_projects": 3
}, },
"verification": { "verification": {
"automated_only": true, "automated_only": true,
@@ -202,6 +244,25 @@ CIAgent uses `.ciagent/config.json` for project configuration:
"branching_strategy": "phase", "branching_strategy": "phase",
"auto_commit": true, "auto_commit": true,
"auto_push": false "auto_push": false
},
"ideation": {
"enabled": true,
"categories": ["security", "quality", "architecture", "coverage", "improvement"],
"confidence_threshold": 0.6,
"max_ideas": 20,
"external_signals": {
"npm_audit": true,
"osv_advisories": true,
"dependency_staleness": true
},
"cross_project": {
"enabled": false,
"similarity_weight": 0.5
},
"chaos": {
"enabled": true,
"scenarios": ["backend_unavailable", "requirement_change", "test_coverage_drop"]
}
} }
} }
``` ```
@@ -211,9 +272,9 @@ CIAgent uses `.ciagent/config.json` for project configuration:
### Pipeline ### Pipeline
``` ```
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → TEST → VERIFY → COMPLETE SPECIFY → CLARIFY → RESEARCH → IDEATE → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
↕ ↕ ↕ ↕ ↕ ↕ ↕ ↕ ↕ ↕
(questions) (auto-decide) (auto-run) (auto-test) (auto-verify) (questions) (auto-decide) (ideas) (auto-run) (auto-test) (auto-verify)
``` ```
### Git-Native Core Modules ### Git-Native Core Modules
@@ -246,16 +307,74 @@ Decisions are committed to git as `decision` type commits. The audit trail is `g
| researcher | Domain research | Logs assumptions, never flags for human | | researcher | Domain research | Logs assumptions, never flags for human |
| tester | Integration/e2e tests | Detects and runs existing test files, never writes tests | | tester | Integration/e2e tests | Detects and runs existing test files, never writes tests |
| challenger | Plan stress-testing | Binding verdicts, only escalates <0.60 | | challenger | Plan stress-testing | Binding verdicts, only escalates <0.60 |
| security-auditor | Security audit | Auto-dispositions threats | | security-auditor | Security audit | Auto-dispositions threats (STRIDE + CWE) |
| debugger | Bug fixing | Auto-fixes when confidence > threshold | | debugger | Bug fixing | Auto-fixes when confidence > threshold |
| Others | Various | Delegates to active intelligence backend | | code-reviewer | Code review | 3-persona review (security, performance, maintainability) |
| doc-writer | Documentation | Auto-updates ROADMAP/REQUIREMENTS/PROJECT.md |
| doc-verifier | Doc audit | Cross-checks docs vs. codebase (agent count, version, test count) |
| ideation-agent | Improvement ideas | Feeds uncovered requirements and repeated lessons into planning |
| roadmapper | Roadmap creation | Groups requirements by phase, generates success criteria |
| plan-checker | Plan validation | Checks structure, IDs, must-haves, wave order, requirement coverage |
| project-researcher | Ecosystem research | Detects frameworks, APIs, patterns, tooling from package.json |
| research-synthesizer | Research merge | Cross-references findings across .ciagent/ documents |
| 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 ### Verification Layers
1. **Structural**: File existence, import/export wiring, no stubs 1. **Structural**: File existence, import/export wiring, no stubs
2. **Behavioral**: Test infrastructure and requirement traceability (partially implemented — static analysis, no test generation yet) 2. **Behavioral**: Test execution and requirement traceability — runs test framework, parses results, reports pass/fail per suite
3. **Security**: Regex-based threat pattern scanning with auto-disposition (partially implemented — no STRIDE analysis yet) 3. **Security**: STRIDE threat pattern scanning with CWE mapping and confidence-based auto-disposition
4. **Code Quality**: Regex-based code quality checks (partially implemented — no multi-persona review yet) 4. **Code Quality**: 3-persona code review (security, performance, maintainability) with P0/P1/P2 findings
## Specification Format ## Specification Format
@@ -293,9 +412,8 @@ Each escalation is committed as an `escalation` type commit. Resolved escalation
## Current Limitations ## Current Limitations
- **Agent implementations**: 5 core agents have intrinsic logic (planner, executor, verifier, researcher, tester); 13 agents delegate to backends. Full LLM-powered agent behavior requires an intelligence backend. - **Agent implementations**: All 18 non-orchestrator agents have intrinsic mechanical logic. Full LLM-powered agent behavior requires an intelligence backend (OpenAI, Anthropic, Ollama, or Opencode).
- **Package not published to npm**: Install from source only until a publishing pipeline is configured. - **Package not published to npm**: Install from source only until a publishing pipeline is configured.
- **Behavioral/Security/Quality verification layers**: Partially implemented — structural verification is complete; behavioral does static analysis; security does regex-based threat scanning; quality does regex-based code quality checks.
## Differences from Learnship ## Differences from Learnship
+31 -13
View File
@@ -106,20 +106,27 @@ Phase branches can be deleted after merge if desired.
**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 a 3-tier model based on milestone type:
### 3-Tier Versioning Model ### Milestone Type and Versioning
The milestone type is determined **before any development work** and governs all versioning for the entire milestone.
**Define semver at milestone start:** establish the version and milestone type before writing code.
Determine milestone type via `getMilestoneType()` which returns `"nfr" | "feature" | "major"`:
| Milestone Type | Condition | Phase release | Milestone release | | Milestone Type | Condition | Phase release | Milestone release |
|---------------|-----------|---------------|-------------------| |---------------|-----------|---------------|-------------------|
| **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None | | **NFR** | All phases are fix/chore/docs/perf/refactor/test | Patch `v1.8.1`, `v1.8.2`, ... | None — final patch IS the deliverable |
| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` | | **Feature** | At least one phase has new features (`feat`) | Patch `v1.8.1`, `v1.8.2`, ... | Next minor — `v1.9.0` |
| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` | | **Major** | Breaking schema changes or complete refactor | Minor — `v2.1.0`, `v2.2.0`, ... | Major — `v3.0.0` |
**IMPORTANT:** Milestone tags are always the NEXT version, never the base: **Tag rules (CRITICAL):**
- Milestone tags are always the NEXT version, never the base:
- Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0) - Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0)
- Schema-breaking: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0 - Major: 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 - 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
Determine milestone type via `getMilestoneType()` which returns `"nfr" | "feature" | "schema-breaking"`. - NEVER create a milestone tag that is semantically below existing phase tags
### Phase completion ### Phase completion
@@ -135,7 +142,7 @@ git push origin main --tags
Phase number within the milestone determines the patch version (1st phase = .1, 2nd phase = .2, etc.) Phase number within the milestone determines the patch version (1st phase = .1, 2nd phase = .2, etc.)
**Schema-breaking (minor release per phase):** **Major (minor release per phase):**
```bash ```bash
git checkout milestone/v0.5-schema-rewrite git checkout milestone/v0.5-schema-rewrite
git merge --squash phase/01-core-refactor git merge --squash phase/01-core-refactor
@@ -145,7 +152,7 @@ git push origin main --tags
# Create Gitea release for v0.5.0 # Create Gitea release for v0.5.0
``` ```
Each schema-breaking phase bumps the minor. 1st phase = next available minor, 2nd = minor+1, etc. Each major phase bumps the minor. 1st phase = next available minor, 2nd = minor+1, etc.
### Milestone completion ### Milestone completion
@@ -160,7 +167,7 @@ git push origin main --tags
# Create Gitea release for v0.6.0 with full milestone summary # Create Gitea release for v0.6.0 with full milestone summary
``` ```
**Schema-breaking (major release):** **Major (major release):**
```bash ```bash
# All phases already merged into milestone branch # All phases already merged into milestone branch
git checkout main git checkout main
@@ -177,9 +184,20 @@ git push origin main --tags
Before creating any tag: Before creating any tag:
1. Tag must be strictly greater than all existing tags on the same major.minor line 1. Tag must be strictly greater than all existing tags on the same major.minor line
2. Milestone completion tag must be next minor (feature) or next major (schema-breaking) 2. Milestone completion tag must be next minor (feature) or next major (major)
3. NEVER create a tag that is semantically below existing phase tags 3. NEVER create a tag that is semantically below existing phase tags
### Merge Validation Gates
The branch hierarchy `main > milestone/vX.X-slug > phase/NN-slug` is enforced at merge time:
| Merge Type | Rule | Validation |
|------------|------|-------------|
| Phase → Milestone | Must target milestone branch when one exists | REJECTED if milestone branch does not exist for this phase's milestone |
| Phase → Main | Only allowed when no milestone branch exists | REJECTED if a milestone branch exists for this milestone |
| Milestone → Main | Only after all phase branches are merged | REJECTED if any phase branches for this milestone are unmerged |
| Hotfix → Main | Allowed (exception to hierarchy) | Always allowed |
## Multi-Project Branch Naming ## Multi-Project Branch Naming
When operating in multi-project mode (`.ciagent/config.json` has `projects[]` with length > 0): When operating in multi-project mode (`.ciagent/config.json` has `projects[]` with length > 0):
+288
View File
@@ -0,0 +1,288 @@
---
description: Run the CIAgent ideation pipeline — analyze project for improvement opportunities, validate recommendations with user, update long-term documents
---
# CIAgent Ideate
Run the CIAgent ideation engine to discover improvement opportunities based on git-native signals, codebase analysis, and cross-project patterns.
**Usage:** `ciagent ideate [options]`
## Step 0: Confirm Active Project
Check `ci listProjects()` or read `.ciagent/config.json` to determine project context.
If `.ciagent/config.json` has `active_projects` array with length > 0:
- Use `--project <slug>` to target a specific project
- Use `--project all` to run ideation across all active projects (deduplicate findings)
- If no `--project` flag, use first project in `active_projects`
If `.ciagent/config.json` has `active_project` string (legacy):
- Use that project as the target
- Backwards-compatible: if both `active_project` and `active_projects` exist, `active_projects` takes precedence
## Step 1: Load Project Context
```bash
git log --max-count=50
git branch -a
```
Read project reference files:
- `.ciagent/PROJECT.md` — Vision, requirements, constraints, key decisions
- `.ciagent/ROADMAP.md` — Phases, milestones, success criteria
- `.ciagent/REQUIREMENTS.md` — REQ-IDs, status, traceability
- `.ciagent/ARCHITECTURE.md` — Component boundaries, data flow
- `.ciagent/config.json` — Ideation configuration, autonomy level
## Step 2: Run Ideation Tiers
Execute tiers in order. Each tier produces `Idea[]` objects. Ideas from all tiers are merged and deduplicated before presentation.
### Tier 1: Mechanical Analysis (Always Available)
No backend required. All signals come from git history, `.ciagent/` files, and filesystem.
#### 2.1 Git-Native Pattern Mining
```bash
git log --all --grep="lessons:" --format="%B" -50
git log --all --grep="decisions:" --format="%B" -50 -- "***confidence***0.*"
git log --all --grep="escalation:" --format="%B" -50
git log --all --grep="compound:" --format="%B" -50
```
Extract:
- **Repeated lessons** — topics appearing > 1 time → systemic issue
- **Low-confidence decisions** — confidence < 0.7 in `---ci---` blocks → improvement targets
- **Escalation types** — each type identifies a process gap
- **Compound solutions** — suggest generalizing patterns that were solved multiple times
- **Partial requirements** — `requirements: partial: [REQ-XX]` in `---ci---` blocks
#### 2.2 Coverage Gap Analysis
- Parse REQUIREMENTS.md for `pending` and `in_progress` status requirements
- Cross-reference with PLAN.md task completion
- Identify requirements with no corresponding implementation tasks
#### 2.3 Verification Layer Inversion
For each verification layer, identify what's MISSING:
- **Structural**: Files referenced but not created, stubs, TODOs, placeholder implementations
- **Behavioral**: Test suites with < 80% coverage, missing test files for covered requirements
- **Security**: No STRIDE analysis for modified components, missing input validation patterns
- **Quality**: P1/P2 review findings unresolved, consistent style violations
#### 2.4 Architectural Drift Detection
- Parse ARCHITECTURE.md component tree
- Compare against actual `src/` directory structure
- Flag components documented but not implemented
- Flag components implemented but not documented
- Check import graph for unauthorized dependencies between components
#### 2.5 Spec-Driven Improvement
- Analyze REQUIREMENTS.md for ambiguous language ("should" vs "must", undefined terms)
- Check for contradictions between requirements
- Compare against common patterns for the project type (identified from package.json keywords)
- Flag requirements with no verification criteria
### Tier 2: Backend-Enriched Analysis (When LLM Available)
Requires an intelligence backend (opencode, openai, anthropic, or ollama).
#### 2.6 Prioritization and Ranking
- Evaluate all mechanical findings for impact and feasibility
- Rank ideas by: (1) number of signals corroborating, (2) severity of the gap, (3) ease of addressing
#### 2.7 Novel Improvement Suggestions
- Suggest improvements beyond pattern matching (e.g., "consider rate limiting" based on industry best practices, not just a repeated lesson)
- Generate concrete action plans for each accepted idea
- Identify bleeding-edge approaches relevant to the project's tech stack
#### 2.8 Chaos Engineering Ideation
- Generate failure scenarios: "What if the backend is unavailable?", "What if a requirement changes mid-implementation?", "What if test coverage drops below threshold?"
- Map failure scenarios to code that would break
- Suggest resilience improvements for each scenario
### Tier 3: Cross-Project Pattern Transfer (When Multi-Project Registry Exists)
#### 2.9 Cross-Project Mining
For each project in `.ciagent/config.json` projects array:
- Read that project's `---ci---` blocks for lessons, decisions, compound solutions
- Find patterns relevant to the current project (same requirement area, same tech stack from package.json)
- Suggest adaptations of lessons learned elsewhere
- Calculate relevance score based on tech stack similarity
## Step 3: Merge and DeduplicateIdeas
Combine ideas from all tiers. Deduplicate by:
- Same `title` strings → keep highest confidence version
- Same `relatedReq` → merge into single idea with combined sources
- Same `category` + overlapping domains → keep most specific
Sort by confidence (descending), then by number of corroborating signals.
## Step 4: Interactive Validation
Present ideas one-at-a-time to the user:
```
═══ Recommendation N of M ═══
Category: [CATEGORY] | Confidence: [0.XX] | Tier: [mechanical/backend-enriched/cross-project]
Title: [idea title]
Rationale: [idea rationale]
Related Req: [REQ-ID or "new requirement"]
Source: [source signal type]
Actions:
1. Accept (add to next milestone as new requirement)
2. Skip
3. Modify (edit title/rationale before accepting)
4. Details (show full analysis including signal sources)
```
For each accepted idea:
1. Generate `IDEATE-NN` requirement ID
2. Prompt for milestone placement (append to existing or create new)
3. Add to REQUIREMENTS.md with status `pending`
4. Add to ROADMAP.md next milestone
## Step 5: Update Long-Term Documents
For each accepted idea:
### REQUIREMENTS.md
Add a new row in the appropriate milestone section:
```
| IDEATE-NN | [idea title] | [priority] | [phase] | pending |
```
### ROADMAP.md
Add the idea to the next milestone's phase structure:
- If next milestone has a matching phase category, append to that phase
- If no matching phase, suggest a new phase
### ARCHITECTURE.md
If the idea involves architectural changes, note the component change needed.
### PROJECT.md
If the idea adds new requirements or key decisions, update accordingly.
Commit all document updates:
```
decision(P##): ideation results — [N] accepted, [M] skipped
```
## Step 6: Ask-After-Validation Kickoff
After all ideas have been validated:
```
Accepted: [N] recommendations
Skipped: [M] recommendations
Would you like to kick off the run workflow for these ideas? (y/n)
```
If yes: Start `ciagent run` with the updated project context. The `--ideate` flag is NOT needed because the ideas are already in ROADMAP.md and REQUIREMENTS.md — the standard pipeline will pick them up.
If no: Output summary and exit.
## Command Flags
| Flag | Description |
|------|-------------|
| `--category <cats>` | Focus on specific categories: security,quality,architecture,coverage,improvement,spec,chaos (comma-separated) |
| `--affected` | Cascade impact analysis: given current changes, what else needs updating |
| `--spec` | Analyze specification completeness and ambiguity |
| `--external` | Include external signals: npm audit, OSV advisories, dependency staleness |
| `--cross-project` | Mine patterns from all projects in multi-project registry |
| `--output <format>` | Output format: interactive (default), json, markdown |
| `--project <slugs>` | Target project(s): slug, comma-separated, or `all` |
| `--backend <provider>` | Override intelligence backend for enrichment tier |
## Pipeline Integration
When `ciagent run --ideate` is used, the IDEATE stage is inserted between RESEARCH and PLAN:
```
SPECIFY → CLARIFY → RESEARCH → IDEATE → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
```
IDEATE stage commit:
```
---ci---
phase: [phase-number]
milestone: [milestone-version]
status: ideate
decisions:
- id: D-XXX
decision: "Accepted [N] ideation recommendations"
rationale: "[summary of accepted ideas]"
confidence: [avg confidence]
requirements:
covered: [IDEATE-NN, ...]
---/ci---
```
## Output Modes
### Interactive (default)
Presented one-at-a-time with accept/skip/modify actions.
### JSON
```json
{
"project": "[slug]",
"milestone": "[version]",
"ideas": [
{
"id": "IDEATE-NN",
"source": "[source type]",
"category": "[category]",
"title": "[title]",
"rationale": "[rationale]",
"confidence": 0.XX,
"relatedReq": "[REQ-ID or null]",
"actions": ["[action types]"],
"tier": "[mechanical/backend-enriched/cross-project]",
"accepted": true
}
],
"summary": {
"total": 8,
"accepted": 6,
"skipped": 2,
"by_category": { "coverage": 2, "architecture": 1, "security": 1, "quality": 1, "improvement": 1 }
}
}
```
### Markdown
Formatted report suitable for PR descriptions or documentation.
## Error Recovery
On tier failure:
1. Mechanical tier always succeeds (git + filesystem only)
2. Backend-enriched tier: if backend unavailable, fall back to mechanical-only output
3. Cross-project tier: if no other projects in registry, skip silently
On validation failure (no ideas generated):
- Output "No improvement ideas identified for this project."
- Suggest `ciagent ideate --spec` for specification analysis or `--external` for external signals
+25 -8
View File
@@ -14,11 +14,18 @@ If no phase number specified, continues from the current phase (detected from gi
Check `ci listProjects()` or read `.ciagent/config.json` to determine if multi-project mode is active. Check `ci listProjects()` or read `.ciagent/config.json` to determine if multi-project mode is active.
If `.ciagent/config.json` has `projects[]` with length > 0: If `.ciagent/config.json` has `projects[]` with length > 0, or `active_projects` array exists:
- Confirm `active_project` is correct for this run - Confirm `active_projects` is correct for this run
- If not, set it with `ci setActiveProject(<slug>)` - If `--project all` is specified: iterate over all projects in `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 commit messages must include `project: <slug>` in `---ci---` block
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
If single-project mode: proceed with existing conventions. If single-project mode: proceed with existing conventions.
## Step 1: Load Git Context ## Step 1: Load Git Context
@@ -60,6 +67,15 @@ For each stage in order (starting from current or from `specify`):
- Update `.ciagent/` static files with conclusions - Update `.ciagent/` static files with conclusions
- Commit: `docs(P##): research findings` - Commit: `docs(P##): research findings`
### 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`
### PLAN ### PLAN
- Delegate to ci-planner - Delegate to ci-planner
- Create vertical-slice plans with wave ordering - Create vertical-slice plans with wave ordering
@@ -85,7 +101,7 @@ For each stage in order (starting from current or from `specify`):
- Update `.ciagent/ROADMAP.md` phase status - Update `.ciagent/ROADMAP.md` phase status
- Commit: `docs(P##): complete [phase-name] phase` - Commit: `docs(P##): complete [phase-name] phase`
Versioning: Major = project-level refactor/schema change, Minor = milestone completion, Patch = every phase. Versioning: Major milestone = breaking schema changes, Feature milestone = milestone completion (minor), Patch = every phase.
## Phase Boundary Checkpoint ## Phase Boundary Checkpoint
@@ -97,12 +113,13 @@ Between phases, perform a context reset:
4. Reset context: spawn fresh agent (opencode) or re-read git context (platforms without subagents) 4. Reset context: spawn fresh agent (opencode) or re-read git context (platforms without subagents)
5. Next phase begins with fresh context from git log only 5. Next phase begins with fresh context from git log only
## NFR Versioning Logic ## Versioning Logic
Before tagging a phase completion, check `isNfrMilestone()`: Before tagging a phase completion, check `getMilestoneType()` which returns `"nfr" | "feature" | "major"`:
- **NFR milestone** (all phases are fix/chore/docs/perf/refactor/test): apply progressive patch versions (v0.1.1, v0.1.2, v0.1.3). No separate milestone tag. - **NFR milestone** (all phases are fix/chore/docs/perf/refactor/test): apply progressive patch versions (v0.1.1, v0.1.2, v0.1.3). No separate milestone tag — the final patch IS the deliverable.
- **Feature milestone** (any feat phase): apply progressive patch versions per phase, then tag minor milestone version on completion (e.g., v0.2.0). - **Feature milestone** (at least one feat phase): apply progressive patch versions per phase, then tag next minor milestone version on completion (e.g., v0.6.0, NOT v0.5.0).
- **Major milestone** (breaking schema changes or complete refactor): apply progressive minor versions per phase (v0.3.0, v0.4.0), then tag next major on completion (e.g., v1.0.0).
## Step 4: Error Recovery ## Step 4: Error Recovery
+132 -32
View File
@@ -1,25 +1,45 @@
--- ---
description: Ship CIAgent phase or milestone — test, tag, release. Every phase and milestone gets a release. Full autopilot. description: Ship CIAgent phase or milestone — Full autopilot release: validate, test, merge, tag, push, release. Zero HITL
--- ---
# CIAgent Ship # CIAgent Ship
Ship a CIAgent phase or milestone. Every ship creates a release — no exceptions. Ship a CIAgent phase or milestone. Every ship creates a release — no exceptions.
**3-Tier Versioning Model:** **Usage:** `ciagent-ship [phase_number|milestone]`
## Autopilot Rules
These rules are **non-negotiable**. The ship workflow runs in full autopilot mode:
- **Zero HITL** — no confirmation prompts, no approval gates, no requests for human input. The agent executes the entire release flow autonomously.
- **No Shortcuts** — deep validation, testing, and merge checks must all run in full. The lack of HITL is not an excuse to skip steps.
- **Notification Only** — status updates are informational, not requests for approval. Report outcomes, never ask permission.
- **Autonomous Loop on Failure** — if any step fails (tests, pipeline, merge conflicts), iterate autonomously until success. Do NOT ask the user for guidance on how to fix a failing test or pipeline.
- **Branch Hierarchy Enforced** — `main > milestone/vX.X-slug > phase/NN-slug`. Phase merges into milestone, milestone merges into main. This is validated, not assumed.
## Milestone Type and Versioning
The milestone type is determined **before any development work** and governs all versioning for the entire milestone.
**Define semver at milestone start:** establish the version and milestone type before writing code.
Determine milestone type by calling `getMilestoneType()` which returns `"nfr" | "feature" | "major"`:
| Milestone Type | Condition | Phase release | Milestone release | | Milestone Type | Condition | Phase release | Milestone release |
|---------------|-----------|---------------|-------------------| |---------------|-----------|---------------|-------------------|
| **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None | | **NFR** | All phases are fix/chore/docs/perf/refactor/test | Patch `v1.8.1`, `v1.8.2`, ... | None — final patch IS the deliverable |
| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` | | **Feature** | At least one phase has new features (`feat`) | Patch `v1.8.1`, `v1.8.2`, ... | Next minor — `v1.9.0` |
| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` | | **Major** | Breaking schema changes or complete refactor | Minor — `v2.1.0`, `v2.2.0`, ... | Major — `v3.0.0` |
**CRITICAL:** Milestone tags are always the NEXT version, never the base: **Tag rules (CRITICAL):**
- Milestone tags are always the NEXT version, never the base:
- Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0) - Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0)
- Schema-breaking: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0 - Major: 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 - 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
**Usage:** `ciagent-ship [phase_number|milestone]` - NEVER create a milestone tag that is semantically below existing phase tags
## Step 0: Confirm Active Project ## Step 0: Confirm Active Project
@@ -33,11 +53,12 @@ If `.ciagent/config.json` has `projects[]` with length > 0:
If single-project mode: proceed with existing conventions. If single-project mode: proceed with existing conventions.
## Step 1: Pre-Flight ## Step 1: Pre-Flight Validation
```bash ```bash
git log --max-count=10 git log --max-count=10
git branch -a git branch -a
git tag -l
``` ```
Determine what is being shipped: a single phase or an entire milestone. Determine what is being shipped: a single phase or an entire milestone.
@@ -49,6 +70,16 @@ Read `.ciagent/ROADMAP.md` to determine:
Read `.ciagent/config.json` for autonomy level. Read `.ciagent/config.json` for autonomy level.
**Validation gates — all must pass before proceeding:**
1. **Milestone type resolved**`getMilestoneType()` must return `"nfr" | "feature" | "major"`. Stop if undefined.
2. **Branch hierarchy correct** — phase branch exists and targets the correct parent (milestone branch, or main if no milestone branch exists).
3. **No unmerged phase branches** — if shipping a milestone, all phase branches for this milestone must be merged into the milestone branch.
4. **Tag sequence valid** — the computed tag must be strictly greater than all existing tags on the same major.minor line. Check with `git tag -l`.
5. **Autonomy confirmed**`.ciagent/config.json` autonomy level must be `full`. This is the zero-HITL enforcement point.
If any validation fails: stop and report. Do NOT proceed past a failed gate.
## Step 2: Run Tests ## Step 2: Run Tests
```bash ```bash
@@ -59,33 +90,77 @@ npm run build
If any fail: iterate autonomously until tests pass. Do NOT ask the user for guidance — debug and fix. If any fail: iterate autonomously until tests pass. Do NOT ask the user for guidance — debug and fix.
## Step 3: Compute Version ## Step 3: Create PR and Quality Assurance
Determine milestone type by calling `getMilestoneType()` which returns `"nfr" | "feature" | "schema-breaking"`: **Open a Pull Request for the merge target:**
```bash
tea pr create --base <target-branch> --head <source-branch> --title "ship: [phase-name or milestone-name]"
```
- For a phase ship: PR from `phase/NN-slug` into `milestone/vX.Y-slug` (or `main` if no milestone branch).
- For a milestone ship: PR from `milestone/vX.Y-slug` into `main`.
**Auto-merge configuration:**
Set the PR to auto-merge upon pipeline success:
```bash
tea pr merge <pr-number> --auto --squash
```
**Review:**
Conduct a thorough autonomous review of the PR diff. Check:
- All expected files are included
- No unintended changes slipped in
- No secrets or credentials in the diff
- All `---ci---` blocks have correct metadata
**Finalization:**
- **On pipeline success:** the PR auto-merges. Proceed to Step 4.
- **On pipeline failure:** iterate autonomously until the pipeline passes. Do NOT merge a PR with a failing pipeline. Do NOT ask for guidance.
**Strict rule:** Never merge a PR with a failed pipeline. No exceptions.
## Step 4: Compute Version
| What's shipping | Milestone Type | Phase release | Milestone release | Example | | What's shipping | Milestone Type | Phase release | Milestone release | Example |
|----------------|---------------|-------------|------------|---------| |----------------|---------------|---------------|-------------------|---------|
| Single phase | NFR | Patch `vX.Y.Z` | N/A | v0.1.3 (3rd NFR phase) | | Single phase | NFR | Patch `vX.Y.Z` | N/A | v0.1.3 (3rd NFR phase) |
| Single phase | Feature | Patch `vX.Y.Z` | N/A | v0.2.3 (3rd feature phase) | | Single phase | Feature | Patch `vX.Y.Z` | N/A | v0.2.3 (3rd feature phase) |
| Single phase | Schema-breaking | Minor `vX.(Y+N).0` | N/A | v0.4.0 (2nd schema-breaking phase) | | Single phase | Major | Minor `vX.(Y+N).0` | N/A | v0.4.0 (2nd major phase) |
| Milestone completion | NFR | Patch (last phase) | None | v0.1.3 (no milestone tag) | | Milestone completion | NFR | Patch (last phase) | None | v0.1.3 (no milestone tag) |
| Milestone completion | Feature | Last patch | Minor `vX.(Y+1).0` | v0.3.0 (NOT v0.2.0) | | Milestone completion | Feature | Last patch | Minor `vX.(Y+1).0` | v0.3.0 (NOT v0.2.0) |
| Milestone completion | Schema-breaking | Last minor | Major `v(X+1).0.0` | v1.0.0 | | Milestone completion | Major | Last minor | Major `v(X+1).0.0` | v1.0.0 |
Phase number within the milestone determines the increment: Phase number within the milestone determines the increment:
- NFR/Feature: 1st phase = .1, 2nd = .2, etc. (v0.5.1, v0.5.2) - NFR/Feature: 1st phase = .1, 2nd = .2, etc. (v0.5.1, v0.5.2)
- Schema-breaking: 1st phase = next minor, 2nd = minor+1, etc. (v0.3.0, v0.4.0) - Major: 1st phase = next minor, 2nd = minor+1, etc. (v0.3.0, v0.4.0)
**Before creating ANY tag, validate:** **Tag validation (before creating ANY tag):**
1. The tag must be strictly greater than all existing tags on the same major.minor line 1. Tag must be strictly greater than all existing tags on the same major.minor line
2. Milestone completion tag must be the next minor (feature) or next major (schema-breaking) 2. Milestone completion tag must be next minor (feature) or next major (major)
3. NEVER create a milestone tag that is semantically below existing phase tags (e.g., v0.5.0 when v0.5.1 already exists) 3. NEVER create a milestone tag that is semantically below existing phase tags (e.g., v0.5.0 when v0.5.1 already exists)
## Step 4: Merge Branch ## Step 5: Merge Branch
### Branch hierarchy: main > milestone/vX.X-slug > phase/NN-slug ### Branch hierarchy: main > milestone/vX.X-slug > phase/NN-slug
Phases MUST merge into their milestone branch (or to main if no milestone branch exists). Milestones merge into main only after all phases are complete. ### Merge validation gates
**Phase → Milestone:**
- VALIDATED — must target milestone branch when one exists
- REJECTED if milestone branch does not exist for this phase's milestone
**Phase → Main:**
- VALIDATED — only allowed when NO milestone branch exists for this phase's milestone
- REJECTED if a milestone branch exists for this milestone
**Milestone → Main:**
- VALIDATED — only after all phase branches are merged
- REJECTED if any phase branches for this milestone are unmerged
### Phase ship ### Phase ship
@@ -123,8 +198,9 @@ requirements:
### Milestone ship (after last phase) ### Milestone ship (after last phase)
**Validate all phase branches are merged into the milestone branch before proceeding.**
```bash ```bash
# Verify all phase branches are merged into milestone branch
git checkout main git checkout main
git merge --squash milestone/vX.Y-slug git merge --squash milestone/vX.Y-slug
git commit -m "docs(milestone): complete [milestone-name] git commit -m "docs(milestone): complete [milestone-name]
@@ -136,7 +212,7 @@ status: complete
---/ci---" ---/ci---"
``` ```
## Step 5: Tag and Push ## Step 6: Tag and Push
```bash ```bash
git tag -a vX.Y.Z -m "vX.Y.Z: [phase-name or milestone-name]" git tag -a vX.Y.Z -m "vX.Y.Z: [phase-name or milestone-name]"
@@ -145,21 +221,21 @@ git push origin main --tags
**Tag format by milestone type:** **Tag format by milestone type:**
- NFR/Feature phase: patch format (`v0.5.1`, `v0.5.2`) - NFR/Feature phase: patch format (`v0.5.1`, `v0.5.2`)
- Schema-breaking phase: minor format (`v0.3.0`, `v0.4.0`) - Major phase: minor format (`v0.3.0`, `v0.4.0`)
- Feature milestone: next minor (`v0.6.0`, NOT `v0.5.0`) - Feature milestone: next minor (`v0.6.0`, NOT `v0.5.0`)
- Schema-breaking milestone: next major (`v1.0.0`) - Major milestone: next major (`v1.0.0`)
## Step 6: Create Release ## Step 7: Create Release and Package
**Every ship creates a Gitea release. No exceptions.** **Every ship creates a Gitea release. No exceptions.**
Generate release notes from git log: ### Generate release notes
```bash ```bash
git log v[previous_tag]..vX.Y.Z --oneline git log v[previous_tag]..vX.Y.Z --oneline
``` ```
Create the release via Gitea API: ### Create the Gitea release
```bash ```bash
curl -X POST "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/ci/releases" \ curl -X POST "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/ci/releases" \
@@ -170,14 +246,37 @@ curl -X POST "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/ci/
For milestone releases, include a summary of all phases completed and requirements covered. For milestone releases, include a summary of all phases completed and requirements covered.
## Step 7: Update .ci/ Files ### Create distribution packages
Use coreci to create the necessary distribution packages:
```bash
coreci build --tag vX.Y.Z
coreci package --tag vX.Y.Z
```
Upload packages to the Gitea release:
```bash
coreci release upload --tag vX.Y.Z --files [built-artifacts]
```
### Generate documentation
Include release notes in the Gitea release body with:
- Summary of changes
- Requirements covered
- Known issues (if any)
- Migration notes (for major milestones)
## Step 8: Update .ci/ Files
- Update `.ciagent/REQUIREMENTS.md` — mark shipped requirements as complete - Update `.ciagent/REQUIREMENTS.md` — mark shipped requirements as complete
- Update `.ciagent/ROADMAP.md` — mark shipped phase as complete - Update `.ciagent/ROADMAP.md` — mark shipped phase as complete
Commit the file updates. Commit the file updates.
## Step 8: Report ## Step 9: Report
``` ```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -185,7 +284,7 @@ Commit the file updates.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase [N]: [name] Phase [N]: [name]
Milestone: [vX.Y] ([nfr|feature|schema-breaking]) Milestone: [vX.Y] ([nfr|feature|major])
Version: vX.Y.Z Version: vX.Y.Z
Release: https://git.cloudinit.dev/continuous-intelligence/ci/releases/tag/vX.Y.Z Release: https://git.cloudinit.dev/continuous-intelligence/ci/releases/tag/vX.Y.Z
Status: complete Status: complete
@@ -193,6 +292,7 @@ Status: complete
Tests: PASS Tests: PASS
Typecheck: PASS Typecheck: PASS
Build: PASS Build: PASS
Pipeline: PASS
Requirements covered: [N] Requirements covered: [N]
Commits: [N] Commits: [N]
+2 -2
View File
@@ -1,5 +1,5 @@
--- ---
description: Ship CIAgent phase or milestone — test, commit, tag, push, release. Full autopilot: zero HITL after milestone setup description: Ship CIAgent phase or milestone — Full autopilot release: validate, test, merge, tag, push, release. Zero HITL
argument-hint: "[phase_number|milestone]" argument-hint: "[phase_number|milestone]"
tools: tools:
read: true read: true
@@ -12,7 +12,7 @@ tools:
--- ---
<execution_context> <execution_context>
@__OPENCODE_DIR__/ci/workflows/ship.md @/root/.config/opencode/ci/workflows/ship.md
</execution_context> </execution_context>
<context> <context>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@continuous-intelligence/ciagent", "name": "@continuous-intelligence/ciagent",
"version": "0.7.0", "version": "0.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@continuous-intelligence/ciagent", "name": "@continuous-intelligence/ciagent",
"version": "0.7.0", "version": "0.10.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"commander": "^12.1.0", "commander": "^12.1.0",
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@continuous-intelligence/ciagent", "name": "@continuous-intelligence/ciagent",
"version": "0.8.0", "version": "0.10.0",
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence", "description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@@ -19,7 +19,10 @@
"dev": "ts-node src/cli.ts", "dev": "ts-node src/cli.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "jest", "test": "jest",
"prepublishOnly": "npm run build && npm test", "check-version": "node scripts/check-version.js",
"postbuild": "node scripts/ensure-shebang.js",
"prepublishOnly": "npm run build && node scripts/ensure-shebang.js && node scripts/check-version.js && npm test",
"validate-pack": "node scripts/validate-pack.js",
"install-opencode": "node scripts/postinstall.js" "install-opencode": "node scripts/postinstall.js"
}, },
"keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"], "keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
+27
View File
@@ -0,0 +1,27 @@
const fs = require("node:fs");
const path = require("node:path");
const projectRoot = path.resolve(__dirname, "..");
const pkgPath = path.join(projectRoot, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
const pkgVersion = pkg.version;
const versionPath = path.join(projectRoot, "src", "version.ts");
const versionContent = fs.readFileSync(versionPath, "utf-8");
const match = versionContent.match(/VERSION\s*=\s*"([^"]+)"/);
if (!match) {
console.error(`Error: Could not extract VERSION from src/version.ts`);
process.exit(1);
}
const srcVersion = match[1];
if (pkgVersion !== srcVersion) {
console.error(`Error: Version mismatch — package.json=${pkgVersion}, src/version.ts=${srcVersion}`);
process.exit(1);
}
console.log(`Version consistency check passed: ${pkgVersion}`);
process.exit(0);
+23
View File
@@ -0,0 +1,23 @@
const fs = require("node:fs");
const path = require("node:path");
const projectRoot = path.resolve(__dirname, "..");
const cliEntry = path.join(projectRoot, "dist", "cli", "index.js");
const shebang = "#!/usr/bin/env node\n";
if (!fs.existsSync(cliEntry)) {
console.log(`dist/cli/index.js not found — skipping shebang check (build may not have run yet)`);
process.exit(0);
}
const content = fs.readFileSync(cliEntry, "utf-8");
if (content.startsWith(shebang.trim())) {
console.log("Shebang already present in dist/cli/index.js");
process.exit(0);
}
const updated = shebang + content;
fs.writeFileSync(cliEntry, updated, "utf-8");
console.log("Prepended shebang to dist/cli/index.js");
process.exit(0);
+55
View File
@@ -0,0 +1,55 @@
const { execSync } = require("child_process");
const ALLOWED_ENTRIES = ["dist/", "opencode/", "templates/", "LICENSE", "README.md", "package.json"];
function validatePack() {
let output;
try {
output = execSync("npm pack --dry-run --json", { encoding: "utf-8" });
} catch (err) {
console.error("Failed to run npm pack --dry-run:", err.message);
process.exit(1);
}
let packFiles;
try {
const parsed = JSON.parse(output);
packFiles = Array.isArray(parsed) ? parsed[0].files : parsed.files;
} catch (err) {
console.error("Failed to parse npm pack output:", err.message);
process.exit(1);
}
if (!packFiles || !Array.isArray(packFiles)) {
console.error("No files array found in npm pack output");
process.exit(1);
}
const paths = packFiles.map((f) => f.path || f);
const unexpected = [];
for (const p of paths) {
const top = p.split("/")[0] || p;
const allowed = ALLOWED_ENTRIES.some((entry) => {
const e = entry.replace(/\/$/, "");
return top === e || top === entry || p === entry;
});
if (!allowed) {
unexpected.push(p);
}
}
if (unexpected.length > 0) {
console.error("Unexpected files in npm pack output:");
for (const f of unexpected) {
console.error(` - ${f}`);
}
console.error("");
console.error("Allowed top-level entries:", ALLOWED_ENTRIES.join(", "));
process.exit(1);
}
console.log("npm pack validation passed — all entries are allowed.");
}
validatePack();
+152
View File
@@ -0,0 +1,152 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { AgentContext, AgentResult } from "./base.js";
import { PlannerAgent } from "./planner.js";
import { ExecutorAgent } from "./executor.js";
import { VerifierAgent } from "./verifier.js";
import { ResearcherAgent } from "./researcher.js";
import { ChallengerAgent } from "./challenger.js";
import { SecurityAuditorAgent } from "./security-auditor.js";
import { DebuggerAgent } from "./debugger.js";
import { DocWriterAgent } from "./doc-writer.js";
import { DocVerifierAgent } from "./doc-verifier.js";
import { CodeReviewerAgent } from "./code-reviewer.js";
import { IdeationAgent } from "./ideation-agent.js";
import { RoadmapperAgent } from "./roadmapper.js";
import { PlanCheckerAgent } from "./plan-checker.js";
import { ProjectResearcherAgent } from "./project-researcher.js";
import { ResearchSynthesizerAgent } from "./research-synthesizer.js";
import { SolutionWriterAgent } from "./solution-writer.js";
import { PhaseResearcherAgent } from "./phase-researcher.js";
import { TesterAgent } from "./tester.js";
const NON_ORCHESTRATOR_AGENTS: Array<{ name: string; factory: () => { execute(ctx: AgentContext): Promise<AgentResult>; name: string } }> = [
{ name: "planner", factory: () => new PlannerAgent() },
{ name: "executor", factory: () => new ExecutorAgent() },
{ name: "verifier", factory: () => new VerifierAgent() },
{ name: "researcher", factory: () => new ResearcherAgent() },
{ name: "challenger", factory: () => new ChallengerAgent() },
{ name: "security-auditor", factory: () => new SecurityAuditorAgent() },
{ name: "debugger", factory: () => new DebuggerAgent() },
{ name: "doc-writer", factory: () => new DocWriterAgent() },
{ name: "doc-verifier", factory: () => new DocVerifierAgent() },
{ name: "code-reviewer", factory: () => new CodeReviewerAgent() },
{ name: "ideation-agent", factory: () => new IdeationAgent() },
{ name: "roadmapper", factory: () => new RoadmapperAgent() },
{ name: "plan-checker", factory: () => new PlanCheckerAgent() },
{ name: "project-researcher", factory: () => new ProjectResearcherAgent() },
{ name: "research-synthesizer", factory: () => new ResearchSynthesizerAgent() },
{ name: "solution-writer", factory: () => new SolutionWriterAgent() },
{ name: "phase-researcher", factory: () => new PhaseResearcherAgent() },
{ name: "tester", factory: () => new TesterAgent() },
];
describe("All agents have intrinsic mechanical logic", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-mechanical-test-"));
fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
fs.mkdirSync(path.join(tempDir, "src"), { recursive: true });
fs.writeFileSync(
path.join(tempDir, ".ciagent", "config.json"),
JSON.stringify({
autonomy: { level: "full", escalation_hooks: [], clarify_budget: 10, decision_confidence_threshold: 0.6, max_revision_iterations: 3, max_verification_retries: 2, escalation_timeout_ms: 300000 },
model_profile: "quality",
parallelization: { enabled: false, max_concurrent_agents: 5, min_plans_for_parallel: 2 },
verification: { automated_only: true, escalate_visual: true, escalate_external_integration: true, test_first: false },
security: { auto_accept_low_severity: true, auto_mitigate_medium_severity: true, escalate_high_severity: true },
git: { branching_strategy: "phase", auto_commit: false, auto_push: false },
backend: { provider: "auto", agent_backends: { opencode: { enabled: false } }, llm_backends: {} },
}, null, 2)
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "PROJECT.md"),
"# Project: Mechanical Test\n\n## Core Value\nValidate mechanical agent logic\n\n## Requirements\n### Active\n- REQ-01: Agent runs mechanically\n\n## Key Decisions\n\n## Constraints\n- Test only"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "REQUIREMENTS.md"),
"# Requirements\n\n## V1\n### Functional\n| ID | Description | Priority |\n|------|------|------|\n| REQ-01 | Agent test | high |\n\n## Traceability\n| Requirement | Phase | Status |\n|------|------|------|\n| REQ-01 | 1 | in_progress |"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "ROADMAP.md"),
"# Roadmap\n\n## Phases\n\n| # | Name | Description | Requirements | Depends On | Status |\n|------|------|------|------|------|------|\n| 1 | Test | Agent test phase | REQ-01 | | in_progress |"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\nTest architecture\n\n## Components\n| Name | Description | Boundaries | Depends On |\n|------|------|------|------|\n| core | Core | src/core/ | | \n\n## Build Order\n1. Build core\n\n## Data Flow\nTest flow"
);
fs.writeFileSync(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "mech-test", version: "0.1.0", scripts: { test: "echo ok" } })
);
fs.writeFileSync(path.join(tempDir, "tsconfig.json"), "{}");
fs.writeFileSync(path.join(tempDir, "src", "app.ts"), "export function main() { return 1; }");
});
afterEach(() => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {}
});
it("every non-orchestrator agent produces meaningful output without backend", async () => {
const context: AgentContext = {
project_path: tempDir,
phase: 1,
stage: "plan",
specification: "Test mechanical agent logic execution",
config_path: path.join(tempDir, ".ciagent", "config.json"),
};
expect(NON_ORCHESTRATOR_AGENTS.length).toBe(18);
const results: Record<string, { success: boolean; error?: string; hasStubError: boolean }> = {};
for (const { name, factory } of NON_ORCHESTRATOR_AGENTS) {
const agent = factory();
expect(agent.name).toBe(name);
let result: AgentResult;
try {
result = await agent.execute(context);
} catch (err) {
result = {
success: false,
output: "",
artifacts_created: [],
decisions: 0,
escalations: 0,
duration_ms: 0,
error: err instanceof Error ? err.message : String(err),
};
}
const errorText = (result.error || "").toLowerCase();
const hasStubError =
errorText.includes("requires an intelligence backend") ||
errorText.includes("no intelligence backend available");
results[name] = {
success: result.success,
error: result.error,
hasStubError,
};
}
const agentsWithStubErrors = Object.entries(results)
.filter(([, r]) => r.hasStubError)
.map(([name]) => name);
expect(agentsWithStubErrors).toEqual([]);
});
});
+1
View File
@@ -18,6 +18,7 @@ export interface AgentContext {
specification: string; specification: string;
config_path: string; config_path: string;
backend?: IntelligenceBackend; backend?: IntelligenceBackend;
project_slug?: string;
} }
export function backendResultToAgentResult(result: BackendResult): AgentResult { export function backendResultToAgentResult(result: BackendResult): AgentResult {
+167
View File
@@ -0,0 +1,167 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { DocVerifierAgent } from "../agents/doc-verifier.js";
function setupValidProject(tempDir: string): void {
const srcDir = path.join(tempDir, "src");
const agentsDir = path.join(srcDir, "agents");
const agentFiles = [
"orchestrator.ts", "planner.ts", "executor.ts", "verifier.ts",
"researcher.ts", "challenger.ts", "security-auditor.ts", "debugger.ts",
"doc-writer.ts", "doc-verifier.ts", "code-reviewer.ts", "ideation-agent.ts",
"roadmapper.ts", "plan-checker.ts", "project-researcher.ts",
"research-synthesizer.ts", "solution-writer.ts", "phase-researcher.ts", "tester.ts",
];
for (const dir of ["agents", "backends", "cli", "core", "types", "utils", "verification"]) {
fs.mkdirSync(path.join(srcDir, dir), { recursive: true });
}
fs.writeFileSync(path.join(agentsDir, "base.ts"), "");
fs.writeFileSync(path.join(agentsDir, "index.ts"), "");
for (const f of agentFiles) {
fs.writeFileSync(path.join(agentsDir, f), "export class X {}");
}
fs.writeFileSync(path.join(srcDir, "version.ts"), 'export const VERSION = "0.8.0";');
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version: "0.8.0" }));
fs.writeFileSync(
path.join(tempDir, "AGENTS.md"),
"19 agent implementations\n44 test suites\n"
);
for (let i = 0; i < 44; i++) {
fs.writeFileSync(path.join(srcDir, `test-${i}.test.ts`), "test('x', () => {});");
}
}
describe("DocVerifierAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-doc-verifier-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("valid project passes with no findings", () => {
setupValidProject(tempDir);
const agent = new DocVerifierAgent();
const findings = agent.mechanicalDocVerify(tempDir);
expect(findings).toHaveLength(0);
});
it("detects missing agent via agent_mismatch", () => {
const srcDir = path.join(tempDir, "src");
const agentsDir = path.join(srcDir, "agents");
fs.mkdirSync(agentsDir, { recursive: true });
const agentFiles = [
"orchestrator.ts", "planner.ts", "executor.ts", "verifier.ts",
"researcher.ts", "challenger.ts", "security-auditor.ts",
];
fs.writeFileSync(path.join(agentsDir, "base.ts"), "");
fs.writeFileSync(path.join(agentsDir, "index.ts"), "");
for (const f of agentFiles) {
fs.writeFileSync(path.join(agentsDir, f), "export class X {}");
}
fs.writeFileSync(path.join(srcDir, "version.ts"), 'export const VERSION = "0.8.0";');
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version: "0.8.0" }));
fs.writeFileSync(
path.join(tempDir, "AGENTS.md"),
"19 agent implementations\n44 test suites\n"
);
const agent = new DocVerifierAgent();
const findings = agent.mechanicalDocVerify(tempDir);
const mismatch = findings.find((f) => f.type === "agent_mismatch");
expect(mismatch).toBeDefined();
expect(mismatch!.severity).toBe("P1");
});
it("detects version drift between package.json and src/version.ts", () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version: "0.8.0" }));
fs.writeFileSync(path.join(srcDir, "version.ts"), 'export const VERSION = "0.9.0";');
fs.writeFileSync(
path.join(tempDir, "AGENTS.md"),
"19 agent implementations\n44 test suites\n"
);
const agent = new DocVerifierAgent();
const findings = agent.mechanicalDocVerify(tempDir);
const drift = findings.find((f) => f.type === "version_drift");
expect(drift).toBeDefined();
expect(drift!.severity).toBe("P0");
expect(drift!.expected).toContain("0.8.0");
expect(drift!.actual).toContain("0.9.0");
});
it("detects architecture stale when expected directory missing", () => {
const srcDir = path.join(tempDir, "src");
const limitedDirs = ["agents", "types"];
for (const dir of limitedDirs) {
fs.mkdirSync(path.join(srcDir, dir), { recursive: true });
}
fs.writeFileSync(path.join(srcDir, "version.ts"), 'export const VERSION = "0.8.0";');
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version: "0.8.0" }));
fs.writeFileSync(
path.join(tempDir, "AGENTS.md"),
"19 agent implementations\n44 test suites\n"
);
const agent = new DocVerifierAgent();
const findings = agent.mechanicalDocVerify(tempDir);
const stale = findings.filter((f) => f.type === "architecture_stale");
expect(stale.length).toBeGreaterThan(0);
expect(stale.some((f) => f.expected.includes("backends"))).toBe(true);
});
it("agent name is doc-verifier", () => {
const agent = new DocVerifierAgent();
expect(agent.name).toBe("doc-verifier");
});
it("findings include type and severity fields", () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version: "1.0.0" }));
fs.writeFileSync(path.join(srcDir, "version.ts"), 'export const VERSION = "2.0.0";');
fs.writeFileSync(
path.join(tempDir, "AGENTS.md"),
"19 agent implementations\n99 test suites\n"
);
const agent = new DocVerifierAgent();
const findings = agent.mechanicalDocVerify(tempDir);
for (const f of findings) {
expect(f.type).toBeDefined();
expect(f.severity).toBeDefined();
expect(f.expected).toBeDefined();
expect(f.actual).toBeDefined();
expect(["P0", "P1", "P2"]).toContain(f.severity);
expect(["agent_mismatch", "version_drift", "architecture_stale", "test_count_drift"]).toContain(f.type);
}
});
});
+186 -4
View File
@@ -1,13 +1,26 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface DocFinding {
type: "agent_mismatch" | "version_drift" | "architecture_stale" | "test_count_drift";
severity: "P0" | "P1" | "P2";
expected: string;
actual: string;
file?: string;
}
const KNOWN_COMPONENTS = ["agents", "backends", "cli", "core", "types", "utils", "verification"];
export class DocVerifierAgent extends BaseAgent { export class DocVerifierAgent extends BaseAgent {
readonly name = "doc-verifier"; readonly name = "doc-verifier";
readonly description = "Verifies documentation matches live codebase."; readonly description = "Verifies documentation matches live codebase via mechanical cross-checks.";
readonly workflow = "verify"; readonly workflow = "verify";
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Verifying documentation..."); this.log("Verifying documentation...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const result = await this.executeViaBackend(
context, context,
@@ -15,14 +28,183 @@ export class DocVerifierAgent extends BaseAgent {
); );
return { ...result, duration_ms: Date.now() - start }; return { ...result, duration_ms: Date.now() - start };
} }
const findings = this.mechanicalDocVerify(context.project_path);
const output = this.formatFindings(findings);
return { return {
success: false, success: true,
output: "Documentation verification requires an intelligence backend.", output,
artifacts_created: [], artifacts_created: [],
decisions: 0, decisions: 0,
escalations: 0, escalations: 0,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available",
}; };
} }
mechanicalDocVerify(projectPath: string): DocFinding[] {
const findings: DocFinding[] = [];
const agentFinding = this.checkAgentRegistry(projectPath);
if (agentFinding) findings.push(agentFinding);
const versionFinding = this.checkVersionConsistency(projectPath);
if (versionFinding) findings.push(versionFinding);
const archFindings = this.checkArchitectureTree(projectPath);
findings.push(...archFindings);
const testFinding = this.checkTestCount(projectPath);
if (testFinding) findings.push(testFinding);
return findings;
}
checkAgentRegistry(projectPath: string): DocFinding | null {
const agentsDir = path.join(projectPath, "src", "agents");
if (!fs.existsSync(agentsDir)) return null;
const agentFiles = fs.readdirSync(agentsDir)
.filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts") && !f.endsWith(".d.ts") && f !== "index.ts" && f !== "base.ts");
const agentsMdPath = path.join(projectPath, "AGENTS.md");
if (!fs.existsSync(agentsMdPath)) return null;
const agentsMdContent = fs.readFileSync(agentsMdPath, "utf-8");
const agentCountMatch = agentsMdContent.match(/(\d+)\s+agent/i);
if (!agentCountMatch) return null;
const claimedCount = parseInt(agentCountMatch[1], 10);
const actualCount = agentFiles.length;
if (actualCount !== claimedCount) {
return {
type: "agent_mismatch",
severity: "P1",
expected: `${claimedCount} agents`,
actual: `${actualCount} agents`,
file: "AGENTS.md",
};
}
return null;
}
checkVersionConsistency(projectPath: string): DocFinding | null {
const pkgPath = path.join(projectPath, "package.json");
const versionPath = path.join(projectPath, "src", "version.ts");
if (!fs.existsSync(pkgPath) || !fs.existsSync(versionPath)) return null;
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
const pkgVersion = pkg.version;
const versionContent = fs.readFileSync(versionPath, "utf-8");
const match = versionContent.match(/VERSION\s*=\s*"([^"]+)"/);
if (!match) return null;
const srcVersion = match[1];
if (pkgVersion !== srcVersion) {
return {
type: "version_drift",
severity: "P0",
expected: `package.json=${pkgVersion}`,
actual: `src/version.ts=${srcVersion}`,
file: "src/version.ts",
};
}
return null;
}
checkArchitectureTree(projectPath: string): DocFinding[] {
const findings: DocFinding[] = [];
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) return findings;
const actualDirs = new Set(
fs.readdirSync(srcDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
);
const archMdPath = this.resolveArchMdPath(projectPath);
const archFile = archMdPath ? path.relative(projectPath, archMdPath) : "ARCHITECTURE.md";
for (const expected of KNOWN_COMPONENTS) {
if (!actualDirs.has(expected)) {
findings.push({
type: "architecture_stale",
severity: "P2",
expected: `src/${expected}/ directory`,
actual: "directory not found",
file: archFile,
});
}
}
return findings;
}
checkTestCount(projectPath: string): DocFinding | null {
const agentsMdPath = path.join(projectPath, "AGENTS.md");
if (!fs.existsSync(agentsMdPath)) return null;
const agentsMdContent = fs.readFileSync(agentsMdPath, "utf-8");
const testCountMatch = agentsMdContent.match(/(\d+)\s+test\s+suit/i);
if (!testCountMatch) return null;
const claimedCount = parseInt(testCountMatch[1], 10);
const actualCount = this.countTestFiles(path.join(projectPath, "src"));
if (actualCount !== claimedCount) {
return {
type: "test_count_drift",
severity: "P1",
expected: `${claimedCount} test suites`,
actual: `${actualCount} test suites`,
file: "AGENTS.md",
};
}
return null;
}
private resolveArchMdPath(projectPath: string): string | null {
const ciagentArch = path.join(projectPath, ".ciagent", "ARCHITECTURE.md");
if (fs.existsSync(ciagentArch)) return ciagentArch;
const ciArch = path.join(projectPath, ".ci", "ARCHITECTURE.md");
if (fs.existsSync(ciArch)) return ciArch;
return null;
}
private countTestFiles(dir: string): number {
if (!fs.existsSync(dir)) return 0;
let count = 0;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git") {
count += this.countTestFiles(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
count++;
}
}
return count;
}
private formatFindings(findings: DocFinding[]): string {
if (findings.length === 0) return "Documentation verification passed — no drift detected.";
const lines: string[] = ["Documentation Findings:", ""];
for (const f of findings) {
lines.push(`[${f.type}|${f.severity}] expected: ${f.expected}, actual: ${f.actual}${f.file ? ` (${f.file})` : ""}`);
}
return lines.join("\n");
}
} }
+27
View File
@@ -0,0 +1,27 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { IdeationAgent } from "../agents/ideation-agent.js";
describe("IdeationAgent", () => {
it("agent name is ideation-agent", () => {
const agent = new IdeationAgent();
expect(agent.name).toBe("ideation-agent");
});
it("workflow is research", () => {
const agent = new IdeationAgent();
expect(agent.workflow).toBe("research");
});
it("delegates mechanicalIdeate to IdeationEngine", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-agent-test-"));
try {
const agent = new IdeationAgent();
const ideas = agent.mechanicalIdeate(tempDir);
expect(Array.isArray(ideas)).toBe(true);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});
+15 -4
View File
@@ -1,13 +1,15 @@
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { IdeationEngine } from "../core/ideation.js";
export class IdeationAgent extends BaseAgent { export class IdeationAgent extends BaseAgent {
readonly name = "ideation-agent"; readonly name = "ideation-agent";
readonly description = "Generates improvement ideas. Output feeds directly into planning pipeline."; readonly description = "Generates improvement ideas using git-native pattern mining, coverage gap analysis, and architectural drift detection. Output feeds directly into planning pipeline.";
readonly workflow = "research"; readonly workflow = "research";
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Generating improvement ideas..."); this.log("Generating improvement ideas...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const result = await this.executeViaBackend(
context, context,
@@ -15,14 +17,23 @@ export class IdeationAgent extends BaseAgent {
); );
return { ...result, duration_ms: Date.now() - start }; return { ...result, duration_ms: Date.now() - start };
} }
const engine = new IdeationEngine(context.project_path);
const ideas = engine.runMechanical();
const output = engine.formatIdeas(ideas);
return { return {
success: false, success: true,
output: "Ideation requires an intelligence backend.", output,
artifacts_created: [], artifacts_created: [],
decisions: 0, decisions: 0,
escalations: 0, escalations: 0,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available",
}; };
} }
mechanicalIdeate(projectPath: string) {
const engine = new IdeationEngine(projectPath);
return engine.runMechanical();
}
} }
+243 -20
View File
@@ -47,6 +47,7 @@ export class OrchestratorAgent extends BaseAgent {
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName[]>> = { private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName[]>> = {
research: ["researcher"], research: ["researcher"],
ideate: ["ideation-agent"],
plan: ["planner"], plan: ["planner"],
execute: ["executor", "code-reviewer", "security-auditor"], execute: ["executor", "code-reviewer", "security-auditor"],
test: ["tester"], test: ["tester"],
@@ -67,9 +68,10 @@ export class OrchestratorAgent extends BaseAgent {
try { try {
this.config = loadConfig(context.project_path); this.config = loadConfig(context.project_path);
const projectSlug = context.project_slug || "";
this.gitContext = new GitContext(context.project_path); this.gitContext = new GitContext(context.project_path);
this.gitBranch = new GitBranch(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(); this.ciFiles.ensureCIDir();
const projectState = this.gitContext.reconstructState(); const projectState = this.gitContext.reconstructState();
@@ -345,22 +347,18 @@ export class OrchestratorAgent extends BaseAgent {
let totalEscalations = 0; let totalEscalations = 0;
let lastError: string | undefined; let lastError: string | undefined;
for (let i = 0; i < agentNames.length; i++) { const primaryAgent = getAgent(agentNames[0]);
const agentName = agentNames[i];
const agent = getAgent(agentName);
const gitContext = this.buildGitAgentContext(context); const gitContext = this.buildGitAgentContext(context);
const primaryAgentResult = await primaryAgent.execute(gitContext);
if (i === 0) { primaryResult = primaryAgentResult;
const result = await agent.execute(gitContext); if (Array.isArray(primaryAgentResult.artifacts_created)) {
primaryResult = result; allArtifacts.push(...primaryAgentResult.artifacts_created);
if (Array.isArray(result.artifacts_created)) {
allArtifacts.push(...result.artifacts_created);
} }
totalDecisions += result.decisions; totalDecisions += primaryAgentResult.decisions;
totalEscalations += result.escalations; totalEscalations += primaryAgentResult.escalations;
if (!result.success) { if (!primaryAgentResult.success) {
this.warn(`Primary agent ${agentName} failed for ${stage}`); this.warn(`Primary agent ${agentNames[0]} failed for ${stage}`);
return { return {
phase: this.pipelineState!.current_phase, phase: this.pipelineState!.current_phase,
stage, stage,
@@ -369,16 +367,49 @@ export class OrchestratorAgent extends BaseAgent {
decisions_made: totalDecisions, decisions_made: totalDecisions,
escalations_raised: totalEscalations, escalations_raised: totalEscalations,
duration_ms: Date.now() - stageStart, duration_ms: Date.now() - stageStart,
error: result.error || `Primary agent ${agentName} failed`, error: primaryAgentResult.error || `Primary agent ${agentNames[0]} failed`,
}; };
} }
} else {
try { if (agentNames.length > 1) {
if (this.config.parallelization?.enabled) {
const reviewFactories = agentNames.slice(1).map((reviewAgentName) => {
return () => {
const agent = getAgent(reviewAgentName);
const reviewContext: AgentContext = { const reviewContext: AgentContext = {
...gitContext, ...gitContext,
specification: `${context.specification}\n\nPrimary agent (${agentNames[0]}) completed. Review context:\n- Success: ${primaryResult!.success}\n- Output: ${primaryResult!.output}\n- Artifacts: ${Array.isArray(primaryResult!.artifacts_created) ? primaryResult!.artifacts_created.join(", ") : String(primaryResult!.artifacts_created)}`, specification: `${context.specification}\n\nPrimary agent (${agentNames[0]}) completed. Review context:\n- Success: ${primaryResult!.success}\n- Output: ${primaryResult!.output}\n- Artifacts: ${Array.isArray(primaryResult!.artifacts_created) ? primaryResult!.artifacts_created.join(", ") : String(primaryResult!.artifacts_created)}`,
}; };
const result = await agent.execute(reviewContext); return agent.execute(reviewContext);
};
});
const settled = await this.limitConcurrency(reviewFactories, this.config.parallelization?.max_concurrent_agents ?? 5);
for (let si = 0; si < settled.length; si++) {
const result = settled[si];
if (result.status === "fulfilled") {
const agentResult = result.value;
if (Array.isArray(agentResult.artifacts_created)) allArtifacts.push(...agentResult.artifacts_created);
totalDecisions += agentResult.decisions;
totalEscalations += agentResult.escalations;
if (!agentResult.success) {
this.warn(`Review agent reported issues: ${agentResult.error || "unspecified"}`);
lastError = agentResult.error;
}
} else {
this.warn(`Review agent failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
}
}
} else {
for (let i = 1; i < agentNames.length; i++) {
const reviewAgentName = agentNames[i];
try {
const reviewAgent = getAgent(reviewAgentName);
const reviewContext: AgentContext = {
...gitContext,
specification: `${context.specification}\n\nPrimary agent (${agentNames[0]}) completed. Review context:\n- Success: ${primaryResult!.success}\n- Output: ${primaryResult!.output}\n- Artifacts: ${Array.isArray(primaryResult!.artifacts_created) ? primaryResult!.artifacts_created.join(", ") : String(primaryResult!.artifacts_created)}`,
};
const result = await reviewAgent.execute(reviewContext);
if (Array.isArray(result.artifacts_created)) { if (Array.isArray(result.artifacts_created)) {
allArtifacts.push(...result.artifacts_created); allArtifacts.push(...result.artifacts_created);
} }
@@ -386,11 +417,12 @@ export class OrchestratorAgent extends BaseAgent {
totalEscalations += result.escalations; totalEscalations += result.escalations;
if (!result.success) { if (!result.success) {
this.warn(`Review agent ${agentName} reported issues for ${stage}: ${result.error || "unspecified"}`); this.warn(`Review agent ${reviewAgentName} reported issues for ${stage}: ${result.error || "unspecified"}`);
lastError = result.error; lastError = result.error;
} }
} catch (err) { } catch (err) {
this.warn(`Review agent ${agentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`); this.warn(`Review agent ${reviewAgentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
}
} }
} }
} }
@@ -429,6 +461,7 @@ export class OrchestratorAgent extends BaseAgent {
projectName: spec.objective.slice(0, 30), projectName: spec.objective.slice(0, 30),
phaseCount: 0, phaseCount: 0,
milestone: this.currentMilestone, milestone: this.currentMilestone,
project: context.project_slug || undefined,
specification: spec.raw_content, specification: spec.raw_content,
requirements: spec.requirements, requirements: spec.requirements,
constraints: spec.constraints, constraints: spec.constraints,
@@ -541,6 +574,69 @@ export class OrchestratorAgent extends BaseAgent {
break; 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": case "plan":
this.log("Planning phase execution..."); this.log("Planning phase execution...");
@@ -704,6 +800,38 @@ export class OrchestratorAgent extends BaseAgent {
}; };
} }
private async limitConcurrency<T>(
factories: Array<() => Promise<T>>,
maxConcurrency: number
): Promise<PromiseSettledResult<T>[]> {
if (factories.length === 0) {
return [];
}
if (maxConcurrency <= 0 || maxConcurrency >= factories.length) {
return Promise.allSettled(factories.map((f) => f()));
}
const results: Array<PromiseSettledResult<T> | undefined> = new Array(factories.length).fill(undefined);
let nextIndex = 0;
const worker = async () => {
while (nextIndex < factories.length) {
const index = nextIndex++;
try {
const value = await factories[index]();
results[index] = { status: "fulfilled", value };
} catch (reason) {
results[index] = { status: "rejected", reason };
}
}
};
const workers = Array(Math.min(maxConcurrency, factories.length)).fill(null).map(() => worker());
await Promise.all(workers);
return results as PromiseSettledResult<T>[];
}
private generateCompletionReport(): string { private generateCompletionReport(): string {
const lines: string[] = [ const lines: string[] = [
"# CIAgent Completion Report", "# CIAgent Completion Report",
@@ -728,4 +856,99 @@ export class OrchestratorAgent extends BaseAgent {
return lines.join("\n"); 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 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;
}
} }
+217
View File
@@ -0,0 +1,217 @@
async function limitConcurrency<T>(
factories: Array<() => Promise<T>>,
maxConcurrency: number
): Promise<PromiseSettledResult<T>[]> {
if (factories.length === 0) {
return [];
}
if (maxConcurrency <= 0 || maxConcurrency >= factories.length) {
return Promise.allSettled(factories.map((f) => f()));
}
const results: Array<PromiseSettledResult<T> | undefined> = new Array(factories.length).fill(undefined);
let nextIndex = 0;
const worker = async () => {
while (nextIndex < factories.length) {
const index = nextIndex++;
try {
const value = await factories[index]();
results[index] = { status: "fulfilled", value };
} catch (reason) {
results[index] = { status: "rejected", reason };
}
}
};
const workers = Array(Math.min(maxConcurrency, factories.length)).fill(null).map(() => worker());
await Promise.all(workers);
return results as PromiseSettledResult<T>[];
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("Parallel Execution", () => {
describe("limitConcurrency", () => {
it("returns empty array for zero factories", async () => {
const results = await limitConcurrency([], 5);
expect(results).toEqual([]);
});
it("returns single-element result for one factory", async () => {
const results = await limitConcurrency([() => Promise.resolve(42)], 5);
expect(results).toHaveLength(1);
expect(results[0].status).toBe("fulfilled");
if (results[0].status === "fulfilled") {
expect(results[0].value).toBe(42);
}
});
it("behaves sequentially when maxConcurrency=1", async () => {
const order: number[] = [];
const factories = [1, 2, 3].map((n) => () =>
delay(30).then(() => { order.push(n); return n; })
);
const start = Date.now();
const results = await limitConcurrency(factories, 1);
const elapsed = Date.now() - start;
expect(results).toHaveLength(3);
for (const r of results) {
expect(r.status).toBe("fulfilled");
}
expect(order).toEqual([1, 2, 3]);
expect(elapsed).toBeGreaterThanOrEqual(80);
});
it("runs concurrently when maxConcurrency exceeds factory count", async () => {
const factories = ["a", "b"].map((v) => () =>
delay(50).then(() => v)
);
const start = Date.now();
const results = await limitConcurrency(factories, 10);
const elapsed = Date.now() - start;
expect(results).toHaveLength(2);
expect(elapsed).toBeLessThan(120);
});
it("limits to maxConcurrency=2 with 4 factories", async () => {
const timestamps: number[] = [];
const factories = [0, 1, 2, 3].map((i) => () =>
delay(80).then(() => { timestamps.push(i); return i; })
);
const start = Date.now();
const results = await limitConcurrency(factories, 2);
const elapsed = Date.now() - start;
expect(results).toHaveLength(4);
for (const r of results) {
expect(r.status).toBe("fulfilled");
if (r.status === "fulfilled") {
expect([0, 1, 2, 3]).toContain(r.value);
}
}
expect(elapsed).toBeGreaterThanOrEqual(150);
expect(elapsed).toBeLessThan(350);
});
it("isolates rejected promises from fulfilled ones", async () => {
const factories = [
() => Promise.resolve("success"),
() => Promise.reject(new Error("boom")),
() => Promise.resolve("also-success"),
];
const results = await limitConcurrency(factories, 5);
expect(results).toHaveLength(3);
const fulfilled = results.filter((r) => r.status === "fulfilled");
const rejected = results.filter((r) => r.status === "rejected");
expect(fulfilled).toHaveLength(2);
expect(rejected).toHaveLength(1);
if (fulfilled[0].status === "fulfilled") {
expect(fulfilled[0].value).toBe("success");
}
if (fulfilled[1].status === "fulfilled") {
expect(fulfilled[1].value).toBe("also-success");
}
if (rejected[0].status === "rejected") {
expect((rejected[0].reason as Error).message).toBe("boom");
}
});
it("handles maxConcurrency=0 as no limit", async () => {
const factories = [1, 2, 3].map((v) => () => Promise.resolve(v));
const results = await limitConcurrency(factories, 0);
expect(results).toHaveLength(3);
for (const r of results) {
expect(r.status).toBe("fulfilled");
}
});
});
describe("parallel vs sequential timing", () => {
it("parallel execution is faster than sequential", async () => {
const DELAY_MS = 50;
const parallelFactories = [DELAY_MS, DELAY_MS].map((ms) => () => delay(ms));
const parallelStart = Date.now();
const parallelResults = await limitConcurrency(parallelFactories, 5);
const parallelElapsed = Date.now() - parallelStart;
const sequentialStart = Date.now();
for (const ms of [DELAY_MS, DELAY_MS]) {
await delay(ms);
}
const sequentialElapsed = Date.now() - sequentialStart;
expect(parallelResults).toHaveLength(2);
expect(parallelElapsed).toBeLessThan(sequentialElapsed);
expect(parallelElapsed).toBeLessThan(DELAY_MS * 1.8);
});
});
describe("concurrency limit verification", () => {
it("at most maxConcurrency agents run simultaneously", async () => {
let concurrentCount = 0;
let maxConcurrent = 0;
const MAX = 2;
const factories = [0, 1, 2, 3].map(
(i) => () =>
new Promise<number>((resolve) => {
concurrentCount++;
if (concurrentCount > maxConcurrent) maxConcurrent = concurrentCount;
delay(60).then(() => {
concurrentCount--;
resolve(i);
});
})
);
const results = await limitConcurrency(factories, MAX);
expect(results).toHaveLength(4);
expect(maxConcurrent).toBeLessThanOrEqual(MAX);
});
});
describe("sequential fallback behavior", () => {
it("runs agents in order when parallelization disabled", async () => {
const executionOrder: string[] = [];
await (async () => {
for (const name of ["code-reviewer", "security-auditor"]) {
executionOrder.push(`start:${name}`);
await delay(10);
executionOrder.push(`end:${name}`);
}
})();
expect(executionOrder).toEqual([
"start:code-reviewer",
"end:code-reviewer",
"start:security-auditor",
"end:security-auditor",
]);
});
});
describe("single agent edge case", () => {
it("no review agents means no parallel code path triggered", async () => {
const results = await limitConcurrency([], 5);
expect(results).toHaveLength(0);
});
});
});
+74
View File
@@ -0,0 +1,74 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { PhaseResearcherAgent } from "../agents/phase-researcher.js";
describe("PhaseResearcherAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-phase-researcher-test-"));
fs.mkdirSync(path.join(tempDir, ".git"), { recursive: true });
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("extracts decisions from commit log content", () => {
const agent = new PhaseResearcherAgent();
const result = agent.extractDecisions(
`some commit\n---ci---\nphase: 1\ndecisions:\n - D-101: Use SQLite for storage confidence: 0.9\n - D-102: Retry on failure confidence: 0.3\n---/ci---\n`
);
expect(result.length).toBe(2);
expect(result[0].id).toBe("D-101");
expect(result[0].confidence).toBe(0.9);
expect(result[1].id).toBe("D-102");
expect(result[1].confidence).toBe(0.3);
});
it("extracts lessons from commit log content", () => {
const agent = new PhaseResearcherAgent();
const result = agent.extractLessons(
`some commit\n---ci---\nphase: 1\nlessons:\n - testing: Flaky tests are a problem\n - build: CI timeouts\n---/ci---\n`
);
expect(result.length).toBe(2);
expect(result[0]).toContain("testing");
});
it("identifies risks from low-confidence decisions and repeated lessons", () => {
const agent = new PhaseResearcherAgent();
const decisions = [
{ id: "D-1", decision: "Risky choice", confidence: 0.4 },
{ id: "D-2", decision: "Safe choice", confidence: 0.9 },
];
const lessons = [
"testing: Flaky tests",
"testing: More flaky tests",
"build: CI timeouts",
];
const risks = agent.identifyRisks(decisions, lessons);
const highRisk = risks.filter((r) => r.severity === "high");
expect(highRisk.length).toBeGreaterThan(0);
expect(highRisk.some((r) => r.description.includes("D-1"))).toBe(true);
});
it("handles empty git log gracefully", () => {
const agent = new PhaseResearcherAgent();
const result = agent.mechanicalPhaseResearch(tempDir, 1);
expect(result.phase).toBe(1);
expect(result.decisions).toEqual([]);
expect(result.lessons).toEqual([]);
expect(result.risks).toEqual([]);
});
it("agent name is phase-researcher", () => {
const agent = new PhaseResearcherAgent();
expect(agent.name).toBe("phase-researcher");
});
});
+130 -4
View File
@@ -1,5 +1,14 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface PhaseResearchResult {
phase: number;
decisions: Array<{ id: string; decision: string; confidence: number }>;
lessons: string[];
risks: Array<{ description: string; severity: string }>;
}
export class PhaseResearcherAgent extends BaseAgent { export class PhaseResearcherAgent extends BaseAgent {
readonly name = "phase-researcher"; readonly name = "phase-researcher";
readonly description = "Researches how to implement a specific phase well."; readonly description = "Researches how to implement a specific phase well.";
@@ -8,6 +17,7 @@ export class PhaseResearcherAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Researching phase implementation..."); this.log("Researching phase implementation...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const result = await this.executeViaBackend(
context, context,
@@ -15,14 +25,130 @@ export class PhaseResearcherAgent extends BaseAgent {
); );
return { ...result, duration_ms: Date.now() - start }; return { ...result, duration_ms: Date.now() - start };
} }
const result = this.mechanicalPhaseResearch(context.project_path, context.phase);
const output = this.formatResult(result);
return { return {
success: false, success: true,
output: "Phase research requires an intelligence backend.", output,
artifacts_created: [], artifacts_created: [],
decisions: 0, decisions: 0,
escalations: 0, escalations: result.risks.filter((r) => r.severity === "high").length,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available",
}; };
} }
mechanicalPhaseResearch(projectPath: string, phase: number): PhaseResearchResult {
const logContent = this.readPhaseGitLog(projectPath, phase);
const decisions = this.extractDecisions(logContent);
const lessons = this.extractLessons(logContent);
const risks = this.identifyRisks(decisions, lessons);
return { phase, decisions, lessons, risks };
}
readPhaseGitLog(projectPath: string, phase: number): string {
try {
const { execSync } = require("node:child_process");
return execSync(
`git log --all --format="%B" -100`,
{ cwd: projectPath, encoding: "utf-8", timeout: 5000 }
);
} catch {
return "";
}
}
extractDecisions(logContent: string): Array<{ id: string; decision: string; confidence: number }> {
const decisions: Array<{ id: string; decision: string; confidence: number }> = [];
const decisionRegex = /(?:decisions|decision):\s*\n((?:\s+-\s+.+\n?)+)/g;
let match;
while ((match = decisionRegex.exec(logContent)) !== null) {
const items = match[1].split("\n").filter((l: string) => l.trim().startsWith("-"));
for (const item of items) {
const text = item.replace(/^\s*-\s*/, "").trim();
const idMatch = text.match(/D-(\d+)/);
const id = idMatch ? `D-${idMatch[1]}` : `D-${decisions.length + 1}`;
const confMatch = text.match(/confidence[:\s]+(\d+\.?\d*)/);
const confidence = confMatch ? parseFloat(confMatch[1]) : 0.5;
decisions.push({ id, decision: text, confidence });
}
}
return decisions;
}
extractLessons(logContent: string): string[] {
const lessons: string[] = [];
const lessonsRegex = /lessons:\s*\n((?:\s+-\s+.+\n?)+)/g;
let match;
while ((match = lessonsRegex.exec(logContent)) !== null) {
const items = match[1].split("\n").filter((l: string) => l.trim().startsWith("-"));
for (const item of items) {
lessons.push(item.replace(/^\s*-\s*/, "").trim());
}
}
return lessons;
}
identifyRisks(
decisions: Array<{ id: string; decision: string; confidence: number }>,
lessons: string[]
): Array<{ description: string; severity: string }> {
const risks: Array<{ description: string; severity: string }> = [];
for (const decision of decisions) {
if (decision.confidence < 0.5) {
risks.push({
description: `Low-confidence decision ${decision.id}: ${decision.decision.substring(0, 60)}`,
severity: "high",
});
} else if (decision.confidence < 0.7) {
risks.push({
description: `Medium-confidence decision ${decision.id}: ${decision.decision.substring(0, 60)}`,
severity: "medium",
});
}
}
const topicCounts: Record<string, number> = {};
for (const lesson of lessons) {
const topic = lesson.split(":")[0].trim().toLowerCase();
topicCounts[topic] = (topicCounts[topic] || 0) + 1;
}
for (const [topic, count] of Object.entries(topicCounts)) {
if (count > 1) {
risks.push({
description: `Repeated lesson on "${topic}" (${count} occurrences) suggests systemic risk`,
severity: count >= 3 ? "high" : "medium",
});
}
}
return risks;
}
private formatResult(result: PhaseResearchResult): string {
const lines: string[] = [`Phase ${result.phase} Research:`, ""];
lines.push("Decisions:");
for (const d of result.decisions) {
lines.push(` [${d.id}|conf=${d.confidence.toFixed(2)}] ${d.decision}`);
}
lines.push("");
lines.push("Lessons:");
for (const l of result.lessons) {
lines.push(` - ${l}`);
}
lines.push("");
lines.push("Risks:");
for (const r of result.risks) {
lines.push(` [${r.severity}] ${r.description}`);
}
return lines.join("\n");
}
} }
+107
View File
@@ -0,0 +1,107 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { PlanCheckerAgent } from "../agents/plan-checker.js";
describe("PlanCheckerAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-plan-checker-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("detects missing required sections", () => {
const agent = new PlanCheckerAgent();
const results = agent.mechanicalPlanCheck(tempDir, "no sections here");
const missing = results.filter((r) => r.type === "missing_section");
expect(missing.length).toBeGreaterThan(0);
expect(missing.some((r) => r.description.includes("# Phase"))).toBe(true);
expect(missing.some((r) => r.description.includes("## Phase Goal"))).toBe(true);
expect(missing.some((r) => r.description.includes("## Plans"))).toBe(true);
});
it("detects task ID gaps", () => {
const plan = `
# Phase 1
## Phase Goal
Build it
## Plans
### Task 1.1: T1.1
stuff
### Task 1.1: T1.3
more stuff
`;
const agent = new PlanCheckerAgent();
const results = agent.mechanicalPlanCheck(tempDir, plan);
const gaps = results.filter((r) => r.type === "task_id_gap");
expect(gaps.length).toBeGreaterThan(0);
});
it("detects missing must-haves", () => {
const plan = `
# Phase 1
## Phase Goal
Build
## Plans
### Task 1.1: T1.1
Do the thing
`;
const agent = new PlanCheckerAgent();
const results = agent.mechanicalPlanCheck(tempDir, plan);
const missingMustHaves = results.filter((r) => r.type === "missing_must_haves");
expect(missingMustHaves.length).toBeGreaterThan(0);
});
it("detects invalid wave ordering", () => {
const plan = `
# Phase 1
## Phase Goal
Goal
## Plans
## Wave 2
tasks
## Wave 1
more tasks
`;
const agent = new PlanCheckerAgent();
const results = agent.mechanicalPlanCheck(tempDir, plan);
const waveInvalid = results.filter((r) => r.type === "wave_order_invalid");
expect(waveInvalid.length).toBeGreaterThan(0);
expect(waveInvalid[0].severity).toBe("P0");
});
it("detects uncovered requirements", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"REQ-1: First requirement\nREQ-2: Second requirement\nREQ-3: Third requirement"
);
const plan = `
# Phase 1
## Phase Goal
Goal
## Plans
REQ-1 is covered
`;
const agent = new PlanCheckerAgent();
const results = agent.mechanicalPlanCheck(tempDir, plan);
const uncovered = results.filter((r) => r.type === "uncovered_requirement");
expect(uncovered.length).toBe(2);
});
it("agent name is plan-checker", () => {
const agent = new PlanCheckerAgent();
expect(agent.name).toBe("plan-checker");
});
});
+153 -4
View File
@@ -1,5 +1,16 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface PlanCheckResult {
type: "missing_section" | "task_id_gap" | "missing_must_haves" | "wave_order_invalid" | "uncovered_requirement";
severity: "P0" | "P1" | "P2";
description: string;
taskId?: string;
}
const REQUIRED_SECTIONS = ["# Phase", "## Phase Goal", "## Plans"];
export class PlanCheckerAgent extends BaseAgent { export class PlanCheckerAgent extends BaseAgent {
readonly name = "plan-checker"; readonly name = "plan-checker";
readonly description = "Verifies plan quality. On ISSUES FOUND, triggers automatic plan revision (up to 3 iterations)."; readonly description = "Verifies plan quality. On ISSUES FOUND, triggers automatic plan revision (up to 3 iterations).";
@@ -8,6 +19,7 @@ export class PlanCheckerAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Checking plan quality..."); this.log("Checking plan quality...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const result = await this.executeViaBackend(
context, context,
@@ -15,14 +27,151 @@ export class PlanCheckerAgent extends BaseAgent {
); );
return { ...result, duration_ms: Date.now() - start }; return { ...result, duration_ms: Date.now() - start };
} }
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
let planContent = "";
if (fs.existsSync(planPath)) {
planContent = fs.readFileSync(planPath, "utf-8");
}
const results = this.mechanicalPlanCheck(context.project_path, planContent);
const p0Count = results.filter((r) => r.severity === "P0").length;
const output = this.formatResults(results);
return { return {
success: false, success: p0Count === 0,
output: "Plan checking requires an intelligence backend.", output,
artifacts_created: [], artifacts_created: [],
decisions: 0, decisions: 0,
escalations: 0, escalations: p0Count,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available", error: p0Count > 0 ? `${p0Count} P0 issue(s) found` : undefined,
}; };
} }
mechanicalPlanCheck(projectPath: string, planContent: string): PlanCheckResult[] {
const results: PlanCheckResult[] = [];
this.checkStructure(planContent, results);
this.checkTaskIds(planContent, results);
this.checkMustHavesPresent(planContent, results);
this.checkWaveOrdering(planContent, results);
this.checkRequirementCoverage(projectPath, planContent, results);
return results;
}
checkStructure(planContent: string, results: PlanCheckResult[]): void {
for (const section of REQUIRED_SECTIONS) {
if (!planContent.includes(section)) {
results.push({
type: "missing_section",
severity: "P0",
description: `Plan is missing required section: ${section}`,
});
}
}
}
checkTaskIds(planContent: string, results: PlanCheckResult[]): void {
const taskIdRegex = /###?\s+Task\s+[\d.]+[:\s]+T?([\d.]+)/gi;
const ids: number[] = [];
let match;
while ((match = taskIdRegex.exec(planContent)) !== null) {
const idParts = match[1].split(".");
const taskId = parseInt(idParts[idParts.length - 1], 10);
if (!isNaN(taskId)) ids.push(taskId);
}
if (ids.length === 0) return;
for (let i = 1; i <= Math.max(...ids); i++) {
if (!ids.includes(i)) {
results.push({
type: "task_id_gap",
severity: "P1",
description: `Task ID gap: missing Task ${i}`,
taskId: `T${i}`,
});
}
}
}
checkMustHavesPresent(planContent: string, results: PlanCheckResult[]): void {
const taskRegex = /###?\s+Task[^]*?(?=###?\s+Task|$)/g;
const taskBlocks = planContent.match(taskRegex) || [];
for (const block of taskBlocks) {
const headerMatch = block.match(/###?\s+Task\s+([\d.]+)/);
if (!headerMatch) continue;
const taskId = headerMatch[1];
const hasMustHaves = /must.haves|acceptance.criteria|must.?have/i.test(block);
if (!hasMustHaves) {
results.push({
type: "missing_must_haves",
severity: "P1",
description: `Task ${taskId} is missing must-haves/acceptance criteria`,
taskId,
});
}
}
}
checkWaveOrdering(planContent: string, results: PlanCheckResult[]): void {
const waveRegex = /##?\s+Wave\s+(\d+)/gi;
const waves: number[] = [];
let match;
while ((match = waveRegex.exec(planContent)) !== null) {
waves.push(parseInt(match[1], 10));
}
for (let i = 1; i < waves.length; i++) {
if (waves[i] < waves[i - 1]) {
results.push({
type: "wave_order_invalid",
severity: "P0",
description: `Wave ordering invalid: Wave ${waves[i]} appears after Wave ${waves[i - 1]}`,
});
}
}
}
checkRequirementCoverage(projectPath: string, planContent: string, results: PlanCheckResult[]): void {
const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md");
if (!fs.existsSync(reqPath)) return;
const reqContent = fs.readFileSync(reqPath, "utf-8");
const reqIdRegex = /REQ-(\d+)/g;
const requirements = new Set<string>();
let reqMatch;
while ((reqMatch = reqIdRegex.exec(reqContent)) !== null) {
requirements.add(`REQ-${reqMatch[1]}`);
}
const planReqIdRegex = /REQ-(\d+)/g;
const coveredReqs = new Set<string>();
let planMatch;
while ((planMatch = planReqIdRegex.exec(planContent)) !== null) {
coveredReqs.add(`REQ-${planMatch[1]}`);
}
for (const req of requirements) {
if (!coveredReqs.has(req)) {
results.push({
type: "uncovered_requirement",
severity: "P2",
description: `Requirement ${req} not covered in plan`,
});
}
}
}
private formatResults(results: PlanCheckResult[]): string {
if (results.length === 0) return "Plan check passed — no issues found.";
const lines: string[] = ["Plan Check Results:", ""];
for (const r of results) {
lines.push(`[${r.type}|${r.severity}] ${r.description}${r.taskId ? ` (task: ${r.taskId})` : ""}`);
}
return lines.join("\n");
}
} }
+93
View File
@@ -0,0 +1,93 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { ProjectResearcherAgent } from "../agents/project-researcher.js";
describe("ProjectResearcherAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-project-researcher-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("reads package.json and categorizes dependencies", () => {
fs.writeFileSync(
path.join(tempDir, "package.json"),
JSON.stringify({
dependencies: { express: "^4.18.0", graphql: "^16.0.0" },
devDependencies: { jest: "^29.0.0", typescript: "^5.0.0" },
})
);
const agent = new ProjectResearcherAgent();
const pkg = agent.readPackageJson(tempDir);
const tsconfig = {};
const summary = agent.categorizeFindings(pkg, tsconfig, []);
expect(summary.frameworks).toContain("express");
expect(summary.frameworks).toContain("jest");
expect(summary.apis).toContain("graphql");
expect(summary.tooling).toContain("typescript");
});
it("reads tsconfig and extracts compiler options", () => {
fs.writeFileSync(
path.join(tempDir, "tsconfig.json"),
JSON.stringify({
compilerOptions: { target: "ES2022", module: "Node16" },
})
);
const agent = new ProjectResearcherAgent();
const tsconfig = agent.readTsconfig(tempDir);
expect((tsconfig.compilerOptions as Record<string, unknown>).target).toBe("ES2022");
expect((tsconfig.compilerOptions as Record<string, unknown>).module).toBe("Node16");
});
it("categorizes tooling from scripts and engines", () => {
const pkg = {
scripts: { build: "tsc", test: "jest", lint: "eslint ." },
engines: { node: ">=18.0.0" },
};
const agent = new ProjectResearcherAgent();
const summary = agent.categorizeFindings(pkg, {}, []);
expect(summary.tooling).toContain("build_script");
expect(summary.tooling).toContain("test_script");
expect(summary.tooling).toContain("lint_script");
expect(summary.tooling).toContain("node:>=18.0.0");
});
it("detects patterns from devDependencies", () => {
const pkg = {
devDependencies: { jest: "^29.0.0", tsyringe: "^4.8.0" },
};
const agent = new ProjectResearcherAgent();
const summary = agent.categorizeFindings(pkg, {}, []);
expect(summary.patterns).toContain("test_driven");
expect(summary.patterns).toContain("dependency_injection");
});
it("returns empty summary when no package.json exists", () => {
const agent = new ProjectResearcherAgent();
const summary = agent.mechanicalProjectResearch(tempDir);
expect(summary.frameworks).toEqual([]);
expect(summary.apis).toEqual([]);
expect(summary.patterns).toEqual([]);
expect(summary.technologyDecisions).toEqual([]);
});
it("agent name is project-researcher", () => {
const agent = new ProjectResearcherAgent();
expect(agent.name).toBe("project-researcher");
});
});
+246 -4
View File
@@ -1,5 +1,57 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface EcosystemSummary {
frameworks: string[];
apis: string[];
patterns: string[];
tooling: string[];
technologyDecisions: Array<{ id: string; decision: string; confidence: number }>;
}
const FRAMEWORK_PATTERNS: Record<string, string[]> = {
react: ["react"],
vue: ["vue"],
angular: ["@angular/core"],
svelte: ["svelte"],
express: ["express"],
fastify: ["fastify"],
nestjs: ["@nestjs/core"],
next: ["next"],
nuxt: ["nuxt"],
koa: ["koa"],
jest: ["jest"],
vitest: ["vitest"],
};
const API_PATTERNS: Record<string, string[]> = {
graphql: ["graphql", "apollo", "@apollo"],
rest: ["express", "fastify", "restana"],
grpc: ["grpc", "@grpc"],
websocket: ["ws", "socket.io"],
};
const PATTERN_PATTERNS: Record<string, string[]> = {
microservices: ["@nestjs/microservices", "amqplib", "kafkajs"],
middleware: ["express", "koa", "fastify"],
cqrs: ["@nestjs/cqrs"],
dependency_injection: ["inversify", "tsyringe", "@nestjs/core"],
test_driven: ["jest", "vitest", "mocha"],
};
const TOOLING_PATTERNS: Record<string, string[]> = {
typescript: ["typescript"],
eslint: ["eslint"],
prettier: ["prettier"],
webpack: ["webpack"],
vite: ["vite"],
rollup: ["rollup"],
esbuild: ["esbuild"],
docker: [],
ci_cd: [],
};
export class ProjectResearcherAgent extends BaseAgent { export class ProjectResearcherAgent extends BaseAgent {
readonly name = "project-researcher"; readonly name = "project-researcher";
readonly description = "Researches the domain ecosystem for a new project."; readonly description = "Researches the domain ecosystem for a new project.";
@@ -8,6 +60,7 @@ export class ProjectResearcherAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Researching project domain ecosystem..."); this.log("Researching project domain ecosystem...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const result = await this.executeViaBackend(
context, context,
@@ -15,14 +68,203 @@ export class ProjectResearcherAgent extends BaseAgent {
); );
return { ...result, duration_ms: Date.now() - start }; return { ...result, duration_ms: Date.now() - start };
} }
const summary = this.mechanicalProjectResearch(context.project_path);
const output = this.formatSummary(summary);
return { return {
success: false, success: true,
output: "Project research requires an intelligence backend.", output,
artifacts_created: [], artifacts_created: [],
decisions: 0, decisions: summary.technologyDecisions.length,
escalations: 0, escalations: 0,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available",
}; };
} }
mechanicalProjectResearch(projectPath: string): EcosystemSummary {
const pkg = this.readPackageJson(projectPath);
const tsconfig = this.readTsconfig(projectPath);
const techDecisions = this.readTechDecisions(projectPath);
const summary = this.categorizeFindings(pkg, tsconfig, techDecisions);
return summary;
}
readPackageJson(projectPath: string): Record<string, unknown> {
const pkgPath = path.join(projectPath, "package.json");
if (!fs.existsSync(pkgPath)) return {};
try {
return JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
} catch {
return {};
}
}
readTsconfig(projectPath: string): Record<string, unknown> {
const tsconfigPath = path.join(projectPath, "tsconfig.json");
if (!fs.existsSync(tsconfigPath)) return {};
try {
return JSON.parse(fs.readFileSync(tsconfigPath, "utf-8"));
} catch {
return {};
}
}
readTechDecisions(projectPath: string): Array<{ id: string; decision: string; confidence: number }> {
const decisions: Array<{ id: string; decision: string; confidence: number }> = [];
const techCategories = ["technology_choice", "implementation_approach", "architecture"];
try {
const { execSync } = require("node:child_process");
const logContent = execSync(
`git log --all --format="%B" -100`,
{ cwd: projectPath, encoding: "utf-8", timeout: 5000 }
);
const categoryRegex = /category:\s*(\S+)/g;
const decisionRegex = /decisions:\s*\n((?:\s+-\s+.+\n?)+)/g;
let catMatch;
let match;
const blocks: Array<{ categories: string[]; items: string[] }> = [];
let currentCategories: string[] = [];
while ((catMatch = categoryRegex.exec(logContent)) !== null) {
currentCategories.push(catMatch[1].toLowerCase());
}
while ((match = decisionRegex.exec(logContent)) !== null) {
const items = match[1].split("\n").filter((l: string) => l.trim().startsWith("-"));
blocks.push({
categories: [...currentCategories],
items: items.map((i: string) => i.replace(/^\s*-\s*/, "").trim()),
});
}
for (const block of blocks) {
const isTech = block.categories.some((c) => techCategories.includes(c));
if (!isTech && block.categories.length > 0) continue;
for (const item of block.items) {
const idMatch = item.match(/D-(\d+)/);
const id = idMatch ? `D-${idMatch[1]}` : `D-${decisions.length + 1}`;
const confMatch = item.match(/confidence[:\s]+(\d+\.?\d*)/);
const confidence = confMatch ? parseFloat(confMatch[1]) : 0.5;
decisions.push({ id, decision: item, confidence });
}
}
} catch {
// git not available or no commits
}
return decisions;
}
categorizeFindings(
pkg: Record<string, unknown>,
tsconfig: Record<string, unknown>,
techDecisions: Array<{ id: string; decision: string; confidence: number }>
): EcosystemSummary {
const allDeps: string[] = [];
const deps = pkg.dependencies as Record<string, string> | undefined;
const devDeps = pkg.devDependencies as Record<string, string> | undefined;
if (deps) allDeps.push(...Object.keys(deps));
if (devDeps) allDeps.push(...Object.keys(devDeps));
const frameworks: string[] = [];
for (const [name, depPatterns] of Object.entries(FRAMEWORK_PATTERNS)) {
if (depPatterns.some((p) => allDeps.includes(p))) {
frameworks.push(name);
}
}
const apis: string[] = [];
for (const [name, depPatterns] of Object.entries(API_PATTERNS)) {
if (depPatterns.some((p) => allDeps.includes(p))) {
apis.push(name);
}
}
const patterns: string[] = [];
for (const [name, depPatterns] of Object.entries(PATTERN_PATTERNS)) {
if (depPatterns.some((p) => allDeps.includes(p))) {
patterns.push(name);
}
}
const tooling: string[] = [];
for (const [name, depPatterns] of Object.entries(TOOLING_PATTERNS)) {
if (depPatterns.some((p) => allDeps.includes(p))) {
tooling.push(name);
}
}
const compilerOptions = tsconfig.compilerOptions as Record<string, unknown> | undefined;
if (compilerOptions) {
const target = compilerOptions.target as string | undefined;
if (target) tooling.push(`es_target:${target.toLowerCase()}`);
const module = compilerOptions.module as string | undefined;
if (module) tooling.push(`module_system:${module.toLowerCase()}`);
}
const scripts = pkg.scripts as Record<string, string> | undefined;
if (scripts) {
if (scripts.build) tooling.push("build_script");
if (scripts.test) tooling.push("test_script");
if (scripts.lint) tooling.push("lint_script");
}
const engines = pkg.engines as Record<string, string> | undefined;
if (engines && engines.node) {
tooling.push(`node:${engines.node}`);
}
return {
frameworks,
apis,
patterns,
tooling,
technologyDecisions: techDecisions,
};
}
private formatSummary(summary: EcosystemSummary): string {
const lines: string[] = ["Ecosystem Summary:", ""];
lines.push("Frameworks:");
for (const f of summary.frameworks) {
lines.push(` - ${f}`);
}
if (summary.frameworks.length === 0) lines.push(" (none detected)");
lines.push("");
lines.push("APIs:");
for (const a of summary.apis) {
lines.push(` - ${a}`);
}
if (summary.apis.length === 0) lines.push(" (none detected)");
lines.push("");
lines.push("Patterns:");
for (const p of summary.patterns) {
lines.push(` - ${p}`);
}
if (summary.patterns.length === 0) lines.push(" (none detected)");
lines.push("");
lines.push("Tooling:");
for (const t of summary.tooling) {
lines.push(` - ${t}`);
}
if (summary.tooling.length === 0) lines.push(" (none detected)");
lines.push("");
lines.push("Technology Decisions:");
for (const d of summary.technologyDecisions) {
lines.push(` [${d.id}|conf=${d.confidence.toFixed(2)}] ${d.decision}`);
}
if (summary.technologyDecisions.length === 0) lines.push(" (none found)");
return lines.join("\n");
}
} }
+72
View File
@@ -0,0 +1,72 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { ResearchSynthesizerAgent } from "../agents/research-synthesizer.js";
describe("ResearchSynthesizerAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-synth-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("reads project files and extracts findings", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "# Architecture\n\n- Component A\n- Component B");
fs.writeFileSync(path.join(ciagentDir, "REQUIREMENTS.md"), "# Requirements\n\n* REQ-1\n* REQ-2");
const agent = new ResearchSynthesizerAgent();
const findings = agent.mechanicalSynthesize(tempDir);
expect(findings.length).toBeGreaterThan(0);
const sources = findings.map((f) => f.source);
expect(sources).toContain("ARCHITECTURE.md");
expect(sources).toContain("REQUIREMENTS.md");
});
it("merges overlapping topics from different sources", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "# Testing Strategy\n\n- Unit tests required");
fs.writeFileSync(path.join(ciagentDir, "PROJECT.md"), "# Testing Strategy\n\n- Integration tests needed");
const agent = new ResearchSynthesizerAgent();
const findings = agent.mechanicalSynthesize(tempDir);
const testingFindings = findings.filter((f) => f.topic.includes("testing strategy"));
expect(testingFindings.length).toBeGreaterThanOrEqual(1);
const refs = testingFindings[0].crossReferences;
expect(refs).toContain("ARCHITECTURE.md");
expect(refs).toContain("PROJECT.md");
});
it("adds cross references between findings with shared topics", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "## API Layer\n\n- REST endpoints");
fs.writeFileSync(path.join(ciagentDir, "REQUIREMENTS.md"), "## API Layer\n\n* Authentication");
const agent = new ResearchSynthesizerAgent();
const findings = agent.mechanicalSynthesize(tempDir);
const apiFindings = findings.filter((f) => f.topic.includes("api layer"));
expect(apiFindings.length).toBeGreaterThan(0);
});
it("returns empty findings when no files exist", () => {
const agent = new ResearchSynthesizerAgent();
const findings = agent.mechanicalSynthesize(tempDir);
expect(findings).toEqual([]);
});
it("agent name is research-synthesizer", () => {
const agent = new ResearchSynthesizerAgent();
expect(agent.name).toBe("research-synthesizer");
});
});
+118 -3
View File
@@ -1,5 +1,20 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface SynthesisFinding {
source: "ARCHITECTURE.md" | "REQUIREMENTS.md" | "PROJECT.md" | "git_log";
topic: string;
summary: string;
crossReferences: string[];
}
const SOURCE_FILES: Array<{ file: string; source: SynthesisFinding["source"] }> = [
{ file: "ARCHITECTURE.md", source: "ARCHITECTURE.md" },
{ file: "REQUIREMENTS.md", source: "REQUIREMENTS.md" },
{ file: "PROJECT.md", source: "PROJECT.md" },
];
export class ResearchSynthesizerAgent extends BaseAgent { export class ResearchSynthesizerAgent extends BaseAgent {
readonly name = "research-synthesizer"; readonly name = "research-synthesizer";
readonly description = "Synthesizes research files into a cohesive summary for roadmap creation."; readonly description = "Synthesizes research files into a cohesive summary for roadmap creation.";
@@ -8,6 +23,7 @@ export class ResearchSynthesizerAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Synthesizing research..."); this.log("Synthesizing research...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const result = await this.executeViaBackend(
context, context,
@@ -15,14 +31,113 @@ export class ResearchSynthesizerAgent extends BaseAgent {
); );
return { ...result, duration_ms: Date.now() - start }; return { ...result, duration_ms: Date.now() - start };
} }
const findings = this.mechanicalSynthesize(context.project_path);
const output = this.formatFindings(findings);
return { return {
success: false, success: true,
output: "Research synthesis requires an intelligence backend.", output,
artifacts_created: [], artifacts_created: [],
decisions: 0, decisions: 0,
escalations: 0, escalations: 0,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available",
}; };
} }
mechanicalSynthesize(projectPath: string): SynthesisFinding[] {
const fileContents = this.readProjectFiles(projectPath);
const allFindings = this.extractKeyStatements(fileContents);
const merged = this.mergeOverlapping(allFindings);
return this.addCrossReferences(merged);
}
readProjectFiles(projectPath: string): Array<{ source: SynthesisFinding["source"]; content: string }> {
const results: Array<{ source: SynthesisFinding["source"]; content: string }> = [];
const ciagentDir = path.join(projectPath, ".ciagent");
for (const { file, source } of SOURCE_FILES) {
const filePath = path.join(ciagentDir, file);
if (fs.existsSync(filePath)) {
results.push({ source, content: fs.readFileSync(filePath, "utf-8") });
}
}
return results;
}
extractKeyStatements(fileContents: Array<{ source: SynthesisFinding["source"]; content: string }>): SynthesisFinding[] {
const findings: SynthesisFinding[] = [];
const topicPatterns = [
/(?:^|\n)#{1,3}\s+(.+)/g,
/(?:^|\n)\*\s+(.+)/g,
/(?:^|\n)-\s+(.{5,80})/g,
];
for (const { source, content } of fileContents) {
if (!content.trim()) continue;
for (const pattern of topicPatterns) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(content)) !== null) {
const topic = match[1].trim().replace(/[*`#]/g, "").substring(0, 80).trim();
if (topic.length < 3) continue;
findings.push({
source,
topic: topic.toLowerCase(),
summary: topic,
crossReferences: [],
});
}
}
}
return findings;
}
mergeOverlapping(findings: SynthesisFinding[]): SynthesisFinding[] {
const merged: Map<string, SynthesisFinding> = new Map();
for (const finding of findings) {
const key = finding.topic;
const existing = merged.get(key);
if (existing) {
if (!existing.crossReferences.includes(finding.source)) {
existing.crossReferences.push(finding.source);
}
} else {
merged.set(key, {
...finding,
crossReferences: [finding.source],
});
}
}
return Array.from(merged.values());
}
addCrossReferences(findings: SynthesisFinding[]): SynthesisFinding[] {
for (let i = 0; i < findings.length; i++) {
for (let j = 0; j < findings.length; j++) {
if (i === j) continue;
const topicA = findings[i].topic.split(" ").slice(0, 2).join(" ");
const topicB = findings[j].topic.split(" ").slice(0, 2).join(" ");
if (topicA === topicB && !findings[i].crossReferences.includes(findings[j].source)) {
findings[i].crossReferences.push(findings[j].source);
}
}
}
const crossReferenced = findings.filter((f) => f.crossReferences.length > 1);
const standalone = findings.filter((f) => f.crossReferences.length <= 1);
return [...crossReferenced, ...standalone];
}
private formatFindings(findings: SynthesisFinding[]): string {
if (findings.length === 0) return "No findings synthesized — no project files found.";
const lines: string[] = ["Synthesis Findings:", ""];
for (const f of findings) {
const refs = f.crossReferences.length > 0 ? f.crossReferences.join(", ") : "none";
lines.push(`[${f.source}] ${f.summary} (refs: ${refs})`);
}
return lines.join("\n");
}
} }
+77
View File
@@ -0,0 +1,77 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { RoadmapperAgent } from "../agents/roadmapper.js";
describe("RoadmapperAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-roadmapper-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("generates phases from REQUIREMENTS.md", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"REQ-1: Phase: 1 — Build core\nREQ-2: Phase: 1 — Build utils\nREQ-3: Phase: 2 — Add features"
);
const agent = new RoadmapperAgent();
const phases = agent.mechanicalRoadmapGenerate(tempDir);
expect(phases.length).toBeGreaterThanOrEqual(2);
expect(phases[0].requirements).toContain("REQ-1");
expect(phases[0].requirements).toContain("REQ-2");
expect(phases[1].requirements).toContain("REQ-3");
});
it("sets phase dependencies correctly", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"REQ-1: Phase: 1 — Core\nREQ-2: Phase: 2 — Extension"
);
const agent = new RoadmapperAgent();
const phases = agent.mechanicalRoadmapGenerate(tempDir);
expect(phases.length).toBe(2);
expect(phases[0].dependencies).toEqual([]);
expect(phases[1].dependencies).toEqual([1]);
});
it("generates success criteria from requirements", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"REQ-1: Phase: 1 — Build core"
);
const agent = new RoadmapperAgent();
const phases = agent.mechanicalRoadmapGenerate(tempDir);
expect(phases.length).toBe(1);
expect(phases[0].successCriteria.length).toBeGreaterThan(0);
expect(phases[0].successCriteria.some((c) => c.includes("REQ-1"))).toBe(true);
});
it("returns empty when no REQUIREMENTS.md exists", () => {
const agent = new RoadmapperAgent();
const phases = agent.mechanicalRoadmapGenerate(tempDir);
expect(phases).toEqual([]);
});
it("agent name is roadmapper", () => {
const agent = new RoadmapperAgent();
expect(agent.name).toBe("roadmapper");
});
});
+110 -3
View File
@@ -1,5 +1,16 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface PhaseDefinition {
number: number;
name: string;
description: string;
requirements: string[];
dependencies: number[];
successCriteria: string[];
}
export class RoadmapperAgent extends BaseAgent { export class RoadmapperAgent extends BaseAgent {
readonly name = "roadmapper"; readonly name = "roadmapper";
readonly description = "Creates and maintains project roadmaps."; readonly description = "Creates and maintains project roadmaps.";
@@ -8,6 +19,7 @@ export class RoadmapperAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Creating roadmap..."); this.log("Creating roadmap...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const result = await this.executeViaBackend(
context, context,
@@ -15,14 +27,109 @@ export class RoadmapperAgent extends BaseAgent {
); );
return { ...result, duration_ms: Date.now() - start }; return { ...result, duration_ms: Date.now() - start };
} }
const phases = this.mechanicalRoadmapGenerate(context.project_path);
const output = this.formatPhases(phases);
return { return {
success: false, success: true,
output: "Roadmap creation requires an intelligence backend.", output,
artifacts_created: [], artifacts_created: [],
decisions: 0, decisions: 0,
escalations: 0, escalations: 0,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available",
}; };
} }
mechanicalRoadmapGenerate(projectPath: string): PhaseDefinition[] {
const requirements = this.readRequirements(projectPath);
const grouped = this.groupRequirementsByPhase(requirements);
const phases = this.assignPhases(grouped);
return phases.map((phase) => ({
...phase,
successCriteria: this.generateSuccessCriteria(phase),
}));
}
readRequirements(projectPath: string): Array<{ id: string; phase: number; text: string }> {
const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md");
if (!fs.existsSync(reqPath)) return [];
const content = fs.readFileSync(reqPath, "utf-8");
const requirements: Array<{ id: string; phase: number; text: string }> = [];
const reqBlockRegex = /REQ-(\d+)[^]*?(?=REQ-\d+|$)/g;
let match;
while ((match = reqBlockRegex.exec(content)) !== null) {
const block = match[0];
const id = `REQ-${match[1]}`;
const phaseMatch = block.match(/phase[:\s]+(\d+)/i);
const phase = phaseMatch ? parseInt(phaseMatch[1], 10) : 1;
const textMatch = block.match(/(?:title|description|requirement)[:\s]+(.+)/i);
const text = textMatch ? textMatch[1].trim() : id;
requirements.push({ id, phase, text });
}
return requirements;
}
groupRequirementsByPhase(requirements: Array<{ id: string; phase: number; text: string }>): Record<number, Array<{ id: string; text: string }>> {
const groups: Record<number, Array<{ id: string; text: string }>> = {};
for (const req of requirements) {
if (!groups[req.phase]) {
groups[req.phase] = [];
}
groups[req.phase].push({ id: req.id, text: req.text });
}
return groups;
}
assignPhases(grouped: Record<number, Array<{ id: string; text: string }>>): PhaseDefinition[] {
const phaseNumbers = Object.keys(grouped).map(Number).sort((a, b) => a - b);
if (phaseNumbers.length === 0) return [];
return phaseNumbers.map((num, idx) => {
const reqs = grouped[num];
const dependencies = idx === 0 ? [] : [phaseNumbers[idx - 1]];
return {
number: num,
name: `Phase ${num}`,
description: `Implementation phase ${num} covering ${reqs.length} requirement(s).`,
requirements: reqs.map((r) => r.id),
dependencies,
successCriteria: [],
};
});
}
generateSuccessCriteria(phase: PhaseDefinition): string[] {
const criteria: string[] = [];
for (const reqId of phase.requirements) {
criteria.push(`${reqId} fully implemented and verified`);
}
if (phase.requirements.length > 0) {
criteria.push("All tests passing for phase requirements");
}
if (phase.dependencies.length > 0) {
criteria.push(`Phase ${phase.dependencies[0]} completion confirmed`);
}
return criteria;
}
private formatPhases(phases: PhaseDefinition[]): string {
if (phases.length === 0) return "No phases generated — no requirements found.";
const lines: string[] = ["Roadmap:", ""];
for (const phase of phases) {
lines.push(`Phase ${phase.number}: ${phase.name}`);
lines.push(` Description: ${phase.description}`);
lines.push(` Requirements: ${phase.requirements.join(", ") || "none"}`);
lines.push(` Dependencies: ${phase.dependencies.map(String).join(", ") || "none"}`);
lines.push(` Success Criteria:`);
for (const criterion of phase.successCriteria) {
lines.push(` - ${criterion}`);
}
lines.push("");
}
return lines.join("\n");
}
} }
+89
View File
@@ -0,0 +1,89 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { SolutionWriterAgent } from "../agents/solution-writer.js";
describe("SolutionWriterAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-solution-writer-test-"));
fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("reads PLAN.md and produces implementation plan section", () => {
fs.writeFileSync(
path.join(tempDir, ".ciagent", "PLAN.md"),
"# Plan\n\n| T-01 | Setup | 1 | none | Files exist | REQ-01 |\n|-----|-------|---|------|------------|--------|\n"
);
const agent = new SolutionWriterAgent();
const result = agent.mechanicalSolutionWrite(tempDir);
expect(result).toContain("# Solution Document");
expect(result).toContain("## Implementation Plan");
expect(result).toContain("T-01");
});
it("reads REQUIREMENTS.md and extracts verification criteria", () => {
fs.writeFileSync(
path.join(tempDir, ".ciagent", "REQUIREMENTS.md"),
"# Requirements\n\nREQ-01: System must boot\nREQ-02: System must respond\n"
);
const agent = new SolutionWriterAgent();
const result = agent.mechanicalSolutionWrite(tempDir);
expect(result).toContain("## Verification Criteria");
expect(result).toContain("REQ-01");
expect(result).toContain("REQ-02");
});
it("reads ARCHITECTURE.md and populates approach and risk sections", () => {
fs.writeFileSync(
path.join(tempDir, ".ciagent", "ARCHITECTURE.md"),
"# Architecture\n\n## Approach\n\nModular monolith with clear boundaries.\n\n## Risks\n\n- Single point of failure in gateway\n"
);
const agent = new SolutionWriterAgent();
const result = agent.mechanicalSolutionWrite(tempDir);
expect(result).toContain("## Approach");
expect(result).toContain("Modular monolith");
expect(result).toContain("## Risk Assessment");
expect(result).toContain("gateway");
});
it("produces structured markdown with all five sections", () => {
const agent = new SolutionWriterAgent();
const result = agent.mechanicalSolutionWrite(tempDir);
expect(result).toContain("# Solution Document");
expect(result).toContain("## Problem Statement");
expect(result).toContain("## Approach");
expect(result).toContain("## Implementation Plan");
expect(result).toContain("## Verification Criteria");
expect(result).toContain("## Risk Assessment");
});
it("extracts problem statement from requirements objective section", () => {
fs.writeFileSync(
path.join(tempDir, ".ciagent", "REQUIREMENTS.md"),
"# Objective\n\nBuild a fast CLI tool.\n\n## Requirements\n\nREQ-01: Speed"
);
const agent = new SolutionWriterAgent();
const result = agent.mechanicalSolutionWrite(tempDir);
expect(result).toContain("Build a fast CLI tool");
});
it("agent name is solution-writer", () => {
const agent = new SolutionWriterAgent();
expect(agent.name).toBe("solution-writer");
});
});
+199 -3
View File
@@ -1,5 +1,12 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface SolutionSection {
title: string;
content: string;
}
export class SolutionWriterAgent extends BaseAgent { export class SolutionWriterAgent extends BaseAgent {
readonly name = "solution-writer"; readonly name = "solution-writer";
readonly description = "Produces structured solution documents."; readonly description = "Produces structured solution documents.";
@@ -8,6 +15,7 @@ export class SolutionWriterAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Writing solution document..."); this.log("Writing solution document...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const result = await this.executeViaBackend(
context, context,
@@ -15,14 +23,202 @@ export class SolutionWriterAgent extends BaseAgent {
); );
return { ...result, duration_ms: Date.now() - start }; return { ...result, duration_ms: Date.now() - start };
} }
const document = this.mechanicalSolutionWrite(context.project_path);
return { return {
success: false, success: true,
output: "Solution writing requires an intelligence backend.", output: document,
artifacts_created: [], artifacts_created: [],
decisions: 0, decisions: 0,
escalations: 0, escalations: 0,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available",
}; };
} }
mechanicalSolutionWrite(projectPath: string): string {
const plan = this.readPlan(projectPath);
const requirements = this.readRequirements(projectPath);
const architecture = this.readArchitecture(projectPath);
const sections: SolutionSection[] = [
{ title: "Problem Statement", content: this.extractProblemStatement(requirements, plan) },
{ title: "Approach", content: this.extractApproach(requirements, architecture) },
{ title: "Implementation Plan", content: this.extractImplementationPlan(plan) },
{ title: "Verification Criteria", content: this.extractVerificationCriteria(requirements) },
{ title: "Risk Assessment", content: this.extractRiskAssessment(architecture) },
];
return this.fillTemplate(sections);
}
readPlan(projectPath: string): string {
const planPath = path.join(projectPath, ".ciagent", "PLAN.md");
if (!fs.existsSync(planPath)) return "";
try {
return fs.readFileSync(planPath, "utf-8");
} catch {
return "";
}
}
readRequirements(projectPath: string): string {
const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md");
if (!fs.existsSync(reqPath)) return "";
try {
return fs.readFileSync(reqPath, "utf-8");
} catch {
return "";
}
}
readArchitecture(projectPath: string): string {
const archPath = path.join(projectPath, ".ciagent", "ARCHITECTURE.md");
if (!fs.existsSync(archPath)) return "";
try {
return fs.readFileSync(archPath, "utf-8");
} catch {
return "";
}
}
fillTemplate(sections: SolutionSection[]): string {
const lines: string[] = ["# Solution Document", ""];
for (const section of sections) {
lines.push(`## ${section.title}`);
lines.push("");
if (section.content.trim()) {
lines.push(section.content.trim());
} else {
lines.push(`_No ${section.title.toLowerCase()} information available._`);
}
lines.push("");
}
return lines.join("\n");
}
extractSectionContent(content: string, headingPatterns: string[]): string {
if (!content.trim()) return "";
const sections = content.split(/(?=^#{1,3}\s)/m).filter((s) => s.trim());
for (const section of sections) {
for (const pattern of headingPatterns) {
if (section.toLowerCase().startsWith(pattern.toLowerCase())) {
const lines = section.split("\n");
return lines.slice(1).join("\n").trim();
}
}
}
return "";
}
extractProblemStatement(requirements: string, plan: string): string {
const content = this.extractSectionContent(requirements, ["# objective", "## objective", "# problem", "## problem", "# goal", "## goal"]);
if (content) return content;
const planContent = this.extractSectionContent(plan, ["# objective", "## objective", "# problem", "## problem", "# goal", "## goal"]);
if (planContent) return planContent;
const firstReq = requirements.match(/-?\s*(REQ-\d+[:\s]+)/g);
if (firstReq) {
return "Requirements to address: " + firstReq.map((m) => m.trim()).join(", ");
}
if (requirements || plan) {
const src = requirements || plan;
const firstParagraph = src.split("\n\n")[0]?.trim();
if (firstParagraph && !firstParagraph.startsWith("#")) return firstParagraph;
}
return "No problem statement could be extracted from project files.";
}
extractApproach(requirements: string, architecture: string): string {
const archContent = this.extractSectionContent(architecture, ["## approach", "### approach", "## design", "### design", "## architecture overview"]);
if (archContent) return archContent;
const reqContent = this.extractSectionContent(requirements, ["## approach", "### approach", "## design", "### design"]);
if (reqContent) return reqContent;
const compContent = this.extractSectionContent(architecture, ["## components", "### components"]);
if (compContent) return "Architecture-based approach: " + compContent.substring(0, 200);
if (architecture) {
const firstParagraph = architecture.split("\n\n")[0]?.trim();
if (firstParagraph && !firstParagraph.startsWith("#")) return firstParagraph;
}
return "No approach information could be extracted from project files.";
}
extractImplementationPlan(plan: string): string {
if (!plan.trim()) return "No implementation plan available — PLAN.md not found.";
const taskPattern = plan.match(/\|\s*T-\d+.*\|/g);
if (taskPattern) {
const lines: string[] = ["Tasks from plan:"];
for (const task of taskPattern) {
lines.push(` ${task.trim()}`);
}
return lines.join("\n");
}
const waveSections = plan.split(/(?=^#{1,3}\s+Wave\s)/mi).filter((s) => s.trim());
if (waveSections.length > 1) {
const lines: string[] = [];
for (const wave of waveSections.slice(1)) {
lines.push(wave.trim());
}
return lines.join("\n\n");
}
const sections = plan.split(/(?=^#{1,3}\s)/m).filter((s) => s.trim());
const lines: string[] = [];
for (const section of sections.slice(0, 5)) {
lines.push(section.trim());
}
return lines.join("\n\n");
}
extractVerificationCriteria(requirements: string): string {
const lines: string[] = [];
const reqIds = requirements.match(/REQ-\d+/g);
if (reqIds) {
const uniqueIds = [...new Set(reqIds)];
lines.push("Requirements coverage:");
for (const id of uniqueIds) {
lines.push(` - ${id}: verified`);
}
}
const verContent = this.extractSectionContent(requirements, ["## verification", "### verification", "## acceptance", "### acceptance", "## testing", "### testing"]);
if (verContent) {
lines.push(verContent);
}
if (lines.length === 0) lines.push("No verification criteria extracted — add requirements with REQ-IDs or a Verification section.");
return lines.join("\n\n");
}
extractRiskAssessment(architecture: string): string {
const lines: string[] = [];
const riskContent = this.extractSectionContent(architecture, ["## risk", "### risk", "## risks", "### risks", "## concern", "## mitigation"]);
if (riskContent) {
lines.push(riskContent);
}
const depContent = this.extractSectionContent(architecture, ["## dependencies", "### dependencies", "## external", "### external"]);
if (depContent) {
lines.push("Dependency risks:");
lines.push(depContent);
}
if (lines.length === 0) lines.push("No risks identified — review architecture for potential concerns.");
return lines.join("\n\n");
}
} }
+196
View File
@@ -0,0 +1,196 @@
import { AnthropicBackend } from "../backends/anthropic.js";
import { ChatCompletionResponse } from "../backends/llm-base.js";
describe("AnthropicBackend", () => {
const originalFetch = globalThis.fetch;
let fetchCalls: Array<{ url: string; headers: Record<string, string>; body: string }>;
beforeEach(() => {
fetchCalls = [];
});
afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.TEST_ANTHROPIC_KEY;
delete process.env.TEST_ANTHROPIC_KEY_EMPTY;
});
function mockFetch(response: Record<string, unknown>, status = 200): void {
globalThis.fetch = ((url: string, init: RequestInit) => {
fetchCalls.push({
url,
headers: (init.headers as Record<string, string>) || {},
body: init.body as string,
});
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
}) as typeof fetch;
}
function makeAnthropicResponse(text: string, usage = { input_tokens: 10, output_tokens: 20 }): Record<string, unknown> {
return {
content: [{ type: "text", text }],
usage,
model: "claude-sonnet-4-20250514",
};
}
describe("isAvailable", () => {
it("returns true when API key is present", async () => {
process.env.TEST_ANTHROPIC_KEY = "sk-ant-test-key-123";
const backend = new AnthropicBackend({
base_url: "https://api.anthropic.com",
api_key_env: "TEST_ANTHROPIC_KEY",
model: "claude-sonnet-4-20250514",
model_profile: "quality",
});
expect(await backend.isAvailable()).toBe(true);
});
it("returns false when API key is absent", async () => {
const backend = new AnthropicBackend({
base_url: "https://api.anthropic.com",
api_key_env: "NONEXISTENT_ANTHROPIC_KEY_VAR_99999",
model: "claude-sonnet-4-20250514",
model_profile: "quality",
});
expect(await backend.isAvailable()).toBe(false);
});
});
describe("resolveModel", () => {
it("returns config.model when set", async () => {
process.env.TEST_ANTHROPIC_KEY = "sk-ant-test";
mockFetch(makeAnthropicResponse('{"success": true, "output": "done"}'));
const backend = new AnthropicBackend({
base_url: "https://api.anthropic.com",
api_key_env: "TEST_ANTHROPIC_KEY",
model: "claude-3-haiku-20240307",
model_profile: "speed",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("claude-3-haiku-20240307");
});
it("defaults to claude-sonnet-4-20250514 when model not specified", async () => {
process.env.TEST_ANTHROPIC_KEY = "sk-ant-test";
mockFetch(makeAnthropicResponse('{"success": true, "output": "done"}'));
const backend = new AnthropicBackend({
base_url: "https://api.anthropic.com",
api_key_env: "TEST_ANTHROPIC_KEY",
model: "",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("claude-sonnet-4-20250514");
});
});
describe("callModel request format", () => {
it("sends correct URL, x-api-key header, anthropic-version header, system field, max_tokens", async () => {
process.env.TEST_ANTHROPIC_KEY = "sk-ant-test-key-abc";
mockFetch(makeAnthropicResponse('{"success": true, "output": "done"}'));
const backend = new AnthropicBackend({
base_url: "https://api.anthropic.com",
api_key_env: "TEST_ANTHROPIC_KEY",
model: "claude-sonnet-4-20250514",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "Do the thing",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls.length).toBe(1);
expect(fetchCalls[0].url).toBe("https://api.anthropic.com/v1/messages");
expect(fetchCalls[0].headers["x-api-key"]).toBe("sk-ant-test-key-abc");
expect(fetchCalls[0].headers["anthropic-version"]).toBe("2023-06-01");
expect(fetchCalls[0].headers["Content-Type"]).toBe("application/json");
expect(fetchCalls[0].headers["Authorization"]).toBeUndefined();
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("claude-sonnet-4-20250514");
expect(body.max_tokens).toBe(4096);
expect(typeof body.system).toBe("string");
expect(body.system.length).toBeGreaterThan(0);
expect(Array.isArray(body.messages)).toBe(true);
expect(body.messages.length).toBeGreaterThanOrEqual(1);
});
});
describe("custom base_url override", () => {
it("sends request to custom base_url", async () => {
process.env.TEST_ANTHROPIC_KEY = "sk-ant-test";
mockFetch(makeAnthropicResponse('{"success": true, "output": "done"}'));
const backend = new AnthropicBackend({
base_url: "https://custom-proxy.example.com/api",
api_key_env: "TEST_ANTHROPIC_KEY",
model: "claude-sonnet-4-20250514",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls[0].url).toBe("https://custom-proxy.example.com/api/v1/messages");
});
});
});
+171
View File
@@ -0,0 +1,171 @@
import { LLMBaseBackend, ChatMessage, ChatCompletionResponse } from "./llm-base.js";
import { BackendType, AnthropicConfig, emptyBackendResult } from "./types.js";
import { ToolRegistry, ToolDefinition } from "./tool-registry.js";
export class AnthropicBackend extends LLMBaseBackend {
readonly name = "anthropic";
readonly type: BackendType = "llm";
private anthropicConfig: AnthropicConfig;
constructor(config: AnthropicConfig) {
super({ ...config, base_url: config.base_url || "https://api.anthropic.com" });
this.anthropicConfig = config;
}
async isAvailable(): Promise<boolean> {
const key = process.env[this.anthropicConfig.api_key_env];
return !!key && key.length > 0;
}
protected resolveModel(): string {
return this.anthropicConfig.model || "claude-sonnet-4-20250514";
}
protected async fetchAvailableModels(): Promise<string[]> {
return [];
}
protected async callModel(
messages: ChatMessage[],
model: string,
toolRegistry: ToolRegistry
): Promise<ChatCompletionResponse> {
const apiKey = process.env[this.anthropicConfig.api_key_env];
if (!apiKey) {
throw new Error(`API key not found. Set ${this.anthropicConfig.api_key_env} environment variable.`);
}
const apiVersion = this.anthropicConfig.api_version || "2023-06-01";
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": apiVersion,
};
let systemContent = "";
const filteredMessages: Array<{ role: string; content: Array<{ type: string; text: string }> }> = [];
for (const m of messages) {
if (m.role === "system") {
systemContent += (systemContent ? "\n" : "") + m.content;
} else if (m.role === "tool") {
filteredMessages.push({
role: "user",
content: [{ type: "text", text: m.content }],
});
} else if (m.role === "assistant") {
const contentBlocks: Array<{ type: string; text: string }> = [];
if (m.content) {
contentBlocks.push({ type: "text", text: m.content });
}
if (m.tool_calls) {
for (const tc of m.tool_calls) {
contentBlocks.push({
type: "tool_use",
text: JSON.stringify({ name: tc.function.name, input: JSON.parse(tc.function.arguments) }),
});
}
}
filteredMessages.push({
role: "assistant",
content: contentBlocks,
});
} else {
filteredMessages.push({
role: m.role,
content: [{ type: "text", text: m.content }],
});
}
}
const toolDefinitions = this.getActiveToolSchema(toolRegistry);
const anthropicTools = toolDefinitions.map((tool) => {
const fn = (tool as Record<string, unknown>).function as Record<string, unknown>;
return {
name: fn.name,
description: fn.description,
input_schema: fn.parameters,
};
});
const body: Record<string, unknown> = {
model,
max_tokens: 4096,
messages: filteredMessages,
};
if (systemContent) {
body.system = systemContent;
}
if (anthropicTools.length > 0) {
body.tools = anthropicTools;
}
const timeout = this.anthropicConfig.timeout_ms || 60000;
const baseUrl = this.config.base_url;
const url = `${baseUrl}/v1/messages`;
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
if (response.status === 401 || response.status === 403) {
throw new Error(`Authentication failed. Check ${this.anthropicConfig.api_key_env} environment variable.`);
}
if (response.status === 429) {
throw new Error("Rate limited by Anthropic API. Please retry after a delay.");
}
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
throw new Error(`Anthropic API error (${response.status}): ${errorText}`);
}
const anthropicResponse = await response.json() as Record<string, unknown>;
return this.translateResponse(anthropicResponse);
}
private translateResponse(response: Record<string, unknown>): ChatCompletionResponse {
const content = (response.content as Array<Record<string, unknown>>) || [];
let textContent = "";
const toolCalls: Array<{ function: { name: string; arguments: string } }> = [];
for (const block of content) {
if (block.type === "text") {
textContent += (block.text as string) || "";
} else if (block.type === "tool_use") {
toolCalls.push({
function: {
name: (block.name as string) || "",
arguments: JSON.stringify(block.input || {}),
},
});
}
}
const usage = response.usage as { input_tokens: number; output_tokens: number } | undefined;
return {
choices: [
{
message: {
content: textContent,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
},
},
],
usage: {
prompt_tokens: usage?.input_tokens || 0,
completion_tokens: usage?.output_tokens || 0,
total_tokens: (usage?.input_tokens || 0) + (usage?.output_tokens || 0),
},
};
}
}
+1
View File
@@ -96,6 +96,7 @@ describe("Backend Availability Detection", () => {
it("contains installation hints", () => { it("contains installation hints", () => {
const err = new BackendUnavailableError("auto"); const err = new BackendUnavailableError("auto");
expect(err.message).toContain("opencode"); expect(err.message).toContain("opencode");
expect(err.message).toContain("OpenAI");
expect(err.message).toContain("Ollama"); expect(err.message).toContain("Ollama");
expect(err.message).toContain("OLLAMA_CLOUD_API_KEY"); expect(err.message).toContain("OLLAMA_CLOUD_API_KEY");
}); });
+1
View File
@@ -54,6 +54,7 @@ describe("DEFAULT_BACKEND_CONFIG", () => {
}); });
it("has ollama-local and ollama-cloud llm backends", () => { it("has ollama-local and ollama-cloud llm backends", () => {
expect(DEFAULT_BACKEND_CONFIG.llm_backends["openai"]).toBeDefined();
expect(DEFAULT_BACKEND_CONFIG.llm_backends["ollama-local"]).toBeDefined(); expect(DEFAULT_BACKEND_CONFIG.llm_backends["ollama-local"]).toBeDefined();
expect(DEFAULT_BACKEND_CONFIG.llm_backends["ollama-cloud"]).toBeDefined(); expect(DEFAULT_BACKEND_CONFIG.llm_backends["ollama-cloud"]).toBeDefined();
}); });
+18 -1
View File
@@ -1,12 +1,16 @@
import { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js"; import { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js";
import { OpencodeBackend } from "./opencode.js"; import { OpencodeBackend } from "./opencode.js";
import { OpenAIBackend } from "./openai.js";
import { OllamaLocalBackend } from "./ollama-local.js"; import { OllamaLocalBackend } from "./ollama-local.js";
import { OllamaCloudBackend } from "./ollama-cloud.js"; import { OllamaCloudBackend } from "./ollama-cloud.js";
import { AnthropicBackend } from "./anthropic.js";
const AUTO_DETECT_ORDER: Array<"opencode" | "ollama-local" | "ollama-cloud"> = [ const AUTO_DETECT_ORDER: Array<"opencode" | "openai" | "ollama-local" | "ollama-cloud" | "anthropic"> = [
"opencode", "opencode",
"openai",
"ollama-local", "ollama-local",
"ollama-cloud", "ollama-cloud",
"anthropic",
]; ];
export function createBackend( export function createBackend(
@@ -16,10 +20,20 @@ export function createBackend(
switch (name) { switch (name) {
case "opencode": case "opencode":
return new OpencodeBackend(config.agent_backends.opencode); return new OpencodeBackend(config.agent_backends.opencode);
case "openai":
if (!config.llm_backends["openai"]) {
throw new BackendUnavailableError("openai");
}
return new OpenAIBackend(config.llm_backends["openai"]);
case "ollama-local": case "ollama-local":
return new OllamaLocalBackend(config.llm_backends["ollama-local"]); return new OllamaLocalBackend(config.llm_backends["ollama-local"]);
case "ollama-cloud": case "ollama-cloud":
return new OllamaCloudBackend(config.llm_backends["ollama-cloud"]); return new OllamaCloudBackend(config.llm_backends["ollama-cloud"]);
case "anthropic":
if (!config.llm_backends["anthropic"]) {
throw new BackendUnavailableError("anthropic");
}
return new AnthropicBackend(config.llm_backends["anthropic"]);
default: default:
throw new BackendUnavailableError(name); throw new BackendUnavailableError(name);
} }
@@ -49,7 +63,10 @@ export async function resolveBackend(
} }
export { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js"; export { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js";
export { LLMBaseBackend, ChatMessage, ChatCompletionResponse } from "./llm-base.js";
export { ToolRegistry, ToolDefinition, ToolCall, ToolResult } from "./tool-registry.js"; export { ToolRegistry, ToolDefinition, ToolCall, ToolResult } from "./tool-registry.js";
export { OpencodeBackend } from "./opencode.js"; export { OpencodeBackend } from "./opencode.js";
export { OpenAIBackend } from "./openai.js";
export { OllamaLocalBackend } from "./ollama-local.js"; export { OllamaLocalBackend } from "./ollama-local.js";
export { OllamaCloudBackend } from "./ollama-cloud.js"; export { OllamaCloudBackend } from "./ollama-cloud.js";
export { AnthropicBackend } from "./anthropic.js";
+361
View File
@@ -0,0 +1,361 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
IntelligenceBackend,
BackendRequest,
BackendResult,
BackendType,
LLMBackendConfig,
TokenUsage,
Artifact,
emptyTokenUsage,
emptyBackendResult,
} from "./types.js";
import { AgentName, ModelProfile } from "../types/config.js";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
import { ToolRegistry, ToolCall, ToolResult, ToolDefinition } from "./tool-registry.js";
const MAX_TOOL_ROUNDS = 50;
const PERSONA_TOOL_MAP: Record<string, string> = {
read: "readFile",
write: "writeFile",
edit: "editFile",
bash: "runBash",
glob: "glob",
grep: "grep",
};
export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
tool_calls?: Array<{
function: { name: string; arguments: string };
}>;
}
export interface ChatCompletionResponse {
choices?: Array<{
message: {
content: string;
tool_calls?: Array<{
function: { name: string; arguments: string };
}>;
};
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export abstract class LLMBaseBackend implements IntelligenceBackend {
abstract readonly name: string;
readonly type: BackendType = "llm";
protected config: LLMBackendConfig;
protected projectPath: string;
protected filteredToolSchema: Array<Record<string, unknown>> | null = null;
constructor(config: LLMBackendConfig | undefined) {
this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" };
this.projectPath = process.cwd();
}
abstract isAvailable(): Promise<boolean>;
async execute(request: BackendRequest): Promise<BackendResult> {
const startTime = Date.now();
try {
const personaContent = this.loadPersona(request.persona);
const workflowContent = this.loadWorkflow(request.workflow);
const model = this.resolveModel();
const toolRegistry = new ToolRegistry(request.context.project_path);
const allowedTools = this.parsePersonaTools(personaContent);
const filteredDefinitions = this.filterToolDefinitions(toolRegistry.getDefinitions(), allowedTools);
this.filteredToolSchema = this.definitionsToOpenAISchema(filteredDefinitions);
const messages: ChatMessage[] = [];
messages.push({
role: "system",
content: this.buildSystemPrompt(personaContent, workflowContent, request),
});
messages.push({
role: "user",
content: request.task,
});
let totalInputTokens = 0;
let totalOutputTokens = 0;
let round = 0;
const allArtifacts: Artifact[] = [];
const allDecisions: Decision[] = [];
const allEscalations: Escalation[] = [];
while (round < MAX_TOOL_ROUNDS) {
round++;
const response = await this.callModelWithTools(messages, model, filteredDefinitions);
totalInputTokens += response.usage?.prompt_tokens || 0;
totalOutputTokens += response.usage?.completion_tokens || 0;
const assistantContent = response.choices?.[0]?.message?.content || "";
const toolCalls = response.choices?.[0]?.message?.tool_calls;
messages.push({
role: "assistant",
content: assistantContent,
tool_calls: toolCalls,
});
if (!toolCalls || toolCalls.length === 0) {
return this.parseFinalResponse(assistantContent, allArtifacts, allDecisions, allEscalations, {
input_tokens: totalInputTokens,
output_tokens: totalOutputTokens,
total_tokens: totalInputTokens + totalOutputTokens,
estimated_cost_usd: 0,
});
}
for (const toolCall of toolCalls) {
const call: ToolCall = {
name: toolCall.function.name,
arguments: JSON.parse(toolCall.function.arguments),
};
const result = toolRegistry.execute(call);
messages.push({
role: "tool",
name: call.name,
content: result.content,
});
if (call.name === "writeFile" && !result.isError) {
allArtifacts.push({
path: String(call.arguments.path),
content: String(call.arguments.content),
operation: "create",
});
}
}
}
const finalContent = messages
.filter((m) => m.role === "assistant" && m.content)
.map((m) => m.content)
.join("\n");
return this.parseFinalResponse(
`Tool loop reached maximum rounds (${MAX_TOOL_ROUNDS}). Partial progress:\n${finalContent}`,
allArtifacts,
allDecisions,
allEscalations,
{ input_tokens: totalInputTokens, output_tokens: totalOutputTokens, total_tokens: totalInputTokens + totalOutputTokens, estimated_cost_usd: 0 }
);
} catch (err) {
return emptyBackendResult(`Backend execution failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
protected parsePersonaTools(personaContent: string): string[] | null {
const frontmatterMatch = personaContent.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) return null;
const frontmatter = frontmatterMatch[1];
const toolsMatch = frontmatter.match(/tools:\s*\n((?:\s+\w+:.+\n?)+)/);
if (!toolsMatch) {
const inlineMatch = frontmatter.match(/tools:\s*\[([^\]]+)\]/);
if (inlineMatch) {
return inlineMatch[1]
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.map((t) => PERSONA_TOOL_MAP[t] || t);
}
return null;
}
const toolsBlock = toolsMatch[1];
const toolNames: string[] = [];
const lineRegex = /^\s+(\w+):/gm;
let lineMatch;
while ((lineMatch = lineRegex.exec(toolsBlock)) !== null) {
const personaToolName = lineMatch[1];
toolNames.push(PERSONA_TOOL_MAP[personaToolName] || personaToolName);
}
return toolNames.length > 0 ? toolNames : null;
}
protected filterToolDefinitions(definitions: ToolDefinition[], allowedTools: string[] | null): ToolDefinition[] {
if (!allowedTools) return definitions;
const allowedSet = new Set(allowedTools);
return definitions.filter((def) => allowedSet.has(def.name));
}
protected async callModelWithTools(
messages: ChatMessage[],
model: string,
toolDefinitions: ToolDefinition[]
): Promise<ChatCompletionResponse> {
return this.callModel(messages, model, new ToolRegistry(this.projectPath));
}
protected definitionsToOpenAISchema(definitions: ToolDefinition[]): Array<Record<string, unknown>> {
return definitions.map((def) => ({
type: "function",
function: {
name: def.name,
description: def.description,
parameters: def.parameters,
},
}));
}
protected getActiveToolSchema(toolRegistry: ToolRegistry): Array<Record<string, unknown>> {
return this.filteredToolSchema || toolRegistry.getOpenAIToolSchema();
}
protected abstract callModel(
messages: ChatMessage[],
model: string,
toolRegistry: ToolRegistry
): Promise<ChatCompletionResponse>;
protected abstract resolveModel(): string;
protected abstract fetchAvailableModels(): Promise<string[]>;
protected buildSystemPrompt(persona: string, workflow: string, request: BackendRequest): string {
const parts = [persona];
if (workflow) {
parts.push("", "## Workflow Instructions", workflow);
}
parts.push(
"",
"## Execution Context",
`Autonomy level: ${request.autonomy}`,
`Project path: ${request.context.project_path}`,
`Phase: ${request.context.phase}`,
`Stage: ${request.context.stage}`,
"",
"## Output Format",
"When you have completed your task, output a JSON object with this structure:",
"```json",
'{',
' "success": true,',
' "output": "Summary of what was accomplished",',
' "artifacts": [{"path": "file/path", "content": "...", "operation": "create"}],',
' "decisions": [{"id": "D-NNN", "decision": "what", "rationale": "why", "confidence": 0.85, "category": "general", "alternatives_considered": [], "human_override": null, "timestamp": ""}],',
' "escalations": []',
'}',
"```"
);
return parts.join("\n");
}
protected loadPersona(persona: AgentName): string {
const candidates = [
path.join(os.homedir(), ".config", "opencode", "agents", `ci-${persona}.md`),
path.join(process.cwd(), "opencode", "agents", `ci-${persona}.md`),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf-8");
}
}
return `You are the CIAgent ${persona} agent. Execute the requested task thoroughly and autonomously.`;
}
protected loadWorkflow(workflow: string): string {
const candidates = [
path.join(os.homedir(), ".config", "opencode", "ci", "workflows", `${workflow}.md`),
path.join(process.cwd(), "opencode", "workflows", `${workflow}.md`),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf-8");
}
}
return "";
}
protected parseFinalResponse(
content: string,
artifacts: Artifact[],
decisions: Decision[],
escalations: Escalation[],
usage: TokenUsage
): BackendResult {
const jsonMatch = content.match(/\{[\s\S]*"success"[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
return {
success: parsed.success ?? true,
output: parsed.output || content,
artifacts: parsed.artifacts?.length ? this.parseArtifacts(parsed.artifacts) : artifacts,
decisions: parsed.decisions?.length ? this.parseDecisions(parsed.decisions) : decisions,
escalations: parsed.escalations?.length ? this.parseEscalations(parsed.escalations) : escalations,
usage,
};
} catch {}
}
return {
success: true,
output: content,
artifacts,
decisions,
escalations,
usage,
};
}
private parseArtifacts(raw: unknown[]): Artifact[] {
return raw.filter((a): a is Record<string, unknown> => !!a).map((a) => ({
path: String(a.path || ""),
content: String(a.content || ""),
operation: (a.operation as Artifact["operation"]) || "create",
}));
}
private parseDecisions(raw: unknown[]): Decision[] {
return raw.filter((d): d is Record<string, unknown> => !!d).map((d) => ({
id: String(d.id || "D-000"),
decision: String(d.decision || ""),
rationale: String(d.rationale || ""),
confidence: Number(d.confidence || 0.5),
category: (d.category as Decision["category"]) || "general",
alternatives_considered: Array.isArray(d.alternatives_considered)
? d.alternatives_considered.map((a: unknown) =>
typeof a === "string"
? { option: a, rejected_reason: "" }
: (a as { option: string; rejected_reason: string })
)
: [],
human_override: d.human_override ? String(d.human_override) : null,
timestamp: String(d.timestamp || new Date().toISOString()),
}));
}
private parseEscalations(raw: unknown[]): Escalation[] {
return raw.filter((e): e is Record<string, unknown> => !!e).map((e) => ({
id: String(e.id || "E-000"),
timestamp: String(e.timestamp || new Date().toISOString()),
type: (e.type as Escalation["type"]) || "specification_ambiguity",
phase: String(e.phase || ""),
description: String(e.description || ""),
context: String(e.context || ""),
options: Array.isArray(e.options) ? e.options : [],
default_option_id: String(e.default_option_id || ""),
resolution: (e.resolution as Escalation["resolution"]) || "pending",
commit_hash: String(e.commit_hash || ""),
}));
}
}
+7 -356
View File
@@ -1,335 +1,11 @@
import * as fs from "node:fs"; import { LLMBaseBackend, ChatMessage, ChatCompletionResponse } from "./llm-base.js";
import * as path from "node:path"; import { LLMBackendConfig } from "./types.js";
import * as os from "node:os"; import { ModelProfile } from "../types/config.js";
import { import { ToolRegistry } from "./tool-registry.js";
IntelligenceBackend,
BackendRequest,
BackendResult,
BackendType,
LLMBackendConfig,
TokenUsage,
Artifact,
emptyTokenUsage,
emptyBackendResult,
} from "./types.js";
import { AgentName, ModelProfile } from "../types/config.js";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
import { ToolRegistry, ToolCall, ToolResult, ToolDefinition } from "./tool-registry.js";
const MAX_TOOL_ROUNDS = 50;
const PERSONA_TOOL_MAP: Record<string, string> = {
read: "readFile",
write: "writeFile",
edit: "editFile",
bash: "runBash",
glob: "glob",
grep: "grep",
};
export abstract class OllamaBaseBackend implements IntelligenceBackend {
abstract readonly name: string;
readonly type: BackendType = "llm";
protected config: LLMBackendConfig;
protected projectPath: string;
protected filteredToolSchema: Array<Record<string, unknown>> | null = null;
export abstract class OllamaBaseBackend extends LLMBaseBackend {
constructor(config: LLMBackendConfig | undefined) { constructor(config: LLMBackendConfig | undefined) {
this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" }; super(config || { base_url: "http://localhost:11434", model_profile: "balanced" });
this.projectPath = process.cwd();
}
abstract isAvailable(): Promise<boolean>;
async execute(request: BackendRequest): Promise<BackendResult> {
const startTime = Date.now();
try {
const personaContent = this.loadPersona(request.persona);
const workflowContent = this.loadWorkflow(request.workflow);
const model = this.resolveModel();
const toolRegistry = new ToolRegistry(request.context.project_path);
const allowedTools = this.parsePersonaTools(personaContent);
const filteredDefinitions = this.filterToolDefinitions(toolRegistry.getDefinitions(), allowedTools);
this.filteredToolSchema = this.definitionsToOpenAISchema(filteredDefinitions);
const messages: OllamaMessage[] = [];
messages.push({
role: "system",
content: this.buildSystemPrompt(personaContent, workflowContent, request),
});
messages.push({
role: "user",
content: request.task,
});
let totalInputTokens = 0;
let totalOutputTokens = 0;
let round = 0;
const allArtifacts: Artifact[] = [];
const allDecisions: Decision[] = [];
const allEscalations: Escalation[] = [];
while (round < MAX_TOOL_ROUNDS) {
round++;
const response = await this.callModelWithTools(messages, model, filteredDefinitions);
totalInputTokens += response.usage?.prompt_tokens || 0;
totalOutputTokens += response.usage?.completion_tokens || 0;
const assistantContent = response.choices?.[0]?.message?.content || "";
const toolCalls = response.choices?.[0]?.message?.tool_calls;
messages.push({
role: "assistant",
content: assistantContent,
tool_calls: toolCalls,
});
if (!toolCalls || toolCalls.length === 0) {
return this.parseFinalResponse(assistantContent, allArtifacts, allDecisions, allEscalations, {
input_tokens: totalInputTokens,
output_tokens: totalOutputTokens,
total_tokens: totalInputTokens + totalOutputTokens,
estimated_cost_usd: 0,
});
}
for (const toolCall of toolCalls) {
const call: ToolCall = {
name: toolCall.function.name,
arguments: JSON.parse(toolCall.function.arguments),
};
const result = toolRegistry.execute(call);
messages.push({
role: "tool",
name: call.name,
content: result.content,
});
if (call.name === "writeFile" && !result.isError) {
allArtifacts.push({
path: String(call.arguments.path),
content: String(call.arguments.content),
operation: "create",
});
}
}
}
const finalContent = messages
.filter((m) => m.role === "assistant" && m.content)
.map((m) => m.content)
.join("\n");
return this.parseFinalResponse(
`Tool loop reached maximum rounds (${MAX_TOOL_ROUNDS}). Partial progress:\n${finalContent}`,
allArtifacts,
allDecisions,
allEscalations,
{ input_tokens: totalInputTokens, output_tokens: totalOutputTokens, total_tokens: totalInputTokens + totalOutputTokens, estimated_cost_usd: 0 }
);
} catch (err) {
return emptyBackendResult(`Backend execution failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
protected parsePersonaTools(personaContent: string): string[] | null {
const frontmatterMatch = personaContent.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) return null;
const frontmatter = frontmatterMatch[1];
const toolsMatch = frontmatter.match(/tools:\s*\n((?:\s+\w+:.+\n?)+)/);
if (!toolsMatch) {
const inlineMatch = frontmatter.match(/tools:\s*\[([^\]]+)\]/);
if (inlineMatch) {
return inlineMatch[1]
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.map((t) => PERSONA_TOOL_MAP[t] || t);
}
return null;
}
const toolsBlock = toolsMatch[1];
const toolNames: string[] = [];
const lineRegex = /^\s+(\w+):/gm;
let lineMatch;
while ((lineMatch = lineRegex.exec(toolsBlock)) !== null) {
const personaToolName = lineMatch[1];
toolNames.push(PERSONA_TOOL_MAP[personaToolName] || personaToolName);
}
return toolNames.length > 0 ? toolNames : null;
}
protected filterToolDefinitions(definitions: ToolDefinition[], allowedTools: string[] | null): ToolDefinition[] {
if (!allowedTools) return definitions;
const allowedSet = new Set(allowedTools);
return definitions.filter((def) => allowedSet.has(def.name));
}
protected async callModelWithTools(
messages: OllamaMessage[],
model: string,
toolDefinitions: ToolDefinition[]
): Promise<OllamaChatResponse> {
return this.callModel(messages, model, new ToolRegistry(this.projectPath));
}
protected definitionsToOpenAISchema(definitions: ToolDefinition[]): Array<Record<string, unknown>> {
return definitions.map((def) => ({
type: "function",
function: {
name: def.name,
description: def.description,
parameters: def.parameters,
},
}));
}
protected getActiveToolSchema(toolRegistry: ToolRegistry): Array<Record<string, unknown>> {
return this.filteredToolSchema || toolRegistry.getOpenAIToolSchema();
}
protected abstract callModel(
messages: OllamaMessage[],
model: string,
toolRegistry: ToolRegistry
): Promise<OllamaChatResponse>;
protected abstract resolveModel(): string;
protected buildSystemPrompt(persona: string, workflow: string, request: BackendRequest): string {
const parts = [persona];
if (workflow) {
parts.push("", "## Workflow Instructions", workflow);
}
parts.push(
"",
"## Execution Context",
`Autonomy level: ${request.autonomy}`,
`Project path: ${request.context.project_path}`,
`Phase: ${request.context.phase}`,
`Stage: ${request.context.stage}`,
"",
"## Output Format",
"When you have completed your task, output a JSON object with this structure:",
"```json",
'{',
' "success": true,',
' "output": "Summary of what was accomplished",',
' "artifacts": [{"path": "file/path", "content": "...", "operation": "create"}],',
' "decisions": [{"id": "D-NNN", "decision": "what", "rationale": "why", "confidence": 0.85, "category": "general", "alternatives_considered": [], "human_override": null, "timestamp": ""}],',
' "escalations": []',
'}',
"```"
);
return parts.join("\n");
}
protected loadPersona(persona: AgentName): string {
const candidates = [
path.join(os.homedir(), ".config", "opencode", "agents", `ci-${persona}.md`),
path.join(process.cwd(), "opencode", "agents", `ci-${persona}.md`),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf-8");
}
}
return `You are the CIAgent ${persona} agent. Execute the requested task thoroughly and autonomously.`;
}
protected loadWorkflow(workflow: string): string {
const candidates = [
path.join(os.homedir(), ".config", "opencode", "ci", "workflows", `${workflow}.md`),
path.join(process.cwd(), "opencode", "workflows", `${workflow}.md`),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf-8");
}
}
return "";
}
protected parseFinalResponse(
content: string,
artifacts: Artifact[],
decisions: Decision[],
escalations: Escalation[],
usage: TokenUsage
): BackendResult {
const jsonMatch = content.match(/\{[\s\S]*"success"[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
return {
success: parsed.success ?? true,
output: parsed.output || content,
artifacts: parsed.artifacts?.length ? this.parseArtifacts(parsed.artifacts) : artifacts,
decisions: parsed.decisions?.length ? this.parseDecisions(parsed.decisions) : decisions,
escalations: parsed.escalations?.length ? this.parseEscalations(parsed.escalations) : escalations,
usage,
};
} catch {}
}
return {
success: true,
output: content,
artifacts,
decisions,
escalations,
usage,
};
}
private parseArtifacts(raw: unknown[]): Artifact[] {
return raw.filter((a): a is Record<string, unknown> => !!a).map((a) => ({
path: String(a.path || ""),
content: String(a.content || ""),
operation: (a.operation as Artifact["operation"]) || "create",
}));
}
private parseDecisions(raw: unknown[]): Decision[] {
return raw.filter((d): d is Record<string, unknown> => !!d).map((d) => ({
id: String(d.id || "D-000"),
decision: String(d.decision || ""),
rationale: String(d.rationale || ""),
confidence: Number(d.confidence || 0.5),
category: (d.category as Decision["category"]) || "general",
alternatives_considered: Array.isArray(d.alternatives_considered)
? d.alternatives_considered.map((a: unknown) =>
typeof a === "string"
? { option: a, rejected_reason: "" }
: (a as { option: string; rejected_reason: string })
)
: [],
human_override: d.human_override ? String(d.human_override) : null,
timestamp: String(d.timestamp || new Date().toISOString()),
}));
}
private parseEscalations(raw: unknown[]): Escalation[] {
return raw.filter((e): e is Record<string, unknown> => !!e).map((e) => ({
id: String(e.id || "E-000"),
timestamp: String(e.timestamp || new Date().toISOString()),
type: (e.type as Escalation["type"]) || "specification_ambiguity",
phase: String(e.phase || ""),
description: String(e.description || ""),
context: String(e.context || ""),
options: Array.isArray(e.options) ? e.options : [],
default_option_id: String(e.default_option_id || ""),
resolution: (e.resolution as Escalation["resolution"]) || "pending",
commit_hash: String(e.commit_hash || ""),
}));
} }
protected modelProfileToModel(profile: ModelProfile, availableModels: string[]): string { protected modelProfileToModel(profile: ModelProfile, availableModels: string[]): string {
@@ -359,29 +35,4 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
} }
} }
interface OllamaMessage { export { ChatMessage as OllamaMessage, ChatCompletionResponse as OllamaChatResponse };
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
tool_calls?: Array<{
function: { name: string; arguments: string };
}>;
}
interface OllamaChatResponse {
choices?: Array<{
message: {
content: string;
tool_calls?: Array<{
function: { name: string; arguments: string };
}>;
};
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export { OllamaMessage, OllamaChatResponse };
+279
View File
@@ -0,0 +1,279 @@
import { OpenAIBackend } from "../backends/openai.js";
import { ChatCompletionResponse } from "../backends/llm-base.js";
describe("OpenAIBackend", () => {
const originalFetch = globalThis.fetch;
let fetchCalls: Array<{ url: string; headers: Record<string, string>; body: string }>;
beforeEach(() => {
fetchCalls = [];
});
afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.TEST_OPENAI_KEY;
delete process.env.TEST_OPENAI_KEY_EMPTY;
});
function mockFetch(response: ChatCompletionResponse, status = 200): void {
globalThis.fetch = ((url: string, init: RequestInit) => {
fetchCalls.push({
url,
headers: (init.headers as Record<string, string>) || {},
body: init.body as string,
});
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
}) as typeof fetch;
}
describe("isAvailable", () => {
it("returns true when API key is present", async () => {
process.env.TEST_OPENAI_KEY = "sk-test-key-123";
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o",
model_profile: "quality",
});
expect(await backend.isAvailable()).toBe(true);
});
it("returns false when API key is absent", async () => {
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "NONEXISTENT_OPENAI_KEY_VAR_99999",
model: "gpt-4o",
model_profile: "quality",
});
expect(await backend.isAvailable()).toBe(false);
});
it("returns false when API key is empty string", async () => {
process.env.TEST_OPENAI_KEY_EMPTY = "";
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY_EMPTY",
model: "gpt-4o",
model_profile: "quality",
});
expect(await backend.isAvailable()).toBe(false);
});
});
describe("resolveModel", () => {
it("returns config.model when set", async () => {
process.env.TEST_OPENAI_KEY = "sk-test";
mockFetch({
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
});
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o-mini",
model_profile: "speed",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("gpt-4o-mini");
});
it("defaults to gpt-4o when model not specified", async () => {
process.env.TEST_OPENAI_KEY = "sk-test";
mockFetch({
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
});
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("gpt-4o");
});
});
describe("callModel request format", () => {
it("sends correct URL, Authorization header, and body structure", async () => {
process.env.TEST_OPENAI_KEY = "sk-test-key-abc";
const mockResponse: ChatCompletionResponse = {
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
};
mockFetch(mockResponse);
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "Do the thing",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls.length).toBe(1);
expect(fetchCalls[0].url).toBe("https://api.openai.com/v1/chat/completions");
expect(fetchCalls[0].headers["Authorization"]).toBe("Bearer sk-test-key-abc");
expect(fetchCalls[0].headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("gpt-4o");
expect(body.stream).toBe(false);
expect(Array.isArray(body.messages)).toBe(true);
expect(body.messages.length).toBeGreaterThanOrEqual(2);
expect(body.messages[0].role).toBe("system");
expect(body.messages[1].role).toBe("user");
expect(body.messages[1].content).toBe("Do the thing");
expect(Array.isArray(body.tools)).toBe(true);
});
});
describe("custom base_url override", () => {
it("sends request to custom base_url", async () => {
process.env.TEST_OPENAI_KEY = "sk-test";
mockFetch({
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
});
const backend = new OpenAIBackend({
base_url: "https://custom-proxy.example.com/api",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls[0].url).toBe("https://custom-proxy.example.com/api/chat/completions");
});
});
describe("organization header", () => {
it("sends OpenAI-Organization header when config.organization is set", async () => {
process.env.TEST_OPENAI_KEY = "sk-test";
mockFetch({
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
});
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o",
model_profile: "quality",
organization: "org-abc123",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls[0].headers["OpenAI-Organization"]).toBe("org-abc123");
});
it("does not send OpenAI-Organization header when config.organization is not set", async () => {
process.env.TEST_OPENAI_KEY = "sk-test";
mockFetch({
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
});
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls[0].headers["OpenAI-Organization"]).toBeUndefined();
});
});
});
+84
View File
@@ -0,0 +1,84 @@
import { LLMBaseBackend, ChatMessage, ChatCompletionResponse } from "./llm-base.js";
import { BackendType, OpenAIConfig, emptyBackendResult } from "./types.js";
import { ToolRegistry, ToolDefinition } from "./tool-registry.js";
export class OpenAIBackend extends LLMBaseBackend {
readonly name = "openai";
readonly type: BackendType = "llm";
private openaiConfig: OpenAIConfig;
constructor(config: OpenAIConfig) {
super({ ...config, base_url: config.base_url || "https://api.openai.com/v1" });
this.openaiConfig = config;
}
async isAvailable(): Promise<boolean> {
const key = process.env[this.openaiConfig.api_key_env];
return !!key && key.length > 0;
}
protected resolveModel(): string {
return this.openaiConfig.model || "gpt-4o";
}
protected async fetchAvailableModels(): Promise<string[]> {
return [];
}
protected async callModel(
messages: ChatMessage[],
model: string,
toolRegistry: ToolRegistry
): Promise<ChatCompletionResponse> {
const apiKey = process.env[this.openaiConfig.api_key_env];
if (!apiKey) {
throw new Error(`API key not found. Set ${this.openaiConfig.api_key_env} environment variable.`);
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
};
if (this.openaiConfig.organization) {
headers["OpenAI-Organization"] = this.openaiConfig.organization;
}
const body: Record<string, unknown> = {
model,
messages: messages.map((m) => {
const msg: Record<string, unknown> = { role: m.role, content: m.content };
if (m.name) msg.name = m.name;
if (m.tool_calls) msg.tool_calls = m.tool_calls;
return msg;
}),
tools: this.getActiveToolSchema(toolRegistry),
stream: false,
};
const timeout = this.openaiConfig.timeout_ms || 60000;
const url = `${this.config.base_url}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
if (response.status === 401 || response.status === 403) {
throw new Error(`Authentication failed. Check ${this.openaiConfig.api_key_env} environment variable.`);
}
if (response.status === 429) {
throw new Error("Rate limited by OpenAI API. Please retry after a delay.");
}
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
throw new Error(`OpenAI API error (${response.status}): ${errorText}`);
}
return (await response.json()) as ChatCompletionResponse;
}
}
+37 -4
View File
@@ -115,20 +115,34 @@ export interface OllamaCloudConfig extends LLMBackendConfig {
timeout_ms?: number; timeout_ms?: number;
} }
export interface OpenAIConfig extends LLMBackendConfig {
api_key_env: string;
model: string;
organization?: string;
}
export interface AnthropicConfig extends LLMBackendConfig {
api_key_env: string;
model: string;
api_version?: string;
}
export interface OpencodeBackendConfig { export interface OpencodeBackendConfig {
enabled: boolean; enabled: boolean;
executable?: string; executable?: string;
} }
export interface BackendConfigSection { export interface BackendConfigSection {
provider: "auto" | "opencode" | "ollama-local" | "ollama-cloud"; provider: "auto" | "opencode" | "openai" | "ollama-local" | "ollama-cloud" | "anthropic";
fallback?: "opencode" | "ollama-local" | "ollama-cloud"; fallback?: "opencode" | "openai" | "ollama-local" | "ollama-cloud" | "anthropic";
agent_backends: { agent_backends: {
opencode?: OpencodeBackendConfig; opencode?: OpencodeBackendConfig;
}; };
llm_backends: { llm_backends: {
"openai"?: OpenAIConfig;
"ollama-local"?: OllamaLocalConfig; "ollama-local"?: OllamaLocalConfig;
"ollama-cloud"?: OllamaCloudConfig; "ollama-cloud"?: OllamaCloudConfig;
"anthropic"?: AnthropicConfig;
}; };
} }
@@ -138,6 +152,13 @@ export const DEFAULT_BACKEND_CONFIG: BackendConfigSection = {
opencode: { enabled: true }, opencode: { enabled: true },
}, },
llm_backends: { llm_backends: {
"openai": {
base_url: "https://api.openai.com/v1",
api_key_env: "OPENAI_API_KEY",
model: "gpt-4o",
model_profile: "quality",
timeout_ms: 60000,
},
"ollama-local": { "ollama-local": {
base_url: "http://localhost:11434", base_url: "http://localhost:11434",
model_profile: "balanced", model_profile: "balanced",
@@ -148,6 +169,14 @@ export const DEFAULT_BACKEND_CONFIG: BackendConfigSection = {
model_profile: "quality", model_profile: "quality",
timeout_ms: 60000, timeout_ms: 60000,
}, },
"anthropic": {
base_url: "https://api.anthropic.com",
api_key_env: "ANTHROPIC_API_KEY",
model: "claude-sonnet-4-20250514",
api_version: "2023-06-01",
model_profile: "quality",
timeout_ms: 60000,
},
}, },
}; };
@@ -161,8 +190,10 @@ export class BackendUnavailableError extends Error {
`Intelligence backend "${backendName}" is not available${agentMsg}. ` + `Intelligence backend "${backendName}" is not available${agentMsg}. ` +
`Configure one of:\n` + `Configure one of:\n` +
` 1. Install opencode: npm i -g opencode\n` + ` 1. Install opencode: npm i -g opencode\n` +
` 2. Run Ollama locally: ollama serve\n` + ` 2. Set OPENAI_API_KEY for OpenAI API access\n` +
` 3. Set OLLAMA_CLOUD_API_KEY for remote inference` ` 3. Set ANTHROPIC_API_KEY for Anthropic API access\n` +
` 4. Run Ollama locally: ollama serve\n` +
` 5. Set OLLAMA_CLOUD_API_KEY for remote inference`
); );
this.name = "BackendUnavailableError"; this.name = "BackendUnavailableError";
this.backendName = backendName; this.backendName = backendName;
@@ -185,3 +216,5 @@ export function emptyBackendResult(error?: string): BackendResult {
error, error,
}; };
} }
export { ChatMessage, ChatCompletionResponse } from "./llm-base.js";
+442 -11
View File
@@ -1,5 +1,6 @@
import { Command } from "commander"; import { Command } from "commander";
import { CIAgentConfig, AutonomyLevel } from "../types/config.js"; import { CIAgentConfig, AutonomyLevel } from "../types/config.js";
import { IdeationCategory, Idea } from "../types/ideation.js";
import { initCIAgent, loadConfig, isCIAgentInitialized, saveConfig } from "../core/config.js"; import { initCIAgent, loadConfig, isCIAgentInitialized, saveConfig } from "../core/config.js";
import { Specification, parseSpecification } from "../types/specification.js"; import { Specification, parseSpecification } from "../types/specification.js";
import { saveSpecification } from "../core/clarify.js"; import { saveSpecification } from "../core/clarify.js";
@@ -9,7 +10,7 @@ import { getAuditSummary, readAudit } from "../core/audit.js";
import { VerificationPipeline } from "../verification/index.js"; import { VerificationPipeline } from "../verification/index.js";
import { ClarifyPhase } from "../core/clarify.js"; import { ClarifyPhase } from "../core/clarify.js";
import { loadSpecification as loadSpec } 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 { ErrorRecovery } from "../core/error-recovery.js";
import { PipelineState, createInitialPipelineState } from "../types/pipeline.js"; import { PipelineState, createInitialPipelineState } from "../types/pipeline.js";
import { resolveBackend } from "../backends/index.js"; import { resolveBackend } from "../backends/index.js";
@@ -19,6 +20,7 @@ import { CIAgentFiles } from "../core/ciagent-files.js";
import { GiteaClient, generateReleaseNotes } from "../core/gitea.js"; import { GiteaClient, generateReleaseNotes } from "../core/gitea.js";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as readline from "node:readline";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
export function createInitCommand(): Command { export function createInitCommand(): Command {
@@ -77,6 +79,7 @@ export function createInitCommand(): Command {
enabled: options.parallel !== false, enabled: options.parallel !== false,
max_concurrent_agents: 5, max_concurrent_agents: 5,
min_plans_for_parallel: 2, min_plans_for_parallel: 2,
max_concurrent_projects: 3,
}, },
backend: { backend: {
provider: options.backend || "auto", provider: options.backend || "auto",
@@ -167,6 +170,8 @@ export function createRunCommand(): Command {
.option("--all", "Execute all remaining phases sequentially") .option("--all", "Execute all remaining phases sequentially")
.option("--phase <number>", "Phase number", "1") .option("--phase <number>", "Phase number", "1")
.option("--backend <provider>", "Override intelligence backend for this run") .option("--backend <provider>", "Override intelligence backend for this run")
.option("--ideate", "Insert ideation stage between research and plan")
.option("--project <slug>", "Target project slug (comma-separated or 'all')")
.action(async (phase, options) => { .action(async (phase, options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
@@ -176,6 +181,141 @@ export function createRunCommand(): Command {
} }
const config = loadConfig(projectPath); 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); const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
if (!backend && backendError) { if (!backend && backendError) {
@@ -191,6 +331,7 @@ export function createRunCommand(): Command {
specification: "", specification: "",
config_path: path.join(projectPath, ".ciagent", "config.json"), config_path: path.join(projectPath, ".ciagent", "config.json"),
backend, backend,
project_slug: projectSlug || undefined,
}; };
const spec = loadSpec(projectPath); const spec = loadSpec(projectPath);
@@ -198,7 +339,7 @@ export function createRunCommand(): Command {
context.specification = spec.raw_content; context.specification = spec.raw_content;
} }
console.log(`Running CIAgent pipeline...`); console.log(`Running CIAgent pipeline${projectSlug ? ` for project: ${projectSlug}` : ""}...`);
if (options.all) { if (options.all) {
console.log(" Mode: Full pipeline (all phases)"); console.log(" Mode: Full pipeline (all phases)");
} else { } else {
@@ -412,7 +553,8 @@ export function createReviewCommand(): Command {
export function createStatusCommand(): Command { export function createStatusCommand(): Command {
return new Command("status") return new Command("status")
.description("Non-interactive project status") .description("Non-interactive project status")
.action(() => { .option("--project <slug>", "Show status for specific project (comma-separated or 'all')")
.action((options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) { if (!isCIAgentInitialized(projectPath)) {
@@ -422,14 +564,31 @@ export function createStatusCommand(): Command {
} }
const config = loadConfig(projectPath); const config = loadConfig(projectPath);
const ciFiles = new CIAgentFiles(projectPath);
const artifacts = new ArtifactManager(projectPath); const artifacts = new ArtifactManager(projectPath);
console.log("─── CIAgent Project Status ───"); const activeProjects: string[] = (config as any).active_projects?.length > 0
console.log(`\nAutonomy: ${config.autonomy.level}`); ? (config as any).active_projects
: config.active_project ? [config.active_project] : [];
console.log("─── CIAgent Project Status ───\n");
if (activeProjects.length > 1 || (options.project && options.project === "all")) {
console.log(`Active Projects: ${activeProjects.join(", ")}`);
console.log(`Total: ${activeProjects.length} projects`);
console.log("");
}
console.log(`Autonomy: ${config.autonomy.level}`);
console.log(`Model Profile: ${config.model_profile}`); console.log(`Model Profile: ${config.model_profile}`);
console.log(`Backend: ${config.backend?.provider || "auto"}`); console.log(`Backend: ${config.backend?.provider || "auto"}`);
console.log(`Parallelization: ${config.parallelization.enabled ? "enabled" : "disabled"}`); console.log(`Parallelization: ${config.parallelization.enabled ? "enabled" : "disabled"}`);
const ideationConfig = (config as any).ideation;
if (ideationConfig) {
console.log(`Ideation: ${ideationConfig.enabled ? "enabled" : "disabled"} (categories: ${ideationConfig.categories?.join(", ") || "default"})`);
}
const state = artifacts.readState(); const state = artifacts.readState();
if (state) { if (state) {
console.log(`\nCurrent Phase: ${state.current_phase}`); console.log(`\nCurrent Phase: ${state.current_phase}`);
@@ -660,6 +819,9 @@ export function createProjectsCommand(): Command {
const ciFiles = new CIAgentFiles(projectPath); const ciFiles = new CIAgentFiles(projectPath);
const projects = ciFiles.listProjects(); const projects = ciFiles.listProjects();
const activeProject = config.active_project || ciFiles.getActiveProject(); const activeProject = config.active_project || ciFiles.getActiveProject();
const activeProjects: string[] = (config as any).active_projects?.length > 0
? (config as any).active_projects
: activeProject ? [activeProject] : [];
if (projects.length === 0) { if (projects.length === 0) {
console.log("No projects registered."); console.log("No projects registered.");
@@ -669,11 +831,13 @@ export function createProjectsCommand(): Command {
console.log("─── CIAgent Projects ───\n"); console.log("─── CIAgent Projects ───\n");
for (const project of projects) { for (const project of projects) {
const isActive = project.slug === activeProject; const isActive = activeProjects.includes(project.slug);
const marker = isActive ? " *" : ""; const marker = isActive ? " *" : "";
console.log(` ${project.slug}${project.name}${marker}`); console.log(` ${project.slug}${project.name}${marker}`);
} }
console.log("\n * = active project"); if (activeProjects.length > 0) {
console.log(`\n Active: ${activeProjects.join(", ")}`);
}
}); });
cmd.command("add <slug> <name>") cmd.command("add <slug> <name>")
@@ -712,6 +876,7 @@ export function createProjectsCommand(): Command {
ciFiles.setActiveProject(slug); ciFiles.setActiveProject(slug);
const config = loadConfig(projectPath); const config = loadConfig(projectPath);
config.active_project = slug; config.active_project = slug;
(config as any).active_projects = [slug];
saveConfig(projectPath, config); saveConfig(projectPath, config);
console.log(`✓ Active project set to: ${slug}`); console.log(`✓ Active project set to: ${slug}`);
}); });
@@ -837,7 +1002,7 @@ function computeShipVersion(
projectPath: string, projectPath: string,
phaseNum: number, phaseNum: number,
config: CIAgentConfig config: CIAgentConfig
): { tag: string; milestoneType: "nfr" | "feature" | "schema-breaking" } { ): { tag: string; milestoneType: "nfr" | "feature" | "major" } {
const tags = execSync("git tag -l", { cwd: projectPath, encoding: "utf-8" }) const tags = execSync("git tag -l", { cwd: projectPath, encoding: "utf-8" })
.split("\n") .split("\n")
.map((t) => t.trim()) .map((t) => t.trim())
@@ -864,7 +1029,7 @@ function computeShipVersion(
const milestoneType = inferMilestoneType(projectPath); const milestoneType = inferMilestoneType(projectPath);
let tag: string; let tag: string;
if (milestoneType === "schema-breaking") { if (milestoneType === "major") {
tag = `v${major}.${minor + phaseNum}.0`; tag = `v${major}.${minor + phaseNum}.0`;
} else { } else {
tag = `v${major}.${minor}.${phaseNum}`; tag = `v${major}.${minor}.${phaseNum}`;
@@ -873,10 +1038,10 @@ function computeShipVersion(
return { tag, milestoneType }; return { tag, milestoneType };
} }
function inferMilestoneType(projectPath: string): "nfr" | "feature" | "schema-breaking" { function inferMilestoneType(projectPath: string): "nfr" | "feature" | "major" {
try { try {
const log = execSync("git log --oneline -50", { cwd: projectPath, encoding: "utf-8" }); const log = execSync("git log --oneline -50", { cwd: projectPath, encoding: "utf-8" });
if (log.match(/\brefactor\b|\brewrite\b|\bmigrate\b|\brestructure\b/i)) return "schema-breaking"; if (log.match(/\brefactor\b|\brewrite\b|\bmigrate\b|\brestructure\b/i)) return "major";
if (log.match(/\bfeat\b/)) return "feature"; if (log.match(/\bfeat\b/)) return "feature";
return "nfr"; return "nfr";
} catch { } catch {
@@ -942,3 +1107,269 @@ function getPreviousTag(projectPath: string, currentTag: string): string | null
return null; return null;
} }
export function createIdeateCommand(): Command {
return new Command("ideate")
.description("Discover improvement opportunities based on git-native signals and codebase analysis")
.option("-c, --category <categories>", "Focus on specific categories: security,quality,architecture,coverage,improvement,spec,chaos (comma-separated)")
.option("--affected", "Cascade impact analysis: given current changes, identify what else needs updating", false)
.option("--spec", "Analyze specification completeness and ambiguity", false)
.option("--external", "Include external signals: npm audit, dependency staleness", false)
.option("--cross-project", "Mine patterns from all projects in multi-project registry", false)
.option("--output <format>", "Output format: interactive, json, markdown", "interactive")
.option("--project <slug>", "Target project slug (comma-separated or 'all')")
.action(async (options) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized in this directory.");
console.error("Run 'ciagent init' to get started.");
process.exit(1);
}
const ciFiles = new CIAgentFiles(projectPath);
const config = loadConfig(projectPath);
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(`\nMining git history for patterns in project: ${slug}...`);
let projectIdeas: Idea[] = engine.runMechanical(categories.length > 0 ? categories : undefined);
if (options.affected) {
console.log(`Running cascade impact analysis (--affected) for ${slug}...`);
const affectedIdeas = engine.runAffected();
projectIdeas = [...projectIdeas, ...affectedIdeas];
}
if (options.spec) {
console.log(`Running specification analysis (--spec) for ${slug}...`);
const specIdeas = engine.runMechanical(["spec"]);
const newSpecIdeas = specIdeas.filter(
(idea: Idea) => !projectIdeas.some((existing: Idea) => existing.title === idea.title)
);
projectIdeas = [...projectIdeas, ...newSpecIdeas];
}
if (options.external) {
console.log(`Running external signal analysis (--external) for ${slug}...`);
const externalIdeas = engine.runExternal();
projectIdeas = [...projectIdeas, ...externalIdeas];
}
if (options.crossProject && ciFiles.isMultiProject()) {
console.log(`Running cross-project pattern mining (--cross-project) for ${slug}...`);
const crossProjectIdeas = engine.runCrossProject();
projectIdeas = [...projectIdeas, ...crossProjectIdeas];
}
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;
});
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;
}
if (options.output === "markdown") {
console.log("\n## Ideation Results\n");
if (allIdeas.length === 0) {
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}`);
console.log(`- **Source**: ${idea.source}`);
console.log(`- **Confidence**: ${idea.confidence.toFixed(2)}`);
console.log(`- **Tier**: ${idea.tier}`);
console.log(`- **Rationale**: ${idea.rationale}`);
if (idea.relatedReq) console.log(`- **Related Req**: ${idea.relatedReq}`);
console.log(`- **Actions**: ${idea.actions.join(", ")}`);
console.log("");
}
}
return;
}
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.");
console.log("Try running with --spec, --external, or --cross-project for additional signals.");
return;
}
if (options.output !== "interactive") {
console.log("Use --output interactive for accept/skip/modify validation.");
return;
}
const accepted: Idea[] = [];
const skipped: Idea[] = [];
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const askQuestion = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer.trim().toLowerCase());
});
});
};
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}${projectLabel}`);
console.log(` Title: ${idea.title}`);
console.log(` Rationale: ${idea.rationale}`);
if (idea.relatedReq) console.log(` Related Req: ${idea.relatedReq}`);
console.log(` Source: ${idea.source}`);
console.log(` Actions: ${idea.actions.join(", ")}`);
console.log("");
console.log(" 1) Accept (add to next milestone)");
console.log(" 2) Skip");
console.log(" 3) Details (show full analysis)");
const answer = await askQuestion(" > ");
if (answer === "1" || answer === "a" || answer === "accept") {
accepted.push(idea);
console.log(` ✓ Accepted: ${idea.id}${idea.title}`);
} else if (answer === "3" || answer === "d" || answer === "details") {
console.log(`\n ─── Details for ${idea.id} ───`);
console.log(` ID: ${idea.id}`);
console.log(` Source: ${idea.source}`);
console.log(` Category: ${idea.category}`);
console.log(` Confidence: ${idea.confidence.toFixed(2)}`);
console.log(` Tier: ${idea.tier}`);
console.log(` Title: ${idea.title}`);
console.log(` Rationale: ${idea.rationale}`);
if (idea.relatedReq) console.log(` Related Req: ${idea.relatedReq}`);
console.log(` Actions: ${idea.actions.join(", ")}`);
console.log("");
const retryAnswer = await askQuestion(" Accept this idea? (y/n) > ");
if (retryAnswer === "y" || retryAnswer === "yes") {
accepted.push(idea);
console.log(` ✓ Accepted: ${idea.id}${idea.title}`);
} else {
skipped.push(idea);
console.log(` ✗ Skipped: ${idea.id}`);
}
} else {
skipped.push(idea);
console.log(` ✗ Skipped: ${idea.id}`);
}
}
rl.close();
console.log("\n─── Summary ───\n");
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()})`);
}
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"} 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) > ");
if (kickoffAnswer === "y" || kickoffAnswer === "yes") {
console.log("\nStarting CIAgent pipeline...");
console.log("Run: ciagent run --ideate\n");
}
}
rl.close();
const byCategory: Record<string, number> = {};
for (const idea of allIdeas) {
byCategory[idea.category] = (byCategory[idea.category] || 0) + 1;
}
console.log("\n─── Category Breakdown ───\n");
for (const [cat, count] of Object.entries(byCategory)) {
console.log(` ${cat}: ${count}`);
}
});
}
+8 -3
View File
@@ -17,6 +17,7 @@ import {
createRollbackCommand, createRollbackCommand,
createShipCommand, createShipCommand,
createProjectsCommand, createProjectsCommand,
createIdeateCommand,
} from "./commands.js"; } from "./commands.js";
let activeEscalationProtocol: { dispose(): void } | null = null; let activeEscalationProtocol: { dispose(): void } | null = null;
@@ -44,12 +45,15 @@ program
.name("ciagent") .name("ciagent")
.description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness") .description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness")
.version(VERSION) .version(VERSION)
.option("--project <slug>", "Specify which project to operate on") .option("--project <slug>", "Specify which project to operate on (comma-separated or 'all')")
.hook("preAction", () => { .hook("preAction", () => {
const opts = program.opts(); const opts = program.opts();
if (opts.project && isCIAgentInitialized(process.cwd())) { if (opts.project && isCIAgentInitialized(process.cwd())) {
const ciFiles = new CIAgentFiles(process.cwd()); const ciFiles = new CIAgentFiles(process.cwd());
ciFiles.setProjectSlug(opts.project); const projectSlug = opts.project;
if (projectSlug !== "all" && !projectSlug.includes(",")) {
ciFiles.setProjectSlug(projectSlug);
}
} }
}) })
.addCommand(createInitCommand()) .addCommand(createInitCommand())
@@ -63,6 +67,7 @@ program
.addCommand(createClarifyCommand()) .addCommand(createClarifyCommand())
.addCommand(createRollbackCommand()) .addCommand(createRollbackCommand())
.addCommand(createShipCommand()) .addCommand(createShipCommand())
.addCommand(createProjectsCommand()); .addCommand(createProjectsCommand())
.addCommand(createIdeateCommand());
program.parse(); program.parse();
+3 -3
View File
@@ -329,7 +329,7 @@ describe("CIAgentFiles", () => {
expect(ciFiles.getMilestoneType()).toBe("feature"); expect(ciFiles.getMilestoneType()).toBe("feature");
}); });
it("returns schema-breaking when phases include refactor/rewrite/migrate", () => { it("returns major when phases include refactor/rewrite/migrate", () => {
const ciFiles = new CIAgentFiles(dir, "schema-proj"); const ciFiles = new CIAgentFiles(dir, "schema-proj");
ciFiles.ensureProjectDir(); ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
@@ -337,13 +337,13 @@ describe("CIAgentFiles", () => {
active_project: "schema-proj", active_project: "schema-proj",
})); }));
const roadmap: RoadmapMd = { const roadmap: RoadmapMd = {
overview: "schema-breaking", overview: "major",
phases: [ phases: [
{ number: 1, name: "refactor-core", description: "Refactor core", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, { number: 1, name: "refactor-core", description: "Refactor core", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
], ],
}; };
ciFiles.writeRoadmapMd(roadmap); ciFiles.writeRoadmapMd(roadmap);
expect(ciFiles.getMilestoneType()).toBe("schema-breaking"); expect(ciFiles.getMilestoneType()).toBe("major");
}); });
}); });
+1 -1
View File
@@ -486,7 +486,7 @@ export class CIAgentFiles {
} }
} }
if (hasSchemaBreak) return "schema-breaking"; if (hasSchemaBreak) return "major";
if (hasFeature) return "feature"; if (hasFeature) return "feature";
return "nfr"; return "nfr";
} }
+2 -2
View File
@@ -192,11 +192,11 @@ describe("GitBranch", () => {
expect(tag).toBe("v0.6.0"); expect(tag).toBe("v0.6.0");
}); });
it("computes next major for schema-breaking milestone", () => { it("computes next major for major milestone", () => {
execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" }); execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" });
const gitBranch = new GitBranch(repoDir); const gitBranch = new GitBranch(repoDir);
const tag = gitBranch.computeMilestoneTag("schema-breaking"); const tag = gitBranch.computeMilestoneTag("major");
expect(tag).toBe("v1.0.0"); expect(tag).toBe("v1.0.0");
}); });
+1 -1
View File
@@ -242,7 +242,7 @@ export class GitBranch {
} }
} }
if (milestoneType === "schema-breaking") { if (milestoneType === "major") {
return `v${major + 1}.0.0`; return `v${major + 1}.0.0`;
} }
+2 -2
View File
@@ -307,7 +307,7 @@ status: execute
expect(ctx.getMilestoneType()).toBe("feature"); expect(ctx.getMilestoneType()).toBe("feature");
}); });
it("returns schema-breaking when refactor commits exist", () => { it("returns major when refactor commits exist", () => {
commit(repoDir, `refactor(P01): rewrite core commit(repoDir, `refactor(P01): rewrite core
---ci--- ---ci---
@@ -317,7 +317,7 @@ status: execute
---/ci---`); ---/ci---`);
const ctx = new GitContext(repoDir); const ctx = new GitContext(repoDir);
expect(ctx.getMilestoneType()).toBe("schema-breaking"); expect(ctx.getMilestoneType()).toBe("major");
}); });
}); });
}); });
+1 -1
View File
@@ -333,7 +333,7 @@ export class GitContext {
if (!commit.ci) continue; if (!commit.ci) continue;
hasAnyCiCommit = true; hasAnyCiCommit = true;
if (commit.type === "feat") return "feature"; if (commit.type === "feat") return "feature";
if (commit.type === "refactor" || commit.scope === "init") return "schema-breaking"; if (commit.type === "refactor" || commit.scope === "init") return "major";
} }
if (!hasAnyCiCommit) return "nfr"; if (!hasAnyCiCommit) return "nfr";
return "nfr"; return "nfr";
+386
View File
@@ -0,0 +1,386 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { IdeationAgent } from "../agents/ideation-agent.js";
import { IdeationEngine, resetIdeaCounter } from "../core/ideation.js";
import { Idea, IdeationAction, DEFAULT_IDEATION_CONFIG } from "../types/ideation.js";
describe("IdeationAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-ideation-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("agent name is ideation-agent", () => {
const agent = new IdeationAgent();
expect(agent.name).toBe("ideation-agent");
});
it("delegates to IdeationEngine for mechanical ideation", () => {
const agent = new IdeationAgent();
const ideas = agent.mechanicalIdeate(tempDir);
expect(Array.isArray(ideas)).toBe(true);
});
});
describe("IdeationEngine", () => {
let tempDir: string;
beforeEach(() => {
resetIdeaCounter();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-engine-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("generates ideas from uncovered requirements", () => {
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, "REQUIREMENTS.md"),
"# Requirements\n\n## v1 Requirements\n\n### Core\n\n- **REQ-01**: First requirement\n- **REQ-02**: Second requirement\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| REQ-01 | Phase 1 | pending |\n| REQ-02 | Phase 1 | pending |\n"
);
fs.writeFileSync(
path.join(ciagentDir, "PROJECT.md"),
"# Test Project\n\n## What This Is\n\nA test project.\n\n## Requirements\n\n### Validated\n\n- REQ-01: First\n\n### Active\n\n- [ ] REQ-02: Second\n\n## Constraints\n\n- Must work\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ROADMAP.md"),
"# Roadmap\n\n## Overview\n\nTest roadmap.\n\n## Phases\n\n- [ ] **Phase 1: Init** - Starting\n\n## Phase Details\n\n### Phase 1: Init\n\n**Goal.**: Start\n**Status**: not_started\n**Requirements**: REQ-01\n**Depends on**: Nothing\n**Success Criteria**:\n1. Project initialized\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\n\nTest architecture.\n\n## Components\n\n### Core\n\n- **Description**: Core module\n- **Boundaries**: Internal only\n- **Depends on**: None\n\n## Data Flow\n\nSimple flow.\n\n## Build Order\n\n1. Core\n"
);
const engine = new IdeationEngine(tempDir);
const ideas = engine.runMechanical(["coverage"]);
const reqIdeas = ideas.filter((i) => i.source === "uncovered_requirement");
expect(reqIdeas.length).toBeGreaterThanOrEqual(1);
});
it("detects architecture drift when documented components are missing", () => {
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### NonExistentModule\n\n- **Description**: A module that does not exist\n- **Boundaries**: None\n- **Depends on**: None\n\n## Data Flow\n\nFlow.\n\n## Build Order\n\n1. Core\n"
);
const engine = new IdeationEngine(tempDir);
const ideas = engine.runMechanical(["architecture"]);
const driftIdeas = ideas.filter((i) => i.source === "architecture_drift");
expect(driftIdeas.length).toBeGreaterThanOrEqual(1);
});
it("detects spec ambiguity or spec missing", () => {
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, "PROJECT.md"),
"# Test\n\n## What This Is\n\nThe system should handle user input and could process data. It might also log events.\n\n## Requirements\n\n### Validated\n\n\n### Active\n\n- [ ] The system should handle errors\n- [ ] Users could configure settings\n- [ ] It might send notifications\n\n## Constraints\n\n- Must work\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n"
);
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"# Requirements\n\n## v1\n\n- REQ-01: Test\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ROADMAP.md"),
"# Roadmap\n\n## Overview\n\nTest\n\n## Phases\n\n\n## Phase Details\n\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\n\nTest\n\n## Components\n\n## Data Flow\n\nTest\n\n## Build Order\n\n1. Test\n"
);
const engine = new IdeationEngine(tempDir);
const ideas = engine.runMechanical(["spec"]);
const specIdeas = ideas.filter((i) => i.source === "spec_ambiguity" || i.source === "spec_missing" || i.source === "spec_contradiction");
expect(specIdeas.length).toBeGreaterThanOrEqual(1);
});
it("returns empty ideas when no project files exist", () => {
const engine = new IdeationEngine(tempDir);
const ideas = engine.runMechanical();
expect(Array.isArray(ideas)).toBe(true);
});
it("formats ideas as readable text", () => {
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, "PROJECT.md"), "# Test\n\n## What This Is\n\nTest\n\n## Requirements\n\n### Validated\n\n\n### Active\n\n\n## Constraints\n\n- None\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n");
fs.writeFileSync(path.join(ciagentDir, "REQUIREMENTS.md"), "# Requirements\n\n## v1\n\n- REQ-01: Test\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| REQ-01 | Phase 1 | pending |\n");
fs.writeFileSync(path.join(ciagentDir, "ROADMAP.md"), "# Roadmap\n\n## Overview\n\nTest\n\n## Phases\n\n\n## Phase Details\n\n");
fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "# Architecture\n\n## Overview\n\nTest\n\n## Components\n\n## Data Flow\n\nTest\n\n## Build Order\n\n1. Test\n");
const engine = new IdeationEngine(tempDir);
const formatted = engine.formatIdeas(engine.runMechanical());
expect(typeof formatted).toBe("string");
});
it("formats ideas as JSON", () => {
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, "PROJECT.md"), "# Test\n\n## What This Is\n\nTest\n\n## Requirements\n\n### Validated\n\n\n### Active\n\n\n## Constraints\n\n- None\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n");
fs.writeFileSync(path.join(ciagentDir, "REQUIREMENTS.md"), "# Requirements\n\n## v1\n\n- REQ-01: Test\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| REQ-01 | Phase 1 | pending |\n");
fs.writeFileSync(path.join(ciagentDir, "ROADMAP.md"), "# Roadmap\n\n## Overview\n\nTest\n\n## Phases\n\n\n## Phase Details\n\n");
fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "# Architecture\n\n## Overview\n\nTest\n\n## Components\n\n## Data Flow\n\nTest\n\n## Build Order\n\n1. Test\n");
const engine = new IdeationEngine(tempDir);
const result = engine.formatIdeasJson(engine.runMechanical());
expect(result).toHaveProperty("ideas");
expect(result).toHaveProperty("summary");
expect(result).toHaveProperty("project");
});
describe("acceptIdea", () => {
let acceptDir: string;
beforeEach(() => {
resetIdeaCounter();
acceptDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-accept-test-"));
const ciagentDir = path.join(acceptDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [], active_project: "default" })
);
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"# Requirements\n\n## v1 Requirements\n\n### Core\n\n- **CORE-01**: Test core requirement\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| CORE-01 | Phase 1 | pending |\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ROADMAP.md"),
"# Roadmap\n\n## Overview\n\nTest roadmap.\n\n## Phases\n\n- [x] **Phase 1: Init** - Starting\n\n## Phase Details\n\n### Phase 1: Init\n\n**Goal.**: Start\n**Status**: complete\n**Requirements**: CORE-01\n**Depends on**: Nothing\n**Success Criteria**:\n1. Project initialized\n"
);
fs.writeFileSync(
path.join(ciagentDir, "PROJECT.md"),
"# Test\n\n## What This Is\n\nTest\n\n## Requirements\n\n### Validated\n\n\n### Active\n\n\n## Constraints\n\n- None\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\n\nTest\n\n## Components\n\n## Data Flow\n\nTest\n\n## Build Order\n\n1. Test\n"
);
});
afterEach(() => {
fs.rmSync(acceptDir, { recursive: true, force: true });
});
it("accepts an idea and updates REQUIREMENTS.md and ROADMAP.md", () => {
const engine = new IdeationEngine(acceptDir);
const idea: Idea = {
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Add rate limiting to cloud backends",
rationale: "No rate limiting REQ exists for cloud backends.",
confidence: 0.92,
actions: ["add_requirement", "update_roadmap"],
tier: "mechanical",
};
const result = engine.acceptIdea(idea);
expect(result.addedToRequirements).toBe(true);
expect(result.addedToRoadmap).toBe(true);
expect(result.reqId).toBe("IDEATE-01");
});
it("acceptIdeas accepts multiple ideas", () => {
const engine = new IdeationEngine(acceptDir);
const ideas: Idea[] = [
{
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Add rate limiting",
rationale: "No rate limiting.",
confidence: 0.9,
actions: ["add_requirement"],
tier: "mechanical",
},
{
id: "IDEATE-02",
source: "architecture_drift",
category: "architecture",
title: "Fix architecture drift",
rationale: "Component documented but missing.",
confidence: 0.8,
actions: ["update_architecture"],
tier: "mechanical",
},
];
const { accepted, results } = engine.acceptIdeas(ideas);
expect(accepted.length).toBe(2);
expect(results.length).toBe(2);
expect(results.every((r) => r.addedToRequirements || r.addedToRoadmap)).toBe(true);
});
});
describe("Phase 2: Backend-enriched and chaos", () => {
let tempDir: string;
beforeEach(() => {
resetIdeaCounter();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-p2-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("runBackendEnriched prioritizes mechanical findings", () => {
const engine = new IdeationEngine(tempDir);
const mechanicalIdeas: Idea[] = [
{
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Missing test",
rationale: "No test file",
confidence: 0.7,
actions: ["add_test"],
tier: "mechanical",
},
{
id: "IDEATE-02",
source: "escalation_pattern",
category: "security",
title: "Security issue",
rationale: "Repeated escalation",
confidence: 0.8,
actions: ["add_security_pattern"],
tier: "mechanical",
},
];
const enriched = engine.runBackendEnriched(mechanicalIdeas);
expect(enriched.length).toBeGreaterThanOrEqual(2);
const prioritizedIdeas = enriched.filter((i) => i.source === "uncovered_requirement" || i.source === "escalation_pattern");
expect(prioritizedIdeas.length).toBeGreaterThanOrEqual(2);
for (const idea of prioritizedIdeas) {
expect(idea.tier).toBe("backend-enriched");
}
});
it("runBackendEnriched adds novel suggestions for missing categories", () => {
const engine = new IdeationEngine(tempDir);
const mechanicalIdeas: Idea[] = [
{
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Cover this",
rationale: "Missing",
confidence: 0.7,
actions: ["add_test"],
tier: "mechanical",
},
];
const enriched = engine.runBackendEnriched(mechanicalIdeas);
const novelIdeas = enriched.filter((i) => i.source === "improvement_pattern");
expect(novelIdeas.length).toBeGreaterThanOrEqual(1);
});
it("generateChaosScenarios uses default scenarios when enabled", () => {
const engine = new IdeationEngine(tempDir);
const chaosIdeas = engine.generateChaosScenarios();
expect(chaosIdeas.length).toBe(3);
expect(chaosIdeas.every((i) => i.source === "chaos_scenario")).toBe(true);
expect(chaosIdeas.every((i) => i.category === "chaos")).toBe(true);
expect(chaosIdeas.every((i) => i.tier === "backend-enriched")).toBe(true);
expect(chaosIdeas.every((i) => i.confidence >= 0.5)).toBe(true);
const titles = chaosIdeas.map((i) => i.title);
expect(titles.some((t) => t.includes("backend"))).toBe(true);
expect(titles.some((t) => t.includes("requirement"))).toBe(true);
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([]);
});
});
});
+1012
View File
File diff suppressed because it is too large Load Diff
+414 -1
View File
@@ -3,7 +3,11 @@ import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
import { CIAgentFiles, ProjectEntry } from "../core/ciagent-files.js"; import { CIAgentFiles, ProjectEntry } from "../core/ciagent-files.js";
import { initCIAgent, loadConfig, saveConfig } from "../core/config.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 { function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-multiproject-test-")); 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 }); 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", () => { describe("Multi-project CIAgentFiles operations", () => {
let dir: string; let dir: string;
@@ -168,4 +287,298 @@ describe("Multi-project CIAgentFiles operations", () => {
expect(projectMd!.name).toBe("Task API"); 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");
});
});
}); });
+163
View File
@@ -0,0 +1,163 @@
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 { IntelligenceBackend, BackendRequest, BackendResult, BackendType, emptyTokenUsage, OpenAIConfig, AnthropicConfig } from "./backends/types.js";
import { OpenAIBackend } from "./backends/openai.js";
import { AnthropicBackend } from "./backends/anthropic.js";
import { PlannerAgent } from "./agents/planner.js";
import { ResearcherAgent } from "./agents/researcher.js";
import { VerifierAgent } from "./agents/verifier.js";
import { SecurityAuditorAgent } from "./agents/security-auditor.js";
import { CodeReviewerAgent } from "./agents/code-reviewer.js";
import { AgentContext } from "./agents/base.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type: BackendType = "llm";
async isAvailable(): Promise<boolean> {
return true;
}
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed task for ${request.persona}: ${request.task.substring(0, 80)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
describe("E2E v0.9 — Integration with mock backend", () => {
let tempDir: string;
const mockBackend = new MockBackend();
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-e2e-v09-"));
fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
fs.mkdirSync(path.join(tempDir, "src"), { recursive: true });
fs.writeFileSync(
path.join(tempDir, ".ciagent", "config.json"),
JSON.stringify({
autonomy: { level: "full", escalation_hooks: [], clarify_budget: 10, decision_confidence_threshold: 0.6, max_revision_iterations: 3, max_verification_retries: 2, escalation_timeout_ms: 300000 },
model_profile: "quality",
parallelization: { enabled: true, max_concurrent_agents: 5, min_plans_for_parallel: 2 },
verification: { automated_only: true, escalate_visual: true, escalate_external_integration: true, test_first: false },
security: { auto_accept_low_severity: true, auto_mitigate_medium_severity: true, escalate_high_severity: true },
git: { branching_strategy: "phase", auto_commit: false, auto_push: false },
backend: { provider: "auto", agent_backends: { opencode: { enabled: false } }, llm_backends: {} },
}, null, 2)
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "PROJECT.md"),
"# Project: E2E Test\n\n## Core Value\nTest CIAgent v0.9 integration\n\n## Requirements\n### Active\n- TEST-01: E2E pipeline completes\n\n## Key Decisions\n\n## Constraints\n- Test environment only"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "REQUIREMENTS.md"),
"# Requirements\n\n## V1\n### Functional\n| ID | Description | Priority |\n|------|------|------|\n| REQ-01 | E2E test completes | high |\n\n## Traceability\n| Requirement | Phase | Status |\n|------|------|------|\n| REQ-01 | 1 | in_progress |"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "ROADMAP.md"),
"# Roadmap\n\n## Phases\n\n| # | Name | Description | Requirements | Depends On | Status |\n|------|------|------|------|------|------|\n| 1 | Test Phase | E2E test phase | REQ-01 | | in_progress |"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\nE2E test architecture\n\n## Components\n| Name | Description | Boundaries | Depends On |\n|------|------|------|------|\n| core | Core module | src/core/ — test support | | \n\n## Build Order\n1. Build core\n\n## Data Flow\nSimple test flow"
);
fs.writeFileSync(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "e2e-test", version: "0.1.0", scripts: { test: "echo 'no tests'" } })
);
fs.writeFileSync(path.join(tempDir, "tsconfig.json"), "{}");
fs.writeFileSync(path.join(tempDir, "src", "app.ts"), "export function main() { return 1; }");
execSync("git init", { cwd: tempDir, stdio: "pipe" });
execSync("git add -A", { cwd: tempDir, stdio: "pipe" });
execSync('git commit -m "init: E2E test project"', { cwd: tempDir, stdio: "pipe" });
});
afterEach(() => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {}
});
it("runs a multi-agent pipeline with mock backend and collects artifacts", async () => {
const context: AgentContext = {
project_path: tempDir,
phase: 1,
stage: "research",
specification: "Build an E2E test project that validates CIAgent v0.9 integration",
config_path: path.join(tempDir, ".ciagent", "config.json"),
backend: mockBackend as unknown as IntelligenceBackend,
};
const researcher = new ResearcherAgent();
const researcherResult = await researcher.execute(context);
expect(researcherResult).toBeDefined();
expect(typeof researcherResult.success).toBe("boolean");
expect(researcherResult.output.length).toBeGreaterThan(0);
const planner = new PlannerAgent();
const plannerResult = await planner.execute({ ...context, stage: "plan" });
expect(plannerResult).toBeDefined();
expect(typeof plannerResult.success).toBe("boolean");
const auditor = new SecurityAuditorAgent();
const auditorResult = await auditor.execute({ ...context, stage: "verify" });
expect(auditorResult).toBeDefined();
expect(typeof auditorResult.success).toBe("boolean");
const reviewer = new CodeReviewerAgent();
const reviewerResult = await reviewer.execute({ ...context, stage: "review" });
expect(reviewerResult).toBeDefined();
expect(typeof reviewerResult.success).toBe("boolean");
const verifier = new VerifierAgent();
const verifierResult = await verifier.execute({ ...context, stage: "verify" });
expect(verifierResult).toBeDefined();
expect(typeof verifierResult.success).toBe("boolean");
});
it("loads OpenAI and Anthropic config types without runtime errors", () => {
const openaiConfig: OpenAIConfig = {
base_url: "https://api.openai.com/v1",
api_key_env: "OPENAI_API_KEY",
model: "gpt-4o",
model_profile: "quality",
timeout_ms: 60000,
};
expect(openaiConfig.model).toBe("gpt-4o");
expect(openaiConfig.api_key_env).toBe("OPENAI_API_KEY");
const anthropicConfig: AnthropicConfig = {
base_url: "https://api.anthropic.com",
api_key_env: "ANTHROPIC_API_KEY",
model: "claude-sonnet-4-20250514",
model_profile: "quality",
timeout_ms: 60000,
api_version: "2023-06-01",
};
expect(anthropicConfig.model).toBe("claude-sonnet-4-20250514");
expect(anthropicConfig.api_key_env).toBe("ANTHROPIC_API_KEY");
const openaiBackend = new OpenAIBackend(openaiConfig);
expect(openaiBackend.name).toBe("openai");
expect(openaiBackend.type).toBe("llm");
const anthropicBackend = new AnthropicBackend(anthropicConfig);
expect(anthropicBackend.name).toBe("anthropic");
expect(anthropicBackend.type).toBe("llm");
});
});
+41 -1
View File
@@ -1,4 +1,5 @@
import { BackendConfigSection } from "../backends/types.js"; import { BackendConfigSection } from "../backends/types.js";
import { IdeationConfig, IdeationCategory } from "./ideation.js";
export type AutonomyLevel = "full" | "supervised" | "guided"; export type AutonomyLevel = "full" | "supervised" | "guided";
@@ -6,7 +7,7 @@ export type ModelProfile = "quality" | "speed" | "balanced";
export type BranchingStrategy = "phase" | "feature" | "trunk"; export type BranchingStrategy = "phase" | "feature" | "trunk";
export type MilestoneType = "nfr" | "feature" | "schema-breaking"; export type MilestoneType = "nfr" | "feature" | "major";
export type PhaseName = "research" | "plan" | "execute" | "verify" | "complete"; export type PhaseName = "research" | "plan" | "execute" | "verify" | "complete";
@@ -45,6 +46,7 @@ export interface ParallelizationConfig {
enabled: boolean; enabled: boolean;
max_concurrent_agents: number; max_concurrent_agents: number;
min_plans_for_parallel: number; min_plans_for_parallel: number;
max_concurrent_projects: number;
} }
export interface VerificationConfig { export interface VerificationConfig {
@@ -82,6 +84,7 @@ export interface ProjectEntry {
export interface CIAgentConfig { export interface CIAgentConfig {
projects: ProjectEntry[]; projects: ProjectEntry[];
active_project: string; active_project: string;
active_projects: string[];
autonomy: AutonomyConfig; autonomy: AutonomyConfig;
model_profile: ModelProfile; model_profile: ModelProfile;
parallelization: ParallelizationConfig; parallelization: ParallelizationConfig;
@@ -90,11 +93,13 @@ export interface CIAgentConfig {
git: GitConfig; git: GitConfig;
backend: BackendConfigSection; backend: BackendConfigSection;
gitea?: GiteaConfig; gitea?: GiteaConfig;
ideation?: IdeationConfig;
} }
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = { export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
projects: [], projects: [],
active_project: "", active_project: "",
active_projects: [],
autonomy: { autonomy: {
level: "full", level: "full",
escalation_hooks: ["deploy", "delete_data", "merge_to_main"], escalation_hooks: ["deploy", "delete_data", "merge_to_main"],
@@ -109,6 +114,7 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
enabled: true, enabled: true,
max_concurrent_agents: 5, max_concurrent_agents: 5,
min_plans_for_parallel: 2, min_plans_for_parallel: 2,
max_concurrent_projects: 3,
}, },
verification: { verification: {
automated_only: true, automated_only: true,
@@ -132,6 +138,13 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
opencode: { enabled: true }, opencode: { enabled: true },
}, },
llm_backends: { llm_backends: {
"openai": {
base_url: "https://api.openai.com/v1",
api_key_env: "OPENAI_API_KEY",
model: "gpt-4o",
model_profile: "quality",
timeout_ms: 60000,
},
"ollama-local": { "ollama-local": {
base_url: "http://localhost:11434", base_url: "http://localhost:11434",
model_profile: "balanced", model_profile: "balanced",
@@ -142,6 +155,14 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
model_profile: "quality", model_profile: "quality",
timeout_ms: 60000, timeout_ms: 60000,
}, },
"anthropic": {
base_url: "https://api.anthropic.com",
api_key_env: "ANTHROPIC_API_KEY",
model: "claude-sonnet-4-20250514",
api_version: "2023-06-01",
model_profile: "quality",
timeout_ms: 60000,
},
}, },
}, },
gitea: { gitea: {
@@ -150,4 +171,23 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
owner: "", owner: "",
repo: "", repo: "",
}, },
ideation: {
enabled: true,
categories: ["security", "quality", "architecture", "coverage", "improvement"] as IdeationCategory[],
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"],
},
},
}; };
+105
View File
@@ -0,0 +1,105 @@
export type IdeationSource =
| "uncovered_requirement"
| "repeated_lesson"
| "low_confidence_decision"
| "escalation_pattern"
| "compound_pattern"
| "partial_requirement"
| "gap_in_coverage"
| "improvement_pattern"
| "architecture_drift"
| "verification_inversion"
| "spec_ambiguity"
| "spec_contradiction"
| "spec_missing"
| "external_signal"
| "cross_project_lesson"
| "chaos_scenario";
export type IdeationCategory =
| "security"
| "quality"
| "architecture"
| "coverage"
| "improvement"
| "spec"
| "chaos";
export type IdeationAction =
| "add_requirement"
| "update_architecture"
| "update_roadmap"
| "fix_documentation"
| "add_test"
| "add_security_pattern"
| "refactor"
| "new_milestone_phase";
export type IdeationTier = "mechanical" | "backend-enriched" | "cross-project";
export interface Idea {
id: string;
source: IdeationSource;
category: IdeationCategory;
title: string;
rationale: string;
confidence: number;
relatedReq?: string;
actions: IdeationAction[];
tier: IdeationTier;
}
export interface IdeationResult {
project: string;
milestone: string;
ideas: Idea[];
summary: IdeationSummary;
}
export interface IdeationSummary {
total: number;
accepted: number;
skipped: number;
by_category: Record<string, number>;
by_tier: Record<string, number>;
}
export interface IdeationConfig {
enabled: boolean;
categories: IdeationCategory[];
confidence_threshold: number;
max_ideas: number;
external_signals: {
npm_audit: boolean;
osv_advisories: boolean;
dependency_staleness: boolean;
};
cross_project: {
enabled: boolean;
similarity_weight: number;
};
chaos: {
enabled: boolean;
scenarios: string[];
};
}
export const DEFAULT_IDEATION_CONFIG: IdeationConfig = {
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"],
},
};
+1 -1
View File
@@ -7,7 +7,7 @@ import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
describe("Type exports", () => { describe("Type exports", () => {
it("pipeline types are importable and functional", () => { it("pipeline types are importable and functional", () => {
expect(STAGE_ORDER).toHaveLength(8); expect(STAGE_ORDER).toHaveLength(9);
expect(getNextStage("specify")).toBe("clarify"); expect(getNextStage("specify")).toBe("clarify");
const state = createInitialPipelineState("/tmp/test"); const state = createInitialPipelineState("/tmp/test");
expect(state.current_stage).toBe("specify"); expect(state.current_stage).toBe("specify");
+15 -2
View File
@@ -8,11 +8,12 @@ import {
} from "../types/pipeline.js"; } from "../types/pipeline.js";
describe("STAGE_ORDER", () => { describe("STAGE_ORDER", () => {
it("has 8 stages in correct order", () => { it("has 9 stages in correct order", () => {
expect(STAGE_ORDER).toEqual([ expect(STAGE_ORDER).toEqual([
"specify", "specify",
"clarify", "clarify",
"research", "research",
"ideate",
"plan", "plan",
"execute", "execute",
"test", "test",
@@ -26,7 +27,8 @@ describe("getNextStage", () => {
it("returns the next stage in sequence", () => { it("returns the next stage in sequence", () => {
expect(getNextStage("specify")).toBe("clarify"); expect(getNextStage("specify")).toBe("clarify");
expect(getNextStage("clarify")).toBe("research"); expect(getNextStage("clarify")).toBe("research");
expect(getNextStage("research")).toBe("plan"); expect(getNextStage("research")).toBe("ideate");
expect(getNextStage("ideate")).toBe("plan");
expect(getNextStage("plan")).toBe("execute"); expect(getNextStage("plan")).toBe("execute");
expect(getNextStage("execute")).toBe("test"); expect(getNextStage("execute")).toBe("test");
expect(getNextStage("test")).toBe("verify"); expect(getNextStage("test")).toBe("verify");
@@ -51,6 +53,7 @@ describe("createInitialPipelineState", () => {
expect(state.specification_loaded).toBe(false); expect(state.specification_loaded).toBe(false);
expect(state.clarify_completed).toBe(false); expect(state.clarify_completed).toBe(false);
expect(state.research_completed).toBe(false); expect(state.research_completed).toBe(false);
expect(state.ideate_completed).toBe(false);
expect(state.plan_completed).toBe(false); expect(state.plan_completed).toBe(false);
expect(state.execute_completed).toBe(false); expect(state.execute_completed).toBe(false);
expect(state.test_completed).toBe(false); expect(state.test_completed).toBe(false);
@@ -60,3 +63,13 @@ describe("createInitialPipelineState", () => {
expect(state.last_updated).toBeTruthy(); 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);
});
});
+4
View File
@@ -4,6 +4,7 @@ export type PipelineStage =
| "specify" | "specify"
| "clarify" | "clarify"
| "research" | "research"
| "ideate"
| "plan" | "plan"
| "execute" | "execute"
| "test" | "test"
@@ -18,6 +19,7 @@ export interface PipelineState {
specification_loaded: boolean; specification_loaded: boolean;
clarify_completed: boolean; clarify_completed: boolean;
research_completed: boolean; research_completed: boolean;
ideate_completed: boolean;
plan_completed: boolean; plan_completed: boolean;
execute_completed: boolean; execute_completed: boolean;
test_completed: boolean; test_completed: boolean;
@@ -61,6 +63,7 @@ export const STAGE_ORDER: PipelineStage[] = [
"specify", "specify",
"clarify", "clarify",
"research", "research",
"ideate",
"plan", "plan",
"execute", "execute",
"test", "test",
@@ -85,6 +88,7 @@ export function createInitialPipelineState(
specification_loaded: false, specification_loaded: false,
clarify_completed: false, clarify_completed: false,
research_completed: false, research_completed: false,
ideate_completed: false,
plan_completed: false, plan_completed: false,
execute_completed: false, execute_completed: false,
test_completed: false, test_completed: false,
+399
View File
@@ -0,0 +1,399 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { execSync } from "node:child_process";
import { IdeationEngine, resetIdeaCounter } from "../core/ideation.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-e2e-ideation-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function initGitRepo(dir: string): void {
execSync("git init", { cwd: dir, stdio: "pipe" });
execSync("git config user.email test@test.com", { cwd: dir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: dir, stdio: "pipe" });
}
function initIdeationProject(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "PROJECT.md"), [
"# Test Project",
"",
"## What This Is",
"",
"A test project for E2E ideation testing",
"",
"## Requirements",
"",
"### Validated",
"",
"- User authentication works correctly",
"- All tests pass",
"",
"### Active",
"",
"- Add real-time notifications",
"- Implement rate limiting for API endpoints",
"- Should handle edge cases gracefully",
"",
"### Out of Scope",
"",
"- Admin dashboard",
"",
"## Context",
"",
"Testing context for ideation engine",
"",
"## Constraints",
"",
"- Must use Node.js",
"- Must be production-ready",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n"));
fs.writeFileSync(path.join(ciDir, "REQUIREMENTS.md"), [
"# Requirements",
"",
"## v0.10 Requirements — Test Project",
"",
"| REQ-ID | Requirement | Priority | Phase | Status |",
"|--------|-------------|----------|-------|--------|",
"| IDEATE-01 | Ideation command | P0 | 1 | pending |",
"| IDEATE-02 | Three-tier engine | P0 | 1 | pending |",
"| IDEATE-03 | Pattern mining | P0 | 1 | covered |",
"| MULTI-01 | Config migration | P0 | 2 | in_progress |",
"| MULTI-02 | Project flag | P0 | 2 | pending |",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| IDEATE-01 | 1 | pending |",
"| IDEATE-02 | 1 | pending |",
"| IDEATE-03 | 1 | covered |",
"| MULTI-01 | 2 | in_progress |",
"| MULTI-02 | 2 | pending |",
].join("\n"));
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), [
"# Roadmap",
"",
"## Overview",
"",
"Test project roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Core** - Build core ideation engine",
"- [ ] **Phase 2: Multi-Project** - Add multi-project support",
"",
"## Phase Details",
"",
"### Phase 1: Core",
"**Goal.**: Build core ideation engine",
"**Depends on**: Nothing",
"**Requirements**: IDEATE-01, IDEATE-02, IDEATE-03",
"**Success Criteria**:",
"1. Ideation command works",
'**Status**: not_started',
"",
"### Phase 2: Multi-Project",
'**Goal.**: Add multi-project support',
"**Depends on**: Phase 1",
"**Requirements**: MULTI-01, MULTI-02",
"**Success Criteria**:",
"1. Multi-project config works",
'**Status**: not_started',
"",
].join("\n"));
fs.writeFileSync(path.join(ciDir, "ARCHITECTURE.md"), [
"# Architecture",
"",
"## Overview",
"",
"Test project architecture",
"",
"## Components",
"",
"### ideation-engine",
"- **Description**: Core ideation engine with 3 tiers",
"- **Boundaries**: No external dependencies",
"- **Depends on**: None",
"",
"### cli",
"- **Description**: Commander.js CLI entry point",
"- **Boundaries**: Terminal I/O only",
"- **Depends on**: ideation-engine",
"",
"### orchestrator",
"- **Description**: Pipeline controller",
"- **Boundaries**: Agent delegation",
"- **Depends on**: cli, ideation-engine",
"",
"## Data Flow",
"",
"CLI -> Engine -> Ideas",
"",
"## Build Order",
"",
"1. ideation-engine",
"2. cli",
"3. orchestrator",
"",
].join("\n"));
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify({
projects: [],
active_project: "",
active_projects: [],
autonomy: { level: "full" },
ideation: {
enabled: true,
categories: ["security", "quality", "architecture", "coverage", "improvement"],
confidence_threshold: 0.6,
max_ideas: 20,
},
}, null, 2));
const srcDir = path.join(dir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.ts"), "export function main() { return 1; }");
fs.writeFileSync(path.join(srcDir, "app.test.ts"), "test('works', () => { expect(main()).toBe(1); });");
}
describe("E2E: Ideation Command (Mechanical Tier)", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initIdeationProject(dir);
initGitRepo(dir);
resetIdeaCounter();
});
afterEach(() => {
cleanup(dir);
});
describe("Mechanical ideation runs without errors", () => {
it("produces ideas from mechanical tier when requirements exist", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
expect(Array.isArray(ideas)).toBe(true);
expect(ideas.length).toBeGreaterThan(0);
});
it("identifies uncovered or partial requirements when they exist", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const coverageIdeas = ideas.filter((i) => i.source === "uncovered_requirement" || i.source === "partial_requirement");
expect(coverageIdeas.length).toBeGreaterThanOrEqual(0);
if (coverageIdeas.length > 0) {
expect(coverageIdeas[0].category).toBe("coverage");
expect(coverageIdeas[0].tier).toBe("mechanical");
}
});
it("respects category filter", () => {
const engine = new IdeationEngine(dir);
const securityOnly = engine.runMechanical(["security"]);
for (const idea of securityOnly) {
expect(idea.category).toBe("security");
}
});
it("can filter by architecture category", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical(["architecture"]);
expect(Array.isArray(ideas)).toBe(true);
expect(ideas.length).toBeGreaterThanOrEqual(0);
});
it("identifies verification inversions when missing tests exist", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical(["quality"]);
const verificationIdeas = ideas.filter((i) => i.source === "verification_inversion");
expect(verificationIdeas.length).toBeGreaterThanOrEqual(0);
});
it("sorts ideas by confidence", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
for (let i = 1; i < ideas.length; i++) {
expect(ideas[i].confidence).toBeLessThanOrEqual(ideas[i - 1].confidence);
}
});
it("formats ideas as text", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const text = engine.formatIdeas(ideas);
expect(text).toContain("Improvement Ideas:");
if (ideas.length > 0) {
expect(text).toContain("[");
}
});
it("formats ideas as JSON", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const result = engine.formatIdeasJson(ideas);
expect(result.project).toBeDefined();
expect(result.summary.total).toBe(ideas.length);
expect(typeof result.summary.by_category).toBe("object");
expect(typeof result.summary.by_tier).toBe("object");
});
});
describe("Mechanical ideation produces specific signals", () => {
it("identifies uncovered requirements", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const uncoveredIdeas = ideas.filter((i) => i.source === "uncovered_requirement");
expect(uncoveredIdeas.length).toBeGreaterThanOrEqual(0);
if (uncoveredIdeas.length > 0) {
expect(uncoveredIdeas[0].category).toBe("coverage");
expect(uncoveredIdeas[0].tier).toBe("mechanical");
}
});
it("identifies partial requirements", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const partialIdeas = ideas.filter((i) => i.source === "partial_requirement");
expect(partialIdeas.length).toBeGreaterThanOrEqual(0);
if (partialIdeas.length > 0) {
expect(partialIdeas[0].relatedReq).toBe("MULTI-01");
}
});
it("identifies verification inversions (missing tests)", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical(["quality"]);
const verificationIdeas = ideas.filter((i) => i.source === "verification_inversion");
expect(verificationIdeas.length).toBeGreaterThanOrEqual(0);
});
it("formats ideas as text with source prefix", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical();
const text = engine.formatIdeas(ideas);
expect(text).toContain("Improvement Ideas:");
if (ideas.length > 0) {
expect(text).toContain("[");
}
});
});
describe("Accepting ideas", () => {
it("accepts ideas and adds to requirements/roadmap", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical().slice(0, 2);
const { accepted, results } = engine.acceptIdeas(ideas);
expect(accepted.length).toBeGreaterThan(0);
expect(results.length).toBe(accepted.length);
for (const result of results) {
expect(result.addedToRequirements || result.addedToRoadmap).toBe(true);
}
});
});
describe("Cascade impact analysis", () => {
it("runs affected analysis without errors", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runAffected();
expect(Array.isArray(ideas)).toBe(true);
});
});
describe("External signals", () => {
it("runs external analysis without errors", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runExternal();
expect(Array.isArray(ideas)).toBe(true);
});
});
describe("Cross-project analysis", () => {
it("runs cross-project analysis in multi-project setup", () => {
const ciDir = path.join(dir, ".ciagent");
const config = JSON.parse(fs.readFileSync(path.join(ciDir, "config.json"), "utf-8"));
config.projects = [
{ slug: "test-project", name: "Test Project", default: true },
{ slug: "other-project", name: "Other Project" },
];
config.active_projects = ["test-project", "other-project"];
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify(config, null, 2));
fs.mkdirSync(path.join(ciDir, "other-project"), { recursive: true });
fs.writeFileSync(path.join(ciDir, "other-project", "PROJECT.md"), "# Other\n\n## What This Is\n\nOther project");
const engine = new IdeationEngine(dir, "test-project");
const ideas = engine.runCrossProject();
expect(Array.isArray(ideas)).toBe(true);
});
});
describe("Chaos scenarios", () => {
it("generates chaos scenario ideas", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.generateChaosScenarios();
expect(Array.isArray(ideas)).toBe(true);
expect(ideas.length).toBeGreaterThan(0);
for (const idea of ideas) {
expect(idea.category).toBe("chaos");
expect(idea.source).toBe("chaos_scenario");
expect(idea.tier).toBe("backend-enriched");
}
});
});
describe("Spec analysis", () => {
it("runs spec analysis without errors", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical(["spec"]);
expect(Array.isArray(ideas)).toBe(true);
});
it("detects missing common categories in spec", () => {
const engine = new IdeationEngine(dir);
const ideas = engine.runMechanical(["spec"]);
const missingIdeas = ideas.filter((i) => i.source === "spec_missing");
expect(missingIdeas.length).toBeGreaterThanOrEqual(0);
if (missingIdeas.length > 0) {
expect(missingIdeas[0].category).toMatch(/^(spec|security|quality|architecture|coverage|improvement)$/);
}
});
});
});
+433
View File
@@ -0,0 +1,433 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { CommitBuilder } from "../core/commit-builder.js";
import { initCIAgent, loadConfig, saveConfig } from "../core/config.js";
import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-e2e-multiproject-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function setupMultiProject(dir: string): void {
const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir();
ciFiles.addProject("task-api", "Task API", true);
ciFiles.addProject("auth-svc", "Auth Service");
const taskApiDir = path.join(dir, ".ciagent", "task-api");
fs.mkdirSync(taskApiDir, { recursive: true });
fs.writeFileSync(path.join(taskApiDir, "PROJECT.md"), [
"# Task API",
"",
"## What This Is",
"",
"A REST API for task management",
"",
"## Requirements",
"",
"### Active",
"",
"- CRUD operations for tasks",
"- Authentication",
"",
"### Validated",
"",
"### Out of Scope",
"",
"## Context",
"",
"Task management API",
"",
"## Constraints",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n"));
fs.writeFileSync(path.join(taskApiDir, "REQUIREMENTS.md"), [
"# Requirements",
"",
"## v1 Requirements",
"",
"### Task API",
"",
"- TASK-01: Create task endpoint",
"- TASK-02: Read tasks endpoint",
"- TASK-03: Update task endpoint",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| TASK-01 | 1 | pending |",
"| TASK-02 | 1 | pending |",
"| TASK-03 | 1 | pending |",
].join("\n"));
fs.writeFileSync(path.join(taskApiDir, "ROADMAP.md"), [
"# Roadmap",
"",
"## Overview",
"",
"Task API roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Core** - Build task CRUD endpoints",
"",
"## Phase Details",
"",
"### Phase 1: Core",
"**Goal.**: Build task CRUD endpoints",
"**Depends on**: Nothing",
"**Requirements**: TASK-01, TASK-02",
"**Success Criteria**:",
"1. All CRUD operations work",
'**Status**: not_started',
"",
].join("\n"));
fs.writeFileSync(path.join(taskApiDir, "ARCHITECTURE.md"), [
"# Architecture",
"",
"## Overview",
"",
"Task API architecture",
"",
"## Components",
"",
"### task-api",
"- **Description**: API server",
"- **Boundaries**: HTTP only",
"- **Depends on**: None",
"",
"## Data Flow",
"",
"Client -> API -> DB",
"",
"## Build Order",
"",
"1. task-api",
"",
].join("\n"));
const authDir = path.join(dir, ".ciagent", "auth-svc");
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, "PROJECT.md"), [
"# Auth Service",
"",
"## What This Is",
"",
"Authentication and authorization service",
"",
"## Requirements",
"",
"### Active",
"",
"- JWT token generation",
"- Password hashing",
"",
"### Validated",
"",
"### Out of Scope",
"",
"## Context",
"",
"Authentication service",
"",
"## Constraints",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n"));
fs.writeFileSync(path.join(authDir, "REQUIREMENTS.md"), [
"# Requirements",
"",
"## v1 Requirements",
"",
"### Auth",
"",
"- AUTH-01: JWT token generation",
"- AUTH-02: Password hashing",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| AUTH-01 | 1 | pending |",
"| AUTH-02 | 1 | pending |",
].join("\n"));
fs.writeFileSync(path.join(authDir, "ROADMAP.md"), [
"# Roadmap",
"",
"## Overview",
"",
"Auth Service roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Auth** - Implement JWT authentication",
"",
"## Phase Details",
"",
"### Phase 1: Auth",
"**Goal.**: Implement JWT authentication",
"**Depends on**: Nothing",
"**Requirements**: AUTH-01, AUTH-02",
"**Success Criteria**:",
"1. JWT tokens are generated correctly",
'**Status**: not_started',
"",
].join("\n"));
fs.writeFileSync(path.join(authDir, "ARCHITECTURE.md"), [
"# Architecture",
"",
"## Overview",
"",
"Auth Service architecture",
"",
"## Components",
"",
"### auth-svc",
"- **Description**: Auth service",
"- **Boundaries**: Auth only",
"- **Depends on**: None",
"",
"## Data Flow",
"",
"Client -> Auth -> Token",
"",
"## Build Order",
"",
"1. auth-svc",
"",
].join("\n"));
const config = loadConfig(dir);
config.active_projects = ["task-api", "auth-svc"];
saveConfig(dir, config);
}
describe("E2E: Multi-Project Execution", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
describe("Project management", () => {
it("lists multiple registered projects", () => {
setupMultiProject(dir);
const ciFiles = new CIAgentFiles(dir);
const projects = ciFiles.listProjects();
expect(projects.length).toBeGreaterThanOrEqual(2);
const slugs = projects.map((p) => p.slug);
expect(slugs).toContain("task-api");
expect(slugs).toContain("auth-svc");
});
it("detects multi-project mode", () => {
setupMultiProject(dir);
const ciFiles = new CIAgentFiles(dir);
expect(ciFiles.isMultiProject()).toBe(true);
});
it("reads and writes per-project files", () => {
setupMultiProject(dir);
const taskFiles = new CIAgentFiles(dir, "task-api");
const taskProject = taskFiles.readProjectMd();
expect(taskProject).not.toBeNull();
expect(taskProject!.name).toBe("Task API");
const authFiles = new CIAgentFiles(dir, "auth-svc");
const authProject = authFiles.readProjectMd();
expect(authProject).not.toBeNull();
expect(authProject!.name).toBe("Auth Service");
});
it("reads per-project requirements", () => {
setupMultiProject(dir);
const taskFiles = new CIAgentFiles(dir, "task-api");
const taskReqs = taskFiles.readRequirementsMd();
expect(taskReqs).not.toBeNull();
const authFiles = new CIAgentFiles(dir, "auth-svc");
const authReqs = authFiles.readRequirementsMd();
expect(authReqs).not.toBeNull();
});
it("reads per-project roadmap", () => {
setupMultiProject(dir);
const taskFiles = new CIAgentFiles(dir, "task-api");
const taskRoadmap = taskFiles.readRoadmapMd();
expect(taskRoadmap).not.toBeNull();
expect(taskRoadmap!.phases.length).toBeGreaterThan(0);
});
it("reads per-project architecture", () => {
setupMultiProject(dir);
const taskFiles = new CIAgentFiles(dir, "task-api");
const taskArch = taskFiles.readArchitectureMd();
expect(taskArch).not.toBeNull();
expect(taskArch!.components.length).toBeGreaterThan(0);
});
});
describe("Config with active_projects", () => {
it("stores active_projects array in config", () => {
setupMultiProject(dir);
const config = loadConfig(dir);
expect(config.active_projects).toContain("task-api");
expect(config.active_projects).toContain("auth-svc");
expect(config.active_projects.length).toBe(2);
});
it("max_concurrent_projects is configurable", () => {
initCIAgent(dir, {
parallelization: {
...DEFAULT_CIAGENT_CONFIG.parallelization,
max_concurrent_projects: 5,
},
});
const config = loadConfig(dir);
expect(config.parallelization.max_concurrent_projects).toBe(5);
});
it("default max_concurrent_projects is 3", () => {
expect(DEFAULT_CIAGENT_CONFIG.parallelization.max_concurrent_projects).toBe(3);
});
});
describe("Commit message project tracking", () => {
it("includes project in ---ci--- block for task commit", () => {
const msg = CommitBuilder.buildTaskCommit({
type: "feat",
phase: 1,
milestone: "v0.10",
project: "task-api",
plan: "01-auth",
task: "01-01",
subject: "implement JWT token generation",
status: "execute",
});
expect(msg).toContain("---ci---");
expect(msg).toContain("project: task-api");
expect(msg).toContain("phase: 1");
expect(msg).toContain("milestone: v0.10");
expect(msg).toContain("status: execute");
});
it("includes project in ---ci--- block for init commit", () => {
const msg = CommitBuilder.buildInitCommit({
projectName: "Auth Service",
phaseCount: 2,
milestone: "v0.10",
project: "auth-svc",
specification: "Authentication and authorization service",
});
expect(msg).toContain("---ci---");
expect(msg).toContain("project: auth-svc");
expect(msg).toContain("phase: 0");
});
it("different projects produce different commit scopes", () => {
const taskMsg = CommitBuilder.buildTaskCommit({
type: "feat",
phase: 1,
milestone: "v0.10",
project: "task-api",
plan: "01",
task: "01",
subject: "create task endpoint",
status: "execute",
});
const authMsg = CommitBuilder.buildTaskCommit({
type: "feat",
phase: 1,
milestone: "v0.10",
project: "auth-svc",
plan: "01",
task: "01",
subject: "JWT token generation",
status: "execute",
});
expect(taskMsg).toContain("task-api/");
expect(taskMsg).toContain("project: task-api");
expect(authMsg).toContain("auth-svc/");
expect(authMsg).toContain("project: auth-svc");
});
});
describe("Per-project ideation", () => {
it("runs ideation engine with project slug", () => {
setupMultiProject(dir);
const { IdeationEngine, resetIdeaCounter } = require("../core/ideation.js");
resetIdeaCounter();
const taskEngine = new IdeationEngine(dir, "task-api");
const taskIdeas = taskEngine.runMechanical();
expect(Array.isArray(taskIdeas)).toBe(true);
expect(taskIdeas.length).toBeGreaterThan(0);
resetIdeaCounter();
const authEngine = new IdeationEngine(dir, "auth-svc");
const authIdeas = authEngine.runMechanical();
expect(Array.isArray(authIdeas)).toBe(true);
expect(authIdeas.length).toBeGreaterThan(0);
});
it("produces different ideas for different projects", () => {
setupMultiProject(dir);
const { IdeationEngine, resetIdeaCounter } = require("../core/ideation.js");
resetIdeaCounter();
const taskEngine = new IdeationEngine(dir, "task-api");
const taskIdeas = taskEngine.runMechanical();
const taskTitles = new Set(taskIdeas.map((i: any) => i.title));
resetIdeaCounter();
const authEngine = new IdeationEngine(dir, "auth-svc");
const authIdeas = authEngine.runMechanical();
const authTitles = new Set(authIdeas.map((i: any) => i.title));
expect(taskTitles.size).toBeGreaterThan(0);
expect(authTitles.size).toBeGreaterThan(0);
});
});
});
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "0.8.0"; export const VERSION = "0.10.0";