@loucompanion/forge-bridge 0.1.1-dev.7d43998c4521 → 0.1.1-dev.9acab970e5d1

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.
Files changed (40) hide show
  1. package/README.md +1 -0
  2. package/dist/bridge/bridge.service.js +83 -11
  3. package/dist/bridge/bridge.types.d.ts +16 -0
  4. package/dist/cli/cli.service.d.ts +27 -2
  5. package/dist/cli/cli.service.js +406 -16
  6. package/dist/cli/local-config.d.ts +4 -1
  7. package/dist/cli/local-config.js +60 -8
  8. package/dist/session/index.d.ts +2 -0
  9. package/dist/session/index.js +1 -0
  10. package/dist/session/internals.d.ts +5 -3
  11. package/dist/session/internals.js +2 -1
  12. package/dist/session/phase-completion.d.ts +111 -0
  13. package/dist/session/phase-completion.js +2350 -0
  14. package/dist/session/provider-cli/index.d.ts +2 -2
  15. package/dist/session/provider-cli/index.js +1 -1
  16. package/dist/session/provider-cli/provider-cli.base.d.ts +2 -2
  17. package/dist/session/provider-cli/provider-cli.service.d.ts +48 -3
  18. package/dist/session/provider-cli/provider-cli.service.js +1885 -45
  19. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  20. package/dist/session/provider-cli/provider-usage.d.ts +25 -0
  21. package/dist/session/provider-cli/provider-usage.js +64 -0
  22. package/dist/session/qa-sidecar-validation.d.ts +3 -0
  23. package/dist/session/qa-sidecar-validation.js +44 -0
  24. package/dist/session/session.service.js +57 -12
  25. package/dist/session/session.types.d.ts +66 -0
  26. package/dist/session/session.types.js +165 -2
  27. package/dist/shared/cursor-agent/cursor-agent.launch.d.ts +9 -0
  28. package/dist/shared/cursor-agent/cursor-agent.launch.js +66 -0
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/dist/worktree/artifact-injector.d.ts +12 -0
  32. package/dist/worktree/artifact-injector.js +133 -0
  33. package/dist/worktree/dependency-warmer.d.ts +32 -0
  34. package/dist/worktree/dependency-warmer.js +297 -0
  35. package/dist/worktree/index.d.ts +2 -0
  36. package/dist/worktree/index.js +1 -0
  37. package/dist/worktree/worktree.service.d.ts +4 -0
  38. package/dist/worktree/worktree.service.js +174 -11
  39. package/dist/worktree/worktree.types.d.ts +7 -1
  40. package/package.json +1 -1
@@ -0,0 +1,2350 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { redactSecrets } from "../shared/redaction/redaction.js";
6
+ import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
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
+ const qaGateFileNames = {
26
+ qa_plan: "qa.plan.json",
27
+ qa_regression: "qa.regression.json",
28
+ qa_api: "qa.api.json",
29
+ qa_manual_web: "qa.manual-web.json",
30
+ qa_bug_repro: "qa.bug-repro.json",
31
+ qa_findings: "qa.findings.json",
32
+ qa_verdict: "qa.verdict.json"
33
+ };
34
+ const qaGateToolNames = {
35
+ qa_plan: "complete_qa_plan",
36
+ qa_regression: "complete_qa_regression",
37
+ qa_api: "complete_qa_api",
38
+ qa_manual_web: "complete_qa_manual_web",
39
+ qa_bug_repro: "complete_qa_bug_repro",
40
+ qa_findings: "complete_qa_findings",
41
+ qa_verdict: "complete_qa_verdict"
42
+ };
43
+ const debugGateToolNames = {
44
+ debug_repro: "complete_debug_repro",
45
+ debug_root_cause: "complete_debug_root_cause",
46
+ debug_fix: "complete_debug_fix",
47
+ debug_regression: "complete_debug_regression"
48
+ };
49
+ const qaBlockerReasons = new Set([
50
+ "missing_app_launch_target",
51
+ "inconclusive",
52
+ "environment_failure",
53
+ "missing_validation_evidence",
54
+ "missing_worktree",
55
+ "runner_failure"
56
+ ]);
57
+ const qaFindingSeverities = new Set(["critical", "major", "minor"]);
58
+ const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
59
+ const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
60
+ const phaseFiles = {
61
+ implementation: {
62
+ jsonFileName: "implementation.completeness.json",
63
+ reportFileName: "implementation.report.md"
64
+ },
65
+ self_review: {
66
+ jsonFileName: "self-review.report.json",
67
+ reportFileName: "self-review.summary.md"
68
+ },
69
+ validation: {
70
+ jsonFileName: "validation.verdict.json",
71
+ reportFileName: "validation.report.md"
72
+ },
73
+ qa: {
74
+ jsonFileName: "qa.verdict.json",
75
+ reportFileName: "qa.report.md"
76
+ },
77
+ debug_repro: {
78
+ jsonFileName: "debug.repro.json",
79
+ reportFileName: "debug.report.md"
80
+ },
81
+ debug_root_cause: {
82
+ jsonFileName: "debug.root-cause.json",
83
+ reportFileName: "debug.report.md"
84
+ },
85
+ debug_fix: {
86
+ jsonFileName: "debug.fix.json",
87
+ reportFileName: "debug.report.md"
88
+ },
89
+ debug_regression: {
90
+ jsonFileName: "debug.regression.json",
91
+ reportFileName: "debug.report.md"
92
+ }
93
+ };
94
+ export function completionPhaseFromDispatch(dispatch) {
95
+ return normalizeCompletionPhase(dispatch.cycleRun.phase || dispatch.execution.lane || dispatch.execution.role);
96
+ }
97
+ export function normalizeCompletionPhase(value) {
98
+ const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
99
+ const debugPhase = normalizeDebugCompletionPhase(normalized);
100
+ if (debugPhase) {
101
+ return debugPhase;
102
+ }
103
+ if (normalized === "self_review" || normalized === "selfreview" || normalized === "review") {
104
+ return "self_review";
105
+ }
106
+ if (normalized === "validate" || normalized === "validation") {
107
+ return "validation";
108
+ }
109
+ if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
110
+ return "qa";
111
+ }
112
+ return "implementation";
113
+ }
114
+ export function usesDevelopCycleCompletion(dispatch) {
115
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
116
+ const role = dispatch.execution.role.trim().toLowerCase();
117
+ const lane = dispatch.execution.lane.trim().toLowerCase();
118
+ return kind === "develop"
119
+ && role === "develop"
120
+ && lane === "develop"
121
+ && completionPhaseFromDispatch(dispatch) === "implementation";
122
+ }
123
+ export function usesQaCycleCompletion(dispatch) {
124
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
125
+ const role = dispatch.execution.role.trim().toLowerCase();
126
+ const lane = dispatch.execution.lane.trim().toLowerCase();
127
+ return kind === "qa"
128
+ && role === "qa"
129
+ && lane === "qa"
130
+ && completionPhaseFromDispatch(dispatch) === "qa";
131
+ }
132
+ export function usesDebugCycleCompletion(dispatch) {
133
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
134
+ const role = dispatch.execution.role.trim().toLowerCase();
135
+ const lane = dispatch.execution.lane.trim().toLowerCase();
136
+ return kind === "debug"
137
+ && role === "debug"
138
+ && lane === "debug"
139
+ && isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
140
+ }
141
+ export function completionToolNamesForPhase(phase) {
142
+ if (isDebugCompletionPhase(phase)) {
143
+ return completionToolNamesForDebugGate(phase);
144
+ }
145
+ const base = phase === "implementation"
146
+ ? "complete_implementation"
147
+ : phase === "self_review"
148
+ ? "complete_self_review"
149
+ : phase === "validation"
150
+ ? "complete_validation"
151
+ : "complete_qa";
152
+ return [`forge.${base}`, base];
153
+ }
154
+ export function completionToolNamesForQaGate(gate) {
155
+ const base = qaGateToolNames[gate];
156
+ return [`forge.${base}`, base];
157
+ }
158
+ export function completionToolNamesForDebugGate(gate) {
159
+ const base = debugGateToolNames[gate];
160
+ return [`forge.${base}`, base];
161
+ }
162
+ export function expectedCompletionToolName(phase) {
163
+ return completionToolNamesForPhase(phase)[0];
164
+ }
165
+ export function expectedQaCompletionToolName(gate) {
166
+ return completionToolNamesForQaGate(gate)[0];
167
+ }
168
+ export function expectedDebugCompletionToolName(gate) {
169
+ return completionToolNamesForDebugGate(gate)[0];
170
+ }
171
+ export function artifactRootFor(worktreePath, workItemId) {
172
+ return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
173
+ }
174
+ function artifactRootForOwner(worktreePath, ownerType, ownerId) {
175
+ return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
176
+ }
177
+ export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
178
+ return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].jsonFileName);
179
+ }
180
+ export function completionArtifactReportPath(worktreePath, workItemId, phase) {
181
+ return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].reportFileName);
182
+ }
183
+ export async function prepareCompletionToolRuntime(options) {
184
+ const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
185
+ const worktreePath = path.resolve(options.worktree.worktreePath);
186
+ const workItemId = options.dispatch.workItem.id;
187
+ const ownerType = normalizedOwnerType(options.dispatch.cycleRun.ownerType) ?? "work_item";
188
+ const ownerId = options.dispatch.cycleRun.ownerId ?? workItemId;
189
+ const mcpArgs = buildCompletionToolServerArgs({
190
+ worktreePath,
191
+ workItemId,
192
+ ownerType,
193
+ ownerId,
194
+ phase,
195
+ producedBy: `${options.dispatch.execution.provider}-cli`,
196
+ developCycle: usesDevelopCycleCompletion(options.dispatch),
197
+ qaCycle: usesQaCycleCompletion(options.dispatch),
198
+ debugCycle: usesDebugCycleCompletion(options.dispatch)
199
+ });
200
+ const runtime = {
201
+ serverName: toolServerName,
202
+ command: process.execPath,
203
+ args: [forgeBridgeCliBinPath(), ...mcpArgs],
204
+ env: {},
205
+ cleanup: async () => undefined
206
+ };
207
+ if (options.dispatch.execution.provider.trim().toLowerCase() !== "cursor") {
208
+ return runtime;
209
+ }
210
+ return await prepareCursorCompletionToolRuntime(worktreePath, runtime);
211
+ }
212
+ export function buildCompletionToolServerArgs(config) {
213
+ const args = [
214
+ "mcp",
215
+ "--worktree",
216
+ path.resolve(config.worktreePath),
217
+ "--work-item-id",
218
+ config.workItemId,
219
+ "--phase",
220
+ config.phase,
221
+ "--produced-by",
222
+ config.producedBy ?? "forge-completion-tool"
223
+ ];
224
+ if (config.ownerType || config.ownerId) {
225
+ args.push("--owner-type", normalizedOwnerType(config.ownerType) ?? "work_item");
226
+ args.push("--owner-id", config.ownerId ?? config.workItemId);
227
+ }
228
+ if (config.developCycle) {
229
+ args.push("--develop-cycle");
230
+ }
231
+ if (config.qaCycle) {
232
+ args.push("--qa-cycle");
233
+ }
234
+ if (config.debugCycle) {
235
+ args.push("--debug-cycle");
236
+ }
237
+ return args;
238
+ }
239
+ export function codexMcpConfigArgs(runtime) {
240
+ const prefix = `mcp_servers.${runtime.serverName}`;
241
+ const args = [
242
+ "-c",
243
+ `${prefix}.command=${tomlString(runtime.command)}`,
244
+ "-c",
245
+ `${prefix}.args=${tomlStringArray(runtime.args)}`
246
+ ];
247
+ for (const [key, value] of Object.entries(runtime.env)) {
248
+ args.push("-c", `${prefix}.env.${key}=${tomlString(value)}`);
249
+ }
250
+ return args;
251
+ }
252
+ export function cursorMcpServerConfig(runtime) {
253
+ return {
254
+ command: runtime.command,
255
+ args: [...runtime.args],
256
+ env: runtime.env
257
+ };
258
+ }
259
+ export function completionPromptForPhase(phase) {
260
+ const toolName = expectedCompletionToolName(phase);
261
+ const alias = completionToolNamesForPhase(phase).find((name) => name !== toolName);
262
+ const artifactName = phaseFiles[phase].jsonFileName;
263
+ if (phase === "qa") {
264
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
265
+ 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.
266
+ 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\`.
267
+ 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.
268
+ Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
269
+ }
270
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
271
+ 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.
272
+ That tool writes \`.forge/work-items/{workItemId}/artifacts/${artifactName}\` and returns the artifact reference that Forge will mirror through the existing phase-artifact pipeline.
273
+ If the work cannot pass, call the same tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
274
+ }
275
+ export function completionPromptForDevelopCycle(phase) {
276
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
277
+ Do not write phase artifacts by hand. The tool server enforces phase order and starts this run in \`${phase}\`.
278
+ Complete the dev cycle in order inside this same provider session:
279
+ 1. Call \`forge.complete_implementation(...)\` when implementation evidence is ready.
280
+ 2. Follow the returned self-review instruction, then call \`forge.complete_self_review(...)\`.
281
+ 3. Follow the returned validation instruction, then call \`forge.complete_validation(...)\`.
282
+ Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
283
+ If the current phase cannot pass, call the current phase tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
284
+ }
285
+ export function completionPromptForQaCycle(currentGate = "qa_plan") {
286
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
287
+ Do not write QA artifacts by hand. The tool server enforces QA gate order and starts this run at \`${currentGate}\`.
288
+ Complete the QA cycle in order inside this same provider session:
289
+ 1. Call \`forge.complete_qa_plan(...)\` after the QA test plan/checklist is ready.
290
+ 2. Follow the returned regression instruction, then call \`forge.complete_qa_regression(...)\`.
291
+ 3. Follow the returned API/backend instruction, then call \`forge.complete_qa_api(...)\`.
292
+ 4. Follow the returned manual web instruction, then call \`forge.complete_qa_manual_web(...)\`.
293
+ 5. Follow the returned bug reproduction instruction, then call \`forge.complete_qa_bug_repro(...)\`.
294
+ 6. Follow the returned findings instruction, then call \`forge.complete_qa_findings(...)\`.
295
+ 7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
296
+ Use \`status: "skipped"\` with a reason for gates that are not applicable. If a gate is blocked, keep moving through later gates as skipped due to the blocker and finish with a blocked verdict.
297
+ The final verdict tool writes the canonical QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`qa.verdict.json\`, and \`qa.report.md\`.
298
+ The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
299
+ }
300
+ export function completionPromptForDebugCycle(currentGate = "debug_repro") {
301
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
302
+ Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
303
+ Complete the Debug cycle in order inside this same provider session:
304
+ 1. Reproduce or tightly characterize the bug, then call \`forge.complete_debug_repro(...)\`.
305
+ 2. Follow the returned root-cause instruction, isolate evidence, then call \`forge.complete_debug_root_cause(...)\`.
306
+ 3. Follow the returned fix instruction, apply the smallest safe fix with regression coverage when practical, then call \`forge.complete_debug_fix(...)\`.
307
+ 4. Follow the returned regression instruction, prove the original repro is negative and the fix is QA-ready, then call \`forge.complete_debug_regression(...)\`.
308
+ Each tool writes a canonical Debug artifact under \`.forge/work-items/{workItemId}/artifacts/\`: \`debug.repro.json\`, \`debug.root-cause.json\`, \`debug.fix.json\`, and \`debug.regression.json\` plus \`debug.report.md\` when report text is provided or generated.
309
+ Use a non-pass status with explicit blockers when the current Debug gate cannot pass. Reporting Debug completion in prose is not enough; the relevant MCP completion tool is the transition request.`;
310
+ }
311
+ export function completionToolNamesForServer(config) {
312
+ return config.debugCycle
313
+ ? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
314
+ : config.qaCycle
315
+ ? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
316
+ : config.developCycle
317
+ ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
318
+ : completionToolNamesForPhase(config.phase);
319
+ }
320
+ export class CompletionToolServerSession {
321
+ config;
322
+ currentPhase;
323
+ currentQaGate = "qa_plan";
324
+ completed = false;
325
+ constructor(config) {
326
+ this.config = config;
327
+ this.currentPhase = config.phase;
328
+ }
329
+ get instructions() {
330
+ return this.config.debugCycle
331
+ ? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
332
+ : this.config.qaCycle
333
+ ? completionPromptForQaCycle(this.currentQaGate)
334
+ : this.config.developCycle
335
+ ? completionPromptForDevelopCycle(this.currentPhase)
336
+ : completionPromptForPhase(this.currentPhase);
337
+ }
338
+ toolDefinitions() {
339
+ return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
340
+ }
341
+ async callTool(toolName, input) {
342
+ const requestedPhase = phaseFromToolName(toolName);
343
+ if (this.completed) {
344
+ throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
345
+ }
346
+ if (this.config.debugCycle) {
347
+ return await this.callDebugGateTool(toolName, requestedPhase, input);
348
+ }
349
+ if (this.config.qaCycle) {
350
+ return await this.callQaGateTool(toolName, requestedPhase, input);
351
+ }
352
+ if (requestedPhase !== this.currentPhase) {
353
+ throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
354
+ }
355
+ const completedPhase = this.currentPhase;
356
+ const reference = await writeCompletionArtifact({ ...this.config, phase: completedPhase }, toolName, input);
357
+ const status = reference.status.trim().toLowerCase();
358
+ const pass = status === "pass";
359
+ const nextPhase = pass
360
+ ? this.advanceAfterPass(completedPhase)
361
+ : completedPhase;
362
+ const nextInstruction = completionToolNextInstruction(completedPhase, status, nextPhase);
363
+ const reason = pass
364
+ ? `${completedPhase} completion artifact accepted.`
365
+ : `${completedPhase} completion artifact recorded with status ${status}; rework is required before phase progression.`;
366
+ return {
367
+ content: [
368
+ {
369
+ type: "text",
370
+ text: `Forge recorded ${reference.phase} completion artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
371
+ }
372
+ ],
373
+ structuredContent: {
374
+ ...reference,
375
+ gate: {
376
+ phase: completedPhase,
377
+ status,
378
+ allowed: pass,
379
+ reason
380
+ },
381
+ nextPhase,
382
+ nextInstruction
383
+ }
384
+ };
385
+ }
386
+ async callDebugGateTool(toolName, requestedPhase, input) {
387
+ const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
388
+ if (!isDebugCompletionPhase(requestedPhase)) {
389
+ throw new Error(`Tool ${toolName} is not available in Debug-cycle mode. Current Forge Debug gate is ${currentDebugGate}.`);
390
+ }
391
+ if (requestedPhase !== currentDebugGate) {
392
+ throw new Error(`Tool ${toolName} is out of order. Current Forge Debug gate is ${currentDebugGate}.`);
393
+ }
394
+ const completedGate = currentDebugGate;
395
+ const reference = await writeCompletionArtifact({ ...this.config, phase: completedGate }, toolName, input);
396
+ const status = reference.status.trim().toLowerCase();
397
+ const pass = status === "pass";
398
+ const nextGate = pass ? this.advanceAfterDebugGate(completedGate) : completedGate;
399
+ const nextPhase = nextGate ?? "qa_ready";
400
+ const nextInstruction = completionToolNextInstructionForDebugGate(completedGate, status, nextGate);
401
+ const reason = pass
402
+ ? `${completedGate} artifact accepted${nextGate ? `; continue to ${nextGate}.` : "; Debug regression is QA-ready."}`
403
+ : `${completedGate} artifact recorded with status ${status}; rework is required before Debug progression.`;
404
+ return {
405
+ content: [
406
+ {
407
+ type: "text",
408
+ text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
409
+ }
410
+ ],
411
+ structuredContent: {
412
+ ...reference,
413
+ gate: {
414
+ phase: completedGate,
415
+ status,
416
+ allowed: pass,
417
+ reason
418
+ },
419
+ nextPhase,
420
+ nextInstruction
421
+ }
422
+ };
423
+ }
424
+ async callQaGateTool(toolName, requestedPhase, input) {
425
+ const requestedGate = qaGateFromToolName(toolName);
426
+ if (requestedPhase !== "qa" || !requestedGate) {
427
+ throw new Error(`Tool ${toolName} is not available in QA-cycle mode. Current Forge QA gate is ${this.currentQaGate}.`);
428
+ }
429
+ if (requestedGate !== this.currentQaGate) {
430
+ throw new Error(`Tool ${toolName} is out of order. Current Forge QA gate is ${this.currentQaGate}.`);
431
+ }
432
+ const completedGate = this.currentQaGate;
433
+ const reference = await writeCompletionArtifact({ ...this.config, phase: "qa" }, toolName, input);
434
+ const status = reference.status.trim().toLowerCase();
435
+ const nextGate = this.advanceAfterQaGate(completedGate);
436
+ const nextPhase = nextGate ? "qa" : "sign_off_ready";
437
+ const nextInstruction = completionToolNextInstructionForQaGate(completedGate, status, nextGate);
438
+ const allowed = status !== "blocked";
439
+ const reason = nextGate
440
+ ? `${completedGate} artifact accepted; continue to ${nextGate}.`
441
+ : `QA verdict artifact accepted with status ${status}.`;
442
+ return {
443
+ content: [
444
+ {
445
+ type: "text",
446
+ text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
447
+ }
448
+ ],
449
+ structuredContent: {
450
+ ...reference,
451
+ gate: {
452
+ phase: "qa",
453
+ qaGate: completedGate,
454
+ status,
455
+ allowed,
456
+ reason
457
+ },
458
+ nextPhase,
459
+ nextInstruction
460
+ }
461
+ };
462
+ }
463
+ advanceAfterPass(phase) {
464
+ if (phase === "qa") {
465
+ this.completed = true;
466
+ return "sign_off_ready";
467
+ }
468
+ if (isDebugCompletionPhase(phase)) {
469
+ return nextDebugPhaseAfter(phase) ?? "qa_ready";
470
+ }
471
+ const next = nextPhaseAfter(phase);
472
+ if (!this.config.developCycle) {
473
+ return next ?? "dev_test_ready";
474
+ }
475
+ if (next) {
476
+ this.currentPhase = next;
477
+ return next;
478
+ }
479
+ this.completed = true;
480
+ return "dev_test_ready";
481
+ }
482
+ advanceAfterQaGate(gate) {
483
+ const index = qaCompletionGateOrder.indexOf(gate);
484
+ const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
485
+ if (next) {
486
+ this.currentQaGate = next;
487
+ return next;
488
+ }
489
+ this.completed = true;
490
+ return null;
491
+ }
492
+ advanceAfterDebugGate(gate) {
493
+ const index = debugCompletionPhaseOrder.indexOf(gate);
494
+ const next = index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
495
+ if (next) {
496
+ this.currentPhase = next;
497
+ return next;
498
+ }
499
+ this.completed = true;
500
+ return null;
501
+ }
502
+ }
503
+ export async function writeCompletionArtifact(config, toolName, input) {
504
+ const phase = phaseFromToolName(toolName);
505
+ if (phase !== config.phase) {
506
+ throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
507
+ }
508
+ const qaGate = qaGateFromToolName(toolName);
509
+ if (qaGate && qaGate !== "qa_verdict") {
510
+ return await writeQaGateCompletionArtifact(config, qaGate, input);
511
+ }
512
+ const payload = asRecord(input, "completion payload");
513
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
514
+ const validationPayload = { ...payload, status };
515
+ validatePayloadShape(phase, validationPayload);
516
+ validateOptionalWorkItemId(validationPayload, config.workItemId);
517
+ const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
518
+ const producedBy = optionalString(redactedPayload, "produced_by")
519
+ ?? optionalString(redactedPayload, "producedBy")
520
+ ?? config.producedBy
521
+ ?? "forge-completion-tool";
522
+ if (phase === "qa") {
523
+ return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
524
+ }
525
+ const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
526
+ phase,
527
+ workItemId: config.workItemId,
528
+ ownerType: config.ownerType,
529
+ ownerId: config.ownerId,
530
+ producedBy,
531
+ status
532
+ });
533
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
534
+ const artifact = {
535
+ ...artifactWithoutHash,
536
+ content_hash: contentHash
537
+ };
538
+ const artifactJson = `${JSON.stringify(artifact, null, 2)}\n`;
539
+ const report = reportMarkdown(redactedPayload, phase, status, producedBy, contentHash);
540
+ const artifactPath = completionArtifactJsonPath(config.worktreePath, config.workItemId, phase);
541
+ const reportPath = completionArtifactReportPath(config.worktreePath, config.workItemId, phase);
542
+ await fs.mkdir(path.dirname(artifactPath), { recursive: true });
543
+ await writeFileAtomic(artifactPath, artifactJson);
544
+ await writeFileAtomic(reportPath, report);
545
+ return {
546
+ phase,
547
+ workItemId: config.workItemId,
548
+ ...artifactReferenceOwnerFields(config),
549
+ repoPath: path.resolve(config.worktreePath),
550
+ path: artifactPath,
551
+ reportPath,
552
+ contentHash,
553
+ status,
554
+ producedBy
555
+ };
556
+ }
557
+ async function writeQaGateCompletionArtifact(config, gate, input) {
558
+ const payload = asRecord(input, "QA gate completion payload");
559
+ const status = normalizeQaGateStatus(requiredString(payload, "status"));
560
+ validateQaGatePayloadShape(gate, payload, status);
561
+ validateOptionalWorkItemId(payload, config.workItemId);
562
+ const redactedPayload = redactSecrets({ ...payload, status }, config.redactionSecrets ?? []);
563
+ const producedBy = optionalString(redactedPayload, "produced_by")
564
+ ?? optionalString(redactedPayload, "producedBy")
565
+ ?? config.producedBy
566
+ ?? "forge-completion-tool";
567
+ const ownerFields = artifactOwnerFields(config);
568
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
569
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
570
+ : artifactRootFor(config.worktreePath, config.workItemId);
571
+ const fileName = qaGateFileNames[gate];
572
+ const artifactPath = path.join(artifactRoot, fileName);
573
+ const reportPath = path.join(artifactRoot, "qa.report.md");
574
+ const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
575
+ gate,
576
+ workItemId: config.workItemId,
577
+ ownerType: config.ownerType,
578
+ ownerId: config.ownerId,
579
+ producedBy,
580
+ status
581
+ });
582
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
583
+ const artifact = {
584
+ ...artifactWithoutHash,
585
+ content_hash: contentHash
586
+ };
587
+ await fs.mkdir(artifactRoot, { recursive: true });
588
+ await writeFileAtomic(artifactPath, jsonText(artifact));
589
+ return {
590
+ phase: "qa",
591
+ workItemId: config.workItemId,
592
+ ...artifactReferenceOwnerFields(config),
593
+ repoPath: path.resolve(config.worktreePath),
594
+ path: artifactPath,
595
+ reportPath,
596
+ contentHash,
597
+ status,
598
+ producedBy
599
+ };
600
+ }
601
+ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
602
+ const ownerFields = artifactOwnerFields(config);
603
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
604
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
605
+ : artifactRootFor(config.worktreePath, config.workItemId);
606
+ const planPath = path.join(artifactRoot, "qa.plan.json");
607
+ const evidencePath = path.join(artifactRoot, "qa.evidence.json");
608
+ const findingsPath = path.join(artifactRoot, "qa.findings.json");
609
+ const verdictPath = path.join(artifactRoot, "qa.verdict.json");
610
+ const reportPath = path.join(artifactRoot, "qa.report.md");
611
+ const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
612
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
613
+ : qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
614
+ const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
615
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
616
+ : qaArtifactRelativePath(config.workItemId, "qa.findings.json");
617
+ const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
618
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
619
+ : qaArtifactRelativePath(config.workItemId, "qa.report.md");
620
+ const evidenceSource = qaEvidenceSource(payload);
621
+ const commands = qaArray(payload, "commands", evidenceSource);
622
+ const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
623
+ const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
624
+ const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
625
+ const findings = qaObjectArray(payload, "findings");
626
+ const candidateBugs = qaObjectArray(payload, "candidate_bugs");
627
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
628
+ const explicitFindingIds = qaStringArray(payload, "finding_ids");
629
+ const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
630
+ const blockerReason = optionalString(payload, "blocker_reason");
631
+ const checklist = qaStringArray(payload, "checklist");
632
+ const planJson = jsonText({
633
+ schema_version: 1,
634
+ phase: "qa",
635
+ ...ownerFields,
636
+ checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
637
+ acceptance_criteria: testedAcceptanceCriteria
638
+ });
639
+ const evidenceJson = jsonText({
640
+ schema_version: 1,
641
+ phase: "qa",
642
+ ...ownerFields,
643
+ commands,
644
+ browser_checks: browserChecks,
645
+ sidecars,
646
+ notes
647
+ });
648
+ const findingsJson = jsonText({
649
+ schema_version: 1,
650
+ phase: "qa",
651
+ ...ownerFields,
652
+ findings,
653
+ candidate_bugs: candidateBugs
654
+ });
655
+ const findingIds = status === "pass"
656
+ ? []
657
+ : explicitFindingIds.length > 0
658
+ ? explicitFindingIds
659
+ : findings
660
+ .filter((finding) => normalizedString(finding.scope) === "in_scope")
661
+ .map((finding) => optionalString(finding, "id"))
662
+ .filter((id) => Boolean(id));
663
+ const candidateBugIds = explicitCandidateBugIds.length > 0
664
+ ? explicitCandidateBugIds
665
+ : candidateBugs
666
+ .map((candidateBug) => optionalString(candidateBug, "id"))
667
+ .filter((id) => Boolean(id));
668
+ const verdictWithoutHash = {
669
+ schema_version: numberOrDefault(payload.schema_version, 1),
670
+ phase: "qa",
671
+ ...ownerFields,
672
+ status,
673
+ summary: requiredString(payload, "summary"),
674
+ tested_acceptance_criteria: testedAcceptanceCriteria,
675
+ finding_ids: findingIds,
676
+ candidate_bug_ids: candidateBugIds,
677
+ blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
678
+ evidence_path: relativeEvidencePath,
679
+ findings_path: relativeFindingsPath,
680
+ report_path: relativeReportPath,
681
+ produced_by: producedBy
682
+ };
683
+ const contentHash = sha256(stableStringify({
684
+ plan: JSON.parse(planJson),
685
+ evidence: JSON.parse(evidenceJson),
686
+ findings: JSON.parse(findingsJson),
687
+ verdict: verdictWithoutHash
688
+ }));
689
+ const verdictJson = jsonText({
690
+ ...verdictWithoutHash,
691
+ content_hash: contentHash
692
+ });
693
+ const finalReport = reportMarkdown(payload, "qa", status, producedBy, contentHash);
694
+ await fs.mkdir(artifactRoot, { recursive: true });
695
+ await writeFileAtomic(planPath, planJson);
696
+ await writeFileAtomic(evidencePath, evidenceJson);
697
+ await writeFileAtomic(findingsPath, findingsJson);
698
+ await writeFileAtomic(verdictPath, verdictJson);
699
+ await writeFileAtomic(reportPath, finalReport);
700
+ return {
701
+ phase: "qa",
702
+ workItemId: config.workItemId,
703
+ ...artifactReferenceOwnerFields(config),
704
+ repoPath: path.resolve(config.worktreePath),
705
+ path: verdictPath,
706
+ reportPath,
707
+ contentHash,
708
+ status,
709
+ producedBy
710
+ };
711
+ }
712
+ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
713
+ if (sidecars.length === 0) {
714
+ return [];
715
+ }
716
+ const artifactRoot = ownerType && ownerId
717
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
718
+ : artifactRootFor(worktreePath, workItemId);
719
+ const qaSidecarRoot = path.resolve(artifactRoot, "qa");
720
+ const validated = [];
721
+ for (const [index, sidecar] of sidecars.entries()) {
722
+ if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
723
+ throw new Error(`sidecars[${index}] must be an object.`);
724
+ }
725
+ const record = sidecar;
726
+ const sidecarPath = optionalString(record, "path");
727
+ const contentHash = optionalString(record, "content_hash");
728
+ const bytes = record.bytes;
729
+ if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
730
+ throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
731
+ }
732
+ if (path.isAbsolute(sidecarPath)) {
733
+ throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
734
+ }
735
+ const fullPath = path.resolve(worktreePath, sidecarPath);
736
+ if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
737
+ throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
738
+ }
739
+ const stat = await fs.lstat(fullPath).catch((error) => {
740
+ if (isNodeError(error) && error.code === "ENOENT") {
741
+ throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
742
+ }
743
+ throw error;
744
+ });
745
+ if (!stat.isFile() || stat.isSymbolicLink()) {
746
+ throw new Error(`sidecars[${index}].path must reference a regular file.`);
747
+ }
748
+ await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
749
+ if (stat.size !== bytes) {
750
+ throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
751
+ }
752
+ const actualHash = await sha256File(fullPath);
753
+ if (actualHash !== contentHash.trim().toLowerCase()) {
754
+ throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
755
+ }
756
+ validated.push({
757
+ ...record,
758
+ path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
759
+ content_hash: actualHash,
760
+ bytes: stat.size
761
+ });
762
+ }
763
+ return validated;
764
+ }
765
+ export async function readCompletionArtifact(worktreePath, workItemId, phase) {
766
+ const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
767
+ const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
768
+ const verdictJson = await readOptionalFile(artifactPath);
769
+ if (verdictJson === null) {
770
+ return null;
771
+ }
772
+ const parsed = parseJsonObject(verdictJson, artifactPath);
773
+ const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
774
+ return {
775
+ repoPath: path.resolve(worktreePath),
776
+ path: artifactPath,
777
+ verdictJson,
778
+ report,
779
+ producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
780
+ status: optionalString(parsed, "status") ?? "unknown",
781
+ phase
782
+ };
783
+ }
784
+ export async function runCompletionMcpServer(config) {
785
+ const session = new CompletionToolServerSession(config);
786
+ process.stdin.setEncoding("utf8");
787
+ let buffer = "";
788
+ for await (const chunk of process.stdin) {
789
+ buffer += chunk;
790
+ let newlineIndex = buffer.indexOf("\n");
791
+ while (newlineIndex >= 0) {
792
+ const rawLine = buffer.slice(0, newlineIndex).trim();
793
+ buffer = buffer.slice(newlineIndex + 1);
794
+ if (rawLine.length > 0) {
795
+ await handleJsonRpcLine(session, rawLine);
796
+ }
797
+ newlineIndex = buffer.indexOf("\n");
798
+ }
799
+ }
800
+ const trailing = buffer.trim();
801
+ if (trailing.length > 0) {
802
+ await handleJsonRpcLine(session, trailing);
803
+ }
804
+ }
805
+ export function parseCompletionMcpArgs(argv) {
806
+ let worktreePath = null;
807
+ let workItemId = null;
808
+ let phase = "implementation";
809
+ let ownerType;
810
+ let ownerId;
811
+ let producedBy;
812
+ let developCycle = false;
813
+ let qaCycle = false;
814
+ let debugCycle = false;
815
+ for (let index = 1; index < argv.length; index += 1) {
816
+ const arg = argv[index];
817
+ if (arg === "--worktree") {
818
+ worktreePath = argv[++index] ?? null;
819
+ }
820
+ else if (arg === "--work-item-id") {
821
+ workItemId = argv[++index] ?? null;
822
+ }
823
+ else if (arg === "--owner-type") {
824
+ ownerType = normalizedOwnerType(argv[++index]);
825
+ if (!ownerType) {
826
+ return { ok: false, error: "mcp --owner-type must be work_item or story." };
827
+ }
828
+ }
829
+ else if (arg === "--owner-id") {
830
+ ownerId = argv[++index] ?? undefined;
831
+ }
832
+ else if (arg === "--phase") {
833
+ const rawPhase = argv[++index];
834
+ const parsedPhase = parseStrictCompletionPhase(rawPhase);
835
+ if (!parsedPhase) {
836
+ return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, debug-repro, debug-root-cause, debug-fix, debug-regression." };
837
+ }
838
+ phase = parsedPhase;
839
+ }
840
+ else if (arg === "--produced-by") {
841
+ producedBy = argv[++index] ?? undefined;
842
+ }
843
+ else if (arg === "--develop-cycle") {
844
+ developCycle = true;
845
+ }
846
+ else if (arg === "--qa-cycle") {
847
+ qaCycle = true;
848
+ }
849
+ else if (arg === "--debug-cycle") {
850
+ debugCycle = true;
851
+ }
852
+ else {
853
+ return { ok: false, error: `Unknown mcp argument: ${arg}` };
854
+ }
855
+ }
856
+ if (!worktreePath?.trim() || !workItemId?.trim()) {
857
+ return { ok: false, error: "mcp requires --worktree and --work-item-id." };
858
+ }
859
+ if ([developCycle, qaCycle, debugCycle].filter(Boolean).length > 1) {
860
+ return { ok: false, error: "mcp accepts only one cycle mode." };
861
+ }
862
+ if (developCycle && !isDevelopCompletionPhase(phase)) {
863
+ return { ok: false, error: "mcp --develop-cycle requires --phase implementation, self-review, or validation." };
864
+ }
865
+ if (qaCycle && phase !== "qa") {
866
+ return { ok: false, error: "mcp --qa-cycle requires --phase qa." };
867
+ }
868
+ if (debugCycle && !isDebugCompletionPhase(phase)) {
869
+ return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
870
+ }
871
+ return {
872
+ ok: true,
873
+ config: {
874
+ worktreePath: path.resolve(worktreePath),
875
+ workItemId,
876
+ ownerType,
877
+ ownerId,
878
+ phase,
879
+ producedBy,
880
+ redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
881
+ developCycle,
882
+ qaCycle,
883
+ debugCycle
884
+ }
885
+ };
886
+ }
887
+ export function completionRedactionEnv(secrets) {
888
+ const filtered = secrets.filter((secret) => secret.length > 0);
889
+ return filtered.length > 0
890
+ ? { [completionRedactionSecretsEnv]: JSON.stringify(filtered) }
891
+ : {};
892
+ }
893
+ async function prepareCursorCompletionToolRuntime(worktreePath, runtime) {
894
+ const cursorDir = path.join(worktreePath, ".cursor");
895
+ const configPath = path.join(cursorDir, "mcp.json");
896
+ const original = await fs.readFile(configPath, "utf8").then((content) => ({ existed: true, content }), () => ({ existed: false, content: "" }));
897
+ const existing = original.existed ? parseOptionalJsonObject(original.content, configPath) : {};
898
+ const servers = asMutableRecord(existing.mcpServers);
899
+ servers[runtime.serverName] = cursorMcpServerConfig(runtime);
900
+ const next = {
901
+ ...existing,
902
+ mcpServers: servers
903
+ };
904
+ await fs.mkdir(cursorDir, { recursive: true });
905
+ await writeFileAtomic(configPath, `${JSON.stringify(next, null, 2)}\n`);
906
+ return {
907
+ ...runtime,
908
+ cursorMcpConfigPath: configPath,
909
+ cleanup: async () => {
910
+ if (original.existed) {
911
+ await writeFileAtomic(configPath, original.content);
912
+ }
913
+ else {
914
+ await fs.rm(configPath, { force: true });
915
+ await fs.rmdir(cursorDir).catch(() => undefined);
916
+ }
917
+ }
918
+ };
919
+ }
920
+ async function handleJsonRpcLine(session, rawLine) {
921
+ let message;
922
+ try {
923
+ message = JSON.parse(rawLine);
924
+ }
925
+ catch (error) {
926
+ writeJsonRpc({ jsonrpc: "2.0", id: null, error: rpcError(-32700, errorMessage(error)) });
927
+ return;
928
+ }
929
+ const request = asJsonRpcMessage(message);
930
+ if (!request) {
931
+ writeJsonRpc({ jsonrpc: "2.0", id: null, error: rpcError(-32600, "Invalid JSON-RPC message.") });
932
+ return;
933
+ }
934
+ if (request.id === undefined) {
935
+ return;
936
+ }
937
+ try {
938
+ const result = await handleJsonRpcRequest(session, request.method, request.params);
939
+ writeJsonRpc({ jsonrpc: "2.0", id: request.id, result });
940
+ }
941
+ catch (error) {
942
+ writeJsonRpc({ jsonrpc: "2.0", id: request.id, error: rpcError(-32000, errorMessage(error)) });
943
+ }
944
+ }
945
+ async function handleJsonRpcRequest(session, method, params) {
946
+ if (method === "initialize") {
947
+ const initializeParams = params && typeof params === "object" && !Array.isArray(params)
948
+ ? params
949
+ : {};
950
+ return {
951
+ protocolVersion: typeof initializeParams.protocolVersion === "string" ? initializeParams.protocolVersion : "2025-06-18",
952
+ capabilities: {
953
+ tools: {}
954
+ },
955
+ serverInfo: {
956
+ name: "forge-completion-tools",
957
+ version: "0.1.0"
958
+ },
959
+ instructions: session.instructions
960
+ };
961
+ }
962
+ if (method === "ping") {
963
+ return {};
964
+ }
965
+ if (method === "tools/list") {
966
+ return {
967
+ tools: session.toolDefinitions()
968
+ };
969
+ }
970
+ if (method === "tools/call") {
971
+ const call = asRecord(params, "tools/call params");
972
+ const name = requiredString(call, "name");
973
+ const args = call.arguments ?? {};
974
+ return await session.callTool(name, args);
975
+ }
976
+ throw new Error(`Unsupported MCP method: ${method}`);
977
+ }
978
+ function toolDefinition(name, phase, qaGate = null) {
979
+ return {
980
+ name,
981
+ description: toolDescription(phase, qaGate),
982
+ inputSchema: toolInputSchema(phase, qaGate)
983
+ };
984
+ }
985
+ function nextPhaseAfter(phase) {
986
+ if (!isDevelopCompletionPhase(phase)) {
987
+ return null;
988
+ }
989
+ const index = completionPhaseOrder.indexOf(phase);
990
+ return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
991
+ }
992
+ function nextDebugPhaseAfter(phase) {
993
+ const index = debugCompletionPhaseOrder.indexOf(phase);
994
+ return index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
995
+ }
996
+ function completionToolNextInstruction(completedPhase, status, nextPhase) {
997
+ if (completedPhase === "qa") {
998
+ if (status === "blocked") {
999
+ return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
1000
+ }
1001
+ if (status === "needs_rework") {
1002
+ return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
1003
+ }
1004
+ return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
1005
+ }
1006
+ if (status !== "pass") {
1007
+ return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
1008
+ }
1009
+ if (isDebugCompletionPhase(completedPhase)) {
1010
+ const debugNext = isDebugCompletionPhase(nextPhase) ? nextPhase : null;
1011
+ return completionToolNextInstructionForDebugGate(completedPhase, status, debugNext);
1012
+ }
1013
+ if (nextPhase === "self_review") {
1014
+ return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
1015
+ }
1016
+ if (nextPhase === "validation") {
1017
+ return "Self-review accepted. Begin validation: run deterministic checks, prove acceptance criteria, and call forge.complete_validation with command evidence.";
1018
+ }
1019
+ return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
1020
+ }
1021
+ function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
1022
+ if (!nextGate) {
1023
+ if (status === "blocked") {
1024
+ return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
1025
+ }
1026
+ if (status === "needs_rework") {
1027
+ return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
1028
+ }
1029
+ return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
1030
+ }
1031
+ const skipped = status === "skipped";
1032
+ const blocked = status === "blocked";
1033
+ const prefix = blocked
1034
+ ? `${completedGate} recorded a blocker. Continue through ${nextGate} and mark downstream gates skipped if the blocker prevents evidence collection.`
1035
+ : skipped
1036
+ ? `${completedGate} skipped. Continue to ${nextGate}.`
1037
+ : `${completedGate} accepted. Continue to ${nextGate}.`;
1038
+ return `${prefix} ${qaGateInstruction(nextGate)}`;
1039
+ }
1040
+ function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
1041
+ if (status !== "pass") {
1042
+ return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
1043
+ }
1044
+ if (nextGate === "debug_root_cause") {
1045
+ return "Debug repro accepted. Begin root-cause analysis: connect the observed failure to code evidence and call forge.complete_debug_root_cause.";
1046
+ }
1047
+ if (nextGate === "debug_fix") {
1048
+ return "Debug root cause accepted. Apply the smallest safe fix with regression coverage where practical, then call forge.complete_debug_fix.";
1049
+ }
1050
+ if (nextGate === "debug_regression") {
1051
+ return "Debug fix accepted. Prove the original repro is negative, run targeted regression checks, and call forge.complete_debug_regression.";
1052
+ }
1053
+ return "Debug regression accepted. Forge has the structured evidence needed to move this bug fix to QA.";
1054
+ }
1055
+ function qaGateInstruction(gate) {
1056
+ switch (gate) {
1057
+ case "qa_regression":
1058
+ return "Run or explicitly skip applicable regression/build/lint/typecheck/e2e checks, then call forge.complete_qa_regression.";
1059
+ case "qa_api":
1060
+ return "Run or explicitly skip applicable API/backend contract checks, then call forge.complete_qa_api.";
1061
+ case "qa_manual_web":
1062
+ return "Run or explicitly skip applicable browser/manual web checks, then call forge.complete_qa_manual_web.";
1063
+ case "qa_bug_repro":
1064
+ return "Reproduce or explicitly skip relevant defects/candidate bugs, then call forge.complete_qa_bug_repro.";
1065
+ case "qa_findings":
1066
+ return "Normalize observations into findings and candidate bugs, then call forge.complete_qa_findings.";
1067
+ case "qa_verdict":
1068
+ return "Assemble the final QA verdict from all prior gate evidence, then call forge.complete_qa_verdict.";
1069
+ case "qa_plan":
1070
+ return "Prepare the QA checklist, then call forge.complete_qa_plan.";
1071
+ }
1072
+ }
1073
+ function toolDescription(phase, qaGate = null) {
1074
+ if (qaGate) {
1075
+ return qaGate === "qa_verdict"
1076
+ ? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
1077
+ : `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
1078
+ }
1079
+ if (isDebugCompletionPhase(phase)) {
1080
+ return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
1081
+ }
1082
+ if (phase === "implementation") {
1083
+ return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
1084
+ }
1085
+ if (phase === "self_review") {
1086
+ 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.";
1087
+ }
1088
+ if (phase === "qa") {
1089
+ 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.";
1090
+ }
1091
+ return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
1092
+ }
1093
+ function toolInputSchema(phase, qaGate = null) {
1094
+ const baseProperties = {
1095
+ status: {
1096
+ type: "string",
1097
+ enum: ["pass", "needs_rework", "fail", "failed"],
1098
+ description: "Use pass only when the phase evidence satisfies Forge's gate. Use needs_rework/fail/failed with explicit blockers or findings otherwise."
1099
+ },
1100
+ summary: { type: "string", description: "Concise phase summary." },
1101
+ report_markdown: { type: "string", description: "Optional human-readable report. Forge will generate a fallback report if omitted." },
1102
+ blockers: {
1103
+ type: "array",
1104
+ description: "Required for non-pass status unless findings, failures, known_gaps, remaining_risks, or commands provide explicit evidence.",
1105
+ items: evidenceObjectSchema(["reason"], {
1106
+ reason: { type: "string" },
1107
+ owner: { type: "string" },
1108
+ next_step: { type: "string" }
1109
+ })
1110
+ }
1111
+ };
1112
+ if (qaGate && qaGate !== "qa_verdict") {
1113
+ return qaGateToolInputSchema(qaGate);
1114
+ }
1115
+ if (isDebugCompletionPhase(phase)) {
1116
+ return debugToolInputSchema(phase, baseProperties);
1117
+ }
1118
+ if (phase === "implementation") {
1119
+ return objectToolSchema({
1120
+ ...baseProperties,
1121
+ plan: {
1122
+ type: "array",
1123
+ description: "Required for pass. Ordered implementation steps with status.",
1124
+ items: evidenceObjectSchema(["step", "status"], {
1125
+ step: { type: "string" },
1126
+ status: { type: "string" },
1127
+ evidence: { type: "string" }
1128
+ })
1129
+ },
1130
+ changed_files: {
1131
+ type: "array",
1132
+ description: "Required for pass. Must be non-empty unless known_gaps explains why.",
1133
+ items: evidenceObjectSchema(["path", "summary"], {
1134
+ path: { type: "string" },
1135
+ summary: { type: "string" }
1136
+ })
1137
+ },
1138
+ tests_added_or_updated: {
1139
+ type: "array",
1140
+ description: "Required for pass. Use an object explaining why no tests were needed for docs/config-only work if empty would be ambiguous.",
1141
+ items: evidenceObjectSchema(["path", "summary"], {
1142
+ path: { type: "string" },
1143
+ summary: { type: "string" }
1144
+ })
1145
+ },
1146
+ docs_added_or_updated: {
1147
+ type: "array",
1148
+ description: "Required for pass. Use an object explaining why no docs changed when appropriate.",
1149
+ items: evidenceObjectSchema(["path", "summary"], {
1150
+ path: { type: "string" },
1151
+ summary: { type: "string" }
1152
+ })
1153
+ },
1154
+ verification: {
1155
+ type: "array",
1156
+ description: "Implementation verification evidence. Objects should include command, exit_code, status, and summary when command-based.",
1157
+ items: commandEvidenceSchema()
1158
+ },
1159
+ acceptance_criteria: acceptanceCriteriaSchema(),
1160
+ known_gaps: {
1161
+ type: "array",
1162
+ description: "Required for pass, even when empty. If changed_files/tests/docs arrays are empty, explain why here.",
1163
+ items: evidenceObjectSchema(["summary"], {
1164
+ summary: { type: "string" },
1165
+ impact: { type: "string" },
1166
+ follow_up: { type: "string" }
1167
+ })
1168
+ },
1169
+ next_phase_context: {
1170
+ description: "Required for pass. Context the self-review phase should carry forward."
1171
+ }
1172
+ }, ["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.");
1173
+ }
1174
+ if (phase === "self_review") {
1175
+ return objectToolSchema({
1176
+ ...baseProperties,
1177
+ reviewed_diff: { type: "boolean", description: "Required for pass. True after reviewing the implementation diff." },
1178
+ findings: {
1179
+ type: "array",
1180
+ description: "Required for pass, even when empty. Include finding objects for issues discovered during review.",
1181
+ items: evidenceObjectSchema(["severity", "summary", "status"], {
1182
+ severity: { type: "string" },
1183
+ summary: { type: "string" },
1184
+ status: { type: "string" },
1185
+ file: { type: "string" }
1186
+ })
1187
+ },
1188
+ security_review: reviewSectionSchema(),
1189
+ edge_case_review: reviewSectionSchema(),
1190
+ design_review: {
1191
+ ...reviewSectionSchema(),
1192
+ properties: {
1193
+ ...reviewSectionProperties(),
1194
+ screenshots: { type: "array", items: evidenceObjectSchema(["path"], { path: { type: "string" }, summary: { type: "string" } }) }
1195
+ }
1196
+ },
1197
+ deterministic_comparison: {
1198
+ type: "object",
1199
+ description: "Required for pass. Evidence that the reviewed diff matches the intended deterministic change.",
1200
+ additionalProperties: true,
1201
+ properties: {
1202
+ status: { type: "string" },
1203
+ command_or_tool: { type: "string" },
1204
+ notes: { type: "string" }
1205
+ }
1206
+ },
1207
+ fixes_applied: {
1208
+ type: "array",
1209
+ description: "Required for pass, even when empty.",
1210
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, path: { type: "string" } })
1211
+ },
1212
+ remaining_risks: {
1213
+ type: "array",
1214
+ description: "Required for pass, even when empty.",
1215
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
1216
+ }
1217
+ }, ["status", "summary"], "For status pass, include reviewed_diff, findings, security_review, edge_case_review, design_review, deterministic_comparison, fixes_applied, and remaining_risks.");
1218
+ }
1219
+ if (phase === "qa") {
1220
+ return objectToolSchema({
1221
+ ...baseProperties,
1222
+ status: {
1223
+ type: "string",
1224
+ enum: ["pass", "needs_rework", "blocked"],
1225
+ description: "Use pass only when QA evidence satisfies acceptance criteria, needs_rework for actionable in-scope defects, or blocked for an inconclusive QA blocker."
1226
+ },
1227
+ checklist: {
1228
+ type: "array",
1229
+ description: "QA plan/checklist. Forge writes this to qa.plan.json; a default checklist is generated if omitted.",
1230
+ items: { type: "string" }
1231
+ },
1232
+ tested_acceptance_criteria: {
1233
+ type: "array",
1234
+ description: "Required and non-empty for pass/needs_rework. Criteria actually tested by QA.",
1235
+ items: { type: "string" }
1236
+ },
1237
+ commands: {
1238
+ type: "array",
1239
+ description: "Command evidence written to qa.evidence.json.",
1240
+ items: commandEvidenceSchema()
1241
+ },
1242
+ browser_checks: {
1243
+ type: "array",
1244
+ description: "Browser/manual UI checks with URL or route, action, observed result, screenshot/trace references, console/network notes, and status.",
1245
+ items: evidenceObjectSchema(["summary", "status"], {
1246
+ summary: { type: "string" },
1247
+ url: { type: "string" },
1248
+ route: { type: "string" },
1249
+ status: { type: "string" },
1250
+ screenshot: { type: "string" },
1251
+ trace: { type: "string" }
1252
+ })
1253
+ },
1254
+ sidecars: {
1255
+ type: "array",
1256
+ description: "Optional QA sidecar files already written under .forge/work-items/<id>/artifacts/qa/ with path/content_hash/bytes metadata.",
1257
+ items: evidenceObjectSchema(["path", "content_hash", "bytes"], {
1258
+ path: { type: "string" },
1259
+ content_hash: { type: "string" },
1260
+ bytes: { type: "number" }
1261
+ })
1262
+ },
1263
+ notes: { type: "string", description: "Additional QA evidence notes." },
1264
+ evidence: {
1265
+ type: "object",
1266
+ description: "Optional wrapper containing commands, browser_checks, and notes when a client prefers nested evidence."
1267
+ },
1268
+ findings: {
1269
+ type: "array",
1270
+ description: "QA findings. needs_rework requires at least one actionable in_scope finding.",
1271
+ items: evidenceObjectSchema(["id", "severity", "scope", "title"], {
1272
+ id: { type: "string" },
1273
+ severity: { type: "string", enum: ["critical", "major", "minor"] },
1274
+ scope: { type: "string", enum: ["in_scope", "out_of_scope", "unclear"] },
1275
+ title: { type: "string" },
1276
+ acceptance_criteria: { type: "array", items: { type: "string" } },
1277
+ repro_steps: { type: "array", items: { type: "string" } },
1278
+ expected: { type: "string" },
1279
+ actual: { type: "string" },
1280
+ evidence: { type: "object" },
1281
+ likely_area: { type: "array", items: { type: "string" } },
1282
+ required_fix: { type: "string" },
1283
+ required_regression: { type: "string" }
1284
+ })
1285
+ },
1286
+ candidate_bugs: {
1287
+ type: "array",
1288
+ description: "Out-of-scope/follow-up bug candidates. They do not block the current item unless attached as in-scope findings.",
1289
+ items: evidenceObjectSchema(["id", "title", "suggested_action"], {
1290
+ id: { type: "string" },
1291
+ title: { type: "string" },
1292
+ severity: { type: "string" },
1293
+ scope_reason: { type: "string" },
1294
+ repro_steps: { type: "array", items: { type: "string" } },
1295
+ evidence: { type: "object" },
1296
+ suggested_action: { type: "string", enum: ["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"] }
1297
+ })
1298
+ },
1299
+ blocker_reason: {
1300
+ type: "string",
1301
+ enum: [...qaBlockerReasons],
1302
+ description: "Required when status is blocked."
1303
+ }
1304
+ }, ["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.");
1305
+ }
1306
+ return objectToolSchema({
1307
+ ...baseProperties,
1308
+ commands: {
1309
+ type: "array",
1310
+ description: "Required for pass. Include at least one deterministic validation command with exit_code: 0 and status: pass.",
1311
+ items: commandEvidenceSchema()
1312
+ },
1313
+ acceptance_criteria: acceptanceCriteriaSchema(),
1314
+ build_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1315
+ test_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1316
+ lint_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1317
+ design_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
1318
+ drift_checks: {
1319
+ type: "array",
1320
+ description: "Required for pass. Deterministic checks for unintended drift.",
1321
+ items: evidenceObjectSchema(["command_or_tool", "status", "summary"], {
1322
+ command_or_tool: { type: "string" },
1323
+ status: { type: "string" },
1324
+ summary: { type: "string" }
1325
+ })
1326
+ },
1327
+ ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
1328
+ }, ["status", "summary"], "For status pass, commands must include at least one passing deterministic command, acceptance criteria entries must use status: satisfied plus evidence, and ready_for_dev_test must be true.");
1329
+ }
1330
+ function debugToolInputSchema(phase, baseProperties) {
1331
+ if (phase === "debug_repro") {
1332
+ return objectToolSchema({
1333
+ ...baseProperties,
1334
+ repro_steps: {
1335
+ type: "array",
1336
+ description: "Required for pass. Minimal reproduction or characterization steps.",
1337
+ items: { type: "string" }
1338
+ },
1339
+ observed: { type: "string", description: "Observed failure behavior." },
1340
+ expected: { type: "string", description: "Expected behavior." },
1341
+ evidence: {
1342
+ type: "array",
1343
+ description: "Required for pass. Concrete logs, commands, screenshots, file refs, or characterization notes.",
1344
+ items: { type: "object", additionalProperties: true }
1345
+ }
1346
+ }, ["status", "summary"], "For status pass, include repro_steps, observed, expected, and evidence.");
1347
+ }
1348
+ if (phase === "debug_root_cause") {
1349
+ return objectToolSchema({
1350
+ ...baseProperties,
1351
+ root_cause: { type: "string", description: "Likely root cause tied to code or config evidence." },
1352
+ evidence: {
1353
+ type: "array",
1354
+ description: "Required for pass. Concrete code/log/test evidence supporting the root cause.",
1355
+ items: { type: "object", additionalProperties: true }
1356
+ },
1357
+ eliminated_alternatives: {
1358
+ type: "array",
1359
+ description: "Required for pass. Plausible alternatives checked or ruled out.",
1360
+ items: { type: "object", additionalProperties: true }
1361
+ },
1362
+ risk_area: { type: "string", description: "Subsystem or behavior most at risk from the fix." }
1363
+ }, ["status", "summary"], "For status pass, include root_cause, evidence, eliminated_alternatives, and risk_area.");
1364
+ }
1365
+ if (phase === "debug_fix") {
1366
+ return objectToolSchema({
1367
+ ...baseProperties,
1368
+ changed_files: {
1369
+ type: "array",
1370
+ description: "Required for pass. Files changed by the bug fix.",
1371
+ items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
1372
+ },
1373
+ tests_added_or_updated: {
1374
+ type: "array",
1375
+ description: "Required for pass. Regression tests or checks added/updated, or justify omission in remaining_risks.",
1376
+ items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
1377
+ },
1378
+ remaining_risks: {
1379
+ type: "array",
1380
+ description: "Required for pass, even when empty. Include why no regression coverage changed when tests_added_or_updated is empty.",
1381
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
1382
+ },
1383
+ resolved_findings: {
1384
+ type: "array",
1385
+ description: "Required for QA rework. Entries should include qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.",
1386
+ items: { type: "object", additionalProperties: true }
1387
+ }
1388
+ }, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
1389
+ }
1390
+ return objectToolSchema({
1391
+ ...baseProperties,
1392
+ commands: {
1393
+ type: "array",
1394
+ description: "Required for pass. Targeted regression/repro-negative commands with exit_code: 0 and status: pass.",
1395
+ items: commandEvidenceSchema()
1396
+ },
1397
+ required_regressions: {
1398
+ type: "array",
1399
+ description: "Required for pass. Repro-negative and regression coverage checklist.",
1400
+ items: { type: "string" }
1401
+ },
1402
+ build_status: { type: "string", description: "Required for pass. pass or not_applicable." },
1403
+ test_status: { type: "string", description: "Required for pass. pass or not_applicable." },
1404
+ repro_negative: { type: "boolean", description: "Required for pass and must be true after the original repro no longer fails." },
1405
+ qa_ready: { type: "boolean", description: "Required for pass and must be true." }
1406
+ }, ["status", "summary"], "For status pass, commands must include at least one passing deterministic debug regression check, repro_negative must be true, and qa_ready must be true.");
1407
+ }
1408
+ function qaGateToolInputSchema(gate) {
1409
+ const baseProperties = {
1410
+ status: {
1411
+ type: "string",
1412
+ enum: ["pass", "skipped", "blocked"],
1413
+ description: "Use pass when this QA gate produced evidence, skipped when not applicable, or blocked when a prerequisite prevents this gate."
1414
+ },
1415
+ summary: { type: "string", description: "Concise QA gate summary." },
1416
+ reason: { type: "string", description: "Required for skipped or blocked gates." },
1417
+ blocker_reason: {
1418
+ type: "string",
1419
+ enum: [...qaBlockerReasons],
1420
+ description: "Required when status is blocked."
1421
+ }
1422
+ };
1423
+ if (gate === "qa_plan") {
1424
+ return objectToolSchema({
1425
+ ...baseProperties,
1426
+ checklist: {
1427
+ type: "array",
1428
+ description: "Required for pass. QA checklist entries planned for this work item.",
1429
+ items: { type: "string" }
1430
+ },
1431
+ acceptance_criteria: {
1432
+ type: "array",
1433
+ description: "Acceptance criteria selected for QA.",
1434
+ items: { type: "string" }
1435
+ }
1436
+ }, ["status", "summary"], "Record the QA plan gate. Pass requires a non-empty checklist.");
1437
+ }
1438
+ if (gate === "qa_findings") {
1439
+ return objectToolSchema({
1440
+ ...baseProperties,
1441
+ findings: {
1442
+ type: "array",
1443
+ description: "Normalized in-scope and out-of-scope QA findings.",
1444
+ items: { type: "object", additionalProperties: true }
1445
+ },
1446
+ candidate_bugs: {
1447
+ type: "array",
1448
+ description: "Out-of-scope or follow-up candidates.",
1449
+ items: { type: "object", additionalProperties: true }
1450
+ }
1451
+ }, ["status", "summary"], "Record normalized QA findings. Pass or skipped requires findings and candidate_bugs arrays, even when empty.");
1452
+ }
1453
+ return objectToolSchema({
1454
+ ...baseProperties,
1455
+ commands: {
1456
+ type: "array",
1457
+ description: "Command evidence for this gate when applicable.",
1458
+ items: commandEvidenceSchema()
1459
+ },
1460
+ browser_checks: {
1461
+ type: "array",
1462
+ description: "Browser/manual evidence for this gate when applicable.",
1463
+ items: { type: "object", additionalProperties: true }
1464
+ },
1465
+ notes: { type: "string", description: "Evidence notes for this gate." }
1466
+ }, ["status", "summary"], "Record this QA evidence gate. Use skipped with a reason when the gate is not applicable.");
1467
+ }
1468
+ function objectToolSchema(properties, required, description) {
1469
+ return {
1470
+ type: "object",
1471
+ additionalProperties: true,
1472
+ description,
1473
+ properties,
1474
+ required: [...required]
1475
+ };
1476
+ }
1477
+ function evidenceObjectSchema(required, properties) {
1478
+ return {
1479
+ type: "object",
1480
+ additionalProperties: true,
1481
+ properties,
1482
+ required: [...required]
1483
+ };
1484
+ }
1485
+ function commandEvidenceSchema() {
1486
+ return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
1487
+ command: { type: "string" },
1488
+ exit_code: { type: "number" },
1489
+ status: { type: "string" },
1490
+ summary: { type: "string" }
1491
+ });
1492
+ }
1493
+ function acceptanceCriteriaSchema() {
1494
+ return {
1495
+ type: "array",
1496
+ description: "Required for pass. Every entry must be an object with status: satisfied and non-empty evidence.",
1497
+ items: evidenceObjectSchema(["criterion", "evidence", "status"], {
1498
+ criterion: { type: "string" },
1499
+ evidence: { type: "string" },
1500
+ status: { type: "string", enum: ["satisfied"] }
1501
+ })
1502
+ };
1503
+ }
1504
+ function reviewSectionSchema() {
1505
+ return {
1506
+ type: "object",
1507
+ description: "Required for pass. Use status pass/fail/needs_rework/not_applicable plus notes.",
1508
+ additionalProperties: true,
1509
+ properties: reviewSectionProperties()
1510
+ };
1511
+ }
1512
+ function reviewSectionProperties() {
1513
+ return {
1514
+ status: { type: "string" },
1515
+ notes: { type: "string" }
1516
+ };
1517
+ }
1518
+ function normalizeCompletionStatus(value, phase) {
1519
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
1520
+ if (phase === "qa") {
1521
+ const canonical = normalized === "needsrework" ? "needs_rework" : normalized;
1522
+ if (canonical !== "pass" && canonical !== "needs_rework" && canonical !== "blocked") {
1523
+ throw new Error("QA status must be pass, needs_rework, or blocked.");
1524
+ }
1525
+ return canonical;
1526
+ }
1527
+ const canonical = normalized === "blocked" || normalized === "needsrework"
1528
+ ? "needs_rework"
1529
+ : normalized === "failed"
1530
+ ? "fail"
1531
+ : normalized;
1532
+ if (canonical !== "pass" && canonical !== "fail" && canonical !== "needs_rework") {
1533
+ throw new Error("status must be pass, fail, or needs_rework.");
1534
+ }
1535
+ return canonical;
1536
+ }
1537
+ function normalizeQaGateStatus(value) {
1538
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
1539
+ if (normalized !== "pass" && normalized !== "skipped" && normalized !== "blocked") {
1540
+ throw new Error("QA gate status must be pass, skipped, or blocked.");
1541
+ }
1542
+ return normalized;
1543
+ }
1544
+ function validatePayloadShape(phase, payload) {
1545
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
1546
+ if (!status) {
1547
+ throw new Error("status is required.");
1548
+ }
1549
+ requireField(payload, "summary");
1550
+ if (phase === "qa") {
1551
+ validateQaPayloadShape(payload, status);
1552
+ return;
1553
+ }
1554
+ const pass = status === "pass";
1555
+ if (!pass) {
1556
+ requireNonPassEvidence(payload);
1557
+ return;
1558
+ }
1559
+ if (isDebugCompletionPhase(phase)) {
1560
+ validateDebugPayloadShape(phase, payload);
1561
+ return;
1562
+ }
1563
+ if (phase === "implementation") {
1564
+ requireArray(payload, "plan");
1565
+ requireArray(payload, "changed_files");
1566
+ requireArray(payload, "tests_added_or_updated");
1567
+ requireArray(payload, "docs_added_or_updated");
1568
+ requireArray(payload, "acceptance_criteria");
1569
+ requireArray(payload, "known_gaps");
1570
+ requireField(payload, "next_phase_context");
1571
+ requireImplementationEvidence(payload);
1572
+ requireSatisfiedAcceptanceCriteria(payload);
1573
+ return;
1574
+ }
1575
+ if (phase === "self_review") {
1576
+ requireBoolean(payload, "reviewed_diff");
1577
+ requireArray(payload, "findings");
1578
+ requireObject(payload, "security_review");
1579
+ requireObject(payload, "edge_case_review");
1580
+ requireObject(payload, "design_review");
1581
+ requireObject(payload, "deterministic_comparison");
1582
+ requireArray(payload, "fixes_applied");
1583
+ requireArray(payload, "remaining_risks");
1584
+ return;
1585
+ }
1586
+ requireArray(payload, "commands");
1587
+ requireArray(payload, "acceptance_criteria");
1588
+ requireField(payload, "build_status");
1589
+ requireField(payload, "test_status");
1590
+ requireField(payload, "lint_status");
1591
+ requireField(payload, "design_status");
1592
+ requireArray(payload, "drift_checks");
1593
+ requireBoolean(payload, "ready_for_dev_test");
1594
+ if (payload.ready_for_dev_test !== true) {
1595
+ throw new Error("validation pass requires ready_for_dev_test: true.");
1596
+ }
1597
+ requirePassingCommands(payload);
1598
+ requireSatisfiedAcceptanceCriteria(payload);
1599
+ }
1600
+ function validateDebugPayloadShape(phase, payload) {
1601
+ if (phase === "debug_repro") {
1602
+ requireArray(payload, "repro_steps");
1603
+ requireField(payload, "observed");
1604
+ requireField(payload, "expected");
1605
+ requireArray(payload, "evidence");
1606
+ requireNonEmptyArray(payload, "repro_steps");
1607
+ requireNonEmptyArray(payload, "evidence");
1608
+ return;
1609
+ }
1610
+ if (phase === "debug_root_cause") {
1611
+ requireField(payload, "root_cause");
1612
+ requireArray(payload, "evidence");
1613
+ requireArray(payload, "eliminated_alternatives");
1614
+ requireField(payload, "risk_area");
1615
+ requireNonEmptyArray(payload, "evidence");
1616
+ return;
1617
+ }
1618
+ if (phase === "debug_fix") {
1619
+ requireArray(payload, "changed_files");
1620
+ requireArray(payload, "tests_added_or_updated");
1621
+ requireArray(payload, "remaining_risks");
1622
+ requireNonEmptyArray(payload, "changed_files");
1623
+ if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
1624
+ throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
1625
+ }
1626
+ return;
1627
+ }
1628
+ requireArray(payload, "commands");
1629
+ requireArray(payload, "required_regressions");
1630
+ requireField(payload, "build_status");
1631
+ requireField(payload, "test_status");
1632
+ requireBoolean(payload, "repro_negative");
1633
+ requireBoolean(payload, "qa_ready");
1634
+ if (payload.repro_negative !== true) {
1635
+ throw new Error("debug_regression pass requires repro_negative: true.");
1636
+ }
1637
+ if (payload.qa_ready !== true) {
1638
+ throw new Error("debug_regression pass requires qa_ready: true.");
1639
+ }
1640
+ requireAllowedStatus(payload, "build_status", ["pass", "not_applicable"]);
1641
+ requireAllowedStatus(payload, "test_status", ["pass", "not_applicable"]);
1642
+ requireNonEmptyArray(payload, "required_regressions");
1643
+ requirePassingCommands(payload);
1644
+ }
1645
+ function validateQaGatePayloadShape(gate, payload, status) {
1646
+ requireField(payload, "summary");
1647
+ if (status === "skipped") {
1648
+ requireField(payload, "reason");
1649
+ }
1650
+ if (status === "blocked") {
1651
+ const reason = normalizedString(optionalString(payload, "blocker_reason"));
1652
+ if (!qaBlockerReasons.has(reason)) {
1653
+ throw new Error("QA gate blocked requires blocker_reason to be one of missing_app_launch_target, inconclusive, environment_failure, missing_validation_evidence, missing_worktree, or runner_failure.");
1654
+ }
1655
+ }
1656
+ if (gate === "qa_plan" && status === "pass") {
1657
+ const checklist = qaStringArray(payload, "checklist");
1658
+ if (checklist.length === 0) {
1659
+ throw new Error("complete_qa_plan pass requires checklist with at least one entry.");
1660
+ }
1661
+ }
1662
+ if (gate === "qa_findings" && status !== "blocked") {
1663
+ qaObjectArray(payload, "findings");
1664
+ qaObjectArray(payload, "candidate_bugs");
1665
+ }
1666
+ if ((gate === "qa_regression" || gate === "qa_api") && status === "pass") {
1667
+ const commands = qaArray(payload, "commands");
1668
+ if (commands.length === 0) {
1669
+ throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
1670
+ }
1671
+ }
1672
+ if (gate === "qa_manual_web" && status === "pass") {
1673
+ const browserChecks = qaArray(payload, "browser_checks");
1674
+ const notes = optionalString(payload, "notes");
1675
+ if (browserChecks.length === 0 && !notes) {
1676
+ throw new Error(`${qaGateToolNames[gate]} pass requires browser_checks or notes.`);
1677
+ }
1678
+ }
1679
+ }
1680
+ function validateQaPayloadShape(payload, status) {
1681
+ const evidenceSource = qaEvidenceSource(payload);
1682
+ const commands = qaArray(payload, "commands", evidenceSource);
1683
+ const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
1684
+ const sidecars = qaArray(payload, "sidecars", evidenceSource);
1685
+ const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
1686
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
1687
+ const findings = qaObjectArray(payload, "findings");
1688
+ const candidateBugs = qaObjectArray(payload, "candidate_bugs");
1689
+ const explicitFindingIds = qaStringArray(payload, "finding_ids");
1690
+ const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
1691
+ if (payload.schema_version !== undefined && payload.schema_version !== 1) {
1692
+ throw new Error("QA schema_version must be numeric 1 when provided.");
1693
+ }
1694
+ if (status === "pass" || status === "needs_rework") {
1695
+ if (testedAcceptanceCriteria.length === 0) {
1696
+ throw new Error("QA pass/needs_rework requires tested_acceptance_criteria with at least one criterion.");
1697
+ }
1698
+ if (commands.length === 0 && browserChecks.length === 0 && notes.length === 0) {
1699
+ throw new Error("QA pass/needs_rework requires at least one command, browser check, or evidence note.");
1700
+ }
1701
+ }
1702
+ for (const [index, command] of commands.entries()) {
1703
+ requireEvidenceRecord(command, `commands[${index}]`);
1704
+ const record = command;
1705
+ requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
1706
+ requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
1707
+ requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
1708
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
1709
+ throw new Error(`commands[${index}].exit_code is required.`);
1710
+ }
1711
+ if (status === "pass" && record.exit_code !== 0) {
1712
+ throw new Error(`commands[${index}].exit_code must be 0 when QA status is pass.`);
1713
+ }
1714
+ if (status === "pass" && normalizedString(record.status) !== "pass") {
1715
+ throw new Error(`commands[${index}].status must be pass when QA status is pass.`);
1716
+ }
1717
+ }
1718
+ for (const [index, browserCheck] of browserChecks.entries()) {
1719
+ requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
1720
+ const record = browserCheck;
1721
+ requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
1722
+ requireNonEmptyStringRecord(record, "status", `browser_checks[${index}].`);
1723
+ const checkStatus = optionalString(record, "status");
1724
+ if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
1725
+ throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
1726
+ }
1727
+ if (!optionalString(record, "url") && !optionalString(record, "route")) {
1728
+ throw new Error(`browser_checks[${index}] must include url or route.`);
1729
+ }
1730
+ }
1731
+ for (const [index, sidecar] of sidecars.entries()) {
1732
+ requireEvidenceRecord(sidecar, `sidecars[${index}]`);
1733
+ const record = sidecar;
1734
+ requireNonEmptyStringRecord(record, "path", `sidecars[${index}].`);
1735
+ requireNonEmptyStringRecord(record, "content_hash", `sidecars[${index}].`);
1736
+ if (typeof record.bytes !== "number" || !Number.isFinite(record.bytes) || record.bytes <= 0) {
1737
+ throw new Error(`sidecars[${index}].bytes must be greater than 0.`);
1738
+ }
1739
+ }
1740
+ for (const [index, finding] of findings.entries()) {
1741
+ requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
1742
+ requireNonEmptyStringRecord(finding, "title", `findings[${index}].`);
1743
+ requireKnownStringRecord(finding, "severity", qaFindingSeverities, `findings[${index}].`);
1744
+ requireKnownStringRecord(finding, "scope", qaFindingScopes, `findings[${index}].`);
1745
+ if (normalizedString(finding.scope) === "in_scope") {
1746
+ validateActionableQaFinding(finding, index);
1747
+ }
1748
+ }
1749
+ for (const [index, candidateBug] of candidateBugs.entries()) {
1750
+ requireNonEmptyStringRecord(candidateBug, "id", `candidate_bugs[${index}].`);
1751
+ requireNonEmptyStringRecord(candidateBug, "title", `candidate_bugs[${index}].`);
1752
+ requireKnownStringRecord(candidateBug, "suggested_action", qaCandidateBugActions, `candidate_bugs[${index}].`);
1753
+ }
1754
+ const inScopeFindingIds = findings
1755
+ .filter((finding) => normalizedString(finding.scope) === "in_scope")
1756
+ .map((finding) => optionalString(finding, "id"))
1757
+ .filter((id) => Boolean(id));
1758
+ for (const findingId of explicitFindingIds) {
1759
+ if (!inScopeFindingIds.includes(findingId)) {
1760
+ throw new Error(`QA finding_ids contains '${findingId}', but findings has no matching in-scope finding.`);
1761
+ }
1762
+ }
1763
+ const candidateBugIds = candidateBugs
1764
+ .map((candidateBug) => optionalString(candidateBug, "id"))
1765
+ .filter((id) => Boolean(id));
1766
+ for (const candidateBugId of explicitCandidateBugIds) {
1767
+ if (!candidateBugIds.includes(candidateBugId)) {
1768
+ throw new Error(`QA candidate_bug_ids contains '${candidateBugId}', but candidate_bugs has no matching candidate bug.`);
1769
+ }
1770
+ }
1771
+ if (status === "pass") {
1772
+ if (explicitFindingIds.length > 0 || inScopeFindingIds.length > 0) {
1773
+ throw new Error("QA pass must not include in-scope finding_ids or in-scope findings.");
1774
+ }
1775
+ if (optionalString(payload, "blocker_reason")) {
1776
+ throw new Error("QA pass must not include blocker_reason.");
1777
+ }
1778
+ }
1779
+ if (status === "needs_rework") {
1780
+ if (inScopeFindingIds.length === 0) {
1781
+ throw new Error("QA needs_rework requires at least one actionable in-scope finding.");
1782
+ }
1783
+ if (optionalString(payload, "blocker_reason")) {
1784
+ throw new Error("QA needs_rework must not include blocker_reason.");
1785
+ }
1786
+ }
1787
+ if (status === "blocked") {
1788
+ const reason = normalizedString(optionalString(payload, "blocker_reason"));
1789
+ if (!qaBlockerReasons.has(reason)) {
1790
+ 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.");
1791
+ }
1792
+ }
1793
+ }
1794
+ function validateActionableQaFinding(finding, index) {
1795
+ const prefix = `findings[${index}].`;
1796
+ requireNonEmptyStringArrayRecord(finding, "acceptance_criteria", prefix);
1797
+ requireNonEmptyStringArrayRecord(finding, "repro_steps", prefix);
1798
+ requireNonEmptyStringRecord(finding, "expected", prefix);
1799
+ requireNonEmptyStringRecord(finding, "actual", prefix);
1800
+ requireSubstantiveEvidenceRecord(finding, prefix);
1801
+ requireNonEmptyStringArrayRecord(finding, "likely_area", prefix);
1802
+ requireNonEmptyStringRecord(finding, "required_fix", prefix);
1803
+ requireNonEmptyStringRecord(finding, "required_regression", prefix);
1804
+ }
1805
+ function validateOptionalWorkItemId(payload, expectedWorkItemId) {
1806
+ const actual = optionalString(payload, "work_item_id") ?? optionalString(payload, "workItemId");
1807
+ if (actual && actual !== expectedWorkItemId) {
1808
+ throw new Error(`completion payload work item ${actual} does not match dispatch work item ${expectedWorkItemId}.`);
1809
+ }
1810
+ }
1811
+ function normalizeArtifactPayload(payload, defaults) {
1812
+ const normalized = stripUndefined({ ...payload });
1813
+ delete normalized.report_markdown;
1814
+ delete normalized.content_hash;
1815
+ delete normalized.contentHash;
1816
+ normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
1817
+ normalized.phase = defaults.phase;
1818
+ normalized.work_item_id = defaults.workItemId;
1819
+ const ownerFields = artifactOwnerFields(defaults);
1820
+ Object.assign(normalized, ownerFields);
1821
+ normalized.status = defaults.status;
1822
+ normalized.produced_by = defaults.producedBy;
1823
+ return normalized;
1824
+ }
1825
+ function normalizeQaGateArtifactPayload(payload, defaults) {
1826
+ const normalized = stripUndefined({ ...payload });
1827
+ delete normalized.report_markdown;
1828
+ delete normalized.content_hash;
1829
+ delete normalized.contentHash;
1830
+ normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
1831
+ normalized.phase = "qa";
1832
+ normalized.gate = defaults.gate;
1833
+ normalized.work_item_id = defaults.workItemId;
1834
+ const ownerFields = artifactOwnerFields(defaults);
1835
+ Object.assign(normalized, ownerFields);
1836
+ normalized.status = defaults.status;
1837
+ normalized.produced_by = defaults.producedBy;
1838
+ if (defaults.gate === "qa_plan") {
1839
+ const checklist = qaStringArray(normalized, "checklist");
1840
+ normalized.checklist = checklist.length > 0 ? checklist : defaultQaChecklist(defaults.status);
1841
+ normalized.acceptance_criteria = qaStringArray(normalized, "acceptance_criteria");
1842
+ }
1843
+ if (defaults.gate === "qa_findings") {
1844
+ normalized.findings = qaObjectArray(normalized, "findings");
1845
+ normalized.candidate_bugs = qaObjectArray(normalized, "candidate_bugs");
1846
+ }
1847
+ return normalized;
1848
+ }
1849
+ function reportMarkdown(payload, phase, status, producedBy, contentHash) {
1850
+ const explicit = optionalString(payload, "report_markdown");
1851
+ if (explicit) {
1852
+ return explicit.endsWith("\n") ? explicit : `${explicit}\n`;
1853
+ }
1854
+ const summary = optionalString(payload, "summary") ?? "No summary provided.";
1855
+ return `# Forge ${phase.replace(/_/g, " ")} completion
1856
+
1857
+ Status: ${status}
1858
+ Produced by: ${producedBy}
1859
+ Content hash: ${contentHash}
1860
+
1861
+ ## Summary
1862
+
1863
+ ${summary}
1864
+ `;
1865
+ }
1866
+ function parseStrictCompletionPhase(value) {
1867
+ const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
1868
+ const debugPhase = normalizeDebugCompletionPhase(normalized);
1869
+ if (debugPhase) {
1870
+ return debugPhase;
1871
+ }
1872
+ if (normalized === "implementation" || normalized === "implement" || normalized === "develop") {
1873
+ return "implementation";
1874
+ }
1875
+ if (normalized === "self_review" || normalized === "selfreview" || normalized === "review") {
1876
+ return "self_review";
1877
+ }
1878
+ if (normalized === "validate" || normalized === "validation") {
1879
+ return "validation";
1880
+ }
1881
+ if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
1882
+ return "qa";
1883
+ }
1884
+ return null;
1885
+ }
1886
+ function normalizeDebugCompletionPhase(value) {
1887
+ const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
1888
+ switch (normalized) {
1889
+ case "debug_repro":
1890
+ case "debugrepro":
1891
+ case "repro":
1892
+ case "reproduce":
1893
+ return "debug_repro";
1894
+ case "debug_root_cause":
1895
+ case "debugrootcause":
1896
+ case "root_cause":
1897
+ case "rootcause":
1898
+ return "debug_root_cause";
1899
+ case "debug_fix":
1900
+ case "debugfix":
1901
+ case "fix":
1902
+ return "debug_fix";
1903
+ case "debug_regression":
1904
+ case "debugregression":
1905
+ case "regression":
1906
+ return "debug_regression";
1907
+ default:
1908
+ return null;
1909
+ }
1910
+ }
1911
+ function isDevelopCompletionPhase(phase) {
1912
+ return phase === "implementation" || phase === "self_review" || phase === "validation";
1913
+ }
1914
+ function isDebugCompletionPhase(phase) {
1915
+ return debugCompletionPhaseOrder.includes(phase);
1916
+ }
1917
+ function asDebugCompletionPhase(phase) {
1918
+ if (isDebugCompletionPhase(phase)) {
1919
+ return phase;
1920
+ }
1921
+ throw new Error(`Expected Debug completion phase, got ${phase}.`);
1922
+ }
1923
+ function parseCompletionRedactionSecrets(raw) {
1924
+ if (!raw?.trim()) {
1925
+ return [];
1926
+ }
1927
+ try {
1928
+ const parsed = JSON.parse(raw);
1929
+ if (!Array.isArray(parsed)) {
1930
+ return [];
1931
+ }
1932
+ return parsed.filter((value) => typeof value === "string" && value.length > 0);
1933
+ }
1934
+ catch {
1935
+ return [];
1936
+ }
1937
+ }
1938
+ function phaseFromToolName(toolName) {
1939
+ const normalized = toolName.trim().toLowerCase();
1940
+ if (normalized === "forge.complete_implementation" || normalized === "complete_implementation") {
1941
+ return "implementation";
1942
+ }
1943
+ if (normalized === "forge.complete_self_review" || normalized === "complete_self_review") {
1944
+ return "self_review";
1945
+ }
1946
+ if (normalized === "forge.complete_validation" || normalized === "complete_validation") {
1947
+ return "validation";
1948
+ }
1949
+ if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
1950
+ return "qa";
1951
+ }
1952
+ const debugGate = debugGateFromToolName(toolName);
1953
+ if (debugGate) {
1954
+ return debugGate;
1955
+ }
1956
+ if (qaGateFromToolName(toolName)) {
1957
+ return "qa";
1958
+ }
1959
+ throw new Error(`Unknown Forge completion tool: ${toolName}`);
1960
+ }
1961
+ function debugGateFromToolName(toolName) {
1962
+ const normalized = toolName.trim().toLowerCase();
1963
+ for (const gate of debugCompletionPhaseOrder) {
1964
+ const base = debugGateToolNames[gate];
1965
+ if (normalized === `forge.${base}` || normalized === base) {
1966
+ return gate;
1967
+ }
1968
+ }
1969
+ return null;
1970
+ }
1971
+ function qaGateFromToolName(toolName) {
1972
+ const normalized = toolName.trim().toLowerCase();
1973
+ for (const gate of qaCompletionGateOrder) {
1974
+ const base = qaGateToolNames[gate];
1975
+ if (normalized === `forge.${base}` || normalized === base) {
1976
+ return gate;
1977
+ }
1978
+ }
1979
+ return null;
1980
+ }
1981
+ function requireField(payload, key) {
1982
+ if (payload[key] === undefined || payload[key] === null || (typeof payload[key] === "string" && payload[key].trim().length === 0)) {
1983
+ throw new Error(`${key} is required.`);
1984
+ }
1985
+ }
1986
+ function requireNonPassEvidence(payload) {
1987
+ const evidenceKeys = ["blockers", "known_gaps", "findings", "remaining_risks", "commands", "failures"];
1988
+ const hasEvidence = evidenceKeys.some((key) => {
1989
+ const value = payload[key];
1990
+ if (typeof value === "string") {
1991
+ return value.trim().length > 0;
1992
+ }
1993
+ if (Array.isArray(value)) {
1994
+ return value.length > 0;
1995
+ }
1996
+ return Boolean(value && typeof value === "object");
1997
+ });
1998
+ if (!hasEvidence) {
1999
+ throw new Error("non-pass completion requires explicit blockers, findings, failures, or known gaps.");
2000
+ }
2001
+ }
2002
+ function requireImplementationEvidence(payload) {
2003
+ const knownGaps = payload.known_gaps;
2004
+ const hasKnownGap = Array.isArray(knownGaps) && knownGaps.length > 0;
2005
+ for (const key of ["changed_files", "tests_added_or_updated", "docs_added_or_updated"]) {
2006
+ const value = payload[key];
2007
+ if (Array.isArray(value) && value.length === 0 && !hasKnownGap) {
2008
+ throw new Error(`${key} is empty; known_gaps must explain why.`);
2009
+ }
2010
+ }
2011
+ }
2012
+ function requirePassingCommands(payload) {
2013
+ const commands = payload.commands;
2014
+ if (!Array.isArray(commands)) {
2015
+ return;
2016
+ }
2017
+ if (commands.length === 0) {
2018
+ throw new Error("commands must include at least one deterministic validation command.");
2019
+ }
2020
+ for (const [index, command] of commands.entries()) {
2021
+ if (!command || typeof command !== "object" || Array.isArray(command)) {
2022
+ throw new Error(`commands[${index}] must be an object.`);
2023
+ }
2024
+ const record = command;
2025
+ if (typeof record.command !== "string" || record.command.trim().length === 0) {
2026
+ throw new Error(`commands[${index}].command must be a non-empty string.`);
2027
+ }
2028
+ if (typeof record.summary !== "string" || record.summary.trim().length === 0) {
2029
+ throw new Error(`commands[${index}].summary must be a non-empty string.`);
2030
+ }
2031
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
2032
+ throw new Error(`commands[${index}].exit_code must be a finite number.`);
2033
+ }
2034
+ if (record.exit_code !== 0) {
2035
+ throw new Error(`commands[${index}].exit_code must be 0 when status is pass.`);
2036
+ }
2037
+ const status = typeof record.status === "string" ? record.status.trim().toLowerCase() : "";
2038
+ if (status !== "pass") {
2039
+ throw new Error(`commands[${index}].status must be pass.`);
2040
+ }
2041
+ }
2042
+ }
2043
+ function requireArray(payload, key) {
2044
+ if (!Array.isArray(payload[key])) {
2045
+ throw new Error(`${key} must be an array.`);
2046
+ }
2047
+ }
2048
+ function requireNonEmptyArray(payload, key) {
2049
+ requireArray(payload, key);
2050
+ if (arrayLength(payload, key) === 0) {
2051
+ throw new Error(`${key} must include at least one value.`);
2052
+ }
2053
+ }
2054
+ function arrayLength(payload, key) {
2055
+ const value = payload[key];
2056
+ return Array.isArray(value) ? value.length : 0;
2057
+ }
2058
+ function requireAllowedStatus(payload, key, allowed) {
2059
+ const value = normalizedString(optionalString(payload, key));
2060
+ if (!allowed.includes(value)) {
2061
+ throw new Error(`${key} must be one of ${allowed.join(", ")}.`);
2062
+ }
2063
+ }
2064
+ function requireObject(payload, key) {
2065
+ const value = payload[key];
2066
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2067
+ throw new Error(`${key} must be an object.`);
2068
+ }
2069
+ }
2070
+ function requireBoolean(payload, key) {
2071
+ if (typeof payload[key] !== "boolean") {
2072
+ throw new Error(`${key} must be a boolean.`);
2073
+ }
2074
+ }
2075
+ function requireSatisfiedAcceptanceCriteria(payload) {
2076
+ const criteria = payload.acceptance_criteria;
2077
+ if (!Array.isArray(criteria)) {
2078
+ return;
2079
+ }
2080
+ for (const [index, criterion] of criteria.entries()) {
2081
+ if (!criterion || typeof criterion !== "object" || Array.isArray(criterion)) {
2082
+ throw new Error(`acceptance_criteria[${index}] must be an object.`);
2083
+ }
2084
+ const record = criterion;
2085
+ const status = optionalString(record, "status")?.toLowerCase();
2086
+ const evidence = optionalString(record, "evidence");
2087
+ if (status !== "satisfied" || !evidence) {
2088
+ throw new Error(`acceptance_criteria[${index}] must be satisfied with evidence for status pass.`);
2089
+ }
2090
+ }
2091
+ }
2092
+ function qaEvidenceSource(payload) {
2093
+ const evidence = payload.evidence;
2094
+ return evidence && typeof evidence === "object" && !Array.isArray(evidence)
2095
+ ? evidence
2096
+ : {};
2097
+ }
2098
+ function qaArray(payload, key, fallback = {}) {
2099
+ const value = payload[key] ?? fallback[key];
2100
+ if (value === undefined || value === null) {
2101
+ return [];
2102
+ }
2103
+ if (!Array.isArray(value)) {
2104
+ throw new Error(`${key} must be an array.`);
2105
+ }
2106
+ return value;
2107
+ }
2108
+ function qaObjectArray(payload, key) {
2109
+ return qaArray(payload, key).map((entry, index) => {
2110
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
2111
+ throw new Error(`${key}[${index}] must be an object.`);
2112
+ }
2113
+ return entry;
2114
+ });
2115
+ }
2116
+ function qaStringArray(payload, key) {
2117
+ const value = payload[key];
2118
+ if (value === undefined || value === null) {
2119
+ return [];
2120
+ }
2121
+ if (!Array.isArray(value)) {
2122
+ throw new Error(`${key} must be an array.`);
2123
+ }
2124
+ return value.map((entry, index) => {
2125
+ if (typeof entry !== "string" || entry.trim().length === 0) {
2126
+ throw new Error(`${key}[${index}] must be a non-empty string.`);
2127
+ }
2128
+ return entry;
2129
+ });
2130
+ }
2131
+ function requireEvidenceRecord(value, label) {
2132
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2133
+ throw new Error(`${label} must be an object.`);
2134
+ }
2135
+ }
2136
+ function requireNonEmptyStringRecord(source, key, prefix = "") {
2137
+ if (!optionalString(source, key)) {
2138
+ throw new Error(`${prefix}${key} is required.`);
2139
+ }
2140
+ }
2141
+ function requireKnownStringRecord(source, key, allowed, prefix = "") {
2142
+ const value = normalizedString(optionalString(source, key));
2143
+ if (!allowed.has(value)) {
2144
+ throw new Error(`${prefix}${key} must be one of ${[...allowed].sort().join(", ")}.`);
2145
+ }
2146
+ }
2147
+ function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
2148
+ const value = source[key];
2149
+ if (!Array.isArray(value) || value.length === 0) {
2150
+ throw new Error(`${prefix}${key} must include at least one value.`);
2151
+ }
2152
+ for (const [index, entry] of value.entries()) {
2153
+ if (typeof entry !== "string" || entry.trim().length === 0) {
2154
+ throw new Error(`${prefix}${key}[${index}] must be a non-empty string.`);
2155
+ }
2156
+ }
2157
+ }
2158
+ function requireSubstantiveEvidenceRecord(source, prefix = "") {
2159
+ const evidence = source.evidence;
2160
+ if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
2161
+ throw new Error(`${prefix}evidence must be an object.`);
2162
+ }
2163
+ if (!Object.values(evidence).some(hasSubstantiveJsonValue)) {
2164
+ throw new Error(`${prefix}evidence must include at least one concrete evidence reference or summary.`);
2165
+ }
2166
+ }
2167
+ function hasSubstantiveJsonValue(value) {
2168
+ if (typeof value === "string") {
2169
+ return value.trim().length > 0;
2170
+ }
2171
+ if (Array.isArray(value)) {
2172
+ return value.length > 0;
2173
+ }
2174
+ if (value && typeof value === "object") {
2175
+ return Object.keys(value).length > 0;
2176
+ }
2177
+ return false;
2178
+ }
2179
+ function normalizedString(value) {
2180
+ return typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
2181
+ }
2182
+ function normalizedOwnerType(value) {
2183
+ const normalized = normalizedString(value);
2184
+ return normalized === "work_item" || normalized === "story" ? normalized : undefined;
2185
+ }
2186
+ function artifactOwnerFields(config) {
2187
+ const ownerType = normalizedOwnerType(config.ownerType);
2188
+ const ownerId = config.ownerId?.trim();
2189
+ if (!ownerType && !ownerId) {
2190
+ return {};
2191
+ }
2192
+ return {
2193
+ owner_type: ownerType ?? "work_item",
2194
+ owner_id: ownerId || config.workItemId
2195
+ };
2196
+ }
2197
+ function artifactReferenceOwnerFields(config) {
2198
+ const owner = artifactOwnerFields(config);
2199
+ return owner.owner_type && owner.owner_id
2200
+ ? { ownerType: owner.owner_type, ownerId: owner.owner_id }
2201
+ : {};
2202
+ }
2203
+ function artifactOwnerDirectory(ownerType) {
2204
+ return normalizedOwnerType(ownerType) === "story" ? "stories" : "work-items";
2205
+ }
2206
+ function artifactOwnerRelativeRoot(ownerType, ownerId, workItemId) {
2207
+ const owner = artifactOwnerFields({ workItemId, ownerType, ownerId });
2208
+ return owner.owner_type && owner.owner_id
2209
+ ? [".forge", artifactOwnerDirectory(owner.owner_type), owner.owner_id, "artifacts"].join("/")
2210
+ : [".forge", "work-items", workItemId, "artifacts"].join("/");
2211
+ }
2212
+ function defaultQaChecklist(status) {
2213
+ return [
2214
+ "Review the validated work item against acceptance criteria.",
2215
+ "Inspect implementation, self-review, and validation evidence when available.",
2216
+ status === "blocked" ? "Record the blocker reason preventing a conclusive QA verdict." : "Record concrete QA evidence and findings."
2217
+ ];
2218
+ }
2219
+ function qaArtifactRelativePath(workItemId, fileName) {
2220
+ return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
2221
+ }
2222
+ function qaArtifactRelativePathForOwner(ownerType, ownerId, fileName) {
2223
+ return [".forge", artifactOwnerDirectory(ownerType), ownerId, "artifacts", fileName].join("/");
2224
+ }
2225
+ function jsonText(value) {
2226
+ return `${JSON.stringify(value, null, 2)}\n`;
2227
+ }
2228
+ function fallbackReport(parsed, phase, artifactPath) {
2229
+ return `# Forge ${phase.replace(/_/g, " ")} completion
2230
+
2231
+ Artifact: ${artifactPath}
2232
+ Status: ${optionalString(parsed, "status") ?? "unknown"}
2233
+ `;
2234
+ }
2235
+ function parseJsonObject(content, label) {
2236
+ try {
2237
+ return asRecord(JSON.parse(content), label);
2238
+ }
2239
+ catch (error) {
2240
+ throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`);
2241
+ }
2242
+ }
2243
+ function parseOptionalJsonObject(content, label) {
2244
+ if (!content.trim()) {
2245
+ return {};
2246
+ }
2247
+ return parseJsonObject(content, label);
2248
+ }
2249
+ function asRecord(value, label) {
2250
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2251
+ throw new Error(`${label} must be an object.`);
2252
+ }
2253
+ return value;
2254
+ }
2255
+ function asMutableRecord(value) {
2256
+ return value && typeof value === "object" && !Array.isArray(value)
2257
+ ? { ...value }
2258
+ : {};
2259
+ }
2260
+ function requiredString(source, key) {
2261
+ const value = source[key];
2262
+ if (typeof value !== "string" || value.trim().length === 0) {
2263
+ throw new Error(`${key} must be a non-empty string.`);
2264
+ }
2265
+ return value;
2266
+ }
2267
+ function optionalString(source, key) {
2268
+ const value = source[key];
2269
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
2270
+ }
2271
+ function numberOrDefault(value, fallback) {
2272
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
2273
+ }
2274
+ function stripUndefined(value) {
2275
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
2276
+ }
2277
+ function safePathSegment(value, label) {
2278
+ if (!value.trim() || value.includes("/") || value.includes("\\") || value === "." || value === "..") {
2279
+ throw new Error(`${label} is not a safe path segment.`);
2280
+ }
2281
+ return value;
2282
+ }
2283
+ async function readOptionalFile(filePath) {
2284
+ return await fs.readFile(filePath, "utf8").catch((error) => {
2285
+ if (isNodeError(error) && error.code === "ENOENT") {
2286
+ return null;
2287
+ }
2288
+ throw error;
2289
+ });
2290
+ }
2291
+ async function writeFileAtomic(filePath, content) {
2292
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
2293
+ const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
2294
+ await fs.writeFile(tempPath, content, "utf8");
2295
+ await fs.rename(tempPath, filePath);
2296
+ }
2297
+ function stableStringify(value) {
2298
+ return JSON.stringify(sortJson(value));
2299
+ }
2300
+ function sortJson(value) {
2301
+ if (Array.isArray(value)) {
2302
+ return value.map(sortJson);
2303
+ }
2304
+ if (value && typeof value === "object") {
2305
+ return Object.fromEntries(Object.entries(value)
2306
+ .filter(([, entry]) => entry !== undefined)
2307
+ .sort(([left], [right]) => left.localeCompare(right))
2308
+ .map(([key, entry]) => [key, sortJson(entry)]));
2309
+ }
2310
+ return value;
2311
+ }
2312
+ function sha256(content) {
2313
+ return crypto.createHash("sha256").update(content).digest("hex");
2314
+ }
2315
+ function forgeBridgeCliBinPath() {
2316
+ return fileURLToPath(new URL("../cli/bin.js", import.meta.url));
2317
+ }
2318
+ function tomlString(value) {
2319
+ return JSON.stringify(value);
2320
+ }
2321
+ function tomlStringArray(values) {
2322
+ return `[${values.map(tomlString).join(", ")}]`;
2323
+ }
2324
+ function writeJsonRpc(message) {
2325
+ process.stdout.write(`${JSON.stringify(message)}\n`);
2326
+ }
2327
+ function rpcError(code, message) {
2328
+ return { code, message };
2329
+ }
2330
+ function asJsonRpcMessage(value) {
2331
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2332
+ return null;
2333
+ }
2334
+ const message = value;
2335
+ if (message.jsonrpc !== "2.0" || typeof message.method !== "string") {
2336
+ return null;
2337
+ }
2338
+ const id = message.id;
2339
+ return {
2340
+ id: typeof id === "string" || typeof id === "number" || id === null ? id : undefined,
2341
+ method: message.method,
2342
+ params: message.params
2343
+ };
2344
+ }
2345
+ function errorMessage(error) {
2346
+ return error instanceof Error ? error.message : String(error);
2347
+ }
2348
+ function isNodeError(error) {
2349
+ return error instanceof Error && "code" in error;
2350
+ }