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:
CI
2026-05-29 16:18:30 +00:00
parent 7a20784c87
commit fb3f1df13e
32 changed files with 364 additions and 136 deletions
+129
View File
@@ -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);
});
});
+1 -2
View File
@@ -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()),
}));
+7 -16
View File
@@ -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 [];
}
}
}
-1
View File
@@ -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()),
}))
+139
View File
@@ -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");
});
});
});