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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,21 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { redactSecrets } from "../shared/redaction/redaction.js";
5
6
  const toolServerName = "forge";
7
+ const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
8
+ const completionPhaseOrder = ["implementation", "self_review", "validation"];
9
+ const qaBlockerReasons = new Set([
10
+ "missing_app_launch_target",
11
+ "inconclusive",
12
+ "environment_failure",
13
+ "missing_validation_evidence",
14
+ "missing_worktree",
15
+ "runner_failure"
16
+ ]);
17
+ const qaFindingSeverities = new Set(["critical", "major", "minor"]);
18
+ const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
19
+ const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
6
20
  const phaseFiles = {
7
21
  implementation: {
8
22
  jsonFileName: "implementation.completeness.json",
@@ -15,6 +29,10 @@ const phaseFiles = {
15
29
  validation: {
16
30
  jsonFileName: "validation.verdict.json",
17
31
  reportFileName: "validation.report.md"
32
+ },
33
+ qa: {
34
+ jsonFileName: "qa.verdict.json",
35
+ reportFileName: "qa.report.md"
18
36
  }
19
37
  };
20
38
  export function completionPhaseFromDispatch(dispatch) {
@@ -28,14 +46,28 @@ export function normalizeCompletionPhase(value) {
28
46
  if (normalized === "validate" || normalized === "validation") {
29
47
  return "validation";
30
48
  }
49
+ if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
50
+ return "qa";
51
+ }
31
52
  return "implementation";
32
53
  }
54
+ export function usesDevelopCycleCompletion(dispatch) {
55
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
56
+ const role = dispatch.execution.role.trim().toLowerCase();
57
+ const lane = dispatch.execution.lane.trim().toLowerCase();
58
+ return kind === "develop"
59
+ && role === "develop"
60
+ && lane === "develop"
61
+ && completionPhaseFromDispatch(dispatch) === "implementation";
62
+ }
33
63
  export function completionToolNamesForPhase(phase) {
34
64
  const base = phase === "implementation"
35
65
  ? "complete_implementation"
36
66
  : phase === "self_review"
37
67
  ? "complete_self_review"
38
- : "complete_validation";
68
+ : phase === "validation"
69
+ ? "complete_validation"
70
+ : "complete_qa";
39
71
  return [`forge.${base}`, base];
40
72
  }
41
73
  export function expectedCompletionToolName(phase) {
@@ -58,7 +90,8 @@ export async function prepareCompletionToolRuntime(options) {
58
90
  worktreePath,
59
91
  workItemId,
60
92
  phase,
61
- producedBy: `${options.dispatch.execution.provider}-cli`
93
+ producedBy: `${options.dispatch.execution.provider}-cli`,
94
+ developCycle: usesDevelopCycleCompletion(options.dispatch)
62
95
  });
63
96
  const runtime = {
64
97
  serverName: toolServerName,
@@ -73,7 +106,7 @@ export async function prepareCompletionToolRuntime(options) {
73
106
  return await prepareCursorCompletionToolRuntime(worktreePath, runtime);
74
107
  }
75
108
  export function buildCompletionToolServerArgs(config) {
76
- return [
109
+ const args = [
77
110
  "mcp",
78
111
  "--worktree",
79
112
  path.resolve(config.worktreePath),
@@ -84,6 +117,10 @@ export function buildCompletionToolServerArgs(config) {
84
117
  "--produced-by",
85
118
  config.producedBy ?? "forge-completion-tool"
86
119
  ];
120
+ if (config.developCycle) {
121
+ args.push("--develop-cycle");
122
+ }
123
+ return args;
87
124
  }
88
125
  export function codexMcpConfigArgs(runtime) {
89
126
  const prefix = `mcp_servers.${runtime.serverName}`;
@@ -109,25 +146,123 @@ export function completionPromptForPhase(phase) {
109
146
  const toolName = expectedCompletionToolName(phase);
110
147
  const alias = completionToolNamesForPhase(phase).find((name) => name !== toolName);
111
148
  const artifactName = phaseFiles[phase].jsonFileName;
149
+ if (phase === "qa") {
150
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
151
+ 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.
152
+ That tool writes the durable QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`${artifactName}\`, and \`qa.report.md\`.
153
+ Use \`status: "pass"\` only when the validated changes satisfy acceptance criteria with concrete evidence. Use \`status: "needs_rework"\` with actionable in-scope findings, or \`status: "blocked"\` with a concrete blocker_reason.
154
+ Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
155
+ }
112
156
  return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
113
157
  Do not write phase artifacts by hand. When the phase is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the required evidence payload.
114
158
  That tool writes \`.forge/work-items/{workItemId}/artifacts/${artifactName}\` and returns the artifact reference that Forge will mirror through the existing phase-artifact pipeline.
115
159
  If the work cannot pass, call the same tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
116
160
  }
161
+ export function completionPromptForDevelopCycle(phase) {
162
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
163
+ Do not write phase artifacts by hand. The tool server enforces phase order and starts this run in \`${phase}\`.
164
+ Complete the dev cycle in order inside this same provider session:
165
+ 1. Call \`forge.complete_implementation(...)\` when implementation evidence is ready.
166
+ 2. Follow the returned self-review instruction, then call \`forge.complete_self_review(...)\`.
167
+ 3. Follow the returned validation instruction, then call \`forge.complete_validation(...)\`.
168
+ Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
169
+ If the current phase cannot pass, call the current phase tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
170
+ }
171
+ export function completionToolNamesForServer(config) {
172
+ return config.developCycle
173
+ ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
174
+ : completionToolNamesForPhase(config.phase);
175
+ }
176
+ export class CompletionToolServerSession {
177
+ config;
178
+ currentPhase;
179
+ completed = false;
180
+ constructor(config) {
181
+ this.config = config;
182
+ this.currentPhase = config.phase;
183
+ }
184
+ get instructions() {
185
+ return this.config.developCycle
186
+ ? completionPromptForDevelopCycle(this.currentPhase)
187
+ : completionPromptForPhase(this.currentPhase);
188
+ }
189
+ toolDefinitions() {
190
+ return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name)));
191
+ }
192
+ async callTool(toolName, input) {
193
+ const requestedPhase = phaseFromToolName(toolName);
194
+ if (this.completed) {
195
+ throw new Error("Forge develop cycle is already complete; no more phase completion calls are accepted.");
196
+ }
197
+ if (requestedPhase !== this.currentPhase) {
198
+ throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
199
+ }
200
+ const completedPhase = this.currentPhase;
201
+ const reference = await writeCompletionArtifact({ ...this.config, phase: completedPhase }, toolName, input);
202
+ const status = reference.status.trim().toLowerCase();
203
+ const pass = status === "pass";
204
+ const nextPhase = pass
205
+ ? this.advanceAfterPass(completedPhase)
206
+ : completedPhase;
207
+ const nextInstruction = completionToolNextInstruction(completedPhase, status, nextPhase);
208
+ const reason = pass
209
+ ? `${completedPhase} completion artifact accepted.`
210
+ : `${completedPhase} completion artifact recorded with status ${status}; rework is required before phase progression.`;
211
+ return {
212
+ content: [
213
+ {
214
+ type: "text",
215
+ text: `Forge recorded ${reference.phase} completion artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
216
+ }
217
+ ],
218
+ structuredContent: {
219
+ ...reference,
220
+ gate: {
221
+ phase: completedPhase,
222
+ status,
223
+ allowed: pass,
224
+ reason
225
+ },
226
+ nextPhase,
227
+ nextInstruction
228
+ }
229
+ };
230
+ }
231
+ advanceAfterPass(phase) {
232
+ if (phase === "qa") {
233
+ return "sign_off_ready";
234
+ }
235
+ const next = nextPhaseAfter(phase);
236
+ if (!this.config.developCycle) {
237
+ return next ?? "dev_test_ready";
238
+ }
239
+ if (next) {
240
+ this.currentPhase = next;
241
+ return next;
242
+ }
243
+ this.completed = true;
244
+ return "dev_test_ready";
245
+ }
246
+ }
117
247
  export async function writeCompletionArtifact(config, toolName, input) {
118
248
  const phase = phaseFromToolName(toolName);
119
249
  if (phase !== config.phase) {
120
250
  throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
121
251
  }
122
252
  const payload = asRecord(input, "completion payload");
123
- validatePayloadShape(phase, payload);
124
- validateOptionalWorkItemId(payload, config.workItemId);
125
- const producedBy = optionalString(payload, "produced_by")
126
- ?? optionalString(payload, "producedBy")
253
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
254
+ const validationPayload = { ...payload, status };
255
+ validatePayloadShape(phase, validationPayload);
256
+ validateOptionalWorkItemId(validationPayload, config.workItemId);
257
+ const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
258
+ const producedBy = optionalString(redactedPayload, "produced_by")
259
+ ?? optionalString(redactedPayload, "producedBy")
127
260
  ?? config.producedBy
128
261
  ?? "forge-completion-tool";
129
- const status = requiredString(payload, "status").trim().toLowerCase();
130
- const artifactWithoutHash = normalizeArtifactPayload(payload, {
262
+ if (phase === "qa") {
263
+ return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
264
+ }
265
+ const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
131
266
  phase,
132
267
  workItemId: config.workItemId,
133
268
  producedBy,
@@ -139,7 +274,7 @@ export async function writeCompletionArtifact(config, toolName, input) {
139
274
  content_hash: contentHash
140
275
  };
141
276
  const artifactJson = `${JSON.stringify(artifact, null, 2)}\n`;
142
- const report = reportMarkdown(payload, phase, status, producedBy, contentHash);
277
+ const report = reportMarkdown(redactedPayload, phase, status, producedBy, contentHash);
143
278
  const artifactPath = completionArtifactJsonPath(config.worktreePath, config.workItemId, phase);
144
279
  const reportPath = completionArtifactReportPath(config.worktreePath, config.workItemId, phase);
145
280
  await fs.mkdir(path.dirname(artifactPath), { recursive: true });
@@ -156,6 +291,101 @@ export async function writeCompletionArtifact(config, toolName, input) {
156
291
  producedBy
157
292
  };
158
293
  }
294
+ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
295
+ const artifactRoot = artifactRootFor(config.worktreePath, config.workItemId);
296
+ const planPath = path.join(artifactRoot, "qa.plan.json");
297
+ const evidencePath = path.join(artifactRoot, "qa.evidence.json");
298
+ const findingsPath = path.join(artifactRoot, "qa.findings.json");
299
+ const verdictPath = path.join(artifactRoot, "qa.verdict.json");
300
+ const reportPath = path.join(artifactRoot, "qa.report.md");
301
+ const relativeEvidencePath = qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
302
+ const relativeFindingsPath = qaArtifactRelativePath(config.workItemId, "qa.findings.json");
303
+ const relativeReportPath = qaArtifactRelativePath(config.workItemId, "qa.report.md");
304
+ const evidenceSource = qaEvidenceSource(payload);
305
+ const commands = qaArray(payload, "commands", evidenceSource);
306
+ const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
307
+ const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
308
+ const findings = qaObjectArray(payload, "findings");
309
+ const candidateBugs = qaObjectArray(payload, "candidate_bugs");
310
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
311
+ const explicitFindingIds = qaStringArray(payload, "finding_ids");
312
+ const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
313
+ const blockerReason = optionalString(payload, "blocker_reason");
314
+ const checklist = qaStringArray(payload, "checklist");
315
+ const planJson = jsonText({
316
+ schema_version: 1,
317
+ phase: "qa",
318
+ checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
319
+ acceptance_criteria: testedAcceptanceCriteria
320
+ });
321
+ const evidenceJson = jsonText({
322
+ schema_version: 1,
323
+ phase: "qa",
324
+ commands,
325
+ browser_checks: browserChecks,
326
+ notes
327
+ });
328
+ const findingsJson = jsonText({
329
+ schema_version: 1,
330
+ phase: "qa",
331
+ findings,
332
+ candidate_bugs: candidateBugs
333
+ });
334
+ const findingIds = status === "pass"
335
+ ? []
336
+ : explicitFindingIds.length > 0
337
+ ? explicitFindingIds
338
+ : findings
339
+ .filter((finding) => normalizedString(finding.scope) === "in_scope")
340
+ .map((finding) => optionalString(finding, "id"))
341
+ .filter((id) => Boolean(id));
342
+ const candidateBugIds = explicitCandidateBugIds.length > 0
343
+ ? explicitCandidateBugIds
344
+ : candidateBugs
345
+ .map((candidateBug) => optionalString(candidateBug, "id"))
346
+ .filter((id) => Boolean(id));
347
+ const verdictWithoutHash = {
348
+ schema_version: numberOrDefault(payload.schema_version, 1),
349
+ phase: "qa",
350
+ status,
351
+ summary: requiredString(payload, "summary"),
352
+ tested_acceptance_criteria: testedAcceptanceCriteria,
353
+ finding_ids: findingIds,
354
+ candidate_bug_ids: candidateBugIds,
355
+ blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
356
+ evidence_path: relativeEvidencePath,
357
+ findings_path: relativeFindingsPath,
358
+ report_path: relativeReportPath,
359
+ produced_by: producedBy
360
+ };
361
+ const contentHash = sha256(stableStringify({
362
+ plan: JSON.parse(planJson),
363
+ evidence: JSON.parse(evidenceJson),
364
+ findings: JSON.parse(findingsJson),
365
+ verdict: verdictWithoutHash
366
+ }));
367
+ const verdictJson = jsonText({
368
+ ...verdictWithoutHash,
369
+ content_hash: contentHash
370
+ });
371
+ const finalReport = reportMarkdown(payload, "qa", status, producedBy, contentHash);
372
+ await fs.mkdir(artifactRoot, { recursive: true });
373
+ await writeFileAtomic(planPath, planJson);
374
+ await writeFileAtomic(evidencePath, evidenceJson);
375
+ await writeFileAtomic(findingsPath, findingsJson);
376
+ await writeFileAtomic(verdictPath, verdictJson);
377
+ await writeFileAtomic(reportPath, finalReport);
378
+ return {
379
+ phase: "qa",
380
+ workItemId: config.workItemId,
381
+ repoPath: path.resolve(config.worktreePath),
382
+ path: verdictPath,
383
+ reportPath,
384
+ contentHash,
385
+ status,
386
+ producedBy
387
+ };
388
+ }
159
389
  export async function readCompletionArtifact(worktreePath, workItemId, phase) {
160
390
  const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
161
391
  const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
@@ -171,10 +401,12 @@ export async function readCompletionArtifact(worktreePath, workItemId, phase) {
171
401
  verdictJson,
172
402
  report,
173
403
  producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
174
- status: optionalString(parsed, "status") ?? "unknown"
404
+ status: optionalString(parsed, "status") ?? "unknown",
405
+ phase
175
406
  };
176
407
  }
177
408
  export async function runCompletionMcpServer(config) {
409
+ const session = new CompletionToolServerSession(config);
178
410
  process.stdin.setEncoding("utf8");
179
411
  let buffer = "";
180
412
  for await (const chunk of process.stdin) {
@@ -184,14 +416,14 @@ export async function runCompletionMcpServer(config) {
184
416
  const rawLine = buffer.slice(0, newlineIndex).trim();
185
417
  buffer = buffer.slice(newlineIndex + 1);
186
418
  if (rawLine.length > 0) {
187
- await handleJsonRpcLine(config, rawLine);
419
+ await handleJsonRpcLine(session, rawLine);
188
420
  }
189
421
  newlineIndex = buffer.indexOf("\n");
190
422
  }
191
423
  }
192
424
  const trailing = buffer.trim();
193
425
  if (trailing.length > 0) {
194
- await handleJsonRpcLine(config, trailing);
426
+ await handleJsonRpcLine(session, trailing);
195
427
  }
196
428
  }
197
429
  export function parseCompletionMcpArgs(argv) {
@@ -199,6 +431,7 @@ export function parseCompletionMcpArgs(argv) {
199
431
  let workItemId = null;
200
432
  let phase = "implementation";
201
433
  let producedBy;
434
+ let developCycle = false;
202
435
  for (let index = 1; index < argv.length; index += 1) {
203
436
  const arg = argv[index];
204
437
  if (arg === "--worktree") {
@@ -211,13 +444,16 @@ export function parseCompletionMcpArgs(argv) {
211
444
  const rawPhase = argv[++index];
212
445
  const parsedPhase = parseStrictCompletionPhase(rawPhase);
213
446
  if (!parsedPhase) {
214
- return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation." };
447
+ return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa." };
215
448
  }
216
449
  phase = parsedPhase;
217
450
  }
218
451
  else if (arg === "--produced-by") {
219
452
  producedBy = argv[++index] ?? undefined;
220
453
  }
454
+ else if (arg === "--develop-cycle") {
455
+ developCycle = true;
456
+ }
221
457
  else {
222
458
  return { ok: false, error: `Unknown mcp argument: ${arg}` };
223
459
  }
@@ -231,10 +467,18 @@ export function parseCompletionMcpArgs(argv) {
231
467
  worktreePath: path.resolve(worktreePath),
232
468
  workItemId,
233
469
  phase,
234
- producedBy
470
+ producedBy,
471
+ redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
472
+ developCycle
235
473
  }
236
474
  };
237
475
  }
476
+ export function completionRedactionEnv(secrets) {
477
+ const filtered = secrets.filter((secret) => secret.length > 0);
478
+ return filtered.length > 0
479
+ ? { [completionRedactionSecretsEnv]: JSON.stringify(filtered) }
480
+ : {};
481
+ }
238
482
  async function prepareCursorCompletionToolRuntime(worktreePath, runtime) {
239
483
  const cursorDir = path.join(worktreePath, ".cursor");
240
484
  const configPath = path.join(cursorDir, "mcp.json");
@@ -262,7 +506,7 @@ async function prepareCursorCompletionToolRuntime(worktreePath, runtime) {
262
506
  }
263
507
  };
264
508
  }
265
- async function handleJsonRpcLine(config, rawLine) {
509
+ async function handleJsonRpcLine(session, rawLine) {
266
510
  let message;
267
511
  try {
268
512
  message = JSON.parse(rawLine);
@@ -280,14 +524,14 @@ async function handleJsonRpcLine(config, rawLine) {
280
524
  return;
281
525
  }
282
526
  try {
283
- const result = await handleJsonRpcRequest(config, request.method, request.params);
527
+ const result = await handleJsonRpcRequest(session, request.method, request.params);
284
528
  writeJsonRpc({ jsonrpc: "2.0", id: request.id, result });
285
529
  }
286
530
  catch (error) {
287
531
  writeJsonRpc({ jsonrpc: "2.0", id: request.id, error: rpcError(-32000, errorMessage(error)) });
288
532
  }
289
533
  }
290
- async function handleJsonRpcRequest(config, method, params) {
534
+ async function handleJsonRpcRequest(session, method, params) {
291
535
  if (method === "initialize") {
292
536
  const initializeParams = params && typeof params === "object" && !Array.isArray(params)
293
537
  ? params
@@ -301,7 +545,7 @@ async function handleJsonRpcRequest(config, method, params) {
301
545
  name: "forge-completion-tools",
302
546
  version: "0.1.0"
303
547
  },
304
- instructions: completionPromptForPhase(config.phase)
548
+ instructions: session.instructions
305
549
  };
306
550
  }
307
551
  if (method === "ping") {
@@ -309,23 +553,14 @@ async function handleJsonRpcRequest(config, method, params) {
309
553
  }
310
554
  if (method === "tools/list") {
311
555
  return {
312
- tools: completionToolNamesForPhase(config.phase).map((name) => toolDefinition(name, config.phase))
556
+ tools: session.toolDefinitions()
313
557
  };
314
558
  }
315
559
  if (method === "tools/call") {
316
560
  const call = asRecord(params, "tools/call params");
317
561
  const name = requiredString(call, "name");
318
562
  const args = call.arguments ?? {};
319
- const reference = await writeCompletionArtifact(config, name, args);
320
- return {
321
- content: [
322
- {
323
- type: "text",
324
- text: `Forge recorded ${reference.phase} completion artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}).`
325
- }
326
- ],
327
- structuredContent: reference
328
- };
563
+ return await session.callTool(name, args);
329
564
  }
330
565
  throw new Error(`Unsupported MCP method: ${method}`);
331
566
  }
@@ -336,6 +571,31 @@ function toolDefinition(name, phase) {
336
571
  inputSchema: toolInputSchema(phase)
337
572
  };
338
573
  }
574
+ function nextPhaseAfter(phase) {
575
+ const index = completionPhaseOrder.indexOf(phase);
576
+ return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
577
+ }
578
+ function completionToolNextInstruction(completedPhase, status, nextPhase) {
579
+ if (completedPhase === "qa") {
580
+ if (status === "blocked") {
581
+ return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
582
+ }
583
+ if (status === "needs_rework") {
584
+ return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
585
+ }
586
+ return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
587
+ }
588
+ if (status !== "pass") {
589
+ return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
590
+ }
591
+ if (nextPhase === "self_review") {
592
+ return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
593
+ }
594
+ if (nextPhase === "validation") {
595
+ return "Self-review accepted. Begin validation: run deterministic checks, prove acceptance criteria, and call forge.complete_validation with command evidence.";
596
+ }
597
+ return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
598
+ }
339
599
  function toolDescription(phase) {
340
600
  if (phase === "implementation") {
341
601
  return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
@@ -343,77 +603,312 @@ function toolDescription(phase) {
343
603
  if (phase === "self_review") {
344
604
  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.";
345
605
  }
606
+ if (phase === "qa") {
607
+ return "Complete Forge QA by writing qa.plan.json, qa.evidence.json, qa.findings.json, qa.verdict.json, and qa.report.md with structured pass, needs-rework, or blocked evidence.";
608
+ }
346
609
  return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
347
610
  }
348
611
  function toolInputSchema(phase) {
349
- const base = {
350
- type: "object",
351
- additionalProperties: true,
352
- properties: {
353
- status: { type: "string", enum: ["pass", "blocked", "fail", "failed"] },
354
- summary: { type: "string" },
355
- report_markdown: { type: "string" },
356
- blockers: { type: "array" }
612
+ const baseProperties = {
613
+ status: {
614
+ type: "string",
615
+ enum: ["pass", "needs_rework", "fail", "failed"],
616
+ description: "Use pass only when the phase evidence satisfies Forge's gate. Use needs_rework/fail/failed with explicit blockers or findings otherwise."
357
617
  },
358
- required: ["status", "summary"]
618
+ summary: { type: "string", description: "Concise phase summary." },
619
+ report_markdown: { type: "string", description: "Optional human-readable report. Forge will generate a fallback report if omitted." },
620
+ blockers: {
621
+ type: "array",
622
+ description: "Required for non-pass status unless findings, failures, known_gaps, remaining_risks, or commands provide explicit evidence.",
623
+ items: evidenceObjectSchema(["reason"], {
624
+ reason: { type: "string" },
625
+ owner: { type: "string" },
626
+ next_step: { type: "string" }
627
+ })
628
+ }
359
629
  };
360
630
  if (phase === "implementation") {
361
- return {
362
- ...base,
363
- properties: {
364
- ...base.properties,
365
- plan: { type: "array" },
366
- changed_files: { type: "array" },
367
- tests_added_or_updated: { type: "array" },
368
- docs_added_or_updated: { type: "array" },
369
- verification: { type: "array" },
370
- acceptance_criteria: { type: "array" },
371
- known_gaps: { type: "array" },
372
- next_phase_context: {}
631
+ return objectToolSchema({
632
+ ...baseProperties,
633
+ plan: {
634
+ type: "array",
635
+ description: "Required for pass. Ordered implementation steps with status.",
636
+ items: evidenceObjectSchema(["step", "status"], {
637
+ step: { type: "string" },
638
+ status: { type: "string" },
639
+ evidence: { type: "string" }
640
+ })
373
641
  },
374
- required: ["status", "summary"]
375
- };
642
+ changed_files: {
643
+ type: "array",
644
+ description: "Required for pass. Must be non-empty unless known_gaps explains why.",
645
+ items: evidenceObjectSchema(["path", "summary"], {
646
+ path: { type: "string" },
647
+ summary: { type: "string" }
648
+ })
649
+ },
650
+ tests_added_or_updated: {
651
+ type: "array",
652
+ description: "Required for pass. Use an object explaining why no tests were needed for docs/config-only work if empty would be ambiguous.",
653
+ items: evidenceObjectSchema(["path", "summary"], {
654
+ path: { type: "string" },
655
+ summary: { type: "string" }
656
+ })
657
+ },
658
+ docs_added_or_updated: {
659
+ type: "array",
660
+ description: "Required for pass. Use an object explaining why no docs changed when appropriate.",
661
+ items: evidenceObjectSchema(["path", "summary"], {
662
+ path: { type: "string" },
663
+ summary: { type: "string" }
664
+ })
665
+ },
666
+ verification: {
667
+ type: "array",
668
+ description: "Implementation verification evidence. Objects should include command, exit_code, status, and summary when command-based.",
669
+ items: commandEvidenceSchema()
670
+ },
671
+ acceptance_criteria: acceptanceCriteriaSchema(),
672
+ known_gaps: {
673
+ type: "array",
674
+ description: "Required for pass, even when empty. If changed_files/tests/docs arrays are empty, explain why here.",
675
+ items: evidenceObjectSchema(["summary"], {
676
+ summary: { type: "string" },
677
+ impact: { type: "string" },
678
+ follow_up: { type: "string" }
679
+ })
680
+ },
681
+ next_phase_context: {
682
+ description: "Required for pass. Context the self-review phase should carry forward."
683
+ }
684
+ }, ["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.");
376
685
  }
377
686
  if (phase === "self_review") {
378
- return {
379
- ...base,
380
- properties: {
381
- ...base.properties,
382
- reviewed_diff: { type: "boolean" },
383
- findings: { type: "array" },
384
- security_review: { type: "object" },
385
- edge_case_review: { type: "object" },
386
- design_review: { type: "object" },
387
- deterministic_comparison: { type: "object" },
388
- fixes_applied: { type: "array" },
389
- remaining_risks: { type: "array" }
687
+ return objectToolSchema({
688
+ ...baseProperties,
689
+ reviewed_diff: { type: "boolean", description: "Required for pass. True after reviewing the implementation diff." },
690
+ findings: {
691
+ type: "array",
692
+ description: "Required for pass, even when empty. Include finding objects for issues discovered during review.",
693
+ items: evidenceObjectSchema(["severity", "summary", "status"], {
694
+ severity: { type: "string" },
695
+ summary: { type: "string" },
696
+ status: { type: "string" },
697
+ file: { type: "string" }
698
+ })
390
699
  },
391
- required: ["status", "summary"]
392
- };
700
+ security_review: reviewSectionSchema(),
701
+ edge_case_review: reviewSectionSchema(),
702
+ design_review: {
703
+ ...reviewSectionSchema(),
704
+ properties: {
705
+ ...reviewSectionProperties(),
706
+ screenshots: { type: "array", items: evidenceObjectSchema(["path"], { path: { type: "string" }, summary: { type: "string" } }) }
707
+ }
708
+ },
709
+ deterministic_comparison: {
710
+ type: "object",
711
+ description: "Required for pass. Evidence that the reviewed diff matches the intended deterministic change.",
712
+ additionalProperties: true,
713
+ properties: {
714
+ status: { type: "string" },
715
+ command_or_tool: { type: "string" },
716
+ notes: { type: "string" }
717
+ }
718
+ },
719
+ fixes_applied: {
720
+ type: "array",
721
+ description: "Required for pass, even when empty.",
722
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, path: { type: "string" } })
723
+ },
724
+ remaining_risks: {
725
+ type: "array",
726
+ description: "Required for pass, even when empty.",
727
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
728
+ }
729
+ }, ["status", "summary"], "For status pass, include reviewed_diff, findings, security_review, edge_case_review, design_review, deterministic_comparison, fixes_applied, and remaining_risks.");
393
730
  }
394
- return {
395
- ...base,
396
- properties: {
397
- ...base.properties,
398
- commands: { type: "array" },
399
- acceptance_criteria: { type: "array" },
400
- build_status: { type: "string" },
401
- test_status: { type: "string" },
402
- lint_status: { type: "string" },
403
- design_status: { type: "string" },
404
- drift_checks: { type: "array" },
405
- ready_for_dev_test: { type: "boolean" }
731
+ if (phase === "qa") {
732
+ return objectToolSchema({
733
+ ...baseProperties,
734
+ status: {
735
+ type: "string",
736
+ enum: ["pass", "needs_rework", "blocked"],
737
+ description: "Use pass only when QA evidence satisfies acceptance criteria, needs_rework for actionable in-scope defects, or blocked for an inconclusive QA blocker."
738
+ },
739
+ checklist: {
740
+ type: "array",
741
+ description: "QA plan/checklist. Forge writes this to qa.plan.json; a default checklist is generated if omitted.",
742
+ items: { type: "string" }
743
+ },
744
+ tested_acceptance_criteria: {
745
+ type: "array",
746
+ description: "Required and non-empty for pass/needs_rework. Criteria actually tested by QA.",
747
+ items: { type: "string" }
748
+ },
749
+ commands: {
750
+ type: "array",
751
+ description: "Command evidence written to qa.evidence.json.",
752
+ items: commandEvidenceSchema()
753
+ },
754
+ browser_checks: {
755
+ type: "array",
756
+ description: "Browser/manual UI checks with URL, action, observed result, screenshot/trace references, console/network notes, and status when applicable.",
757
+ items: evidenceObjectSchema(["summary"], {
758
+ summary: { type: "string" },
759
+ url: { type: "string" },
760
+ status: { type: "string" },
761
+ screenshot: { type: "string" },
762
+ trace: { type: "string" }
763
+ })
764
+ },
765
+ notes: { type: "string", description: "Additional QA evidence notes." },
766
+ evidence: {
767
+ type: "object",
768
+ description: "Optional wrapper containing commands, browser_checks, and notes when a client prefers nested evidence."
769
+ },
770
+ findings: {
771
+ type: "array",
772
+ description: "QA findings. needs_rework requires at least one actionable in_scope finding.",
773
+ items: evidenceObjectSchema(["id", "severity", "scope", "title"], {
774
+ id: { type: "string" },
775
+ severity: { type: "string", enum: ["critical", "major", "minor"] },
776
+ scope: { type: "string", enum: ["in_scope", "out_of_scope", "unclear"] },
777
+ title: { type: "string" },
778
+ acceptance_criteria: { type: "array", items: { type: "string" } },
779
+ repro_steps: { type: "array", items: { type: "string" } },
780
+ expected: { type: "string" },
781
+ actual: { type: "string" },
782
+ evidence: { type: "object" },
783
+ likely_area: { type: "array", items: { type: "string" } },
784
+ required_fix: { type: "string" },
785
+ required_regression: { type: "string" }
786
+ })
787
+ },
788
+ candidate_bugs: {
789
+ type: "array",
790
+ description: "Out-of-scope/follow-up bug candidates. They do not block the current item unless attached as in-scope findings.",
791
+ items: evidenceObjectSchema(["id", "title", "suggested_action"], {
792
+ id: { type: "string" },
793
+ title: { type: "string" },
794
+ severity: { type: "string" },
795
+ scope_reason: { type: "string" },
796
+ repro_steps: { type: "array", items: { type: "string" } },
797
+ evidence: { type: "object" },
798
+ suggested_action: { type: "string", enum: ["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"] }
799
+ })
800
+ },
801
+ blocker_reason: {
802
+ type: "string",
803
+ enum: [...qaBlockerReasons],
804
+ description: "Required when status is blocked."
805
+ }
806
+ }, ["status", "summary"], "For pass, include tested_acceptance_criteria plus concrete command/browser/note evidence. For needs_rework, include at least one actionable in_scope finding. For blocked, include blocker_reason.");
807
+ }
808
+ return objectToolSchema({
809
+ ...baseProperties,
810
+ commands: {
811
+ type: "array",
812
+ description: "Required for pass. Include at least one deterministic validation command with exit_code: 0 and status: pass.",
813
+ items: commandEvidenceSchema()
814
+ },
815
+ acceptance_criteria: acceptanceCriteriaSchema(),
816
+ build_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
817
+ test_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
818
+ lint_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
819
+ design_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
820
+ drift_checks: {
821
+ type: "array",
822
+ description: "Required for pass. Deterministic checks for unintended drift.",
823
+ items: evidenceObjectSchema(["command_or_tool", "status", "summary"], {
824
+ command_or_tool: { type: "string" },
825
+ status: { type: "string" },
826
+ summary: { type: "string" }
827
+ })
406
828
  },
407
- required: ["status", "summary"]
829
+ ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
830
+ }, ["status", "summary"], "For status pass, commands must include at least one passing deterministic command, acceptance criteria entries must use status: satisfied plus evidence, and ready_for_dev_test must be true.");
831
+ }
832
+ function objectToolSchema(properties, required, description) {
833
+ return {
834
+ type: "object",
835
+ additionalProperties: true,
836
+ description,
837
+ properties,
838
+ required: [...required]
839
+ };
840
+ }
841
+ function evidenceObjectSchema(required, properties) {
842
+ return {
843
+ type: "object",
844
+ additionalProperties: true,
845
+ properties,
846
+ required: [...required]
847
+ };
848
+ }
849
+ function commandEvidenceSchema() {
850
+ return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
851
+ command: { type: "string" },
852
+ exit_code: { type: "number" },
853
+ status: { type: "string" },
854
+ summary: { type: "string" }
855
+ });
856
+ }
857
+ function acceptanceCriteriaSchema() {
858
+ return {
859
+ type: "array",
860
+ description: "Required for pass. Every entry must be an object with status: satisfied and non-empty evidence.",
861
+ items: evidenceObjectSchema(["criterion", "evidence", "status"], {
862
+ criterion: { type: "string" },
863
+ evidence: { type: "string" },
864
+ status: { type: "string", enum: ["satisfied"] }
865
+ })
408
866
  };
409
867
  }
868
+ function reviewSectionSchema() {
869
+ return {
870
+ type: "object",
871
+ description: "Required for pass. Use status pass/fail/needs_rework/not_applicable plus notes.",
872
+ additionalProperties: true,
873
+ properties: reviewSectionProperties()
874
+ };
875
+ }
876
+ function reviewSectionProperties() {
877
+ return {
878
+ status: { type: "string" },
879
+ notes: { type: "string" }
880
+ };
881
+ }
882
+ function normalizeCompletionStatus(value, phase) {
883
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
884
+ if (phase === "qa") {
885
+ const canonical = normalized === "needsrework" ? "needs_rework" : normalized;
886
+ if (canonical !== "pass" && canonical !== "needs_rework" && canonical !== "blocked") {
887
+ throw new Error("QA status must be pass, needs_rework, or blocked.");
888
+ }
889
+ return canonical;
890
+ }
891
+ const canonical = normalized === "blocked" || normalized === "needsrework"
892
+ ? "needs_rework"
893
+ : normalized === "failed"
894
+ ? "fail"
895
+ : normalized;
896
+ if (canonical !== "pass" && canonical !== "fail" && canonical !== "needs_rework") {
897
+ throw new Error("status must be pass, fail, or needs_rework.");
898
+ }
899
+ return canonical;
900
+ }
410
901
  function validatePayloadShape(phase, payload) {
411
- const status = requiredString(payload, "status").trim().toLowerCase();
902
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
412
903
  if (!status) {
413
904
  throw new Error("status is required.");
414
905
  }
415
- const pass = status === "pass";
416
906
  requireField(payload, "summary");
907
+ if (phase === "qa") {
908
+ validateQaPayloadShape(payload, status);
909
+ return;
910
+ }
911
+ const pass = status === "pass";
417
912
  if (!pass) {
418
913
  requireNonPassEvidence(payload);
419
914
  return;
@@ -421,10 +916,12 @@ function validatePayloadShape(phase, payload) {
421
916
  if (phase === "implementation") {
422
917
  requireArray(payload, "plan");
423
918
  requireArray(payload, "changed_files");
424
- requireArray(payload, "verification");
919
+ requireArray(payload, "tests_added_or_updated");
920
+ requireArray(payload, "docs_added_or_updated");
425
921
  requireArray(payload, "acceptance_criteria");
426
922
  requireArray(payload, "known_gaps");
427
923
  requireField(payload, "next_phase_context");
924
+ requireImplementationEvidence(payload);
428
925
  requireSatisfiedAcceptanceCriteria(payload);
429
926
  return;
430
927
  }
@@ -450,8 +947,120 @@ function validatePayloadShape(phase, payload) {
450
947
  if (payload.ready_for_dev_test !== true) {
451
948
  throw new Error("validation pass requires ready_for_dev_test: true.");
452
949
  }
950
+ requirePassingCommands(payload);
453
951
  requireSatisfiedAcceptanceCriteria(payload);
454
952
  }
953
+ function validateQaPayloadShape(payload, status) {
954
+ const evidenceSource = qaEvidenceSource(payload);
955
+ const commands = qaArray(payload, "commands", evidenceSource);
956
+ const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
957
+ const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
958
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
959
+ const findings = qaObjectArray(payload, "findings");
960
+ const candidateBugs = qaObjectArray(payload, "candidate_bugs");
961
+ const explicitFindingIds = qaStringArray(payload, "finding_ids");
962
+ const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
963
+ if (payload.schema_version !== undefined && payload.schema_version !== 1) {
964
+ throw new Error("QA schema_version must be numeric 1 when provided.");
965
+ }
966
+ if (status === "pass" || status === "needs_rework") {
967
+ if (testedAcceptanceCriteria.length === 0) {
968
+ throw new Error("QA pass/needs_rework requires tested_acceptance_criteria with at least one criterion.");
969
+ }
970
+ if (commands.length === 0 && browserChecks.length === 0 && notes.length === 0) {
971
+ throw new Error("QA pass/needs_rework requires at least one command, browser check, or evidence note.");
972
+ }
973
+ }
974
+ for (const [index, command] of commands.entries()) {
975
+ requireEvidenceRecord(command, `commands[${index}]`);
976
+ const record = command;
977
+ requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
978
+ requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
979
+ requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
980
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
981
+ throw new Error(`commands[${index}].exit_code is required.`);
982
+ }
983
+ if (status === "pass" && record.exit_code !== 0) {
984
+ throw new Error(`commands[${index}].exit_code must be 0 when QA status is pass.`);
985
+ }
986
+ if (status === "pass" && normalizedString(record.status) !== "pass") {
987
+ throw new Error(`commands[${index}].status must be pass when QA status is pass.`);
988
+ }
989
+ }
990
+ for (const [index, browserCheck] of browserChecks.entries()) {
991
+ requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
992
+ const record = browserCheck;
993
+ requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
994
+ const checkStatus = optionalString(record, "status");
995
+ if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
996
+ throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
997
+ }
998
+ }
999
+ for (const [index, finding] of findings.entries()) {
1000
+ requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
1001
+ requireNonEmptyStringRecord(finding, "title", `findings[${index}].`);
1002
+ requireKnownStringRecord(finding, "severity", qaFindingSeverities, `findings[${index}].`);
1003
+ requireKnownStringRecord(finding, "scope", qaFindingScopes, `findings[${index}].`);
1004
+ if (normalizedString(finding.scope) === "in_scope") {
1005
+ validateActionableQaFinding(finding, index);
1006
+ }
1007
+ }
1008
+ for (const [index, candidateBug] of candidateBugs.entries()) {
1009
+ requireNonEmptyStringRecord(candidateBug, "id", `candidate_bugs[${index}].`);
1010
+ requireNonEmptyStringRecord(candidateBug, "title", `candidate_bugs[${index}].`);
1011
+ requireKnownStringRecord(candidateBug, "suggested_action", qaCandidateBugActions, `candidate_bugs[${index}].`);
1012
+ }
1013
+ const inScopeFindingIds = findings
1014
+ .filter((finding) => normalizedString(finding.scope) === "in_scope")
1015
+ .map((finding) => optionalString(finding, "id"))
1016
+ .filter((id) => Boolean(id));
1017
+ for (const findingId of explicitFindingIds) {
1018
+ if (!inScopeFindingIds.includes(findingId)) {
1019
+ throw new Error(`QA finding_ids contains '${findingId}', but findings has no matching in-scope finding.`);
1020
+ }
1021
+ }
1022
+ const candidateBugIds = candidateBugs
1023
+ .map((candidateBug) => optionalString(candidateBug, "id"))
1024
+ .filter((id) => Boolean(id));
1025
+ for (const candidateBugId of explicitCandidateBugIds) {
1026
+ if (!candidateBugIds.includes(candidateBugId)) {
1027
+ throw new Error(`QA candidate_bug_ids contains '${candidateBugId}', but candidate_bugs has no matching candidate bug.`);
1028
+ }
1029
+ }
1030
+ if (status === "pass") {
1031
+ if (explicitFindingIds.length > 0 || inScopeFindingIds.length > 0) {
1032
+ throw new Error("QA pass must not include in-scope finding_ids or in-scope findings.");
1033
+ }
1034
+ if (optionalString(payload, "blocker_reason")) {
1035
+ throw new Error("QA pass must not include blocker_reason.");
1036
+ }
1037
+ }
1038
+ if (status === "needs_rework") {
1039
+ if (inScopeFindingIds.length === 0) {
1040
+ throw new Error("QA needs_rework requires at least one actionable in-scope finding.");
1041
+ }
1042
+ if (optionalString(payload, "blocker_reason")) {
1043
+ throw new Error("QA needs_rework must not include blocker_reason.");
1044
+ }
1045
+ }
1046
+ if (status === "blocked") {
1047
+ const reason = normalizedString(optionalString(payload, "blocker_reason"));
1048
+ if (!qaBlockerReasons.has(reason)) {
1049
+ 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.");
1050
+ }
1051
+ }
1052
+ }
1053
+ function validateActionableQaFinding(finding, index) {
1054
+ const prefix = `findings[${index}].`;
1055
+ requireNonEmptyStringArrayRecord(finding, "acceptance_criteria", prefix);
1056
+ requireNonEmptyStringArrayRecord(finding, "repro_steps", prefix);
1057
+ requireNonEmptyStringRecord(finding, "expected", prefix);
1058
+ requireNonEmptyStringRecord(finding, "actual", prefix);
1059
+ requireSubstantiveEvidenceRecord(finding, prefix);
1060
+ requireNonEmptyStringArrayRecord(finding, "likely_area", prefix);
1061
+ requireNonEmptyStringRecord(finding, "required_fix", prefix);
1062
+ requireNonEmptyStringRecord(finding, "required_regression", prefix);
1063
+ }
455
1064
  function validateOptionalWorkItemId(payload, expectedWorkItemId) {
456
1065
  const actual = optionalString(payload, "work_item_id") ?? optionalString(payload, "workItemId");
457
1066
  if (actual && actual !== expectedWorkItemId) {
@@ -498,8 +1107,26 @@ function parseStrictCompletionPhase(value) {
498
1107
  if (normalized === "validate" || normalized === "validation") {
499
1108
  return "validation";
500
1109
  }
1110
+ if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
1111
+ return "qa";
1112
+ }
501
1113
  return null;
502
1114
  }
1115
+ function parseCompletionRedactionSecrets(raw) {
1116
+ if (!raw?.trim()) {
1117
+ return [];
1118
+ }
1119
+ try {
1120
+ const parsed = JSON.parse(raw);
1121
+ if (!Array.isArray(parsed)) {
1122
+ return [];
1123
+ }
1124
+ return parsed.filter((value) => typeof value === "string" && value.length > 0);
1125
+ }
1126
+ catch {
1127
+ return [];
1128
+ }
1129
+ }
503
1130
  function phaseFromToolName(toolName) {
504
1131
  const normalized = toolName.trim().toLowerCase();
505
1132
  if (normalized === "forge.complete_implementation" || normalized === "complete_implementation") {
@@ -511,6 +1138,9 @@ function phaseFromToolName(toolName) {
511
1138
  if (normalized === "forge.complete_validation" || normalized === "complete_validation") {
512
1139
  return "validation";
513
1140
  }
1141
+ if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
1142
+ return "qa";
1143
+ }
514
1144
  throw new Error(`Unknown Forge completion tool: ${toolName}`);
515
1145
  }
516
1146
  function requireField(payload, key) {
@@ -534,6 +1164,47 @@ function requireNonPassEvidence(payload) {
534
1164
  throw new Error("non-pass completion requires explicit blockers, findings, failures, or known gaps.");
535
1165
  }
536
1166
  }
1167
+ function requireImplementationEvidence(payload) {
1168
+ const knownGaps = payload.known_gaps;
1169
+ const hasKnownGap = Array.isArray(knownGaps) && knownGaps.length > 0;
1170
+ for (const key of ["changed_files", "tests_added_or_updated", "docs_added_or_updated"]) {
1171
+ const value = payload[key];
1172
+ if (Array.isArray(value) && value.length === 0 && !hasKnownGap) {
1173
+ throw new Error(`${key} is empty; known_gaps must explain why.`);
1174
+ }
1175
+ }
1176
+ }
1177
+ function requirePassingCommands(payload) {
1178
+ const commands = payload.commands;
1179
+ if (!Array.isArray(commands)) {
1180
+ return;
1181
+ }
1182
+ if (commands.length === 0) {
1183
+ throw new Error("commands must include at least one deterministic validation command.");
1184
+ }
1185
+ for (const [index, command] of commands.entries()) {
1186
+ if (!command || typeof command !== "object" || Array.isArray(command)) {
1187
+ throw new Error(`commands[${index}] must be an object.`);
1188
+ }
1189
+ const record = command;
1190
+ if (typeof record.command !== "string" || record.command.trim().length === 0) {
1191
+ throw new Error(`commands[${index}].command must be a non-empty string.`);
1192
+ }
1193
+ if (typeof record.summary !== "string" || record.summary.trim().length === 0) {
1194
+ throw new Error(`commands[${index}].summary must be a non-empty string.`);
1195
+ }
1196
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
1197
+ throw new Error(`commands[${index}].exit_code must be a finite number.`);
1198
+ }
1199
+ if (record.exit_code !== 0) {
1200
+ throw new Error(`commands[${index}].exit_code must be 0 when status is pass.`);
1201
+ }
1202
+ const status = typeof record.status === "string" ? record.status.trim().toLowerCase() : "";
1203
+ if (status !== "pass") {
1204
+ throw new Error(`commands[${index}].status must be pass.`);
1205
+ }
1206
+ }
1207
+ }
537
1208
  function requireArray(payload, key) {
538
1209
  if (!Array.isArray(payload[key])) {
539
1210
  throw new Error(`${key} must be an array.`);
@@ -567,6 +1238,109 @@ function requireSatisfiedAcceptanceCriteria(payload) {
567
1238
  }
568
1239
  }
569
1240
  }
1241
+ function qaEvidenceSource(payload) {
1242
+ const evidence = payload.evidence;
1243
+ return evidence && typeof evidence === "object" && !Array.isArray(evidence)
1244
+ ? evidence
1245
+ : {};
1246
+ }
1247
+ function qaArray(payload, key, fallback = {}) {
1248
+ const value = payload[key] ?? fallback[key];
1249
+ if (value === undefined || value === null) {
1250
+ return [];
1251
+ }
1252
+ if (!Array.isArray(value)) {
1253
+ throw new Error(`${key} must be an array.`);
1254
+ }
1255
+ return value;
1256
+ }
1257
+ function qaObjectArray(payload, key) {
1258
+ return qaArray(payload, key).map((entry, index) => {
1259
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
1260
+ throw new Error(`${key}[${index}] must be an object.`);
1261
+ }
1262
+ return entry;
1263
+ });
1264
+ }
1265
+ function qaStringArray(payload, key) {
1266
+ const value = payload[key];
1267
+ if (value === undefined || value === null) {
1268
+ return [];
1269
+ }
1270
+ if (!Array.isArray(value)) {
1271
+ throw new Error(`${key} must be an array.`);
1272
+ }
1273
+ return value.map((entry, index) => {
1274
+ if (typeof entry !== "string" || entry.trim().length === 0) {
1275
+ throw new Error(`${key}[${index}] must be a non-empty string.`);
1276
+ }
1277
+ return entry;
1278
+ });
1279
+ }
1280
+ function requireEvidenceRecord(value, label) {
1281
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1282
+ throw new Error(`${label} must be an object.`);
1283
+ }
1284
+ }
1285
+ function requireNonEmptyStringRecord(source, key, prefix = "") {
1286
+ if (!optionalString(source, key)) {
1287
+ throw new Error(`${prefix}${key} is required.`);
1288
+ }
1289
+ }
1290
+ function requireKnownStringRecord(source, key, allowed, prefix = "") {
1291
+ const value = normalizedString(optionalString(source, key));
1292
+ if (!allowed.has(value)) {
1293
+ throw new Error(`${prefix}${key} must be one of ${[...allowed].sort().join(", ")}.`);
1294
+ }
1295
+ }
1296
+ function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
1297
+ const value = source[key];
1298
+ if (!Array.isArray(value) || value.length === 0) {
1299
+ throw new Error(`${prefix}${key} must include at least one value.`);
1300
+ }
1301
+ for (const [index, entry] of value.entries()) {
1302
+ if (typeof entry !== "string" || entry.trim().length === 0) {
1303
+ throw new Error(`${prefix}${key}[${index}] must be a non-empty string.`);
1304
+ }
1305
+ }
1306
+ }
1307
+ function requireSubstantiveEvidenceRecord(source, prefix = "") {
1308
+ const evidence = source.evidence;
1309
+ if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
1310
+ throw new Error(`${prefix}evidence must be an object.`);
1311
+ }
1312
+ if (!Object.values(evidence).some(hasSubstantiveJsonValue)) {
1313
+ throw new Error(`${prefix}evidence must include at least one concrete evidence reference or summary.`);
1314
+ }
1315
+ }
1316
+ function hasSubstantiveJsonValue(value) {
1317
+ if (typeof value === "string") {
1318
+ return value.trim().length > 0;
1319
+ }
1320
+ if (Array.isArray(value)) {
1321
+ return value.length > 0;
1322
+ }
1323
+ if (value && typeof value === "object") {
1324
+ return Object.keys(value).length > 0;
1325
+ }
1326
+ return false;
1327
+ }
1328
+ function normalizedString(value) {
1329
+ return typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
1330
+ }
1331
+ function defaultQaChecklist(status) {
1332
+ return [
1333
+ "Review the validated work item against acceptance criteria.",
1334
+ "Inspect implementation, self-review, and validation evidence when available.",
1335
+ status === "blocked" ? "Record the blocker reason preventing a conclusive QA verdict." : "Record concrete QA evidence and findings."
1336
+ ];
1337
+ }
1338
+ function qaArtifactRelativePath(workItemId, fileName) {
1339
+ return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
1340
+ }
1341
+ function jsonText(value) {
1342
+ return `${JSON.stringify(value, null, 2)}\n`;
1343
+ }
570
1344
  function fallbackReport(parsed, phase, artifactPath) {
571
1345
  return `# Forge ${phase.replace(/_/g, " ")} completion
572
1346