940b85bfae
Add IntelligenceBackend abstraction with two categories: - LLMBackend (OllamaLocal, OllamaCloud): CI runs tool loop, provides tools, constructs prompts - AgentBackend (Opencode): agent runs own tool loop, CI serializes request Refactor all 18 agents from hardcoded stubs to persona loaders that delegate to the active backend or fail honestly when no backend is available. Refactor OrchestratorAgent.executeStage() from monolithic switch to agent delegation via STAGE_AGENT_MAP for intelligent stages (research, plan, execute, verify), with mechanical stages (specify, clarify, complete) staying inline. Wire CLI commands with --backend flag and auto-detection (opencode → ollama-local → ollama-cloud). Harden rollback/ship with real git operations. No command returns fake success.
183 lines
6.4 KiB
TypeScript
183 lines
6.4 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 })
|
|
)
|
|
: [],
|
|
learnship_equivalent: String(d.learnship_equivalent || ""),
|
|
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),
|
|
},
|
|
};
|
|
}
|
|
} |