@loucompanion/forge-bridge 0.1.1-dev.af36c7fa102b → 0.1.1-dev.e52b249f07a8
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/bridge/bridge.service.js +29 -1
- package/dist/bridge/bridge.types.d.ts +9 -0
- package/dist/cli/cli.service.js +7 -0
- package/dist/cli/local-config.js +18 -1
- package/dist/session/phase-completion.d.ts +37 -1
- package/dist/session/phase-completion.js +1013 -83
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +5 -4
- package/dist/session/provider-cli/provider-cli.service.js +1075 -116
- package/dist/session/provider-cli/provider-cli.types.d.ts +1 -0
- package/dist/session/provider-cli/provider-usage.d.ts +25 -0
- package/dist/session/provider-cli/provider-usage.js +64 -0
- package/dist/session/qa-sidecar-validation.d.ts +3 -0
- package/dist/session/qa-sidecar-validation.js +44 -0
- package/dist/session/session.service.js +12 -14
- package/dist/session/session.types.d.ts +38 -0
- package/dist/session/session.types.js +96 -2
- package/dist/shared/cursor-agent/cursor-agent.launch.d.ts +9 -0
- package/dist/shared/cursor-agent/cursor-agent.launch.js +66 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.js +1 -4
- package/package.json +1 -1
|
@@ -2,7 +2,22 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { redactSecrets } from "../shared/redaction/redaction.js";
|
|
6
|
+
import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
|
|
5
7
|
const toolServerName = "forge";
|
|
8
|
+
const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
|
|
9
|
+
const completionPhaseOrder = ["implementation", "self_review", "validation"];
|
|
10
|
+
const qaBlockerReasons = new Set([
|
|
11
|
+
"missing_app_launch_target",
|
|
12
|
+
"inconclusive",
|
|
13
|
+
"environment_failure",
|
|
14
|
+
"missing_validation_evidence",
|
|
15
|
+
"missing_worktree",
|
|
16
|
+
"runner_failure"
|
|
17
|
+
]);
|
|
18
|
+
const qaFindingSeverities = new Set(["critical", "major", "minor"]);
|
|
19
|
+
const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
|
|
20
|
+
const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
|
|
6
21
|
const phaseFiles = {
|
|
7
22
|
implementation: {
|
|
8
23
|
jsonFileName: "implementation.completeness.json",
|
|
@@ -15,6 +30,10 @@ const phaseFiles = {
|
|
|
15
30
|
validation: {
|
|
16
31
|
jsonFileName: "validation.verdict.json",
|
|
17
32
|
reportFileName: "validation.report.md"
|
|
33
|
+
},
|
|
34
|
+
qa: {
|
|
35
|
+
jsonFileName: "qa.verdict.json",
|
|
36
|
+
reportFileName: "qa.report.md"
|
|
18
37
|
}
|
|
19
38
|
};
|
|
20
39
|
export function completionPhaseFromDispatch(dispatch) {
|
|
@@ -28,14 +47,28 @@ export function normalizeCompletionPhase(value) {
|
|
|
28
47
|
if (normalized === "validate" || normalized === "validation") {
|
|
29
48
|
return "validation";
|
|
30
49
|
}
|
|
50
|
+
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
51
|
+
return "qa";
|
|
52
|
+
}
|
|
31
53
|
return "implementation";
|
|
32
54
|
}
|
|
55
|
+
export function usesDevelopCycleCompletion(dispatch) {
|
|
56
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
57
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
58
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
59
|
+
return kind === "develop"
|
|
60
|
+
&& role === "develop"
|
|
61
|
+
&& lane === "develop"
|
|
62
|
+
&& completionPhaseFromDispatch(dispatch) === "implementation";
|
|
63
|
+
}
|
|
33
64
|
export function completionToolNamesForPhase(phase) {
|
|
34
65
|
const base = phase === "implementation"
|
|
35
66
|
? "complete_implementation"
|
|
36
67
|
: phase === "self_review"
|
|
37
68
|
? "complete_self_review"
|
|
38
|
-
: "
|
|
69
|
+
: phase === "validation"
|
|
70
|
+
? "complete_validation"
|
|
71
|
+
: "complete_qa";
|
|
39
72
|
return [`forge.${base}`, base];
|
|
40
73
|
}
|
|
41
74
|
export function expectedCompletionToolName(phase) {
|
|
@@ -44,6 +77,9 @@ export function expectedCompletionToolName(phase) {
|
|
|
44
77
|
export function artifactRootFor(worktreePath, workItemId) {
|
|
45
78
|
return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
|
|
46
79
|
}
|
|
80
|
+
function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
81
|
+
return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
|
|
82
|
+
}
|
|
47
83
|
export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
|
|
48
84
|
return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].jsonFileName);
|
|
49
85
|
}
|
|
@@ -54,11 +90,16 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
54
90
|
const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
|
|
55
91
|
const worktreePath = path.resolve(options.worktree.worktreePath);
|
|
56
92
|
const workItemId = options.dispatch.workItem.id;
|
|
93
|
+
const ownerType = normalizedOwnerType(options.dispatch.cycleRun.ownerType) ?? "work_item";
|
|
94
|
+
const ownerId = options.dispatch.cycleRun.ownerId ?? workItemId;
|
|
57
95
|
const mcpArgs = buildCompletionToolServerArgs({
|
|
58
96
|
worktreePath,
|
|
59
97
|
workItemId,
|
|
98
|
+
ownerType,
|
|
99
|
+
ownerId,
|
|
60
100
|
phase,
|
|
61
|
-
producedBy: `${options.dispatch.execution.provider}-cli
|
|
101
|
+
producedBy: `${options.dispatch.execution.provider}-cli`,
|
|
102
|
+
developCycle: usesDevelopCycleCompletion(options.dispatch)
|
|
62
103
|
});
|
|
63
104
|
const runtime = {
|
|
64
105
|
serverName: toolServerName,
|
|
@@ -73,7 +114,7 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
73
114
|
return await prepareCursorCompletionToolRuntime(worktreePath, runtime);
|
|
74
115
|
}
|
|
75
116
|
export function buildCompletionToolServerArgs(config) {
|
|
76
|
-
|
|
117
|
+
const args = [
|
|
77
118
|
"mcp",
|
|
78
119
|
"--worktree",
|
|
79
120
|
path.resolve(config.worktreePath),
|
|
@@ -84,6 +125,14 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
84
125
|
"--produced-by",
|
|
85
126
|
config.producedBy ?? "forge-completion-tool"
|
|
86
127
|
];
|
|
128
|
+
if (config.ownerType || config.ownerId) {
|
|
129
|
+
args.push("--owner-type", normalizedOwnerType(config.ownerType) ?? "work_item");
|
|
130
|
+
args.push("--owner-id", config.ownerId ?? config.workItemId);
|
|
131
|
+
}
|
|
132
|
+
if (config.developCycle) {
|
|
133
|
+
args.push("--develop-cycle");
|
|
134
|
+
}
|
|
135
|
+
return args;
|
|
87
136
|
}
|
|
88
137
|
export function codexMcpConfigArgs(runtime) {
|
|
89
138
|
const prefix = `mcp_servers.${runtime.serverName}`;
|
|
@@ -109,27 +158,127 @@ export function completionPromptForPhase(phase) {
|
|
|
109
158
|
const toolName = expectedCompletionToolName(phase);
|
|
110
159
|
const alias = completionToolNamesForPhase(phase).find((name) => name !== toolName);
|
|
111
160
|
const artifactName = phaseFiles[phase].jsonFileName;
|
|
161
|
+
if (phase === "qa") {
|
|
162
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
163
|
+
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.
|
|
164
|
+
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\`.
|
|
165
|
+
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.
|
|
166
|
+
Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
|
|
167
|
+
}
|
|
112
168
|
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
113
169
|
Do not write phase artifacts by hand. When the phase is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the required evidence payload.
|
|
114
170
|
That tool writes \`.forge/work-items/{workItemId}/artifacts/${artifactName}\` and returns the artifact reference that Forge will mirror through the existing phase-artifact pipeline.
|
|
115
171
|
If the work cannot pass, call the same tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
|
|
116
172
|
}
|
|
173
|
+
export function completionPromptForDevelopCycle(phase) {
|
|
174
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
175
|
+
Do not write phase artifacts by hand. The tool server enforces phase order and starts this run in \`${phase}\`.
|
|
176
|
+
Complete the dev cycle in order inside this same provider session:
|
|
177
|
+
1. Call \`forge.complete_implementation(...)\` when implementation evidence is ready.
|
|
178
|
+
2. Follow the returned self-review instruction, then call \`forge.complete_self_review(...)\`.
|
|
179
|
+
3. Follow the returned validation instruction, then call \`forge.complete_validation(...)\`.
|
|
180
|
+
Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
|
|
181
|
+
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.`;
|
|
182
|
+
}
|
|
183
|
+
export function completionToolNamesForServer(config) {
|
|
184
|
+
return config.developCycle
|
|
185
|
+
? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
|
|
186
|
+
: completionToolNamesForPhase(config.phase);
|
|
187
|
+
}
|
|
188
|
+
export class CompletionToolServerSession {
|
|
189
|
+
config;
|
|
190
|
+
currentPhase;
|
|
191
|
+
completed = false;
|
|
192
|
+
constructor(config) {
|
|
193
|
+
this.config = config;
|
|
194
|
+
this.currentPhase = config.phase;
|
|
195
|
+
}
|
|
196
|
+
get instructions() {
|
|
197
|
+
return this.config.developCycle
|
|
198
|
+
? completionPromptForDevelopCycle(this.currentPhase)
|
|
199
|
+
: completionPromptForPhase(this.currentPhase);
|
|
200
|
+
}
|
|
201
|
+
toolDefinitions() {
|
|
202
|
+
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name)));
|
|
203
|
+
}
|
|
204
|
+
async callTool(toolName, input) {
|
|
205
|
+
const requestedPhase = phaseFromToolName(toolName);
|
|
206
|
+
if (this.completed) {
|
|
207
|
+
throw new Error("Forge develop cycle is already complete; no more phase completion calls are accepted.");
|
|
208
|
+
}
|
|
209
|
+
if (requestedPhase !== this.currentPhase) {
|
|
210
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
|
|
211
|
+
}
|
|
212
|
+
const completedPhase = this.currentPhase;
|
|
213
|
+
const reference = await writeCompletionArtifact({ ...this.config, phase: completedPhase }, toolName, input);
|
|
214
|
+
const status = reference.status.trim().toLowerCase();
|
|
215
|
+
const pass = status === "pass";
|
|
216
|
+
const nextPhase = pass
|
|
217
|
+
? this.advanceAfterPass(completedPhase)
|
|
218
|
+
: completedPhase;
|
|
219
|
+
const nextInstruction = completionToolNextInstruction(completedPhase, status, nextPhase);
|
|
220
|
+
const reason = pass
|
|
221
|
+
? `${completedPhase} completion artifact accepted.`
|
|
222
|
+
: `${completedPhase} completion artifact recorded with status ${status}; rework is required before phase progression.`;
|
|
223
|
+
return {
|
|
224
|
+
content: [
|
|
225
|
+
{
|
|
226
|
+
type: "text",
|
|
227
|
+
text: `Forge recorded ${reference.phase} completion artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
|
|
228
|
+
}
|
|
229
|
+
],
|
|
230
|
+
structuredContent: {
|
|
231
|
+
...reference,
|
|
232
|
+
gate: {
|
|
233
|
+
phase: completedPhase,
|
|
234
|
+
status,
|
|
235
|
+
allowed: pass,
|
|
236
|
+
reason
|
|
237
|
+
},
|
|
238
|
+
nextPhase,
|
|
239
|
+
nextInstruction
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
advanceAfterPass(phase) {
|
|
244
|
+
if (phase === "qa") {
|
|
245
|
+
return "sign_off_ready";
|
|
246
|
+
}
|
|
247
|
+
const next = nextPhaseAfter(phase);
|
|
248
|
+
if (!this.config.developCycle) {
|
|
249
|
+
return next ?? "dev_test_ready";
|
|
250
|
+
}
|
|
251
|
+
if (next) {
|
|
252
|
+
this.currentPhase = next;
|
|
253
|
+
return next;
|
|
254
|
+
}
|
|
255
|
+
this.completed = true;
|
|
256
|
+
return "dev_test_ready";
|
|
257
|
+
}
|
|
258
|
+
}
|
|
117
259
|
export async function writeCompletionArtifact(config, toolName, input) {
|
|
118
260
|
const phase = phaseFromToolName(toolName);
|
|
119
261
|
if (phase !== config.phase) {
|
|
120
262
|
throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
|
|
121
263
|
}
|
|
122
264
|
const payload = asRecord(input, "completion payload");
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
265
|
+
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
266
|
+
const validationPayload = { ...payload, status };
|
|
267
|
+
validatePayloadShape(phase, validationPayload);
|
|
268
|
+
validateOptionalWorkItemId(validationPayload, config.workItemId);
|
|
269
|
+
const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
|
|
270
|
+
const producedBy = optionalString(redactedPayload, "produced_by")
|
|
271
|
+
?? optionalString(redactedPayload, "producedBy")
|
|
127
272
|
?? config.producedBy
|
|
128
273
|
?? "forge-completion-tool";
|
|
129
|
-
|
|
130
|
-
|
|
274
|
+
if (phase === "qa") {
|
|
275
|
+
return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
276
|
+
}
|
|
277
|
+
const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
|
|
131
278
|
phase,
|
|
132
279
|
workItemId: config.workItemId,
|
|
280
|
+
ownerType: config.ownerType,
|
|
281
|
+
ownerId: config.ownerId,
|
|
133
282
|
producedBy,
|
|
134
283
|
status
|
|
135
284
|
});
|
|
@@ -139,7 +288,7 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
139
288
|
content_hash: contentHash
|
|
140
289
|
};
|
|
141
290
|
const artifactJson = `${JSON.stringify(artifact, null, 2)}\n`;
|
|
142
|
-
const report = reportMarkdown(
|
|
291
|
+
const report = reportMarkdown(redactedPayload, phase, status, producedBy, contentHash);
|
|
143
292
|
const artifactPath = completionArtifactJsonPath(config.worktreePath, config.workItemId, phase);
|
|
144
293
|
const reportPath = completionArtifactReportPath(config.worktreePath, config.workItemId, phase);
|
|
145
294
|
await fs.mkdir(path.dirname(artifactPath), { recursive: true });
|
|
@@ -148,6 +297,7 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
148
297
|
return {
|
|
149
298
|
phase,
|
|
150
299
|
workItemId: config.workItemId,
|
|
300
|
+
...artifactReferenceOwnerFields(config),
|
|
151
301
|
repoPath: path.resolve(config.worktreePath),
|
|
152
302
|
path: artifactPath,
|
|
153
303
|
reportPath,
|
|
@@ -156,6 +306,170 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
156
306
|
producedBy
|
|
157
307
|
};
|
|
158
308
|
}
|
|
309
|
+
async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
310
|
+
const ownerFields = artifactOwnerFields(config);
|
|
311
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
312
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
313
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
314
|
+
const planPath = path.join(artifactRoot, "qa.plan.json");
|
|
315
|
+
const evidencePath = path.join(artifactRoot, "qa.evidence.json");
|
|
316
|
+
const findingsPath = path.join(artifactRoot, "qa.findings.json");
|
|
317
|
+
const verdictPath = path.join(artifactRoot, "qa.verdict.json");
|
|
318
|
+
const reportPath = path.join(artifactRoot, "qa.report.md");
|
|
319
|
+
const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
|
|
320
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
|
|
321
|
+
: qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
|
|
322
|
+
const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
|
|
323
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
|
|
324
|
+
: qaArtifactRelativePath(config.workItemId, "qa.findings.json");
|
|
325
|
+
const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
|
|
326
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
|
|
327
|
+
: qaArtifactRelativePath(config.workItemId, "qa.report.md");
|
|
328
|
+
const evidenceSource = qaEvidenceSource(payload);
|
|
329
|
+
const commands = qaArray(payload, "commands", evidenceSource);
|
|
330
|
+
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
331
|
+
const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
|
|
332
|
+
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
333
|
+
const findings = qaObjectArray(payload, "findings");
|
|
334
|
+
const candidateBugs = qaObjectArray(payload, "candidate_bugs");
|
|
335
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
336
|
+
const explicitFindingIds = qaStringArray(payload, "finding_ids");
|
|
337
|
+
const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
|
|
338
|
+
const blockerReason = optionalString(payload, "blocker_reason");
|
|
339
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
340
|
+
const planJson = jsonText({
|
|
341
|
+
schema_version: 1,
|
|
342
|
+
phase: "qa",
|
|
343
|
+
...ownerFields,
|
|
344
|
+
checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
|
|
345
|
+
acceptance_criteria: testedAcceptanceCriteria
|
|
346
|
+
});
|
|
347
|
+
const evidenceJson = jsonText({
|
|
348
|
+
schema_version: 1,
|
|
349
|
+
phase: "qa",
|
|
350
|
+
...ownerFields,
|
|
351
|
+
commands,
|
|
352
|
+
browser_checks: browserChecks,
|
|
353
|
+
sidecars,
|
|
354
|
+
notes
|
|
355
|
+
});
|
|
356
|
+
const findingsJson = jsonText({
|
|
357
|
+
schema_version: 1,
|
|
358
|
+
phase: "qa",
|
|
359
|
+
...ownerFields,
|
|
360
|
+
findings,
|
|
361
|
+
candidate_bugs: candidateBugs
|
|
362
|
+
});
|
|
363
|
+
const findingIds = status === "pass"
|
|
364
|
+
? []
|
|
365
|
+
: explicitFindingIds.length > 0
|
|
366
|
+
? explicitFindingIds
|
|
367
|
+
: findings
|
|
368
|
+
.filter((finding) => normalizedString(finding.scope) === "in_scope")
|
|
369
|
+
.map((finding) => optionalString(finding, "id"))
|
|
370
|
+
.filter((id) => Boolean(id));
|
|
371
|
+
const candidateBugIds = explicitCandidateBugIds.length > 0
|
|
372
|
+
? explicitCandidateBugIds
|
|
373
|
+
: candidateBugs
|
|
374
|
+
.map((candidateBug) => optionalString(candidateBug, "id"))
|
|
375
|
+
.filter((id) => Boolean(id));
|
|
376
|
+
const verdictWithoutHash = {
|
|
377
|
+
schema_version: numberOrDefault(payload.schema_version, 1),
|
|
378
|
+
phase: "qa",
|
|
379
|
+
...ownerFields,
|
|
380
|
+
status,
|
|
381
|
+
summary: requiredString(payload, "summary"),
|
|
382
|
+
tested_acceptance_criteria: testedAcceptanceCriteria,
|
|
383
|
+
finding_ids: findingIds,
|
|
384
|
+
candidate_bug_ids: candidateBugIds,
|
|
385
|
+
blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
|
|
386
|
+
evidence_path: relativeEvidencePath,
|
|
387
|
+
findings_path: relativeFindingsPath,
|
|
388
|
+
report_path: relativeReportPath,
|
|
389
|
+
produced_by: producedBy
|
|
390
|
+
};
|
|
391
|
+
const contentHash = sha256(stableStringify({
|
|
392
|
+
plan: JSON.parse(planJson),
|
|
393
|
+
evidence: JSON.parse(evidenceJson),
|
|
394
|
+
findings: JSON.parse(findingsJson),
|
|
395
|
+
verdict: verdictWithoutHash
|
|
396
|
+
}));
|
|
397
|
+
const verdictJson = jsonText({
|
|
398
|
+
...verdictWithoutHash,
|
|
399
|
+
content_hash: contentHash
|
|
400
|
+
});
|
|
401
|
+
const finalReport = reportMarkdown(payload, "qa", status, producedBy, contentHash);
|
|
402
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
403
|
+
await writeFileAtomic(planPath, planJson);
|
|
404
|
+
await writeFileAtomic(evidencePath, evidenceJson);
|
|
405
|
+
await writeFileAtomic(findingsPath, findingsJson);
|
|
406
|
+
await writeFileAtomic(verdictPath, verdictJson);
|
|
407
|
+
await writeFileAtomic(reportPath, finalReport);
|
|
408
|
+
return {
|
|
409
|
+
phase: "qa",
|
|
410
|
+
workItemId: config.workItemId,
|
|
411
|
+
...artifactReferenceOwnerFields(config),
|
|
412
|
+
repoPath: path.resolve(config.worktreePath),
|
|
413
|
+
path: verdictPath,
|
|
414
|
+
reportPath,
|
|
415
|
+
contentHash,
|
|
416
|
+
status,
|
|
417
|
+
producedBy
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
421
|
+
if (sidecars.length === 0) {
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
const artifactRoot = ownerType && ownerId
|
|
425
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
426
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
427
|
+
const qaSidecarRoot = path.resolve(artifactRoot, "qa");
|
|
428
|
+
const validated = [];
|
|
429
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
430
|
+
if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
|
|
431
|
+
throw new Error(`sidecars[${index}] must be an object.`);
|
|
432
|
+
}
|
|
433
|
+
const record = sidecar;
|
|
434
|
+
const sidecarPath = optionalString(record, "path");
|
|
435
|
+
const contentHash = optionalString(record, "content_hash");
|
|
436
|
+
const bytes = record.bytes;
|
|
437
|
+
if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
|
|
438
|
+
throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
|
|
439
|
+
}
|
|
440
|
+
if (path.isAbsolute(sidecarPath)) {
|
|
441
|
+
throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
|
|
442
|
+
}
|
|
443
|
+
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
444
|
+
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
445
|
+
throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
|
|
446
|
+
}
|
|
447
|
+
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
448
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
449
|
+
throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
|
|
450
|
+
}
|
|
451
|
+
throw error;
|
|
452
|
+
});
|
|
453
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
454
|
+
throw new Error(`sidecars[${index}].path must reference a regular file.`);
|
|
455
|
+
}
|
|
456
|
+
await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
|
|
457
|
+
if (stat.size !== bytes) {
|
|
458
|
+
throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
|
|
459
|
+
}
|
|
460
|
+
const actualHash = await sha256File(fullPath);
|
|
461
|
+
if (actualHash !== contentHash.trim().toLowerCase()) {
|
|
462
|
+
throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
|
|
463
|
+
}
|
|
464
|
+
validated.push({
|
|
465
|
+
...record,
|
|
466
|
+
path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
|
|
467
|
+
content_hash: actualHash,
|
|
468
|
+
bytes: stat.size
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return validated;
|
|
472
|
+
}
|
|
159
473
|
export async function readCompletionArtifact(worktreePath, workItemId, phase) {
|
|
160
474
|
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
|
|
161
475
|
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
|
|
@@ -171,10 +485,12 @@ export async function readCompletionArtifact(worktreePath, workItemId, phase) {
|
|
|
171
485
|
verdictJson,
|
|
172
486
|
report,
|
|
173
487
|
producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
|
|
174
|
-
status: optionalString(parsed, "status") ?? "unknown"
|
|
488
|
+
status: optionalString(parsed, "status") ?? "unknown",
|
|
489
|
+
phase
|
|
175
490
|
};
|
|
176
491
|
}
|
|
177
492
|
export async function runCompletionMcpServer(config) {
|
|
493
|
+
const session = new CompletionToolServerSession(config);
|
|
178
494
|
process.stdin.setEncoding("utf8");
|
|
179
495
|
let buffer = "";
|
|
180
496
|
for await (const chunk of process.stdin) {
|
|
@@ -184,21 +500,24 @@ export async function runCompletionMcpServer(config) {
|
|
|
184
500
|
const rawLine = buffer.slice(0, newlineIndex).trim();
|
|
185
501
|
buffer = buffer.slice(newlineIndex + 1);
|
|
186
502
|
if (rawLine.length > 0) {
|
|
187
|
-
await handleJsonRpcLine(
|
|
503
|
+
await handleJsonRpcLine(session, rawLine);
|
|
188
504
|
}
|
|
189
505
|
newlineIndex = buffer.indexOf("\n");
|
|
190
506
|
}
|
|
191
507
|
}
|
|
192
508
|
const trailing = buffer.trim();
|
|
193
509
|
if (trailing.length > 0) {
|
|
194
|
-
await handleJsonRpcLine(
|
|
510
|
+
await handleJsonRpcLine(session, trailing);
|
|
195
511
|
}
|
|
196
512
|
}
|
|
197
513
|
export function parseCompletionMcpArgs(argv) {
|
|
198
514
|
let worktreePath = null;
|
|
199
515
|
let workItemId = null;
|
|
200
516
|
let phase = "implementation";
|
|
517
|
+
let ownerType;
|
|
518
|
+
let ownerId;
|
|
201
519
|
let producedBy;
|
|
520
|
+
let developCycle = false;
|
|
202
521
|
for (let index = 1; index < argv.length; index += 1) {
|
|
203
522
|
const arg = argv[index];
|
|
204
523
|
if (arg === "--worktree") {
|
|
@@ -207,17 +526,29 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
207
526
|
else if (arg === "--work-item-id") {
|
|
208
527
|
workItemId = argv[++index] ?? null;
|
|
209
528
|
}
|
|
529
|
+
else if (arg === "--owner-type") {
|
|
530
|
+
ownerType = normalizedOwnerType(argv[++index]);
|
|
531
|
+
if (!ownerType) {
|
|
532
|
+
return { ok: false, error: "mcp --owner-type must be work_item or story." };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else if (arg === "--owner-id") {
|
|
536
|
+
ownerId = argv[++index] ?? undefined;
|
|
537
|
+
}
|
|
210
538
|
else if (arg === "--phase") {
|
|
211
539
|
const rawPhase = argv[++index];
|
|
212
540
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
213
541
|
if (!parsedPhase) {
|
|
214
|
-
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation." };
|
|
542
|
+
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa." };
|
|
215
543
|
}
|
|
216
544
|
phase = parsedPhase;
|
|
217
545
|
}
|
|
218
546
|
else if (arg === "--produced-by") {
|
|
219
547
|
producedBy = argv[++index] ?? undefined;
|
|
220
548
|
}
|
|
549
|
+
else if (arg === "--develop-cycle") {
|
|
550
|
+
developCycle = true;
|
|
551
|
+
}
|
|
221
552
|
else {
|
|
222
553
|
return { ok: false, error: `Unknown mcp argument: ${arg}` };
|
|
223
554
|
}
|
|
@@ -230,11 +561,21 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
230
561
|
config: {
|
|
231
562
|
worktreePath: path.resolve(worktreePath),
|
|
232
563
|
workItemId,
|
|
564
|
+
ownerType,
|
|
565
|
+
ownerId,
|
|
233
566
|
phase,
|
|
234
|
-
producedBy
|
|
567
|
+
producedBy,
|
|
568
|
+
redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
|
|
569
|
+
developCycle
|
|
235
570
|
}
|
|
236
571
|
};
|
|
237
572
|
}
|
|
573
|
+
export function completionRedactionEnv(secrets) {
|
|
574
|
+
const filtered = secrets.filter((secret) => secret.length > 0);
|
|
575
|
+
return filtered.length > 0
|
|
576
|
+
? { [completionRedactionSecretsEnv]: JSON.stringify(filtered) }
|
|
577
|
+
: {};
|
|
578
|
+
}
|
|
238
579
|
async function prepareCursorCompletionToolRuntime(worktreePath, runtime) {
|
|
239
580
|
const cursorDir = path.join(worktreePath, ".cursor");
|
|
240
581
|
const configPath = path.join(cursorDir, "mcp.json");
|
|
@@ -262,7 +603,7 @@ async function prepareCursorCompletionToolRuntime(worktreePath, runtime) {
|
|
|
262
603
|
}
|
|
263
604
|
};
|
|
264
605
|
}
|
|
265
|
-
async function handleJsonRpcLine(
|
|
606
|
+
async function handleJsonRpcLine(session, rawLine) {
|
|
266
607
|
let message;
|
|
267
608
|
try {
|
|
268
609
|
message = JSON.parse(rawLine);
|
|
@@ -280,14 +621,14 @@ async function handleJsonRpcLine(config, rawLine) {
|
|
|
280
621
|
return;
|
|
281
622
|
}
|
|
282
623
|
try {
|
|
283
|
-
const result = await handleJsonRpcRequest(
|
|
624
|
+
const result = await handleJsonRpcRequest(session, request.method, request.params);
|
|
284
625
|
writeJsonRpc({ jsonrpc: "2.0", id: request.id, result });
|
|
285
626
|
}
|
|
286
627
|
catch (error) {
|
|
287
628
|
writeJsonRpc({ jsonrpc: "2.0", id: request.id, error: rpcError(-32000, errorMessage(error)) });
|
|
288
629
|
}
|
|
289
630
|
}
|
|
290
|
-
async function handleJsonRpcRequest(
|
|
631
|
+
async function handleJsonRpcRequest(session, method, params) {
|
|
291
632
|
if (method === "initialize") {
|
|
292
633
|
const initializeParams = params && typeof params === "object" && !Array.isArray(params)
|
|
293
634
|
? params
|
|
@@ -301,7 +642,7 @@ async function handleJsonRpcRequest(config, method, params) {
|
|
|
301
642
|
name: "forge-completion-tools",
|
|
302
643
|
version: "0.1.0"
|
|
303
644
|
},
|
|
304
|
-
instructions:
|
|
645
|
+
instructions: session.instructions
|
|
305
646
|
};
|
|
306
647
|
}
|
|
307
648
|
if (method === "ping") {
|
|
@@ -309,23 +650,14 @@ async function handleJsonRpcRequest(config, method, params) {
|
|
|
309
650
|
}
|
|
310
651
|
if (method === "tools/list") {
|
|
311
652
|
return {
|
|
312
|
-
tools:
|
|
653
|
+
tools: session.toolDefinitions()
|
|
313
654
|
};
|
|
314
655
|
}
|
|
315
656
|
if (method === "tools/call") {
|
|
316
657
|
const call = asRecord(params, "tools/call params");
|
|
317
658
|
const name = requiredString(call, "name");
|
|
318
659
|
const args = call.arguments ?? {};
|
|
319
|
-
|
|
320
|
-
return {
|
|
321
|
-
content: [
|
|
322
|
-
{
|
|
323
|
-
type: "text",
|
|
324
|
-
text: `Forge recorded ${reference.phase} completion artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}).`
|
|
325
|
-
}
|
|
326
|
-
],
|
|
327
|
-
structuredContent: reference
|
|
328
|
-
};
|
|
660
|
+
return await session.callTool(name, args);
|
|
329
661
|
}
|
|
330
662
|
throw new Error(`Unsupported MCP method: ${method}`);
|
|
331
663
|
}
|
|
@@ -336,6 +668,31 @@ function toolDefinition(name, phase) {
|
|
|
336
668
|
inputSchema: toolInputSchema(phase)
|
|
337
669
|
};
|
|
338
670
|
}
|
|
671
|
+
function nextPhaseAfter(phase) {
|
|
672
|
+
const index = completionPhaseOrder.indexOf(phase);
|
|
673
|
+
return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
|
|
674
|
+
}
|
|
675
|
+
function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
676
|
+
if (completedPhase === "qa") {
|
|
677
|
+
if (status === "blocked") {
|
|
678
|
+
return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
|
|
679
|
+
}
|
|
680
|
+
if (status === "needs_rework") {
|
|
681
|
+
return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
|
|
682
|
+
}
|
|
683
|
+
return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
|
|
684
|
+
}
|
|
685
|
+
if (status !== "pass") {
|
|
686
|
+
return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
|
|
687
|
+
}
|
|
688
|
+
if (nextPhase === "self_review") {
|
|
689
|
+
return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
|
|
690
|
+
}
|
|
691
|
+
if (nextPhase === "validation") {
|
|
692
|
+
return "Self-review accepted. Begin validation: run deterministic checks, prove acceptance criteria, and call forge.complete_validation with command evidence.";
|
|
693
|
+
}
|
|
694
|
+
return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
|
|
695
|
+
}
|
|
339
696
|
function toolDescription(phase) {
|
|
340
697
|
if (phase === "implementation") {
|
|
341
698
|
return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
|
|
@@ -343,77 +700,322 @@ function toolDescription(phase) {
|
|
|
343
700
|
if (phase === "self_review") {
|
|
344
701
|
return "Complete Forge self-review by writing self-review.report.json with diff review, findings, security, edge-case, design, deterministic-comparison, fixes, and remaining-risk evidence.";
|
|
345
702
|
}
|
|
703
|
+
if (phase === "qa") {
|
|
704
|
+
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.";
|
|
705
|
+
}
|
|
346
706
|
return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
|
|
347
707
|
}
|
|
348
708
|
function toolInputSchema(phase) {
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
summary: { type: "string" },
|
|
355
|
-
report_markdown: { type: "string" },
|
|
356
|
-
blockers: { type: "array" }
|
|
709
|
+
const baseProperties = {
|
|
710
|
+
status: {
|
|
711
|
+
type: "string",
|
|
712
|
+
enum: ["pass", "needs_rework", "fail", "failed"],
|
|
713
|
+
description: "Use pass only when the phase evidence satisfies Forge's gate. Use needs_rework/fail/failed with explicit blockers or findings otherwise."
|
|
357
714
|
},
|
|
358
|
-
|
|
715
|
+
summary: { type: "string", description: "Concise phase summary." },
|
|
716
|
+
report_markdown: { type: "string", description: "Optional human-readable report. Forge will generate a fallback report if omitted." },
|
|
717
|
+
blockers: {
|
|
718
|
+
type: "array",
|
|
719
|
+
description: "Required for non-pass status unless findings, failures, known_gaps, remaining_risks, or commands provide explicit evidence.",
|
|
720
|
+
items: evidenceObjectSchema(["reason"], {
|
|
721
|
+
reason: { type: "string" },
|
|
722
|
+
owner: { type: "string" },
|
|
723
|
+
next_step: { type: "string" }
|
|
724
|
+
})
|
|
725
|
+
}
|
|
359
726
|
};
|
|
360
727
|
if (phase === "implementation") {
|
|
361
|
-
return {
|
|
362
|
-
...
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
known_gaps: { type: "array" },
|
|
372
|
-
next_phase_context: {}
|
|
728
|
+
return objectToolSchema({
|
|
729
|
+
...baseProperties,
|
|
730
|
+
plan: {
|
|
731
|
+
type: "array",
|
|
732
|
+
description: "Required for pass. Ordered implementation steps with status.",
|
|
733
|
+
items: evidenceObjectSchema(["step", "status"], {
|
|
734
|
+
step: { type: "string" },
|
|
735
|
+
status: { type: "string" },
|
|
736
|
+
evidence: { type: "string" }
|
|
737
|
+
})
|
|
373
738
|
},
|
|
374
|
-
|
|
375
|
-
|
|
739
|
+
changed_files: {
|
|
740
|
+
type: "array",
|
|
741
|
+
description: "Required for pass. Must be non-empty unless known_gaps explains why.",
|
|
742
|
+
items: evidenceObjectSchema(["path", "summary"], {
|
|
743
|
+
path: { type: "string" },
|
|
744
|
+
summary: { type: "string" }
|
|
745
|
+
})
|
|
746
|
+
},
|
|
747
|
+
tests_added_or_updated: {
|
|
748
|
+
type: "array",
|
|
749
|
+
description: "Required for pass. Use an object explaining why no tests were needed for docs/config-only work if empty would be ambiguous.",
|
|
750
|
+
items: evidenceObjectSchema(["path", "summary"], {
|
|
751
|
+
path: { type: "string" },
|
|
752
|
+
summary: { type: "string" }
|
|
753
|
+
})
|
|
754
|
+
},
|
|
755
|
+
docs_added_or_updated: {
|
|
756
|
+
type: "array",
|
|
757
|
+
description: "Required for pass. Use an object explaining why no docs changed when appropriate.",
|
|
758
|
+
items: evidenceObjectSchema(["path", "summary"], {
|
|
759
|
+
path: { type: "string" },
|
|
760
|
+
summary: { type: "string" }
|
|
761
|
+
})
|
|
762
|
+
},
|
|
763
|
+
verification: {
|
|
764
|
+
type: "array",
|
|
765
|
+
description: "Implementation verification evidence. Objects should include command, exit_code, status, and summary when command-based.",
|
|
766
|
+
items: commandEvidenceSchema()
|
|
767
|
+
},
|
|
768
|
+
acceptance_criteria: acceptanceCriteriaSchema(),
|
|
769
|
+
known_gaps: {
|
|
770
|
+
type: "array",
|
|
771
|
+
description: "Required for pass, even when empty. If changed_files/tests/docs arrays are empty, explain why here.",
|
|
772
|
+
items: evidenceObjectSchema(["summary"], {
|
|
773
|
+
summary: { type: "string" },
|
|
774
|
+
impact: { type: "string" },
|
|
775
|
+
follow_up: { type: "string" }
|
|
776
|
+
})
|
|
777
|
+
},
|
|
778
|
+
next_phase_context: {
|
|
779
|
+
description: "Required for pass. Context the self-review phase should carry forward."
|
|
780
|
+
}
|
|
781
|
+
}, ["status", "summary"], "For status pass, include plan, changed_files, tests_added_or_updated, docs_added_or_updated, acceptance_criteria, known_gaps, and next_phase_context. Acceptance criteria entries must use status: satisfied plus evidence.");
|
|
376
782
|
}
|
|
377
783
|
if (phase === "self_review") {
|
|
378
|
-
return {
|
|
379
|
-
...
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
784
|
+
return objectToolSchema({
|
|
785
|
+
...baseProperties,
|
|
786
|
+
reviewed_diff: { type: "boolean", description: "Required for pass. True after reviewing the implementation diff." },
|
|
787
|
+
findings: {
|
|
788
|
+
type: "array",
|
|
789
|
+
description: "Required for pass, even when empty. Include finding objects for issues discovered during review.",
|
|
790
|
+
items: evidenceObjectSchema(["severity", "summary", "status"], {
|
|
791
|
+
severity: { type: "string" },
|
|
792
|
+
summary: { type: "string" },
|
|
793
|
+
status: { type: "string" },
|
|
794
|
+
file: { type: "string" }
|
|
795
|
+
})
|
|
390
796
|
},
|
|
391
|
-
|
|
392
|
-
|
|
797
|
+
security_review: reviewSectionSchema(),
|
|
798
|
+
edge_case_review: reviewSectionSchema(),
|
|
799
|
+
design_review: {
|
|
800
|
+
...reviewSectionSchema(),
|
|
801
|
+
properties: {
|
|
802
|
+
...reviewSectionProperties(),
|
|
803
|
+
screenshots: { type: "array", items: evidenceObjectSchema(["path"], { path: { type: "string" }, summary: { type: "string" } }) }
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
deterministic_comparison: {
|
|
807
|
+
type: "object",
|
|
808
|
+
description: "Required for pass. Evidence that the reviewed diff matches the intended deterministic change.",
|
|
809
|
+
additionalProperties: true,
|
|
810
|
+
properties: {
|
|
811
|
+
status: { type: "string" },
|
|
812
|
+
command_or_tool: { type: "string" },
|
|
813
|
+
notes: { type: "string" }
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
fixes_applied: {
|
|
817
|
+
type: "array",
|
|
818
|
+
description: "Required for pass, even when empty.",
|
|
819
|
+
items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, path: { type: "string" } })
|
|
820
|
+
},
|
|
821
|
+
remaining_risks: {
|
|
822
|
+
type: "array",
|
|
823
|
+
description: "Required for pass, even when empty.",
|
|
824
|
+
items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
|
|
825
|
+
}
|
|
826
|
+
}, ["status", "summary"], "For status pass, include reviewed_diff, findings, security_review, edge_case_review, design_review, deterministic_comparison, fixes_applied, and remaining_risks.");
|
|
393
827
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
828
|
+
if (phase === "qa") {
|
|
829
|
+
return objectToolSchema({
|
|
830
|
+
...baseProperties,
|
|
831
|
+
status: {
|
|
832
|
+
type: "string",
|
|
833
|
+
enum: ["pass", "needs_rework", "blocked"],
|
|
834
|
+
description: "Use pass only when QA evidence satisfies acceptance criteria, needs_rework for actionable in-scope defects, or blocked for an inconclusive QA blocker."
|
|
835
|
+
},
|
|
836
|
+
checklist: {
|
|
837
|
+
type: "array",
|
|
838
|
+
description: "QA plan/checklist. Forge writes this to qa.plan.json; a default checklist is generated if omitted.",
|
|
839
|
+
items: { type: "string" }
|
|
840
|
+
},
|
|
841
|
+
tested_acceptance_criteria: {
|
|
842
|
+
type: "array",
|
|
843
|
+
description: "Required and non-empty for pass/needs_rework. Criteria actually tested by QA.",
|
|
844
|
+
items: { type: "string" }
|
|
845
|
+
},
|
|
846
|
+
commands: {
|
|
847
|
+
type: "array",
|
|
848
|
+
description: "Command evidence written to qa.evidence.json.",
|
|
849
|
+
items: commandEvidenceSchema()
|
|
850
|
+
},
|
|
851
|
+
browser_checks: {
|
|
852
|
+
type: "array",
|
|
853
|
+
description: "Browser/manual UI checks with URL or route, action, observed result, screenshot/trace references, console/network notes, and status.",
|
|
854
|
+
items: evidenceObjectSchema(["summary", "status"], {
|
|
855
|
+
summary: { type: "string" },
|
|
856
|
+
url: { type: "string" },
|
|
857
|
+
route: { type: "string" },
|
|
858
|
+
status: { type: "string" },
|
|
859
|
+
screenshot: { type: "string" },
|
|
860
|
+
trace: { type: "string" }
|
|
861
|
+
})
|
|
862
|
+
},
|
|
863
|
+
sidecars: {
|
|
864
|
+
type: "array",
|
|
865
|
+
description: "Optional QA sidecar files already written under .forge/work-items/<id>/artifacts/qa/ with path/content_hash/bytes metadata.",
|
|
866
|
+
items: evidenceObjectSchema(["path", "content_hash", "bytes"], {
|
|
867
|
+
path: { type: "string" },
|
|
868
|
+
content_hash: { type: "string" },
|
|
869
|
+
bytes: { type: "number" }
|
|
870
|
+
})
|
|
871
|
+
},
|
|
872
|
+
notes: { type: "string", description: "Additional QA evidence notes." },
|
|
873
|
+
evidence: {
|
|
874
|
+
type: "object",
|
|
875
|
+
description: "Optional wrapper containing commands, browser_checks, and notes when a client prefers nested evidence."
|
|
876
|
+
},
|
|
877
|
+
findings: {
|
|
878
|
+
type: "array",
|
|
879
|
+
description: "QA findings. needs_rework requires at least one actionable in_scope finding.",
|
|
880
|
+
items: evidenceObjectSchema(["id", "severity", "scope", "title"], {
|
|
881
|
+
id: { type: "string" },
|
|
882
|
+
severity: { type: "string", enum: ["critical", "major", "minor"] },
|
|
883
|
+
scope: { type: "string", enum: ["in_scope", "out_of_scope", "unclear"] },
|
|
884
|
+
title: { type: "string" },
|
|
885
|
+
acceptance_criteria: { type: "array", items: { type: "string" } },
|
|
886
|
+
repro_steps: { type: "array", items: { type: "string" } },
|
|
887
|
+
expected: { type: "string" },
|
|
888
|
+
actual: { type: "string" },
|
|
889
|
+
evidence: { type: "object" },
|
|
890
|
+
likely_area: { type: "array", items: { type: "string" } },
|
|
891
|
+
required_fix: { type: "string" },
|
|
892
|
+
required_regression: { type: "string" }
|
|
893
|
+
})
|
|
894
|
+
},
|
|
895
|
+
candidate_bugs: {
|
|
896
|
+
type: "array",
|
|
897
|
+
description: "Out-of-scope/follow-up bug candidates. They do not block the current item unless attached as in-scope findings.",
|
|
898
|
+
items: evidenceObjectSchema(["id", "title", "suggested_action"], {
|
|
899
|
+
id: { type: "string" },
|
|
900
|
+
title: { type: "string" },
|
|
901
|
+
severity: { type: "string" },
|
|
902
|
+
scope_reason: { type: "string" },
|
|
903
|
+
repro_steps: { type: "array", items: { type: "string" } },
|
|
904
|
+
evidence: { type: "object" },
|
|
905
|
+
suggested_action: { type: "string", enum: ["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"] }
|
|
906
|
+
})
|
|
907
|
+
},
|
|
908
|
+
blocker_reason: {
|
|
909
|
+
type: "string",
|
|
910
|
+
enum: [...qaBlockerReasons],
|
|
911
|
+
description: "Required when status is blocked."
|
|
912
|
+
}
|
|
913
|
+
}, ["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.");
|
|
914
|
+
}
|
|
915
|
+
return objectToolSchema({
|
|
916
|
+
...baseProperties,
|
|
917
|
+
commands: {
|
|
918
|
+
type: "array",
|
|
919
|
+
description: "Required for pass. Include at least one deterministic validation command with exit_code: 0 and status: pass.",
|
|
920
|
+
items: commandEvidenceSchema()
|
|
921
|
+
},
|
|
922
|
+
acceptance_criteria: acceptanceCriteriaSchema(),
|
|
923
|
+
build_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
|
|
924
|
+
test_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
|
|
925
|
+
lint_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
|
|
926
|
+
design_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
|
|
927
|
+
drift_checks: {
|
|
928
|
+
type: "array",
|
|
929
|
+
description: "Required for pass. Deterministic checks for unintended drift.",
|
|
930
|
+
items: evidenceObjectSchema(["command_or_tool", "status", "summary"], {
|
|
931
|
+
command_or_tool: { type: "string" },
|
|
932
|
+
status: { type: "string" },
|
|
933
|
+
summary: { type: "string" }
|
|
934
|
+
})
|
|
406
935
|
},
|
|
407
|
-
|
|
936
|
+
ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
|
|
937
|
+
}, ["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.");
|
|
938
|
+
}
|
|
939
|
+
function objectToolSchema(properties, required, description) {
|
|
940
|
+
return {
|
|
941
|
+
type: "object",
|
|
942
|
+
additionalProperties: true,
|
|
943
|
+
description,
|
|
944
|
+
properties,
|
|
945
|
+
required: [...required]
|
|
408
946
|
};
|
|
409
947
|
}
|
|
948
|
+
function evidenceObjectSchema(required, properties) {
|
|
949
|
+
return {
|
|
950
|
+
type: "object",
|
|
951
|
+
additionalProperties: true,
|
|
952
|
+
properties,
|
|
953
|
+
required: [...required]
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
function commandEvidenceSchema() {
|
|
957
|
+
return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
|
|
958
|
+
command: { type: "string" },
|
|
959
|
+
exit_code: { type: "number" },
|
|
960
|
+
status: { type: "string" },
|
|
961
|
+
summary: { type: "string" }
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
function acceptanceCriteriaSchema() {
|
|
965
|
+
return {
|
|
966
|
+
type: "array",
|
|
967
|
+
description: "Required for pass. Every entry must be an object with status: satisfied and non-empty evidence.",
|
|
968
|
+
items: evidenceObjectSchema(["criterion", "evidence", "status"], {
|
|
969
|
+
criterion: { type: "string" },
|
|
970
|
+
evidence: { type: "string" },
|
|
971
|
+
status: { type: "string", enum: ["satisfied"] }
|
|
972
|
+
})
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
function reviewSectionSchema() {
|
|
976
|
+
return {
|
|
977
|
+
type: "object",
|
|
978
|
+
description: "Required for pass. Use status pass/fail/needs_rework/not_applicable plus notes.",
|
|
979
|
+
additionalProperties: true,
|
|
980
|
+
properties: reviewSectionProperties()
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
function reviewSectionProperties() {
|
|
984
|
+
return {
|
|
985
|
+
status: { type: "string" },
|
|
986
|
+
notes: { type: "string" }
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
function normalizeCompletionStatus(value, phase) {
|
|
990
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
991
|
+
if (phase === "qa") {
|
|
992
|
+
const canonical = normalized === "needsrework" ? "needs_rework" : normalized;
|
|
993
|
+
if (canonical !== "pass" && canonical !== "needs_rework" && canonical !== "blocked") {
|
|
994
|
+
throw new Error("QA status must be pass, needs_rework, or blocked.");
|
|
995
|
+
}
|
|
996
|
+
return canonical;
|
|
997
|
+
}
|
|
998
|
+
const canonical = normalized === "blocked" || normalized === "needsrework"
|
|
999
|
+
? "needs_rework"
|
|
1000
|
+
: normalized === "failed"
|
|
1001
|
+
? "fail"
|
|
1002
|
+
: normalized;
|
|
1003
|
+
if (canonical !== "pass" && canonical !== "fail" && canonical !== "needs_rework") {
|
|
1004
|
+
throw new Error("status must be pass, fail, or needs_rework.");
|
|
1005
|
+
}
|
|
1006
|
+
return canonical;
|
|
1007
|
+
}
|
|
410
1008
|
function validatePayloadShape(phase, payload) {
|
|
411
|
-
const status = requiredString(payload, "status")
|
|
1009
|
+
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
412
1010
|
if (!status) {
|
|
413
1011
|
throw new Error("status is required.");
|
|
414
1012
|
}
|
|
415
|
-
const pass = status === "pass";
|
|
416
1013
|
requireField(payload, "summary");
|
|
1014
|
+
if (phase === "qa") {
|
|
1015
|
+
validateQaPayloadShape(payload, status);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const pass = status === "pass";
|
|
417
1019
|
if (!pass) {
|
|
418
1020
|
requireNonPassEvidence(payload);
|
|
419
1021
|
return;
|
|
@@ -421,10 +1023,12 @@ function validatePayloadShape(phase, payload) {
|
|
|
421
1023
|
if (phase === "implementation") {
|
|
422
1024
|
requireArray(payload, "plan");
|
|
423
1025
|
requireArray(payload, "changed_files");
|
|
424
|
-
requireArray(payload, "
|
|
1026
|
+
requireArray(payload, "tests_added_or_updated");
|
|
1027
|
+
requireArray(payload, "docs_added_or_updated");
|
|
425
1028
|
requireArray(payload, "acceptance_criteria");
|
|
426
1029
|
requireArray(payload, "known_gaps");
|
|
427
1030
|
requireField(payload, "next_phase_context");
|
|
1031
|
+
requireImplementationEvidence(payload);
|
|
428
1032
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
429
1033
|
return;
|
|
430
1034
|
}
|
|
@@ -450,8 +1054,134 @@ function validatePayloadShape(phase, payload) {
|
|
|
450
1054
|
if (payload.ready_for_dev_test !== true) {
|
|
451
1055
|
throw new Error("validation pass requires ready_for_dev_test: true.");
|
|
452
1056
|
}
|
|
1057
|
+
requirePassingCommands(payload);
|
|
453
1058
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
454
1059
|
}
|
|
1060
|
+
function validateQaPayloadShape(payload, status) {
|
|
1061
|
+
const evidenceSource = qaEvidenceSource(payload);
|
|
1062
|
+
const commands = qaArray(payload, "commands", evidenceSource);
|
|
1063
|
+
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
1064
|
+
const sidecars = qaArray(payload, "sidecars", evidenceSource);
|
|
1065
|
+
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
1066
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
1067
|
+
const findings = qaObjectArray(payload, "findings");
|
|
1068
|
+
const candidateBugs = qaObjectArray(payload, "candidate_bugs");
|
|
1069
|
+
const explicitFindingIds = qaStringArray(payload, "finding_ids");
|
|
1070
|
+
const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
|
|
1071
|
+
if (payload.schema_version !== undefined && payload.schema_version !== 1) {
|
|
1072
|
+
throw new Error("QA schema_version must be numeric 1 when provided.");
|
|
1073
|
+
}
|
|
1074
|
+
if (status === "pass" || status === "needs_rework") {
|
|
1075
|
+
if (testedAcceptanceCriteria.length === 0) {
|
|
1076
|
+
throw new Error("QA pass/needs_rework requires tested_acceptance_criteria with at least one criterion.");
|
|
1077
|
+
}
|
|
1078
|
+
if (commands.length === 0 && browserChecks.length === 0 && notes.length === 0) {
|
|
1079
|
+
throw new Error("QA pass/needs_rework requires at least one command, browser check, or evidence note.");
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
for (const [index, command] of commands.entries()) {
|
|
1083
|
+
requireEvidenceRecord(command, `commands[${index}]`);
|
|
1084
|
+
const record = command;
|
|
1085
|
+
requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
|
|
1086
|
+
requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
|
|
1087
|
+
requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
|
|
1088
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
1089
|
+
throw new Error(`commands[${index}].exit_code is required.`);
|
|
1090
|
+
}
|
|
1091
|
+
if (status === "pass" && record.exit_code !== 0) {
|
|
1092
|
+
throw new Error(`commands[${index}].exit_code must be 0 when QA status is pass.`);
|
|
1093
|
+
}
|
|
1094
|
+
if (status === "pass" && normalizedString(record.status) !== "pass") {
|
|
1095
|
+
throw new Error(`commands[${index}].status must be pass when QA status is pass.`);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
for (const [index, browserCheck] of browserChecks.entries()) {
|
|
1099
|
+
requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
|
|
1100
|
+
const record = browserCheck;
|
|
1101
|
+
requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
|
|
1102
|
+
requireNonEmptyStringRecord(record, "status", `browser_checks[${index}].`);
|
|
1103
|
+
const checkStatus = optionalString(record, "status");
|
|
1104
|
+
if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
|
|
1105
|
+
throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
|
|
1106
|
+
}
|
|
1107
|
+
if (!optionalString(record, "url") && !optionalString(record, "route")) {
|
|
1108
|
+
throw new Error(`browser_checks[${index}] must include url or route.`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
1112
|
+
requireEvidenceRecord(sidecar, `sidecars[${index}]`);
|
|
1113
|
+
const record = sidecar;
|
|
1114
|
+
requireNonEmptyStringRecord(record, "path", `sidecars[${index}].`);
|
|
1115
|
+
requireNonEmptyStringRecord(record, "content_hash", `sidecars[${index}].`);
|
|
1116
|
+
if (typeof record.bytes !== "number" || !Number.isFinite(record.bytes) || record.bytes <= 0) {
|
|
1117
|
+
throw new Error(`sidecars[${index}].bytes must be greater than 0.`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
for (const [index, finding] of findings.entries()) {
|
|
1121
|
+
requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
|
|
1122
|
+
requireNonEmptyStringRecord(finding, "title", `findings[${index}].`);
|
|
1123
|
+
requireKnownStringRecord(finding, "severity", qaFindingSeverities, `findings[${index}].`);
|
|
1124
|
+
requireKnownStringRecord(finding, "scope", qaFindingScopes, `findings[${index}].`);
|
|
1125
|
+
if (normalizedString(finding.scope) === "in_scope") {
|
|
1126
|
+
validateActionableQaFinding(finding, index);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
for (const [index, candidateBug] of candidateBugs.entries()) {
|
|
1130
|
+
requireNonEmptyStringRecord(candidateBug, "id", `candidate_bugs[${index}].`);
|
|
1131
|
+
requireNonEmptyStringRecord(candidateBug, "title", `candidate_bugs[${index}].`);
|
|
1132
|
+
requireKnownStringRecord(candidateBug, "suggested_action", qaCandidateBugActions, `candidate_bugs[${index}].`);
|
|
1133
|
+
}
|
|
1134
|
+
const inScopeFindingIds = findings
|
|
1135
|
+
.filter((finding) => normalizedString(finding.scope) === "in_scope")
|
|
1136
|
+
.map((finding) => optionalString(finding, "id"))
|
|
1137
|
+
.filter((id) => Boolean(id));
|
|
1138
|
+
for (const findingId of explicitFindingIds) {
|
|
1139
|
+
if (!inScopeFindingIds.includes(findingId)) {
|
|
1140
|
+
throw new Error(`QA finding_ids contains '${findingId}', but findings has no matching in-scope finding.`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
const candidateBugIds = candidateBugs
|
|
1144
|
+
.map((candidateBug) => optionalString(candidateBug, "id"))
|
|
1145
|
+
.filter((id) => Boolean(id));
|
|
1146
|
+
for (const candidateBugId of explicitCandidateBugIds) {
|
|
1147
|
+
if (!candidateBugIds.includes(candidateBugId)) {
|
|
1148
|
+
throw new Error(`QA candidate_bug_ids contains '${candidateBugId}', but candidate_bugs has no matching candidate bug.`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
if (status === "pass") {
|
|
1152
|
+
if (explicitFindingIds.length > 0 || inScopeFindingIds.length > 0) {
|
|
1153
|
+
throw new Error("QA pass must not include in-scope finding_ids or in-scope findings.");
|
|
1154
|
+
}
|
|
1155
|
+
if (optionalString(payload, "blocker_reason")) {
|
|
1156
|
+
throw new Error("QA pass must not include blocker_reason.");
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
if (status === "needs_rework") {
|
|
1160
|
+
if (inScopeFindingIds.length === 0) {
|
|
1161
|
+
throw new Error("QA needs_rework requires at least one actionable in-scope finding.");
|
|
1162
|
+
}
|
|
1163
|
+
if (optionalString(payload, "blocker_reason")) {
|
|
1164
|
+
throw new Error("QA needs_rework must not include blocker_reason.");
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (status === "blocked") {
|
|
1168
|
+
const reason = normalizedString(optionalString(payload, "blocker_reason"));
|
|
1169
|
+
if (!qaBlockerReasons.has(reason)) {
|
|
1170
|
+
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.");
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
function validateActionableQaFinding(finding, index) {
|
|
1175
|
+
const prefix = `findings[${index}].`;
|
|
1176
|
+
requireNonEmptyStringArrayRecord(finding, "acceptance_criteria", prefix);
|
|
1177
|
+
requireNonEmptyStringArrayRecord(finding, "repro_steps", prefix);
|
|
1178
|
+
requireNonEmptyStringRecord(finding, "expected", prefix);
|
|
1179
|
+
requireNonEmptyStringRecord(finding, "actual", prefix);
|
|
1180
|
+
requireSubstantiveEvidenceRecord(finding, prefix);
|
|
1181
|
+
requireNonEmptyStringArrayRecord(finding, "likely_area", prefix);
|
|
1182
|
+
requireNonEmptyStringRecord(finding, "required_fix", prefix);
|
|
1183
|
+
requireNonEmptyStringRecord(finding, "required_regression", prefix);
|
|
1184
|
+
}
|
|
455
1185
|
function validateOptionalWorkItemId(payload, expectedWorkItemId) {
|
|
456
1186
|
const actual = optionalString(payload, "work_item_id") ?? optionalString(payload, "workItemId");
|
|
457
1187
|
if (actual && actual !== expectedWorkItemId) {
|
|
@@ -466,6 +1196,8 @@ function normalizeArtifactPayload(payload, defaults) {
|
|
|
466
1196
|
normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
|
|
467
1197
|
normalized.phase = defaults.phase;
|
|
468
1198
|
normalized.work_item_id = defaults.workItemId;
|
|
1199
|
+
const ownerFields = artifactOwnerFields(defaults);
|
|
1200
|
+
Object.assign(normalized, ownerFields);
|
|
469
1201
|
normalized.status = defaults.status;
|
|
470
1202
|
normalized.produced_by = defaults.producedBy;
|
|
471
1203
|
return normalized;
|
|
@@ -498,8 +1230,26 @@ function parseStrictCompletionPhase(value) {
|
|
|
498
1230
|
if (normalized === "validate" || normalized === "validation") {
|
|
499
1231
|
return "validation";
|
|
500
1232
|
}
|
|
1233
|
+
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
1234
|
+
return "qa";
|
|
1235
|
+
}
|
|
501
1236
|
return null;
|
|
502
1237
|
}
|
|
1238
|
+
function parseCompletionRedactionSecrets(raw) {
|
|
1239
|
+
if (!raw?.trim()) {
|
|
1240
|
+
return [];
|
|
1241
|
+
}
|
|
1242
|
+
try {
|
|
1243
|
+
const parsed = JSON.parse(raw);
|
|
1244
|
+
if (!Array.isArray(parsed)) {
|
|
1245
|
+
return [];
|
|
1246
|
+
}
|
|
1247
|
+
return parsed.filter((value) => typeof value === "string" && value.length > 0);
|
|
1248
|
+
}
|
|
1249
|
+
catch {
|
|
1250
|
+
return [];
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
503
1253
|
function phaseFromToolName(toolName) {
|
|
504
1254
|
const normalized = toolName.trim().toLowerCase();
|
|
505
1255
|
if (normalized === "forge.complete_implementation" || normalized === "complete_implementation") {
|
|
@@ -511,6 +1261,9 @@ function phaseFromToolName(toolName) {
|
|
|
511
1261
|
if (normalized === "forge.complete_validation" || normalized === "complete_validation") {
|
|
512
1262
|
return "validation";
|
|
513
1263
|
}
|
|
1264
|
+
if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
|
|
1265
|
+
return "qa";
|
|
1266
|
+
}
|
|
514
1267
|
throw new Error(`Unknown Forge completion tool: ${toolName}`);
|
|
515
1268
|
}
|
|
516
1269
|
function requireField(payload, key) {
|
|
@@ -534,6 +1287,47 @@ function requireNonPassEvidence(payload) {
|
|
|
534
1287
|
throw new Error("non-pass completion requires explicit blockers, findings, failures, or known gaps.");
|
|
535
1288
|
}
|
|
536
1289
|
}
|
|
1290
|
+
function requireImplementationEvidence(payload) {
|
|
1291
|
+
const knownGaps = payload.known_gaps;
|
|
1292
|
+
const hasKnownGap = Array.isArray(knownGaps) && knownGaps.length > 0;
|
|
1293
|
+
for (const key of ["changed_files", "tests_added_or_updated", "docs_added_or_updated"]) {
|
|
1294
|
+
const value = payload[key];
|
|
1295
|
+
if (Array.isArray(value) && value.length === 0 && !hasKnownGap) {
|
|
1296
|
+
throw new Error(`${key} is empty; known_gaps must explain why.`);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
function requirePassingCommands(payload) {
|
|
1301
|
+
const commands = payload.commands;
|
|
1302
|
+
if (!Array.isArray(commands)) {
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
if (commands.length === 0) {
|
|
1306
|
+
throw new Error("commands must include at least one deterministic validation command.");
|
|
1307
|
+
}
|
|
1308
|
+
for (const [index, command] of commands.entries()) {
|
|
1309
|
+
if (!command || typeof command !== "object" || Array.isArray(command)) {
|
|
1310
|
+
throw new Error(`commands[${index}] must be an object.`);
|
|
1311
|
+
}
|
|
1312
|
+
const record = command;
|
|
1313
|
+
if (typeof record.command !== "string" || record.command.trim().length === 0) {
|
|
1314
|
+
throw new Error(`commands[${index}].command must be a non-empty string.`);
|
|
1315
|
+
}
|
|
1316
|
+
if (typeof record.summary !== "string" || record.summary.trim().length === 0) {
|
|
1317
|
+
throw new Error(`commands[${index}].summary must be a non-empty string.`);
|
|
1318
|
+
}
|
|
1319
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
1320
|
+
throw new Error(`commands[${index}].exit_code must be a finite number.`);
|
|
1321
|
+
}
|
|
1322
|
+
if (record.exit_code !== 0) {
|
|
1323
|
+
throw new Error(`commands[${index}].exit_code must be 0 when status is pass.`);
|
|
1324
|
+
}
|
|
1325
|
+
const status = typeof record.status === "string" ? record.status.trim().toLowerCase() : "";
|
|
1326
|
+
if (status !== "pass") {
|
|
1327
|
+
throw new Error(`commands[${index}].status must be pass.`);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
537
1331
|
function requireArray(payload, key) {
|
|
538
1332
|
if (!Array.isArray(payload[key])) {
|
|
539
1333
|
throw new Error(`${key} must be an array.`);
|
|
@@ -567,6 +1361,142 @@ function requireSatisfiedAcceptanceCriteria(payload) {
|
|
|
567
1361
|
}
|
|
568
1362
|
}
|
|
569
1363
|
}
|
|
1364
|
+
function qaEvidenceSource(payload) {
|
|
1365
|
+
const evidence = payload.evidence;
|
|
1366
|
+
return evidence && typeof evidence === "object" && !Array.isArray(evidence)
|
|
1367
|
+
? evidence
|
|
1368
|
+
: {};
|
|
1369
|
+
}
|
|
1370
|
+
function qaArray(payload, key, fallback = {}) {
|
|
1371
|
+
const value = payload[key] ?? fallback[key];
|
|
1372
|
+
if (value === undefined || value === null) {
|
|
1373
|
+
return [];
|
|
1374
|
+
}
|
|
1375
|
+
if (!Array.isArray(value)) {
|
|
1376
|
+
throw new Error(`${key} must be an array.`);
|
|
1377
|
+
}
|
|
1378
|
+
return value;
|
|
1379
|
+
}
|
|
1380
|
+
function qaObjectArray(payload, key) {
|
|
1381
|
+
return qaArray(payload, key).map((entry, index) => {
|
|
1382
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
1383
|
+
throw new Error(`${key}[${index}] must be an object.`);
|
|
1384
|
+
}
|
|
1385
|
+
return entry;
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
function qaStringArray(payload, key) {
|
|
1389
|
+
const value = payload[key];
|
|
1390
|
+
if (value === undefined || value === null) {
|
|
1391
|
+
return [];
|
|
1392
|
+
}
|
|
1393
|
+
if (!Array.isArray(value)) {
|
|
1394
|
+
throw new Error(`${key} must be an array.`);
|
|
1395
|
+
}
|
|
1396
|
+
return value.map((entry, index) => {
|
|
1397
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
1398
|
+
throw new Error(`${key}[${index}] must be a non-empty string.`);
|
|
1399
|
+
}
|
|
1400
|
+
return entry;
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
function requireEvidenceRecord(value, label) {
|
|
1404
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1405
|
+
throw new Error(`${label} must be an object.`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
function requireNonEmptyStringRecord(source, key, prefix = "") {
|
|
1409
|
+
if (!optionalString(source, key)) {
|
|
1410
|
+
throw new Error(`${prefix}${key} is required.`);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
function requireKnownStringRecord(source, key, allowed, prefix = "") {
|
|
1414
|
+
const value = normalizedString(optionalString(source, key));
|
|
1415
|
+
if (!allowed.has(value)) {
|
|
1416
|
+
throw new Error(`${prefix}${key} must be one of ${[...allowed].sort().join(", ")}.`);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
|
|
1420
|
+
const value = source[key];
|
|
1421
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
1422
|
+
throw new Error(`${prefix}${key} must include at least one value.`);
|
|
1423
|
+
}
|
|
1424
|
+
for (const [index, entry] of value.entries()) {
|
|
1425
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
1426
|
+
throw new Error(`${prefix}${key}[${index}] must be a non-empty string.`);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
function requireSubstantiveEvidenceRecord(source, prefix = "") {
|
|
1431
|
+
const evidence = source.evidence;
|
|
1432
|
+
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
1433
|
+
throw new Error(`${prefix}evidence must be an object.`);
|
|
1434
|
+
}
|
|
1435
|
+
if (!Object.values(evidence).some(hasSubstantiveJsonValue)) {
|
|
1436
|
+
throw new Error(`${prefix}evidence must include at least one concrete evidence reference or summary.`);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
function hasSubstantiveJsonValue(value) {
|
|
1440
|
+
if (typeof value === "string") {
|
|
1441
|
+
return value.trim().length > 0;
|
|
1442
|
+
}
|
|
1443
|
+
if (Array.isArray(value)) {
|
|
1444
|
+
return value.length > 0;
|
|
1445
|
+
}
|
|
1446
|
+
if (value && typeof value === "object") {
|
|
1447
|
+
return Object.keys(value).length > 0;
|
|
1448
|
+
}
|
|
1449
|
+
return false;
|
|
1450
|
+
}
|
|
1451
|
+
function normalizedString(value) {
|
|
1452
|
+
return typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
|
|
1453
|
+
}
|
|
1454
|
+
function normalizedOwnerType(value) {
|
|
1455
|
+
const normalized = normalizedString(value);
|
|
1456
|
+
return normalized === "work_item" || normalized === "story" ? normalized : undefined;
|
|
1457
|
+
}
|
|
1458
|
+
function artifactOwnerFields(config) {
|
|
1459
|
+
const ownerType = normalizedOwnerType(config.ownerType);
|
|
1460
|
+
const ownerId = config.ownerId?.trim();
|
|
1461
|
+
if (!ownerType && !ownerId) {
|
|
1462
|
+
return {};
|
|
1463
|
+
}
|
|
1464
|
+
return {
|
|
1465
|
+
owner_type: ownerType ?? "work_item",
|
|
1466
|
+
owner_id: ownerId || config.workItemId
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function artifactReferenceOwnerFields(config) {
|
|
1470
|
+
const owner = artifactOwnerFields(config);
|
|
1471
|
+
return owner.owner_type && owner.owner_id
|
|
1472
|
+
? { ownerType: owner.owner_type, ownerId: owner.owner_id }
|
|
1473
|
+
: {};
|
|
1474
|
+
}
|
|
1475
|
+
function artifactOwnerDirectory(ownerType) {
|
|
1476
|
+
return normalizedOwnerType(ownerType) === "story" ? "stories" : "work-items";
|
|
1477
|
+
}
|
|
1478
|
+
function artifactOwnerRelativeRoot(ownerType, ownerId, workItemId) {
|
|
1479
|
+
const owner = artifactOwnerFields({ workItemId, ownerType, ownerId });
|
|
1480
|
+
return owner.owner_type && owner.owner_id
|
|
1481
|
+
? [".forge", artifactOwnerDirectory(owner.owner_type), owner.owner_id, "artifacts"].join("/")
|
|
1482
|
+
: [".forge", "work-items", workItemId, "artifacts"].join("/");
|
|
1483
|
+
}
|
|
1484
|
+
function defaultQaChecklist(status) {
|
|
1485
|
+
return [
|
|
1486
|
+
"Review the validated work item against acceptance criteria.",
|
|
1487
|
+
"Inspect implementation, self-review, and validation evidence when available.",
|
|
1488
|
+
status === "blocked" ? "Record the blocker reason preventing a conclusive QA verdict." : "Record concrete QA evidence and findings."
|
|
1489
|
+
];
|
|
1490
|
+
}
|
|
1491
|
+
function qaArtifactRelativePath(workItemId, fileName) {
|
|
1492
|
+
return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
|
|
1493
|
+
}
|
|
1494
|
+
function qaArtifactRelativePathForOwner(ownerType, ownerId, fileName) {
|
|
1495
|
+
return [".forge", artifactOwnerDirectory(ownerType), ownerId, "artifacts", fileName].join("/");
|
|
1496
|
+
}
|
|
1497
|
+
function jsonText(value) {
|
|
1498
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
1499
|
+
}
|
|
570
1500
|
function fallbackReport(parsed, phase, artifactPath) {
|
|
571
1501
|
return `# Forge ${phase.replace(/_/g, " ")} completion
|
|
572
1502
|
|