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

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.
@@ -8,11 +8,52 @@ import { GitClient } from "../../shared/git/git.js";
8
8
  import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
9
9
  import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "../qa-sidecar-validation.js";
10
10
  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";
11
+ 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
12
  import { addUsage, buildProviderUsageEvent, emptyUsage, hasUsage, readUsageNumber } from "./provider-usage.js";
13
13
  const fakeWorkerModel = "server-local-fake-worker";
14
14
  const execFileAsync = promisify(execFile);
15
15
  const developCyclePhaseOrder = ["implementation", "self_review", "validation"];
16
+ const allowedProviderParentEnvNames = [
17
+ "PATH",
18
+ "HOME",
19
+ "USERPROFILE",
20
+ "TMPDIR",
21
+ "TEMP",
22
+ "TMP",
23
+ "LANG"
24
+ ];
25
+ const windowsAllowedProviderParentEnvNames = [
26
+ "SystemRoot",
27
+ "ComSpec",
28
+ "PATHEXT",
29
+ "APPDATA",
30
+ "LOCALAPPDATA"
31
+ ];
32
+ function buildProviderChildEnvironment(requestEnv) {
33
+ const env = {};
34
+ for (const [key, value] of Object.entries(process.env)) {
35
+ if (typeof value === "string" && isAllowedProviderParentEnv(key)) {
36
+ env[key] = value;
37
+ }
38
+ }
39
+ if (requestEnv) {
40
+ for (const [key, value] of Object.entries(requestEnv)) {
41
+ const trimmedKey = key.trim();
42
+ if (trimmedKey.length > 0) {
43
+ env[trimmedKey] = value;
44
+ }
45
+ }
46
+ }
47
+ return env;
48
+ }
49
+ function isAllowedProviderParentEnv(key) {
50
+ return isAllowedProviderParentEnvName(key, allowedProviderParentEnvNames)
51
+ || key.toUpperCase().startsWith("LC_")
52
+ || (process.platform === "win32" && isAllowedProviderParentEnvName(key, windowsAllowedProviderParentEnvNames));
53
+ }
54
+ function isAllowedProviderParentEnvName(key, allowedNames) {
55
+ return allowedNames.some((allowed) => allowed.toLowerCase() === key.toLowerCase());
56
+ }
16
57
  export class RoutingProviderCliService {
17
58
  realProvider;
18
59
  deterministicProvider;
@@ -25,6 +66,11 @@ export class RoutingProviderCliService {
25
66
  ? await this.deterministicProvider.runImplementation(context)
26
67
  : await this.realProvider.runImplementation(context);
27
68
  }
69
+ async runArchitect(context) {
70
+ return isFakeWorkerModel(context.dispatch.execution.model)
71
+ ? await this.deterministicProvider.runArchitect(context)
72
+ : await this.realProvider.runArchitect(context);
73
+ }
28
74
  async runConflictResolve(context) {
29
75
  return isFakeWorkerModel(context.dispatch.execution.model)
30
76
  ? await this.deterministicProvider.runConflictResolve(context)
@@ -47,6 +93,7 @@ export class ProcessBackedProviderCliService {
47
93
  const { dispatch, worktree } = context;
48
94
  const phase = completionPhaseFromDispatch(dispatch);
49
95
  const developCycle = usesDevelopCycleCompletion(dispatch);
96
+ const debugCycle = usesDebugCycleCompletion(dispatch);
50
97
  const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
51
98
  await fs.mkdir(metadataRoot, { recursive: true });
52
99
  const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
@@ -60,7 +107,11 @@ export class ProcessBackedProviderCliService {
60
107
  const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase });
61
108
  const command = buildProviderCliCommand(dispatch, {
62
109
  worktreePath: worktree.worktreePath,
63
- prompt: developCycle ? buildDevelopCyclePrompt(dispatch, phase) : buildImplementationPrompt(dispatch, phase),
110
+ prompt: debugCycle
111
+ ? buildDebugCyclePrompt(dispatch, phase)
112
+ : developCycle
113
+ ? buildDevelopCyclePrompt(dispatch, phase)
114
+ : buildImplementationPrompt(dispatch, phase),
64
115
  completionTools
65
116
  });
66
117
  await context.emit({
@@ -72,13 +123,15 @@ export class ProcessBackedProviderCliService {
72
123
  await context.emit({
73
124
  role: "runner",
74
125
  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.`,
126
+ content: debugCycle
127
+ ? "Forge Debug completion tools configured for debug_repro -> debug_root_cause -> debug_fix -> debug_regression; provider must call each MCP completion tool before exit."
128
+ : developCycle
129
+ ? "Forge completion tools configured for implementation -> self_review -> validation; provider must call each MCP completion tool before exit."
130
+ : `Forge completion tools configured for ${phase}; provider must call the MCP completion tool before exit.`,
78
131
  eventType: "completion_tools_configured"
79
132
  });
80
133
  const outputState = createProviderOutputState(dispatch.execution.provider);
81
- const liveCompletionEvents = new LiveCompletionArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, context.emit);
134
+ const liveCompletionEvents = new LiveCompletionArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, debugCycle, context.emit);
82
135
  let result;
83
136
  try {
84
137
  liveCompletionEvents.start();
@@ -86,11 +139,10 @@ export class ProcessBackedProviderCliService {
86
139
  command: command.command,
87
140
  args: command.args,
88
141
  cwd: worktree.worktreePath,
89
- env: providerRedactionEnv(dispatch)
142
+ env: providerProcessEnv(dispatch)
90
143
  }, {
91
144
  onStdoutLine: async (line) => {
92
- const event = outputState.consume(line);
93
- if (event) {
145
+ for (const event of outputState.consume(line)) {
94
146
  await context.emit(event);
95
147
  }
96
148
  }
@@ -112,7 +164,7 @@ export class ProcessBackedProviderCliService {
112
164
  });
113
165
  await emitProviderUsageEvent(context.emit, dispatch, outputState);
114
166
  const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
115
- const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle);
167
+ const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, debugCycle);
116
168
  for (const artifact of completionArtifacts) {
117
169
  if (!liveCompletionEvents.hasEmitted(completionPhaseFromArtifact(artifact))) {
118
170
  await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, artifact);
@@ -138,7 +190,7 @@ export class ProcessBackedProviderCliService {
138
190
  });
139
191
  }
140
192
  return {
141
- summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}, ${committed.commitSha.slice(0, 12)}).`,
193
+ summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${debugCycle ? "debug cycle" : developCycle ? "develop cycle" : phase}, ${committed.commitSha.slice(0, 12)}).`,
142
194
  spec: {
143
195
  repoPath: worktree.worktreePath,
144
196
  path: specPath,
@@ -150,12 +202,105 @@ export class ProcessBackedProviderCliService {
150
202
  artifacts: completionArtifacts
151
203
  };
152
204
  }
205
+ async runArchitect(context) {
206
+ const { dispatch, worktree } = context;
207
+ const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "architect" });
208
+ const command = buildProviderCliCommand(dispatch, {
209
+ worktreePath: worktree.worktreePath,
210
+ prompt: buildArchitectPrompt(dispatch),
211
+ completionTools
212
+ });
213
+ const commandText = formatProviderCliCommand(command);
214
+ await context.emit({
215
+ role: "runner",
216
+ kind: "checkpoint",
217
+ content: `Starting provider CLI Architect: ${commandText} in ${worktree.worktreePath}`,
218
+ eventType: "provider_cli_started"
219
+ });
220
+ await context.emit({
221
+ role: "runner",
222
+ kind: "checkpoint",
223
+ content: "Forge Architect completion tools configured; provider must call forge.complete_architect_plan before exit.",
224
+ eventType: "completion_tools_configured"
225
+ });
226
+ const outputState = createProviderOutputState(dispatch.execution.provider);
227
+ const liveArchitectEvents = new LiveArchitectArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, context.emit);
228
+ let result;
229
+ try {
230
+ liveArchitectEvents.start();
231
+ result = await this.processRunner.run({
232
+ command: command.command,
233
+ args: command.args,
234
+ cwd: worktree.worktreePath,
235
+ env: providerProcessEnv(dispatch)
236
+ }, {
237
+ onStdoutLine: async (line) => {
238
+ for (const event of outputState.consume(line)) {
239
+ await context.emit(event);
240
+ }
241
+ }
242
+ }, context.signal);
243
+ }
244
+ finally {
245
+ await liveArchitectEvents.stop();
246
+ await cleanupCompletionTools(completionTools, context.emit);
247
+ }
248
+ if (result.exitCode !== 0) {
249
+ const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
250
+ throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
251
+ }
252
+ await context.emit({
253
+ role: "runner",
254
+ kind: "checkpoint",
255
+ content: `Provider CLI Architect completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
256
+ eventType: "provider_cli_completed"
257
+ });
258
+ await emitProviderUsageEvent(context.emit, dispatch, outputState);
259
+ const architectArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "architect");
260
+ if (!architectArtifact) {
261
+ throw new Error("Provider CLI Architect completed but did not call the Forge Architect completion tool.");
262
+ }
263
+ if (!liveArchitectEvents.hasEmitted()) {
264
+ await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, architectArtifact);
265
+ await emitArchitectStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, architectArtifact);
266
+ }
267
+ await context.emit({
268
+ role: "assistant",
269
+ kind: "message",
270
+ content: outputState.finalMessage || `${dispatch.execution.provider} CLI produced an Architect plan for ${worktree.branchName}.`,
271
+ eventType: "architect_complete"
272
+ });
273
+ const specPath = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id, "spec.md");
274
+ const specBody = await fs.readFile(specPath, "utf8").catch(() => `# ${dispatch.workItem.title}\n\nArchitect plan pending approval.\n`);
275
+ return {
276
+ summary: `${dispatch.execution.provider} CLI Architect plan produced.`,
277
+ spec: {
278
+ repoPath: worktree.worktreePath,
279
+ path: specPath,
280
+ body: specBody,
281
+ producedBy: `${dispatch.execution.provider}-cli`,
282
+ status: "pending_approval"
283
+ },
284
+ artifact: architectArtifact,
285
+ artifacts: [architectArtifact]
286
+ };
287
+ }
153
288
  async runQa(context) {
154
289
  const { dispatch, worktree } = context;
290
+ const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
291
+ await fs.mkdir(metadataRoot, { recursive: true });
292
+ const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
293
+ if (!laneCommand) {
294
+ throw new Error(`Lane command for lane '${dispatch.execution.lane}' phase '${dispatch.cycleRun.phase}' was not resolved.`);
295
+ }
296
+ const specBody = buildSpecBody(laneCommand, dispatch.workItem, "qa");
297
+ const specPath = path.join(metadataRoot, "spec.md");
298
+ await fs.writeFile(specPath, specBody, "utf8");
299
+ await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-lane-command" }) + "\n", "utf8");
155
300
  const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "qa" });
156
301
  const command = buildProviderCliCommand(dispatch, {
157
302
  worktreePath: worktree.worktreePath,
158
- prompt: buildQaPrompt(dispatch),
303
+ prompt: buildQaPrompt(dispatch, specBody),
159
304
  completionTools
160
305
  });
161
306
  const commandText = formatProviderCliCommand(command);
@@ -168,7 +313,7 @@ export class ProcessBackedProviderCliService {
168
313
  await context.emit({
169
314
  role: "runner",
170
315
  kind: "checkpoint",
171
- content: "Forge QA completion tool configured; provider must call forge.complete_qa before exit.",
316
+ content: "Forge QA completion tools configured; provider must call all seven QA MCP gates before exit.",
172
317
  eventType: "completion_tools_configured"
173
318
  });
174
319
  const outputState = createProviderOutputState(dispatch.execution.provider);
@@ -180,11 +325,10 @@ export class ProcessBackedProviderCliService {
180
325
  command: command.command,
181
326
  args: command.args,
182
327
  cwd: worktree.worktreePath,
183
- env: providerRedactionEnv(dispatch)
328
+ env: providerProcessEnv(dispatch)
184
329
  }, {
185
330
  onStdoutLine: async (line) => {
186
- const event = outputState.consume(line);
187
- if (event) {
331
+ for (const event of outputState.consume(line)) {
188
332
  await context.emit(event);
189
333
  }
190
334
  }
@@ -205,7 +349,7 @@ export class ProcessBackedProviderCliService {
205
349
  eventType: "provider_cli_completed"
206
350
  });
207
351
  await emitProviderUsageEvent(context.emit, dispatch, outputState);
208
- const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id);
352
+ const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId);
209
353
  if (!qaArtifact) {
210
354
  const verdictOnly = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "qa");
211
355
  throw new Error(verdictOnly
@@ -215,7 +359,7 @@ export class ProcessBackedProviderCliService {
215
359
  enforceQaBrowserLaunchTargetGate(dispatch, qaArtifact);
216
360
  if (!liveQaEvents.hasEmitted()) {
217
361
  await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
218
- await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
362
+ await emitQaStructuredEvents(context.emit, qaArtifact);
219
363
  }
220
364
  const status = (qaArtifact.status ?? "unknown").trim().toLowerCase();
221
365
  const statusSummary = status === "pass"
@@ -225,31 +369,41 @@ export class ProcessBackedProviderCliService {
225
369
  : "found rework";
226
370
  return {
227
371
  summary: `${dispatch.execution.provider} CLI QA ${statusSummary}.`,
372
+ spec: {
373
+ repoPath: worktree.worktreePath,
374
+ path: specPath,
375
+ body: specBody,
376
+ producedBy: "forge-lane-command",
377
+ status: "approved"
378
+ },
228
379
  artifact: qaArtifact,
229
380
  artifacts: [qaArtifact]
230
381
  };
231
382
  }
232
383
  async runConflictResolve(context) {
233
384
  const { dispatch, worktree } = context;
385
+ const addressComments = isPullRequestCommentsDispatch(dispatch);
234
386
  const command = buildProviderCliCommand(dispatch, {
235
387
  worktreePath: worktree.worktreePath,
236
- prompt: buildConflictResolvePrompt(dispatch, worktree.branchName)
388
+ prompt: addressComments
389
+ ? buildPullRequestCommentsPrompt(dispatch, worktree.branchName)
390
+ : buildConflictResolvePrompt(dispatch, worktree.branchName)
237
391
  });
238
392
  await context.emit({
239
393
  role: "runner",
240
394
  kind: "checkpoint",
241
- content: `Starting provider CLI conflict resolve: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
395
+ content: `Starting provider CLI ${addressComments ? "PR comment address" : "conflict resolve"}: ${formatProviderCliCommand(command)} in ${worktree.worktreePath}`,
242
396
  eventType: "provider_cli_started"
243
397
  });
244
398
  const outputState = createProviderOutputState(dispatch.execution.provider);
245
399
  const result = await this.processRunner.run({
246
400
  command: command.command,
247
401
  args: command.args,
248
- cwd: worktree.worktreePath
402
+ cwd: worktree.worktreePath,
403
+ env: providerProcessEnv(dispatch)
249
404
  }, {
250
405
  onStdoutLine: async (line) => {
251
- const event = outputState.consume(line);
252
- if (event) {
406
+ for (const event of outputState.consume(line)) {
253
407
  await context.emit(event);
254
408
  }
255
409
  }
@@ -264,11 +418,15 @@ export class ProcessBackedProviderCliService {
264
418
  await context.emit({
265
419
  role: "assistant",
266
420
  kind: "message",
267
- content: outputState.finalMessage || `${dispatch.execution.provider} CLI resolved merge conflicts for ${worktree.branchName}.`,
268
- eventType: "conflict_resolve_complete"
421
+ content: outputState.finalMessage || (addressComments
422
+ ? `${dispatch.execution.provider} CLI addressed pull request comments for ${worktree.branchName}.`
423
+ : `${dispatch.execution.provider} CLI resolved merge conflicts for ${worktree.branchName}.`),
424
+ eventType: addressComments ? "pr_comments_complete" : "conflict_resolve_complete"
269
425
  });
270
426
  return {
271
- summary: `${dispatch.execution.provider} CLI resolved conflicts on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
427
+ summary: addressComments
428
+ ? `${dispatch.execution.provider} CLI addressed PR comments on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
429
+ : `${dispatch.execution.provider} CLI resolved conflicts on ${worktree.branchName} (${committed.commitSha.slice(0, 12)}).`
272
430
  };
273
431
  }
274
432
  }
@@ -281,6 +439,7 @@ export class DeterministicProviderCliService {
281
439
  const { dispatch, worktree } = context;
282
440
  const phase = completionPhaseFromDispatch(dispatch);
283
441
  const developCycle = usesDevelopCycleCompletion(dispatch);
442
+ const debugCycle = usesDebugCycleCompletion(dispatch);
284
443
  const providerCommand = buildProviderCliCommand(dispatch);
285
444
  const providerCommandText = formatProviderCliCommand(providerCommand);
286
445
  const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
@@ -311,9 +470,11 @@ export class DeterministicProviderCliService {
311
470
  content: `Created bridge changeset at ${changesetPath}`,
312
471
  eventType: "changeset"
313
472
  });
314
- const phases = developCycle
315
- ? ["implementation", "self_review", "validation"]
316
- : [phase];
473
+ const phases = debugCycle
474
+ ? debugCompletionPhaseOrder
475
+ : developCycle
476
+ ? ["implementation", "self_review", "validation"]
477
+ : [phase];
317
478
  const completionArtifacts = [];
318
479
  for (const completionPhase of phases) {
319
480
  await writeCompletionArtifact({
@@ -322,7 +483,9 @@ export class DeterministicProviderCliService {
322
483
  phase: completionPhase,
323
484
  producedBy: "forge-bridge",
324
485
  redactionSecrets: exactRedactionSecrets(dispatch)
325
- }, expectedCompletionToolName(completionPhase), deterministicCompletionPayload(completionPhase, dispatch, changesetBody));
486
+ }, isDebugCompletionPhaseForProvider(completionPhase)
487
+ ? expectedDebugCompletionToolName(completionPhase)
488
+ : expectedCompletionToolName(completionPhase), deterministicCompletionPayload(completionPhase, dispatch, changesetBody));
326
489
  const completionArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, completionPhase);
327
490
  if (!completionArtifact) {
328
491
  throw new Error(`Deterministic provider did not produce the Forge ${completionPhase} completion artifact.`);
@@ -338,7 +501,7 @@ export class DeterministicProviderCliService {
338
501
  }
339
502
  await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
340
503
  return {
341
- summary: `forge-bridge completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}).`,
504
+ summary: `forge-bridge completed ${worktree.branchName} (${debugCycle ? "debug cycle" : developCycle ? "develop cycle" : phase}).`,
342
505
  spec: {
343
506
  repoPath: worktree.worktreePath,
344
507
  path: specPath,
@@ -350,6 +513,47 @@ export class DeterministicProviderCliService {
350
513
  artifacts: completionArtifacts
351
514
  };
352
515
  }
516
+ async runArchitect(context) {
517
+ const { dispatch, worktree } = context;
518
+ const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
519
+ await fs.mkdir(metadataRoot, { recursive: true });
520
+ 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`;
521
+ const specPath = path.join(metadataRoot, "spec.md");
522
+ await fs.writeFile(specPath, specBody, "utf8");
523
+ await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "pending_approval", produced_by: "forge-bridge" }) + "\n", "utf8");
524
+ await writeCompletionArtifact({
525
+ worktreePath: worktree.worktreePath,
526
+ workItemId: dispatch.workItem.id,
527
+ phase: "architect",
528
+ producedBy: "forge-bridge",
529
+ redactionSecrets: exactRedactionSecrets(dispatch),
530
+ architectCycle: true
531
+ }, expectedCompletionToolName("architect"), deterministicArchitectPayload(dispatch));
532
+ const artifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "architect");
533
+ if (!artifact) {
534
+ throw new Error("Deterministic provider did not produce the Forge Architect completion artifact.");
535
+ }
536
+ await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, artifact);
537
+ await emitArchitectStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, artifact);
538
+ await context.emit({
539
+ role: "assistant",
540
+ kind: "message",
541
+ content: "Architect plan complete. forge-bridge produced deterministic planning artifacts.",
542
+ eventType: "architect_complete"
543
+ });
544
+ return {
545
+ summary: "forge-bridge completed Architect plan (deterministic).",
546
+ spec: {
547
+ repoPath: worktree.worktreePath,
548
+ path: specPath,
549
+ body: specBody,
550
+ producedBy: "forge-bridge",
551
+ status: "pending_approval"
552
+ },
553
+ artifact,
554
+ artifacts: [artifact]
555
+ };
556
+ }
353
557
  async runQa(context) {
354
558
  const { dispatch, worktree } = context;
355
559
  await context.emit({
@@ -362,30 +566,45 @@ export class DeterministicProviderCliService {
362
566
  const launchTarget = qaBrowser?.launchTarget ?? null;
363
567
  const missingLaunchTarget = qaBrowser?.required === true && !hasLaunchTarget(launchTarget);
364
568
  const browserSidecar = !missingLaunchTarget && qaBrowser?.required === true && launchTarget
365
- ? deterministicBrowserSidecar(dispatch.workItem.id, launchTarget)
569
+ ? deterministicBrowserSidecar(dispatch.workItem.id, launchTarget, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId)
366
570
  : null;
367
571
  const browserChecks = browserSidecar && launchTarget
368
572
  ? [deterministicBrowserCheck(launchTarget, browserSidecar.relativePath)]
369
573
  : [];
574
+ const sidecars = await writeQaSidecarFiles(worktree.worktreePath, browserSidecar ? [browserSidecar] : []);
370
575
  const summary = missingLaunchTarget
371
576
  ? "QA blocked because browser testing is required but no app launch target was provided."
372
577
  : "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
- });
578
+ const criterion = dispatch.workItem.acceptanceCriteria.trim() || "Forge QA evidence exists";
579
+ for (const gate of qaCompletionGateOrder) {
580
+ await writeCompletionArtifact({
581
+ worktreePath: worktree.worktreePath,
582
+ workItemId: dispatch.workItem.id,
583
+ phase: "qa",
584
+ producedBy: "forge-bridge",
585
+ redactionSecrets: exactRedactionSecrets(dispatch),
586
+ qaCycle: true,
587
+ ownerType: dispatch.cycleRun.ownerType ?? undefined,
588
+ ownerId: dispatch.cycleRun.ownerId ?? undefined
589
+ }, expectedQaCompletionToolName(gate), deterministicQaGatePayload({
590
+ gate,
591
+ criterion,
592
+ summary,
593
+ missingLaunchTarget,
594
+ browserChecks,
595
+ sidecars
596
+ }));
597
+ const gateArtifact = await readQaGateArtifact(qaArtifactRootForDispatch(worktree.worktreePath, dispatch.workItem.id, dispatch), gate);
598
+ if (gateArtifact) {
599
+ await emitQaGateAccepted(context.emit, worktree.worktreePath, gateArtifact, nextQaGate(gate));
600
+ }
601
+ }
602
+ const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId);
603
+ if (!qaArtifact) {
604
+ throw new Error("Deterministic QA did not produce the complete durable QA artifact bundle.");
605
+ }
387
606
  await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
388
- await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
607
+ await emitQaStructuredEvents(context.emit, qaArtifact);
389
608
  await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
390
609
  return {
391
610
  summary: missingLaunchTarget ? "forge-bridge QA blocked." : "forge-bridge QA passed.",
@@ -395,22 +614,30 @@ export class DeterministicProviderCliService {
395
614
  }
396
615
  async runConflictResolve(context) {
397
616
  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)}`]);
617
+ const addressComments = isPullRequestCommentsDispatch(dispatch);
618
+ 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
619
  await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
400
620
  await context.emit({
401
621
  role: "assistant",
402
622
  kind: "message",
403
- content: `Deterministic conflict resolve completed for ${worktree.branchName}.`,
404
- eventType: "conflict_resolve_complete"
623
+ content: addressComments
624
+ ? `Deterministic PR comment addressing completed for ${worktree.branchName}.`
625
+ : `Deterministic conflict resolve completed for ${worktree.branchName}.`,
626
+ eventType: addressComments ? "pr_comments_complete" : "conflict_resolve_complete"
405
627
  });
406
628
  return {
407
- summary: `forge-bridge resolved conflicts for ${worktree.branchName}.`
629
+ summary: addressComments
630
+ ? `forge-bridge addressed PR comments for ${worktree.branchName}.`
631
+ : `forge-bridge resolved conflicts for ${worktree.branchName}.`
408
632
  };
409
633
  }
410
634
  }
411
635
  function phaseCompleteEventType(phase) {
412
636
  return phase === "implementation" ? "implementation_complete" : `${phase}_complete`;
413
637
  }
638
+ function isDebugCompletionPhaseForProvider(phase) {
639
+ return debugCompletionPhaseOrder.includes(phase);
640
+ }
414
641
  function deterministicCompletionPayload(phase, dispatch, changesetBody) {
415
642
  const changesetHash = sha256(changesetBody);
416
643
  const criterion = dispatch.workItem.acceptanceCriteria || "Deterministic bridge changeset exists";
@@ -433,6 +660,56 @@ function deterministicCompletionPayload(phase, dispatch, changesetBody) {
433
660
  next_phase_context: "Review FORGE_PHASE5_CHANGESET.md and the bridge commit before validation."
434
661
  };
435
662
  }
663
+ if (phase === "debug_repro") {
664
+ return {
665
+ status: "pass",
666
+ summary: "forge-bridge reproduced the bug deterministically.",
667
+ repro_steps: ["Run the deterministic fake-worker debug reproduction."],
668
+ observed: "The configured bug scenario is reproducible in deterministic bridge mode.",
669
+ expected: "The bug scenario should be fixed before QA handoff.",
670
+ evidence: [{ kind: "changeset", path: "FORGE_PHASE5_CHANGESET.md", content_hash: changesetHash }]
671
+ };
672
+ }
673
+ if (phase === "debug_root_cause") {
674
+ return {
675
+ status: "pass",
676
+ summary: "forge-bridge isolated a deterministic root cause.",
677
+ root_cause: "The deterministic fake-worker bug is represented by the generated changeset evidence.",
678
+ evidence: [{ kind: "changeset", path: "FORGE_PHASE5_CHANGESET.md", content_hash: changesetHash }],
679
+ eliminated_alternatives: [{ summary: "No alternate deterministic fake-worker path was implicated." }],
680
+ risk_area: "forge-bridge deterministic debug lane"
681
+ };
682
+ }
683
+ if (phase === "debug_fix") {
684
+ return {
685
+ status: "pass",
686
+ summary: "forge-bridge applied a deterministic debug fix.",
687
+ changed_files: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Recorded deterministic debug fix evidence." }],
688
+ tests_added_or_updated: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Deterministic debug evidence doubles as fake-worker regression coverage." }],
689
+ remaining_risks: [],
690
+ resolved_findings: (dispatch.qaRework?.findingIds ?? []).map((qaId) => ({
691
+ qa_id: qaId,
692
+ status: "resolved",
693
+ summary: `Resolved deterministic QA finding ${qaId}.`,
694
+ evidence: { path: "FORGE_PHASE5_CHANGESET.md", content_hash: changesetHash },
695
+ changed_files: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Recorded deterministic debug fix evidence." }],
696
+ tests_added_or_updated: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Deterministic fake-worker regression evidence." }],
697
+ regression_evidence: [{ command: "forge-bridge deterministic debug regression", status: "pass" }]
698
+ }))
699
+ };
700
+ }
701
+ if (phase === "debug_regression") {
702
+ return {
703
+ status: "pass",
704
+ summary: "forge-bridge proved the deterministic debug regression is QA-ready.",
705
+ commands: [{ command: "forge-bridge deterministic debug regression", exit_code: 0, status: "pass", summary: "Deterministic debug regression completed." }],
706
+ required_regressions: ["Original deterministic repro no longer fails."],
707
+ build_status: "not_applicable",
708
+ test_status: "pass",
709
+ repro_negative: true,
710
+ qa_ready: true
711
+ };
712
+ }
436
713
  if (phase === "self_review") {
437
714
  return {
438
715
  status: "pass",
@@ -493,6 +770,149 @@ function deterministicCompletionPayload(phase, dispatch, changesetBody) {
493
770
  ready_for_dev_test: true
494
771
  };
495
772
  }
773
+ function deterministicArchitectPayload(dispatch) {
774
+ const planId = `architect-${dispatch.workItem.id.replace(/-/g, "")}`;
775
+ return {
776
+ status: "pass",
777
+ summary: "forge-bridge produced a deterministic Architect plan.",
778
+ implementation_plan: {
779
+ schema_version: 1,
780
+ plan_id: planId,
781
+ story_id: dispatch.workItem.id,
782
+ phases: [
783
+ { id: "phase-1", name: "Foundation", tasks: ["task-1"], gate: "phase-1-gate" }
784
+ ],
785
+ tasks: [
786
+ {
787
+ id: "task-1",
788
+ title: `Implement ${dispatch.workItem.title}`,
789
+ phase: "phase-1",
790
+ summary: "Deterministic bridge Architect task proving MCP artifact contract persistence.",
791
+ acceptance_criteria: [dispatch.workItem.acceptanceCriteria || `Preserve story intent for ${dispatch.workItem.title}`],
792
+ tests: [
793
+ { command: "dotnet test /home/dillon/repos/Forge/Forge.sln --filter Architect", purpose: "Architect bridge contract regression" }
794
+ ]
795
+ }
796
+ ],
797
+ dependencies: [],
798
+ phase_gates: [
799
+ { id: "phase-1-gate", phase: "phase-1", checks: ["Task tests pass", "Artifacts validate"] }
800
+ ]
801
+ },
802
+ qa_plan: {
803
+ schema_version: 1,
804
+ plan_id: planId,
805
+ final_qa: [
806
+ { id: "qa-1", coverage: `Verify Architect plan artifacts and pending approval status for ${dispatch.workItem.title}` }
807
+ ]
808
+ },
809
+ 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`
810
+ };
811
+ }
812
+ function deterministicQaGatePayload(options) {
813
+ const blocked = options.missingLaunchTarget;
814
+ const skippedBecauseBlocked = blocked
815
+ ? {
816
+ status: "skipped",
817
+ summary: "Skipped because browser QA was blocked by missing launch target.",
818
+ reason: "Upstream browser QA blocker prevents meaningful downstream evidence."
819
+ }
820
+ : null;
821
+ switch (options.gate) {
822
+ case "qa_plan":
823
+ return {
824
+ status: "pass",
825
+ summary: "Deterministic QA plan recorded.",
826
+ checklist: [
827
+ "Review the validated work item against acceptance criteria.",
828
+ "Run deterministic regression evidence.",
829
+ "Confirm no in-scope QA findings remain."
830
+ ],
831
+ acceptance_criteria: [options.criterion]
832
+ };
833
+ case "qa_regression":
834
+ return {
835
+ status: "pass",
836
+ summary: "Deterministic regression QA completed.",
837
+ commands: [
838
+ {
839
+ command: "forge-bridge deterministic QA",
840
+ exit_code: 0,
841
+ status: "pass",
842
+ summary: "Deterministic regression evidence completed."
843
+ }
844
+ ],
845
+ notes: "Regression gate passed in deterministic fake-worker mode."
846
+ };
847
+ case "qa_api":
848
+ return {
849
+ status: "skipped",
850
+ summary: "No API/backend surface was identified for deterministic QA.",
851
+ reason: "The deterministic fake-worker changeset does not exercise API/backend behavior."
852
+ };
853
+ case "qa_manual_web":
854
+ return blocked
855
+ ? {
856
+ status: "blocked",
857
+ summary: "Browser QA could not run because no app launch target was provided.",
858
+ blocker_reason: "missing_app_launch_target"
859
+ }
860
+ : options.browserChecks.length > 0
861
+ ? {
862
+ status: "pass",
863
+ summary: "Deterministic browser QA exercised the configured launch target.",
864
+ browser_checks: options.browserChecks,
865
+ sidecars: options.sidecars,
866
+ notes: "Browser sidecar evidence recorded."
867
+ }
868
+ : {
869
+ status: "skipped",
870
+ summary: "Manual web QA was not required for this deterministic run.",
871
+ reason: "Forge did not require browser QA and no launch target was necessary."
872
+ };
873
+ case "qa_bug_repro":
874
+ return skippedBecauseBlocked ?? {
875
+ status: "skipped",
876
+ summary: "No failed QA observation required bug reproduction.",
877
+ reason: "No defects or candidate bugs were observed."
878
+ };
879
+ case "qa_findings":
880
+ return skippedBecauseBlocked ?? {
881
+ status: "pass",
882
+ summary: "No QA findings or candidate bugs were identified.",
883
+ findings: [],
884
+ candidate_bugs: []
885
+ };
886
+ case "qa_verdict":
887
+ return {
888
+ status: blocked ? "blocked" : "pass",
889
+ summary: options.summary,
890
+ checklist: [
891
+ "Review the validated work item against acceptance criteria.",
892
+ "Run deterministic regression evidence.",
893
+ blocked ? "Record missing browser launch target blocker." : "Confirm no in-scope QA findings remain."
894
+ ],
895
+ tested_acceptance_criteria: blocked ? [] : [options.criterion],
896
+ commands: blocked
897
+ ? []
898
+ : [
899
+ {
900
+ command: "forge-bridge deterministic QA",
901
+ exit_code: 0,
902
+ status: "pass",
903
+ summary: "Deterministic QA command completed."
904
+ }
905
+ ],
906
+ browser_checks: blocked ? [] : options.browserChecks,
907
+ sidecars: blocked ? [] : options.sidecars,
908
+ notes: options.summary,
909
+ findings: [],
910
+ candidate_bugs: [],
911
+ blocker_reason: blocked ? "missing_app_launch_target" : null,
912
+ report_markdown: `# QA report\n\n${options.summary}\n`
913
+ };
914
+ }
915
+ }
496
916
  const qaArtifactFileNames = {
497
917
  plan: "qa.plan.json",
498
918
  evidence: "qa.evidence.json",
@@ -500,16 +920,25 @@ const qaArtifactFileNames = {
500
920
  verdict: "qa.verdict.json",
501
921
  report: "qa.report.md"
502
922
  };
923
+ const qaGateArtifactFileNames = {
924
+ qa_plan: "qa.plan.json",
925
+ qa_regression: "qa.regression.json",
926
+ qa_api: "qa.api.json",
927
+ qa_manual_web: "qa.manual-web.json",
928
+ qa_bug_repro: "qa.bug-repro.json",
929
+ qa_findings: "qa.findings.json",
930
+ qa_verdict: "qa.verdict.json"
931
+ };
503
932
  function hasLaunchTarget(target) {
504
933
  return Boolean(target?.url?.trim()
505
934
  || target?.ready_url?.trim()
506
935
  || target?.command?.trim()
507
936
  || (target?.routes ?? []).some((route) => route.trim().length > 0));
508
937
  }
509
- function deterministicBrowserSidecar(workItemId, target) {
938
+ function deterministicBrowserSidecar(workItemId, target, ownerType, ownerId) {
510
939
  const route = primaryLaunchRoute(target);
511
940
  return {
512
- relativePath: qaArtifactRelativePath(workItemId, "qa/screenshots/deterministic-browser-check.txt"),
941
+ relativePath: qaArtifactRelativePath(workItemId, "qa/screenshots/deterministic-browser-check.txt", ownerType, ownerId),
513
942
  content: `Deterministic Forge QA browser evidence\nRoute: ${route}\nConsole errors: []\nNetwork errors: []\nResponsive checks: desktop, mobile\n`
514
943
  };
515
944
  }
@@ -551,78 +980,6 @@ async function writeQaSidecarFiles(worktreePath, files) {
551
980
  }
552
981
  return sidecars;
553
982
  }
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
983
  async function emitQaArtifactRecorded(emit, worktreePath, artifact) {
627
984
  const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
628
985
  await emit({
@@ -638,16 +995,29 @@ async function emitQaArtifactRecorded(emit, worktreePath, artifact) {
638
995
  eventType: "phase_gate"
639
996
  });
640
997
  }
641
- async function readQaCompletionArtifactBundle(worktreePath, workItemId) {
642
- const artifact = await readCompletionArtifact(worktreePath, workItemId, "qa");
643
- if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId)) {
998
+ async function readQaCompletionArtifactBundle(worktreePath, workItemId, ownerType, ownerId) {
999
+ const artifact = await readCompletionArtifact(worktreePath, workItemId, "qa", ownerType, ownerId);
1000
+ if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId, ownerType, ownerId)) {
644
1001
  return null;
645
1002
  }
646
- await validateQaBundleSidecarReferences(worktreePath, workItemId);
647
- return artifact;
1003
+ const artifactRoot = ownerType && ownerId
1004
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
1005
+ : artifactRootFor(worktreePath, workItemId);
1006
+ const planJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.plan), "utf8");
1007
+ const evidenceJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.evidence), "utf8");
1008
+ const findingsJson = await fs.readFile(path.join(artifactRoot, qaArtifactFileNames.findings), "utf8");
1009
+ await validateQaBundleSidecarReferences(worktreePath, workItemId, ownerType, ownerId);
1010
+ return {
1011
+ ...artifact,
1012
+ planJson,
1013
+ evidenceJson,
1014
+ findingsJson
1015
+ };
648
1016
  }
649
- async function hasCompleteQaArtifactBundle(worktreePath, workItemId) {
650
- const artifactRoot = artifactRootFor(worktreePath, workItemId);
1017
+ async function hasCompleteQaArtifactBundle(worktreePath, workItemId, ownerType, ownerId) {
1018
+ const artifactRoot = ownerType && ownerId
1019
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
1020
+ : artifactRootFor(worktreePath, workItemId);
651
1021
  for (const fileName of Object.values(qaArtifactFileNames)) {
652
1022
  try {
653
1023
  const stat = await fs.stat(path.join(artifactRoot, fileName));
@@ -676,8 +1046,10 @@ function enforceQaBrowserLaunchTargetGate(dispatch, artifact) {
676
1046
  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
1047
  }
678
1048
  }
679
- async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
680
- const artifactRoot = artifactRootFor(worktreePath, workItemId);
1049
+ async function validateQaBundleSidecarReferences(worktreePath, workItemId, ownerType, ownerId) {
1050
+ const artifactRoot = ownerType && ownerId
1051
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
1052
+ : artifactRootFor(worktreePath, workItemId);
681
1053
  const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
682
1054
  const sidecars = arrayField(evidence, "sidecars");
683
1055
  if (sidecars.length === 0) {
@@ -696,7 +1068,9 @@ async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
696
1068
  }
697
1069
  const fullPath = path.resolve(worktreePath, sidecarPath);
698
1070
  if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
699
- throw new Error(`QA sidecar ${index} path must be under .forge/work-items/${workItemId}/artifacts/qa/.`);
1071
+ const ownerDirectory = ownerType && ownerId && ownerType.trim().toLowerCase() === "story" ? "stories" : "work-items";
1072
+ const ownerSegment = ownerType && ownerId ? ownerId : workItemId;
1073
+ throw new Error(`QA sidecar ${index} path must be under .forge/${ownerDirectory}/${ownerSegment}/artifacts/qa/.`);
700
1074
  }
701
1075
  const stat = await fs.lstat(fullPath).catch((error) => {
702
1076
  if (isNodeError(error) && error.code === "ENOENT") {
@@ -717,8 +1091,8 @@ async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
717
1091
  }
718
1092
  }
719
1093
  }
720
- async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact) {
721
- const artifactRoot = artifactRootFor(worktreePath, workItemId);
1094
+ async function emitQaStructuredEvents(emit, artifact) {
1095
+ const artifactRoot = path.dirname(artifact.path);
722
1096
  const plan = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.plan));
723
1097
  const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
724
1098
  const findings = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.findings));
@@ -758,12 +1132,76 @@ async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact)
758
1132
  eventType: status === "blocked" ? "qa_blocked" : "qa_complete"
759
1133
  });
760
1134
  }
1135
+ function qaArtifactRootForDispatch(worktreePath, workItemId, dispatch) {
1136
+ return dispatch.cycleRun.ownerType && dispatch.cycleRun.ownerId
1137
+ ? artifactRootForOwner(worktreePath, dispatch.cycleRun.ownerType, dispatch.cycleRun.ownerId)
1138
+ : artifactRootFor(worktreePath, workItemId);
1139
+ }
1140
+ async function readQaGateArtifact(artifactRoot, gate) {
1141
+ const artifactPath = path.join(artifactRoot, qaGateArtifactFileNames[gate]);
1142
+ try {
1143
+ const stat = await fs.stat(artifactPath);
1144
+ if (!stat.isFile()) {
1145
+ return null;
1146
+ }
1147
+ }
1148
+ catch (error) {
1149
+ if (isNodeError(error) && error.code === "ENOENT") {
1150
+ return null;
1151
+ }
1152
+ throw error;
1153
+ }
1154
+ const payload = await readJsonObjectFile(artifactPath);
1155
+ return {
1156
+ gate,
1157
+ path: artifactPath,
1158
+ status: stringField(payload, "status") ?? "unknown",
1159
+ producedBy: stringField(payload, "produced_by") ?? stringField(payload, "producedBy") ?? undefined,
1160
+ contentHash: stringField(payload, "content_hash") ?? undefined
1161
+ };
1162
+ }
1163
+ function nextQaGate(gate) {
1164
+ const index = qaCompletionGateOrder.indexOf(gate);
1165
+ return index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
1166
+ }
1167
+ async function emitQaGateAccepted(emit, worktreePath, artifact, nextGate) {
1168
+ const relativePath = path.relative(worktreePath, artifact.path).split(path.sep).join("/");
1169
+ await emit({
1170
+ role: "hook",
1171
+ kind: artifact.status === "blocked" ? "error" : "checkpoint",
1172
+ content: `phase_gate: ${artifact.gate} artifact accepted with status ${artifact.status}.`,
1173
+ eventType: "phase_gate",
1174
+ data: JSON.stringify({
1175
+ phase: "qa",
1176
+ qa_gate: artifact.gate,
1177
+ status: artifact.status,
1178
+ artifact_relative_paths: [relativePath],
1179
+ content_hash: artifact.contentHash ?? null,
1180
+ produced_by: artifact.producedBy ?? null
1181
+ })
1182
+ });
1183
+ if (!nextGate) {
1184
+ return;
1185
+ }
1186
+ await emit({
1187
+ role: "hook",
1188
+ kind: "checkpoint",
1189
+ content: `phase_progression: ${artifact.gate} -> ${nextGate}`,
1190
+ eventType: "phase_gate",
1191
+ data: JSON.stringify({
1192
+ phase: "qa",
1193
+ from_gate: artifact.gate,
1194
+ to_gate: nextGate
1195
+ })
1196
+ });
1197
+ }
761
1198
  class LiveQaArtifactEmitter {
762
1199
  worktreePath;
763
1200
  workItemId;
764
1201
  dispatch;
765
1202
  emit;
766
- emitted = false;
1203
+ emittedBundle = false;
1204
+ emittedGates = new Set();
767
1205
  timer = null;
768
1206
  pending = Promise.resolve();
769
1207
  constructor(worktreePath, workItemId, dispatch, emit) {
@@ -786,7 +1224,7 @@ class LiveQaArtifactEmitter {
786
1224
  await this.pollQueued();
787
1225
  }
788
1226
  hasEmitted() {
789
- return this.emitted;
1227
+ return this.emittedBundle;
790
1228
  }
791
1229
  pollQueued() {
792
1230
  this.pending = this.pending
@@ -795,19 +1233,125 @@ class LiveQaArtifactEmitter {
795
1233
  return this.pending;
796
1234
  }
797
1235
  async poll() {
798
- if (this.emitted) {
1236
+ await this.emitAvailableGateEvents();
1237
+ if (this.emittedBundle) {
799
1238
  return;
800
1239
  }
801
- const artifact = await readQaCompletionArtifactBundle(this.worktreePath, this.workItemId);
1240
+ const artifact = await readQaCompletionArtifactBundle(this.worktreePath, this.workItemId, this.dispatch.cycleRun.ownerType, this.dispatch.cycleRun.ownerId);
802
1241
  if (!artifact) {
803
1242
  return;
804
1243
  }
805
1244
  enforceQaBrowserLaunchTargetGate(this.dispatch, artifact);
806
- this.emitted = true;
1245
+ this.emittedBundle = true;
807
1246
  await emitQaArtifactRecorded(this.emit, this.worktreePath, artifact);
808
- await emitQaStructuredEvents(this.emit, this.worktreePath, this.workItemId, artifact);
1247
+ await emitQaStructuredEvents(this.emit, artifact);
1248
+ }
1249
+ async emitAvailableGateEvents() {
1250
+ const artifactRoot = qaArtifactRootForDispatch(this.worktreePath, this.workItemId, this.dispatch);
1251
+ for (const gate of qaCompletionGateOrder) {
1252
+ if (this.emittedGates.has(gate)) {
1253
+ continue;
1254
+ }
1255
+ const artifact = await readQaGateArtifact(artifactRoot, gate);
1256
+ if (!artifact) {
1257
+ return;
1258
+ }
1259
+ this.emittedGates.add(gate);
1260
+ await emitQaGateAccepted(this.emit, this.worktreePath, artifact, nextQaGate(gate));
1261
+ }
809
1262
  }
810
1263
  }
1264
+ class LiveArchitectArtifactEmitter {
1265
+ worktreePath;
1266
+ workItemId;
1267
+ emit;
1268
+ emitted = false;
1269
+ timer = null;
1270
+ pending = Promise.resolve();
1271
+ constructor(worktreePath, workItemId, emit) {
1272
+ this.worktreePath = worktreePath;
1273
+ this.workItemId = workItemId;
1274
+ this.emit = emit;
1275
+ }
1276
+ start() {
1277
+ void this.pollQueued();
1278
+ this.timer = setInterval(() => {
1279
+ void this.pollQueued();
1280
+ }, 250);
1281
+ }
1282
+ async stop() {
1283
+ if (this.timer) {
1284
+ clearInterval(this.timer);
1285
+ this.timer = null;
1286
+ }
1287
+ await this.pollQueued();
1288
+ }
1289
+ hasEmitted() {
1290
+ return this.emitted;
1291
+ }
1292
+ pollQueued() {
1293
+ this.pending = this.pending
1294
+ .then(() => this.poll())
1295
+ .catch(() => undefined);
1296
+ return this.pending;
1297
+ }
1298
+ async poll() {
1299
+ if (this.emitted || !await hasCompleteArchitectArtifactBundle(this.worktreePath, this.workItemId)) {
1300
+ return;
1301
+ }
1302
+ const artifact = await readCompletionArtifact(this.worktreePath, this.workItemId, "architect");
1303
+ if (!artifact) {
1304
+ return;
1305
+ }
1306
+ this.emitted = true;
1307
+ await emitCompletionArtifactRecorded(this.emit, this.worktreePath, artifact);
1308
+ await emitArchitectStructuredEvents(this.emit, this.worktreePath, this.workItemId, artifact);
1309
+ }
1310
+ }
1311
+ async function hasCompleteArchitectArtifactBundle(worktreePath, workItemId) {
1312
+ const artifactRoot = artifactRootFor(worktreePath, workItemId);
1313
+ for (const fileName of ["implementation.plan.json", "qa.plan.json", "architect.verdict.json", "architect.report.md"]) {
1314
+ try {
1315
+ const stat = await fs.stat(path.join(artifactRoot, fileName));
1316
+ if (!stat.isFile()) {
1317
+ return false;
1318
+ }
1319
+ }
1320
+ catch (error) {
1321
+ if (isNodeError(error) && error.code === "ENOENT") {
1322
+ return false;
1323
+ }
1324
+ throw error;
1325
+ }
1326
+ }
1327
+ return true;
1328
+ }
1329
+ async function emitArchitectStructuredEvents(emit, worktreePath, workItemId, artifact) {
1330
+ const artifactRoot = artifactRootFor(worktreePath, workItemId);
1331
+ const implementationPlan = await readJsonObjectFile(path.join(artifactRoot, "implementation.plan.json"));
1332
+ const qaPlan = await readJsonObjectFile(path.join(artifactRoot, "qa.plan.json"));
1333
+ const verdict = parseJsonLine(artifact.verdictJson) ?? {};
1334
+ const status = stringField(verdict, "status") ?? artifact.status ?? "unknown";
1335
+ const planId = stringField(implementationPlan, "plan_id") ?? "unknown";
1336
+ await emit({
1337
+ role: "worker",
1338
+ kind: "checkpoint",
1339
+ content: `Architect plan recorded: ${planId} with ${arrayField(implementationPlan, "phases").length} phases and ${arrayField(implementationPlan, "tasks").length} tasks.`,
1340
+ eventType: "architect_plan"
1341
+ });
1342
+ await emit({
1343
+ role: "worker",
1344
+ kind: "checkpoint",
1345
+ content: `Architect QA seed recorded with ${arrayField(qaPlan, "final_qa").length} final QA items.`,
1346
+ eventType: "architect_qa_seed"
1347
+ });
1348
+ await emit({
1349
+ role: "hook",
1350
+ kind: status === "pass" ? "checkpoint" : "error",
1351
+ content: `architect_mcp_complete: ${status === "pass" ? "accepted" : "recorded"} - ${stringField(verdict, "summary") ?? "Architect completion payload recorded."}`,
1352
+ eventType: "architect_mcp_complete"
1353
+ });
1354
+ }
811
1355
  async function readJsonObjectFile(filePath) {
812
1356
  const content = await fs.readFile(filePath, "utf8").catch(() => "");
813
1357
  return parseJsonLine(content) ?? {};
@@ -832,19 +1376,36 @@ function summarizeQaObject(source) {
832
1376
  ?? stringField(source, "id")
833
1377
  ?? JSON.stringify(source), 500);
834
1378
  }
835
- function qaArtifactRelativePath(workItemId, fileName) {
1379
+ function qaArtifactRelativePath(workItemId, fileName, ownerType, ownerId) {
1380
+ if (ownerType && ownerId) {
1381
+ const ownerDirectory = ownerType.trim().toLowerCase() === "story" ? "stories" : "work-items";
1382
+ return [".forge", ownerDirectory, ownerId, "artifacts", fileName].join("/");
1383
+ }
836
1384
  return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
837
1385
  }
838
- function jsonText(value) {
839
- return `${JSON.stringify(value, null, 2)}\n`;
840
- }
841
1386
  async function emitCompletionArtifactRecorded(emit, worktreePath, artifact) {
842
1387
  const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
1388
+ await emit({
1389
+ role: "tool",
1390
+ kind: "checkpoint",
1391
+ content: `Forge completion tool validation passed for ${artifact.phase ?? "unknown"} (${artifact.status}).`,
1392
+ eventType: "completion_tool_validation_passed",
1393
+ data: JSON.stringify({
1394
+ phase: artifact.phase ?? "unknown",
1395
+ status: artifact.status,
1396
+ artifact_relative_paths: [relativePath.split(path.sep).join("/")]
1397
+ })
1398
+ });
843
1399
  await emit({
844
1400
  role: "tool",
845
1401
  kind: "tool_result",
846
1402
  content: `Forge completion tool recorded ${artifact.phase ?? "unknown"} artifact ${relativePath} (${artifact.status}).`,
847
- eventType: "completion_tool_artifact_recorded"
1403
+ eventType: "completion_tool_artifact_recorded",
1404
+ data: JSON.stringify({
1405
+ phase: artifact.phase ?? "unknown",
1406
+ status: artifact.status,
1407
+ artifact_relative_paths: [relativePath.split(path.sep).join("/")]
1408
+ })
848
1409
  });
849
1410
  }
850
1411
  async function emitPhaseCompletionEvent(emit, artifact) {
@@ -872,7 +1433,15 @@ async function emitPhaseGateEvents(emit, artifact) {
872
1433
  ? "phase_progression: implementation -> self_review"
873
1434
  : phase === "self_review"
874
1435
  ? "phase_progression: self_review -> validation"
875
- : "phase_progression: validation -> QA ready";
1436
+ : phase === "debug_repro"
1437
+ ? "phase_progression: debug_repro -> debug_root_cause"
1438
+ : phase === "debug_root_cause"
1439
+ ? "phase_progression: debug_root_cause -> debug_fix"
1440
+ : phase === "debug_fix"
1441
+ ? "phase_progression: debug_fix -> debug_regression"
1442
+ : phase === "debug_regression"
1443
+ ? "phase_progression: debug_regression -> QA ready"
1444
+ : "phase_progression: validation -> QA ready";
876
1445
  await emit({
877
1446
  role: "hook",
878
1447
  kind: "checkpoint",
@@ -883,6 +1452,7 @@ async function emitPhaseGateEvents(emit, artifact) {
883
1452
  function completionPhaseFromArtifact(artifact) {
884
1453
  const phase = artifact.phase?.trim();
885
1454
  return phase === "self_review" || phase === "validation" || phase === "implementation" || phase === "qa"
1455
+ || phase === "architect" || phase === "debug_repro" || phase === "debug_root_cause" || phase === "debug_fix" || phase === "debug_regression"
886
1456
  ? phase
887
1457
  : "implementation";
888
1458
  }
@@ -894,13 +1464,18 @@ class LiveCompletionArtifactEmitter {
894
1464
  emitted = new Set();
895
1465
  timer = null;
896
1466
  pending = Promise.resolve();
897
- constructor(worktreePath, workItemId, phase, developCycle, emit) {
1467
+ constructor(worktreePath, workItemId, phase, developCycle, debugCycle, emit) {
898
1468
  this.worktreePath = worktreePath;
899
1469
  this.workItemId = workItemId;
900
1470
  this.emit = emit;
901
- this.phases = developCycle
902
- ? developCyclePhaseOrder.slice(developCyclePhaseOrder.indexOf(phase))
903
- : [phase];
1471
+ if (debugCycle && isDebugCompletionPhaseForProvider(phase)) {
1472
+ this.phases = debugCompletionPhaseOrder.slice(debugCompletionPhaseOrder.indexOf(phase));
1473
+ }
1474
+ else {
1475
+ this.phases = developCycle
1476
+ ? developCyclePhaseOrder.slice(developCyclePhaseOrder.indexOf(phase))
1477
+ : [phase];
1478
+ }
904
1479
  }
905
1480
  start() {
906
1481
  void this.pollQueued();
@@ -943,20 +1518,23 @@ class LiveCompletionArtifactEmitter {
943
1518
  }
944
1519
  }
945
1520
  }
946
- async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, developCycle) {
947
- if (!developCycle) {
1521
+ async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, developCycle, debugCycle) {
1522
+ if (!developCycle && !debugCycle) {
948
1523
  const artifact = await readCompletionArtifact(worktreePath, workItemId, phase);
949
1524
  if (!artifact) {
950
1525
  throw new Error(`Provider CLI completed but did not call the Forge ${phase} completion tool.`);
951
1526
  }
952
1527
  return [artifact];
953
1528
  }
1529
+ const expectedPhases = debugCycle && isDebugCompletionPhaseForProvider(phase)
1530
+ ? debugCompletionPhaseOrder.slice(debugCompletionPhaseOrder.indexOf(phase))
1531
+ : ["implementation", "self_review", "validation"];
954
1532
  const artifacts = [];
955
- for (const expectedPhase of ["implementation", "self_review", "validation"]) {
1533
+ for (const expectedPhase of expectedPhases) {
956
1534
  const artifact = await readCompletionArtifact(worktreePath, workItemId, expectedPhase);
957
1535
  if (!artifact) {
958
1536
  if (artifacts.length === 0) {
959
- throw new Error("Provider CLI completed but did not call the Forge implementation completion tool.");
1537
+ throw new Error(`Provider CLI completed but did not call the Forge ${expectedPhase} completion tool.`);
960
1538
  }
961
1539
  const previous = artifacts[artifacts.length - 1];
962
1540
  if ((previous.status ?? "").trim().toLowerCase() === "pass") {
@@ -971,10 +1549,24 @@ async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, de
971
1549
  }
972
1550
  return artifacts;
973
1551
  }
974
- function providerRedactionEnv(dispatch) {
975
- const env = completionRedactionEnv(exactRedactionSecrets(dispatch));
1552
+ function providerProcessEnv(dispatch) {
1553
+ const env = {
1554
+ ...completionRedactionEnv(exactRedactionSecrets(dispatch)),
1555
+ ...providerCredentialEnv(dispatch)
1556
+ };
976
1557
  return Object.keys(env).length > 0 ? env : undefined;
977
1558
  }
1559
+ function providerCredentialEnv(dispatch) {
1560
+ const secret = dispatch.apiKeySecretGrant?.secret;
1561
+ if (!secret || !dispatch.execution.requiresApiKeySecretGrant) {
1562
+ return {};
1563
+ }
1564
+ const provider = dispatch.execution.provider.trim().toLowerCase();
1565
+ if (provider === "codex" || provider === "openai") {
1566
+ return { OPENAI_API_KEY: secret };
1567
+ }
1568
+ return {};
1569
+ }
978
1570
  function exactRedactionSecrets(dispatch) {
979
1571
  return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
980
1572
  }
@@ -997,7 +1589,7 @@ export class StreamingProviderProcessRunner {
997
1589
  return await new Promise((resolve, reject) => {
998
1590
  const child = spawn(launch.command, launch.args, {
999
1591
  cwd: request.cwd,
1000
- env: request.env ? { ...process.env, ...request.env } : undefined,
1592
+ env: buildProviderChildEnvironment(request.env),
1001
1593
  stdio: ["ignore", "pipe", "pipe"],
1002
1594
  windowsHide: true
1003
1595
  });
@@ -1132,9 +1724,6 @@ function quoteWindowsCommandLineArg(value) {
1132
1724
  }
1133
1725
  return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
1134
1726
  }
1135
- async function fileExists(inputPath) {
1136
- return fs.stat(inputPath).then((stat) => stat.isFile()).catch(() => false);
1137
- }
1138
1727
  async function readProcessLines(stream, onLine) {
1139
1728
  const reader = readline.createInterface({ input: stream });
1140
1729
  try {
@@ -1155,9 +1744,6 @@ function sha256(content) {
1155
1744
  export function formatProviderCliCommand(command) {
1156
1745
  return [command.command, ...command.args].map(singleLine).join(" ");
1157
1746
  }
1158
- function yamlQuotedScalar(value) {
1159
- return JSON.stringify(singleLine(value));
1160
- }
1161
1747
  function singleLine(value) {
1162
1748
  return value.replace(/\s+/g, " ").trim();
1163
1749
  }
@@ -1166,13 +1752,13 @@ export function buildProviderCliCommand(dispatch, run) {
1166
1752
  const normalizedProvider = provider.toLowerCase();
1167
1753
  const model = dispatch.execution.model.trim();
1168
1754
  if (normalizedProvider === "codex") {
1169
- const args = ["exec"];
1755
+ const args = run ? ["-s", "workspace-write", "-a", "never", "exec"] : ["exec"];
1170
1756
  if (run?.completionTools) {
1171
1757
  args.push(...codexMcpConfigArgs(run.completionTools));
1172
1758
  }
1173
1759
  args.push("--model", model);
1174
1760
  if (run) {
1175
- args.push("--json", "--dangerously-bypass-approvals-and-sandbox");
1761
+ args.push("--json");
1176
1762
  if (run.worktreePath) {
1177
1763
  args.push("--cd", run.worktreePath);
1178
1764
  }
@@ -1196,6 +1782,31 @@ export function buildProviderCliCommand(dispatch, run) {
1196
1782
  }
1197
1783
  return { command: provider, args };
1198
1784
  }
1785
+ function buildArchitectPrompt(dispatch) {
1786
+ return `You are Forge's Architect lane running locally in a prepared git worktree.
1787
+
1788
+ Work item: ${dispatch.workItem.id} — ${dispatch.workItem.title}
1789
+
1790
+ Plan only. Do not implement source changes, do not create Develop phase artifacts, and do not mark the spec approved.
1791
+ 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.
1792
+ ${architectRevisionInstruction(dispatch)}
1793
+ Forge owns protocol files: write Architect artifacts only through the completion MCP tool.
1794
+
1795
+ ${completionPromptForPhase("architect")}
1796
+
1797
+ Finish only after calling forge.complete_architect_plan with implementation_plan, qa_plan, status, summary, and report/report_markdown.`;
1798
+ }
1799
+ function architectRevisionInstruction(dispatch) {
1800
+ if (!dispatch.architectRevision) {
1801
+ return "";
1802
+ }
1803
+ return `
1804
+ Architect revision context:
1805
+ - Previous plan id: ${dispatch.architectRevision.priorPlanId}
1806
+ - Previous content hash: ${dispatch.architectRevision.priorContentHash}
1807
+ - Reviewer feedback: ${dispatch.architectRevision.feedback}
1808
+ Revise the prior plan in response to the feedback. Preserve accepted scope and only change what the feedback requires.`;
1809
+ }
1199
1810
  function buildImplementationPrompt(dispatch, phase = completionPhaseFromDispatch(dispatch)) {
1200
1811
  return `You are Forge's ${phase.replace(/_/g, " ")} phase runner running locally in a prepared git worktree.
1201
1812
 
@@ -1224,6 +1835,20 @@ ${completionPromptForDevelopCycle(phase)}
1224
1835
 
1225
1836
  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
1837
  }
1838
+ function buildDebugCyclePrompt(dispatch, phase = completionPhaseFromDispatch(dispatch)) {
1839
+ return `You are Forge's Debug lane running locally in a prepared git worktree for a bug work item.
1840
+
1841
+ 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.
1842
+ 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.
1843
+ ${debugQaReworkInstruction(dispatch)}
1844
+ Use the repository's existing conventions. Run focused tests when practical, especially the original repro and required regressions.
1845
+ Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, Debug verdict artifacts, reports, and commits.
1846
+ Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge Debug artifacts by hand.
1847
+
1848
+ ${completionPromptForDebugCycle(isDebugCompletionPhaseForProvider(phase) ? phase : "debug_repro")}
1849
+
1850
+ 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.`;
1851
+ }
1227
1852
  function qaReworkImplementationInstruction(dispatch) {
1228
1853
  if (!dispatch.qaRework) {
1229
1854
  return "";
@@ -1233,7 +1858,20 @@ QA rework context:
1233
1858
  - Attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
1234
1859
  - Rework packet: ${dispatch.qaRework.packetPath}
1235
1860
  - 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.`;
1861
+ 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.
1862
+ Use only these resolved_findings status values: resolved, disputed, or not_reproduced. Use resolved when the finding was fixed; do not use fixed.`;
1863
+ }
1864
+ function debugQaReworkInstruction(dispatch) {
1865
+ if (!dispatch.qaRework) {
1866
+ return "";
1867
+ }
1868
+ return `
1869
+ QA rework context for Debug:
1870
+ - Attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
1871
+ - Rework packet: ${dispatch.qaRework.packetPath}
1872
+ - Required QA finding IDs: ${dispatch.qaRework.findingIds.join(", ")}
1873
+ 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.
1874
+ Use only these resolved_findings status values: resolved, disputed, or not_reproduced. Use resolved when the finding was fixed; do not use fixed.`;
1237
1875
  }
1238
1876
  function qaReworkRetestInstruction(dispatch) {
1239
1877
  if (!dispatch.qaRework) {
@@ -1270,9 +1908,49 @@ function qaBrowserInstruction(dispatch) {
1270
1908
  const routes = launchTarget.routes.length > 0 ? launchTarget.routes.join(", ") : "not specified";
1271
1909
  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
1910
  }
1273
- function buildQaPrompt(dispatch) {
1911
+ function storyQaInstruction(dispatch) {
1912
+ const storyQa = dispatch.storyQa;
1913
+ if (!storyQa) {
1914
+ return "";
1915
+ }
1916
+ const childLines = storyQa.childTasks.map((child) => {
1917
+ const dependencies = child.dependencyWorkItemIds.length > 0
1918
+ ? child.dependencyWorkItemIds.join(", ")
1919
+ : "none";
1920
+ const validation = child.validationArtifact
1921
+ ? `${child.validationArtifact.status} (${child.validationArtifact.path || "artifact path unavailable"})`
1922
+ : "missing";
1923
+ const childQa = child.qaArtifact
1924
+ ? `${child.qaArtifact.status} (${child.qaArtifact.path || "artifact path unavailable"})`
1925
+ : "not present";
1926
+ return `- ${child.title} (${child.workItemId}, architect ${child.architectTaskId}, phase ${child.phaseId}/${child.phaseOrder}); deps: ${dependencies}; validation: ${validation}; child QA: ${childQa}`;
1927
+ }).join("\n");
1928
+ const finalQa = storyQa.finalQaChecks.length > 0
1929
+ ? storyQa.finalQaChecks.map((check) => `- ${check}`).join("\n")
1930
+ : "- No final QA checks were provided in qa.plan.final_qa.";
1931
+ return `Story QA integration context:
1932
+ - Story work item: ${storyQa.storyWorkItemId}
1933
+ - Plan: ${storyQa.planId} / ${storyQa.planContentHash}
1934
+ - Story worktree: ${storyQa.worktreeKey}
1935
+ - Branch: ${storyQa.branch}
1936
+ - Architect implementation.plan.json provided: ${storyQa.implementationPlanJson ? "yes" : "no"}
1937
+ - Architect qa.plan.json provided: ${storyQa.qaPlanJson ? "yes" : "no"}
1938
+ Materialized child tasks and evidence:
1939
+ ${childLines || "- No child tasks were provided."}
1940
+ Architect final QA checks:
1941
+ ${finalQa}
1942
+ 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.`;
1943
+ }
1944
+ function buildQaPrompt(dispatch, laneSpec) {
1274
1945
  return `You are Forge's QA lane running locally in a prepared git worktree.
1275
1946
 
1947
+ 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.
1948
+
1949
+ Injected QA lane command and work item spec:
1950
+ ${laneSpec}
1951
+
1952
+ ---
1953
+
1276
1954
  Work item: ${dispatch.workItem.id}
1277
1955
  Title: ${dispatch.workItem.title}
1278
1956
  Description:
@@ -1287,6 +1965,7 @@ ${dispatch.workItem.reproSteps || "Not provided."}
1287
1965
  System info / environment notes:
1288
1966
  ${dispatch.workItem.systemInfo || "Not provided."}
1289
1967
 
1968
+ ${storyQaInstruction(dispatch)}
1290
1969
  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
1970
  ${qaBrowserInstruction(dispatch)}
1292
1971
  ${qaReworkRetestInstruction(dispatch)}
@@ -1295,9 +1974,9 @@ Record concrete evidence. For UI/browser work, include browser_checks with URLs/
1295
1974
  Report in-scope defects as actionable findings with repro steps, expected/actual behavior, evidence, likely area, required fix, and required regression.
1296
1975
  Out-of-scope or follow-up issues belong in candidate_bugs instead of blocking the current item.
1297
1976
 
1298
- ${completionPromptForPhase("qa")}
1977
+ ${completionPromptForQaCycle()}
1299
1978
 
1300
- Finish with a concise summary only after forge.complete_qa accepts the QA bundle.`;
1979
+ Finish with a concise summary only after forge.complete_qa_verdict accepts the QA bundle.`;
1301
1980
  }
1302
1981
  function buildConflictResolvePrompt(dispatch, branchName) {
1303
1982
  const targetBranch = dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
@@ -1312,6 +1991,34 @@ Keep the original work item intent intact:
1312
1991
 
1313
1992
  Finish with a concise summary of the conflicts resolved and verification performed.`;
1314
1993
  }
1994
+ function buildPullRequestCommentsPrompt(dispatch, branchName) {
1995
+ const pr = dispatch.pullRequest;
1996
+ const targetBranch = pr?.targetBranch || dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
1997
+ const prLocator = pr
1998
+ ? `PR #${pr.number}${pr.url ? ` (${pr.url})` : ""}`
1999
+ : "the open pull request for this work item";
2000
+ return `You are Forge's Pull Request Comments lane running locally in a prepared git worktree.
2001
+
2002
+ The current branch is the pull request branch ${branchName}. Address unresolved review comments on ${prLocator}.
2003
+ PR context:
2004
+ - Provider: ${pr?.provider || "not provided"}
2005
+ - Source branch: ${pr?.sourceBranch || branchName}
2006
+ - Target branch: ${targetBranch}
2007
+ - Unresolved comments: ${pr?.unresolvedThreads ?? "unknown"}
2008
+
2009
+ 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.
2010
+ After the push succeeds, Forge will resolve the addressed review threads through the pull request API.
2011
+ If the review thread contents are unavailable, stop with a clear summary of what could not be accessed instead of guessing.
2012
+ Keep the original work item intent intact:
2013
+ - Work item: ${dispatch.workItem.id}
2014
+ - Title: ${dispatch.workItem.title}
2015
+ - Acceptance criteria: ${dispatch.workItem.acceptanceCriteria || "Not specified"}
2016
+
2017
+ Finish with a concise summary of comments addressed and verification performed.`;
2018
+ }
2019
+ function isPullRequestCommentsDispatch(dispatch) {
2020
+ return dispatch.cycleRun.kind.trim().toLowerCase() === "pr_comments";
2021
+ }
1315
2022
  async function cleanupCompletionTools(completionTools, emit) {
1316
2023
  try {
1317
2024
  await completionTools.cleanup();
@@ -1369,31 +2076,31 @@ class CursorStreamJsonState {
1369
2076
  consume(line) {
1370
2077
  const event = parseJsonLine(line);
1371
2078
  if (!event) {
1372
- return null;
2079
+ return [];
1373
2080
  }
1374
2081
  const type = stringField(event, "type");
1375
2082
  if (type === "system" && stringField(event, "subtype") === "init") {
1376
2083
  this.sessionId = stringField(event, "session_id");
1377
2084
  if (this.sessionId && !this.emittedSession) {
1378
2085
  this.emittedSession = true;
1379
- return {
1380
- role: "runner",
1381
- kind: "checkpoint",
1382
- content: `Cursor session started: ${this.sessionId}.`,
1383
- eventType: "provider_thread_started"
1384
- };
2086
+ return [{
2087
+ role: "runner",
2088
+ kind: "checkpoint",
2089
+ content: `Cursor session started: ${this.sessionId}.`,
2090
+ eventType: "provider_thread_started"
2091
+ }];
1385
2092
  }
1386
2093
  }
1387
2094
  if (type === "assistant") {
1388
2095
  const text = assistantMessageText(event);
1389
2096
  if (text) {
1390
2097
  this.finalMessage = clip(text, 4000);
1391
- return {
1392
- role: "assistant",
1393
- kind: "message",
1394
- content: this.finalMessage,
1395
- eventType: "provider_message"
1396
- };
2098
+ return [{
2099
+ role: "assistant",
2100
+ kind: "message",
2101
+ content: this.finalMessage,
2102
+ eventType: "provider_message"
2103
+ }];
1397
2104
  }
1398
2105
  }
1399
2106
  if (type === "tool_call") {
@@ -1402,23 +2109,23 @@ class CursorStreamJsonState {
1402
2109
  if (subtype === "started") {
1403
2110
  const summary = formatCursorToolCallStarted(toolCall);
1404
2111
  if (summary) {
1405
- return {
1406
- role: "worker",
1407
- kind: "checkpoint",
1408
- content: summary,
1409
- eventType: "provider_tool_call"
1410
- };
2112
+ return [{
2113
+ role: "worker",
2114
+ kind: "checkpoint",
2115
+ content: summary,
2116
+ eventType: "provider_tool_call"
2117
+ }];
1411
2118
  }
1412
2119
  }
1413
2120
  if (subtype === "completed") {
1414
2121
  const completed = formatCursorToolCallCompleted(toolCall);
1415
2122
  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
- };
2123
+ return [{
2124
+ role: "worker",
2125
+ kind: completed.kind,
2126
+ content: completed.content,
2127
+ eventType: completed.kind === "file_edit" ? "provider_file_edit" : "provider_tool_call"
2128
+ }];
1422
2129
  }
1423
2130
  }
1424
2131
  }
@@ -1430,14 +2137,14 @@ class CursorStreamJsonState {
1430
2137
  this.accumulateUsage(event);
1431
2138
  }
1432
2139
  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
- };
2140
+ return [{
2141
+ role: "runner",
2142
+ kind: "error",
2143
+ content: clip(line.trim(), 1000),
2144
+ eventType: "provider_cli_error"
2145
+ }];
1439
2146
  }
1440
- return null;
2147
+ return [];
1441
2148
  }
1442
2149
  accumulateUsage(event) {
1443
2150
  const usage = objectField(event, "usage");
@@ -1568,37 +2275,37 @@ class CodexJsonState {
1568
2275
  consume(line) {
1569
2276
  const event = parseJsonLine(line);
1570
2277
  if (!event) {
1571
- return null;
2278
+ return [];
1572
2279
  }
1573
2280
  const type = stringField(event, "type") ?? "";
1574
2281
  if (type === "thread.started") {
1575
2282
  this.threadId = stringField(event, "thread_id");
1576
2283
  if (this.threadId && !this.emittedThread) {
1577
2284
  this.emittedThread = true;
1578
- return {
1579
- role: "runner",
1580
- kind: "checkpoint",
1581
- content: `Codex thread started: ${this.threadId}.`,
1582
- eventType: "provider_thread_started"
1583
- };
2285
+ return [{
2286
+ role: "runner",
2287
+ kind: "checkpoint",
2288
+ content: `Codex thread started: ${this.threadId}.`,
2289
+ eventType: "provider_thread_started"
2290
+ }];
1584
2291
  }
1585
2292
  }
1586
2293
  if (type === "turn.completed") {
1587
2294
  this.accumulateUsage(event);
1588
- return {
1589
- role: "runner",
1590
- kind: "checkpoint",
1591
- content: "Codex turn completed.",
1592
- eventType: "provider_turn_completed"
1593
- };
2295
+ return [{
2296
+ role: "runner",
2297
+ kind: "checkpoint",
2298
+ content: "Codex turn completed.",
2299
+ eventType: "provider_turn_completed"
2300
+ }];
1594
2301
  }
1595
2302
  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
- };
2303
+ return [{
2304
+ role: "runner",
2305
+ kind: "error",
2306
+ content: clip(line.trim(), 1000),
2307
+ eventType: "provider_cli_error"
2308
+ }];
1602
2309
  }
1603
2310
  if (type === "item.completed" || type === "item.updated") {
1604
2311
  const item = objectField(event, "item");
@@ -1607,28 +2314,32 @@ class CodexJsonState {
1607
2314
  const text = stringField(item, "text");
1608
2315
  if (text) {
1609
2316
  this.finalMessage = clip(text, 4000);
1610
- return {
1611
- role: "assistant",
1612
- kind: "message",
1613
- content: this.finalMessage,
1614
- eventType: "provider_message"
1615
- };
2317
+ return [{
2318
+ role: "assistant",
2319
+ kind: "message",
2320
+ content: this.finalMessage,
2321
+ eventType: "provider_message"
2322
+ }];
1616
2323
  }
1617
2324
  }
1618
2325
  if (itemType === "command_execution") {
1619
2326
  const command = stringField(item, "command");
1620
2327
  const status = stringField(item, "status");
1621
2328
  if (command) {
1622
- return {
1623
- role: "worker",
1624
- kind: "checkpoint",
1625
- content: `Codex command ${status || "completed"}: ${clip(command, 500)}`,
1626
- eventType: "provider_command"
1627
- };
2329
+ return [{
2330
+ role: "worker",
2331
+ kind: "checkpoint",
2332
+ content: `Codex command ${status || "completed"}: ${clip(command, 500)}`,
2333
+ eventType: "provider_command"
2334
+ }];
1628
2335
  }
1629
2336
  }
1630
2337
  }
1631
- return null;
2338
+ const mcpEvents = formatCodexMcpToolCallEvents(type, event);
2339
+ if (mcpEvents.length > 0) {
2340
+ return mcpEvents;
2341
+ }
2342
+ return [];
1632
2343
  }
1633
2344
  accumulateUsage(event) {
1634
2345
  const usage = objectField(event, "usage");
@@ -1644,6 +2355,113 @@ class CodexJsonState {
1644
2355
  });
1645
2356
  }
1646
2357
  }
2358
+ function formatCodexMcpToolCallEvents(type, event) {
2359
+ if (type !== "item.started" && type !== "item.updated" && type !== "item.completed") {
2360
+ return [];
2361
+ }
2362
+ const item = objectField(event, "item");
2363
+ const itemType = stringField(item, "type") ?? "";
2364
+ const serverName = mcpServerName(item);
2365
+ const toolName = mcpToolName(item);
2366
+ const isMcpItem = itemType.toLowerCase().includes("mcp")
2367
+ || Boolean(serverName)
2368
+ || (toolName ? isForgeCompletionTool(toolName) || toolName.toLowerCase().includes("mcp") : false);
2369
+ if (!isMcpItem || !toolName) {
2370
+ return [];
2371
+ }
2372
+ const status = normalizedString(stringField(item, "status")
2373
+ ?? stringField(event, "status")
2374
+ ?? (type === "item.started" ? "started" : type === "item.completed" ? "completed" : "updated"));
2375
+ const failed = status === "failed" || status === "error" || status === "cancelled" || Boolean(item.error);
2376
+ const completed = failed || type === "item.completed" || status === "completed" || status === "succeeded" || status === "success";
2377
+ const started = type === "item.started" || status === "started" || status === "running" || status === "in_progress";
2378
+ const data = safeProviderMcpData(item, serverName ?? "unknown", toolName, status);
2379
+ const label = `${serverName ?? "unknown"}.${toolName}`;
2380
+ if (failed) {
2381
+ const summary = mcpErrorSummary(item) ?? "MCP tool call failed.";
2382
+ const events = [{
2383
+ role: "tool",
2384
+ kind: "error",
2385
+ content: `MCP tool call failed: ${label} - ${summary}`,
2386
+ eventType: "provider_mcp_call_failed",
2387
+ data
2388
+ }];
2389
+ if (isForgeCompletionTool(toolName)) {
2390
+ events.push({
2391
+ role: "tool",
2392
+ kind: "error",
2393
+ content: `Forge completion tool validation failed: ${label} - ${summary}`,
2394
+ eventType: "completion_tool_validation_failed",
2395
+ data
2396
+ });
2397
+ }
2398
+ return events;
2399
+ }
2400
+ if (completed) {
2401
+ return [{
2402
+ role: "tool",
2403
+ kind: "checkpoint",
2404
+ content: `MCP tool call completed: ${label}.`,
2405
+ eventType: "provider_mcp_call_completed",
2406
+ data
2407
+ }];
2408
+ }
2409
+ if (started) {
2410
+ return [{
2411
+ role: "tool",
2412
+ kind: "checkpoint",
2413
+ content: `MCP tool call started: ${label}.`,
2414
+ eventType: "provider_mcp_call_started",
2415
+ data
2416
+ }];
2417
+ }
2418
+ return [];
2419
+ }
2420
+ function mcpServerName(item) {
2421
+ return stringField(item, "server")
2422
+ ?? stringField(item, "server_name")
2423
+ ?? stringField(item, "mcp_server")
2424
+ ?? stringField(objectField(item, "mcp"), "server")
2425
+ ?? stringField(objectField(item, "mcp"), "server_name");
2426
+ }
2427
+ function mcpToolName(item) {
2428
+ return stringField(item, "tool")
2429
+ ?? stringField(item, "tool_name")
2430
+ ?? stringField(item, "name")
2431
+ ?? stringField(objectField(item, "function"), "name")
2432
+ ?? stringField(objectField(item, "mcp"), "tool")
2433
+ ?? stringField(objectField(item, "mcp"), "tool_name");
2434
+ }
2435
+ function mcpErrorSummary(item) {
2436
+ const direct = stringField(item, "error")
2437
+ ?? stringField(item, "error_message")
2438
+ ?? stringField(item, "message");
2439
+ if (direct) {
2440
+ return clip(direct, 600);
2441
+ }
2442
+ const error = objectField(item, "error");
2443
+ return stringField(error, "message")
2444
+ ?? stringField(error, "summary")
2445
+ ?? stringField(objectField(item, "result"), "error")
2446
+ ?? null;
2447
+ }
2448
+ function safeProviderMcpData(item, serverName, toolName, status) {
2449
+ const elapsedMs = numericField(item, "elapsed_ms") ?? numericField(item, "duration_ms");
2450
+ return JSON.stringify(stripUndefined({
2451
+ server_name: serverName,
2452
+ tool_name: toolName,
2453
+ status,
2454
+ elapsed_ms: elapsedMs,
2455
+ error_summary: mcpErrorSummary(item)
2456
+ }));
2457
+ }
2458
+ function isForgeCompletionTool(toolName) {
2459
+ const normalized = toolName.trim().toLowerCase();
2460
+ return normalized.startsWith("forge.complete_")
2461
+ || normalized.startsWith("complete_")
2462
+ || normalized.includes("forge.complete_")
2463
+ || normalized.includes("complete_architect_plan");
2464
+ }
1647
2465
  function objectField(source, key) {
1648
2466
  const value = source[key];
1649
2467
  return value && typeof value === "object" && !Array.isArray(value)
@@ -1654,6 +2472,13 @@ function stringField(source, key) {
1654
2472
  const value = source[key];
1655
2473
  return typeof value === "string" && value.trim().length > 0 ? value : null;
1656
2474
  }
2475
+ function numericField(source, key) {
2476
+ const value = source[key];
2477
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
2478
+ }
2479
+ function stripUndefined(value) {
2480
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
2481
+ }
1657
2482
  function normalizedString(value) {
1658
2483
  return value.trim().toLowerCase().replace(/-/g, "_");
1659
2484
  }