@loucompanion/forge-bridge 0.1.1-dev.32d4dd5874ee → 0.1.1-dev.440026b50fad

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.
@@ -2,7 +2,90 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { redactSecrets } from "../shared/redaction/redaction.js";
6
+ import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
5
7
  const toolServerName = "forge";
8
+ const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
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
+ export const draftCompletionGateOrder = [
26
+ "draft_intent",
27
+ "draft_repo_context",
28
+ "draft_graph_context",
29
+ "draft_product_scope",
30
+ "draft_work_order_plan",
31
+ "draft_critic",
32
+ "draft_proposal"
33
+ ];
34
+ const draftGateFileNames = {
35
+ draft_intent: "draft.intent.json",
36
+ draft_repo_context: "draft.repo-context.json",
37
+ draft_graph_context: "draft.graph-context.json",
38
+ draft_product_scope: "draft.product-scope.json",
39
+ draft_work_order_plan: "draft.work-order-plan.json",
40
+ draft_critic: "draft.critic.json",
41
+ draft_proposal: "draft.proposal.json"
42
+ };
43
+ const draftGateToolNames = {
44
+ draft_intent: "complete_draft_intent",
45
+ draft_repo_context: "complete_draft_repo_context",
46
+ draft_graph_context: "complete_draft_graph_context",
47
+ draft_product_scope: "complete_draft_product_scope",
48
+ draft_work_order_plan: "complete_draft_work_order_plan",
49
+ draft_critic: "complete_draft_critic",
50
+ draft_proposal: "complete_draft_proposal"
51
+ };
52
+ const qaGateFileNames = {
53
+ qa_plan: "qa.plan.json",
54
+ qa_regression: "qa.regression.json",
55
+ qa_api: "qa.api.json",
56
+ qa_manual_web: "qa.manual-web.json",
57
+ qa_bug_repro: "qa.bug-repro.json",
58
+ qa_findings: "qa.findings.json",
59
+ qa_verdict: "qa.verdict.json"
60
+ };
61
+ const qaGateToolNames = {
62
+ qa_plan: "complete_qa_plan",
63
+ qa_regression: "complete_qa_regression",
64
+ qa_api: "complete_qa_api",
65
+ qa_manual_web: "complete_qa_manual_web",
66
+ qa_bug_repro: "complete_qa_bug_repro",
67
+ qa_findings: "complete_qa_findings",
68
+ qa_verdict: "complete_qa_verdict"
69
+ };
70
+ const debugGateToolNames = {
71
+ debug_repro: "complete_debug_repro",
72
+ debug_root_cause: "complete_debug_root_cause",
73
+ debug_fix: "complete_debug_fix",
74
+ debug_regression: "complete_debug_regression"
75
+ };
76
+ const qaBlockerReasons = new Set([
77
+ "missing_app_launch_target",
78
+ "inconclusive",
79
+ "environment_failure",
80
+ "missing_validation_evidence",
81
+ "missing_worktree",
82
+ "runner_failure"
83
+ ]);
84
+ const resolvedFindingStatusValues = ["resolved", "disputed", "not_reproduced"];
85
+ const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
86
+ const qaFindingSeverities = new Set(["critical", "major", "minor"]);
87
+ const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
88
+ const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
6
89
  const phaseFiles = {
7
90
  implementation: {
8
91
  jsonFileName: "implementation.completeness.json",
@@ -15,6 +98,34 @@ const phaseFiles = {
15
98
  validation: {
16
99
  jsonFileName: "validation.verdict.json",
17
100
  reportFileName: "validation.report.md"
101
+ },
102
+ qa: {
103
+ jsonFileName: "qa.verdict.json",
104
+ reportFileName: "qa.report.md"
105
+ },
106
+ debug_repro: {
107
+ jsonFileName: "debug.repro.json",
108
+ reportFileName: "debug.report.md"
109
+ },
110
+ debug_root_cause: {
111
+ jsonFileName: "debug.root-cause.json",
112
+ reportFileName: "debug.report.md"
113
+ },
114
+ debug_fix: {
115
+ jsonFileName: "debug.fix.json",
116
+ reportFileName: "debug.report.md"
117
+ },
118
+ debug_regression: {
119
+ jsonFileName: "debug.regression.json",
120
+ reportFileName: "debug.report.md"
121
+ },
122
+ architect: {
123
+ jsonFileName: "architect.verdict.json",
124
+ reportFileName: "architect.report.md"
125
+ },
126
+ draft: {
127
+ jsonFileName: "draft.proposal.json",
128
+ reportFileName: "draft.report.md"
18
129
  }
19
130
  };
20
131
  export function completionPhaseFromDispatch(dispatch) {
@@ -22,43 +133,138 @@ export function completionPhaseFromDispatch(dispatch) {
22
133
  }
23
134
  export function normalizeCompletionPhase(value) {
24
135
  const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
136
+ const debugPhase = normalizeDebugCompletionPhase(normalized);
137
+ if (debugPhase) {
138
+ return debugPhase;
139
+ }
25
140
  if (normalized === "self_review" || normalized === "selfreview" || normalized === "review") {
26
141
  return "self_review";
27
142
  }
28
143
  if (normalized === "validate" || normalized === "validation") {
29
144
  return "validation";
30
145
  }
146
+ if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
147
+ return "qa";
148
+ }
149
+ if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
150
+ return "architect";
151
+ }
152
+ if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
153
+ return "draft";
154
+ }
31
155
  return "implementation";
32
156
  }
157
+ export function usesDevelopCycleCompletion(dispatch) {
158
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
159
+ const role = dispatch.execution.role.trim().toLowerCase();
160
+ const lane = dispatch.execution.lane.trim().toLowerCase();
161
+ return kind === "develop"
162
+ && role === "develop"
163
+ && lane === "develop"
164
+ && completionPhaseFromDispatch(dispatch) === "implementation";
165
+ }
166
+ export function usesQaCycleCompletion(dispatch) {
167
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
168
+ const role = dispatch.execution.role.trim().toLowerCase();
169
+ const lane = dispatch.execution.lane.trim().toLowerCase();
170
+ return kind === "qa"
171
+ && role === "qa"
172
+ && lane === "qa"
173
+ && completionPhaseFromDispatch(dispatch) === "qa";
174
+ }
175
+ export function usesDebugCycleCompletion(dispatch) {
176
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
177
+ const role = dispatch.execution.role.trim().toLowerCase();
178
+ const lane = dispatch.execution.lane.trim().toLowerCase();
179
+ return kind === "debug"
180
+ && role === "debug"
181
+ && lane === "debug"
182
+ && isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
183
+ }
184
+ export function usesArchitectCycleCompletion(dispatch) {
185
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
186
+ const role = dispatch.execution.role.trim().toLowerCase();
187
+ const lane = dispatch.execution.lane.trim().toLowerCase();
188
+ return kind === "architect" || role === "architect" || lane === "architect";
189
+ }
33
190
  export function completionToolNamesForPhase(phase) {
191
+ if (phase === "draft") {
192
+ return completionToolNamesForDraftGate("draft_proposal");
193
+ }
194
+ if (isDebugCompletionPhase(phase)) {
195
+ return completionToolNamesForDebugGate(phase);
196
+ }
34
197
  const base = phase === "implementation"
35
198
  ? "complete_implementation"
36
199
  : phase === "self_review"
37
200
  ? "complete_self_review"
38
- : "complete_validation";
201
+ : phase === "validation"
202
+ ? "complete_validation"
203
+ : phase === "architect"
204
+ ? "complete_architect_plan"
205
+ : "complete_qa";
206
+ return [`forge.${base}`, base];
207
+ }
208
+ export function completionToolNamesForQaGate(gate) {
209
+ const base = qaGateToolNames[gate];
210
+ return [`forge.${base}`, base];
211
+ }
212
+ export function completionToolNamesForDebugGate(gate) {
213
+ const base = debugGateToolNames[gate];
214
+ return [`forge.${base}`, base];
215
+ }
216
+ export function completionToolNamesForDraftGate(gate) {
217
+ const base = draftGateToolNames[gate];
39
218
  return [`forge.${base}`, base];
40
219
  }
41
220
  export function expectedCompletionToolName(phase) {
42
221
  return completionToolNamesForPhase(phase)[0];
43
222
  }
223
+ export function expectedQaCompletionToolName(gate) {
224
+ return completionToolNamesForQaGate(gate)[0];
225
+ }
226
+ export function expectedDebugCompletionToolName(gate) {
227
+ return completionToolNamesForDebugGate(gate)[0];
228
+ }
229
+ export function expectedDraftCompletionToolName(gate) {
230
+ return completionToolNamesForDraftGate(gate)[0];
231
+ }
44
232
  export function artifactRootFor(worktreePath, workItemId) {
45
233
  return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
46
234
  }
47
- export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
48
- return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].jsonFileName);
235
+ export function artifactRootForOwner(worktreePath, ownerType, ownerId) {
236
+ return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
237
+ }
238
+ export function completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId) {
239
+ const artifactRoot = ownerType && ownerId
240
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
241
+ : artifactRootFor(worktreePath, workItemId);
242
+ return path.join(artifactRoot, phaseFiles[phase].jsonFileName);
49
243
  }
50
- export function completionArtifactReportPath(worktreePath, workItemId, phase) {
51
- return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].reportFileName);
244
+ export function completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId) {
245
+ const artifactRoot = ownerType && ownerId
246
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
247
+ : artifactRootFor(worktreePath, workItemId);
248
+ return path.join(artifactRoot, phaseFiles[phase].reportFileName);
52
249
  }
53
250
  export async function prepareCompletionToolRuntime(options) {
54
251
  const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
55
252
  const worktreePath = path.resolve(options.worktree.worktreePath);
56
253
  const workItemId = options.dispatch.workItem.id;
254
+ const ownerType = normalizedOwnerType(options.dispatch.cycleRun.ownerType) ?? "work_item";
255
+ const ownerId = options.dispatch.cycleRun.ownerId ?? workItemId;
57
256
  const mcpArgs = buildCompletionToolServerArgs({
58
257
  worktreePath,
59
258
  workItemId,
259
+ ownerType,
260
+ ownerId,
60
261
  phase,
61
- producedBy: `${options.dispatch.execution.provider}-cli`
262
+ producedBy: `${options.dispatch.execution.provider}-cli`,
263
+ developCycle: usesDevelopCycleCompletion(options.dispatch),
264
+ qaCycle: usesQaCycleCompletion(options.dispatch),
265
+ debugCycle: usesDebugCycleCompletion(options.dispatch),
266
+ architectCycle: usesArchitectCycleCompletion(options.dispatch),
267
+ draftCycle: options.dispatch.execution.lane.trim().toLowerCase() === "draft" || options.dispatch.cycleRun.kind.trim().toLowerCase() === "draft"
62
268
  });
63
269
  const runtime = {
64
270
  serverName: toolServerName,
@@ -73,7 +279,7 @@ export async function prepareCompletionToolRuntime(options) {
73
279
  return await prepareCursorCompletionToolRuntime(worktreePath, runtime);
74
280
  }
75
281
  export function buildCompletionToolServerArgs(config) {
76
- return [
282
+ const args = [
77
283
  "mcp",
78
284
  "--worktree",
79
285
  path.resolve(config.worktreePath),
@@ -84,6 +290,26 @@ export function buildCompletionToolServerArgs(config) {
84
290
  "--produced-by",
85
291
  config.producedBy ?? "forge-completion-tool"
86
292
  ];
293
+ if (config.ownerType || config.ownerId) {
294
+ args.push("--owner-type", normalizedOwnerType(config.ownerType) ?? "work_item");
295
+ args.push("--owner-id", config.ownerId ?? config.workItemId);
296
+ }
297
+ if (config.developCycle) {
298
+ args.push("--develop-cycle");
299
+ }
300
+ if (config.qaCycle) {
301
+ args.push("--qa-cycle");
302
+ }
303
+ if (config.debugCycle) {
304
+ args.push("--debug-cycle");
305
+ }
306
+ if (config.architectCycle) {
307
+ args.push("--architect-cycle");
308
+ }
309
+ if (config.draftCycle) {
310
+ args.push("--draft-cycle");
311
+ }
312
+ return args;
87
313
  }
88
314
  export function codexMcpConfigArgs(runtime) {
89
315
  const prefix = `mcp_servers.${runtime.serverName}`;
@@ -109,27 +335,384 @@ export function completionPromptForPhase(phase) {
109
335
  const toolName = expectedCompletionToolName(phase);
110
336
  const alias = completionToolNamesForPhase(phase).find((name) => name !== toolName);
111
337
  const artifactName = phaseFiles[phase].jsonFileName;
338
+ if (phase === "qa") {
339
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
340
+ 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.
341
+ 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\`.
342
+ 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.
343
+ Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
344
+ }
345
+ if (phase === "architect") {
346
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
347
+ 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.
348
+ That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
349
+ The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
350
+ \`{ "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" }\`.
351
+ \`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.
352
+ 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.
353
+ Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
354
+ Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
355
+ }
112
356
  return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
113
357
  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.
114
358
  That tool writes \`.forge/work-items/{workItemId}/artifacts/${artifactName}\` and returns the artifact reference that Forge will mirror through the existing phase-artifact pipeline.
115
359
  If the work cannot pass, call the same tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
116
360
  }
361
+ export function completionPromptForDevelopCycle(phase) {
362
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
363
+ Do not write phase artifacts by hand. The tool server enforces phase order and starts this run in \`${phase}\`.
364
+ Complete the dev cycle in order inside this same provider session:
365
+ 1. Call \`forge.complete_implementation(...)\` when implementation evidence is ready.
366
+ 2. Follow the returned self-review instruction, then call \`forge.complete_self_review(...)\`.
367
+ 3. Follow the returned validation instruction, then call \`forge.complete_validation(...)\`.
368
+ Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
369
+ 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.`;
370
+ }
371
+ export function completionPromptForQaCycle(currentGate = "qa_plan") {
372
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
373
+ Do not write QA artifacts by hand. The tool server enforces QA gate order and starts this run at \`${currentGate}\`.
374
+ Complete the QA cycle in order inside this same provider session:
375
+ 1. Call \`forge.complete_qa_plan(...)\` after the QA test plan/checklist is ready.
376
+ 2. Follow the returned regression instruction, then call \`forge.complete_qa_regression(...)\`.
377
+ 3. Follow the returned API/backend instruction, then call \`forge.complete_qa_api(...)\`.
378
+ 4. Follow the returned manual web instruction, then call \`forge.complete_qa_manual_web(...)\`.
379
+ 5. Follow the returned bug reproduction instruction, then call \`forge.complete_qa_bug_repro(...)\`.
380
+ 6. Follow the returned findings instruction, then call \`forge.complete_qa_findings(...)\`.
381
+ 7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
382
+ 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.
383
+ 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\`.
384
+ The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
385
+ }
386
+ export function completionPromptForDraftCycle(currentGate = "draft_intent") {
387
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
388
+ Do not finish Draft by prose or raw JSON. The tool server enforces Draft gate order and starts at \`${currentGate}\`.
389
+ Complete the Draft cycle in order inside this same provider session:
390
+ 1. Call \`forge.complete_draft_intent(...)\` after interpreting user intent and non-goals.
391
+ 2. Follow the returned repo-context instruction, inspect selected project/repo assumptions, then call \`forge.complete_draft_repo_context(...)\`.
392
+ 3. Follow the returned graph-context instruction, account for DAG/dependency placement, then call \`forge.complete_draft_graph_context(...)\`.
393
+ 4. Follow the returned product-scope instruction, choose product scope for the selected project, then call \`forge.complete_draft_product_scope(...)\`.
394
+ 5. Follow the returned work-order-plan instruction, draft Work Order fields and candidate feature boundaries, then call \`forge.complete_draft_work_order_plan(...)\`.
395
+ 6. Follow the returned critic instruction, critique literalism, wrong-project scope, and decomposition quality, then call \`forge.complete_draft_critic(...)\`.
396
+ 7. Follow the returned proposal instruction, then call \`forge.complete_draft_proposal(...)\` with the final Work Order JSON in \`draft_json\`.
397
+ The final proposal tool writes \`draft.proposal.json\` under \`.forge/work-items/{workItemId}/artifacts/\`. Reporting Draft completion in prose is not enough.`;
398
+ }
399
+ export function completionPromptForDebugCycle(currentGate = "debug_repro") {
400
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
401
+ Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
402
+ Complete the Debug cycle in order inside this same provider session:
403
+ 1. Reproduce or tightly characterize the bug, then call \`forge.complete_debug_repro(...)\`.
404
+ 2. Follow the returned root-cause instruction, isolate evidence, then call \`forge.complete_debug_root_cause(...)\`.
405
+ 3. Follow the returned fix instruction, apply the smallest safe fix with regression coverage when practical, then call \`forge.complete_debug_fix(...)\`.
406
+ 4. Follow the returned regression instruction, prove the original repro is negative and the fix is QA-ready, then call \`forge.complete_debug_regression(...)\`.
407
+ 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.
408
+ 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.`;
409
+ }
410
+ export function completionToolNamesForServer(config) {
411
+ return config.draftCycle
412
+ ? draftCompletionGateOrder.flatMap((gate) => completionToolNamesForDraftGate(gate))
413
+ : config.debugCycle
414
+ ? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
415
+ : config.qaCycle
416
+ ? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
417
+ : config.developCycle
418
+ ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
419
+ : config.architectCycle
420
+ ? completionToolNamesForPhase("architect")
421
+ : completionToolNamesForPhase(config.phase);
422
+ }
423
+ export class CompletionToolServerSession {
424
+ config;
425
+ currentPhase;
426
+ currentQaGate = "qa_plan";
427
+ currentDraftGate = "draft_intent";
428
+ completed = false;
429
+ constructor(config) {
430
+ this.config = config;
431
+ this.currentPhase = config.phase;
432
+ }
433
+ get instructions() {
434
+ return this.config.draftCycle
435
+ ? completionPromptForDraftCycle(this.currentDraftGate)
436
+ : this.config.debugCycle
437
+ ? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
438
+ : this.config.qaCycle
439
+ ? completionPromptForQaCycle(this.currentQaGate)
440
+ : this.config.developCycle
441
+ ? completionPromptForDevelopCycle(this.currentPhase)
442
+ : completionPromptForPhase(this.currentPhase);
443
+ }
444
+ toolDefinitions() {
445
+ return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
446
+ }
447
+ async callTool(toolName, input) {
448
+ const requestedPhase = phaseFromToolName(toolName);
449
+ if (this.completed) {
450
+ throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
451
+ }
452
+ if (this.config.draftCycle) {
453
+ return await this.callDraftGateTool(toolName, requestedPhase, input);
454
+ }
455
+ if (this.config.debugCycle) {
456
+ return await this.callDebugGateTool(toolName, requestedPhase, input);
457
+ }
458
+ if (this.config.qaCycle) {
459
+ return await this.callQaGateTool(toolName, requestedPhase, input);
460
+ }
461
+ if (requestedPhase !== this.currentPhase) {
462
+ throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
463
+ }
464
+ const completedPhase = this.currentPhase;
465
+ const reference = await writeCompletionArtifact({ ...this.config, phase: completedPhase }, toolName, input);
466
+ const status = reference.status.trim().toLowerCase();
467
+ const pass = status === "pass";
468
+ const nextPhase = pass
469
+ ? this.advanceAfterPass(completedPhase)
470
+ : completedPhase;
471
+ const nextInstruction = completionToolNextInstruction(completedPhase, status, nextPhase);
472
+ const reason = pass
473
+ ? `${completedPhase} completion artifact accepted.`
474
+ : `${completedPhase} completion artifact recorded with status ${status}; rework is required before phase progression.`;
475
+ return {
476
+ content: [
477
+ {
478
+ type: "text",
479
+ text: `Forge recorded ${reference.phase} completion artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
480
+ }
481
+ ],
482
+ structuredContent: {
483
+ ...reference,
484
+ gate: {
485
+ phase: completedPhase,
486
+ status,
487
+ allowed: pass,
488
+ reason
489
+ },
490
+ nextPhase,
491
+ nextInstruction
492
+ }
493
+ };
494
+ }
495
+ async callDraftGateTool(toolName, requestedPhase, input) {
496
+ const requestedGate = draftGateFromToolName(toolName);
497
+ if (requestedPhase !== "draft" || !requestedGate) {
498
+ throw new Error(`Tool ${toolName} is not available in Draft-cycle mode. Current Forge Draft gate is ${this.currentDraftGate}.`);
499
+ }
500
+ if (requestedGate !== this.currentDraftGate) {
501
+ throw new Error(`Tool ${toolName} is out of order. Current Forge Draft gate is ${this.currentDraftGate}.`);
502
+ }
503
+ const completedGate = this.currentDraftGate;
504
+ const reference = await writeDraftGateCompletionArtifact(this.config, completedGate, toolName, input);
505
+ const status = reference.status.trim().toLowerCase();
506
+ const nextGate = this.advanceAfterDraftGate(completedGate);
507
+ const nextPhase = nextGate ? "draft" : "sign_off_ready";
508
+ const nextInstruction = completionToolNextInstructionForDraftGate(completedGate, status, nextGate);
509
+ return {
510
+ content: [{ type: "text", text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}` }],
511
+ structuredContent: {
512
+ ...reference,
513
+ gate: { phase: "draft", draftGate: completedGate, status, allowed: status !== "blocked", reason: nextGate ? `${completedGate} artifact accepted; continue to ${nextGate}.` : `Draft proposal artifact accepted with status ${status}.` },
514
+ nextPhase,
515
+ nextInstruction
516
+ }
517
+ };
518
+ }
519
+ async callDebugGateTool(toolName, requestedPhase, input) {
520
+ const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
521
+ if (!isDebugCompletionPhase(requestedPhase)) {
522
+ throw new Error(`Tool ${toolName} is not available in Debug-cycle mode. Current Forge Debug gate is ${currentDebugGate}.`);
523
+ }
524
+ if (requestedPhase !== currentDebugGate) {
525
+ throw new Error(`Tool ${toolName} is out of order. Current Forge Debug gate is ${currentDebugGate}.`);
526
+ }
527
+ const completedGate = currentDebugGate;
528
+ const reference = await writeCompletionArtifact({ ...this.config, phase: completedGate }, toolName, input);
529
+ const status = reference.status.trim().toLowerCase();
530
+ const pass = status === "pass";
531
+ const nextGate = pass ? this.advanceAfterDebugGate(completedGate) : completedGate;
532
+ const nextPhase = nextGate ?? "qa_ready";
533
+ const nextInstruction = completionToolNextInstructionForDebugGate(completedGate, status, nextGate);
534
+ const reason = pass
535
+ ? `${completedGate} artifact accepted${nextGate ? `; continue to ${nextGate}.` : "; Debug regression is QA-ready."}`
536
+ : `${completedGate} artifact recorded with status ${status}; rework is required before Debug progression.`;
537
+ return {
538
+ content: [
539
+ {
540
+ type: "text",
541
+ text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
542
+ }
543
+ ],
544
+ structuredContent: {
545
+ ...reference,
546
+ gate: {
547
+ phase: completedGate,
548
+ status,
549
+ allowed: pass,
550
+ reason
551
+ },
552
+ nextPhase,
553
+ nextInstruction
554
+ }
555
+ };
556
+ }
557
+ async callQaGateTool(toolName, requestedPhase, input) {
558
+ const requestedGate = qaGateFromToolName(toolName);
559
+ if (requestedPhase !== "qa" || !requestedGate) {
560
+ throw new Error(`Tool ${toolName} is not available in QA-cycle mode. Current Forge QA gate is ${this.currentQaGate}.`);
561
+ }
562
+ if (requestedGate !== this.currentQaGate) {
563
+ throw new Error(`Tool ${toolName} is out of order. Current Forge QA gate is ${this.currentQaGate}.`);
564
+ }
565
+ const completedGate = this.currentQaGate;
566
+ const reference = await writeCompletionArtifact({ ...this.config, phase: "qa" }, toolName, input);
567
+ const status = reference.status.trim().toLowerCase();
568
+ const nextGate = this.advanceAfterQaGate(completedGate);
569
+ const nextPhase = nextGate ? "qa" : "sign_off_ready";
570
+ const nextInstruction = completionToolNextInstructionForQaGate(completedGate, status, nextGate);
571
+ const allowed = status !== "blocked";
572
+ const reason = nextGate
573
+ ? `${completedGate} artifact accepted; continue to ${nextGate}.`
574
+ : `QA verdict artifact accepted with status ${status}.`;
575
+ return {
576
+ content: [
577
+ {
578
+ type: "text",
579
+ text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
580
+ }
581
+ ],
582
+ structuredContent: {
583
+ ...reference,
584
+ gate: {
585
+ phase: "qa",
586
+ qaGate: completedGate,
587
+ status,
588
+ allowed,
589
+ reason
590
+ },
591
+ nextPhase,
592
+ nextInstruction
593
+ }
594
+ };
595
+ }
596
+ advanceAfterPass(phase) {
597
+ if (phase === "qa") {
598
+ this.completed = true;
599
+ return "sign_off_ready";
600
+ }
601
+ if (isDebugCompletionPhase(phase)) {
602
+ return nextDebugPhaseAfter(phase) ?? "qa_ready";
603
+ }
604
+ const next = nextPhaseAfter(phase);
605
+ if (!this.config.developCycle) {
606
+ return next ?? "dev_test_ready";
607
+ }
608
+ if (next) {
609
+ this.currentPhase = next;
610
+ return next;
611
+ }
612
+ this.completed = true;
613
+ return "dev_test_ready";
614
+ }
615
+ advanceAfterDraftGate(gate) {
616
+ const index = draftCompletionGateOrder.indexOf(gate);
617
+ const next = index >= 0 ? draftCompletionGateOrder[index + 1] ?? null : null;
618
+ if (next) {
619
+ this.currentDraftGate = next;
620
+ return next;
621
+ }
622
+ this.completed = true;
623
+ return null;
624
+ }
625
+ advanceAfterQaGate(gate) {
626
+ const index = qaCompletionGateOrder.indexOf(gate);
627
+ const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
628
+ if (next) {
629
+ this.currentQaGate = next;
630
+ return next;
631
+ }
632
+ this.completed = true;
633
+ return null;
634
+ }
635
+ advanceAfterDebugGate(gate) {
636
+ const index = debugCompletionPhaseOrder.indexOf(gate);
637
+ const next = index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
638
+ if (next) {
639
+ this.currentPhase = next;
640
+ return next;
641
+ }
642
+ this.completed = true;
643
+ return null;
644
+ }
645
+ }
646
+ async function writeDraftGateCompletionArtifact(config, gate, toolName, input) {
647
+ const payload = asRecord(input, "Draft completion payload");
648
+ const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
649
+ if (gate === "draft_proposal") {
650
+ const draftJson = asRecord(payload["draft_json"], "draft_json");
651
+ for (const key of ["title", "summary", "objective", "planningPacket", "decompositionContext"]) {
652
+ if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
653
+ throw new Error(`draft_json.${key}: expected non-empty string`);
654
+ }
655
+ }
656
+ }
657
+ const redactedPayload = redactSecrets({ ...payload, status, phase: "draft", gate, tool_name: toolName }, config.redactionSecrets ?? []);
658
+ const root = artifactRootFor(config.worktreePath, config.workItemId);
659
+ await fs.mkdir(root, { recursive: true });
660
+ const artifactPath = path.join(root, draftGateFileNames[gate]);
661
+ const reportPath = gate === "draft_proposal" ? path.join(root, phaseFiles.draft.reportFileName) : path.join(root, `${gate}.md`);
662
+ const verdictJson = jsonText(redactedPayload);
663
+ const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}\n\n${optionalString(redactedPayload, "summary") ?? "Draft gate recorded."}\n`;
664
+ await writeFileAtomic(artifactPath, verdictJson);
665
+ await writeFileAtomic(reportPath, report);
666
+ return {
667
+ phase: "draft",
668
+ workItemId: config.workItemId,
669
+ ownerType: config.ownerType,
670
+ ownerId: config.ownerId,
671
+ repoPath: path.resolve(config.worktreePath),
672
+ path: artifactPath,
673
+ reportPath,
674
+ contentHash: sha256(verdictJson),
675
+ status,
676
+ producedBy: config.producedBy ?? "forge-completion-tool"
677
+ };
678
+ }
679
+ function normalizeDraftStatus(value) {
680
+ const normalized = value.trim().toLowerCase();
681
+ return normalized === "blocked" || normalized === "needs_revision" || normalized === "needs_rework" ? normalized : "pass";
682
+ }
117
683
  export async function writeCompletionArtifact(config, toolName, input) {
118
684
  const phase = phaseFromToolName(toolName);
119
685
  if (phase !== config.phase) {
120
686
  throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
121
687
  }
688
+ const qaGate = qaGateFromToolName(toolName);
689
+ if (qaGate && qaGate !== "qa_verdict") {
690
+ return await writeQaGateCompletionArtifact(config, qaGate, input);
691
+ }
122
692
  const payload = asRecord(input, "completion payload");
123
- validatePayloadShape(phase, payload);
124
- validateOptionalWorkItemId(payload, config.workItemId);
125
- const producedBy = optionalString(payload, "produced_by")
126
- ?? optionalString(payload, "producedBy")
693
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
694
+ let validationPayload = { ...payload, status };
695
+ if (config.qaCycle && qaGate === "qa_verdict") {
696
+ validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
697
+ }
698
+ validatePayloadShape(phase, validationPayload);
699
+ validateOptionalWorkItemId(validationPayload, config.workItemId);
700
+ const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
701
+ const producedBy = optionalString(redactedPayload, "produced_by")
702
+ ?? optionalString(redactedPayload, "producedBy")
127
703
  ?? config.producedBy
128
704
  ?? "forge-completion-tool";
129
- const status = requiredString(payload, "status").trim().toLowerCase();
130
- const artifactWithoutHash = normalizeArtifactPayload(payload, {
705
+ if (phase === "qa") {
706
+ return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
707
+ }
708
+ if (phase === "architect") {
709
+ return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
710
+ }
711
+ const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
131
712
  phase,
132
713
  workItemId: config.workItemId,
714
+ ownerType: config.ownerType,
715
+ ownerId: config.ownerId,
133
716
  producedBy,
134
717
  status
135
718
  });
@@ -139,7 +722,7 @@ export async function writeCompletionArtifact(config, toolName, input) {
139
722
  content_hash: contentHash
140
723
  };
141
724
  const artifactJson = `${JSON.stringify(artifact, null, 2)}\n`;
142
- const report = reportMarkdown(payload, phase, status, producedBy, contentHash);
725
+ const report = reportMarkdown(redactedPayload, phase, status, producedBy, contentHash);
143
726
  const artifactPath = completionArtifactJsonPath(config.worktreePath, config.workItemId, phase);
144
727
  const reportPath = completionArtifactReportPath(config.worktreePath, config.workItemId, phase);
145
728
  await fs.mkdir(path.dirname(artifactPath), { recursive: true });
@@ -148,6 +731,93 @@ export async function writeCompletionArtifact(config, toolName, input) {
148
731
  return {
149
732
  phase,
150
733
  workItemId: config.workItemId,
734
+ ...artifactReferenceOwnerFields(config),
735
+ repoPath: path.resolve(config.worktreePath),
736
+ path: artifactPath,
737
+ reportPath,
738
+ contentHash,
739
+ status,
740
+ producedBy
741
+ };
742
+ }
743
+ async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
744
+ const ownerFields = artifactOwnerFields(config);
745
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
746
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
747
+ : artifactRootFor(config.worktreePath, config.workItemId);
748
+ const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
749
+ const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
750
+ const verdictPath = path.join(artifactRoot, "architect.verdict.json");
751
+ const reportPath = path.join(artifactRoot, "architect.report.md");
752
+ const implementationPlan = requiredRecord(payload, "implementation_plan");
753
+ const qaPlan = requiredRecord(payload, "qa_plan");
754
+ const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
755
+ const artifactWithoutHash = normalizeArtifactPayload(payload, {
756
+ phase: "architect",
757
+ workItemId: config.workItemId,
758
+ ownerType: config.ownerType,
759
+ ownerId: config.ownerId,
760
+ producedBy,
761
+ status
762
+ });
763
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
764
+ const verdict = {
765
+ ...artifactWithoutHash,
766
+ content_hash: contentHash
767
+ };
768
+ await fs.mkdir(artifactRoot, { recursive: true });
769
+ await writeFileAtomic(implementationPlanPath, jsonText(implementationPlan));
770
+ await writeFileAtomic(qaPlanPath, jsonText(qaPlan));
771
+ await writeFileAtomic(verdictPath, jsonText(verdict));
772
+ await writeFileAtomic(reportPath, `${report.trim()}\n`);
773
+ return {
774
+ phase: "architect",
775
+ workItemId: config.workItemId,
776
+ ...artifactReferenceOwnerFields(config),
777
+ repoPath: path.resolve(config.worktreePath),
778
+ path: verdictPath,
779
+ reportPath,
780
+ contentHash,
781
+ status,
782
+ producedBy
783
+ };
784
+ }
785
+ async function writeQaGateCompletionArtifact(config, gate, input) {
786
+ const payload = asRecord(input, "QA gate completion payload");
787
+ const status = normalizeQaGateStatus(requiredString(payload, "status"));
788
+ validateQaGatePayloadShape(gate, payload, status);
789
+ validateOptionalWorkItemId(payload, config.workItemId);
790
+ const redactedPayload = redactSecrets({ ...payload, status }, config.redactionSecrets ?? []);
791
+ const producedBy = optionalString(redactedPayload, "produced_by")
792
+ ?? optionalString(redactedPayload, "producedBy")
793
+ ?? config.producedBy
794
+ ?? "forge-completion-tool";
795
+ const ownerFields = artifactOwnerFields(config);
796
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
797
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
798
+ : artifactRootFor(config.worktreePath, config.workItemId);
799
+ const fileName = qaGateFileNames[gate];
800
+ const artifactPath = path.join(artifactRoot, fileName);
801
+ const reportPath = path.join(artifactRoot, "qa.report.md");
802
+ const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
803
+ gate,
804
+ workItemId: config.workItemId,
805
+ ownerType: config.ownerType,
806
+ ownerId: config.ownerId,
807
+ producedBy,
808
+ status
809
+ });
810
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
811
+ const artifact = {
812
+ ...artifactWithoutHash,
813
+ content_hash: contentHash
814
+ };
815
+ await fs.mkdir(artifactRoot, { recursive: true });
816
+ await writeFileAtomic(artifactPath, jsonText(artifact));
817
+ return {
818
+ phase: "qa",
819
+ workItemId: config.workItemId,
820
+ ...artifactReferenceOwnerFields(config),
151
821
  repoPath: path.resolve(config.worktreePath),
152
822
  path: artifactPath,
153
823
  reportPath,
@@ -156,19 +826,319 @@ export async function writeCompletionArtifact(config, toolName, input) {
156
826
  producedBy
157
827
  };
158
828
  }
159
- export async function readCompletionArtifact(worktreePath, workItemId, phase) {
160
- const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
161
- const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
829
+ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
830
+ const ownerFields = artifactOwnerFields(config);
831
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
832
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
833
+ : artifactRootFor(config.worktreePath, config.workItemId);
834
+ const planPath = path.join(artifactRoot, "qa.plan.json");
835
+ const evidencePath = path.join(artifactRoot, "qa.evidence.json");
836
+ const findingsPath = path.join(artifactRoot, "qa.findings.json");
837
+ const verdictPath = path.join(artifactRoot, "qa.verdict.json");
838
+ const reportPath = path.join(artifactRoot, "qa.report.md");
839
+ const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
840
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
841
+ : qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
842
+ const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
843
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
844
+ : qaArtifactRelativePath(config.workItemId, "qa.findings.json");
845
+ const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
846
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
847
+ : qaArtifactRelativePath(config.workItemId, "qa.report.md");
848
+ const evidenceSource = qaEvidenceSource(payload);
849
+ const commands = qaArray(payload, "commands", evidenceSource);
850
+ const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
851
+ const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
852
+ const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
853
+ const findings = qaObjectArray(payload, "findings");
854
+ const candidateBugs = qaObjectArray(payload, "candidate_bugs");
855
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
856
+ const explicitFindingIds = qaStringArray(payload, "finding_ids");
857
+ const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
858
+ const blockerReason = optionalString(payload, "blocker_reason");
859
+ const checklist = qaStringArray(payload, "checklist");
860
+ const planJson = jsonText({
861
+ schema_version: 1,
862
+ phase: "qa",
863
+ ...ownerFields,
864
+ checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
865
+ acceptance_criteria: testedAcceptanceCriteria
866
+ });
867
+ const evidenceJson = jsonText({
868
+ schema_version: 1,
869
+ phase: "qa",
870
+ ...ownerFields,
871
+ commands,
872
+ browser_checks: browserChecks,
873
+ sidecars,
874
+ notes
875
+ });
876
+ const findingsJson = jsonText({
877
+ schema_version: 1,
878
+ phase: "qa",
879
+ ...ownerFields,
880
+ findings,
881
+ candidate_bugs: candidateBugs
882
+ });
883
+ const findingIds = status === "pass"
884
+ ? []
885
+ : explicitFindingIds.length > 0
886
+ ? explicitFindingIds
887
+ : findings
888
+ .filter((finding) => normalizedString(finding.scope) === "in_scope")
889
+ .map((finding) => optionalString(finding, "id"))
890
+ .filter((id) => Boolean(id));
891
+ const candidateBugIds = explicitCandidateBugIds.length > 0
892
+ ? explicitCandidateBugIds
893
+ : candidateBugs
894
+ .map((candidateBug) => optionalString(candidateBug, "id"))
895
+ .filter((id) => Boolean(id));
896
+ const verdictWithoutHash = {
897
+ schema_version: numberOrDefault(payload.schema_version, 1),
898
+ phase: "qa",
899
+ ...ownerFields,
900
+ status,
901
+ summary: requiredString(payload, "summary"),
902
+ tested_acceptance_criteria: testedAcceptanceCriteria,
903
+ finding_ids: findingIds,
904
+ candidate_bug_ids: candidateBugIds,
905
+ blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
906
+ evidence_path: relativeEvidencePath,
907
+ findings_path: relativeFindingsPath,
908
+ report_path: relativeReportPath,
909
+ produced_by: producedBy
910
+ };
911
+ const contentHash = sha256(stableStringify({
912
+ plan: JSON.parse(planJson),
913
+ evidence: JSON.parse(evidenceJson),
914
+ findings: JSON.parse(findingsJson),
915
+ verdict: verdictWithoutHash
916
+ }));
917
+ const verdictJson = jsonText({
918
+ ...verdictWithoutHash,
919
+ content_hash: contentHash
920
+ });
921
+ const finalReport = reportMarkdown(payload, "qa", status, producedBy, contentHash);
922
+ await fs.mkdir(artifactRoot, { recursive: true });
923
+ await writeFileAtomic(planPath, planJson);
924
+ await writeFileAtomic(evidencePath, evidenceJson);
925
+ await writeFileAtomic(findingsPath, findingsJson);
926
+ await writeFileAtomic(verdictPath, verdictJson);
927
+ await writeFileAtomic(reportPath, finalReport);
928
+ return {
929
+ phase: "qa",
930
+ workItemId: config.workItemId,
931
+ ...artifactReferenceOwnerFields(config),
932
+ repoPath: path.resolve(config.worktreePath),
933
+ path: verdictPath,
934
+ reportPath,
935
+ contentHash,
936
+ status,
937
+ producedBy
938
+ };
939
+ }
940
+ async function synthesizeQaCycleVerdictPayload(config, payload, status) {
941
+ const qaPlan = await readQaGateArtifact(config, "qa_plan");
942
+ const qaRegression = await readQaGateArtifact(config, "qa_regression");
943
+ const qaApi = await readQaGateArtifact(config, "qa_api");
944
+ const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
945
+ const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
946
+ const qaFindings = await readQaGateArtifact(config, "qa_findings");
947
+ const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
948
+ const payloadEvidence = qaEvidenceSource(payload);
949
+ const merged = { ...payload };
950
+ const checklist = qaStringArray(payload, "checklist");
951
+ if (checklist.length === 0) {
952
+ const plannedChecklist = qaStringArray(qaPlan, "checklist");
953
+ merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
954
+ }
955
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
956
+ if (testedAcceptanceCriteria.length === 0) {
957
+ const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
958
+ const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
959
+ const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
960
+ if (acceptanceCriteria.length > 0) {
961
+ merged.tested_acceptance_criteria = acceptanceCriteria;
962
+ }
963
+ }
964
+ const payloadCommands = qaArray(payload, "commands", payloadEvidence);
965
+ const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
966
+ const inheritedDiagnosticCommandNotes = status === "pass"
967
+ ? qaNonPassingCommandNotes(inheritedCommands)
968
+ : [];
969
+ merged.commands = dedupeUnknownArray([
970
+ ...payloadCommands,
971
+ ...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
972
+ ]);
973
+ merged.browser_checks = dedupeUnknownArray([
974
+ ...qaArray(payload, "browser_checks", payloadEvidence),
975
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
976
+ ]);
977
+ merged.sidecars = dedupeUnknownArray([
978
+ ...qaArray(payload, "sidecars", payloadEvidence),
979
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
980
+ ]);
981
+ const notes = dedupeStrings([
982
+ optionalString(payload, "notes"),
983
+ optionalString(payloadEvidence, "notes"),
984
+ ...evidenceGates.map((gate) => optionalString(gate, "notes")),
985
+ ...inheritedDiagnosticCommandNotes
986
+ ]);
987
+ if (notes.length > 0) {
988
+ merged.notes = notes.join("\n");
989
+ }
990
+ merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
991
+ merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
992
+ return merged;
993
+ }
994
+ async function readQaGateArtifact(config, gate) {
995
+ const ownerFields = artifactOwnerFields(config);
996
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
997
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
998
+ : artifactRootFor(config.worktreePath, config.workItemId);
999
+ const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
1000
+ const content = await readOptionalFile(artifactPath);
1001
+ return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
1002
+ }
1003
+ function dedupeUnknownArray(values) {
1004
+ const seen = new Set();
1005
+ const deduped = [];
1006
+ for (const value of values) {
1007
+ const key = stableStringify(value);
1008
+ if (seen.has(key)) {
1009
+ continue;
1010
+ }
1011
+ seen.add(key);
1012
+ deduped.push(value);
1013
+ }
1014
+ return deduped;
1015
+ }
1016
+ function isQaPassingCommandEvidence(value) {
1017
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1018
+ return false;
1019
+ }
1020
+ const record = value;
1021
+ return record.exit_code === 0 && normalizedString(record.status) === "pass";
1022
+ }
1023
+ function qaNonPassingCommandNotes(values) {
1024
+ return values
1025
+ .filter((value) => !isQaPassingCommandEvidence(value))
1026
+ .map((value, index) => {
1027
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1028
+ return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
1029
+ }
1030
+ const record = value;
1031
+ const command = optionalString(record, "command") ?? "<missing command>";
1032
+ const status = optionalString(record, "status") ?? "<missing status>";
1033
+ const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
1034
+ ? String(record.exit_code)
1035
+ : "<missing exit_code>";
1036
+ const summary = optionalString(record, "summary");
1037
+ return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
1038
+ });
1039
+ }
1040
+ function mergeQaObjectArraysById(primary, fallback) {
1041
+ const seen = new Set();
1042
+ const merged = [];
1043
+ for (const value of [...primary, ...fallback]) {
1044
+ const id = optionalString(value, "id");
1045
+ const key = id ? `id:${id}` : stableStringify(value);
1046
+ if (seen.has(key)) {
1047
+ continue;
1048
+ }
1049
+ seen.add(key);
1050
+ merged.push(value);
1051
+ }
1052
+ return merged;
1053
+ }
1054
+ function dedupeStrings(values) {
1055
+ const seen = new Set();
1056
+ const deduped = [];
1057
+ for (const value of values) {
1058
+ const trimmed = value?.trim();
1059
+ if (!trimmed || seen.has(trimmed)) {
1060
+ continue;
1061
+ }
1062
+ seen.add(trimmed);
1063
+ deduped.push(trimmed);
1064
+ }
1065
+ return deduped;
1066
+ }
1067
+ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
1068
+ if (sidecars.length === 0) {
1069
+ return [];
1070
+ }
1071
+ const artifactRoot = ownerType && ownerId
1072
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
1073
+ : artifactRootFor(worktreePath, workItemId);
1074
+ const qaSidecarRoot = path.resolve(artifactRoot, "qa");
1075
+ const validated = [];
1076
+ for (const [index, sidecar] of sidecars.entries()) {
1077
+ if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
1078
+ throw new Error(`sidecars[${index}] must be an object.`);
1079
+ }
1080
+ const record = sidecar;
1081
+ const sidecarPath = optionalString(record, "path");
1082
+ const contentHash = optionalString(record, "content_hash");
1083
+ const bytes = record.bytes;
1084
+ if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
1085
+ throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
1086
+ }
1087
+ if (path.isAbsolute(sidecarPath)) {
1088
+ throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
1089
+ }
1090
+ const fullPath = path.resolve(worktreePath, sidecarPath);
1091
+ if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
1092
+ throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
1093
+ }
1094
+ const stat = await fs.lstat(fullPath).catch((error) => {
1095
+ if (isNodeError(error) && error.code === "ENOENT") {
1096
+ throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
1097
+ }
1098
+ throw error;
1099
+ });
1100
+ if (!stat.isFile() || stat.isSymbolicLink()) {
1101
+ throw new Error(`sidecars[${index}].path must reference a regular file.`);
1102
+ }
1103
+ await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
1104
+ if (stat.size !== bytes) {
1105
+ throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
1106
+ }
1107
+ const actualHash = await sha256File(fullPath);
1108
+ if (actualHash !== contentHash.trim().toLowerCase()) {
1109
+ throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
1110
+ }
1111
+ validated.push({
1112
+ ...record,
1113
+ path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
1114
+ content_hash: actualHash,
1115
+ bytes: stat.size
1116
+ });
1117
+ }
1118
+ return validated;
1119
+ }
1120
+ export async function readCompletionArtifact(worktreePath, workItemId, phase, ownerType, ownerId) {
1121
+ const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId);
1122
+ const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId);
162
1123
  const verdictJson = await readOptionalFile(artifactPath);
163
1124
  if (verdictJson === null) {
164
1125
  return null;
165
1126
  }
166
1127
  const parsed = parseJsonObject(verdictJson, artifactPath);
167
1128
  const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
1129
+ const artifactRoot = path.dirname(artifactPath);
1130
+ const implementationPlanJson = phase === "architect"
1131
+ ? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
1132
+ : null;
1133
+ const qaPlanJson = phase === "architect"
1134
+ ? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
1135
+ : null;
168
1136
  return {
169
1137
  repoPath: path.resolve(worktreePath),
170
1138
  path: artifactPath,
171
1139
  verdictJson,
1140
+ implementationPlanJson,
1141
+ qaPlanJson,
172
1142
  report,
173
1143
  producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
174
1144
  status: optionalString(parsed, "status") ?? "unknown",
@@ -176,6 +1146,7 @@ export async function readCompletionArtifact(worktreePath, workItemId, phase) {
176
1146
  };
177
1147
  }
178
1148
  export async function runCompletionMcpServer(config) {
1149
+ const session = new CompletionToolServerSession(config);
179
1150
  process.stdin.setEncoding("utf8");
180
1151
  let buffer = "";
181
1152
  for await (const chunk of process.stdin) {
@@ -185,21 +1156,28 @@ export async function runCompletionMcpServer(config) {
185
1156
  const rawLine = buffer.slice(0, newlineIndex).trim();
186
1157
  buffer = buffer.slice(newlineIndex + 1);
187
1158
  if (rawLine.length > 0) {
188
- await handleJsonRpcLine(config, rawLine);
1159
+ await handleJsonRpcLine(session, rawLine);
189
1160
  }
190
1161
  newlineIndex = buffer.indexOf("\n");
191
1162
  }
192
1163
  }
193
1164
  const trailing = buffer.trim();
194
1165
  if (trailing.length > 0) {
195
- await handleJsonRpcLine(config, trailing);
1166
+ await handleJsonRpcLine(session, trailing);
196
1167
  }
197
1168
  }
198
1169
  export function parseCompletionMcpArgs(argv) {
199
1170
  let worktreePath = null;
200
1171
  let workItemId = null;
201
1172
  let phase = "implementation";
1173
+ let ownerType;
1174
+ let ownerId;
202
1175
  let producedBy;
1176
+ let developCycle = false;
1177
+ let qaCycle = false;
1178
+ let debugCycle = false;
1179
+ let architectCycle = false;
1180
+ let draftCycle = false;
203
1181
  for (let index = 1; index < argv.length; index += 1) {
204
1182
  const arg = argv[index];
205
1183
  if (arg === "--worktree") {
@@ -208,17 +1186,41 @@ export function parseCompletionMcpArgs(argv) {
208
1186
  else if (arg === "--work-item-id") {
209
1187
  workItemId = argv[++index] ?? null;
210
1188
  }
1189
+ else if (arg === "--owner-type") {
1190
+ ownerType = normalizedOwnerType(argv[++index]);
1191
+ if (!ownerType) {
1192
+ return { ok: false, error: "mcp --owner-type must be work_item or story." };
1193
+ }
1194
+ }
1195
+ else if (arg === "--owner-id") {
1196
+ ownerId = argv[++index] ?? undefined;
1197
+ }
211
1198
  else if (arg === "--phase") {
212
1199
  const rawPhase = argv[++index];
213
1200
  const parsedPhase = parseStrictCompletionPhase(rawPhase);
214
1201
  if (!parsedPhase) {
215
- return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation." };
1202
+ return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, architect, draft, debug-repro, debug-root-cause, debug-fix, debug-regression." };
216
1203
  }
217
1204
  phase = parsedPhase;
218
1205
  }
219
1206
  else if (arg === "--produced-by") {
220
1207
  producedBy = argv[++index] ?? undefined;
221
1208
  }
1209
+ else if (arg === "--develop-cycle") {
1210
+ developCycle = true;
1211
+ }
1212
+ else if (arg === "--qa-cycle") {
1213
+ qaCycle = true;
1214
+ }
1215
+ else if (arg === "--debug-cycle") {
1216
+ debugCycle = true;
1217
+ }
1218
+ else if (arg === "--architect-cycle") {
1219
+ architectCycle = true;
1220
+ }
1221
+ else if (arg === "--draft-cycle") {
1222
+ draftCycle = true;
1223
+ }
222
1224
  else {
223
1225
  return { ok: false, error: `Unknown mcp argument: ${arg}` };
224
1226
  }
@@ -226,16 +1228,48 @@ export function parseCompletionMcpArgs(argv) {
226
1228
  if (!worktreePath?.trim() || !workItemId?.trim()) {
227
1229
  return { ok: false, error: "mcp requires --worktree and --work-item-id." };
228
1230
  }
1231
+ if ([developCycle, qaCycle, debugCycle, architectCycle, draftCycle].filter(Boolean).length > 1) {
1232
+ return { ok: false, error: "mcp accepts only one cycle mode." };
1233
+ }
1234
+ if (developCycle && !isDevelopCompletionPhase(phase)) {
1235
+ return { ok: false, error: "mcp --develop-cycle requires --phase implementation, self-review, or validation." };
1236
+ }
1237
+ if (qaCycle && phase !== "qa") {
1238
+ return { ok: false, error: "mcp --qa-cycle requires --phase qa." };
1239
+ }
1240
+ if (debugCycle && !isDebugCompletionPhase(phase)) {
1241
+ return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
1242
+ }
1243
+ if (architectCycle && phase !== "architect") {
1244
+ return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
1245
+ }
1246
+ if (draftCycle && phase !== "draft") {
1247
+ return { ok: false, error: "mcp --draft-cycle requires --phase draft." };
1248
+ }
229
1249
  return {
230
1250
  ok: true,
231
1251
  config: {
232
1252
  worktreePath: path.resolve(worktreePath),
233
1253
  workItemId,
1254
+ ownerType,
1255
+ ownerId,
234
1256
  phase,
235
- producedBy
1257
+ producedBy,
1258
+ redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
1259
+ developCycle,
1260
+ qaCycle,
1261
+ debugCycle,
1262
+ architectCycle,
1263
+ draftCycle
236
1264
  }
237
1265
  };
238
1266
  }
1267
+ export function completionRedactionEnv(secrets) {
1268
+ const filtered = secrets.filter((secret) => secret.length > 0);
1269
+ return filtered.length > 0
1270
+ ? { [completionRedactionSecretsEnv]: JSON.stringify(filtered) }
1271
+ : {};
1272
+ }
239
1273
  async function prepareCursorCompletionToolRuntime(worktreePath, runtime) {
240
1274
  const cursorDir = path.join(worktreePath, ".cursor");
241
1275
  const configPath = path.join(cursorDir, "mcp.json");
@@ -263,7 +1297,7 @@ async function prepareCursorCompletionToolRuntime(worktreePath, runtime) {
263
1297
  }
264
1298
  };
265
1299
  }
266
- async function handleJsonRpcLine(config, rawLine) {
1300
+ async function handleJsonRpcLine(session, rawLine) {
267
1301
  let message;
268
1302
  try {
269
1303
  message = JSON.parse(rawLine);
@@ -281,14 +1315,14 @@ async function handleJsonRpcLine(config, rawLine) {
281
1315
  return;
282
1316
  }
283
1317
  try {
284
- const result = await handleJsonRpcRequest(config, request.method, request.params);
1318
+ const result = await handleJsonRpcRequest(session, request.method, request.params);
285
1319
  writeJsonRpc({ jsonrpc: "2.0", id: request.id, result });
286
1320
  }
287
1321
  catch (error) {
288
1322
  writeJsonRpc({ jsonrpc: "2.0", id: request.id, error: rpcError(-32000, errorMessage(error)) });
289
1323
  }
290
1324
  }
291
- async function handleJsonRpcRequest(config, method, params) {
1325
+ async function handleJsonRpcRequest(session, method, params) {
292
1326
  if (method === "initialize") {
293
1327
  const initializeParams = params && typeof params === "object" && !Array.isArray(params)
294
1328
  ? params
@@ -302,7 +1336,7 @@ async function handleJsonRpcRequest(config, method, params) {
302
1336
  name: "forge-completion-tools",
303
1337
  version: "0.1.0"
304
1338
  },
305
- instructions: completionPromptForPhase(config.phase)
1339
+ instructions: session.instructions
306
1340
  };
307
1341
  }
308
1342
  if (method === "ping") {
@@ -310,115 +1344,739 @@ async function handleJsonRpcRequest(config, method, params) {
310
1344
  }
311
1345
  if (method === "tools/list") {
312
1346
  return {
313
- tools: completionToolNamesForPhase(config.phase).map((name) => toolDefinition(name, config.phase))
1347
+ tools: session.toolDefinitions()
314
1348
  };
315
1349
  }
316
1350
  if (method === "tools/call") {
317
1351
  const call = asRecord(params, "tools/call params");
318
1352
  const name = requiredString(call, "name");
319
1353
  const args = call.arguments ?? {};
320
- const reference = await writeCompletionArtifact(config, name, args);
321
- return {
322
- content: [
323
- {
324
- type: "text",
325
- text: `Forge recorded ${reference.phase} completion artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}).`
326
- }
327
- ],
328
- structuredContent: reference
329
- };
1354
+ return await session.callTool(name, args);
330
1355
  }
331
1356
  throw new Error(`Unsupported MCP method: ${method}`);
332
1357
  }
333
- function toolDefinition(name, phase) {
1358
+ function toolDefinition(name, phase, qaGate = null) {
334
1359
  return {
335
1360
  name,
336
- description: toolDescription(phase),
337
- inputSchema: toolInputSchema(phase)
1361
+ description: toolDescription(phase, qaGate),
1362
+ inputSchema: toolInputSchema(phase, qaGate)
338
1363
  };
339
1364
  }
340
- function toolDescription(phase) {
341
- if (phase === "implementation") {
342
- return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
1365
+ function nextPhaseAfter(phase) {
1366
+ if (!isDevelopCompletionPhase(phase)) {
1367
+ return null;
343
1368
  }
344
- if (phase === "self_review") {
345
- 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.";
1369
+ const index = completionPhaseOrder.indexOf(phase);
1370
+ return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
1371
+ }
1372
+ function nextDebugPhaseAfter(phase) {
1373
+ const index = debugCompletionPhaseOrder.indexOf(phase);
1374
+ return index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
1375
+ }
1376
+ function completionToolNextInstruction(completedPhase, status, nextPhase) {
1377
+ if (completedPhase === "qa") {
1378
+ if (status === "blocked") {
1379
+ return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
1380
+ }
1381
+ if (status === "needs_rework") {
1382
+ return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
1383
+ }
1384
+ return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
346
1385
  }
347
- return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
1386
+ if (status !== "pass") {
1387
+ return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
1388
+ }
1389
+ if (isDebugCompletionPhase(completedPhase)) {
1390
+ const debugNext = isDebugCompletionPhase(nextPhase) ? nextPhase : null;
1391
+ return completionToolNextInstructionForDebugGate(completedPhase, status, debugNext);
1392
+ }
1393
+ if (nextPhase === "self_review") {
1394
+ return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
1395
+ }
1396
+ if (nextPhase === "validation") {
1397
+ return "Self-review accepted. Begin validation: run deterministic checks, prove acceptance criteria, and call forge.complete_validation with command evidence.";
1398
+ }
1399
+ return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
348
1400
  }
349
- function toolInputSchema(phase) {
350
- const base = {
351
- type: "object",
352
- additionalProperties: true,
353
- properties: {
354
- status: { type: "string", enum: ["pass", "blocked", "fail", "failed"] },
355
- summary: { type: "string" },
356
- report_markdown: { type: "string" },
357
- blockers: { type: "array" }
1401
+ function completionToolNextInstructionForDraftGate(completedGate, status, nextGate) {
1402
+ if (status === "blocked") {
1403
+ return `Stay in ${completedGate}; record the blocker and retry the same Draft gate when unblocked.`;
1404
+ }
1405
+ if (!nextGate) {
1406
+ return "Draft proposal accepted. Forge has the structured Work Order draft JSON needed to populate the form.";
1407
+ }
1408
+ const instructions = {
1409
+ draft_intent: "Capture intent/non-goals, then call forge.complete_draft_intent.",
1410
+ draft_repo_context: "Inspect selected repo/project assumptions, then call forge.complete_draft_repo_context.",
1411
+ draft_graph_context: "Account for DAG dependencies and placement, then call forge.complete_draft_graph_context.",
1412
+ draft_product_scope: "Choose product scope for the selected project, then call forge.complete_draft_product_scope.",
1413
+ draft_work_order_plan: "Draft Work Order fields and candidate Feature boundaries, then call forge.complete_draft_work_order_plan.",
1414
+ draft_critic: "Critique literalism/wrong-project scope/decomposition risk, then call forge.complete_draft_critic.",
1415
+ draft_proposal: "Submit final draft_json through forge.complete_draft_proposal."
1416
+ };
1417
+ return `${completedGate} accepted. Continue to ${nextGate}. ${instructions[nextGate]}`;
1418
+ }
1419
+ function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
1420
+ if (!nextGate) {
1421
+ if (status === "blocked") {
1422
+ return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
1423
+ }
1424
+ if (status === "needs_rework") {
1425
+ return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
1426
+ }
1427
+ return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
1428
+ }
1429
+ const skipped = status === "skipped";
1430
+ const blocked = status === "blocked";
1431
+ const prefix = blocked
1432
+ ? `${completedGate} recorded a blocker. Continue through ${nextGate} and mark downstream gates skipped if the blocker prevents evidence collection.`
1433
+ : skipped
1434
+ ? `${completedGate} skipped. Continue to ${nextGate}.`
1435
+ : `${completedGate} accepted. Continue to ${nextGate}.`;
1436
+ return `${prefix} ${qaGateInstruction(nextGate)}`;
1437
+ }
1438
+ function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
1439
+ if (status !== "pass") {
1440
+ return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
1441
+ }
1442
+ if (nextGate === "debug_root_cause") {
1443
+ return "Debug repro accepted. Begin root-cause analysis: connect the observed failure to code evidence and call forge.complete_debug_root_cause.";
1444
+ }
1445
+ if (nextGate === "debug_fix") {
1446
+ return "Debug root cause accepted. Apply the smallest safe fix with regression coverage where practical, then call forge.complete_debug_fix.";
1447
+ }
1448
+ if (nextGate === "debug_regression") {
1449
+ return "Debug fix accepted. Prove the original repro is negative, run targeted regression checks, and call forge.complete_debug_regression.";
1450
+ }
1451
+ return "Debug regression accepted. Forge has the structured evidence needed to move this bug fix to QA.";
1452
+ }
1453
+ function qaGateInstruction(gate) {
1454
+ switch (gate) {
1455
+ case "qa_regression":
1456
+ return "Run or explicitly skip applicable regression/build/lint/typecheck/e2e checks, then call forge.complete_qa_regression.";
1457
+ case "qa_api":
1458
+ return "Run or explicitly skip applicable API/backend contract checks, then call forge.complete_qa_api.";
1459
+ case "qa_manual_web":
1460
+ return "Run or explicitly skip applicable browser/manual web checks, then call forge.complete_qa_manual_web.";
1461
+ case "qa_bug_repro":
1462
+ return "Reproduce or explicitly skip relevant defects/candidate bugs, then call forge.complete_qa_bug_repro.";
1463
+ case "qa_findings":
1464
+ return "Normalize observations into findings and candidate bugs, then call forge.complete_qa_findings.";
1465
+ case "qa_verdict":
1466
+ return "Assemble the final QA verdict from all prior gate evidence, then call forge.complete_qa_verdict.";
1467
+ case "qa_plan":
1468
+ return "Prepare the QA checklist, then call forge.complete_qa_plan.";
1469
+ }
1470
+ }
1471
+ function toolDescription(phase, qaGate = null) {
1472
+ if (qaGate) {
1473
+ return qaGate === "qa_verdict"
1474
+ ? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
1475
+ : `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
1476
+ }
1477
+ if (isDebugCompletionPhase(phase)) {
1478
+ return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
1479
+ }
1480
+ if (phase === "implementation") {
1481
+ return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
1482
+ }
1483
+ if (phase === "self_review") {
1484
+ 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.";
1485
+ }
1486
+ if (phase === "qa") {
1487
+ 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.";
1488
+ }
1489
+ if (phase === "architect") {
1490
+ 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.";
1491
+ }
1492
+ return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
1493
+ }
1494
+ function toolInputSchema(phase, qaGate = null) {
1495
+ const baseProperties = {
1496
+ status: {
1497
+ type: "string",
1498
+ enum: ["pass", "needs_rework", "fail", "failed"],
1499
+ description: "Use pass only when the phase evidence satisfies Forge's gate. Use needs_rework/fail/failed with explicit blockers or findings otherwise."
358
1500
  },
359
- required: ["status", "summary"]
1501
+ summary: { type: "string", description: "Concise phase summary." },
1502
+ report_markdown: { type: "string", description: "Optional human-readable report. Forge will generate a fallback report if omitted." },
1503
+ blockers: {
1504
+ type: "array",
1505
+ description: "Required for non-pass status unless findings, failures, known_gaps, remaining_risks, or commands provide explicit evidence.",
1506
+ items: evidenceObjectSchema(["reason"], {
1507
+ reason: { type: "string" },
1508
+ owner: { type: "string" },
1509
+ next_step: { type: "string" }
1510
+ })
1511
+ }
360
1512
  };
1513
+ if (qaGate && qaGate !== "qa_verdict") {
1514
+ return qaGateToolInputSchema(qaGate);
1515
+ }
1516
+ if (isDebugCompletionPhase(phase)) {
1517
+ return debugToolInputSchema(phase, baseProperties);
1518
+ }
361
1519
  if (phase === "implementation") {
362
- return {
363
- ...base,
364
- properties: {
365
- ...base.properties,
366
- plan: { type: "array" },
367
- changed_files: { type: "array" },
368
- tests_added_or_updated: { type: "array" },
369
- docs_added_or_updated: { type: "array" },
370
- verification: { type: "array" },
371
- acceptance_criteria: { type: "array" },
372
- known_gaps: { type: "array" },
373
- next_phase_context: {}
374
- },
375
- required: ["status", "summary"]
376
- };
1520
+ return objectToolSchema({
1521
+ ...baseProperties,
1522
+ plan: {
1523
+ type: "array",
1524
+ description: "Required for pass. Ordered implementation steps with status.",
1525
+ items: evidenceObjectSchema(["step", "status"], {
1526
+ step: { type: "string" },
1527
+ status: { type: "string" },
1528
+ evidence: { type: "string" }
1529
+ })
1530
+ },
1531
+ changed_files: {
1532
+ type: "array",
1533
+ description: "Required for pass. Must be non-empty unless known_gaps explains why.",
1534
+ items: evidenceObjectSchema(["path", "summary"], {
1535
+ path: { type: "string" },
1536
+ summary: { type: "string" }
1537
+ })
1538
+ },
1539
+ tests_added_or_updated: {
1540
+ type: "array",
1541
+ description: "Required for pass. Use an object explaining why no tests were needed for documentation/config-only work if empty would be ambiguous.",
1542
+ items: evidenceObjectSchema(["path", "summary"], {
1543
+ path: { type: "string" },
1544
+ summary: { type: "string" }
1545
+ })
1546
+ },
1547
+ docs_added_or_updated: {
1548
+ type: "array",
1549
+ description: "Required for pass. Use an object explaining why no docs changed when appropriate.",
1550
+ items: evidenceObjectSchema(["path", "summary"], {
1551
+ path: { type: "string" },
1552
+ summary: { type: "string" }
1553
+ })
1554
+ },
1555
+ verification: {
1556
+ type: "array",
1557
+ description: "Implementation verification evidence. Objects should include command, exit_code, status, and summary when command-based.",
1558
+ items: commandEvidenceSchema()
1559
+ },
1560
+ acceptance_criteria: acceptanceCriteriaSchema(),
1561
+ known_gaps: {
1562
+ type: "array",
1563
+ description: "Required for pass, even when empty. If changed_files/tests/docs arrays are empty, explain why here.",
1564
+ items: evidenceObjectSchema(["summary"], {
1565
+ summary: { type: "string" },
1566
+ impact: { type: "string" },
1567
+ follow_up: { type: "string" }
1568
+ })
1569
+ },
1570
+ next_phase_context: {
1571
+ description: "Required for pass. Context the self-review phase should carry forward."
1572
+ },
1573
+ resolved_findings: {
1574
+ type: "array",
1575
+ description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
1576
+ items: resolvedFindingSchema()
1577
+ }
1578
+ }, ["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.");
377
1579
  }
378
1580
  if (phase === "self_review") {
379
- return {
380
- ...base,
381
- properties: {
382
- ...base.properties,
383
- reviewed_diff: { type: "boolean" },
384
- findings: { type: "array" },
385
- security_review: { type: "object" },
386
- edge_case_review: { type: "object" },
387
- design_review: { type: "object" },
388
- deterministic_comparison: { type: "object" },
389
- fixes_applied: { type: "array" },
390
- remaining_risks: { type: "array" }
391
- },
392
- required: ["status", "summary"]
393
- };
1581
+ return objectToolSchema({
1582
+ ...baseProperties,
1583
+ reviewed_diff: { type: "boolean", description: "Required for pass. True after reviewing the implementation diff." },
1584
+ findings: {
1585
+ type: "array",
1586
+ description: "Required for pass, even when empty. Include finding objects for issues discovered during review.",
1587
+ items: evidenceObjectSchema(["severity", "summary", "status"], {
1588
+ severity: { type: "string" },
1589
+ summary: { type: "string" },
1590
+ status: { type: "string" },
1591
+ file: { type: "string" }
1592
+ })
1593
+ },
1594
+ security_review: reviewSectionSchema(),
1595
+ edge_case_review: reviewSectionSchema(),
1596
+ design_review: {
1597
+ ...reviewSectionSchema(),
1598
+ properties: {
1599
+ ...reviewSectionProperties(),
1600
+ screenshots: { type: "array", items: evidenceObjectSchema(["path"], { path: { type: "string" }, summary: { type: "string" } }) }
1601
+ }
1602
+ },
1603
+ deterministic_comparison: {
1604
+ type: "object",
1605
+ description: "Required for pass. Evidence that the reviewed diff matches the intended deterministic change.",
1606
+ additionalProperties: true,
1607
+ properties: {
1608
+ status: { type: "string" },
1609
+ command_or_tool: { type: "string" },
1610
+ notes: { type: "string" }
1611
+ }
1612
+ },
1613
+ fixes_applied: {
1614
+ type: "array",
1615
+ description: "Required for pass, even when empty.",
1616
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, path: { type: "string" } })
1617
+ },
1618
+ remaining_risks: {
1619
+ type: "array",
1620
+ description: "Required for pass, even when empty.",
1621
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
1622
+ }
1623
+ }, ["status", "summary"], "For status pass, include reviewed_diff, findings, security_review, edge_case_review, design_review, deterministic_comparison, fixes_applied, and remaining_risks.");
1624
+ }
1625
+ if (phase === "qa") {
1626
+ return objectToolSchema({
1627
+ ...baseProperties,
1628
+ status: {
1629
+ type: "string",
1630
+ enum: ["pass", "needs_rework", "blocked"],
1631
+ description: "Use pass only when QA evidence satisfies acceptance criteria, needs_rework for actionable in-scope defects, or blocked for an inconclusive QA blocker."
1632
+ },
1633
+ checklist: {
1634
+ type: "array",
1635
+ description: "QA plan/checklist. Forge writes this to qa.plan.json; a default checklist is generated if omitted.",
1636
+ items: { type: "string" }
1637
+ },
1638
+ tested_acceptance_criteria: {
1639
+ type: "array",
1640
+ description: "Required and non-empty for pass/needs_rework. Criteria actually tested by QA.",
1641
+ items: { type: "string" }
1642
+ },
1643
+ commands: {
1644
+ type: "array",
1645
+ description: "Command evidence written to qa.evidence.json.",
1646
+ items: commandEvidenceSchema()
1647
+ },
1648
+ browser_checks: {
1649
+ type: "array",
1650
+ description: "Browser/manual UI checks with URL or route, action, observed result, screenshot/trace references, console/network notes, and status.",
1651
+ items: evidenceObjectSchema(["summary", "status"], {
1652
+ summary: { type: "string" },
1653
+ url: { type: "string" },
1654
+ route: { type: "string" },
1655
+ status: { type: "string" },
1656
+ screenshot: { type: "string" },
1657
+ trace: { type: "string" }
1658
+ })
1659
+ },
1660
+ sidecars: {
1661
+ type: "array",
1662
+ description: "Optional QA sidecar files already written under .forge/work-items/<id>/artifacts/qa/ with path/content_hash/bytes metadata.",
1663
+ items: evidenceObjectSchema(["path", "content_hash", "bytes"], {
1664
+ path: { type: "string" },
1665
+ content_hash: { type: "string" },
1666
+ bytes: { type: "number" }
1667
+ })
1668
+ },
1669
+ notes: { type: "string", description: "Additional QA evidence notes." },
1670
+ evidence: {
1671
+ type: "object",
1672
+ description: "Optional wrapper containing commands, browser_checks, and notes when a client prefers nested evidence."
1673
+ },
1674
+ findings: {
1675
+ type: "array",
1676
+ description: "QA findings. needs_rework requires at least one actionable in_scope finding.",
1677
+ items: evidenceObjectSchema(["id", "severity", "scope", "title"], {
1678
+ id: { type: "string" },
1679
+ severity: { type: "string", enum: ["critical", "major", "minor"] },
1680
+ scope: { type: "string", enum: ["in_scope", "out_of_scope", "unclear"] },
1681
+ title: { type: "string" },
1682
+ acceptance_criteria: { type: "array", items: { type: "string" } },
1683
+ repro_steps: { type: "array", items: { type: "string" } },
1684
+ expected: { type: "string" },
1685
+ actual: { type: "string" },
1686
+ evidence: { type: "object" },
1687
+ likely_area: { type: "array", items: { type: "string" } },
1688
+ required_fix: { type: "string" },
1689
+ required_regression: { type: "string" }
1690
+ })
1691
+ },
1692
+ candidate_bugs: {
1693
+ type: "array",
1694
+ description: "Out-of-scope/follow-up bug candidates. They do not block the current item unless attached as in-scope findings.",
1695
+ items: evidenceObjectSchema(["id", "title", "suggested_action"], {
1696
+ id: { type: "string" },
1697
+ title: { type: "string" },
1698
+ severity: { type: "string" },
1699
+ scope_reason: { type: "string" },
1700
+ repro_steps: { type: "array", items: { type: "string" } },
1701
+ evidence: { type: "object" },
1702
+ suggested_action: { type: "string", enum: ["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"] }
1703
+ })
1704
+ },
1705
+ blocker_reason: {
1706
+ type: "string",
1707
+ enum: [...qaBlockerReasons],
1708
+ description: "Required when status is blocked."
1709
+ }
1710
+ }, ["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.");
1711
+ }
1712
+ if (phase === "architect") {
1713
+ return objectToolSchema({
1714
+ status: {
1715
+ type: "string",
1716
+ enum: ["pass", "needs_revision", "blocked"],
1717
+ 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."
1718
+ },
1719
+ summary: { type: "string", description: "Concise Architect plan verdict summary." },
1720
+ implementation_plan: {
1721
+ type: "object",
1722
+ description: "Required for pass. Top-level fields inside this object must include schema_version, plan_id, phases, tasks, dependencies, and phase_gates.",
1723
+ additionalProperties: true,
1724
+ properties: {
1725
+ schema_version: { type: "number", enum: [1] },
1726
+ plan_id: { type: "string" },
1727
+ phases: {
1728
+ type: "array",
1729
+ description: "Required top-level implementation_plan.phases array.",
1730
+ items: { type: "object", additionalProperties: true }
1731
+ },
1732
+ tasks: {
1733
+ type: "array",
1734
+ 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.",
1735
+ items: { type: "object", additionalProperties: true }
1736
+ },
1737
+ dependencies: {
1738
+ type: "array",
1739
+ 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.",
1740
+ items: {
1741
+ type: "object",
1742
+ additionalProperties: true,
1743
+ properties: {
1744
+ id: { type: "string" },
1745
+ before: { type: "string", description: "Task id that must complete first." },
1746
+ after: { type: "string", description: "Task id that depends on before." },
1747
+ from: { type: "string", description: "Alias for before." },
1748
+ to: { type: "string", description: "Alias for after." },
1749
+ type: { type: "string" },
1750
+ rationale: { type: "string" }
1751
+ }
1752
+ }
1753
+ },
1754
+ phase_gates: {
1755
+ type: "array",
1756
+ description: "Required non-empty phase gate array.",
1757
+ items: { type: "object", additionalProperties: true }
1758
+ }
1759
+ }
1760
+ },
1761
+ qa_plan: {
1762
+ type: "object",
1763
+ description: "Required for pass. Must include schema_version: 1 and non-empty final_qa coverage.",
1764
+ additionalProperties: true,
1765
+ properties: {
1766
+ schema_version: { type: "number", enum: [1] },
1767
+ final_qa: {
1768
+ type: "array",
1769
+ description: "Required non-empty final QA coverage array.",
1770
+ items: { type: "object", additionalProperties: true }
1771
+ }
1772
+ }
1773
+ },
1774
+ report_markdown: { type: "string", description: "Human-readable Architect report. Use report as an alias only when the client cannot send report_markdown." },
1775
+ report: { type: "string", description: "Alias for report_markdown." },
1776
+ blockers: baseProperties.blockers,
1777
+ revision_questions: {
1778
+ type: "array",
1779
+ description: "Required for needs_revision when blockers are not used.",
1780
+ items: { type: "string" }
1781
+ },
1782
+ revision_reasons: {
1783
+ type: "array",
1784
+ description: "Concrete reasons that prevent pass status.",
1785
+ items: { type: "string" }
1786
+ }
1787
+ }, ["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.");
1788
+ }
1789
+ return objectToolSchema({
1790
+ ...baseProperties,
1791
+ commands: {
1792
+ type: "array",
1793
+ description: "Required for pass. Include at least one deterministic validation command with exit_code: 0 and status: pass.",
1794
+ items: commandEvidenceSchema()
1795
+ },
1796
+ acceptance_criteria: acceptanceCriteriaSchema(),
1797
+ build_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1798
+ test_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1799
+ lint_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1800
+ design_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1801
+ drift_checks: {
1802
+ type: "array",
1803
+ description: "Required for pass. Deterministic checks for unintended drift.",
1804
+ items: evidenceObjectSchema(["command_or_tool", "status", "summary"], {
1805
+ command_or_tool: { type: "string" },
1806
+ status: { type: "string" },
1807
+ summary: { type: "string" }
1808
+ })
1809
+ },
1810
+ ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
1811
+ }, ["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.");
1812
+ }
1813
+ function debugToolInputSchema(phase, baseProperties) {
1814
+ if (phase === "debug_repro") {
1815
+ return objectToolSchema({
1816
+ ...baseProperties,
1817
+ repro_steps: {
1818
+ type: "array",
1819
+ description: "Required for pass. Minimal reproduction or characterization steps.",
1820
+ items: { type: "string" }
1821
+ },
1822
+ observed: { type: "string", description: "Observed failure behavior." },
1823
+ expected: { type: "string", description: "Expected behavior." },
1824
+ evidence: {
1825
+ type: "array",
1826
+ description: "Required for pass. Concrete logs, commands, screenshots, file refs, or characterization notes.",
1827
+ items: { type: "object", additionalProperties: true }
1828
+ }
1829
+ }, ["status", "summary"], "For status pass, include repro_steps, observed, expected, and evidence.");
1830
+ }
1831
+ if (phase === "debug_root_cause") {
1832
+ return objectToolSchema({
1833
+ ...baseProperties,
1834
+ root_cause: { type: "string", description: "Likely root cause tied to code or config evidence." },
1835
+ evidence: {
1836
+ type: "array",
1837
+ description: "Required for pass. Concrete code/log/test evidence supporting the root cause.",
1838
+ items: { type: "object", additionalProperties: true }
1839
+ },
1840
+ eliminated_alternatives: {
1841
+ type: "array",
1842
+ description: "Required for pass. Plausible alternatives checked or ruled out.",
1843
+ items: { type: "object", additionalProperties: true }
1844
+ },
1845
+ risk_area: { type: "string", description: "Subsystem or behavior most at risk from the fix." }
1846
+ }, ["status", "summary"], "For status pass, include root_cause, evidence, eliminated_alternatives, and risk_area.");
1847
+ }
1848
+ if (phase === "debug_fix") {
1849
+ return objectToolSchema({
1850
+ ...baseProperties,
1851
+ changed_files: {
1852
+ type: "array",
1853
+ description: "Required for pass. Files changed by the bug fix.",
1854
+ items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
1855
+ },
1856
+ tests_added_or_updated: {
1857
+ type: "array",
1858
+ description: "Required for pass. Regression tests or checks added/updated, or justify omission in remaining_risks.",
1859
+ items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
1860
+ },
1861
+ remaining_risks: {
1862
+ type: "array",
1863
+ description: "Required for pass, even when empty. Include why no regression coverage changed when tests_added_or_updated is empty.",
1864
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
1865
+ },
1866
+ resolved_findings: {
1867
+ type: "array",
1868
+ description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
1869
+ items: resolvedFindingSchema()
1870
+ }
1871
+ }, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
1872
+ }
1873
+ return objectToolSchema({
1874
+ ...baseProperties,
1875
+ commands: {
1876
+ type: "array",
1877
+ description: "Required for pass. Targeted regression/repro-negative commands with exit_code: 0 and status: pass.",
1878
+ items: commandEvidenceSchema()
1879
+ },
1880
+ required_regressions: {
1881
+ type: "array",
1882
+ description: "Required for pass. Repro-negative and regression coverage checklist.",
1883
+ items: { type: "string" }
1884
+ },
1885
+ build_status: { type: "string", description: "Required for pass. pass or not_applicable." },
1886
+ test_status: { type: "string", description: "Required for pass. pass or not_applicable." },
1887
+ repro_negative: { type: "boolean", description: "Required for pass and must be true after the original repro no longer fails." },
1888
+ qa_ready: { type: "boolean", description: "Required for pass and must be true." }
1889
+ }, ["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.");
1890
+ }
1891
+ function qaGateToolInputSchema(gate) {
1892
+ const baseProperties = {
1893
+ status: {
1894
+ type: "string",
1895
+ enum: ["pass", "skipped", "blocked"],
1896
+ description: "Use pass when this QA gate produced evidence, skipped when not applicable, or blocked when a prerequisite prevents this gate."
1897
+ },
1898
+ summary: { type: "string", description: "Concise QA gate summary." },
1899
+ reason: { type: "string", description: "Required for skipped or blocked gates." },
1900
+ blocker_reason: {
1901
+ type: "string",
1902
+ enum: [...qaBlockerReasons],
1903
+ description: "Required when status is blocked."
1904
+ }
1905
+ };
1906
+ if (gate === "qa_plan") {
1907
+ return objectToolSchema({
1908
+ ...baseProperties,
1909
+ checklist: {
1910
+ type: "array",
1911
+ description: "Required for pass. QA checklist entries planned for this work item.",
1912
+ items: { type: "string" }
1913
+ },
1914
+ acceptance_criteria: {
1915
+ type: "array",
1916
+ description: "Acceptance criteria selected for QA.",
1917
+ items: { type: "string" }
1918
+ }
1919
+ }, ["status", "summary"], "Record the QA plan gate. Pass requires a non-empty checklist.");
394
1920
  }
1921
+ if (gate === "qa_findings") {
1922
+ return objectToolSchema({
1923
+ ...baseProperties,
1924
+ findings: {
1925
+ type: "array",
1926
+ description: "Normalized in-scope and out-of-scope QA findings.",
1927
+ items: { type: "object", additionalProperties: true }
1928
+ },
1929
+ candidate_bugs: {
1930
+ type: "array",
1931
+ description: "Out-of-scope or follow-up candidates.",
1932
+ items: { type: "object", additionalProperties: true }
1933
+ }
1934
+ }, ["status", "summary"], "Record normalized QA findings. Pass or skipped requires findings and candidate_bugs arrays, even when empty.");
1935
+ }
1936
+ return objectToolSchema({
1937
+ ...baseProperties,
1938
+ commands: {
1939
+ type: "array",
1940
+ description: "Command evidence for this gate when applicable.",
1941
+ items: commandEvidenceSchema()
1942
+ },
1943
+ browser_checks: {
1944
+ type: "array",
1945
+ description: "Browser/manual evidence for this gate when applicable.",
1946
+ items: { type: "object", additionalProperties: true }
1947
+ },
1948
+ notes: { type: "string", description: "Evidence notes for this gate." }
1949
+ }, ["status", "summary"], "Record this QA evidence gate. Use skipped with a reason when the gate is not applicable.");
1950
+ }
1951
+ function objectToolSchema(properties, required, description) {
1952
+ return {
1953
+ type: "object",
1954
+ additionalProperties: true,
1955
+ description,
1956
+ properties,
1957
+ required: [...required]
1958
+ };
1959
+ }
1960
+ function evidenceObjectSchema(required, properties) {
395
1961
  return {
396
- ...base,
397
- properties: {
398
- ...base.properties,
399
- commands: { type: "array" },
400
- acceptance_criteria: { type: "array" },
401
- build_status: { type: "string" },
402
- test_status: { type: "string" },
403
- lint_status: { type: "string" },
404
- design_status: { type: "string" },
405
- drift_checks: { type: "array" },
406
- ready_for_dev_test: { type: "boolean" }
1962
+ type: "object",
1963
+ additionalProperties: true,
1964
+ properties,
1965
+ required: [...required]
1966
+ };
1967
+ }
1968
+ function commandEvidenceSchema() {
1969
+ return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
1970
+ command: { type: "string" },
1971
+ exit_code: { type: "number" },
1972
+ status: { type: "string" },
1973
+ summary: { type: "string" }
1974
+ });
1975
+ }
1976
+ function resolvedFindingSchema() {
1977
+ return evidenceObjectSchema([
1978
+ "qa_id",
1979
+ "status",
1980
+ "summary",
1981
+ "evidence",
1982
+ "changed_files",
1983
+ "tests_added_or_updated",
1984
+ "regression_evidence"
1985
+ ], {
1986
+ qa_id: { type: "string" },
1987
+ status: {
1988
+ type: "string",
1989
+ enum: [...resolvedFindingStatusValues],
1990
+ description: "Use resolved when fixed, disputed when the QA finding is incorrect/out-of-scope, or not_reproduced when retesting cannot reproduce it."
407
1991
  },
408
- required: ["status", "summary"]
1992
+ summary: { type: "string" },
1993
+ evidence: { description: "Concrete evidence for the resolution." },
1994
+ changed_files: { type: "array", description: "Required and non-empty when status is resolved." },
1995
+ tests_added_or_updated: { type: "array", description: "Required and non-empty when status is resolved." },
1996
+ regression_evidence: { description: "Concrete regression evidence for this QA finding." }
1997
+ });
1998
+ }
1999
+ function acceptanceCriteriaSchema() {
2000
+ return {
2001
+ type: "array",
2002
+ description: "Required for pass. Every entry must be an object with status: satisfied and non-empty evidence.",
2003
+ items: evidenceObjectSchema(["criterion", "evidence", "status"], {
2004
+ criterion: { type: "string" },
2005
+ evidence: { type: "string" },
2006
+ status: { type: "string", enum: ["satisfied"] }
2007
+ })
2008
+ };
2009
+ }
2010
+ function reviewSectionSchema() {
2011
+ return {
2012
+ type: "object",
2013
+ description: "Required for pass. Use status pass/fail/needs_rework/not_applicable plus notes.",
2014
+ additionalProperties: true,
2015
+ properties: reviewSectionProperties()
2016
+ };
2017
+ }
2018
+ function reviewSectionProperties() {
2019
+ return {
2020
+ status: { type: "string" },
2021
+ notes: { type: "string" }
409
2022
  };
410
2023
  }
2024
+ function normalizeCompletionStatus(value, phase) {
2025
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
2026
+ if (phase === "qa") {
2027
+ const canonical = normalized === "needsrework" ? "needs_rework" : normalized;
2028
+ if (canonical !== "pass" && canonical !== "needs_rework" && canonical !== "blocked") {
2029
+ throw new Error("QA status must be pass, needs_rework, or blocked.");
2030
+ }
2031
+ return canonical;
2032
+ }
2033
+ if (phase === "architect") {
2034
+ const canonical = normalized === "needsrevision" ? "needs_revision" : normalized;
2035
+ if (canonical !== "pass" && canonical !== "needs_revision" && canonical !== "blocked") {
2036
+ throw new Error("Architect status must be pass, needs_revision, or blocked.");
2037
+ }
2038
+ return canonical;
2039
+ }
2040
+ const canonical = normalized === "blocked" || normalized === "needsrework"
2041
+ ? "needs_rework"
2042
+ : normalized === "failed"
2043
+ ? "fail"
2044
+ : normalized;
2045
+ if (canonical !== "pass" && canonical !== "fail" && canonical !== "needs_rework") {
2046
+ throw new Error("status must be pass, fail, or needs_rework.");
2047
+ }
2048
+ return canonical;
2049
+ }
2050
+ function normalizeQaGateStatus(value) {
2051
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
2052
+ if (normalized !== "pass" && normalized !== "skipped" && normalized !== "blocked") {
2053
+ throw new Error("QA gate status must be pass, skipped, or blocked.");
2054
+ }
2055
+ return normalized;
2056
+ }
411
2057
  function validatePayloadShape(phase, payload) {
412
- const status = requiredString(payload, "status").trim().toLowerCase();
2058
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
413
2059
  if (!status) {
414
2060
  throw new Error("status is required.");
415
2061
  }
416
- const pass = status === "pass";
417
2062
  requireField(payload, "summary");
2063
+ if (phase === "qa") {
2064
+ validateQaPayloadShape(payload, status);
2065
+ return;
2066
+ }
2067
+ if (phase === "architect") {
2068
+ validateArchitectPayloadShape(payload, status);
2069
+ return;
2070
+ }
2071
+ const pass = status === "pass";
418
2072
  if (!pass) {
419
2073
  requireNonPassEvidence(payload);
420
2074
  return;
421
2075
  }
2076
+ if (isDebugCompletionPhase(phase)) {
2077
+ validateDebugPayloadShape(phase, payload);
2078
+ return;
2079
+ }
422
2080
  if (phase === "implementation") {
423
2081
  requireArray(payload, "plan");
424
2082
  requireArray(payload, "changed_files");
@@ -429,6 +2087,7 @@ function validatePayloadShape(phase, payload) {
429
2087
  requireField(payload, "next_phase_context");
430
2088
  requireImplementationEvidence(payload);
431
2089
  requireSatisfiedAcceptanceCriteria(payload);
2090
+ validateResolvedFindings(payload);
432
2091
  return;
433
2092
  }
434
2093
  if (phase === "self_review") {
@@ -456,6 +2115,504 @@ function validatePayloadShape(phase, payload) {
456
2115
  requirePassingCommands(payload);
457
2116
  requireSatisfiedAcceptanceCriteria(payload);
458
2117
  }
2118
+ function validateDebugPayloadShape(phase, payload) {
2119
+ if (phase === "debug_repro") {
2120
+ requireArray(payload, "repro_steps");
2121
+ requireField(payload, "observed");
2122
+ requireField(payload, "expected");
2123
+ requireArray(payload, "evidence");
2124
+ requireNonEmptyArray(payload, "repro_steps");
2125
+ requireNonEmptyArray(payload, "evidence");
2126
+ return;
2127
+ }
2128
+ if (phase === "debug_root_cause") {
2129
+ requireField(payload, "root_cause");
2130
+ requireArray(payload, "evidence");
2131
+ requireArray(payload, "eliminated_alternatives");
2132
+ requireField(payload, "risk_area");
2133
+ requireNonEmptyArray(payload, "evidence");
2134
+ return;
2135
+ }
2136
+ if (phase === "debug_fix") {
2137
+ requireArray(payload, "changed_files");
2138
+ requireArray(payload, "tests_added_or_updated");
2139
+ requireArray(payload, "remaining_risks");
2140
+ requireNonEmptyArray(payload, "changed_files");
2141
+ if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
2142
+ throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
2143
+ }
2144
+ validateResolvedFindings(payload);
2145
+ return;
2146
+ }
2147
+ requireArray(payload, "commands");
2148
+ requireArray(payload, "required_regressions");
2149
+ requireField(payload, "build_status");
2150
+ requireField(payload, "test_status");
2151
+ requireBoolean(payload, "repro_negative");
2152
+ requireBoolean(payload, "qa_ready");
2153
+ if (payload.repro_negative !== true) {
2154
+ throw new Error("debug_regression pass requires repro_negative: true.");
2155
+ }
2156
+ if (payload.qa_ready !== true) {
2157
+ throw new Error("debug_regression pass requires qa_ready: true.");
2158
+ }
2159
+ requireAllowedStatus(payload, "build_status", ["pass", "not_applicable"]);
2160
+ requireAllowedStatus(payload, "test_status", ["pass", "not_applicable"]);
2161
+ requireNonEmptyArray(payload, "required_regressions");
2162
+ requirePassingCommands(payload);
2163
+ }
2164
+ function validateQaGatePayloadShape(gate, payload, status) {
2165
+ requireField(payload, "summary");
2166
+ if (status === "skipped") {
2167
+ requireField(payload, "reason");
2168
+ }
2169
+ if (status === "blocked") {
2170
+ const reason = normalizedString(optionalString(payload, "blocker_reason"));
2171
+ if (!qaBlockerReasons.has(reason)) {
2172
+ 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.");
2173
+ }
2174
+ }
2175
+ if (gate === "qa_plan" && status === "pass") {
2176
+ const checklist = qaStringArray(payload, "checklist");
2177
+ if (checklist.length === 0) {
2178
+ throw new Error("complete_qa_plan pass requires checklist with at least one entry.");
2179
+ }
2180
+ }
2181
+ if (gate === "qa_findings" && status !== "blocked") {
2182
+ qaObjectArray(payload, "findings");
2183
+ qaObjectArray(payload, "candidate_bugs");
2184
+ }
2185
+ if ((gate === "qa_regression" || gate === "qa_api") && status === "pass") {
2186
+ const commands = qaArray(payload, "commands");
2187
+ if (commands.length === 0) {
2188
+ throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
2189
+ }
2190
+ validateQaGatePassingCommands(commands);
2191
+ }
2192
+ if (gate === "qa_manual_web" && status === "pass") {
2193
+ const browserChecks = qaArray(payload, "browser_checks");
2194
+ const notes = optionalString(payload, "notes");
2195
+ if (browserChecks.length === 0 && !notes) {
2196
+ throw new Error(`${qaGateToolNames[gate]} pass requires browser_checks or notes.`);
2197
+ }
2198
+ }
2199
+ }
2200
+ function validateQaGatePassingCommands(commands) {
2201
+ for (const [index, command] of commands.entries()) {
2202
+ requireEvidenceRecord(command, `commands[${index}]`);
2203
+ const record = command;
2204
+ requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
2205
+ requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
2206
+ requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
2207
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
2208
+ throw new Error(`commands[${index}].exit_code is required.`);
2209
+ }
2210
+ if (record.exit_code !== 0) {
2211
+ throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
2212
+ }
2213
+ if (normalizedString(record.status) !== "pass") {
2214
+ throw new Error(`commands[${index}].status must be pass when QA gate status is pass.`);
2215
+ }
2216
+ }
2217
+ }
2218
+ function validateArchitectPayloadShape(payload, status) {
2219
+ if (status !== "pass") {
2220
+ requireArchitectNonPassEvidence(payload);
2221
+ return;
2222
+ }
2223
+ const implementationPlan = requiredRecord(payload, "implementation_plan");
2224
+ const qaPlan = requiredRecord(payload, "qa_plan");
2225
+ requireNumericOne(implementationPlan, "schema_version", "implementation_plan.schema_version");
2226
+ requireNonEmptyStringRecord(implementationPlan, "plan_id", "implementation_plan.");
2227
+ requireArchitectArray(implementationPlan, "phases", payload);
2228
+ requireArchitectArray(implementationPlan, "tasks", payload);
2229
+ requireArchitectArray(implementationPlan, "dependencies", payload, { allowEmpty: true });
2230
+ requireArchitectArray(implementationPlan, "phase_gates", payload);
2231
+ if (arrayLength(implementationPlan, "tasks") === 0) {
2232
+ throw new Error("implementation_plan.tasks: expected non-empty array; correction: include at least one task with id and tests.");
2233
+ }
2234
+ const taskIds = new Set();
2235
+ for (const task of implementationPlan.tasks) {
2236
+ if (!task || typeof task !== "object" || Array.isArray(task)) {
2237
+ throw new Error("implementation_plan.tasks[]: expected object entries; correction: each task must be an object with id and tests.");
2238
+ }
2239
+ const taskRecord = task;
2240
+ requireNonEmptyStringRecord(taskRecord, "id", "implementation_plan.tasks[].");
2241
+ const taskId = optionalString(taskRecord, "id") ?? "";
2242
+ if (taskIds.has(taskId)) {
2243
+ throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
2244
+ }
2245
+ taskIds.add(taskId);
2246
+ validateArchitectTaskDevelopScope(taskRecord, taskId);
2247
+ requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
2248
+ if (arrayLength(taskRecord, "tests") === 0) {
2249
+ throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
2250
+ }
2251
+ }
2252
+ validateAndNormalizeArchitectDependencies(implementationPlan, taskIds);
2253
+ requireNumericOne(qaPlan, "schema_version", "qa_plan.schema_version");
2254
+ requireArchitectArray(qaPlan, "final_qa", payload, { path: "qa_plan.final_qa" });
2255
+ if (arrayLength(qaPlan, "final_qa") === 0) {
2256
+ throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
2257
+ }
2258
+ }
2259
+ function validateArchitectTaskDevelopScope(task, taskId) {
2260
+ const explicitScope = firstOptionalString(task, ["execution_scope", "scope", "task_scope", "task_type", "lane", "kind", "type"]);
2261
+ if (isStoryQaScope(explicitScope)) {
2262
+ 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.`);
2263
+ }
2264
+ if (looksLikeStoryLevelVerificationTask(architectTaskScopeText(task))) {
2265
+ 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.`);
2266
+ }
2267
+ }
2268
+ function firstOptionalString(record, keys) {
2269
+ for (const key of keys) {
2270
+ const value = optionalString(record, key);
2271
+ if (value) {
2272
+ return value;
2273
+ }
2274
+ }
2275
+ return null;
2276
+ }
2277
+ function isStoryQaScope(value) {
2278
+ if (!value) {
2279
+ return false;
2280
+ }
2281
+ const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
2282
+ return [
2283
+ "qa",
2284
+ "story_qa",
2285
+ "final_qa",
2286
+ "sign_off",
2287
+ "signoff",
2288
+ "story_sign_off",
2289
+ "story_verification",
2290
+ "story_validation"
2291
+ ].includes(normalized);
2292
+ }
2293
+ function architectTaskScopeText(task) {
2294
+ const parts = [];
2295
+ for (const key of ["id", "title", "summary", "phase", "phase_id"]) {
2296
+ appendOptionalString(parts, task, key);
2297
+ }
2298
+ for (const key of ["acceptance_criteria", "expected_paths", "touched_surfaces", "implementation_notes"]) {
2299
+ appendOptionalStringArray(parts, task, key);
2300
+ }
2301
+ const tests = task.tests;
2302
+ if (Array.isArray(tests)) {
2303
+ for (const test of tests) {
2304
+ if (!test || typeof test !== "object" || Array.isArray(test)) {
2305
+ continue;
2306
+ }
2307
+ const record = test;
2308
+ for (const key of ["id", "command", "purpose", "target", "expected"]) {
2309
+ appendOptionalString(parts, record, key);
2310
+ }
2311
+ appendOptionalStringArray(parts, record, "steps");
2312
+ }
2313
+ }
2314
+ return parts.join(" ");
2315
+ }
2316
+ function appendOptionalString(parts, record, key) {
2317
+ const value = optionalString(record, key);
2318
+ if (value) {
2319
+ parts.push(value);
2320
+ }
2321
+ }
2322
+ function appendOptionalStringArray(parts, record, key) {
2323
+ const value = record[key];
2324
+ if (!Array.isArray(value)) {
2325
+ return;
2326
+ }
2327
+ for (const entry of value) {
2328
+ if (typeof entry === "string" && entry.trim().length > 0) {
2329
+ parts.push(entry.trim());
2330
+ }
2331
+ }
2332
+ }
2333
+ function looksLikeStoryLevelVerificationTask(text) {
2334
+ if (!text.trim()) {
2335
+ return false;
2336
+ }
2337
+ const normalized = text.toLowerCase();
2338
+ const verificationWords = [
2339
+ "verify",
2340
+ "verification",
2341
+ "validate",
2342
+ "validation",
2343
+ "inspect",
2344
+ "audit",
2345
+ "check",
2346
+ "confirm",
2347
+ "prove",
2348
+ "evidence",
2349
+ "sign-off",
2350
+ "sign off"
2351
+ ];
2352
+ if (!verificationWords.some((word) => normalized.includes(word))) {
2353
+ return false;
2354
+ }
2355
+ return [
2356
+ "materialization",
2357
+ "materialized child",
2358
+ "child inventory",
2359
+ "child work-item",
2360
+ "child work item",
2361
+ "created task count",
2362
+ "created_task_count",
2363
+ "parent plan",
2364
+ "parent story",
2365
+ "story sign-off",
2366
+ "story sign off",
2367
+ ".forge/work-items",
2368
+ ".forge\\work-items",
2369
+ ".forge/stories",
2370
+ ".forge\\stories",
2371
+ "implementation.plan.json",
2372
+ "qa.plan.json",
2373
+ "architect plan artifact",
2374
+ "child spec"
2375
+ ].some((needle) => normalized.includes(needle));
2376
+ }
2377
+ function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
2378
+ const dependencies = implementationPlan.dependencies;
2379
+ const edges = new Map();
2380
+ for (const taskId of taskIds) {
2381
+ edges.set(taskId, []);
2382
+ }
2383
+ const normalizedDependencies = [];
2384
+ for (const [index, dependency] of dependencies.entries()) {
2385
+ if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
2386
+ throw new Error("implementation_plan.dependencies[]: expected object entries; correction: each dependency must be an object with before and after task ids.");
2387
+ }
2388
+ const record = dependency;
2389
+ const before = architectDependencyEndpoint(record, index, "before", "from");
2390
+ const after = architectDependencyEndpoint(record, index, "after", "to");
2391
+ if (!taskIds.has(before) || !taskIds.has(after)) {
2392
+ throw new Error("implementation_plan.dependencies: dependency references an unknown task id.");
2393
+ }
2394
+ const normalized = stripUndefined({
2395
+ ...record,
2396
+ before,
2397
+ after,
2398
+ from: undefined,
2399
+ to: undefined
2400
+ });
2401
+ normalizedDependencies.push(normalized);
2402
+ edges.get(before)?.push(after);
2403
+ }
2404
+ implementationPlan.dependencies = normalizedDependencies;
2405
+ if (hasArchitectDependencyCycle(edges)) {
2406
+ throw new Error("implementation_plan.dependencies: expected acyclic graph; correction: remove or revise cyclic task dependencies.");
2407
+ }
2408
+ }
2409
+ function architectDependencyEndpoint(dependency, index, canonicalKey, aliasKey) {
2410
+ const canonical = optionalString(dependency, canonicalKey);
2411
+ const alias = optionalString(dependency, aliasKey);
2412
+ if (canonical && alias && canonical !== alias) {
2413
+ throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: conflicts with ${aliasKey}; correction: use one task id for ${canonicalKey}.`);
2414
+ }
2415
+ const value = canonical ?? alias;
2416
+ if (!value) {
2417
+ throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: expected string; correction: use before and after task ids for each dependency.`);
2418
+ }
2419
+ return value;
2420
+ }
2421
+ function hasArchitectDependencyCycle(edges) {
2422
+ const visiting = new Set();
2423
+ const visited = new Set();
2424
+ const visit = (node) => {
2425
+ if (visited.has(node)) {
2426
+ return false;
2427
+ }
2428
+ if (visiting.has(node)) {
2429
+ return true;
2430
+ }
2431
+ visiting.add(node);
2432
+ for (const next of edges.get(node) ?? []) {
2433
+ if (visit(next)) {
2434
+ return true;
2435
+ }
2436
+ }
2437
+ visiting.delete(node);
2438
+ visited.add(node);
2439
+ return false;
2440
+ };
2441
+ for (const node of edges.keys()) {
2442
+ if (visit(node)) {
2443
+ return true;
2444
+ }
2445
+ }
2446
+ return false;
2447
+ }
2448
+ function requireArchitectNonPassEvidence(payload) {
2449
+ const evidenceKeys = ["blockers", "revision_questions", "revision_reasons", "known_gaps", "findings", "remaining_risks", "failures"];
2450
+ const hasEvidence = evidenceKeys.some((key) => {
2451
+ const value = payload[key];
2452
+ if (typeof value === "string") {
2453
+ return value.trim().length > 0;
2454
+ }
2455
+ if (Array.isArray(value)) {
2456
+ return value.length > 0;
2457
+ }
2458
+ return Boolean(value && typeof value === "object");
2459
+ });
2460
+ if (!hasEvidence) {
2461
+ 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.");
2462
+ }
2463
+ }
2464
+ function requireArchitectArray(source, key, rootPayload, options = {}) {
2465
+ const pathLabel = options.path ?? `implementation_plan.${key}`;
2466
+ if (!Array.isArray(source[key])) {
2467
+ const nestedHint = architectNestedArrayHint(rootPayload, key);
2468
+ 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"}.`}`);
2469
+ }
2470
+ if (!options.allowEmpty && arrayLength(source, key) === 0) {
2471
+ throw new Error(`${pathLabel}: expected non-empty array; correction: include at least one entry.`);
2472
+ }
2473
+ }
2474
+ function architectNestedArrayHint(rootPayload, key) {
2475
+ if (Array.isArray(rootPayload[key])) {
2476
+ return `move top-level ${key} into implementation_plan.${key}.`;
2477
+ }
2478
+ const implementationPlan = rootPayload.implementation_plan;
2479
+ if (!implementationPlan || typeof implementationPlan !== "object" || Array.isArray(implementationPlan)) {
2480
+ return null;
2481
+ }
2482
+ const plan = implementationPlan;
2483
+ for (const container of ["plan", "dag", "implementation_dag", "implementationPlan", "implementation_plan"]) {
2484
+ const nested = plan[container];
2485
+ if (nested && typeof nested === "object" && !Array.isArray(nested) && Array.isArray(nested[key])) {
2486
+ return `move implementation_plan.${container}.${key} to implementation_plan.${key}.`;
2487
+ }
2488
+ }
2489
+ return null;
2490
+ }
2491
+ function validateQaPayloadShape(payload, status) {
2492
+ const evidenceSource = qaEvidenceSource(payload);
2493
+ const commands = qaArray(payload, "commands", evidenceSource);
2494
+ const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
2495
+ const sidecars = qaArray(payload, "sidecars", evidenceSource);
2496
+ const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
2497
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
2498
+ const findings = qaObjectArray(payload, "findings");
2499
+ const candidateBugs = qaObjectArray(payload, "candidate_bugs");
2500
+ const explicitFindingIds = qaStringArray(payload, "finding_ids");
2501
+ const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
2502
+ if (payload.schema_version !== undefined && payload.schema_version !== 1) {
2503
+ throw new Error("QA schema_version must be numeric 1 when provided.");
2504
+ }
2505
+ if (status === "pass" || status === "needs_rework") {
2506
+ if (testedAcceptanceCriteria.length === 0) {
2507
+ throw new Error("QA pass/needs_rework requires tested_acceptance_criteria with at least one criterion.");
2508
+ }
2509
+ if (commands.length === 0 && browserChecks.length === 0 && notes.length === 0) {
2510
+ throw new Error("QA pass/needs_rework requires at least one command, browser check, or evidence note.");
2511
+ }
2512
+ }
2513
+ for (const [index, command] of commands.entries()) {
2514
+ requireEvidenceRecord(command, `commands[${index}]`);
2515
+ const record = command;
2516
+ requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
2517
+ requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
2518
+ requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
2519
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
2520
+ throw new Error(`commands[${index}].exit_code is required.`);
2521
+ }
2522
+ if (status === "pass" && record.exit_code !== 0) {
2523
+ throw new Error(`commands[${index}].exit_code must be 0 when QA status is pass.`);
2524
+ }
2525
+ if (status === "pass" && normalizedString(record.status) !== "pass") {
2526
+ throw new Error(`commands[${index}].status must be pass when QA status is pass.`);
2527
+ }
2528
+ }
2529
+ for (const [index, browserCheck] of browserChecks.entries()) {
2530
+ requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
2531
+ const record = browserCheck;
2532
+ requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
2533
+ requireNonEmptyStringRecord(record, "status", `browser_checks[${index}].`);
2534
+ const checkStatus = optionalString(record, "status");
2535
+ if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
2536
+ throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
2537
+ }
2538
+ if (!optionalString(record, "url") && !optionalString(record, "route")) {
2539
+ throw new Error(`browser_checks[${index}] must include url or route.`);
2540
+ }
2541
+ }
2542
+ for (const [index, sidecar] of sidecars.entries()) {
2543
+ requireEvidenceRecord(sidecar, `sidecars[${index}]`);
2544
+ const record = sidecar;
2545
+ requireNonEmptyStringRecord(record, "path", `sidecars[${index}].`);
2546
+ requireNonEmptyStringRecord(record, "content_hash", `sidecars[${index}].`);
2547
+ if (typeof record.bytes !== "number" || !Number.isFinite(record.bytes) || record.bytes <= 0) {
2548
+ throw new Error(`sidecars[${index}].bytes must be greater than 0.`);
2549
+ }
2550
+ }
2551
+ for (const [index, finding] of findings.entries()) {
2552
+ requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
2553
+ requireNonEmptyStringRecord(finding, "title", `findings[${index}].`);
2554
+ requireKnownStringRecord(finding, "severity", qaFindingSeverities, `findings[${index}].`);
2555
+ requireKnownStringRecord(finding, "scope", qaFindingScopes, `findings[${index}].`);
2556
+ if (normalizedString(finding.scope) === "in_scope") {
2557
+ validateActionableQaFinding(finding, index);
2558
+ }
2559
+ }
2560
+ for (const [index, candidateBug] of candidateBugs.entries()) {
2561
+ requireNonEmptyStringRecord(candidateBug, "id", `candidate_bugs[${index}].`);
2562
+ requireNonEmptyStringRecord(candidateBug, "title", `candidate_bugs[${index}].`);
2563
+ requireKnownStringRecord(candidateBug, "suggested_action", qaCandidateBugActions, `candidate_bugs[${index}].`);
2564
+ }
2565
+ const inScopeFindingIds = findings
2566
+ .filter((finding) => normalizedString(finding.scope) === "in_scope")
2567
+ .map((finding) => optionalString(finding, "id"))
2568
+ .filter((id) => Boolean(id));
2569
+ for (const findingId of explicitFindingIds) {
2570
+ if (!inScopeFindingIds.includes(findingId)) {
2571
+ throw new Error(`QA finding_ids contains '${findingId}', but findings has no matching in-scope finding.`);
2572
+ }
2573
+ }
2574
+ const candidateBugIds = candidateBugs
2575
+ .map((candidateBug) => optionalString(candidateBug, "id"))
2576
+ .filter((id) => Boolean(id));
2577
+ for (const candidateBugId of explicitCandidateBugIds) {
2578
+ if (!candidateBugIds.includes(candidateBugId)) {
2579
+ throw new Error(`QA candidate_bug_ids contains '${candidateBugId}', but candidate_bugs has no matching candidate bug.`);
2580
+ }
2581
+ }
2582
+ if (status === "pass") {
2583
+ if (explicitFindingIds.length > 0 || inScopeFindingIds.length > 0) {
2584
+ throw new Error("QA pass must not include in-scope finding_ids or in-scope findings.");
2585
+ }
2586
+ if (optionalString(payload, "blocker_reason")) {
2587
+ throw new Error("QA pass must not include blocker_reason.");
2588
+ }
2589
+ }
2590
+ if (status === "needs_rework") {
2591
+ if (inScopeFindingIds.length === 0) {
2592
+ throw new Error("QA needs_rework requires at least one actionable in-scope finding.");
2593
+ }
2594
+ if (optionalString(payload, "blocker_reason")) {
2595
+ throw new Error("QA needs_rework must not include blocker_reason.");
2596
+ }
2597
+ }
2598
+ if (status === "blocked") {
2599
+ const reason = normalizedString(optionalString(payload, "blocker_reason"));
2600
+ if (!qaBlockerReasons.has(reason)) {
2601
+ 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.");
2602
+ }
2603
+ }
2604
+ }
2605
+ function validateActionableQaFinding(finding, index) {
2606
+ const prefix = `findings[${index}].`;
2607
+ requireNonEmptyStringArrayRecord(finding, "acceptance_criteria", prefix);
2608
+ requireNonEmptyStringArrayRecord(finding, "repro_steps", prefix);
2609
+ requireNonEmptyStringRecord(finding, "expected", prefix);
2610
+ requireNonEmptyStringRecord(finding, "actual", prefix);
2611
+ requireSubstantiveEvidenceRecord(finding, prefix);
2612
+ requireNonEmptyStringArrayRecord(finding, "likely_area", prefix);
2613
+ requireNonEmptyStringRecord(finding, "required_fix", prefix);
2614
+ requireNonEmptyStringRecord(finding, "required_regression", prefix);
2615
+ }
459
2616
  function validateOptionalWorkItemId(payload, expectedWorkItemId) {
460
2617
  const actual = optionalString(payload, "work_item_id") ?? optionalString(payload, "workItemId");
461
2618
  if (actual && actual !== expectedWorkItemId) {
@@ -470,10 +2627,36 @@ function normalizeArtifactPayload(payload, defaults) {
470
2627
  normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
471
2628
  normalized.phase = defaults.phase;
472
2629
  normalized.work_item_id = defaults.workItemId;
2630
+ const ownerFields = artifactOwnerFields(defaults);
2631
+ Object.assign(normalized, ownerFields);
473
2632
  normalized.status = defaults.status;
474
2633
  normalized.produced_by = defaults.producedBy;
475
2634
  return normalized;
476
2635
  }
2636
+ function normalizeQaGateArtifactPayload(payload, defaults) {
2637
+ const normalized = stripUndefined({ ...payload });
2638
+ delete normalized.report_markdown;
2639
+ delete normalized.content_hash;
2640
+ delete normalized.contentHash;
2641
+ normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
2642
+ normalized.phase = "qa";
2643
+ normalized.gate = defaults.gate;
2644
+ normalized.work_item_id = defaults.workItemId;
2645
+ const ownerFields = artifactOwnerFields(defaults);
2646
+ Object.assign(normalized, ownerFields);
2647
+ normalized.status = defaults.status;
2648
+ normalized.produced_by = defaults.producedBy;
2649
+ if (defaults.gate === "qa_plan") {
2650
+ const checklist = qaStringArray(normalized, "checklist");
2651
+ normalized.checklist = checklist.length > 0 ? checklist : defaultQaChecklist(defaults.status);
2652
+ normalized.acceptance_criteria = qaStringArray(normalized, "acceptance_criteria");
2653
+ }
2654
+ if (defaults.gate === "qa_findings") {
2655
+ normalized.findings = qaObjectArray(normalized, "findings");
2656
+ normalized.candidate_bugs = qaObjectArray(normalized, "candidate_bugs");
2657
+ }
2658
+ return normalized;
2659
+ }
477
2660
  function reportMarkdown(payload, phase, status, producedBy, contentHash) {
478
2661
  const explicit = optionalString(payload, "report_markdown");
479
2662
  if (explicit) {
@@ -493,6 +2676,10 @@ ${summary}
493
2676
  }
494
2677
  function parseStrictCompletionPhase(value) {
495
2678
  const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
2679
+ const debugPhase = normalizeDebugCompletionPhase(normalized);
2680
+ if (debugPhase) {
2681
+ return debugPhase;
2682
+ }
496
2683
  if (normalized === "implementation" || normalized === "implement" || normalized === "develop") {
497
2684
  return "implementation";
498
2685
  }
@@ -502,8 +2689,69 @@ function parseStrictCompletionPhase(value) {
502
2689
  if (normalized === "validate" || normalized === "validation") {
503
2690
  return "validation";
504
2691
  }
2692
+ if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
2693
+ return "qa";
2694
+ }
2695
+ if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
2696
+ return "architect";
2697
+ }
2698
+ if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
2699
+ return "draft";
2700
+ }
505
2701
  return null;
506
2702
  }
2703
+ function normalizeDebugCompletionPhase(value) {
2704
+ const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
2705
+ switch (normalized) {
2706
+ case "debug_repro":
2707
+ case "debugrepro":
2708
+ case "repro":
2709
+ case "reproduce":
2710
+ return "debug_repro";
2711
+ case "debug_root_cause":
2712
+ case "debugrootcause":
2713
+ case "root_cause":
2714
+ case "rootcause":
2715
+ return "debug_root_cause";
2716
+ case "debug_fix":
2717
+ case "debugfix":
2718
+ case "fix":
2719
+ return "debug_fix";
2720
+ case "debug_regression":
2721
+ case "debugregression":
2722
+ case "regression":
2723
+ return "debug_regression";
2724
+ default:
2725
+ return null;
2726
+ }
2727
+ }
2728
+ function isDevelopCompletionPhase(phase) {
2729
+ return phase === "implementation" || phase === "self_review" || phase === "validation";
2730
+ }
2731
+ function isDebugCompletionPhase(phase) {
2732
+ return debugCompletionPhaseOrder.includes(phase);
2733
+ }
2734
+ function asDebugCompletionPhase(phase) {
2735
+ if (isDebugCompletionPhase(phase)) {
2736
+ return phase;
2737
+ }
2738
+ throw new Error(`Expected Debug completion phase, got ${phase}.`);
2739
+ }
2740
+ function parseCompletionRedactionSecrets(raw) {
2741
+ if (!raw?.trim()) {
2742
+ return [];
2743
+ }
2744
+ try {
2745
+ const parsed = JSON.parse(raw);
2746
+ if (!Array.isArray(parsed)) {
2747
+ return [];
2748
+ }
2749
+ return parsed.filter((value) => typeof value === "string" && value.length > 0);
2750
+ }
2751
+ catch {
2752
+ return [];
2753
+ }
2754
+ }
507
2755
  function phaseFromToolName(toolName) {
508
2756
  const normalized = toolName.trim().toLowerCase();
509
2757
  if (normalized === "forge.complete_implementation" || normalized === "complete_implementation") {
@@ -515,8 +2763,54 @@ function phaseFromToolName(toolName) {
515
2763
  if (normalized === "forge.complete_validation" || normalized === "complete_validation") {
516
2764
  return "validation";
517
2765
  }
2766
+ if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
2767
+ return "qa";
2768
+ }
2769
+ if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
2770
+ return "architect";
2771
+ }
2772
+ if (draftGateFromToolName(toolName)) {
2773
+ return "draft";
2774
+ }
2775
+ const debugGate = debugGateFromToolName(toolName);
2776
+ if (debugGate) {
2777
+ return debugGate;
2778
+ }
2779
+ if (qaGateFromToolName(toolName)) {
2780
+ return "qa";
2781
+ }
518
2782
  throw new Error(`Unknown Forge completion tool: ${toolName}`);
519
2783
  }
2784
+ function draftGateFromToolName(toolName) {
2785
+ const normalized = toolName.trim().toLowerCase();
2786
+ for (const gate of draftCompletionGateOrder) {
2787
+ const base = draftGateToolNames[gate];
2788
+ if (normalized === `forge.${base}` || normalized === base) {
2789
+ return gate;
2790
+ }
2791
+ }
2792
+ return null;
2793
+ }
2794
+ function debugGateFromToolName(toolName) {
2795
+ const normalized = toolName.trim().toLowerCase();
2796
+ for (const gate of debugCompletionPhaseOrder) {
2797
+ const base = debugGateToolNames[gate];
2798
+ if (normalized === `forge.${base}` || normalized === base) {
2799
+ return gate;
2800
+ }
2801
+ }
2802
+ return null;
2803
+ }
2804
+ function qaGateFromToolName(toolName) {
2805
+ const normalized = toolName.trim().toLowerCase();
2806
+ for (const gate of qaCompletionGateOrder) {
2807
+ const base = qaGateToolNames[gate];
2808
+ if (normalized === `forge.${base}` || normalized === base) {
2809
+ return gate;
2810
+ }
2811
+ }
2812
+ return null;
2813
+ }
520
2814
  function requireField(payload, key) {
521
2815
  if (payload[key] === undefined || payload[key] === null || (typeof payload[key] === "string" && payload[key].trim().length === 0)) {
522
2816
  throw new Error(`${key} is required.`);
@@ -584,17 +2878,42 @@ function requireArray(payload, key) {
584
2878
  throw new Error(`${key} must be an array.`);
585
2879
  }
586
2880
  }
2881
+ function requireNonEmptyArray(payload, key) {
2882
+ requireArray(payload, key);
2883
+ if (arrayLength(payload, key) === 0) {
2884
+ throw new Error(`${key} must include at least one value.`);
2885
+ }
2886
+ }
2887
+ function arrayLength(payload, key) {
2888
+ const value = payload[key];
2889
+ return Array.isArray(value) ? value.length : 0;
2890
+ }
2891
+ function requireAllowedStatus(payload, key, allowed) {
2892
+ const value = normalizedString(optionalString(payload, key));
2893
+ if (!allowed.includes(value)) {
2894
+ throw new Error(`${key} must be one of ${allowed.join(", ")}.`);
2895
+ }
2896
+ }
587
2897
  function requireObject(payload, key) {
588
2898
  const value = payload[key];
589
2899
  if (!value || typeof value !== "object" || Array.isArray(value)) {
590
2900
  throw new Error(`${key} must be an object.`);
591
2901
  }
592
2902
  }
2903
+ function requiredRecord(payload, key) {
2904
+ requireObject(payload, key);
2905
+ return payload[key];
2906
+ }
593
2907
  function requireBoolean(payload, key) {
594
2908
  if (typeof payload[key] !== "boolean") {
595
2909
  throw new Error(`${key} must be a boolean.`);
596
2910
  }
597
2911
  }
2912
+ function requireNumericOne(payload, key, pathLabel) {
2913
+ if (payload[key] !== 1) {
2914
+ throw new Error(`${pathLabel}: expected numeric 1; correction: set ${pathLabel} to 1.`);
2915
+ }
2916
+ }
598
2917
  function requireSatisfiedAcceptanceCriteria(payload) {
599
2918
  const criteria = payload.acceptance_criteria;
600
2919
  if (!Array.isArray(criteria)) {
@@ -612,6 +2931,183 @@ function requireSatisfiedAcceptanceCriteria(payload) {
612
2931
  }
613
2932
  }
614
2933
  }
2934
+ function validateResolvedFindings(payload) {
2935
+ const resolvedFindings = payload.resolved_findings;
2936
+ if (resolvedFindings === undefined || resolvedFindings === null) {
2937
+ return;
2938
+ }
2939
+ if (!Array.isArray(resolvedFindings)) {
2940
+ throw new Error("resolved_findings must be an array.");
2941
+ }
2942
+ for (const [index, finding] of resolvedFindings.entries()) {
2943
+ const prefix = `resolved_findings[${index}].`;
2944
+ if (!finding || typeof finding !== "object" || Array.isArray(finding)) {
2945
+ throw new Error(`resolved_findings[${index}] must be an object.`);
2946
+ }
2947
+ const record = finding;
2948
+ requireNonEmptyStringRecord(record, "qa_id", prefix);
2949
+ requireKnownStringRecord(record, "status", resolvedFindingStatuses, prefix);
2950
+ requireNonEmptyStringRecord(record, "summary", prefix);
2951
+ requireSubstantiveValueRecord(record, "evidence", prefix);
2952
+ requireArrayRecord(record, "changed_files", prefix);
2953
+ requireArrayRecord(record, "tests_added_or_updated", prefix);
2954
+ requireSubstantiveValueRecord(record, "regression_evidence", prefix);
2955
+ if (normalizedString(record.status) === "resolved") {
2956
+ if (record.changed_files.length === 0) {
2957
+ throw new Error(`${prefix}changed_files must include at least one changed file for resolved QA findings.`);
2958
+ }
2959
+ if (record.tests_added_or_updated.length === 0) {
2960
+ throw new Error(`${prefix}tests_added_or_updated must include at least one regression test for resolved QA findings.`);
2961
+ }
2962
+ }
2963
+ }
2964
+ }
2965
+ function qaEvidenceSource(payload) {
2966
+ const evidence = payload.evidence;
2967
+ return evidence && typeof evidence === "object" && !Array.isArray(evidence)
2968
+ ? evidence
2969
+ : {};
2970
+ }
2971
+ function qaArray(payload, key, fallback = {}) {
2972
+ const value = payload[key] ?? fallback[key];
2973
+ if (value === undefined || value === null) {
2974
+ return [];
2975
+ }
2976
+ if (!Array.isArray(value)) {
2977
+ throw new Error(`${key} must be an array.`);
2978
+ }
2979
+ return value;
2980
+ }
2981
+ function qaObjectArray(payload, key) {
2982
+ return qaArray(payload, key).map((entry, index) => {
2983
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
2984
+ throw new Error(`${key}[${index}] must be an object.`);
2985
+ }
2986
+ return entry;
2987
+ });
2988
+ }
2989
+ function qaStringArray(payload, key) {
2990
+ const value = payload[key];
2991
+ if (value === undefined || value === null) {
2992
+ return [];
2993
+ }
2994
+ if (!Array.isArray(value)) {
2995
+ throw new Error(`${key} must be an array.`);
2996
+ }
2997
+ return value.map((entry, index) => {
2998
+ if (typeof entry !== "string" || entry.trim().length === 0) {
2999
+ throw new Error(`${key}[${index}] must be a non-empty string.`);
3000
+ }
3001
+ return entry;
3002
+ });
3003
+ }
3004
+ function requireEvidenceRecord(value, label) {
3005
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3006
+ throw new Error(`${label} must be an object.`);
3007
+ }
3008
+ }
3009
+ function requireNonEmptyStringRecord(source, key, prefix = "") {
3010
+ if (!optionalString(source, key)) {
3011
+ throw new Error(`${prefix}${key} is required.`);
3012
+ }
3013
+ }
3014
+ function requireArrayRecord(source, key, prefix = "") {
3015
+ if (!Array.isArray(source[key])) {
3016
+ throw new Error(`${prefix}${key} must be an array.`);
3017
+ }
3018
+ }
3019
+ function requireKnownStringRecord(source, key, allowed, prefix = "") {
3020
+ const value = normalizedString(optionalString(source, key));
3021
+ if (!allowed.has(value)) {
3022
+ throw new Error(`${prefix}${key} must be one of ${[...allowed].sort().join(", ")}.`);
3023
+ }
3024
+ }
3025
+ function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
3026
+ const value = source[key];
3027
+ if (!Array.isArray(value) || value.length === 0) {
3028
+ throw new Error(`${prefix}${key} must include at least one value.`);
3029
+ }
3030
+ for (const [index, entry] of value.entries()) {
3031
+ if (typeof entry !== "string" || entry.trim().length === 0) {
3032
+ throw new Error(`${prefix}${key}[${index}] must be a non-empty string.`);
3033
+ }
3034
+ }
3035
+ }
3036
+ function requireSubstantiveValueRecord(source, key, prefix = "") {
3037
+ if (!hasSubstantiveJsonValue(source[key])) {
3038
+ throw new Error(`${prefix}${key} must include at least one concrete evidence reference or summary.`);
3039
+ }
3040
+ }
3041
+ function requireSubstantiveEvidenceRecord(source, prefix = "") {
3042
+ const evidence = source.evidence;
3043
+ if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
3044
+ throw new Error(`${prefix}evidence must be an object.`);
3045
+ }
3046
+ if (!Object.values(evidence).some(hasSubstantiveJsonValue)) {
3047
+ throw new Error(`${prefix}evidence must include at least one concrete evidence reference or summary.`);
3048
+ }
3049
+ }
3050
+ function hasSubstantiveJsonValue(value) {
3051
+ if (typeof value === "string") {
3052
+ return value.trim().length > 0;
3053
+ }
3054
+ if (Array.isArray(value)) {
3055
+ return value.length > 0;
3056
+ }
3057
+ if (value && typeof value === "object") {
3058
+ return Object.keys(value).length > 0;
3059
+ }
3060
+ return false;
3061
+ }
3062
+ function normalizedString(value) {
3063
+ return typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
3064
+ }
3065
+ function normalizedOwnerType(value) {
3066
+ const normalized = normalizedString(value);
3067
+ return normalized === "work_item" || normalized === "story" ? normalized : undefined;
3068
+ }
3069
+ function artifactOwnerFields(config) {
3070
+ const ownerType = normalizedOwnerType(config.ownerType);
3071
+ const ownerId = config.ownerId?.trim();
3072
+ if (!ownerType && !ownerId) {
3073
+ return {};
3074
+ }
3075
+ return {
3076
+ owner_type: ownerType ?? "work_item",
3077
+ owner_id: ownerId || config.workItemId
3078
+ };
3079
+ }
3080
+ function artifactReferenceOwnerFields(config) {
3081
+ const owner = artifactOwnerFields(config);
3082
+ return owner.owner_type && owner.owner_id
3083
+ ? { ownerType: owner.owner_type, ownerId: owner.owner_id }
3084
+ : {};
3085
+ }
3086
+ function artifactOwnerDirectory(ownerType) {
3087
+ return normalizedOwnerType(ownerType) === "story" ? "stories" : "work-items";
3088
+ }
3089
+ function artifactOwnerRelativeRoot(ownerType, ownerId, workItemId) {
3090
+ const owner = artifactOwnerFields({ workItemId, ownerType, ownerId });
3091
+ return owner.owner_type && owner.owner_id
3092
+ ? [".forge", artifactOwnerDirectory(owner.owner_type), owner.owner_id, "artifacts"].join("/")
3093
+ : [".forge", "work-items", workItemId, "artifacts"].join("/");
3094
+ }
3095
+ function defaultQaChecklist(status) {
3096
+ return [
3097
+ "Review the validated work item against acceptance criteria.",
3098
+ "Inspect implementation, self-review, and validation evidence when available.",
3099
+ status === "blocked" ? "Record the blocker reason preventing a conclusive QA verdict." : "Record concrete QA evidence and findings."
3100
+ ];
3101
+ }
3102
+ function qaArtifactRelativePath(workItemId, fileName) {
3103
+ return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
3104
+ }
3105
+ function qaArtifactRelativePathForOwner(ownerType, ownerId, fileName) {
3106
+ return [".forge", artifactOwnerDirectory(ownerType), ownerId, "artifacts", fileName].join("/");
3107
+ }
3108
+ function jsonText(value) {
3109
+ return `${JSON.stringify(value, null, 2)}\n`;
3110
+ }
615
3111
  function fallbackReport(parsed, phase, artifactPath) {
616
3112
  return `# Forge ${phase.replace(/_/g, " ")} completion
617
3113
 
@@ -624,7 +3120,7 @@ function parseJsonObject(content, label) {
624
3120
  return asRecord(JSON.parse(content), label);
625
3121
  }
626
3122
  catch (error) {
627
- throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`);
3123
+ throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`, { cause: error });
628
3124
  }
629
3125
  }
630
3126
  function parseOptionalJsonObject(content, label) {