@loucompanion/forge-bridge 0.1.1-dev.718bdb0018b9 → 0.1.1-dev.895607f4402a

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.
@@ -7,6 +7,45 @@ import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from ".
7
7
  const toolServerName = "forge";
8
8
  const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
9
9
  const completionPhaseOrder = ["implementation", "self_review", "validation"];
10
+ export const debugCompletionPhaseOrder = [
11
+ "debug_repro",
12
+ "debug_root_cause",
13
+ "debug_fix",
14
+ "debug_regression"
15
+ ];
16
+ export const qaCompletionGateOrder = [
17
+ "qa_plan",
18
+ "qa_regression",
19
+ "qa_api",
20
+ "qa_manual_web",
21
+ "qa_bug_repro",
22
+ "qa_findings",
23
+ "qa_verdict"
24
+ ];
25
+ const qaGateFileNames = {
26
+ qa_plan: "qa.plan.json",
27
+ qa_regression: "qa.regression.json",
28
+ qa_api: "qa.api.json",
29
+ qa_manual_web: "qa.manual-web.json",
30
+ qa_bug_repro: "qa.bug-repro.json",
31
+ qa_findings: "qa.findings.json",
32
+ qa_verdict: "qa.verdict.json"
33
+ };
34
+ const qaGateToolNames = {
35
+ qa_plan: "complete_qa_plan",
36
+ qa_regression: "complete_qa_regression",
37
+ qa_api: "complete_qa_api",
38
+ qa_manual_web: "complete_qa_manual_web",
39
+ qa_bug_repro: "complete_qa_bug_repro",
40
+ qa_findings: "complete_qa_findings",
41
+ qa_verdict: "complete_qa_verdict"
42
+ };
43
+ const debugGateToolNames = {
44
+ debug_repro: "complete_debug_repro",
45
+ debug_root_cause: "complete_debug_root_cause",
46
+ debug_fix: "complete_debug_fix",
47
+ debug_regression: "complete_debug_regression"
48
+ };
10
49
  const qaBlockerReasons = new Set([
11
50
  "missing_app_launch_target",
12
51
  "inconclusive",
@@ -34,6 +73,22 @@ const phaseFiles = {
34
73
  qa: {
35
74
  jsonFileName: "qa.verdict.json",
36
75
  reportFileName: "qa.report.md"
76
+ },
77
+ debug_repro: {
78
+ jsonFileName: "debug.repro.json",
79
+ reportFileName: "debug.report.md"
80
+ },
81
+ debug_root_cause: {
82
+ jsonFileName: "debug.root-cause.json",
83
+ reportFileName: "debug.report.md"
84
+ },
85
+ debug_fix: {
86
+ jsonFileName: "debug.fix.json",
87
+ reportFileName: "debug.report.md"
88
+ },
89
+ debug_regression: {
90
+ jsonFileName: "debug.regression.json",
91
+ reportFileName: "debug.report.md"
37
92
  }
38
93
  };
39
94
  export function completionPhaseFromDispatch(dispatch) {
@@ -41,6 +96,10 @@ export function completionPhaseFromDispatch(dispatch) {
41
96
  }
42
97
  export function normalizeCompletionPhase(value) {
43
98
  const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
99
+ const debugPhase = normalizeDebugCompletionPhase(normalized);
100
+ if (debugPhase) {
101
+ return debugPhase;
102
+ }
44
103
  if (normalized === "self_review" || normalized === "selfreview" || normalized === "review") {
45
104
  return "self_review";
46
105
  }
@@ -61,7 +120,28 @@ export function usesDevelopCycleCompletion(dispatch) {
61
120
  && lane === "develop"
62
121
  && completionPhaseFromDispatch(dispatch) === "implementation";
63
122
  }
123
+ export function usesQaCycleCompletion(dispatch) {
124
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
125
+ const role = dispatch.execution.role.trim().toLowerCase();
126
+ const lane = dispatch.execution.lane.trim().toLowerCase();
127
+ return kind === "qa"
128
+ && role === "qa"
129
+ && lane === "qa"
130
+ && completionPhaseFromDispatch(dispatch) === "qa";
131
+ }
132
+ export function usesDebugCycleCompletion(dispatch) {
133
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
134
+ const role = dispatch.execution.role.trim().toLowerCase();
135
+ const lane = dispatch.execution.lane.trim().toLowerCase();
136
+ return kind === "debug"
137
+ && role === "debug"
138
+ && lane === "debug"
139
+ && isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
140
+ }
64
141
  export function completionToolNamesForPhase(phase) {
142
+ if (isDebugCompletionPhase(phase)) {
143
+ return completionToolNamesForDebugGate(phase);
144
+ }
65
145
  const base = phase === "implementation"
66
146
  ? "complete_implementation"
67
147
  : phase === "self_review"
@@ -71,12 +151,29 @@ export function completionToolNamesForPhase(phase) {
71
151
  : "complete_qa";
72
152
  return [`forge.${base}`, base];
73
153
  }
154
+ export function completionToolNamesForQaGate(gate) {
155
+ const base = qaGateToolNames[gate];
156
+ return [`forge.${base}`, base];
157
+ }
158
+ export function completionToolNamesForDebugGate(gate) {
159
+ const base = debugGateToolNames[gate];
160
+ return [`forge.${base}`, base];
161
+ }
74
162
  export function expectedCompletionToolName(phase) {
75
163
  return completionToolNamesForPhase(phase)[0];
76
164
  }
165
+ export function expectedQaCompletionToolName(gate) {
166
+ return completionToolNamesForQaGate(gate)[0];
167
+ }
168
+ export function expectedDebugCompletionToolName(gate) {
169
+ return completionToolNamesForDebugGate(gate)[0];
170
+ }
77
171
  export function artifactRootFor(worktreePath, workItemId) {
78
172
  return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
79
173
  }
174
+ function artifactRootForOwner(worktreePath, ownerType, ownerId) {
175
+ return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
176
+ }
80
177
  export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
81
178
  return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].jsonFileName);
82
179
  }
@@ -87,12 +184,18 @@ export async function prepareCompletionToolRuntime(options) {
87
184
  const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
88
185
  const worktreePath = path.resolve(options.worktree.worktreePath);
89
186
  const workItemId = options.dispatch.workItem.id;
187
+ const ownerType = normalizedOwnerType(options.dispatch.cycleRun.ownerType) ?? "work_item";
188
+ const ownerId = options.dispatch.cycleRun.ownerId ?? workItemId;
90
189
  const mcpArgs = buildCompletionToolServerArgs({
91
190
  worktreePath,
92
191
  workItemId,
192
+ ownerType,
193
+ ownerId,
93
194
  phase,
94
195
  producedBy: `${options.dispatch.execution.provider}-cli`,
95
- developCycle: usesDevelopCycleCompletion(options.dispatch)
196
+ developCycle: usesDevelopCycleCompletion(options.dispatch),
197
+ qaCycle: usesQaCycleCompletion(options.dispatch),
198
+ debugCycle: usesDebugCycleCompletion(options.dispatch)
96
199
  });
97
200
  const runtime = {
98
201
  serverName: toolServerName,
@@ -118,9 +221,19 @@ export function buildCompletionToolServerArgs(config) {
118
221
  "--produced-by",
119
222
  config.producedBy ?? "forge-completion-tool"
120
223
  ];
224
+ if (config.ownerType || config.ownerId) {
225
+ args.push("--owner-type", normalizedOwnerType(config.ownerType) ?? "work_item");
226
+ args.push("--owner-id", config.ownerId ?? config.workItemId);
227
+ }
121
228
  if (config.developCycle) {
122
229
  args.push("--develop-cycle");
123
230
  }
231
+ if (config.qaCycle) {
232
+ args.push("--qa-cycle");
233
+ }
234
+ if (config.debugCycle) {
235
+ args.push("--debug-cycle");
236
+ }
124
237
  return args;
125
238
  }
126
239
  export function codexMcpConfigArgs(runtime) {
@@ -169,31 +282,72 @@ Complete the dev cycle in order inside this same provider session:
169
282
  Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
170
283
  If the current phase cannot pass, call the current phase tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
171
284
  }
285
+ export function completionPromptForQaCycle(currentGate = "qa_plan") {
286
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
287
+ Do not write QA artifacts by hand. The tool server enforces QA gate order and starts this run at \`${currentGate}\`.
288
+ Complete the QA cycle in order inside this same provider session:
289
+ 1. Call \`forge.complete_qa_plan(...)\` after the QA test plan/checklist is ready.
290
+ 2. Follow the returned regression instruction, then call \`forge.complete_qa_regression(...)\`.
291
+ 3. Follow the returned API/backend instruction, then call \`forge.complete_qa_api(...)\`.
292
+ 4. Follow the returned manual web instruction, then call \`forge.complete_qa_manual_web(...)\`.
293
+ 5. Follow the returned bug reproduction instruction, then call \`forge.complete_qa_bug_repro(...)\`.
294
+ 6. Follow the returned findings instruction, then call \`forge.complete_qa_findings(...)\`.
295
+ 7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
296
+ Use \`status: "skipped"\` with a reason for gates that are not applicable. If a gate is blocked, keep moving through later gates as skipped due to the blocker and finish with a blocked verdict.
297
+ The final verdict tool writes the canonical QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`qa.verdict.json\`, and \`qa.report.md\`.
298
+ The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
299
+ }
300
+ export function completionPromptForDebugCycle(currentGate = "debug_repro") {
301
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
302
+ Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
303
+ Complete the Debug cycle in order inside this same provider session:
304
+ 1. Reproduce or tightly characterize the bug, then call \`forge.complete_debug_repro(...)\`.
305
+ 2. Follow the returned root-cause instruction, isolate evidence, then call \`forge.complete_debug_root_cause(...)\`.
306
+ 3. Follow the returned fix instruction, apply the smallest safe fix with regression coverage when practical, then call \`forge.complete_debug_fix(...)\`.
307
+ 4. Follow the returned regression instruction, prove the original repro is negative and the fix is QA-ready, then call \`forge.complete_debug_regression(...)\`.
308
+ Each tool writes a canonical Debug artifact under \`.forge/work-items/{workItemId}/artifacts/\`: \`debug.repro.json\`, \`debug.root-cause.json\`, \`debug.fix.json\`, and \`debug.regression.json\` plus \`debug.report.md\` when report text is provided or generated.
309
+ Use a non-pass status with explicit blockers when the current Debug gate cannot pass. Reporting Debug completion in prose is not enough; the relevant MCP completion tool is the transition request.`;
310
+ }
172
311
  export function completionToolNamesForServer(config) {
173
- return config.developCycle
174
- ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
175
- : completionToolNamesForPhase(config.phase);
312
+ return config.debugCycle
313
+ ? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
314
+ : config.qaCycle
315
+ ? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
316
+ : config.developCycle
317
+ ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
318
+ : completionToolNamesForPhase(config.phase);
176
319
  }
177
320
  export class CompletionToolServerSession {
178
321
  config;
179
322
  currentPhase;
323
+ currentQaGate = "qa_plan";
180
324
  completed = false;
181
325
  constructor(config) {
182
326
  this.config = config;
183
327
  this.currentPhase = config.phase;
184
328
  }
185
329
  get instructions() {
186
- return this.config.developCycle
187
- ? completionPromptForDevelopCycle(this.currentPhase)
188
- : completionPromptForPhase(this.currentPhase);
330
+ return this.config.debugCycle
331
+ ? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
332
+ : this.config.qaCycle
333
+ ? completionPromptForQaCycle(this.currentQaGate)
334
+ : this.config.developCycle
335
+ ? completionPromptForDevelopCycle(this.currentPhase)
336
+ : completionPromptForPhase(this.currentPhase);
189
337
  }
190
338
  toolDefinitions() {
191
- return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name)));
339
+ return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
192
340
  }
193
341
  async callTool(toolName, input) {
194
342
  const requestedPhase = phaseFromToolName(toolName);
195
343
  if (this.completed) {
196
- throw new Error("Forge develop cycle is already complete; no more phase completion calls are accepted.");
344
+ throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
345
+ }
346
+ if (this.config.debugCycle) {
347
+ return await this.callDebugGateTool(toolName, requestedPhase, input);
348
+ }
349
+ if (this.config.qaCycle) {
350
+ return await this.callQaGateTool(toolName, requestedPhase, input);
197
351
  }
198
352
  if (requestedPhase !== this.currentPhase) {
199
353
  throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
@@ -229,10 +383,91 @@ export class CompletionToolServerSession {
229
383
  }
230
384
  };
231
385
  }
386
+ async callDebugGateTool(toolName, requestedPhase, input) {
387
+ const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
388
+ if (!isDebugCompletionPhase(requestedPhase)) {
389
+ throw new Error(`Tool ${toolName} is not available in Debug-cycle mode. Current Forge Debug gate is ${currentDebugGate}.`);
390
+ }
391
+ if (requestedPhase !== currentDebugGate) {
392
+ throw new Error(`Tool ${toolName} is out of order. Current Forge Debug gate is ${currentDebugGate}.`);
393
+ }
394
+ const completedGate = currentDebugGate;
395
+ const reference = await writeCompletionArtifact({ ...this.config, phase: completedGate }, toolName, input);
396
+ const status = reference.status.trim().toLowerCase();
397
+ const pass = status === "pass";
398
+ const nextGate = pass ? this.advanceAfterDebugGate(completedGate) : completedGate;
399
+ const nextPhase = nextGate ?? "qa_ready";
400
+ const nextInstruction = completionToolNextInstructionForDebugGate(completedGate, status, nextGate);
401
+ const reason = pass
402
+ ? `${completedGate} artifact accepted${nextGate ? `; continue to ${nextGate}.` : "; Debug regression is QA-ready."}`
403
+ : `${completedGate} artifact recorded with status ${status}; rework is required before Debug progression.`;
404
+ return {
405
+ content: [
406
+ {
407
+ type: "text",
408
+ text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
409
+ }
410
+ ],
411
+ structuredContent: {
412
+ ...reference,
413
+ gate: {
414
+ phase: completedGate,
415
+ status,
416
+ allowed: pass,
417
+ reason
418
+ },
419
+ nextPhase,
420
+ nextInstruction
421
+ }
422
+ };
423
+ }
424
+ async callQaGateTool(toolName, requestedPhase, input) {
425
+ const requestedGate = qaGateFromToolName(toolName);
426
+ if (requestedPhase !== "qa" || !requestedGate) {
427
+ throw new Error(`Tool ${toolName} is not available in QA-cycle mode. Current Forge QA gate is ${this.currentQaGate}.`);
428
+ }
429
+ if (requestedGate !== this.currentQaGate) {
430
+ throw new Error(`Tool ${toolName} is out of order. Current Forge QA gate is ${this.currentQaGate}.`);
431
+ }
432
+ const completedGate = this.currentQaGate;
433
+ const reference = await writeCompletionArtifact({ ...this.config, phase: "qa" }, toolName, input);
434
+ const status = reference.status.trim().toLowerCase();
435
+ const nextGate = this.advanceAfterQaGate(completedGate);
436
+ const nextPhase = nextGate ? "qa" : "sign_off_ready";
437
+ const nextInstruction = completionToolNextInstructionForQaGate(completedGate, status, nextGate);
438
+ const allowed = status !== "blocked";
439
+ const reason = nextGate
440
+ ? `${completedGate} artifact accepted; continue to ${nextGate}.`
441
+ : `QA verdict artifact accepted with status ${status}.`;
442
+ return {
443
+ content: [
444
+ {
445
+ type: "text",
446
+ text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
447
+ }
448
+ ],
449
+ structuredContent: {
450
+ ...reference,
451
+ gate: {
452
+ phase: "qa",
453
+ qaGate: completedGate,
454
+ status,
455
+ allowed,
456
+ reason
457
+ },
458
+ nextPhase,
459
+ nextInstruction
460
+ }
461
+ };
462
+ }
232
463
  advanceAfterPass(phase) {
233
464
  if (phase === "qa") {
465
+ this.completed = true;
234
466
  return "sign_off_ready";
235
467
  }
468
+ if (isDebugCompletionPhase(phase)) {
469
+ return nextDebugPhaseAfter(phase) ?? "qa_ready";
470
+ }
236
471
  const next = nextPhaseAfter(phase);
237
472
  if (!this.config.developCycle) {
238
473
  return next ?? "dev_test_ready";
@@ -244,12 +479,36 @@ export class CompletionToolServerSession {
244
479
  this.completed = true;
245
480
  return "dev_test_ready";
246
481
  }
482
+ advanceAfterQaGate(gate) {
483
+ const index = qaCompletionGateOrder.indexOf(gate);
484
+ const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
485
+ if (next) {
486
+ this.currentQaGate = next;
487
+ return next;
488
+ }
489
+ this.completed = true;
490
+ return null;
491
+ }
492
+ advanceAfterDebugGate(gate) {
493
+ const index = debugCompletionPhaseOrder.indexOf(gate);
494
+ const next = index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
495
+ if (next) {
496
+ this.currentPhase = next;
497
+ return next;
498
+ }
499
+ this.completed = true;
500
+ return null;
501
+ }
247
502
  }
248
503
  export async function writeCompletionArtifact(config, toolName, input) {
249
504
  const phase = phaseFromToolName(toolName);
250
505
  if (phase !== config.phase) {
251
506
  throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
252
507
  }
508
+ const qaGate = qaGateFromToolName(toolName);
509
+ if (qaGate && qaGate !== "qa_verdict") {
510
+ return await writeQaGateCompletionArtifact(config, qaGate, input);
511
+ }
253
512
  const payload = asRecord(input, "completion payload");
254
513
  const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
255
514
  const validationPayload = { ...payload, status };
@@ -266,6 +525,8 @@ export async function writeCompletionArtifact(config, toolName, input) {
266
525
  const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
267
526
  phase,
268
527
  workItemId: config.workItemId,
528
+ ownerType: config.ownerType,
529
+ ownerId: config.ownerId,
269
530
  producedBy,
270
531
  status
271
532
  });
@@ -284,6 +545,51 @@ export async function writeCompletionArtifact(config, toolName, input) {
284
545
  return {
285
546
  phase,
286
547
  workItemId: config.workItemId,
548
+ ...artifactReferenceOwnerFields(config),
549
+ repoPath: path.resolve(config.worktreePath),
550
+ path: artifactPath,
551
+ reportPath,
552
+ contentHash,
553
+ status,
554
+ producedBy
555
+ };
556
+ }
557
+ async function writeQaGateCompletionArtifact(config, gate, input) {
558
+ const payload = asRecord(input, "QA gate completion payload");
559
+ const status = normalizeQaGateStatus(requiredString(payload, "status"));
560
+ validateQaGatePayloadShape(gate, payload, status);
561
+ validateOptionalWorkItemId(payload, config.workItemId);
562
+ const redactedPayload = redactSecrets({ ...payload, status }, config.redactionSecrets ?? []);
563
+ const producedBy = optionalString(redactedPayload, "produced_by")
564
+ ?? optionalString(redactedPayload, "producedBy")
565
+ ?? config.producedBy
566
+ ?? "forge-completion-tool";
567
+ const ownerFields = artifactOwnerFields(config);
568
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
569
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
570
+ : artifactRootFor(config.worktreePath, config.workItemId);
571
+ const fileName = qaGateFileNames[gate];
572
+ const artifactPath = path.join(artifactRoot, fileName);
573
+ const reportPath = path.join(artifactRoot, "qa.report.md");
574
+ const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
575
+ gate,
576
+ workItemId: config.workItemId,
577
+ ownerType: config.ownerType,
578
+ ownerId: config.ownerId,
579
+ producedBy,
580
+ status
581
+ });
582
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
583
+ const artifact = {
584
+ ...artifactWithoutHash,
585
+ content_hash: contentHash
586
+ };
587
+ await fs.mkdir(artifactRoot, { recursive: true });
588
+ await writeFileAtomic(artifactPath, jsonText(artifact));
589
+ return {
590
+ phase: "qa",
591
+ workItemId: config.workItemId,
592
+ ...artifactReferenceOwnerFields(config),
287
593
  repoPath: path.resolve(config.worktreePath),
288
594
  path: artifactPath,
289
595
  reportPath,
@@ -293,19 +599,28 @@ export async function writeCompletionArtifact(config, toolName, input) {
293
599
  };
294
600
  }
295
601
  async function writeQaCompletionArtifact(config, payload, status, producedBy) {
296
- const artifactRoot = artifactRootFor(config.worktreePath, config.workItemId);
602
+ const ownerFields = artifactOwnerFields(config);
603
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
604
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
605
+ : artifactRootFor(config.worktreePath, config.workItemId);
297
606
  const planPath = path.join(artifactRoot, "qa.plan.json");
298
607
  const evidencePath = path.join(artifactRoot, "qa.evidence.json");
299
608
  const findingsPath = path.join(artifactRoot, "qa.findings.json");
300
609
  const verdictPath = path.join(artifactRoot, "qa.verdict.json");
301
610
  const reportPath = path.join(artifactRoot, "qa.report.md");
302
- const relativeEvidencePath = qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
303
- const relativeFindingsPath = qaArtifactRelativePath(config.workItemId, "qa.findings.json");
304
- const relativeReportPath = qaArtifactRelativePath(config.workItemId, "qa.report.md");
611
+ const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
612
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
613
+ : qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
614
+ const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
615
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
616
+ : qaArtifactRelativePath(config.workItemId, "qa.findings.json");
617
+ const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
618
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
619
+ : qaArtifactRelativePath(config.workItemId, "qa.report.md");
305
620
  const evidenceSource = qaEvidenceSource(payload);
306
621
  const commands = qaArray(payload, "commands", evidenceSource);
307
622
  const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
308
- const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource));
623
+ const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
309
624
  const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
310
625
  const findings = qaObjectArray(payload, "findings");
311
626
  const candidateBugs = qaObjectArray(payload, "candidate_bugs");
@@ -317,12 +632,14 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
317
632
  const planJson = jsonText({
318
633
  schema_version: 1,
319
634
  phase: "qa",
635
+ ...ownerFields,
320
636
  checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
321
637
  acceptance_criteria: testedAcceptanceCriteria
322
638
  });
323
639
  const evidenceJson = jsonText({
324
640
  schema_version: 1,
325
641
  phase: "qa",
642
+ ...ownerFields,
326
643
  commands,
327
644
  browser_checks: browserChecks,
328
645
  sidecars,
@@ -331,6 +648,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
331
648
  const findingsJson = jsonText({
332
649
  schema_version: 1,
333
650
  phase: "qa",
651
+ ...ownerFields,
334
652
  findings,
335
653
  candidate_bugs: candidateBugs
336
654
  });
@@ -350,6 +668,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
350
668
  const verdictWithoutHash = {
351
669
  schema_version: numberOrDefault(payload.schema_version, 1),
352
670
  phase: "qa",
671
+ ...ownerFields,
353
672
  status,
354
673
  summary: requiredString(payload, "summary"),
355
674
  tested_acceptance_criteria: testedAcceptanceCriteria,
@@ -381,6 +700,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
381
700
  return {
382
701
  phase: "qa",
383
702
  workItemId: config.workItemId,
703
+ ...artifactReferenceOwnerFields(config),
384
704
  repoPath: path.resolve(config.worktreePath),
385
705
  path: verdictPath,
386
706
  reportPath,
@@ -389,11 +709,13 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
389
709
  producedBy
390
710
  };
391
711
  }
392
- async function validateQaSidecarReferences(worktreePath, workItemId, sidecars) {
712
+ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
393
713
  if (sidecars.length === 0) {
394
714
  return [];
395
715
  }
396
- const artifactRoot = artifactRootFor(worktreePath, workItemId);
716
+ const artifactRoot = ownerType && ownerId
717
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
718
+ : artifactRootFor(worktreePath, workItemId);
397
719
  const qaSidecarRoot = path.resolve(artifactRoot, "qa");
398
720
  const validated = [];
399
721
  for (const [index, sidecar] of sidecars.entries()) {
@@ -412,7 +734,7 @@ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars) {
412
734
  }
413
735
  const fullPath = path.resolve(worktreePath, sidecarPath);
414
736
  if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
415
- throw new Error(`sidecars[${index}].path must be under .forge/work-items/${workItemId}/artifacts/qa/.`);
737
+ throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
416
738
  }
417
739
  const stat = await fs.lstat(fullPath).catch((error) => {
418
740
  if (isNodeError(error) && error.code === "ENOENT") {
@@ -484,8 +806,12 @@ export function parseCompletionMcpArgs(argv) {
484
806
  let worktreePath = null;
485
807
  let workItemId = null;
486
808
  let phase = "implementation";
809
+ let ownerType;
810
+ let ownerId;
487
811
  let producedBy;
488
812
  let developCycle = false;
813
+ let qaCycle = false;
814
+ let debugCycle = false;
489
815
  for (let index = 1; index < argv.length; index += 1) {
490
816
  const arg = argv[index];
491
817
  if (arg === "--worktree") {
@@ -494,11 +820,20 @@ export function parseCompletionMcpArgs(argv) {
494
820
  else if (arg === "--work-item-id") {
495
821
  workItemId = argv[++index] ?? null;
496
822
  }
823
+ else if (arg === "--owner-type") {
824
+ ownerType = normalizedOwnerType(argv[++index]);
825
+ if (!ownerType) {
826
+ return { ok: false, error: "mcp --owner-type must be work_item or story." };
827
+ }
828
+ }
829
+ else if (arg === "--owner-id") {
830
+ ownerId = argv[++index] ?? undefined;
831
+ }
497
832
  else if (arg === "--phase") {
498
833
  const rawPhase = argv[++index];
499
834
  const parsedPhase = parseStrictCompletionPhase(rawPhase);
500
835
  if (!parsedPhase) {
501
- return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa." };
836
+ return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, debug-repro, debug-root-cause, debug-fix, debug-regression." };
502
837
  }
503
838
  phase = parsedPhase;
504
839
  }
@@ -508,6 +843,12 @@ export function parseCompletionMcpArgs(argv) {
508
843
  else if (arg === "--develop-cycle") {
509
844
  developCycle = true;
510
845
  }
846
+ else if (arg === "--qa-cycle") {
847
+ qaCycle = true;
848
+ }
849
+ else if (arg === "--debug-cycle") {
850
+ debugCycle = true;
851
+ }
511
852
  else {
512
853
  return { ok: false, error: `Unknown mcp argument: ${arg}` };
513
854
  }
@@ -515,15 +856,31 @@ export function parseCompletionMcpArgs(argv) {
515
856
  if (!worktreePath?.trim() || !workItemId?.trim()) {
516
857
  return { ok: false, error: "mcp requires --worktree and --work-item-id." };
517
858
  }
859
+ if ([developCycle, qaCycle, debugCycle].filter(Boolean).length > 1) {
860
+ return { ok: false, error: "mcp accepts only one cycle mode." };
861
+ }
862
+ if (developCycle && !isDevelopCompletionPhase(phase)) {
863
+ return { ok: false, error: "mcp --develop-cycle requires --phase implementation, self-review, or validation." };
864
+ }
865
+ if (qaCycle && phase !== "qa") {
866
+ return { ok: false, error: "mcp --qa-cycle requires --phase qa." };
867
+ }
868
+ if (debugCycle && !isDebugCompletionPhase(phase)) {
869
+ return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
870
+ }
518
871
  return {
519
872
  ok: true,
520
873
  config: {
521
874
  worktreePath: path.resolve(worktreePath),
522
875
  workItemId,
876
+ ownerType,
877
+ ownerId,
523
878
  phase,
524
879
  producedBy,
525
880
  redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
526
- developCycle
881
+ developCycle,
882
+ qaCycle,
883
+ debugCycle
527
884
  }
528
885
  };
529
886
  }
@@ -618,17 +975,24 @@ async function handleJsonRpcRequest(session, method, params) {
618
975
  }
619
976
  throw new Error(`Unsupported MCP method: ${method}`);
620
977
  }
621
- function toolDefinition(name, phase) {
978
+ function toolDefinition(name, phase, qaGate = null) {
622
979
  return {
623
980
  name,
624
- description: toolDescription(phase),
625
- inputSchema: toolInputSchema(phase)
981
+ description: toolDescription(phase, qaGate),
982
+ inputSchema: toolInputSchema(phase, qaGate)
626
983
  };
627
984
  }
628
985
  function nextPhaseAfter(phase) {
986
+ if (!isDevelopCompletionPhase(phase)) {
987
+ return null;
988
+ }
629
989
  const index = completionPhaseOrder.indexOf(phase);
630
990
  return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
631
991
  }
992
+ function nextDebugPhaseAfter(phase) {
993
+ const index = debugCompletionPhaseOrder.indexOf(phase);
994
+ return index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
995
+ }
632
996
  function completionToolNextInstruction(completedPhase, status, nextPhase) {
633
997
  if (completedPhase === "qa") {
634
998
  if (status === "blocked") {
@@ -642,6 +1006,10 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
642
1006
  if (status !== "pass") {
643
1007
  return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
644
1008
  }
1009
+ if (isDebugCompletionPhase(completedPhase)) {
1010
+ const debugNext = isDebugCompletionPhase(nextPhase) ? nextPhase : null;
1011
+ return completionToolNextInstructionForDebugGate(completedPhase, status, debugNext);
1012
+ }
645
1013
  if (nextPhase === "self_review") {
646
1014
  return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
647
1015
  }
@@ -650,7 +1018,67 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
650
1018
  }
651
1019
  return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
652
1020
  }
653
- function toolDescription(phase) {
1021
+ function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
1022
+ if (!nextGate) {
1023
+ if (status === "blocked") {
1024
+ return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
1025
+ }
1026
+ if (status === "needs_rework") {
1027
+ return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
1028
+ }
1029
+ return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
1030
+ }
1031
+ const skipped = status === "skipped";
1032
+ const blocked = status === "blocked";
1033
+ const prefix = blocked
1034
+ ? `${completedGate} recorded a blocker. Continue through ${nextGate} and mark downstream gates skipped if the blocker prevents evidence collection.`
1035
+ : skipped
1036
+ ? `${completedGate} skipped. Continue to ${nextGate}.`
1037
+ : `${completedGate} accepted. Continue to ${nextGate}.`;
1038
+ return `${prefix} ${qaGateInstruction(nextGate)}`;
1039
+ }
1040
+ function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
1041
+ if (status !== "pass") {
1042
+ return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
1043
+ }
1044
+ if (nextGate === "debug_root_cause") {
1045
+ return "Debug repro accepted. Begin root-cause analysis: connect the observed failure to code evidence and call forge.complete_debug_root_cause.";
1046
+ }
1047
+ if (nextGate === "debug_fix") {
1048
+ return "Debug root cause accepted. Apply the smallest safe fix with regression coverage where practical, then call forge.complete_debug_fix.";
1049
+ }
1050
+ if (nextGate === "debug_regression") {
1051
+ return "Debug fix accepted. Prove the original repro is negative, run targeted regression checks, and call forge.complete_debug_regression.";
1052
+ }
1053
+ return "Debug regression accepted. Forge has the structured evidence needed to move this bug fix to QA.";
1054
+ }
1055
+ function qaGateInstruction(gate) {
1056
+ switch (gate) {
1057
+ case "qa_regression":
1058
+ return "Run or explicitly skip applicable regression/build/lint/typecheck/e2e checks, then call forge.complete_qa_regression.";
1059
+ case "qa_api":
1060
+ return "Run or explicitly skip applicable API/backend contract checks, then call forge.complete_qa_api.";
1061
+ case "qa_manual_web":
1062
+ return "Run or explicitly skip applicable browser/manual web checks, then call forge.complete_qa_manual_web.";
1063
+ case "qa_bug_repro":
1064
+ return "Reproduce or explicitly skip relevant defects/candidate bugs, then call forge.complete_qa_bug_repro.";
1065
+ case "qa_findings":
1066
+ return "Normalize observations into findings and candidate bugs, then call forge.complete_qa_findings.";
1067
+ case "qa_verdict":
1068
+ return "Assemble the final QA verdict from all prior gate evidence, then call forge.complete_qa_verdict.";
1069
+ case "qa_plan":
1070
+ return "Prepare the QA checklist, then call forge.complete_qa_plan.";
1071
+ }
1072
+ }
1073
+ function toolDescription(phase, qaGate = null) {
1074
+ if (qaGate) {
1075
+ return qaGate === "qa_verdict"
1076
+ ? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
1077
+ : `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
1078
+ }
1079
+ if (isDebugCompletionPhase(phase)) {
1080
+ return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
1081
+ }
654
1082
  if (phase === "implementation") {
655
1083
  return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
656
1084
  }
@@ -662,7 +1090,7 @@ function toolDescription(phase) {
662
1090
  }
663
1091
  return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
664
1092
  }
665
- function toolInputSchema(phase) {
1093
+ function toolInputSchema(phase, qaGate = null) {
666
1094
  const baseProperties = {
667
1095
  status: {
668
1096
  type: "string",
@@ -681,6 +1109,12 @@ function toolInputSchema(phase) {
681
1109
  })
682
1110
  }
683
1111
  };
1112
+ if (qaGate && qaGate !== "qa_verdict") {
1113
+ return qaGateToolInputSchema(qaGate);
1114
+ }
1115
+ if (isDebugCompletionPhase(phase)) {
1116
+ return debugToolInputSchema(phase, baseProperties);
1117
+ }
684
1118
  if (phase === "implementation") {
685
1119
  return objectToolSchema({
686
1120
  ...baseProperties,
@@ -893,6 +1327,144 @@ function toolInputSchema(phase) {
893
1327
  ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
894
1328
  }, ["status", "summary"], "For status pass, commands must include at least one passing deterministic command, acceptance criteria entries must use status: satisfied plus evidence, and ready_for_dev_test must be true.");
895
1329
  }
1330
+ function debugToolInputSchema(phase, baseProperties) {
1331
+ if (phase === "debug_repro") {
1332
+ return objectToolSchema({
1333
+ ...baseProperties,
1334
+ repro_steps: {
1335
+ type: "array",
1336
+ description: "Required for pass. Minimal reproduction or characterization steps.",
1337
+ items: { type: "string" }
1338
+ },
1339
+ observed: { type: "string", description: "Observed failure behavior." },
1340
+ expected: { type: "string", description: "Expected behavior." },
1341
+ evidence: {
1342
+ type: "array",
1343
+ description: "Required for pass. Concrete logs, commands, screenshots, file refs, or characterization notes.",
1344
+ items: { type: "object", additionalProperties: true }
1345
+ }
1346
+ }, ["status", "summary"], "For status pass, include repro_steps, observed, expected, and evidence.");
1347
+ }
1348
+ if (phase === "debug_root_cause") {
1349
+ return objectToolSchema({
1350
+ ...baseProperties,
1351
+ root_cause: { type: "string", description: "Likely root cause tied to code or config evidence." },
1352
+ evidence: {
1353
+ type: "array",
1354
+ description: "Required for pass. Concrete code/log/test evidence supporting the root cause.",
1355
+ items: { type: "object", additionalProperties: true }
1356
+ },
1357
+ eliminated_alternatives: {
1358
+ type: "array",
1359
+ description: "Required for pass. Plausible alternatives checked or ruled out.",
1360
+ items: { type: "object", additionalProperties: true }
1361
+ },
1362
+ risk_area: { type: "string", description: "Subsystem or behavior most at risk from the fix." }
1363
+ }, ["status", "summary"], "For status pass, include root_cause, evidence, eliminated_alternatives, and risk_area.");
1364
+ }
1365
+ if (phase === "debug_fix") {
1366
+ return objectToolSchema({
1367
+ ...baseProperties,
1368
+ changed_files: {
1369
+ type: "array",
1370
+ description: "Required for pass. Files changed by the bug fix.",
1371
+ items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
1372
+ },
1373
+ tests_added_or_updated: {
1374
+ type: "array",
1375
+ description: "Required for pass. Regression tests or checks added/updated, or justify omission in remaining_risks.",
1376
+ items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
1377
+ },
1378
+ remaining_risks: {
1379
+ type: "array",
1380
+ description: "Required for pass, even when empty. Include why no regression coverage changed when tests_added_or_updated is empty.",
1381
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
1382
+ },
1383
+ resolved_findings: {
1384
+ type: "array",
1385
+ description: "Required for QA rework. Entries should include qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.",
1386
+ items: { type: "object", additionalProperties: true }
1387
+ }
1388
+ }, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
1389
+ }
1390
+ return objectToolSchema({
1391
+ ...baseProperties,
1392
+ commands: {
1393
+ type: "array",
1394
+ description: "Required for pass. Targeted regression/repro-negative commands with exit_code: 0 and status: pass.",
1395
+ items: commandEvidenceSchema()
1396
+ },
1397
+ required_regressions: {
1398
+ type: "array",
1399
+ description: "Required for pass. Repro-negative and regression coverage checklist.",
1400
+ items: { type: "string" }
1401
+ },
1402
+ build_status: { type: "string", description: "Required for pass. pass or not_applicable." },
1403
+ test_status: { type: "string", description: "Required for pass. pass or not_applicable." },
1404
+ repro_negative: { type: "boolean", description: "Required for pass and must be true after the original repro no longer fails." },
1405
+ qa_ready: { type: "boolean", description: "Required for pass and must be true." }
1406
+ }, ["status", "summary"], "For status pass, commands must include at least one passing deterministic debug regression check, repro_negative must be true, and qa_ready must be true.");
1407
+ }
1408
+ function qaGateToolInputSchema(gate) {
1409
+ const baseProperties = {
1410
+ status: {
1411
+ type: "string",
1412
+ enum: ["pass", "skipped", "blocked"],
1413
+ description: "Use pass when this QA gate produced evidence, skipped when not applicable, or blocked when a prerequisite prevents this gate."
1414
+ },
1415
+ summary: { type: "string", description: "Concise QA gate summary." },
1416
+ reason: { type: "string", description: "Required for skipped or blocked gates." },
1417
+ blocker_reason: {
1418
+ type: "string",
1419
+ enum: [...qaBlockerReasons],
1420
+ description: "Required when status is blocked."
1421
+ }
1422
+ };
1423
+ if (gate === "qa_plan") {
1424
+ return objectToolSchema({
1425
+ ...baseProperties,
1426
+ checklist: {
1427
+ type: "array",
1428
+ description: "Required for pass. QA checklist entries planned for this work item.",
1429
+ items: { type: "string" }
1430
+ },
1431
+ acceptance_criteria: {
1432
+ type: "array",
1433
+ description: "Acceptance criteria selected for QA.",
1434
+ items: { type: "string" }
1435
+ }
1436
+ }, ["status", "summary"], "Record the QA plan gate. Pass requires a non-empty checklist.");
1437
+ }
1438
+ if (gate === "qa_findings") {
1439
+ return objectToolSchema({
1440
+ ...baseProperties,
1441
+ findings: {
1442
+ type: "array",
1443
+ description: "Normalized in-scope and out-of-scope QA findings.",
1444
+ items: { type: "object", additionalProperties: true }
1445
+ },
1446
+ candidate_bugs: {
1447
+ type: "array",
1448
+ description: "Out-of-scope or follow-up candidates.",
1449
+ items: { type: "object", additionalProperties: true }
1450
+ }
1451
+ }, ["status", "summary"], "Record normalized QA findings. Pass or skipped requires findings and candidate_bugs arrays, even when empty.");
1452
+ }
1453
+ return objectToolSchema({
1454
+ ...baseProperties,
1455
+ commands: {
1456
+ type: "array",
1457
+ description: "Command evidence for this gate when applicable.",
1458
+ items: commandEvidenceSchema()
1459
+ },
1460
+ browser_checks: {
1461
+ type: "array",
1462
+ description: "Browser/manual evidence for this gate when applicable.",
1463
+ items: { type: "object", additionalProperties: true }
1464
+ },
1465
+ notes: { type: "string", description: "Evidence notes for this gate." }
1466
+ }, ["status", "summary"], "Record this QA evidence gate. Use skipped with a reason when the gate is not applicable.");
1467
+ }
896
1468
  function objectToolSchema(properties, required, description) {
897
1469
  return {
898
1470
  type: "object",
@@ -962,6 +1534,13 @@ function normalizeCompletionStatus(value, phase) {
962
1534
  }
963
1535
  return canonical;
964
1536
  }
1537
+ function normalizeQaGateStatus(value) {
1538
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
1539
+ if (normalized !== "pass" && normalized !== "skipped" && normalized !== "blocked") {
1540
+ throw new Error("QA gate status must be pass, skipped, or blocked.");
1541
+ }
1542
+ return normalized;
1543
+ }
965
1544
  function validatePayloadShape(phase, payload) {
966
1545
  const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
967
1546
  if (!status) {
@@ -977,6 +1556,10 @@ function validatePayloadShape(phase, payload) {
977
1556
  requireNonPassEvidence(payload);
978
1557
  return;
979
1558
  }
1559
+ if (isDebugCompletionPhase(phase)) {
1560
+ validateDebugPayloadShape(phase, payload);
1561
+ return;
1562
+ }
980
1563
  if (phase === "implementation") {
981
1564
  requireArray(payload, "plan");
982
1565
  requireArray(payload, "changed_files");
@@ -1014,6 +1597,86 @@ function validatePayloadShape(phase, payload) {
1014
1597
  requirePassingCommands(payload);
1015
1598
  requireSatisfiedAcceptanceCriteria(payload);
1016
1599
  }
1600
+ function validateDebugPayloadShape(phase, payload) {
1601
+ if (phase === "debug_repro") {
1602
+ requireArray(payload, "repro_steps");
1603
+ requireField(payload, "observed");
1604
+ requireField(payload, "expected");
1605
+ requireArray(payload, "evidence");
1606
+ requireNonEmptyArray(payload, "repro_steps");
1607
+ requireNonEmptyArray(payload, "evidence");
1608
+ return;
1609
+ }
1610
+ if (phase === "debug_root_cause") {
1611
+ requireField(payload, "root_cause");
1612
+ requireArray(payload, "evidence");
1613
+ requireArray(payload, "eliminated_alternatives");
1614
+ requireField(payload, "risk_area");
1615
+ requireNonEmptyArray(payload, "evidence");
1616
+ return;
1617
+ }
1618
+ if (phase === "debug_fix") {
1619
+ requireArray(payload, "changed_files");
1620
+ requireArray(payload, "tests_added_or_updated");
1621
+ requireArray(payload, "remaining_risks");
1622
+ requireNonEmptyArray(payload, "changed_files");
1623
+ if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
1624
+ throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
1625
+ }
1626
+ return;
1627
+ }
1628
+ requireArray(payload, "commands");
1629
+ requireArray(payload, "required_regressions");
1630
+ requireField(payload, "build_status");
1631
+ requireField(payload, "test_status");
1632
+ requireBoolean(payload, "repro_negative");
1633
+ requireBoolean(payload, "qa_ready");
1634
+ if (payload.repro_negative !== true) {
1635
+ throw new Error("debug_regression pass requires repro_negative: true.");
1636
+ }
1637
+ if (payload.qa_ready !== true) {
1638
+ throw new Error("debug_regression pass requires qa_ready: true.");
1639
+ }
1640
+ requireAllowedStatus(payload, "build_status", ["pass", "not_applicable"]);
1641
+ requireAllowedStatus(payload, "test_status", ["pass", "not_applicable"]);
1642
+ requireNonEmptyArray(payload, "required_regressions");
1643
+ requirePassingCommands(payload);
1644
+ }
1645
+ function validateQaGatePayloadShape(gate, payload, status) {
1646
+ requireField(payload, "summary");
1647
+ if (status === "skipped") {
1648
+ requireField(payload, "reason");
1649
+ }
1650
+ if (status === "blocked") {
1651
+ const reason = normalizedString(optionalString(payload, "blocker_reason"));
1652
+ if (!qaBlockerReasons.has(reason)) {
1653
+ throw new Error("QA gate blocked requires blocker_reason to be one of missing_app_launch_target, inconclusive, environment_failure, missing_validation_evidence, missing_worktree, or runner_failure.");
1654
+ }
1655
+ }
1656
+ if (gate === "qa_plan" && status === "pass") {
1657
+ const checklist = qaStringArray(payload, "checklist");
1658
+ if (checklist.length === 0) {
1659
+ throw new Error("complete_qa_plan pass requires checklist with at least one entry.");
1660
+ }
1661
+ }
1662
+ if (gate === "qa_findings" && status !== "blocked") {
1663
+ qaObjectArray(payload, "findings");
1664
+ qaObjectArray(payload, "candidate_bugs");
1665
+ }
1666
+ if ((gate === "qa_regression" || gate === "qa_api") && status === "pass") {
1667
+ const commands = qaArray(payload, "commands");
1668
+ if (commands.length === 0) {
1669
+ throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
1670
+ }
1671
+ }
1672
+ if (gate === "qa_manual_web" && status === "pass") {
1673
+ const browserChecks = qaArray(payload, "browser_checks");
1674
+ const notes = optionalString(payload, "notes");
1675
+ if (browserChecks.length === 0 && !notes) {
1676
+ throw new Error(`${qaGateToolNames[gate]} pass requires browser_checks or notes.`);
1677
+ }
1678
+ }
1679
+ }
1017
1680
  function validateQaPayloadShape(payload, status) {
1018
1681
  const evidenceSource = qaEvidenceSource(payload);
1019
1682
  const commands = qaArray(payload, "commands", evidenceSource);
@@ -1153,10 +1816,36 @@ function normalizeArtifactPayload(payload, defaults) {
1153
1816
  normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
1154
1817
  normalized.phase = defaults.phase;
1155
1818
  normalized.work_item_id = defaults.workItemId;
1819
+ const ownerFields = artifactOwnerFields(defaults);
1820
+ Object.assign(normalized, ownerFields);
1156
1821
  normalized.status = defaults.status;
1157
1822
  normalized.produced_by = defaults.producedBy;
1158
1823
  return normalized;
1159
1824
  }
1825
+ function normalizeQaGateArtifactPayload(payload, defaults) {
1826
+ const normalized = stripUndefined({ ...payload });
1827
+ delete normalized.report_markdown;
1828
+ delete normalized.content_hash;
1829
+ delete normalized.contentHash;
1830
+ normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
1831
+ normalized.phase = "qa";
1832
+ normalized.gate = defaults.gate;
1833
+ normalized.work_item_id = defaults.workItemId;
1834
+ const ownerFields = artifactOwnerFields(defaults);
1835
+ Object.assign(normalized, ownerFields);
1836
+ normalized.status = defaults.status;
1837
+ normalized.produced_by = defaults.producedBy;
1838
+ if (defaults.gate === "qa_plan") {
1839
+ const checklist = qaStringArray(normalized, "checklist");
1840
+ normalized.checklist = checklist.length > 0 ? checklist : defaultQaChecklist(defaults.status);
1841
+ normalized.acceptance_criteria = qaStringArray(normalized, "acceptance_criteria");
1842
+ }
1843
+ if (defaults.gate === "qa_findings") {
1844
+ normalized.findings = qaObjectArray(normalized, "findings");
1845
+ normalized.candidate_bugs = qaObjectArray(normalized, "candidate_bugs");
1846
+ }
1847
+ return normalized;
1848
+ }
1160
1849
  function reportMarkdown(payload, phase, status, producedBy, contentHash) {
1161
1850
  const explicit = optionalString(payload, "report_markdown");
1162
1851
  if (explicit) {
@@ -1176,6 +1865,10 @@ ${summary}
1176
1865
  }
1177
1866
  function parseStrictCompletionPhase(value) {
1178
1867
  const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
1868
+ const debugPhase = normalizeDebugCompletionPhase(normalized);
1869
+ if (debugPhase) {
1870
+ return debugPhase;
1871
+ }
1179
1872
  if (normalized === "implementation" || normalized === "implement" || normalized === "develop") {
1180
1873
  return "implementation";
1181
1874
  }
@@ -1190,6 +1883,43 @@ function parseStrictCompletionPhase(value) {
1190
1883
  }
1191
1884
  return null;
1192
1885
  }
1886
+ function normalizeDebugCompletionPhase(value) {
1887
+ const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
1888
+ switch (normalized) {
1889
+ case "debug_repro":
1890
+ case "debugrepro":
1891
+ case "repro":
1892
+ case "reproduce":
1893
+ return "debug_repro";
1894
+ case "debug_root_cause":
1895
+ case "debugrootcause":
1896
+ case "root_cause":
1897
+ case "rootcause":
1898
+ return "debug_root_cause";
1899
+ case "debug_fix":
1900
+ case "debugfix":
1901
+ case "fix":
1902
+ return "debug_fix";
1903
+ case "debug_regression":
1904
+ case "debugregression":
1905
+ case "regression":
1906
+ return "debug_regression";
1907
+ default:
1908
+ return null;
1909
+ }
1910
+ }
1911
+ function isDevelopCompletionPhase(phase) {
1912
+ return phase === "implementation" || phase === "self_review" || phase === "validation";
1913
+ }
1914
+ function isDebugCompletionPhase(phase) {
1915
+ return debugCompletionPhaseOrder.includes(phase);
1916
+ }
1917
+ function asDebugCompletionPhase(phase) {
1918
+ if (isDebugCompletionPhase(phase)) {
1919
+ return phase;
1920
+ }
1921
+ throw new Error(`Expected Debug completion phase, got ${phase}.`);
1922
+ }
1193
1923
  function parseCompletionRedactionSecrets(raw) {
1194
1924
  if (!raw?.trim()) {
1195
1925
  return [];
@@ -1219,8 +1949,35 @@ function phaseFromToolName(toolName) {
1219
1949
  if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
1220
1950
  return "qa";
1221
1951
  }
1952
+ const debugGate = debugGateFromToolName(toolName);
1953
+ if (debugGate) {
1954
+ return debugGate;
1955
+ }
1956
+ if (qaGateFromToolName(toolName)) {
1957
+ return "qa";
1958
+ }
1222
1959
  throw new Error(`Unknown Forge completion tool: ${toolName}`);
1223
1960
  }
1961
+ function debugGateFromToolName(toolName) {
1962
+ const normalized = toolName.trim().toLowerCase();
1963
+ for (const gate of debugCompletionPhaseOrder) {
1964
+ const base = debugGateToolNames[gate];
1965
+ if (normalized === `forge.${base}` || normalized === base) {
1966
+ return gate;
1967
+ }
1968
+ }
1969
+ return null;
1970
+ }
1971
+ function qaGateFromToolName(toolName) {
1972
+ const normalized = toolName.trim().toLowerCase();
1973
+ for (const gate of qaCompletionGateOrder) {
1974
+ const base = qaGateToolNames[gate];
1975
+ if (normalized === `forge.${base}` || normalized === base) {
1976
+ return gate;
1977
+ }
1978
+ }
1979
+ return null;
1980
+ }
1224
1981
  function requireField(payload, key) {
1225
1982
  if (payload[key] === undefined || payload[key] === null || (typeof payload[key] === "string" && payload[key].trim().length === 0)) {
1226
1983
  throw new Error(`${key} is required.`);
@@ -1288,6 +2045,22 @@ function requireArray(payload, key) {
1288
2045
  throw new Error(`${key} must be an array.`);
1289
2046
  }
1290
2047
  }
2048
+ function requireNonEmptyArray(payload, key) {
2049
+ requireArray(payload, key);
2050
+ if (arrayLength(payload, key) === 0) {
2051
+ throw new Error(`${key} must include at least one value.`);
2052
+ }
2053
+ }
2054
+ function arrayLength(payload, key) {
2055
+ const value = payload[key];
2056
+ return Array.isArray(value) ? value.length : 0;
2057
+ }
2058
+ function requireAllowedStatus(payload, key, allowed) {
2059
+ const value = normalizedString(optionalString(payload, key));
2060
+ if (!allowed.includes(value)) {
2061
+ throw new Error(`${key} must be one of ${allowed.join(", ")}.`);
2062
+ }
2063
+ }
1291
2064
  function requireObject(payload, key) {
1292
2065
  const value = payload[key];
1293
2066
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -1406,6 +2179,36 @@ function hasSubstantiveJsonValue(value) {
1406
2179
  function normalizedString(value) {
1407
2180
  return typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
1408
2181
  }
2182
+ function normalizedOwnerType(value) {
2183
+ const normalized = normalizedString(value);
2184
+ return normalized === "work_item" || normalized === "story" ? normalized : undefined;
2185
+ }
2186
+ function artifactOwnerFields(config) {
2187
+ const ownerType = normalizedOwnerType(config.ownerType);
2188
+ const ownerId = config.ownerId?.trim();
2189
+ if (!ownerType && !ownerId) {
2190
+ return {};
2191
+ }
2192
+ return {
2193
+ owner_type: ownerType ?? "work_item",
2194
+ owner_id: ownerId || config.workItemId
2195
+ };
2196
+ }
2197
+ function artifactReferenceOwnerFields(config) {
2198
+ const owner = artifactOwnerFields(config);
2199
+ return owner.owner_type && owner.owner_id
2200
+ ? { ownerType: owner.owner_type, ownerId: owner.owner_id }
2201
+ : {};
2202
+ }
2203
+ function artifactOwnerDirectory(ownerType) {
2204
+ return normalizedOwnerType(ownerType) === "story" ? "stories" : "work-items";
2205
+ }
2206
+ function artifactOwnerRelativeRoot(ownerType, ownerId, workItemId) {
2207
+ const owner = artifactOwnerFields({ workItemId, ownerType, ownerId });
2208
+ return owner.owner_type && owner.owner_id
2209
+ ? [".forge", artifactOwnerDirectory(owner.owner_type), owner.owner_id, "artifacts"].join("/")
2210
+ : [".forge", "work-items", workItemId, "artifacts"].join("/");
2211
+ }
1409
2212
  function defaultQaChecklist(status) {
1410
2213
  return [
1411
2214
  "Review the validated work item against acceptance criteria.",
@@ -1416,6 +2219,9 @@ function defaultQaChecklist(status) {
1416
2219
  function qaArtifactRelativePath(workItemId, fileName) {
1417
2220
  return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
1418
2221
  }
2222
+ function qaArtifactRelativePathForOwner(ownerType, ownerId, fileName) {
2223
+ return [".forge", artifactOwnerDirectory(ownerType), ownerId, "artifacts", fileName].join("/");
2224
+ }
1419
2225
  function jsonText(value) {
1420
2226
  return `${JSON.stringify(value, null, 2)}\n`;
1421
2227
  }
@@ -1431,7 +2237,7 @@ function parseJsonObject(content, label) {
1431
2237
  return asRecord(JSON.parse(content), label);
1432
2238
  }
1433
2239
  catch (error) {
1434
- throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`);
2240
+ throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`, { cause: error });
1435
2241
  }
1436
2242
  }
1437
2243
  function parseOptionalJsonObject(content, label) {