feat(ci): v0.9.0 — Distribution & Expansion milestone complete
---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
This commit is contained in:
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -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)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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
|
- 57 test suites, 527 tests covering types, core, git-native, verification, agent, backends, 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,19 @@ 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.9.0**: Integration & hardening — OpenAI and Anthropic backends, all 19 agents with intrinsic mechanical logic, E2E v0.9 integration tests, parallel agent execution
|
||||||
|
- **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows
|
||||||
|
- **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation)
|
||||||
|
- **Config expansion**: BackendConfigSection now includes `openai` and `anthropic` in `llm_backends` with dedicated `OpenAIConfig` and `AnthropicConfig` types
|
||||||
|
- **Auto-detection order (v0.9)**: opencode → openai → ollama-local → ollama-cloud → anthropic
|
||||||
|
- **All agents mechanical**: Every non-orchestrator agent (18/19) produces meaningful output without a backend — no "requires intelligence backend" stub errors
|
||||||
|
- **Integration tests**: E2E v0.9 test with mock backend verifies multi-agent pipeline (researcher → planner → security-auditor → code-reviewer → verifier); all-agents-mechanical test iterates 18 agents
|
||||||
|
- **Parallel execution**: OrchestratorAgent supports concurrent review agents with `limitConcurrency()`, controlled by `parallelization.max_concurrent_agents`
|
||||||
- **New modules**: commit-parser (`---ci---` YAML block extraction/parsing), commit-builder (structured commit message generation), git-context (project state reconstruction from git log + branches), git-branch (phase/milestone branch lifecycle), ciagent-files (`.ciagent/` long-lived reference file management)
|
- **New modules**: commit-parser (`---ci---` YAML block extraction/parsing), commit-builder (structured commit message generation), git-context (project state reconstruction from git log + branches), git-branch (phase/milestone branch lifecycle), ciagent-files (`.ciagent/` long-lived reference file management)
|
||||||
- **Commit schema**: Every CIAgent-generated commit contains a `---ci---` YAML block with phase, milestone, status, decisions, escalations, requirements, lessons, and compound metadata
|
- **Commit schema**: Every CIAgent-generated commit contains a `---ci---` YAML block with phase, milestone, status, decisions, escalations, requirements, lessons, and compound metadata
|
||||||
- **Branch strategy**: `phase/NN-slug` and `milestone/vX.X-slug` branches encode project structure; merged = complete, active = in progress
|
- **Branch strategy**: `phase/NN-slug` and `milestone/vX.X-slug` branches encode project structure; merged = complete, active = in progress
|
||||||
- **Core engine rewrites**: DecisionEngine generates commit messages (not audit JSON), EscalationProtocol commits escalations as git artifacts, OrchestratorAgent uses git log as first impulse
|
- **Core engine rewrites**: DecisionEngine generates commit messages (not audit JSON), EscalationProtocol commits escalations as git artifacts, OrchestratorAgent uses git log as first impulse
|
||||||
- **Removed**: `.ciagent/audit/` directory (audit trail is git log), `.planning/` directory (dynamic state derived from git history)
|
- **Verification layers**: All 4 layers implemented — structural, behavioral (test execution), security (STRIDE + CWE), quality (3-persona review)
|
||||||
- **`.ciagent/` contents**: `config.json`, `PROJECT.md`, `ARCHITECTURE.md`, `ROADMAP.md`, `REQUIREMENTS.md` — long-lived reference docs updated with discipline
|
|
||||||
- **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`)
|
- **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**: 5 options — OpenAI (LLM), Anthropic (LLM), OllamaLocal (LLM, localhost), OllamaCloud (LLM, remote), Opencode (Agent, --non-interactive). Auto-detection: opencode → openai → ollama-local → ollama-cloud → anthropic.
|
||||||
- **Intelligence backends**: OllamaLocal (LLM, localhost), OllamaCloud (LLM, remote), Opencode (Agent, --non-interactive). Auto-detection: opencode → ollama-local → ollama-cloud.
|
- **Tests**: 57 test suites, 527 tests covering types, config, decision-engine, escalation, clarify, commit-parser, commit-builder, git-context, git-branch, ciagent-files, all 4 verification layers, file utils, backends (ollama, openai, anthropic, opencode, tool-registry), agents (all 18 non-orchestrator), zod validation, e2e, parallel execution
|
||||||
- **Tests**: 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
|
|
||||||
@@ -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,6 +52,11 @@ 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"
|
||||||
|
|
||||||
@@ -58,7 +77,7 @@ ciagent rollback 1
|
|||||||
ciagent ship 1
|
ciagent ship 1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Git-Native Architecture (v0.2.0)
|
## Git-Native Architecture (v0.9.0)
|
||||||
|
|
||||||
### The Commit Schema
|
### The Commit Schema
|
||||||
|
|
||||||
@@ -246,16 +265,25 @@ 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 |
|
||||||
|
|
||||||
### 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 +321,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
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@continuous-intelligence/ciagent",
|
"name": "@continuous-intelligence/ciagent",
|
||||||
"version": "0.8.0",
|
"version": "0.9.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"],
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-ideation-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, "REQUIREMENTS.md"),
|
||||||
|
"REQ-1: First requirement\nREQ-2: Second requirement"
|
||||||
|
);
|
||||||
|
|
||||||
|
const agent = new IdeationAgent();
|
||||||
|
const ideas = agent.mechanicalIdeate(tempDir);
|
||||||
|
|
||||||
|
const reqIdeas = ideas.filter((i) => i.source === "uncovered_requirement");
|
||||||
|
expect(reqIdeas.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(reqIdeas.some((i) => i.relatedReq === "REQ-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("identifies coverage gaps from PROJECT.md", () => {
|
||||||
|
const ciagentDir = path.join(tempDir, ".ciagent");
|
||||||
|
fs.mkdirSync(ciagentDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ciagentDir, "PROJECT.md"),
|
||||||
|
"We use agent: magic-agent and agent: super-agent for tasks."
|
||||||
|
);
|
||||||
|
|
||||||
|
const srcDir = path.join(tempDir, "src", "agents");
|
||||||
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(srcDir, "base.ts"), "");
|
||||||
|
fs.writeFileSync(path.join(srcDir, "index.ts"), "");
|
||||||
|
|
||||||
|
const agent = new IdeationAgent();
|
||||||
|
const gaps = agent.identifyCoverageGaps(tempDir);
|
||||||
|
|
||||||
|
expect(gaps).toContain("magic-agent");
|
||||||
|
expect(gaps).toContain("super-agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds repeated patterns from lessons list", () => {
|
||||||
|
const agent = new IdeationAgent();
|
||||||
|
const lessons = [
|
||||||
|
{ topic: "testing", detail: "testing: tests are flaky" },
|
||||||
|
{ topic: "testing", detail: "testing: more test failures" },
|
||||||
|
{ topic: "build", detail: "build: CI broken" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const repeated = agent.findRepeatedPatterns(lessons);
|
||||||
|
expect(repeated).toContain("testing");
|
||||||
|
expect(repeated).not.toContain("build");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty ideas when no project files exist", () => {
|
||||||
|
const agent = new IdeationAgent();
|
||||||
|
const ideas = agent.mechanicalIdeate(tempDir);
|
||||||
|
|
||||||
|
expect(ideas).toEqual(expect.arrayContaining([]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent name is ideation-agent", () => {
|
||||||
|
const agent = new IdeationAgent();
|
||||||
|
expect(agent.name).toBe("ideation-agent");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
|
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 Idea {
|
||||||
|
source: "uncovered_requirement" | "repeated_lesson" | "gap_in_coverage" | "improvement_pattern";
|
||||||
|
title: string;
|
||||||
|
rationale: string;
|
||||||
|
confidence: number;
|
||||||
|
relatedReq?: string;
|
||||||
|
}
|
||||||
|
|
||||||
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. Output feeds directly into planning pipeline.";
|
||||||
@@ -8,6 +18,7 @@ export class IdeationAgent extends BaseAgent {
|
|||||||
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 +26,167 @@ export class IdeationAgent extends BaseAgent {
|
|||||||
);
|
);
|
||||||
return { ...result, duration_ms: Date.now() - start };
|
return { ...result, duration_ms: Date.now() - start };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ideas = this.mechanicalIdeate(context.project_path);
|
||||||
|
const output = this.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): Idea[] {
|
||||||
|
const ideas: Idea[] = [];
|
||||||
|
const uncoveredReqs = this.readUncoveredRequirements(projectPath);
|
||||||
|
const lessons = this.readRecentLessons(projectPath);
|
||||||
|
const repeated = this.findRepeatedPatterns(lessons);
|
||||||
|
const coverageGaps = this.identifyCoverageGaps(projectPath);
|
||||||
|
|
||||||
|
for (const req of uncoveredReqs) {
|
||||||
|
ideas.push({
|
||||||
|
source: "uncovered_requirement",
|
||||||
|
title: `Address uncovered requirement: ${req}`,
|
||||||
|
rationale: `Requirement ${req} has no corresponding implementation task.`,
|
||||||
|
confidence: 0.8,
|
||||||
|
relatedReq: req,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const topic of repeated) {
|
||||||
|
ideas.push({
|
||||||
|
source: "repeated_lesson",
|
||||||
|
title: `Investigate repeated lesson: ${topic}`,
|
||||||
|
rationale: `Topic "${topic}" appears in multiple commit lessons, indicating a systemic issue.`,
|
||||||
|
confidence: 0.7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const gap of coverageGaps) {
|
||||||
|
ideas.push({
|
||||||
|
source: "gap_in_coverage",
|
||||||
|
title: `Fill coverage gap: ${gap}`,
|
||||||
|
rationale: `Agent "${gap}" is claimed in PROJECT.md but not found in the agent registry.`,
|
||||||
|
confidence: 0.75,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.generateIdeas(uncoveredReqs, repeated, ideas);
|
||||||
|
|
||||||
|
return ideas;
|
||||||
|
}
|
||||||
|
|
||||||
|
readUncoveredRequirements(projectPath: string): string[] {
|
||||||
|
const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md");
|
||||||
|
if (!fs.existsSync(reqPath)) return [];
|
||||||
|
|
||||||
|
const content = fs.readFileSync(reqPath, "utf-8");
|
||||||
|
const reqIds: string[] = [];
|
||||||
|
const reqIdRegex = /REQ-(\d+)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = reqIdRegex.exec(content)) !== null) {
|
||||||
|
reqIds.push(`REQ-${match[1]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const planPath = path.join(projectPath, ".ciagent", "PLAN.md");
|
||||||
|
if (!fs.existsSync(planPath)) return reqIds;
|
||||||
|
|
||||||
|
const planContent = fs.readFileSync(planPath, "utf-8");
|
||||||
|
const coveredReqIds = new Set<string>();
|
||||||
|
const planRegex = /REQ-(\d+)/g;
|
||||||
|
let planMatch;
|
||||||
|
while ((planMatch = planRegex.exec(planContent)) !== null) {
|
||||||
|
coveredReqIds.add(`REQ-${planMatch[1]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reqIds.filter((id) => !coveredReqIds.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
readRecentLessons(projectPath: string): Array<{ topic: string; detail: string }> {
|
||||||
|
const lessons: Array<{ topic: string; detail: string }> = [];
|
||||||
|
try {
|
||||||
|
const { execSync } = require("node:child_process");
|
||||||
|
const log = execSync('git log --grep="lessons:" --format="%B" -50', {
|
||||||
|
cwd: projectPath,
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
const lessonsRegex = /lessons:\s*\n((?:\s+-\s+.+\n?)+)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = lessonsRegex.exec(log)) !== null) {
|
||||||
|
const items = match[1].split("\n").filter((l: string) => l.trim().startsWith("-"));
|
||||||
|
for (const item of items) {
|
||||||
|
const detail = item.replace(/^\s*-\s*/, "").trim();
|
||||||
|
const topic = detail.split(":")[0].trim().toLowerCase();
|
||||||
|
lessons.push({ topic, detail });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return lessons;
|
||||||
|
}
|
||||||
|
|
||||||
|
findRepeatedPatterns(lessons: Array<{ topic: string; detail: string }>): string[] {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
counts[lesson.topic] = (counts[lesson.topic] || 0) + 1;
|
||||||
|
}
|
||||||
|
return Object.entries(counts)
|
||||||
|
.filter(([, count]) => count > 1)
|
||||||
|
.map(([topic]) => topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateIdeas(uncoveredReqs: string[], repeated: string[], ideas: Idea[]): void {
|
||||||
|
const repeatedSet = new Set(repeated.map((r) => r.toLowerCase()));
|
||||||
|
for (const req of uncoveredReqs) {
|
||||||
|
for (const topic of repeated) {
|
||||||
|
if (req.toLowerCase().includes(topic) || topic.includes(req.toLowerCase())) {
|
||||||
|
ideas.push({
|
||||||
|
source: "improvement_pattern",
|
||||||
|
title: `Cross-reference: ${req} ↔ ${topic}`,
|
||||||
|
rationale: `Repeated lesson "${topic}" directly relates to uncovered requirement ${req}.`,
|
||||||
|
confidence: 0.85,
|
||||||
|
relatedReq: req,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identifyCoverageGaps(projectPath: string): string[] {
|
||||||
|
const projectMdPath = path.join(projectPath, ".ciagent", "PROJECT.md");
|
||||||
|
if (!fs.existsSync(projectMdPath)) return [];
|
||||||
|
|
||||||
|
const content = fs.readFileSync(projectMdPath, "utf-8");
|
||||||
|
const agentMentionRegex = /(?:agent|Agent):\s*(\S+)/g;
|
||||||
|
const mentionedAgents: string[] = [];
|
||||||
|
let match;
|
||||||
|
while ((match = agentMentionRegex.exec(content)) !== null) {
|
||||||
|
mentionedAgents.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentsDir = path.join(projectPath, "src", "agents");
|
||||||
|
if (!fs.existsSync(agentsDir)) return mentionedAgents;
|
||||||
|
|
||||||
|
const existingAgents = new Set(
|
||||||
|
fs.readdirSync(agentsDir)
|
||||||
|
.filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts") && !f.endsWith(".d.ts") && f !== "index.ts" && f !== "base.ts")
|
||||||
|
.map((f) => f.replace(".ts", ""))
|
||||||
|
);
|
||||||
|
|
||||||
|
return mentionedAgents.filter((a) => !existingAgents.has(a) && !existingAgents.has(a.replace(/-agent$/, "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatIdeas(ideas: Idea[]): string {
|
||||||
|
if (ideas.length === 0) return "No improvement ideas generated.";
|
||||||
|
const lines: string[] = ["Improvement Ideas:", ""];
|
||||||
|
for (const idea of ideas) {
|
||||||
|
lines.push(`[${idea.source}|${idea.confidence.toFixed(2)}] ${idea.title} — ${idea.rationale}${idea.relatedReq ? ` (req: ${idea.relatedReq})` : ""}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+101
-39
@@ -345,52 +345,82 @@ 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 gitContext = this.buildGitAgentContext(context);
|
||||||
const agent = getAgent(agentName);
|
const primaryAgentResult = await primaryAgent.execute(gitContext);
|
||||||
const gitContext = this.buildGitAgentContext(context);
|
primaryResult = primaryAgentResult;
|
||||||
|
if (Array.isArray(primaryAgentResult.artifacts_created)) {
|
||||||
|
allArtifacts.push(...primaryAgentResult.artifacts_created);
|
||||||
|
}
|
||||||
|
totalDecisions += primaryAgentResult.decisions;
|
||||||
|
totalEscalations += primaryAgentResult.escalations;
|
||||||
|
|
||||||
if (i === 0) {
|
if (!primaryAgentResult.success) {
|
||||||
const result = await agent.execute(gitContext);
|
this.warn(`Primary agent ${agentNames[0]} failed for ${stage}`);
|
||||||
primaryResult = result;
|
return {
|
||||||
if (Array.isArray(result.artifacts_created)) {
|
phase: this.pipelineState!.current_phase,
|
||||||
allArtifacts.push(...result.artifacts_created);
|
stage,
|
||||||
}
|
success: false,
|
||||||
totalDecisions += result.decisions;
|
artifacts_created: allArtifacts,
|
||||||
totalEscalations += result.escalations;
|
decisions_made: totalDecisions,
|
||||||
|
escalations_raised: totalEscalations,
|
||||||
|
duration_ms: Date.now() - stageStart,
|
||||||
|
error: primaryAgentResult.error || `Primary agent ${agentNames[0]} failed`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.success) {
|
if (agentNames.length > 1) {
|
||||||
this.warn(`Primary agent ${agentName} failed for ${stage}`);
|
if (this.config.parallelization?.enabled) {
|
||||||
return {
|
const reviewFactories = agentNames.slice(1).map((reviewAgentName) => {
|
||||||
phase: this.pipelineState!.current_phase,
|
return () => {
|
||||||
stage,
|
const agent = getAgent(reviewAgentName);
|
||||||
success: false,
|
const reviewContext: AgentContext = {
|
||||||
artifacts_created: allArtifacts,
|
...gitContext,
|
||||||
decisions_made: totalDecisions,
|
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)}`,
|
||||||
escalations_raised: totalEscalations,
|
};
|
||||||
duration_ms: Date.now() - stageStart,
|
return agent.execute(reviewContext);
|
||||||
error: result.error || `Primary agent ${agentName} failed`,
|
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
try {
|
for (let i = 1; i < agentNames.length; i++) {
|
||||||
const reviewContext: AgentContext = {
|
const reviewAgentName = agentNames[i];
|
||||||
...gitContext,
|
try {
|
||||||
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 reviewAgent = getAgent(reviewAgentName);
|
||||||
};
|
const reviewContext: AgentContext = {
|
||||||
const result = await agent.execute(reviewContext);
|
...gitContext,
|
||||||
if (Array.isArray(result.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)}`,
|
||||||
allArtifacts.push(...result.artifacts_created);
|
};
|
||||||
}
|
const result = await reviewAgent.execute(reviewContext);
|
||||||
totalDecisions += result.decisions;
|
if (Array.isArray(result.artifacts_created)) {
|
||||||
totalEscalations += result.escalations;
|
allArtifacts.push(...result.artifacts_created);
|
||||||
|
}
|
||||||
|
totalDecisions += result.decisions;
|
||||||
|
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) {
|
||||||
|
this.warn(`Review agent ${reviewAgentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
this.warn(`Review agent ${agentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -704,6 +734,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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-2
@@ -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";
|
||||||
@@ -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
@@ -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 };
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-5
@@ -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;
|
||||||
@@ -184,4 +215,6 @@ export function emptyBackendResult(error?: string): BackendResult {
|
|||||||
usage: emptyTokenUsage(),
|
usage: emptyTokenUsage(),
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ChatMessage, ChatCompletionResponse } from "./llm-base.js";
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,6 +132,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 +149,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: {
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const VERSION = "0.8.0";
|
export const VERSION = "0.9.0";
|
||||||
Reference in New Issue
Block a user