@loucompanion/forge-bridge 0.1.1-dev.af36c7fa102b → 0.1.1-dev.ec3c3e74a648

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.
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { promisify } from "node:util";
7
+ import { resolveCursorAgentDirectLaunch } from "../shared/cursor-agent/cursor-agent.launch.js";
7
8
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
8
9
  import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
9
10
  import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
@@ -633,6 +634,12 @@ function cliProbeCommandsFor(provider) {
633
634
  ];
634
635
  }
635
636
  async function execVersionCommand(probe, timeout = 2000) {
637
+ const directLaunch = process.platform === "win32"
638
+ ? await resolveCursorAgentDirectLaunch(probe.command, probe.args)
639
+ : null;
640
+ if (directLaunch) {
641
+ return await execFileAsync(directLaunch.command, [...directLaunch.args], { timeout });
642
+ }
636
643
  try {
637
644
  return await execFileAsync(probe.command, probe.args, { timeout });
638
645
  }
@@ -19,6 +19,7 @@ export interface CompletionToolServerConfig {
19
19
  readonly workItemId: string;
20
20
  readonly phase: CompletionPhase;
21
21
  readonly producedBy?: string;
22
+ readonly redactionSecrets?: readonly string[];
22
23
  }
23
24
  export interface CompletionArtifactReference {
24
25
  readonly phase: CompletionPhase;
@@ -52,3 +53,4 @@ export declare function parseCompletionMcpArgs(argv: readonly string[]): {
52
53
  readonly ok: false;
53
54
  readonly error: string;
54
55
  };
56
+ export declare function completionRedactionEnv(secrets: readonly string[]): Readonly<Record<string, string>>;
@@ -2,7 +2,9 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { redactSecrets } from "../shared/redaction/redaction.js";
5
6
  const toolServerName = "forge";
7
+ const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
6
8
  const phaseFiles = {
7
9
  implementation: {
8
10
  jsonFileName: "implementation.completeness.json",
@@ -122,12 +124,13 @@ export async function writeCompletionArtifact(config, toolName, input) {
122
124
  const payload = asRecord(input, "completion payload");
123
125
  validatePayloadShape(phase, payload);
124
126
  validateOptionalWorkItemId(payload, config.workItemId);
125
- const producedBy = optionalString(payload, "produced_by")
126
- ?? optionalString(payload, "producedBy")
127
+ const redactedPayload = redactSecrets(payload, config.redactionSecrets ?? []);
128
+ const producedBy = optionalString(redactedPayload, "produced_by")
129
+ ?? optionalString(redactedPayload, "producedBy")
127
130
  ?? config.producedBy
128
131
  ?? "forge-completion-tool";
129
- const status = requiredString(payload, "status").trim().toLowerCase();
130
- const artifactWithoutHash = normalizeArtifactPayload(payload, {
132
+ const status = requiredString(redactedPayload, "status").trim().toLowerCase();
133
+ const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
131
134
  phase,
132
135
  workItemId: config.workItemId,
133
136
  producedBy,
@@ -139,7 +142,7 @@ export async function writeCompletionArtifact(config, toolName, input) {
139
142
  content_hash: contentHash
140
143
  };
141
144
  const artifactJson = `${JSON.stringify(artifact, null, 2)}\n`;
142
- const report = reportMarkdown(payload, phase, status, producedBy, contentHash);
145
+ const report = reportMarkdown(redactedPayload, phase, status, producedBy, contentHash);
143
146
  const artifactPath = completionArtifactJsonPath(config.worktreePath, config.workItemId, phase);
144
147
  const reportPath = completionArtifactReportPath(config.worktreePath, config.workItemId, phase);
145
148
  await fs.mkdir(path.dirname(artifactPath), { recursive: true });
@@ -171,7 +174,8 @@ export async function readCompletionArtifact(worktreePath, workItemId, phase) {
171
174
  verdictJson,
172
175
  report,
173
176
  producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
174
- status: optionalString(parsed, "status") ?? "unknown"
177
+ status: optionalString(parsed, "status") ?? "unknown",
178
+ phase
175
179
  };
176
180
  }
177
181
  export async function runCompletionMcpServer(config) {
@@ -231,10 +235,17 @@ export function parseCompletionMcpArgs(argv) {
231
235
  worktreePath: path.resolve(worktreePath),
232
236
  workItemId,
233
237
  phase,
234
- producedBy
238
+ producedBy,
239
+ redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv])
235
240
  }
236
241
  };
237
242
  }
243
+ export function completionRedactionEnv(secrets) {
244
+ const filtered = secrets.filter((secret) => secret.length > 0);
245
+ return filtered.length > 0
246
+ ? { [completionRedactionSecretsEnv]: JSON.stringify(filtered) }
247
+ : {};
248
+ }
238
249
  async function prepareCursorCompletionToolRuntime(worktreePath, runtime) {
239
250
  const cursorDir = path.join(worktreePath, ".cursor");
240
251
  const configPath = path.join(cursorDir, "mcp.json");
@@ -421,10 +432,12 @@ function validatePayloadShape(phase, payload) {
421
432
  if (phase === "implementation") {
422
433
  requireArray(payload, "plan");
423
434
  requireArray(payload, "changed_files");
424
- requireArray(payload, "verification");
435
+ requireArray(payload, "tests_added_or_updated");
436
+ requireArray(payload, "docs_added_or_updated");
425
437
  requireArray(payload, "acceptance_criteria");
426
438
  requireArray(payload, "known_gaps");
427
439
  requireField(payload, "next_phase_context");
440
+ requireImplementationEvidence(payload);
428
441
  requireSatisfiedAcceptanceCriteria(payload);
429
442
  return;
430
443
  }
@@ -450,6 +463,7 @@ function validatePayloadShape(phase, payload) {
450
463
  if (payload.ready_for_dev_test !== true) {
451
464
  throw new Error("validation pass requires ready_for_dev_test: true.");
452
465
  }
466
+ requirePassingCommands(payload);
453
467
  requireSatisfiedAcceptanceCriteria(payload);
454
468
  }
455
469
  function validateOptionalWorkItemId(payload, expectedWorkItemId) {
@@ -500,6 +514,21 @@ function parseStrictCompletionPhase(value) {
500
514
  }
501
515
  return null;
502
516
  }
517
+ function parseCompletionRedactionSecrets(raw) {
518
+ if (!raw?.trim()) {
519
+ return [];
520
+ }
521
+ try {
522
+ const parsed = JSON.parse(raw);
523
+ if (!Array.isArray(parsed)) {
524
+ return [];
525
+ }
526
+ return parsed.filter((value) => typeof value === "string" && value.length > 0);
527
+ }
528
+ catch {
529
+ return [];
530
+ }
531
+ }
503
532
  function phaseFromToolName(toolName) {
504
533
  const normalized = toolName.trim().toLowerCase();
505
534
  if (normalized === "forge.complete_implementation" || normalized === "complete_implementation") {
@@ -534,6 +563,47 @@ function requireNonPassEvidence(payload) {
534
563
  throw new Error("non-pass completion requires explicit blockers, findings, failures, or known gaps.");
535
564
  }
536
565
  }
566
+ function requireImplementationEvidence(payload) {
567
+ const knownGaps = payload.known_gaps;
568
+ const hasKnownGap = Array.isArray(knownGaps) && knownGaps.length > 0;
569
+ for (const key of ["changed_files", "tests_added_or_updated", "docs_added_or_updated"]) {
570
+ const value = payload[key];
571
+ if (Array.isArray(value) && value.length === 0 && !hasKnownGap) {
572
+ throw new Error(`${key} is empty; known_gaps must explain why.`);
573
+ }
574
+ }
575
+ }
576
+ function requirePassingCommands(payload) {
577
+ const commands = payload.commands;
578
+ if (!Array.isArray(commands)) {
579
+ return;
580
+ }
581
+ if (commands.length === 0) {
582
+ throw new Error("commands must include at least one deterministic validation command.");
583
+ }
584
+ for (const [index, command] of commands.entries()) {
585
+ if (!command || typeof command !== "object" || Array.isArray(command)) {
586
+ throw new Error(`commands[${index}] must be an object.`);
587
+ }
588
+ const record = command;
589
+ if (typeof record.command !== "string" || record.command.trim().length === 0) {
590
+ throw new Error(`commands[${index}].command must be a non-empty string.`);
591
+ }
592
+ if (typeof record.summary !== "string" || record.summary.trim().length === 0) {
593
+ throw new Error(`commands[${index}].summary must be a non-empty string.`);
594
+ }
595
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
596
+ throw new Error(`commands[${index}].exit_code must be a finite number.`);
597
+ }
598
+ if (record.exit_code !== 0) {
599
+ throw new Error(`commands[${index}].exit_code must be 0 when status is pass.`);
600
+ }
601
+ const status = typeof record.status === "string" ? record.status.trim().toLowerCase() : "";
602
+ if (status !== "pass") {
603
+ throw new Error(`commands[${index}].status must be pass.`);
604
+ }
605
+ }
606
+ }
537
607
  function requireArray(payload, key) {
538
608
  if (!Array.isArray(payload[key])) {
539
609
  throw new Error(`${key} must be an array.`);
@@ -11,6 +11,7 @@ export interface ProviderProcessRunRequest {
11
11
  readonly command: string;
12
12
  readonly args: readonly string[];
13
13
  readonly cwd: string;
14
+ readonly env?: Readonly<Record<string, string>>;
14
15
  }
15
16
  export interface ProviderProcessRunResult {
16
17
  readonly exitCode: number;
@@ -5,8 +5,9 @@ import { execFile, spawn } from "node:child_process";
5
5
  import readline from "node:readline";
6
6
  import { promisify } from "node:util";
7
7
  import { GitClient } from "../../shared/git/git.js";
8
+ import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
8
9
  import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
9
- import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForPhase, prepareCompletionToolRuntime, readCompletionArtifact } from "../phase-completion.js";
10
+ import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForPhase, completionRedactionEnv, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, writeCompletionArtifact } from "../phase-completion.js";
10
11
  const fakeWorkerModel = "server-local-fake-worker";
11
12
  const execFileAsync = promisify(execFile);
12
13
  export class RoutingProviderCliService {
@@ -70,16 +71,17 @@ export class ProcessBackedProviderCliService {
70
71
  content: `Forge completion tools configured for ${phase}; provider must call the MCP completion tool before exit.`,
71
72
  eventType: "completion_tools_configured"
72
73
  });
73
- const codexState = new CodexJsonState();
74
+ const outputState = createProviderOutputState(dispatch.execution.provider);
74
75
  let result;
75
76
  try {
76
77
  result = await this.processRunner.run({
77
78
  command: command.command,
78
79
  args: command.args,
79
- cwd: worktree.worktreePath
80
+ cwd: worktree.worktreePath,
81
+ env: providerRedactionEnv(dispatch)
80
82
  }, {
81
83
  onStdoutLine: async (line) => {
82
- const event = codexState.consume(line);
84
+ const event = outputState.consume(line);
83
85
  if (event) {
84
86
  await context.emit(event);
85
87
  }
@@ -96,7 +98,7 @@ export class ProcessBackedProviderCliService {
96
98
  await context.emit({
97
99
  role: "runner",
98
100
  kind: "checkpoint",
99
- content: `Provider CLI completed: ${command.command} exit ${result.exitCode}${codexState.threadId ? ` thread ${codexState.threadId}` : ""}.`,
101
+ content: `Provider CLI completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
100
102
  eventType: "provider_cli_completed"
101
103
  });
102
104
  const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
@@ -104,12 +106,13 @@ export class ProcessBackedProviderCliService {
104
106
  if (!completionArtifact) {
105
107
  throw new Error(`Provider CLI completed but did not call the Forge ${phase} completion tool.`);
106
108
  }
107
- if (codexState.finalMessage) {
109
+ await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, completionArtifact);
110
+ if (outputState.finalMessage) {
108
111
  await context.emit({
109
112
  role: "assistant",
110
113
  kind: "message",
111
- content: codexState.finalMessage,
112
- eventType: "implementation_complete"
114
+ content: outputState.finalMessage,
115
+ eventType: phaseCompleteEventType(phase)
113
116
  });
114
117
  }
115
118
  else {
@@ -117,7 +120,7 @@ export class ProcessBackedProviderCliService {
117
120
  role: "assistant",
118
121
  kind: "message",
119
122
  content: `${dispatch.execution.provider} CLI completed for ${worktree.branchName}.`,
120
- eventType: "implementation_complete"
123
+ eventType: phaseCompleteEventType(phase)
121
124
  });
122
125
  }
123
126
  return {
@@ -142,14 +145,14 @@ export class ProcessBackedProviderCliService {
142
145
  content: `Starting provider CLI QA: ${formatProviderCliCommand(command)}`,
143
146
  eventType: "provider_cli_started"
144
147
  });
145
- const codexState = new CodexJsonState();
148
+ const outputState = createProviderOutputState(dispatch.execution.provider);
146
149
  const result = await this.processRunner.run({
147
150
  command: command.command,
148
151
  args: command.args,
149
152
  cwd: process.cwd()
150
153
  }, {
151
154
  onStdoutLine: async (line) => {
152
- const event = codexState.consume(line);
155
+ const event = outputState.consume(line);
153
156
  if (event) {
154
157
  await emit(event);
155
158
  }
@@ -162,7 +165,7 @@ export class ProcessBackedProviderCliService {
162
165
  await emit({
163
166
  role: "runner",
164
167
  kind: "checkpoint",
165
- content: `Provider CLI QA completed: ${command.command} exit ${result.exitCode}${codexState.threadId ? ` thread ${codexState.threadId}` : ""}.`,
168
+ content: `Provider CLI QA completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
166
169
  eventType: "provider_cli_completed"
167
170
  });
168
171
  return {
@@ -181,14 +184,14 @@ export class ProcessBackedProviderCliService {
181
184
  content: `Starting provider CLI conflict resolve: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
182
185
  eventType: "provider_cli_started"
183
186
  });
184
- const codexState = new CodexJsonState();
187
+ const outputState = createProviderOutputState(dispatch.execution.provider);
185
188
  const result = await this.processRunner.run({
186
189
  command: command.command,
187
190
  args: command.args,
188
191
  cwd: worktree.worktreePath
189
192
  }, {
190
193
  onStdoutLine: async (line) => {
191
- const event = codexState.consume(line);
194
+ const event = outputState.consume(line);
192
195
  if (event) {
193
196
  await context.emit(event);
194
197
  }
@@ -203,7 +206,7 @@ export class ProcessBackedProviderCliService {
203
206
  await context.emit({
204
207
  role: "assistant",
205
208
  kind: "message",
206
- content: codexState.finalMessage || `${dispatch.execution.provider} CLI resolved merge conflicts for ${worktree.branchName}.`,
209
+ content: outputState.finalMessage || `${dispatch.execution.provider} CLI resolved merge conflicts for ${worktree.branchName}.`,
207
210
  eventType: "conflict_resolve_complete"
208
211
  });
209
212
  return {
@@ -249,27 +252,26 @@ export class DeterministicProviderCliService {
249
252
  content: `Created bridge changeset at ${changesetPath}`,
250
253
  eventType: "changeset"
251
254
  });
252
- const verdict = {
253
- schema_version: 1,
254
- status: "pass",
255
- checks: [{ name: "forge_bridge_run_once", status: "pass" }],
256
- metrics: { files_changed: 1 },
257
- produced_by: "forge-bridge",
258
- content_hash: sha256(changesetBody)
259
- };
260
- const verdictJson = JSON.stringify(verdict);
261
- const report = `# Implementation report\n\nforge-bridge prepared \`${worktree.branchName}\` and created \`${changesetPath}\`.\n`;
262
- const artifactPath = path.join(artifactRoot, "implementation.verdict.json");
263
- await fs.writeFile(artifactPath, verdictJson + "\n", "utf8");
264
- await fs.writeFile(path.join(artifactRoot, "implementation.report.md"), report, "utf8");
255
+ await writeCompletionArtifact({
256
+ worktreePath: worktree.worktreePath,
257
+ workItemId: dispatch.workItem.id,
258
+ phase,
259
+ producedBy: "forge-bridge",
260
+ redactionSecrets: exactRedactionSecrets(dispatch)
261
+ }, expectedCompletionToolName(phase), deterministicCompletionPayload(phase, dispatch, changesetBody));
262
+ const completionArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, phase);
263
+ if (!completionArtifact) {
264
+ throw new Error(`Deterministic provider did not produce the Forge ${phase} completion artifact.`);
265
+ }
266
+ await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, completionArtifact);
265
267
  await context.emit({
266
268
  role: "assistant",
267
269
  kind: "message",
268
- content: "Implementation complete. forge-bridge produced deterministic phase artifacts.",
269
- eventType: "implementation_complete"
270
+ content: `${phase.replace(/_/g, " ")} complete. forge-bridge produced deterministic phase artifacts.`,
271
+ eventType: phaseCompleteEventType(phase)
270
272
  });
271
273
  return {
272
- summary: `forge-bridge completed ${worktree.branchName}.`,
274
+ summary: `forge-bridge completed ${worktree.branchName} (${phase}).`,
273
275
  spec: {
274
276
  repoPath: worktree.worktreePath,
275
277
  path: specPath,
@@ -277,14 +279,7 @@ export class DeterministicProviderCliService {
277
279
  producedBy: "forge-lane-command",
278
280
  status: "approved"
279
281
  },
280
- artifact: {
281
- repoPath: worktree.worktreePath,
282
- path: artifactPath,
283
- verdictJson,
284
- report,
285
- producedBy: "forge-bridge",
286
- status: "pass"
287
- }
282
+ artifact: completionArtifact
288
283
  };
289
284
  }
290
285
  async runQa(dispatch, emit) {
@@ -318,12 +313,114 @@ export class DeterministicProviderCliService {
318
313
  };
319
314
  }
320
315
  }
316
+ function phaseCompleteEventType(phase) {
317
+ return phase === "implementation" ? "implementation_complete" : `${phase}_complete`;
318
+ }
319
+ function deterministicCompletionPayload(phase, dispatch, changesetBody) {
320
+ const changesetHash = sha256(changesetBody);
321
+ const criterion = dispatch.workItem.acceptanceCriteria || "Deterministic bridge changeset exists";
322
+ if (phase === "implementation") {
323
+ return {
324
+ status: "pass",
325
+ summary: "forge-bridge produced a deterministic implementation changeset.",
326
+ plan: [{ step: "Create deterministic changeset", status: "done" }],
327
+ changed_files: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Recorded deterministic bridge evidence." }],
328
+ tests_added_or_updated: [{ name: "forge_bridge_run_once", evidence: "The bridge committed FORGE_PHASE5_CHANGESET.md." }],
329
+ docs_added_or_updated: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Deterministic evidence document created for the run." }],
330
+ acceptance_criteria: [
331
+ {
332
+ criterion,
333
+ evidence: `FORGE_PHASE5_CHANGESET.md was committed with content hash ${changesetHash}.`,
334
+ status: "satisfied"
335
+ }
336
+ ],
337
+ known_gaps: [],
338
+ next_phase_context: "Review FORGE_PHASE5_CHANGESET.md and the bridge commit before validation."
339
+ };
340
+ }
341
+ if (phase === "self_review") {
342
+ return {
343
+ status: "pass",
344
+ summary: "forge-bridge fake worker completed deterministic self-review.",
345
+ reviewed_diff: true,
346
+ findings: [],
347
+ security_review: {
348
+ status: "pass",
349
+ notes: "No secret handling, auth, or destructive behavior changes were introduced by the deterministic changeset."
350
+ },
351
+ edge_case_review: {
352
+ status: "pass",
353
+ notes: "The deterministic fake-worker changeset is intentionally narrow and repeatable."
354
+ },
355
+ design_review: {
356
+ status: "not_applicable",
357
+ screenshots: [],
358
+ notes: "No UI changes."
359
+ },
360
+ deterministic_comparison: {
361
+ status: "pass",
362
+ command_or_tool: "git diff --stat HEAD~1..HEAD",
363
+ notes: `Reviewed deterministic changeset with content hash ${changesetHash}.`
364
+ },
365
+ fixes_applied: [],
366
+ remaining_risks: []
367
+ };
368
+ }
369
+ return {
370
+ status: "pass",
371
+ summary: "forge-bridge fake worker completed deterministic validation.",
372
+ commands: [
373
+ {
374
+ command: "git status --short && git diff --check",
375
+ exit_code: 0,
376
+ status: "pass",
377
+ summary: "Deterministic validation command completed for the fake-worker changeset."
378
+ }
379
+ ],
380
+ acceptance_criteria: [
381
+ {
382
+ criterion,
383
+ evidence: `FORGE_PHASE5_CHANGESET.md exists with content hash ${changesetHash}.`,
384
+ status: "satisfied"
385
+ }
386
+ ],
387
+ build_status: "pass",
388
+ test_status: "pass",
389
+ lint_status: "not_applicable",
390
+ design_status: "not_applicable",
391
+ drift_checks: [
392
+ {
393
+ command_or_tool: "git diff --check",
394
+ status: "pass",
395
+ summary: "No deterministic drift or whitespace errors were reported for the fake-worker changeset."
396
+ }
397
+ ],
398
+ ready_for_dev_test: true
399
+ };
400
+ }
401
+ async function emitCompletionArtifactRecorded(emit, worktreePath, artifact) {
402
+ const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
403
+ await emit({
404
+ role: "tool",
405
+ kind: "tool_result",
406
+ content: `Forge completion tool recorded ${artifact.phase ?? "unknown"} artifact ${relativePath} (${artifact.status}).`,
407
+ eventType: "completion_tool_artifact_recorded"
408
+ });
409
+ }
410
+ function providerRedactionEnv(dispatch) {
411
+ const env = completionRedactionEnv(exactRedactionSecrets(dispatch));
412
+ return Object.keys(env).length > 0 ? env : undefined;
413
+ }
414
+ function exactRedactionSecrets(dispatch) {
415
+ return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
416
+ }
321
417
  export class StreamingProviderProcessRunner {
322
418
  async run(request, handlers = {}, signal) {
323
419
  const launch = await resolveProviderProcessLaunch(request.command, request.args);
324
420
  return await new Promise((resolve, reject) => {
325
421
  const child = spawn(launch.command, launch.args, {
326
422
  cwd: request.cwd,
423
+ env: request.env ? { ...process.env, ...request.env } : undefined,
327
424
  stdio: ["ignore", "pipe", "pipe"],
328
425
  windowsHide: true
329
426
  });
@@ -392,6 +489,10 @@ async function resolveProviderProcessLaunch(command, args) {
392
489
  if (process.platform !== "win32") {
393
490
  return { command, args };
394
491
  }
492
+ const cursorDirect = await resolveCursorAgentDirectLaunch(command, args);
493
+ if (cursorDirect) {
494
+ return cursorDirect;
495
+ }
395
496
  const resolved = await resolveWindowsCommand(command);
396
497
  return resolved ? buildWindowsLaunchCommand(resolved, args) : { command, args };
397
498
  }
@@ -399,10 +500,6 @@ async function resolveWindowsCommand(command) {
399
500
  if (path.isAbsolute(command)) {
400
501
  return command;
401
502
  }
402
- const cursorScript = await resolveCursorAgentPowerShellScript(command);
403
- if (cursorScript) {
404
- return cursorScript;
405
- }
406
503
  try {
407
504
  const result = await execFileAsync("where.exe", [command], { timeout: 2000 });
408
505
  return chooseWindowsCommandCandidate(result.stdout.split(/\r?\n/));
@@ -411,18 +508,6 @@ async function resolveWindowsCommand(command) {
411
508
  return null;
412
509
  }
413
510
  }
414
- async function resolveCursorAgentPowerShellScript(command) {
415
- const normalized = command.trim().toLowerCase();
416
- if (normalized !== "cursor-agent" && normalized !== "agent") {
417
- return null;
418
- }
419
- const localAppData = process.env.LOCALAPPDATA;
420
- if (!localAppData) {
421
- return null;
422
- }
423
- const script = path.join(localAppData, "cursor-agent", "cursor-agent.ps1");
424
- return await fileExists(script) ? script : null;
425
- }
426
511
  export function chooseWindowsCommandCandidate(candidates) {
427
512
  const normalized = candidates
428
513
  .map((candidate) => candidate.trim())
@@ -519,10 +604,12 @@ export function buildProviderCliCommand(dispatch, run) {
519
604
  return { command: "codex", args };
520
605
  }
521
606
  if (normalizedProvider === "cursor") {
522
- const args = ["--model", model];
607
+ const args = ["--print", "--output-format", "stream-json", "--model", model];
608
+ if (run?.worktreePath) {
609
+ args.push("--workspace", run.worktreePath);
610
+ }
523
611
  if (run?.prompt) {
524
- args.push("--yolo");
525
- args.push(run.prompt);
612
+ args.push("--yolo", "--approve-mcps", "--trust", run.prompt);
526
613
  }
527
614
  return { command: "cursor-agent", args };
528
615
  }
@@ -611,27 +698,203 @@ function clip(value, maxLength) {
611
698
  }
612
699
  return `${value.slice(0, maxLength)}...`;
613
700
  }
614
- class CodexJsonState {
615
- threadId = null;
701
+ function createProviderOutputState(provider) {
702
+ return provider.trim().toLowerCase() === "cursor"
703
+ ? new CursorStreamJsonState()
704
+ : new CodexJsonState();
705
+ }
706
+ class CursorStreamJsonState {
707
+ sessionId = null;
616
708
  finalMessage = null;
617
- emittedThread = false;
709
+ emittedSession = false;
710
+ get sessionLabel() {
711
+ return this.sessionId ? `session ${this.sessionId}` : null;
712
+ }
618
713
  consume(line) {
619
- const trimmed = line.trim();
620
- if (!trimmed.startsWith("{")) {
714
+ const event = parseJsonLine(line);
715
+ if (!event) {
621
716
  return null;
622
717
  }
623
- let parsed;
624
- try {
625
- parsed = JSON.parse(trimmed);
718
+ const type = stringField(event, "type");
719
+ if (type === "system" && stringField(event, "subtype") === "init") {
720
+ this.sessionId = stringField(event, "session_id");
721
+ if (this.sessionId && !this.emittedSession) {
722
+ this.emittedSession = true;
723
+ return {
724
+ role: "runner",
725
+ kind: "checkpoint",
726
+ content: `Cursor session started: ${this.sessionId}.`,
727
+ eventType: "provider_thread_started"
728
+ };
729
+ }
626
730
  }
627
- catch {
731
+ if (type === "assistant") {
732
+ const text = assistantMessageText(event);
733
+ if (text) {
734
+ this.finalMessage = clip(text, 4000);
735
+ return {
736
+ role: "assistant",
737
+ kind: "message",
738
+ content: this.finalMessage,
739
+ eventType: "provider_message"
740
+ };
741
+ }
742
+ }
743
+ if (type === "tool_call") {
744
+ const subtype = stringField(event, "subtype");
745
+ const toolCall = objectField(event, "tool_call");
746
+ if (subtype === "started") {
747
+ const summary = formatCursorToolCallStarted(toolCall);
748
+ if (summary) {
749
+ return {
750
+ role: "worker",
751
+ kind: "checkpoint",
752
+ content: summary,
753
+ eventType: "provider_tool_call"
754
+ };
755
+ }
756
+ }
757
+ if (subtype === "completed") {
758
+ const completed = formatCursorToolCallCompleted(toolCall);
759
+ if (completed) {
760
+ return {
761
+ role: "worker",
762
+ kind: completed.kind,
763
+ content: completed.content,
764
+ eventType: completed.kind === "file_edit" ? "provider_file_edit" : "provider_tool_call"
765
+ };
766
+ }
767
+ }
768
+ }
769
+ if (type === "result" && stringField(event, "subtype") === "success") {
770
+ const result = stringField(event, "result");
771
+ if (result) {
772
+ this.finalMessage = clip(result, 4000);
773
+ }
774
+ }
775
+ if (type === "error" || (type === "result" && event.is_error === true)) {
776
+ return {
777
+ role: "runner",
778
+ kind: "error",
779
+ content: clip(line.trim(), 1000),
780
+ eventType: "provider_cli_error"
781
+ };
782
+ }
783
+ return null;
784
+ }
785
+ }
786
+ function formatCursorToolCallStarted(toolCall) {
787
+ const readArgs = objectField(objectField(toolCall, "readToolCall"), "args");
788
+ const readPath = stringField(readArgs, "path");
789
+ if (readPath) {
790
+ return `Reading ${readPath}`;
791
+ }
792
+ const writeArgs = objectField(objectField(toolCall, "writeToolCall"), "args");
793
+ const writePath = stringField(writeArgs, "path");
794
+ if (writePath) {
795
+ return `Writing ${writePath}`;
796
+ }
797
+ const shellArgs = objectField(objectField(toolCall, "shellToolCall"), "args");
798
+ const command = stringField(shellArgs, "command");
799
+ if (command) {
800
+ return `Shell command started: ${clip(command, 500)}`;
801
+ }
802
+ const fn = objectField(toolCall, "function");
803
+ const name = stringField(fn, "name");
804
+ if (name) {
805
+ return `Tool call started: ${name}`;
806
+ }
807
+ return null;
808
+ }
809
+ function formatCursorToolCallCompleted(toolCall) {
810
+ const writeArgs = objectField(objectField(toolCall, "writeToolCall"), "args");
811
+ const writePath = stringField(writeArgs, "path");
812
+ const writeResult = objectField(objectField(objectField(toolCall, "writeToolCall"), "result"), "success");
813
+ const writtenPath = stringField(writeResult, "path") ?? writePath;
814
+ if (writtenPath) {
815
+ return {
816
+ kind: "file_edit",
817
+ content: `Wrote ${writtenPath}`
818
+ };
819
+ }
820
+ const readArgs = objectField(objectField(toolCall, "readToolCall"), "args");
821
+ const readPath = stringField(readArgs, "path");
822
+ if (readPath) {
823
+ const readResult = objectField(objectField(objectField(toolCall, "readToolCall"), "result"), "success");
824
+ const totalLines = readResult.totalLines;
825
+ const lineSummary = typeof totalLines === "number" ? ` (${totalLines} lines)` : "";
826
+ return {
827
+ kind: "checkpoint",
828
+ content: `Read ${readPath}${lineSummary}`
829
+ };
830
+ }
831
+ const shellArgs = objectField(objectField(toolCall, "shellToolCall"), "args");
832
+ const command = stringField(shellArgs, "command");
833
+ if (command) {
834
+ return {
835
+ kind: "checkpoint",
836
+ content: `Shell command completed: ${clip(command, 500)}`
837
+ };
838
+ }
839
+ const fn = objectField(toolCall, "function");
840
+ const name = stringField(fn, "name");
841
+ if (name) {
842
+ return {
843
+ kind: "checkpoint",
844
+ content: `Tool call completed: ${name}`
845
+ };
846
+ }
847
+ return null;
848
+ }
849
+ function assistantMessageText(event) {
850
+ const message = objectField(event, "message");
851
+ const content = message.content;
852
+ if (!Array.isArray(content)) {
853
+ return null;
854
+ }
855
+ const parts = content
856
+ .map((entry) => {
857
+ if (!entry || typeof entry !== "object") {
628
858
  return null;
629
859
  }
630
- if (!parsed || typeof parsed !== "object") {
860
+ const block = entry;
861
+ return stringField(block, "text");
862
+ })
863
+ .filter((text) => Boolean(text));
864
+ if (parts.length === 0) {
865
+ return null;
866
+ }
867
+ return parts.join("");
868
+ }
869
+ function parseJsonLine(line) {
870
+ const trimmed = line.trim();
871
+ if (!trimmed.startsWith("{")) {
872
+ return null;
873
+ }
874
+ let parsed;
875
+ try {
876
+ parsed = JSON.parse(trimmed);
877
+ }
878
+ catch {
879
+ return null;
880
+ }
881
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
882
+ ? parsed
883
+ : null;
884
+ }
885
+ class CodexJsonState {
886
+ threadId = null;
887
+ finalMessage = null;
888
+ emittedThread = false;
889
+ get sessionLabel() {
890
+ return this.threadId ? `thread ${this.threadId}` : null;
891
+ }
892
+ consume(line) {
893
+ const event = parseJsonLine(line);
894
+ if (!event) {
631
895
  return null;
632
896
  }
633
- const event = parsed;
634
- const type = typeof event.type === "string" ? event.type : "";
897
+ const type = stringField(event, "type") ?? "";
635
898
  if (type === "thread.started") {
636
899
  this.threadId = stringField(event, "thread_id");
637
900
  if (this.threadId && !this.emittedThread) {
@@ -656,7 +919,7 @@ class CodexJsonState {
656
919
  return {
657
920
  role: "runner",
658
921
  kind: "error",
659
- content: clip(trimmed, 1000),
922
+ content: clip(line.trim(), 1000),
660
923
  eventType: "provider_cli_error"
661
924
  };
662
925
  }
@@ -10,4 +10,5 @@ export interface ProviderCliRunResult {
10
10
  readonly summary: string;
11
11
  readonly spec?: RunnerDispatchSpec | null;
12
12
  readonly artifact?: RunnerDispatchArtifact | null;
13
+ readonly artifacts?: readonly RunnerDispatchArtifact[] | null;
13
14
  }
@@ -65,13 +65,15 @@ export class BridgeSessionService {
65
65
  emit,
66
66
  signal
67
67
  });
68
+ const phaseArtifacts = run.artifacts?.length ? run.artifacts : run.artifact ? [run.artifact] : [];
68
69
  return redactSecrets({
69
70
  schemaVersion: 1,
70
71
  exitCode: 0,
71
72
  summary: run.summary,
72
73
  events,
73
74
  spec: run.spec ?? null,
74
- artifact: run.artifact ?? null
75
+ artifact: run.artifact ?? null,
76
+ artifacts: phaseArtifacts
75
77
  }, redactionSecrets);
76
78
  }
77
79
  finally {
@@ -18,6 +18,7 @@ export interface DispatchResolvedArtifact {
18
18
  readonly scope: string;
19
19
  readonly body: string;
20
20
  readonly contentHash: string;
21
+ readonly version: number;
21
22
  readonly lane?: string | null;
22
23
  readonly event?: string | null;
23
24
  }
@@ -101,6 +102,7 @@ export interface RunnerDispatchArtifact {
101
102
  readonly report: string;
102
103
  readonly producedBy: string;
103
104
  readonly status: string;
105
+ readonly phase?: string | null;
104
106
  }
105
107
  export interface RunOnceResultPacket {
106
108
  readonly schemaVersion: 1;
@@ -109,6 +111,7 @@ export interface RunOnceResultPacket {
109
111
  readonly events: readonly RunnerDispatchEvent[];
110
112
  readonly spec?: RunnerDispatchSpec | null;
111
113
  readonly artifact?: RunnerDispatchArtifact | null;
114
+ readonly artifacts?: readonly RunnerDispatchArtifact[] | null;
112
115
  }
113
116
  export interface RunnerEventLine {
114
117
  readonly type: "runner_event";
@@ -153,6 +153,7 @@ function parseArtifacts(value) {
153
153
  scope: requiredString(artifact, "scope"),
154
154
  body: optionalString(artifact, "body") ?? "",
155
155
  contentHash: requiredString(artifact, "contentHash"),
156
+ version: optionalPositiveInteger(artifact, "version") ?? 1,
156
157
  lane: optionalString(artifact, "lane"),
157
158
  event: optionalString(artifact, "event")
158
159
  };
@@ -188,6 +189,16 @@ function requiredBoolean(source, key) {
188
189
  }
189
190
  return value;
190
191
  }
192
+ function optionalPositiveInteger(source, key) {
193
+ const value = source[key];
194
+ if (value === undefined || value === null) {
195
+ return null;
196
+ }
197
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
198
+ throw new Error(`${key} must be a positive integer when provided.`);
199
+ }
200
+ return value;
201
+ }
191
202
  function requiredNumber(source, key) {
192
203
  const value = source[key];
193
204
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -0,0 +1,9 @@
1
+ export interface CursorAgentLaunch {
2
+ readonly command: string;
3
+ readonly args: readonly string[];
4
+ }
5
+ export declare function isCursorAgentCommand(command: string): boolean;
6
+ export declare function resolveCursorAgentDirectLaunch(command: string, args: readonly string[]): Promise<CursorAgentLaunch | null>;
7
+ export declare function findLatestCursorAgentVersionDir(versionsRoot: string): Promise<string | null>;
8
+ export declare function compareCursorAgentVersionDirNames(left: string, right: string): number;
9
+ export declare function parseCursorAgentVersionSortKey(name: string): number;
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const cursorAgentVersionDirPattern = /^\d{4}\.\d{1,2}\.\d{1,2}-.+/;
4
+ export function isCursorAgentCommand(command) {
5
+ const normalized = command.trim().toLowerCase();
6
+ return normalized === "cursor-agent" || normalized === "agent";
7
+ }
8
+ export async function resolveCursorAgentDirectLaunch(command, args) {
9
+ if (!isCursorAgentCommand(command)) {
10
+ return null;
11
+ }
12
+ const localAppData = process.env.LOCALAPPDATA;
13
+ if (!localAppData) {
14
+ return null;
15
+ }
16
+ const versionDir = await findLatestCursorAgentVersionDir(path.join(localAppData, "cursor-agent", "versions"));
17
+ if (!versionDir) {
18
+ return null;
19
+ }
20
+ const nodePath = path.join(versionDir, "node.exe");
21
+ const indexPath = path.join(versionDir, "index.js");
22
+ if (!(await fileExists(nodePath)) || !(await fileExists(indexPath))) {
23
+ return null;
24
+ }
25
+ return {
26
+ command: nodePath,
27
+ args: [indexPath, ...args]
28
+ };
29
+ }
30
+ export async function findLatestCursorAgentVersionDir(versionsRoot) {
31
+ let entries;
32
+ try {
33
+ entries = await fs.readdir(versionsRoot);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ const latestName = entries
39
+ .filter((name) => cursorAgentVersionDirPattern.test(name))
40
+ .sort(compareCursorAgentVersionDirNames)
41
+ .at(-1);
42
+ return latestName ? path.join(versionsRoot, latestName) : null;
43
+ }
44
+ export function compareCursorAgentVersionDirNames(left, right) {
45
+ const leftKey = parseCursorAgentVersionSortKey(left);
46
+ const rightKey = parseCursorAgentVersionSortKey(right);
47
+ if (leftKey !== rightKey) {
48
+ return leftKey - rightKey;
49
+ }
50
+ return left.localeCompare(right);
51
+ }
52
+ export function parseCursorAgentVersionSortKey(name) {
53
+ const datePart = name.split("-")[0] ?? "";
54
+ const parts = datePart.split(".");
55
+ if (parts.length !== 3) {
56
+ return 0;
57
+ }
58
+ const year = parts[0] ?? "";
59
+ const month = (parts[1] ?? "").padStart(2, "0");
60
+ const day = (parts[2] ?? "").padStart(2, "0");
61
+ const numeric = Number(`${year}${month}${day}`);
62
+ return Number.isFinite(numeric) ? numeric : 0;
63
+ }
64
+ async function fileExists(inputPath) {
65
+ return fs.stat(inputPath).then((stat) => stat.isFile()).catch(() => false);
66
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const forgeBridgeVersion = "0.1.1-dev.af36c7fa102b";
1
+ export declare const forgeBridgeVersion = "0.1.1-dev.ec3c3e74a648";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const forgeBridgeVersion = "0.1.1-dev.af36c7fa102b";
1
+ export const forgeBridgeVersion = "0.1.1-dev.ec3c3e74a648";
@@ -29,10 +29,7 @@ export function selectLaneCommand(artifacts, lane, phase) {
29
29
  return artifacts.find((artifact) => normalizedArtifactKind(artifact.kind) === "command" && artifact.slug.trim().toLowerCase() === slug);
30
30
  }
31
31
  export function resolveCommandSlug(lane, phase) {
32
- const normalizedPhase = phase.trim().toLowerCase();
33
- if (normalizedPhase === "self_review") {
34
- return "self-review-prompt";
35
- }
32
+ const normalizedPhase = phase.trim().toLowerCase().replace(/-/g, "_");
36
33
  if (normalizedPhase === "rework") {
37
34
  return "rework-prompt";
38
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.1-dev.af36c7fa102b",
3
+ "version": "0.1.1-dev.ec3c3e74a648",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",