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:
@@ -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,',
|
||||
' "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": [], "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": []',
|
||||
'}',
|
||||
"```"
|
||||
@@ -241,7 +241,6 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
||||
: (a as { option: string; rejected_reason: string })
|
||||
)
|
||||
: [],
|
||||
learnship_equivalent: String(d.learnship_equivalent || ""),
|
||||
human_override: d.human_override ? String(d.human_override) : null,
|
||||
timestamp: String(d.timestamp || new Date().toISOString()),
|
||||
}));
|
||||
|
||||
@@ -25,8 +25,7 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
|
||||
|
||||
protected resolveModel(): string {
|
||||
if (this.localConfig.model) return this.localConfig.model;
|
||||
const models = this.fetchAvailableModelsSync();
|
||||
return this.modelProfileToModel(this.localConfig.model_profile, models);
|
||||
return this.modelProfileToModel(this.localConfig.model_profile, []);
|
||||
}
|
||||
|
||||
protected async callModel(
|
||||
@@ -34,10 +33,15 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
|
||||
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,
|
||||
model: resolvedModel,
|
||||
messages: messages.map((m) => {
|
||||
const msg: Record<string, unknown> = { role: m.role, content: m.content };
|
||||
if (m.name) msg.name = m.name;
|
||||
@@ -65,17 +69,4 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
|
||||
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 })
|
||||
)
|
||||
: [],
|
||||
learnship_equivalent: String(d.learnship_equivalent || ""),
|
||||
human_override: d.human_override ? String(d.human_override) : null,
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user