@loucompanion/forge-bridge 0.1.1-dev.4f31cdd58cac → 0.1.1-dev.65ea6889412c

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.
@@ -1,6 +1,6 @@
1
1
  import type { RunOnceDispatchPacket, RunnerDispatchArtifact } from "./session.types.js";
2
2
  import type { WorktreeHandle } from "../worktree/worktree.types.js";
3
- export type CompletionPhase = "implementation" | "self_review" | "validation";
3
+ export type CompletionPhase = "implementation" | "self_review" | "validation" | "qa";
4
4
  export interface CompletionToolRuntime {
5
5
  readonly serverName: string;
6
6
  readonly command: string;
@@ -34,7 +34,7 @@ export interface CompletionToolCallResult {
34
34
  readonly allowed: boolean;
35
35
  readonly reason: string;
36
36
  };
37
- readonly nextPhase: CompletionPhase | "dev_test_ready";
37
+ readonly nextPhase: CompletionPhase | "dev_test_ready" | "sign_off_ready";
38
38
  readonly nextInstruction: string;
39
39
  };
40
40
  }
@@ -6,6 +6,17 @@ import { redactSecrets } from "../shared/redaction/redaction.js";
6
6
  const toolServerName = "forge";
7
7
  const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
8
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"]);
9
20
  const phaseFiles = {
10
21
  implementation: {
11
22
  jsonFileName: "implementation.completeness.json",
@@ -18,6 +29,10 @@ const phaseFiles = {
18
29
  validation: {
19
30
  jsonFileName: "validation.verdict.json",
20
31
  reportFileName: "validation.report.md"
32
+ },
33
+ qa: {
34
+ jsonFileName: "qa.verdict.json",
35
+ reportFileName: "qa.report.md"
21
36
  }
22
37
  };
23
38
  export function completionPhaseFromDispatch(dispatch) {
@@ -31,6 +46,9 @@ export function normalizeCompletionPhase(value) {
31
46
  if (normalized === "validate" || normalized === "validation") {
32
47
  return "validation";
33
48
  }
49
+ if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
50
+ return "qa";
51
+ }
34
52
  return "implementation";
35
53
  }
36
54
  export function usesDevelopCycleCompletion(dispatch) {
@@ -47,7 +65,9 @@ export function completionToolNamesForPhase(phase) {
47
65
  ? "complete_implementation"
48
66
  : phase === "self_review"
49
67
  ? "complete_self_review"
50
- : "complete_validation";
68
+ : phase === "validation"
69
+ ? "complete_validation"
70
+ : "complete_qa";
51
71
  return [`forge.${base}`, base];
52
72
  }
53
73
  export function expectedCompletionToolName(phase) {
@@ -126,6 +146,13 @@ export function completionPromptForPhase(phase) {
126
146
  const toolName = expectedCompletionToolName(phase);
127
147
  const alias = completionToolNamesForPhase(phase).find((name) => name !== toolName);
128
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
+ }
129
156
  return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
130
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.
131
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.
@@ -202,6 +229,9 @@ export class CompletionToolServerSession {
202
229
  };
203
230
  }
204
231
  advanceAfterPass(phase) {
232
+ if (phase === "qa") {
233
+ return "sign_off_ready";
234
+ }
205
235
  const next = nextPhaseAfter(phase);
206
236
  if (!this.config.developCycle) {
207
237
  return next ?? "dev_test_ready";
@@ -220,7 +250,7 @@ export async function writeCompletionArtifact(config, toolName, input) {
220
250
  throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
221
251
  }
222
252
  const payload = asRecord(input, "completion payload");
223
- const status = normalizeCompletionStatus(requiredString(payload, "status"));
253
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
224
254
  const validationPayload = { ...payload, status };
225
255
  validatePayloadShape(phase, validationPayload);
226
256
  validateOptionalWorkItemId(validationPayload, config.workItemId);
@@ -229,6 +259,9 @@ export async function writeCompletionArtifact(config, toolName, input) {
229
259
  ?? optionalString(redactedPayload, "producedBy")
230
260
  ?? config.producedBy
231
261
  ?? "forge-completion-tool";
262
+ if (phase === "qa") {
263
+ return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
264
+ }
232
265
  const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
233
266
  phase,
234
267
  workItemId: config.workItemId,
@@ -258,6 +291,101 @@ export async function writeCompletionArtifact(config, toolName, input) {
258
291
  producedBy
259
292
  };
260
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
+ }
261
389
  export async function readCompletionArtifact(worktreePath, workItemId, phase) {
262
390
  const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
263
391
  const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
@@ -316,7 +444,7 @@ export function parseCompletionMcpArgs(argv) {
316
444
  const rawPhase = argv[++index];
317
445
  const parsedPhase = parseStrictCompletionPhase(rawPhase);
318
446
  if (!parsedPhase) {
319
- 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." };
320
448
  }
321
449
  phase = parsedPhase;
322
450
  }
@@ -448,6 +576,15 @@ function nextPhaseAfter(phase) {
448
576
  return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
449
577
  }
450
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
+ }
451
588
  if (status !== "pass") {
452
589
  return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
453
590
  }
@@ -457,7 +594,7 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
457
594
  if (nextPhase === "validation") {
458
595
  return "Self-review accepted. Begin validation: run deterministic checks, prove acceptance criteria, and call forge.complete_validation with command evidence.";
459
596
  }
460
- return "Validation accepted. Forge has the structured evidence needed to move this work item to Dev Test.";
597
+ return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
461
598
  }
462
599
  function toolDescription(phase) {
463
600
  if (phase === "implementation") {
@@ -466,6 +603,9 @@ function toolDescription(phase) {
466
603
  if (phase === "self_review") {
467
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.";
468
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
+ }
469
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.";
470
610
  }
471
611
  function toolInputSchema(phase) {
@@ -588,6 +728,83 @@ function toolInputSchema(phase) {
588
728
  }
589
729
  }, ["status", "summary"], "For status pass, include reviewed_diff, findings, security_review, edge_case_review, design_review, deterministic_comparison, fixes_applied, and remaining_risks.");
590
730
  }
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
+ }
591
808
  return objectToolSchema({
592
809
  ...baseProperties,
593
810
  commands: {
@@ -662,8 +879,15 @@ function reviewSectionProperties() {
662
879
  notes: { type: "string" }
663
880
  };
664
881
  }
665
- function normalizeCompletionStatus(value) {
882
+ function normalizeCompletionStatus(value, phase) {
666
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
+ }
667
891
  const canonical = normalized === "blocked" || normalized === "needsrework"
668
892
  ? "needs_rework"
669
893
  : normalized === "failed"
@@ -675,12 +899,16 @@ function normalizeCompletionStatus(value) {
675
899
  return canonical;
676
900
  }
677
901
  function validatePayloadShape(phase, payload) {
678
- const status = normalizeCompletionStatus(requiredString(payload, "status"));
902
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
679
903
  if (!status) {
680
904
  throw new Error("status is required.");
681
905
  }
682
- const pass = status === "pass";
683
906
  requireField(payload, "summary");
907
+ if (phase === "qa") {
908
+ validateQaPayloadShape(payload, status);
909
+ return;
910
+ }
911
+ const pass = status === "pass";
684
912
  if (!pass) {
685
913
  requireNonPassEvidence(payload);
686
914
  return;
@@ -722,6 +950,117 @@ function validatePayloadShape(phase, payload) {
722
950
  requirePassingCommands(payload);
723
951
  requireSatisfiedAcceptanceCriteria(payload);
724
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
+ }
725
1064
  function validateOptionalWorkItemId(payload, expectedWorkItemId) {
726
1065
  const actual = optionalString(payload, "work_item_id") ?? optionalString(payload, "workItemId");
727
1066
  if (actual && actual !== expectedWorkItemId) {
@@ -768,6 +1107,9 @@ function parseStrictCompletionPhase(value) {
768
1107
  if (normalized === "validate" || normalized === "validation") {
769
1108
  return "validation";
770
1109
  }
1110
+ if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
1111
+ return "qa";
1112
+ }
771
1113
  return null;
772
1114
  }
773
1115
  function parseCompletionRedactionSecrets(raw) {
@@ -796,6 +1138,9 @@ function phaseFromToolName(toolName) {
796
1138
  if (normalized === "forge.complete_validation" || normalized === "complete_validation") {
797
1139
  return "validation";
798
1140
  }
1141
+ if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
1142
+ return "qa";
1143
+ }
799
1144
  throw new Error(`Unknown Forge completion tool: ${toolName}`);
800
1145
  }
801
1146
  function requireField(payload, key) {
@@ -893,6 +1238,109 @@ function requireSatisfiedAcceptanceCriteria(payload) {
893
1238
  }
894
1239
  }
895
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
+ }
896
1344
  function fallbackReport(parsed, phase, artifactPath) {
897
1345
  return `# Forge ${phase.replace(/_/g, " ")} completion
898
1346
 
@@ -1,7 +1,6 @@
1
1
  import type { ProviderCliRunContext, ProviderCliRunResult } from "./provider-cli.types.js";
2
- import type { RunOnceDispatchPacket, RunnerDispatchEvent } from "../session.types.js";
3
2
  export interface ProviderCliService {
4
3
  runImplementation(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
5
4
  runConflictResolve(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
6
- runQa(dispatch: RunOnceDispatchPacket, emit: (event: RunnerDispatchEvent) => Promise<void>, signal?: AbortSignal): Promise<ProviderCliRunResult>;
5
+ runQa(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
7
6
  }
@@ -2,7 +2,7 @@ import { GitClient } from "../../shared/git/git.js";
2
2
  import { type CompletionToolRuntime } from "../phase-completion.js";
3
3
  import type { ProviderCliService } from "./provider-cli.base.js";
4
4
  import type { ProviderCliRunContext, ProviderCliRunResult } from "./provider-cli.types.js";
5
- import type { RunOnceDispatchPacket, RunnerDispatchEvent } from "../session.types.js";
5
+ import type { RunOnceDispatchPacket } from "../session.types.js";
6
6
  export interface ProviderCliCommand {
7
7
  readonly command: string;
8
8
  readonly args: readonly string[];
@@ -31,21 +31,21 @@ export declare class RoutingProviderCliService implements ProviderCliService {
31
31
  constructor(realProvider?: ProviderCliService, deterministicProvider?: ProviderCliService);
32
32
  runImplementation(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
33
33
  runConflictResolve(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
34
- runQa(dispatch: RunOnceDispatchPacket, emit: (event: RunnerDispatchEvent) => Promise<void>, signal?: AbortSignal): Promise<ProviderCliRunResult>;
34
+ runQa(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
35
35
  }
36
36
  export declare class ProcessBackedProviderCliService implements ProviderCliService {
37
37
  private readonly processRunner;
38
38
  private readonly git;
39
39
  constructor(processRunner?: ProviderProcessRunner, git?: GitClient);
40
40
  runImplementation(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
41
- runQa(dispatch: RunOnceDispatchPacket, emit: (event: RunnerDispatchEvent) => Promise<void>, signal?: AbortSignal): Promise<ProviderCliRunResult>;
41
+ runQa(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
42
42
  runConflictResolve(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
43
43
  }
44
44
  export declare class DeterministicProviderCliService implements ProviderCliService {
45
45
  private readonly git;
46
46
  constructor(git?: GitClient);
47
47
  runImplementation(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
48
- runQa(dispatch: RunOnceDispatchPacket, emit: (event: RunnerDispatchEvent) => Promise<void>): Promise<ProviderCliRunResult>;
48
+ runQa(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
49
49
  runConflictResolve(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
50
50
  }
51
51
  export declare class StreamingProviderProcessRunner implements ProviderProcessRunner {
@@ -7,7 +7,7 @@ import { promisify } from "node:util";
7
7
  import { GitClient } from "../../shared/git/git.js";
8
8
  import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
9
9
  import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
10
- import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDevelopCycle, completionPromptForPhase, completionRedactionEnv, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDevelopCycleCompletion, writeCompletionArtifact } from "../phase-completion.js";
10
+ import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDevelopCycle, completionPromptForPhase, completionRedactionEnv, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDevelopCycleCompletion, writeCompletionArtifact, artifactRootFor } from "../phase-completion.js";
11
11
  import { addUsage, buildProviderUsageEvent, emptyUsage, hasUsage, readUsageNumber } from "./provider-usage.js";
12
12
  const fakeWorkerModel = "server-local-fake-worker";
13
13
  const execFileAsync = promisify(execFile);
@@ -29,10 +29,10 @@ export class RoutingProviderCliService {
29
29
  ? await this.deterministicProvider.runConflictResolve(context)
30
30
  : await this.realProvider.runConflictResolve(context);
31
31
  }
32
- async runQa(dispatch, emit, signal) {
33
- return isFakeWorkerModel(dispatch.execution.model)
34
- ? await this.deterministicProvider.runQa(dispatch, emit, signal)
35
- : await this.realProvider.runQa(dispatch, emit, signal);
32
+ async runQa(context) {
33
+ return isFakeWorkerModel(context.dispatch.execution.model)
34
+ ? await this.deterministicProvider.runQa(context)
35
+ : await this.realProvider.runQa(context);
36
36
  }
37
37
  }
38
38
  export class ProcessBackedProviderCliService {
@@ -149,42 +149,82 @@ export class ProcessBackedProviderCliService {
149
149
  artifacts: completionArtifacts
150
150
  };
151
151
  }
152
- async runQa(dispatch, emit, signal) {
152
+ async runQa(context) {
153
+ const { dispatch, worktree } = context;
154
+ const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "qa" });
153
155
  const command = buildProviderCliCommand(dispatch, {
154
- prompt: buildQaPrompt(dispatch)
156
+ worktreePath: worktree.worktreePath,
157
+ prompt: buildQaPrompt(dispatch),
158
+ completionTools
155
159
  });
156
- await emit({
160
+ const commandText = formatProviderCliCommand(command);
161
+ await context.emit({
157
162
  role: "runner",
158
163
  kind: "checkpoint",
159
- content: `Starting provider CLI QA: ${formatProviderCliCommand(command)}`,
164
+ content: `Starting provider CLI QA: ${commandText} in ${worktree.worktreePath}`,
160
165
  eventType: "provider_cli_started"
161
166
  });
167
+ await context.emit({
168
+ role: "runner",
169
+ kind: "checkpoint",
170
+ content: "Forge QA completion tool configured; provider must call forge.complete_qa before exit.",
171
+ eventType: "completion_tools_configured"
172
+ });
162
173
  const outputState = createProviderOutputState(dispatch.execution.provider);
163
- const result = await this.processRunner.run({
164
- command: command.command,
165
- args: command.args,
166
- cwd: process.cwd()
167
- }, {
168
- onStdoutLine: async (line) => {
169
- const event = outputState.consume(line);
170
- if (event) {
171
- await emit(event);
174
+ const liveQaEvents = new LiveQaArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, context.emit);
175
+ let result;
176
+ try {
177
+ liveQaEvents.start();
178
+ result = await this.processRunner.run({
179
+ command: command.command,
180
+ args: command.args,
181
+ cwd: worktree.worktreePath,
182
+ env: providerRedactionEnv(dispatch)
183
+ }, {
184
+ onStdoutLine: async (line) => {
185
+ const event = outputState.consume(line);
186
+ if (event) {
187
+ await context.emit(event);
188
+ }
172
189
  }
173
- }
174
- }, signal);
190
+ }, context.signal);
191
+ }
192
+ finally {
193
+ await liveQaEvents.stop();
194
+ await cleanupCompletionTools(completionTools, context.emit);
195
+ }
175
196
  if (result.exitCode !== 0) {
176
197
  const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
177
198
  throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
178
199
  }
179
- await emit({
200
+ await context.emit({
180
201
  role: "runner",
181
202
  kind: "checkpoint",
182
203
  content: `Provider CLI QA completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
183
204
  eventType: "provider_cli_completed"
184
205
  });
185
- await emitProviderUsageEvent(emit, dispatch, outputState);
206
+ await emitProviderUsageEvent(context.emit, dispatch, outputState);
207
+ const qaArtifact = await readQaCompletionArtifactBundle(worktree.worktreePath, dispatch.workItem.id);
208
+ if (!qaArtifact) {
209
+ const verdictOnly = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, "qa");
210
+ throw new Error(verdictOnly
211
+ ? "Provider CLI QA completed but did not produce the complete durable QA artifact bundle."
212
+ : "Provider CLI QA completed but did not call the Forge QA completion tool.");
213
+ }
214
+ if (!liveQaEvents.hasEmitted()) {
215
+ await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
216
+ await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
217
+ }
218
+ const status = (qaArtifact.status ?? "unknown").trim().toLowerCase();
219
+ const statusSummary = status === "pass"
220
+ ? "passed"
221
+ : status === "blocked"
222
+ ? "blocked"
223
+ : "found rework";
186
224
  return {
187
- summary: `${dispatch.execution.provider} CLI QA passed.`
225
+ summary: `${dispatch.execution.provider} CLI QA ${statusSummary}.`,
226
+ artifact: qaArtifact,
227
+ artifacts: [qaArtifact]
188
228
  };
189
229
  }
190
230
  async runConflictResolve(context) {
@@ -308,22 +348,32 @@ export class DeterministicProviderCliService {
308
348
  artifacts: completionArtifacts
309
349
  };
310
350
  }
311
- async runQa(dispatch, emit) {
312
- await emit({
351
+ async runQa(context) {
352
+ const { dispatch, worktree } = context;
353
+ await context.emit({
313
354
  role: "worker",
314
355
  kind: "message",
315
- content: `Running ${dispatch.execution.provider} QA checks through forge-bridge.`,
316
- eventType: "qa_analysis"
356
+ content: `Planning ${dispatch.execution.provider} QA checks through forge-bridge.`,
357
+ eventType: "qa_plan"
317
358
  });
318
- await emit({
319
- role: "assistant",
320
- kind: "message",
321
- content: "All checks passed. forge-bridge QA completed.",
322
- eventType: "qa_complete"
359
+ const summary = "All checks passed. forge-bridge QA completed.";
360
+ const qaArtifact = await writeQaArtifacts({
361
+ worktreePath: worktree.worktreePath,
362
+ workItemId: dispatch.workItem.id,
363
+ acceptanceCriteria: dispatch.workItem.acceptanceCriteria,
364
+ producedBy: "forge-bridge",
365
+ provider: dispatch.execution.provider,
366
+ model: dispatch.execution.model,
367
+ command: "forge-bridge deterministic QA",
368
+ summary
323
369
  });
324
- await emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
370
+ await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
371
+ await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
372
+ await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
325
373
  return {
326
- summary: "forge-bridge QA passed."
374
+ summary: "forge-bridge QA passed.",
375
+ artifact: qaArtifact,
376
+ artifacts: [qaArtifact]
327
377
  };
328
378
  }
329
379
  async runConflictResolve(context) {
@@ -426,6 +476,238 @@ function deterministicCompletionPayload(phase, dispatch, changesetBody) {
426
476
  ready_for_dev_test: true
427
477
  };
428
478
  }
479
+ const qaArtifactFileNames = {
480
+ plan: "qa.plan.json",
481
+ evidence: "qa.evidence.json",
482
+ findings: "qa.findings.json",
483
+ verdict: "qa.verdict.json",
484
+ report: "qa.report.md"
485
+ };
486
+ async function writeQaArtifacts(request) {
487
+ const artifactRoot = artifactRootFor(request.worktreePath, request.workItemId);
488
+ await fs.mkdir(artifactRoot, { recursive: true });
489
+ const criterion = request.acceptanceCriteria.trim() || "Forge QA evidence exists";
490
+ const relativeEvidencePath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.evidence);
491
+ const relativeFindingsPath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.findings);
492
+ const relativeReportPath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.report);
493
+ const planJson = jsonText({
494
+ schema_version: 1,
495
+ phase: "qa",
496
+ checklist: [
497
+ "Review the validated work item against acceptance criteria.",
498
+ "Run relevant provider QA verification.",
499
+ "Confirm no in-scope QA findings remain."
500
+ ],
501
+ acceptance_criteria: [criterion]
502
+ });
503
+ const evidenceJson = jsonText({
504
+ schema_version: 1,
505
+ phase: "qa",
506
+ commands: [
507
+ {
508
+ command: request.command,
509
+ exit_code: 0,
510
+ status: "pass",
511
+ summary: `${request.provider} QA completed successfully.`
512
+ }
513
+ ],
514
+ browser_checks: [],
515
+ notes: request.summary
516
+ });
517
+ const findingsJson = jsonText({
518
+ schema_version: 1,
519
+ phase: "qa",
520
+ findings: [],
521
+ candidate_bugs: []
522
+ });
523
+ const verdictJson = jsonText({
524
+ schema_version: 1,
525
+ phase: "qa",
526
+ status: "pass",
527
+ summary: request.summary,
528
+ tested_acceptance_criteria: [criterion],
529
+ finding_ids: [],
530
+ candidate_bug_ids: [],
531
+ blocker_reason: null,
532
+ evidence_path: relativeEvidencePath,
533
+ findings_path: relativeFindingsPath,
534
+ report_path: relativeReportPath
535
+ });
536
+ const report = `# QA report\n\n${request.summary}\n\n- Provider: ${singleLine(request.provider)}\n- Model: ${singleLine(request.model)}\n- Command: ${singleLine(request.command)}\n`;
537
+ await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.plan), planJson, "utf8");
538
+ await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.evidence), evidenceJson, "utf8");
539
+ await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.findings), findingsJson, "utf8");
540
+ const verdictPath = path.join(artifactRoot, qaArtifactFileNames.verdict);
541
+ await fs.writeFile(verdictPath, verdictJson, "utf8");
542
+ await fs.writeFile(path.join(artifactRoot, qaArtifactFileNames.report), report, "utf8");
543
+ return {
544
+ repoPath: request.worktreePath,
545
+ path: verdictPath,
546
+ verdictJson,
547
+ report,
548
+ producedBy: request.producedBy,
549
+ status: "pass",
550
+ phase: "qa"
551
+ };
552
+ }
553
+ async function emitQaArtifactRecorded(emit, worktreePath, artifact) {
554
+ const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
555
+ await emit({
556
+ role: "tool",
557
+ kind: "tool_result",
558
+ content: `Forge QA artifact recorded ${relativePath} (${artifact.status}).`,
559
+ eventType: "qa_artifact_recorded"
560
+ });
561
+ await emit({
562
+ role: "hook",
563
+ kind: "checkpoint",
564
+ content: `phase_gate: qa artifact accepted with status ${artifact.status}.`,
565
+ eventType: "phase_gate"
566
+ });
567
+ }
568
+ async function readQaCompletionArtifactBundle(worktreePath, workItemId) {
569
+ const artifact = await readCompletionArtifact(worktreePath, workItemId, "qa");
570
+ if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId)) {
571
+ return null;
572
+ }
573
+ return artifact;
574
+ }
575
+ async function hasCompleteQaArtifactBundle(worktreePath, workItemId) {
576
+ const artifactRoot = artifactRootFor(worktreePath, workItemId);
577
+ for (const fileName of Object.values(qaArtifactFileNames)) {
578
+ try {
579
+ const stat = await fs.stat(path.join(artifactRoot, fileName));
580
+ if (!stat.isFile()) {
581
+ return false;
582
+ }
583
+ }
584
+ catch (error) {
585
+ if (isNodeError(error) && error.code === "ENOENT") {
586
+ return false;
587
+ }
588
+ throw error;
589
+ }
590
+ }
591
+ return true;
592
+ }
593
+ async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact) {
594
+ const artifactRoot = artifactRootFor(worktreePath, workItemId);
595
+ const plan = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.plan));
596
+ const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
597
+ const findings = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.findings));
598
+ const verdict = parseJsonLine(artifact.verdictJson) ?? {};
599
+ const status = stringField(verdict, "status") ?? artifact.status ?? "unknown";
600
+ const checklist = stringArrayField(plan, "checklist");
601
+ await emit({
602
+ role: "worker",
603
+ kind: "message",
604
+ content: checklist.length > 0
605
+ ? `QA plan: ${checklist.join("; ")}`
606
+ : "QA plan recorded.",
607
+ eventType: "qa_plan"
608
+ });
609
+ for (const browserCheck of arrayField(evidence, "browser_checks")) {
610
+ await emit({
611
+ role: "worker",
612
+ kind: "checkpoint",
613
+ content: `QA browser check: ${summarizeQaObject(browserCheck)}`,
614
+ eventType: "qa_browser_check"
615
+ });
616
+ }
617
+ for (const finding of [...arrayField(findings, "findings"), ...arrayField(findings, "candidate_bugs")]) {
618
+ await emit({
619
+ role: "worker",
620
+ kind: "checkpoint",
621
+ content: `QA finding: ${summarizeQaObject(finding)}`,
622
+ eventType: "qa_finding"
623
+ });
624
+ }
625
+ await emit({
626
+ role: "assistant",
627
+ kind: "message",
628
+ content: status === "blocked"
629
+ ? `QA blocked: ${stringField(verdict, "summary") ?? "No summary provided."}`
630
+ : `QA completed with status ${status}: ${stringField(verdict, "summary") ?? "No summary provided."}`,
631
+ eventType: status === "blocked" ? "qa_blocked" : "qa_complete"
632
+ });
633
+ }
634
+ class LiveQaArtifactEmitter {
635
+ worktreePath;
636
+ workItemId;
637
+ emit;
638
+ emitted = false;
639
+ timer = null;
640
+ pending = Promise.resolve();
641
+ constructor(worktreePath, workItemId, emit) {
642
+ this.worktreePath = worktreePath;
643
+ this.workItemId = workItemId;
644
+ this.emit = emit;
645
+ }
646
+ start() {
647
+ void this.pollQueued();
648
+ this.timer = setInterval(() => {
649
+ void this.pollQueued();
650
+ }, 250);
651
+ }
652
+ async stop() {
653
+ if (this.timer) {
654
+ clearInterval(this.timer);
655
+ this.timer = null;
656
+ }
657
+ await this.pollQueued();
658
+ }
659
+ hasEmitted() {
660
+ return this.emitted;
661
+ }
662
+ pollQueued() {
663
+ this.pending = this.pending
664
+ .then(() => this.poll())
665
+ .catch(() => undefined);
666
+ return this.pending;
667
+ }
668
+ async poll() {
669
+ if (this.emitted) {
670
+ return;
671
+ }
672
+ const artifact = await readQaCompletionArtifactBundle(this.worktreePath, this.workItemId);
673
+ if (!artifact) {
674
+ return;
675
+ }
676
+ this.emitted = true;
677
+ await emitQaArtifactRecorded(this.emit, this.worktreePath, artifact);
678
+ await emitQaStructuredEvents(this.emit, this.worktreePath, this.workItemId, artifact);
679
+ }
680
+ }
681
+ async function readJsonObjectFile(filePath) {
682
+ const content = await fs.readFile(filePath, "utf8").catch(() => "");
683
+ return parseJsonLine(content) ?? {};
684
+ }
685
+ function arrayField(source, key) {
686
+ const value = source[key];
687
+ if (!Array.isArray(value)) {
688
+ return [];
689
+ }
690
+ return value.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
691
+ }
692
+ function stringArrayField(source, key) {
693
+ const value = source[key];
694
+ if (!Array.isArray(value)) {
695
+ return [];
696
+ }
697
+ return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
698
+ }
699
+ function summarizeQaObject(source) {
700
+ return clip(stringField(source, "title")
701
+ ?? stringField(source, "summary")
702
+ ?? stringField(source, "id")
703
+ ?? JSON.stringify(source), 500);
704
+ }
705
+ function qaArtifactRelativePath(workItemId, fileName) {
706
+ return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
707
+ }
708
+ function jsonText(value) {
709
+ return `${JSON.stringify(value, null, 2)}\n`;
710
+ }
429
711
  async function emitCompletionArtifactRecorded(emit, worktreePath, artifact) {
430
712
  const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
431
713
  await emit({
@@ -460,7 +742,7 @@ async function emitPhaseGateEvents(emit, artifact) {
460
742
  ? "phase_progression: implementation -> self_review"
461
743
  : phase === "self_review"
462
744
  ? "phase_progression: self_review -> validation"
463
- : "phase_progression: validation -> Dev Test ready";
745
+ : "phase_progression: validation -> QA ready";
464
746
  await emit({
465
747
  role: "hook",
466
748
  kind: "checkpoint",
@@ -470,7 +752,7 @@ async function emitPhaseGateEvents(emit, artifact) {
470
752
  }
471
753
  function completionPhaseFromArtifact(artifact) {
472
754
  const phase = artifact.phase?.trim();
473
- return phase === "self_review" || phase === "validation" || phase === "implementation"
755
+ return phase === "self_review" || phase === "validation" || phase === "implementation" || phase === "qa"
474
756
  ? phase
475
757
  : "implementation";
476
758
  }
@@ -789,6 +1071,7 @@ function buildImplementationPrompt(dispatch, phase = completionPhaseFromDispatch
789
1071
 
790
1072
  Read .forge/work-items/${dispatch.workItem.id}/spec.md and any prior phase artifacts required by this phase.
791
1073
  ${phaseWorkInstruction(phase)}
1074
+ ${qaReworkImplementationInstruction(dispatch)}
792
1075
  Use the repository's existing conventions. Run relevant tests when practical.
793
1076
  Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, phase verdict artifacts, reports, and commits.
794
1077
  Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge verdict/report artifacts by hand.
@@ -802,6 +1085,7 @@ function buildDevelopCyclePrompt(dispatch, phase = completionPhaseFromDispatch(d
802
1085
 
803
1086
  Read .forge/work-items/${dispatch.workItem.id}/spec.md, implement the work item, self-review the diff, then validate it in this same provider session.
804
1087
  Start in the ${phase.replace(/_/g, " ")} phase. Do not stop after implementation if it passes; continue through self-review and validation using the next instruction returned by each completion tool.
1088
+ ${qaReworkImplementationInstruction(dispatch)}
805
1089
  Use the repository's existing conventions. Run relevant tests when practical.
806
1090
  Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, phase verdict artifacts, reports, and commits.
807
1091
  Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge verdict/report artifacts by hand.
@@ -810,20 +1094,64 @@ ${completionPromptForDevelopCycle(phase)}
810
1094
 
811
1095
  Finish with a concise summary of changed files and verification after the validation completion tool has accepted the work, or after the current phase tool records a non-pass blocker.`;
812
1096
  }
1097
+ function qaReworkImplementationInstruction(dispatch) {
1098
+ if (!dispatch.qaRework) {
1099
+ return "";
1100
+ }
1101
+ return `
1102
+ QA rework context:
1103
+ - Attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
1104
+ - Rework packet: ${dispatch.qaRework.packetPath}
1105
+ - Required QA finding IDs: ${dispatch.qaRework.findingIds.join(", ")}
1106
+ Read the packet JSON and fix or explicitly dispute each listed finding. The implementation completion artifact must include resolved_findings entries for every required QA ID with qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.`;
1107
+ }
1108
+ function qaReworkRetestInstruction(dispatch) {
1109
+ if (!dispatch.qaRework) {
1110
+ return "";
1111
+ }
1112
+ return `
1113
+ QA retest context:
1114
+ - Rework attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
1115
+ - Rework packet: ${dispatch.qaRework.packetPath}
1116
+ - Priority finding IDs: ${dispatch.qaRework.findingIds.join(", ")}
1117
+ Prioritize retesting the listed QA findings and their required regressions before deciding pass, needs_rework, or blocked.`;
1118
+ }
813
1119
  function phaseWorkInstruction(phase) {
814
1120
  if (phase === "self_review") {
815
1121
  return "Review the implementation diff, apply safe fixes when needed, and record remaining risks explicitly.";
816
1122
  }
817
1123
  if (phase === "validation") {
818
- return "Run the validation commands and deterministic drift checks needed to prove the work is ready for Dev Test.";
1124
+ return "Run the validation commands and deterministic drift checks needed to prove the work is ready for QA.";
819
1125
  }
820
1126
  return "Implement the work item and keep the change focused.";
821
1127
  }
822
1128
  function buildQaPrompt(dispatch) {
823
- return `You are Forge's QA lane running locally.
1129
+ return `You are Forge's QA lane running locally in a prepared git worktree.
1130
+
1131
+ Work item: ${dispatch.workItem.id}
1132
+ Title: ${dispatch.workItem.title}
1133
+ Description:
1134
+ ${dispatch.workItem.description || "Not provided."}
1135
+
1136
+ Acceptance criteria:
1137
+ ${dispatch.workItem.acceptanceCriteria || "Not specified."}
824
1138
 
825
- Review the implementation for work item ${dispatch.workItem.id}: ${dispatch.workItem.title}
826
- Run relevant verification when practical and finish with a concise QA verdict.`;
1139
+ Repro steps / scenario:
1140
+ ${dispatch.workItem.reproSteps || "Not provided."}
1141
+
1142
+ System info / environment notes:
1143
+ ${dispatch.workItem.systemInfo || "Not provided."}
1144
+
1145
+ Inspect the validated implementation and Forge artifacts under .forge/work-items/${dispatch.workItem.id}/artifacts/ when present, especially implementation, self-review, and validation evidence.
1146
+ ${qaReworkRetestInstruction(dispatch)}
1147
+ QA is read-mostly: inspect code, run commands, and exercise browser/app flows when available, but do not edit product source or tests.
1148
+ Record concrete evidence. For UI/browser work, include browser_checks with URLs, actions, observed results, screenshots/traces when available, and console/network notes.
1149
+ Report in-scope defects as actionable findings with repro steps, expected/actual behavior, evidence, likely area, required fix, and required regression.
1150
+ Out-of-scope or follow-up issues belong in candidate_bugs instead of blocking the current item.
1151
+
1152
+ ${completionPromptForPhase("qa")}
1153
+
1154
+ Finish with a concise summary only after forge.complete_qa accepts the QA bundle.`;
827
1155
  }
828
1156
  function buildConflictResolvePrompt(dispatch, branchName) {
829
1157
  const targetBranch = dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
@@ -1180,3 +1508,6 @@ function stringField(source, key) {
1180
1508
  const value = source[key];
1181
1509
  return typeof value === "string" && value.trim().length > 0 ? value : null;
1182
1510
  }
1511
+ function isNodeError(error) {
1512
+ return error instanceof Error && "code" in error;
1513
+ }
@@ -50,21 +50,17 @@ export class BridgeSessionService {
50
50
  const role = dispatch.execution.role.toLowerCase();
51
51
  const isQa = role === "qa" || lane === "qa";
52
52
  const isConflictResolve = role === "conflict_resolve" || lane === "conflict_resolve";
53
+ const context = {
54
+ dispatch,
55
+ worktree,
56
+ emit,
57
+ signal
58
+ };
53
59
  const run = isQa
54
- ? await this.providerCli.runQa(dispatch, emit, signal)
60
+ ? await this.providerCli.runQa(context)
55
61
  : isConflictResolve
56
- ? await this.providerCli.runConflictResolve({
57
- dispatch,
58
- worktree,
59
- emit,
60
- signal
61
- })
62
- : await this.providerCli.runImplementation({
63
- dispatch,
64
- worktree,
65
- emit,
66
- signal
67
- });
62
+ ? await this.providerCli.runConflictResolve(context)
63
+ : await this.providerCli.runImplementation(context);
68
64
  const phaseArtifacts = run.artifacts?.length ? run.artifacts : run.artifact ? [run.artifact] : [];
69
65
  return redactSecrets({
70
66
  schemaVersion: 1,
@@ -11,6 +11,15 @@ export interface RunOnceDispatchPacket {
11
11
  readonly apiKeySecretGrant?: ApiKeySecretGrant | null;
12
12
  readonly artifacts: readonly DispatchResolvedArtifact[];
13
13
  readonly warmPathOverrides?: readonly string[];
14
+ readonly qaRework?: DispatchQaReworkContext | null;
15
+ }
16
+ export interface DispatchQaReworkContext {
17
+ readonly attempt: number;
18
+ readonly maxAttempts: number;
19
+ readonly packetPath: string;
20
+ readonly packetJson: string;
21
+ readonly findingIds: readonly string[];
22
+ readonly findings: readonly Record<string, unknown>[];
14
23
  }
15
24
  export interface DispatchResolvedArtifact {
16
25
  readonly kind: string;
@@ -17,7 +17,8 @@ export function parseRunOnceDispatchPacket(value) {
17
17
  serverLocal: parseServerLocal(packet.serverLocal),
18
18
  apiKeySecretGrant: parseApiKeySecretGrant(packet.apiKeySecretGrant),
19
19
  artifacts: parseArtifacts(packet.artifacts),
20
- warmPathOverrides: parseWarmPathOverrides(packet.warmPathOverrides)
20
+ warmPathOverrides: parseWarmPathOverrides(packet.warmPathOverrides),
21
+ qaRework: parseQaRework(packet.qaRework)
21
22
  };
22
23
  }
23
24
  export function failureResult(summary, events = []) {
@@ -138,6 +139,33 @@ function parseWarmPathOverride(entry, index) {
138
139
  }
139
140
  return segments.join("/");
140
141
  }
142
+ function parseQaRework(value) {
143
+ if (value === undefined || value === null) {
144
+ return null;
145
+ }
146
+ const qaRework = asObject(value, "qaRework");
147
+ const findingIdsValue = qaRework.findingIds;
148
+ if (!Array.isArray(findingIdsValue)) {
149
+ throw new Error("qaRework.findingIds must be an array.");
150
+ }
151
+ const findingsValue = qaRework.findings;
152
+ if (!Array.isArray(findingsValue)) {
153
+ throw new Error("qaRework.findings must be an array.");
154
+ }
155
+ return {
156
+ attempt: requiredNumber(qaRework, "attempt"),
157
+ maxAttempts: requiredNumber(qaRework, "maxAttempts"),
158
+ packetPath: requiredString(qaRework, "packetPath"),
159
+ packetJson: requiredString(qaRework, "packetJson"),
160
+ findingIds: findingIdsValue.map((entry, index) => {
161
+ if (typeof entry !== "string" || entry.trim().length === 0) {
162
+ throw new Error(`qaRework.findingIds[${index}] must be a non-empty string.`);
163
+ }
164
+ return entry;
165
+ }),
166
+ findings: findingsValue.map((entry, index) => asObject(entry, `qaRework.findings[${index}]`))
167
+ };
168
+ }
141
169
  function parseArtifacts(value) {
142
170
  if (value === undefined || value === null) {
143
171
  return [];
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const forgeBridgeVersion = "0.1.1-dev.4f31cdd58cac";
1
+ export declare const forgeBridgeVersion = "0.1.1-dev.65ea6889412c";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const forgeBridgeVersion = "0.1.1-dev.4f31cdd58cac";
1
+ export const forgeBridgeVersion = "0.1.1-dev.65ea6889412c";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.1-dev.4f31cdd58cac",
3
+ "version": "0.1.1-dev.65ea6889412c",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",