@loucompanion/forge-bridge 0.1.1-dev.caea7216568f → 0.1.1-dev.e646c8fd6b9b

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.
@@ -3,9 +3,49 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { redactSecrets } from "../shared/redaction/redaction.js";
6
+ import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
6
7
  const toolServerName = "forge";
7
8
  const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
8
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
+ };
9
49
  const qaBlockerReasons = new Set([
10
50
  "missing_app_launch_target",
11
51
  "inconclusive",
@@ -14,6 +54,8 @@ const qaBlockerReasons = new Set([
14
54
  "missing_worktree",
15
55
  "runner_failure"
16
56
  ]);
57
+ const resolvedFindingStatusValues = ["resolved", "disputed", "not_reproduced"];
58
+ const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
17
59
  const qaFindingSeverities = new Set(["critical", "major", "minor"]);
18
60
  const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
19
61
  const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
@@ -33,6 +75,26 @@ const phaseFiles = {
33
75
  qa: {
34
76
  jsonFileName: "qa.verdict.json",
35
77
  reportFileName: "qa.report.md"
78
+ },
79
+ debug_repro: {
80
+ jsonFileName: "debug.repro.json",
81
+ reportFileName: "debug.report.md"
82
+ },
83
+ debug_root_cause: {
84
+ jsonFileName: "debug.root-cause.json",
85
+ reportFileName: "debug.report.md"
86
+ },
87
+ debug_fix: {
88
+ jsonFileName: "debug.fix.json",
89
+ reportFileName: "debug.report.md"
90
+ },
91
+ debug_regression: {
92
+ jsonFileName: "debug.regression.json",
93
+ reportFileName: "debug.report.md"
94
+ },
95
+ architect: {
96
+ jsonFileName: "architect.verdict.json",
97
+ reportFileName: "architect.report.md"
36
98
  }
37
99
  };
38
100
  export function completionPhaseFromDispatch(dispatch) {
@@ -40,6 +102,10 @@ export function completionPhaseFromDispatch(dispatch) {
40
102
  }
41
103
  export function normalizeCompletionPhase(value) {
42
104
  const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
105
+ const debugPhase = normalizeDebugCompletionPhase(normalized);
106
+ if (debugPhase) {
107
+ return debugPhase;
108
+ }
43
109
  if (normalized === "self_review" || normalized === "selfreview" || normalized === "review") {
44
110
  return "self_review";
45
111
  }
@@ -49,6 +115,9 @@ export function normalizeCompletionPhase(value) {
49
115
  if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
50
116
  return "qa";
51
117
  }
118
+ if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
119
+ return "architect";
120
+ }
52
121
  return "implementation";
53
122
  }
54
123
  export function usesDevelopCycleCompletion(dispatch) {
@@ -60,38 +129,97 @@ export function usesDevelopCycleCompletion(dispatch) {
60
129
  && lane === "develop"
61
130
  && completionPhaseFromDispatch(dispatch) === "implementation";
62
131
  }
132
+ export function usesQaCycleCompletion(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 === "qa"
137
+ && role === "qa"
138
+ && lane === "qa"
139
+ && completionPhaseFromDispatch(dispatch) === "qa";
140
+ }
141
+ export function usesDebugCycleCompletion(dispatch) {
142
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
143
+ const role = dispatch.execution.role.trim().toLowerCase();
144
+ const lane = dispatch.execution.lane.trim().toLowerCase();
145
+ return kind === "debug"
146
+ && role === "debug"
147
+ && lane === "debug"
148
+ && isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
149
+ }
150
+ export function usesArchitectCycleCompletion(dispatch) {
151
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
152
+ const role = dispatch.execution.role.trim().toLowerCase();
153
+ const lane = dispatch.execution.lane.trim().toLowerCase();
154
+ return kind === "architect" || role === "architect" || lane === "architect";
155
+ }
63
156
  export function completionToolNamesForPhase(phase) {
157
+ if (isDebugCompletionPhase(phase)) {
158
+ return completionToolNamesForDebugGate(phase);
159
+ }
64
160
  const base = phase === "implementation"
65
161
  ? "complete_implementation"
66
162
  : phase === "self_review"
67
163
  ? "complete_self_review"
68
164
  : phase === "validation"
69
165
  ? "complete_validation"
70
- : "complete_qa";
166
+ : phase === "architect"
167
+ ? "complete_architect_plan"
168
+ : "complete_qa";
169
+ return [`forge.${base}`, base];
170
+ }
171
+ export function completionToolNamesForQaGate(gate) {
172
+ const base = qaGateToolNames[gate];
173
+ return [`forge.${base}`, base];
174
+ }
175
+ export function completionToolNamesForDebugGate(gate) {
176
+ const base = debugGateToolNames[gate];
71
177
  return [`forge.${base}`, base];
72
178
  }
73
179
  export function expectedCompletionToolName(phase) {
74
180
  return completionToolNamesForPhase(phase)[0];
75
181
  }
182
+ export function expectedQaCompletionToolName(gate) {
183
+ return completionToolNamesForQaGate(gate)[0];
184
+ }
185
+ export function expectedDebugCompletionToolName(gate) {
186
+ return completionToolNamesForDebugGate(gate)[0];
187
+ }
76
188
  export function artifactRootFor(worktreePath, workItemId) {
77
189
  return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
78
190
  }
79
- export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
80
- return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].jsonFileName);
191
+ export function artifactRootForOwner(worktreePath, ownerType, ownerId) {
192
+ return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
193
+ }
194
+ export function completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId) {
195
+ const artifactRoot = ownerType && ownerId
196
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
197
+ : artifactRootFor(worktreePath, workItemId);
198
+ return path.join(artifactRoot, phaseFiles[phase].jsonFileName);
81
199
  }
82
- export function completionArtifactReportPath(worktreePath, workItemId, phase) {
83
- return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].reportFileName);
200
+ export function completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId) {
201
+ const artifactRoot = ownerType && ownerId
202
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
203
+ : artifactRootFor(worktreePath, workItemId);
204
+ return path.join(artifactRoot, phaseFiles[phase].reportFileName);
84
205
  }
85
206
  export async function prepareCompletionToolRuntime(options) {
86
207
  const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
87
208
  const worktreePath = path.resolve(options.worktree.worktreePath);
88
209
  const workItemId = options.dispatch.workItem.id;
210
+ const ownerType = normalizedOwnerType(options.dispatch.cycleRun.ownerType) ?? "work_item";
211
+ const ownerId = options.dispatch.cycleRun.ownerId ?? workItemId;
89
212
  const mcpArgs = buildCompletionToolServerArgs({
90
213
  worktreePath,
91
214
  workItemId,
215
+ ownerType,
216
+ ownerId,
92
217
  phase,
93
218
  producedBy: `${options.dispatch.execution.provider}-cli`,
94
- developCycle: usesDevelopCycleCompletion(options.dispatch)
219
+ developCycle: usesDevelopCycleCompletion(options.dispatch),
220
+ qaCycle: usesQaCycleCompletion(options.dispatch),
221
+ debugCycle: usesDebugCycleCompletion(options.dispatch),
222
+ architectCycle: usesArchitectCycleCompletion(options.dispatch)
95
223
  });
96
224
  const runtime = {
97
225
  serverName: toolServerName,
@@ -117,9 +245,22 @@ export function buildCompletionToolServerArgs(config) {
117
245
  "--produced-by",
118
246
  config.producedBy ?? "forge-completion-tool"
119
247
  ];
248
+ if (config.ownerType || config.ownerId) {
249
+ args.push("--owner-type", normalizedOwnerType(config.ownerType) ?? "work_item");
250
+ args.push("--owner-id", config.ownerId ?? config.workItemId);
251
+ }
120
252
  if (config.developCycle) {
121
253
  args.push("--develop-cycle");
122
254
  }
255
+ if (config.qaCycle) {
256
+ args.push("--qa-cycle");
257
+ }
258
+ if (config.debugCycle) {
259
+ args.push("--debug-cycle");
260
+ }
261
+ if (config.architectCycle) {
262
+ args.push("--architect-cycle");
263
+ }
123
264
  return args;
124
265
  }
125
266
  export function codexMcpConfigArgs(runtime) {
@@ -152,6 +293,16 @@ Do not write QA artifacts by hand. When QA is complete, call \`${toolName}(...)\
152
293
  That tool writes the durable QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`${artifactName}\`, and \`qa.report.md\`.
153
294
  Use \`status: "pass"\` only when the validated changes satisfy acceptance criteria with concrete evidence. Use \`status: "needs_rework"\` with actionable in-scope findings, or \`status: "blocked"\` with a concrete blocker_reason.
154
295
  Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
296
+ }
297
+ if (phase === "architect") {
298
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
299
+ Do not write Architect artifacts by hand. When the plan is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the full structured Architect planning payload.
300
+ That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
301
+ The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
302
+ \`{ "status": "pass", "summary": "...", "implementation_plan": { "schema_version": 1, "plan_id": "plan-...", "phases": [{ "id": "phase-1", "tasks": ["task-1"], "gate": "gate-1" }], "tasks": [{ "id": "task-1", "tests": [{ "command": "npm test" }] }], "dependencies": [], "phase_gates": [{ "id": "gate-1", "phase": "phase-1" }] }, "qa_plan": { "schema_version": 1, "final_qa": [{ "id": "qa-1", "coverage": "..." }] }, "report_markdown": "# Architect Report" }\`.
303
+ Dependency entries use canonical \`before\` and \`after\` task ids, for example \`{ "before": "task-1", "after": "task-2" }\`. Forge accepts \`from\`/\`to\` only as aliases and normalizes them before writing artifacts.
304
+ Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
305
+ Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
155
306
  }
156
307
  return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
157
308
  Do not write phase artifacts by hand. When the phase is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the required evidence payload.
@@ -168,31 +319,74 @@ Complete the dev cycle in order inside this same provider session:
168
319
  Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
169
320
  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.`;
170
321
  }
322
+ export function completionPromptForQaCycle(currentGate = "qa_plan") {
323
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
324
+ Do not write QA artifacts by hand. The tool server enforces QA gate order and starts this run at \`${currentGate}\`.
325
+ Complete the QA cycle in order inside this same provider session:
326
+ 1. Call \`forge.complete_qa_plan(...)\` after the QA test plan/checklist is ready.
327
+ 2. Follow the returned regression instruction, then call \`forge.complete_qa_regression(...)\`.
328
+ 3. Follow the returned API/backend instruction, then call \`forge.complete_qa_api(...)\`.
329
+ 4. Follow the returned manual web instruction, then call \`forge.complete_qa_manual_web(...)\`.
330
+ 5. Follow the returned bug reproduction instruction, then call \`forge.complete_qa_bug_repro(...)\`.
331
+ 6. Follow the returned findings instruction, then call \`forge.complete_qa_findings(...)\`.
332
+ 7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
333
+ 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.
334
+ 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\`.
335
+ The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
336
+ }
337
+ export function completionPromptForDebugCycle(currentGate = "debug_repro") {
338
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
339
+ Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
340
+ Complete the Debug cycle in order inside this same provider session:
341
+ 1. Reproduce or tightly characterize the bug, then call \`forge.complete_debug_repro(...)\`.
342
+ 2. Follow the returned root-cause instruction, isolate evidence, then call \`forge.complete_debug_root_cause(...)\`.
343
+ 3. Follow the returned fix instruction, apply the smallest safe fix with regression coverage when practical, then call \`forge.complete_debug_fix(...)\`.
344
+ 4. Follow the returned regression instruction, prove the original repro is negative and the fix is QA-ready, then call \`forge.complete_debug_regression(...)\`.
345
+ 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.
346
+ 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.`;
347
+ }
171
348
  export function completionToolNamesForServer(config) {
172
- return config.developCycle
173
- ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
174
- : completionToolNamesForPhase(config.phase);
349
+ return config.debugCycle
350
+ ? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
351
+ : config.qaCycle
352
+ ? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
353
+ : config.developCycle
354
+ ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
355
+ : config.architectCycle
356
+ ? completionToolNamesForPhase("architect")
357
+ : completionToolNamesForPhase(config.phase);
175
358
  }
176
359
  export class CompletionToolServerSession {
177
360
  config;
178
361
  currentPhase;
362
+ currentQaGate = "qa_plan";
179
363
  completed = false;
180
364
  constructor(config) {
181
365
  this.config = config;
182
366
  this.currentPhase = config.phase;
183
367
  }
184
368
  get instructions() {
185
- return this.config.developCycle
186
- ? completionPromptForDevelopCycle(this.currentPhase)
187
- : completionPromptForPhase(this.currentPhase);
369
+ return this.config.debugCycle
370
+ ? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
371
+ : this.config.qaCycle
372
+ ? completionPromptForQaCycle(this.currentQaGate)
373
+ : this.config.developCycle
374
+ ? completionPromptForDevelopCycle(this.currentPhase)
375
+ : completionPromptForPhase(this.currentPhase);
188
376
  }
189
377
  toolDefinitions() {
190
- return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name)));
378
+ return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
191
379
  }
192
380
  async callTool(toolName, input) {
193
381
  const requestedPhase = phaseFromToolName(toolName);
194
382
  if (this.completed) {
195
- throw new Error("Forge develop cycle is already complete; no more phase completion calls are accepted.");
383
+ throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
384
+ }
385
+ if (this.config.debugCycle) {
386
+ return await this.callDebugGateTool(toolName, requestedPhase, input);
387
+ }
388
+ if (this.config.qaCycle) {
389
+ return await this.callQaGateTool(toolName, requestedPhase, input);
196
390
  }
197
391
  if (requestedPhase !== this.currentPhase) {
198
392
  throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
@@ -228,10 +422,91 @@ export class CompletionToolServerSession {
228
422
  }
229
423
  };
230
424
  }
425
+ async callDebugGateTool(toolName, requestedPhase, input) {
426
+ const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
427
+ if (!isDebugCompletionPhase(requestedPhase)) {
428
+ throw new Error(`Tool ${toolName} is not available in Debug-cycle mode. Current Forge Debug gate is ${currentDebugGate}.`);
429
+ }
430
+ if (requestedPhase !== currentDebugGate) {
431
+ throw new Error(`Tool ${toolName} is out of order. Current Forge Debug gate is ${currentDebugGate}.`);
432
+ }
433
+ const completedGate = currentDebugGate;
434
+ const reference = await writeCompletionArtifact({ ...this.config, phase: completedGate }, toolName, input);
435
+ const status = reference.status.trim().toLowerCase();
436
+ const pass = status === "pass";
437
+ const nextGate = pass ? this.advanceAfterDebugGate(completedGate) : completedGate;
438
+ const nextPhase = nextGate ?? "qa_ready";
439
+ const nextInstruction = completionToolNextInstructionForDebugGate(completedGate, status, nextGate);
440
+ const reason = pass
441
+ ? `${completedGate} artifact accepted${nextGate ? `; continue to ${nextGate}.` : "; Debug regression is QA-ready."}`
442
+ : `${completedGate} artifact recorded with status ${status}; rework is required before Debug progression.`;
443
+ return {
444
+ content: [
445
+ {
446
+ type: "text",
447
+ text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
448
+ }
449
+ ],
450
+ structuredContent: {
451
+ ...reference,
452
+ gate: {
453
+ phase: completedGate,
454
+ status,
455
+ allowed: pass,
456
+ reason
457
+ },
458
+ nextPhase,
459
+ nextInstruction
460
+ }
461
+ };
462
+ }
463
+ async callQaGateTool(toolName, requestedPhase, input) {
464
+ const requestedGate = qaGateFromToolName(toolName);
465
+ if (requestedPhase !== "qa" || !requestedGate) {
466
+ throw new Error(`Tool ${toolName} is not available in QA-cycle mode. Current Forge QA gate is ${this.currentQaGate}.`);
467
+ }
468
+ if (requestedGate !== this.currentQaGate) {
469
+ throw new Error(`Tool ${toolName} is out of order. Current Forge QA gate is ${this.currentQaGate}.`);
470
+ }
471
+ const completedGate = this.currentQaGate;
472
+ const reference = await writeCompletionArtifact({ ...this.config, phase: "qa" }, toolName, input);
473
+ const status = reference.status.trim().toLowerCase();
474
+ const nextGate = this.advanceAfterQaGate(completedGate);
475
+ const nextPhase = nextGate ? "qa" : "sign_off_ready";
476
+ const nextInstruction = completionToolNextInstructionForQaGate(completedGate, status, nextGate);
477
+ const allowed = status !== "blocked";
478
+ const reason = nextGate
479
+ ? `${completedGate} artifact accepted; continue to ${nextGate}.`
480
+ : `QA verdict artifact accepted with status ${status}.`;
481
+ return {
482
+ content: [
483
+ {
484
+ type: "text",
485
+ text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
486
+ }
487
+ ],
488
+ structuredContent: {
489
+ ...reference,
490
+ gate: {
491
+ phase: "qa",
492
+ qaGate: completedGate,
493
+ status,
494
+ allowed,
495
+ reason
496
+ },
497
+ nextPhase,
498
+ nextInstruction
499
+ }
500
+ };
501
+ }
231
502
  advanceAfterPass(phase) {
232
503
  if (phase === "qa") {
504
+ this.completed = true;
233
505
  return "sign_off_ready";
234
506
  }
507
+ if (isDebugCompletionPhase(phase)) {
508
+ return nextDebugPhaseAfter(phase) ?? "qa_ready";
509
+ }
235
510
  const next = nextPhaseAfter(phase);
236
511
  if (!this.config.developCycle) {
237
512
  return next ?? "dev_test_ready";
@@ -243,15 +518,42 @@ export class CompletionToolServerSession {
243
518
  this.completed = true;
244
519
  return "dev_test_ready";
245
520
  }
521
+ advanceAfterQaGate(gate) {
522
+ const index = qaCompletionGateOrder.indexOf(gate);
523
+ const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
524
+ if (next) {
525
+ this.currentQaGate = next;
526
+ return next;
527
+ }
528
+ this.completed = true;
529
+ return null;
530
+ }
531
+ advanceAfterDebugGate(gate) {
532
+ const index = debugCompletionPhaseOrder.indexOf(gate);
533
+ const next = index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
534
+ if (next) {
535
+ this.currentPhase = next;
536
+ return next;
537
+ }
538
+ this.completed = true;
539
+ return null;
540
+ }
246
541
  }
247
542
  export async function writeCompletionArtifact(config, toolName, input) {
248
543
  const phase = phaseFromToolName(toolName);
249
544
  if (phase !== config.phase) {
250
545
  throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
251
546
  }
547
+ const qaGate = qaGateFromToolName(toolName);
548
+ if (qaGate && qaGate !== "qa_verdict") {
549
+ return await writeQaGateCompletionArtifact(config, qaGate, input);
550
+ }
252
551
  const payload = asRecord(input, "completion payload");
253
552
  const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
254
- const validationPayload = { ...payload, status };
553
+ let validationPayload = { ...payload, status };
554
+ if (config.qaCycle && qaGate === "qa_verdict") {
555
+ validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
556
+ }
255
557
  validatePayloadShape(phase, validationPayload);
256
558
  validateOptionalWorkItemId(validationPayload, config.workItemId);
257
559
  const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
@@ -262,9 +564,14 @@ export async function writeCompletionArtifact(config, toolName, input) {
262
564
  if (phase === "qa") {
263
565
  return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
264
566
  }
567
+ if (phase === "architect") {
568
+ return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
569
+ }
265
570
  const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
266
571
  phase,
267
572
  workItemId: config.workItemId,
573
+ ownerType: config.ownerType,
574
+ ownerId: config.ownerId,
268
575
  producedBy,
269
576
  status
270
577
  });
@@ -283,6 +590,93 @@ export async function writeCompletionArtifact(config, toolName, input) {
283
590
  return {
284
591
  phase,
285
592
  workItemId: config.workItemId,
593
+ ...artifactReferenceOwnerFields(config),
594
+ repoPath: path.resolve(config.worktreePath),
595
+ path: artifactPath,
596
+ reportPath,
597
+ contentHash,
598
+ status,
599
+ producedBy
600
+ };
601
+ }
602
+ async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
603
+ const ownerFields = artifactOwnerFields(config);
604
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
605
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
606
+ : artifactRootFor(config.worktreePath, config.workItemId);
607
+ const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
608
+ const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
609
+ const verdictPath = path.join(artifactRoot, "architect.verdict.json");
610
+ const reportPath = path.join(artifactRoot, "architect.report.md");
611
+ const implementationPlan = requiredRecord(payload, "implementation_plan");
612
+ const qaPlan = requiredRecord(payload, "qa_plan");
613
+ const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
614
+ const artifactWithoutHash = normalizeArtifactPayload(payload, {
615
+ phase: "architect",
616
+ workItemId: config.workItemId,
617
+ ownerType: config.ownerType,
618
+ ownerId: config.ownerId,
619
+ producedBy,
620
+ status
621
+ });
622
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
623
+ const verdict = {
624
+ ...artifactWithoutHash,
625
+ content_hash: contentHash
626
+ };
627
+ await fs.mkdir(artifactRoot, { recursive: true });
628
+ await writeFileAtomic(implementationPlanPath, jsonText(implementationPlan));
629
+ await writeFileAtomic(qaPlanPath, jsonText(qaPlan));
630
+ await writeFileAtomic(verdictPath, jsonText(verdict));
631
+ await writeFileAtomic(reportPath, `${report.trim()}\n`);
632
+ return {
633
+ phase: "architect",
634
+ workItemId: config.workItemId,
635
+ ...artifactReferenceOwnerFields(config),
636
+ repoPath: path.resolve(config.worktreePath),
637
+ path: verdictPath,
638
+ reportPath,
639
+ contentHash,
640
+ status,
641
+ producedBy
642
+ };
643
+ }
644
+ async function writeQaGateCompletionArtifact(config, gate, input) {
645
+ const payload = asRecord(input, "QA gate completion payload");
646
+ const status = normalizeQaGateStatus(requiredString(payload, "status"));
647
+ validateQaGatePayloadShape(gate, payload, status);
648
+ validateOptionalWorkItemId(payload, config.workItemId);
649
+ const redactedPayload = redactSecrets({ ...payload, status }, config.redactionSecrets ?? []);
650
+ const producedBy = optionalString(redactedPayload, "produced_by")
651
+ ?? optionalString(redactedPayload, "producedBy")
652
+ ?? config.producedBy
653
+ ?? "forge-completion-tool";
654
+ const ownerFields = artifactOwnerFields(config);
655
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
656
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
657
+ : artifactRootFor(config.worktreePath, config.workItemId);
658
+ const fileName = qaGateFileNames[gate];
659
+ const artifactPath = path.join(artifactRoot, fileName);
660
+ const reportPath = path.join(artifactRoot, "qa.report.md");
661
+ const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
662
+ gate,
663
+ workItemId: config.workItemId,
664
+ ownerType: config.ownerType,
665
+ ownerId: config.ownerId,
666
+ producedBy,
667
+ status
668
+ });
669
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
670
+ const artifact = {
671
+ ...artifactWithoutHash,
672
+ content_hash: contentHash
673
+ };
674
+ await fs.mkdir(artifactRoot, { recursive: true });
675
+ await writeFileAtomic(artifactPath, jsonText(artifact));
676
+ return {
677
+ phase: "qa",
678
+ workItemId: config.workItemId,
679
+ ...artifactReferenceOwnerFields(config),
286
680
  repoPath: path.resolve(config.worktreePath),
287
681
  path: artifactPath,
288
682
  reportPath,
@@ -292,18 +686,28 @@ export async function writeCompletionArtifact(config, toolName, input) {
292
686
  };
293
687
  }
294
688
  async function writeQaCompletionArtifact(config, payload, status, producedBy) {
295
- const artifactRoot = artifactRootFor(config.worktreePath, config.workItemId);
689
+ const ownerFields = artifactOwnerFields(config);
690
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
691
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
692
+ : artifactRootFor(config.worktreePath, config.workItemId);
296
693
  const planPath = path.join(artifactRoot, "qa.plan.json");
297
694
  const evidencePath = path.join(artifactRoot, "qa.evidence.json");
298
695
  const findingsPath = path.join(artifactRoot, "qa.findings.json");
299
696
  const verdictPath = path.join(artifactRoot, "qa.verdict.json");
300
697
  const reportPath = path.join(artifactRoot, "qa.report.md");
301
- const relativeEvidencePath = qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
302
- const relativeFindingsPath = qaArtifactRelativePath(config.workItemId, "qa.findings.json");
303
- const relativeReportPath = qaArtifactRelativePath(config.workItemId, "qa.report.md");
698
+ const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
699
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
700
+ : qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
701
+ const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
702
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
703
+ : qaArtifactRelativePath(config.workItemId, "qa.findings.json");
704
+ const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
705
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
706
+ : qaArtifactRelativePath(config.workItemId, "qa.report.md");
304
707
  const evidenceSource = qaEvidenceSource(payload);
305
708
  const commands = qaArray(payload, "commands", evidenceSource);
306
709
  const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
710
+ const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
307
711
  const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
308
712
  const findings = qaObjectArray(payload, "findings");
309
713
  const candidateBugs = qaObjectArray(payload, "candidate_bugs");
@@ -315,19 +719,23 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
315
719
  const planJson = jsonText({
316
720
  schema_version: 1,
317
721
  phase: "qa",
722
+ ...ownerFields,
318
723
  checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
319
724
  acceptance_criteria: testedAcceptanceCriteria
320
725
  });
321
726
  const evidenceJson = jsonText({
322
727
  schema_version: 1,
323
728
  phase: "qa",
729
+ ...ownerFields,
324
730
  commands,
325
731
  browser_checks: browserChecks,
732
+ sidecars,
326
733
  notes
327
734
  });
328
735
  const findingsJson = jsonText({
329
736
  schema_version: 1,
330
737
  phase: "qa",
738
+ ...ownerFields,
331
739
  findings,
332
740
  candidate_bugs: candidateBugs
333
741
  });
@@ -347,6 +755,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
347
755
  const verdictWithoutHash = {
348
756
  schema_version: numberOrDefault(payload.schema_version, 1),
349
757
  phase: "qa",
758
+ ...ownerFields,
350
759
  status,
351
760
  summary: requiredString(payload, "summary"),
352
761
  tested_acceptance_criteria: testedAcceptanceCriteria,
@@ -378,6 +787,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
378
787
  return {
379
788
  phase: "qa",
380
789
  workItemId: config.workItemId,
790
+ ...artifactReferenceOwnerFields(config),
381
791
  repoPath: path.resolve(config.worktreePath),
382
792
  path: verdictPath,
383
793
  reportPath,
@@ -386,19 +796,208 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
386
796
  producedBy
387
797
  };
388
798
  }
389
- export async function readCompletionArtifact(worktreePath, workItemId, phase) {
390
- const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
391
- const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
799
+ async function synthesizeQaCycleVerdictPayload(config, payload, status) {
800
+ const qaPlan = await readQaGateArtifact(config, "qa_plan");
801
+ const qaRegression = await readQaGateArtifact(config, "qa_regression");
802
+ const qaApi = await readQaGateArtifact(config, "qa_api");
803
+ const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
804
+ const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
805
+ const qaFindings = await readQaGateArtifact(config, "qa_findings");
806
+ const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
807
+ const payloadEvidence = qaEvidenceSource(payload);
808
+ const merged = { ...payload };
809
+ const checklist = qaStringArray(payload, "checklist");
810
+ if (checklist.length === 0) {
811
+ const plannedChecklist = qaStringArray(qaPlan, "checklist");
812
+ merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
813
+ }
814
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
815
+ if (testedAcceptanceCriteria.length === 0) {
816
+ const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
817
+ const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
818
+ const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
819
+ if (acceptanceCriteria.length > 0) {
820
+ merged.tested_acceptance_criteria = acceptanceCriteria;
821
+ }
822
+ }
823
+ const payloadCommands = qaArray(payload, "commands", payloadEvidence);
824
+ const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
825
+ const inheritedDiagnosticCommandNotes = status === "pass"
826
+ ? qaNonPassingCommandNotes(inheritedCommands)
827
+ : [];
828
+ merged.commands = dedupeUnknownArray([
829
+ ...payloadCommands,
830
+ ...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
831
+ ]);
832
+ merged.browser_checks = dedupeUnknownArray([
833
+ ...qaArray(payload, "browser_checks", payloadEvidence),
834
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
835
+ ]);
836
+ merged.sidecars = dedupeUnknownArray([
837
+ ...qaArray(payload, "sidecars", payloadEvidence),
838
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
839
+ ]);
840
+ const notes = dedupeStrings([
841
+ optionalString(payload, "notes"),
842
+ optionalString(payloadEvidence, "notes"),
843
+ ...evidenceGates.map((gate) => optionalString(gate, "notes")),
844
+ ...inheritedDiagnosticCommandNotes
845
+ ]);
846
+ if (notes.length > 0) {
847
+ merged.notes = notes.join("\n");
848
+ }
849
+ merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
850
+ merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
851
+ return merged;
852
+ }
853
+ async function readQaGateArtifact(config, gate) {
854
+ const ownerFields = artifactOwnerFields(config);
855
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
856
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
857
+ : artifactRootFor(config.worktreePath, config.workItemId);
858
+ const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
859
+ const content = await readOptionalFile(artifactPath);
860
+ return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
861
+ }
862
+ function dedupeUnknownArray(values) {
863
+ const seen = new Set();
864
+ const deduped = [];
865
+ for (const value of values) {
866
+ const key = stableStringify(value);
867
+ if (seen.has(key)) {
868
+ continue;
869
+ }
870
+ seen.add(key);
871
+ deduped.push(value);
872
+ }
873
+ return deduped;
874
+ }
875
+ function isQaPassingCommandEvidence(value) {
876
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
877
+ return false;
878
+ }
879
+ const record = value;
880
+ return record.exit_code === 0 && normalizedString(record.status) === "pass";
881
+ }
882
+ function qaNonPassingCommandNotes(values) {
883
+ return values
884
+ .filter((value) => !isQaPassingCommandEvidence(value))
885
+ .map((value, index) => {
886
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
887
+ return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
888
+ }
889
+ const record = value;
890
+ const command = optionalString(record, "command") ?? "<missing command>";
891
+ const status = optionalString(record, "status") ?? "<missing status>";
892
+ const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
893
+ ? String(record.exit_code)
894
+ : "<missing exit_code>";
895
+ const summary = optionalString(record, "summary");
896
+ return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
897
+ });
898
+ }
899
+ function mergeQaObjectArraysById(primary, fallback) {
900
+ const seen = new Set();
901
+ const merged = [];
902
+ for (const value of [...primary, ...fallback]) {
903
+ const id = optionalString(value, "id");
904
+ const key = id ? `id:${id}` : stableStringify(value);
905
+ if (seen.has(key)) {
906
+ continue;
907
+ }
908
+ seen.add(key);
909
+ merged.push(value);
910
+ }
911
+ return merged;
912
+ }
913
+ function dedupeStrings(values) {
914
+ const seen = new Set();
915
+ const deduped = [];
916
+ for (const value of values) {
917
+ const trimmed = value?.trim();
918
+ if (!trimmed || seen.has(trimmed)) {
919
+ continue;
920
+ }
921
+ seen.add(trimmed);
922
+ deduped.push(trimmed);
923
+ }
924
+ return deduped;
925
+ }
926
+ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
927
+ if (sidecars.length === 0) {
928
+ return [];
929
+ }
930
+ const artifactRoot = ownerType && ownerId
931
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
932
+ : artifactRootFor(worktreePath, workItemId);
933
+ const qaSidecarRoot = path.resolve(artifactRoot, "qa");
934
+ const validated = [];
935
+ for (const [index, sidecar] of sidecars.entries()) {
936
+ if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
937
+ throw new Error(`sidecars[${index}] must be an object.`);
938
+ }
939
+ const record = sidecar;
940
+ const sidecarPath = optionalString(record, "path");
941
+ const contentHash = optionalString(record, "content_hash");
942
+ const bytes = record.bytes;
943
+ if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
944
+ throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
945
+ }
946
+ if (path.isAbsolute(sidecarPath)) {
947
+ throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
948
+ }
949
+ const fullPath = path.resolve(worktreePath, sidecarPath);
950
+ if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
951
+ throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
952
+ }
953
+ const stat = await fs.lstat(fullPath).catch((error) => {
954
+ if (isNodeError(error) && error.code === "ENOENT") {
955
+ throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
956
+ }
957
+ throw error;
958
+ });
959
+ if (!stat.isFile() || stat.isSymbolicLink()) {
960
+ throw new Error(`sidecars[${index}].path must reference a regular file.`);
961
+ }
962
+ await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
963
+ if (stat.size !== bytes) {
964
+ throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
965
+ }
966
+ const actualHash = await sha256File(fullPath);
967
+ if (actualHash !== contentHash.trim().toLowerCase()) {
968
+ throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
969
+ }
970
+ validated.push({
971
+ ...record,
972
+ path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
973
+ content_hash: actualHash,
974
+ bytes: stat.size
975
+ });
976
+ }
977
+ return validated;
978
+ }
979
+ export async function readCompletionArtifact(worktreePath, workItemId, phase, ownerType, ownerId) {
980
+ const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId);
981
+ const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId);
392
982
  const verdictJson = await readOptionalFile(artifactPath);
393
983
  if (verdictJson === null) {
394
984
  return null;
395
985
  }
396
986
  const parsed = parseJsonObject(verdictJson, artifactPath);
397
987
  const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
988
+ const artifactRoot = path.dirname(artifactPath);
989
+ const implementationPlanJson = phase === "architect"
990
+ ? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
991
+ : null;
992
+ const qaPlanJson = phase === "architect"
993
+ ? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
994
+ : null;
398
995
  return {
399
996
  repoPath: path.resolve(worktreePath),
400
997
  path: artifactPath,
401
998
  verdictJson,
999
+ implementationPlanJson,
1000
+ qaPlanJson,
402
1001
  report,
403
1002
  producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
404
1003
  status: optionalString(parsed, "status") ?? "unknown",
@@ -430,8 +1029,13 @@ export function parseCompletionMcpArgs(argv) {
430
1029
  let worktreePath = null;
431
1030
  let workItemId = null;
432
1031
  let phase = "implementation";
1032
+ let ownerType;
1033
+ let ownerId;
433
1034
  let producedBy;
434
1035
  let developCycle = false;
1036
+ let qaCycle = false;
1037
+ let debugCycle = false;
1038
+ let architectCycle = false;
435
1039
  for (let index = 1; index < argv.length; index += 1) {
436
1040
  const arg = argv[index];
437
1041
  if (arg === "--worktree") {
@@ -440,11 +1044,20 @@ export function parseCompletionMcpArgs(argv) {
440
1044
  else if (arg === "--work-item-id") {
441
1045
  workItemId = argv[++index] ?? null;
442
1046
  }
1047
+ else if (arg === "--owner-type") {
1048
+ ownerType = normalizedOwnerType(argv[++index]);
1049
+ if (!ownerType) {
1050
+ return { ok: false, error: "mcp --owner-type must be work_item or story." };
1051
+ }
1052
+ }
1053
+ else if (arg === "--owner-id") {
1054
+ ownerId = argv[++index] ?? undefined;
1055
+ }
443
1056
  else if (arg === "--phase") {
444
1057
  const rawPhase = argv[++index];
445
1058
  const parsedPhase = parseStrictCompletionPhase(rawPhase);
446
1059
  if (!parsedPhase) {
447
- return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa." };
1060
+ return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, architect, debug-repro, debug-root-cause, debug-fix, debug-regression." };
448
1061
  }
449
1062
  phase = parsedPhase;
450
1063
  }
@@ -454,6 +1067,15 @@ export function parseCompletionMcpArgs(argv) {
454
1067
  else if (arg === "--develop-cycle") {
455
1068
  developCycle = true;
456
1069
  }
1070
+ else if (arg === "--qa-cycle") {
1071
+ qaCycle = true;
1072
+ }
1073
+ else if (arg === "--debug-cycle") {
1074
+ debugCycle = true;
1075
+ }
1076
+ else if (arg === "--architect-cycle") {
1077
+ architectCycle = true;
1078
+ }
457
1079
  else {
458
1080
  return { ok: false, error: `Unknown mcp argument: ${arg}` };
459
1081
  }
@@ -461,15 +1083,35 @@ export function parseCompletionMcpArgs(argv) {
461
1083
  if (!worktreePath?.trim() || !workItemId?.trim()) {
462
1084
  return { ok: false, error: "mcp requires --worktree and --work-item-id." };
463
1085
  }
1086
+ if ([developCycle, qaCycle, debugCycle, architectCycle].filter(Boolean).length > 1) {
1087
+ return { ok: false, error: "mcp accepts only one cycle mode." };
1088
+ }
1089
+ if (developCycle && !isDevelopCompletionPhase(phase)) {
1090
+ return { ok: false, error: "mcp --develop-cycle requires --phase implementation, self-review, or validation." };
1091
+ }
1092
+ if (qaCycle && phase !== "qa") {
1093
+ return { ok: false, error: "mcp --qa-cycle requires --phase qa." };
1094
+ }
1095
+ if (debugCycle && !isDebugCompletionPhase(phase)) {
1096
+ return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
1097
+ }
1098
+ if (architectCycle && phase !== "architect") {
1099
+ return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
1100
+ }
464
1101
  return {
465
1102
  ok: true,
466
1103
  config: {
467
1104
  worktreePath: path.resolve(worktreePath),
468
1105
  workItemId,
1106
+ ownerType,
1107
+ ownerId,
469
1108
  phase,
470
1109
  producedBy,
471
1110
  redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
472
- developCycle
1111
+ developCycle,
1112
+ qaCycle,
1113
+ debugCycle,
1114
+ architectCycle
473
1115
  }
474
1116
  };
475
1117
  }
@@ -564,17 +1206,24 @@ async function handleJsonRpcRequest(session, method, params) {
564
1206
  }
565
1207
  throw new Error(`Unsupported MCP method: ${method}`);
566
1208
  }
567
- function toolDefinition(name, phase) {
1209
+ function toolDefinition(name, phase, qaGate = null) {
568
1210
  return {
569
1211
  name,
570
- description: toolDescription(phase),
571
- inputSchema: toolInputSchema(phase)
1212
+ description: toolDescription(phase, qaGate),
1213
+ inputSchema: toolInputSchema(phase, qaGate)
572
1214
  };
573
1215
  }
574
1216
  function nextPhaseAfter(phase) {
1217
+ if (!isDevelopCompletionPhase(phase)) {
1218
+ return null;
1219
+ }
575
1220
  const index = completionPhaseOrder.indexOf(phase);
576
1221
  return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
577
1222
  }
1223
+ function nextDebugPhaseAfter(phase) {
1224
+ const index = debugCompletionPhaseOrder.indexOf(phase);
1225
+ return index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
1226
+ }
578
1227
  function completionToolNextInstruction(completedPhase, status, nextPhase) {
579
1228
  if (completedPhase === "qa") {
580
1229
  if (status === "blocked") {
@@ -588,6 +1237,10 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
588
1237
  if (status !== "pass") {
589
1238
  return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
590
1239
  }
1240
+ if (isDebugCompletionPhase(completedPhase)) {
1241
+ const debugNext = isDebugCompletionPhase(nextPhase) ? nextPhase : null;
1242
+ return completionToolNextInstructionForDebugGate(completedPhase, status, debugNext);
1243
+ }
591
1244
  if (nextPhase === "self_review") {
592
1245
  return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
593
1246
  }
@@ -596,7 +1249,67 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
596
1249
  }
597
1250
  return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
598
1251
  }
599
- function toolDescription(phase) {
1252
+ function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
1253
+ if (!nextGate) {
1254
+ if (status === "blocked") {
1255
+ return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
1256
+ }
1257
+ if (status === "needs_rework") {
1258
+ return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
1259
+ }
1260
+ return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
1261
+ }
1262
+ const skipped = status === "skipped";
1263
+ const blocked = status === "blocked";
1264
+ const prefix = blocked
1265
+ ? `${completedGate} recorded a blocker. Continue through ${nextGate} and mark downstream gates skipped if the blocker prevents evidence collection.`
1266
+ : skipped
1267
+ ? `${completedGate} skipped. Continue to ${nextGate}.`
1268
+ : `${completedGate} accepted. Continue to ${nextGate}.`;
1269
+ return `${prefix} ${qaGateInstruction(nextGate)}`;
1270
+ }
1271
+ function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
1272
+ if (status !== "pass") {
1273
+ return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
1274
+ }
1275
+ if (nextGate === "debug_root_cause") {
1276
+ return "Debug repro accepted. Begin root-cause analysis: connect the observed failure to code evidence and call forge.complete_debug_root_cause.";
1277
+ }
1278
+ if (nextGate === "debug_fix") {
1279
+ return "Debug root cause accepted. Apply the smallest safe fix with regression coverage where practical, then call forge.complete_debug_fix.";
1280
+ }
1281
+ if (nextGate === "debug_regression") {
1282
+ return "Debug fix accepted. Prove the original repro is negative, run targeted regression checks, and call forge.complete_debug_regression.";
1283
+ }
1284
+ return "Debug regression accepted. Forge has the structured evidence needed to move this bug fix to QA.";
1285
+ }
1286
+ function qaGateInstruction(gate) {
1287
+ switch (gate) {
1288
+ case "qa_regression":
1289
+ return "Run or explicitly skip applicable regression/build/lint/typecheck/e2e checks, then call forge.complete_qa_regression.";
1290
+ case "qa_api":
1291
+ return "Run or explicitly skip applicable API/backend contract checks, then call forge.complete_qa_api.";
1292
+ case "qa_manual_web":
1293
+ return "Run or explicitly skip applicable browser/manual web checks, then call forge.complete_qa_manual_web.";
1294
+ case "qa_bug_repro":
1295
+ return "Reproduce or explicitly skip relevant defects/candidate bugs, then call forge.complete_qa_bug_repro.";
1296
+ case "qa_findings":
1297
+ return "Normalize observations into findings and candidate bugs, then call forge.complete_qa_findings.";
1298
+ case "qa_verdict":
1299
+ return "Assemble the final QA verdict from all prior gate evidence, then call forge.complete_qa_verdict.";
1300
+ case "qa_plan":
1301
+ return "Prepare the QA checklist, then call forge.complete_qa_plan.";
1302
+ }
1303
+ }
1304
+ function toolDescription(phase, qaGate = null) {
1305
+ if (qaGate) {
1306
+ return qaGate === "qa_verdict"
1307
+ ? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
1308
+ : `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
1309
+ }
1310
+ if (isDebugCompletionPhase(phase)) {
1311
+ return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
1312
+ }
600
1313
  if (phase === "implementation") {
601
1314
  return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
602
1315
  }
@@ -606,9 +1319,12 @@ function toolDescription(phase) {
606
1319
  if (phase === "qa") {
607
1320
  return "Complete Forge QA by writing qa.plan.json, qa.evidence.json, qa.findings.json, qa.verdict.json, and qa.report.md with structured pass, needs-rework, or blocked evidence.";
608
1321
  }
1322
+ if (phase === "architect") {
1323
+ return "Complete Forge Architect planning by writing implementation.plan.json, qa.plan.json, architect.verdict.json, and architect.report.md with a pending-approval plan or explicit revision/blocker reasons.";
1324
+ }
609
1325
  return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
610
1326
  }
611
- function toolInputSchema(phase) {
1327
+ function toolInputSchema(phase, qaGate = null) {
612
1328
  const baseProperties = {
613
1329
  status: {
614
1330
  type: "string",
@@ -627,6 +1343,12 @@ function toolInputSchema(phase) {
627
1343
  })
628
1344
  }
629
1345
  };
1346
+ if (qaGate && qaGate !== "qa_verdict") {
1347
+ return qaGateToolInputSchema(qaGate);
1348
+ }
1349
+ if (isDebugCompletionPhase(phase)) {
1350
+ return debugToolInputSchema(phase, baseProperties);
1351
+ }
630
1352
  if (phase === "implementation") {
631
1353
  return objectToolSchema({
632
1354
  ...baseProperties,
@@ -649,7 +1371,7 @@ function toolInputSchema(phase) {
649
1371
  },
650
1372
  tests_added_or_updated: {
651
1373
  type: "array",
652
- description: "Required for pass. Use an object explaining why no tests were needed for docs/config-only work if empty would be ambiguous.",
1374
+ description: "Required for pass. Use an object explaining why no tests were needed for documentation/config-only work if empty would be ambiguous.",
653
1375
  items: evidenceObjectSchema(["path", "summary"], {
654
1376
  path: { type: "string" },
655
1377
  summary: { type: "string" }
@@ -680,6 +1402,11 @@ function toolInputSchema(phase) {
680
1402
  },
681
1403
  next_phase_context: {
682
1404
  description: "Required for pass. Context the self-review phase should carry forward."
1405
+ },
1406
+ resolved_findings: {
1407
+ type: "array",
1408
+ description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
1409
+ items: resolvedFindingSchema()
683
1410
  }
684
1411
  }, ["status", "summary"], "For status pass, include plan, changed_files, tests_added_or_updated, docs_added_or_updated, acceptance_criteria, known_gaps, and next_phase_context. Acceptance criteria entries must use status: satisfied plus evidence.");
685
1412
  }
@@ -753,15 +1480,25 @@ function toolInputSchema(phase) {
753
1480
  },
754
1481
  browser_checks: {
755
1482
  type: "array",
756
- description: "Browser/manual UI checks with URL, action, observed result, screenshot/trace references, console/network notes, and status when applicable.",
757
- items: evidenceObjectSchema(["summary"], {
1483
+ description: "Browser/manual UI checks with URL or route, action, observed result, screenshot/trace references, console/network notes, and status.",
1484
+ items: evidenceObjectSchema(["summary", "status"], {
758
1485
  summary: { type: "string" },
759
1486
  url: { type: "string" },
1487
+ route: { type: "string" },
760
1488
  status: { type: "string" },
761
1489
  screenshot: { type: "string" },
762
1490
  trace: { type: "string" }
763
1491
  })
764
1492
  },
1493
+ sidecars: {
1494
+ type: "array",
1495
+ description: "Optional QA sidecar files already written under .forge/work-items/<id>/artifacts/qa/ with path/content_hash/bytes metadata.",
1496
+ items: evidenceObjectSchema(["path", "content_hash", "bytes"], {
1497
+ path: { type: "string" },
1498
+ content_hash: { type: "string" },
1499
+ bytes: { type: "number" }
1500
+ })
1501
+ },
765
1502
  notes: { type: "string", description: "Additional QA evidence notes." },
766
1503
  evidence: {
767
1504
  type: "object",
@@ -805,6 +1542,83 @@ function toolInputSchema(phase) {
805
1542
  }
806
1543
  }, ["status", "summary"], "For pass, include tested_acceptance_criteria plus concrete command/browser/note evidence. For needs_rework, include at least one actionable in_scope finding. For blocked, include blocker_reason.");
807
1544
  }
1545
+ if (phase === "architect") {
1546
+ return objectToolSchema({
1547
+ status: {
1548
+ type: "string",
1549
+ enum: ["pass", "needs_revision", "blocked"],
1550
+ description: "Use pass only for a complete pending-approval Architect plan. Use needs_revision for fixable story/design/repo gaps, or blocked for missing required context or decisions."
1551
+ },
1552
+ summary: { type: "string", description: "Concise Architect plan verdict summary." },
1553
+ implementation_plan: {
1554
+ type: "object",
1555
+ description: "Required for pass. Top-level fields inside this object must include schema_version, plan_id, phases, tasks, dependencies, and phase_gates.",
1556
+ additionalProperties: true,
1557
+ properties: {
1558
+ schema_version: { type: "number", enum: [1] },
1559
+ plan_id: { type: "string" },
1560
+ phases: {
1561
+ type: "array",
1562
+ description: "Required top-level implementation_plan.phases array.",
1563
+ items: { type: "object", additionalProperties: true }
1564
+ },
1565
+ tasks: {
1566
+ type: "array",
1567
+ description: "Required task list. Each task must include id and a non-empty tests array.",
1568
+ items: { type: "object", additionalProperties: true }
1569
+ },
1570
+ dependencies: {
1571
+ type: "array",
1572
+ description: "Required dependency edge array. Use [] when no task dependencies exist. Each edge should use before and after task ids; from/to are accepted only as aliases and normalized before writing artifacts.",
1573
+ items: {
1574
+ type: "object",
1575
+ additionalProperties: true,
1576
+ properties: {
1577
+ id: { type: "string" },
1578
+ before: { type: "string", description: "Task id that must complete first." },
1579
+ after: { type: "string", description: "Task id that depends on before." },
1580
+ from: { type: "string", description: "Alias for before." },
1581
+ to: { type: "string", description: "Alias for after." },
1582
+ type: { type: "string" },
1583
+ rationale: { type: "string" }
1584
+ }
1585
+ }
1586
+ },
1587
+ phase_gates: {
1588
+ type: "array",
1589
+ description: "Required non-empty phase gate array.",
1590
+ items: { type: "object", additionalProperties: true }
1591
+ }
1592
+ }
1593
+ },
1594
+ qa_plan: {
1595
+ type: "object",
1596
+ description: "Required for pass. Must include schema_version: 1 and non-empty final_qa coverage.",
1597
+ additionalProperties: true,
1598
+ properties: {
1599
+ schema_version: { type: "number", enum: [1] },
1600
+ final_qa: {
1601
+ type: "array",
1602
+ description: "Required non-empty final QA coverage array.",
1603
+ items: { type: "object", additionalProperties: true }
1604
+ }
1605
+ }
1606
+ },
1607
+ report_markdown: { type: "string", description: "Human-readable Architect report. Use report as an alias only when the client cannot send report_markdown." },
1608
+ report: { type: "string", description: "Alias for report_markdown." },
1609
+ blockers: baseProperties.blockers,
1610
+ revision_questions: {
1611
+ type: "array",
1612
+ description: "Required for needs_revision when blockers are not used.",
1613
+ items: { type: "string" }
1614
+ },
1615
+ revision_reasons: {
1616
+ type: "array",
1617
+ description: "Concrete reasons that prevent pass status.",
1618
+ items: { type: "string" }
1619
+ }
1620
+ }, ["status", "summary"], "For status pass, include implementation_plan and qa_plan at the top level of the MCP tool arguments. implementation_plan.phases must be a top-level array inside implementation_plan, not nested under another object.");
1621
+ }
808
1622
  return objectToolSchema({
809
1623
  ...baseProperties,
810
1624
  commands: {
@@ -829,6 +1643,144 @@ function toolInputSchema(phase) {
829
1643
  ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
830
1644
  }, ["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.");
831
1645
  }
1646
+ function debugToolInputSchema(phase, baseProperties) {
1647
+ if (phase === "debug_repro") {
1648
+ return objectToolSchema({
1649
+ ...baseProperties,
1650
+ repro_steps: {
1651
+ type: "array",
1652
+ description: "Required for pass. Minimal reproduction or characterization steps.",
1653
+ items: { type: "string" }
1654
+ },
1655
+ observed: { type: "string", description: "Observed failure behavior." },
1656
+ expected: { type: "string", description: "Expected behavior." },
1657
+ evidence: {
1658
+ type: "array",
1659
+ description: "Required for pass. Concrete logs, commands, screenshots, file refs, or characterization notes.",
1660
+ items: { type: "object", additionalProperties: true }
1661
+ }
1662
+ }, ["status", "summary"], "For status pass, include repro_steps, observed, expected, and evidence.");
1663
+ }
1664
+ if (phase === "debug_root_cause") {
1665
+ return objectToolSchema({
1666
+ ...baseProperties,
1667
+ root_cause: { type: "string", description: "Likely root cause tied to code or config evidence." },
1668
+ evidence: {
1669
+ type: "array",
1670
+ description: "Required for pass. Concrete code/log/test evidence supporting the root cause.",
1671
+ items: { type: "object", additionalProperties: true }
1672
+ },
1673
+ eliminated_alternatives: {
1674
+ type: "array",
1675
+ description: "Required for pass. Plausible alternatives checked or ruled out.",
1676
+ items: { type: "object", additionalProperties: true }
1677
+ },
1678
+ risk_area: { type: "string", description: "Subsystem or behavior most at risk from the fix." }
1679
+ }, ["status", "summary"], "For status pass, include root_cause, evidence, eliminated_alternatives, and risk_area.");
1680
+ }
1681
+ if (phase === "debug_fix") {
1682
+ return objectToolSchema({
1683
+ ...baseProperties,
1684
+ changed_files: {
1685
+ type: "array",
1686
+ description: "Required for pass. Files changed by the bug fix.",
1687
+ items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
1688
+ },
1689
+ tests_added_or_updated: {
1690
+ type: "array",
1691
+ description: "Required for pass. Regression tests or checks added/updated, or justify omission in remaining_risks.",
1692
+ items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
1693
+ },
1694
+ remaining_risks: {
1695
+ type: "array",
1696
+ description: "Required for pass, even when empty. Include why no regression coverage changed when tests_added_or_updated is empty.",
1697
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
1698
+ },
1699
+ resolved_findings: {
1700
+ type: "array",
1701
+ description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
1702
+ items: resolvedFindingSchema()
1703
+ }
1704
+ }, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
1705
+ }
1706
+ return objectToolSchema({
1707
+ ...baseProperties,
1708
+ commands: {
1709
+ type: "array",
1710
+ description: "Required for pass. Targeted regression/repro-negative commands with exit_code: 0 and status: pass.",
1711
+ items: commandEvidenceSchema()
1712
+ },
1713
+ required_regressions: {
1714
+ type: "array",
1715
+ description: "Required for pass. Repro-negative and regression coverage checklist.",
1716
+ items: { type: "string" }
1717
+ },
1718
+ build_status: { type: "string", description: "Required for pass. pass or not_applicable." },
1719
+ test_status: { type: "string", description: "Required for pass. pass or not_applicable." },
1720
+ repro_negative: { type: "boolean", description: "Required for pass and must be true after the original repro no longer fails." },
1721
+ qa_ready: { type: "boolean", description: "Required for pass and must be true." }
1722
+ }, ["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.");
1723
+ }
1724
+ function qaGateToolInputSchema(gate) {
1725
+ const baseProperties = {
1726
+ status: {
1727
+ type: "string",
1728
+ enum: ["pass", "skipped", "blocked"],
1729
+ description: "Use pass when this QA gate produced evidence, skipped when not applicable, or blocked when a prerequisite prevents this gate."
1730
+ },
1731
+ summary: { type: "string", description: "Concise QA gate summary." },
1732
+ reason: { type: "string", description: "Required for skipped or blocked gates." },
1733
+ blocker_reason: {
1734
+ type: "string",
1735
+ enum: [...qaBlockerReasons],
1736
+ description: "Required when status is blocked."
1737
+ }
1738
+ };
1739
+ if (gate === "qa_plan") {
1740
+ return objectToolSchema({
1741
+ ...baseProperties,
1742
+ checklist: {
1743
+ type: "array",
1744
+ description: "Required for pass. QA checklist entries planned for this work item.",
1745
+ items: { type: "string" }
1746
+ },
1747
+ acceptance_criteria: {
1748
+ type: "array",
1749
+ description: "Acceptance criteria selected for QA.",
1750
+ items: { type: "string" }
1751
+ }
1752
+ }, ["status", "summary"], "Record the QA plan gate. Pass requires a non-empty checklist.");
1753
+ }
1754
+ if (gate === "qa_findings") {
1755
+ return objectToolSchema({
1756
+ ...baseProperties,
1757
+ findings: {
1758
+ type: "array",
1759
+ description: "Normalized in-scope and out-of-scope QA findings.",
1760
+ items: { type: "object", additionalProperties: true }
1761
+ },
1762
+ candidate_bugs: {
1763
+ type: "array",
1764
+ description: "Out-of-scope or follow-up candidates.",
1765
+ items: { type: "object", additionalProperties: true }
1766
+ }
1767
+ }, ["status", "summary"], "Record normalized QA findings. Pass or skipped requires findings and candidate_bugs arrays, even when empty.");
1768
+ }
1769
+ return objectToolSchema({
1770
+ ...baseProperties,
1771
+ commands: {
1772
+ type: "array",
1773
+ description: "Command evidence for this gate when applicable.",
1774
+ items: commandEvidenceSchema()
1775
+ },
1776
+ browser_checks: {
1777
+ type: "array",
1778
+ description: "Browser/manual evidence for this gate when applicable.",
1779
+ items: { type: "object", additionalProperties: true }
1780
+ },
1781
+ notes: { type: "string", description: "Evidence notes for this gate." }
1782
+ }, ["status", "summary"], "Record this QA evidence gate. Use skipped with a reason when the gate is not applicable.");
1783
+ }
832
1784
  function objectToolSchema(properties, required, description) {
833
1785
  return {
834
1786
  type: "object",
@@ -854,6 +1806,29 @@ function commandEvidenceSchema() {
854
1806
  summary: { type: "string" }
855
1807
  });
856
1808
  }
1809
+ function resolvedFindingSchema() {
1810
+ return evidenceObjectSchema([
1811
+ "qa_id",
1812
+ "status",
1813
+ "summary",
1814
+ "evidence",
1815
+ "changed_files",
1816
+ "tests_added_or_updated",
1817
+ "regression_evidence"
1818
+ ], {
1819
+ qa_id: { type: "string" },
1820
+ status: {
1821
+ type: "string",
1822
+ enum: [...resolvedFindingStatusValues],
1823
+ description: "Use resolved when fixed, disputed when the QA finding is incorrect/out-of-scope, or not_reproduced when retesting cannot reproduce it."
1824
+ },
1825
+ summary: { type: "string" },
1826
+ evidence: { description: "Concrete evidence for the resolution." },
1827
+ changed_files: { type: "array", description: "Required and non-empty when status is resolved." },
1828
+ tests_added_or_updated: { type: "array", description: "Required and non-empty when status is resolved." },
1829
+ regression_evidence: { description: "Concrete regression evidence for this QA finding." }
1830
+ });
1831
+ }
857
1832
  function acceptanceCriteriaSchema() {
858
1833
  return {
859
1834
  type: "array",
@@ -888,6 +1863,13 @@ function normalizeCompletionStatus(value, phase) {
888
1863
  }
889
1864
  return canonical;
890
1865
  }
1866
+ if (phase === "architect") {
1867
+ const canonical = normalized === "needsrevision" ? "needs_revision" : normalized;
1868
+ if (canonical !== "pass" && canonical !== "needs_revision" && canonical !== "blocked") {
1869
+ throw new Error("Architect status must be pass, needs_revision, or blocked.");
1870
+ }
1871
+ return canonical;
1872
+ }
891
1873
  const canonical = normalized === "blocked" || normalized === "needsrework"
892
1874
  ? "needs_rework"
893
1875
  : normalized === "failed"
@@ -898,6 +1880,13 @@ function normalizeCompletionStatus(value, phase) {
898
1880
  }
899
1881
  return canonical;
900
1882
  }
1883
+ function normalizeQaGateStatus(value) {
1884
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
1885
+ if (normalized !== "pass" && normalized !== "skipped" && normalized !== "blocked") {
1886
+ throw new Error("QA gate status must be pass, skipped, or blocked.");
1887
+ }
1888
+ return normalized;
1889
+ }
901
1890
  function validatePayloadShape(phase, payload) {
902
1891
  const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
903
1892
  if (!status) {
@@ -908,11 +1897,19 @@ function validatePayloadShape(phase, payload) {
908
1897
  validateQaPayloadShape(payload, status);
909
1898
  return;
910
1899
  }
1900
+ if (phase === "architect") {
1901
+ validateArchitectPayloadShape(payload, status);
1902
+ return;
1903
+ }
911
1904
  const pass = status === "pass";
912
1905
  if (!pass) {
913
1906
  requireNonPassEvidence(payload);
914
1907
  return;
915
1908
  }
1909
+ if (isDebugCompletionPhase(phase)) {
1910
+ validateDebugPayloadShape(phase, payload);
1911
+ return;
1912
+ }
916
1913
  if (phase === "implementation") {
917
1914
  requireArray(payload, "plan");
918
1915
  requireArray(payload, "changed_files");
@@ -923,6 +1920,7 @@ function validatePayloadShape(phase, payload) {
923
1920
  requireField(payload, "next_phase_context");
924
1921
  requireImplementationEvidence(payload);
925
1922
  requireSatisfiedAcceptanceCriteria(payload);
1923
+ validateResolvedFindings(payload);
926
1924
  return;
927
1925
  }
928
1926
  if (phase === "self_review") {
@@ -950,10 +1948,265 @@ function validatePayloadShape(phase, payload) {
950
1948
  requirePassingCommands(payload);
951
1949
  requireSatisfiedAcceptanceCriteria(payload);
952
1950
  }
1951
+ function validateDebugPayloadShape(phase, payload) {
1952
+ if (phase === "debug_repro") {
1953
+ requireArray(payload, "repro_steps");
1954
+ requireField(payload, "observed");
1955
+ requireField(payload, "expected");
1956
+ requireArray(payload, "evidence");
1957
+ requireNonEmptyArray(payload, "repro_steps");
1958
+ requireNonEmptyArray(payload, "evidence");
1959
+ return;
1960
+ }
1961
+ if (phase === "debug_root_cause") {
1962
+ requireField(payload, "root_cause");
1963
+ requireArray(payload, "evidence");
1964
+ requireArray(payload, "eliminated_alternatives");
1965
+ requireField(payload, "risk_area");
1966
+ requireNonEmptyArray(payload, "evidence");
1967
+ return;
1968
+ }
1969
+ if (phase === "debug_fix") {
1970
+ requireArray(payload, "changed_files");
1971
+ requireArray(payload, "tests_added_or_updated");
1972
+ requireArray(payload, "remaining_risks");
1973
+ requireNonEmptyArray(payload, "changed_files");
1974
+ if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
1975
+ throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
1976
+ }
1977
+ validateResolvedFindings(payload);
1978
+ return;
1979
+ }
1980
+ requireArray(payload, "commands");
1981
+ requireArray(payload, "required_regressions");
1982
+ requireField(payload, "build_status");
1983
+ requireField(payload, "test_status");
1984
+ requireBoolean(payload, "repro_negative");
1985
+ requireBoolean(payload, "qa_ready");
1986
+ if (payload.repro_negative !== true) {
1987
+ throw new Error("debug_regression pass requires repro_negative: true.");
1988
+ }
1989
+ if (payload.qa_ready !== true) {
1990
+ throw new Error("debug_regression pass requires qa_ready: true.");
1991
+ }
1992
+ requireAllowedStatus(payload, "build_status", ["pass", "not_applicable"]);
1993
+ requireAllowedStatus(payload, "test_status", ["pass", "not_applicable"]);
1994
+ requireNonEmptyArray(payload, "required_regressions");
1995
+ requirePassingCommands(payload);
1996
+ }
1997
+ function validateQaGatePayloadShape(gate, payload, status) {
1998
+ requireField(payload, "summary");
1999
+ if (status === "skipped") {
2000
+ requireField(payload, "reason");
2001
+ }
2002
+ if (status === "blocked") {
2003
+ const reason = normalizedString(optionalString(payload, "blocker_reason"));
2004
+ if (!qaBlockerReasons.has(reason)) {
2005
+ 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.");
2006
+ }
2007
+ }
2008
+ if (gate === "qa_plan" && status === "pass") {
2009
+ const checklist = qaStringArray(payload, "checklist");
2010
+ if (checklist.length === 0) {
2011
+ throw new Error("complete_qa_plan pass requires checklist with at least one entry.");
2012
+ }
2013
+ }
2014
+ if (gate === "qa_findings" && status !== "blocked") {
2015
+ qaObjectArray(payload, "findings");
2016
+ qaObjectArray(payload, "candidate_bugs");
2017
+ }
2018
+ if ((gate === "qa_regression" || gate === "qa_api") && status === "pass") {
2019
+ const commands = qaArray(payload, "commands");
2020
+ if (commands.length === 0) {
2021
+ throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
2022
+ }
2023
+ validateQaGatePassingCommands(commands);
2024
+ }
2025
+ if (gate === "qa_manual_web" && status === "pass") {
2026
+ const browserChecks = qaArray(payload, "browser_checks");
2027
+ const notes = optionalString(payload, "notes");
2028
+ if (browserChecks.length === 0 && !notes) {
2029
+ throw new Error(`${qaGateToolNames[gate]} pass requires browser_checks or notes.`);
2030
+ }
2031
+ }
2032
+ }
2033
+ function validateQaGatePassingCommands(commands) {
2034
+ for (const [index, command] of commands.entries()) {
2035
+ requireEvidenceRecord(command, `commands[${index}]`);
2036
+ const record = command;
2037
+ requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
2038
+ requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
2039
+ requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
2040
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
2041
+ throw new Error(`commands[${index}].exit_code is required.`);
2042
+ }
2043
+ if (record.exit_code !== 0) {
2044
+ throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
2045
+ }
2046
+ if (normalizedString(record.status) !== "pass") {
2047
+ throw new Error(`commands[${index}].status must be pass when QA gate status is pass.`);
2048
+ }
2049
+ }
2050
+ }
2051
+ function validateArchitectPayloadShape(payload, status) {
2052
+ if (status !== "pass") {
2053
+ requireArchitectNonPassEvidence(payload);
2054
+ return;
2055
+ }
2056
+ const implementationPlan = requiredRecord(payload, "implementation_plan");
2057
+ const qaPlan = requiredRecord(payload, "qa_plan");
2058
+ requireNumericOne(implementationPlan, "schema_version", "implementation_plan.schema_version");
2059
+ requireNonEmptyStringRecord(implementationPlan, "plan_id", "implementation_plan.");
2060
+ requireArchitectArray(implementationPlan, "phases", payload);
2061
+ requireArchitectArray(implementationPlan, "tasks", payload);
2062
+ requireArchitectArray(implementationPlan, "dependencies", payload, { allowEmpty: true });
2063
+ requireArchitectArray(implementationPlan, "phase_gates", payload);
2064
+ if (arrayLength(implementationPlan, "tasks") === 0) {
2065
+ throw new Error("implementation_plan.tasks: expected non-empty array; correction: include at least one task with id and tests.");
2066
+ }
2067
+ const taskIds = new Set();
2068
+ for (const task of implementationPlan.tasks) {
2069
+ if (!task || typeof task !== "object" || Array.isArray(task)) {
2070
+ throw new Error("implementation_plan.tasks[]: expected object entries; correction: each task must be an object with id and tests.");
2071
+ }
2072
+ const taskRecord = task;
2073
+ requireNonEmptyStringRecord(taskRecord, "id", "implementation_plan.tasks[].");
2074
+ const taskId = optionalString(taskRecord, "id") ?? "";
2075
+ if (taskIds.has(taskId)) {
2076
+ throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
2077
+ }
2078
+ taskIds.add(taskId);
2079
+ requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
2080
+ if (arrayLength(taskRecord, "tests") === 0) {
2081
+ throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
2082
+ }
2083
+ }
2084
+ validateAndNormalizeArchitectDependencies(implementationPlan, taskIds);
2085
+ requireNumericOne(qaPlan, "schema_version", "qa_plan.schema_version");
2086
+ requireArchitectArray(qaPlan, "final_qa", payload, { path: "qa_plan.final_qa" });
2087
+ if (arrayLength(qaPlan, "final_qa") === 0) {
2088
+ throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
2089
+ }
2090
+ }
2091
+ function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
2092
+ const dependencies = implementationPlan.dependencies;
2093
+ const edges = new Map();
2094
+ for (const taskId of taskIds) {
2095
+ edges.set(taskId, []);
2096
+ }
2097
+ const normalizedDependencies = [];
2098
+ for (const [index, dependency] of dependencies.entries()) {
2099
+ if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
2100
+ throw new Error("implementation_plan.dependencies[]: expected object entries; correction: each dependency must be an object with before and after task ids.");
2101
+ }
2102
+ const record = dependency;
2103
+ const before = architectDependencyEndpoint(record, index, "before", "from");
2104
+ const after = architectDependencyEndpoint(record, index, "after", "to");
2105
+ if (!taskIds.has(before) || !taskIds.has(after)) {
2106
+ throw new Error("implementation_plan.dependencies: dependency references an unknown task id.");
2107
+ }
2108
+ const normalized = stripUndefined({
2109
+ ...record,
2110
+ before,
2111
+ after,
2112
+ from: undefined,
2113
+ to: undefined
2114
+ });
2115
+ normalizedDependencies.push(normalized);
2116
+ edges.get(before)?.push(after);
2117
+ }
2118
+ implementationPlan.dependencies = normalizedDependencies;
2119
+ if (hasArchitectDependencyCycle(edges)) {
2120
+ throw new Error("implementation_plan.dependencies: expected acyclic graph; correction: remove or revise cyclic task dependencies.");
2121
+ }
2122
+ }
2123
+ function architectDependencyEndpoint(dependency, index, canonicalKey, aliasKey) {
2124
+ const canonical = optionalString(dependency, canonicalKey);
2125
+ const alias = optionalString(dependency, aliasKey);
2126
+ if (canonical && alias && canonical !== alias) {
2127
+ throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: conflicts with ${aliasKey}; correction: use one task id for ${canonicalKey}.`);
2128
+ }
2129
+ const value = canonical ?? alias;
2130
+ if (!value) {
2131
+ throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: expected string; correction: use before and after task ids for each dependency.`);
2132
+ }
2133
+ return value;
2134
+ }
2135
+ function hasArchitectDependencyCycle(edges) {
2136
+ const visiting = new Set();
2137
+ const visited = new Set();
2138
+ const visit = (node) => {
2139
+ if (visited.has(node)) {
2140
+ return false;
2141
+ }
2142
+ if (visiting.has(node)) {
2143
+ return true;
2144
+ }
2145
+ visiting.add(node);
2146
+ for (const next of edges.get(node) ?? []) {
2147
+ if (visit(next)) {
2148
+ return true;
2149
+ }
2150
+ }
2151
+ visiting.delete(node);
2152
+ visited.add(node);
2153
+ return false;
2154
+ };
2155
+ for (const node of edges.keys()) {
2156
+ if (visit(node)) {
2157
+ return true;
2158
+ }
2159
+ }
2160
+ return false;
2161
+ }
2162
+ function requireArchitectNonPassEvidence(payload) {
2163
+ const evidenceKeys = ["blockers", "revision_questions", "revision_reasons", "known_gaps", "findings", "remaining_risks", "failures"];
2164
+ const hasEvidence = evidenceKeys.some((key) => {
2165
+ const value = payload[key];
2166
+ if (typeof value === "string") {
2167
+ return value.trim().length > 0;
2168
+ }
2169
+ if (Array.isArray(value)) {
2170
+ return value.length > 0;
2171
+ }
2172
+ return Boolean(value && typeof value === "object");
2173
+ });
2174
+ if (!hasEvidence) {
2175
+ throw new Error("status: non-pass Architect completion requires blockers, revision_questions, revision_reasons, findings, failures, or known_gaps; correction: explain why the plan cannot pass.");
2176
+ }
2177
+ }
2178
+ function requireArchitectArray(source, key, rootPayload, options = {}) {
2179
+ const pathLabel = options.path ?? `implementation_plan.${key}`;
2180
+ if (!Array.isArray(source[key])) {
2181
+ const nestedHint = architectNestedArrayHint(rootPayload, key);
2182
+ throw new Error(`${pathLabel}: expected array${key === "phases" ? " as a top-level field inside implementation_plan" : ""}; correction: ${nestedHint ?? `set ${pathLabel} to an array${options.allowEmpty ? " (use [] when none exist)" : " with at least one entry"}.`}`);
2183
+ }
2184
+ if (!options.allowEmpty && arrayLength(source, key) === 0) {
2185
+ throw new Error(`${pathLabel}: expected non-empty array; correction: include at least one entry.`);
2186
+ }
2187
+ }
2188
+ function architectNestedArrayHint(rootPayload, key) {
2189
+ if (Array.isArray(rootPayload[key])) {
2190
+ return `move top-level ${key} into implementation_plan.${key}.`;
2191
+ }
2192
+ const implementationPlan = rootPayload.implementation_plan;
2193
+ if (!implementationPlan || typeof implementationPlan !== "object" || Array.isArray(implementationPlan)) {
2194
+ return null;
2195
+ }
2196
+ const plan = implementationPlan;
2197
+ for (const container of ["plan", "dag", "implementation_dag", "implementationPlan", "implementation_plan"]) {
2198
+ const nested = plan[container];
2199
+ if (nested && typeof nested === "object" && !Array.isArray(nested) && Array.isArray(nested[key])) {
2200
+ return `move implementation_plan.${container}.${key} to implementation_plan.${key}.`;
2201
+ }
2202
+ }
2203
+ return null;
2204
+ }
953
2205
  function validateQaPayloadShape(payload, status) {
954
2206
  const evidenceSource = qaEvidenceSource(payload);
955
2207
  const commands = qaArray(payload, "commands", evidenceSource);
956
2208
  const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
2209
+ const sidecars = qaArray(payload, "sidecars", evidenceSource);
957
2210
  const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
958
2211
  const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
959
2212
  const findings = qaObjectArray(payload, "findings");
@@ -991,10 +2244,23 @@ function validateQaPayloadShape(payload, status) {
991
2244
  requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
992
2245
  const record = browserCheck;
993
2246
  requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
2247
+ requireNonEmptyStringRecord(record, "status", `browser_checks[${index}].`);
994
2248
  const checkStatus = optionalString(record, "status");
995
2249
  if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
996
2250
  throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
997
2251
  }
2252
+ if (!optionalString(record, "url") && !optionalString(record, "route")) {
2253
+ throw new Error(`browser_checks[${index}] must include url or route.`);
2254
+ }
2255
+ }
2256
+ for (const [index, sidecar] of sidecars.entries()) {
2257
+ requireEvidenceRecord(sidecar, `sidecars[${index}]`);
2258
+ const record = sidecar;
2259
+ requireNonEmptyStringRecord(record, "path", `sidecars[${index}].`);
2260
+ requireNonEmptyStringRecord(record, "content_hash", `sidecars[${index}].`);
2261
+ if (typeof record.bytes !== "number" || !Number.isFinite(record.bytes) || record.bytes <= 0) {
2262
+ throw new Error(`sidecars[${index}].bytes must be greater than 0.`);
2263
+ }
998
2264
  }
999
2265
  for (const [index, finding] of findings.entries()) {
1000
2266
  requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
@@ -1075,8 +2341,34 @@ function normalizeArtifactPayload(payload, defaults) {
1075
2341
  normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
1076
2342
  normalized.phase = defaults.phase;
1077
2343
  normalized.work_item_id = defaults.workItemId;
2344
+ const ownerFields = artifactOwnerFields(defaults);
2345
+ Object.assign(normalized, ownerFields);
2346
+ normalized.status = defaults.status;
2347
+ normalized.produced_by = defaults.producedBy;
2348
+ return normalized;
2349
+ }
2350
+ function normalizeQaGateArtifactPayload(payload, defaults) {
2351
+ const normalized = stripUndefined({ ...payload });
2352
+ delete normalized.report_markdown;
2353
+ delete normalized.content_hash;
2354
+ delete normalized.contentHash;
2355
+ normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
2356
+ normalized.phase = "qa";
2357
+ normalized.gate = defaults.gate;
2358
+ normalized.work_item_id = defaults.workItemId;
2359
+ const ownerFields = artifactOwnerFields(defaults);
2360
+ Object.assign(normalized, ownerFields);
1078
2361
  normalized.status = defaults.status;
1079
2362
  normalized.produced_by = defaults.producedBy;
2363
+ if (defaults.gate === "qa_plan") {
2364
+ const checklist = qaStringArray(normalized, "checklist");
2365
+ normalized.checklist = checklist.length > 0 ? checklist : defaultQaChecklist(defaults.status);
2366
+ normalized.acceptance_criteria = qaStringArray(normalized, "acceptance_criteria");
2367
+ }
2368
+ if (defaults.gate === "qa_findings") {
2369
+ normalized.findings = qaObjectArray(normalized, "findings");
2370
+ normalized.candidate_bugs = qaObjectArray(normalized, "candidate_bugs");
2371
+ }
1080
2372
  return normalized;
1081
2373
  }
1082
2374
  function reportMarkdown(payload, phase, status, producedBy, contentHash) {
@@ -1098,6 +2390,10 @@ ${summary}
1098
2390
  }
1099
2391
  function parseStrictCompletionPhase(value) {
1100
2392
  const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
2393
+ const debugPhase = normalizeDebugCompletionPhase(normalized);
2394
+ if (debugPhase) {
2395
+ return debugPhase;
2396
+ }
1101
2397
  if (normalized === "implementation" || normalized === "implement" || normalized === "develop") {
1102
2398
  return "implementation";
1103
2399
  }
@@ -1110,8 +2406,48 @@ function parseStrictCompletionPhase(value) {
1110
2406
  if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
1111
2407
  return "qa";
1112
2408
  }
2409
+ if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
2410
+ return "architect";
2411
+ }
1113
2412
  return null;
1114
2413
  }
2414
+ function normalizeDebugCompletionPhase(value) {
2415
+ const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
2416
+ switch (normalized) {
2417
+ case "debug_repro":
2418
+ case "debugrepro":
2419
+ case "repro":
2420
+ case "reproduce":
2421
+ return "debug_repro";
2422
+ case "debug_root_cause":
2423
+ case "debugrootcause":
2424
+ case "root_cause":
2425
+ case "rootcause":
2426
+ return "debug_root_cause";
2427
+ case "debug_fix":
2428
+ case "debugfix":
2429
+ case "fix":
2430
+ return "debug_fix";
2431
+ case "debug_regression":
2432
+ case "debugregression":
2433
+ case "regression":
2434
+ return "debug_regression";
2435
+ default:
2436
+ return null;
2437
+ }
2438
+ }
2439
+ function isDevelopCompletionPhase(phase) {
2440
+ return phase === "implementation" || phase === "self_review" || phase === "validation";
2441
+ }
2442
+ function isDebugCompletionPhase(phase) {
2443
+ return debugCompletionPhaseOrder.includes(phase);
2444
+ }
2445
+ function asDebugCompletionPhase(phase) {
2446
+ if (isDebugCompletionPhase(phase)) {
2447
+ return phase;
2448
+ }
2449
+ throw new Error(`Expected Debug completion phase, got ${phase}.`);
2450
+ }
1115
2451
  function parseCompletionRedactionSecrets(raw) {
1116
2452
  if (!raw?.trim()) {
1117
2453
  return [];
@@ -1141,8 +2477,38 @@ function phaseFromToolName(toolName) {
1141
2477
  if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
1142
2478
  return "qa";
1143
2479
  }
2480
+ if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
2481
+ return "architect";
2482
+ }
2483
+ const debugGate = debugGateFromToolName(toolName);
2484
+ if (debugGate) {
2485
+ return debugGate;
2486
+ }
2487
+ if (qaGateFromToolName(toolName)) {
2488
+ return "qa";
2489
+ }
1144
2490
  throw new Error(`Unknown Forge completion tool: ${toolName}`);
1145
2491
  }
2492
+ function debugGateFromToolName(toolName) {
2493
+ const normalized = toolName.trim().toLowerCase();
2494
+ for (const gate of debugCompletionPhaseOrder) {
2495
+ const base = debugGateToolNames[gate];
2496
+ if (normalized === `forge.${base}` || normalized === base) {
2497
+ return gate;
2498
+ }
2499
+ }
2500
+ return null;
2501
+ }
2502
+ function qaGateFromToolName(toolName) {
2503
+ const normalized = toolName.trim().toLowerCase();
2504
+ for (const gate of qaCompletionGateOrder) {
2505
+ const base = qaGateToolNames[gate];
2506
+ if (normalized === `forge.${base}` || normalized === base) {
2507
+ return gate;
2508
+ }
2509
+ }
2510
+ return null;
2511
+ }
1146
2512
  function requireField(payload, key) {
1147
2513
  if (payload[key] === undefined || payload[key] === null || (typeof payload[key] === "string" && payload[key].trim().length === 0)) {
1148
2514
  throw new Error(`${key} is required.`);
@@ -1210,17 +2576,42 @@ function requireArray(payload, key) {
1210
2576
  throw new Error(`${key} must be an array.`);
1211
2577
  }
1212
2578
  }
2579
+ function requireNonEmptyArray(payload, key) {
2580
+ requireArray(payload, key);
2581
+ if (arrayLength(payload, key) === 0) {
2582
+ throw new Error(`${key} must include at least one value.`);
2583
+ }
2584
+ }
2585
+ function arrayLength(payload, key) {
2586
+ const value = payload[key];
2587
+ return Array.isArray(value) ? value.length : 0;
2588
+ }
2589
+ function requireAllowedStatus(payload, key, allowed) {
2590
+ const value = normalizedString(optionalString(payload, key));
2591
+ if (!allowed.includes(value)) {
2592
+ throw new Error(`${key} must be one of ${allowed.join(", ")}.`);
2593
+ }
2594
+ }
1213
2595
  function requireObject(payload, key) {
1214
2596
  const value = payload[key];
1215
2597
  if (!value || typeof value !== "object" || Array.isArray(value)) {
1216
2598
  throw new Error(`${key} must be an object.`);
1217
2599
  }
1218
2600
  }
2601
+ function requiredRecord(payload, key) {
2602
+ requireObject(payload, key);
2603
+ return payload[key];
2604
+ }
1219
2605
  function requireBoolean(payload, key) {
1220
2606
  if (typeof payload[key] !== "boolean") {
1221
2607
  throw new Error(`${key} must be a boolean.`);
1222
2608
  }
1223
2609
  }
2610
+ function requireNumericOne(payload, key, pathLabel) {
2611
+ if (payload[key] !== 1) {
2612
+ throw new Error(`${pathLabel}: expected numeric 1; correction: set ${pathLabel} to 1.`);
2613
+ }
2614
+ }
1224
2615
  function requireSatisfiedAcceptanceCriteria(payload) {
1225
2616
  const criteria = payload.acceptance_criteria;
1226
2617
  if (!Array.isArray(criteria)) {
@@ -1238,6 +2629,37 @@ function requireSatisfiedAcceptanceCriteria(payload) {
1238
2629
  }
1239
2630
  }
1240
2631
  }
2632
+ function validateResolvedFindings(payload) {
2633
+ const resolvedFindings = payload.resolved_findings;
2634
+ if (resolvedFindings === undefined || resolvedFindings === null) {
2635
+ return;
2636
+ }
2637
+ if (!Array.isArray(resolvedFindings)) {
2638
+ throw new Error("resolved_findings must be an array.");
2639
+ }
2640
+ for (const [index, finding] of resolvedFindings.entries()) {
2641
+ const prefix = `resolved_findings[${index}].`;
2642
+ if (!finding || typeof finding !== "object" || Array.isArray(finding)) {
2643
+ throw new Error(`resolved_findings[${index}] must be an object.`);
2644
+ }
2645
+ const record = finding;
2646
+ requireNonEmptyStringRecord(record, "qa_id", prefix);
2647
+ requireKnownStringRecord(record, "status", resolvedFindingStatuses, prefix);
2648
+ requireNonEmptyStringRecord(record, "summary", prefix);
2649
+ requireSubstantiveValueRecord(record, "evidence", prefix);
2650
+ requireArrayRecord(record, "changed_files", prefix);
2651
+ requireArrayRecord(record, "tests_added_or_updated", prefix);
2652
+ requireSubstantiveValueRecord(record, "regression_evidence", prefix);
2653
+ if (normalizedString(record.status) === "resolved") {
2654
+ if (record.changed_files.length === 0) {
2655
+ throw new Error(`${prefix}changed_files must include at least one changed file for resolved QA findings.`);
2656
+ }
2657
+ if (record.tests_added_or_updated.length === 0) {
2658
+ throw new Error(`${prefix}tests_added_or_updated must include at least one regression test for resolved QA findings.`);
2659
+ }
2660
+ }
2661
+ }
2662
+ }
1241
2663
  function qaEvidenceSource(payload) {
1242
2664
  const evidence = payload.evidence;
1243
2665
  return evidence && typeof evidence === "object" && !Array.isArray(evidence)
@@ -1287,6 +2709,11 @@ function requireNonEmptyStringRecord(source, key, prefix = "") {
1287
2709
  throw new Error(`${prefix}${key} is required.`);
1288
2710
  }
1289
2711
  }
2712
+ function requireArrayRecord(source, key, prefix = "") {
2713
+ if (!Array.isArray(source[key])) {
2714
+ throw new Error(`${prefix}${key} must be an array.`);
2715
+ }
2716
+ }
1290
2717
  function requireKnownStringRecord(source, key, allowed, prefix = "") {
1291
2718
  const value = normalizedString(optionalString(source, key));
1292
2719
  if (!allowed.has(value)) {
@@ -1304,6 +2731,11 @@ function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
1304
2731
  }
1305
2732
  }
1306
2733
  }
2734
+ function requireSubstantiveValueRecord(source, key, prefix = "") {
2735
+ if (!hasSubstantiveJsonValue(source[key])) {
2736
+ throw new Error(`${prefix}${key} must include at least one concrete evidence reference or summary.`);
2737
+ }
2738
+ }
1307
2739
  function requireSubstantiveEvidenceRecord(source, prefix = "") {
1308
2740
  const evidence = source.evidence;
1309
2741
  if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
@@ -1328,6 +2760,36 @@ function hasSubstantiveJsonValue(value) {
1328
2760
  function normalizedString(value) {
1329
2761
  return typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
1330
2762
  }
2763
+ function normalizedOwnerType(value) {
2764
+ const normalized = normalizedString(value);
2765
+ return normalized === "work_item" || normalized === "story" ? normalized : undefined;
2766
+ }
2767
+ function artifactOwnerFields(config) {
2768
+ const ownerType = normalizedOwnerType(config.ownerType);
2769
+ const ownerId = config.ownerId?.trim();
2770
+ if (!ownerType && !ownerId) {
2771
+ return {};
2772
+ }
2773
+ return {
2774
+ owner_type: ownerType ?? "work_item",
2775
+ owner_id: ownerId || config.workItemId
2776
+ };
2777
+ }
2778
+ function artifactReferenceOwnerFields(config) {
2779
+ const owner = artifactOwnerFields(config);
2780
+ return owner.owner_type && owner.owner_id
2781
+ ? { ownerType: owner.owner_type, ownerId: owner.owner_id }
2782
+ : {};
2783
+ }
2784
+ function artifactOwnerDirectory(ownerType) {
2785
+ return normalizedOwnerType(ownerType) === "story" ? "stories" : "work-items";
2786
+ }
2787
+ function artifactOwnerRelativeRoot(ownerType, ownerId, workItemId) {
2788
+ const owner = artifactOwnerFields({ workItemId, ownerType, ownerId });
2789
+ return owner.owner_type && owner.owner_id
2790
+ ? [".forge", artifactOwnerDirectory(owner.owner_type), owner.owner_id, "artifacts"].join("/")
2791
+ : [".forge", "work-items", workItemId, "artifacts"].join("/");
2792
+ }
1331
2793
  function defaultQaChecklist(status) {
1332
2794
  return [
1333
2795
  "Review the validated work item against acceptance criteria.",
@@ -1338,6 +2800,9 @@ function defaultQaChecklist(status) {
1338
2800
  function qaArtifactRelativePath(workItemId, fileName) {
1339
2801
  return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
1340
2802
  }
2803
+ function qaArtifactRelativePathForOwner(ownerType, ownerId, fileName) {
2804
+ return [".forge", artifactOwnerDirectory(ownerType), ownerId, "artifacts", fileName].join("/");
2805
+ }
1341
2806
  function jsonText(value) {
1342
2807
  return `${JSON.stringify(value, null, 2)}\n`;
1343
2808
  }
@@ -1353,7 +2818,7 @@ function parseJsonObject(content, label) {
1353
2818
  return asRecord(JSON.parse(content), label);
1354
2819
  }
1355
2820
  catch (error) {
1356
- throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`);
2821
+ throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`, { cause: error });
1357
2822
  }
1358
2823
  }
1359
2824
  function parseOptionalJsonObject(content, label) {