@fusionkit/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/cli.d.ts +8 -0
  2. package/dist/cli.js +34 -0
  3. package/dist/commands/ensemble-gateway.d.ts +2 -0
  4. package/dist/commands/ensemble-gateway.js +114 -0
  5. package/dist/commands/ensemble-records.d.ts +33 -0
  6. package/dist/commands/ensemble-records.js +207 -0
  7. package/dist/commands/ensemble.d.ts +2 -0
  8. package/dist/commands/ensemble.js +254 -0
  9. package/dist/commands/fusion.d.ts +2 -0
  10. package/dist/commands/fusion.js +112 -0
  11. package/dist/commands/init.d.ts +2 -0
  12. package/dist/commands/init.js +24 -0
  13. package/dist/commands/lifecycle.d.ts +2 -0
  14. package/dist/commands/lifecycle.js +124 -0
  15. package/dist/commands/local.d.ts +2 -0
  16. package/dist/commands/local.js +25 -0
  17. package/dist/commands/plane.d.ts +2 -0
  18. package/dist/commands/plane.js +30 -0
  19. package/dist/commands/run.d.ts +2 -0
  20. package/dist/commands/run.js +149 -0
  21. package/dist/commands/runner.d.ts +2 -0
  22. package/dist/commands/runner.js +33 -0
  23. package/dist/commands/secrets.d.ts +2 -0
  24. package/dist/commands/secrets.js +21 -0
  25. package/dist/config.d.ts +30 -0
  26. package/dist/config.js +69 -0
  27. package/dist/fusion-quickstart.d.ts +182 -0
  28. package/dist/fusion-quickstart.js +673 -0
  29. package/dist/gateway.d.ts +63 -0
  30. package/dist/gateway.js +304 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +28 -0
  33. package/dist/local.d.ts +40 -0
  34. package/dist/local.js +144 -0
  35. package/dist/render.d.ts +7 -0
  36. package/dist/render.js +131 -0
  37. package/dist/shared/errors.d.ts +6 -0
  38. package/dist/shared/errors.js +9 -0
  39. package/dist/shared/options.d.ts +24 -0
  40. package/dist/shared/options.js +106 -0
  41. package/dist/shared/plane.d.ts +13 -0
  42. package/dist/shared/plane.js +46 -0
  43. package/dist/shared/preflight.d.ts +15 -0
  44. package/dist/shared/preflight.js +48 -0
  45. package/dist/shared/proc.d.ts +41 -0
  46. package/dist/shared/proc.js +122 -0
  47. package/dist/test/cli.test.d.ts +1 -0
  48. package/dist/test/cli.test.js +867 -0
  49. package/dist/test/e2e.test.d.ts +1 -0
  50. package/dist/test/e2e.test.js +250 -0
  51. package/dist/test/fusion-quickstart.test.d.ts +1 -0
  52. package/dist/test/fusion-quickstart.test.js +189 -0
  53. package/dist/test/gateway-e2e.test.d.ts +1 -0
  54. package/dist/test/gateway-e2e.test.js +606 -0
  55. package/dist/test/handoff.test.d.ts +1 -0
  56. package/dist/test/handoff.test.js +212 -0
  57. package/dist/test/local.test.d.ts +1 -0
  58. package/dist/test/local.test.js +39 -0
  59. package/dist/test/proc.test.d.ts +1 -0
  60. package/dist/test/proc.test.js +22 -0
  61. package/package.json +48 -0
package/dist/cli.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * Build the `fusionkit` command tree. The global `--dir` option must precede the
4
+ * subcommand (`enablePositionalOptions` keeps the launcher commands' passthrough
5
+ * unambiguous). Each `register*` helper attaches its command(s) and reads the
6
+ * global home directory via `program.opts().dir`.
7
+ */
8
+ export declare function buildProgram(): Command;
package/dist/cli.js ADDED
@@ -0,0 +1,34 @@
1
+ import { Command } from "commander";
2
+ import { registerEnsemble } from "./commands/ensemble.js";
3
+ import { registerFusion } from "./commands/fusion.js";
4
+ import { registerInit } from "./commands/init.js";
5
+ import { registerLifecycle } from "./commands/lifecycle.js";
6
+ import { registerLocal } from "./commands/local.js";
7
+ import { registerPlane } from "./commands/plane.js";
8
+ import { registerRun } from "./commands/run.js";
9
+ import { registerRunner } from "./commands/runner.js";
10
+ import { registerSecrets } from "./commands/secrets.js";
11
+ /**
12
+ * Build the `fusionkit` command tree. The global `--dir` option must precede the
13
+ * subcommand (`enablePositionalOptions` keeps the launcher commands' passthrough
14
+ * unambiguous). Each `register*` helper attaches its command(s) and reads the
15
+ * global home directory via `program.opts().dir`.
16
+ */
17
+ export function buildProgram() {
18
+ const program = new Command();
19
+ program
20
+ .name("fusionkit")
21
+ .description("real model fusion behind your coding agent (codex, claude, cursor)")
22
+ .option("-d, --dir <dir>", "fusionkit home (default: ./.fusionkit)")
23
+ .enablePositionalOptions();
24
+ registerInit(program);
25
+ registerPlane(program);
26
+ registerRunner(program);
27
+ registerSecrets(program);
28
+ registerRun(program);
29
+ registerLifecycle(program);
30
+ registerEnsemble(program);
31
+ registerLocal(program);
32
+ registerFusion(program);
33
+ return program;
34
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function buildGatewayCommand(): Command;
@@ -0,0 +1,114 @@
1
+ import { join, resolve } from "node:path";
2
+ import { Command } from "commander";
3
+ import { codexConfigSnippet, gatewaySetupSnippets, installRegistryAdapters, runGatewayAcceptance, runGatewayAcp, startConfiguredGateway } from "../gateway.js";
4
+ import { fail } from "../shared/errors.js";
5
+ import { collect, ensembleModels, parsePort, parseTimeoutMs, unifiedHarnessKinds } from "../shared/options.js";
6
+ function addCommonGatewayOptions(cmd) {
7
+ return cmd
8
+ .option("--fusion-backend <url>", "FusionKit/OpenAI-compatible backend URL")
9
+ .option("--harness <target>", "mock | command | codex | claude-code | cursor-acp (repeatable)", collect)
10
+ .option("--command <cmd>", "command harness script")
11
+ .option("--repo <dir>", "workspace repository", ".")
12
+ .option("--out <dir>", "output directory")
13
+ .option("--model <spec>", "panel model mapping ID=MODEL (repeatable)", collect)
14
+ .option("--judge-model <model>", "model used for judge synthesis")
15
+ .option("--cursor-kit-dir <dir>", "Cursorkit repo for cursor scenarios")
16
+ .option("--timeout-ms <n>", "candidate timeout")
17
+ .option("--fusion-api-key <key>", "API key for the fusion backend");
18
+ }
19
+ function gatewayConfig(opts) {
20
+ const fusionBackendUrl = opts.fusionBackend;
21
+ if (!fusionBackendUrl)
22
+ fail("--fusion-backend is required");
23
+ const timeoutMs = parseTimeoutMs(opts.timeoutMs, 120000);
24
+ return {
25
+ fusionBackendUrl,
26
+ repo: resolve(opts.repo ?? "."),
27
+ outputRoot: resolve(opts.out ?? ".warrant/gateway"),
28
+ harnesses: unifiedHarnessKinds(opts.harness),
29
+ models: ensembleModels(opts.model),
30
+ timeoutMs,
31
+ ...(opts.command !== undefined ? { command: opts.command } : {}),
32
+ ...(opts.judgeModel !== undefined ? { judgeModel: opts.judgeModel } : {}),
33
+ ...(opts.cursorKitDir !== undefined ? { cursorKitDir: resolve(opts.cursorKitDir) } : {}),
34
+ ...(opts.fusionApiKey !== undefined ? { fusionApiKey: opts.fusionApiKey } : {})
35
+ };
36
+ }
37
+ export function buildGatewayCommand() {
38
+ const gateway = new Command("gateway").description("front door: tools drive the fusion ensemble");
39
+ const serve = addCommonGatewayOptions(new Command("serve"))
40
+ .description("serve the fusion harness gateway over the provider wire protocols")
41
+ .option("--host <host>", "bind host", "127.0.0.1")
42
+ .option("--port <n>", "bind port", "8787")
43
+ .option("--auth-token <token>", "require a bearer token on the gateway")
44
+ .action(async (opts) => {
45
+ const config = gatewayConfig(opts);
46
+ const host = opts.host ?? "127.0.0.1";
47
+ const port = parsePort(opts.port, 8787);
48
+ const instance = await startConfiguredGateway({
49
+ config,
50
+ host,
51
+ port,
52
+ ...(opts.authToken !== undefined ? { authToken: opts.authToken } : {})
53
+ });
54
+ console.log(`fusion harness gateway listening on ${instance.url()}`);
55
+ console.log("");
56
+ console.log(gatewaySetupSnippets(instance.url(), "http://127.0.0.1:<cursorkit-port>"));
57
+ });
58
+ gateway.addCommand(serve, { isDefault: true });
59
+ gateway.addCommand(addCommonGatewayOptions(new Command("acp"))
60
+ .description("ACP local agent over JSON-RPC stdio")
61
+ .action(async (opts) => {
62
+ await runGatewayAcp(gatewayConfig(opts));
63
+ }));
64
+ const acpRegistry = new Command("acp-registry").description("registry-backed ACP adapters");
65
+ acpRegistry
66
+ .command("install [ids...]")
67
+ .description("install registry-backed ACP adapters")
68
+ .option("--install-dir <dir>", "adapter metadata dir", ".warrant/acp-registry")
69
+ .action(async (ids, opts) => {
70
+ const agentIds = ids.length > 0 ? ids : ["codex-cli", "claude-agent"];
71
+ const installed = await installRegistryAdapters({
72
+ agentIds,
73
+ installDir: resolve(opts.installDir)
74
+ });
75
+ console.log(`installed ${installed.length} ACP registry adapter(s):`);
76
+ for (const line of installed)
77
+ console.log(` ${line}`);
78
+ });
79
+ gateway.addCommand(acpRegistry);
80
+ gateway.addCommand(addCommonGatewayOptions(new Command("test"))
81
+ .description("unified front-door acceptance suite")
82
+ .option("--host <host>", "bind host", "127.0.0.1")
83
+ .option("--sentinel <text>", "expected substring", "FUSION_OK")
84
+ .action(async (opts) => {
85
+ const config = gatewayConfig(opts);
86
+ const sentinel = opts.sentinel ?? "FUSION_OK";
87
+ const outPath = resolve(opts.out ?? ".warrant/front-door-e2e/front-door-report.json");
88
+ // The report path and the per-run gateway output root must not collide.
89
+ const acceptanceConfig = {
90
+ ...config,
91
+ outputRoot: join(resolve(outPath, ".."), "gateway-runs")
92
+ };
93
+ const { reportPath, failed } = await runGatewayAcceptance({
94
+ config: acceptanceConfig,
95
+ sentinel,
96
+ host: opts.host ?? "127.0.0.1",
97
+ outPath
98
+ });
99
+ console.log(`front-door acceptance report: ${reportPath}`);
100
+ if (failed)
101
+ process.exitCode = 1;
102
+ }));
103
+ gateway
104
+ .command("codex-config")
105
+ .description("print Codex provider config snippet")
106
+ .option("--fusion-backend <url>", "FusionKit/OpenAI-compatible backend URL")
107
+ .option("--host <host>", "bind host", "127.0.0.1")
108
+ .option("--port <n>", "bind port", "8787")
109
+ .action((opts) => {
110
+ const base = opts.fusionBackend ?? `http://${opts.host}:${opts.port}`;
111
+ console.log(codexConfigSnippet(base));
112
+ });
113
+ return gateway;
114
+ }
@@ -0,0 +1,33 @@
1
+ import type { EnsembleRunResult, HarnessAdapter, HarnessSmokeDashboard } from "@fusionkit/ensemble";
2
+ import type { BenchmarkTaskRecordV1, ModelFusionHarnessKind, ModelFusionRecordV1, ModelFusionSideEffects } from "@fusionkit/protocol";
3
+ export type HandoffPayload = {
4
+ category?: string;
5
+ manifest_path?: string;
6
+ task?: unknown;
7
+ };
8
+ export type HandoffHarnessSelection = {
9
+ harness: HarnessAdapter;
10
+ harnessKind: ModelFusionHarnessKind;
11
+ } | {
12
+ skipReason: string;
13
+ harnessKind: ModelFusionHarnessKind;
14
+ harnessId: string;
15
+ };
16
+ export declare function safeId(value: string): string;
17
+ export declare function writeEnsembleOutput(outDir: string, result: EnsembleRunResult): void;
18
+ export declare function readStdinJson(): unknown;
19
+ export declare function parseHandoffTask(payload: unknown): BenchmarkTaskRecordV1;
20
+ export declare function baseGitSha(repo: string): string;
21
+ export declare function recordsForResult(task: BenchmarkTaskRecordV1, result: EnsembleRunResult): ModelFusionRecordV1[];
22
+ export declare function skippedHandoffRecords(input: {
23
+ task: BenchmarkTaskRecordV1;
24
+ descriptorId: string;
25
+ repo: string;
26
+ harnessKind: ModelFusionHarnessKind;
27
+ harnessId: string;
28
+ reason: string;
29
+ }): ModelFusionRecordV1[];
30
+ export declare function selectHandoffHarness(harnessId: string, command: string | undefined, repo: string, timeoutMs: number): HandoffHarnessSelection;
31
+ export declare function handoffSideEffects(harness: string | undefined, task: BenchmarkTaskRecordV1): ModelFusionSideEffects;
32
+ export declare function renderEnsembleSummary(outDir: string, result: EnsembleRunResult): string;
33
+ export declare function renderHarnessSmokeDashboardSummary(dashboard: HarnessSmokeDashboard): string;
@@ -0,0 +1,207 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { claudeCodeHarness, claudeCodeHarnessCredentialSkipReason, codexHarness, codexHarnessCredentialSkipReason, createCommandHarness, createMockHarness } from "@fusionkit/ensemble";
4
+ import { assertBenchmarkTaskRecordV1, assertHarnessCandidateRecordV1, assertJudgeSynthesisRecordV1, assertModelCallRecordV1, assertModelFusionRecord, assertToolExecutionRecordV1, MODEL_FUSION_SCHEMA_BUNDLE_HASH } from "@fusionkit/protocol";
5
+ import { gitText } from "@fusionkit/workspace";
6
+ import { fail } from "../shared/errors.js";
7
+ export function safeId(value) {
8
+ return value.replace(/[^A-Za-z0-9_.:-]/g, "_");
9
+ }
10
+ function writeJson(path, value) {
11
+ writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
12
+ }
13
+ export function writeEnsembleOutput(outDir, result) {
14
+ mkdirSync(outDir, { recursive: true });
15
+ mkdirSync(join(outDir, "candidates"), { recursive: true });
16
+ mkdirSync(join(outDir, "model-call-records"), { recursive: true });
17
+ writeJson(join(outDir, "summary.json"), result.summary ?? {});
18
+ writeJson(join(outDir, "harness-run-request.json"), result.harnessRunRequest);
19
+ writeJson(join(outDir, "harness-run-result.json"), result.harnessRunResult);
20
+ for (const candidate of result.candidates) {
21
+ assertHarnessCandidateRecordV1(candidate);
22
+ writeJson(join(outDir, "candidates", `${safeId(candidate.candidate_id)}.json`), candidate);
23
+ }
24
+ for (const record of result.modelCallRecords) {
25
+ assertModelCallRecordV1(record);
26
+ writeJson(join(outDir, "model-call-records", `${safeId(record.call_id)}.json`), record);
27
+ }
28
+ if (result.judgeSynthesisRecord !== undefined) {
29
+ assertJudgeSynthesisRecordV1(result.judgeSynthesisRecord);
30
+ writeJson(join(outDir, "judge-synthesis-record.json"), result.judgeSynthesisRecord);
31
+ }
32
+ }
33
+ export function readStdinJson() {
34
+ const input = readFileSync(0, "utf8").trim();
35
+ if (!input)
36
+ fail("handoff payload is required on stdin");
37
+ try {
38
+ return JSON.parse(input);
39
+ }
40
+ catch (error) {
41
+ fail(`handoff payload must be valid JSON: ${error.message}`);
42
+ }
43
+ }
44
+ export function parseHandoffTask(payload) {
45
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
46
+ fail("handoff payload must be a JSON object");
47
+ }
48
+ const task = payload.task;
49
+ assertBenchmarkTaskRecordV1(task);
50
+ return task;
51
+ }
52
+ export function baseGitSha(repo) {
53
+ try {
54
+ return gitText(repo, ["rev-parse", "HEAD"]).trim();
55
+ }
56
+ catch {
57
+ return "0".repeat(40);
58
+ }
59
+ }
60
+ function metadata(schema, createdAt) {
61
+ return {
62
+ schema,
63
+ schema_version: "v1",
64
+ schema_bundle_hash: MODEL_FUSION_SCHEMA_BUNDLE_HASH,
65
+ producer: "handoffkit-cli",
66
+ producer_version: "0.1.0",
67
+ producer_git_sha: "0".repeat(40),
68
+ created_at: createdAt
69
+ };
70
+ }
71
+ function toolRecordsForResult(result) {
72
+ return result.toolRecords.map((record) => {
73
+ const toolRecord = {
74
+ ...metadata("tool-execution-record.v1", result.harnessRunResult.created_at),
75
+ execution_id: record.execution_id,
76
+ plan_id: record.plan_id,
77
+ status: record.status,
78
+ ...(record.output_hash !== undefined ? { output_hash: record.output_hash } : {}),
79
+ ...(record.error !== undefined ? { error: record.error } : {})
80
+ };
81
+ assertToolExecutionRecordV1(toolRecord);
82
+ return toolRecord;
83
+ });
84
+ }
85
+ export function recordsForResult(task, result) {
86
+ const toolRecords = toolRecordsForResult(result);
87
+ const records = [
88
+ task,
89
+ result.harnessRunRequest,
90
+ result.harnessRunResult,
91
+ ...result.candidates,
92
+ ...result.modelCallRecords,
93
+ ...toolRecords
94
+ ];
95
+ if (result.judgeSynthesisRecord !== undefined)
96
+ records.push(result.judgeSynthesisRecord);
97
+ for (const record of records)
98
+ assertModelFusionRecord(record);
99
+ return records;
100
+ }
101
+ export function skippedHandoffRecords(input) {
102
+ const createdAt = new Date().toISOString();
103
+ const request = {
104
+ ...metadata("harness-run-request.v1", createdAt),
105
+ request_id: `ensemble_req_${input.descriptorId}`,
106
+ harness_kind: input.harnessKind,
107
+ source_repo: "handoffkit",
108
+ base_git_sha: baseGitSha(input.repo),
109
+ prompt: input.task.prompt ?? "",
110
+ prompt_hash: input.task.prompt_hash,
111
+ allowed_tools: input.task.allowed_tools,
112
+ side_effects: "unknown",
113
+ requested_capabilities: { coding_harness: "unsupported" },
114
+ metadata: {
115
+ harness_id: input.harnessId,
116
+ skipped: true,
117
+ skip_reason: input.reason
118
+ }
119
+ };
120
+ const result = {
121
+ ...metadata("harness-run-result.v1", createdAt),
122
+ result_id: `ensemble_result_${input.descriptorId}`,
123
+ request_id: request.request_id,
124
+ harness_kind: input.harnessKind,
125
+ status: "skipped",
126
+ candidate_ids: [],
127
+ output_summary: input.reason,
128
+ capabilities: { coding_harness: "unsupported" },
129
+ started_at: createdAt,
130
+ finished_at: createdAt,
131
+ errors: [{ kind: "capability_missing", message: input.reason, retryable: false }],
132
+ metadata: {
133
+ descriptor_id: input.descriptorId,
134
+ harness_id: input.harnessId,
135
+ skipped: true
136
+ }
137
+ };
138
+ const records = [input.task, request, result];
139
+ for (const record of records)
140
+ assertModelFusionRecord(record);
141
+ return records;
142
+ }
143
+ export function selectHandoffHarness(harnessId, command, repo, timeoutMs) {
144
+ switch (harnessId) {
145
+ case "mock":
146
+ return { harness: createMockHarness(), harnessKind: "generic" };
147
+ case "command":
148
+ if (!command)
149
+ fail("--command is required when --harness command");
150
+ return {
151
+ harness: createCommandHarness({ command, cwd: repo, timeoutMs }),
152
+ harnessKind: "generic"
153
+ };
154
+ case "claude-code": {
155
+ const reason = claudeCodeHarnessCredentialSkipReason();
156
+ if (reason !== undefined) {
157
+ return { skipReason: reason, harnessKind: "claude_code", harnessId };
158
+ }
159
+ return { harness: claudeCodeHarness({ timeoutMs }), harnessKind: "claude_code" };
160
+ }
161
+ case "codex": {
162
+ const reason = codexHarnessCredentialSkipReason();
163
+ if (reason !== undefined) {
164
+ return { skipReason: reason, harnessKind: "codex", harnessId };
165
+ }
166
+ return { harness: codexHarness({ cwd: repo, timeoutMs }), harnessKind: "codex" };
167
+ }
168
+ default:
169
+ fail('--harness must be "mock", "command", "claude-code", or "codex"');
170
+ }
171
+ }
172
+ export function handoffSideEffects(harness, task) {
173
+ if (harness === "command")
174
+ return "tool_execution";
175
+ const writeTools = new Set(["apply_patch", "write_file", "run_tests", "shell_command"]);
176
+ return task.allowed_tools.some((tool) => writeTools.has(tool)) ? "writes_workspace" : "read_only";
177
+ }
178
+ export function renderEnsembleSummary(outDir, result) {
179
+ const lines = [
180
+ `ensemble ${result.descriptorId} [${result.harnessRunResult.status}]`,
181
+ `candidates: ${result.candidates.length}`,
182
+ ...result.candidates.map((candidate) => ` ${candidate.candidate_id}: ${candidate.status}`),
183
+ `verification: ${result.verification.id}`,
184
+ result.judgeSynthesisRecord
185
+ ? `judge: ${result.judgeSynthesisRecord.status}/${result.judgeSynthesisRecord.decision}`
186
+ : "judge: none",
187
+ `output: ${outDir}`,
188
+ `summary: ${join(outDir, "summary.json")}`
189
+ ];
190
+ return lines.join("\n");
191
+ }
192
+ export function renderHarnessSmokeDashboardSummary(dashboard) {
193
+ const counts = new Map();
194
+ for (const record of dashboard.records) {
195
+ counts.set(record.result.status, (counts.get(record.result.status) ?? 0) + 1);
196
+ }
197
+ const countText = [...counts.entries()]
198
+ .sort(([left], [right]) => left.localeCompare(right))
199
+ .map(([status, count]) => `${status}:${count}`)
200
+ .join(", ");
201
+ return [
202
+ `harness dashboard [${countText}]`,
203
+ `records: ${dashboard.records.length}`,
204
+ `dashboard: ${dashboard.dashboardPath}`,
205
+ `output: ${dashboard.outputRoot}`
206
+ ].join("\n");
207
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerEnsemble(program: Command): void;
@@ -0,0 +1,254 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { Command } from "commander";
4
+ import { createCommandHarness, createMockHarness, createMockJudgeSynthesizer, runEnsemble, runHarnessSmokeDashboard, runUnifiedHarnessE2E } from "@fusionkit/ensemble";
5
+ import { assertHarnessRunRequestV1, assertHarnessRunResultV1 } from "@fusionkit/protocol";
6
+ import { gitText } from "@fusionkit/workspace";
7
+ import { fail } from "../shared/errors.js";
8
+ import { collect, ensembleModels, liveSmokeTargets, parseTimeoutMs, unifiedHarnessKinds } from "../shared/options.js";
9
+ import { buildGatewayCommand } from "./ensemble-gateway.js";
10
+ import { handoffSideEffects, parseHandoffTask, readStdinJson, recordsForResult, renderEnsembleSummary, renderHarnessSmokeDashboardSummary, safeId, selectHandoffHarness, skippedHandoffRecords, writeEnsembleOutput } from "./ensemble-records.js";
11
+ async function runEnsembleRun(task, opts) {
12
+ const prompt = opts.taskFile !== undefined ? readFileSync(opts.taskFile, "utf8") : task.join(" ").trim();
13
+ if (!prompt.trim())
14
+ fail("a task prompt or --task-file is required");
15
+ const harnessId = opts.harness;
16
+ if (harnessId !== "mock" && harnessId !== "command") {
17
+ fail('--harness must be "mock" or "command"');
18
+ }
19
+ const repo = resolve(opts.repo);
20
+ const outDir = resolve(opts.out ?? ".warrant/ensemble-cli");
21
+ const timeoutMs = parseTimeoutMs(opts.timeoutMs, 30000);
22
+ if (harnessId === "command" && !opts.command) {
23
+ fail("--command is required when --harness command");
24
+ }
25
+ const id = opts.id ?? `ensemble_${Date.now()}`;
26
+ const harness = harnessId === "command"
27
+ ? createCommandHarness({ command: opts.command ?? "", cwd: repo, timeoutMs })
28
+ : createMockHarness();
29
+ const judgeId = opts.judge;
30
+ const descriptor = {
31
+ id,
32
+ harness,
33
+ models: ensembleModels(opts.model, harnessId),
34
+ runtime: { id: "local" },
35
+ judge: judgeId === "none"
36
+ ? { id: "none" }
37
+ : {
38
+ id: judgeId,
39
+ synthesizer: createMockJudgeSynthesizer({
40
+ output: {
41
+ decision: "synthesize",
42
+ finalOutput: "CI-safe ensemble smoke synthesis",
43
+ rationale: "synthetic smoke run",
44
+ patch: { content: "", author: "judge" }
45
+ }
46
+ })
47
+ },
48
+ policy: {
49
+ id: opts.policy,
50
+ allowedTools: harnessId === "command" ? ["shell_command"] : ["read_file"],
51
+ sideEffects: harnessId === "command" ? "tool_execution" : "read_only",
52
+ timeoutMs
53
+ },
54
+ prompt,
55
+ sourceRepo: "handoffkit",
56
+ baseGitSha: gitText(repo, ["rev-parse", "HEAD"]).trim(),
57
+ workspace: repo,
58
+ outputRoot: outDir,
59
+ cleanupWorktrees: true
60
+ };
61
+ const result = await runEnsemble(descriptor);
62
+ assertHarnessRunRequestV1(result.harnessRunRequest);
63
+ assertHarnessRunResultV1(result.harnessRunResult);
64
+ writeEnsembleOutput(outDir, result);
65
+ console.log(renderEnsembleSummary(outDir, result));
66
+ if (result.harnessRunResult.status !== "succeeded" || result.failureSummary) {
67
+ process.exitCode = 1;
68
+ }
69
+ }
70
+ async function runEnsembleHandoff(extra, opts) {
71
+ if (extra.length > 0) {
72
+ fail("ensemble handoff reads task payload from stdin and does not accept positional arguments");
73
+ }
74
+ const payload = readStdinJson();
75
+ const task = parseHandoffTask(payload);
76
+ const repo = resolve(opts.repo);
77
+ const outDir = resolve(opts.out ?? ".warrant/ensemble-handoff");
78
+ const timeoutMs = parseTimeoutMs(opts.timeoutMs, 30000);
79
+ const id = opts.id ?? `handoff_${safeId(task.task_id)}`;
80
+ const harnessId = opts.harness;
81
+ const selection = selectHandoffHarness(harnessId, opts.command, repo, timeoutMs);
82
+ if ("skipReason" in selection) {
83
+ process.stdout.write(JSON.stringify({
84
+ records: skippedHandoffRecords({
85
+ task,
86
+ descriptorId: id,
87
+ repo,
88
+ harnessKind: selection.harnessKind,
89
+ harnessId: selection.harnessId,
90
+ reason: selection.skipReason
91
+ })
92
+ }) + "\n");
93
+ return;
94
+ }
95
+ const handoffPayload = payload;
96
+ const descriptor = {
97
+ id,
98
+ harness: selection.harness,
99
+ models: ensembleModels(opts.model, harnessId),
100
+ runtime: { id: "handoff-local" },
101
+ judge: {
102
+ id: opts.judge,
103
+ synthesizer: createMockJudgeSynthesizer({
104
+ output: {
105
+ decision: "synthesize",
106
+ finalOutput: "CI-safe handoff synthesis",
107
+ rationale: "synthetic handoff smoke run",
108
+ patch: { content: "", author: "judge" }
109
+ }
110
+ })
111
+ },
112
+ policy: {
113
+ id: opts.policy ?? "handoff-smoke",
114
+ allowedTools: task.allowed_tools,
115
+ sideEffects: handoffSideEffects(harnessId, task),
116
+ timeoutMs
117
+ },
118
+ prompt: task.prompt ?? "",
119
+ sourceRepo: "handoffkit",
120
+ baseGitSha: gitText(repo, ["rev-parse", "HEAD"]).trim(),
121
+ workspace: repo,
122
+ outputRoot: outDir,
123
+ cleanupWorktrees: true,
124
+ metadata: {
125
+ handoff_protocol: "fusionkit-command-v1",
126
+ benchmark_task_id: task.task_id,
127
+ ...(typeof handoffPayload.manifest_path === "string"
128
+ ? { benchmark_manifest_path: handoffPayload.manifest_path }
129
+ : {}),
130
+ ...(typeof handoffPayload.category === "string"
131
+ ? { benchmark_category: handoffPayload.category }
132
+ : {})
133
+ }
134
+ };
135
+ const result = await runEnsemble(descriptor);
136
+ writeEnsembleOutput(outDir, result);
137
+ process.stdout.write(JSON.stringify({ records: recordsForResult(task, result) }) + "\n");
138
+ }
139
+ async function runEnsembleDashboard(extra, opts) {
140
+ if (extra.length > 0)
141
+ fail("ensemble dashboard does not accept positional arguments");
142
+ const timeoutMs = parseTimeoutMs(opts.timeoutMs, 30000);
143
+ const dashboard = await runHarnessSmokeDashboard({
144
+ repo: resolve(opts.repo),
145
+ ...(opts.out !== undefined ? { outputRoot: resolve(opts.out) } : {}),
146
+ timeoutMs,
147
+ liveSmoke: liveSmokeTargets(opts.liveSmoke)
148
+ });
149
+ console.log(renderHarnessSmokeDashboardSummary(dashboard));
150
+ if (dashboard.records.some((record) => record.purpose === "live" && record.result.status !== "succeeded")) {
151
+ process.exitCode = 1;
152
+ }
153
+ }
154
+ async function runEnsembleE2E(task, opts) {
155
+ const prompt = opts.taskFile !== undefined ? readFileSync(opts.taskFile, "utf8") : task.join(" ").trim();
156
+ if (!prompt.trim())
157
+ fail("a task prompt or --task-file is required");
158
+ const fusionBackendUrl = opts.fusionBackend;
159
+ if (!fusionBackendUrl)
160
+ fail("--fusion-backend is required");
161
+ const timeoutMs = parseTimeoutMs(opts.timeoutMs, 30000);
162
+ const repo = resolve(opts.repo);
163
+ const outDir = resolve(opts.out ?? ".warrant/ensemble-e2e");
164
+ const models = ensembleModels(opts.model);
165
+ const result = await runUnifiedHarnessE2E({
166
+ id: opts.id ?? `unified_${Date.now()}`,
167
+ fusionBackendUrl,
168
+ repo,
169
+ outputRoot: outDir,
170
+ prompt,
171
+ harnesses: unifiedHarnessKinds(opts.harness),
172
+ models,
173
+ ...(opts.command !== undefined ? { command: opts.command } : {}),
174
+ timeoutMs,
175
+ ...(opts.judgeModel !== undefined ? { judgeModel: opts.judgeModel } : {}),
176
+ ...(opts.cursorKitDir !== undefined ? { cursorKitDir: resolve(opts.cursorKitDir) } : {})
177
+ });
178
+ const counts = new Map();
179
+ for (const row of result.results) {
180
+ counts.set(row.status, (counts.get(row.status) ?? 0) + 1);
181
+ }
182
+ const countText = [...counts.entries()]
183
+ .sort(([left], [right]) => left.localeCompare(right))
184
+ .map(([status, count]) => `${status}:${count}`)
185
+ .join(", ");
186
+ console.log(`unified e2e [${countText}]`);
187
+ console.log(`results: ${result.results.length}`);
188
+ console.log(`report: ${result.reportPath}`);
189
+ for (const row of result.results) {
190
+ console.log(` ${row.harness}: ${row.status} (${row.message})`);
191
+ }
192
+ if (result.results.some((row) => row.status === "failed")) {
193
+ process.exitCode = 1;
194
+ }
195
+ }
196
+ export function registerEnsemble(program) {
197
+ const ensemble = new Command("ensemble").description("local ensemble + FusionKit harness tooling");
198
+ ensemble
199
+ .command("run")
200
+ .description("run local ensemble smoke")
201
+ .argument("[task...]", "task prompt")
202
+ .option("--harness <h>", "harness to run: mock | command", "mock")
203
+ .option("--command <cmd>", "shell command for command harness")
204
+ .option("--repo <dir>", "workspace repository", ".")
205
+ .option("--out <dir>", "output directory")
206
+ .option("--id <id>", "descriptor id")
207
+ .option("--model <spec>", "candidate model mapping ID=MODEL (repeatable)", collect)
208
+ .option("--judge <id>", "judge id", "mock")
209
+ .option("--policy <id>", "policy id", "local-smoke")
210
+ .option("--timeout-ms <n>", "command timeout")
211
+ .option("--task-file <file>", "read task prompt from file")
212
+ .action(runEnsembleRun);
213
+ ensemble
214
+ .command("handoff")
215
+ .description("FusionKit stdin/stdout handoff executor")
216
+ .argument("[extra...]", "(handoff reads its task from stdin)")
217
+ .option("--harness <h>", "mock | command | claude-code | codex", "mock")
218
+ .option("--command <cmd>", "shell command for command harness")
219
+ .option("--repo <dir>", "workspace repository", ".")
220
+ .option("--out <dir>", "output directory")
221
+ .option("--id <id>", "descriptor id")
222
+ .option("--model <spec>", "candidate model mapping ID=MODEL (repeatable)", collect)
223
+ .option("--judge <id>", "judge id", "mock")
224
+ .option("--policy <id>", "policy id", "local-smoke")
225
+ .option("--timeout-ms <n>", "command/coding harness timeout")
226
+ .action(runEnsembleHandoff);
227
+ ensemble
228
+ .command("dashboard")
229
+ .description("generate harness smoke dashboard")
230
+ .argument("[extra...]", "(dashboard takes no positional arguments)")
231
+ .option("--repo <dir>", "workspace repository", ".")
232
+ .option("--out <dir>", "output directory")
233
+ .option("--timeout-ms <n>", "command timeout")
234
+ .option("--live-smoke <target>", "include env-gated live smoke: claude-code | codex (repeatable)", collect)
235
+ .action(runEnsembleDashboard);
236
+ ensemble
237
+ .command("e2e")
238
+ .description("unified FusionKit-backed harness matrix")
239
+ .argument("[task...]", "task prompt")
240
+ .option("--fusion-backend <url>", "FusionKit/OpenAI-compatible backend URL")
241
+ .option("--harness <target>", "mock | command | codex | claude-code | cursor-acp | cursor-desktop (repeatable)", collect)
242
+ .option("--command <cmd>", "command harness script")
243
+ .option("--repo <dir>", "workspace repository", ".")
244
+ .option("--out <dir>", "output directory")
245
+ .option("--id <id>", "descriptor id")
246
+ .option("--model <spec>", "panel model mapping ID=MODEL (repeatable)", collect)
247
+ .option("--judge-model <model>", "model used for judge synthesis")
248
+ .option("--cursor-kit-dir <dir>", "Cursorkit repo for cursor ACP/desktop scenarios")
249
+ .option("--timeout-ms <n>", "candidate timeout")
250
+ .option("--task-file <file>", "read task prompt from file")
251
+ .action(runEnsembleE2E);
252
+ ensemble.addCommand(buildGatewayCommand());
253
+ program.addCommand(ensemble);
254
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerFusion(program: Command): void;