@loucompanion/forge-bridge 0.1.1-dev.ac486debe210 → 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.
@@ -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
  }
@@ -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
 
@@ -151,9 +151,11 @@ export class ProcessBackedProviderCliService {
151
151
  }
152
152
  async runQa(context) {
153
153
  const { dispatch, worktree } = context;
154
+ const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase: "qa" });
154
155
  const command = buildProviderCliCommand(dispatch, {
155
156
  worktreePath: worktree.worktreePath,
156
- prompt: buildQaPrompt(dispatch)
157
+ prompt: buildQaPrompt(dispatch),
158
+ completionTools
157
159
  });
158
160
  const commandText = formatProviderCliCommand(command);
159
161
  await context.emit({
@@ -162,20 +164,35 @@ export class ProcessBackedProviderCliService {
162
164
  content: `Starting provider CLI QA: ${commandText} in ${worktree.worktreePath}`,
163
165
  eventType: "provider_cli_started"
164
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
+ });
165
173
  const outputState = createProviderOutputState(dispatch.execution.provider);
166
- const result = await this.processRunner.run({
167
- command: command.command,
168
- args: command.args,
169
- cwd: worktree.worktreePath,
170
- env: providerRedactionEnv(dispatch)
171
- }, {
172
- onStdoutLine: async (line) => {
173
- const event = outputState.consume(line);
174
- if (event) {
175
- await context.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
+ }
176
189
  }
177
- }
178
- }, context.signal);
190
+ }, context.signal);
191
+ }
192
+ finally {
193
+ await liveQaEvents.stop();
194
+ await cleanupCompletionTools(completionTools, context.emit);
195
+ }
179
196
  if (result.exitCode !== 0) {
180
197
  const stderr = clip(result.stderr.trim() || result.stdout.trim(), 1200);
181
198
  throw new Error(`${command.command} exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
@@ -187,20 +204,25 @@ export class ProcessBackedProviderCliService {
187
204
  eventType: "provider_cli_completed"
188
205
  });
189
206
  await emitProviderUsageEvent(context.emit, dispatch, outputState);
190
- const summary = outputState.finalMessage || `${dispatch.execution.provider} CLI QA passed.`;
191
- const qaArtifact = await writeQaArtifacts({
192
- worktreePath: worktree.worktreePath,
193
- workItemId: dispatch.workItem.id,
194
- acceptanceCriteria: dispatch.workItem.acceptanceCriteria,
195
- producedBy: `${dispatch.execution.provider}-cli`,
196
- provider: dispatch.execution.provider,
197
- model: dispatch.execution.model,
198
- command: commandText,
199
- summary
200
- });
201
- await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
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";
202
224
  return {
203
- summary: `${dispatch.execution.provider} CLI QA passed.`,
225
+ summary: `${dispatch.execution.provider} CLI QA ${statusSummary}.`,
204
226
  artifact: qaArtifact,
205
227
  artifacts: [qaArtifact]
206
228
  };
@@ -331,8 +353,8 @@ export class DeterministicProviderCliService {
331
353
  await context.emit({
332
354
  role: "worker",
333
355
  kind: "message",
334
- content: `Running ${dispatch.execution.provider} QA checks through forge-bridge.`,
335
- eventType: "qa_analysis"
356
+ content: `Planning ${dispatch.execution.provider} QA checks through forge-bridge.`,
357
+ eventType: "qa_plan"
336
358
  });
337
359
  const summary = "All checks passed. forge-bridge QA completed.";
338
360
  const qaArtifact = await writeQaArtifacts({
@@ -346,12 +368,7 @@ export class DeterministicProviderCliService {
346
368
  summary
347
369
  });
348
370
  await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
349
- await context.emit({
350
- role: "assistant",
351
- kind: "message",
352
- content: summary,
353
- eventType: "qa_complete"
354
- });
371
+ await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
355
372
  await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
356
373
  return {
357
374
  summary: "forge-bridge QA passed.",
@@ -548,6 +565,143 @@ async function emitQaArtifactRecorded(emit, worktreePath, artifact) {
548
565
  eventType: "phase_gate"
549
566
  });
550
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
+ }
551
705
  function qaArtifactRelativePath(workItemId, fileName) {
552
706
  return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
553
707
  }
@@ -598,7 +752,7 @@ async function emitPhaseGateEvents(emit, artifact) {
598
752
  }
599
753
  function completionPhaseFromArtifact(artifact) {
600
754
  const phase = artifact.phase?.trim();
601
- return phase === "self_review" || phase === "validation" || phase === "implementation"
755
+ return phase === "self_review" || phase === "validation" || phase === "implementation" || phase === "qa"
602
756
  ? phase
603
757
  : "implementation";
604
758
  }
@@ -948,10 +1102,31 @@ function phaseWorkInstruction(phase) {
948
1102
  return "Implement the work item and keep the change focused.";
949
1103
  }
950
1104
  function buildQaPrompt(dispatch) {
951
- return `You are Forge's QA lane running locally.
1105
+ return `You are Forge's QA lane running locally in a prepared git worktree.
1106
+
1107
+ Work item: ${dispatch.workItem.id}
1108
+ Title: ${dispatch.workItem.title}
1109
+ Description:
1110
+ ${dispatch.workItem.description || "Not provided."}
1111
+
1112
+ Acceptance criteria:
1113
+ ${dispatch.workItem.acceptanceCriteria || "Not specified."}
1114
+
1115
+ Repro steps / scenario:
1116
+ ${dispatch.workItem.reproSteps || "Not provided."}
952
1117
 
953
- Review the implementation for work item ${dispatch.workItem.id}: ${dispatch.workItem.title}
954
- Run relevant verification when practical and finish with a concise QA verdict.`;
1118
+ System info / environment notes:
1119
+ ${dispatch.workItem.systemInfo || "Not provided."}
1120
+
1121
+ Inspect the validated implementation and Forge artifacts under .forge/work-items/${dispatch.workItem.id}/artifacts/ when present, especially implementation, self-review, and validation evidence.
1122
+ QA is read-mostly: inspect code, run commands, and exercise browser/app flows when available, but do not edit product source or tests.
1123
+ Record concrete evidence. For UI/browser work, include browser_checks with URLs, actions, observed results, screenshots/traces when available, and console/network notes.
1124
+ Report in-scope defects as actionable findings with repro steps, expected/actual behavior, evidence, likely area, required fix, and required regression.
1125
+ Out-of-scope or follow-up issues belong in candidate_bugs instead of blocking the current item.
1126
+
1127
+ ${completionPromptForPhase("qa")}
1128
+
1129
+ Finish with a concise summary only after forge.complete_qa accepts the QA bundle.`;
955
1130
  }
956
1131
  function buildConflictResolvePrompt(dispatch, branchName) {
957
1132
  const targetBranch = dispatch.cycleRun.requestedBase || dispatch.repo.defaultBranch || "main";
@@ -1308,3 +1483,6 @@ function stringField(source, key) {
1308
1483
  const value = source[key];
1309
1484
  return typeof value === "string" && value.trim().length > 0 ? value : null;
1310
1485
  }
1486
+ function isNodeError(error) {
1487
+ return error instanceof Error && "code" in error;
1488
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const forgeBridgeVersion = "0.1.1-dev.ac486debe210";
1
+ export declare const forgeBridgeVersion = "0.1.1-dev.caea7216568f";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const forgeBridgeVersion = "0.1.1-dev.ac486debe210";
1
+ export const forgeBridgeVersion = "0.1.1-dev.caea7216568f";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.1-dev.ac486debe210",
3
+ "version": "0.1.1-dev.caea7216568f",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",