@loucompanion/forge-bridge 0.1.1-dev.e52b249f07a8 → 0.1.1-dev.eee62899e922

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.
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { execFile, spawn } from "node:child_process";
5
6
  import readline from "node:readline";
@@ -8,11 +9,52 @@ import { GitClient } from "../../shared/git/git.js";
8
9
  import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
9
10
  import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "../qa-sidecar-validation.js";
10
11
  import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
11
- import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDevelopCycle, completionPromptForPhase, completionRedactionEnv, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDevelopCycleCompletion, writeCompletionArtifact, artifactRootFor } from "../phase-completion.js";
12
+ import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDebugCycle, completionPromptForDevelopCycle, completionPromptForPhase, completionPromptForQaCycle, completionRedactionEnv, expectedCompletionToolName, expectedDebugCompletionToolName, expectedQaCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDebugCycleCompletion, usesDevelopCycleCompletion, writeCompletionArtifact, artifactRootFor, artifactRootForOwner, debugCompletionPhaseOrder, qaCompletionGateOrder } from "../phase-completion.js";
12
13
  import { addUsage, buildProviderUsageEvent, emptyUsage, hasUsage, readUsageNumber } from "./provider-usage.js";
13
14
  const fakeWorkerModel = "server-local-fake-worker";
14
15
  const execFileAsync = promisify(execFile);
15
16
  const developCyclePhaseOrder = ["implementation", "self_review", "validation"];
17
+ const allowedProviderParentEnvNames = [
18
+ "PATH",
19
+ "HOME",
20
+ "USERPROFILE",
21
+ "TMPDIR",
22
+ "TEMP",
23
+ "TMP",
24
+ "LANG"
25
+ ];
26
+ const windowsAllowedProviderParentEnvNames = [
27
+ "SystemRoot",
28
+ "ComSpec",
29
+ "PATHEXT",
30
+ "APPDATA",
31
+ "LOCALAPPDATA"
32
+ ];
33
+ function buildProviderChildEnvironment(requestEnv) {
34
+ const env = {};
35
+ for (const [key, value] of Object.entries(process.env)) {
36
+ if (typeof value === "string" && isAllowedProviderParentEnv(key)) {
37
+ env[key] = value;
38
+ }
39
+ }
40
+ if (requestEnv) {
41
+ for (const [key, value] of Object.entries(requestEnv)) {
42
+ const trimmedKey = key.trim();
43
+ if (trimmedKey.length > 0) {
44
+ env[trimmedKey] = value;
45
+ }
46
+ }
47
+ }
48
+ return env;
49
+ }
50
+ function isAllowedProviderParentEnv(key) {
51
+ return isAllowedProviderParentEnvName(key, allowedProviderParentEnvNames)
52
+ || key.toUpperCase().startsWith("LC_")
53
+ || (process.platform === "win32" && isAllowedProviderParentEnvName(key, windowsAllowedProviderParentEnvNames));
54
+ }
55
+ function isAllowedProviderParentEnvName(key, allowedNames) {
56
+ return allowedNames.some((allowed) => allowed.toLowerCase() === key.toLowerCase());
57
+ }
16
58
  export class RoutingProviderCliService {
17
59
  realProvider;
18
60
  deterministicProvider;
@@ -25,6 +67,11 @@ export class RoutingProviderCliService {
25
67
  ? await this.deterministicProvider.runImplementation(context)
26
68
  : await this.realProvider.runImplementation(context);
27
69
  }
70
+ async runArchitect(context) {
71
+ return isFakeWorkerModel(context.dispatch.execution.model)
72
+ ? await this.deterministicProvider.runArchitect(context)
73
+ : await this.realProvider.runArchitect(context);
74
+ }
28
75
  async runConflictResolve(context) {
29
76
  return isFakeWorkerModel(context.dispatch.execution.model)
30
77
  ? await this.deterministicProvider.runConflictResolve(context)
@@ -35,6 +82,11 @@ export class RoutingProviderCliService {
35
82
  ? await this.deterministicProvider.runQa(context)
36
83
  : await this.realProvider.runQa(context);
37
84
  }
85
+ async runDraft(context) {
86
+ return isFakeWorkerModel(context.dispatch.execution.model)
87
+ ? await this.deterministicProvider.runDraft(context)
88
+ : await this.realProvider.runDraft(context);
89
+ }
38
90
  }
39
91
  export class ProcessBackedProviderCliService {
40
92
  processRunner;
@@ -43,10 +95,65 @@ export class ProcessBackedProviderCliService {
43
95
  this.processRunner = processRunner;
44
96
  this.git = git;
45
97
  }
98
+ async runDraft(context) {
99
+ const { dispatch } = context;
100
+ if (!dispatch.draft) {
101
+ throw new Error("Draft dispatch context is missing.");
102
+ }
103
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "forge-bridge-draft-"));
104
+ const command = buildProviderCliCommand(dispatch, {
105
+ worktreePath: tempDir,
106
+ prompt: buildDraftPrompt(dispatch),
107
+ skipGitRepoCheck: true
108
+ });
109
+ await context.emit({
110
+ role: "runner",
111
+ kind: "checkpoint",
112
+ content: `Starting provider CLI Draft: ${formatProviderCliCommand(command)}`,
113
+ eventType: "provider_cli_started"
114
+ });
115
+ const outputState = createProviderOutputState(dispatch.execution.provider);
116
+ try {
117
+ const result = await this.processRunner.run({
118
+ command: command.command,
119
+ args: command.args,
120
+ cwd: tempDir,
121
+ env: providerProcessEnv(dispatch)
122
+ }, {
123
+ onStdoutLine: async (line) => {
124
+ for (const event of outputState.consume(line)) {
125
+ await context.emit(event);
126
+ }
127
+ }
128
+ }, context.signal);
129
+ if (result.exitCode !== 0) {
130
+ throw new Error(`Provider CLI exited with code ${result.exitCode}${summarizeStderr(result.stderr)}.`);
131
+ }
132
+ const content = outputState.finalMessage ?? result.stdout.trim();
133
+ if (!content) {
134
+ throw new Error("Provider CLI completed without returning a draft.");
135
+ }
136
+ const json = extractJsonObject(content);
137
+ await context.emit({
138
+ role: "assistant",
139
+ kind: "message",
140
+ content: json,
141
+ eventType: "draft_complete"
142
+ });
143
+ await emitProviderUsageEvent(context.emit, dispatch, outputState);
144
+ return {
145
+ summary: "Draft generated through Forge Runner."
146
+ };
147
+ }
148
+ finally {
149
+ await fs.rm(tempDir, { recursive: true, force: true });
150
+ }
151
+ }
46
152
  async runImplementation(context) {
47
153
  const { dispatch, worktree } = context;
48
154
  const phase = completionPhaseFromDispatch(dispatch);
49
155
  const developCycle = usesDevelopCycleCompletion(dispatch);
156
+ const debugCycle = usesDebugCycleCompletion(dispatch);
50
157
  const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
51
158
  await fs.mkdir(metadataRoot, { recursive: true });
52
159
  const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
@@ -60,7 +167,11 @@ export class ProcessBackedProviderCliService {
60
167
  const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase });
61
168
  const command = buildProviderCliCommand(dispatch, {
62
169
  worktreePath: worktree.worktreePath,
63
- prompt: developCycle ? buildDevelopCyclePrompt(dispatch, phase) : buildImplementationPrompt(dispatch, phase),
170
+ prompt: debugCycle
171
+ ? buildDebugCyclePrompt(dispatch, phase)
172
+ : developCycle
173
+ ? buildDevelopCyclePrompt(dispatch, phase)
174
+ : buildImplementationPrompt(dispatch, phase),
64
175
  completionTools
65
176
  });
66
177
  await context.emit({
@@ -72,13 +183,15 @@ export class ProcessBackedProviderCliService {
72
183
  await context.emit({
73
184
  role: "runner",
74
185
  kind: "checkpoint",
75
- content: developCycle
76
- ? "Forge completion tools configured for implementation -> self_review -> validation; provider must call each MCP completion tool before exit."
77
- : `Forge completion tools configured for ${phase}; provider must call the MCP completion tool before exit.`,
186
+ content: debugCycle
187
+ ? "Forge Debug completion tools configured for debug_repro -> debug_root_cause -> debug_fix -> debug_regression; provider must call each MCP completion tool before exit."
188
+ : developCycle
189
+ ? "Forge completion tools configured for implementation -> self_review -> validation; provider must call each MCP completion tool before exit."
190
+ : `Forge completion tools configured for ${phase}; provider must call the MCP completion tool before exit.`,
78
191
  eventType: "completion_tools_configured"
79
192
  });
80
193
  const outputState = createProviderOutputState(dispatch.execution.provider);
81
- const liveCompletionEvents = new LiveCompletionArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, context.emit);
194
+ const liveCompletionEvents = new LiveCompletionArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, debugCycle, context.emit);
82
195
  let result;
83
196
  try {
84
197
  liveCompletionEvents.start();
@@ -86,11 +199,10 @@ export class ProcessBackedProviderCliService {
86
199
  command: command.command,
87
200
  args: command.args,
88
201
  cwd: worktree.worktreePath,
89
- env: providerRedactionEnv(dispatch)
202
+ env: providerProcessEnv(dispatch)
90
203
  }, {
91
204
  onStdoutLine: async (line) => {
92
- const event = outputState.consume(line);
93
- if (event) {
205
+ for (const event of outputState.consume(line)) {
94
206
  await context.emit(event);
95
207
  }
96
208
  }
@@ -112,7 +224,7 @@ export class ProcessBackedProviderCliService {
112
224
  });
113
225
  await emitProviderUsageEvent(context.emit, dispatch, outputState);
114
226
  const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
115
- const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle);
227
+ const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, debugCycle);
116
228
  for (const artifact of completionArtifacts) {
117
229
  if (!liveCompletionEvents.hasEmitted(completionPhaseFromArtifact(artifact))) {
118
230
  await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, artifact);
@@ -138,7 +250,7 @@ export class ProcessBackedProviderCliService {
138
250
  });
139
251
  }
140
252
  return {
141
- summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}, ${committed.commitSha.slice(0, 12)}).`,
253
+ summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${debugCycle ? "debug cycle" : developCycle ? "develop cycle" : phase}, ${committed.commitSha.slice(0, 12)}).`,
142
254
  spec: {
143
255
  repoPath: worktree.worktreePath,
144
256
  path: specPath,
@@ -150,12 +262,105 @@ export class ProcessBackedProviderCliService {
150
262
  artifacts: completionArtifacts
151
263
  };
152
264
  }
265
+ async runArchitect(context) {
266
+ const { dispatch, worktree } = context;
267
+ const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "architect" });
268
+ const command = buildProviderCliCommand(dispatch, {
269
+ worktreePath: worktree.worktreePath,
270
+ prompt: buildArchitectPrompt(dispatch),
271
+ completionTools
272
+ });
273
+ const commandText = formatProviderCliCommand(command);
274
+ await context.emit({
275
+ role: "runner",
276
+ kind: "checkpoint",
277
+ content: `Starting provider CLI Architect: ${commandText} in ${worktree.worktreePath}`,
278
+ eventType: "provider_cli_started"
279
+ });
280
+ await context.emit({
281
+ role: "runner",
282
+ kind: "checkpoint",
283
+ content: "Forge Architect completion tools configured; provider must call forge.complete_architect_plan before exit.",
284
+ eventType: "completion_tools_configured"
285
+ });
286
+ const outputState = createProviderOutputState(dispatch.execution.provider);
287
+ const liveArchitectEvents = new LiveArchitectArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, context.emit);
288
+ let result;
289
+ try {
290
+ liveArchitectEvents.start();
291
+ result = await this.processRunner.run({
292
+ command: command.command,
293
+ args: command.args,
294
+ cwd: worktree.worktreePath,
295
+ env: providerProcessEnv(dispatch)
296
+ }, {
297
+ onStdoutLine: async (line) => {
298
+ for (const event of outputState.consume(line)) {
299
+ await context.emit(event);
300
+ }
301
+ }
302
+ }, context.signal);
303
+ }
304
+ finally {
305
+ await liveArchitectEvents.stop();
306
+ await cleanupCompletionTools(completionTools, context.emit);
307
+ }
308
+ if (result.exitCode !== 0) {
309
+ const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
310
+ throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
311
+ }
312
+ await context.emit({
313
+ role: "runner",
314
+ kind: "checkpoint",
315
+ content: `Provider CLI Architect completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
316
+ eventType: "provider_cli_completed"
317
+ });
318
+ await emitProviderUsageEvent(context.emit, dispatch, outputState);
319
+ const architectArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "architect");
320
+ if (!architectArtifact) {
321
+ throw new Error("Provider CLI Architect completed but did not call the Forge Architect completion tool.");
322
+ }
323
+ if (!liveArchitectEvents.hasEmitted()) {
324
+ await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, architectArtifact);
325
+ await emitArchitectStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, architectArtifact);
326
+ }
327
+ await context.emit({
328
+ role: "assistant",
329
+ kind: "message",
330
+ content: outputState.finalMessage || `${dispatch.execution.provider} CLI produced an Architect plan for ${worktree.branchName}.`,
331
+ eventType: "architect_complete"
332
+ });
333
+ const specPath = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id, "spec.md");
334
+ const specBody = await fs.readFile(specPath, "utf8").catch(() => `# ${dispatch.workItem.title}\n\nArchitect plan pending approval.\n`);
335
+ return {
336
+ summary: `${dispatch.execution.provider} CLI Architect plan produced.`,
337
+ spec: {
338
+ repoPath: worktree.worktreePath,
339
+ path: specPath,
340
+ body: specBody,
341
+ producedBy: `${dispatch.execution.provider}-cli`,
342
+ status: "pending_approval"
343
+ },
344
+ artifact: architectArtifact,
345
+ artifacts: [architectArtifact]
346
+ };
347
+ }
153
348
  async runQa(context) {
154
349
  const { dispatch, worktree } = context;
350
+ const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
351
+ await fs.mkdir(metadataRoot, { recursive: true });
352
+ const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
353
+ if (!laneCommand) {
354
+ throw new Error(`Lane command for lane '${dispatch.execution.lane}' phase '${dispatch.cycleRun.phase}' was not resolved.`);
355
+ }
356
+ const specBody = buildSpecBody(laneCommand, dispatch.workItem, "qa");
357
+ const specPath = path.join(metadataRoot, "spec.md");
358
+ await fs.writeFile(specPath, specBody, "utf8");
359
+ await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-lane-command" }) + "\n", "utf8");
155
360
  const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "qa" });
156
361
  const command = buildProviderCliCommand(dispatch, {
157
362
  worktreePath: worktree.worktreePath,
158
- prompt: buildQaPrompt(dispatch),
363
+ prompt: buildQaPrompt(dispatch, specBody),
159
364
  completionTools
160
365
  });
161
366
  const commandText = formatProviderCliCommand(command);
@@ -168,7 +373,7 @@ export class ProcessBackedProviderCliService {
168
373
  await context.emit({
169
374
  role: "runner",
170
375
  kind: "checkpoint",
171
- content: "Forge QA completion tool configured; provider must call forge.complete_qa before exit.",
376
+ content: "Forge QA completion tools configured; provider must call all seven QA MCP gates before exit.",
172
377
  eventType: "completion_tools_configured"
173
378
  });
174
379
  const outputState = createProviderOutputState(dispatch.execution.provider);
@@ -180,11 +385,10 @@ export class ProcessBackedProviderCliService {
180
385
  command: command.command,
181
386
  args: command.args,
182
387
  cwd: worktree.worktreePath,
183
- env: providerRedactionEnv(dispatch)
388
+ env: providerProcessEnv(dispatch)
184
389
  }, {
185
390
  onStdoutLine: async (line) => {
186
- const event = outputState.consume(line);
187
- if (event) {
391
+ for (const event of outputState.consume(line)) {
188
392
  await context.emit(event);
189
393
  }
190
394
  }
@@ -205,7 +409,7 @@ export class ProcessBackedProviderCliService {
205
409
  eventType: "provider_cli_completed"
206
410
  });
207
411
  await emitProviderUsageEvent(context.emit, dispatch, outputState);
208
- const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id);
412
+ const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId);
209
413
  if (!qaArtifact) {
210
414
  const verdictOnly = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "qa");
211
415
  throw new Error(verdictOnly
@@ -215,7 +419,7 @@ export class ProcessBackedProviderCliService {
215
419
  enforceQaBrowserLaunchTargetGate(dispatch, qaArtifact);
216
420
  if (!liveQaEvents.hasEmitted()) {
217
421
  await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
218
- await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
422
+ await emitQaStructuredEvents(context.emit, qaArtifact);
219
423
  }
220
424
  const status = (qaArtifact.status ?? "unknown").trim().toLowerCase();
221
425
  const statusSummary = status === "pass"
@@ -225,31 +429,41 @@ export class ProcessBackedProviderCliService {
225
429
  : "found rework";
226
430
  return {
227
431
  summary: `${dispatch.execution.provider} CLI QA ${statusSummary}.`,
432
+ spec: {
433
+ repoPath: worktree.worktreePath,
434
+ path: specPath,
435
+ body: specBody,
436
+ producedBy: "forge-lane-command",
437
+ status: "approved"
438
+ },
228
439
  artifact: qaArtifact,
229
440
  artifacts: [qaArtifact]
230
441
  };
231
442
  }
232
443
  async runConflictResolve(context) {
233
444
  const { dispatch, worktree } = context;
445
+ const addressComments = isPullRequestCommentsDispatch(dispatch);
234
446
  const command = buildProviderCliCommand(dispatch, {
235
447
  worktreePath: worktree.worktreePath,
236
- prompt: buildConflictResolvePrompt(dispatch, worktree.branchName)
448
+ prompt: addressComments
449
+ ? buildPullRequestCommentsPrompt(dispatch, worktree.branchName)
450
+ : buildConflictResolvePrompt(dispatch, worktree.branchName)
237
451
  });
238
452
  await context.emit({
239
453
  role: "runner",
240
454
  kind: "checkpoint",
241
- content: `Starting provider CLI conflict resolve: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
455
+ content: `Starting provider CLI ${addressComments ? "PR comment address" : "conflict resolve"}: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
242
456
  eventType: "provider_cli_started"
243
457
  });
244
458
  const outputState = createProviderOutputState(dispatch.execution.provider);
245
459
  const result = await this.processRunner.run({
246
460
  command: command.command,
247
461
  args: command.args,
248
- cwd: worktree.worktreePath
462
+ cwd: worktree.worktreePath,
463
+ env: providerProcessEnv(dispatch)
249
464
  }, {
250
465
  onStdoutLine: async (line) => {
251
- const event = outputState.consume(line);
252
- if (event) {
466
+ for (const event of outputState.consume(line)) {
253
467
  await context.emit(event);
254
468
  }
255
469
  }
@@ -264,11 +478,15 @@ export class ProcessBackedProviderCliService {
264
478
  await context.emit({
265
479
  role: "assistant",
266
480
  kind: "message",
267
- content: outputState.finalMessage || `${dispatch.execution.provider} CLI resolved merge conflicts for ${worktree.branchName}.`,
268
- eventType: "conflict_resolve_complete"
481
+ content: outputState.finalMessage || (addressComments
482
+ ? `${dispatch.execution.provider} CLI addressed pull request comments for ${worktree.branchName}.`
483
+ : `${dispatch.execution.provider} CLI resolved merge conflicts for ${worktree.branchName}.`),
484
+ eventType: addressComments ? "pr_comments_complete" : "conflict_resolve_complete"
269
485
  });
270
486
  return {
271
- summary: `${dispatch.execution.provider} CLI resolved conflicts on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
487
+ summary: addressComments
488
+ ? `${dispatch.execution.provider} CLI addressed PR comments on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
489
+ : `${dispatch.execution.provider} CLI resolved conflicts on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
272
490
  };
273
491
  }
274
492
  }
@@ -277,10 +495,22 @@ export class DeterministicProviderCliService {
277
495
  constructor(git = new GitClient()) {
278
496
  this.git = git;
279
497
  }
498
+ async runDraft(context) {
499
+ const content = JSON.stringify(deterministicDraftPayload(context.dispatch));
500
+ await context.emit({
501
+ role: "assistant",
502
+ kind: "message",
503
+ content,
504
+ eventType: "draft_complete"
505
+ });
506
+ await context.emit(buildProviderUsageEvent(context.dispatch, estimatedDeterministicUsage()));
507
+ return { summary: "forge-bridge generated deterministic draft." };
508
+ }
280
509
  async runImplementation(context) {
281
510
  const { dispatch, worktree } = context;
282
511
  const phase = completionPhaseFromDispatch(dispatch);
283
512
  const developCycle = usesDevelopCycleCompletion(dispatch);
513
+ const debugCycle = usesDebugCycleCompletion(dispatch);
284
514
  const providerCommand = buildProviderCliCommand(dispatch);
285
515
  const providerCommandText = formatProviderCliCommand(providerCommand);
286
516
  const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
@@ -311,9 +541,11 @@ export class DeterministicProviderCliService {
311
541
  content: `Created bridge changeset at ${changesetPath}`,
312
542
  eventType: "changeset"
313
543
  });
314
- const phases = developCycle
315
- ? ["implementation", "self_review", "validation"]
316
- : [phase];
544
+ const phases = debugCycle
545
+ ? debugCompletionPhaseOrder
546
+ : developCycle
547
+ ? ["implementation", "self_review", "validation"]
548
+ : [phase];
317
549
  const completionArtifacts = [];
318
550
  for (const completionPhase of phases) {
319
551
  await writeCompletionArtifact({
@@ -322,7 +554,9 @@ export class DeterministicProviderCliService {
322
554
  phase: completionPhase,
323
555
  producedBy: "forge-bridge",
324
556
  redactionSecrets: exactRedactionSecrets(dispatch)
325
- }, expectedCompletionToolName(completionPhase), deterministicCompletionPayload(completionPhase, dispatch, changesetBody));
557
+ }, isDebugCompletionPhaseForProvider(completionPhase)
558
+ ? expectedDebugCompletionToolName(completionPhase)
559
+ : expectedCompletionToolName(completionPhase), deterministicCompletionPayload(completionPhase, dispatch, changesetBody));
326
560
  const completionArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, completionPhase);
327
561
  if (!completionArtifact) {
328
562
  throw new Error(`Deterministic provider did not produce the Forge ${completionPhase} completion artifact.`);
@@ -338,7 +572,7 @@ export class DeterministicProviderCliService {
338
572
  }
339
573
  await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
340
574
  return {
341
- summary: `forge-bridge completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}).`,
575
+ summary: `forge-bridge completed ${worktree.branchName} (${debugCycle ? "debug cycle" : developCycle ? "develop cycle" : phase}).`,
342
576
  spec: {
343
577
  repoPath: worktree.worktreePath,
344
578
  path: specPath,
@@ -350,6 +584,47 @@ export class DeterministicProviderCliService {
350
584
  artifacts: completionArtifacts
351
585
  };
352
586
  }
587
+ async runArchitect(context) {
588
+ const { dispatch, worktree } = context;
589
+ const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
590
+ await fs.mkdir(metadataRoot, { recursive: true });
591
+ const specBody = `---\ntitle: "${dispatch.workItem.title}"\nstatus: pending_approval\nproduced_by: forge-bridge\n---\n\n# ${dispatch.workItem.title}\n\nArchitect plan pending approval.\n`;
592
+ const specPath = path.join(metadataRoot, "spec.md");
593
+ await fs.writeFile(specPath, specBody, "utf8");
594
+ await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "pending_approval", produced_by: "forge-bridge" }) + "\n", "utf8");
595
+ await writeCompletionArtifact({
596
+ worktreePath: worktree.worktreePath,
597
+ workItemId: dispatch.workItem.id,
598
+ phase: "architect",
599
+ producedBy: "forge-bridge",
600
+ redactionSecrets: exactRedactionSecrets(dispatch),
601
+ architectCycle: true
602
+ }, expectedCompletionToolName("architect"), deterministicArchitectPayload(dispatch));
603
+ const artifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "architect");
604
+ if (!artifact) {
605
+ throw new Error("Deterministic provider did not produce the Forge Architect completion artifact.");
606
+ }
607
+ await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, artifact);
608
+ await emitArchitectStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, artifact);
609
+ await context.emit({
610
+ role: "assistant",
611
+ kind: "message",
612
+ content: "Architect plan complete. forge-bridge produced deterministic planning artifacts.",
613
+ eventType: "architect_complete"
614
+ });
615
+ return {
616
+ summary: "forge-bridge completed Architect plan (deterministic).",
617
+ spec: {
618
+ repoPath: worktree.worktreePath,
619
+ path: specPath,
620
+ body: specBody,
621
+ producedBy: "forge-bridge",
622
+ status: "pending_approval"
623
+ },
624
+ artifact,
625
+ artifacts: [artifact]
626
+ };
627
+ }
353
628
  async runQa(context) {
354
629
  const { dispatch, worktree } = context;
355
630
  await context.emit({
@@ -362,30 +637,45 @@ export class DeterministicProviderCliService {
362
637
  const launchTarget = qaBrowser?.launchTarget ?? null;
363
638
  const missingLaunchTarget = qaBrowser?.required === true && !hasLaunchTarget(launchTarget);
364
639
  const browserSidecar = !missingLaunchTarget && qaBrowser?.required === true && launchTarget
365
- ? deterministicBrowserSidecar(dispatch.workItem.id, launchTarget)
640
+ ? deterministicBrowserSidecar(dispatch.workItem.id, launchTarget, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId)
366
641
  : null;
367
642
  const browserChecks = browserSidecar && launchTarget
368
643
  ? [deterministicBrowserCheck(launchTarget, browserSidecar.relativePath)]
369
644
  : [];
645
+ const sidecars = await writeQaSidecarFiles(worktree.worktreePath, browserSidecar ? [browserSidecar] : []);
370
646
  const summary = missingLaunchTarget
371
647
  ? "QA blocked because browser testing is required but no app launch target was provided."
372
648
  : "All checks passed. forge-bridge QA completed.";
373
- const qaArtifact = await writeQaArtifacts({
374
- worktreePath: worktree.worktreePath,
375
- workItemId: dispatch.workItem.id,
376
- acceptanceCriteria: dispatch.workItem.acceptanceCriteria,
377
- producedBy: "forge-bridge",
378
- provider: dispatch.execution.provider,
379
- model: dispatch.execution.model,
380
- command: "forge-bridge deterministic QA",
381
- summary,
382
- status: missingLaunchTarget ? "blocked" : "pass",
383
- blockerReason: missingLaunchTarget ? "missing_app_launch_target" : null,
384
- browserChecks,
385
- sidecarFiles: browserSidecar ? [browserSidecar] : []
386
- });
649
+ const criterion = dispatch.workItem.acceptanceCriteria.trim() || "Forge QA evidence exists";
650
+ for (const gate of qaCompletionGateOrder) {
651
+ await writeCompletionArtifact({
652
+ worktreePath: worktree.worktreePath,
653
+ workItemId: dispatch.workItem.id,
654
+ phase: "qa",
655
+ producedBy: "forge-bridge",
656
+ redactionSecrets: exactRedactionSecrets(dispatch),
657
+ qaCycle: true,
658
+ ownerType: dispatch.cycleRun.ownerType ?? undefined,
659
+ ownerId: dispatch.cycleRun.ownerId ?? undefined
660
+ }, expectedQaCompletionToolName(gate), deterministicQaGatePayload({
661
+ gate,
662
+ criterion,
663
+ summary,
664
+ missingLaunchTarget,
665
+ browserChecks,
666
+ sidecars
667
+ }));
668
+ const gateArtifact = await readQaGateArtifact(qaArtifactRootForDispatch(worktree.worktreePath, dispatch.workItem.id, dispatch), gate);
669
+ if (gateArtifact) {
670
+ await emitQaGateAccepted(context.emit, worktree.worktreePath, gateArtifact, nextQaGate(gate));
671
+ }
672
+ }
673
+ const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId);
674
+ if (!qaArtifact) {
675
+ throw new Error("Deterministic QA did not produce the complete durable QA artifact bundle.");
676
+ }
387
677
  await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
388
- await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
678
+ await emitQaStructuredEvents(context.emit, qaArtifact);
389
679
  await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
390
680
  return {
391
681
  summary: missingLaunchTarget ? "forge-bridge QA blocked." : "forge-bridge QA passed.",
@@ -395,22 +685,30 @@ export class DeterministicProviderCliService {
395
685
  }
396
686
  async runConflictResolve(context) {
397
687
  const { dispatch, worktree } = context;
398
- await this.git.run(worktree.worktreePath, ["commit", "--allow-empty", "-m", `forge bridge conflict resolve for ${dispatch.workItem.id.slice(0, 8)}`]);
688
+ const addressComments = isPullRequestCommentsDispatch(dispatch);
689
+ await this.git.run(worktree.worktreePath, ["commit", "--allow-empty", "-m", `forge bridge ${addressComments ? "pr comments" : "conflict resolve"} for ${dispatch.workItem.id.slice(0, 8)}`]);
399
690
  await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
400
691
  await context.emit({
401
692
  role: "assistant",
402
693
  kind: "message",
403
- content: `Deterministic conflict resolve completed for ${worktree.branchName}.`,
404
- eventType: "conflict_resolve_complete"
694
+ content: addressComments
695
+ ? `Deterministic PR comment addressing completed for ${worktree.branchName}.`
696
+ : `Deterministic conflict resolve completed for ${worktree.branchName}.`,
697
+ eventType: addressComments ? "pr_comments_complete" : "conflict_resolve_complete"
405
698
  });
406
699
  return {
407
- summary: `forge-bridge resolved conflicts for ${worktree.branchName}.`
700
+ summary: addressComments
701
+ ? `forge-bridge addressed PR comments for ${worktree.branchName}.`
702
+ : `forge-bridge resolved conflicts for ${worktree.branchName}.`
408
703
  };
409
704
  }
410
705
  }
411
706
  function phaseCompleteEventType(phase) {
412
707
  return phase === "implementation" ? "implementation_complete" : `${phase}_complete`;
413
708
  }
709
+ function isDebugCompletionPhaseForProvider(phase) {
710
+ return debugCompletionPhaseOrder.includes(phase);
711
+ }
414
712
  function deterministicCompletionPayload(phase, dispatch, changesetBody) {
415
713
  const changesetHash = sha256(changesetBody);
416
714
  const criterion = dispatch.workItem.acceptanceCriteria || "Deterministic bridge changeset exists";
@@ -433,6 +731,56 @@ function deterministicCompletionPayload(phase, dispatch, changesetBody) {
433
731
  next_phase_context: "Review FORGE_PHASE5_CHANGESET.md and the bridge commit before validation."
434
732
  };
435
733
  }
734
+ if (phase === "debug_repro") {
735
+ return {
736
+ status: "pass",
737
+ summary: "forge-bridge reproduced the bug deterministically.",
738
+ repro_steps: ["Run the deterministic fake-worker debug reproduction."],
739
+ observed: "The configured bug scenario is reproducible in deterministic bridge mode.",
740
+ expected: "The bug scenario should be fixed before QA handoff.",
741
+ evidence: [{ kind: "changeset", path: "FORGE_PHASE5_CHANGESET.md", content_hash: changesetHash }]
742
+ };
743
+ }
744
+ if (phase === "debug_root_cause") {
745
+ return {
746
+ status: "pass",
747
+ summary: "forge-bridge isolated a deterministic root cause.",
748
+ root_cause: "The deterministic fake-worker bug is represented by the generated changeset evidence.",
749
+ evidence: [{ kind: "changeset", path: "FORGE_PHASE5_CHANGESET.md", content_hash: changesetHash }],
750
+ eliminated_alternatives: [{ summary: "No alternate deterministic fake-worker path was implicated." }],
751
+ risk_area: "forge-bridge deterministic debug lane"
752
+ };
753
+ }
754
+ if (phase === "debug_fix") {
755
+ return {
756
+ status: "pass",
757
+ summary: "forge-bridge applied a deterministic debug fix.",
758
+ changed_files: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Recorded deterministic debug fix evidence." }],
759
+ tests_added_or_updated: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Deterministic debug evidence doubles as fake-worker regression coverage." }],
760
+ remaining_risks: [],
761
+ resolved_findings: (dispatch.qaRework?.findingIds ?? []).map((qaId) => ({
762
+ qa_id: qaId,
763
+ status: "resolved",
764
+ summary: `Resolved deterministic QA finding ${qaId}.`,
765
+ evidence: { path: "FORGE_PHASE5_CHANGESET.md", content_hash: changesetHash },
766
+ changed_files: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Recorded deterministic debug fix evidence." }],
767
+ tests_added_or_updated: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Deterministic fake-worker regression evidence." }],
768
+ regression_evidence: [{ command: "forge-bridge deterministic debug regression", status: "pass" }]
769
+ }))
770
+ };
771
+ }
772
+ if (phase === "debug_regression") {
773
+ return {
774
+ status: "pass",
775
+ summary: "forge-bridge proved the deterministic debug regression is QA-ready.",
776
+ commands: [{ command: "forge-bridge deterministic debug regression", exit_code: 0, status: "pass", summary: "Deterministic debug regression completed." }],
777
+ required_regressions: ["Original deterministic repro no longer fails."],
778
+ build_status: "not_applicable",
779
+ test_status: "pass",
780
+ repro_negative: true,
781
+ qa_ready: true
782
+ };
783
+ }
436
784
  if (phase === "self_review") {
437
785
  return {
438
786
  status: "pass",
@@ -493,6 +841,149 @@ function deterministicCompletionPayload(phase, dispatch, changesetBody) {
493
841
  ready_for_dev_test: true
494
842
  };
495
843
  }
844
+ function deterministicArchitectPayload(dispatch) {
845
+ const planId = `architect-${dispatch.workItem.id.replace(/-/g, "")}`;
846
+ return {
847
+ status: "pass",
848
+ summary: "forge-bridge produced a deterministic Architect plan.",
849
+ implementation_plan: {
850
+ schema_version: 1,
851
+ plan_id: planId,
852
+ story_id: dispatch.workItem.id,
853
+ phases: [
854
+ { id: "phase-1", name: "Foundation", tasks: ["task-1"], gate: "phase-1-gate" }
855
+ ],
856
+ tasks: [
857
+ {
858
+ id: "task-1",
859
+ title: `Implement ${dispatch.workItem.title}`,
860
+ phase: "phase-1",
861
+ summary: "Deterministic bridge Architect task proving MCP artifact contract persistence.",
862
+ acceptance_criteria: [dispatch.workItem.acceptanceCriteria || `Preserve story intent for ${dispatch.workItem.title}`],
863
+ tests: [
864
+ { command: "dotnet test /home/dillon/repos/Forge/Forge.sln --filter Architect", purpose: "Architect bridge contract regression" }
865
+ ]
866
+ }
867
+ ],
868
+ dependencies: [],
869
+ phase_gates: [
870
+ { id: "phase-1-gate", phase: "phase-1", checks: ["Task tests pass", "Artifacts validate"] }
871
+ ]
872
+ },
873
+ qa_plan: {
874
+ schema_version: 1,
875
+ plan_id: planId,
876
+ final_qa: [
877
+ { id: "qa-1", coverage: `Verify Architect plan artifacts and pending approval status for ${dispatch.workItem.title}` }
878
+ ]
879
+ },
880
+ report_markdown: `# Architect Report\n\nWork item: ${dispatch.workItem.id}\nTitle: ${dispatch.workItem.title}\n\nVerdict: pass\n\nDeterministic bridge Architect plan produced for pending approval.\n`
881
+ };
882
+ }
883
+ function deterministicQaGatePayload(options) {
884
+ const blocked = options.missingLaunchTarget;
885
+ const skippedBecauseBlocked = blocked
886
+ ? {
887
+ status: "skipped",
888
+ summary: "Skipped because browser QA was blocked by missing launch target.",
889
+ reason: "Upstream browser QA blocker prevents meaningful downstream evidence."
890
+ }
891
+ : null;
892
+ switch (options.gate) {
893
+ case "qa_plan":
894
+ return {
895
+ status: "pass",
896
+ summary: "Deterministic QA plan recorded.",
897
+ checklist: [
898
+ "Review the validated work item against acceptance criteria.",
899
+ "Run deterministic regression evidence.",
900
+ "Confirm no in-scope QA findings remain."
901
+ ],
902
+ acceptance_criteria: [options.criterion]
903
+ };
904
+ case "qa_regression":
905
+ return {
906
+ status: "pass",
907
+ summary: "Deterministic regression QA completed.",
908
+ commands: [
909
+ {
910
+ command: "forge-bridge deterministic QA",
911
+ exit_code: 0,
912
+ status: "pass",
913
+ summary: "Deterministic regression evidence completed."
914
+ }
915
+ ],
916
+ notes: "Regression gate passed in deterministic fake-worker mode."
917
+ };
918
+ case "qa_api":
919
+ return {
920
+ status: "skipped",
921
+ summary: "No API/backend surface was identified for deterministic QA.",
922
+ reason: "The deterministic fake-worker changeset does not exercise API/backend behavior."
923
+ };
924
+ case "qa_manual_web":
925
+ return blocked
926
+ ? {
927
+ status: "blocked",
928
+ summary: "Browser QA could not run because no app launch target was provided.",
929
+ blocker_reason: "missing_app_launch_target"
930
+ }
931
+ : options.browserChecks.length > 0
932
+ ? {
933
+ status: "pass",
934
+ summary: "Deterministic browser QA exercised the configured launch target.",
935
+ browser_checks: options.browserChecks,
936
+ sidecars: options.sidecars,
937
+ notes: "Browser sidecar evidence recorded."
938
+ }
939
+ : {
940
+ status: "skipped",
941
+ summary: "Manual web QA was not required for this deterministic run.",
942
+ reason: "Forge did not require browser QA and no launch target was necessary."
943
+ };
944
+ case "qa_bug_repro":
945
+ return skippedBecauseBlocked ?? {
946
+ status: "skipped",
947
+ summary: "No failed QA observation required bug reproduction.",
948
+ reason: "No defects or candidate bugs were observed."
949
+ };
950
+ case "qa_findings":
951
+ return skippedBecauseBlocked ?? {
952
+ status: "pass",
953
+ summary: "No QA findings or candidate bugs were identified.",
954
+ findings: [],
955
+ candidate_bugs: []
956
+ };
957
+ case "qa_verdict":
958
+ return {
959
+ status: blocked ? "blocked" : "pass",
960
+ summary: options.summary,
961
+ checklist: [
962
+ "Review the validated work item against acceptance criteria.",
963
+ "Run deterministic regression evidence.",
964
+ blocked ? "Record missing browser launch target blocker." : "Confirm no in-scope QA findings remain."
965
+ ],
966
+ tested_acceptance_criteria: blocked ? [] : [options.criterion],
967
+ commands: blocked
968
+ ? []
969
+ : [
970
+ {
971
+ command: "forge-bridge deterministic QA",
972
+ exit_code: 0,
973
+ status: "pass",
974
+ summary: "Deterministic QA command completed."
975
+ }
976
+ ],
977
+ browser_checks: blocked ? [] : options.browserChecks,
978
+ sidecars: blocked ? [] : options.sidecars,
979
+ notes: options.summary,
980
+ findings: [],
981
+ candidate_bugs: [],
982
+ blocker_reason: blocked ? "missing_app_launch_target" : null,
983
+ report_markdown: `# QA report\n\n${options.summary}\n`
984
+ };
985
+ }
986
+ }
496
987
  const qaArtifactFileNames = {
497
988
  plan: "qa.plan.json",
498
989
  evidence: "qa.evidence.json",
@@ -500,16 +991,25 @@ const qaArtifactFileNames = {
500
991
  verdict: "qa.verdict.json",
501
992
  report: "qa.report.md"
502
993
  };
994
+ const qaGateArtifactFileNames = {
995
+ qa_plan: "qa.plan.json",
996
+ qa_regression: "qa.regression.json",
997
+ qa_api: "qa.api.json",
998
+ qa_manual_web: "qa.manual-web.json",
999
+ qa_bug_repro: "qa.bug-repro.json",
1000
+ qa_findings: "qa.findings.json",
1001
+ qa_verdict: "qa.verdict.json"
1002
+ };
503
1003
  function hasLaunchTarget(target) {
504
1004
  return Boolean(target?.url?.trim()
505
1005
  || target?.ready_url?.trim()
506
1006
  || target?.command?.trim()
507
1007
  || (target?.routes ?? []).some((route) => route.trim().length > 0));
508
1008
  }
509
- function deterministicBrowserSidecar(workItemId, target) {
1009
+ function deterministicBrowserSidecar(workItemId, target, ownerType, ownerId) {
510
1010
  const route = primaryLaunchRoute(target);
511
1011
  return {
512
- relativePath: qaArtifactRelativePath(workItemId, "qa/screenshots/deterministic-browser-check.txt"),
1012
+ relativePath: qaArtifactRelativePath(workItemId, "qa/screenshots/deterministic-browser-check.txt", ownerType, ownerId),
513
1013
  content: `Deterministic Forge QA browser evidence\nRoute: ${route}\nConsole errors: []\nNetwork errors: []\nResponsive checks: desktop, mobile\n`
514
1014
  };
515
1015
  }
@@ -551,78 +1051,6 @@ async function writeQaSidecarFiles(worktreePath, files) {
551
1051
  }
552
1052
  return sidecars;
553
1053
  }
554
- async function writeQaArtifacts(request) {
555
- const artifactRoot = artifactRootFor(request.worktreePath, request.workItemId);
556
- await fs.mkdir(artifactRoot, { recursive: true });
557
- const sidecars = await writeQaSidecarFiles(request.worktreePath, request.sidecarFiles ?? []);
558
- const status = request.status ?? "pass";
559
- const criterion = request.acceptanceCriteria.trim() || "Forge QA evidence exists";
560
- const relativeEvidencePath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.evidence);
561
- const relativeFindingsPath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.findings);
562
- const relativeReportPath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.report);
563
- const planJson = jsonText({
564
- schema_version: 1,
565
- phase: "qa",
566
- checklist: [
567
- "Review the validated work item against acceptance criteria.",
568
- "Run relevant provider QA verification.",
569
- "Confirm no in-scope QA findings remain."
570
- ],
571
- acceptance_criteria: [criterion]
572
- });
573
- const evidenceJson = jsonText({
574
- schema_version: 1,
575
- phase: "qa",
576
- commands: status === "blocked"
577
- ? []
578
- : [
579
- {
580
- command: request.command,
581
- exit_code: 0,
582
- status: "pass",
583
- summary: `${request.provider} QA completed successfully.`
584
- }
585
- ],
586
- browser_checks: request.browserChecks ?? [],
587
- sidecars,
588
- notes: request.summary
589
- });
590
- const findingsJson = jsonText({
591
- schema_version: 1,
592
- phase: "qa",
593
- findings: [],
594
- candidate_bugs: []
595
- });
596
- const verdictJson = jsonText({
597
- schema_version: 1,
598
- phase: "qa",
599
- status,
600
- summary: request.summary,
601
- tested_acceptance_criteria: status === "blocked" ? [] : [criterion],
602
- finding_ids: [],
603
- candidate_bug_ids: [],
604
- blocker_reason: status === "blocked" ? request.blockerReason ?? "inconclusive" : null,
605
- evidence_path: relativeEvidencePath,
606
- findings_path: relativeFindingsPath,
607
- report_path: relativeReportPath
608
- });
609
- const report = `# QA report\n\n${request.summary}\n\n- Provider: ${singleLine(request.provider)}\n- Model: ${singleLine(request.model)}\n- Command: ${singleLine(request.command)}\n`;
610
- await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.plan), planJson, "utf8");
611
- await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.evidence), evidenceJson, "utf8");
612
- await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.findings), findingsJson, "utf8");
613
- const verdictPath = path.join(artifactRoot, qaArtifactFileNames.verdict);
614
- await fs.writeFile(verdictPath, verdictJson, "utf8");
615
- await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.report), report, "utf8");
616
- return {
617
- repoPath: request.worktreePath,
618
- path: verdictPath,
619
- verdictJson,
620
- report,
621
- producedBy: request.producedBy,
622
- status,
623
- phase: "qa"
624
- };
625
- }
626
1054
  async function emitQaArtifactRecorded(emit, worktreePath, artifact) {
627
1055
  const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
628
1056
  await emit({
@@ -638,16 +1066,29 @@ async function emitQaArtifactRecorded(emit, worktreePath, artifact) {
638
1066
  eventType: "phase_gate"
639
1067
  });
640
1068
  }
641
- async function readQaCompletionArtifactBundle(worktreePath, workItemId) {
642
- const artifact = await readCompletionArtifact(worktreePath, workItemId, "qa");
643
- if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId)) {
1069
+ async function readQaCompletionArtifactBundle(worktreePath, workItemId, ownerType, ownerId) {
1070
+ const artifact = await readCompletionArtifact(worktreePath, workItemId, "qa", ownerType, ownerId);
1071
+ if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId, ownerType, ownerId)) {
644
1072
  return null;
645
1073
  }
646
- await validateQaBundleSidecarReferences(worktreePath, workItemId);
647
- return artifact;
1074
+ const artifactRoot = ownerType && ownerId
1075
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
1076
+ : artifactRootFor(worktreePath, workItemId);
1077
+ const planJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.plan), "utf8");
1078
+ const evidenceJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.evidence), "utf8");
1079
+ const findingsJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.findings), "utf8");
1080
+ await validateQaBundleSidecarReferences(worktreePath, workItemId, ownerType, ownerId);
1081
+ return {
1082
+ ...artifact,
1083
+ planJson,
1084
+ evidenceJson,
1085
+ findingsJson
1086
+ };
648
1087
  }
649
- async function hasCompleteQaArtifactBundle(worktreePath, workItemId) {
650
- const artifactRoot = artifactRootFor(worktreePath, workItemId);
1088
+ async function hasCompleteQaArtifactBundle(worktreePath, workItemId, ownerType, ownerId) {
1089
+ const artifactRoot = ownerType && ownerId
1090
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
1091
+ : artifactRootFor(worktreePath, workItemId);
651
1092
  for (const fileName of Object.values(qaArtifactFileNames)) {
652
1093
  try {
653
1094
  const stat = await fs.stat(path.join(artifactRoot, fileName));
@@ -676,8 +1117,10 @@ function enforceQaBrowserLaunchTargetGate(dispatch, artifact) {
676
1117
  throw new Error("QA browser testing is required but no app launch target was provided; provider must return blocked with blocker_reason missing_app_launch_target.");
677
1118
  }
678
1119
  }
679
- async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
680
- const artifactRoot = artifactRootFor(worktreePath, workItemId);
1120
+ async function validateQaBundleSidecarReferences(worktreePath, workItemId, ownerType, ownerId) {
1121
+ const artifactRoot = ownerType && ownerId
1122
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
1123
+ : artifactRootFor(worktreePath, workItemId);
681
1124
  const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
682
1125
  const sidecars = arrayField(evidence, "sidecars");
683
1126
  if (sidecars.length === 0) {
@@ -696,7 +1139,9 @@ async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
696
1139
  }
697
1140
  const fullPath = path.resolve(worktreePath, sidecarPath);
698
1141
  if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
699
- throw new Error(`QA sidecar ${index} path must be under .forge/work-items/${workItemId}/artifacts/qa/.`);
1142
+ const ownerDirectory = ownerType && ownerId && ownerType.trim().toLowerCase() === "story" ? "stories" : "work-items";
1143
+ const ownerSegment = ownerType && ownerId ? ownerId : workItemId;
1144
+ throw new Error(`QA sidecar ${index} path must be under .forge/${ownerDirectory}/${ownerSegment}/artifacts/qa/.`);
700
1145
  }
701
1146
  const stat = await fs.lstat(fullPath).catch((error) => {
702
1147
  if (isNodeError(error) && error.code === "ENOENT") {
@@ -717,8 +1162,8 @@ async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
717
1162
  }
718
1163
  }
719
1164
  }
720
- async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact) {
721
- const artifactRoot = artifactRootFor(worktreePath, workItemId);
1165
+ async function emitQaStructuredEvents(emit, artifact) {
1166
+ const artifactRoot = path.dirname(artifact.path);
722
1167
  const plan = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.plan));
723
1168
  const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
724
1169
  const findings = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.findings));
@@ -758,12 +1203,76 @@ async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact)
758
1203
  eventType: status === "blocked" ? "qa_blocked" : "qa_complete"
759
1204
  });
760
1205
  }
1206
+ function qaArtifactRootForDispatch(worktreePath, workItemId, dispatch) {
1207
+ return dispatch.cycleRun.ownerType && dispatch.cycleRun.ownerId
1208
+ ? artifactRootForOwner(worktreePath, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId)
1209
+ : artifactRootFor(worktreePath, workItemId);
1210
+ }
1211
+ async function readQaGateArtifact(artifactRoot, gate) {
1212
+ const artifactPath = path.join(artifactRoot, qaGateArtifactFileNames[gate]);
1213
+ try {
1214
+ const stat = await fs.stat(artifactPath);
1215
+ if (!stat.isFile()) {
1216
+ return null;
1217
+ }
1218
+ }
1219
+ catch (error) {
1220
+ if (isNodeError(error) && error.code === "ENOENT") {
1221
+ return null;
1222
+ }
1223
+ throw error;
1224
+ }
1225
+ const payload = await readJsonObjectFile(artifactPath);
1226
+ return {
1227
+ gate,
1228
+ path: artifactPath,
1229
+ status: stringField(payload, "status") ?? "unknown",
1230
+ producedBy: stringField(payload, "produced_by") ?? stringField(payload, "producedBy") ?? undefined,
1231
+ contentHash: stringField(payload, "content_hash") ?? undefined
1232
+ };
1233
+ }
1234
+ function nextQaGate(gate) {
1235
+ const index = qaCompletionGateOrder.indexOf(gate);
1236
+ return index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
1237
+ }
1238
+ async function emitQaGateAccepted(emit, worktreePath, artifact, nextGate) {
1239
+ const relativePath = path.relative(worktreePath, artifact.path).split(path.sep).join("/");
1240
+ await emit({
1241
+ role: "hook",
1242
+ kind: artifact.status === "blocked" ? "error" : "checkpoint",
1243
+ content: `phase_gate: ${artifact.gate} artifact accepted with status ${artifact.status}.`,
1244
+ eventType: "phase_gate",
1245
+ data: JSON.stringify({
1246
+ phase: "qa",
1247
+ qa_gate: artifact.gate,
1248
+ status: artifact.status,
1249
+ artifact_relative_paths: [relativePath],
1250
+ content_hash: artifact.contentHash ?? null,
1251
+ produced_by: artifact.producedBy ?? null
1252
+ })
1253
+ });
1254
+ if (!nextGate) {
1255
+ return;
1256
+ }
1257
+ await emit({
1258
+ role: "hook",
1259
+ kind: "checkpoint",
1260
+ content: `phase_progression: ${artifact.gate} -> ${nextGate}`,
1261
+ eventType: "phase_gate",
1262
+ data: JSON.stringify({
1263
+ phase: "qa",
1264
+ from_gate: artifact.gate,
1265
+ to_gate: nextGate
1266
+ })
1267
+ });
1268
+ }
761
1269
  class LiveQaArtifactEmitter {
762
1270
  worktreePath;
763
1271
  workItemId;
764
1272
  dispatch;
765
1273
  emit;
766
- emitted = false;
1274
+ emittedBundle = false;
1275
+ emittedGates = new Set();
767
1276
  timer = null;
768
1277
  pending = Promise.resolve();
769
1278
  constructor(worktreePath, workItemId, dispatch, emit) {
@@ -786,7 +1295,7 @@ class LiveQaArtifactEmitter {
786
1295
  await this.pollQueued();
787
1296
  }
788
1297
  hasEmitted() {
789
- return this.emitted;
1298
+ return this.emittedBundle;
790
1299
  }
791
1300
  pollQueued() {
792
1301
  this.pending = this.pending
@@ -795,19 +1304,125 @@ class LiveQaArtifactEmitter {
795
1304
  return this.pending;
796
1305
  }
797
1306
  async poll() {
798
- if (this.emitted) {
1307
+ await this.emitAvailableGateEvents();
1308
+ if (this.emittedBundle) {
799
1309
  return;
800
1310
  }
801
- const artifact = await readQaCompletionArtifactBundle(this.worktreePath, this.workItemId);
1311
+ const artifact = await readQaCompletionArtifactBundle(this.worktreePath, this.workItemId, this.dispatch.cycleRun.ownerType, this.dispatch.cycleRun.ownerId);
802
1312
  if (!artifact) {
803
1313
  return;
804
1314
  }
805
1315
  enforceQaBrowserLaunchTargetGate(this.dispatch, artifact);
806
- this.emitted = true;
1316
+ this.emittedBundle = true;
807
1317
  await emitQaArtifactRecorded(this.emit, this.worktreePath, artifact);
808
- await emitQaStructuredEvents(this.emit, this.worktreePath, this.workItemId, artifact);
1318
+ await emitQaStructuredEvents(this.emit, artifact);
1319
+ }
1320
+ async emitAvailableGateEvents() {
1321
+ const artifactRoot = qaArtifactRootForDispatch(this.worktreePath, this.workItemId, this.dispatch);
1322
+ for (const gate of qaCompletionGateOrder) {
1323
+ if (this.emittedGates.has(gate)) {
1324
+ continue;
1325
+ }
1326
+ const artifact = await readQaGateArtifact(artifactRoot, gate);
1327
+ if (!artifact) {
1328
+ return;
1329
+ }
1330
+ this.emittedGates.add(gate);
1331
+ await emitQaGateAccepted(this.emit, this.worktreePath, artifact, nextQaGate(gate));
1332
+ }
809
1333
  }
810
1334
  }
1335
+ class LiveArchitectArtifactEmitter {
1336
+ worktreePath;
1337
+ workItemId;
1338
+ emit;
1339
+ emitted = false;
1340
+ timer = null;
1341
+ pending = Promise.resolve();
1342
+ constructor(worktreePath, workItemId, emit) {
1343
+ this.worktreePath = worktreePath;
1344
+ this.workItemId = workItemId;
1345
+ this.emit = emit;
1346
+ }
1347
+ start() {
1348
+ void this.pollQueued();
1349
+ this.timer = setInterval(() => {
1350
+ void this.pollQueued();
1351
+ }, 250);
1352
+ }
1353
+ async stop() {
1354
+ if (this.timer) {
1355
+ clearInterval(this.timer);
1356
+ this.timer = null;
1357
+ }
1358
+ await this.pollQueued();
1359
+ }
1360
+ hasEmitted() {
1361
+ return this.emitted;
1362
+ }
1363
+ pollQueued() {
1364
+ this.pending = this.pending
1365
+ .then(() => this.poll())
1366
+ .catch(() => undefined);
1367
+ return this.pending;
1368
+ }
1369
+ async poll() {
1370
+ if (this.emitted || !await hasCompleteArchitectArtifactBundle(this.worktreePath, this.workItemId)) {
1371
+ return;
1372
+ }
1373
+ const artifact = await readCompletionArtifact(this.worktreePath, this.workItemId, "architect");
1374
+ if (!artifact) {
1375
+ return;
1376
+ }
1377
+ this.emitted = true;
1378
+ await emitCompletionArtifactRecorded(this.emit, this.worktreePath, artifact);
1379
+ await emitArchitectStructuredEvents(this.emit, this.worktreePath, this.workItemId, artifact);
1380
+ }
1381
+ }
1382
+ async function hasCompleteArchitectArtifactBundle(worktreePath, workItemId) {
1383
+ const artifactRoot = artifactRootFor(worktreePath, workItemId);
1384
+ for (const fileName of ["implementation.plan.json", "qa.plan.json", "architect.verdict.json", "architect.report.md"]) {
1385
+ try {
1386
+ const stat = await fs.stat(path.join(artifactRoot, fileName));
1387
+ if (!stat.isFile()) {
1388
+ return false;
1389
+ }
1390
+ }
1391
+ catch (error) {
1392
+ if (isNodeError(error) && error.code === "ENOENT") {
1393
+ return false;
1394
+ }
1395
+ throw error;
1396
+ }
1397
+ }
1398
+ return true;
1399
+ }
1400
+ async function emitArchitectStructuredEvents(emit, worktreePath, workItemId, artifact) {
1401
+ const artifactRoot = artifactRootFor(worktreePath, workItemId);
1402
+ const implementationPlan = await readJsonObjectFile(path.join(artifactRoot, "implementation.plan.json"));
1403
+ const qaPlan = await readJsonObjectFile(path.join(artifactRoot, "qa.plan.json"));
1404
+ const verdict = parseJsonLine(artifact.verdictJson) ?? {};
1405
+ const status = stringField(verdict, "status") ?? artifact.status ?? "unknown";
1406
+ const planId = stringField(implementationPlan, "plan_id") ?? "unknown";
1407
+ await emit({
1408
+ role: "worker",
1409
+ kind: "checkpoint",
1410
+ content: `Architect plan recorded: ${planId} with ${arrayField(implementationPlan, "phases").length} phases and ${arrayField(implementationPlan, "tasks").length} tasks.`,
1411
+ eventType: "architect_plan"
1412
+ });
1413
+ await emit({
1414
+ role: "worker",
1415
+ kind: "checkpoint",
1416
+ content: `Architect QA seed recorded with ${arrayField(qaPlan, "final_qa").length} final QA items.`,
1417
+ eventType: "architect_qa_seed"
1418
+ });
1419
+ await emit({
1420
+ role: "hook",
1421
+ kind: status === "pass" ? "checkpoint" : "error",
1422
+ content: `architect_mcp_complete: ${status === "pass" ? "accepted" : "recorded"} - ${stringField(verdict, "summary") ?? "Architect completion payload recorded."}`,
1423
+ eventType: "architect_mcp_complete"
1424
+ });
1425
+ }
811
1426
  async function readJsonObjectFile(filePath) {
812
1427
  const content = await fs.readFile(filePath, "utf8").catch(() => "");
813
1428
  return parseJsonLine(content) ?? {};
@@ -832,19 +1447,36 @@ function summarizeQaObject(source) {
832
1447
  ?? stringField(source, "id")
833
1448
  ?? JSON.stringify(source), 500);
834
1449
  }
835
- function qaArtifactRelativePath(workItemId, fileName) {
1450
+ function qaArtifactRelativePath(workItemId, fileName, ownerType, ownerId) {
1451
+ if (ownerType && ownerId) {
1452
+ const ownerDirectory = ownerType.trim().toLowerCase() === "story" ? "stories" : "work-items";
1453
+ return [".forge", ownerDirectory, ownerId, "artifacts", fileName].join("/");
1454
+ }
836
1455
  return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
837
1456
  }
838
- function jsonText(value) {
839
- return `${JSON.stringify(value, null, 2)}\n`;
840
- }
841
1457
  async function emitCompletionArtifactRecorded(emit, worktreePath, artifact) {
842
1458
  const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
1459
+ await emit({
1460
+ role: "tool",
1461
+ kind: "checkpoint",
1462
+ content: `Forge completion tool validation passed for ${artifact.phase ?? "unknown"} (${artifact.status}).`,
1463
+ eventType: "completion_tool_validation_passed",
1464
+ data: JSON.stringify({
1465
+ phase: artifact.phase ?? "unknown",
1466
+ status: artifact.status,
1467
+ artifact_relative_paths: [relativePath.split(path.sep).join("/")]
1468
+ })
1469
+ });
843
1470
  await emit({
844
1471
  role: "tool",
845
1472
  kind: "tool_result",
846
1473
  content: `Forge completion tool recorded ${artifact.phase ?? "unknown"} artifact ${relativePath} (${artifact.status}).`,
847
- eventType: "completion_tool_artifact_recorded"
1474
+ eventType: "completion_tool_artifact_recorded",
1475
+ data: JSON.stringify({
1476
+ phase: artifact.phase ?? "unknown",
1477
+ status: artifact.status,
1478
+ artifact_relative_paths: [relativePath.split(path.sep).join("/")]
1479
+ })
848
1480
  });
849
1481
  }
850
1482
  async function emitPhaseCompletionEvent(emit, artifact) {
@@ -872,7 +1504,15 @@ async function emitPhaseGateEvents(emit, artifact) {
872
1504
  ? "phase_progression: implementation -> self_review"
873
1505
  : phase === "self_review"
874
1506
  ? "phase_progression: self_review -> validation"
875
- : "phase_progression: validation -> QA ready";
1507
+ : phase === "debug_repro"
1508
+ ? "phase_progression: debug_repro -> debug_root_cause"
1509
+ : phase === "debug_root_cause"
1510
+ ? "phase_progression: debug_root_cause -> debug_fix"
1511
+ : phase === "debug_fix"
1512
+ ? "phase_progression: debug_fix -> debug_regression"
1513
+ : phase === "debug_regression"
1514
+ ? "phase_progression: debug_regression -> QA ready"
1515
+ : "phase_progression: validation -> QA ready";
876
1516
  await emit({
877
1517
  role: "hook",
878
1518
  kind: "checkpoint",
@@ -883,6 +1523,7 @@ async function emitPhaseGateEvents(emit, artifact) {
883
1523
  function completionPhaseFromArtifact(artifact) {
884
1524
  const phase = artifact.phase?.trim();
885
1525
  return phase === "self_review" || phase === "validation" || phase === "implementation" || phase === "qa"
1526
+ || phase === "architect" || phase === "debug_repro" || phase === "debug_root_cause" || phase === "debug_fix" || phase === "debug_regression"
886
1527
  ? phase
887
1528
  : "implementation";
888
1529
  }
@@ -894,13 +1535,18 @@ class LiveCompletionArtifactEmitter {
894
1535
  emitted = new Set();
895
1536
  timer = null;
896
1537
  pending = Promise.resolve();
897
- constructor(worktreePath, workItemId, phase, developCycle, emit) {
1538
+ constructor(worktreePath, workItemId, phase, developCycle, debugCycle, emit) {
898
1539
  this.worktreePath = worktreePath;
899
1540
  this.workItemId = workItemId;
900
1541
  this.emit = emit;
901
- this.phases = developCycle
902
- ? developCyclePhaseOrder.slice(developCyclePhaseOrder.indexOf(phase))
903
- : [phase];
1542
+ if (debugCycle && isDebugCompletionPhaseForProvider(phase)) {
1543
+ this.phases = debugCompletionPhaseOrder.slice(debugCompletionPhaseOrder.indexOf(phase));
1544
+ }
1545
+ else {
1546
+ this.phases = developCycle
1547
+ ? developCyclePhaseOrder.slice(developCyclePhaseOrder.indexOf(phase))
1548
+ : [phase];
1549
+ }
904
1550
  }
905
1551
  start() {
906
1552
  void this.pollQueued();
@@ -943,20 +1589,23 @@ class LiveCompletionArtifactEmitter {
943
1589
  }
944
1590
  }
945
1591
  }
946
- async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, developCycle) {
947
- if (!developCycle) {
1592
+ async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, developCycle, debugCycle) {
1593
+ if (!developCycle && !debugCycle) {
948
1594
  const artifact = await readCompletionArtifact(worktreePath, workItemId, phase);
949
1595
  if (!artifact) {
950
1596
  throw new Error(`Provider CLI completed but did not call the Forge ${phase} completion tool.`);
951
1597
  }
952
1598
  return [artifact];
953
1599
  }
1600
+ const expectedPhases = debugCycle && isDebugCompletionPhaseForProvider(phase)
1601
+ ? debugCompletionPhaseOrder.slice(debugCompletionPhaseOrder.indexOf(phase))
1602
+ : ["implementation", "self_review", "validation"];
954
1603
  const artifacts = [];
955
- for (const expectedPhase of ["implementation", "self_review", "validation"]) {
1604
+ for (const expectedPhase of expectedPhases) {
956
1605
  const artifact = await readCompletionArtifact(worktreePath, workItemId, expectedPhase);
957
1606
  if (!artifact) {
958
1607
  if (artifacts.length === 0) {
959
- throw new Error("Provider CLI completed but did not call the Forge implementation completion tool.");
1608
+ throw new Error(`Provider CLI completed but did not call the Forge ${expectedPhase} completion tool.`);
960
1609
  }
961
1610
  const previous = artifacts[artifacts.length - 1];
962
1611
  if ((previous.status ?? "").trim().toLowerCase() === "pass") {
@@ -971,10 +1620,24 @@ async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, de
971
1620
  }
972
1621
  return artifacts;
973
1622
  }
974
- function providerRedactionEnv(dispatch) {
975
- const env = completionRedactionEnv(exactRedactionSecrets(dispatch));
1623
+ function providerProcessEnv(dispatch) {
1624
+ const env = {
1625
+ ...completionRedactionEnv(exactRedactionSecrets(dispatch)),
1626
+ ...providerCredentialEnv(dispatch)
1627
+ };
976
1628
  return Object.keys(env).length > 0 ? env : undefined;
977
1629
  }
1630
+ function providerCredentialEnv(dispatch) {
1631
+ const secret = dispatch.apiKeySecretGrant?.secret;
1632
+ if (!secret || !dispatch.execution.requiresApiKeySecretGrant) {
1633
+ return {};
1634
+ }
1635
+ const provider = dispatch.execution.provider.trim().toLowerCase();
1636
+ if (provider === "codex" || provider === "openai") {
1637
+ return { OPENAI_API_KEY: secret };
1638
+ }
1639
+ return {};
1640
+ }
978
1641
  function exactRedactionSecrets(dispatch) {
979
1642
  return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
980
1643
  }
@@ -997,7 +1660,7 @@ export class StreamingProviderProcessRunner {
997
1660
  return await new Promise((resolve, reject) => {
998
1661
  const child = spawn(launch.command, launch.args, {
999
1662
  cwd: request.cwd,
1000
- env: request.env ? { ...process.env, ...request.env } : undefined,
1663
+ env: buildProviderChildEnvironment(request.env),
1001
1664
  stdio: ["ignore", "pipe", "pipe"],
1002
1665
  windowsHide: true
1003
1666
  });
@@ -1132,9 +1795,6 @@ function quoteWindowsCommandLineArg(value) {
1132
1795
  }
1133
1796
  return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
1134
1797
  }
1135
- async function fileExists(inputPath) {
1136
- return fs.stat(inputPath).then((stat) => stat.isFile()).catch(() => false);
1137
- }
1138
1798
  async function readProcessLines(stream, onLine) {
1139
1799
  const reader = readline.createInterface({ input: stream });
1140
1800
  try {
@@ -1152,12 +1812,69 @@ function isFakeWorkerModel(model) {
1152
1812
  function sha256(content) {
1153
1813
  return crypto.createHash("sha256").update(content).digest("hex");
1154
1814
  }
1815
+ function buildDraftPrompt(dispatch) {
1816
+ const draft = dispatch.draft;
1817
+ if (!draft) {
1818
+ throw new Error("Draft prompt is missing.");
1819
+ }
1820
+ return `${draft.systemPrompt}
1821
+
1822
+ Draft kind: ${draft.kind}
1823
+
1824
+ ${draft.userPrompt}
1825
+
1826
+ Return ONLY the requested JSON object. Do not wrap it in markdown and do not modify files.`;
1827
+ }
1828
+ function extractJsonObject(content) {
1829
+ let trimmed = content.trim();
1830
+ if (trimmed.startsWith("```")) {
1831
+ const firstNewline = trimmed.indexOf("\n");
1832
+ const lastFence = trimmed.lastIndexOf("```");
1833
+ if (firstNewline >= 0 && lastFence > firstNewline) {
1834
+ trimmed = trimmed.slice(firstNewline + 1, lastFence).trim();
1835
+ }
1836
+ }
1837
+ const start = trimmed.indexOf("{");
1838
+ const end = trimmed.lastIndexOf("}");
1839
+ return start >= 0 && end >= start ? trimmed.slice(start, end + 1) : trimmed;
1840
+ }
1841
+ function summarizeStderr(stderr) {
1842
+ const trimmed = stderr.trim();
1843
+ return trimmed ? `: ${clip(trimmed, 1000)}` : "";
1844
+ }
1845
+ function deterministicDraftPayload(dispatch) {
1846
+ const kind = dispatch.draft?.kind ?? "work_item";
1847
+ if (kind === "work_order") {
1848
+ return {
1849
+ title: "Deterministic runner Work Order draft",
1850
+ summary: "Forge Runner generated this deterministic Work Order draft.",
1851
+ objective: "Validate runner-backed Draft generation.",
1852
+ planningPacket: "Runner-backed draft generation returned a Work Order JSON payload.",
1853
+ decompositionContext: "Use this deterministic fixture only in bridge tests.",
1854
+ assumptions: ["Forge Runner dispatch is available."],
1855
+ warnings: []
1856
+ };
1857
+ }
1858
+ if (kind === "story") {
1859
+ return {
1860
+ title: "Deterministic runner Story draft",
1861
+ description: "Forge Runner generated this deterministic Story draft.",
1862
+ acceptanceCriteria: "Given runner-backed Draft generation, when Forge requests a Story draft, then JSON is returned.",
1863
+ points: 3
1864
+ };
1865
+ }
1866
+ return {
1867
+ title: "Deterministic runner Work Item draft",
1868
+ description: "Forge Runner generated this deterministic Work Item draft.",
1869
+ acceptanceCriteria: "Given runner-backed Draft generation, when Forge requests a draft, then JSON is returned.",
1870
+ reproSteps: "Run Draft with a Forge Runner provider selected.",
1871
+ systemInfo: "forge-bridge deterministic provider",
1872
+ points: 1
1873
+ };
1874
+ }
1155
1875
  export function formatProviderCliCommand(command) {
1156
1876
  return [command.command, ...command.args].map(singleLine).join(" ");
1157
1877
  }
1158
- function yamlQuotedScalar(value) {
1159
- return JSON.stringify(singleLine(value));
1160
- }
1161
1878
  function singleLine(value) {
1162
1879
  return value.replace(/\s+/g, " ").trim();
1163
1880
  }
@@ -1166,16 +1883,19 @@ export function buildProviderCliCommand(dispatch, run) {
1166
1883
  const normalizedProvider = provider.toLowerCase();
1167
1884
  const model = dispatch.execution.model.trim();
1168
1885
  if (normalizedProvider === "codex") {
1169
- const args = ["exec"];
1886
+ const args = run ? ["-s", "workspace-write", "-a", "never", "exec"] : ["exec"];
1170
1887
  if (run?.completionTools) {
1171
1888
  args.push(...codexMcpConfigArgs(run.completionTools));
1172
1889
  }
1173
1890
  args.push("--model", model);
1174
1891
  if (run) {
1175
- args.push("--json", "--dangerously-bypass-approvals-and-sandbox");
1892
+ args.push("--json");
1176
1893
  if (run.worktreePath) {
1177
1894
  args.push("--cd", run.worktreePath);
1178
1895
  }
1896
+ if (run.skipGitRepoCheck) {
1897
+ args.push("--skip-git-repo-check");
1898
+ }
1179
1899
  args.push(run.prompt ?? "");
1180
1900
  }
1181
1901
  return { command: "codex", args };
@@ -1196,6 +1916,31 @@ export function buildProviderCliCommand(dispatch, run) {
1196
1916
  }
1197
1917
  return { command: provider, args };
1198
1918
  }
1919
+ function buildArchitectPrompt(dispatch) {
1920
+ return `You are Forge's Architect lane running locally in a prepared git worktree.
1921
+
1922
+ Work item: ${dispatch.workItem.id} — ${dispatch.workItem.title}
1923
+
1924
+ Plan only. Do not implement source changes, do not create Develop phase artifacts, and do not mark the spec approved.
1925
+ Read injected Forge context under .forge plus the repository shape, then produce a repo-grounded implementation DAG with task tests, phase gates, final QA coverage, and a pending-approval spec.
1926
+ ${architectRevisionInstruction(dispatch)}
1927
+ Forge owns protocol files: write Architect artifacts only through the completion MCP tool.
1928
+
1929
+ ${completionPromptForPhase("architect")}
1930
+
1931
+ Finish only after calling forge.complete_architect_plan with implementation_plan, qa_plan, status, summary, and report/report_markdown.`;
1932
+ }
1933
+ function architectRevisionInstruction(dispatch) {
1934
+ if (!dispatch.architectRevision) {
1935
+ return "";
1936
+ }
1937
+ return `
1938
+ Architect revision context:
1939
+ - Previous plan id: ${dispatch.architectRevision.priorPlanId}
1940
+ - Previous content hash: ${dispatch.architectRevision.priorContentHash}
1941
+ - Reviewer feedback: ${dispatch.architectRevision.feedback}
1942
+ Revise the prior plan in response to the feedback. Preserve accepted scope and only change what the feedback requires.`;
1943
+ }
1199
1944
  function buildImplementationPrompt(dispatch, phase = completionPhaseFromDispatch(dispatch)) {
1200
1945
  return `You are Forge's ${phase.replace(/_/g, " ")} phase runner running locally in a prepared git worktree.
1201
1946
 
@@ -1224,6 +1969,20 @@ ${completionPromptForDevelopCycle(phase)}
1224
1969
 
1225
1970
  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.`;
1226
1971
  }
1972
+ function buildDebugCyclePrompt(dispatch, phase = completionPhaseFromDispatch(dispatch)) {
1973
+ return `You are Forge's Debug lane running locally in a prepared git worktree for a bug work item.
1974
+
1975
+ Read .forge/work-items/${dispatch.workItem.id}/spec.md, reproduce or characterize the bug, isolate root cause, apply the smallest safe fix, then prove the regression in this same provider session.
1976
+ Start in the ${phase.replace(/_/g, " ")} gate. Do not stop after a passing Debug gate; continue through the next Debug MCP instruction returned by the tool server until debug_regression is accepted or the current gate records a non-pass blocker.
1977
+ ${debugQaReworkInstruction(dispatch)}
1978
+ Use the repository's existing conventions. Run focused tests when practical, especially the original repro and required regressions.
1979
+ Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, Debug verdict artifacts, reports, and commits.
1980
+ Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge Debug artifacts by hand.
1981
+
1982
+ ${completionPromptForDebugCycle(isDebugCompletionPhaseForProvider(phase) ? phase : "debug_repro")}
1983
+
1984
+ Finish with a concise summary of changed files and verification after the debug_regression completion tool has accepted the work, or after the current Debug gate tool records a non-pass blocker.`;
1985
+ }
1227
1986
  function qaReworkImplementationInstruction(dispatch) {
1228
1987
  if (!dispatch.qaRework) {
1229
1988
  return "";
@@ -1233,7 +1992,20 @@ QA rework context:
1233
1992
  - Attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
1234
1993
  - Rework packet: ${dispatch.qaRework.packetPath}
1235
1994
  - Required QA finding IDs: ${dispatch.qaRework.findingIds.join(", ")}
1236
- Read the packet JSON and fix or explicitly dispute each listed finding. The implementation completion artifact must include resolved_findings entries for every required QA ID with qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.`;
1995
+ Read the packet JSON and fix or explicitly dispute each listed finding. The implementation completion artifact must include resolved_findings entries for every required QA ID with qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.
1996
+ Use only these resolved_findings status values: resolved, disputed, or not_reproduced. Use resolved when the finding was fixed; do not use fixed.`;
1997
+ }
1998
+ function debugQaReworkInstruction(dispatch) {
1999
+ if (!dispatch.qaRework) {
2000
+ return "";
2001
+ }
2002
+ return `
2003
+ QA rework context for Debug:
2004
+ - Attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
2005
+ - Rework packet: ${dispatch.qaRework.packetPath}
2006
+ - Required QA finding IDs: ${dispatch.qaRework.findingIds.join(", ")}
2007
+ Read the packet JSON, reproduce or characterize each listed finding, fix or explicitly dispute it, and include resolved_findings entries for every required QA ID in forge.complete_debug_fix with qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.
2008
+ Use only these resolved_findings status values: resolved, disputed, or not_reproduced. Use resolved when the finding was fixed; do not use fixed.`;
1237
2009
  }
1238
2010
  function qaReworkRetestInstruction(dispatch) {
1239
2011
  if (!dispatch.qaRework) {
@@ -1270,9 +2042,49 @@ function qaBrowserInstruction(dispatch) {
1270
2042
  const routes = launchTarget.routes.length > 0 ? launchTarget.routes.join(", ") : "not specified";
1271
2043
  return `Browser/app launch context:\n- Browser QA required: ${qaBrowser.required ? "yes" : "no, unless the diff/AC requires it"}\n- URL: ${launchTarget.url || "not specified"}\n- Ready URL: ${launchTarget.ready_url || "not specified"}\n- Launch command: ${launchTarget.command || "not specified"}\n- Working directory: ${launchTarget.working_directory || "repo root"}\n- Routes to cover: ${routes}\n- Notes: ${launchTarget.notes || "none"}\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
1272
2044
  }
1273
- function buildQaPrompt(dispatch) {
2045
+ function storyQaInstruction(dispatch) {
2046
+ const storyQa = dispatch.storyQa;
2047
+ if (!storyQa) {
2048
+ return "";
2049
+ }
2050
+ const childLines = storyQa.childTasks.map((child) => {
2051
+ const dependencies = child.dependencyWorkItemIds.length > 0
2052
+ ? child.dependencyWorkItemIds.join(", ")
2053
+ : "none";
2054
+ const validation = child.validationArtifact
2055
+ ? `${child.validationArtifact.status} (${child.validationArtifact.path || "artifact path unavailable"})`
2056
+ : "missing";
2057
+ const childQa = child.qaArtifact
2058
+ ? `${child.qaArtifact.status} (${child.qaArtifact.path || "artifact path unavailable"})`
2059
+ : "not present";
2060
+ return `- ${child.title} (${child.workItemId}, architect ${child.architectTaskId}, phase ${child.phaseId}/${child.phaseOrder}); deps: ${dependencies}; validation: ${validation}; child QA: ${childQa}`;
2061
+ }).join("\n");
2062
+ const finalQa = storyQa.finalQaChecks.length > 0
2063
+ ? storyQa.finalQaChecks.map((check) => `- ${check}`).join("\n")
2064
+ : "- No final QA checks were provided in qa.plan.final_qa.";
2065
+ return `Story QA integration context:
2066
+ - Story work item: ${storyQa.storyWorkItemId}
2067
+ - Plan: ${storyQa.planId} / ${storyQa.planContentHash}
2068
+ - Story worktree: ${storyQa.worktreeKey}
2069
+ - Branch: ${storyQa.branch}
2070
+ - Architect implementation.plan.json provided: ${storyQa.implementationPlanJson ? "yes" : "no"}
2071
+ - Architect qa.plan.json provided: ${storyQa.qaPlanJson ? "yes" : "no"}
2072
+ Materialized child tasks and evidence:
2073
+ ${childLines || "- No child tasks were provided."}
2074
+ Architect final QA checks:
2075
+ ${finalQa}
2076
+ This is parent Story QA: inspect the integrated Story result, all current-plan child specs/evidence, and the final worktree state. Do not treat the final child task as separately signed off.`;
2077
+ }
2078
+ function buildQaPrompt(dispatch, laneSpec) {
1274
2079
  return `You are Forge's QA lane running locally in a prepared git worktree.
1275
2080
 
2081
+ First read and follow the injected QA lane command at .forge/work-items/${dispatch.workItem.id}/spec.md. It defines the required gated QA skill workflow for this run.
2082
+
2083
+ Injected QA lane command and work item spec:
2084
+ ${laneSpec}
2085
+
2086
+ ---
2087
+
1276
2088
  Work item: ${dispatch.workItem.id}
1277
2089
  Title: ${dispatch.workItem.title}
1278
2090
  Description:
@@ -1287,6 +2099,7 @@ ${dispatch.workItem.reproSteps || "Not provided."}
1287
2099
  System info / environment notes:
1288
2100
  ${dispatch.workItem.systemInfo || "Not provided."}
1289
2101
 
2102
+ ${storyQaInstruction(dispatch)}
1290
2103
  Inspect the validated implementation and Forge artifacts under .forge/work-items/${dispatch.workItem.id}/artifacts/ when present, especially implementation, self-review, and validation evidence.
1291
2104
  ${qaBrowserInstruction(dispatch)}
1292
2105
  ${qaReworkRetestInstruction(dispatch)}
@@ -1295,9 +2108,9 @@ Record concrete evidence. For UI/browser work, include browser_checks with URLs/
1295
2108
  Report in-scope defects as actionable findings with repro steps, expected/actual behavior, evidence, likely area, required fix, and required regression.
1296
2109
  Out-of-scope or follow-up issues belong in candidate_bugs instead of blocking the current item.
1297
2110
 
1298
- ${completionPromptForPhase("qa")}
2111
+ ${completionPromptForQaCycle()}
1299
2112
 
1300
- Finish with a concise summary only after forge.complete_qa accepts the QA bundle.`;
2113
+ Finish with a concise summary only after forge.complete_qa_verdict accepts the QA bundle.`;
1301
2114
  }
1302
2115
  function buildConflictResolvePrompt(dispatch, branchName) {
1303
2116
  const targetBranch = dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
@@ -1312,6 +2125,34 @@ Keep the original work item intent intact:
1312
2125
 
1313
2126
  Finish with a concise summary of the conflicts resolved and verification performed.`;
1314
2127
  }
2128
+ function buildPullRequestCommentsPrompt(dispatch, branchName) {
2129
+ const pr = dispatch.pullRequest;
2130
+ const targetBranch = pr?.targetBranch || dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
2131
+ const prLocator = pr
2132
+ ? `PR #${pr.number}${pr.url ? ` (${pr.url})` : ""}`
2133
+ : "the open pull request for this work item";
2134
+ return `You are Forge's Pull Request Comments lane running locally in a prepared git worktree.
2135
+
2136
+ The current branch is the pull request branch ${branchName}. Address unresolved review comments on ${prLocator}.
2137
+ PR context:
2138
+ - Provider: ${pr?.provider || "not provided"}
2139
+ - Source branch: ${pr?.sourceBranch || branchName}
2140
+ - Target branch: ${targetBranch}
2141
+ - Unresolved comments: ${pr?.unresolvedThreads ?? "unknown"}
2142
+
2143
+ Use the PR URL, local git metadata, and available GitHub tooling/auth to inspect unresolved review threads. Make focused code or test changes that answer the feedback, commit the changes, and push ${branchName} to origin.
2144
+ After the push succeeds, Forge will resolve the addressed review threads through the pull request API.
2145
+ If the review thread contents are unavailable, stop with a clear summary of what could not be accessed instead of guessing.
2146
+ Keep the original work item intent intact:
2147
+ - Work item: ${dispatch.workItem.id}
2148
+ - Title: ${dispatch.workItem.title}
2149
+ - Acceptance criteria: ${dispatch.workItem.acceptanceCriteria || "Not specified"}
2150
+
2151
+ Finish with a concise summary of comments addressed and verification performed.`;
2152
+ }
2153
+ function isPullRequestCommentsDispatch(dispatch) {
2154
+ return dispatch.cycleRun.kind.trim().toLowerCase() === "pr_comments";
2155
+ }
1315
2156
  async function cleanupCompletionTools(completionTools, emit) {
1316
2157
  try {
1317
2158
  await completionTools.cleanup();
@@ -1369,31 +2210,31 @@ class CursorStreamJsonState {
1369
2210
  consume(line) {
1370
2211
  const event = parseJsonLine(line);
1371
2212
  if (!event) {
1372
- return null;
2213
+ return [];
1373
2214
  }
1374
2215
  const type = stringField(event, "type");
1375
2216
  if (type === "system" && stringField(event, "subtype") === "init") {
1376
2217
  this.sessionId = stringField(event, "session_id");
1377
2218
  if (this.sessionId && !this.emittedSession) {
1378
2219
  this.emittedSession = true;
1379
- return {
1380
- role: "runner",
1381
- kind: "checkpoint",
1382
- content: `Cursor session started: ${this.sessionId}.`,
1383
- eventType: "provider_thread_started"
1384
- };
2220
+ return [{
2221
+ role: "runner",
2222
+ kind: "checkpoint",
2223
+ content: `Cursor session started: ${this.sessionId}.`,
2224
+ eventType: "provider_thread_started"
2225
+ }];
1385
2226
  }
1386
2227
  }
1387
2228
  if (type === "assistant") {
1388
2229
  const text = assistantMessageText(event);
1389
2230
  if (text) {
1390
2231
  this.finalMessage = clip(text, 4000);
1391
- return {
1392
- role: "assistant",
1393
- kind: "message",
1394
- content: this.finalMessage,
1395
- eventType: "provider_message"
1396
- };
2232
+ return [{
2233
+ role: "assistant",
2234
+ kind: "message",
2235
+ content: this.finalMessage,
2236
+ eventType: "provider_message"
2237
+ }];
1397
2238
  }
1398
2239
  }
1399
2240
  if (type === "tool_call") {
@@ -1402,23 +2243,23 @@ class CursorStreamJsonState {
1402
2243
  if (subtype === "started") {
1403
2244
  const summary = formatCursorToolCallStarted(toolCall);
1404
2245
  if (summary) {
1405
- return {
1406
- role: "worker",
1407
- kind: "checkpoint",
1408
- content: summary,
1409
- eventType: "provider_tool_call"
1410
- };
2246
+ return [{
2247
+ role: "worker",
2248
+ kind: "checkpoint",
2249
+ content: summary,
2250
+ eventType: "provider_tool_call"
2251
+ }];
1411
2252
  }
1412
2253
  }
1413
2254
  if (subtype === "completed") {
1414
2255
  const completed = formatCursorToolCallCompleted(toolCall);
1415
2256
  if (completed) {
1416
- return {
1417
- role: "worker",
1418
- kind: completed.kind,
1419
- content: completed.content,
1420
- eventType: completed.kind === "file_edit" ? "provider_file_edit" : "provider_tool_call"
1421
- };
2257
+ return [{
2258
+ role: "worker",
2259
+ kind: completed.kind,
2260
+ content: completed.content,
2261
+ eventType: completed.kind === "file_edit" ? "provider_file_edit" : "provider_tool_call"
2262
+ }];
1422
2263
  }
1423
2264
  }
1424
2265
  }
@@ -1430,14 +2271,14 @@ class CursorStreamJsonState {
1430
2271
  this.accumulateUsage(event);
1431
2272
  }
1432
2273
  if (type === "error" || (type === "result" && event.is_error === true)) {
1433
- return {
1434
- role: "runner",
1435
- kind: "error",
1436
- content: clip(line.trim(), 1000),
1437
- eventType: "provider_cli_error"
1438
- };
2274
+ return [{
2275
+ role: "runner",
2276
+ kind: "error",
2277
+ content: clip(line.trim(), 1000),
2278
+ eventType: "provider_cli_error"
2279
+ }];
1439
2280
  }
1440
- return null;
2281
+ return [];
1441
2282
  }
1442
2283
  accumulateUsage(event) {
1443
2284
  const usage = objectField(event, "usage");
@@ -1568,37 +2409,37 @@ class CodexJsonState {
1568
2409
  consume(line) {
1569
2410
  const event = parseJsonLine(line);
1570
2411
  if (!event) {
1571
- return null;
2412
+ return [];
1572
2413
  }
1573
2414
  const type = stringField(event, "type") ?? "";
1574
2415
  if (type === "thread.started") {
1575
2416
  this.threadId = stringField(event, "thread_id");
1576
2417
  if (this.threadId && !this.emittedThread) {
1577
2418
  this.emittedThread = true;
1578
- return {
1579
- role: "runner",
1580
- kind: "checkpoint",
1581
- content: `Codex thread started: ${this.threadId}.`,
1582
- eventType: "provider_thread_started"
1583
- };
2419
+ return [{
2420
+ role: "runner",
2421
+ kind: "checkpoint",
2422
+ content: `Codex thread started: ${this.threadId}.`,
2423
+ eventType: "provider_thread_started"
2424
+ }];
1584
2425
  }
1585
2426
  }
1586
2427
  if (type === "turn.completed") {
1587
2428
  this.accumulateUsage(event);
1588
- return {
1589
- role: "runner",
1590
- kind: "checkpoint",
1591
- content: "Codex turn completed.",
1592
- eventType: "provider_turn_completed"
1593
- };
2429
+ return [{
2430
+ role: "runner",
2431
+ kind: "checkpoint",
2432
+ content: "Codex turn completed.",
2433
+ eventType: "provider_turn_completed"
2434
+ }];
1594
2435
  }
1595
2436
  if (type === "turn.failed" || type === "error") {
1596
- return {
1597
- role: "runner",
1598
- kind: "error",
1599
- content: clip(line.trim(), 1000),
1600
- eventType: "provider_cli_error"
1601
- };
2437
+ return [{
2438
+ role: "runner",
2439
+ kind: "error",
2440
+ content: clip(line.trim(), 1000),
2441
+ eventType: "provider_cli_error"
2442
+ }];
1602
2443
  }
1603
2444
  if (type === "item.completed" || type === "item.updated") {
1604
2445
  const item = objectField(event, "item");
@@ -1607,28 +2448,32 @@ class CodexJsonState {
1607
2448
  const text = stringField(item, "text");
1608
2449
  if (text) {
1609
2450
  this.finalMessage = clip(text, 4000);
1610
- return {
1611
- role: "assistant",
1612
- kind: "message",
1613
- content: this.finalMessage,
1614
- eventType: "provider_message"
1615
- };
2451
+ return [{
2452
+ role: "assistant",
2453
+ kind: "message",
2454
+ content: this.finalMessage,
2455
+ eventType: "provider_message"
2456
+ }];
1616
2457
  }
1617
2458
  }
1618
2459
  if (itemType === "command_execution") {
1619
2460
  const command = stringField(item, "command");
1620
2461
  const status = stringField(item, "status");
1621
2462
  if (command) {
1622
- return {
1623
- role: "worker",
1624
- kind: "checkpoint",
1625
- content: `Codex command ${status || "completed"}: ${clip(command, 500)}`,
1626
- eventType: "provider_command"
1627
- };
2463
+ return [{
2464
+ role: "worker",
2465
+ kind: "checkpoint",
2466
+ content: `Codex command ${status || "completed"}: ${clip(command, 500)}`,
2467
+ eventType: "provider_command"
2468
+ }];
1628
2469
  }
1629
2470
  }
1630
2471
  }
1631
- return null;
2472
+ const mcpEvents = formatCodexMcpToolCallEvents(type, event);
2473
+ if (mcpEvents.length > 0) {
2474
+ return mcpEvents;
2475
+ }
2476
+ return [];
1632
2477
  }
1633
2478
  accumulateUsage(event) {
1634
2479
  const usage = objectField(event, "usage");
@@ -1644,6 +2489,113 @@ class CodexJsonState {
1644
2489
  });
1645
2490
  }
1646
2491
  }
2492
+ function formatCodexMcpToolCallEvents(type, event) {
2493
+ if (type !== "item.started" && type !== "item.updated" && type !== "item.completed") {
2494
+ return [];
2495
+ }
2496
+ const item = objectField(event, "item");
2497
+ const itemType = stringField(item, "type") ?? "";
2498
+ const serverName = mcpServerName(item);
2499
+ const toolName = mcpToolName(item);
2500
+ const isMcpItem = itemType.toLowerCase().includes("mcp")
2501
+ || Boolean(serverName)
2502
+ || (toolName ? isForgeCompletionTool(toolName) || toolName.toLowerCase().includes("mcp") : false);
2503
+ if (!isMcpItem || !toolName) {
2504
+ return [];
2505
+ }
2506
+ const status = normalizedString(stringField(item, "status")
2507
+ ?? stringField(event, "status")
2508
+ ?? (type === "item.started" ? "started" : type === "item.completed" ? "completed" : "updated"));
2509
+ const failed = status === "failed" || status === "error" || status === "cancelled" || Boolean(item.error);
2510
+ const completed = failed || type === "item.completed" || status === "completed" || status === "succeeded" || status === "success";
2511
+ const started = type === "item.started" || status === "started" || status === "running" || status === "in_progress";
2512
+ const data = safeProviderMcpData(item, serverName ?? "unknown", toolName, status);
2513
+ const label = `${serverName ?? "unknown"}.${toolName}`;
2514
+ if (failed) {
2515
+ const summary = mcpErrorSummary(item) ?? "MCP tool call failed.";
2516
+ const events = [{
2517
+ role: "tool",
2518
+ kind: "error",
2519
+ content: `MCP tool call failed: ${label} - ${summary}`,
2520
+ eventType: "provider_mcp_call_failed",
2521
+ data
2522
+ }];
2523
+ if (isForgeCompletionTool(toolName)) {
2524
+ events.push({
2525
+ role: "tool",
2526
+ kind: "error",
2527
+ content: `Forge completion tool validation failed: ${label} - ${summary}`,
2528
+ eventType: "completion_tool_validation_failed",
2529
+ data
2530
+ });
2531
+ }
2532
+ return events;
2533
+ }
2534
+ if (completed) {
2535
+ return [{
2536
+ role: "tool",
2537
+ kind: "checkpoint",
2538
+ content: `MCP tool call completed: ${label}.`,
2539
+ eventType: "provider_mcp_call_completed",
2540
+ data
2541
+ }];
2542
+ }
2543
+ if (started) {
2544
+ return [{
2545
+ role: "tool",
2546
+ kind: "checkpoint",
2547
+ content: `MCP tool call started: ${label}.`,
2548
+ eventType: "provider_mcp_call_started",
2549
+ data
2550
+ }];
2551
+ }
2552
+ return [];
2553
+ }
2554
+ function mcpServerName(item) {
2555
+ return stringField(item, "server")
2556
+ ?? stringField(item, "server_name")
2557
+ ?? stringField(item, "mcp_server")
2558
+ ?? stringField(objectField(item, "mcp"), "server")
2559
+ ?? stringField(objectField(item, "mcp"), "server_name");
2560
+ }
2561
+ function mcpToolName(item) {
2562
+ return stringField(item, "tool")
2563
+ ?? stringField(item, "tool_name")
2564
+ ?? stringField(item, "name")
2565
+ ?? stringField(objectField(item, "function"), "name")
2566
+ ?? stringField(objectField(item, "mcp"), "tool")
2567
+ ?? stringField(objectField(item, "mcp"), "tool_name");
2568
+ }
2569
+ function mcpErrorSummary(item) {
2570
+ const direct = stringField(item, "error")
2571
+ ?? stringField(item, "error_message")
2572
+ ?? stringField(item, "message");
2573
+ if (direct) {
2574
+ return clip(direct, 600);
2575
+ }
2576
+ const error = objectField(item, "error");
2577
+ return stringField(error, "message")
2578
+ ?? stringField(error, "summary")
2579
+ ?? stringField(objectField(item, "result"), "error")
2580
+ ?? null;
2581
+ }
2582
+ function safeProviderMcpData(item, serverName, toolName, status) {
2583
+ const elapsedMs = numericField(item, "elapsed_ms") ?? numericField(item, "duration_ms");
2584
+ return JSON.stringify(stripUndefined({
2585
+ server_name: serverName,
2586
+ tool_name: toolName,
2587
+ status,
2588
+ elapsed_ms: elapsedMs,
2589
+ error_summary: mcpErrorSummary(item)
2590
+ }));
2591
+ }
2592
+ function isForgeCompletionTool(toolName) {
2593
+ const normalized = toolName.trim().toLowerCase();
2594
+ return normalized.startsWith("forge.complete_")
2595
+ || normalized.startsWith("complete_")
2596
+ || normalized.includes("forge.complete_")
2597
+ || normalized.includes("complete_architect_plan");
2598
+ }
1647
2599
  function objectField(source, key) {
1648
2600
  const value = source[key];
1649
2601
  return value && typeof value === "object" && !Array.isArray(value)
@@ -1654,6 +2606,13 @@ function stringField(source, key) {
1654
2606
  const value = source[key];
1655
2607
  return typeof value === "string" && value.trim().length > 0 ? value : null;
1656
2608
  }
2609
+ function numericField(source, key) {
2610
+ const value = source[key];
2611
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
2612
+ }
2613
+ function stripUndefined(value) {
2614
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
2615
+ }
1657
2616
  function normalizedString(value) {
1658
2617
  return value.trim().toLowerCase().replace(/-/g, "_");
1659
2618
  }