@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.
- package/dist/session/phase-completion.d.ts +2 -2
- package/dist/session/phase-completion.js +455 -7
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +4 -4
- package/dist/session/provider-cli/provider-cli.service.js +371 -40
- package/dist/session/session.service.js +9 -13
- package/dist/session/session.types.d.ts +9 -0
- package/dist/session/session.types.js +29 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
: "
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
33
|
-
return isFakeWorkerModel(dispatch.execution.model)
|
|
34
|
-
? await this.deterministicProvider.runQa(
|
|
35
|
-
: await this.realProvider.runQa(
|
|
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(
|
|
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
|
-
|
|
156
|
+
worktreePath: worktree.worktreePath,
|
|
157
|
+
prompt: buildQaPrompt(dispatch),
|
|
158
|
+
completionTools
|
|
155
159
|
});
|
|
156
|
-
|
|
160
|
+
const commandText = formatProviderCliCommand(command);
|
|
161
|
+
await context.emit({
|
|
157
162
|
role: "runner",
|
|
158
163
|
kind: "checkpoint",
|
|
159
|
-
content: `Starting provider CLI QA: ${
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
}
|
|
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
|
|
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(
|
|
312
|
-
|
|
351
|
+
async runQa(context) {
|
|
352
|
+
const { dispatch, worktree } = context;
|
|
353
|
+
await context.emit({
|
|
313
354
|
role: "worker",
|
|
314
355
|
kind: "message",
|
|
315
|
-
content: `
|
|
316
|
-
eventType: "
|
|
356
|
+
content: `Planning ${dispatch.execution.provider} QA checks through forge-bridge.`,
|
|
357
|
+
eventType: "qa_plan"
|
|
317
358
|
});
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
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 ->
|
|
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
|
|
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
|
-
|
|
826
|
-
|
|
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(
|
|
60
|
+
? await this.providerCli.runQa(context)
|
|
55
61
|
: isConflictResolve
|
|
56
|
-
? await this.providerCli.runConflictResolve(
|
|
57
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|