release(v0.4.0): purge learnship, migrate .planning→.ci, fix backends, add test coverage
- Remove all learnship references: Decision.learnship_equivalent field,
agent persona prompts, opencode.json permissions, test fixtures
- Migrate verification layers from .planning/ to .ci/: structural
checks .ci/ dir + ROADMAP.md, behavioral checks ROADMAP.md
- Fix ollama-local: remove sync require+curl blocking, use async
fetchAvailableModels() in callModel
- Fix opencode.json: use __OPENCODE_DIR__ template tokens, remove
legacy learnship permission entries
- Remove duplicate install script from package.json (keep postinstall)
- Fix quality any-regex false positives (target type annotations only)
- Add backends test coverage: backends.test.ts, tool-registry.test.ts
- Version bump 0.3.0 → 0.4.0
- Artifacts module: rename .planning→.ci internal paths
- Remove dead TODO_PATTERN/FIXME_PATTERN constants
---ci---
phase: 3
milestone: v0.4
status: complete
requirements:
covered: [REQ-09, REQ-10, REQ-11, REQ-13, REQ-14, REQ-17]
partial: []
decisions:
- id: D-001
decision: purge all learnship references from codebase
rationale: project is CI-only, learnship is no longer a dependency
confidence: 0.99
category: scope
alternatives: [keep for historical reference]
- id: D-002
decision: migrate verification from .planning/ to .ci/ paths
rationale: .planning/ is removed schema, all current state lives in .ci/
confidence: 0.95
category: architecture
alternatives: [keep dual-path support]
- id: D-003
decision: use __OPENCODE_DIR__ template tokens in opencode.json
rationale: hardcoded ~ paths fail in containers and non-standard homes
confidence: 0.90
category: implementation_approach
alternatives: [keep tilde expansion]
---/ci---
This commit is contained in:
@@ -26,7 +26,7 @@ src/
|
|||||||
index.ts # Backend registry + auto-detection
|
index.ts # Backend registry + auto-detection
|
||||||
cli/ # Commander.js CLI (commands.ts, index.ts)
|
cli/ # Commander.js CLI (commands.ts, index.ts)
|
||||||
core/ # Core engine components
|
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)
|
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.)
|
ci-files.ts # .ci/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.)
|
||||||
clarify.ts # Clarify phase: question generation, default acceptance
|
clarify.ts # Clarify phase: question generation, default acceptance
|
||||||
@@ -49,11 +49,11 @@ src/
|
|||||||
utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON)
|
utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON)
|
||||||
verification/ # 4-layer verification pipeline
|
verification/ # 4-layer verification pipeline
|
||||||
structural.ts # Layer 1: file existence, imports wired, no stubs
|
structural.ts # Layer 1: file existence, imports wired, no stubs
|
||||||
behavioral.ts # Layer 2: test generation and execution (stub)
|
behavioral.ts # Layer 2: test infrastructure checks (static analysis, no test generation yet)
|
||||||
security.ts # Layer 3: STRIDE threat analysis (stub)
|
security.ts # Layer 3: regex-based threat pattern scanning (no STRIDE analysis yet)
|
||||||
quality.ts # Layer 4: multi-persona code review (stub)
|
quality.ts # Layer 4: regex-based code quality checks (no multi-persona review yet)
|
||||||
index.ts # Public API exports
|
index.ts # Public API exports
|
||||||
version.ts # VERSION = "0.3.0"
|
version.ts # VERSION = "0.4.0"
|
||||||
templates/ # Template files (config.json, DECISIONS.md, specification.md)
|
templates/ # Template files (config.json, DECISIONS.md, specification.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -62,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)
|
- **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
|
- **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
|
- **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).
|
- **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.
|
- **Artifact compatibility**: CI no longer writes `.planning/` schema. Dynamic state is derived from git history. `.ci/` files follow a CI-native schema.
|
||||||
|
|
||||||
@@ -122,9 +122,9 @@ IntelligenceBackend (unified interface)
|
|||||||
## Verification Layers
|
## Verification Layers
|
||||||
|
|
||||||
1. **Structural**: Files exist, imports wired, no stubs/TODOs
|
1. **Structural**: Files exist, imports wired, no stubs/TODOs
|
||||||
2. **Behavioral**: Generate and run automated tests for must-haves (currently stub)
|
2. **Behavioral**: Check test infrastructure and requirement traceability (static analysis — test generation not yet implemented)
|
||||||
3. **Security**: STRIDE analysis with auto-disposition (currently stub)
|
3. **Security**: Regex-based threat pattern scanning with auto-disposition (STRIDE analysis not yet implemented)
|
||||||
4. **Code Quality**: Multi-persona review with P0 auto-fix (currently stub)
|
4. **Code Quality**: Regex-based code quality checks (multi-persona review not yet implemented)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ IntelligenceBackend (unified interface)
|
|||||||
|
|
||||||
## Current State
|
## 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)
|
- **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
|
- **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
|
- **Branch strategy**: `phase/NN-slug` and `milestone/vX.X-slug` branches encode project structure; merged = complete, active = in progress
|
||||||
@@ -203,4 +203,4 @@ IntelligenceBackend (unified interface)
|
|||||||
- **CLI**: All 11 commands wired up (`init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`)
|
- **CLI**: All 11 commands wired up (`init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`)
|
||||||
- **Agent implementations**: Persona loaders that delegate to active backend. Fail honestly when no backend is available (no more fake success).
|
- **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.
|
- **Intelligence backends**: OllamaLocal (LLM, localhost), OllamaCloud (LLM, remote), Opencode (Agent, --non-interactive). Auto-detection: opencode → ollama-local → ollama-cloud.
|
||||||
- **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
|
- **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
|
||||||
@@ -11,7 +11,7 @@ tools:
|
|||||||
<role>
|
<role>
|
||||||
You are a CI challenger. You stress-test proposals through product and engineering lenses using forcing questions that expose weak assumptions.
|
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**
|
**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.
|
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>
|
<role>
|
||||||
You are a CI code reviewer. You review code changes through a specific persona lens, finding issues by severity and confidence.
|
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**
|
**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.
|
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>
|
<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.
|
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**
|
**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.
|
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>
|
<role>
|
||||||
You are a CI executor. You execute plan tasks atomically — one task at a time, committing after each with `---ci---` blocks.
|
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**
|
**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.
|
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>
|
<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.
|
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.
|
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>
|
<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.
|
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**
|
**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.
|
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>
|
<role>
|
||||||
You are a CI researcher. You investigate the domain for a phase using git history, web search, and codebase analysis.
|
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**
|
**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.
|
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>
|
<role>
|
||||||
You are a CI security auditor. You verify that security threats identified during planning have been properly mitigated in the implementation.
|
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.
|
You are READ-ONLY. Do not modify source code.
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ tools:
|
|||||||
<role>
|
<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.
|
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**
|
**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.
|
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.4.0
|
||||||
@@ -2,12 +2,10 @@
|
|||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"permission": {
|
"permission": {
|
||||||
"read": {
|
"read": {
|
||||||
"~/.config/opencode/learnship/*": "allow",
|
"__OPENCODE_DIR__/ci/*": "allow"
|
||||||
"~/.config/opencode/ci/*": "allow"
|
|
||||||
},
|
},
|
||||||
"external_directory": {
|
"external_directory": {
|
||||||
"~/.config/opencode/learnship/*": "allow",
|
"__OPENCODE_DIR__/ci/*": "allow"
|
||||||
"~/.config/opencode/ci/*": "allow"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@continuous-intelligence/ci",
|
"name": "@continuous-intelligence/ci",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
|
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
@@ -21,8 +21,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"prepublishOnly": "npm run build",
|
"prepublishOnly": "npm run build",
|
||||||
"postinstall": "node scripts/postinstall.js",
|
"postinstall": "node scripts/postinstall.js"
|
||||||
"install": "bash scripts/install.sh"
|
|
||||||
},
|
},
|
||||||
"keywords": ["ci", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
|
"keywords": ["ci", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
|||||||
|
|
||||||
export class SolutionWriterAgent extends BaseAgent {
|
export class SolutionWriterAgent extends BaseAgent {
|
||||||
readonly name = "solution-writer";
|
readonly name = "solution-writer";
|
||||||
readonly description = "Produces structured solution documents for .planning/solutions/.";
|
readonly description = "Produces structured solution documents.";
|
||||||
readonly workflow = "execute";
|
readonly workflow = "execute";
|
||||||
|
|
||||||
async execute(context: AgentContext): Promise<AgentResult> {
|
async execute(context: AgentContext): Promise<AgentResult> {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -153,7 +153,7 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
|||||||
' "success": true,',
|
' "success": true,',
|
||||||
' "output": "Summary of what was accomplished",',
|
' "output": "Summary of what was accomplished",',
|
||||||
' "artifacts": [{"path": "file/path", "content": "...", "operation": "create"}],',
|
' "artifacts": [{"path": "file/path", "content": "...", "operation": "create"}],',
|
||||||
' "decisions": [{"id": "D-NNN", "decision": "what", "rationale": "why", "confidence": 0.85, "category": "general", "alternatives_considered": [], "learnship_equivalent": "", "human_override": null, "timestamp": ""}],',
|
' "decisions": [{"id": "D-NNN", "decision": "what", "rationale": "why", "confidence": 0.85, "category": "general", "alternatives_considered": [], "human_override": null, "timestamp": ""}],',
|
||||||
' "escalations": []',
|
' "escalations": []',
|
||||||
'}',
|
'}',
|
||||||
"```"
|
"```"
|
||||||
@@ -241,7 +241,6 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
|||||||
: (a as { option: string; rejected_reason: string })
|
: (a as { option: string; rejected_reason: string })
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
learnship_equivalent: String(d.learnship_equivalent || ""),
|
|
||||||
human_override: d.human_override ? String(d.human_override) : null,
|
human_override: d.human_override ? String(d.human_override) : null,
|
||||||
timestamp: String(d.timestamp || new Date().toISOString()),
|
timestamp: String(d.timestamp || new Date().toISOString()),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
|
|||||||
|
|
||||||
protected resolveModel(): string {
|
protected resolveModel(): string {
|
||||||
if (this.localConfig.model) return this.localConfig.model;
|
if (this.localConfig.model) return this.localConfig.model;
|
||||||
const models = this.fetchAvailableModelsSync();
|
return this.modelProfileToModel(this.localConfig.model_profile, []);
|
||||||
return this.modelProfileToModel(this.localConfig.model_profile, models);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async callModel(
|
protected async callModel(
|
||||||
@@ -34,10 +33,15 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
|
|||||||
model: string,
|
model: string,
|
||||||
toolRegistry: ToolRegistry
|
toolRegistry: ToolRegistry
|
||||||
): Promise<OllamaChatResponse> {
|
): 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 url = `${this.localConfig.base_url}/v1/chat/completions`;
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
model,
|
model: resolvedModel,
|
||||||
messages: messages.map((m) => {
|
messages: messages.map((m) => {
|
||||||
const msg: Record<string, unknown> = { role: m.role, content: m.content };
|
const msg: Record<string, unknown> = { role: m.role, content: m.content };
|
||||||
if (m.name) msg.name = m.name;
|
if (m.name) msg.name = m.name;
|
||||||
@@ -65,17 +69,4 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
|
|||||||
return (await response.json()) as OllamaChatResponse;
|
return (await response.json()) as OllamaChatResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchAvailableModelsSync(): string[] {
|
|
||||||
try {
|
|
||||||
const { execSync } = require("node:child_process");
|
|
||||||
const result = execSync(`curl -s ${this.localConfig.base_url}/api/tags`, {
|
|
||||||
encoding: "utf-8",
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
const data = JSON.parse(result) as { models?: Array<{ name: string }> };
|
|
||||||
return (data.models || []).map((m) => m.name);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,6 @@ export class OpencodeBackend implements IntelligenceBackend {
|
|||||||
: (a as { option: string; rejected_reason: string })
|
: (a as { option: string; rejected_reason: string })
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
learnship_equivalent: String(d.learnship_equivalent || ""),
|
|
||||||
human_override: d.human_override ? String(d.human_override) : null,
|
human_override: d.human_override ? String(d.human_override) : null,
|
||||||
timestamp: String(d.timestamp || new Date().toISOString()),
|
timestamp: String(d.timestamp || new Date().toISOString()),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,17 +17,16 @@ describe("ArtifactManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ensureStructure", () => {
|
describe("ensureStructure", () => {
|
||||||
it("creates .planning directory structure", () => {
|
it("creates .ci directory structure", () => {
|
||||||
manager.ensureStructure();
|
manager.ensureStructure();
|
||||||
expect(fs.existsSync(path.join(tempDir, ".planning"))).toBe(true);
|
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(tempDir, ".planning", "phases"))).toBe(true);
|
|
||||||
expect(fs.existsSync(path.join(tempDir, ".ci", "audit"))).toBe(true);
|
expect(fs.existsSync(path.join(tempDir, ".ci", "audit"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is idempotent", () => {
|
it("is idempotent", () => {
|
||||||
manager.ensureStructure();
|
manager.ensureStructure();
|
||||||
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);
|
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);
|
expect(fs.existsSync(projectPath)).toBe(true);
|
||||||
const content = fs.readFileSync(projectPath, "utf-8");
|
const content = fs.readFileSync(projectPath, "utf-8");
|
||||||
expect(content).toContain("Test Project");
|
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);
|
expect(fs.existsSync(decisionsPath)).toBe(true);
|
||||||
const content = fs.readFileSync(decisionsPath, "utf-8");
|
const content = fs.readFileSync(decisionsPath, "utf-8");
|
||||||
expect(content).toContain("D-001");
|
expect(content).toContain("D-001");
|
||||||
|
|||||||
+14
-14
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { writeFile, readFile, ensureDir } from "../utils/file.js";
|
import { writeFile, readFile, ensureDir } from "../utils/file.js";
|
||||||
|
|
||||||
const PLANNING_DIR = ".planning";
|
const CI_DIR = ".ci";
|
||||||
|
|
||||||
export interface ProjectManifest {
|
export interface ProjectManifest {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -48,18 +48,18 @@ export class ArtifactManager {
|
|||||||
this.projectPath = projectPath;
|
this.projectPath = projectPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get planningDir(): string {
|
private get ciDir(): string {
|
||||||
return path.join(this.projectPath, PLANNING_DIR);
|
return path.join(this.projectPath, CI_DIR);
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureStructure(): void {
|
ensureStructure(): void {
|
||||||
ensureDir(this.planningDir);
|
ensureDir(this.ciDir);
|
||||||
ensureDir(path.join(this.planningDir, "phases"));
|
ensureDir(path.join(this.ciDir, "phases"));
|
||||||
ensureDir(path.join(this.projectPath, ".ci", "audit"));
|
ensureDir(path.join(this.ciDir, "audit"));
|
||||||
}
|
}
|
||||||
|
|
||||||
isInitialized(): boolean {
|
isInitialized(): boolean {
|
||||||
return fs.existsSync(path.join(this.planningDir, "PROJECT.md"));
|
return fs.existsSync(path.join(this.ciDir, "PROJECT.md"));
|
||||||
}
|
}
|
||||||
|
|
||||||
writeProject(manifest: ProjectManifest): void {
|
writeProject(manifest: ProjectManifest): void {
|
||||||
@@ -81,7 +81,7 @@ export class ArtifactManager {
|
|||||||
}
|
}
|
||||||
lines.push("");
|
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 {
|
writeDecisions(decisions: DecisionsManifest): void {
|
||||||
@@ -99,11 +99,11 @@ export class ArtifactManager {
|
|||||||
lines.push(`- **Timestamp**: ${d.timestamp}`);
|
lines.push(`- **Timestamp**: ${d.timestamp}`);
|
||||||
lines.push("");
|
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 {
|
writeState(state: StateManifest): void {
|
||||||
writeJSON(path.join(this.planningDir, "STATE.md.json"), state);
|
writeJSON(path.join(this.ciDir, "STATE.md.json"), state);
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
"# Project State",
|
"# Project State",
|
||||||
@@ -124,11 +124,11 @@ export class ArtifactManager {
|
|||||||
}
|
}
|
||||||
lines.push("");
|
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 {
|
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;
|
if (!fs.existsSync(filePath)) return null;
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ export class ArtifactManager {
|
|||||||
artifactName: string,
|
artifactName: string,
|
||||||
content: string
|
content: string
|
||||||
): void {
|
): void {
|
||||||
const phaseDir = path.join(this.planningDir, "phases", `phase-${phase}`);
|
const phaseDir = path.join(this.ciDir, "phases", `phase-${phase}`);
|
||||||
ensureDir(phaseDir);
|
ensureDir(phaseDir);
|
||||||
writeFile(path.join(phaseDir, artifactName), content);
|
writeFile(path.join(phaseDir, artifactName), content);
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,7 @@ export class ArtifactManager {
|
|||||||
artifactName: string
|
artifactName: string
|
||||||
): string | null {
|
): string | null {
|
||||||
const filePath = path.join(
|
const filePath = path.join(
|
||||||
this.planningDir,
|
this.ciDir,
|
||||||
"phases",
|
"phases",
|
||||||
`phase-${phase}`,
|
`phase-${phase}`,
|
||||||
artifactName
|
artifactName
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ describe("Audit", () => {
|
|||||||
confidence: 0.92,
|
confidence: 0.92,
|
||||||
category: "technology_choice",
|
category: "technology_choice",
|
||||||
alternatives_considered: [{ option: "MongoDB", rejected_reason: "No ACID" }],
|
alternatives_considered: [{ option: "MongoDB", rejected_reason: "No ACID" }],
|
||||||
learnship_equivalent: "discuss-phase would ask: What database?",
|
|
||||||
human_override: null,
|
human_override: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ describe("DecisionEngine", () => {
|
|||||||
{ option: "MongoDB", rejected_reason: "No ACID transactions" },
|
{ option: "MongoDB", rejected_reason: "No ACID transactions" },
|
||||||
{ option: "SQLite", rejected_reason: "No concurrent writes" },
|
{ option: "SQLite", rejected_reason: "No concurrent writes" },
|
||||||
],
|
],
|
||||||
learnship_equivalent: "discuss-phase would ask: What database? Options: A) PostgreSQL B) MongoDB",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("makeDecision", () => {
|
describe("makeDecision", () => {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export interface DecisionInput {
|
|||||||
confidence: number;
|
confidence: number;
|
||||||
category: DecisionCategory;
|
category: DecisionCategory;
|
||||||
alternatives_considered: Alternative[];
|
alternatives_considered: Alternative[];
|
||||||
learnship_equivalent: string;
|
|
||||||
phase?: string;
|
phase?: string;
|
||||||
task?: string;
|
task?: string;
|
||||||
}
|
}
|
||||||
@@ -57,7 +56,6 @@ export class DecisionEngine {
|
|||||||
confidence: input.confidence,
|
confidence: input.confidence,
|
||||||
category: input.category,
|
category: input.category,
|
||||||
alternatives_considered: input.alternatives_considered,
|
alternatives_considered: input.alternatives_considered,
|
||||||
learnship_equivalent: input.learnship_equivalent,
|
|
||||||
human_override: null,
|
human_override: null,
|
||||||
phase: input.phase,
|
phase: input.phase,
|
||||||
task: input.task,
|
task: input.task,
|
||||||
@@ -101,8 +99,7 @@ export class DecisionEngine {
|
|||||||
decision: string,
|
decision: string,
|
||||||
rationale: string,
|
rationale: string,
|
||||||
category: DecisionCategory,
|
category: DecisionCategory,
|
||||||
alternatives: Alternative[] = [],
|
alternatives: Alternative[] = []
|
||||||
learnship_equivalent: string = ""
|
|
||||||
): DecisionResult {
|
): DecisionResult {
|
||||||
return this.makeDecision({
|
return this.makeDecision({
|
||||||
decision,
|
decision,
|
||||||
@@ -110,7 +107,6 @@ export class DecisionEngine {
|
|||||||
confidence: 0.95,
|
confidence: 0.95,
|
||||||
category,
|
category,
|
||||||
alternatives_considered: alternatives,
|
alternatives_considered: alternatives,
|
||||||
learnship_equivalent,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,8 +114,7 @@ export class DecisionEngine {
|
|||||||
decision: string,
|
decision: string,
|
||||||
rationale: string,
|
rationale: string,
|
||||||
category: DecisionCategory,
|
category: DecisionCategory,
|
||||||
alternatives: Alternative[] = [],
|
alternatives: Alternative[] = []
|
||||||
learnship_equivalent: string = ""
|
|
||||||
): DecisionResult {
|
): DecisionResult {
|
||||||
return this.makeDecision({
|
return this.makeDecision({
|
||||||
decision,
|
decision,
|
||||||
@@ -127,7 +122,6 @@ export class DecisionEngine {
|
|||||||
confidence: 0.7,
|
confidence: 0.7,
|
||||||
category,
|
category,
|
||||||
alternatives_considered: alternatives,
|
alternatives_considered: alternatives,
|
||||||
learnship_equivalent,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ describe("ErrorRecovery", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-recovery-test-"));
|
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"), { recursive: true });
|
||||||
fs.mkdirSync(path.join(tempDir, ".ci", "audit"), { recursive: true });
|
|
||||||
recovery = new ErrorRecovery(DEFAULT_CI_CONFIG, tempDir);
|
recovery = new ErrorRecovery(DEFAULT_CI_CONFIG, tempDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export interface Decision {
|
|||||||
confidence: number;
|
confidence: number;
|
||||||
category: DecisionCategory;
|
category: DecisionCategory;
|
||||||
alternatives_considered: Alternative[];
|
alternatives_considered: Alternative[];
|
||||||
learnship_equivalent: string;
|
|
||||||
human_override: string | null;
|
human_override: string | null;
|
||||||
phase?: string;
|
phase?: string;
|
||||||
task?: string;
|
task?: string;
|
||||||
|
|||||||
@@ -141,38 +141,36 @@ export class BehavioralVerification extends VerificationLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private checkPlanMustHaves(projectPath: string, phase: number): VerificationCheck {
|
private checkPlanMustHaves(projectPath: string, phase: number): VerificationCheck {
|
||||||
const planPath = path.join(
|
const roadmapPath = path.join(
|
||||||
projectPath,
|
projectPath,
|
||||||
".planning",
|
".ci",
|
||||||
"phases",
|
"ROADMAP.md"
|
||||||
`phase-${phase}`,
|
|
||||||
"PLAN.md"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!fs.existsSync(planPath)) {
|
if (!fs.existsSync(roadmapPath)) {
|
||||||
return this.check(
|
return this.check(
|
||||||
"Plan must-haves covered",
|
"Plan must-haves covered",
|
||||||
"skipped",
|
"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 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(
|
return this.check(
|
||||||
"Plan must-haves covered",
|
"Plan must-haves covered",
|
||||||
"warning",
|
"warning",
|
||||||
"PLAN.md has no tasks or must-have items"
|
"ROADMAP.md has no phases or must-have items"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.check(
|
return this.check(
|
||||||
"Plan must-haves covered",
|
"Plan must-haves covered",
|
||||||
"pass",
|
"pass",
|
||||||
"PLAN.md contains task definitions"
|
"ROADMAP.md contains phase definitions"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ describe("VerificationPipeline", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-pipeline-test-"));
|
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");
|
const ciDir = path.join(tempDir, ".ci");
|
||||||
fs.mkdirSync(ciDir, { recursive: true });
|
fs.mkdirSync(ciDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify({ autonomy: { level: "full" } }));
|
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, "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(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const CODE_QUALITY_PATTERNS: Array<{
|
|||||||
message: "Direct console.log usage — consider structured logging",
|
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",
|
severity: "P1",
|
||||||
category: "type_safety",
|
category: "type_safety",
|
||||||
message: "Use of 'any' type — loses type safety",
|
message: "Use of 'any' type — loses type safety",
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ describe("StructuralVerification", () => {
|
|||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupProjectStructure(hasPhaseDir = true, hasPlan = true, hasCIConfig = true, hasSpec = true) {
|
function setupProjectStructure(hasCIDir = true, hasRoadmap = true, hasCIConfig = true, hasSpec = true) {
|
||||||
if (hasPhaseDir) {
|
if (hasCIDir) {
|
||||||
const phaseDir = path.join(tempDir, ".planning", "phases", "phase-1");
|
const ciDir = path.join(tempDir, ".ci");
|
||||||
fs.mkdirSync(phaseDir, { recursive: true });
|
fs.mkdirSync(ciDir, { recursive: true });
|
||||||
if (hasPlan) {
|
if (hasRoadmap) {
|
||||||
fs.writeFileSync(path.join(phaseDir, "PLAN.md"), "# Plan\n\nTasks:\n- [ ] Task 1\n- [ ] Task 2\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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasCIConfig) {
|
if (hasCIConfig) {
|
||||||
@@ -43,10 +43,10 @@ describe("StructuralVerification", () => {
|
|||||||
expect(result.name).toBe("Structural");
|
expect(result.name).toBe("Structural");
|
||||||
expect(result.checks.length).toBeGreaterThan(0);
|
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");
|
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");
|
expect(planCheck?.status).toBe("pass");
|
||||||
|
|
||||||
const configCheck = result.checks.find((c) => c.name === "CI config valid");
|
const configCheck = result.checks.find((c) => c.name === "CI config valid");
|
||||||
@@ -56,29 +56,29 @@ describe("StructuralVerification", () => {
|
|||||||
expect(specCheck?.status).toBe("pass");
|
expect(specCheck?.status).toBe("pass");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails when phase directory is missing", async () => {
|
it("fails when .ci directory is missing", async () => {
|
||||||
setupProjectStructure(false, false, true, true);
|
setupProjectStructure(false, false, false, false);
|
||||||
const verifier = new StructuralVerification();
|
const verifier = new StructuralVerification();
|
||||||
const result = await verifier.verify(tempDir, 1);
|
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");
|
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);
|
setupProjectStructure(true, false, true, true);
|
||||||
const verifier = new StructuralVerification();
|
const verifier = new StructuralVerification();
|
||||||
const result = await verifier.verify(tempDir, 1);
|
const result = await verifier.verify(tempDir, 1);
|
||||||
|
|
||||||
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("fail");
|
expect(planCheck?.status).toBe("warning");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails when CI config has invalid JSON", async () => {
|
it("fails when CI config has invalid JSON", async () => {
|
||||||
const ciDir = path.join(tempDir, ".ci");
|
const ciDir = path.join(tempDir, ".ci");
|
||||||
fs.mkdirSync(ciDir, { recursive: true });
|
fs.mkdirSync(ciDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(ciDir, "config.json"), "not valid json{{{");
|
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 verifier = new StructuralVerification();
|
||||||
const result = await verifier.verify(tempDir, 1);
|
const result = await verifier.verify(tempDir, 1);
|
||||||
@@ -91,8 +91,6 @@ describe("StructuralVerification", () => {
|
|||||||
const srcDir = path.join(tempDir, "src");
|
const srcDir = path.join(tempDir, "src");
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(srcDir, "app.ts"), "export function main() { /* TODO: implement */ }");
|
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");
|
const ciDir = path.join(tempDir, ".ci");
|
||||||
fs.mkdirSync(ciDir, { recursive: true });
|
fs.mkdirSync(ciDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(ciDir, "config.json"), "{}");
|
fs.writeFileSync(path.join(ciDir, "config.json"), "{}");
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ const STUB_PATTERNS = [
|
|||||||
/not\s+implemented/i,
|
/not\s+implemented/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
const TODO_PATTERN = /\bTODO\b/gi;
|
|
||||||
const FIXME_PATTERN = /\bFIXME\b/gi;
|
|
||||||
|
|
||||||
export class StructuralVerification extends VerificationLayer {
|
export class StructuralVerification extends VerificationLayer {
|
||||||
readonly layer = 1;
|
readonly layer = 1;
|
||||||
readonly name = "Structural";
|
readonly name = "Structural";
|
||||||
@@ -44,30 +41,24 @@ export class StructuralVerification extends VerificationLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private checkPhaseDir(projectPath: string, phase: number) {
|
private checkPhaseDir(projectPath: string, phase: number) {
|
||||||
const phaseDir = path.join(projectPath, ".planning", "phases", `phase-${phase}`);
|
const ciDir = path.join(projectPath, ".ci");
|
||||||
const exists = fs.existsSync(phaseDir);
|
const exists = fs.existsSync(ciDir);
|
||||||
return this.check(
|
return this.check(
|
||||||
"Phase directory exists",
|
".ci directory exists",
|
||||||
exists ? "pass" : "fail",
|
exists ? "pass" : "fail",
|
||||||
exists ? `Phase ${phase} directory found` : `Phase ${phase} directory not found`,
|
exists ? ".ci directory found" : ".ci directory not found",
|
||||||
phaseDir
|
ciDir
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkPlanExists(projectPath: string, phase: number) {
|
private checkPlanExists(projectPath: string, phase: number) {
|
||||||
const planPath = path.join(
|
const roadmapPath = path.join(projectPath, ".ci", "ROADMAP.md");
|
||||||
projectPath,
|
const exists = fs.existsSync(roadmapPath);
|
||||||
".planning",
|
|
||||||
"phases",
|
|
||||||
`phase-${phase}`,
|
|
||||||
"PLAN.md"
|
|
||||||
);
|
|
||||||
const exists = fs.existsSync(planPath);
|
|
||||||
return this.check(
|
return this.check(
|
||||||
"PLAN.md exists",
|
"ROADMAP.md exists",
|
||||||
exists ? "pass" : "fail",
|
exists ? "pass" : "warning",
|
||||||
exists ? "PLAN.md found" : "PLAN.md not found",
|
exists ? "ROADMAP.md found" : "ROADMAP.md not found (run 'ci init' first)",
|
||||||
planPath
|
roadmapPath
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const VERSION = "0.3.0";
|
export const VERSION = "0.4.0";
|
||||||
Reference in New Issue
Block a user