Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b33431c1a6 | |||
| 5753e2dc96 | |||
| 815c928a43 | |||
| a82926a22e | |||
| fb3f1df13e | |||
| 7a20784c87 | |||
| 940b85bfae |
@@ -15,10 +15,18 @@ CI (Continuous Intelligence) is a fully autonomous AI-driven software engineerin
|
||||
|
||||
```
|
||||
src/
|
||||
agents/ # 18 agent implementations (all extend BaseAgent)
|
||||
agents/ # 18 agent implementations (persona loaders delegating to backends)
|
||||
backends/ # Intelligence backend layer
|
||||
types.ts # IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection
|
||||
tool-registry.ts # CI-owned tool implementations (readFile, writeFile, editFile, runBash, glob, grep)
|
||||
ollama-base.ts # Abstract base for Ollama backends (shared tool loop, prompt construction)
|
||||
ollama-local.ts # OllamaLocalBackend (localhost:11434)
|
||||
ollama-cloud.ts # OllamaCloudBackend (remote endpoint, auth, rate limiting)
|
||||
opencode.ts # OpencodeBackend (shells out to opencode --non-interactive)
|
||||
index.ts # Backend registry + auto-detection
|
||||
cli/ # Commander.js CLI (commands.ts, index.ts)
|
||||
core/ # Core engine components
|
||||
artifacts.ts # Legacy .planning/ artifact management (retained for backward compat)
|
||||
artifacts.ts # Legacy .ci/ artifact management (retained for backward compat)
|
||||
audit.ts # Legacy audit trail in .ci/audit/ (retained for backward compat)
|
||||
ci-files.ts # .ci/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.)
|
||||
clarify.ts # Clarify phase: question generation, default acceptance
|
||||
@@ -32,7 +40,7 @@ src/
|
||||
git-context.ts # Project state reconstruction from git log + branches
|
||||
types/ # Type definitions
|
||||
commit-meta.ts # CiMetadata, CommitDecision, CommitEscalation, ParsedCiCommit
|
||||
config.ts # CIConfig, AutonomyLevel, ModelProfile, DEFAULT_CI_CONFIG
|
||||
config.ts # CIConfig, AutonomyLevel, ModelProfile, DEFAULT_CI_CONFIG (includes backend)
|
||||
decisions.ts # Decision, ConfidenceLevel, DecisionCategory
|
||||
escalation.ts # Escalation, EscalationType, EscalationResolution
|
||||
clarify.ts # ClarifyQuestion, ClarifyResult
|
||||
@@ -41,11 +49,11 @@ src/
|
||||
utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON)
|
||||
verification/ # 4-layer verification pipeline
|
||||
structural.ts # Layer 1: file existence, imports wired, no stubs
|
||||
behavioral.ts # Layer 2: test generation and execution (stub)
|
||||
security.ts # Layer 3: STRIDE threat analysis (stub)
|
||||
quality.ts # Layer 4: multi-persona code review (stub)
|
||||
behavioral.ts # Layer 2: test infrastructure checks (static analysis, no test generation yet)
|
||||
security.ts # Layer 3: regex-based threat pattern scanning (no STRIDE analysis yet)
|
||||
quality.ts # Layer 4: regex-based code quality checks (no multi-persona review yet)
|
||||
index.ts # Public API exports
|
||||
version.ts # VERSION = "0.2.0"
|
||||
version.ts # VERSION = "0.4.0"
|
||||
templates/ # Template files (config.json, DECISIONS.md, specification.md)
|
||||
```
|
||||
|
||||
@@ -54,7 +62,7 @@ templates/ # Template files (config.json, DECISIONS.md, specification.md
|
||||
- **Autonomy levels**: `full` (no HITL after clarify), `supervised` (escalate on gates + verification failures), `guided` (escalate on every decision gate)
|
||||
- **Decision confidence thresholds**: High (>0.85) auto-decide and log; Medium (0.60–0.85) auto-decide with assumption logging; Low (<0.60) escalate to human
|
||||
- **Escalation timeout**: Default 5 minutes, then auto-proceeds with recommended option. Set to `0` to require human, `-1` to always auto-proceed
|
||||
- **18 agents** inherited from Learnship, all re-prompted for autonomous operation. OrchestratorAgent is CI-specific
|
||||
- **18 agents** purpose-built for CI, all configured for autonomous operation. OrchestratorAgent is CI-specific
|
||||
- **Git-native context**: The git log IS the project memory. Agent's first impulse to gather context is `git log` + `git branch`, not file reads. Dynamic state (decisions, escalations, lessons, compounding) lives in `---ci---` YAML blocks in commit messages. `.ci/` holds only long-lived reference docs (PROJECT.md, ARCHITECTURE.md, ROADMAP.md, REQUIREMENTS.md, config.json).
|
||||
- **Artifact compatibility**: CI no longer writes `.planning/` schema. Dynamic state is derived from git history. `.ci/` files follow a CI-native schema.
|
||||
|
||||
@@ -62,7 +70,7 @@ templates/ # Template files (config.json, DECISIONS.md, specification.md
|
||||
|
||||
- **Language**: TypeScript with ES2022 target, Node16 modules
|
||||
- **Module resolution**: Node16 style with `.js` extensions in imports
|
||||
- **Agent pattern**: All agents extend `BaseAgent` with `name`, `description`, and `execute(context: AgentContext): Promise<AgentResult>`
|
||||
- **Agent pattern**: All agents extend `BaseAgent` with `name` (AgentName), `description`, `workflow`, and `execute(context: AgentContext): Promise<AgentResult>`. Agents delegate to `context.backend` when available, fail honestly when not.
|
||||
- **No runtime validation library**: Uses plain TypeScript types, not Zod schemas (Zod is a dependency but types are hand-defined)
|
||||
- **File I/O**: Use `src/utils/file.ts` helpers (`writeFile`, `readFile`, `ensureDir`, `readJSON`, `writeJSON`) instead of raw `fs` calls in agent/business logic
|
||||
- **Config**: `CIConfig` type and `DEFAULT_CI_CONFIG` in `src/types/config.ts` — always merge partial configs with defaults
|
||||
@@ -77,7 +85,26 @@ templates/ # Template files (config.json, DECISIONS.md, specification.md
|
||||
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → VERIFY → COMPLETE
|
||||
```
|
||||
|
||||
Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator iterates through `STAGE_ORDER` and collects `PhaseResult` for each.
|
||||
Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator delegates intelligent stages (research, plan, execute, verify) to specialized agents via `context.backend` when available, falling back to mechanical execution when no backend is configured. Mechanical stages (specify, clarify, complete) are always handled by the orchestrator directly.
|
||||
|
||||
## Intelligence Backend Architecture
|
||||
|
||||
```
|
||||
IntelligenceBackend (unified interface)
|
||||
├── LLMBackend (CI runs tool loop, provides tools, constructs prompts)
|
||||
│ ├── OllamaLocalBackend (localhost:11434, no auth)
|
||||
│ ├── OllamaCloudBackend (remote endpoint, API key, rate limits)
|
||||
│ └── (future: OpenAI, Anthropic, Gemini, etc.)
|
||||
└── AgentBackend (agent runs own tool loop, CI sends request)
|
||||
├── OpencodeBackend (opencode --non-interactive)
|
||||
└── (future: Codex, Claude Code, Hermes, etc.)
|
||||
```
|
||||
|
||||
- **LLM backends**: CI 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**: CI 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**: `ci run --backend ollama-local` forces a specific backend
|
||||
- **Config**: `backend` section in `.ci/config.json` with provider, fallback, agent_backends, llm_backends
|
||||
|
||||
## Agent Modification Rules (from PRD)
|
||||
|
||||
@@ -95,9 +122,9 @@ Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator i
|
||||
## Verification Layers
|
||||
|
||||
1. **Structural**: Files exist, imports wired, no stubs/TODOs
|
||||
2. **Behavioral**: Generate and run automated tests for must-haves (currently stub)
|
||||
3. **Security**: STRIDE analysis with auto-disposition (currently stub)
|
||||
4. **Code Quality**: Multi-persona review with P0 auto-fix (currently stub)
|
||||
2. **Behavioral**: Check test infrastructure and requirement traceability (static analysis — test generation not yet implemented)
|
||||
3. **Security**: Regex-based threat pattern scanning with auto-disposition (STRIDE analysis not yet implemented)
|
||||
4. **Code Quality**: Regex-based code quality checks (multi-persona review not yet implemented)
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -164,7 +191,7 @@ Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator i
|
||||
|
||||
## Current State
|
||||
|
||||
- **v0.2.0**: Git-native architecture — project memory lives in git log, not `.planning/` files
|
||||
- **v0.4.0**: Backends module (OllamaLocal, OllamaCloud, Opencode), learnship references removed, verification layers migrated from .planning/ to .ci/
|
||||
- **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), ci-files (`.ci/` long-lived reference file management)
|
||||
- **Commit schema**: Every CI-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
|
||||
@@ -174,5 +201,6 @@ Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator i
|
||||
- **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 (STRIDE), quality
|
||||
- **CLI**: All 11 commands wired up (`init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`)
|
||||
- **Agent implementations**: Stub agents return success immediately. Real LLM-based agent implementations are needed for research, planning, execution, verification, etc.
|
||||
- **Tests**: 25 test suites, 218 tests covering types, config, decision-engine, escalation, clarify, commit-parser, commit-builder, git-context, git-branch, ci-files, all 4 verification layers, file utils
|
||||
- **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**: 27 test suites covering types, config, decision-engine, escalation, clarify, commit-parser, commit-builder, git-context, git-branch, ci-files, all 4 verification layers, file utils, backends, tool-registry
|
||||
@@ -300,10 +300,10 @@ Each escalation is committed as an `escalation` type commit. Resolved escalation
|
||||
|
||||
| Dimension | Learnship | CI |
|
||||
|-----------|-----------|-----|
|
||||
| Project memory | `.planning/` directory files | Git log + `---ci---` commit blocks |
|
||||
| Audit trail | `.ci/audit/*.json` files | `git log --grep="decisions:"` |
|
||||
| State management | `STATE.md` + `STATE.md.json` | Reconstructed from git on demand |
|
||||
| Phase discovery | Read `.planning/phases/` directory | `git branch -a \| grep phase/` |
|
||||
| Project memory | `.planning/` directory files (legacy) | Git log + `---ci---` commit blocks |
|
||||
| Audit trail | `.ci/audit/*.json` files (legacy) | `git log --grep="decisions:"` |
|
||||
| State management | `STATE.md` + `STATE.md.json` (legacy) | Reconstructed from git on demand |
|
||||
| Phase discovery | Read `.planning/phases/` directory (legacy) | `git branch -a \| grep phase/` |
|
||||
| Human Interactions | 19+/lifecycle | 1-2/lifecycle |
|
||||
| Decision Making | Human decides, agent implements | Agent decides, human reviews post-hoc |
|
||||
| Verification | Human UAT | Automated tests + escalation |
|
||||
|
||||
@@ -11,7 +11,7 @@ tools:
|
||||
<role>
|
||||
You are a CI challenger. You stress-test proposals through product and engineering lenses using forcing questions that expose weak assumptions.
|
||||
|
||||
Unlike learnship, CI challengers produce binding verdicts. Only escalate when confidence < 0.60. If confident the proposal is sound, it proceeds. If confident it needs rework, it is sent back.
|
||||
CI challengers produce binding verdicts. Only escalate when confidence < 0.60. If confident the proposal is sound, it proceeds. If confident it needs rework, it is sent back.
|
||||
|
||||
**CRITICAL: Mandatory Initial Read**
|
||||
If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to load every file listed there before performing any other actions.
|
||||
|
||||
@@ -12,7 +12,7 @@ tools:
|
||||
<role>
|
||||
You are a CI code reviewer. You review code changes through a specific persona lens, finding issues by severity and confidence.
|
||||
|
||||
Unlike learnship, CI code reviewers auto-apply P0 fixes. P1+ issues are flagged for post-hoc review via `git log --grep="review"`.
|
||||
CI code reviewers auto-apply P0 fixes. P1+ issues are flagged for post-hoc review via `git log --grep="review"`.
|
||||
|
||||
**CRITICAL: Mandatory Initial Read**
|
||||
If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to load every file listed there before performing any other actions.
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
<role>
|
||||
You are a CI debugger. You investigate bugs using systematic scientific method — forming hypotheses, testing them against the codebase, and finding the exact root cause.
|
||||
|
||||
Unlike learnship, CI debuggers auto-diagnose and auto-fix when confidence > 0.60. Only low-confidence root causes are escalated to human.
|
||||
CI debuggers auto-diagnose and auto-fix when confidence > 0.60. Only low-confidence root causes are escalated to human.
|
||||
|
||||
**CRITICAL: Mandatory Initial Read**
|
||||
If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to load every file listed there before performing any other actions.
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
<role>
|
||||
You are a CI executor. You execute plan tasks atomically — one task at a time, committing after each with `---ci---` blocks.
|
||||
|
||||
Unlike learnship, CI executors NEVER pause for checkpoints. Every task is autonomous. Create automated verification scripts for traditionally human tasks (manual testing, visual inspection, etc.).
|
||||
CI executors NEVER pause for checkpoints. Every task is autonomous. Create automated verification scripts for traditionally human tasks (manual testing, visual inspection, etc.).
|
||||
|
||||
**CRITICAL: Mandatory Initial Read**
|
||||
If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to load every file listed there before performing any other actions.
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
<role>
|
||||
You are the CI orchestrator. You drive the full CI pipeline by iterating through pipeline stages, making git-first context loading decisions, and delegating to specialized agents.
|
||||
|
||||
Unlike learnship, CI operates autonomously after the clarify phase. You never pause for human checkpoints unless a decision falls below the confidence threshold or an escalation hook is triggered.
|
||||
CI operates autonomously after the clarify phase. You never pause for human checkpoints unless a decision falls below the confidence threshold or an escalation hook is triggered.
|
||||
|
||||
Your job: Execute stages in order, collect PhaseResult for each, handle errors via ErrorRecovery, and produce a final project outcome.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ tools:
|
||||
<role>
|
||||
You are a CI planner. You create executable plans for a phase by decomposing goals into atomic, independently verifiable tasks with wave-based dependency ordering.
|
||||
|
||||
Unlike learnship, CI plans NEVER have `autonomous: false`. Every task is autonomous by default. Decompose into verifiable subtasks that an executor can implement without interpretation.
|
||||
CI plans NEVER have `autonomous: false`. Every task is autonomous by default. Decompose into verifiable subtasks that an executor can implement without interpretation.
|
||||
|
||||
**CRITICAL: Mandatory Initial Read**
|
||||
If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to load every file listed there before performing any other actions.
|
||||
|
||||
@@ -11,7 +11,7 @@ tools:
|
||||
<role>
|
||||
You are a CI researcher. You investigate the domain for a phase using git history, web search, and codebase analysis.
|
||||
|
||||
Unlike learnship, CI researchers NEVER flag `[ASSUMED]` for human validation. Instead, log assumptions to DecisionEngine with confidence scores. Low-confidence assumptions are escalated through the normal decision flow.
|
||||
CI researchers NEVER flag `[ASSUMED]` for human validation. Instead, log assumptions to DecisionEngine with confidence scores. Low-confidence assumptions are escalated through the normal decision flow.
|
||||
|
||||
**CRITICAL: Mandatory Initial Read**
|
||||
If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to load every file listed there before performing any other actions.
|
||||
|
||||
@@ -11,7 +11,7 @@ tools:
|
||||
<role>
|
||||
You are a CI security auditor. You verify that security threats identified during planning have been properly mitigated in the implementation.
|
||||
|
||||
Unlike learnship, CI security auditors auto-disposition threats: low=accept, medium=mitigate, high=escalate. Only high-severity threats with no clear mitigation are escalated to human.
|
||||
CI security auditors auto-disposition threats: low=accept, medium=mitigate, high=escalate. Only high-severity threats with no clear mitigation are escalated to human.
|
||||
|
||||
You are READ-ONLY. Do not modify source code.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ tools:
|
||||
<role>
|
||||
You are a CI verifier. You verify that a phase was completed correctly — not just that code was written, but that the phase goal is genuinely achieved.
|
||||
|
||||
Unlike learnship, CI verifiers NEVER produce `human_needed` unless something is truly unverifiable. Generate automated test scripts for traditionally human-verified items.
|
||||
CI verifiers NEVER produce `human_needed` unless something is truly unverifiable. Generate automated test scripts for traditionally human-verified items.
|
||||
|
||||
**CRITICAL: Mandatory Initial Read**
|
||||
If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to load every file listed there before performing any other actions.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.3.0
|
||||
0.5.0
|
||||
@@ -41,7 +41,7 @@ These were removed in v0.2.0 and now live in the git log:
|
||||
| `.ci/audit/decisions.json` | `---ci---` decisions block | `GitContext.getDecisions()` |
|
||||
| `.ci/audit/escalations.json` | `---ci---` escalations block | `GitContext.getEscalations()` |
|
||||
| `.ci/audit/lessons.json` | `---ci---` lessons block | `GitContext.getLessons()` |
|
||||
| `.planning/` directory | Git log + branches | `GitContext.reconstructState()` |
|
||||
| `.planning/` directory (removed) | Git log + branches | `GitContext.reconstructState()` |
|
||||
|
||||
## CiFiles API
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@/home/jchery/.config/opencode/ci/workflows/audit.md
|
||||
@__OPENCODE_DIR__/ci/workflows/audit.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@/home/jchery/.config/opencode/ci/workflows/clarify.md
|
||||
@__OPENCODE_DIR__/ci/workflows/clarify.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@/home/jchery/.config/opencode/ci/workflows/debug.md
|
||||
@__OPENCODE_DIR__/ci/workflows/debug.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@/home/jchery/.config/opencode/ci/workflows/init.md
|
||||
@__OPENCODE_DIR__/ci/workflows/init.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@/home/jchery/.config/opencode/ci/workflows/quick.md
|
||||
@__OPENCODE_DIR__/ci/workflows/quick.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
@@ -12,7 +12,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@/home/jchery/.config/opencode/ci/workflows/review.md
|
||||
@__OPENCODE_DIR__/ci/workflows/review.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@/home/jchery/.config/opencode/ci/workflows/rollback.md
|
||||
@__OPENCODE_DIR__/ci/workflows/rollback.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
@@ -13,7 +13,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@/home/jchery/.config/opencode/ci/workflows/run.md
|
||||
@__OPENCODE_DIR__/ci/workflows/run.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
@@ -12,7 +12,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@/home/jchery/.config/opencode/ci/workflows/ship.md
|
||||
@__OPENCODE_DIR__/ci/workflows/ship.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
@@ -8,7 +8,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@/home/jchery/.config/opencode/ci/workflows/status.md
|
||||
@__OPENCODE_DIR__/ci/workflows/status.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
@@ -12,7 +12,7 @@ tools:
|
||||
---
|
||||
|
||||
<execution_context>
|
||||
@/home/jchery/.config/opencode/ci/workflows/verify.md
|
||||
@__OPENCODE_DIR__/ci/workflows/verify.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"read": {
|
||||
"~/.config/opencode/learnship/*": "allow",
|
||||
"~/.config/opencode/ci/*": "allow"
|
||||
"__OPENCODE_DIR__/ci/*": "allow"
|
||||
},
|
||||
"external_directory": {
|
||||
"~/.config/opencode/learnship/*": "allow",
|
||||
"~/.config/opencode/ci/*": "allow"
|
||||
"__OPENCODE_DIR__/ci/*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@continuous-intelligence/ci",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -10,7 +10,6 @@
|
||||
"files": [
|
||||
"dist/",
|
||||
"opencode/",
|
||||
"scripts/",
|
||||
"templates/",
|
||||
"LICENSE",
|
||||
"README.md"
|
||||
@@ -21,8 +20,7 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"prepublishOnly": "npm run build",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"install": "bash scripts/install.sh"
|
||||
"install-opencode": "node scripts/postinstall.js"
|
||||
},
|
||||
"keywords": ["ci", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
|
||||
"license": "MIT",
|
||||
|
||||
+6
-3
@@ -70,7 +70,7 @@ copy_file() {
|
||||
return
|
||||
fi
|
||||
|
||||
cp "$src" "$dest"
|
||||
sed "s|__OPENCODE_DIR__|${OPENCODE_DIR}|g" "$src" > "$dest"
|
||||
COPIED=$((COPIED + 1))
|
||||
}
|
||||
|
||||
@@ -109,14 +109,16 @@ CI_JSON="${CI_DIR}/opencode.json"
|
||||
|
||||
if [ -f "$CI_JSON" ]; then
|
||||
if [ ! -f "$OPENCODE_JSON" ]; then
|
||||
cp "$CI_JSON" "$OPENCODE_JSON"
|
||||
sed "s|__OPENCODE_DIR__|${OPENCODE_DIR}|g" "$CI_JSON" > "$OPENCODE_JSON"
|
||||
echo " Created opencode.json"
|
||||
else
|
||||
if command -v node &>/dev/null; then
|
||||
local_ci_json="$(sed "s|__OPENCODE_DIR__|${OPENCODE_DIR}|g" "$CI_JSON")"
|
||||
echo "$local_ci_json" > /tmp/ci-json-merge.json
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const existing = JSON.parse(fs.readFileSync('${OPENCODE_JSON}', 'utf8'));
|
||||
const ci = JSON.parse(fs.readFileSync('${CI_JSON}', 'utf8'));
|
||||
const ci = JSON.parse(fs.readFileSync('/tmp/ci-json-merge.json', 'utf8'));
|
||||
const merged = { ...existing };
|
||||
merged.permission = merged.permission || {};
|
||||
merged.permission.read = merged.permission.read || {};
|
||||
@@ -130,6 +132,7 @@ if [ -f "$CI_JSON" ]; then
|
||||
fs.writeFileSync('${OPENCODE_JSON}', JSON.stringify(merged, null, 2));
|
||||
console.log(' Merged permissions (preserved existing entries)');
|
||||
"
|
||||
rm -f /tmp/ci-json-merge.json
|
||||
else
|
||||
echo " Warning: node not found. Manually merge opencode.json permissions."
|
||||
echo " Add to opencode.json:"
|
||||
|
||||
+22
-7
@@ -20,7 +20,7 @@ function isGlobalInstall() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function copyFile(src, dest, force) {
|
||||
function copyFile(src, dest, force, templateVars) {
|
||||
if (!fs.existsSync(src)) return { copied: 0, skipped: 0 };
|
||||
|
||||
const dir = path.dirname(dest);
|
||||
@@ -28,17 +28,27 @@ function copyFile(src, dest, force) {
|
||||
|
||||
if (fs.existsSync(dest) && !force) {
|
||||
try {
|
||||
const srcContent = fs.readFileSync(src, "utf8");
|
||||
const srcContent = applyTemplate(fs.readFileSync(src, "utf8"), templateVars);
|
||||
const destContent = fs.readFileSync(dest, "utf8");
|
||||
if (srcContent === destContent) return { copied: 0, skipped: 1 };
|
||||
} catch {}
|
||||
return { copied: 0, skipped: 1 };
|
||||
}
|
||||
|
||||
fs.copyFileSync(src, dest);
|
||||
const content = applyTemplate(fs.readFileSync(src, "utf8"), templateVars);
|
||||
fs.writeFileSync(dest, content, "utf8");
|
||||
return { copied: 1, skipped: 0 };
|
||||
}
|
||||
|
||||
function applyTemplate(content, vars) {
|
||||
if (!vars) return content;
|
||||
let result = content;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
result = result.replaceAll(key, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function install() {
|
||||
const pkgDir = getPackageDir();
|
||||
if (!pkgDir) {
|
||||
@@ -58,6 +68,10 @@ function install() {
|
||||
return;
|
||||
}
|
||||
|
||||
const templateVars = {
|
||||
__OPENCODE_DIR__: OPENCODE_DIR,
|
||||
};
|
||||
|
||||
let copied = 0;
|
||||
let skipped = 0;
|
||||
|
||||
@@ -68,7 +82,7 @@ function install() {
|
||||
return f.startsWith(pattern);
|
||||
});
|
||||
for (const entry of entries) {
|
||||
const result = copyFile(path.join(srcDir, entry), path.join(destDir, entry), false);
|
||||
const result = copyFile(path.join(srcDir, entry), path.join(destDir, entry), false, templateVars);
|
||||
copied += result.copied;
|
||||
skipped += result.skipped;
|
||||
}
|
||||
@@ -88,7 +102,7 @@ function install() {
|
||||
|
||||
const versionFile = path.join(opencodeDir, "ci", "VERSION");
|
||||
if (fs.existsSync(versionFile)) {
|
||||
const result = copyFile(versionFile, path.join(OPENCODE_DIR, "ci", "VERSION"), false);
|
||||
const result = copyFile(versionFile, path.join(OPENCODE_DIR, "ci", "VERSION"), false, templateVars);
|
||||
copied += result.copied;
|
||||
skipped += result.skipped;
|
||||
}
|
||||
@@ -98,11 +112,12 @@ function install() {
|
||||
|
||||
if (fs.existsSync(ciJsonPath)) {
|
||||
if (!fs.existsSync(targetJsonPath)) {
|
||||
fs.copyFileSync(ciJsonPath, targetJsonPath);
|
||||
const content = applyTemplate(fs.readFileSync(ciJsonPath, "utf8"), templateVars);
|
||||
fs.writeFileSync(targetJsonPath, content, "utf8");
|
||||
} else {
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(targetJsonPath, "utf8"));
|
||||
const ciJson = JSON.parse(fs.readFileSync(ciJsonPath, "utf8"));
|
||||
const ciJson = JSON.parse(applyTemplate(fs.readFileSync(ciJsonPath, "utf8"), templateVars));
|
||||
existing.permission = existing.permission || {};
|
||||
existing.permission.read = existing.permission.read || {};
|
||||
existing.permission.external_directory = existing.permission.external_directory || {};
|
||||
|
||||
+33
-1
@@ -1,3 +1,6 @@
|
||||
import { IntelligenceBackend, BackendRequest, BackendResult, BackendUnavailableError, emptyBackendResult } from "../backends/types.js";
|
||||
import { AgentName, AutonomyLevel } from "../types/config.js";
|
||||
|
||||
export interface AgentResult {
|
||||
success: boolean;
|
||||
output: string;
|
||||
@@ -14,14 +17,43 @@ export interface AgentContext {
|
||||
stage: string;
|
||||
specification: string;
|
||||
config_path: string;
|
||||
backend?: IntelligenceBackend;
|
||||
}
|
||||
|
||||
export function backendResultToAgentResult(result: BackendResult): AgentResult {
|
||||
return {
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
artifacts_created: result.artifacts.map((a) => a.path),
|
||||
decisions: result.decisions.length,
|
||||
escalations: result.escalations.length,
|
||||
duration_ms: 0,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class BaseAgent {
|
||||
abstract readonly name: string;
|
||||
abstract readonly name: AgentName;
|
||||
abstract readonly description: string;
|
||||
abstract readonly workflow: string;
|
||||
|
||||
abstract execute(context: AgentContext): Promise<AgentResult>;
|
||||
|
||||
protected async executeViaBackend(context: AgentContext, task: string): Promise<AgentResult> {
|
||||
if (!context.backend) {
|
||||
throw new BackendUnavailableError("none", this.name);
|
||||
}
|
||||
const request: BackendRequest = {
|
||||
persona: this.name,
|
||||
workflow: this.workflow,
|
||||
task,
|
||||
context,
|
||||
autonomy: "full",
|
||||
};
|
||||
const result = await context.backend.execute(request);
|
||||
return backendResultToAgentResult(result);
|
||||
}
|
||||
|
||||
protected log(message: string): void {
|
||||
console.log(`[${this.name}] ${message}`);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class ChallengerAgent extends BaseAgent {
|
||||
readonly name = "challenger";
|
||||
readonly description = "Stress-tests plans with binding verdicts. Only escalates when confidence < 0.60.";
|
||||
readonly workflow = "plan";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Challenging plan...");
|
||||
const start = Date.now();
|
||||
this.log("Challenging plan...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Stress-test the plan for phase ${context.phase}. Specification: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Plan challenge complete — verdict: proceed",
|
||||
success: false,
|
||||
output: "Plan challenge requires an intelligence backend. Configure one with: ci init --backend",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class CodeReviewerAgent extends BaseAgent {
|
||||
readonly name = "code-reviewer";
|
||||
readonly description = "Multi-persona code review. Auto-applies P0 fixes. Flags P1+ for post-hoc review.";
|
||||
readonly workflow = "review";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Running code review...");
|
||||
const start = Date.now();
|
||||
this.log("Running code review...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Perform multi-persona code review for phase ${context.phase}. Specification: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Code review complete — P0 fixes applied, P1+ flagged for review",
|
||||
artifacts_created: ["CODE-REVIEW.md"],
|
||||
success: false,
|
||||
output: "Code review requires an intelligence backend. Configure one with: ci init --backend",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class DebuggerAgent extends BaseAgent {
|
||||
readonly name = "debugger";
|
||||
readonly description = "Autonomous debugging. Auto-fixes when root cause confidence > 0.60, escalates otherwise.";
|
||||
readonly workflow = "debug";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Running autonomous debug...");
|
||||
const start = Date.now();
|
||||
this.log("Running autonomous debug...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Debug the following issue: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Debug complete — issue identified and resolved",
|
||||
artifacts_created: ["DEBUG.md"],
|
||||
success: false,
|
||||
output: "Debugging requires an intelligence backend. Configure one with: ci init --backend",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class DocVerifierAgent extends BaseAgent {
|
||||
readonly name = "doc-verifier";
|
||||
readonly description = "Verifies documentation matches live codebase.";
|
||||
readonly workflow = "verify";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Verifying documentation...");
|
||||
const start = Date.now();
|
||||
this.log("Verifying documentation...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Verify documentation matches codebase for phase ${context.phase}.`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Documentation verification complete",
|
||||
success: false,
|
||||
output: "Documentation verification requires an intelligence backend.",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class DocWriterAgent extends BaseAgent {
|
||||
readonly name = "doc-writer";
|
||||
readonly description = "Autonomous documentation writer. No behavioral changes from Learnship.";
|
||||
readonly workflow = "execute";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Writing documentation...");
|
||||
const start = Date.now();
|
||||
this.log("Writing documentation...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Write documentation for phase ${context.phase}. Specification: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Documentation written",
|
||||
artifacts_created: ["DOCS.md"],
|
||||
success: false,
|
||||
output: "Documentation writing requires an intelligence backend.",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class ExecutorAgent extends BaseAgent {
|
||||
readonly name = "executor";
|
||||
readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints.";
|
||||
readonly workflow = "execute";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Executing tasks...");
|
||||
const start = Date.now();
|
||||
this.log("Executing tasks...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Execute implementation for stage ${context.stage}, phase ${context.phase}. Specification: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Tasks executed",
|
||||
success: false,
|
||||
output: "Execution requires an intelligence backend. Configure one with: ci init --backend",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class IdeationAgent extends BaseAgent {
|
||||
readonly name = "ideation-agent";
|
||||
readonly description = "Generates improvement ideas. Output feeds directly into planning pipeline.";
|
||||
readonly workflow = "research";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Generating improvement ideas...");
|
||||
const start = Date.now();
|
||||
this.log("Generating improvement ideas...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Generate improvement ideas for: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Ideation complete",
|
||||
artifacts_created: ["IDEAS.md"],
|
||||
success: false,
|
||||
output: "Ideation requires an intelligence backend.",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export { BaseAgent } from "./base.js";
|
||||
export { BaseAgent, AgentContext, AgentResult, backendResultToAgentResult } from "./base.js";
|
||||
export { OrchestratorAgent } from "./orchestrator.js";
|
||||
export { PlannerAgent } from "./planner.js";
|
||||
export { ExecutorAgent } from "./executor.js";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { GitContext, ProjectState } from "../core/git-context.js";
|
||||
import { GitBranch } from "../core/git-branch.js";
|
||||
import { CiFiles } from "../core/ci-files.js";
|
||||
import { CommitBuilder } from "../core/commit-builder.js";
|
||||
import { CIConfig } from "../types/config.js";
|
||||
import { CIConfig, AgentName } from "../types/config.js";
|
||||
import {
|
||||
PipelineState,
|
||||
PipelineStage,
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from "../types/pipeline.js";
|
||||
import { Specification, parseSpecification } from "../types/specification.js";
|
||||
import { loadConfig, saveConfig, isCIInitialized, initCI } from "../core/config.js";
|
||||
import { getAgent } from "./index.js";
|
||||
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
|
||||
|
||||
export interface GitAgentContext extends AgentContext {
|
||||
gitContext: GitContext;
|
||||
@@ -26,8 +28,9 @@ export interface GitAgentContext extends AgentContext {
|
||||
}
|
||||
|
||||
export class OrchestratorAgent extends BaseAgent {
|
||||
readonly name = "orchestrator";
|
||||
readonly name: AgentName = "orchestrator";
|
||||
readonly description = "Top-level autonomous controller that coordinates the full CI pipeline";
|
||||
readonly workflow = "run";
|
||||
|
||||
private config: CIConfig;
|
||||
private pipelineState: PipelineState | null = null;
|
||||
@@ -39,6 +42,13 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
private currentMilestone: string;
|
||||
private phaseResults: PhaseResult[] = [];
|
||||
|
||||
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName>> = {
|
||||
research: "researcher",
|
||||
plan: "planner",
|
||||
execute: "executor",
|
||||
verify: "verifier",
|
||||
};
|
||||
|
||||
constructor(config?: CIConfig) {
|
||||
super();
|
||||
this.config = config || loadConfig(process.cwd());
|
||||
@@ -149,6 +159,32 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
context: AgentContext
|
||||
): Promise<PhaseResult> {
|
||||
const stageStart = Date.now();
|
||||
const agentName = OrchestratorAgent.STAGE_AGENT_MAP[stage];
|
||||
|
||||
if (agentName && context.backend) {
|
||||
this.log(`Delegating ${stage} to ${agentName} agent via backend...`);
|
||||
try {
|
||||
const agent = getAgent(agentName);
|
||||
const result = await agent.execute(context);
|
||||
return {
|
||||
phase: this.pipelineState!.current_phase,
|
||||
stage,
|
||||
success: result.success,
|
||||
artifacts_created: Array.isArray(result.artifacts_created) ? result.artifacts_created : [],
|
||||
decisions_made: result.decisions,
|
||||
escalations_raised: result.escalations,
|
||||
duration_ms: Date.now() - stageStart,
|
||||
error: result.error,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof BackendUnavailableError) {
|
||||
this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`);
|
||||
} else {
|
||||
this.warn(`Agent ${agentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let decisionsMade = 0;
|
||||
let escalationsRaised = 0;
|
||||
const artifactsCreated: string[] = [];
|
||||
@@ -188,7 +224,8 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
this.warn(`Specify commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -239,6 +276,21 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
this.log("Researching project domain...");
|
||||
this.decisionEngine!.setPhase(1);
|
||||
|
||||
const archMd = this.ciFiles!.readArchitectureMd();
|
||||
if (!archMd) {
|
||||
this.log("No ARCHITECTURE.md found — mechanical research cannot proceed without backend");
|
||||
return {
|
||||
phase: this.pipelineState!.current_phase,
|
||||
stage: "research",
|
||||
success: false,
|
||||
artifacts_created: artifactsCreated,
|
||||
decisions_made: decisionsMade,
|
||||
escalations_raised: escalationsRaised,
|
||||
duration_ms: Date.now() - stageStart,
|
||||
error: "Research stage requires intelligence backend or existing ARCHITECTURE.md",
|
||||
};
|
||||
}
|
||||
|
||||
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
||||
const researchCommit = CommitBuilder.buildResearchCommit(
|
||||
1,
|
||||
@@ -252,7 +304,8 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
this.warn(`Research commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,11 +326,42 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
|
||||
case "execute":
|
||||
this.log("Executing implementation...");
|
||||
if (!context.backend) {
|
||||
this.log("No backend available — mechanical execution cannot implement code changes");
|
||||
return {
|
||||
phase: this.pipelineState!.current_phase,
|
||||
stage: "execute",
|
||||
success: false,
|
||||
artifacts_created: artifactsCreated,
|
||||
decisions_made: decisionsMade,
|
||||
escalations_raised: escalationsRaised,
|
||||
duration_ms: Date.now() - stageStart,
|
||||
error: "Execute stage requires intelligence backend for code implementation",
|
||||
};
|
||||
}
|
||||
this.pipelineState!.execute_completed = true;
|
||||
break;
|
||||
|
||||
case "verify": {
|
||||
this.log("Running verification...");
|
||||
|
||||
const { VerificationPipeline } = await import("../verification/index.js");
|
||||
const verification = new VerificationPipeline(context.project_path);
|
||||
const verifyResult = await verification.run(this.pipelineState!.current_phase || 1);
|
||||
|
||||
if (!verifyResult.all_passed) {
|
||||
return {
|
||||
phase: this.pipelineState!.current_phase,
|
||||
stage: "verify",
|
||||
success: false,
|
||||
artifacts_created: artifactsCreated,
|
||||
decisions_made: decisionsMade,
|
||||
escalations_raised: escalationsRaised,
|
||||
duration_ms: Date.now() - stageStart,
|
||||
error: `Verification failed: ${verifyResult.escalations_needed.join("; ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
this.pipelineState!.verify_completed = true;
|
||||
|
||||
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
||||
@@ -293,7 +377,8 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
this.warn(`Verify commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +403,8 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
this.warn(`Completion commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class PhaseResearcherAgent extends BaseAgent {
|
||||
readonly name = "phase-researcher";
|
||||
readonly description = "Researches how to implement a specific phase well.";
|
||||
readonly workflow = "research";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Researching phase implementation...");
|
||||
const start = Date.now();
|
||||
this.log("Researching phase implementation...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Research how to implement phase ${context.phase} well. Specification: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Phase research complete",
|
||||
artifacts_created: ["RESEARCH.md"],
|
||||
success: false,
|
||||
output: "Phase research requires an intelligence backend.",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
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).";
|
||||
readonly workflow = "plan";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Checking plan quality...");
|
||||
const start = Date.now();
|
||||
this.log("Checking plan quality...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Verify plan quality for phase ${context.phase}. Specification: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Plan check passed",
|
||||
success: false,
|
||||
output: "Plan checking requires an intelligence backend.",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
+14
-5
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class PlannerAgent extends BaseAgent {
|
||||
readonly name = "planner";
|
||||
readonly description = "Creates phase plans with tasks. Never sets autonomous:false — decomposes into verifiable subtasks.";
|
||||
readonly workflow = "plan";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Creating phase plan...");
|
||||
const start = Date.now();
|
||||
this.log("Creating phase plan...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Create a phase plan for stage ${context.stage}, phase ${context.phase}. Specification: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Plan created with verifiable subtasks",
|
||||
artifacts_created: ["PLAN.md"],
|
||||
decisions: 1,
|
||||
success: false,
|
||||
output: "Planning requires an intelligence backend. Configure one with: ci init --backend",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class ProjectResearcherAgent extends BaseAgent {
|
||||
readonly name = "project-researcher";
|
||||
readonly description = "Researches the domain ecosystem for a new project.";
|
||||
readonly workflow = "research";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Researching project domain ecosystem...");
|
||||
const start = Date.now();
|
||||
this.log("Researching project domain ecosystem...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Research the domain ecosystem for: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Project research complete",
|
||||
artifacts_created: ["RESEARCH.md"],
|
||||
success: false,
|
||||
output: "Project research requires an intelligence backend.",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class ResearchSynthesizerAgent extends BaseAgent {
|
||||
readonly name = "research-synthesizer";
|
||||
readonly description = "Synthesizes research files into a cohesive summary for roadmap creation.";
|
||||
readonly workflow = "research";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Synthesizing research...");
|
||||
const start = Date.now();
|
||||
this.log("Synthesizing research...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Synthesize research findings into a summary for: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Research synthesis complete",
|
||||
artifacts_created: ["SUMMARY.md"],
|
||||
success: false,
|
||||
output: "Research synthesis requires an intelligence backend.",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class ResearcherAgent extends BaseAgent {
|
||||
readonly name = "researcher";
|
||||
readonly description = "Researches project domain. Logs assumptions instead of asking for validation.";
|
||||
readonly workflow = "research";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Researching domain...");
|
||||
const start = Date.now();
|
||||
this.log("Researching domain...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Research the domain for: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Research complete",
|
||||
artifacts_created: ["RESEARCH.md"],
|
||||
decisions: 1,
|
||||
success: false,
|
||||
output: "Research requires an intelligence backend. Configure one with: ci init --backend",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class RoadmapperAgent extends BaseAgent {
|
||||
readonly name = "roadmapper";
|
||||
readonly description = "Creates and maintains project roadmaps.";
|
||||
readonly workflow = "plan";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Creating roadmap...");
|
||||
const start = Date.now();
|
||||
this.log("Creating roadmap...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Create project roadmap for: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Roadmap created",
|
||||
artifacts_created: ["ROADMAP.md"],
|
||||
decisions: 1,
|
||||
success: false,
|
||||
output: "Roadmap creation requires an intelligence backend.",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class SecurityAuditorAgent extends BaseAgent {
|
||||
readonly name = "security-auditor";
|
||||
readonly description = "Auto-dispositions threats: low=accept, medium=mitigate, high=escalate.";
|
||||
readonly workflow = "verify";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Running security audit...");
|
||||
const start = Date.now();
|
||||
this.log("Running security audit...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Perform security audit for phase ${context.phase}. Specification: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Security audit complete",
|
||||
artifacts_created: ["SECURITY.md"],
|
||||
decisions: 1,
|
||||
success: false,
|
||||
output: "Security auditing requires an intelligence backend. Configure one with: ci init --backend",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,27 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
|
||||
export class SolutionWriterAgent extends BaseAgent {
|
||||
readonly name = "solution-writer";
|
||||
readonly description = "Produces structured solution documents for .planning/solutions/.";
|
||||
readonly description = "Produces structured solution documents.";
|
||||
readonly workflow = "execute";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Writing solution document...");
|
||||
const start = Date.now();
|
||||
this.log("Writing solution document...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Write a structured solution document for: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Solution document written",
|
||||
artifacts_created: ["SOLUTION.md"],
|
||||
success: false,
|
||||
output: "Solution writing requires an intelligence backend.",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
export class VerifierAgent extends BaseAgent {
|
||||
readonly name = "verifier";
|
||||
readonly description = "Verifies phase outputs. Generates automated tests instead of requesting human UAT.";
|
||||
readonly workflow = "verify";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
this.log("Verifying phase output...");
|
||||
const start = Date.now();
|
||||
this.log("Verifying phase output...");
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Verify phase ${context.phase} output. Specification: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: "Verification complete — all checks passed",
|
||||
artifacts_created: ["VERIFICATION.md"],
|
||||
success: false,
|
||||
output: "Verification requires an intelligence backend. Configure one with: ci init --backend",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { OllamaLocalBackend } from "../backends/ollama-local.js";
|
||||
import { OllamaCloudBackend } from "../backends/ollama-cloud.js";
|
||||
import { OpencodeBackend } from "../backends/opencode.js";
|
||||
import { resolveBackend, createBackend } from "../backends/index.js";
|
||||
import { DEFAULT_BACKEND_CONFIG, BackendUnavailableError } from "../backends/types.js";
|
||||
|
||||
describe("Backend Availability Detection", () => {
|
||||
describe("OllamaLocalBackend.isAvailable", () => {
|
||||
it("returns false for unreachable host", async () => {
|
||||
const backend = new OllamaLocalBackend({
|
||||
base_url: "http://localhost:1",
|
||||
model_profile: "balanced",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for invalid URL", async () => {
|
||||
const backend = new OllamaLocalBackend({
|
||||
base_url: "not-a-url",
|
||||
model_profile: "balanced",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for timeout", async () => {
|
||||
const backend = new OllamaLocalBackend({
|
||||
base_url: "http://192.0.2.1",
|
||||
model_profile: "balanced",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("OllamaCloudBackend.isAvailable", () => {
|
||||
it("returns false when base_url is empty", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "",
|
||||
api_key_env: "OLLAMA_CLOUD_API_KEY",
|
||||
model_profile: "quality",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no API key in env", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "https://api.example.com",
|
||||
api_key_env: "NONEXISTENT_ENV_VAR_12345",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 5000,
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeBackend.isAvailable", () => {
|
||||
it("returns false when executable not found", async () => {
|
||||
const backend = new OpencodeBackend({
|
||||
enabled: true,
|
||||
executable: "nonexistent-opencode-binary-xyz",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when disabled", async () => {
|
||||
const backend = new OpencodeBackend({ enabled: false });
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBackend auto-detection", () => {
|
||||
it("throws BackendUnavailableError when no backends available", async () => {
|
||||
const config = {
|
||||
...DEFAULT_BACKEND_CONFIG,
|
||||
llm_backends: {
|
||||
"ollama-local": { base_url: "http://localhost:1", model_profile: "balanced" as const },
|
||||
"ollama-cloud": { base_url: "", api_key_env: "NONEXISTENT_12345", model_profile: "quality" as const },
|
||||
},
|
||||
agent_backends: {
|
||||
opencode: { enabled: true, executable: "nonexistent-opencode-binary-xyz" },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(resolveBackend(config)).rejects.toThrow(BackendUnavailableError);
|
||||
});
|
||||
|
||||
it("tries opencode before ollama-local", async () => {
|
||||
expect(DEFAULT_BACKEND_CONFIG.provider).toBe("auto");
|
||||
});
|
||||
|
||||
it("createBackend throws for unknown provider", () => {
|
||||
expect(() => createBackend("unknown-provider" as "opencode", DEFAULT_BACKEND_CONFIG)).toThrow(BackendUnavailableError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackendUnavailableError", () => {
|
||||
it("contains installation hints", () => {
|
||||
const err = new BackendUnavailableError("auto");
|
||||
expect(err.message).toContain("opencode");
|
||||
expect(err.message).toContain("Ollama");
|
||||
expect(err.message).toContain("OLLAMA_CLOUD_API_KEY");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { BackendUnavailableError, emptyTokenUsage, emptyBackendResult, DEFAULT_BACKEND_CONFIG } from "../backends/types.js";
|
||||
import { OllamaLocalBackend } from "../backends/ollama-local.js";
|
||||
import { OllamaCloudBackend } from "../backends/ollama-cloud.js";
|
||||
import { OpencodeBackend } from "../backends/opencode.js";
|
||||
|
||||
describe("BackendUnavailableError", () => {
|
||||
it("includes backend name in message", () => {
|
||||
const err = new BackendUnavailableError("ollama-local");
|
||||
expect(err.message).toContain("ollama-local");
|
||||
expect(err.backendName).toBe("ollama-local");
|
||||
});
|
||||
|
||||
it("includes agent name when provided", () => {
|
||||
const err = new BackendUnavailableError("opencode", "executor");
|
||||
expect(err.agentName).toBe("executor");
|
||||
expect(err.message).toContain("executor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("emptyTokenUsage", () => {
|
||||
it("returns zeroed usage", () => {
|
||||
const usage = emptyTokenUsage();
|
||||
expect(usage.input_tokens).toBe(0);
|
||||
expect(usage.output_tokens).toBe(0);
|
||||
expect(usage.total_tokens).toBe(0);
|
||||
expect(usage.estimated_cost_usd).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emptyBackendResult", () => {
|
||||
it("returns failed result with no artifacts", () => {
|
||||
const result = emptyBackendResult("something failed");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("something failed");
|
||||
expect(result.artifacts).toEqual([]);
|
||||
expect(result.decisions).toEqual([]);
|
||||
expect(result.escalations).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns result without error when no message provided", () => {
|
||||
const result = emptyBackendResult();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_BACKEND_CONFIG", () => {
|
||||
it("has auto provider by default", () => {
|
||||
expect(DEFAULT_BACKEND_CONFIG.provider).toBe("auto");
|
||||
});
|
||||
|
||||
it("has opencode agent backend enabled", () => {
|
||||
expect(DEFAULT_BACKEND_CONFIG.agent_backends.opencode?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("has ollama-local and ollama-cloud llm backends", () => {
|
||||
expect(DEFAULT_BACKEND_CONFIG.llm_backends["ollama-local"]).toBeDefined();
|
||||
expect(DEFAULT_BACKEND_CONFIG.llm_backends["ollama-cloud"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OllamaLocalBackend", () => {
|
||||
it("has correct name and type", () => {
|
||||
const backend = new OllamaLocalBackend();
|
||||
expect(backend.name).toBe("ollama-local");
|
||||
expect(backend.type).toBe("llm");
|
||||
});
|
||||
|
||||
it("returns false when local Ollama is not available", async () => {
|
||||
const backend = new OllamaLocalBackend({
|
||||
base_url: "http://localhost:1",
|
||||
model_profile: "balanced",
|
||||
});
|
||||
const available = await backend.isAvailable();
|
||||
expect(available).toBe(false);
|
||||
});
|
||||
|
||||
it("uses default config when none provided", () => {
|
||||
const backend = new OllamaLocalBackend();
|
||||
expect(backend).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OllamaCloudBackend", () => {
|
||||
it("has correct name and type", () => {
|
||||
const backend = new OllamaCloudBackend();
|
||||
expect(backend.name).toBe("ollama-cloud");
|
||||
expect(backend.type).toBe("llm");
|
||||
});
|
||||
|
||||
it("returns false when no base_url configured", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "",
|
||||
api_key_env: "NONEXISTENT_KEY",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 5000,
|
||||
});
|
||||
const available = await backend.isAvailable();
|
||||
expect(available).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no API key available", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "https://example.com",
|
||||
api_key_env: "NONEXISTENT_CI_KEY_12345",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 5000,
|
||||
});
|
||||
const available = await backend.isAvailable();
|
||||
expect(available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeBackend", () => {
|
||||
it("has correct name and type", () => {
|
||||
const backend = new OpencodeBackend();
|
||||
expect(backend.name).toBe("opencode");
|
||||
expect(backend.type).toBe("agent");
|
||||
});
|
||||
|
||||
it("returns false when opencode is not installed", async () => {
|
||||
const backend = new OpencodeBackend({
|
||||
enabled: true,
|
||||
executable: "nonexistent-opencode-binary-xyz",
|
||||
});
|
||||
const available = await backend.isAvailable();
|
||||
expect(available).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js";
|
||||
import { OpencodeBackend } from "./opencode.js";
|
||||
import { OllamaLocalBackend } from "./ollama-local.js";
|
||||
import { OllamaCloudBackend } from "./ollama-cloud.js";
|
||||
|
||||
const AUTO_DETECT_ORDER: Array<"opencode" | "ollama-local" | "ollama-cloud"> = [
|
||||
"opencode",
|
||||
"ollama-local",
|
||||
"ollama-cloud",
|
||||
];
|
||||
|
||||
export function createBackend(
|
||||
name: string,
|
||||
config: BackendConfigSection
|
||||
): IntelligenceBackend {
|
||||
switch (name) {
|
||||
case "opencode":
|
||||
return new OpencodeBackend(config.agent_backends.opencode);
|
||||
case "ollama-local":
|
||||
return new OllamaLocalBackend(config.llm_backends["ollama-local"]);
|
||||
case "ollama-cloud":
|
||||
return new OllamaCloudBackend(config.llm_backends["ollama-cloud"]);
|
||||
default:
|
||||
throw new BackendUnavailableError(name);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveBackend(
|
||||
config: BackendConfigSection
|
||||
): Promise<IntelligenceBackend> {
|
||||
if (config.provider !== "auto") {
|
||||
const backend = createBackend(config.provider, config);
|
||||
if (!(await backend.isAvailable())) {
|
||||
throw new BackendUnavailableError(config.provider);
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
for (const name of AUTO_DETECT_ORDER) {
|
||||
try {
|
||||
const backend = createBackend(name, config);
|
||||
if (await backend.isAvailable()) {
|
||||
return backend;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
throw new BackendUnavailableError("auto");
|
||||
}
|
||||
|
||||
export { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js";
|
||||
export { ToolRegistry, ToolDefinition, ToolCall, ToolResult } from "./tool-registry.js";
|
||||
export { OpencodeBackend } from "./opencode.js";
|
||||
export { OllamaLocalBackend } from "./ollama-local.js";
|
||||
export { OllamaCloudBackend } from "./ollama-cloud.js";
|
||||
@@ -0,0 +1,229 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { OllamaBaseBackend, OllamaMessage, OllamaChatResponse } from "../backends/ollama-base.js";
|
||||
import { ToolRegistry } from "../backends/tool-registry.js";
|
||||
import { BackendRequest } from "../backends/types.js";
|
||||
|
||||
class TestableOllamaBaseBackend extends OllamaBaseBackend {
|
||||
readonly name = "test-base";
|
||||
private mockResponse: OllamaChatResponse;
|
||||
private callCount: number;
|
||||
|
||||
constructor(mockResponse: OllamaChatResponse) {
|
||||
super(undefined);
|
||||
this.mockResponse = mockResponse;
|
||||
this.callCount = 0;
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
getCallCount(): number {
|
||||
return this.callCount;
|
||||
}
|
||||
|
||||
protected async callModel(
|
||||
messages: OllamaMessage[],
|
||||
model: string,
|
||||
toolRegistry: ToolRegistry
|
||||
): Promise<OllamaChatResponse> {
|
||||
this.callCount++;
|
||||
return this.mockResponse;
|
||||
}
|
||||
|
||||
protected resolveModel(): string {
|
||||
return "test-model";
|
||||
}
|
||||
}
|
||||
|
||||
describe("OllamaBaseBackend", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-ollama-base-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns success when model responds without tool calls", async () => {
|
||||
const mockResponse: OllamaChatResponse = {
|
||||
choices: [{
|
||||
message: {
|
||||
content: '{"success": true, "output": "task completed"}',
|
||||
},
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
};
|
||||
|
||||
const backend = new TestableOllamaBaseBackend(mockResponse);
|
||||
const request: BackendRequest = {
|
||||
persona: "executor",
|
||||
workflow: "execute",
|
||||
task: "Do something",
|
||||
context: {
|
||||
project_path: tempDir,
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "",
|
||||
config_path: "",
|
||||
},
|
||||
autonomy: "full",
|
||||
};
|
||||
|
||||
const result = await backend.execute(request);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("task completed");
|
||||
});
|
||||
|
||||
it("handles tool calls in response", async () => {
|
||||
const writePath = path.join(tempDir, "output.txt");
|
||||
const responses: OllamaChatResponse[] = [
|
||||
{
|
||||
choices: [{
|
||||
message: {
|
||||
content: "",
|
||||
tool_calls: [{
|
||||
function: { name: "writeFile", arguments: JSON.stringify({ path: writePath, content: "hello" }) },
|
||||
}],
|
||||
},
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
},
|
||||
{
|
||||
choices: [{
|
||||
message: {
|
||||
content: '{"success": true, "output": "file written"}',
|
||||
},
|
||||
}],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 },
|
||||
},
|
||||
];
|
||||
|
||||
let callIndex = 0;
|
||||
class ToolCallBackend extends OllamaBaseBackend {
|
||||
readonly name = "tool-call-test";
|
||||
constructor() {
|
||||
super(undefined);
|
||||
}
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
protected async callModel(): Promise<OllamaChatResponse> {
|
||||
return responses[callIndex++];
|
||||
}
|
||||
protected resolveModel(): string { return "test-model"; }
|
||||
}
|
||||
|
||||
const backend = new ToolCallBackend();
|
||||
const request: BackendRequest = {
|
||||
persona: "executor",
|
||||
workflow: "execute",
|
||||
task: "Write a file",
|
||||
context: {
|
||||
project_path: tempDir,
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "",
|
||||
config_path: "",
|
||||
},
|
||||
autonomy: "full",
|
||||
};
|
||||
|
||||
const result = await backend.execute(request);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.existsSync(writePath)).toBe(true);
|
||||
expect(fs.readFileSync(writePath, "utf-8")).toBe("hello");
|
||||
expect(result.artifacts.length).toBe(1);
|
||||
expect(result.artifacts[0].path).toBe(writePath);
|
||||
});
|
||||
|
||||
it("stops after max tool rounds", async () => {
|
||||
const alwaysToolCall: OllamaChatResponse = {
|
||||
choices: [{
|
||||
message: {
|
||||
content: "",
|
||||
tool_calls: [{
|
||||
function: { name: "readFile", arguments: JSON.stringify({ path: "/etc/hostname" }) },
|
||||
}],
|
||||
},
|
||||
}],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
};
|
||||
|
||||
class InfiniteLoopBackend extends OllamaBaseBackend {
|
||||
readonly name = "infinite-loop";
|
||||
private callCount = 0;
|
||||
constructor() {
|
||||
super(undefined);
|
||||
}
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
protected async callModel(): Promise<OllamaChatResponse> {
|
||||
this.callCount++;
|
||||
return alwaysToolCall;
|
||||
}
|
||||
protected resolveModel(): string { return "test-model"; }
|
||||
getCallCount() { return this.callCount; }
|
||||
}
|
||||
|
||||
const backend = new InfiniteLoopBackend();
|
||||
const request: BackendRequest = {
|
||||
persona: "executor",
|
||||
workflow: "execute",
|
||||
task: "Infinite loop test",
|
||||
context: {
|
||||
project_path: tempDir,
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "",
|
||||
config_path: "",
|
||||
},
|
||||
autonomy: "full",
|
||||
};
|
||||
|
||||
const result = await backend.execute(request);
|
||||
expect(result.output).toContain("maximum rounds");
|
||||
expect(backend.getCallCount()).toBe(50);
|
||||
});
|
||||
|
||||
it("handles error from callModel gracefully", async () => {
|
||||
class ErrorBackend extends OllamaBaseBackend {
|
||||
readonly name = "error-backend";
|
||||
constructor() {
|
||||
super(undefined);
|
||||
}
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
protected async callModel(): Promise<OllamaChatResponse> {
|
||||
throw new Error("Model connection failed");
|
||||
}
|
||||
protected resolveModel(): string { return "test-model"; }
|
||||
}
|
||||
|
||||
const backend = new ErrorBackend();
|
||||
const request: BackendRequest = {
|
||||
persona: "executor",
|
||||
workflow: "execute",
|
||||
task: "Fail test",
|
||||
context: {
|
||||
project_path: tempDir,
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "",
|
||||
config_path: "",
|
||||
},
|
||||
autonomy: "full",
|
||||
};
|
||||
|
||||
const result = await backend.execute(request);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Backend execution failed");
|
||||
});
|
||||
|
||||
it("modelProfileToModel selects smallest for speed", () => {
|
||||
const backend = new TestableOllamaBaseBackend({} as OllamaChatResponse);
|
||||
const models = ["llama3.1:70b", "llama3.1:8b", "llama3.1"];
|
||||
const selected = (backend as unknown as { modelProfileToModel: (p: string, m: string[]) => string }).modelProfileToModel("speed", models);
|
||||
expect(selected).toBe("llama3.1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
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 } from "./tool-registry.js";
|
||||
|
||||
const MAX_TOOL_ROUNDS = 50;
|
||||
|
||||
export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
||||
abstract readonly name: string;
|
||||
readonly type: BackendType = "llm";
|
||||
|
||||
protected config: LLMBackendConfig;
|
||||
protected projectPath: string;
|
||||
|
||||
constructor(config: LLMBackendConfig | undefined) {
|
||||
this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" };
|
||||
this.projectPath = process.cwd();
|
||||
}
|
||||
|
||||
abstract isAvailable(): Promise<boolean>;
|
||||
|
||||
async execute(request: BackendRequest): Promise<BackendResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const personaContent = this.loadPersona(request.persona);
|
||||
const workflowContent = this.loadWorkflow(request.workflow);
|
||||
const model = this.resolveModel();
|
||||
|
||||
const toolRegistry = new ToolRegistry(request.context.project_path);
|
||||
|
||||
const 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.callModel(messages, model, toolRegistry);
|
||||
|
||||
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 abstract callModel(
|
||||
messages: OllamaMessage[],
|
||||
model: string,
|
||||
toolRegistry: ToolRegistry
|
||||
): Promise<OllamaChatResponse>;
|
||||
|
||||
protected abstract resolveModel(): string;
|
||||
|
||||
protected buildSystemPrompt(persona: string, workflow: string, request: BackendRequest): string {
|
||||
const parts = [persona];
|
||||
if (workflow) {
|
||||
parts.push("", "## Workflow Instructions", workflow);
|
||||
}
|
||||
parts.push(
|
||||
"",
|
||||
"## Execution Context",
|
||||
`Autonomy level: ${request.autonomy}`,
|
||||
`Project path: ${request.context.project_path}`,
|
||||
`Phase: ${request.context.phase}`,
|
||||
`Stage: ${request.context.stage}`,
|
||||
"",
|
||||
"## Output Format",
|
||||
"When you have completed your task, output a JSON object with this structure:",
|
||||
"```json",
|
||||
'{',
|
||||
' "success": true,',
|
||||
' "output": "Summary of what was accomplished",',
|
||||
' "artifacts": [{"path": "file/path", "content": "...", "operation": "create"}],',
|
||||
' "decisions": [{"id": "D-NNN", "decision": "what", "rationale": "why", "confidence": 0.85, "category": "general", "alternatives_considered": [], "human_override": null, "timestamp": ""}],',
|
||||
' "escalations": []',
|
||||
'}',
|
||||
"```"
|
||||
);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
protected loadPersona(persona: AgentName): string {
|
||||
const candidates = [
|
||||
path.join(os.homedir(), ".config", "opencode", "agents", `ci-${persona}.md`),
|
||||
path.join(process.cwd(), "opencode", "agents", `ci-${persona}.md`),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return fs.readFileSync(candidate, "utf-8");
|
||||
}
|
||||
}
|
||||
return `You are the CI ${persona} agent. Execute the requested task thoroughly and autonomously.`;
|
||||
}
|
||||
|
||||
protected loadWorkflow(workflow: string): string {
|
||||
const candidates = [
|
||||
path.join(os.homedir(), ".config", "opencode", "ci", "workflows", `${workflow}.md`),
|
||||
path.join(process.cwd(), "opencode", "workflows", `${workflow}.md`),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return fs.readFileSync(candidate, "utf-8");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
protected parseFinalResponse(
|
||||
content: string,
|
||||
artifacts: Artifact[],
|
||||
decisions: Decision[],
|
||||
escalations: Escalation[],
|
||||
usage: TokenUsage
|
||||
): BackendResult {
|
||||
const jsonMatch = content.match(/\{[\s\S]*"success"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
return {
|
||||
success: parsed.success ?? true,
|
||||
output: parsed.output || content,
|
||||
artifacts: parsed.artifacts?.length ? this.parseArtifacts(parsed.artifacts) : artifacts,
|
||||
decisions: parsed.decisions?.length ? this.parseDecisions(parsed.decisions) : decisions,
|
||||
escalations: parsed.escalations?.length ? this.parseEscalations(parsed.escalations) : escalations,
|
||||
usage,
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: content,
|
||||
artifacts,
|
||||
decisions,
|
||||
escalations,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
private parseArtifacts(raw: unknown[]): Artifact[] {
|
||||
return raw.filter((a): a is Record<string, unknown> => !!a).map((a) => ({
|
||||
path: String(a.path || ""),
|
||||
content: String(a.content || ""),
|
||||
operation: (a.operation as Artifact["operation"]) || "create",
|
||||
}));
|
||||
}
|
||||
|
||||
private parseDecisions(raw: unknown[]): Decision[] {
|
||||
return raw.filter((d): d is Record<string, unknown> => !!d).map((d) => ({
|
||||
id: String(d.id || "D-000"),
|
||||
decision: String(d.decision || ""),
|
||||
rationale: String(d.rationale || ""),
|
||||
confidence: Number(d.confidence || 0.5),
|
||||
category: (d.category as Decision["category"]) || "general",
|
||||
alternatives_considered: Array.isArray(d.alternatives_considered)
|
||||
? d.alternatives_considered.map((a: unknown) =>
|
||||
typeof a === "string"
|
||||
? { option: a, rejected_reason: "" }
|
||||
: (a as { option: string; rejected_reason: string })
|
||||
)
|
||||
: [],
|
||||
human_override: d.human_override ? String(d.human_override) : null,
|
||||
timestamp: String(d.timestamp || new Date().toISOString()),
|
||||
}));
|
||||
}
|
||||
|
||||
private parseEscalations(raw: unknown[]): Escalation[] {
|
||||
return raw.filter((e): e is Record<string, unknown> => !!e).map((e) => ({
|
||||
id: String(e.id || "E-000"),
|
||||
timestamp: String(e.timestamp || new Date().toISOString()),
|
||||
type: (e.type as Escalation["type"]) || "specification_ambiguity",
|
||||
phase: String(e.phase || ""),
|
||||
description: String(e.description || ""),
|
||||
context: String(e.context || ""),
|
||||
options: Array.isArray(e.options) ? e.options : [],
|
||||
default_option_id: String(e.default_option_id || ""),
|
||||
resolution: (e.resolution as Escalation["resolution"]) || "pending",
|
||||
audit_file: String(e.audit_file || ""),
|
||||
}));
|
||||
}
|
||||
|
||||
protected modelProfileToModel(profile: ModelProfile, availableModels: string[]): string {
|
||||
if (availableModels.length === 0) return "llama3.1";
|
||||
|
||||
const sorted = [...availableModels].sort((a, b) => a.length - b.length);
|
||||
switch (profile) {
|
||||
case "speed":
|
||||
return sorted[0];
|
||||
case "quality":
|
||||
return sorted[sorted.length - 1];
|
||||
case "balanced":
|
||||
default:
|
||||
return sorted[Math.floor(sorted.length / 2)] || sorted[0];
|
||||
}
|
||||
}
|
||||
|
||||
protected async fetchAvailableModels(): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.config.base_url}/api/tags`);
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json() as { models?: Array<{ name: string }> };
|
||||
return (data.models || []).map((m) => m.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as os from "node:os";
|
||||
import { OllamaCloudBackend } from "../backends/ollama-cloud.js";
|
||||
|
||||
describe("OllamaCloudBackend Retry/Rate-Limit", () => {
|
||||
describe("configuration", () => {
|
||||
it("uses default config when none provided", () => {
|
||||
const backend = new OllamaCloudBackend();
|
||||
expect(backend.name).toBe("ollama-cloud");
|
||||
expect(backend.type).toBe("llm");
|
||||
});
|
||||
|
||||
it("accepts custom config", () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "https://custom.api.com",
|
||||
api_key_env: "MY_API_KEY",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 30000,
|
||||
});
|
||||
expect(backend).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAvailable", () => {
|
||||
it("returns false when base_url is empty", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "",
|
||||
api_key_env: "KEY",
|
||||
model_profile: "quality",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no API key in environment", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "https://api.example.com",
|
||||
api_key_env: "NONEXISTENT_API_KEY_VAR_98765",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 5000,
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unreachable endpoint", async () => {
|
||||
process.env.TEST_OLLAMA_CLOUD_KEY = "test-key";
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "http://localhost:1",
|
||||
api_key_env: "TEST_OLLAMA_CLOUD_KEY",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 5000,
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
delete process.env.TEST_OLLAMA_CLOUD_KEY;
|
||||
});
|
||||
});
|
||||
|
||||
describe("retry behavior", () => {
|
||||
it("MAX_RETRIES is 3", () => {
|
||||
const source = OllamaCloudBackend.toString();
|
||||
expect(source).toBeDefined();
|
||||
});
|
||||
|
||||
it("BASE_BACKOFF_MS is 1000", () => {
|
||||
const source = OllamaCloudBackend.toString();
|
||||
expect(source).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authentication", () => {
|
||||
it("uses API key from environment variable", () => {
|
||||
process.env.TEST_CI_CLOUD_KEY = "sk-test-key-123";
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "https://api.example.com",
|
||||
api_key_env: "TEST_CI_CLOUD_KEY",
|
||||
model_profile: "quality",
|
||||
});
|
||||
expect(backend).toBeDefined();
|
||||
delete process.env.TEST_CI_CLOUD_KEY;
|
||||
});
|
||||
|
||||
it("returns false when API key env var is not set", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "https://api.example.com",
|
||||
api_key_env: "DEFINITELY_NOT_SET_99999",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 5000,
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { OllamaBaseBackend, OllamaMessage, OllamaChatResponse } from "./ollama-base.js";
|
||||
import { OllamaCloudConfig, emptyBackendResult } from "./types.js";
|
||||
import { ToolRegistry } from "./tool-registry.js";
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_BACKOFF_MS = 1000;
|
||||
|
||||
export class OllamaCloudBackend extends OllamaBaseBackend {
|
||||
readonly name = "ollama-cloud";
|
||||
|
||||
private cloudConfig: OllamaCloudConfig;
|
||||
private apiKey: string | null;
|
||||
|
||||
constructor(config?: OllamaCloudConfig) {
|
||||
super(config);
|
||||
this.cloudConfig = config || {
|
||||
base_url: "",
|
||||
api_key_env: "OLLAMA_CLOUD_API_KEY",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 60000,
|
||||
};
|
||||
this.apiKey = this.resolveApiKey();
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (!this.cloudConfig.base_url) return false;
|
||||
if (!this.apiKey) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.cloudConfig.base_url}/v1/models`, {
|
||||
headers: this.getAuthHeaders(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected resolveModel(): string {
|
||||
if (this.cloudConfig.model) return this.cloudConfig.model;
|
||||
return "llama3.1:70b";
|
||||
}
|
||||
|
||||
protected async callModel(
|
||||
messages: OllamaMessage[],
|
||||
model: string,
|
||||
toolRegistry: ToolRegistry
|
||||
): Promise<OllamaChatResponse> {
|
||||
if (!this.apiKey) {
|
||||
throw new Error(`API key not found. Set ${this.cloudConfig.api_key_env} environment variable.`);
|
||||
}
|
||||
|
||||
const url = `${this.cloudConfig.base_url}/v1/chat/completions`;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages: messages.map((m) => {
|
||||
const msg: Record<string, unknown> = { role: m.role, content: m.content };
|
||||
if (m.name) msg.name = m.name;
|
||||
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
||||
return msg;
|
||||
}),
|
||||
tools: toolRegistry.getOpenAIToolSchema(),
|
||||
stream: false,
|
||||
};
|
||||
|
||||
return this.callWithRetry(url, body);
|
||||
}
|
||||
|
||||
private async callWithRetry(
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
attempt: number = 0
|
||||
): Promise<OllamaChatResponse> {
|
||||
const timeout = this.cloudConfig.timeout_ms || 60000;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...this.getAuthHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
|
||||
if (response.status === 429 && attempt < MAX_RETRIES) {
|
||||
const retryAfter = response.headers.get("Retry-After");
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter) * 1000
|
||||
: BASE_BACKOFF_MS * Math.pow(2, attempt);
|
||||
|
||||
await this.sleep(delay);
|
||||
return this.callWithRetry(url, body, attempt + 1);
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error(`Authentication failed. Check ${this.cloudConfig.api_key_env} environment variable.`);
|
||||
}
|
||||
|
||||
if (response.status === 402) {
|
||||
throw new Error("Quota exceeded. Check your Ollama Cloud billing status.");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "unknown error");
|
||||
throw new Error(`Ollama Cloud API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as OllamaChatResponse;
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError && err.message.includes("fetch")) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await this.sleep(BASE_BACKOFF_MS * Math.pow(2, attempt));
|
||||
return this.callWithRetry(url, body, attempt + 1);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private resolveApiKey(): string | null {
|
||||
return process.env[this.cloudConfig.api_key_env] || null;
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { OllamaBaseBackend, OllamaMessage, OllamaChatResponse } from "./ollama-base.js";
|
||||
import { OllamaLocalConfig } from "./types.js";
|
||||
import { ToolRegistry } from "./tool-registry.js";
|
||||
|
||||
export class OllamaLocalBackend extends OllamaBaseBackend {
|
||||
readonly name = "ollama-local";
|
||||
|
||||
private localConfig: OllamaLocalConfig;
|
||||
|
||||
constructor(config?: OllamaLocalConfig) {
|
||||
super(config);
|
||||
this.localConfig = config || { base_url: "http://localhost:11434", model_profile: "balanced" };
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.localConfig.base_url}/api/tags`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected resolveModel(): string {
|
||||
if (this.localConfig.model) return this.localConfig.model;
|
||||
return this.modelProfileToModel(this.localConfig.model_profile, []);
|
||||
}
|
||||
|
||||
protected async callModel(
|
||||
messages: OllamaMessage[],
|
||||
model: string,
|
||||
toolRegistry: ToolRegistry
|
||||
): Promise<OllamaChatResponse> {
|
||||
let resolvedModel = model;
|
||||
if (!this.localConfig.model) {
|
||||
const models = await this.fetchAvailableModels();
|
||||
resolvedModel = this.modelProfileToModel(this.localConfig.model_profile, models);
|
||||
}
|
||||
const url = `${this.localConfig.base_url}/v1/chat/completions`;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: resolvedModel,
|
||||
messages: messages.map((m) => {
|
||||
const msg: Record<string, unknown> = { role: m.role, content: m.content };
|
||||
if (m.name) msg.name = m.name;
|
||||
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
||||
return msg;
|
||||
}),
|
||||
tools: toolRegistry.getOpenAIToolSchema(),
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const timeout = this.localConfig.timeout_ms || 10000;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "unknown error");
|
||||
throw new Error(`Ollama local API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as OllamaChatResponse;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { execSync, spawn } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import {
|
||||
IntelligenceBackend,
|
||||
BackendRequest,
|
||||
BackendResult,
|
||||
BackendType,
|
||||
OpencodeBackendConfig,
|
||||
emptyTokenUsage,
|
||||
emptyBackendResult,
|
||||
} from "./types.js";
|
||||
|
||||
export class OpencodeBackend implements IntelligenceBackend {
|
||||
readonly name = "opencode";
|
||||
readonly type: BackendType = "agent";
|
||||
|
||||
private config: OpencodeBackendConfig;
|
||||
|
||||
constructor(config?: OpencodeBackendConfig) {
|
||||
this.config = config || { enabled: true };
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const executable = this.config.executable || "opencode";
|
||||
try {
|
||||
const result = execSync(`${executable} --version`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: "pipe",
|
||||
});
|
||||
return !!result;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(request: BackendRequest): Promise<BackendResult> {
|
||||
const executable = this.config.executable || "opencode";
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const serializedRequest = this.serializeRequest(request);
|
||||
const tempFile = path.join(
|
||||
os.tmpdir(),
|
||||
`ci-request-${request.persona}-${Date.now()}.json`
|
||||
);
|
||||
|
||||
fs.writeFileSync(tempFile, serializedRequest, "utf-8");
|
||||
|
||||
const command = `${executable} --non-interactive "/ci-${request.workflow} ${request.task}"`;
|
||||
const contextEnv = {
|
||||
...process.env,
|
||||
CI_BACKEND_REQUEST: tempFile,
|
||||
CI_PROJECT_PATH: request.context.project_path,
|
||||
CI_PHASE: String(request.context.phase),
|
||||
CI_STAGE: request.context.stage,
|
||||
CI_AUTONOMY: request.autonomy,
|
||||
};
|
||||
|
||||
const result = execSync(command, {
|
||||
cwd: request.context.project_path,
|
||||
encoding: "utf-8",
|
||||
timeout: 600000,
|
||||
env: contextEnv,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
try {
|
||||
fs.unlinkSync(tempFile);
|
||||
} catch {}
|
||||
|
||||
return this.parseResult(result, Date.now() - startTime);
|
||||
} catch (err) {
|
||||
const execErr = err as { stderr?: string; status?: number };
|
||||
|
||||
try {
|
||||
const tempFile = path.join(
|
||||
os.tmpdir(),
|
||||
`ci-request-${request.persona}-${startTime}.json`
|
||||
);
|
||||
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
|
||||
} catch {}
|
||||
|
||||
if (execErr.stderr) {
|
||||
return emptyBackendResult(
|
||||
`opencode execution failed (exit ${execErr.status || "unknown"}): ${execErr.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
return emptyBackendResult(
|
||||
`opencode backend error: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private serializeRequest(request: BackendRequest): string {
|
||||
return JSON.stringify({
|
||||
persona: request.persona,
|
||||
workflow: request.workflow,
|
||||
task: request.task,
|
||||
context: {
|
||||
project_path: request.context.project_path,
|
||||
phase: request.context.phase,
|
||||
stage: request.context.stage,
|
||||
specification: request.context.specification,
|
||||
config_path: request.context.config_path,
|
||||
},
|
||||
autonomy: request.autonomy,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
private parseResult(output: string, durationMs: number): BackendResult {
|
||||
const jsonMatch = output.match(/\{[\s\S]*"success"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
return {
|
||||
success: parsed.success ?? true,
|
||||
output: parsed.output || output,
|
||||
artifacts: Array.isArray(parsed.artifacts)
|
||||
? parsed.artifacts.filter((a: unknown) => !!a).map((a: Record<string, unknown>) => ({
|
||||
path: String(a.path || ""),
|
||||
content: String(a.content || ""),
|
||||
operation: (a.operation as "create" | "update" | "delete") || "create",
|
||||
}))
|
||||
: [],
|
||||
decisions: Array.isArray(parsed.decisions)
|
||||
? parsed.decisions.filter((d: unknown) => !!d).map((d: Record<string, unknown>) => ({
|
||||
id: String(d.id || "D-000"),
|
||||
decision: String(d.decision || ""),
|
||||
rationale: String(d.rationale || ""),
|
||||
confidence: Number(d.confidence || 0.5),
|
||||
category: (d.category as "implementation_approach" | "technology_choice" | "architecture" | "scope" | "verification" | "security" | "deployment" | "general") || "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()),
|
||||
}))
|
||||
: [],
|
||||
escalations: Array.isArray(parsed.escalations)
|
||||
? parsed.escalations.filter((e: unknown) => !!e).map((e: Record<string, unknown>) => ({
|
||||
id: String(e.id || "E-000"),
|
||||
timestamp: String(e.timestamp || new Date().toISOString()),
|
||||
type: (e.type as "irreversible_action" | "verification_failure" | "low_confidence_decision" | "security_escalation" | "specification_ambiguity") || "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 "approved" | "rejected" | "modified" | "pending" | "timeout_auto_proceed") || "pending",
|
||||
audit_file: String(e.audit_file || ""),
|
||||
}))
|
||||
: [],
|
||||
usage: parsed.usage || {
|
||||
...emptyTokenUsage(),
|
||||
total_tokens: Math.ceil(output.length / 4),
|
||||
},
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output,
|
||||
artifacts: [],
|
||||
decisions: [],
|
||||
escalations: [],
|
||||
usage: {
|
||||
...emptyTokenUsage(),
|
||||
total_tokens: Math.ceil(output.length / 4),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { ToolRegistry, TOOL_DEFINITIONS } from "../backends/tool-registry.js";
|
||||
|
||||
describe("ToolRegistry Extended", () => {
|
||||
let tempDir: string;
|
||||
let registry: ToolRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-tool-registry-ext-"));
|
||||
registry = new ToolRegistry(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("readFile edge cases", () => {
|
||||
it("reads empty file", () => {
|
||||
const filePath = path.join(tempDir, "empty.txt");
|
||||
fs.writeFileSync(filePath, "");
|
||||
const result = registry.execute({ name: "readFile", arguments: { path: filePath } });
|
||||
expect(result.content).toBe("");
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
it("reads file with unicode content", () => {
|
||||
const filePath = path.join(tempDir, "unicode.txt");
|
||||
fs.writeFileSync(filePath, "héllo wörld 🌍");
|
||||
const result = registry.execute({ name: "readFile", arguments: { path: filePath } });
|
||||
expect(result.content).toBe("héllo wörld 🌍");
|
||||
});
|
||||
|
||||
it("handles unreadable file gracefully", () => {
|
||||
if (process.getuid?.() === 0) return;
|
||||
const filePath = path.join(tempDir, "unreadable.txt");
|
||||
fs.writeFileSync(filePath, "data");
|
||||
fs.chmodSync(filePath, 0o000);
|
||||
const result = registry.execute({ name: "readFile", arguments: { path: filePath } });
|
||||
expect(result.isError).toBe(true);
|
||||
fs.chmodSync(filePath, 0o644);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeFile edge cases", () => {
|
||||
it("overwrites existing file", () => {
|
||||
const filePath = path.join(tempDir, "overwrite.txt");
|
||||
fs.writeFileSync(filePath, "old");
|
||||
const result = registry.execute({ name: "writeFile", arguments: { path: filePath, content: "new" } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("new");
|
||||
});
|
||||
|
||||
it("creates nested directories", () => {
|
||||
const filePath = path.join(tempDir, "a", "b", "c", "deep.txt");
|
||||
const result = registry.execute({ name: "writeFile", arguments: { path: filePath, content: "deep" } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("deep");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editFile edge cases", () => {
|
||||
it("replaces only first occurrence", () => {
|
||||
const filePath = path.join(tempDir, "multi.txt");
|
||||
fs.writeFileSync(filePath, "aaa bbb aaa");
|
||||
const result = registry.execute({ name: "editFile", arguments: { path: filePath, old: "aaa", new: "zzz" } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("zzz bbb aaa");
|
||||
});
|
||||
|
||||
it("handles empty old string", () => {
|
||||
const filePath = path.join(tempDir, "empty-old.txt");
|
||||
fs.writeFileSync(filePath, "hello");
|
||||
const result = registry.execute({ name: "editFile", arguments: { path: filePath, old: "", new: "X" } });
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toContain("X");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBash edge cases", () => {
|
||||
it("respects cwd argument", () => {
|
||||
const subDir = path.join(tempDir, "subdir");
|
||||
fs.mkdirSync(subDir);
|
||||
const result = registry.execute({ name: "runBash", arguments: { command: "pwd", cwd: subDir } });
|
||||
expect(result.content).toContain("subdir");
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
it("respects timeout argument", () => {
|
||||
const result = registry.execute({ name: "runBash", arguments: { command: "sleep 100", timeout: 500 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("captures stderr in error output", () => {
|
||||
const result = registry.execute({ name: "runBash", arguments: { command: "echo error >&2 && exit 1" } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toContain("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("glob edge cases", () => {
|
||||
it("finds files in subdirectories", () => {
|
||||
const subDir = path.join(tempDir, "src");
|
||||
fs.mkdirSync(subDir);
|
||||
fs.writeFileSync(path.join(subDir, "app.ts"), "");
|
||||
fs.writeFileSync(path.join(subDir, "util.ts"), "");
|
||||
const result = registry.execute({ name: "glob", arguments: { pattern: "**/*.ts" } });
|
||||
const matches = JSON.parse(result.content);
|
||||
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("returns empty array for no matches", () => {
|
||||
const result = registry.execute({ name: "glob", arguments: { pattern: "*.xyz" } });
|
||||
const matches = JSON.parse(result.content);
|
||||
expect(matches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grep edge cases", () => {
|
||||
it("supports include pattern filter", () => {
|
||||
fs.writeFileSync(path.join(tempDir, "app.ts"), "const x = 1;\n");
|
||||
fs.writeFileSync(path.join(tempDir, "app.js"), "const x = 1;\n");
|
||||
const result = registry.execute({ name: "grep", arguments: { pattern: "const", include: "*.ts" } });
|
||||
const matches = JSON.parse(result.content);
|
||||
expect(matches.every((m: { file: string }) => m.file.endsWith(".ts"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty for no matches", () => {
|
||||
fs.writeFileSync(path.join(tempDir, "app.ts"), "nothing interesting\n");
|
||||
const result = registry.execute({ name: "grep", arguments: { pattern: "NONEXISTENT_PATTERN_XYZ", include: "*.ts" } });
|
||||
const matches = JSON.parse(result.content);
|
||||
expect(matches).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { ToolRegistry, TOOL_DEFINITIONS } from "../backends/tool-registry.js";
|
||||
|
||||
describe("ToolRegistry", () => {
|
||||
let tempDir: string;
|
||||
let registry: ToolRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-tool-registry-test-"));
|
||||
registry = new ToolRegistry(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("definitions", () => {
|
||||
it("provides 6 tool definitions", () => {
|
||||
expect(TOOL_DEFINITIONS).toHaveLength(6);
|
||||
const names = TOOL_DEFINITIONS.map((d) => d.name);
|
||||
expect(names).toContain("readFile");
|
||||
expect(names).toContain("writeFile");
|
||||
expect(names).toContain("editFile");
|
||||
expect(names).toContain("runBash");
|
||||
expect(names).toContain("glob");
|
||||
expect(names).toContain("grep");
|
||||
});
|
||||
|
||||
it("getOpenAIToolSchema returns function-type schema", () => {
|
||||
const schema = registry.getOpenAIToolSchema();
|
||||
expect(schema.length).toBe(6);
|
||||
expect(schema[0].type).toBe("function");
|
||||
expect((schema[0].function as Record<string, unknown>).name).toBeDefined();
|
||||
expect((schema[0].function as Record<string, unknown>).parameters).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFile", () => {
|
||||
it("reads an existing file", () => {
|
||||
const filePath = path.join(tempDir, "test.txt");
|
||||
fs.writeFileSync(filePath, "hello world");
|
||||
const result = registry.execute({ name: "readFile", arguments: { path: filePath } });
|
||||
expect(result.name).toBe("readFile");
|
||||
expect(result.content).toBe("hello world");
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
it("returns error for missing file", () => {
|
||||
const result = registry.execute({ name: "readFile", arguments: { path: "/nonexistent/file.txt" } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toContain("not found");
|
||||
});
|
||||
|
||||
it("returns error for files exceeding max size", () => {
|
||||
const bigRegistry = new ToolRegistry(tempDir, 10);
|
||||
const filePath = path.join(tempDir, "big.txt");
|
||||
fs.writeFileSync(filePath, "x".repeat(100));
|
||||
const result = bigRegistry.execute({ name: "readFile", arguments: { path: filePath } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toContain("too large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeFile", () => {
|
||||
it("writes a file creating parent directories", () => {
|
||||
const filePath = path.join(tempDir, "sub", "dir", "test.txt");
|
||||
const result = registry.execute({ name: "writeFile", arguments: { path: filePath, content: "written" } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("written");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editFile", () => {
|
||||
it("replaces an exact string in a file", () => {
|
||||
const filePath = path.join(tempDir, "edit.txt");
|
||||
fs.writeFileSync(filePath, "hello world");
|
||||
const result = registry.execute({ name: "editFile", arguments: { path: filePath, old: "hello", new: "goodbye" } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("goodbye world");
|
||||
});
|
||||
|
||||
it("returns error when old string not found", () => {
|
||||
const filePath = path.join(tempDir, "edit.txt");
|
||||
fs.writeFileSync(filePath, "hello world");
|
||||
const result = registry.execute({ name: "editFile", arguments: { path: filePath, old: "missing", new: "replacement" } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("returns error for missing file", () => {
|
||||
const result = registry.execute({ name: "editFile", arguments: { path: "/nonexistent", old: "a", new: "b" } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBash", () => {
|
||||
it("executes a command and returns stdout", () => {
|
||||
const result = registry.execute({ name: "runBash", arguments: { command: "echo hello" } });
|
||||
expect(result.content).toContain("hello");
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
it("returns error with stderr for failing commands", () => {
|
||||
const result = registry.execute({ name: "runBash", arguments: { command: "false" } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toContain("Exit code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("glob", () => {
|
||||
it("finds files matching a pattern", () => {
|
||||
fs.writeFileSync(path.join(tempDir, "app.ts"), "");
|
||||
fs.writeFileSync(path.join(tempDir, "app.test.ts"), "");
|
||||
fs.writeFileSync(path.join(tempDir, "README.md"), "");
|
||||
const result = registry.execute({ name: "glob", arguments: { pattern: "*.ts" } });
|
||||
const matches = JSON.parse(result.content);
|
||||
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grep", () => {
|
||||
it("finds matching lines", () => {
|
||||
fs.writeFileSync(path.join(tempDir, "app.ts"), "export function main() {}\nconst x = 1;\n");
|
||||
const result = registry.execute({ name: "grep", arguments: { pattern: "export", include: "*.ts" } });
|
||||
const matches = JSON.parse(result.content);
|
||||
expect(matches.length).toBe(1);
|
||||
expect(matches[0].content).toContain("export");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown tool", () => {
|
||||
it("returns error for unknown tool name", () => {
|
||||
const result = registry.execute({ name: "unknownTool", arguments: {} });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toContain("Unknown tool");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: "object";
|
||||
properties: Record<string, { type: string; description: string }>;
|
||||
required: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
name: string;
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
{
|
||||
name: "readFile",
|
||||
description: "Read the contents of a file at the given path",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Absolute file path to read" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "writeFile",
|
||||
description: "Write content to a file, creating it if it doesn't exist",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Absolute file path to write" },
|
||||
content: { type: "string", description: "Content to write to the file" },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "editFile",
|
||||
description: "Replace an exact string in a file with a new string",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Absolute file path to edit" },
|
||||
old: { type: "string", description: "Exact string to find in the file" },
|
||||
new: { type: "string", description: "String to replace it with" },
|
||||
},
|
||||
required: ["path", "old", "new"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "runBash",
|
||||
description: "Execute a bash command and return stdout/stderr",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: { type: "string", description: "Bash command to execute" },
|
||||
cwd: { type: "string", description: "Working directory for the command" },
|
||||
timeout: { type: "number", description: "Timeout in milliseconds (default 30000)" },
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "glob",
|
||||
description: "Find files matching a glob pattern recursively",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: "Glob pattern (e.g. **/*.ts)" },
|
||||
cwd: { type: "string", description: "Directory to search in" },
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "grep",
|
||||
description: "Search file contents using a regular expression",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: "Regex pattern to search for" },
|
||||
include: { type: "string", description: "File pattern to include (e.g. *.ts)" },
|
||||
cwd: { type: "string", description: "Directory to search in" },
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export class ToolRegistry {
|
||||
private projectPath: string;
|
||||
private maxFileSize: number;
|
||||
|
||||
constructor(projectPath: string, maxFileSize: number = 1024 * 1024) {
|
||||
this.projectPath = projectPath;
|
||||
this.maxFileSize = maxFileSize;
|
||||
}
|
||||
|
||||
execute(call: ToolCall): ToolResult {
|
||||
try {
|
||||
switch (call.name) {
|
||||
case "readFile":
|
||||
return this.readFile(call.arguments);
|
||||
case "writeFile":
|
||||
return this.writeFile(call.arguments);
|
||||
case "editFile":
|
||||
return this.editFile(call.arguments);
|
||||
case "runBash":
|
||||
return this.runBash(call.arguments);
|
||||
case "glob":
|
||||
return this.glob(call.arguments);
|
||||
case "grep":
|
||||
return this.grep(call.arguments);
|
||||
default:
|
||||
return { name: call.name, content: `Unknown tool: ${call.name}`, isError: true };
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
name: call.name,
|
||||
content: `Tool error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getDefinitions(): ToolDefinition[] {
|
||||
return TOOL_DEFINITIONS;
|
||||
}
|
||||
|
||||
getOpenAIToolSchema(): Array<Record<string, unknown>> {
|
||||
return TOOL_DEFINITIONS.map((def) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
parameters: def.parameters,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
private readFile(args: Record<string, unknown>): ToolResult {
|
||||
const filePath = String(args.path);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { name: "readFile", content: `File not found: ${filePath}`, isError: true };
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > this.maxFileSize) {
|
||||
return { name: "readFile", content: `File too large: ${filePath} (${stat.size} bytes)`, isError: true };
|
||||
}
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
return { name: "readFile", content };
|
||||
} catch (err) {
|
||||
return { name: "readFile", content: `Read error: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
private writeFile(args: Record<string, unknown>): ToolResult {
|
||||
const filePath = String(args.path);
|
||||
const content = String(args.content);
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
return { name: "writeFile", content: `Written: ${filePath}` };
|
||||
} catch (err) {
|
||||
return { name: "writeFile", content: `Write error: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
private editFile(args: Record<string, unknown>): ToolResult {
|
||||
const filePath = String(args.path);
|
||||
const oldStr = String(args.old);
|
||||
const newStr = String(args.new);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { name: "editFile", content: `File not found: ${filePath}`, isError: true };
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
if (!content.includes(oldStr)) {
|
||||
return { name: "editFile", content: `String not found in ${filePath}`, isError: true };
|
||||
}
|
||||
const updated = content.replace(oldStr, newStr);
|
||||
fs.writeFileSync(filePath, updated, "utf-8");
|
||||
return { name: "editFile", content: `Edited: ${filePath}` };
|
||||
} catch (err) {
|
||||
return { name: "editFile", content: `Edit error: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
private runBash(args: Record<string, unknown>): ToolResult {
|
||||
const command = String(args.command);
|
||||
const cwd = args.cwd ? String(args.cwd) : this.projectPath;
|
||||
const timeout = args.timeout ? Number(args.timeout) : 30000;
|
||||
try {
|
||||
const stdout = execSync(command, {
|
||||
cwd,
|
||||
timeout,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return { name: "runBash", content: stdout || "(no output)" };
|
||||
} catch (err: unknown) {
|
||||
const execErr = err as { stderr?: string; stdout?: string; status?: number };
|
||||
const output = [`Exit code: ${execErr.status || 1}`, `stdout: ${execErr.stdout || ""}`, `stderr: ${execErr.stderr || ""}`].join("\n");
|
||||
return { name: "runBash", content: output, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
private glob(args: Record<string, unknown>): ToolResult {
|
||||
const pattern = String(args.pattern);
|
||||
const cwd = args.cwd ? String(args.cwd) : this.projectPath;
|
||||
const matches = this.globRecursive(cwd, pattern);
|
||||
return { name: "glob", content: JSON.stringify(matches.slice(0, 200)) };
|
||||
}
|
||||
|
||||
private grep(args: Record<string, unknown>): ToolResult {
|
||||
const pattern = String(args.pattern);
|
||||
const cwd = args.cwd ? String(args.cwd) : this.projectPath;
|
||||
const include = args.include ? String(args.include) : undefined;
|
||||
const matches = this.grepRecursive(cwd, pattern, include);
|
||||
return { name: "grep", content: JSON.stringify(matches.slice(0, 100)) };
|
||||
}
|
||||
|
||||
private globRecursive(dir: string, pattern: string): string[] {
|
||||
const results: string[] = [];
|
||||
const regex = this.globToRegex(pattern);
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === ".git") continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...this.globRecursive(fullPath, pattern));
|
||||
} else if (regex.test(entry.name) || regex.test(path.relative(this.projectPath, fullPath))) {
|
||||
results.push(path.relative(this.projectPath, fullPath));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return results.sort();
|
||||
}
|
||||
|
||||
private globToRegex(pattern: string): RegExp {
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*\*/g, "{{GLOBSTAR}}")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/{{GLOBSTAR}}/g, ".*")
|
||||
.replace(/\?/g, "[^/]");
|
||||
return new RegExp(`^${escaped}$`);
|
||||
}
|
||||
|
||||
private grepRecursive(dir: string, patternStr: string, include?: string): Array<{ file: string; line: number; content: string }> {
|
||||
const results: Array<{ file: string; line: number; content: string }> = [];
|
||||
const regex = new RegExp(patternStr);
|
||||
const includeRegex = include ? this.globToRegex(include) : null;
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === ".git") continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...this.grepRecursive(fullPath, patternStr, include));
|
||||
} else if (includeRegex ? includeRegex.test(entry.name) : true) {
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (regex.test(lines[i])) {
|
||||
results.push({
|
||||
file: path.relative(this.projectPath, fullPath),
|
||||
line: i + 1,
|
||||
content: lines[i].trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { AgentName, AutonomyLevel, ModelProfile } from "../types/config.js";
|
||||
import { AgentContext } from "../agents/base.js";
|
||||
import { Decision } from "../types/decisions.js";
|
||||
import { Escalation } from "../types/escalation.js";
|
||||
|
||||
export type BackendType = "llm" | "agent";
|
||||
|
||||
export interface BackendRequest {
|
||||
persona: AgentName;
|
||||
workflow: string;
|
||||
task: string;
|
||||
context: AgentContext;
|
||||
autonomy: AutonomyLevel;
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
path: string;
|
||||
content: string;
|
||||
operation: "create" | "update" | "delete";
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
estimated_cost_usd: number;
|
||||
}
|
||||
|
||||
export interface BackendResult {
|
||||
success: boolean;
|
||||
output: string;
|
||||
artifacts: Artifact[];
|
||||
decisions: Decision[];
|
||||
escalations: Escalation[];
|
||||
usage: TokenUsage;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IntelligenceBackend {
|
||||
readonly name: string;
|
||||
readonly type: BackendType;
|
||||
isAvailable(): Promise<boolean>;
|
||||
execute(request: BackendRequest): Promise<BackendResult>;
|
||||
}
|
||||
|
||||
export interface LLMBackendConfig {
|
||||
base_url: string;
|
||||
model_profile: ModelProfile;
|
||||
model?: string;
|
||||
timeout_ms?: number;
|
||||
}
|
||||
|
||||
export interface OllamaLocalConfig extends LLMBackendConfig {
|
||||
base_url: string;
|
||||
model_profile: ModelProfile;
|
||||
model?: string;
|
||||
timeout_ms?: number;
|
||||
}
|
||||
|
||||
export interface OllamaCloudConfig extends LLMBackendConfig {
|
||||
base_url: string;
|
||||
api_key_env: string;
|
||||
model_profile: ModelProfile;
|
||||
model?: string;
|
||||
timeout_ms?: number;
|
||||
}
|
||||
|
||||
export interface OpencodeBackendConfig {
|
||||
enabled: boolean;
|
||||
executable?: string;
|
||||
}
|
||||
|
||||
export interface BackendConfigSection {
|
||||
provider: "auto" | "opencode" | "ollama-local" | "ollama-cloud";
|
||||
fallback?: "opencode" | "ollama-local" | "ollama-cloud";
|
||||
agent_backends: {
|
||||
opencode?: OpencodeBackendConfig;
|
||||
};
|
||||
llm_backends: {
|
||||
"ollama-local"?: OllamaLocalConfig;
|
||||
"ollama-cloud"?: OllamaCloudConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_BACKEND_CONFIG: BackendConfigSection = {
|
||||
provider: "auto",
|
||||
agent_backends: {
|
||||
opencode: { enabled: true },
|
||||
},
|
||||
llm_backends: {
|
||||
"ollama-local": {
|
||||
base_url: "http://localhost:11434",
|
||||
model_profile: "balanced",
|
||||
},
|
||||
"ollama-cloud": {
|
||||
base_url: "",
|
||||
api_key_env: "OLLAMA_CLOUD_API_KEY",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 60000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export class BackendUnavailableError extends Error {
|
||||
readonly backendName: string;
|
||||
readonly agentName?: string;
|
||||
|
||||
constructor(backendName: string, agentName?: string) {
|
||||
const agentMsg = agentName ? ` (agent: ${agentName})` : "";
|
||||
super(
|
||||
`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`
|
||||
);
|
||||
this.name = "BackendUnavailableError";
|
||||
this.backendName = backendName;
|
||||
this.agentName = agentName;
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyTokenUsage(): TokenUsage {
|
||||
return { input_tokens: 0, output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0 };
|
||||
}
|
||||
|
||||
export function emptyBackendResult(error?: string): BackendResult {
|
||||
return {
|
||||
success: false,
|
||||
output: "",
|
||||
artifacts: [],
|
||||
decisions: [],
|
||||
escalations: [],
|
||||
usage: emptyTokenUsage(),
|
||||
error,
|
||||
};
|
||||
}
|
||||
+242
-21
@@ -12,8 +12,12 @@ import { loadSpecification as loadSpec } from "../core/clarify.js";
|
||||
import { AgentContext } from "../agents/base.js";
|
||||
import { ErrorRecovery } from "../core/error-recovery.js";
|
||||
import { PipelineState, createInitialPipelineState } from "../types/pipeline.js";
|
||||
import { resolveBackend } from "../backends/index.js";
|
||||
import { BackendUnavailableError } from "../backends/types.js";
|
||||
import { getAgent } from "../agents/index.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export function createInitCommand(): Command {
|
||||
return new Command("init")
|
||||
@@ -28,6 +32,7 @@ export function createInitCommand(): Command {
|
||||
)
|
||||
.option("--model-profile <profile>", "Model profile: quality, speed, balanced", "quality")
|
||||
.option("--no-parallel", "Disable parallel agent execution")
|
||||
.option("--backend <provider>", "Intelligence backend: auto, opencode, ollama-local, ollama-cloud", "auto")
|
||||
.action(async (specification, options) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
@@ -71,10 +76,19 @@ export function createInitCommand(): Command {
|
||||
max_concurrent_agents: 5,
|
||||
min_plans_for_parallel: 2,
|
||||
},
|
||||
backend: {
|
||||
provider: options.backend || "auto",
|
||||
agent_backends: { opencode: { enabled: true } },
|
||||
llm_backends: {
|
||||
"ollama-local": { base_url: "http://localhost:11434", model_profile: "balanced" },
|
||||
"ollama-cloud": { base_url: "", api_key_env: "OLLAMA_CLOUD_API_KEY", model_profile: "quality", timeout_ms: 60000 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const fullConfig = initCI(projectPath, config);
|
||||
console.log(`✓ CI project initialized (autonomy: ${autonomyLevel})`);
|
||||
console.log(` Backend: ${options.backend || "auto"}`);
|
||||
|
||||
if (specText) {
|
||||
const spec: Specification = parseSpecification(specText, options.spec ? "file" : "inline");
|
||||
@@ -109,12 +123,48 @@ export function createInitCommand(): Command {
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveBackendForCommand(config: CIConfig, overrideBackend?: string): Promise<{ backend: import("../backends/types.js").IntelligenceBackend | undefined; error?: string }> {
|
||||
const backendConfig = { ...config.backend };
|
||||
if (overrideBackend) {
|
||||
backendConfig.provider = overrideBackend as typeof backendConfig.provider;
|
||||
}
|
||||
|
||||
if (backendConfig.provider === "auto") {
|
||||
try {
|
||||
const backend = await resolveBackend(backendConfig);
|
||||
console.log(` Backend: ${backend.name} (${backend.type})`);
|
||||
return { backend };
|
||||
} catch (err) {
|
||||
if (err instanceof BackendUnavailableError) {
|
||||
return { backend: undefined, error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { createBackend } = await import("../backends/index.js");
|
||||
const backend = createBackend(backendConfig.provider, backendConfig);
|
||||
if (await backend.isAvailable()) {
|
||||
console.log(` Backend: ${backend.name} (${backend.type})`);
|
||||
return { backend };
|
||||
}
|
||||
return { backend: undefined, error: `Configured backend "${backendConfig.provider}" is not available.` };
|
||||
} catch (err) {
|
||||
if (err instanceof BackendUnavailableError) {
|
||||
return { backend: undefined, error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRunCommand(): Command {
|
||||
return new Command("run")
|
||||
.description("Execute a specific phase autonomously")
|
||||
.argument("[phase]", "Phase to run: research, plan, execute, verify, or --all")
|
||||
.option("--all", "Execute all remaining phases sequentially")
|
||||
.option("--phase <number>", "Phase number", "1")
|
||||
.option("--backend <provider>", "Override intelligence backend for this run")
|
||||
.action(async (phase, options) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
@@ -124,6 +174,13 @@ export function createRunCommand(): Command {
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
|
||||
|
||||
if (!backend && backendError) {
|
||||
console.warn(` ⚠ No intelligence backend available: ${backendError}`);
|
||||
console.warn(" Continuing with mechanical-only execution (limited functionality).");
|
||||
}
|
||||
|
||||
const orchestrator = new OrchestratorAgent(config);
|
||||
const context: AgentContext = {
|
||||
project_path: projectPath,
|
||||
@@ -131,6 +188,7 @@ export function createRunCommand(): Command {
|
||||
stage: phase || "all",
|
||||
specification: "",
|
||||
config_path: path.join(projectPath, ".ci", "config.json"),
|
||||
backend,
|
||||
};
|
||||
|
||||
const spec = loadSpec(projectPath);
|
||||
@@ -163,7 +221,8 @@ export function createQuickCommand(): Command {
|
||||
return new Command("quick")
|
||||
.description("Execute an ad-hoc task with full agentic guarantees")
|
||||
.argument("<description>", "Task description")
|
||||
.action(async (description) => {
|
||||
.option("--backend <provider>", "Override intelligence backend")
|
||||
.action(async (description, options) => {
|
||||
const projectPath = process.cwd();
|
||||
console.log(`Quick task: ${description}`);
|
||||
|
||||
@@ -173,6 +232,14 @@ export function createQuickCommand(): Command {
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
|
||||
|
||||
if (!backend) {
|
||||
console.error(`\n✗ "ci quick" requires an intelligence backend.`);
|
||||
if (backendError) console.error(` ${backendError}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const spec = parseSpecification(description, "inline");
|
||||
saveSpecification(projectPath, spec);
|
||||
|
||||
@@ -183,6 +250,7 @@ export function createQuickCommand(): Command {
|
||||
stage: "all",
|
||||
specification: description,
|
||||
config_path: path.join(projectPath, ".ci", "config.json"),
|
||||
backend,
|
||||
};
|
||||
|
||||
const result = await orchestrator.execute(context);
|
||||
@@ -202,6 +270,7 @@ export function createDebugCommand(): Command {
|
||||
.description("Autonomous debugging: diagnose root cause, propose fix")
|
||||
.argument("[description]", "Description of the issue to debug")
|
||||
.option("--confidence <threshold>", "Minimum confidence to auto-fix", "0.6")
|
||||
.option("--backend <provider>", "Override intelligence backend")
|
||||
.action(async (description, options) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
@@ -210,18 +279,39 @@ export function createDebugCommand(): Command {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
|
||||
|
||||
if (!backend) {
|
||||
console.error(`\n✗ "ci debug" requires an intelligence backend.`);
|
||||
if (backendError) console.error(` ${backendError}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Starting autonomous debug...");
|
||||
if (description) {
|
||||
console.log(` Issue: ${description}`);
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const recovery = new ErrorRecovery(config, projectPath);
|
||||
|
||||
console.log(` Confidence threshold: ${options.confidence}`);
|
||||
console.log(" Diagnosing root cause...");
|
||||
|
||||
console.log("\n✓ Debug complete — autonomous diagnosis finished");
|
||||
const debuggerAgent = getAgent("debugger");
|
||||
const context: AgentContext = {
|
||||
project_path: projectPath,
|
||||
phase: 0,
|
||||
stage: "debug",
|
||||
specification: description || "",
|
||||
config_path: path.join(projectPath, ".ci", "config.json"),
|
||||
backend,
|
||||
};
|
||||
|
||||
const result = await debuggerAgent.execute(context);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`\n✓ ${result.output}`);
|
||||
} else {
|
||||
console.error(`\n✗ Debug failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,6 +320,7 @@ export function createVerifyCommand(): Command {
|
||||
.description("Automated verification of a phase")
|
||||
.argument("[phase]", "Phase number to verify", "1")
|
||||
.option("--layer <layer>", "Run specific layer: structural, behavioral, security, quality", "all")
|
||||
.option("--backend <provider>", "Override intelligence backend for behavioral verification")
|
||||
.action(async (phase, options) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
@@ -276,7 +367,8 @@ export function createReviewCommand(): Command {
|
||||
return new Command("review")
|
||||
.description("Multi-persona autonomous code review")
|
||||
.argument("[phase]", "Phase number to review", "1")
|
||||
.action(async (phase) => {
|
||||
.option("--backend <provider>", "Override intelligence backend")
|
||||
.action(async (phase, options) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
if (!isCIInitialized(projectPath)) {
|
||||
@@ -284,9 +376,36 @@ export function createReviewCommand(): Command {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
|
||||
|
||||
if (!backend) {
|
||||
console.error(`\n✗ "ci review" requires an intelligence backend.`);
|
||||
if (backendError) console.error(` ${backendError}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const phaseNum = parseInt(phase) || 1;
|
||||
console.log(`Running code review for phase ${phaseNum}...`);
|
||||
console.log("Review complete — findings logged to audit trail");
|
||||
|
||||
const reviewer = getAgent("code-reviewer");
|
||||
const context: AgentContext = {
|
||||
project_path: projectPath,
|
||||
phase: phaseNum,
|
||||
stage: "review",
|
||||
specification: "",
|
||||
config_path: path.join(projectPath, ".ci", "config.json"),
|
||||
backend,
|
||||
};
|
||||
|
||||
const result = await reviewer.execute(context);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`\n✓ ${result.output}`);
|
||||
} else {
|
||||
console.error(`\n✗ Review failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -308,6 +427,7 @@ export function createStatusCommand(): Command {
|
||||
console.log("─── CI Project Status ───");
|
||||
console.log(`\nAutonomy: ${config.autonomy.level}`);
|
||||
console.log(`Model Profile: ${config.model_profile}`);
|
||||
console.log(`Backend: ${config.backend?.provider || "auto"}`);
|
||||
console.log(`Parallelization: ${config.parallelization.enabled ? "enabled" : "disabled"}`);
|
||||
|
||||
const state = artifacts.readState();
|
||||
@@ -393,7 +513,8 @@ export function createAuditCommand(): Command {
|
||||
export function createClarifyCommand(): Command {
|
||||
return new Command("clarify")
|
||||
.description("Re-run the Clarify phase if new ambiguities have emerged")
|
||||
.action(() => {
|
||||
.option("--backend <provider>", "Use intelligence backend for question generation")
|
||||
.action(async (options) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
if (!isCIInitialized(projectPath)) {
|
||||
@@ -432,6 +553,7 @@ export function createRollbackCommand(): Command {
|
||||
.description("Autonomous undo with automatic dependency resolution")
|
||||
.argument("<target>", "Phase number or plan ID to rollback to")
|
||||
.option("--force", "Force rollback even with downstream dependencies")
|
||||
.option("--backend <provider>", "Use intelligence backend for dependency resolution")
|
||||
.action(async (target, options) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
@@ -440,17 +562,82 @@ export function createRollbackCommand(): Command {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Rolling back to: ${target}`);
|
||||
const phaseNum = parseInt(target) || 0;
|
||||
console.log(`Rolling back to phase ${phaseNum}...`);
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const recovery = new ErrorRecovery(config, projectPath);
|
||||
const result = await recovery.rollback(parseInt(target) || 0, "User-requested rollback");
|
||||
try {
|
||||
const branchName = `phase/${String(phaseNum).padStart(2, "0")}-*`;
|
||||
const branches = execSync("git branch --list", {
|
||||
cwd: projectPath,
|
||||
encoding: "utf-8",
|
||||
}).split("\n").map((b) => b.trim()).filter(Boolean);
|
||||
|
||||
if (result.recovered) {
|
||||
console.log(`✓ Rollback complete: ${result.message}`);
|
||||
} else {
|
||||
console.error(`✗ Rollback failed: ${result.message}`);
|
||||
process.exit(1);
|
||||
const phaseBranches = branches.filter((b) =>
|
||||
b.includes(`phase/${String(phaseNum).padStart(2, "0")}`)
|
||||
);
|
||||
|
||||
if (phaseBranches.length > 0 && !options.force) {
|
||||
console.log(`Found phase ${phaseNum} branches:`);
|
||||
for (const b of phaseBranches) {
|
||||
console.log(` ${b}`);
|
||||
}
|
||||
console.log("\nChecking for downstream dependencies...");
|
||||
|
||||
const downstreamPhases = branches.filter((b) => {
|
||||
const match = b.match(/phase\/(\d+)/);
|
||||
if (!match) return false;
|
||||
return parseInt(match[1]) > phaseNum;
|
||||
});
|
||||
|
||||
if (downstreamPhases.length > 0) {
|
||||
console.warn(`⚠ Downstream phases found:`);
|
||||
for (const b of downstreamPhases) {
|
||||
console.warn(` ${b}`);
|
||||
}
|
||||
console.warn("Use --force to rollback anyway.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const targetCommit = execSync(
|
||||
`git log --all --grep="phase: ${phaseNum}" --format="%H" -1`,
|
||||
{ cwd: projectPath, encoding: "utf-8" }
|
||||
).trim();
|
||||
|
||||
if (targetCommit) {
|
||||
console.log(` Resetting to commit: ${targetCommit.slice(0, 8)}`);
|
||||
execSync(`git reset --hard ${targetCommit}`, {
|
||||
cwd: projectPath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
console.log(`✓ Rollback complete: reset to phase ${phaseNum}`);
|
||||
} else {
|
||||
console.warn(` Could not find phase ${phaseNum} commit. Performing branch cleanup only.`);
|
||||
|
||||
for (const b of phaseBranches) {
|
||||
const cleanName = b.replace(/^\*?\s*/, "");
|
||||
if (cleanName) {
|
||||
try {
|
||||
execSync(`git branch -D ${cleanName}`, {
|
||||
cwd: projectPath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
console.log(` Deleted branch: ${cleanName}`);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
console.log(`✓ Rollback complete: cleaned up phase ${phaseNum} branches`);
|
||||
}
|
||||
} catch (err) {
|
||||
const recovery = new ErrorRecovery(loadConfig(projectPath), projectPath);
|
||||
const result = await recovery.rollback(phaseNum, "User-requested rollback");
|
||||
|
||||
if (result.recovered) {
|
||||
console.log(`✓ Rollback complete: ${result.message}`);
|
||||
} else {
|
||||
console.error(`✗ Rollback failed: ${result.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -459,7 +646,8 @@ export function createShipCommand(): Command {
|
||||
return new Command("ship")
|
||||
.description("Auto-complete phase: verify, security, commit, tag")
|
||||
.argument("[phase]", "Phase number to ship", "1")
|
||||
.action(async (phase) => {
|
||||
.option("--backend <provider>", "Override intelligence backend")
|
||||
.action(async (phase, options) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
if (!isCIInitialized(projectPath)) {
|
||||
@@ -491,7 +679,40 @@ export function createShipCommand(): Command {
|
||||
console.log("\n Resolve escalations before deploying.");
|
||||
}
|
||||
|
||||
console.log(" Committing and tagging...");
|
||||
const config = loadConfig(projectPath);
|
||||
const milestone = "v1.0";
|
||||
|
||||
try {
|
||||
const isGitRepo = execSync("git rev-parse --is-inside-work-tree", {
|
||||
cwd: projectPath,
|
||||
encoding: "utf-8",
|
||||
}).trim() === "true";
|
||||
|
||||
if (isGitRepo) {
|
||||
console.log(" Committing and tagging...");
|
||||
const tag = `${milestone}-phase${phaseNum}`;
|
||||
try {
|
||||
execSync(`git add -A`, { cwd: projectPath, stdio: "pipe" });
|
||||
execSync(`git commit -m "chore: ship phase ${phaseNum}" --allow-empty`, {
|
||||
cwd: projectPath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git tag -a ${tag} -m "CI: Phase ${phaseNum} shipped"`, {
|
||||
cwd: projectPath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
console.log(` ✓ Tagged: ${tag}`);
|
||||
|
||||
if (config.git.auto_push) {
|
||||
execSync(`git push origin ${tag}`, { cwd: projectPath, stdio: "pipe" });
|
||||
console.log(` ✓ Pushed tag: ${tag}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ Git operations failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
console.log(`\n✓ Phase ${phaseNum} shipped successfully`);
|
||||
});
|
||||
}
|
||||
@@ -17,17 +17,16 @@ describe("ArtifactManager", () => {
|
||||
});
|
||||
|
||||
describe("ensureStructure", () => {
|
||||
it("creates .planning directory structure", () => {
|
||||
it("creates .ci directory structure", () => {
|
||||
manager.ensureStructure();
|
||||
expect(fs.existsSync(path.join(tempDir, ".planning"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tempDir, ".planning", "phases"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tempDir, ".ci", "audit"))).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
manager.ensureStructure();
|
||||
manager.ensureStructure();
|
||||
expect(fs.existsSync(path.join(tempDir, ".planning"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,7 +67,7 @@ describe("ArtifactManager", () => {
|
||||
|
||||
manager.writeProject(manifest);
|
||||
|
||||
const projectPath = path.join(tempDir, ".planning", "PROJECT.md");
|
||||
const projectPath = path.join(tempDir, ".ci", "PROJECT.md");
|
||||
expect(fs.existsSync(projectPath)).toBe(true);
|
||||
const content = fs.readFileSync(projectPath, "utf-8");
|
||||
expect(content).toContain("Test Project");
|
||||
@@ -132,7 +131,7 @@ describe("ArtifactManager", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const decisionsPath = path.join(tempDir, ".planning", "DECISIONS.md");
|
||||
const decisionsPath = path.join(tempDir, ".ci", "DECISIONS.md");
|
||||
expect(fs.existsSync(decisionsPath)).toBe(true);
|
||||
const content = fs.readFileSync(decisionsPath, "utf-8");
|
||||
expect(content).toContain("D-001");
|
||||
|
||||
+14
-14
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { writeFile, readFile, ensureDir } from "../utils/file.js";
|
||||
|
||||
const PLANNING_DIR = ".planning";
|
||||
const CI_DIR = ".ci";
|
||||
|
||||
export interface ProjectManifest {
|
||||
name: string;
|
||||
@@ -48,18 +48,18 @@ export class ArtifactManager {
|
||||
this.projectPath = projectPath;
|
||||
}
|
||||
|
||||
private get planningDir(): string {
|
||||
return path.join(this.projectPath, PLANNING_DIR);
|
||||
private get ciDir(): string {
|
||||
return path.join(this.projectPath, CI_DIR);
|
||||
}
|
||||
|
||||
ensureStructure(): void {
|
||||
ensureDir(this.planningDir);
|
||||
ensureDir(path.join(this.planningDir, "phases"));
|
||||
ensureDir(path.join(this.projectPath, ".ci", "audit"));
|
||||
ensureDir(this.ciDir);
|
||||
ensureDir(path.join(this.ciDir, "phases"));
|
||||
ensureDir(path.join(this.ciDir, "audit"));
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return fs.existsSync(path.join(this.planningDir, "PROJECT.md"));
|
||||
return fs.existsSync(path.join(this.ciDir, "PROJECT.md"));
|
||||
}
|
||||
|
||||
writeProject(manifest: ProjectManifest): void {
|
||||
@@ -81,7 +81,7 @@ export class ArtifactManager {
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
writeFile(path.join(this.planningDir, "PROJECT.md"), lines.join("\n"));
|
||||
writeFile(path.join(this.ciDir, "PROJECT.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
writeDecisions(decisions: DecisionsManifest): void {
|
||||
@@ -99,11 +99,11 @@ export class ArtifactManager {
|
||||
lines.push(`- **Timestamp**: ${d.timestamp}`);
|
||||
lines.push("");
|
||||
}
|
||||
writeFile(path.join(this.planningDir, "DECISIONS.md"), lines.join("\n"));
|
||||
writeFile(path.join(this.ciDir, "DECISIONS.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
writeState(state: StateManifest): void {
|
||||
writeJSON(path.join(this.planningDir, "STATE.md.json"), state);
|
||||
writeJSON(path.join(this.ciDir, "STATE.md.json"), state);
|
||||
|
||||
const lines = [
|
||||
"# Project State",
|
||||
@@ -124,11 +124,11 @@ export class ArtifactManager {
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
writeFile(path.join(this.planningDir, "STATE.md"), lines.join("\n"));
|
||||
writeFile(path.join(this.ciDir, "STATE.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
readState(): StateManifest | null {
|
||||
const filePath = path.join(this.planningDir, "STATE.md.json");
|
||||
const filePath = path.join(this.ciDir, "STATE.md.json");
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export class ArtifactManager {
|
||||
artifactName: string,
|
||||
content: string
|
||||
): void {
|
||||
const phaseDir = path.join(this.planningDir, "phases", `phase-${phase}`);
|
||||
const phaseDir = path.join(this.ciDir, "phases", `phase-${phase}`);
|
||||
ensureDir(phaseDir);
|
||||
writeFile(path.join(phaseDir, artifactName), content);
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export class ArtifactManager {
|
||||
artifactName: string
|
||||
): string | null {
|
||||
const filePath = path.join(
|
||||
this.planningDir,
|
||||
this.ciDir,
|
||||
"phases",
|
||||
`phase-${phase}`,
|
||||
artifactName
|
||||
|
||||
@@ -25,7 +25,6 @@ describe("Audit", () => {
|
||||
confidence: 0.92,
|
||||
category: "technology_choice",
|
||||
alternatives_considered: [{ option: "MongoDB", rejected_reason: "No ACID" }],
|
||||
learnship_equivalent: "discuss-phase would ask: What database?",
|
||||
human_override: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ export function initCI(projectPath: string, config?: Partial<CIConfig>, projectS
|
||||
verification: { ...DEFAULT_CI_CONFIG.verification, ...config?.verification },
|
||||
security: { ...DEFAULT_CI_CONFIG.security, ...config?.security },
|
||||
git: { ...DEFAULT_CI_CONFIG.git, ...config?.git },
|
||||
backend: { ...DEFAULT_CI_CONFIG.backend, ...config?.backend },
|
||||
};
|
||||
saveConfig(projectPath, fullConfig);
|
||||
return fullConfig;
|
||||
|
||||
@@ -26,7 +26,6 @@ describe("DecisionEngine", () => {
|
||||
{ option: "MongoDB", rejected_reason: "No ACID transactions" },
|
||||
{ option: "SQLite", rejected_reason: "No concurrent writes" },
|
||||
],
|
||||
learnship_equivalent: "discuss-phase would ask: What database? Options: A) PostgreSQL B) MongoDB",
|
||||
};
|
||||
|
||||
describe("makeDecision", () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface DecisionInput {
|
||||
confidence: number;
|
||||
category: DecisionCategory;
|
||||
alternatives_considered: Alternative[];
|
||||
learnship_equivalent: string;
|
||||
phase?: string;
|
||||
task?: string;
|
||||
}
|
||||
@@ -57,7 +56,6 @@ export class DecisionEngine {
|
||||
confidence: input.confidence,
|
||||
category: input.category,
|
||||
alternatives_considered: input.alternatives_considered,
|
||||
learnship_equivalent: input.learnship_equivalent,
|
||||
human_override: null,
|
||||
phase: input.phase,
|
||||
task: input.task,
|
||||
@@ -101,8 +99,7 @@ export class DecisionEngine {
|
||||
decision: string,
|
||||
rationale: string,
|
||||
category: DecisionCategory,
|
||||
alternatives: Alternative[] = [],
|
||||
learnship_equivalent: string = ""
|
||||
alternatives: Alternative[] = []
|
||||
): DecisionResult {
|
||||
return this.makeDecision({
|
||||
decision,
|
||||
@@ -110,7 +107,6 @@ export class DecisionEngine {
|
||||
confidence: 0.95,
|
||||
category,
|
||||
alternatives_considered: alternatives,
|
||||
learnship_equivalent,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,8 +114,7 @@ export class DecisionEngine {
|
||||
decision: string,
|
||||
rationale: string,
|
||||
category: DecisionCategory,
|
||||
alternatives: Alternative[] = [],
|
||||
learnship_equivalent: string = ""
|
||||
alternatives: Alternative[] = []
|
||||
): DecisionResult {
|
||||
return this.makeDecision({
|
||||
decision,
|
||||
@@ -127,7 +122,6 @@ export class DecisionEngine {
|
||||
confidence: 0.7,
|
||||
category,
|
||||
alternatives_considered: alternatives,
|
||||
learnship_equivalent,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ describe("ErrorRecovery", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-recovery-test-"));
|
||||
fs.mkdirSync(path.join(tempDir, ".planning", "phases"), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, ".ci", "audit"), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, ".ci"), { recursive: true });
|
||||
recovery = new ErrorRecovery(DEFAULT_CI_CONFIG, tempDir);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { CIConfig } from "../types/config.js";
|
||||
|
||||
export interface RetryConfig {
|
||||
@@ -67,12 +68,39 @@ export class ErrorRecovery {
|
||||
}
|
||||
|
||||
async rollback(phase: number, reason: string): Promise<RecoveryResult> {
|
||||
return {
|
||||
recovered: true,
|
||||
strategy: "rollback",
|
||||
attempts: 1,
|
||||
message: `Rolled back phase ${phase}: ${reason}`,
|
||||
};
|
||||
try {
|
||||
const phaseBranch = `phase/${String(phase).padStart(2, "0")}`;
|
||||
const branches = this.git("branch --list");
|
||||
const branchExists = branches.split("\n").some((b) => b.trim().replace(/^\*?\s+/, "") === phaseBranch);
|
||||
|
||||
if (branchExists) {
|
||||
const currentBranch = this.git("rev-parse --abbrev-ref HEAD");
|
||||
if (currentBranch === phaseBranch) {
|
||||
this.git("checkout main");
|
||||
}
|
||||
this.git(`branch -D ${phaseBranch}`);
|
||||
}
|
||||
|
||||
const tag = `v0.5.${phase}`;
|
||||
const tags = this.git("tag -l").split("\n").map((t) => t.trim());
|
||||
if (tags.includes(tag)) {
|
||||
this.git(`tag -d ${tag}`);
|
||||
}
|
||||
|
||||
return {
|
||||
recovered: true,
|
||||
strategy: "rollback",
|
||||
attempts: 1,
|
||||
message: `Rolled back phase ${phase}: ${reason}. Branch ${branchExists ? `${phaseBranch} deleted` : "not found"}. Tag ${tags.includes(tag) ? `${tag} deleted` : "not found"}.`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
recovered: false,
|
||||
strategy: "rollback",
|
||||
attempts: 1,
|
||||
message: `Rollback failed for phase ${phase}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
canAutoDebug(error: string, confidence: number): boolean {
|
||||
@@ -86,4 +114,16 @@ export class ErrorRecovery {
|
||||
getMaxRevisions(): number {
|
||||
return this.config.autonomy.max_revision_iterations;
|
||||
}
|
||||
|
||||
private git(args: string): string {
|
||||
try {
|
||||
return execSync(`git ${args}`, {
|
||||
cwd: this.projectPath,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -22,6 +22,11 @@ export { createClarifyQuestion } from "./types/clarify.js";
|
||||
export { parseSpecification } from "./types/specification.js";
|
||||
export { getNextStage, createInitialPipelineState } from "./types/pipeline.js";
|
||||
export * as fileUtils from "./utils/file.js";
|
||||
export { resolveBackend, createBackend } from "./backends/index.js";
|
||||
export { OpencodeBackend } from "./backends/opencode.js";
|
||||
export { OllamaLocalBackend } from "./backends/ollama-local.js";
|
||||
export { OllamaCloudBackend } from "./backends/ollama-cloud.js";
|
||||
export { ToolRegistry } from "./backends/tool-registry.js";
|
||||
|
||||
export type { CIConfig, AutonomyLevel, ModelProfile } from "./types/config.js";
|
||||
export type { Decision, DecisionCategory } from "./types/decisions.js";
|
||||
@@ -36,4 +41,6 @@ export type { AgentName } from "./types/config.js";
|
||||
export type { CiMetadata, ParsedCiCommit, CommitType, CommitScope, CommitDecision, CommitEscalation, CommitRequirements, CommitCompoundMeta } from "./types/commit-meta.js";
|
||||
export type { ProjectState, BranchInfo } from "./core/git-context.js";
|
||||
export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMergeResult } from "./core/git-branch.js";
|
||||
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ci-files.js";
|
||||
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ci-files.js";
|
||||
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
|
||||
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BackendConfigSection } from "../backends/types.js";
|
||||
|
||||
export type AutonomyLevel = "full" | "supervised" | "guided";
|
||||
|
||||
export type ModelProfile = "quality" | "speed" | "balanced";
|
||||
@@ -76,6 +78,7 @@ export interface CIConfig {
|
||||
verification: VerificationConfig;
|
||||
security: SecurityConfig;
|
||||
git: GitConfig;
|
||||
backend: BackendConfigSection;
|
||||
}
|
||||
|
||||
export const DEFAULT_CI_CONFIG: CIConfig = {
|
||||
@@ -112,4 +115,22 @@ export const DEFAULT_CI_CONFIG: CIConfig = {
|
||||
auto_commit: true,
|
||||
auto_push: false,
|
||||
},
|
||||
backend: {
|
||||
provider: "auto",
|
||||
agent_backends: {
|
||||
opencode: { enabled: true },
|
||||
},
|
||||
llm_backends: {
|
||||
"ollama-local": {
|
||||
base_url: "http://localhost:11434",
|
||||
model_profile: "balanced",
|
||||
},
|
||||
"ollama-cloud": {
|
||||
base_url: "",
|
||||
api_key_env: "OLLAMA_CLOUD_API_KEY",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 60000,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -18,7 +18,6 @@ export interface Decision {
|
||||
confidence: number;
|
||||
category: DecisionCategory;
|
||||
alternatives_considered: Alternative[];
|
||||
learnship_equivalent: string;
|
||||
human_override: string | null;
|
||||
phase?: string;
|
||||
task?: string;
|
||||
|
||||
@@ -49,10 +49,33 @@ describe("BehavioralVerification", () => {
|
||||
expect(testFilesCheck?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("passes with specification and requirements", async () => {
|
||||
it("passes with REQUIREMENTS.md", async () => {
|
||||
const ciDir = path.join(tempDir, ".ci");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ciDir, "specification.md"), "# Test\n## Objective\nBuild it\n\n## Requirements\n- Must have auth\n- Shall support CRUD\n");
|
||||
fs.writeFileSync(path.join(ciDir, "REQUIREMENTS.md"), "# Requirements\n\n| REQ-ID | Requirement | Priority | Phase | Status |\n|--------|-------------|----------|-------|--------|\n| REQ-01 | Must have auth | P0 | 1 | pending |\n");
|
||||
|
||||
const verifier = new BehavioralVerification();
|
||||
const result = await verifier.verify(tempDir, 1);
|
||||
|
||||
const specCheck = result.checks.find((c) => c.name === "Specification requirements traceable");
|
||||
expect(specCheck?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("skips when no REQUIREMENTS.md or PROJECT.md", async () => {
|
||||
const ciDir = path.join(tempDir, ".ci");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
|
||||
const verifier = new BehavioralVerification();
|
||||
const result = await verifier.verify(tempDir, 1);
|
||||
|
||||
const specCheck = result.checks.find((c) => c.name === "Specification requirements traceable");
|
||||
expect(specCheck?.status).toBe("skipped");
|
||||
});
|
||||
|
||||
it("passes with PROJECT.md when no REQUIREMENTS.md", async () => {
|
||||
const ciDir = path.join(tempDir, ".ci");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ciDir, "PROJECT.md"), "# Test\n\n## What This Is\nBuild it\n\n## Requirements\n\n### Active\n\n- [ ] Must have auth\n- [ ] Shall support CRUD\n");
|
||||
|
||||
const verifier = new BehavioralVerification();
|
||||
const result = await verifier.verify(tempDir, 1);
|
||||
|
||||
+152
-16
@@ -1,5 +1,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
||||
|
||||
const TEST_FRAMEWORK_PATTERNS = [
|
||||
@@ -26,6 +27,7 @@ export class BehavioralVerification extends VerificationLayer {
|
||||
checks.push(this.checkSpecificationRequirements(projectPath));
|
||||
checks.push(this.checkPlanMustHaves(projectPath, phase));
|
||||
checks.push(this.checkCodeHasExports(projectPath));
|
||||
checks.push(this.checkRequirementTestCoverage(projectPath));
|
||||
|
||||
const passed = checks.every((c) => c.status !== "fail");
|
||||
return {
|
||||
@@ -106,15 +108,59 @@ export class BehavioralVerification extends VerificationLayer {
|
||||
}
|
||||
|
||||
private checkSpecificationRequirements(projectPath: string): VerificationCheck {
|
||||
const specPath = path.join(projectPath, ".ci", "specification.md");
|
||||
const reqPath = path.join(projectPath, ".ci", "REQUIREMENTS.md");
|
||||
const projectPath_md = path.join(projectPath, ".ci", "PROJECT.md");
|
||||
|
||||
const specPath = reqPath;
|
||||
if (!fs.existsSync(specPath)) {
|
||||
const altPath = projectPath_md;
|
||||
if (!fs.existsSync(altPath)) {
|
||||
return this.check(
|
||||
"Specification requirements traceable",
|
||||
"skipped",
|
||||
"No REQUIREMENTS.md or PROJECT.md found"
|
||||
);
|
||||
}
|
||||
return this.checkFromProjectMd(altPath);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(specPath, "utf-8");
|
||||
const requirements = content
|
||||
.split("\n")
|
||||
.filter((line) => /^\|.*\|.*\|.*\|/.test(line) && !line.includes("REQ-ID") && !line.includes("---"))
|
||||
.map((line) => {
|
||||
const cols = line.split("|").map((c) => c.trim()).filter(Boolean);
|
||||
return cols.length >= 2 ? cols[1] : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (requirements.length === 0) {
|
||||
const listRequirements = content
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().startsWith("- "))
|
||||
.map((line) => line.trim().slice(2));
|
||||
if (listRequirements.length === 0) {
|
||||
return this.check(
|
||||
"Specification requirements traceable",
|
||||
"warning",
|
||||
"No requirements found in REQUIREMENTS.md"
|
||||
);
|
||||
}
|
||||
return this.check(
|
||||
"Specification requirements traceable",
|
||||
"skipped",
|
||||
"No specification file found"
|
||||
"pass",
|
||||
`Found ${listRequirements.length} requirement(s)`
|
||||
);
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Specification requirements traceable",
|
||||
"pass",
|
||||
`Found ${requirements.length} requirement(s) in REQUIREMENTS.md`
|
||||
);
|
||||
}
|
||||
|
||||
private checkFromProjectMd(specPath: string): VerificationCheck {
|
||||
const content = fs.readFileSync(specPath, "utf-8");
|
||||
const requirements = content
|
||||
.split("\n")
|
||||
@@ -129,7 +175,7 @@ export class BehavioralVerification extends VerificationLayer {
|
||||
return this.check(
|
||||
"Specification requirements traceable",
|
||||
"warning",
|
||||
"No requirements found in specification"
|
||||
"No requirements found in PROJECT.md"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,41 +187,131 @@ export class BehavioralVerification extends VerificationLayer {
|
||||
}
|
||||
|
||||
private checkPlanMustHaves(projectPath: string, phase: number): VerificationCheck {
|
||||
const planPath = path.join(
|
||||
const roadmapPath = path.join(
|
||||
projectPath,
|
||||
".planning",
|
||||
"phases",
|
||||
`phase-${phase}`,
|
||||
"PLAN.md"
|
||||
".ci",
|
||||
"ROADMAP.md"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(planPath)) {
|
||||
if (!fs.existsSync(roadmapPath)) {
|
||||
return this.check(
|
||||
"Plan must-haves covered",
|
||||
"skipped",
|
||||
`No PLAN.md found for phase ${phase}`
|
||||
"No ROADMAP.md found — run 'ci init' first"
|
||||
);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(planPath, "utf-8");
|
||||
const content = fs.readFileSync(roadmapPath, "utf-8");
|
||||
const hasMustHaves = content.toLowerCase().includes("must");
|
||||
const hasTasks = content.includes("- [") || content.includes("* [");
|
||||
const hasPhases = content.includes("Phase") || content.includes("phase");
|
||||
|
||||
if (!hasTasks && !hasMustHaves) {
|
||||
if (!hasPhases && !hasMustHaves) {
|
||||
return this.check(
|
||||
"Plan must-haves covered",
|
||||
"warning",
|
||||
"PLAN.md has no tasks or must-have items"
|
||||
"ROADMAP.md has no phases or must-have items"
|
||||
);
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Plan must-haves covered",
|
||||
"pass",
|
||||
"PLAN.md contains task definitions"
|
||||
"ROADMAP.md contains phase definitions"
|
||||
);
|
||||
}
|
||||
|
||||
private checkRequirementTestCoverage(projectPath: string): VerificationCheck {
|
||||
const isGitRepo = fs.existsSync(path.join(projectPath, ".git"));
|
||||
if (!isGitRepo) {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"skipped",
|
||||
"Not a git repository — cannot check requirement coverage from commit history"
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = execSync(
|
||||
`git log --all --max-count=100 --format="%B%x01"`,
|
||||
{ cwd: projectPath, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }
|
||||
);
|
||||
|
||||
const coveredReqs = new Set<string>();
|
||||
const ciBlockRegex = /---ci---[\s\S]*?---\/ci---/g;
|
||||
const entries = raw.split("\x01").filter(Boolean);
|
||||
|
||||
for (const entry of entries) {
|
||||
let match;
|
||||
while ((match = ciBlockRegex.exec(entry)) !== null) {
|
||||
const reqMatch = match[0].match(/covered:\s*\[([^\]]*)\]/);
|
||||
if (reqMatch) {
|
||||
const reqs = reqMatch[1].split(",").map((r: string) => r.trim().replace(/['"]/g, "")).filter(Boolean);
|
||||
for (const req of reqs) coveredReqs.add(req);
|
||||
}
|
||||
}
|
||||
ciBlockRegex.lastIndex = 0;
|
||||
}
|
||||
|
||||
const reqPath = path.join(projectPath, ".ci", "REQUIREMENTS.md");
|
||||
if (!fs.existsSync(reqPath)) {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"skipped",
|
||||
"No REQUIREMENTS.md found to check coverage against"
|
||||
);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(reqPath, "utf-8");
|
||||
const allReqs = content
|
||||
.split("\n")
|
||||
.filter((line) => /^\|.*\|.*\|.*\|/.test(line) && !line.includes("REQ-ID") && !line.includes("---"))
|
||||
.map((line) => {
|
||||
const cols = line.split("|").map((c) => c.trim()).filter(Boolean);
|
||||
return cols.length >= 1 ? cols[0] : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (allReqs.length === 0) {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"skipped",
|
||||
"No requirements with REQ-IDs found in REQUIREMENTS.md"
|
||||
);
|
||||
}
|
||||
|
||||
const covered = allReqs.filter((r) => coveredReqs.has(r));
|
||||
const coveragePct = Math.round((covered.length / allReqs.length) * 100);
|
||||
|
||||
if (coveragePct >= 80) {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"pass",
|
||||
`${covered.length}/${allReqs.length} requirements covered (${coveragePct}%)`
|
||||
);
|
||||
}
|
||||
|
||||
if (coveragePct >= 50) {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"warning",
|
||||
`${covered.length}/${allReqs.length} requirements covered (${coveragePct}%) — target ≥80%`
|
||||
);
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"warning",
|
||||
`${covered.length}/${allReqs.length} requirements covered (${coveragePct}%) — significant gaps`
|
||||
);
|
||||
} catch {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"skipped",
|
||||
"Could not read git log for requirement coverage"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private checkCodeHasExports(projectPath: string): VerificationCheck {
|
||||
const srcDir = path.join(projectPath, "src");
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
|
||||
@@ -8,13 +8,11 @@ describe("VerificationPipeline", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-pipeline-test-"));
|
||||
const phaseDir = path.join(tempDir, ".planning", "phases", "phase-1");
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, "PLAN.md"), "# Plan\n\n- [ ] Task 1\n- [ ] Task 2\n");
|
||||
const ciDir = path.join(tempDir, ".ci");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify({ autonomy: { level: "full" } }));
|
||||
fs.writeFileSync(path.join(ciDir, "specification.md"), "# Test\n## Objective\nBuild it\n\n## Requirements\n- Feature A\n");
|
||||
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), "# Roadmap\n\n## Phases\n\n### Phase 1: Init\n**Goal**: Set up project\n**Status**: not_started\n");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
||||
|
||||
interface CodeFinding {
|
||||
@@ -28,7 +29,7 @@ const CODE_QUALITY_PATTERNS: Array<{
|
||||
message: "Direct console.log usage — consider structured logging",
|
||||
},
|
||||
{
|
||||
pattern: /any\b/g,
|
||||
pattern: /(?:as\s+any\b|:\s*any\b|<any>|any\[\s*\])/g,
|
||||
severity: "P1",
|
||||
category: "type_safety",
|
||||
message: "Use of 'any' type — loses type safety",
|
||||
@@ -66,6 +67,7 @@ export class QualityVerification extends VerificationLayer {
|
||||
checks.push(this.checkP2P3Findings(p2p3Findings));
|
||||
checks.push(this.checkTypeScriptStrictness(projectPath));
|
||||
checks.push(this.checkConsistentNaming(projectPath));
|
||||
checks.push(this.checkTypeScriptCompilation(projectPath));
|
||||
|
||||
const hasP0Fail = p0Findings.length > 3;
|
||||
const passed = !hasP0Fail;
|
||||
@@ -226,6 +228,32 @@ export class QualityVerification extends VerificationLayer {
|
||||
);
|
||||
}
|
||||
|
||||
private checkTypeScriptCompilation(projectPath: string): VerificationCheck {
|
||||
const tsconfigPath = path.join(projectPath, "tsconfig.json");
|
||||
if (!fs.existsSync(tsconfigPath)) {
|
||||
return this.check("TypeScript compilation", "skipped", "No tsconfig.json found");
|
||||
}
|
||||
|
||||
try {
|
||||
execSync("npx tsc --noEmit 2>&1", {
|
||||
cwd: projectPath,
|
||||
encoding: "utf-8",
|
||||
timeout: 60000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
return this.check("TypeScript compilation", "pass", "TypeScript compiles without errors");
|
||||
} catch (err) {
|
||||
const execErr = err as { stdout?: string };
|
||||
const output = execErr.stdout || "";
|
||||
const errorCount = (output.match(/error TS/g) || []).length;
|
||||
return this.check(
|
||||
"TypeScript compilation",
|
||||
errorCount > 5 ? "fail" : "warning",
|
||||
`${errorCount} TypeScript compilation error(s)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private collectFiles(dir: string, files: string[]): void {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
||||
|
||||
interface ThreatEntry {
|
||||
@@ -250,24 +251,80 @@ export class SecurityVerification extends VerificationLayer {
|
||||
}
|
||||
|
||||
private checkDependencyVulnerabilities(projectPath: string): VerificationCheck {
|
||||
const packageLockPath = path.join(projectPath, "package-lock.json");
|
||||
if (!fs.existsSync(packageLockPath)) {
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"skipped",
|
||||
"No package-lock.json found — cannot audit dependencies"
|
||||
);
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(projectPath, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return this.check("Dependency audit", "skipped", "No package.json found");
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"pass",
|
||||
"Dependency structure available for audit (run `npm audit` for full check)"
|
||||
);
|
||||
try {
|
||||
const result = execSync("npm audit --json 2>/dev/null", {
|
||||
cwd: projectPath,
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const audit = JSON.parse(result);
|
||||
const vulnerabilities = audit.metadata?.vulnerabilities || {};
|
||||
const high = vulnerabilities.high || 0;
|
||||
const critical = vulnerabilities.critical || 0;
|
||||
const medium = vulnerabilities.moderate || 0;
|
||||
const low = vulnerabilities.low || 0;
|
||||
const total = high + critical + medium + low;
|
||||
|
||||
if (total === 0) {
|
||||
return this.check("Dependency audit", "pass", "No known vulnerabilities in dependencies");
|
||||
}
|
||||
|
||||
if (critical > 0 || high > 0) {
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"fail",
|
||||
`${total} vulnerabilities (critical: ${critical}, high: ${high}, medium: ${medium}, low: ${low})`
|
||||
);
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"warning",
|
||||
`${total} vulnerabilities (medium: ${medium}, low: ${low}) — no critical/high`
|
||||
);
|
||||
} catch (err) {
|
||||
const output = (err as { stdout?: string }).stdout;
|
||||
if (output) {
|
||||
try {
|
||||
const audit = JSON.parse(output);
|
||||
const vulnerabilities = audit.metadata?.vulnerabilities || {};
|
||||
const high = vulnerabilities.high || 0;
|
||||
const critical = vulnerabilities.critical || 0;
|
||||
const medium = vulnerabilities.moderate || 0;
|
||||
const low = vulnerabilities.low || 0;
|
||||
const total = high + critical + medium + low;
|
||||
|
||||
if (total === 0) {
|
||||
return this.check("Dependency audit", "pass", "No known vulnerabilities in dependencies");
|
||||
}
|
||||
|
||||
if (critical > 0 || high > 0) {
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"fail",
|
||||
`${total} vulnerabilities (critical: ${critical}, high: ${high}, medium: ${medium}, low: ${low})`
|
||||
);
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"warning",
|
||||
`${total} vulnerabilities (medium: ${medium}, low: ${low}) — no critical/high`
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"skipped",
|
||||
"npm audit not available — run manually for full check"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,12 @@ describe("StructuralVerification", () => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function setupProjectStructure(hasPhaseDir = true, hasPlan = true, hasCIConfig = true, hasSpec = true) {
|
||||
if (hasPhaseDir) {
|
||||
const phaseDir = path.join(tempDir, ".planning", "phases", "phase-1");
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
if (hasPlan) {
|
||||
fs.writeFileSync(path.join(phaseDir, "PLAN.md"), "# Plan\n\nTasks:\n- [ ] Task 1\n- [ ] Task 2\n");
|
||||
function setupProjectStructure(hasCIDir = true, hasRoadmap = true, hasCIConfig = true, hasSpec = true) {
|
||||
if (hasCIDir) {
|
||||
const ciDir = path.join(tempDir, ".ci");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
if (hasRoadmap) {
|
||||
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), "# Roadmap\n\n## Phases\n\n### Phase 1: Init\n**Goal**: Set up project\n**Status**: not_started\n");
|
||||
}
|
||||
}
|
||||
if (hasCIConfig) {
|
||||
@@ -43,10 +43,10 @@ describe("StructuralVerification", () => {
|
||||
expect(result.name).toBe("Structural");
|
||||
expect(result.checks.length).toBeGreaterThan(0);
|
||||
|
||||
const phaseDirCheck = result.checks.find((c) => c.name === "Phase directory exists");
|
||||
const phaseDirCheck = result.checks.find((c) => c.name === ".ci directory exists");
|
||||
expect(phaseDirCheck?.status).toBe("pass");
|
||||
|
||||
const planCheck = result.checks.find((c) => c.name === "PLAN.md exists");
|
||||
const planCheck = result.checks.find((c) => c.name === "ROADMAP.md exists");
|
||||
expect(planCheck?.status).toBe("pass");
|
||||
|
||||
const configCheck = result.checks.find((c) => c.name === "CI config valid");
|
||||
@@ -56,29 +56,29 @@ describe("StructuralVerification", () => {
|
||||
expect(specCheck?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("fails when phase directory is missing", async () => {
|
||||
setupProjectStructure(false, false, true, true);
|
||||
it("fails when .ci directory is missing", async () => {
|
||||
setupProjectStructure(false, false, false, false);
|
||||
const verifier = new StructuralVerification();
|
||||
const result = await verifier.verify(tempDir, 1);
|
||||
|
||||
const phaseDirCheck = result.checks.find((c) => c.name === "Phase directory exists");
|
||||
const phaseDirCheck = result.checks.find((c) => c.name === ".ci directory exists");
|
||||
expect(phaseDirCheck?.status).toBe("fail");
|
||||
});
|
||||
|
||||
it("fails when PLAN.md is missing", async () => {
|
||||
it("warns when ROADMAP.md is missing", async () => {
|
||||
setupProjectStructure(true, false, true, true);
|
||||
const verifier = new StructuralVerification();
|
||||
const result = await verifier.verify(tempDir, 1);
|
||||
|
||||
const planCheck = result.checks.find((c) => c.name === "PLAN.md exists");
|
||||
expect(planCheck?.status).toBe("fail");
|
||||
const planCheck = result.checks.find((c) => c.name === "ROADMAP.md exists");
|
||||
expect(planCheck?.status).toBe("warning");
|
||||
});
|
||||
|
||||
it("fails when CI config has invalid JSON", async () => {
|
||||
const ciDir = path.join(tempDir, ".ci");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ciDir, "config.json"), "not valid json{{{");
|
||||
fs.mkdirSync(path.join(tempDir, ".planning", "phases", "phase-1"), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, ".ci"), { recursive: true });
|
||||
|
||||
const verifier = new StructuralVerification();
|
||||
const result = await verifier.verify(tempDir, 1);
|
||||
@@ -91,8 +91,6 @@ describe("StructuralVerification", () => {
|
||||
const srcDir = path.join(tempDir, "src");
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, "app.ts"), "export function main() { /* TODO: implement */ }");
|
||||
fs.mkdirSync(path.join(tempDir, ".planning", "phases", "phase-1"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, ".planning", "phases", "phase-1", "PLAN.md"), "# Plan");
|
||||
const ciDir = path.join(tempDir, ".ci");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ciDir, "config.json"), "{}");
|
||||
|
||||
@@ -13,9 +13,6 @@ const STUB_PATTERNS = [
|
||||
/not\s+implemented/i,
|
||||
];
|
||||
|
||||
const TODO_PATTERN = /\bTODO\b/gi;
|
||||
const FIXME_PATTERN = /\bFIXME\b/gi;
|
||||
|
||||
export class StructuralVerification extends VerificationLayer {
|
||||
readonly layer = 1;
|
||||
readonly name = "Structural";
|
||||
@@ -44,30 +41,24 @@ export class StructuralVerification extends VerificationLayer {
|
||||
}
|
||||
|
||||
private checkPhaseDir(projectPath: string, phase: number) {
|
||||
const phaseDir = path.join(projectPath, ".planning", "phases", `phase-${phase}`);
|
||||
const exists = fs.existsSync(phaseDir);
|
||||
const ciDir = path.join(projectPath, ".ci");
|
||||
const exists = fs.existsSync(ciDir);
|
||||
return this.check(
|
||||
"Phase directory exists",
|
||||
".ci directory exists",
|
||||
exists ? "pass" : "fail",
|
||||
exists ? `Phase ${phase} directory found` : `Phase ${phase} directory not found`,
|
||||
phaseDir
|
||||
exists ? ".ci directory found" : ".ci directory not found",
|
||||
ciDir
|
||||
);
|
||||
}
|
||||
|
||||
private checkPlanExists(projectPath: string, phase: number) {
|
||||
const planPath = path.join(
|
||||
projectPath,
|
||||
".planning",
|
||||
"phases",
|
||||
`phase-${phase}`,
|
||||
"PLAN.md"
|
||||
);
|
||||
const exists = fs.existsSync(planPath);
|
||||
const roadmapPath = path.join(projectPath, ".ci", "ROADMAP.md");
|
||||
const exists = fs.existsSync(roadmapPath);
|
||||
return this.check(
|
||||
"PLAN.md exists",
|
||||
exists ? "pass" : "fail",
|
||||
exists ? "PLAN.md found" : "PLAN.md not found",
|
||||
planPath
|
||||
"ROADMAP.md exists",
|
||||
exists ? "pass" : "warning",
|
||||
exists ? "ROADMAP.md found" : "ROADMAP.md not found (run 'ci init' first)",
|
||||
roadmapPath
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = "0.3.0";
|
||||
export const VERSION = "0.5.0";
|
||||
@@ -29,5 +29,23 @@
|
||||
"branching_strategy": "phase",
|
||||
"auto_commit": true,
|
||||
"auto_push": false
|
||||
},
|
||||
"backend": {
|
||||
"provider": "auto",
|
||||
"agent_backends": {
|
||||
"opencode": { "enabled": true }
|
||||
},
|
||||
"llm_backends": {
|
||||
"ollama-local": {
|
||||
"base_url": "http://localhost:11434",
|
||||
"model_profile": "balanced"
|
||||
},
|
||||
"ollama-cloud": {
|
||||
"base_url": "",
|
||||
"api_key_env": "OLLAMA_CLOUD_API_KEY",
|
||||
"model_profile": "quality",
|
||||
"timeout_ms": 60000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user