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

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,62 @@ 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
+ };
49
+ const qaBlockerReasons = new Set([
50
+ "missing_app_launch_target",
51
+ "inconclusive",
52
+ "environment_failure",
53
+ "missing_validation_evidence",
54
+ "missing_worktree",
55
+ "runner_failure"
56
+ ]);
57
+ const resolvedFindingStatusValues = ["resolved", "disputed", "not_reproduced"];
58
+ const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
59
+ const qaFindingSeverities = new Set(["critical", "major", "minor"]);
60
+ const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
61
+ const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
9
62
  const phaseFiles = {
10
63
  implementation: {
11
64
  jsonFileName: "implementation.completeness.json",
@@ -18,6 +71,30 @@ const phaseFiles = {
18
71
  validation: {
19
72
  jsonFileName: "validation.verdict.json",
20
73
  reportFileName: "validation.report.md"
74
+ },
75
+ qa: {
76
+ jsonFileName: "qa.verdict.json",
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"
21
98
  }
22
99
  };
23
100
  export function completionPhaseFromDispatch(dispatch) {
@@ -25,12 +102,22 @@ export function completionPhaseFromDispatch(dispatch) {
25
102
  }
26
103
  export function normalizeCompletionPhase(value) {
27
104
  const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
105
+ const debugPhase = normalizeDebugCompletionPhase(normalized);
106
+ if (debugPhase) {
107
+ return debugPhase;
108
+ }
28
109
  if (normalized === "self_review" || normalized === "selfreview" || normalized === "review") {
29
110
  return "self_review";
30
111
  }
31
112
  if (normalized === "validate" || normalized === "validation") {
32
113
  return "validation";
33
114
  }
115
+ if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
116
+ return "qa";
117
+ }
118
+ if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
119
+ return "architect";
120
+ }
34
121
  return "implementation";
35
122
  }
36
123
  export function usesDevelopCycleCompletion(dispatch) {
@@ -42,36 +129,97 @@ export function usesDevelopCycleCompletion(dispatch) {
42
129
  && lane === "develop"
43
130
  && completionPhaseFromDispatch(dispatch) === "implementation";
44
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
+ }
45
156
  export function completionToolNamesForPhase(phase) {
157
+ if (isDebugCompletionPhase(phase)) {
158
+ return completionToolNamesForDebugGate(phase);
159
+ }
46
160
  const base = phase === "implementation"
47
161
  ? "complete_implementation"
48
162
  : phase === "self_review"
49
163
  ? "complete_self_review"
50
- : "complete_validation";
164
+ : phase === "validation"
165
+ ? "complete_validation"
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];
51
177
  return [`forge.${base}`, base];
52
178
  }
53
179
  export function expectedCompletionToolName(phase) {
54
180
  return completionToolNamesForPhase(phase)[0];
55
181
  }
182
+ export function expectedQaCompletionToolName(gate) {
183
+ return completionToolNamesForQaGate(gate)[0];
184
+ }
185
+ export function expectedDebugCompletionToolName(gate) {
186
+ return completionToolNamesForDebugGate(gate)[0];
187
+ }
56
188
  export function artifactRootFor(worktreePath, workItemId) {
57
189
  return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
58
190
  }
59
- export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
60
- 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);
61
199
  }
62
- export function completionArtifactReportPath(worktreePath, workItemId, phase) {
63
- 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);
64
205
  }
65
206
  export async function prepareCompletionToolRuntime(options) {
66
207
  const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
67
208
  const worktreePath = path.resolve(options.worktree.worktreePath);
68
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;
69
212
  const mcpArgs = buildCompletionToolServerArgs({
70
213
  worktreePath,
71
214
  workItemId,
215
+ ownerType,
216
+ ownerId,
72
217
  phase,
73
218
  producedBy: `${options.dispatch.execution.provider}-cli`,
74
- developCycle: usesDevelopCycleCompletion(options.dispatch)
219
+ developCycle: usesDevelopCycleCompletion(options.dispatch),
220
+ qaCycle: usesQaCycleCompletion(options.dispatch),
221
+ debugCycle: usesDebugCycleCompletion(options.dispatch),
222
+ architectCycle: usesArchitectCycleCompletion(options.dispatch)
75
223
  });
76
224
  const runtime = {
77
225
  serverName: toolServerName,
@@ -97,9 +245,22 @@ export function buildCompletionToolServerArgs(config) {
97
245
  "--produced-by",
98
246
  config.producedBy ?? "forge-completion-tool"
99
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
+ }
100
252
  if (config.developCycle) {
101
253
  args.push("--develop-cycle");
102
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
+ }
103
264
  return args;
104
265
  }
105
266
  export function codexMcpConfigArgs(runtime) {
@@ -126,6 +287,24 @@ export function completionPromptForPhase(phase) {
126
287
  const toolName = expectedCompletionToolName(phase);
127
288
  const alias = completionToolNamesForPhase(phase).find((name) => name !== toolName);
128
289
  const artifactName = phaseFiles[phase].jsonFileName;
290
+ if (phase === "qa") {
291
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
292
+ Do not write QA artifacts by hand. When QA is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the required structured QA payload.
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\`.
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.
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
+ \`implementation_plan.tasks\` are Develop child tasks only: each task must require a focused repo/product implementation change. Put parent-story QA, sign-off, child-count/materialization inventory, parent plan artifact checks, and final integrated acceptance proof in \`qa_plan.final_qa\`, not as child Develop tasks.
304
+ 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.
305
+ Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
306
+ Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
307
+ }
129
308
  return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
130
309
  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.
131
310
  That tool writes \`.forge/work-items/{workItemId}/artifacts/${artifactName}\` and returns the artifact reference that Forge will mirror through the existing phase-artifact pipeline.
@@ -141,31 +320,74 @@ Complete the dev cycle in order inside this same provider session:
141
320
  Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
142
321
  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.`;
143
322
  }
323
+ export function completionPromptForQaCycle(currentGate = "qa_plan") {
324
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
325
+ Do not write QA artifacts by hand. The tool server enforces QA gate order and starts this run at \`${currentGate}\`.
326
+ Complete the QA cycle in order inside this same provider session:
327
+ 1. Call \`forge.complete_qa_plan(...)\` after the QA test plan/checklist is ready.
328
+ 2. Follow the returned regression instruction, then call \`forge.complete_qa_regression(...)\`.
329
+ 3. Follow the returned API/backend instruction, then call \`forge.complete_qa_api(...)\`.
330
+ 4. Follow the returned manual web instruction, then call \`forge.complete_qa_manual_web(...)\`.
331
+ 5. Follow the returned bug reproduction instruction, then call \`forge.complete_qa_bug_repro(...)\`.
332
+ 6. Follow the returned findings instruction, then call \`forge.complete_qa_findings(...)\`.
333
+ 7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
334
+ 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.
335
+ 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\`.
336
+ The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
337
+ }
338
+ export function completionPromptForDebugCycle(currentGate = "debug_repro") {
339
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
340
+ Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
341
+ Complete the Debug cycle in order inside this same provider session:
342
+ 1. Reproduce or tightly characterize the bug, then call \`forge.complete_debug_repro(...)\`.
343
+ 2. Follow the returned root-cause instruction, isolate evidence, then call \`forge.complete_debug_root_cause(...)\`.
344
+ 3. Follow the returned fix instruction, apply the smallest safe fix with regression coverage when practical, then call \`forge.complete_debug_fix(...)\`.
345
+ 4. Follow the returned regression instruction, prove the original repro is negative and the fix is QA-ready, then call \`forge.complete_debug_regression(...)\`.
346
+ 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.
347
+ 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.`;
348
+ }
144
349
  export function completionToolNamesForServer(config) {
145
- return config.developCycle
146
- ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
147
- : completionToolNamesForPhase(config.phase);
350
+ return config.debugCycle
351
+ ? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
352
+ : config.qaCycle
353
+ ? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
354
+ : config.developCycle
355
+ ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
356
+ : config.architectCycle
357
+ ? completionToolNamesForPhase("architect")
358
+ : completionToolNamesForPhase(config.phase);
148
359
  }
149
360
  export class CompletionToolServerSession {
150
361
  config;
151
362
  currentPhase;
363
+ currentQaGate = "qa_plan";
152
364
  completed = false;
153
365
  constructor(config) {
154
366
  this.config = config;
155
367
  this.currentPhase = config.phase;
156
368
  }
157
369
  get instructions() {
158
- return this.config.developCycle
159
- ? completionPromptForDevelopCycle(this.currentPhase)
160
- : completionPromptForPhase(this.currentPhase);
370
+ return this.config.debugCycle
371
+ ? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
372
+ : this.config.qaCycle
373
+ ? completionPromptForQaCycle(this.currentQaGate)
374
+ : this.config.developCycle
375
+ ? completionPromptForDevelopCycle(this.currentPhase)
376
+ : completionPromptForPhase(this.currentPhase);
161
377
  }
162
378
  toolDefinitions() {
163
- return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name)));
379
+ return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
164
380
  }
165
381
  async callTool(toolName, input) {
166
382
  const requestedPhase = phaseFromToolName(toolName);
167
383
  if (this.completed) {
168
- throw new Error("Forge develop cycle is already complete; no more phase completion calls are accepted.");
384
+ throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
385
+ }
386
+ if (this.config.debugCycle) {
387
+ return await this.callDebugGateTool(toolName, requestedPhase, input);
388
+ }
389
+ if (this.config.qaCycle) {
390
+ return await this.callQaGateTool(toolName, requestedPhase, input);
169
391
  }
170
392
  if (requestedPhase !== this.currentPhase) {
171
393
  throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
@@ -201,7 +423,91 @@ export class CompletionToolServerSession {
201
423
  }
202
424
  };
203
425
  }
426
+ async callDebugGateTool(toolName, requestedPhase, input) {
427
+ const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
428
+ if (!isDebugCompletionPhase(requestedPhase)) {
429
+ throw new Error(`Tool ${toolName} is not available in Debug-cycle mode. Current Forge Debug gate is ${currentDebugGate}.`);
430
+ }
431
+ if (requestedPhase !== currentDebugGate) {
432
+ throw new Error(`Tool ${toolName} is out of order. Current Forge Debug gate is ${currentDebugGate}.`);
433
+ }
434
+ const completedGate = currentDebugGate;
435
+ const reference = await writeCompletionArtifact({ ...this.config, phase: completedGate }, toolName, input);
436
+ const status = reference.status.trim().toLowerCase();
437
+ const pass = status === "pass";
438
+ const nextGate = pass ? this.advanceAfterDebugGate(completedGate) : completedGate;
439
+ const nextPhase = nextGate ?? "qa_ready";
440
+ const nextInstruction = completionToolNextInstructionForDebugGate(completedGate, status, nextGate);
441
+ const reason = pass
442
+ ? `${completedGate} artifact accepted${nextGate ? `; continue to ${nextGate}.` : "; Debug regression is QA-ready."}`
443
+ : `${completedGate} artifact recorded with status ${status}; rework is required before Debug progression.`;
444
+ return {
445
+ content: [
446
+ {
447
+ type: "text",
448
+ text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
449
+ }
450
+ ],
451
+ structuredContent: {
452
+ ...reference,
453
+ gate: {
454
+ phase: completedGate,
455
+ status,
456
+ allowed: pass,
457
+ reason
458
+ },
459
+ nextPhase,
460
+ nextInstruction
461
+ }
462
+ };
463
+ }
464
+ async callQaGateTool(toolName, requestedPhase, input) {
465
+ const requestedGate = qaGateFromToolName(toolName);
466
+ if (requestedPhase !== "qa" || !requestedGate) {
467
+ throw new Error(`Tool ${toolName} is not available in QA-cycle mode. Current Forge QA gate is ${this.currentQaGate}.`);
468
+ }
469
+ if (requestedGate !== this.currentQaGate) {
470
+ throw new Error(`Tool ${toolName} is out of order. Current Forge QA gate is ${this.currentQaGate}.`);
471
+ }
472
+ const completedGate = this.currentQaGate;
473
+ const reference = await writeCompletionArtifact({ ...this.config, phase: "qa" }, toolName, input);
474
+ const status = reference.status.trim().toLowerCase();
475
+ const nextGate = this.advanceAfterQaGate(completedGate);
476
+ const nextPhase = nextGate ? "qa" : "sign_off_ready";
477
+ const nextInstruction = completionToolNextInstructionForQaGate(completedGate, status, nextGate);
478
+ const allowed = status !== "blocked";
479
+ const reason = nextGate
480
+ ? `${completedGate} artifact accepted; continue to ${nextGate}.`
481
+ : `QA verdict artifact accepted with status ${status}.`;
482
+ return {
483
+ content: [
484
+ {
485
+ type: "text",
486
+ text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
487
+ }
488
+ ],
489
+ structuredContent: {
490
+ ...reference,
491
+ gate: {
492
+ phase: "qa",
493
+ qaGate: completedGate,
494
+ status,
495
+ allowed,
496
+ reason
497
+ },
498
+ nextPhase,
499
+ nextInstruction
500
+ }
501
+ };
502
+ }
204
503
  advanceAfterPass(phase) {
504
+ if (phase === "qa") {
505
+ this.completed = true;
506
+ return "sign_off_ready";
507
+ }
508
+ if (isDebugCompletionPhase(phase)) {
509
+ return nextDebugPhaseAfter(phase) ?? "qa_ready";
510
+ }
205
511
  const next = nextPhaseAfter(phase);
206
512
  if (!this.config.developCycle) {
207
513
  return next ?? "dev_test_ready";
@@ -213,15 +519,42 @@ export class CompletionToolServerSession {
213
519
  this.completed = true;
214
520
  return "dev_test_ready";
215
521
  }
522
+ advanceAfterQaGate(gate) {
523
+ const index = qaCompletionGateOrder.indexOf(gate);
524
+ const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
525
+ if (next) {
526
+ this.currentQaGate = next;
527
+ return next;
528
+ }
529
+ this.completed = true;
530
+ return null;
531
+ }
532
+ advanceAfterDebugGate(gate) {
533
+ const index = debugCompletionPhaseOrder.indexOf(gate);
534
+ const next = index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
535
+ if (next) {
536
+ this.currentPhase = next;
537
+ return next;
538
+ }
539
+ this.completed = true;
540
+ return null;
541
+ }
216
542
  }
217
543
  export async function writeCompletionArtifact(config, toolName, input) {
218
544
  const phase = phaseFromToolName(toolName);
219
545
  if (phase !== config.phase) {
220
546
  throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
221
547
  }
548
+ const qaGate = qaGateFromToolName(toolName);
549
+ if (qaGate && qaGate !== "qa_verdict") {
550
+ return await writeQaGateCompletionArtifact(config, qaGate, input);
551
+ }
222
552
  const payload = asRecord(input, "completion payload");
223
- const status = normalizeCompletionStatus(requiredString(payload, "status"));
224
- const validationPayload = { ...payload, status };
553
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
554
+ let validationPayload = { ...payload, status };
555
+ if (config.qaCycle && qaGate === "qa_verdict") {
556
+ validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
557
+ }
225
558
  validatePayloadShape(phase, validationPayload);
226
559
  validateOptionalWorkItemId(validationPayload, config.workItemId);
227
560
  const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
@@ -229,9 +562,17 @@ export async function writeCompletionArtifact(config, toolName, input) {
229
562
  ?? optionalString(redactedPayload, "producedBy")
230
563
  ?? config.producedBy
231
564
  ?? "forge-completion-tool";
565
+ if (phase === "qa") {
566
+ return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
567
+ }
568
+ if (phase === "architect") {
569
+ return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
570
+ }
232
571
  const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
233
572
  phase,
234
573
  workItemId: config.workItemId,
574
+ ownerType: config.ownerType,
575
+ ownerId: config.ownerId,
235
576
  producedBy,
236
577
  status
237
578
  });
@@ -250,6 +591,93 @@ export async function writeCompletionArtifact(config, toolName, input) {
250
591
  return {
251
592
  phase,
252
593
  workItemId: config.workItemId,
594
+ ...artifactReferenceOwnerFields(config),
595
+ repoPath: path.resolve(config.worktreePath),
596
+ path: artifactPath,
597
+ reportPath,
598
+ contentHash,
599
+ status,
600
+ producedBy
601
+ };
602
+ }
603
+ async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
604
+ const ownerFields = artifactOwnerFields(config);
605
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
606
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
607
+ : artifactRootFor(config.worktreePath, config.workItemId);
608
+ const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
609
+ const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
610
+ const verdictPath = path.join(artifactRoot, "architect.verdict.json");
611
+ const reportPath = path.join(artifactRoot, "architect.report.md");
612
+ const implementationPlan = requiredRecord(payload, "implementation_plan");
613
+ const qaPlan = requiredRecord(payload, "qa_plan");
614
+ const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
615
+ const artifactWithoutHash = normalizeArtifactPayload(payload, {
616
+ phase: "architect",
617
+ workItemId: config.workItemId,
618
+ ownerType: config.ownerType,
619
+ ownerId: config.ownerId,
620
+ producedBy,
621
+ status
622
+ });
623
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
624
+ const verdict = {
625
+ ...artifactWithoutHash,
626
+ content_hash: contentHash
627
+ };
628
+ await fs.mkdir(artifactRoot, { recursive: true });
629
+ await writeFileAtomic(implementationPlanPath, jsonText(implementationPlan));
630
+ await writeFileAtomic(qaPlanPath, jsonText(qaPlan));
631
+ await writeFileAtomic(verdictPath, jsonText(verdict));
632
+ await writeFileAtomic(reportPath, `${report.trim()}\n`);
633
+ return {
634
+ phase: "architect",
635
+ workItemId: config.workItemId,
636
+ ...artifactReferenceOwnerFields(config),
637
+ repoPath: path.resolve(config.worktreePath),
638
+ path: verdictPath,
639
+ reportPath,
640
+ contentHash,
641
+ status,
642
+ producedBy
643
+ };
644
+ }
645
+ async function writeQaGateCompletionArtifact(config, gate, input) {
646
+ const payload = asRecord(input, "QA gate completion payload");
647
+ const status = normalizeQaGateStatus(requiredString(payload, "status"));
648
+ validateQaGatePayloadShape(gate, payload, status);
649
+ validateOptionalWorkItemId(payload, config.workItemId);
650
+ const redactedPayload = redactSecrets({ ...payload, status }, config.redactionSecrets ?? []);
651
+ const producedBy = optionalString(redactedPayload, "produced_by")
652
+ ?? optionalString(redactedPayload, "producedBy")
653
+ ?? config.producedBy
654
+ ?? "forge-completion-tool";
655
+ const ownerFields = artifactOwnerFields(config);
656
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
657
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
658
+ : artifactRootFor(config.worktreePath, config.workItemId);
659
+ const fileName = qaGateFileNames[gate];
660
+ const artifactPath = path.join(artifactRoot, fileName);
661
+ const reportPath = path.join(artifactRoot, "qa.report.md");
662
+ const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
663
+ gate,
664
+ workItemId: config.workItemId,
665
+ ownerType: config.ownerType,
666
+ ownerId: config.ownerId,
667
+ producedBy,
668
+ status
669
+ });
670
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
671
+ const artifact = {
672
+ ...artifactWithoutHash,
673
+ content_hash: contentHash
674
+ };
675
+ await fs.mkdir(artifactRoot, { recursive: true });
676
+ await writeFileAtomic(artifactPath, jsonText(artifact));
677
+ return {
678
+ phase: "qa",
679
+ workItemId: config.workItemId,
680
+ ...artifactReferenceOwnerFields(config),
253
681
  repoPath: path.resolve(config.worktreePath),
254
682
  path: artifactPath,
255
683
  reportPath,
@@ -258,19 +686,319 @@ export async function writeCompletionArtifact(config, toolName, input) {
258
686
  producedBy
259
687
  };
260
688
  }
261
- export async function readCompletionArtifact(worktreePath, workItemId, phase) {
262
- const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
263
- const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
689
+ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
690
+ const ownerFields = artifactOwnerFields(config);
691
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
692
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
693
+ : artifactRootFor(config.worktreePath, config.workItemId);
694
+ const planPath = path.join(artifactRoot, "qa.plan.json");
695
+ const evidencePath = path.join(artifactRoot, "qa.evidence.json");
696
+ const findingsPath = path.join(artifactRoot, "qa.findings.json");
697
+ const verdictPath = path.join(artifactRoot, "qa.verdict.json");
698
+ const reportPath = path.join(artifactRoot, "qa.report.md");
699
+ const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
700
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
701
+ : qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
702
+ const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
703
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
704
+ : qaArtifactRelativePath(config.workItemId, "qa.findings.json");
705
+ const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
706
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
707
+ : qaArtifactRelativePath(config.workItemId, "qa.report.md");
708
+ const evidenceSource = qaEvidenceSource(payload);
709
+ const commands = qaArray(payload, "commands", evidenceSource);
710
+ const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
711
+ const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
712
+ const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
713
+ const findings = qaObjectArray(payload, "findings");
714
+ const candidateBugs = qaObjectArray(payload, "candidate_bugs");
715
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
716
+ const explicitFindingIds = qaStringArray(payload, "finding_ids");
717
+ const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
718
+ const blockerReason = optionalString(payload, "blocker_reason");
719
+ const checklist = qaStringArray(payload, "checklist");
720
+ const planJson = jsonText({
721
+ schema_version: 1,
722
+ phase: "qa",
723
+ ...ownerFields,
724
+ checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
725
+ acceptance_criteria: testedAcceptanceCriteria
726
+ });
727
+ const evidenceJson = jsonText({
728
+ schema_version: 1,
729
+ phase: "qa",
730
+ ...ownerFields,
731
+ commands,
732
+ browser_checks: browserChecks,
733
+ sidecars,
734
+ notes
735
+ });
736
+ const findingsJson = jsonText({
737
+ schema_version: 1,
738
+ phase: "qa",
739
+ ...ownerFields,
740
+ findings,
741
+ candidate_bugs: candidateBugs
742
+ });
743
+ const findingIds = status === "pass"
744
+ ? []
745
+ : explicitFindingIds.length > 0
746
+ ? explicitFindingIds
747
+ : findings
748
+ .filter((finding) => normalizedString(finding.scope) === "in_scope")
749
+ .map((finding) => optionalString(finding, "id"))
750
+ .filter((id) => Boolean(id));
751
+ const candidateBugIds = explicitCandidateBugIds.length > 0
752
+ ? explicitCandidateBugIds
753
+ : candidateBugs
754
+ .map((candidateBug) => optionalString(candidateBug, "id"))
755
+ .filter((id) => Boolean(id));
756
+ const verdictWithoutHash = {
757
+ schema_version: numberOrDefault(payload.schema_version, 1),
758
+ phase: "qa",
759
+ ...ownerFields,
760
+ status,
761
+ summary: requiredString(payload, "summary"),
762
+ tested_acceptance_criteria: testedAcceptanceCriteria,
763
+ finding_ids: findingIds,
764
+ candidate_bug_ids: candidateBugIds,
765
+ blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
766
+ evidence_path: relativeEvidencePath,
767
+ findings_path: relativeFindingsPath,
768
+ report_path: relativeReportPath,
769
+ produced_by: producedBy
770
+ };
771
+ const contentHash = sha256(stableStringify({
772
+ plan: JSON.parse(planJson),
773
+ evidence: JSON.parse(evidenceJson),
774
+ findings: JSON.parse(findingsJson),
775
+ verdict: verdictWithoutHash
776
+ }));
777
+ const verdictJson = jsonText({
778
+ ...verdictWithoutHash,
779
+ content_hash: contentHash
780
+ });
781
+ const finalReport = reportMarkdown(payload, "qa", status, producedBy, contentHash);
782
+ await fs.mkdir(artifactRoot, { recursive: true });
783
+ await writeFileAtomic(planPath, planJson);
784
+ await writeFileAtomic(evidencePath, evidenceJson);
785
+ await writeFileAtomic(findingsPath, findingsJson);
786
+ await writeFileAtomic(verdictPath, verdictJson);
787
+ await writeFileAtomic(reportPath, finalReport);
788
+ return {
789
+ phase: "qa",
790
+ workItemId: config.workItemId,
791
+ ...artifactReferenceOwnerFields(config),
792
+ repoPath: path.resolve(config.worktreePath),
793
+ path: verdictPath,
794
+ reportPath,
795
+ contentHash,
796
+ status,
797
+ producedBy
798
+ };
799
+ }
800
+ async function synthesizeQaCycleVerdictPayload(config, payload, status) {
801
+ const qaPlan = await readQaGateArtifact(config, "qa_plan");
802
+ const qaRegression = await readQaGateArtifact(config, "qa_regression");
803
+ const qaApi = await readQaGateArtifact(config, "qa_api");
804
+ const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
805
+ const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
806
+ const qaFindings = await readQaGateArtifact(config, "qa_findings");
807
+ const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
808
+ const payloadEvidence = qaEvidenceSource(payload);
809
+ const merged = { ...payload };
810
+ const checklist = qaStringArray(payload, "checklist");
811
+ if (checklist.length === 0) {
812
+ const plannedChecklist = qaStringArray(qaPlan, "checklist");
813
+ merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
814
+ }
815
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
816
+ if (testedAcceptanceCriteria.length === 0) {
817
+ const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
818
+ const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
819
+ const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
820
+ if (acceptanceCriteria.length > 0) {
821
+ merged.tested_acceptance_criteria = acceptanceCriteria;
822
+ }
823
+ }
824
+ const payloadCommands = qaArray(payload, "commands", payloadEvidence);
825
+ const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
826
+ const inheritedDiagnosticCommandNotes = status === "pass"
827
+ ? qaNonPassingCommandNotes(inheritedCommands)
828
+ : [];
829
+ merged.commands = dedupeUnknownArray([
830
+ ...payloadCommands,
831
+ ...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
832
+ ]);
833
+ merged.browser_checks = dedupeUnknownArray([
834
+ ...qaArray(payload, "browser_checks", payloadEvidence),
835
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
836
+ ]);
837
+ merged.sidecars = dedupeUnknownArray([
838
+ ...qaArray(payload, "sidecars", payloadEvidence),
839
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
840
+ ]);
841
+ const notes = dedupeStrings([
842
+ optionalString(payload, "notes"),
843
+ optionalString(payloadEvidence, "notes"),
844
+ ...evidenceGates.map((gate) => optionalString(gate, "notes")),
845
+ ...inheritedDiagnosticCommandNotes
846
+ ]);
847
+ if (notes.length > 0) {
848
+ merged.notes = notes.join("\n");
849
+ }
850
+ merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
851
+ merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
852
+ return merged;
853
+ }
854
+ async function readQaGateArtifact(config, gate) {
855
+ const ownerFields = artifactOwnerFields(config);
856
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
857
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
858
+ : artifactRootFor(config.worktreePath, config.workItemId);
859
+ const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
860
+ const content = await readOptionalFile(artifactPath);
861
+ return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
862
+ }
863
+ function dedupeUnknownArray(values) {
864
+ const seen = new Set();
865
+ const deduped = [];
866
+ for (const value of values) {
867
+ const key = stableStringify(value);
868
+ if (seen.has(key)) {
869
+ continue;
870
+ }
871
+ seen.add(key);
872
+ deduped.push(value);
873
+ }
874
+ return deduped;
875
+ }
876
+ function isQaPassingCommandEvidence(value) {
877
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
878
+ return false;
879
+ }
880
+ const record = value;
881
+ return record.exit_code === 0 && normalizedString(record.status) === "pass";
882
+ }
883
+ function qaNonPassingCommandNotes(values) {
884
+ return values
885
+ .filter((value) => !isQaPassingCommandEvidence(value))
886
+ .map((value, index) => {
887
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
888
+ return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
889
+ }
890
+ const record = value;
891
+ const command = optionalString(record, "command") ?? "<missing command>";
892
+ const status = optionalString(record, "status") ?? "<missing status>";
893
+ const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
894
+ ? String(record.exit_code)
895
+ : "<missing exit_code>";
896
+ const summary = optionalString(record, "summary");
897
+ return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
898
+ });
899
+ }
900
+ function mergeQaObjectArraysById(primary, fallback) {
901
+ const seen = new Set();
902
+ const merged = [];
903
+ for (const value of [...primary, ...fallback]) {
904
+ const id = optionalString(value, "id");
905
+ const key = id ? `id:${id}` : stableStringify(value);
906
+ if (seen.has(key)) {
907
+ continue;
908
+ }
909
+ seen.add(key);
910
+ merged.push(value);
911
+ }
912
+ return merged;
913
+ }
914
+ function dedupeStrings(values) {
915
+ const seen = new Set();
916
+ const deduped = [];
917
+ for (const value of values) {
918
+ const trimmed = value?.trim();
919
+ if (!trimmed || seen.has(trimmed)) {
920
+ continue;
921
+ }
922
+ seen.add(trimmed);
923
+ deduped.push(trimmed);
924
+ }
925
+ return deduped;
926
+ }
927
+ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
928
+ if (sidecars.length === 0) {
929
+ return [];
930
+ }
931
+ const artifactRoot = ownerType && ownerId
932
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
933
+ : artifactRootFor(worktreePath, workItemId);
934
+ const qaSidecarRoot = path.resolve(artifactRoot, "qa");
935
+ const validated = [];
936
+ for (const [index, sidecar] of sidecars.entries()) {
937
+ if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
938
+ throw new Error(`sidecars[${index}] must be an object.`);
939
+ }
940
+ const record = sidecar;
941
+ const sidecarPath = optionalString(record, "path");
942
+ const contentHash = optionalString(record, "content_hash");
943
+ const bytes = record.bytes;
944
+ if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
945
+ throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
946
+ }
947
+ if (path.isAbsolute(sidecarPath)) {
948
+ throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
949
+ }
950
+ const fullPath = path.resolve(worktreePath, sidecarPath);
951
+ if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
952
+ throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
953
+ }
954
+ const stat = await fs.lstat(fullPath).catch((error) => {
955
+ if (isNodeError(error) && error.code === "ENOENT") {
956
+ throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
957
+ }
958
+ throw error;
959
+ });
960
+ if (!stat.isFile() || stat.isSymbolicLink()) {
961
+ throw new Error(`sidecars[${index}].path must reference a regular file.`);
962
+ }
963
+ await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
964
+ if (stat.size !== bytes) {
965
+ throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
966
+ }
967
+ const actualHash = await sha256File(fullPath);
968
+ if (actualHash !== contentHash.trim().toLowerCase()) {
969
+ throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
970
+ }
971
+ validated.push({
972
+ ...record,
973
+ path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
974
+ content_hash: actualHash,
975
+ bytes: stat.size
976
+ });
977
+ }
978
+ return validated;
979
+ }
980
+ export async function readCompletionArtifact(worktreePath, workItemId, phase, ownerType, ownerId) {
981
+ const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId);
982
+ const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId);
264
983
  const verdictJson = await readOptionalFile(artifactPath);
265
984
  if (verdictJson === null) {
266
985
  return null;
267
986
  }
268
987
  const parsed = parseJsonObject(verdictJson, artifactPath);
269
988
  const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
989
+ const artifactRoot = path.dirname(artifactPath);
990
+ const implementationPlanJson = phase === "architect"
991
+ ? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
992
+ : null;
993
+ const qaPlanJson = phase === "architect"
994
+ ? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
995
+ : null;
270
996
  return {
271
997
  repoPath: path.resolve(worktreePath),
272
998
  path: artifactPath,
273
999
  verdictJson,
1000
+ implementationPlanJson,
1001
+ qaPlanJson,
274
1002
  report,
275
1003
  producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
276
1004
  status: optionalString(parsed, "status") ?? "unknown",
@@ -302,8 +1030,13 @@ export function parseCompletionMcpArgs(argv) {
302
1030
  let worktreePath = null;
303
1031
  let workItemId = null;
304
1032
  let phase = "implementation";
1033
+ let ownerType;
1034
+ let ownerId;
305
1035
  let producedBy;
306
1036
  let developCycle = false;
1037
+ let qaCycle = false;
1038
+ let debugCycle = false;
1039
+ let architectCycle = false;
307
1040
  for (let index = 1; index < argv.length; index += 1) {
308
1041
  const arg = argv[index];
309
1042
  if (arg === "--worktree") {
@@ -312,11 +1045,20 @@ export function parseCompletionMcpArgs(argv) {
312
1045
  else if (arg === "--work-item-id") {
313
1046
  workItemId = argv[++index] ?? null;
314
1047
  }
1048
+ else if (arg === "--owner-type") {
1049
+ ownerType = normalizedOwnerType(argv[++index]);
1050
+ if (!ownerType) {
1051
+ return { ok: false, error: "mcp --owner-type must be work_item or story." };
1052
+ }
1053
+ }
1054
+ else if (arg === "--owner-id") {
1055
+ ownerId = argv[++index] ?? undefined;
1056
+ }
315
1057
  else if (arg === "--phase") {
316
1058
  const rawPhase = argv[++index];
317
1059
  const parsedPhase = parseStrictCompletionPhase(rawPhase);
318
1060
  if (!parsedPhase) {
319
- return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation." };
1061
+ 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." };
320
1062
  }
321
1063
  phase = parsedPhase;
322
1064
  }
@@ -326,6 +1068,15 @@ export function parseCompletionMcpArgs(argv) {
326
1068
  else if (arg === "--develop-cycle") {
327
1069
  developCycle = true;
328
1070
  }
1071
+ else if (arg === "--qa-cycle") {
1072
+ qaCycle = true;
1073
+ }
1074
+ else if (arg === "--debug-cycle") {
1075
+ debugCycle = true;
1076
+ }
1077
+ else if (arg === "--architect-cycle") {
1078
+ architectCycle = true;
1079
+ }
329
1080
  else {
330
1081
  return { ok: false, error: `Unknown mcp argument: ${arg}` };
331
1082
  }
@@ -333,15 +1084,35 @@ export function parseCompletionMcpArgs(argv) {
333
1084
  if (!worktreePath?.trim() || !workItemId?.trim()) {
334
1085
  return { ok: false, error: "mcp requires --worktree and --work-item-id." };
335
1086
  }
1087
+ if ([developCycle, qaCycle, debugCycle, architectCycle].filter(Boolean).length > 1) {
1088
+ return { ok: false, error: "mcp accepts only one cycle mode." };
1089
+ }
1090
+ if (developCycle && !isDevelopCompletionPhase(phase)) {
1091
+ return { ok: false, error: "mcp --develop-cycle requires --phase implementation, self-review, or validation." };
1092
+ }
1093
+ if (qaCycle && phase !== "qa") {
1094
+ return { ok: false, error: "mcp --qa-cycle requires --phase qa." };
1095
+ }
1096
+ if (debugCycle && !isDebugCompletionPhase(phase)) {
1097
+ return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
1098
+ }
1099
+ if (architectCycle && phase !== "architect") {
1100
+ return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
1101
+ }
336
1102
  return {
337
1103
  ok: true,
338
1104
  config: {
339
1105
  worktreePath: path.resolve(worktreePath),
340
1106
  workItemId,
1107
+ ownerType,
1108
+ ownerId,
341
1109
  phase,
342
1110
  producedBy,
343
1111
  redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
344
- developCycle
1112
+ developCycle,
1113
+ qaCycle,
1114
+ debugCycle,
1115
+ architectCycle
345
1116
  }
346
1117
  };
347
1118
  }
@@ -436,39 +1207,125 @@ async function handleJsonRpcRequest(session, method, params) {
436
1207
  }
437
1208
  throw new Error(`Unsupported MCP method: ${method}`);
438
1209
  }
439
- function toolDefinition(name, phase) {
1210
+ function toolDefinition(name, phase, qaGate = null) {
440
1211
  return {
441
1212
  name,
442
- description: toolDescription(phase),
443
- inputSchema: toolInputSchema(phase)
1213
+ description: toolDescription(phase, qaGate),
1214
+ inputSchema: toolInputSchema(phase, qaGate)
444
1215
  };
445
1216
  }
446
1217
  function nextPhaseAfter(phase) {
1218
+ if (!isDevelopCompletionPhase(phase)) {
1219
+ return null;
1220
+ }
447
1221
  const index = completionPhaseOrder.indexOf(phase);
448
1222
  return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
449
1223
  }
1224
+ function nextDebugPhaseAfter(phase) {
1225
+ const index = debugCompletionPhaseOrder.indexOf(phase);
1226
+ return index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
1227
+ }
450
1228
  function completionToolNextInstruction(completedPhase, status, nextPhase) {
1229
+ if (completedPhase === "qa") {
1230
+ if (status === "blocked") {
1231
+ return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
1232
+ }
1233
+ if (status === "needs_rework") {
1234
+ return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
1235
+ }
1236
+ return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
1237
+ }
451
1238
  if (status !== "pass") {
452
1239
  return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
453
1240
  }
1241
+ if (isDebugCompletionPhase(completedPhase)) {
1242
+ const debugNext = isDebugCompletionPhase(nextPhase) ? nextPhase : null;
1243
+ return completionToolNextInstructionForDebugGate(completedPhase, status, debugNext);
1244
+ }
454
1245
  if (nextPhase === "self_review") {
455
1246
  return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
456
1247
  }
457
1248
  if (nextPhase === "validation") {
458
1249
  return "Self-review accepted. Begin validation: run deterministic checks, prove acceptance criteria, and call forge.complete_validation with command evidence.";
459
1250
  }
460
- return "Validation accepted. Forge has the structured evidence needed to move this work item to Dev Test.";
1251
+ return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
461
1252
  }
462
- function toolDescription(phase) {
1253
+ function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
1254
+ if (!nextGate) {
1255
+ if (status === "blocked") {
1256
+ return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
1257
+ }
1258
+ if (status === "needs_rework") {
1259
+ return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
1260
+ }
1261
+ return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
1262
+ }
1263
+ const skipped = status === "skipped";
1264
+ const blocked = status === "blocked";
1265
+ const prefix = blocked
1266
+ ? `${completedGate} recorded a blocker. Continue through ${nextGate} and mark downstream gates skipped if the blocker prevents evidence collection.`
1267
+ : skipped
1268
+ ? `${completedGate} skipped. Continue to ${nextGate}.`
1269
+ : `${completedGate} accepted. Continue to ${nextGate}.`;
1270
+ return `${prefix} ${qaGateInstruction(nextGate)}`;
1271
+ }
1272
+ function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
1273
+ if (status !== "pass") {
1274
+ return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
1275
+ }
1276
+ if (nextGate === "debug_root_cause") {
1277
+ return "Debug repro accepted. Begin root-cause analysis: connect the observed failure to code evidence and call forge.complete_debug_root_cause.";
1278
+ }
1279
+ if (nextGate === "debug_fix") {
1280
+ return "Debug root cause accepted. Apply the smallest safe fix with regression coverage where practical, then call forge.complete_debug_fix.";
1281
+ }
1282
+ if (nextGate === "debug_regression") {
1283
+ return "Debug fix accepted. Prove the original repro is negative, run targeted regression checks, and call forge.complete_debug_regression.";
1284
+ }
1285
+ return "Debug regression accepted. Forge has the structured evidence needed to move this bug fix to QA.";
1286
+ }
1287
+ function qaGateInstruction(gate) {
1288
+ switch (gate) {
1289
+ case "qa_regression":
1290
+ return "Run or explicitly skip applicable regression/build/lint/typecheck/e2e checks, then call forge.complete_qa_regression.";
1291
+ case "qa_api":
1292
+ return "Run or explicitly skip applicable API/backend contract checks, then call forge.complete_qa_api.";
1293
+ case "qa_manual_web":
1294
+ return "Run or explicitly skip applicable browser/manual web checks, then call forge.complete_qa_manual_web.";
1295
+ case "qa_bug_repro":
1296
+ return "Reproduce or explicitly skip relevant defects/candidate bugs, then call forge.complete_qa_bug_repro.";
1297
+ case "qa_findings":
1298
+ return "Normalize observations into findings and candidate bugs, then call forge.complete_qa_findings.";
1299
+ case "qa_verdict":
1300
+ return "Assemble the final QA verdict from all prior gate evidence, then call forge.complete_qa_verdict.";
1301
+ case "qa_plan":
1302
+ return "Prepare the QA checklist, then call forge.complete_qa_plan.";
1303
+ }
1304
+ }
1305
+ function toolDescription(phase, qaGate = null) {
1306
+ if (qaGate) {
1307
+ return qaGate === "qa_verdict"
1308
+ ? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
1309
+ : `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
1310
+ }
1311
+ if (isDebugCompletionPhase(phase)) {
1312
+ return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
1313
+ }
463
1314
  if (phase === "implementation") {
464
1315
  return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
465
1316
  }
466
1317
  if (phase === "self_review") {
467
1318
  return "Complete Forge self-review by writing self-review.report.json with diff review, findings, security, edge-case, design, deterministic-comparison, fixes, and remaining-risk evidence.";
468
1319
  }
1320
+ if (phase === "qa") {
1321
+ 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.";
1322
+ }
1323
+ if (phase === "architect") {
1324
+ 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.";
1325
+ }
469
1326
  return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
470
1327
  }
471
- function toolInputSchema(phase) {
1328
+ function toolInputSchema(phase, qaGate = null) {
472
1329
  const baseProperties = {
473
1330
  status: {
474
1331
  type: "string",
@@ -487,6 +1344,12 @@ function toolInputSchema(phase) {
487
1344
  })
488
1345
  }
489
1346
  };
1347
+ if (qaGate && qaGate !== "qa_verdict") {
1348
+ return qaGateToolInputSchema(qaGate);
1349
+ }
1350
+ if (isDebugCompletionPhase(phase)) {
1351
+ return debugToolInputSchema(phase, baseProperties);
1352
+ }
490
1353
  if (phase === "implementation") {
491
1354
  return objectToolSchema({
492
1355
  ...baseProperties,
@@ -509,7 +1372,7 @@ function toolInputSchema(phase) {
509
1372
  },
510
1373
  tests_added_or_updated: {
511
1374
  type: "array",
512
- description: "Required for pass. Use an object explaining why no tests were needed for docs/config-only work if empty would be ambiguous.",
1375
+ description: "Required for pass. Use an object explaining why no tests were needed for documentation/config-only work if empty would be ambiguous.",
513
1376
  items: evidenceObjectSchema(["path", "summary"], {
514
1377
  path: { type: "string" },
515
1378
  summary: { type: "string" }
@@ -540,6 +1403,11 @@ function toolInputSchema(phase) {
540
1403
  },
541
1404
  next_phase_context: {
542
1405
  description: "Required for pass. Context the self-review phase should carry forward."
1406
+ },
1407
+ resolved_findings: {
1408
+ type: "array",
1409
+ description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
1410
+ items: resolvedFindingSchema()
543
1411
  }
544
1412
  }, ["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.");
545
1413
  }
@@ -588,55 +1456,380 @@ function toolInputSchema(phase) {
588
1456
  }
589
1457
  }, ["status", "summary"], "For status pass, include reviewed_diff, findings, security_review, edge_case_review, design_review, deterministic_comparison, fixes_applied, and remaining_risks.");
590
1458
  }
591
- return objectToolSchema({
592
- ...baseProperties,
593
- commands: {
594
- type: "array",
595
- description: "Required for pass. Include at least one deterministic validation command with exit_code: 0 and status: pass.",
596
- items: commandEvidenceSchema()
597
- },
598
- acceptance_criteria: acceptanceCriteriaSchema(),
599
- build_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
600
- test_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
601
- lint_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
602
- design_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
603
- drift_checks: {
604
- type: "array",
605
- description: "Required for pass. Deterministic checks for unintended drift.",
606
- items: evidenceObjectSchema(["command_or_tool", "status", "summary"], {
607
- command_or_tool: { type: "string" },
608
- status: { type: "string" },
609
- summary: { type: "string" }
610
- })
611
- },
612
- ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
613
- }, ["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.");
614
- }
615
- function objectToolSchema(properties, required, description) {
616
- return {
617
- type: "object",
618
- additionalProperties: true,
619
- description,
620
- properties,
621
- required: [...required]
622
- };
623
- }
624
- function evidenceObjectSchema(required, properties) {
625
- return {
626
- type: "object",
627
- additionalProperties: true,
628
- properties,
629
- required: [...required]
630
- };
631
- }
632
- function commandEvidenceSchema() {
633
- return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
1459
+ if (phase === "qa") {
1460
+ return objectToolSchema({
1461
+ ...baseProperties,
1462
+ status: {
1463
+ type: "string",
1464
+ enum: ["pass", "needs_rework", "blocked"],
1465
+ description: "Use pass only when QA evidence satisfies acceptance criteria, needs_rework for actionable in-scope defects, or blocked for an inconclusive QA blocker."
1466
+ },
1467
+ checklist: {
1468
+ type: "array",
1469
+ description: "QA plan/checklist. Forge writes this to qa.plan.json; a default checklist is generated if omitted.",
1470
+ items: { type: "string" }
1471
+ },
1472
+ tested_acceptance_criteria: {
1473
+ type: "array",
1474
+ description: "Required and non-empty for pass/needs_rework. Criteria actually tested by QA.",
1475
+ items: { type: "string" }
1476
+ },
1477
+ commands: {
1478
+ type: "array",
1479
+ description: "Command evidence written to qa.evidence.json.",
1480
+ items: commandEvidenceSchema()
1481
+ },
1482
+ browser_checks: {
1483
+ type: "array",
1484
+ description: "Browser/manual UI checks with URL or route, action, observed result, screenshot/trace references, console/network notes, and status.",
1485
+ items: evidenceObjectSchema(["summary", "status"], {
1486
+ summary: { type: "string" },
1487
+ url: { type: "string" },
1488
+ route: { type: "string" },
1489
+ status: { type: "string" },
1490
+ screenshot: { type: "string" },
1491
+ trace: { type: "string" }
1492
+ })
1493
+ },
1494
+ sidecars: {
1495
+ type: "array",
1496
+ description: "Optional QA sidecar files already written under .forge/work-items/<id>/artifacts/qa/ with path/content_hash/bytes metadata.",
1497
+ items: evidenceObjectSchema(["path", "content_hash", "bytes"], {
1498
+ path: { type: "string" },
1499
+ content_hash: { type: "string" },
1500
+ bytes: { type: "number" }
1501
+ })
1502
+ },
1503
+ notes: { type: "string", description: "Additional QA evidence notes." },
1504
+ evidence: {
1505
+ type: "object",
1506
+ description: "Optional wrapper containing commands, browser_checks, and notes when a client prefers nested evidence."
1507
+ },
1508
+ findings: {
1509
+ type: "array",
1510
+ description: "QA findings. needs_rework requires at least one actionable in_scope finding.",
1511
+ items: evidenceObjectSchema(["id", "severity", "scope", "title"], {
1512
+ id: { type: "string" },
1513
+ severity: { type: "string", enum: ["critical", "major", "minor"] },
1514
+ scope: { type: "string", enum: ["in_scope", "out_of_scope", "unclear"] },
1515
+ title: { type: "string" },
1516
+ acceptance_criteria: { type: "array", items: { type: "string" } },
1517
+ repro_steps: { type: "array", items: { type: "string" } },
1518
+ expected: { type: "string" },
1519
+ actual: { type: "string" },
1520
+ evidence: { type: "object" },
1521
+ likely_area: { type: "array", items: { type: "string" } },
1522
+ required_fix: { type: "string" },
1523
+ required_regression: { type: "string" }
1524
+ })
1525
+ },
1526
+ candidate_bugs: {
1527
+ type: "array",
1528
+ description: "Out-of-scope/follow-up bug candidates. They do not block the current item unless attached as in-scope findings.",
1529
+ items: evidenceObjectSchema(["id", "title", "suggested_action"], {
1530
+ id: { type: "string" },
1531
+ title: { type: "string" },
1532
+ severity: { type: "string" },
1533
+ scope_reason: { type: "string" },
1534
+ repro_steps: { type: "array", items: { type: "string" } },
1535
+ evidence: { type: "object" },
1536
+ suggested_action: { type: "string", enum: ["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"] }
1537
+ })
1538
+ },
1539
+ blocker_reason: {
1540
+ type: "string",
1541
+ enum: [...qaBlockerReasons],
1542
+ description: "Required when status is blocked."
1543
+ }
1544
+ }, ["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.");
1545
+ }
1546
+ if (phase === "architect") {
1547
+ return objectToolSchema({
1548
+ status: {
1549
+ type: "string",
1550
+ enum: ["pass", "needs_revision", "blocked"],
1551
+ 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."
1552
+ },
1553
+ summary: { type: "string", description: "Concise Architect plan verdict summary." },
1554
+ implementation_plan: {
1555
+ type: "object",
1556
+ description: "Required for pass. Top-level fields inside this object must include schema_version, plan_id, phases, tasks, dependencies, and phase_gates.",
1557
+ additionalProperties: true,
1558
+ properties: {
1559
+ schema_version: { type: "number", enum: [1] },
1560
+ plan_id: { type: "string" },
1561
+ phases: {
1562
+ type: "array",
1563
+ description: "Required top-level implementation_plan.phases array.",
1564
+ items: { type: "object", additionalProperties: true }
1565
+ },
1566
+ tasks: {
1567
+ type: "array",
1568
+ description: "Required Develop child task list. Each task must include id and a non-empty tests array, and must require a focused repo/product implementation change. Do not put parent-story QA/sign-off, child-count/materialization inventory, parent plan artifact checks, or final acceptance proof here; those belong in qa_plan.final_qa.",
1569
+ items: { type: "object", additionalProperties: true }
1570
+ },
1571
+ dependencies: {
1572
+ type: "array",
1573
+ 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.",
1574
+ items: {
1575
+ type: "object",
1576
+ additionalProperties: true,
1577
+ properties: {
1578
+ id: { type: "string" },
1579
+ before: { type: "string", description: "Task id that must complete first." },
1580
+ after: { type: "string", description: "Task id that depends on before." },
1581
+ from: { type: "string", description: "Alias for before." },
1582
+ to: { type: "string", description: "Alias for after." },
1583
+ type: { type: "string" },
1584
+ rationale: { type: "string" }
1585
+ }
1586
+ }
1587
+ },
1588
+ phase_gates: {
1589
+ type: "array",
1590
+ description: "Required non-empty phase gate array.",
1591
+ items: { type: "object", additionalProperties: true }
1592
+ }
1593
+ }
1594
+ },
1595
+ qa_plan: {
1596
+ type: "object",
1597
+ description: "Required for pass. Must include schema_version: 1 and non-empty final_qa coverage.",
1598
+ additionalProperties: true,
1599
+ properties: {
1600
+ schema_version: { type: "number", enum: [1] },
1601
+ final_qa: {
1602
+ type: "array",
1603
+ description: "Required non-empty final QA coverage array.",
1604
+ items: { type: "object", additionalProperties: true }
1605
+ }
1606
+ }
1607
+ },
1608
+ report_markdown: { type: "string", description: "Human-readable Architect report. Use report as an alias only when the client cannot send report_markdown." },
1609
+ report: { type: "string", description: "Alias for report_markdown." },
1610
+ blockers: baseProperties.blockers,
1611
+ revision_questions: {
1612
+ type: "array",
1613
+ description: "Required for needs_revision when blockers are not used.",
1614
+ items: { type: "string" }
1615
+ },
1616
+ revision_reasons: {
1617
+ type: "array",
1618
+ description: "Concrete reasons that prevent pass status.",
1619
+ items: { type: "string" }
1620
+ }
1621
+ }, ["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.");
1622
+ }
1623
+ return objectToolSchema({
1624
+ ...baseProperties,
1625
+ commands: {
1626
+ type: "array",
1627
+ description: "Required for pass. Include at least one deterministic validation command with exit_code: 0 and status: pass.",
1628
+ items: commandEvidenceSchema()
1629
+ },
1630
+ acceptance_criteria: acceptanceCriteriaSchema(),
1631
+ build_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1632
+ test_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1633
+ lint_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1634
+ design_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1635
+ drift_checks: {
1636
+ type: "array",
1637
+ description: "Required for pass. Deterministic checks for unintended drift.",
1638
+ items: evidenceObjectSchema(["command_or_tool", "status", "summary"], {
1639
+ command_or_tool: { type: "string" },
1640
+ status: { type: "string" },
1641
+ summary: { type: "string" }
1642
+ })
1643
+ },
1644
+ ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
1645
+ }, ["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.");
1646
+ }
1647
+ function debugToolInputSchema(phase, baseProperties) {
1648
+ if (phase === "debug_repro") {
1649
+ return objectToolSchema({
1650
+ ...baseProperties,
1651
+ repro_steps: {
1652
+ type: "array",
1653
+ description: "Required for pass. Minimal reproduction or characterization steps.",
1654
+ items: { type: "string" }
1655
+ },
1656
+ observed: { type: "string", description: "Observed failure behavior." },
1657
+ expected: { type: "string", description: "Expected behavior." },
1658
+ evidence: {
1659
+ type: "array",
1660
+ description: "Required for pass. Concrete logs, commands, screenshots, file refs, or characterization notes.",
1661
+ items: { type: "object", additionalProperties: true }
1662
+ }
1663
+ }, ["status", "summary"], "For status pass, include repro_steps, observed, expected, and evidence.");
1664
+ }
1665
+ if (phase === "debug_root_cause") {
1666
+ return objectToolSchema({
1667
+ ...baseProperties,
1668
+ root_cause: { type: "string", description: "Likely root cause tied to code or config evidence." },
1669
+ evidence: {
1670
+ type: "array",
1671
+ description: "Required for pass. Concrete code/log/test evidence supporting the root cause.",
1672
+ items: { type: "object", additionalProperties: true }
1673
+ },
1674
+ eliminated_alternatives: {
1675
+ type: "array",
1676
+ description: "Required for pass. Plausible alternatives checked or ruled out.",
1677
+ items: { type: "object", additionalProperties: true }
1678
+ },
1679
+ risk_area: { type: "string", description: "Subsystem or behavior most at risk from the fix." }
1680
+ }, ["status", "summary"], "For status pass, include root_cause, evidence, eliminated_alternatives, and risk_area.");
1681
+ }
1682
+ if (phase === "debug_fix") {
1683
+ return objectToolSchema({
1684
+ ...baseProperties,
1685
+ changed_files: {
1686
+ type: "array",
1687
+ description: "Required for pass. Files changed by the bug fix.",
1688
+ items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
1689
+ },
1690
+ tests_added_or_updated: {
1691
+ type: "array",
1692
+ description: "Required for pass. Regression tests or checks added/updated, or justify omission in remaining_risks.",
1693
+ items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
1694
+ },
1695
+ remaining_risks: {
1696
+ type: "array",
1697
+ description: "Required for pass, even when empty. Include why no regression coverage changed when tests_added_or_updated is empty.",
1698
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
1699
+ },
1700
+ resolved_findings: {
1701
+ type: "array",
1702
+ description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
1703
+ items: resolvedFindingSchema()
1704
+ }
1705
+ }, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
1706
+ }
1707
+ return objectToolSchema({
1708
+ ...baseProperties,
1709
+ commands: {
1710
+ type: "array",
1711
+ description: "Required for pass. Targeted regression/repro-negative commands with exit_code: 0 and status: pass.",
1712
+ items: commandEvidenceSchema()
1713
+ },
1714
+ required_regressions: {
1715
+ type: "array",
1716
+ description: "Required for pass. Repro-negative and regression coverage checklist.",
1717
+ items: { type: "string" }
1718
+ },
1719
+ build_status: { type: "string", description: "Required for pass. pass or not_applicable." },
1720
+ test_status: { type: "string", description: "Required for pass. pass or not_applicable." },
1721
+ repro_negative: { type: "boolean", description: "Required for pass and must be true after the original repro no longer fails." },
1722
+ qa_ready: { type: "boolean", description: "Required for pass and must be true." }
1723
+ }, ["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.");
1724
+ }
1725
+ function qaGateToolInputSchema(gate) {
1726
+ const baseProperties = {
1727
+ status: {
1728
+ type: "string",
1729
+ enum: ["pass", "skipped", "blocked"],
1730
+ description: "Use pass when this QA gate produced evidence, skipped when not applicable, or blocked when a prerequisite prevents this gate."
1731
+ },
1732
+ summary: { type: "string", description: "Concise QA gate summary." },
1733
+ reason: { type: "string", description: "Required for skipped or blocked gates." },
1734
+ blocker_reason: {
1735
+ type: "string",
1736
+ enum: [...qaBlockerReasons],
1737
+ description: "Required when status is blocked."
1738
+ }
1739
+ };
1740
+ if (gate === "qa_plan") {
1741
+ return objectToolSchema({
1742
+ ...baseProperties,
1743
+ checklist: {
1744
+ type: "array",
1745
+ description: "Required for pass. QA checklist entries planned for this work item.",
1746
+ items: { type: "string" }
1747
+ },
1748
+ acceptance_criteria: {
1749
+ type: "array",
1750
+ description: "Acceptance criteria selected for QA.",
1751
+ items: { type: "string" }
1752
+ }
1753
+ }, ["status", "summary"], "Record the QA plan gate. Pass requires a non-empty checklist.");
1754
+ }
1755
+ if (gate === "qa_findings") {
1756
+ return objectToolSchema({
1757
+ ...baseProperties,
1758
+ findings: {
1759
+ type: "array",
1760
+ description: "Normalized in-scope and out-of-scope QA findings.",
1761
+ items: { type: "object", additionalProperties: true }
1762
+ },
1763
+ candidate_bugs: {
1764
+ type: "array",
1765
+ description: "Out-of-scope or follow-up candidates.",
1766
+ items: { type: "object", additionalProperties: true }
1767
+ }
1768
+ }, ["status", "summary"], "Record normalized QA findings. Pass or skipped requires findings and candidate_bugs arrays, even when empty.");
1769
+ }
1770
+ return objectToolSchema({
1771
+ ...baseProperties,
1772
+ commands: {
1773
+ type: "array",
1774
+ description: "Command evidence for this gate when applicable.",
1775
+ items: commandEvidenceSchema()
1776
+ },
1777
+ browser_checks: {
1778
+ type: "array",
1779
+ description: "Browser/manual evidence for this gate when applicable.",
1780
+ items: { type: "object", additionalProperties: true }
1781
+ },
1782
+ notes: { type: "string", description: "Evidence notes for this gate." }
1783
+ }, ["status", "summary"], "Record this QA evidence gate. Use skipped with a reason when the gate is not applicable.");
1784
+ }
1785
+ function objectToolSchema(properties, required, description) {
1786
+ return {
1787
+ type: "object",
1788
+ additionalProperties: true,
1789
+ description,
1790
+ properties,
1791
+ required: [...required]
1792
+ };
1793
+ }
1794
+ function evidenceObjectSchema(required, properties) {
1795
+ return {
1796
+ type: "object",
1797
+ additionalProperties: true,
1798
+ properties,
1799
+ required: [...required]
1800
+ };
1801
+ }
1802
+ function commandEvidenceSchema() {
1803
+ return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
634
1804
  command: { type: "string" },
635
1805
  exit_code: { type: "number" },
636
1806
  status: { type: "string" },
637
1807
  summary: { type: "string" }
638
1808
  });
639
1809
  }
1810
+ function resolvedFindingSchema() {
1811
+ return evidenceObjectSchema([
1812
+ "qa_id",
1813
+ "status",
1814
+ "summary",
1815
+ "evidence",
1816
+ "changed_files",
1817
+ "tests_added_or_updated",
1818
+ "regression_evidence"
1819
+ ], {
1820
+ qa_id: { type: "string" },
1821
+ status: {
1822
+ type: "string",
1823
+ enum: [...resolvedFindingStatusValues],
1824
+ description: "Use resolved when fixed, disputed when the QA finding is incorrect/out-of-scope, or not_reproduced when retesting cannot reproduce it."
1825
+ },
1826
+ summary: { type: "string" },
1827
+ evidence: { description: "Concrete evidence for the resolution." },
1828
+ changed_files: { type: "array", description: "Required and non-empty when status is resolved." },
1829
+ tests_added_or_updated: { type: "array", description: "Required and non-empty when status is resolved." },
1830
+ regression_evidence: { description: "Concrete regression evidence for this QA finding." }
1831
+ });
1832
+ }
640
1833
  function acceptanceCriteriaSchema() {
641
1834
  return {
642
1835
  type: "array",
@@ -662,8 +1855,22 @@ function reviewSectionProperties() {
662
1855
  notes: { type: "string" }
663
1856
  };
664
1857
  }
665
- function normalizeCompletionStatus(value) {
1858
+ function normalizeCompletionStatus(value, phase) {
666
1859
  const normalized = value.trim().toLowerCase().replace(/-/g, "_");
1860
+ if (phase === "qa") {
1861
+ const canonical = normalized === "needsrework" ? "needs_rework" : normalized;
1862
+ if (canonical !== "pass" && canonical !== "needs_rework" && canonical !== "blocked") {
1863
+ throw new Error("QA status must be pass, needs_rework, or blocked.");
1864
+ }
1865
+ return canonical;
1866
+ }
1867
+ if (phase === "architect") {
1868
+ const canonical = normalized === "needsrevision" ? "needs_revision" : normalized;
1869
+ if (canonical !== "pass" && canonical !== "needs_revision" && canonical !== "blocked") {
1870
+ throw new Error("Architect status must be pass, needs_revision, or blocked.");
1871
+ }
1872
+ return canonical;
1873
+ }
667
1874
  const canonical = normalized === "blocked" || normalized === "needsrework"
668
1875
  ? "needs_rework"
669
1876
  : normalized === "failed"
@@ -674,17 +1881,36 @@ function normalizeCompletionStatus(value) {
674
1881
  }
675
1882
  return canonical;
676
1883
  }
1884
+ function normalizeQaGateStatus(value) {
1885
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
1886
+ if (normalized !== "pass" && normalized !== "skipped" && normalized !== "blocked") {
1887
+ throw new Error("QA gate status must be pass, skipped, or blocked.");
1888
+ }
1889
+ return normalized;
1890
+ }
677
1891
  function validatePayloadShape(phase, payload) {
678
- const status = normalizeCompletionStatus(requiredString(payload, "status"));
1892
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
679
1893
  if (!status) {
680
1894
  throw new Error("status is required.");
681
1895
  }
682
- const pass = status === "pass";
683
1896
  requireField(payload, "summary");
1897
+ if (phase === "qa") {
1898
+ validateQaPayloadShape(payload, status);
1899
+ return;
1900
+ }
1901
+ if (phase === "architect") {
1902
+ validateArchitectPayloadShape(payload, status);
1903
+ return;
1904
+ }
1905
+ const pass = status === "pass";
684
1906
  if (!pass) {
685
1907
  requireNonPassEvidence(payload);
686
1908
  return;
687
1909
  }
1910
+ if (isDebugCompletionPhase(phase)) {
1911
+ validateDebugPayloadShape(phase, payload);
1912
+ return;
1913
+ }
688
1914
  if (phase === "implementation") {
689
1915
  requireArray(payload, "plan");
690
1916
  requireArray(payload, "changed_files");
@@ -695,6 +1921,7 @@ function validatePayloadShape(phase, payload) {
695
1921
  requireField(payload, "next_phase_context");
696
1922
  requireImplementationEvidence(payload);
697
1923
  requireSatisfiedAcceptanceCriteria(payload);
1924
+ validateResolvedFindings(payload);
698
1925
  return;
699
1926
  }
700
1927
  if (phase === "self_review") {
@@ -722,6 +1949,504 @@ function validatePayloadShape(phase, payload) {
722
1949
  requirePassingCommands(payload);
723
1950
  requireSatisfiedAcceptanceCriteria(payload);
724
1951
  }
1952
+ function validateDebugPayloadShape(phase, payload) {
1953
+ if (phase === "debug_repro") {
1954
+ requireArray(payload, "repro_steps");
1955
+ requireField(payload, "observed");
1956
+ requireField(payload, "expected");
1957
+ requireArray(payload, "evidence");
1958
+ requireNonEmptyArray(payload, "repro_steps");
1959
+ requireNonEmptyArray(payload, "evidence");
1960
+ return;
1961
+ }
1962
+ if (phase === "debug_root_cause") {
1963
+ requireField(payload, "root_cause");
1964
+ requireArray(payload, "evidence");
1965
+ requireArray(payload, "eliminated_alternatives");
1966
+ requireField(payload, "risk_area");
1967
+ requireNonEmptyArray(payload, "evidence");
1968
+ return;
1969
+ }
1970
+ if (phase === "debug_fix") {
1971
+ requireArray(payload, "changed_files");
1972
+ requireArray(payload, "tests_added_or_updated");
1973
+ requireArray(payload, "remaining_risks");
1974
+ requireNonEmptyArray(payload, "changed_files");
1975
+ if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
1976
+ throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
1977
+ }
1978
+ validateResolvedFindings(payload);
1979
+ return;
1980
+ }
1981
+ requireArray(payload, "commands");
1982
+ requireArray(payload, "required_regressions");
1983
+ requireField(payload, "build_status");
1984
+ requireField(payload, "test_status");
1985
+ requireBoolean(payload, "repro_negative");
1986
+ requireBoolean(payload, "qa_ready");
1987
+ if (payload.repro_negative !== true) {
1988
+ throw new Error("debug_regression pass requires repro_negative: true.");
1989
+ }
1990
+ if (payload.qa_ready !== true) {
1991
+ throw new Error("debug_regression pass requires qa_ready: true.");
1992
+ }
1993
+ requireAllowedStatus(payload, "build_status", ["pass", "not_applicable"]);
1994
+ requireAllowedStatus(payload, "test_status", ["pass", "not_applicable"]);
1995
+ requireNonEmptyArray(payload, "required_regressions");
1996
+ requirePassingCommands(payload);
1997
+ }
1998
+ function validateQaGatePayloadShape(gate, payload, status) {
1999
+ requireField(payload, "summary");
2000
+ if (status === "skipped") {
2001
+ requireField(payload, "reason");
2002
+ }
2003
+ if (status === "blocked") {
2004
+ const reason = normalizedString(optionalString(payload, "blocker_reason"));
2005
+ if (!qaBlockerReasons.has(reason)) {
2006
+ 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.");
2007
+ }
2008
+ }
2009
+ if (gate === "qa_plan" && status === "pass") {
2010
+ const checklist = qaStringArray(payload, "checklist");
2011
+ if (checklist.length === 0) {
2012
+ throw new Error("complete_qa_plan pass requires checklist with at least one entry.");
2013
+ }
2014
+ }
2015
+ if (gate === "qa_findings" && status !== "blocked") {
2016
+ qaObjectArray(payload, "findings");
2017
+ qaObjectArray(payload, "candidate_bugs");
2018
+ }
2019
+ if ((gate === "qa_regression" || gate === "qa_api") && status === "pass") {
2020
+ const commands = qaArray(payload, "commands");
2021
+ if (commands.length === 0) {
2022
+ throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
2023
+ }
2024
+ validateQaGatePassingCommands(commands);
2025
+ }
2026
+ if (gate === "qa_manual_web" && status === "pass") {
2027
+ const browserChecks = qaArray(payload, "browser_checks");
2028
+ const notes = optionalString(payload, "notes");
2029
+ if (browserChecks.length === 0 && !notes) {
2030
+ throw new Error(`${qaGateToolNames[gate]} pass requires browser_checks or notes.`);
2031
+ }
2032
+ }
2033
+ }
2034
+ function validateQaGatePassingCommands(commands) {
2035
+ for (const [index, command] of commands.entries()) {
2036
+ requireEvidenceRecord(command, `commands[${index}]`);
2037
+ const record = command;
2038
+ requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
2039
+ requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
2040
+ requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
2041
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
2042
+ throw new Error(`commands[${index}].exit_code is required.`);
2043
+ }
2044
+ if (record.exit_code !== 0) {
2045
+ throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
2046
+ }
2047
+ if (normalizedString(record.status) !== "pass") {
2048
+ throw new Error(`commands[${index}].status must be pass when QA gate status is pass.`);
2049
+ }
2050
+ }
2051
+ }
2052
+ function validateArchitectPayloadShape(payload, status) {
2053
+ if (status !== "pass") {
2054
+ requireArchitectNonPassEvidence(payload);
2055
+ return;
2056
+ }
2057
+ const implementationPlan = requiredRecord(payload, "implementation_plan");
2058
+ const qaPlan = requiredRecord(payload, "qa_plan");
2059
+ requireNumericOne(implementationPlan, "schema_version", "implementation_plan.schema_version");
2060
+ requireNonEmptyStringRecord(implementationPlan, "plan_id", "implementation_plan.");
2061
+ requireArchitectArray(implementationPlan, "phases", payload);
2062
+ requireArchitectArray(implementationPlan, "tasks", payload);
2063
+ requireArchitectArray(implementationPlan, "dependencies", payload, { allowEmpty: true });
2064
+ requireArchitectArray(implementationPlan, "phase_gates", payload);
2065
+ if (arrayLength(implementationPlan, "tasks") === 0) {
2066
+ throw new Error("implementation_plan.tasks: expected non-empty array; correction: include at least one task with id and tests.");
2067
+ }
2068
+ const taskIds = new Set();
2069
+ for (const task of implementationPlan.tasks) {
2070
+ if (!task || typeof task !== "object" || Array.isArray(task)) {
2071
+ throw new Error("implementation_plan.tasks[]: expected object entries; correction: each task must be an object with id and tests.");
2072
+ }
2073
+ const taskRecord = task;
2074
+ requireNonEmptyStringRecord(taskRecord, "id", "implementation_plan.tasks[].");
2075
+ const taskId = optionalString(taskRecord, "id") ?? "";
2076
+ if (taskIds.has(taskId)) {
2077
+ throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
2078
+ }
2079
+ taskIds.add(taskId);
2080
+ validateArchitectTaskDevelopScope(taskRecord, taskId);
2081
+ requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
2082
+ if (arrayLength(taskRecord, "tests") === 0) {
2083
+ throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
2084
+ }
2085
+ }
2086
+ validateAndNormalizeArchitectDependencies(implementationPlan, taskIds);
2087
+ requireNumericOne(qaPlan, "schema_version", "qa_plan.schema_version");
2088
+ requireArchitectArray(qaPlan, "final_qa", payload, { path: "qa_plan.final_qa" });
2089
+ if (arrayLength(qaPlan, "final_qa") === 0) {
2090
+ throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
2091
+ }
2092
+ }
2093
+ function validateArchitectTaskDevelopScope(task, taskId) {
2094
+ const explicitScope = firstOptionalString(task, ["execution_scope", "scope", "task_scope", "task_type", "lane", "kind", "type"]);
2095
+ if (isStoryQaScope(explicitScope)) {
2096
+ throw new Error(`implementation_plan.tasks[${taskId}]: story QA/sign-off work must not be materialized as a Develop child task; correction: move this coverage to qa_plan.final_qa and keep implementation_plan.tasks limited to repo/product changes.`);
2097
+ }
2098
+ if (looksLikeStoryLevelVerificationTask(architectTaskScopeText(task))) {
2099
+ throw new Error(`implementation_plan.tasks[${taskId}]: parent-story materialization or sign-off verification belongs in qa_plan.final_qa, not implementation_plan.tasks; correction: keep child tasks focused on implementation work and put child-count/parent-plan artifact checks in final QA coverage.`);
2100
+ }
2101
+ }
2102
+ function firstOptionalString(record, keys) {
2103
+ for (const key of keys) {
2104
+ const value = optionalString(record, key);
2105
+ if (value) {
2106
+ return value;
2107
+ }
2108
+ }
2109
+ return null;
2110
+ }
2111
+ function isStoryQaScope(value) {
2112
+ if (!value) {
2113
+ return false;
2114
+ }
2115
+ const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
2116
+ return [
2117
+ "qa",
2118
+ "story_qa",
2119
+ "final_qa",
2120
+ "sign_off",
2121
+ "signoff",
2122
+ "story_sign_off",
2123
+ "story_verification",
2124
+ "story_validation"
2125
+ ].includes(normalized);
2126
+ }
2127
+ function architectTaskScopeText(task) {
2128
+ const parts = [];
2129
+ for (const key of ["id", "title", "summary", "phase", "phase_id"]) {
2130
+ appendOptionalString(parts, task, key);
2131
+ }
2132
+ for (const key of ["acceptance_criteria", "expected_paths", "touched_surfaces", "implementation_notes"]) {
2133
+ appendOptionalStringArray(parts, task, key);
2134
+ }
2135
+ const tests = task.tests;
2136
+ if (Array.isArray(tests)) {
2137
+ for (const test of tests) {
2138
+ if (!test || typeof test !== "object" || Array.isArray(test)) {
2139
+ continue;
2140
+ }
2141
+ const record = test;
2142
+ for (const key of ["id", "command", "purpose", "target", "expected"]) {
2143
+ appendOptionalString(parts, record, key);
2144
+ }
2145
+ appendOptionalStringArray(parts, record, "steps");
2146
+ }
2147
+ }
2148
+ return parts.join(" ");
2149
+ }
2150
+ function appendOptionalString(parts, record, key) {
2151
+ const value = optionalString(record, key);
2152
+ if (value) {
2153
+ parts.push(value);
2154
+ }
2155
+ }
2156
+ function appendOptionalStringArray(parts, record, key) {
2157
+ const value = record[key];
2158
+ if (!Array.isArray(value)) {
2159
+ return;
2160
+ }
2161
+ for (const entry of value) {
2162
+ if (typeof entry === "string" && entry.trim().length > 0) {
2163
+ parts.push(entry.trim());
2164
+ }
2165
+ }
2166
+ }
2167
+ function looksLikeStoryLevelVerificationTask(text) {
2168
+ if (!text.trim()) {
2169
+ return false;
2170
+ }
2171
+ const normalized = text.toLowerCase();
2172
+ const verificationWords = [
2173
+ "verify",
2174
+ "verification",
2175
+ "validate",
2176
+ "validation",
2177
+ "inspect",
2178
+ "audit",
2179
+ "check",
2180
+ "confirm",
2181
+ "prove",
2182
+ "evidence",
2183
+ "sign-off",
2184
+ "sign off"
2185
+ ];
2186
+ if (!verificationWords.some((word) => normalized.includes(word))) {
2187
+ return false;
2188
+ }
2189
+ return [
2190
+ "materialization",
2191
+ "materialized child",
2192
+ "child inventory",
2193
+ "child work-item",
2194
+ "child work item",
2195
+ "created task count",
2196
+ "created_task_count",
2197
+ "parent plan",
2198
+ "parent story",
2199
+ "story sign-off",
2200
+ "story sign off",
2201
+ ".forge/work-items",
2202
+ ".forge\\work-items",
2203
+ ".forge/stories",
2204
+ ".forge\\stories",
2205
+ "implementation.plan.json",
2206
+ "qa.plan.json",
2207
+ "architect plan artifact",
2208
+ "child spec"
2209
+ ].some((needle) => normalized.includes(needle));
2210
+ }
2211
+ function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
2212
+ const dependencies = implementationPlan.dependencies;
2213
+ const edges = new Map();
2214
+ for (const taskId of taskIds) {
2215
+ edges.set(taskId, []);
2216
+ }
2217
+ const normalizedDependencies = [];
2218
+ for (const [index, dependency] of dependencies.entries()) {
2219
+ if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
2220
+ throw new Error("implementation_plan.dependencies[]: expected object entries; correction: each dependency must be an object with before and after task ids.");
2221
+ }
2222
+ const record = dependency;
2223
+ const before = architectDependencyEndpoint(record, index, "before", "from");
2224
+ const after = architectDependencyEndpoint(record, index, "after", "to");
2225
+ if (!taskIds.has(before) || !taskIds.has(after)) {
2226
+ throw new Error("implementation_plan.dependencies: dependency references an unknown task id.");
2227
+ }
2228
+ const normalized = stripUndefined({
2229
+ ...record,
2230
+ before,
2231
+ after,
2232
+ from: undefined,
2233
+ to: undefined
2234
+ });
2235
+ normalizedDependencies.push(normalized);
2236
+ edges.get(before)?.push(after);
2237
+ }
2238
+ implementationPlan.dependencies = normalizedDependencies;
2239
+ if (hasArchitectDependencyCycle(edges)) {
2240
+ throw new Error("implementation_plan.dependencies: expected acyclic graph; correction: remove or revise cyclic task dependencies.");
2241
+ }
2242
+ }
2243
+ function architectDependencyEndpoint(dependency, index, canonicalKey, aliasKey) {
2244
+ const canonical = optionalString(dependency, canonicalKey);
2245
+ const alias = optionalString(dependency, aliasKey);
2246
+ if (canonical && alias && canonical !== alias) {
2247
+ throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: conflicts with ${aliasKey}; correction: use one task id for ${canonicalKey}.`);
2248
+ }
2249
+ const value = canonical ?? alias;
2250
+ if (!value) {
2251
+ throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: expected string; correction: use before and after task ids for each dependency.`);
2252
+ }
2253
+ return value;
2254
+ }
2255
+ function hasArchitectDependencyCycle(edges) {
2256
+ const visiting = new Set();
2257
+ const visited = new Set();
2258
+ const visit = (node) => {
2259
+ if (visited.has(node)) {
2260
+ return false;
2261
+ }
2262
+ if (visiting.has(node)) {
2263
+ return true;
2264
+ }
2265
+ visiting.add(node);
2266
+ for (const next of edges.get(node) ?? []) {
2267
+ if (visit(next)) {
2268
+ return true;
2269
+ }
2270
+ }
2271
+ visiting.delete(node);
2272
+ visited.add(node);
2273
+ return false;
2274
+ };
2275
+ for (const node of edges.keys()) {
2276
+ if (visit(node)) {
2277
+ return true;
2278
+ }
2279
+ }
2280
+ return false;
2281
+ }
2282
+ function requireArchitectNonPassEvidence(payload) {
2283
+ const evidenceKeys = ["blockers", "revision_questions", "revision_reasons", "known_gaps", "findings", "remaining_risks", "failures"];
2284
+ const hasEvidence = evidenceKeys.some((key) => {
2285
+ const value = payload[key];
2286
+ if (typeof value === "string") {
2287
+ return value.trim().length > 0;
2288
+ }
2289
+ if (Array.isArray(value)) {
2290
+ return value.length > 0;
2291
+ }
2292
+ return Boolean(value && typeof value === "object");
2293
+ });
2294
+ if (!hasEvidence) {
2295
+ 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.");
2296
+ }
2297
+ }
2298
+ function requireArchitectArray(source, key, rootPayload, options = {}) {
2299
+ const pathLabel = options.path ?? `implementation_plan.${key}`;
2300
+ if (!Array.isArray(source[key])) {
2301
+ const nestedHint = architectNestedArrayHint(rootPayload, key);
2302
+ 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"}.`}`);
2303
+ }
2304
+ if (!options.allowEmpty && arrayLength(source, key) === 0) {
2305
+ throw new Error(`${pathLabel}: expected non-empty array; correction: include at least one entry.`);
2306
+ }
2307
+ }
2308
+ function architectNestedArrayHint(rootPayload, key) {
2309
+ if (Array.isArray(rootPayload[key])) {
2310
+ return `move top-level ${key} into implementation_plan.${key}.`;
2311
+ }
2312
+ const implementationPlan = rootPayload.implementation_plan;
2313
+ if (!implementationPlan || typeof implementationPlan !== "object" || Array.isArray(implementationPlan)) {
2314
+ return null;
2315
+ }
2316
+ const plan = implementationPlan;
2317
+ for (const container of ["plan", "dag", "implementation_dag", "implementationPlan", "implementation_plan"]) {
2318
+ const nested = plan[container];
2319
+ if (nested && typeof nested === "object" && !Array.isArray(nested) && Array.isArray(nested[key])) {
2320
+ return `move implementation_plan.${container}.${key} to implementation_plan.${key}.`;
2321
+ }
2322
+ }
2323
+ return null;
2324
+ }
2325
+ function validateQaPayloadShape(payload, status) {
2326
+ const evidenceSource = qaEvidenceSource(payload);
2327
+ const commands = qaArray(payload, "commands", evidenceSource);
2328
+ const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
2329
+ const sidecars = qaArray(payload, "sidecars", evidenceSource);
2330
+ const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
2331
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
2332
+ const findings = qaObjectArray(payload, "findings");
2333
+ const candidateBugs = qaObjectArray(payload, "candidate_bugs");
2334
+ const explicitFindingIds = qaStringArray(payload, "finding_ids");
2335
+ const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
2336
+ if (payload.schema_version !== undefined && payload.schema_version !== 1) {
2337
+ throw new Error("QA schema_version must be numeric 1 when provided.");
2338
+ }
2339
+ if (status === "pass" || status === "needs_rework") {
2340
+ if (testedAcceptanceCriteria.length === 0) {
2341
+ throw new Error("QA pass/needs_rework requires tested_acceptance_criteria with at least one criterion.");
2342
+ }
2343
+ if (commands.length === 0 && browserChecks.length === 0 && notes.length === 0) {
2344
+ throw new Error("QA pass/needs_rework requires at least one command, browser check, or evidence note.");
2345
+ }
2346
+ }
2347
+ for (const [index, command] of commands.entries()) {
2348
+ requireEvidenceRecord(command, `commands[${index}]`);
2349
+ const record = command;
2350
+ requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
2351
+ requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
2352
+ requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
2353
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
2354
+ throw new Error(`commands[${index}].exit_code is required.`);
2355
+ }
2356
+ if (status === "pass" && record.exit_code !== 0) {
2357
+ throw new Error(`commands[${index}].exit_code must be 0 when QA status is pass.`);
2358
+ }
2359
+ if (status === "pass" && normalizedString(record.status) !== "pass") {
2360
+ throw new Error(`commands[${index}].status must be pass when QA status is pass.`);
2361
+ }
2362
+ }
2363
+ for (const [index, browserCheck] of browserChecks.entries()) {
2364
+ requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
2365
+ const record = browserCheck;
2366
+ requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
2367
+ requireNonEmptyStringRecord(record, "status", `browser_checks[${index}].`);
2368
+ const checkStatus = optionalString(record, "status");
2369
+ if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
2370
+ throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
2371
+ }
2372
+ if (!optionalString(record, "url") && !optionalString(record, "route")) {
2373
+ throw new Error(`browser_checks[${index}] must include url or route.`);
2374
+ }
2375
+ }
2376
+ for (const [index, sidecar] of sidecars.entries()) {
2377
+ requireEvidenceRecord(sidecar, `sidecars[${index}]`);
2378
+ const record = sidecar;
2379
+ requireNonEmptyStringRecord(record, "path", `sidecars[${index}].`);
2380
+ requireNonEmptyStringRecord(record, "content_hash", `sidecars[${index}].`);
2381
+ if (typeof record.bytes !== "number" || !Number.isFinite(record.bytes) || record.bytes <= 0) {
2382
+ throw new Error(`sidecars[${index}].bytes must be greater than 0.`);
2383
+ }
2384
+ }
2385
+ for (const [index, finding] of findings.entries()) {
2386
+ requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
2387
+ requireNonEmptyStringRecord(finding, "title", `findings[${index}].`);
2388
+ requireKnownStringRecord(finding, "severity", qaFindingSeverities, `findings[${index}].`);
2389
+ requireKnownStringRecord(finding, "scope", qaFindingScopes, `findings[${index}].`);
2390
+ if (normalizedString(finding.scope) === "in_scope") {
2391
+ validateActionableQaFinding(finding, index);
2392
+ }
2393
+ }
2394
+ for (const [index, candidateBug] of candidateBugs.entries()) {
2395
+ requireNonEmptyStringRecord(candidateBug, "id", `candidate_bugs[${index}].`);
2396
+ requireNonEmptyStringRecord(candidateBug, "title", `candidate_bugs[${index}].`);
2397
+ requireKnownStringRecord(candidateBug, "suggested_action", qaCandidateBugActions, `candidate_bugs[${index}].`);
2398
+ }
2399
+ const inScopeFindingIds = findings
2400
+ .filter((finding) => normalizedString(finding.scope) === "in_scope")
2401
+ .map((finding) => optionalString(finding, "id"))
2402
+ .filter((id) => Boolean(id));
2403
+ for (const findingId of explicitFindingIds) {
2404
+ if (!inScopeFindingIds.includes(findingId)) {
2405
+ throw new Error(`QA finding_ids contains '${findingId}', but findings has no matching in-scope finding.`);
2406
+ }
2407
+ }
2408
+ const candidateBugIds = candidateBugs
2409
+ .map((candidateBug) => optionalString(candidateBug, "id"))
2410
+ .filter((id) => Boolean(id));
2411
+ for (const candidateBugId of explicitCandidateBugIds) {
2412
+ if (!candidateBugIds.includes(candidateBugId)) {
2413
+ throw new Error(`QA candidate_bug_ids contains '${candidateBugId}', but candidate_bugs has no matching candidate bug.`);
2414
+ }
2415
+ }
2416
+ if (status === "pass") {
2417
+ if (explicitFindingIds.length > 0 || inScopeFindingIds.length > 0) {
2418
+ throw new Error("QA pass must not include in-scope finding_ids or in-scope findings.");
2419
+ }
2420
+ if (optionalString(payload, "blocker_reason")) {
2421
+ throw new Error("QA pass must not include blocker_reason.");
2422
+ }
2423
+ }
2424
+ if (status === "needs_rework") {
2425
+ if (inScopeFindingIds.length === 0) {
2426
+ throw new Error("QA needs_rework requires at least one actionable in-scope finding.");
2427
+ }
2428
+ if (optionalString(payload, "blocker_reason")) {
2429
+ throw new Error("QA needs_rework must not include blocker_reason.");
2430
+ }
2431
+ }
2432
+ if (status === "blocked") {
2433
+ const reason = normalizedString(optionalString(payload, "blocker_reason"));
2434
+ if (!qaBlockerReasons.has(reason)) {
2435
+ throw new Error("QA blocked requires blocker_reason to be one of missing_app_launch_target, inconclusive, environment_failure, missing_validation_evidence, missing_worktree, or runner_failure.");
2436
+ }
2437
+ }
2438
+ }
2439
+ function validateActionableQaFinding(finding, index) {
2440
+ const prefix = `findings[${index}].`;
2441
+ requireNonEmptyStringArrayRecord(finding, "acceptance_criteria", prefix);
2442
+ requireNonEmptyStringArrayRecord(finding, "repro_steps", prefix);
2443
+ requireNonEmptyStringRecord(finding, "expected", prefix);
2444
+ requireNonEmptyStringRecord(finding, "actual", prefix);
2445
+ requireSubstantiveEvidenceRecord(finding, prefix);
2446
+ requireNonEmptyStringArrayRecord(finding, "likely_area", prefix);
2447
+ requireNonEmptyStringRecord(finding, "required_fix", prefix);
2448
+ requireNonEmptyStringRecord(finding, "required_regression", prefix);
2449
+ }
725
2450
  function validateOptionalWorkItemId(payload, expectedWorkItemId) {
726
2451
  const actual = optionalString(payload, "work_item_id") ?? optionalString(payload, "workItemId");
727
2452
  if (actual && actual !== expectedWorkItemId) {
@@ -736,10 +2461,36 @@ function normalizeArtifactPayload(payload, defaults) {
736
2461
  normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
737
2462
  normalized.phase = defaults.phase;
738
2463
  normalized.work_item_id = defaults.workItemId;
2464
+ const ownerFields = artifactOwnerFields(defaults);
2465
+ Object.assign(normalized, ownerFields);
739
2466
  normalized.status = defaults.status;
740
2467
  normalized.produced_by = defaults.producedBy;
741
2468
  return normalized;
742
2469
  }
2470
+ function normalizeQaGateArtifactPayload(payload, defaults) {
2471
+ const normalized = stripUndefined({ ...payload });
2472
+ delete normalized.report_markdown;
2473
+ delete normalized.content_hash;
2474
+ delete normalized.contentHash;
2475
+ normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
2476
+ normalized.phase = "qa";
2477
+ normalized.gate = defaults.gate;
2478
+ normalized.work_item_id = defaults.workItemId;
2479
+ const ownerFields = artifactOwnerFields(defaults);
2480
+ Object.assign(normalized, ownerFields);
2481
+ normalized.status = defaults.status;
2482
+ normalized.produced_by = defaults.producedBy;
2483
+ if (defaults.gate === "qa_plan") {
2484
+ const checklist = qaStringArray(normalized, "checklist");
2485
+ normalized.checklist = checklist.length > 0 ? checklist : defaultQaChecklist(defaults.status);
2486
+ normalized.acceptance_criteria = qaStringArray(normalized, "acceptance_criteria");
2487
+ }
2488
+ if (defaults.gate === "qa_findings") {
2489
+ normalized.findings = qaObjectArray(normalized, "findings");
2490
+ normalized.candidate_bugs = qaObjectArray(normalized, "candidate_bugs");
2491
+ }
2492
+ return normalized;
2493
+ }
743
2494
  function reportMarkdown(payload, phase, status, producedBy, contentHash) {
744
2495
  const explicit = optionalString(payload, "report_markdown");
745
2496
  if (explicit) {
@@ -759,6 +2510,10 @@ ${summary}
759
2510
  }
760
2511
  function parseStrictCompletionPhase(value) {
761
2512
  const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
2513
+ const debugPhase = normalizeDebugCompletionPhase(normalized);
2514
+ if (debugPhase) {
2515
+ return debugPhase;
2516
+ }
762
2517
  if (normalized === "implementation" || normalized === "implement" || normalized === "develop") {
763
2518
  return "implementation";
764
2519
  }
@@ -768,8 +2523,51 @@ function parseStrictCompletionPhase(value) {
768
2523
  if (normalized === "validate" || normalized === "validation") {
769
2524
  return "validation";
770
2525
  }
2526
+ if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
2527
+ return "qa";
2528
+ }
2529
+ if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
2530
+ return "architect";
2531
+ }
771
2532
  return null;
772
2533
  }
2534
+ function normalizeDebugCompletionPhase(value) {
2535
+ const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
2536
+ switch (normalized) {
2537
+ case "debug_repro":
2538
+ case "debugrepro":
2539
+ case "repro":
2540
+ case "reproduce":
2541
+ return "debug_repro";
2542
+ case "debug_root_cause":
2543
+ case "debugrootcause":
2544
+ case "root_cause":
2545
+ case "rootcause":
2546
+ return "debug_root_cause";
2547
+ case "debug_fix":
2548
+ case "debugfix":
2549
+ case "fix":
2550
+ return "debug_fix";
2551
+ case "debug_regression":
2552
+ case "debugregression":
2553
+ case "regression":
2554
+ return "debug_regression";
2555
+ default:
2556
+ return null;
2557
+ }
2558
+ }
2559
+ function isDevelopCompletionPhase(phase) {
2560
+ return phase === "implementation" || phase === "self_review" || phase === "validation";
2561
+ }
2562
+ function isDebugCompletionPhase(phase) {
2563
+ return debugCompletionPhaseOrder.includes(phase);
2564
+ }
2565
+ function asDebugCompletionPhase(phase) {
2566
+ if (isDebugCompletionPhase(phase)) {
2567
+ return phase;
2568
+ }
2569
+ throw new Error(`Expected Debug completion phase, got ${phase}.`);
2570
+ }
773
2571
  function parseCompletionRedactionSecrets(raw) {
774
2572
  if (!raw?.trim()) {
775
2573
  return [];
@@ -796,8 +2594,41 @@ function phaseFromToolName(toolName) {
796
2594
  if (normalized === "forge.complete_validation" || normalized === "complete_validation") {
797
2595
  return "validation";
798
2596
  }
2597
+ if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
2598
+ return "qa";
2599
+ }
2600
+ if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
2601
+ return "architect";
2602
+ }
2603
+ const debugGate = debugGateFromToolName(toolName);
2604
+ if (debugGate) {
2605
+ return debugGate;
2606
+ }
2607
+ if (qaGateFromToolName(toolName)) {
2608
+ return "qa";
2609
+ }
799
2610
  throw new Error(`Unknown Forge completion tool: ${toolName}`);
800
2611
  }
2612
+ function debugGateFromToolName(toolName) {
2613
+ const normalized = toolName.trim().toLowerCase();
2614
+ for (const gate of debugCompletionPhaseOrder) {
2615
+ const base = debugGateToolNames[gate];
2616
+ if (normalized === `forge.${base}` || normalized === base) {
2617
+ return gate;
2618
+ }
2619
+ }
2620
+ return null;
2621
+ }
2622
+ function qaGateFromToolName(toolName) {
2623
+ const normalized = toolName.trim().toLowerCase();
2624
+ for (const gate of qaCompletionGateOrder) {
2625
+ const base = qaGateToolNames[gate];
2626
+ if (normalized === `forge.${base}` || normalized === base) {
2627
+ return gate;
2628
+ }
2629
+ }
2630
+ return null;
2631
+ }
801
2632
  function requireField(payload, key) {
802
2633
  if (payload[key] === undefined || payload[key] === null || (typeof payload[key] === "string" && payload[key].trim().length === 0)) {
803
2634
  throw new Error(`${key} is required.`);
@@ -865,17 +2696,42 @@ function requireArray(payload, key) {
865
2696
  throw new Error(`${key} must be an array.`);
866
2697
  }
867
2698
  }
2699
+ function requireNonEmptyArray(payload, key) {
2700
+ requireArray(payload, key);
2701
+ if (arrayLength(payload, key) === 0) {
2702
+ throw new Error(`${key} must include at least one value.`);
2703
+ }
2704
+ }
2705
+ function arrayLength(payload, key) {
2706
+ const value = payload[key];
2707
+ return Array.isArray(value) ? value.length : 0;
2708
+ }
2709
+ function requireAllowedStatus(payload, key, allowed) {
2710
+ const value = normalizedString(optionalString(payload, key));
2711
+ if (!allowed.includes(value)) {
2712
+ throw new Error(`${key} must be one of ${allowed.join(", ")}.`);
2713
+ }
2714
+ }
868
2715
  function requireObject(payload, key) {
869
2716
  const value = payload[key];
870
2717
  if (!value || typeof value !== "object" || Array.isArray(value)) {
871
2718
  throw new Error(`${key} must be an object.`);
872
2719
  }
873
2720
  }
2721
+ function requiredRecord(payload, key) {
2722
+ requireObject(payload, key);
2723
+ return payload[key];
2724
+ }
874
2725
  function requireBoolean(payload, key) {
875
2726
  if (typeof payload[key] !== "boolean") {
876
2727
  throw new Error(`${key} must be a boolean.`);
877
2728
  }
878
2729
  }
2730
+ function requireNumericOne(payload, key, pathLabel) {
2731
+ if (payload[key] !== 1) {
2732
+ throw new Error(`${pathLabel}: expected numeric 1; correction: set ${pathLabel} to 1.`);
2733
+ }
2734
+ }
879
2735
  function requireSatisfiedAcceptanceCriteria(payload) {
880
2736
  const criteria = payload.acceptance_criteria;
881
2737
  if (!Array.isArray(criteria)) {
@@ -893,6 +2749,183 @@ function requireSatisfiedAcceptanceCriteria(payload) {
893
2749
  }
894
2750
  }
895
2751
  }
2752
+ function validateResolvedFindings(payload) {
2753
+ const resolvedFindings = payload.resolved_findings;
2754
+ if (resolvedFindings === undefined || resolvedFindings === null) {
2755
+ return;
2756
+ }
2757
+ if (!Array.isArray(resolvedFindings)) {
2758
+ throw new Error("resolved_findings must be an array.");
2759
+ }
2760
+ for (const [index, finding] of resolvedFindings.entries()) {
2761
+ const prefix = `resolved_findings[${index}].`;
2762
+ if (!finding || typeof finding !== "object" || Array.isArray(finding)) {
2763
+ throw new Error(`resolved_findings[${index}] must be an object.`);
2764
+ }
2765
+ const record = finding;
2766
+ requireNonEmptyStringRecord(record, "qa_id", prefix);
2767
+ requireKnownStringRecord(record, "status", resolvedFindingStatuses, prefix);
2768
+ requireNonEmptyStringRecord(record, "summary", prefix);
2769
+ requireSubstantiveValueRecord(record, "evidence", prefix);
2770
+ requireArrayRecord(record, "changed_files", prefix);
2771
+ requireArrayRecord(record, "tests_added_or_updated", prefix);
2772
+ requireSubstantiveValueRecord(record, "regression_evidence", prefix);
2773
+ if (normalizedString(record.status) === "resolved") {
2774
+ if (record.changed_files.length === 0) {
2775
+ throw new Error(`${prefix}changed_files must include at least one changed file for resolved QA findings.`);
2776
+ }
2777
+ if (record.tests_added_or_updated.length === 0) {
2778
+ throw new Error(`${prefix}tests_added_or_updated must include at least one regression test for resolved QA findings.`);
2779
+ }
2780
+ }
2781
+ }
2782
+ }
2783
+ function qaEvidenceSource(payload) {
2784
+ const evidence = payload.evidence;
2785
+ return evidence && typeof evidence === "object" && !Array.isArray(evidence)
2786
+ ? evidence
2787
+ : {};
2788
+ }
2789
+ function qaArray(payload, key, fallback = {}) {
2790
+ const value = payload[key] ?? fallback[key];
2791
+ if (value === undefined || value === null) {
2792
+ return [];
2793
+ }
2794
+ if (!Array.isArray(value)) {
2795
+ throw new Error(`${key} must be an array.`);
2796
+ }
2797
+ return value;
2798
+ }
2799
+ function qaObjectArray(payload, key) {
2800
+ return qaArray(payload, key).map((entry, index) => {
2801
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
2802
+ throw new Error(`${key}[${index}] must be an object.`);
2803
+ }
2804
+ return entry;
2805
+ });
2806
+ }
2807
+ function qaStringArray(payload, key) {
2808
+ const value = payload[key];
2809
+ if (value === undefined || value === null) {
2810
+ return [];
2811
+ }
2812
+ if (!Array.isArray(value)) {
2813
+ throw new Error(`${key} must be an array.`);
2814
+ }
2815
+ return value.map((entry, index) => {
2816
+ if (typeof entry !== "string" || entry.trim().length === 0) {
2817
+ throw new Error(`${key}[${index}] must be a non-empty string.`);
2818
+ }
2819
+ return entry;
2820
+ });
2821
+ }
2822
+ function requireEvidenceRecord(value, label) {
2823
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2824
+ throw new Error(`${label} must be an object.`);
2825
+ }
2826
+ }
2827
+ function requireNonEmptyStringRecord(source, key, prefix = "") {
2828
+ if (!optionalString(source, key)) {
2829
+ throw new Error(`${prefix}${key} is required.`);
2830
+ }
2831
+ }
2832
+ function requireArrayRecord(source, key, prefix = "") {
2833
+ if (!Array.isArray(source[key])) {
2834
+ throw new Error(`${prefix}${key} must be an array.`);
2835
+ }
2836
+ }
2837
+ function requireKnownStringRecord(source, key, allowed, prefix = "") {
2838
+ const value = normalizedString(optionalString(source, key));
2839
+ if (!allowed.has(value)) {
2840
+ throw new Error(`${prefix}${key} must be one of ${[...allowed].sort().join(", ")}.`);
2841
+ }
2842
+ }
2843
+ function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
2844
+ const value = source[key];
2845
+ if (!Array.isArray(value) || value.length === 0) {
2846
+ throw new Error(`${prefix}${key} must include at least one value.`);
2847
+ }
2848
+ for (const [index, entry] of value.entries()) {
2849
+ if (typeof entry !== "string" || entry.trim().length === 0) {
2850
+ throw new Error(`${prefix}${key}[${index}] must be a non-empty string.`);
2851
+ }
2852
+ }
2853
+ }
2854
+ function requireSubstantiveValueRecord(source, key, prefix = "") {
2855
+ if (!hasSubstantiveJsonValue(source[key])) {
2856
+ throw new Error(`${prefix}${key} must include at least one concrete evidence reference or summary.`);
2857
+ }
2858
+ }
2859
+ function requireSubstantiveEvidenceRecord(source, prefix = "") {
2860
+ const evidence = source.evidence;
2861
+ if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
2862
+ throw new Error(`${prefix}evidence must be an object.`);
2863
+ }
2864
+ if (!Object.values(evidence).some(hasSubstantiveJsonValue)) {
2865
+ throw new Error(`${prefix}evidence must include at least one concrete evidence reference or summary.`);
2866
+ }
2867
+ }
2868
+ function hasSubstantiveJsonValue(value) {
2869
+ if (typeof value === "string") {
2870
+ return value.trim().length > 0;
2871
+ }
2872
+ if (Array.isArray(value)) {
2873
+ return value.length > 0;
2874
+ }
2875
+ if (value && typeof value === "object") {
2876
+ return Object.keys(value).length > 0;
2877
+ }
2878
+ return false;
2879
+ }
2880
+ function normalizedString(value) {
2881
+ return typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
2882
+ }
2883
+ function normalizedOwnerType(value) {
2884
+ const normalized = normalizedString(value);
2885
+ return normalized === "work_item" || normalized === "story" ? normalized : undefined;
2886
+ }
2887
+ function artifactOwnerFields(config) {
2888
+ const ownerType = normalizedOwnerType(config.ownerType);
2889
+ const ownerId = config.ownerId?.trim();
2890
+ if (!ownerType && !ownerId) {
2891
+ return {};
2892
+ }
2893
+ return {
2894
+ owner_type: ownerType ?? "work_item",
2895
+ owner_id: ownerId || config.workItemId
2896
+ };
2897
+ }
2898
+ function artifactReferenceOwnerFields(config) {
2899
+ const owner = artifactOwnerFields(config);
2900
+ return owner.owner_type && owner.owner_id
2901
+ ? { ownerType: owner.owner_type, ownerId: owner.owner_id }
2902
+ : {};
2903
+ }
2904
+ function artifactOwnerDirectory(ownerType) {
2905
+ return normalizedOwnerType(ownerType) === "story" ? "stories" : "work-items";
2906
+ }
2907
+ function artifactOwnerRelativeRoot(ownerType, ownerId, workItemId) {
2908
+ const owner = artifactOwnerFields({ workItemId, ownerType, ownerId });
2909
+ return owner.owner_type && owner.owner_id
2910
+ ? [".forge", artifactOwnerDirectory(owner.owner_type), owner.owner_id, "artifacts"].join("/")
2911
+ : [".forge", "work-items", workItemId, "artifacts"].join("/");
2912
+ }
2913
+ function defaultQaChecklist(status) {
2914
+ return [
2915
+ "Review the validated work item against acceptance criteria.",
2916
+ "Inspect implementation, self-review, and validation evidence when available.",
2917
+ status === "blocked" ? "Record the blocker reason preventing a conclusive QA verdict." : "Record concrete QA evidence and findings."
2918
+ ];
2919
+ }
2920
+ function qaArtifactRelativePath(workItemId, fileName) {
2921
+ return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
2922
+ }
2923
+ function qaArtifactRelativePathForOwner(ownerType, ownerId, fileName) {
2924
+ return [".forge", artifactOwnerDirectory(ownerType), ownerId, "artifacts", fileName].join("/");
2925
+ }
2926
+ function jsonText(value) {
2927
+ return `${JSON.stringify(value, null, 2)}\n`;
2928
+ }
896
2929
  function fallbackReport(parsed, phase, artifactPath) {
897
2930
  return `# Forge ${phase.replace(/_/g, " ")} completion
898
2931
 
@@ -905,7 +2938,7 @@ function parseJsonObject(content, label) {
905
2938
  return asRecord(JSON.parse(content), label);
906
2939
  }
907
2940
  catch (error) {
908
- throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`);
2941
+ throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`, { cause: error });
909
2942
  }
910
2943
  }
911
2944
  function parseOptionalJsonObject(content, label) {