@loucompanion/forge-bridge 0.1.1-dev.03b8ba168599 → 0.1.1-dev.2bc1e7f19b3a

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.
@@ -5,7 +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";
10
+ import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDevelopCycle, completionPromptForPhase, completionRedactionEnv, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDevelopCycleCompletion, writeCompletionArtifact } from "../phase-completion.js";
9
11
  const fakeWorkerModel = "server-local-fake-worker";
10
12
  const execFileAsync = promisify(execFile);
11
13
  export class RoutingProviderCliService {
@@ -40,20 +42,23 @@ export class ProcessBackedProviderCliService {
40
42
  }
41
43
  async runImplementation(context) {
42
44
  const { dispatch, worktree } = context;
45
+ const phase = completionPhaseFromDispatch(dispatch);
46
+ const developCycle = usesDevelopCycleCompletion(dispatch);
43
47
  const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
44
- const artifactRoot = path.join(metadataRoot, "artifacts");
45
- await fs.mkdir(artifactRoot, { recursive: true });
48
+ await fs.mkdir(metadataRoot, { recursive: true });
46
49
  const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
47
50
  if (!laneCommand) {
48
51
  throw new Error(`Lane command for lane '${dispatch.execution.lane}' phase '${dispatch.cycleRun.phase}' was not resolved.`);
49
52
  }
50
- const specBody = buildSpecBody(laneCommand, dispatch.workItem);
53
+ const specBody = buildSpecBody(laneCommand, dispatch.workItem, phase);
51
54
  const specPath = path.join(metadataRoot, "spec.md");
52
55
  await fs.writeFile(specPath, specBody, "utf8");
53
56
  await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-lane-command" }) + "\n", "utf8");
57
+ const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase });
54
58
  const command = buildProviderCliCommand(dispatch, {
55
59
  worktreePath: worktree.worktreePath,
56
- prompt: buildImplementationPrompt(dispatch)
60
+ prompt: developCycle ? buildDevelopCyclePrompt(dispatch, phase) : buildImplementationPrompt(dispatch, phase),
61
+ completionTools
57
62
  });
58
63
  await context.emit({
59
64
  role: "runner",
@@ -61,19 +66,34 @@ export class ProcessBackedProviderCliService {
61
66
  content: `Starting provider CLI: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
62
67
  eventType: "provider_cli_started"
63
68
  });
64
- const codexState = new CodexJsonState();
65
- const result = await this.processRunner.run({
66
- command: command.command,
67
- args: command.args,
68
- cwd: worktree.worktreePath
69
- }, {
70
- onStdoutLine: async (line) => {
71
- const event = codexState.consume(line);
72
- if (event) {
73
- await context.emit(event);
69
+ await context.emit({
70
+ role: "runner",
71
+ kind: "checkpoint",
72
+ content: developCycle
73
+ ? "Forge completion tools configured for implementation -> self_review -> validation; provider must call each MCP completion tool before exit."
74
+ : `Forge completion tools configured for ${phase}; provider must call the MCP completion tool before exit.`,
75
+ eventType: "completion_tools_configured"
76
+ });
77
+ const outputState = createProviderOutputState(dispatch.execution.provider);
78
+ let result;
79
+ try {
80
+ result = await this.processRunner.run({
81
+ command: command.command,
82
+ args: command.args,
83
+ cwd: worktree.worktreePath,
84
+ env: providerRedactionEnv(dispatch)
85
+ }, {
86
+ onStdoutLine: async (line) => {
87
+ const event = outputState.consume(line);
88
+ if (event) {
89
+ await context.emit(event);
90
+ }
74
91
  }
75
- }
76
- }, context.signal);
92
+ }, context.signal);
93
+ }
94
+ finally {
95
+ await cleanupCompletionTools(completionTools, context.emit);
96
+ }
77
97
  if (result.exitCode !== 0) {
78
98
  const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
79
99
  throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
@@ -81,43 +101,22 @@ export class ProcessBackedProviderCliService {
81
101
  await context.emit({
82
102
  role: "runner",
83
103
  kind: "checkpoint",
84
- content: `Provider CLI completed: ${command.command} exit ${result.exitCode}${codexState.threadId ? ` thread ${codexState.threadId}` : ""}.`,
104
+ content: `Provider CLI completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
85
105
  eventType: "provider_cli_completed"
86
106
  });
87
107
  const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
88
- const artifactPath = path.join(artifactRoot, "implementation.verdict.json");
89
- const reportPath = path.join(artifactRoot, "implementation.report.md");
90
- const report = buildImplementationReport(dispatch, command, committed, codexState);
91
- const verdict = {
92
- schema_version: 1,
93
- status: "pass",
94
- checks: [
95
- {
96
- name: "provider_cli_exit",
97
- status: "pass",
98
- command: formatProviderCliCommand(command),
99
- exit_code: result.exitCode
100
- }
101
- ],
102
- metrics: {
103
- files_changed: committed.changedFiles.length
104
- },
105
- provider: dispatch.execution.provider,
106
- model: dispatch.execution.model,
107
- produced_by: `${dispatch.execution.provider}-cli`,
108
- thread_id: codexState.threadId,
109
- commit_sha: committed.commitSha,
110
- content_hash: sha256(report)
111
- };
112
- const verdictJson = JSON.stringify(verdict);
113
- await fs.writeFile(artifactPath, `${verdictJson}\n`, "utf8");
114
- await fs.writeFile(reportPath, report, "utf8");
115
- if (codexState.finalMessage) {
108
+ const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle);
109
+ for (const artifact of completionArtifacts) {
110
+ await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, artifact);
111
+ await emitPhaseCompletionEvent(context.emit, artifact);
112
+ }
113
+ const completionArtifact = completionArtifacts[0];
114
+ if (outputState.finalMessage) {
116
115
  await context.emit({
117
116
  role: "assistant",
118
117
  kind: "message",
119
- content: codexState.finalMessage,
120
- eventType: "implementation_complete"
118
+ content: outputState.finalMessage,
119
+ eventType: "provider_final_message"
121
120
  });
122
121
  }
123
122
  else {
@@ -125,11 +124,11 @@ export class ProcessBackedProviderCliService {
125
124
  role: "assistant",
126
125
  kind: "message",
127
126
  content: `${dispatch.execution.provider} CLI completed for ${worktree.branchName}.`,
128
- eventType: "implementation_complete"
127
+ eventType: "provider_final_message"
129
128
  });
130
129
  }
131
130
  return {
132
- summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName}.`,
131
+ summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}, ${committed.commitSha.slice(0, 12)}).`,
133
132
  spec: {
134
133
  repoPath: worktree.worktreePath,
135
134
  path: specPath,
@@ -137,14 +136,8 @@ export class ProcessBackedProviderCliService {
137
136
  producedBy: "forge-lane-command",
138
137
  status: "approved"
139
138
  },
140
- artifact: {
141
- repoPath: worktree.worktreePath,
142
- path: artifactPath,
143
- verdictJson,
144
- report,
145
- producedBy: `${dispatch.execution.provider}-cli`,
146
- status: "pass"
147
- }
139
+ artifact: completionArtifact,
140
+ artifacts: completionArtifacts
148
141
  };
149
142
  }
150
143
  async runQa(dispatch, emit, signal) {
@@ -157,14 +150,14 @@ export class ProcessBackedProviderCliService {
157
150
  content: `Starting provider CLI QA: ${formatProviderCliCommand(command)}`,
158
151
  eventType: "provider_cli_started"
159
152
  });
160
- const codexState = new CodexJsonState();
153
+ const outputState = createProviderOutputState(dispatch.execution.provider);
161
154
  const result = await this.processRunner.run({
162
155
  command: command.command,
163
156
  args: command.args,
164
157
  cwd: process.cwd()
165
158
  }, {
166
159
  onStdoutLine: async (line) => {
167
- const event = codexState.consume(line);
160
+ const event = outputState.consume(line);
168
161
  if (event) {
169
162
  await emit(event);
170
163
  }
@@ -177,7 +170,7 @@ export class ProcessBackedProviderCliService {
177
170
  await emit({
178
171
  role: "runner",
179
172
  kind: "checkpoint",
180
- content: `Provider CLI QA completed: ${command.command} exit ${result.exitCode}${codexState.threadId ? ` thread ${codexState.threadId}` : ""}.`,
173
+ content: `Provider CLI QA completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
181
174
  eventType: "provider_cli_completed"
182
175
  });
183
176
  return {
@@ -196,14 +189,14 @@ export class ProcessBackedProviderCliService {
196
189
  content: `Starting provider CLI conflict resolve: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
197
190
  eventType: "provider_cli_started"
198
191
  });
199
- const codexState = new CodexJsonState();
192
+ const outputState = createProviderOutputState(dispatch.execution.provider);
200
193
  const result = await this.processRunner.run({
201
194
  command: command.command,
202
195
  args: command.args,
203
196
  cwd: worktree.worktreePath
204
197
  }, {
205
198
  onStdoutLine: async (line) => {
206
- const event = codexState.consume(line);
199
+ const event = outputState.consume(line);
207
200
  if (event) {
208
201
  await context.emit(event);
209
202
  }
@@ -218,7 +211,7 @@ export class ProcessBackedProviderCliService {
218
211
  await context.emit({
219
212
  role: "assistant",
220
213
  kind: "message",
221
- content: codexState.finalMessage || `${dispatch.execution.provider} CLI resolved merge conflicts for ${worktree.branchName}.`,
214
+ content: outputState.finalMessage || `${dispatch.execution.provider} CLI resolved merge conflicts for ${worktree.branchName}.`,
222
215
  eventType: "conflict_resolve_complete"
223
216
  });
224
217
  return {
@@ -233,6 +226,8 @@ export class DeterministicProviderCliService {
233
226
  }
234
227
  async runImplementation(context) {
235
228
  const { dispatch, worktree } = context;
229
+ const phase = completionPhaseFromDispatch(dispatch);
230
+ const developCycle = usesDevelopCycleCompletion(dispatch);
236
231
  const providerCommand = buildProviderCliCommand(dispatch);
237
232
  const providerCommandText = formatProviderCliCommand(providerCommand);
238
233
  const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
@@ -242,7 +237,7 @@ export class DeterministicProviderCliService {
242
237
  if (!laneCommand) {
243
238
  throw new Error(`Lane command for lane '${dispatch.execution.lane}' phase '${dispatch.cycleRun.phase}' was not resolved.`);
244
239
  }
245
- const specBody = `${buildSpecBody(laneCommand, dispatch.workItem)}
240
+ const specBody = `${buildSpecBody(laneCommand, dispatch.workItem, phase)}
246
241
  ## Execution
247
242
 
248
243
  - Provider: ${singleLine(dispatch.execution.provider)}
@@ -263,27 +258,33 @@ export class DeterministicProviderCliService {
263
258
  content: `Created bridge changeset at ${changesetPath}`,
264
259
  eventType: "changeset"
265
260
  });
266
- const verdict = {
267
- schema_version: 1,
268
- status: "pass",
269
- checks: [{ name: "forge_bridge_run_once", status: "pass" }],
270
- metrics: { files_changed: 1 },
271
- produced_by: "forge-bridge",
272
- content_hash: sha256(changesetBody)
273
- };
274
- const verdictJson = JSON.stringify(verdict);
275
- const report = `# Implementation report\n\nforge-bridge prepared \`${worktree.branchName}\` and created \`${changesetPath}\`.\n`;
276
- const artifactPath = path.join(artifactRoot, "implementation.verdict.json");
277
- await fs.writeFile(artifactPath, verdictJson + "\n", "utf8");
278
- await fs.writeFile(path.join(artifactRoot, "implementation.report.md"), report, "utf8");
279
- await context.emit({
280
- role: "assistant",
281
- kind: "message",
282
- content: "Implementation complete. forge-bridge produced deterministic phase artifacts.",
283
- eventType: "implementation_complete"
284
- });
261
+ const phases = developCycle
262
+ ? ["implementation", "self_review", "validation"]
263
+ : [phase];
264
+ const completionArtifacts = [];
265
+ for (const completionPhase of phases) {
266
+ await writeCompletionArtifact({
267
+ worktreePath: worktree.worktreePath,
268
+ workItemId: dispatch.workItem.id,
269
+ phase: completionPhase,
270
+ producedBy: "forge-bridge",
271
+ redactionSecrets: exactRedactionSecrets(dispatch)
272
+ }, expectedCompletionToolName(completionPhase), deterministicCompletionPayload(completionPhase, dispatch, changesetBody));
273
+ const completionArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, completionPhase);
274
+ if (!completionArtifact) {
275
+ throw new Error(`Deterministic provider did not produce the Forge ${completionPhase} completion artifact.`);
276
+ }
277
+ completionArtifacts.push(completionArtifact);
278
+ await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, completionArtifact);
279
+ await context.emit({
280
+ role: "assistant",
281
+ kind: "message",
282
+ content: `${completionPhase.replace(/_/g, " ")} complete. forge-bridge produced deterministic phase artifacts.`,
283
+ eventType: phaseCompleteEventType(completionPhase)
284
+ });
285
+ }
285
286
  return {
286
- summary: `forge-bridge completed ${worktree.branchName}.`,
287
+ summary: `forge-bridge completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}).`,
287
288
  spec: {
288
289
  repoPath: worktree.worktreePath,
289
290
  path: specPath,
@@ -291,14 +292,8 @@ export class DeterministicProviderCliService {
291
292
  producedBy: "forge-lane-command",
292
293
  status: "approved"
293
294
  },
294
- artifact: {
295
- repoPath: worktree.worktreePath,
296
- path: artifactPath,
297
- verdictJson,
298
- report,
299
- producedBy: "forge-bridge",
300
- status: "pass"
301
- }
295
+ artifact: completionArtifacts[0],
296
+ artifacts: completionArtifacts
302
297
  };
303
298
  }
304
299
  async runQa(dispatch, emit) {
@@ -332,12 +327,157 @@ export class DeterministicProviderCliService {
332
327
  };
333
328
  }
334
329
  }
330
+ function phaseCompleteEventType(phase) {
331
+ return phase === "implementation" ? "implementation_complete" : `${phase}_complete`;
332
+ }
333
+ function deterministicCompletionPayload(phase, dispatch, changesetBody) {
334
+ const changesetHash = sha256(changesetBody);
335
+ const criterion = dispatch.workItem.acceptanceCriteria || "Deterministic bridge changeset exists";
336
+ if (phase === "implementation") {
337
+ return {
338
+ status: "pass",
339
+ summary: "forge-bridge produced a deterministic implementation changeset.",
340
+ plan: [{ step: "Create deterministic changeset", status: "done" }],
341
+ changed_files: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Recorded deterministic bridge evidence." }],
342
+ tests_added_or_updated: [{ name: "forge_bridge_run_once", evidence: "The bridge committed FORGE_PHASE5_CHANGESET.md." }],
343
+ docs_added_or_updated: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Deterministic evidence document created for the run." }],
344
+ acceptance_criteria: [
345
+ {
346
+ criterion,
347
+ evidence: `FORGE_PHASE5_CHANGESET.md was committed with content hash ${changesetHash}.`,
348
+ status: "satisfied"
349
+ }
350
+ ],
351
+ known_gaps: [],
352
+ next_phase_context: "Review FORGE_PHASE5_CHANGESET.md and the bridge commit before validation."
353
+ };
354
+ }
355
+ if (phase === "self_review") {
356
+ return {
357
+ status: "pass",
358
+ summary: "forge-bridge fake worker completed deterministic self-review.",
359
+ reviewed_diff: true,
360
+ findings: [],
361
+ security_review: {
362
+ status: "pass",
363
+ notes: "No secret handling, auth, or destructive behavior changes were introduced by the deterministic changeset."
364
+ },
365
+ edge_case_review: {
366
+ status: "pass",
367
+ notes: "The deterministic fake-worker changeset is intentionally narrow and repeatable."
368
+ },
369
+ design_review: {
370
+ status: "not_applicable",
371
+ screenshots: [],
372
+ notes: "No UI changes."
373
+ },
374
+ deterministic_comparison: {
375
+ status: "pass",
376
+ command_or_tool: "git diff --stat HEAD~1..HEAD",
377
+ notes: `Reviewed deterministic changeset with content hash ${changesetHash}.`
378
+ },
379
+ fixes_applied: [],
380
+ remaining_risks: []
381
+ };
382
+ }
383
+ return {
384
+ status: "pass",
385
+ summary: "forge-bridge fake worker completed deterministic validation.",
386
+ commands: [
387
+ {
388
+ command: "git status --short && git diff --check",
389
+ exit_code: 0,
390
+ status: "pass",
391
+ summary: "Deterministic validation command completed for the fake-worker changeset."
392
+ }
393
+ ],
394
+ acceptance_criteria: [
395
+ {
396
+ criterion,
397
+ evidence: `FORGE_PHASE5_CHANGESET.md exists with content hash ${changesetHash}.`,
398
+ status: "satisfied"
399
+ }
400
+ ],
401
+ build_status: "pass",
402
+ test_status: "pass",
403
+ lint_status: "not_applicable",
404
+ design_status: "not_applicable",
405
+ drift_checks: [
406
+ {
407
+ command_or_tool: "git diff --check",
408
+ status: "pass",
409
+ summary: "No deterministic drift or whitespace errors were reported for the fake-worker changeset."
410
+ }
411
+ ],
412
+ ready_for_dev_test: true
413
+ };
414
+ }
415
+ async function emitCompletionArtifactRecorded(emit, worktreePath, artifact) {
416
+ const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
417
+ await emit({
418
+ role: "tool",
419
+ kind: "tool_result",
420
+ content: `Forge completion tool recorded ${artifact.phase ?? "unknown"} artifact ${relativePath} (${artifact.status}).`,
421
+ eventType: "completion_tool_artifact_recorded"
422
+ });
423
+ }
424
+ async function emitPhaseCompletionEvent(emit, artifact) {
425
+ const phase = completionPhaseFromArtifact(artifact);
426
+ await emit({
427
+ role: "assistant",
428
+ kind: "message",
429
+ content: `${phase.replace(/_/g, " ")} completion artifact recorded with status ${artifact.status}.`,
430
+ eventType: phaseCompleteEventType(phase)
431
+ });
432
+ }
433
+ function completionPhaseFromArtifact(artifact) {
434
+ const phase = artifact.phase?.trim();
435
+ return phase === "self_review" || phase === "validation" || phase === "implementation"
436
+ ? phase
437
+ : "implementation";
438
+ }
439
+ async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, developCycle) {
440
+ if (!developCycle) {
441
+ const artifact = await readCompletionArtifact(worktreePath, workItemId, phase);
442
+ if (!artifact) {
443
+ throw new Error(`Provider CLI completed but did not call the Forge ${phase} completion tool.`);
444
+ }
445
+ return [artifact];
446
+ }
447
+ const artifacts = [];
448
+ for (const expectedPhase of ["implementation", "self_review", "validation"]) {
449
+ const artifact = await readCompletionArtifact(worktreePath, workItemId, expectedPhase);
450
+ if (!artifact) {
451
+ if (artifacts.length === 0) {
452
+ throw new Error("Provider CLI completed but did not call the Forge implementation completion tool.");
453
+ }
454
+ const previous = artifacts[artifacts.length - 1];
455
+ if ((previous.status ?? "").trim().toLowerCase() === "pass") {
456
+ throw new Error(`Provider CLI completed after ${previous.phase} passed but did not call the Forge ${expectedPhase} completion tool.`);
457
+ }
458
+ return artifacts;
459
+ }
460
+ artifacts.push(artifact);
461
+ if ((artifact.status ?? "").trim().toLowerCase() !== "pass") {
462
+ return artifacts;
463
+ }
464
+ }
465
+ return artifacts;
466
+ }
467
+ function providerRedactionEnv(dispatch) {
468
+ const env = completionRedactionEnv(exactRedactionSecrets(dispatch));
469
+ return Object.keys(env).length > 0 ? env : undefined;
470
+ }
471
+ function exactRedactionSecrets(dispatch) {
472
+ return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
473
+ }
335
474
  export class StreamingProviderProcessRunner {
336
475
  async run(request, handlers = {}, signal) {
337
476
  const launch = await resolveProviderProcessLaunch(request.command, request.args);
338
477
  return await new Promise((resolve, reject) => {
339
478
  const child = spawn(launch.command, launch.args, {
340
479
  cwd: request.cwd,
480
+ env: request.env ? { ...process.env, ...request.env } : undefined,
341
481
  stdio: ["ignore", "pipe", "pipe"],
342
482
  windowsHide: true
343
483
  });
@@ -406,6 +546,10 @@ async function resolveProviderProcessLaunch(command, args) {
406
546
  if (process.platform !== "win32") {
407
547
  return { command, args };
408
548
  }
549
+ const cursorDirect = await resolveCursorAgentDirectLaunch(command, args);
550
+ if (cursorDirect) {
551
+ return cursorDirect;
552
+ }
409
553
  const resolved = await resolveWindowsCommand(command);
410
554
  return resolved ? buildWindowsLaunchCommand(resolved, args) : { command, args };
411
555
  }
@@ -413,10 +557,6 @@ async function resolveWindowsCommand(command) {
413
557
  if (path.isAbsolute(command)) {
414
558
  return command;
415
559
  }
416
- const cursorScript = await resolveCursorAgentPowerShellScript(command);
417
- if (cursorScript) {
418
- return cursorScript;
419
- }
420
560
  try {
421
561
  const result = await execFileAsync("where.exe", [command], { timeout: 2000 });
422
562
  return chooseWindowsCommandCandidate(result.stdout.split(/\r?\n/));
@@ -425,18 +565,6 @@ async function resolveWindowsCommand(command) {
425
565
  return null;
426
566
  }
427
567
  }
428
- async function resolveCursorAgentPowerShellScript(command) {
429
- const normalized = command.trim().toLowerCase();
430
- if (normalized !== "cursor-agent" && normalized !== "agent") {
431
- return null;
432
- }
433
- const localAppData = process.env.LOCALAPPDATA;
434
- if (!localAppData) {
435
- return null;
436
- }
437
- const script = path.join(localAppData, "cursor-agent", "cursor-agent.ps1");
438
- return await fileExists(script) ? script : null;
439
- }
440
568
  export function chooseWindowsCommandCandidate(candidates) {
441
569
  const normalized = candidates
442
570
  .map((candidate) => candidate.trim())
@@ -518,7 +646,11 @@ export function buildProviderCliCommand(dispatch, run) {
518
646
  const normalizedProvider = provider.toLowerCase();
519
647
  const model = dispatch.execution.model.trim();
520
648
  if (normalizedProvider === "codex") {
521
- const args = ["exec", "--model", model];
649
+ const args = ["exec"];
650
+ if (run?.completionTools) {
651
+ args.push(...codexMcpConfigArgs(run.completionTools));
652
+ }
653
+ args.push("--model", model);
522
654
  if (run) {
523
655
  args.push("--json", "--dangerously-bypass-approvals-and-sandbox");
524
656
  if (run.worktreePath) {
@@ -529,10 +661,12 @@ export function buildProviderCliCommand(dispatch, run) {
529
661
  return { command: "codex", args };
530
662
  }
531
663
  if (normalizedProvider === "cursor") {
532
- const args = ["--model", model];
664
+ const args = ["--print", "--output-format", "stream-json", "--model", model];
665
+ if (run?.worktreePath) {
666
+ args.push("--workspace", run.worktreePath);
667
+ }
533
668
  if (run?.prompt) {
534
- args.push("--yolo");
535
- args.push(run.prompt);
669
+ args.push("--yolo", "--approve-mcps", "--trust", run.prompt);
536
670
  }
537
671
  return { command: "cursor-agent", args };
538
672
  }
@@ -542,14 +676,40 @@ export function buildProviderCliCommand(dispatch, run) {
542
676
  }
543
677
  return { command: provider, args };
544
678
  }
545
- function buildImplementationPrompt(dispatch) {
546
- return `You are Forge's Develop lane running locally in a prepared git worktree.
679
+ function buildImplementationPrompt(dispatch, phase = completionPhaseFromDispatch(dispatch)) {
680
+ return `You are Forge's ${phase.replace(/_/g, " ")} phase runner running locally in a prepared git worktree.
547
681
 
548
- Read .forge/work-items/${dispatch.workItem.id}/spec.md, implement the work item, and keep the change focused.
682
+ Read .forge/work-items/${dispatch.workItem.id}/spec.md and any prior phase artifacts required by this phase.
683
+ ${phaseWorkInstruction(phase)}
549
684
  Use the repository's existing conventions. Run relevant tests when practical.
550
- The runner owns Forge protocol files: worktree prep, injected .forge commands/hooks/skills, phase verdict artifacts, reports, and commits.
551
- Only read the work item spec under .forge; do not edit .forge files or create Forge verdict/report artifacts.
552
- Finish with a concise summary of changed files and verification.`;
685
+ Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, phase verdict artifacts, reports, and commits.
686
+ Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge verdict/report artifacts by hand.
687
+
688
+ ${completionPromptForPhase(phase)}
689
+
690
+ Finish with a concise summary of changed files and verification after calling the completion tool.`;
691
+ }
692
+ function buildDevelopCyclePrompt(dispatch, phase = completionPhaseFromDispatch(dispatch)) {
693
+ return `You are Forge's develop-cycle runner running locally in a prepared git worktree.
694
+
695
+ Read .forge/work-items/${dispatch.workItem.id}/spec.md, implement the work item, self-review the diff, then validate it in this same provider session.
696
+ Start in the ${phase.replace(/_/g, " ")} phase. Do not stop after implementation if it passes; continue through self-review and validation using the next instruction returned by each completion tool.
697
+ Use the repository's existing conventions. Run relevant tests when practical.
698
+ Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, phase verdict artifacts, reports, and commits.
699
+ Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge verdict/report artifacts by hand.
700
+
701
+ ${completionPromptForDevelopCycle(phase)}
702
+
703
+ Finish with a concise summary of changed files and verification after the validation completion tool has accepted the work, or after the current phase tool records a non-pass blocker.`;
704
+ }
705
+ function phaseWorkInstruction(phase) {
706
+ if (phase === "self_review") {
707
+ return "Review the implementation diff, apply safe fixes when needed, and record remaining risks explicitly.";
708
+ }
709
+ if (phase === "validation") {
710
+ return "Run the validation commands and deterministic drift checks needed to prove the work is ready for Dev Test.";
711
+ }
712
+ return "Implement the work item and keep the change focused.";
553
713
  }
554
714
  function buildQaPrompt(dispatch) {
555
715
  return `You are Forge's QA lane running locally.
@@ -570,6 +730,19 @@ Keep the original work item intent intact:
570
730
 
571
731
  Finish with a concise summary of the conflicts resolved and verification performed.`;
572
732
  }
733
+ async function cleanupCompletionTools(completionTools, emit) {
734
+ try {
735
+ await completionTools.cleanup();
736
+ }
737
+ catch (error) {
738
+ await emit({
739
+ role: "runner",
740
+ kind: "checkpoint",
741
+ content: `Forge completion tool cleanup failed and was ignored: ${clip(messageFromError(error), 600)}`,
742
+ eventType: "completion_tools_cleanup_failed"
743
+ }).catch(() => undefined);
744
+ }
745
+ }
573
746
  async function commitWorktreeChanges(git, worktreePath, workItemId) {
574
747
  await git.run(worktreePath, ["add", "-A"]);
575
748
  await git.run(worktreePath, ["reset", "--", ".forge"]).catch(() => "");
@@ -586,26 +759,8 @@ async function commitWorktreeChanges(git, worktreePath, workItemId) {
586
759
  const commitSha = (await git.run(worktreePath, ["rev-parse", "HEAD"])).trim();
587
760
  return { commitSha, changedFiles };
588
761
  }
589
- function buildImplementationReport(dispatch, command, committed, codexState) {
590
- const files = committed.changedFiles.length > 0
591
- ? committed.changedFiles.map((file) => `- ${file}`).join("\n")
592
- : "- No staged repository files changed; Forge recorded an empty completion commit.";
593
- return `# Implementation report
594
-
595
- Provider: ${dispatch.execution.provider}
596
- Model: ${dispatch.execution.model}
597
- Command: ${formatProviderCliCommand(command)}
598
- Thread: ${codexState.threadId ?? "n/a"}
599
- Commit: ${committed.commitSha}
600
-
601
- ## Changed files
602
-
603
- ${files}
604
-
605
- ## Final message
606
-
607
- ${codexState.finalMessage ?? "Provider CLI completed without a final assistant message."}
608
- `;
762
+ function messageFromError(error) {
763
+ return error instanceof Error ? error.message : String(error);
609
764
  }
610
765
  function clip(value, maxLength) {
611
766
  if (value.length <= maxLength) {
@@ -613,27 +768,203 @@ function clip(value, maxLength) {
613
768
  }
614
769
  return `${value.slice(0, maxLength)}...`;
615
770
  }
616
- class CodexJsonState {
617
- threadId = null;
771
+ function createProviderOutputState(provider) {
772
+ return provider.trim().toLowerCase() === "cursor"
773
+ ? new CursorStreamJsonState()
774
+ : new CodexJsonState();
775
+ }
776
+ class CursorStreamJsonState {
777
+ sessionId = null;
618
778
  finalMessage = null;
619
- emittedThread = false;
779
+ emittedSession = false;
780
+ get sessionLabel() {
781
+ return this.sessionId ? `session ${this.sessionId}` : null;
782
+ }
620
783
  consume(line) {
621
- const trimmed = line.trim();
622
- if (!trimmed.startsWith("{")) {
784
+ const event = parseJsonLine(line);
785
+ if (!event) {
623
786
  return null;
624
787
  }
625
- let parsed;
626
- try {
627
- parsed = JSON.parse(trimmed);
788
+ const type = stringField(event, "type");
789
+ if (type === "system" && stringField(event, "subtype") === "init") {
790
+ this.sessionId = stringField(event, "session_id");
791
+ if (this.sessionId && !this.emittedSession) {
792
+ this.emittedSession = true;
793
+ return {
794
+ role: "runner",
795
+ kind: "checkpoint",
796
+ content: `Cursor session started: ${this.sessionId}.`,
797
+ eventType: "provider_thread_started"
798
+ };
799
+ }
800
+ }
801
+ if (type === "assistant") {
802
+ const text = assistantMessageText(event);
803
+ if (text) {
804
+ this.finalMessage = clip(text, 4000);
805
+ return {
806
+ role: "assistant",
807
+ kind: "message",
808
+ content: this.finalMessage,
809
+ eventType: "provider_message"
810
+ };
811
+ }
812
+ }
813
+ if (type === "tool_call") {
814
+ const subtype = stringField(event, "subtype");
815
+ const toolCall = objectField(event, "tool_call");
816
+ if (subtype === "started") {
817
+ const summary = formatCursorToolCallStarted(toolCall);
818
+ if (summary) {
819
+ return {
820
+ role: "worker",
821
+ kind: "checkpoint",
822
+ content: summary,
823
+ eventType: "provider_tool_call"
824
+ };
825
+ }
826
+ }
827
+ if (subtype === "completed") {
828
+ const completed = formatCursorToolCallCompleted(toolCall);
829
+ if (completed) {
830
+ return {
831
+ role: "worker",
832
+ kind: completed.kind,
833
+ content: completed.content,
834
+ eventType: completed.kind === "file_edit" ? "provider_file_edit" : "provider_tool_call"
835
+ };
836
+ }
837
+ }
838
+ }
839
+ if (type === "result" && stringField(event, "subtype") === "success") {
840
+ const result = stringField(event, "result");
841
+ if (result) {
842
+ this.finalMessage = clip(result, 4000);
843
+ }
628
844
  }
629
- catch {
845
+ if (type === "error" || (type === "result" && event.is_error === true)) {
846
+ return {
847
+ role: "runner",
848
+ kind: "error",
849
+ content: clip(line.trim(), 1000),
850
+ eventType: "provider_cli_error"
851
+ };
852
+ }
853
+ return null;
854
+ }
855
+ }
856
+ function formatCursorToolCallStarted(toolCall) {
857
+ const readArgs = objectField(objectField(toolCall, "readToolCall"), "args");
858
+ const readPath = stringField(readArgs, "path");
859
+ if (readPath) {
860
+ return `Reading ${readPath}`;
861
+ }
862
+ const writeArgs = objectField(objectField(toolCall, "writeToolCall"), "args");
863
+ const writePath = stringField(writeArgs, "path");
864
+ if (writePath) {
865
+ return `Writing ${writePath}`;
866
+ }
867
+ const shellArgs = objectField(objectField(toolCall, "shellToolCall"), "args");
868
+ const command = stringField(shellArgs, "command");
869
+ if (command) {
870
+ return `Shell command started: ${clip(command, 500)}`;
871
+ }
872
+ const fn = objectField(toolCall, "function");
873
+ const name = stringField(fn, "name");
874
+ if (name) {
875
+ return `Tool call started: ${name}`;
876
+ }
877
+ return null;
878
+ }
879
+ function formatCursorToolCallCompleted(toolCall) {
880
+ const writeArgs = objectField(objectField(toolCall, "writeToolCall"), "args");
881
+ const writePath = stringField(writeArgs, "path");
882
+ const writeResult = objectField(objectField(objectField(toolCall, "writeToolCall"), "result"), "success");
883
+ const writtenPath = stringField(writeResult, "path") ?? writePath;
884
+ if (writtenPath) {
885
+ return {
886
+ kind: "file_edit",
887
+ content: `Wrote ${writtenPath}`
888
+ };
889
+ }
890
+ const readArgs = objectField(objectField(toolCall, "readToolCall"), "args");
891
+ const readPath = stringField(readArgs, "path");
892
+ if (readPath) {
893
+ const readResult = objectField(objectField(objectField(toolCall, "readToolCall"), "result"), "success");
894
+ const totalLines = readResult.totalLines;
895
+ const lineSummary = typeof totalLines === "number" ? ` (${totalLines} lines)` : "";
896
+ return {
897
+ kind: "checkpoint",
898
+ content: `Read ${readPath}${lineSummary}`
899
+ };
900
+ }
901
+ const shellArgs = objectField(objectField(toolCall, "shellToolCall"), "args");
902
+ const command = stringField(shellArgs, "command");
903
+ if (command) {
904
+ return {
905
+ kind: "checkpoint",
906
+ content: `Shell command completed: ${clip(command, 500)}`
907
+ };
908
+ }
909
+ const fn = objectField(toolCall, "function");
910
+ const name = stringField(fn, "name");
911
+ if (name) {
912
+ return {
913
+ kind: "checkpoint",
914
+ content: `Tool call completed: ${name}`
915
+ };
916
+ }
917
+ return null;
918
+ }
919
+ function assistantMessageText(event) {
920
+ const message = objectField(event, "message");
921
+ const content = message.content;
922
+ if (!Array.isArray(content)) {
923
+ return null;
924
+ }
925
+ const parts = content
926
+ .map((entry) => {
927
+ if (!entry || typeof entry !== "object") {
630
928
  return null;
631
929
  }
632
- if (!parsed || typeof parsed !== "object") {
930
+ const block = entry;
931
+ return stringField(block, "text");
932
+ })
933
+ .filter((text) => Boolean(text));
934
+ if (parts.length === 0) {
935
+ return null;
936
+ }
937
+ return parts.join("");
938
+ }
939
+ function parseJsonLine(line) {
940
+ const trimmed = line.trim();
941
+ if (!trimmed.startsWith("{")) {
942
+ return null;
943
+ }
944
+ let parsed;
945
+ try {
946
+ parsed = JSON.parse(trimmed);
947
+ }
948
+ catch {
949
+ return null;
950
+ }
951
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
952
+ ? parsed
953
+ : null;
954
+ }
955
+ class CodexJsonState {
956
+ threadId = null;
957
+ finalMessage = null;
958
+ emittedThread = false;
959
+ get sessionLabel() {
960
+ return this.threadId ? `thread ${this.threadId}` : null;
961
+ }
962
+ consume(line) {
963
+ const event = parseJsonLine(line);
964
+ if (!event) {
633
965
  return null;
634
966
  }
635
- const event = parsed;
636
- const type = typeof event.type === "string" ? event.type : "";
967
+ const type = stringField(event, "type") ?? "";
637
968
  if (type === "thread.started") {
638
969
  this.threadId = stringField(event, "thread_id");
639
970
  if (this.threadId && !this.emittedThread) {
@@ -658,7 +989,7 @@ class CodexJsonState {
658
989
  return {
659
990
  role: "runner",
660
991
  kind: "error",
661
- content: clip(trimmed, 1000),
992
+ content: clip(line.trim(), 1000),
662
993
  eventType: "provider_cli_error"
663
994
  };
664
995
  }