@fusionkit/ensemble 0.1.4 → 0.1.5

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.
@@ -0,0 +1,42 @@
1
+ import type { EnsembleModel, HarnessAdapter } from "./harness.js";
2
+ export type CursorRunMode = "ask" | "agent";
3
+ export type CursorExecInput = {
4
+ prompt: string;
5
+ cwd: string;
6
+ fusionBackendUrl: string;
7
+ apiKey?: string;
8
+ model: EnsembleModel;
9
+ cursorKitDir: string;
10
+ command: string;
11
+ modelName: string;
12
+ providerModel: string;
13
+ mode: CursorRunMode;
14
+ timeoutMs?: number;
15
+ env: Record<string, string>;
16
+ };
17
+ export type CursorExecResult = {
18
+ status: "succeeded" | "failed";
19
+ transcript: string;
20
+ diff?: string;
21
+ toolEvents: number;
22
+ exitCode?: number;
23
+ reason?: string;
24
+ };
25
+ export type CursorExecRunner = (input: CursorExecInput) => Promise<CursorExecResult> | CursorExecResult;
26
+ export type CursorHarnessOptions = {
27
+ id?: string;
28
+ command?: string;
29
+ cursorKitDir?: string;
30
+ fusionBackendUrl?: string;
31
+ apiKey?: string;
32
+ modelName?: string;
33
+ providerModel?: string;
34
+ mode?: CursorRunMode;
35
+ timeoutMs?: number;
36
+ env?: Record<string, string | undefined>;
37
+ runner?: CursorExecRunner;
38
+ skipWhenUnavailable?: boolean;
39
+ };
40
+ export declare function cursorHarnessUnavailableReason(env?: Record<string, string | undefined>, options?: Pick<CursorHarnessOptions, "command" | "cursorKitDir">): string | undefined;
41
+ export declare function createCursorHarness(options?: CursorHarnessOptions): HarnessAdapter;
42
+ export declare const cursorHarness: typeof createCursorHarness;
package/dist/cursor.js ADDED
@@ -0,0 +1,440 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ import { artifactHash } from "@fusionkit/protocol";
5
+ const DEFAULT_CURSOR_COMMAND = "cursor-agent";
6
+ const DEFAULT_BRIDGE_MODEL_NAME = "local-fusion";
7
+ const DEFAULT_BRIDGE_PROVIDER_MODEL = "fusion-panel";
8
+ const BRIDGE_START_TIMEOUT_MS = 20_000;
9
+ function definedEnv(env) {
10
+ const result = {};
11
+ for (const [key, value] of Object.entries(env)) {
12
+ if (value !== undefined)
13
+ result[key] = value;
14
+ }
15
+ return result;
16
+ }
17
+ function normalizeModelBaseUrl(fusionBackendUrl) {
18
+ const trimmed = fusionBackendUrl.replace(/\/+$/, "");
19
+ return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
20
+ }
21
+ function commandOnPath(command, env) {
22
+ if (command.includes("/")) {
23
+ return existsSync(command);
24
+ }
25
+ const pathValue = env.PATH ?? process.env.PATH ?? "";
26
+ return pathValue
27
+ .split(delimiter)
28
+ .filter((entry) => entry.length > 0)
29
+ .some((dir) => existsSync(join(dir, command)));
30
+ }
31
+ function resolveCursorKitDir(options, env) {
32
+ return (options.cursorKitDir ??
33
+ env.WARRANT_CURSORKIT_DIR ??
34
+ env.FUSIONKIT_CURSORKIT_DIR);
35
+ }
36
+ function resolveAvailability(options, env) {
37
+ const command = options.command ?? DEFAULT_CURSOR_COMMAND;
38
+ if (options.runner !== undefined) {
39
+ return {
40
+ available: true,
41
+ cursorKitDir: resolveCursorKitDir(options, env) ?? ".",
42
+ command
43
+ };
44
+ }
45
+ const cursorKitDir = resolveCursorKitDir(options, env);
46
+ if (cursorKitDir === undefined) {
47
+ return {
48
+ available: false,
49
+ reason: "Cursorkit checkout is not configured; set WARRANT_CURSORKIT_DIR or pass cursorKitDir."
50
+ };
51
+ }
52
+ if (!existsSync(join(cursorKitDir, "dist/src/cli.js"))) {
53
+ return {
54
+ available: false,
55
+ reason: `Cursorkit bridge build was not found at ${join(cursorKitDir, "dist/src/cli.js")}; run pnpm build in the Cursorkit checkout.`
56
+ };
57
+ }
58
+ if (!commandOnPath(command, env)) {
59
+ return {
60
+ available: false,
61
+ reason: `Cursor CLI "${command}" was not found on PATH; install the Cursor CLI (https://cursor.com/cli) and log in.`
62
+ };
63
+ }
64
+ return { available: true, cursorKitDir, command };
65
+ }
66
+ export function cursorHarnessUnavailableReason(env = process.env, options = {}) {
67
+ const availability = resolveAvailability(options, definedEnv(env));
68
+ return availability.available ? undefined : availability.reason;
69
+ }
70
+ function modeFor(descriptor, override) {
71
+ if (override !== undefined)
72
+ return override;
73
+ switch (descriptor.policy.sideEffects) {
74
+ case "none":
75
+ case "read_only":
76
+ return "ask";
77
+ case "writes_workspace":
78
+ case "network":
79
+ case "tool_execution":
80
+ case "unknown":
81
+ return "agent";
82
+ default: {
83
+ const exhausted = descriptor.policy.sideEffects;
84
+ throw new Error(`unsupported side effects policy: ${String(exhausted)}`);
85
+ }
86
+ }
87
+ }
88
+ function skippedCandidate(input) {
89
+ const transcript = `Cursor adapter skipped: ${input.reason}`;
90
+ const hash = artifactHash(transcript);
91
+ return {
92
+ candidateId: `${input.descriptor.id}_${input.model.id}_${input.ordinal}`,
93
+ model: input.model,
94
+ status: "skipped",
95
+ transcript,
96
+ log: transcript,
97
+ artifacts: [
98
+ {
99
+ artifact_id: `artifact_${input.descriptor.id}_${input.model.id}_cursor_skip`,
100
+ kind: "log",
101
+ hash,
102
+ redaction_status: "synthetic"
103
+ }
104
+ ],
105
+ verification: {
106
+ status: "skipped",
107
+ evidence: [input.reason]
108
+ },
109
+ error: {
110
+ kind: "capability_missing",
111
+ message: input.reason,
112
+ retryable: false
113
+ },
114
+ metadata: {
115
+ adapter: "cursor",
116
+ skip_reason: input.reason
117
+ }
118
+ };
119
+ }
120
+ /**
121
+ * Drives the real cursor-agent CLI in ACP mode against a freshly spawned
122
+ * Cursorkit bridge whose local-model backend points at the fusion gateway.
123
+ * The bridge runs with BRIDGE_AGENT_TOOL_POLICY=all so Cursor can read, edit
124
+ * (apply_patch/write_file), and run shell commands inside the worktree.
125
+ */
126
+ async function defaultCursorRunner(input) {
127
+ const bridgePort = 9700 + Math.floor(Math.random() * 250);
128
+ const bridgeEnv = { ...input.env };
129
+ for (const key of Object.keys(bridgeEnv)) {
130
+ if (key.startsWith("BRIDGE_") ||
131
+ key.startsWith("MODEL_") ||
132
+ key.startsWith("CURSOR_UPSTREAM")) {
133
+ delete bridgeEnv[key];
134
+ }
135
+ }
136
+ Object.assign(bridgeEnv, {
137
+ BRIDGE_PORT: String(bridgePort),
138
+ BRIDGE_ROUTE_INVENTORY: "true",
139
+ BRIDGE_AGENT_TOOL_POLICY: "all",
140
+ BRIDGE_AGENT_TOOL_MAX_ITERATIONS: "24",
141
+ CURSOR_UPSTREAM_BASE_URL: "https://api2.cursor.sh",
142
+ MODEL_BASE_URL: normalizeModelBaseUrl(input.fusionBackendUrl),
143
+ MODEL_API_KEY: input.apiKey ?? "local",
144
+ MODEL_NAME: input.modelName,
145
+ MODEL_PROVIDER_MODEL: input.providerModel,
146
+ MODEL_CONTEXT_TOKEN_LIMIT: "128000"
147
+ });
148
+ let bridgeOut = "";
149
+ const bridge = spawn(process.execPath, ["dist/src/cli.js", "serve"], {
150
+ cwd: input.cursorKitDir,
151
+ env: bridgeEnv,
152
+ stdio: ["ignore", "pipe", "pipe"]
153
+ });
154
+ bridge.stdout.on("data", (chunk) => {
155
+ bridgeOut += chunk.toString("utf8");
156
+ });
157
+ bridge.stderr.on("data", (chunk) => {
158
+ bridgeOut += chunk.toString("utf8");
159
+ });
160
+ const timeoutMs = input.timeoutMs ?? 180_000;
161
+ try {
162
+ const deadline = Date.now() + BRIDGE_START_TIMEOUT_MS;
163
+ while (!/bridge listening/.test(bridgeOut) && Date.now() < deadline) {
164
+ await new Promise((resolve) => setTimeout(resolve, 250));
165
+ }
166
+ if (!/bridge listening/.test(bridgeOut)) {
167
+ return {
168
+ status: "failed",
169
+ transcript: bridgeOut,
170
+ toolEvents: 0,
171
+ reason: "Cursorkit bridge did not start in time."
172
+ };
173
+ }
174
+ const printResult = await driveCursorAgentPrint({
175
+ command: input.command,
176
+ bridgePort,
177
+ modelName: input.modelName,
178
+ mode: input.mode,
179
+ cwd: input.cwd,
180
+ prompt: input.prompt,
181
+ timeoutMs
182
+ });
183
+ const diff = captureWorktreeDiff(input.cwd);
184
+ return {
185
+ status: printResult.status,
186
+ transcript: printResult.transcript,
187
+ toolEvents: diff !== undefined && diff.length > 0 ? 1 : 0,
188
+ ...(printResult.exitCode !== undefined ? { exitCode: printResult.exitCode } : {}),
189
+ ...(diff !== undefined ? { diff } : {}),
190
+ ...(printResult.reason !== undefined ? { reason: printResult.reason } : {})
191
+ };
192
+ }
193
+ finally {
194
+ bridge.kill("SIGTERM");
195
+ }
196
+ }
197
+ /**
198
+ * Drives cursor-agent in headless print mode (`-p`), which "has access to all
199
+ * tools, including write and shell". The bridge runs the Cursor tool loop over
200
+ * the SSE/BidiAppend transport, so the agent can read, apply_patch/write, and
201
+ * run shell inside the worktree. `--trust` skips the workspace-trust prompt and
202
+ * `--force` auto-approves tool actions. For read-only tasks we pass `--mode ask`.
203
+ */
204
+ async function driveCursorAgentPrint(input) {
205
+ const args = [
206
+ "-p",
207
+ "--force",
208
+ "--trust",
209
+ "--output-format",
210
+ "text",
211
+ "--model",
212
+ input.modelName,
213
+ "--endpoint",
214
+ `http://127.0.0.1:${input.bridgePort}`
215
+ ];
216
+ if (input.mode === "ask") {
217
+ args.push("--mode", "ask");
218
+ }
219
+ args.push(input.prompt);
220
+ return await new Promise((resolve) => {
221
+ const child = spawn(input.command, args, {
222
+ cwd: input.cwd,
223
+ stdio: ["ignore", "pipe", "pipe"]
224
+ });
225
+ let stdout = "";
226
+ let stderr = "";
227
+ let timedOut = false;
228
+ const timer = setTimeout(() => {
229
+ timedOut = true;
230
+ child.kill("SIGTERM");
231
+ }, input.timeoutMs);
232
+ child.stdout.on("data", (chunk) => {
233
+ stdout += chunk.toString("utf8");
234
+ });
235
+ child.stderr.on("data", (chunk) => {
236
+ stderr += chunk.toString("utf8");
237
+ });
238
+ child.on("error", (error) => {
239
+ clearTimeout(timer);
240
+ resolve({
241
+ status: "failed",
242
+ transcript: stdout,
243
+ reason: error instanceof Error ? error.message : String(error)
244
+ });
245
+ });
246
+ child.on("exit", (code) => {
247
+ clearTimeout(timer);
248
+ const transcript = [stdout, stderr].filter(Boolean).join("\n");
249
+ if (timedOut) {
250
+ resolve({
251
+ status: "failed",
252
+ transcript,
253
+ reason: "cursor-agent timed out"
254
+ });
255
+ return;
256
+ }
257
+ resolve({
258
+ status: code === 0 ? "succeeded" : "failed",
259
+ transcript,
260
+ exitCode: code ?? 0,
261
+ ...(code === 0 ? {} : { reason: stderr.slice(0, 500) })
262
+ });
263
+ });
264
+ });
265
+ }
266
+ function captureWorktreeDiff(cwd) {
267
+ try {
268
+ const result = spawnSync("git", ["-C", cwd, "diff"], { encoding: "utf8" });
269
+ const stdout = result.stdout ?? "";
270
+ return result.status === 0 && stdout.length > 0 ? stdout : undefined;
271
+ }
272
+ catch {
273
+ return undefined;
274
+ }
275
+ }
276
+ export function createCursorHarness(options = {}) {
277
+ const id = options.id ?? "cursor";
278
+ const runner = options.runner ?? defaultCursorRunner;
279
+ const skipWhenUnavailable = options.skipWhenUnavailable ?? true;
280
+ return {
281
+ id,
282
+ harnessKind: "cursor",
283
+ prepare: () => {
284
+ const env = definedEnv(options.env ?? process.env);
285
+ return { env, availability: resolveAvailability(options, env) };
286
+ },
287
+ capabilities: () => {
288
+ const env = definedEnv(options.env ?? process.env);
289
+ const available = resolveAvailability(options, env).available;
290
+ const status = available ? "supported" : "degraded";
291
+ return {
292
+ workspace_read: status,
293
+ workspace_write: status,
294
+ apply_patch: status,
295
+ tool_call_loop: status,
296
+ tool_records: status,
297
+ verification: status,
298
+ route_observation: "supported",
299
+ adapter_available: available ? "supported" : "unsupported"
300
+ };
301
+ },
302
+ verificationProfile: () => ({
303
+ id: `${id}-verification`,
304
+ requiredEvidence: [
305
+ "cursor-agent transcript",
306
+ "session status",
307
+ "worktree diff or skip reason"
308
+ ]
309
+ }),
310
+ run: async ({ descriptor, model, ordinal, prepared, worktree }) => {
311
+ const state = prepared;
312
+ if (!state.availability.available) {
313
+ if (!skipWhenUnavailable) {
314
+ throw new Error(state.availability.reason);
315
+ }
316
+ return skippedCandidate({
317
+ descriptor,
318
+ model,
319
+ ordinal,
320
+ reason: state.availability.reason
321
+ });
322
+ }
323
+ const fusionBackendUrl = options.fusionBackendUrl ?? state.env.FUSIONKIT_BASE_URL;
324
+ if (fusionBackendUrl === undefined || fusionBackendUrl.length === 0) {
325
+ return skippedCandidate({
326
+ descriptor,
327
+ model,
328
+ ordinal,
329
+ reason: "Fusion backend URL is not configured for the Cursor harness."
330
+ });
331
+ }
332
+ const cwd = worktree?.path ?? descriptor.workspace ?? process.cwd();
333
+ let result;
334
+ try {
335
+ result = await runner({
336
+ prompt: descriptor.prompt,
337
+ cwd,
338
+ fusionBackendUrl,
339
+ ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
340
+ model,
341
+ cursorKitDir: state.availability.cursorKitDir,
342
+ command: state.availability.command,
343
+ modelName: options.modelName ?? DEFAULT_BRIDGE_MODEL_NAME,
344
+ providerModel: options.providerModel ?? model.model ?? DEFAULT_BRIDGE_PROVIDER_MODEL,
345
+ mode: modeFor(descriptor, options.mode),
346
+ ...(options.timeoutMs !== undefined
347
+ ? { timeoutMs: options.timeoutMs }
348
+ : descriptor.policy.timeoutMs !== undefined
349
+ ? { timeoutMs: descriptor.policy.timeoutMs }
350
+ : {}),
351
+ env: state.env
352
+ });
353
+ }
354
+ catch (error) {
355
+ return skippedCandidate({
356
+ descriptor,
357
+ model,
358
+ ordinal,
359
+ reason: error instanceof Error ? error.message : String(error)
360
+ });
361
+ }
362
+ const transcript = result.transcript;
363
+ const outputHash = artifactHash(transcript.length > 0 ? transcript : `cursor:${descriptor.id}`);
364
+ const status = result.status;
365
+ const candidateId = `${descriptor.id}_${model.id}_${ordinal}`;
366
+ const artifacts = [
367
+ {
368
+ artifact_id: `artifact_${descriptor.id}_${model.id}_cursor_transcript`,
369
+ kind: "transcript",
370
+ hash: outputHash,
371
+ redaction_status: "synthetic"
372
+ }
373
+ ];
374
+ if (result.diff !== undefined && result.diff.length > 0) {
375
+ artifacts.push({
376
+ artifact_id: `artifact_${descriptor.id}_${model.id}_cursor_patch`,
377
+ kind: "patch",
378
+ hash: artifactHash(result.diff),
379
+ redaction_status: "synthetic"
380
+ });
381
+ }
382
+ return {
383
+ candidateId,
384
+ model,
385
+ status,
386
+ ...(worktree
387
+ ? { branchName: worktree.branchName, worktreePath: worktree.path }
388
+ : {}),
389
+ transcript,
390
+ ...(result.diff !== undefined ? { diff: result.diff } : {}),
391
+ log: transcript,
392
+ artifacts,
393
+ toolRecords: [
394
+ {
395
+ execution_id: `exec_${candidateId}_cursor`,
396
+ plan_id: `plan_${candidateId}_cursor`,
397
+ status,
398
+ output_hash: outputHash,
399
+ ...(status === "failed"
400
+ ? {
401
+ error: {
402
+ kind: "provider_error",
403
+ message: result.reason ?? "Cursor run failed.",
404
+ retryable: false
405
+ }
406
+ }
407
+ : {})
408
+ }
409
+ ],
410
+ verification: {
411
+ status,
412
+ evidence: [
413
+ `tool_events=${result.toolEvents}`,
414
+ outputHash,
415
+ ...(result.diff !== undefined ? ["worktree_diff"] : [])
416
+ ],
417
+ ...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {})
418
+ },
419
+ ...(status === "failed"
420
+ ? {
421
+ error: {
422
+ kind: "provider_error",
423
+ message: result.reason ?? "Cursor run failed.",
424
+ retryable: false
425
+ }
426
+ }
427
+ : {}),
428
+ metadata: {
429
+ adapter: "cursor",
430
+ mode: modeFor(descriptor, options.mode),
431
+ tool_events: result.toolEvents,
432
+ has_diff: result.diff !== undefined && result.diff.length > 0
433
+ }
434
+ };
435
+ },
436
+ collectArtifacts: () => [],
437
+ cleanup: () => undefined
438
+ };
439
+ }
440
+ export const cursorHarness = createCursorHarness;
@@ -1,6 +1,6 @@
1
1
  import type { HarnessRunResultV1, ModelFusionHarnessKind } from "@fusionkit/protocol";
2
2
  import type { HarnessAdapter, HarnessCapabilities } from "./harness.js";
3
- declare const LIVE_SMOKE_TARGETS: readonly ["claude-code", "codex"];
3
+ declare const LIVE_SMOKE_TARGETS: readonly ["claude-code", "codex", "cursor"];
4
4
  export type HarnessCapabilityTarget = "cursor" | "claude-code" | "codex" | "command" | "mock";
5
5
  export type HarnessAvailability = "available" | "credential_gated" | "missing";
6
6
  export type HarnessLiveSmokeTarget = (typeof LIVE_SMOKE_TARGETS)[number];
package/dist/dashboard.js CHANGED
@@ -5,6 +5,7 @@ import { gitText } from "@fusionkit/workspace";
5
5
  import { claudeCodeHarness, claudeCodeHarnessCredentialSkipReason } from "./claude-code.js";
6
6
  import { createCommandHarness } from "./command.js";
7
7
  import { codexHarness, codexHarnessCredentialSkipReason } from "./codex.js";
8
+ import { createCursorHarness, cursorHarnessUnavailableReason } from "./cursor.js";
8
9
  import { createMockHarness } from "./mock.js";
9
10
  import { runEnsemble } from "./run.js";
10
11
  const PRODUCER_GIT_SHA = "0".repeat(40);
@@ -18,10 +19,12 @@ const DEFAULT_COMMAND_FAILURE = "exit 7";
18
19
  const DEFAULT_OUTPUT_DIR = ".warrant/ensemble-dashboard";
19
20
  const CLAUDE_LIVE_SMOKE_ENV = "WARRANT_CLAUDE_SMOKE";
20
21
  const CODEX_LIVE_SMOKE_ENV = "WARRANT_CODEX_SMOKE";
22
+ const CURSOR_LIVE_SMOKE_ENV = "WARRANT_CURSOR_SMOKE";
21
23
  const ALL_LIVE_SMOKE_ENV = "WARRANT_ENSEMBLE_LIVE_SMOKE";
22
- const LIVE_SMOKE_TARGETS = ["claude-code", "codex"];
24
+ const LIVE_SMOKE_TARGETS = ["claude-code", "codex", "cursor"];
23
25
  const CLAUDE_LIVE_SMOKE_PROMPT = "Read README.md if present, then reply exactly CLAUDE_LIVE_SMOKE_OK. Do not modify files.";
24
26
  const CODEX_LIVE_SMOKE_PROMPT = "Read README.md if present, then reply exactly CODEX_LIVE_SMOKE_OK. Do not modify files.";
27
+ const CURSOR_LIVE_SMOKE_PROMPT = "Read README.md if present, then reply exactly CURSOR_LIVE_SMOKE_OK. Do not modify files.";
25
28
  function metadata(schema, createdAt) {
26
29
  return {
27
30
  schema,
@@ -51,6 +54,8 @@ function liveSmokeEnvName(target) {
51
54
  return CLAUDE_LIVE_SMOKE_ENV;
52
55
  case "codex":
53
56
  return CODEX_LIVE_SMOKE_ENV;
57
+ case "cursor":
58
+ return CURSOR_LIVE_SMOKE_ENV;
54
59
  default: {
55
60
  const exhausted = target;
56
61
  throw new Error(`unsupported live smoke target: ${String(exhausted)}`);
@@ -95,29 +100,18 @@ function displayNameFor(target) {
95
100
  return assertNever(target);
96
101
  }
97
102
  }
98
- function cursorCapabilities() {
99
- return {
100
- workspace_read: "degraded",
101
- workspace_write: "degraded",
102
- apply_patch: "degraded",
103
- tool_records: "degraded",
104
- verification: "degraded",
105
- proprietary_harness: "unsupported",
106
- adapter_available: "unsupported"
107
- };
108
- }
109
103
  function dashboardCapabilitiesFor(target) {
110
104
  switch (target) {
111
105
  case "cursor":
112
106
  return {
113
- model_override: "degraded",
114
- transcript_capture: "degraded",
115
- diff_capture: "degraded",
116
- tool_loop_capture: "degraded",
117
- patch_apply_visibility: "degraded",
118
- route_model_observation: "degraded",
119
- verification_hint: "degraded",
120
- replay_support: "unsupported"
107
+ model_override: "supported",
108
+ transcript_capture: "supported",
109
+ diff_capture: "supported",
110
+ tool_loop_capture: "supported",
111
+ patch_apply_visibility: "supported",
112
+ route_model_observation: "supported",
113
+ verification_hint: "supported",
114
+ replay_support: "degraded"
121
115
  };
122
116
  case "claude-code":
123
117
  return {
@@ -209,9 +203,11 @@ export function createHarnessCapabilityMatrix(options = {}) {
209
203
  const rows = [
210
204
  matrixRow({
211
205
  harnessId: "cursor",
212
- availability: "missing",
213
- capabilities: matrixCapabilities("cursor", cursorCapabilities()),
214
- notes: ["No CI-safe package adapter; represented as an unsupported result record."]
206
+ availability: "credential_gated",
207
+ capabilities: matrixCapabilities("cursor", adapterCapabilities(createCursorHarness({ env }))),
208
+ notes: [
209
+ "Credential-gated; requires a logged-in Cursor CLI and a built Cursorkit checkout (WARRANT_CURSORKIT_DIR)."
210
+ ]
215
211
  }),
216
212
  matrixRow({
217
213
  harnessId: "claude-code",
@@ -272,33 +268,6 @@ function smokeDescriptor(input) {
272
268
  outputRoot: join(input.outputRoot, "runs", input.id)
273
269
  };
274
270
  }
275
- function unsupportedCursorResult(input) {
276
- const result = {
277
- ...metadata("harness-run-result.v1", input.createdAt),
278
- result_id: `ensemble_result_${input.taskId}`,
279
- request_id: `ensemble_req_${input.taskId}`,
280
- harness_kind: "cursor",
281
- status: "unsupported",
282
- candidate_ids: [],
283
- output_summary: "Cursor harness unavailable in CI-safe package context.",
284
- capabilities: cursorCapabilities(),
285
- started_at: input.createdAt,
286
- finished_at: input.createdAt,
287
- errors: [
288
- {
289
- kind: "capability_missing",
290
- message: "Cursor proprietary harness is not available from @fusionkit/ensemble.",
291
- retryable: false
292
- }
293
- ],
294
- metadata: {
295
- dashboard_outcome: "missing",
296
- harness_id: "cursor"
297
- }
298
- };
299
- assertHarnessRunResultV1(result);
300
- return result;
301
- }
302
271
  function liveSmokePreflightFailureResult(input) {
303
272
  const result = {
304
273
  ...metadata("harness-run-result.v1", input.createdAt),
@@ -382,21 +351,6 @@ async function runSmokeTask(input) {
382
351
  resultPath
383
352
  };
384
353
  }
385
- if (input.run.harnessId === "cursor") {
386
- const result = unsupportedCursorResult({
387
- createdAt: input.createdAt,
388
- taskId: input.run.taskId
389
- });
390
- const resultPath = writeRunResult(input.outputRoot, input.run.taskId, result);
391
- return {
392
- taskId: input.run.taskId,
393
- harnessId: input.run.harnessId,
394
- purpose: input.run.purpose,
395
- outcome: input.run.outcome,
396
- result,
397
- resultPath
398
- };
399
- }
400
354
  const descriptor = smokeDescriptor({
401
355
  id: input.run.taskId,
402
356
  harness: input.run.harness,
@@ -476,14 +430,14 @@ function smokeRuns(options) {
476
430
  allowedTools: ["read_file", "apply_patch"]
477
431
  },
478
432
  {
479
- taskId: "cursor-missing",
433
+ taskId: "cursor-skipped",
480
434
  harnessId: "cursor",
481
- purpose: "missing",
482
- outcome: "missing",
483
- harness: createMockHarness({ id: "cursor-missing-placeholder" }),
484
- model: { id: "cursor", model: "cursor-proprietary" },
435
+ purpose: "credential-skip",
436
+ outcome: "skipped",
437
+ harness: createCursorHarness({ env: {} }),
438
+ model: { id: "cursor", model: "fusion-panel" },
485
439
  sideEffects: "writes_workspace",
486
- allowedTools: ["read_file", "write_file", "apply_patch"]
440
+ allowedTools: ["read_file", "write_file", "apply_patch", "run_shell"]
487
441
  }
488
442
  ];
489
443
  }
@@ -526,6 +480,25 @@ function liveSmokeRuns(options) {
526
480
  preflightFailureReason: codexHarnessCredentialSkipReason(options.env)
527
481
  });
528
482
  }
483
+ if (options.targets.includes("cursor")) {
484
+ const harness = options.harnesses?.cursor ??
485
+ createCursorHarness({ env: options.env, skipWhenUnavailable: false });
486
+ runs.push({
487
+ taskId: "cursor-live",
488
+ harnessId: "cursor",
489
+ purpose: "live",
490
+ outcome: "success",
491
+ harness,
492
+ model: {
493
+ id: "cursor",
494
+ model: options.env.WARRANT_CURSOR_SMOKE_MODEL ?? "fusion-panel"
495
+ },
496
+ sideEffects: "read_only",
497
+ allowedTools: ["read_file"],
498
+ prompt: CURSOR_LIVE_SMOKE_PROMPT,
499
+ preflightFailureReason: cursorHarnessUnavailableReason(options.env)
500
+ });
501
+ }
529
502
  return runs;
530
503
  }
531
504
  function capabilityCell(capabilities, capability) {
@@ -592,7 +565,9 @@ function credentialStateFor(harnessId, env) {
592
565
  case "mock":
593
566
  return "not required";
594
567
  case "cursor":
595
- return "not applicable";
568
+ return cursorHarnessUnavailableReason(env) === undefined
569
+ ? "credentials available"
570
+ : "credentials missing/skipped";
596
571
  default:
597
572
  return assertNever(harnessId);
598
573
  }
@@ -604,9 +579,8 @@ function contractReadinessFor(harnessId) {
604
579
  return "contract/mock ready";
605
580
  case "claude-code":
606
581
  case "codex":
607
- return "contract/mock ready";
608
582
  case "cursor":
609
- return "adapter missing";
583
+ return "contract/mock ready";
610
584
  default:
611
585
  return assertNever(harnessId);
612
586
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,97 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { cursorHarness } from "../cursor.js";
7
+ import { createMockHarness } from "../mock.js";
8
+ import { ensemble } from "../run.js";
9
+ function tempOutputRoot() {
10
+ const outputRoot = mkdtempSync(join(tmpdir(), "ensemble-cursor-out-"));
11
+ return {
12
+ outputRoot,
13
+ cleanup: () => rmSync(outputRoot, { recursive: true, force: true })
14
+ };
15
+ }
16
+ function descriptor(outputRoot, overrides = {}) {
17
+ return {
18
+ id: "cursor_ensemble_test",
19
+ harness: createMockHarness(),
20
+ models: [{ id: "cursor", model: "fusion-panel" }],
21
+ runtime: { id: "local" },
22
+ judge: { id: "judge", model: "fake-judge" },
23
+ policy: {
24
+ id: "policy",
25
+ allowedTools: ["read_file", "apply_patch", "run_shell"],
26
+ sideEffects: "writes_workspace",
27
+ timeoutMs: 1_000
28
+ },
29
+ prompt: "Fix the failing test in the repo.",
30
+ sourceRepo: "handoffkit",
31
+ baseGitSha: "b".repeat(40),
32
+ outputRoot,
33
+ ...overrides
34
+ };
35
+ }
36
+ test("cursor adapter skips clearly when Cursorkit is not configured", async () => {
37
+ const { outputRoot, cleanup } = tempOutputRoot();
38
+ try {
39
+ const result = await ensemble.run(descriptor(outputRoot, {
40
+ harness: cursorHarness({ env: {} })
41
+ }));
42
+ assert.equal(result.harnessRunResult.status, "skipped");
43
+ assert.equal(result.candidates[0]?.status, "skipped");
44
+ assert.equal(result.candidates[0]?.error?.kind, "capability_missing");
45
+ assert.match(result.candidates[0]?.error?.message ?? "", /Cursorkit checkout is not configured/);
46
+ }
47
+ finally {
48
+ cleanup();
49
+ }
50
+ });
51
+ test("cursor adapter produces a real candidate with a diff via the injected runner", async () => {
52
+ const { outputRoot, cleanup } = tempOutputRoot();
53
+ let observedMode;
54
+ let observedBackend;
55
+ const runner = (input) => {
56
+ observedMode = input.mode;
57
+ observedBackend = input.fusionBackendUrl;
58
+ return {
59
+ status: "succeeded",
60
+ transcript: "Applied the fix and verified the tests pass.",
61
+ diff: "--- a/calc.ts\n+++ b/calc.ts\n@@ -1,1 +1,1 @@\n-return a - b;\n+return a + b;",
62
+ toolEvents: 3
63
+ };
64
+ };
65
+ try {
66
+ const result = await ensemble.run(descriptor(outputRoot, {
67
+ harness: cursorHarness({
68
+ fusionBackendUrl: "http://127.0.0.1:9999",
69
+ runner
70
+ })
71
+ }));
72
+ assert.equal(observedMode, "agent");
73
+ assert.equal(observedBackend, "http://127.0.0.1:9999");
74
+ assert.equal(result.harnessRunResult.status, "succeeded");
75
+ const candidate = result.candidates[0];
76
+ assert.equal(candidate?.status, "succeeded");
77
+ assert.equal(candidate?.metadata?.adapter, "cursor");
78
+ assert.equal(candidate?.metadata?.tool_events, 3);
79
+ assert.equal(candidate?.metadata?.has_diff, true);
80
+ assert.ok(result.artifacts.some((artifact) => artifact.kind === "patch"), "a patch artifact should be captured");
81
+ }
82
+ finally {
83
+ cleanup();
84
+ }
85
+ });
86
+ test("cursor adapter capabilities report supported when available", () => {
87
+ const runner = () => ({
88
+ status: "succeeded",
89
+ transcript: "ok",
90
+ toolEvents: 0
91
+ });
92
+ const harness = cursorHarness({ runner, fusionBackendUrl: "http://x/v1" });
93
+ const capabilities = harness.capabilities({});
94
+ assert.equal(capabilities.apply_patch, "supported");
95
+ assert.equal(capabilities.tool_call_loop, "supported");
96
+ assert.equal(capabilities.adapter_available, "supported");
97
+ });
@@ -37,7 +37,7 @@ test("capability matrix covers Cursor, Claude Code, Codex, command, and mock", (
37
37
  assert.ok(matrix.capabilities.includes("replay_support"));
38
38
  assert.ok(matrix.capabilities.includes("workspace_read"));
39
39
  assert.ok(matrix.capabilities.includes("verification"));
40
- assert.equal(matrix.rows.find((row) => row.harnessId === "cursor")?.availability, "missing");
40
+ assert.equal(matrix.rows.find((row) => row.harnessId === "cursor")?.availability, "credential_gated");
41
41
  assert.equal(matrix.rows.find((row) => row.harnessId === "claude-code")?.harnessKind, "claude_code");
42
42
  assert.equal(matrix.rows.find((row) => row.harnessId === "codex")?.harnessKind, "codex");
43
43
  });
@@ -63,15 +63,15 @@ test("smoke dashboard writes schema-valid success, failure, skipped, and missing
63
63
  "failed",
64
64
  "skipped",
65
65
  "skipped",
66
+ "skipped",
66
67
  "succeeded",
67
- "succeeded",
68
- "unsupported"
68
+ "succeeded"
69
69
  ]);
70
70
  assert.equal(dashboard.records.find((record) => record.taskId === "claude-code-skipped")?.result
71
71
  .harness_kind, "claude_code");
72
72
  assert.equal(dashboard.records.find((record) => record.taskId === "codex-skipped")?.result.harness_kind, "codex");
73
- assert.equal(dashboard.records.find((record) => record.taskId === "cursor-missing")?.result
74
- .errors?.[0]?.kind, "capability_missing");
73
+ assert.equal(dashboard.records.find((record) => record.taskId === "cursor-skipped")?.result.harness_kind, "cursor");
74
+ assert.equal(dashboard.records.find((record) => record.taskId === "cursor-skipped")?.result.status, "skipped");
75
75
  const markdown = readFileSync(dashboard.dashboardPath, "utf8");
76
76
  assert.match(markdown, /# HandoffKit Harness Smoke Dashboard/);
77
77
  assert.match(markdown, /## Capability Matrix/);
@@ -80,7 +80,7 @@ test("smoke dashboard writes schema-valid success, failure, skipped, and missing
80
80
  assert.match(markdown, /credentials missing\/skipped/);
81
81
  assert.match(markdown, /live smoke not requested/);
82
82
  assert.match(markdown, /command-failure/);
83
- assert.match(markdown, /cursor-missing/);
83
+ assert.match(markdown, /cursor-skipped/);
84
84
  assert.match(markdown, /harness-run-results\/mock-success\.json/);
85
85
  assert.equal(dashboard.readiness.length, 5);
86
86
  }
package/dist/unified.js CHANGED
@@ -7,6 +7,7 @@ import { createAgentHarness } from "./agent.js";
7
7
  import { claudeCodeHarness } from "./claude-code.js";
8
8
  import { createCommandHarness } from "./command.js";
9
9
  import { codexHarness } from "./codex.js";
10
+ import { createCursorHarness } from "./cursor.js";
10
11
  import { createMockHarness } from "./mock.js";
11
12
  import { runEnsemble } from "./run.js";
12
13
  function normalizeFusionBackendUrl(value) {
@@ -84,7 +85,13 @@ function harnessAdapter(kind, options) {
84
85
  return claudeCodeHarness({ timeoutMs: options.timeoutMs });
85
86
  case "cursor-acp":
86
87
  case "cursor-desktop":
87
- throw new Error(`${kind} runs through the Cursor harness adapter path`);
88
+ return createCursorHarness({
89
+ id: kind,
90
+ fusionBackendUrl: normalizeFusionBackendUrl(options.fusionBackendUrl),
91
+ ...(options.fusionApiKey ? { apiKey: options.fusionApiKey } : {}),
92
+ ...(options.cursorKitDir !== undefined ? { cursorKitDir: options.cursorKitDir } : {}),
93
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
94
+ });
88
95
  default: {
89
96
  const exhausted = kind;
90
97
  throw new Error(`unsupported unified harness: ${String(exhausted)}`);
@@ -385,7 +392,10 @@ export async function runUnifiedHarnessE2E(options) {
385
392
  mkdirSync(outputRoot, { recursive: true });
386
393
  const results = [];
387
394
  for (const kind of options.harnesses) {
388
- if (kind === "cursor-acp" || kind === "cursor-desktop") {
395
+ if ((kind === "cursor-acp" || kind === "cursor-desktop") &&
396
+ options.cursorRunner !== undefined) {
397
+ // Explicit probe runner: drive the Cursorkit harness suite and record a
398
+ // route/transcript probe instead of producing real ensemble candidates.
389
399
  results.push(await runCursorHarness(kind, options));
390
400
  continue;
391
401
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusionkit/ensemble",
3
3
  "private": false,
4
- "version": "0.1.4",
4
+ "version": "0.1.5",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/handoffkit.git",
@@ -25,11 +25,11 @@
25
25
  "provenance": true
26
26
  },
27
27
  "dependencies": {
28
- "@fusionkit/adapter-ai-sdk": "0.1.4",
29
- "@fusionkit/model-gateway": "0.1.4",
30
- "@fusionkit/protocol": "0.1.4",
31
- "@fusionkit/runner": "0.1.4",
32
- "@fusionkit/session-harness": "0.1.4",
33
- "@fusionkit/workspace": "0.1.4"
28
+ "@fusionkit/adapter-ai-sdk": "0.1.5",
29
+ "@fusionkit/model-gateway": "0.1.5",
30
+ "@fusionkit/runner": "0.1.5",
31
+ "@fusionkit/session-harness": "0.1.5",
32
+ "@fusionkit/workspace": "0.1.5",
33
+ "@fusionkit/protocol": "0.1.5"
34
34
  }
35
35
  }