Files
ci/src/backends/opencode.ts
T
CI fb3f1df13e 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---
2026-05-29 16:18:30 +00:00

182 lines
6.3 KiB
TypeScript

import { execSync, spawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
IntelligenceBackend,
BackendRequest,
BackendResult,
BackendType,
OpencodeBackendConfig,
emptyTokenUsage,
emptyBackendResult,
} from "./types.js";
export class OpencodeBackend implements IntelligenceBackend {
readonly name = "opencode";
readonly type: BackendType = "agent";
private config: OpencodeBackendConfig;
constructor(config?: OpencodeBackendConfig) {
this.config = config || { enabled: true };
}
async isAvailable(): Promise<boolean> {
const executable = this.config.executable || "opencode";
try {
const result = execSync(`${executable} --version`, {
encoding: "utf-8",
timeout: 5000,
stdio: "pipe",
});
return !!result;
} catch {
return false;
}
}
async execute(request: BackendRequest): Promise<BackendResult> {
const executable = this.config.executable || "opencode";
const startTime = Date.now();
try {
const serializedRequest = this.serializeRequest(request);
const tempFile = path.join(
os.tmpdir(),
`ci-request-${request.persona}-${Date.now()}.json`
);
fs.writeFileSync(tempFile, serializedRequest, "utf-8");
const command = `${executable} --non-interactive "/ci-${request.workflow} ${request.task}"`;
const contextEnv = {
...process.env,
CI_BACKEND_REQUEST: tempFile,
CI_PROJECT_PATH: request.context.project_path,
CI_PHASE: String(request.context.phase),
CI_STAGE: request.context.stage,
CI_AUTONOMY: request.autonomy,
};
const result = execSync(command, {
cwd: request.context.project_path,
encoding: "utf-8",
timeout: 600000,
env: contextEnv,
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: 10 * 1024 * 1024,
});
try {
fs.unlinkSync(tempFile);
} catch {}
return this.parseResult(result, Date.now() - startTime);
} catch (err) {
const execErr = err as { stderr?: string; status?: number };
try {
const tempFile = path.join(
os.tmpdir(),
`ci-request-${request.persona}-${startTime}.json`
);
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
} catch {}
if (execErr.stderr) {
return emptyBackendResult(
`opencode execution failed (exit ${execErr.status || "unknown"}): ${execErr.stderr}`
);
}
return emptyBackendResult(
`opencode backend error: ${err instanceof Error ? err.message : String(err)}`
);
}
}
private serializeRequest(request: BackendRequest): string {
return JSON.stringify({
persona: request.persona,
workflow: request.workflow,
task: request.task,
context: {
project_path: request.context.project_path,
phase: request.context.phase,
stage: request.context.stage,
specification: request.context.specification,
config_path: request.context.config_path,
},
autonomy: request.autonomy,
}, null, 2);
}
private parseResult(output: string, durationMs: number): BackendResult {
const jsonMatch = output.match(/\{[\s\S]*"success"[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
return {
success: parsed.success ?? true,
output: parsed.output || output,
artifacts: Array.isArray(parsed.artifacts)
? parsed.artifacts.filter((a: unknown) => !!a).map((a: Record<string, unknown>) => ({
path: String(a.path || ""),
content: String(a.content || ""),
operation: (a.operation as "create" | "update" | "delete") || "create",
}))
: [],
decisions: Array.isArray(parsed.decisions)
? parsed.decisions.filter((d: unknown) => !!d).map((d: Record<string, unknown>) => ({
id: String(d.id || "D-000"),
decision: String(d.decision || ""),
rationale: String(d.rationale || ""),
confidence: Number(d.confidence || 0.5),
category: (d.category as "implementation_approach" | "technology_choice" | "architecture" | "scope" | "verification" | "security" | "deployment" | "general") || "general",
alternatives_considered: Array.isArray(d.alternatives_considered)
? d.alternatives_considered.map((a: unknown) =>
typeof a === "string"
? { option: a, rejected_reason: "" }
: (a as { option: string; rejected_reason: string })
)
: [],
human_override: d.human_override ? String(d.human_override) : null,
timestamp: String(d.timestamp || new Date().toISOString()),
}))
: [],
escalations: Array.isArray(parsed.escalations)
? parsed.escalations.filter((e: unknown) => !!e).map((e: Record<string, unknown>) => ({
id: String(e.id || "E-000"),
timestamp: String(e.timestamp || new Date().toISOString()),
type: (e.type as "irreversible_action" | "verification_failure" | "low_confidence_decision" | "security_escalation" | "specification_ambiguity") || "specification_ambiguity",
phase: String(e.phase || ""),
description: String(e.description || ""),
context: String(e.context || ""),
options: Array.isArray(e.options) ? e.options : [],
default_option_id: String(e.default_option_id || ""),
resolution: (e.resolution as "approved" | "rejected" | "modified" | "pending" | "timeout_auto_proceed") || "pending",
audit_file: String(e.audit_file || ""),
}))
: [],
usage: parsed.usage || {
...emptyTokenUsage(),
total_tokens: Math.ceil(output.length / 4),
},
};
} catch {}
}
return {
success: true,
output,
artifacts: [],
decisions: [],
escalations: [],
usage: {
...emptyTokenUsage(),
total_tokens: Math.ceil(output.length / 4),
},
};
}
}