fb3f1df13e
- 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---
182 lines
6.3 KiB
TypeScript
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),
|
|
},
|
|
};
|
|
}
|
|
} |