From a8b50f5109d9947e4431dd0906ca6631400a019f Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Sat, 30 May 2026 02:19:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(ci):=20v0.9.0=20=E2=80=94=20Distribution?= =?UTF-8?q?=20&=20Expansion=20milestone=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---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 --- .gitea/workflows/ci.yml | 19 ++ .gitea/workflows/publish.yml | 20 ++ AGENTS.md | 34 ++- README.md | 43 ++- package.json | 7 +- scripts/check-version.js | 27 ++ scripts/ensure-shebang.js | 23 ++ scripts/validate-pack.js | 55 ++++ src/agents/all-agents-mechanical.test.ts | 152 ++++++++++ src/agents/doc-verifier.test.ts | 167 +++++++++++ src/agents/doc-verifier.ts | 190 +++++++++++- src/agents/ideation-agent.test.ts | 77 +++++ src/agents/ideation-agent.ts | 170 ++++++++++- src/agents/orchestrator.ts | 140 ++++++--- src/agents/parallel-execution.test.ts | 217 ++++++++++++++ src/agents/phase-researcher.test.ts | 74 +++++ src/agents/phase-researcher.ts | 134 ++++++++- src/agents/plan-checker.test.ts | 107 +++++++ src/agents/plan-checker.ts | 157 +++++++++- src/agents/project-researcher.test.ts | 93 ++++++ src/agents/project-researcher.ts | 250 +++++++++++++++- src/agents/research-synthesizer.test.ts | 72 +++++ src/agents/research-synthesizer.ts | 121 +++++++- src/agents/roadmapper.test.ts | 77 +++++ src/agents/roadmapper.ts | 113 ++++++- src/agents/solution-writer.test.ts | 89 ++++++ src/agents/solution-writer.ts | 202 ++++++++++++- src/backends/anthropic.test.ts | 196 ++++++++++++ src/backends/anthropic.ts | 171 +++++++++++ src/backends/availability.test.ts | 1 + src/backends/backends.test.ts | 1 + src/backends/index.ts | 21 +- src/backends/llm-base.ts | 361 ++++++++++++++++++++++ src/backends/ollama-base.ts | 363 +---------------------- src/backends/openai.test.ts | 279 +++++++++++++++++ src/backends/openai.ts | 84 ++++++ src/backends/types.ts | 43 ++- src/e2e-v0.9.test.ts | 163 ++++++++++ src/types/config.ts | 15 + src/version.ts | 2 +- 40 files changed, 4075 insertions(+), 455 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/publish.yml create mode 100644 scripts/check-version.js create mode 100644 scripts/ensure-shebang.js create mode 100644 scripts/validate-pack.js create mode 100644 src/agents/all-agents-mechanical.test.ts create mode 100644 src/agents/doc-verifier.test.ts create mode 100644 src/agents/ideation-agent.test.ts create mode 100644 src/agents/parallel-execution.test.ts create mode 100644 src/agents/phase-researcher.test.ts create mode 100644 src/agents/plan-checker.test.ts create mode 100644 src/agents/project-researcher.test.ts create mode 100644 src/agents/research-synthesizer.test.ts create mode 100644 src/agents/roadmapper.test.ts create mode 100644 src/agents/solution-writer.test.ts create mode 100644 src/backends/anthropic.test.ts create mode 100644 src/backends/anthropic.ts create mode 100644 src/backends/llm-base.ts create mode 100644 src/backends/openai.test.ts create mode 100644 src/backends/openai.ts create mode 100644 src/e2e-v0.9.test.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4435381 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 \ No newline at end of file diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..6258070 --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -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 }} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index f961ed5..7076d44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,11 @@ src/ backends/ # Intelligence backend layer types.ts # IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection 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-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) index.ts # Backend registry + auto-detection 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) quality.ts # Layer 4: regex-based code quality checks (no multi-persona review yet) 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) ``` @@ -94,7 +96,8 @@ IntelligenceBackend (unified interface) ├── LLMBackend (CIAgent runs tool loop, provides tools, constructs prompts) │ ├── OllamaLocalBackend (localhost:11434, no auth) │ ├── 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) ├── OpencodeBackend (opencode --non-interactive) └── (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 - **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 -- **Per-command override**: `ciagent run --backend ollama-local` forces a specific backend +- **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 (options: opencode, openai, anthropic, ollama-local, ollama-cloud) - **Config**: `backend` section in `.ciagent/config.json` with provider, fallback, agent_backends, llm_backends ## Agent Modification Rules (from PRD) @@ -131,7 +134,7 @@ IntelligenceBackend (unified interface) - Test framework: Jest with ts-jest - Test file pattern: `**/*.test.ts` in `src/` - 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 - Module resolution in jest uses moduleNameMapper to strip `.js` extensions @@ -191,16 +194,19 @@ IntelligenceBackend (unified interface) ## 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) - **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 - **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) -- **`.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 +- **Verification layers**: All 4 layers implemented — structural, behavioral (test execution), security (STRIDE + CWE), quality (3-persona review) - **CLI**: All 11 commands wired up (`init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`) -- **Agent implementations**: Persona loaders that delegate to active backend. Fail honestly when no backend is available (no more fake success). -- **Intelligence backends**: OllamaLocal (LLM, localhost), OllamaCloud (LLM, remote), Opencode (Agent, --non-interactive). Auto-detection: opencode → ollama-local → ollama-cloud. -- **Tests**: 44 test suites, 454 tests covering types, config, decision-engine, escalation, clarify, commit-parser, commit-builder, git-context, git-branch, ciagent-files, all 4 verification layers, file utils, backends, tool-registry, agents (security-auditor, doc-writer, debugger, challenger, code-reviewer), zod validation, e2e \ No newline at end of file +- **Intelligence backends**: 5 options — OpenAI (LLM), Anthropic (LLM), OllamaLocal (LLM, localhost), OllamaCloud (LLM, remote), Opencode (Agent, --non-interactive). Auto-detection: opencode → openai → ollama-local → ollama-cloud → anthropic. +- **Tests**: 57 test suites, 527 tests covering types, config, decision-engine, escalation, clarify, commit-parser, commit-builder, git-context, git-branch, ciagent-files, all 4 verification layers, file utils, backends (ollama, openai, anthropic, opencode, tool-registry), agents (all 18 non-orchestrator), zod validation, e2e, parallel execution \ No newline at end of file diff --git a/README.md b/README.md index 8821c0e..c628edf 100644 --- a/README.md +++ b/README.md @@ -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. +## 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 From source (package not yet published to npm): @@ -38,6 +52,11 @@ ciagent run plan ciagent run execute 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 ciagent quick "Add authentication middleware" @@ -58,7 +77,7 @@ ciagent rollback 1 ciagent ship 1 ``` -## Git-Native Architecture (v0.2.0) +## Git-Native Architecture (v0.9.0) ### 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 | | tester | Integration/e2e tests | Detects and runs existing test files, never writes tests | | 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 | -| 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 1. **Structural**: File existence, import/export wiring, no stubs -2. **Behavioral**: Test infrastructure and requirement traceability (partially implemented — static analysis, no test generation yet) -3. **Security**: Regex-based threat pattern scanning with auto-disposition (partially implemented — no STRIDE analysis yet) -4. **Code Quality**: Regex-based code quality checks (partially implemented — no multi-persona review yet) +2. **Behavioral**: Test execution and requirement traceability — runs test framework, parses results, reports pass/fail per suite +3. **Security**: STRIDE threat pattern scanning with CWE mapping and confidence-based auto-disposition +4. **Code Quality**: 3-persona code review (security, performance, maintainability) with P0/P1/P2 findings ## Specification Format @@ -293,9 +321,8 @@ Each escalation is committed as an `escalation` type commit. Resolved escalation ## 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. -- **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 diff --git a/package.json b/package.json index 5da64ff..b7f642d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@continuous-intelligence/ciagent", - "version": "0.8.0", + "version": "0.9.0", "description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -19,7 +19,10 @@ "dev": "ts-node src/cli.ts", "typecheck": "tsc --noEmit", "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" }, "keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"], diff --git a/scripts/check-version.js b/scripts/check-version.js new file mode 100644 index 0000000..a8f461b --- /dev/null +++ b/scripts/check-version.js @@ -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); \ No newline at end of file diff --git a/scripts/ensure-shebang.js b/scripts/ensure-shebang.js new file mode 100644 index 0000000..382b7fc --- /dev/null +++ b/scripts/ensure-shebang.js @@ -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); \ No newline at end of file diff --git a/scripts/validate-pack.js b/scripts/validate-pack.js new file mode 100644 index 0000000..12bdd76 --- /dev/null +++ b/scripts/validate-pack.js @@ -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(); \ No newline at end of file diff --git a/src/agents/all-agents-mechanical.test.ts b/src/agents/all-agents-mechanical.test.ts new file mode 100644 index 0000000..db2dbaa --- /dev/null +++ b/src/agents/all-agents-mechanical.test.ts @@ -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; 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 = {}; + + 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([]); + }); +}); \ No newline at end of file diff --git a/src/agents/doc-verifier.test.ts b/src/agents/doc-verifier.test.ts new file mode 100644 index 0000000..d58bfff --- /dev/null +++ b/src/agents/doc-verifier.test.ts @@ -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); + } + }); +}); \ No newline at end of file diff --git a/src/agents/doc-verifier.ts b/src/agents/doc-verifier.ts index e2fbd9e..274ff90 100644 --- a/src/agents/doc-verifier.ts +++ b/src/agents/doc-verifier.ts @@ -1,13 +1,26 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; 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 { 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"; async execute(context: AgentContext): Promise { const start = Date.now(); this.log("Verifying documentation..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +28,183 @@ export class DocVerifierAgent extends BaseAgent { ); return { ...result, duration_ms: Date.now() - start }; } + + const findings = this.mechanicalDocVerify(context.project_path); + const output = this.formatFindings(findings); + return { - success: false, - output: "Documentation verification requires an intelligence backend.", + success: true, + output, artifacts_created: [], decisions: 0, escalations: 0, 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"); + } } \ No newline at end of file diff --git a/src/agents/ideation-agent.test.ts b/src/agents/ideation-agent.test.ts new file mode 100644 index 0000000..a2eb418 --- /dev/null +++ b/src/agents/ideation-agent.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/src/agents/ideation-agent.ts b/src/agents/ideation-agent.ts index 56a093d..4dad2de 100644 --- a/src/agents/ideation-agent.ts +++ b/src/agents/ideation-agent.ts @@ -1,5 +1,15 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; 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 { readonly name = "ideation-agent"; 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 { const start = Date.now(); this.log("Generating improvement ideas..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +26,167 @@ export class IdeationAgent extends BaseAgent { ); return { ...result, duration_ms: Date.now() - start }; } + + const ideas = this.mechanicalIdeate(context.project_path); + const output = this.formatIdeas(ideas); + return { - success: false, - output: "Ideation requires an intelligence backend.", + success: true, + output, artifacts_created: [], decisions: 0, escalations: 0, 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(); + 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 = {}; + 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"); + } } \ No newline at end of file diff --git a/src/agents/orchestrator.ts b/src/agents/orchestrator.ts index a7166c7..e7d9cb5 100644 --- a/src/agents/orchestrator.ts +++ b/src/agents/orchestrator.ts @@ -345,52 +345,82 @@ export class OrchestratorAgent extends BaseAgent { let totalEscalations = 0; let lastError: string | undefined; - for (let i = 0; i < agentNames.length; i++) { - const agentName = agentNames[i]; - const agent = getAgent(agentName); - const gitContext = this.buildGitAgentContext(context); + const primaryAgent = getAgent(agentNames[0]); + const gitContext = this.buildGitAgentContext(context); + const primaryAgentResult = await primaryAgent.execute(gitContext); + primaryResult = primaryAgentResult; + if (Array.isArray(primaryAgentResult.artifacts_created)) { + allArtifacts.push(...primaryAgentResult.artifacts_created); + } + totalDecisions += primaryAgentResult.decisions; + totalEscalations += primaryAgentResult.escalations; - if (i === 0) { - const result = await agent.execute(gitContext); - primaryResult = result; - if (Array.isArray(result.artifacts_created)) { - allArtifacts.push(...result.artifacts_created); - } - totalDecisions += result.decisions; - totalEscalations += result.escalations; + if (!primaryAgentResult.success) { + this.warn(`Primary agent ${agentNames[0]} failed for ${stage}`); + return { + phase: this.pipelineState!.current_phase, + stage, + success: false, + artifacts_created: allArtifacts, + decisions_made: totalDecisions, + escalations_raised: totalEscalations, + duration_ms: Date.now() - stageStart, + error: primaryAgentResult.error || `Primary agent ${agentNames[0]} failed`, + }; + } - if (!result.success) { - this.warn(`Primary agent ${agentName} failed for ${stage}`); - return { - phase: this.pipelineState!.current_phase, - stage, - success: false, - artifacts_created: allArtifacts, - decisions_made: totalDecisions, - escalations_raised: totalEscalations, - duration_ms: Date.now() - stageStart, - error: result.error || `Primary agent ${agentName} failed`, + if (agentNames.length > 1) { + if (this.config.parallelization?.enabled) { + const reviewFactories = agentNames.slice(1).map((reviewAgentName) => { + return () => { + const agent = getAgent(reviewAgentName); + const reviewContext: AgentContext = { + ...gitContext, + specification: `${context.specification}\n\nPrimary agent (${agentNames[0]}) completed. Review context:\n- Success: ${primaryResult!.success}\n- Output: ${primaryResult!.output}\n- Artifacts: ${Array.isArray(primaryResult!.artifacts_created) ? primaryResult!.artifacts_created.join(", ") : String(primaryResult!.artifacts_created)}`, + }; + return agent.execute(reviewContext); }; + }); + + const settled = await this.limitConcurrency(reviewFactories, this.config.parallelization?.max_concurrent_agents ?? 5); + for (let si = 0; si < settled.length; si++) { + const result = settled[si]; + if (result.status === "fulfilled") { + const agentResult = result.value; + if (Array.isArray(agentResult.artifacts_created)) allArtifacts.push(...agentResult.artifacts_created); + totalDecisions += agentResult.decisions; + totalEscalations += agentResult.escalations; + if (!agentResult.success) { + this.warn(`Review agent reported issues: ${agentResult.error || "unspecified"}`); + lastError = agentResult.error; + } + } else { + this.warn(`Review agent failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`); + } } } else { - try { - const reviewContext: AgentContext = { - ...gitContext, - specification: `${context.specification}\n\nPrimary agent (${agentNames[0]}) completed. Review context:\n- Success: ${primaryResult!.success}\n- Output: ${primaryResult!.output}\n- Artifacts: ${Array.isArray(primaryResult!.artifacts_created) ? primaryResult!.artifacts_created.join(", ") : String(primaryResult!.artifacts_created)}`, - }; - const result = await agent.execute(reviewContext); - if (Array.isArray(result.artifacts_created)) { - allArtifacts.push(...result.artifacts_created); - } - totalDecisions += result.decisions; - totalEscalations += result.escalations; + for (let i = 1; i < agentNames.length; i++) { + const reviewAgentName = agentNames[i]; + try { + const reviewAgent = getAgent(reviewAgentName); + const reviewContext: AgentContext = { + ...gitContext, + specification: `${context.specification}\n\nPrimary agent (${agentNames[0]}) completed. Review context:\n- Success: ${primaryResult!.success}\n- Output: ${primaryResult!.output}\n- Artifacts: ${Array.isArray(primaryResult!.artifacts_created) ? primaryResult!.artifacts_created.join(", ") : String(primaryResult!.artifacts_created)}`, + }; + const result = await reviewAgent.execute(reviewContext); + if (Array.isArray(result.artifacts_created)) { + allArtifacts.push(...result.artifacts_created); + } + totalDecisions += result.decisions; + totalEscalations += result.escalations; - if (!result.success) { - this.warn(`Review agent ${agentName} reported issues for ${stage}: ${result.error || "unspecified"}`); - lastError = result.error; + if (!result.success) { + this.warn(`Review agent ${reviewAgentName} reported issues for ${stage}: ${result.error || "unspecified"}`); + 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( + factories: Array<() => Promise>, + maxConcurrency: number + ): Promise[]> { + if (factories.length === 0) { + return []; + } + + if (maxConcurrency <= 0 || maxConcurrency >= factories.length) { + return Promise.allSettled(factories.map((f) => f())); + } + + const results: Array | 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[]; + } + private generateCompletionReport(): string { const lines: string[] = [ "# CIAgent Completion Report", diff --git a/src/agents/parallel-execution.test.ts b/src/agents/parallel-execution.test.ts new file mode 100644 index 0000000..29465ef --- /dev/null +++ b/src/agents/parallel-execution.test.ts @@ -0,0 +1,217 @@ +async function limitConcurrency( + factories: Array<() => Promise>, + maxConcurrency: number +): Promise[]> { + if (factories.length === 0) { + return []; + } + + if (maxConcurrency <= 0 || maxConcurrency >= factories.length) { + return Promise.allSettled(factories.map((f) => f())); + } + + const results: Array | 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[]; +} + +function delay(ms: number): Promise { + 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((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); + }); + }); +}); \ No newline at end of file diff --git a/src/agents/phase-researcher.test.ts b/src/agents/phase-researcher.test.ts new file mode 100644 index 0000000..e7275f2 --- /dev/null +++ b/src/agents/phase-researcher.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/src/agents/phase-researcher.ts b/src/agents/phase-researcher.ts index 52f9197..bf49335 100644 --- a/src/agents/phase-researcher.ts +++ b/src/agents/phase-researcher.ts @@ -1,5 +1,14 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; 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 { readonly name = "phase-researcher"; readonly description = "Researches how to implement a specific phase well."; @@ -8,6 +17,7 @@ export class PhaseResearcherAgent extends BaseAgent { async execute(context: AgentContext): Promise { const start = Date.now(); this.log("Researching phase implementation..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +25,130 @@ export class PhaseResearcherAgent extends BaseAgent { ); return { ...result, duration_ms: Date.now() - start }; } + + const result = this.mechanicalPhaseResearch(context.project_path, context.phase); + const output = this.formatResult(result); + return { - success: false, - output: "Phase research requires an intelligence backend.", + success: true, + output, artifacts_created: [], decisions: 0, - escalations: 0, + escalations: result.risks.filter((r) => r.severity === "high").length, 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 = {}; + 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"); + } } \ No newline at end of file diff --git a/src/agents/plan-checker.test.ts b/src/agents/plan-checker.test.ts new file mode 100644 index 0000000..d135aa8 --- /dev/null +++ b/src/agents/plan-checker.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/src/agents/plan-checker.ts b/src/agents/plan-checker.ts index ee4023c..6e3a01c 100644 --- a/src/agents/plan-checker.ts +++ b/src/agents/plan-checker.ts @@ -1,5 +1,16 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; 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 { readonly name = "plan-checker"; 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 { const start = Date.now(); this.log("Checking plan quality..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +27,151 @@ export class PlanCheckerAgent extends BaseAgent { ); 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 { - success: false, - output: "Plan checking requires an intelligence backend.", + success: p0Count === 0, + output, artifacts_created: [], decisions: 0, - escalations: 0, + escalations: p0Count, 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(); + let reqMatch; + while ((reqMatch = reqIdRegex.exec(reqContent)) !== null) { + requirements.add(`REQ-${reqMatch[1]}`); + } + + const planReqIdRegex = /REQ-(\d+)/g; + const coveredReqs = new Set(); + 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"); + } } \ No newline at end of file diff --git a/src/agents/project-researcher.test.ts b/src/agents/project-researcher.test.ts new file mode 100644 index 0000000..fb2b658 --- /dev/null +++ b/src/agents/project-researcher.test.ts @@ -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).target).toBe("ES2022"); + expect((tsconfig.compilerOptions as Record).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"); + }); +}); \ No newline at end of file diff --git a/src/agents/project-researcher.ts b/src/agents/project-researcher.ts index 5a01675..06b6d1b 100644 --- a/src/agents/project-researcher.ts +++ b/src/agents/project-researcher.ts @@ -1,5 +1,57 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; 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 = { + 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 = { + graphql: ["graphql", "apollo", "@apollo"], + rest: ["express", "fastify", "restana"], + grpc: ["grpc", "@grpc"], + websocket: ["ws", "socket.io"], +}; + +const PATTERN_PATTERNS: Record = { + 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 = { + typescript: ["typescript"], + eslint: ["eslint"], + prettier: ["prettier"], + webpack: ["webpack"], + vite: ["vite"], + rollup: ["rollup"], + esbuild: ["esbuild"], + docker: [], + ci_cd: [], +}; + export class ProjectResearcherAgent extends BaseAgent { readonly name = "project-researcher"; readonly description = "Researches the domain ecosystem for a new project."; @@ -8,6 +60,7 @@ export class ProjectResearcherAgent extends BaseAgent { async execute(context: AgentContext): Promise { const start = Date.now(); this.log("Researching project domain ecosystem..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +68,203 @@ export class ProjectResearcherAgent extends BaseAgent { ); return { ...result, duration_ms: Date.now() - start }; } + + const summary = this.mechanicalProjectResearch(context.project_path); + const output = this.formatSummary(summary); + return { - success: false, - output: "Project research requires an intelligence backend.", + success: true, + output, artifacts_created: [], - decisions: 0, + decisions: summary.technologyDecisions.length, escalations: 0, 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 { + 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 { + 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, + tsconfig: Record, + techDecisions: Array<{ id: string; decision: string; confidence: number }> + ): EcosystemSummary { + const allDeps: string[] = []; + const deps = pkg.dependencies as Record | undefined; + const devDeps = pkg.devDependencies as Record | 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 | 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 | 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 | 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"); + } } \ No newline at end of file diff --git a/src/agents/research-synthesizer.test.ts b/src/agents/research-synthesizer.test.ts new file mode 100644 index 0000000..0e249fa --- /dev/null +++ b/src/agents/research-synthesizer.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/src/agents/research-synthesizer.ts b/src/agents/research-synthesizer.ts index 96cb34b..ae7cc74 100644 --- a/src/agents/research-synthesizer.ts +++ b/src/agents/research-synthesizer.ts @@ -1,5 +1,20 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; 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 { readonly name = "research-synthesizer"; 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 { const start = Date.now(); this.log("Synthesizing research..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +31,113 @@ export class ResearchSynthesizerAgent extends BaseAgent { ); return { ...result, duration_ms: Date.now() - start }; } + + const findings = this.mechanicalSynthesize(context.project_path); + const output = this.formatFindings(findings); + return { - success: false, - output: "Research synthesis requires an intelligence backend.", + success: true, + output, artifacts_created: [], decisions: 0, escalations: 0, 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 = 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"); + } } \ No newline at end of file diff --git a/src/agents/roadmapper.test.ts b/src/agents/roadmapper.test.ts new file mode 100644 index 0000000..b866b7f --- /dev/null +++ b/src/agents/roadmapper.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/src/agents/roadmapper.ts b/src/agents/roadmapper.ts index 8e6e265..1e1513d 100644 --- a/src/agents/roadmapper.ts +++ b/src/agents/roadmapper.ts @@ -1,5 +1,16 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; 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 { readonly name = "roadmapper"; readonly description = "Creates and maintains project roadmaps."; @@ -8,6 +19,7 @@ export class RoadmapperAgent extends BaseAgent { async execute(context: AgentContext): Promise { const start = Date.now(); this.log("Creating roadmap..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +27,109 @@ export class RoadmapperAgent extends BaseAgent { ); return { ...result, duration_ms: Date.now() - start }; } + + const phases = this.mechanicalRoadmapGenerate(context.project_path); + const output = this.formatPhases(phases); + return { - success: false, - output: "Roadmap creation requires an intelligence backend.", + success: true, + output, artifacts_created: [], decisions: 0, escalations: 0, 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> { + const groups: Record> = {}; + 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>): 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"); + } } \ No newline at end of file diff --git a/src/agents/solution-writer.test.ts b/src/agents/solution-writer.test.ts new file mode 100644 index 0000000..958f693 --- /dev/null +++ b/src/agents/solution-writer.test.ts @@ -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"); + }); +}); \ No newline at end of file diff --git a/src/agents/solution-writer.ts b/src/agents/solution-writer.ts index 4f9cbce..6cb120f 100644 --- a/src/agents/solution-writer.ts +++ b/src/agents/solution-writer.ts @@ -1,5 +1,12 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; import { BaseAgent, AgentContext, AgentResult } from "./base.js"; +interface SolutionSection { + title: string; + content: string; +} + export class SolutionWriterAgent extends BaseAgent { readonly name = "solution-writer"; readonly description = "Produces structured solution documents."; @@ -8,6 +15,7 @@ export class SolutionWriterAgent extends BaseAgent { async execute(context: AgentContext): Promise { const start = Date.now(); this.log("Writing solution document..."); + if (context.backend) { const result = await this.executeViaBackend( context, @@ -15,14 +23,202 @@ export class SolutionWriterAgent extends BaseAgent { ); return { ...result, duration_ms: Date.now() - start }; } + + const document = this.mechanicalSolutionWrite(context.project_path); + return { - success: false, - output: "Solution writing requires an intelligence backend.", + success: true, + output: document, artifacts_created: [], decisions: 0, escalations: 0, 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"); + } } \ No newline at end of file diff --git a/src/backends/anthropic.test.ts b/src/backends/anthropic.test.ts new file mode 100644 index 0000000..ea6c430 --- /dev/null +++ b/src/backends/anthropic.test.ts @@ -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; 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, status = 200): void { + globalThis.fetch = ((url: string, init: RequestInit) => { + fetchCalls.push({ + url, + headers: (init.headers as Record) || {}, + 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 { + 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"); + }); + }); +}); \ No newline at end of file diff --git a/src/backends/anthropic.ts b/src/backends/anthropic.ts new file mode 100644 index 0000000..a2fd6e4 --- /dev/null +++ b/src/backends/anthropic.ts @@ -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 { + 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 { + return []; + } + + protected async callModel( + messages: ChatMessage[], + model: string, + toolRegistry: ToolRegistry + ): Promise { + 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 = { + "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).function as Record; + return { + name: fn.name, + description: fn.description, + input_schema: fn.parameters, + }; + }); + + const body: Record = { + 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; + return this.translateResponse(anthropicResponse); + } + + private translateResponse(response: Record): ChatCompletionResponse { + const content = (response.content as Array>) || []; + 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), + }, + }; + } +} \ No newline at end of file diff --git a/src/backends/availability.test.ts b/src/backends/availability.test.ts index 4185fe6..0ae3617 100644 --- a/src/backends/availability.test.ts +++ b/src/backends/availability.test.ts @@ -96,6 +96,7 @@ describe("Backend Availability Detection", () => { it("contains installation hints", () => { const err = new BackendUnavailableError("auto"); expect(err.message).toContain("opencode"); + expect(err.message).toContain("OpenAI"); expect(err.message).toContain("Ollama"); expect(err.message).toContain("OLLAMA_CLOUD_API_KEY"); }); diff --git a/src/backends/backends.test.ts b/src/backends/backends.test.ts index 4285bbf..14e7d05 100644 --- a/src/backends/backends.test.ts +++ b/src/backends/backends.test.ts @@ -54,6 +54,7 @@ describe("DEFAULT_BACKEND_CONFIG", () => { }); 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-cloud"]).toBeDefined(); }); diff --git a/src/backends/index.ts b/src/backends/index.ts index 150e406..8959896 100644 --- a/src/backends/index.ts +++ b/src/backends/index.ts @@ -1,12 +1,16 @@ import { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js"; import { OpencodeBackend } from "./opencode.js"; +import { OpenAIBackend } from "./openai.js"; import { OllamaLocalBackend } from "./ollama-local.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", + "openai", "ollama-local", "ollama-cloud", + "anthropic", ]; export function createBackend( @@ -16,10 +20,20 @@ export function createBackend( switch (name) { case "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": return new OllamaLocalBackend(config.llm_backends["ollama-local"]); case "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: throw new BackendUnavailableError(name); } @@ -49,7 +63,10 @@ export async function resolveBackend( } 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 { OpencodeBackend } from "./opencode.js"; +export { OpenAIBackend } from "./openai.js"; export { OllamaLocalBackend } from "./ollama-local.js"; -export { OllamaCloudBackend } from "./ollama-cloud.js"; \ No newline at end of file +export { OllamaCloudBackend } from "./ollama-cloud.js"; +export { AnthropicBackend } from "./anthropic.js"; \ No newline at end of file diff --git a/src/backends/llm-base.ts b/src/backends/llm-base.ts new file mode 100644 index 0000000..3263e5c --- /dev/null +++ b/src/backends/llm-base.ts @@ -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 = { + 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> | null = null; + + constructor(config: LLMBackendConfig | undefined) { + this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" }; + this.projectPath = process.cwd(); + } + + abstract isAvailable(): Promise; + + async execute(request: BackendRequest): Promise { + 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 { + return this.callModel(messages, model, new ToolRegistry(this.projectPath)); + } + + protected definitionsToOpenAISchema(definitions: ToolDefinition[]): Array> { + return definitions.map((def) => ({ + type: "function", + function: { + name: def.name, + description: def.description, + parameters: def.parameters, + }, + })); + } + + protected getActiveToolSchema(toolRegistry: ToolRegistry): Array> { + return this.filteredToolSchema || toolRegistry.getOpenAIToolSchema(); + } + + protected abstract callModel( + messages: ChatMessage[], + model: string, + toolRegistry: ToolRegistry + ): Promise; + + protected abstract resolveModel(): string; + + protected abstract fetchAvailableModels(): Promise; + + 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 => !!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 => !!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 => !!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 || ""), + })); + } +} \ No newline at end of file diff --git a/src/backends/ollama-base.ts b/src/backends/ollama-base.ts index 6b37745..61e6952 100644 --- a/src/backends/ollama-base.ts +++ b/src/backends/ollama-base.ts @@ -1,335 +1,11 @@ -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 = { - 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> | null = null; +import { LLMBaseBackend, ChatMessage, ChatCompletionResponse } from "./llm-base.js"; +import { LLMBackendConfig } from "./types.js"; +import { ModelProfile } from "../types/config.js"; +import { ToolRegistry } from "./tool-registry.js"; +export abstract class OllamaBaseBackend extends LLMBaseBackend { constructor(config: LLMBackendConfig | undefined) { - this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" }; - this.projectPath = process.cwd(); - } - - abstract isAvailable(): Promise; - - async execute(request: BackendRequest): Promise { - 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 { - return this.callModel(messages, model, new ToolRegistry(this.projectPath)); - } - - protected definitionsToOpenAISchema(definitions: ToolDefinition[]): Array> { - return definitions.map((def) => ({ - type: "function", - function: { - name: def.name, - description: def.description, - parameters: def.parameters, - }, - })); - } - - protected getActiveToolSchema(toolRegistry: ToolRegistry): Array> { - return this.filteredToolSchema || toolRegistry.getOpenAIToolSchema(); - } - - protected abstract callModel( - messages: OllamaMessage[], - model: string, - toolRegistry: ToolRegistry - ): Promise; - - 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 => !!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 => !!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 => !!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 || ""), - })); + super(config || { base_url: "http://localhost:11434", model_profile: "balanced" }); } protected modelProfileToModel(profile: ModelProfile, availableModels: string[]): string { @@ -359,29 +35,4 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend { } } -interface OllamaMessage { - 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 }; \ No newline at end of file +export { ChatMessage as OllamaMessage, ChatCompletionResponse as OllamaChatResponse }; \ No newline at end of file diff --git a/src/backends/openai.test.ts b/src/backends/openai.test.ts new file mode 100644 index 0000000..5ff5353 --- /dev/null +++ b/src/backends/openai.test.ts @@ -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; 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) || {}, + 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(); + }); + }); +}); \ No newline at end of file diff --git a/src/backends/openai.ts b/src/backends/openai.ts new file mode 100644 index 0000000..40a8fca --- /dev/null +++ b/src/backends/openai.ts @@ -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 { + 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 { + return []; + } + + protected async callModel( + messages: ChatMessage[], + model: string, + toolRegistry: ToolRegistry + ): Promise { + 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 = { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }; + if (this.openaiConfig.organization) { + headers["OpenAI-Organization"] = this.openaiConfig.organization; + } + + const body: Record = { + model, + messages: messages.map((m) => { + const msg: Record = { 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; + } +} \ No newline at end of file diff --git a/src/backends/types.ts b/src/backends/types.ts index a82e2f6..6fed77b 100644 --- a/src/backends/types.ts +++ b/src/backends/types.ts @@ -115,20 +115,34 @@ export interface OllamaCloudConfig extends LLMBackendConfig { 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 { enabled: boolean; executable?: string; } export interface BackendConfigSection { - provider: "auto" | "opencode" | "ollama-local" | "ollama-cloud"; - fallback?: "opencode" | "ollama-local" | "ollama-cloud"; + provider: "auto" | "opencode" | "openai" | "ollama-local" | "ollama-cloud" | "anthropic"; + fallback?: "opencode" | "openai" | "ollama-local" | "ollama-cloud" | "anthropic"; agent_backends: { opencode?: OpencodeBackendConfig; }; llm_backends: { + "openai"?: OpenAIConfig; "ollama-local"?: OllamaLocalConfig; "ollama-cloud"?: OllamaCloudConfig; + "anthropic"?: AnthropicConfig; }; } @@ -138,6 +152,13 @@ export const DEFAULT_BACKEND_CONFIG: BackendConfigSection = { opencode: { enabled: true }, }, 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": { base_url: "http://localhost:11434", model_profile: "balanced", @@ -148,6 +169,14 @@ export const DEFAULT_BACKEND_CONFIG: BackendConfigSection = { model_profile: "quality", 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}. ` + `Configure one of:\n` + ` 1. Install opencode: npm i -g opencode\n` + - ` 2. Run Ollama locally: ollama serve\n` + - ` 3. Set OLLAMA_CLOUD_API_KEY for remote inference` + ` 2. Set OPENAI_API_KEY for OpenAI API access\n` + + ` 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.backendName = backendName; @@ -184,4 +215,6 @@ export function emptyBackendResult(error?: string): BackendResult { usage: emptyTokenUsage(), error, }; -} \ No newline at end of file +} + +export { ChatMessage, ChatCompletionResponse } from "./llm-base.js"; \ No newline at end of file diff --git a/src/e2e-v0.9.test.ts b/src/e2e-v0.9.test.ts new file mode 100644 index 0000000..a9766b2 --- /dev/null +++ b/src/e2e-v0.9.test.ts @@ -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 { + return true; + } + + async execute(request: BackendRequest): Promise { + 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"); + }); +}); \ No newline at end of file diff --git a/src/types/config.ts b/src/types/config.ts index 83dcb05..3c7aaba 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -132,6 +132,13 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = { opencode: { enabled: true }, }, 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": { base_url: "http://localhost:11434", model_profile: "balanced", @@ -142,6 +149,14 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = { model_profile: "quality", 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: { diff --git a/src/version.ts b/src/version.ts index 6c8c568..25206c9 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.8.0"; \ No newline at end of file +export const VERSION = "0.9.0"; \ No newline at end of file