@loucompanion/forge-bridge 0.1.1-dev.615630fb4618 → 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 +31 -1
- package/dist/session/phase-completion.js +782 -78
- 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 +612 -65
- package/dist/session/provider-cli/provider-usage.d.ts +25 -0
- package/dist/session/provider-cli/provider-usage.js +64 -0
- package/dist/session/session.service.js +9 -13
- package/dist/session/session.types.d.ts +14 -0
- package/dist/session/session.types.js +40 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -5,6 +5,18 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { redactSecrets } from "../shared/redaction/redaction.js";
|
|
6
6
|
const toolServerName = "forge";
|
|
7
7
|
const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
|
|
8
|
+
const completionPhaseOrder = ["implementation", "self_review", "validation"];
|
|
9
|
+
const qaBlockerReasons = new Set([
|
|
10
|
+
"missing_app_launch_target",
|
|
11
|
+
"inconclusive",
|
|
12
|
+
"environment_failure",
|
|
13
|
+
"missing_validation_evidence",
|
|
14
|
+
"missing_worktree",
|
|
15
|
+
"runner_failure"
|
|
16
|
+
]);
|
|
17
|
+
const qaFindingSeverities = new Set(["critical", "major", "minor"]);
|
|
18
|
+
const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
|
|
19
|
+
const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
|
|
8
20
|
const phaseFiles = {
|
|
9
21
|
implementation: {
|
|
10
22
|
jsonFileName: "implementation.completeness.json",
|
|
@@ -17,6 +29,10 @@ const phaseFiles = {
|
|
|
17
29
|
validation: {
|
|
18
30
|
jsonFileName: "validation.verdict.json",
|
|
19
31
|
reportFileName: "validation.report.md"
|
|
32
|
+
},
|
|
33
|
+
qa: {
|
|
34
|
+
jsonFileName: "qa.verdict.json",
|
|
35
|
+
reportFileName: "qa.report.md"
|
|
20
36
|
}
|
|
21
37
|
};
|
|
22
38
|
export function completionPhaseFromDispatch(dispatch) {
|
|
@@ -30,14 +46,28 @@ export function normalizeCompletionPhase(value) {
|
|
|
30
46
|
if (normalized === "validate" || normalized === "validation") {
|
|
31
47
|
return "validation";
|
|
32
48
|
}
|
|
49
|
+
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
50
|
+
return "qa";
|
|
51
|
+
}
|
|
33
52
|
return "implementation";
|
|
34
53
|
}
|
|
54
|
+
export function usesDevelopCycleCompletion(dispatch) {
|
|
55
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
56
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
57
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
58
|
+
return kind === "develop"
|
|
59
|
+
&& role === "develop"
|
|
60
|
+
&& lane === "develop"
|
|
61
|
+
&& completionPhaseFromDispatch(dispatch) === "implementation";
|
|
62
|
+
}
|
|
35
63
|
export function completionToolNamesForPhase(phase) {
|
|
36
64
|
const base = phase === "implementation"
|
|
37
65
|
? "complete_implementation"
|
|
38
66
|
: phase === "self_review"
|
|
39
67
|
? "complete_self_review"
|
|
40
|
-
: "
|
|
68
|
+
: phase === "validation"
|
|
69
|
+
? "complete_validation"
|
|
70
|
+
: "complete_qa";
|
|
41
71
|
return [`forge.${base}`, base];
|
|
42
72
|
}
|
|
43
73
|
export function expectedCompletionToolName(phase) {
|
|
@@ -60,7 +90,8 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
60
90
|
worktreePath,
|
|
61
91
|
workItemId,
|
|
62
92
|
phase,
|
|
63
|
-
producedBy: `${options.dispatch.execution.provider}-cli
|
|
93
|
+
producedBy: `${options.dispatch.execution.provider}-cli`,
|
|
94
|
+
developCycle: usesDevelopCycleCompletion(options.dispatch)
|
|
64
95
|
});
|
|
65
96
|
const runtime = {
|
|
66
97
|
serverName: toolServerName,
|
|
@@ -75,7 +106,7 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
75
106
|
return await prepareCursorCompletionToolRuntime(worktreePath, runtime);
|
|
76
107
|
}
|
|
77
108
|
export function buildCompletionToolServerArgs(config) {
|
|
78
|
-
|
|
109
|
+
const args = [
|
|
79
110
|
"mcp",
|
|
80
111
|
"--worktree",
|
|
81
112
|
path.resolve(config.worktreePath),
|
|
@@ -86,6 +117,10 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
86
117
|
"--produced-by",
|
|
87
118
|
config.producedBy ?? "forge-completion-tool"
|
|
88
119
|
];
|
|
120
|
+
if (config.developCycle) {
|
|
121
|
+
args.push("--develop-cycle");
|
|
122
|
+
}
|
|
123
|
+
return args;
|
|
89
124
|
}
|
|
90
125
|
export function codexMcpConfigArgs(runtime) {
|
|
91
126
|
const prefix = `mcp_servers.${runtime.serverName}`;
|
|
@@ -111,25 +146,122 @@ export function completionPromptForPhase(phase) {
|
|
|
111
146
|
const toolName = expectedCompletionToolName(phase);
|
|
112
147
|
const alias = completionToolNamesForPhase(phase).find((name) => name !== toolName);
|
|
113
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
|
+
}
|
|
114
156
|
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
115
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.
|
|
116
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.
|
|
117
159
|
If the work cannot pass, call the same tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
|
|
118
160
|
}
|
|
161
|
+
export function completionPromptForDevelopCycle(phase) {
|
|
162
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
163
|
+
Do not write phase artifacts by hand. The tool server enforces phase order and starts this run in \`${phase}\`.
|
|
164
|
+
Complete the dev cycle in order inside this same provider session:
|
|
165
|
+
1. Call \`forge.complete_implementation(...)\` when implementation evidence is ready.
|
|
166
|
+
2. Follow the returned self-review instruction, then call \`forge.complete_self_review(...)\`.
|
|
167
|
+
3. Follow the returned validation instruction, then call \`forge.complete_validation(...)\`.
|
|
168
|
+
Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
|
|
169
|
+
If the current phase cannot pass, call the current phase tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
|
|
170
|
+
}
|
|
171
|
+
export function completionToolNamesForServer(config) {
|
|
172
|
+
return config.developCycle
|
|
173
|
+
? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
|
|
174
|
+
: completionToolNamesForPhase(config.phase);
|
|
175
|
+
}
|
|
176
|
+
export class CompletionToolServerSession {
|
|
177
|
+
config;
|
|
178
|
+
currentPhase;
|
|
179
|
+
completed = false;
|
|
180
|
+
constructor(config) {
|
|
181
|
+
this.config = config;
|
|
182
|
+
this.currentPhase = config.phase;
|
|
183
|
+
}
|
|
184
|
+
get instructions() {
|
|
185
|
+
return this.config.developCycle
|
|
186
|
+
? completionPromptForDevelopCycle(this.currentPhase)
|
|
187
|
+
: completionPromptForPhase(this.currentPhase);
|
|
188
|
+
}
|
|
189
|
+
toolDefinitions() {
|
|
190
|
+
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name)));
|
|
191
|
+
}
|
|
192
|
+
async callTool(toolName, input) {
|
|
193
|
+
const requestedPhase = phaseFromToolName(toolName);
|
|
194
|
+
if (this.completed) {
|
|
195
|
+
throw new Error("Forge develop cycle is already complete; no more phase completion calls are accepted.");
|
|
196
|
+
}
|
|
197
|
+
if (requestedPhase !== this.currentPhase) {
|
|
198
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
|
|
199
|
+
}
|
|
200
|
+
const completedPhase = this.currentPhase;
|
|
201
|
+
const reference = await writeCompletionArtifact({ ...this.config, phase: completedPhase }, toolName, input);
|
|
202
|
+
const status = reference.status.trim().toLowerCase();
|
|
203
|
+
const pass = status === "pass";
|
|
204
|
+
const nextPhase = pass
|
|
205
|
+
? this.advanceAfterPass(completedPhase)
|
|
206
|
+
: completedPhase;
|
|
207
|
+
const nextInstruction = completionToolNextInstruction(completedPhase, status, nextPhase);
|
|
208
|
+
const reason = pass
|
|
209
|
+
? `${completedPhase} completion artifact accepted.`
|
|
210
|
+
: `${completedPhase} completion artifact recorded with status ${status}; rework is required before phase progression.`;
|
|
211
|
+
return {
|
|
212
|
+
content: [
|
|
213
|
+
{
|
|
214
|
+
type: "text",
|
|
215
|
+
text: `Forge recorded ${reference.phase} completion artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
|
|
216
|
+
}
|
|
217
|
+
],
|
|
218
|
+
structuredContent: {
|
|
219
|
+
...reference,
|
|
220
|
+
gate: {
|
|
221
|
+
phase: completedPhase,
|
|
222
|
+
status,
|
|
223
|
+
allowed: pass,
|
|
224
|
+
reason
|
|
225
|
+
},
|
|
226
|
+
nextPhase,
|
|
227
|
+
nextInstruction
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
advanceAfterPass(phase) {
|
|
232
|
+
if (phase === "qa") {
|
|
233
|
+
return "sign_off_ready";
|
|
234
|
+
}
|
|
235
|
+
const next = nextPhaseAfter(phase);
|
|
236
|
+
if (!this.config.developCycle) {
|
|
237
|
+
return next ?? "dev_test_ready";
|
|
238
|
+
}
|
|
239
|
+
if (next) {
|
|
240
|
+
this.currentPhase = next;
|
|
241
|
+
return next;
|
|
242
|
+
}
|
|
243
|
+
this.completed = true;
|
|
244
|
+
return "dev_test_ready";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
119
247
|
export async function writeCompletionArtifact(config, toolName, input) {
|
|
120
248
|
const phase = phaseFromToolName(toolName);
|
|
121
249
|
if (phase !== config.phase) {
|
|
122
250
|
throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
|
|
123
251
|
}
|
|
124
252
|
const payload = asRecord(input, "completion payload");
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
253
|
+
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
254
|
+
const validationPayload = { ...payload, status };
|
|
255
|
+
validatePayloadShape(phase, validationPayload);
|
|
256
|
+
validateOptionalWorkItemId(validationPayload, config.workItemId);
|
|
257
|
+
const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
|
|
128
258
|
const producedBy = optionalString(redactedPayload, "produced_by")
|
|
129
259
|
?? optionalString(redactedPayload, "producedBy")
|
|
130
260
|
?? config.producedBy
|
|
131
261
|
?? "forge-completion-tool";
|
|
132
|
-
|
|
262
|
+
if (phase === "qa") {
|
|
263
|
+
return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
264
|
+
}
|
|
133
265
|
const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
|
|
134
266
|
phase,
|
|
135
267
|
workItemId: config.workItemId,
|
|
@@ -159,6 +291,101 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
159
291
|
producedBy
|
|
160
292
|
};
|
|
161
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
|
+
}
|
|
162
389
|
export async function readCompletionArtifact(worktreePath, workItemId, phase) {
|
|
163
390
|
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
|
|
164
391
|
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
|
|
@@ -179,6 +406,7 @@ export async function readCompletionArtifact(worktreePath, workItemId, phase) {
|
|
|
179
406
|
};
|
|
180
407
|
}
|
|
181
408
|
export async function runCompletionMcpServer(config) {
|
|
409
|
+
const session = new CompletionToolServerSession(config);
|
|
182
410
|
process.stdin.setEncoding("utf8");
|
|
183
411
|
let buffer = "";
|
|
184
412
|
for await (const chunk of process.stdin) {
|
|
@@ -188,14 +416,14 @@ export async function runCompletionMcpServer(config) {
|
|
|
188
416
|
const rawLine = buffer.slice(0, newlineIndex).trim();
|
|
189
417
|
buffer = buffer.slice(newlineIndex + 1);
|
|
190
418
|
if (rawLine.length > 0) {
|
|
191
|
-
await handleJsonRpcLine(
|
|
419
|
+
await handleJsonRpcLine(session, rawLine);
|
|
192
420
|
}
|
|
193
421
|
newlineIndex = buffer.indexOf("\n");
|
|
194
422
|
}
|
|
195
423
|
}
|
|
196
424
|
const trailing = buffer.trim();
|
|
197
425
|
if (trailing.length > 0) {
|
|
198
|
-
await handleJsonRpcLine(
|
|
426
|
+
await handleJsonRpcLine(session, trailing);
|
|
199
427
|
}
|
|
200
428
|
}
|
|
201
429
|
export function parseCompletionMcpArgs(argv) {
|
|
@@ -203,6 +431,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
203
431
|
let workItemId = null;
|
|
204
432
|
let phase = "implementation";
|
|
205
433
|
let producedBy;
|
|
434
|
+
let developCycle = false;
|
|
206
435
|
for (let index = 1; index < argv.length; index += 1) {
|
|
207
436
|
const arg = argv[index];
|
|
208
437
|
if (arg === "--worktree") {
|
|
@@ -215,13 +444,16 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
215
444
|
const rawPhase = argv[++index];
|
|
216
445
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
217
446
|
if (!parsedPhase) {
|
|
218
|
-
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." };
|
|
219
448
|
}
|
|
220
449
|
phase = parsedPhase;
|
|
221
450
|
}
|
|
222
451
|
else if (arg === "--produced-by") {
|
|
223
452
|
producedBy = argv[++index] ?? undefined;
|
|
224
453
|
}
|
|
454
|
+
else if (arg === "--develop-cycle") {
|
|
455
|
+
developCycle = true;
|
|
456
|
+
}
|
|
225
457
|
else {
|
|
226
458
|
return { ok: false, error: `Unknown mcp argument: ${arg}` };
|
|
227
459
|
}
|
|
@@ -236,7 +468,8 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
236
468
|
workItemId,
|
|
237
469
|
phase,
|
|
238
470
|
producedBy,
|
|
239
|
-
redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv])
|
|
471
|
+
redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
|
|
472
|
+
developCycle
|
|
240
473
|
}
|
|
241
474
|
};
|
|
242
475
|
}
|
|
@@ -273,7 +506,7 @@ async function prepareCursorCompletionToolRuntime(worktreePath, runtime) {
|
|
|
273
506
|
}
|
|
274
507
|
};
|
|
275
508
|
}
|
|
276
|
-
async function handleJsonRpcLine(
|
|
509
|
+
async function handleJsonRpcLine(session, rawLine) {
|
|
277
510
|
let message;
|
|
278
511
|
try {
|
|
279
512
|
message = JSON.parse(rawLine);
|
|
@@ -291,14 +524,14 @@ async function handleJsonRpcLine(config, rawLine) {
|
|
|
291
524
|
return;
|
|
292
525
|
}
|
|
293
526
|
try {
|
|
294
|
-
const result = await handleJsonRpcRequest(
|
|
527
|
+
const result = await handleJsonRpcRequest(session, request.method, request.params);
|
|
295
528
|
writeJsonRpc({ jsonrpc: "2.0", id: request.id, result });
|
|
296
529
|
}
|
|
297
530
|
catch (error) {
|
|
298
531
|
writeJsonRpc({ jsonrpc: "2.0", id: request.id, error: rpcError(-32000, errorMessage(error)) });
|
|
299
532
|
}
|
|
300
533
|
}
|
|
301
|
-
async function handleJsonRpcRequest(
|
|
534
|
+
async function handleJsonRpcRequest(session, method, params) {
|
|
302
535
|
if (method === "initialize") {
|
|
303
536
|
const initializeParams = params && typeof params === "object" && !Array.isArray(params)
|
|
304
537
|
? params
|
|
@@ -312,7 +545,7 @@ async function handleJsonRpcRequest(config, method, params) {
|
|
|
312
545
|
name: "forge-completion-tools",
|
|
313
546
|
version: "0.1.0"
|
|
314
547
|
},
|
|
315
|
-
instructions:
|
|
548
|
+
instructions: session.instructions
|
|
316
549
|
};
|
|
317
550
|
}
|
|
318
551
|
if (method === "ping") {
|
|
@@ -320,23 +553,14 @@ async function handleJsonRpcRequest(config, method, params) {
|
|
|
320
553
|
}
|
|
321
554
|
if (method === "tools/list") {
|
|
322
555
|
return {
|
|
323
|
-
tools:
|
|
556
|
+
tools: session.toolDefinitions()
|
|
324
557
|
};
|
|
325
558
|
}
|
|
326
559
|
if (method === "tools/call") {
|
|
327
560
|
const call = asRecord(params, "tools/call params");
|
|
328
561
|
const name = requiredString(call, "name");
|
|
329
562
|
const args = call.arguments ?? {};
|
|
330
|
-
|
|
331
|
-
return {
|
|
332
|
-
content: [
|
|
333
|
-
{
|
|
334
|
-
type: "text",
|
|
335
|
-
text: `Forge recorded ${reference.phase} completion artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}).`
|
|
336
|
-
}
|
|
337
|
-
],
|
|
338
|
-
structuredContent: reference
|
|
339
|
-
};
|
|
563
|
+
return await session.callTool(name, args);
|
|
340
564
|
}
|
|
341
565
|
throw new Error(`Unsupported MCP method: ${method}`);
|
|
342
566
|
}
|
|
@@ -347,6 +571,31 @@ function toolDefinition(name, phase) {
|
|
|
347
571
|
inputSchema: toolInputSchema(phase)
|
|
348
572
|
};
|
|
349
573
|
}
|
|
574
|
+
function nextPhaseAfter(phase) {
|
|
575
|
+
const index = completionPhaseOrder.indexOf(phase);
|
|
576
|
+
return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
|
|
577
|
+
}
|
|
578
|
+
function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
579
|
+
if (completedPhase === "qa") {
|
|
580
|
+
if (status === "blocked") {
|
|
581
|
+
return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
|
|
582
|
+
}
|
|
583
|
+
if (status === "needs_rework") {
|
|
584
|
+
return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
|
|
585
|
+
}
|
|
586
|
+
return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
|
|
587
|
+
}
|
|
588
|
+
if (status !== "pass") {
|
|
589
|
+
return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
|
|
590
|
+
}
|
|
591
|
+
if (nextPhase === "self_review") {
|
|
592
|
+
return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
|
|
593
|
+
}
|
|
594
|
+
if (nextPhase === "validation") {
|
|
595
|
+
return "Self-review accepted. Begin validation: run deterministic checks, prove acceptance criteria, and call forge.complete_validation with command evidence.";
|
|
596
|
+
}
|
|
597
|
+
return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
|
|
598
|
+
}
|
|
350
599
|
function toolDescription(phase) {
|
|
351
600
|
if (phase === "implementation") {
|
|
352
601
|
return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
|
|
@@ -354,77 +603,312 @@ function toolDescription(phase) {
|
|
|
354
603
|
if (phase === "self_review") {
|
|
355
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.";
|
|
356
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
|
+
}
|
|
357
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.";
|
|
358
610
|
}
|
|
359
611
|
function toolInputSchema(phase) {
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
summary: { type: "string" },
|
|
366
|
-
report_markdown: { type: "string" },
|
|
367
|
-
blockers: { type: "array" }
|
|
612
|
+
const baseProperties = {
|
|
613
|
+
status: {
|
|
614
|
+
type: "string",
|
|
615
|
+
enum: ["pass", "needs_rework", "fail", "failed"],
|
|
616
|
+
description: "Use pass only when the phase evidence satisfies Forge's gate. Use needs_rework/fail/failed with explicit blockers or findings otherwise."
|
|
368
617
|
},
|
|
369
|
-
|
|
618
|
+
summary: { type: "string", description: "Concise phase summary." },
|
|
619
|
+
report_markdown: { type: "string", description: "Optional human-readable report. Forge will generate a fallback report if omitted." },
|
|
620
|
+
blockers: {
|
|
621
|
+
type: "array",
|
|
622
|
+
description: "Required for non-pass status unless findings, failures, known_gaps, remaining_risks, or commands provide explicit evidence.",
|
|
623
|
+
items: evidenceObjectSchema(["reason"], {
|
|
624
|
+
reason: { type: "string" },
|
|
625
|
+
owner: { type: "string" },
|
|
626
|
+
next_step: { type: "string" }
|
|
627
|
+
})
|
|
628
|
+
}
|
|
370
629
|
};
|
|
371
630
|
if (phase === "implementation") {
|
|
372
|
-
return {
|
|
373
|
-
...
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
known_gaps: { type: "array" },
|
|
383
|
-
next_phase_context: {}
|
|
631
|
+
return objectToolSchema({
|
|
632
|
+
...baseProperties,
|
|
633
|
+
plan: {
|
|
634
|
+
type: "array",
|
|
635
|
+
description: "Required for pass. Ordered implementation steps with status.",
|
|
636
|
+
items: evidenceObjectSchema(["step", "status"], {
|
|
637
|
+
step: { type: "string" },
|
|
638
|
+
status: { type: "string" },
|
|
639
|
+
evidence: { type: "string" }
|
|
640
|
+
})
|
|
384
641
|
},
|
|
385
|
-
|
|
386
|
-
|
|
642
|
+
changed_files: {
|
|
643
|
+
type: "array",
|
|
644
|
+
description: "Required for pass. Must be non-empty unless known_gaps explains why.",
|
|
645
|
+
items: evidenceObjectSchema(["path", "summary"], {
|
|
646
|
+
path: { type: "string" },
|
|
647
|
+
summary: { type: "string" }
|
|
648
|
+
})
|
|
649
|
+
},
|
|
650
|
+
tests_added_or_updated: {
|
|
651
|
+
type: "array",
|
|
652
|
+
description: "Required for pass. Use an object explaining why no tests were needed for docs/config-only work if empty would be ambiguous.",
|
|
653
|
+
items: evidenceObjectSchema(["path", "summary"], {
|
|
654
|
+
path: { type: "string" },
|
|
655
|
+
summary: { type: "string" }
|
|
656
|
+
})
|
|
657
|
+
},
|
|
658
|
+
docs_added_or_updated: {
|
|
659
|
+
type: "array",
|
|
660
|
+
description: "Required for pass. Use an object explaining why no docs changed when appropriate.",
|
|
661
|
+
items: evidenceObjectSchema(["path", "summary"], {
|
|
662
|
+
path: { type: "string" },
|
|
663
|
+
summary: { type: "string" }
|
|
664
|
+
})
|
|
665
|
+
},
|
|
666
|
+
verification: {
|
|
667
|
+
type: "array",
|
|
668
|
+
description: "Implementation verification evidence. Objects should include command, exit_code, status, and summary when command-based.",
|
|
669
|
+
items: commandEvidenceSchema()
|
|
670
|
+
},
|
|
671
|
+
acceptance_criteria: acceptanceCriteriaSchema(),
|
|
672
|
+
known_gaps: {
|
|
673
|
+
type: "array",
|
|
674
|
+
description: "Required for pass, even when empty. If changed_files/tests/docs arrays are empty, explain why here.",
|
|
675
|
+
items: evidenceObjectSchema(["summary"], {
|
|
676
|
+
summary: { type: "string" },
|
|
677
|
+
impact: { type: "string" },
|
|
678
|
+
follow_up: { type: "string" }
|
|
679
|
+
})
|
|
680
|
+
},
|
|
681
|
+
next_phase_context: {
|
|
682
|
+
description: "Required for pass. Context the self-review phase should carry forward."
|
|
683
|
+
}
|
|
684
|
+
}, ["status", "summary"], "For status pass, include plan, changed_files, tests_added_or_updated, docs_added_or_updated, acceptance_criteria, known_gaps, and next_phase_context. Acceptance criteria entries must use status: satisfied plus evidence.");
|
|
387
685
|
}
|
|
388
686
|
if (phase === "self_review") {
|
|
389
|
-
return {
|
|
390
|
-
...
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
687
|
+
return objectToolSchema({
|
|
688
|
+
...baseProperties,
|
|
689
|
+
reviewed_diff: { type: "boolean", description: "Required for pass. True after reviewing the implementation diff." },
|
|
690
|
+
findings: {
|
|
691
|
+
type: "array",
|
|
692
|
+
description: "Required for pass, even when empty. Include finding objects for issues discovered during review.",
|
|
693
|
+
items: evidenceObjectSchema(["severity", "summary", "status"], {
|
|
694
|
+
severity: { type: "string" },
|
|
695
|
+
summary: { type: "string" },
|
|
696
|
+
status: { type: "string" },
|
|
697
|
+
file: { type: "string" }
|
|
698
|
+
})
|
|
401
699
|
},
|
|
402
|
-
|
|
403
|
-
|
|
700
|
+
security_review: reviewSectionSchema(),
|
|
701
|
+
edge_case_review: reviewSectionSchema(),
|
|
702
|
+
design_review: {
|
|
703
|
+
...reviewSectionSchema(),
|
|
704
|
+
properties: {
|
|
705
|
+
...reviewSectionProperties(),
|
|
706
|
+
screenshots: { type: "array", items: evidenceObjectSchema(["path"], { path: { type: "string" }, summary: { type: "string" } }) }
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
deterministic_comparison: {
|
|
710
|
+
type: "object",
|
|
711
|
+
description: "Required for pass. Evidence that the reviewed diff matches the intended deterministic change.",
|
|
712
|
+
additionalProperties: true,
|
|
713
|
+
properties: {
|
|
714
|
+
status: { type: "string" },
|
|
715
|
+
command_or_tool: { type: "string" },
|
|
716
|
+
notes: { type: "string" }
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
fixes_applied: {
|
|
720
|
+
type: "array",
|
|
721
|
+
description: "Required for pass, even when empty.",
|
|
722
|
+
items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, path: { type: "string" } })
|
|
723
|
+
},
|
|
724
|
+
remaining_risks: {
|
|
725
|
+
type: "array",
|
|
726
|
+
description: "Required for pass, even when empty.",
|
|
727
|
+
items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
|
|
728
|
+
}
|
|
729
|
+
}, ["status", "summary"], "For status pass, include reviewed_diff, findings, security_review, edge_case_review, design_review, deterministic_comparison, fixes_applied, and remaining_risks.");
|
|
404
730
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
731
|
+
if (phase === "qa") {
|
|
732
|
+
return objectToolSchema({
|
|
733
|
+
...baseProperties,
|
|
734
|
+
status: {
|
|
735
|
+
type: "string",
|
|
736
|
+
enum: ["pass", "needs_rework", "blocked"],
|
|
737
|
+
description: "Use pass only when QA evidence satisfies acceptance criteria, needs_rework for actionable in-scope defects, or blocked for an inconclusive QA blocker."
|
|
738
|
+
},
|
|
739
|
+
checklist: {
|
|
740
|
+
type: "array",
|
|
741
|
+
description: "QA plan/checklist. Forge writes this to qa.plan.json; a default checklist is generated if omitted.",
|
|
742
|
+
items: { type: "string" }
|
|
743
|
+
},
|
|
744
|
+
tested_acceptance_criteria: {
|
|
745
|
+
type: "array",
|
|
746
|
+
description: "Required and non-empty for pass/needs_rework. Criteria actually tested by QA.",
|
|
747
|
+
items: { type: "string" }
|
|
748
|
+
},
|
|
749
|
+
commands: {
|
|
750
|
+
type: "array",
|
|
751
|
+
description: "Command evidence written to qa.evidence.json.",
|
|
752
|
+
items: commandEvidenceSchema()
|
|
753
|
+
},
|
|
754
|
+
browser_checks: {
|
|
755
|
+
type: "array",
|
|
756
|
+
description: "Browser/manual UI checks with URL, action, observed result, screenshot/trace references, console/network notes, and status when applicable.",
|
|
757
|
+
items: evidenceObjectSchema(["summary"], {
|
|
758
|
+
summary: { type: "string" },
|
|
759
|
+
url: { type: "string" },
|
|
760
|
+
status: { type: "string" },
|
|
761
|
+
screenshot: { type: "string" },
|
|
762
|
+
trace: { type: "string" }
|
|
763
|
+
})
|
|
764
|
+
},
|
|
765
|
+
notes: { type: "string", description: "Additional QA evidence notes." },
|
|
766
|
+
evidence: {
|
|
767
|
+
type: "object",
|
|
768
|
+
description: "Optional wrapper containing commands, browser_checks, and notes when a client prefers nested evidence."
|
|
769
|
+
},
|
|
770
|
+
findings: {
|
|
771
|
+
type: "array",
|
|
772
|
+
description: "QA findings. needs_rework requires at least one actionable in_scope finding.",
|
|
773
|
+
items: evidenceObjectSchema(["id", "severity", "scope", "title"], {
|
|
774
|
+
id: { type: "string" },
|
|
775
|
+
severity: { type: "string", enum: ["critical", "major", "minor"] },
|
|
776
|
+
scope: { type: "string", enum: ["in_scope", "out_of_scope", "unclear"] },
|
|
777
|
+
title: { type: "string" },
|
|
778
|
+
acceptance_criteria: { type: "array", items: { type: "string" } },
|
|
779
|
+
repro_steps: { type: "array", items: { type: "string" } },
|
|
780
|
+
expected: { type: "string" },
|
|
781
|
+
actual: { type: "string" },
|
|
782
|
+
evidence: { type: "object" },
|
|
783
|
+
likely_area: { type: "array", items: { type: "string" } },
|
|
784
|
+
required_fix: { type: "string" },
|
|
785
|
+
required_regression: { type: "string" }
|
|
786
|
+
})
|
|
787
|
+
},
|
|
788
|
+
candidate_bugs: {
|
|
789
|
+
type: "array",
|
|
790
|
+
description: "Out-of-scope/follow-up bug candidates. They do not block the current item unless attached as in-scope findings.",
|
|
791
|
+
items: evidenceObjectSchema(["id", "title", "suggested_action"], {
|
|
792
|
+
id: { type: "string" },
|
|
793
|
+
title: { type: "string" },
|
|
794
|
+
severity: { type: "string" },
|
|
795
|
+
scope_reason: { type: "string" },
|
|
796
|
+
repro_steps: { type: "array", items: { type: "string" } },
|
|
797
|
+
evidence: { type: "object" },
|
|
798
|
+
suggested_action: { type: "string", enum: ["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"] }
|
|
799
|
+
})
|
|
800
|
+
},
|
|
801
|
+
blocker_reason: {
|
|
802
|
+
type: "string",
|
|
803
|
+
enum: [...qaBlockerReasons],
|
|
804
|
+
description: "Required when status is blocked."
|
|
805
|
+
}
|
|
806
|
+
}, ["status", "summary"], "For pass, include tested_acceptance_criteria plus concrete command/browser/note evidence. For needs_rework, include at least one actionable in_scope finding. For blocked, include blocker_reason.");
|
|
807
|
+
}
|
|
808
|
+
return objectToolSchema({
|
|
809
|
+
...baseProperties,
|
|
810
|
+
commands: {
|
|
811
|
+
type: "array",
|
|
812
|
+
description: "Required for pass. Include at least one deterministic validation command with exit_code: 0 and status: pass.",
|
|
813
|
+
items: commandEvidenceSchema()
|
|
814
|
+
},
|
|
815
|
+
acceptance_criteria: acceptanceCriteriaSchema(),
|
|
816
|
+
build_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
|
|
817
|
+
test_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
|
|
818
|
+
lint_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
|
|
819
|
+
design_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
|
|
820
|
+
drift_checks: {
|
|
821
|
+
type: "array",
|
|
822
|
+
description: "Required for pass. Deterministic checks for unintended drift.",
|
|
823
|
+
items: evidenceObjectSchema(["command_or_tool", "status", "summary"], {
|
|
824
|
+
command_or_tool: { type: "string" },
|
|
825
|
+
status: { type: "string" },
|
|
826
|
+
summary: { type: "string" }
|
|
827
|
+
})
|
|
417
828
|
},
|
|
418
|
-
|
|
829
|
+
ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
|
|
830
|
+
}, ["status", "summary"], "For status pass, commands must include at least one passing deterministic command, acceptance criteria entries must use status: satisfied plus evidence, and ready_for_dev_test must be true.");
|
|
831
|
+
}
|
|
832
|
+
function objectToolSchema(properties, required, description) {
|
|
833
|
+
return {
|
|
834
|
+
type: "object",
|
|
835
|
+
additionalProperties: true,
|
|
836
|
+
description,
|
|
837
|
+
properties,
|
|
838
|
+
required: [...required]
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function evidenceObjectSchema(required, properties) {
|
|
842
|
+
return {
|
|
843
|
+
type: "object",
|
|
844
|
+
additionalProperties: true,
|
|
845
|
+
properties,
|
|
846
|
+
required: [...required]
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function commandEvidenceSchema() {
|
|
850
|
+
return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
|
|
851
|
+
command: { type: "string" },
|
|
852
|
+
exit_code: { type: "number" },
|
|
853
|
+
status: { type: "string" },
|
|
854
|
+
summary: { type: "string" }
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
function acceptanceCriteriaSchema() {
|
|
858
|
+
return {
|
|
859
|
+
type: "array",
|
|
860
|
+
description: "Required for pass. Every entry must be an object with status: satisfied and non-empty evidence.",
|
|
861
|
+
items: evidenceObjectSchema(["criterion", "evidence", "status"], {
|
|
862
|
+
criterion: { type: "string" },
|
|
863
|
+
evidence: { type: "string" },
|
|
864
|
+
status: { type: "string", enum: ["satisfied"] }
|
|
865
|
+
})
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
function reviewSectionSchema() {
|
|
869
|
+
return {
|
|
870
|
+
type: "object",
|
|
871
|
+
description: "Required for pass. Use status pass/fail/needs_rework/not_applicable plus notes.",
|
|
872
|
+
additionalProperties: true,
|
|
873
|
+
properties: reviewSectionProperties()
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
function reviewSectionProperties() {
|
|
877
|
+
return {
|
|
878
|
+
status: { type: "string" },
|
|
879
|
+
notes: { type: "string" }
|
|
419
880
|
};
|
|
420
881
|
}
|
|
882
|
+
function normalizeCompletionStatus(value, phase) {
|
|
883
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
884
|
+
if (phase === "qa") {
|
|
885
|
+
const canonical = normalized === "needsrework" ? "needs_rework" : normalized;
|
|
886
|
+
if (canonical !== "pass" && canonical !== "needs_rework" && canonical !== "blocked") {
|
|
887
|
+
throw new Error("QA status must be pass, needs_rework, or blocked.");
|
|
888
|
+
}
|
|
889
|
+
return canonical;
|
|
890
|
+
}
|
|
891
|
+
const canonical = normalized === "blocked" || normalized === "needsrework"
|
|
892
|
+
? "needs_rework"
|
|
893
|
+
: normalized === "failed"
|
|
894
|
+
? "fail"
|
|
895
|
+
: normalized;
|
|
896
|
+
if (canonical !== "pass" && canonical !== "fail" && canonical !== "needs_rework") {
|
|
897
|
+
throw new Error("status must be pass, fail, or needs_rework.");
|
|
898
|
+
}
|
|
899
|
+
return canonical;
|
|
900
|
+
}
|
|
421
901
|
function validatePayloadShape(phase, payload) {
|
|
422
|
-
const status = requiredString(payload, "status")
|
|
902
|
+
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
423
903
|
if (!status) {
|
|
424
904
|
throw new Error("status is required.");
|
|
425
905
|
}
|
|
426
|
-
const pass = status === "pass";
|
|
427
906
|
requireField(payload, "summary");
|
|
907
|
+
if (phase === "qa") {
|
|
908
|
+
validateQaPayloadShape(payload, status);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const pass = status === "pass";
|
|
428
912
|
if (!pass) {
|
|
429
913
|
requireNonPassEvidence(payload);
|
|
430
914
|
return;
|
|
@@ -466,6 +950,117 @@ function validatePayloadShape(phase, payload) {
|
|
|
466
950
|
requirePassingCommands(payload);
|
|
467
951
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
468
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
|
+
}
|
|
469
1064
|
function validateOptionalWorkItemId(payload, expectedWorkItemId) {
|
|
470
1065
|
const actual = optionalString(payload, "work_item_id") ?? optionalString(payload, "workItemId");
|
|
471
1066
|
if (actual && actual !== expectedWorkItemId) {
|
|
@@ -512,6 +1107,9 @@ function parseStrictCompletionPhase(value) {
|
|
|
512
1107
|
if (normalized === "validate" || normalized === "validation") {
|
|
513
1108
|
return "validation";
|
|
514
1109
|
}
|
|
1110
|
+
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
1111
|
+
return "qa";
|
|
1112
|
+
}
|
|
515
1113
|
return null;
|
|
516
1114
|
}
|
|
517
1115
|
function parseCompletionRedactionSecrets(raw) {
|
|
@@ -540,6 +1138,9 @@ function phaseFromToolName(toolName) {
|
|
|
540
1138
|
if (normalized === "forge.complete_validation" || normalized === "complete_validation") {
|
|
541
1139
|
return "validation";
|
|
542
1140
|
}
|
|
1141
|
+
if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
|
|
1142
|
+
return "qa";
|
|
1143
|
+
}
|
|
543
1144
|
throw new Error(`Unknown Forge completion tool: ${toolName}`);
|
|
544
1145
|
}
|
|
545
1146
|
function requireField(payload, key) {
|
|
@@ -637,6 +1238,109 @@ function requireSatisfiedAcceptanceCriteria(payload) {
|
|
|
637
1238
|
}
|
|
638
1239
|
}
|
|
639
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
|
+
}
|
|
640
1344
|
function fallbackReport(parsed, phase, artifactPath) {
|
|
641
1345
|
return `# Forge ${phase.replace(/_/g, " ")} completion
|
|
642
1346
|
|