@loucompanion/forge-bridge 0.1.1-dev.2ac58b6f94ce → 0.1.1-dev.2d1a116ca78d
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.d.ts +1 -0
- package/dist/bridge/bridge.service.js +146 -17
- package/dist/bridge/bridge.types.d.ts +4 -2
- package/dist/cli/cli.service.js +225 -10
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +18 -3
- package/dist/config.js +1 -1
- package/dist/session/phase-completion.d.ts +19 -5
- package/dist/session/phase-completion.js +1092 -26
- package/dist/session/provider-cli/provider-cli.base.d.ts +3 -1
- package/dist/session/provider-cli/provider-cli.service.d.ts +16 -1
- package/dist/session/provider-cli/provider-cli.service.js +1108 -216
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/session.service.js +43 -4
- package/dist/session/session.types.d.ts +77 -0
- package/dist/session/session.types.js +143 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +1 -0
- package/dist/worktree/artifact-injector.js +117 -1
- package/dist/worktree/worktree.service.d.ts +1 -0
- package/dist/worktree/worktree.service.js +132 -6
- package/dist/worktree/worktree.types.d.ts +8 -1
- package/package.json +8 -3
|
@@ -7,6 +7,12 @@ import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from ".
|
|
|
7
7
|
const toolServerName = "forge";
|
|
8
8
|
const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
|
|
9
9
|
const completionPhaseOrder = ["implementation", "self_review", "validation"];
|
|
10
|
+
export const debugCompletionPhaseOrder = [
|
|
11
|
+
"debug_repro",
|
|
12
|
+
"debug_root_cause",
|
|
13
|
+
"debug_fix",
|
|
14
|
+
"debug_regression"
|
|
15
|
+
];
|
|
10
16
|
export const qaCompletionGateOrder = [
|
|
11
17
|
"qa_plan",
|
|
12
18
|
"qa_regression",
|
|
@@ -34,6 +40,12 @@ const qaGateToolNames = {
|
|
|
34
40
|
qa_findings: "complete_qa_findings",
|
|
35
41
|
qa_verdict: "complete_qa_verdict"
|
|
36
42
|
};
|
|
43
|
+
const debugGateToolNames = {
|
|
44
|
+
debug_repro: "complete_debug_repro",
|
|
45
|
+
debug_root_cause: "complete_debug_root_cause",
|
|
46
|
+
debug_fix: "complete_debug_fix",
|
|
47
|
+
debug_regression: "complete_debug_regression"
|
|
48
|
+
};
|
|
37
49
|
const qaBlockerReasons = new Set([
|
|
38
50
|
"missing_app_launch_target",
|
|
39
51
|
"inconclusive",
|
|
@@ -42,6 +54,8 @@ const qaBlockerReasons = new Set([
|
|
|
42
54
|
"missing_worktree",
|
|
43
55
|
"runner_failure"
|
|
44
56
|
]);
|
|
57
|
+
const resolvedFindingStatusValues = ["resolved", "disputed", "not_reproduced"];
|
|
58
|
+
const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
|
|
45
59
|
const qaFindingSeverities = new Set(["critical", "major", "minor"]);
|
|
46
60
|
const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
|
|
47
61
|
const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
|
|
@@ -61,6 +75,26 @@ const phaseFiles = {
|
|
|
61
75
|
qa: {
|
|
62
76
|
jsonFileName: "qa.verdict.json",
|
|
63
77
|
reportFileName: "qa.report.md"
|
|
78
|
+
},
|
|
79
|
+
debug_repro: {
|
|
80
|
+
jsonFileName: "debug.repro.json",
|
|
81
|
+
reportFileName: "debug.report.md"
|
|
82
|
+
},
|
|
83
|
+
debug_root_cause: {
|
|
84
|
+
jsonFileName: "debug.root-cause.json",
|
|
85
|
+
reportFileName: "debug.report.md"
|
|
86
|
+
},
|
|
87
|
+
debug_fix: {
|
|
88
|
+
jsonFileName: "debug.fix.json",
|
|
89
|
+
reportFileName: "debug.report.md"
|
|
90
|
+
},
|
|
91
|
+
debug_regression: {
|
|
92
|
+
jsonFileName: "debug.regression.json",
|
|
93
|
+
reportFileName: "debug.report.md"
|
|
94
|
+
},
|
|
95
|
+
architect: {
|
|
96
|
+
jsonFileName: "architect.verdict.json",
|
|
97
|
+
reportFileName: "architect.report.md"
|
|
64
98
|
}
|
|
65
99
|
};
|
|
66
100
|
export function completionPhaseFromDispatch(dispatch) {
|
|
@@ -68,6 +102,10 @@ export function completionPhaseFromDispatch(dispatch) {
|
|
|
68
102
|
}
|
|
69
103
|
export function normalizeCompletionPhase(value) {
|
|
70
104
|
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
105
|
+
const debugPhase = normalizeDebugCompletionPhase(normalized);
|
|
106
|
+
if (debugPhase) {
|
|
107
|
+
return debugPhase;
|
|
108
|
+
}
|
|
71
109
|
if (normalized === "self_review" || normalized === "selfreview" || normalized === "review") {
|
|
72
110
|
return "self_review";
|
|
73
111
|
}
|
|
@@ -77,6 +115,9 @@ export function normalizeCompletionPhase(value) {
|
|
|
77
115
|
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
78
116
|
return "qa";
|
|
79
117
|
}
|
|
118
|
+
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
119
|
+
return "architect";
|
|
120
|
+
}
|
|
80
121
|
return "implementation";
|
|
81
122
|
}
|
|
82
123
|
export function usesDevelopCycleCompletion(dispatch) {
|
|
@@ -97,37 +138,70 @@ export function usesQaCycleCompletion(dispatch) {
|
|
|
97
138
|
&& lane === "qa"
|
|
98
139
|
&& completionPhaseFromDispatch(dispatch) === "qa";
|
|
99
140
|
}
|
|
141
|
+
export function usesDebugCycleCompletion(dispatch) {
|
|
142
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
143
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
144
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
145
|
+
return kind === "debug"
|
|
146
|
+
&& role === "debug"
|
|
147
|
+
&& lane === "debug"
|
|
148
|
+
&& isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
|
|
149
|
+
}
|
|
150
|
+
export function usesArchitectCycleCompletion(dispatch) {
|
|
151
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
152
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
153
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
154
|
+
return kind === "architect" || role === "architect" || lane === "architect";
|
|
155
|
+
}
|
|
100
156
|
export function completionToolNamesForPhase(phase) {
|
|
157
|
+
if (isDebugCompletionPhase(phase)) {
|
|
158
|
+
return completionToolNamesForDebugGate(phase);
|
|
159
|
+
}
|
|
101
160
|
const base = phase === "implementation"
|
|
102
161
|
? "complete_implementation"
|
|
103
162
|
: phase === "self_review"
|
|
104
163
|
? "complete_self_review"
|
|
105
164
|
: phase === "validation"
|
|
106
165
|
? "complete_validation"
|
|
107
|
-
: "
|
|
166
|
+
: phase === "architect"
|
|
167
|
+
? "complete_architect_plan"
|
|
168
|
+
: "complete_qa";
|
|
108
169
|
return [`forge.${base}`, base];
|
|
109
170
|
}
|
|
110
171
|
export function completionToolNamesForQaGate(gate) {
|
|
111
172
|
const base = qaGateToolNames[gate];
|
|
112
173
|
return [`forge.${base}`, base];
|
|
113
174
|
}
|
|
175
|
+
export function completionToolNamesForDebugGate(gate) {
|
|
176
|
+
const base = debugGateToolNames[gate];
|
|
177
|
+
return [`forge.${base}`, base];
|
|
178
|
+
}
|
|
114
179
|
export function expectedCompletionToolName(phase) {
|
|
115
180
|
return completionToolNamesForPhase(phase)[0];
|
|
116
181
|
}
|
|
117
182
|
export function expectedQaCompletionToolName(gate) {
|
|
118
183
|
return completionToolNamesForQaGate(gate)[0];
|
|
119
184
|
}
|
|
185
|
+
export function expectedDebugCompletionToolName(gate) {
|
|
186
|
+
return completionToolNamesForDebugGate(gate)[0];
|
|
187
|
+
}
|
|
120
188
|
export function artifactRootFor(worktreePath, workItemId) {
|
|
121
189
|
return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
|
|
122
190
|
}
|
|
123
|
-
function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
191
|
+
export function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
124
192
|
return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
|
|
125
193
|
}
|
|
126
|
-
export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
|
|
127
|
-
|
|
194
|
+
export function completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
195
|
+
const artifactRoot = ownerType && ownerId
|
|
196
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
197
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
198
|
+
return path.join(artifactRoot, phaseFiles[phase].jsonFileName);
|
|
128
199
|
}
|
|
129
|
-
export function completionArtifactReportPath(worktreePath, workItemId, phase) {
|
|
130
|
-
|
|
200
|
+
export function completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
201
|
+
const artifactRoot = ownerType && ownerId
|
|
202
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
203
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
204
|
+
return path.join(artifactRoot, phaseFiles[phase].reportFileName);
|
|
131
205
|
}
|
|
132
206
|
export async function prepareCompletionToolRuntime(options) {
|
|
133
207
|
const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
|
|
@@ -143,7 +217,9 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
143
217
|
phase,
|
|
144
218
|
producedBy: `${options.dispatch.execution.provider}-cli`,
|
|
145
219
|
developCycle: usesDevelopCycleCompletion(options.dispatch),
|
|
146
|
-
qaCycle: usesQaCycleCompletion(options.dispatch)
|
|
220
|
+
qaCycle: usesQaCycleCompletion(options.dispatch),
|
|
221
|
+
debugCycle: usesDebugCycleCompletion(options.dispatch),
|
|
222
|
+
architectCycle: usesArchitectCycleCompletion(options.dispatch)
|
|
147
223
|
});
|
|
148
224
|
const runtime = {
|
|
149
225
|
serverName: toolServerName,
|
|
@@ -179,6 +255,12 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
179
255
|
if (config.qaCycle) {
|
|
180
256
|
args.push("--qa-cycle");
|
|
181
257
|
}
|
|
258
|
+
if (config.debugCycle) {
|
|
259
|
+
args.push("--debug-cycle");
|
|
260
|
+
}
|
|
261
|
+
if (config.architectCycle) {
|
|
262
|
+
args.push("--architect-cycle");
|
|
263
|
+
}
|
|
182
264
|
return args;
|
|
183
265
|
}
|
|
184
266
|
export function codexMcpConfigArgs(runtime) {
|
|
@@ -211,6 +293,17 @@ Do not write QA artifacts by hand. When QA is complete, call \`${toolName}(...)\
|
|
|
211
293
|
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\`.
|
|
212
294
|
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.
|
|
213
295
|
Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
|
|
296
|
+
}
|
|
297
|
+
if (phase === "architect") {
|
|
298
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
299
|
+
Do not write Architect artifacts by hand. When the plan is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the full structured Architect planning payload.
|
|
300
|
+
That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
|
|
301
|
+
The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
|
|
302
|
+
\`{ "status": "pass", "summary": "...", "implementation_plan": { "schema_version": 1, "plan_id": "plan-...", "phases": [{ "id": "phase-1", "tasks": ["task-1"], "gate": "gate-1" }], "tasks": [{ "id": "task-1", "tests": [{ "command": "npm test" }] }], "dependencies": [], "phase_gates": [{ "id": "gate-1", "phase": "phase-1" }] }, "qa_plan": { "schema_version": 1, "final_qa": [{ "id": "qa-1", "coverage": "..." }] }, "report_markdown": "# Architect Report" }\`.
|
|
303
|
+
\`implementation_plan.tasks\` are Develop child tasks only: each task must require a focused repo/product implementation change. Put parent-story QA, sign-off, child-count/materialization inventory, parent plan artifact checks, and final integrated acceptance proof in \`qa_plan.final_qa\`, not as child Develop tasks.
|
|
304
|
+
Dependency entries use canonical \`before\` and \`after\` task ids, for example \`{ "before": "task-1", "after": "task-2" }\`. Forge accepts \`from\`/\`to\` only as aliases and normalizes them before writing artifacts.
|
|
305
|
+
Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
|
|
306
|
+
Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
|
|
214
307
|
}
|
|
215
308
|
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
216
309
|
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.
|
|
@@ -242,12 +335,27 @@ Use \`status: "skipped"\` with a reason for gates that are not applicable. If a
|
|
|
242
335
|
The final verdict tool writes the canonical QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`qa.verdict.json\`, and \`qa.report.md\`.
|
|
243
336
|
The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
|
|
244
337
|
}
|
|
338
|
+
export function completionPromptForDebugCycle(currentGate = "debug_repro") {
|
|
339
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
340
|
+
Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
|
|
341
|
+
Complete the Debug cycle in order inside this same provider session:
|
|
342
|
+
1. Reproduce or tightly characterize the bug, then call \`forge.complete_debug_repro(...)\`.
|
|
343
|
+
2. Follow the returned root-cause instruction, isolate evidence, then call \`forge.complete_debug_root_cause(...)\`.
|
|
344
|
+
3. Follow the returned fix instruction, apply the smallest safe fix with regression coverage when practical, then call \`forge.complete_debug_fix(...)\`.
|
|
345
|
+
4. Follow the returned regression instruction, prove the original repro is negative and the fix is QA-ready, then call \`forge.complete_debug_regression(...)\`.
|
|
346
|
+
Each tool writes a canonical Debug artifact under \`.forge/work-items/{workItemId}/artifacts/\`: \`debug.repro.json\`, \`debug.root-cause.json\`, \`debug.fix.json\`, and \`debug.regression.json\` plus \`debug.report.md\` when report text is provided or generated.
|
|
347
|
+
Use a non-pass status with explicit blockers when the current Debug gate cannot pass. Reporting Debug completion in prose is not enough; the relevant MCP completion tool is the transition request.`;
|
|
348
|
+
}
|
|
245
349
|
export function completionToolNamesForServer(config) {
|
|
246
|
-
return config.
|
|
247
|
-
?
|
|
248
|
-
: config.
|
|
249
|
-
?
|
|
250
|
-
:
|
|
350
|
+
return config.debugCycle
|
|
351
|
+
? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
|
|
352
|
+
: config.qaCycle
|
|
353
|
+
? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
|
|
354
|
+
: config.developCycle
|
|
355
|
+
? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
|
|
356
|
+
: config.architectCycle
|
|
357
|
+
? completionToolNamesForPhase("architect")
|
|
358
|
+
: completionToolNamesForPhase(config.phase);
|
|
251
359
|
}
|
|
252
360
|
export class CompletionToolServerSession {
|
|
253
361
|
config;
|
|
@@ -259,11 +367,13 @@ export class CompletionToolServerSession {
|
|
|
259
367
|
this.currentPhase = config.phase;
|
|
260
368
|
}
|
|
261
369
|
get instructions() {
|
|
262
|
-
return this.config.
|
|
263
|
-
?
|
|
264
|
-
: this.config.
|
|
265
|
-
?
|
|
266
|
-
:
|
|
370
|
+
return this.config.debugCycle
|
|
371
|
+
? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
|
|
372
|
+
: this.config.qaCycle
|
|
373
|
+
? completionPromptForQaCycle(this.currentQaGate)
|
|
374
|
+
: this.config.developCycle
|
|
375
|
+
? completionPromptForDevelopCycle(this.currentPhase)
|
|
376
|
+
: completionPromptForPhase(this.currentPhase);
|
|
267
377
|
}
|
|
268
378
|
toolDefinitions() {
|
|
269
379
|
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
|
|
@@ -273,6 +383,9 @@ export class CompletionToolServerSession {
|
|
|
273
383
|
if (this.completed) {
|
|
274
384
|
throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
|
|
275
385
|
}
|
|
386
|
+
if (this.config.debugCycle) {
|
|
387
|
+
return await this.callDebugGateTool(toolName, requestedPhase, input);
|
|
388
|
+
}
|
|
276
389
|
if (this.config.qaCycle) {
|
|
277
390
|
return await this.callQaGateTool(toolName, requestedPhase, input);
|
|
278
391
|
}
|
|
@@ -310,6 +423,44 @@ export class CompletionToolServerSession {
|
|
|
310
423
|
}
|
|
311
424
|
};
|
|
312
425
|
}
|
|
426
|
+
async callDebugGateTool(toolName, requestedPhase, input) {
|
|
427
|
+
const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
|
|
428
|
+
if (!isDebugCompletionPhase(requestedPhase)) {
|
|
429
|
+
throw new Error(`Tool ${toolName} is not available in Debug-cycle mode. Current Forge Debug gate is ${currentDebugGate}.`);
|
|
430
|
+
}
|
|
431
|
+
if (requestedPhase !== currentDebugGate) {
|
|
432
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge Debug gate is ${currentDebugGate}.`);
|
|
433
|
+
}
|
|
434
|
+
const completedGate = currentDebugGate;
|
|
435
|
+
const reference = await writeCompletionArtifact({ ...this.config, phase: completedGate }, toolName, input);
|
|
436
|
+
const status = reference.status.trim().toLowerCase();
|
|
437
|
+
const pass = status === "pass";
|
|
438
|
+
const nextGate = pass ? this.advanceAfterDebugGate(completedGate) : completedGate;
|
|
439
|
+
const nextPhase = nextGate ?? "qa_ready";
|
|
440
|
+
const nextInstruction = completionToolNextInstructionForDebugGate(completedGate, status, nextGate);
|
|
441
|
+
const reason = pass
|
|
442
|
+
? `${completedGate} artifact accepted${nextGate ? `; continue to ${nextGate}.` : "; Debug regression is QA-ready."}`
|
|
443
|
+
: `${completedGate} artifact recorded with status ${status}; rework is required before Debug progression.`;
|
|
444
|
+
return {
|
|
445
|
+
content: [
|
|
446
|
+
{
|
|
447
|
+
type: "text",
|
|
448
|
+
text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
|
|
449
|
+
}
|
|
450
|
+
],
|
|
451
|
+
structuredContent: {
|
|
452
|
+
...reference,
|
|
453
|
+
gate: {
|
|
454
|
+
phase: completedGate,
|
|
455
|
+
status,
|
|
456
|
+
allowed: pass,
|
|
457
|
+
reason
|
|
458
|
+
},
|
|
459
|
+
nextPhase,
|
|
460
|
+
nextInstruction
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
313
464
|
async callQaGateTool(toolName, requestedPhase, input) {
|
|
314
465
|
const requestedGate = qaGateFromToolName(toolName);
|
|
315
466
|
if (requestedPhase !== "qa" || !requestedGate) {
|
|
@@ -354,6 +505,9 @@ export class CompletionToolServerSession {
|
|
|
354
505
|
this.completed = true;
|
|
355
506
|
return "sign_off_ready";
|
|
356
507
|
}
|
|
508
|
+
if (isDebugCompletionPhase(phase)) {
|
|
509
|
+
return nextDebugPhaseAfter(phase) ?? "qa_ready";
|
|
510
|
+
}
|
|
357
511
|
const next = nextPhaseAfter(phase);
|
|
358
512
|
if (!this.config.developCycle) {
|
|
359
513
|
return next ?? "dev_test_ready";
|
|
@@ -375,6 +529,16 @@ export class CompletionToolServerSession {
|
|
|
375
529
|
this.completed = true;
|
|
376
530
|
return null;
|
|
377
531
|
}
|
|
532
|
+
advanceAfterDebugGate(gate) {
|
|
533
|
+
const index = debugCompletionPhaseOrder.indexOf(gate);
|
|
534
|
+
const next = index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
|
|
535
|
+
if (next) {
|
|
536
|
+
this.currentPhase = next;
|
|
537
|
+
return next;
|
|
538
|
+
}
|
|
539
|
+
this.completed = true;
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
378
542
|
}
|
|
379
543
|
export async function writeCompletionArtifact(config, toolName, input) {
|
|
380
544
|
const phase = phaseFromToolName(toolName);
|
|
@@ -387,7 +551,10 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
387
551
|
}
|
|
388
552
|
const payload = asRecord(input, "completion payload");
|
|
389
553
|
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
390
|
-
|
|
554
|
+
let validationPayload = { ...payload, status };
|
|
555
|
+
if (config.qaCycle && qaGate === "qa_verdict") {
|
|
556
|
+
validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
|
|
557
|
+
}
|
|
391
558
|
validatePayloadShape(phase, validationPayload);
|
|
392
559
|
validateOptionalWorkItemId(validationPayload, config.workItemId);
|
|
393
560
|
const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
|
|
@@ -398,6 +565,9 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
398
565
|
if (phase === "qa") {
|
|
399
566
|
return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
400
567
|
}
|
|
568
|
+
if (phase === "architect") {
|
|
569
|
+
return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
570
|
+
}
|
|
401
571
|
const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
|
|
402
572
|
phase,
|
|
403
573
|
workItemId: config.workItemId,
|
|
@@ -430,6 +600,48 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
430
600
|
producedBy
|
|
431
601
|
};
|
|
432
602
|
}
|
|
603
|
+
async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
|
|
604
|
+
const ownerFields = artifactOwnerFields(config);
|
|
605
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
606
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
607
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
608
|
+
const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
|
|
609
|
+
const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
|
|
610
|
+
const verdictPath = path.join(artifactRoot, "architect.verdict.json");
|
|
611
|
+
const reportPath = path.join(artifactRoot, "architect.report.md");
|
|
612
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
613
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
614
|
+
const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
|
|
615
|
+
const artifactWithoutHash = normalizeArtifactPayload(payload, {
|
|
616
|
+
phase: "architect",
|
|
617
|
+
workItemId: config.workItemId,
|
|
618
|
+
ownerType: config.ownerType,
|
|
619
|
+
ownerId: config.ownerId,
|
|
620
|
+
producedBy,
|
|
621
|
+
status
|
|
622
|
+
});
|
|
623
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
624
|
+
const verdict = {
|
|
625
|
+
...artifactWithoutHash,
|
|
626
|
+
content_hash: contentHash
|
|
627
|
+
};
|
|
628
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
629
|
+
await writeFileAtomic(implementationPlanPath, jsonText(implementationPlan));
|
|
630
|
+
await writeFileAtomic(qaPlanPath, jsonText(qaPlan));
|
|
631
|
+
await writeFileAtomic(verdictPath, jsonText(verdict));
|
|
632
|
+
await writeFileAtomic(reportPath, `${report.trim()}\n`);
|
|
633
|
+
return {
|
|
634
|
+
phase: "architect",
|
|
635
|
+
workItemId: config.workItemId,
|
|
636
|
+
...artifactReferenceOwnerFields(config),
|
|
637
|
+
repoPath: path.resolve(config.worktreePath),
|
|
638
|
+
path: verdictPath,
|
|
639
|
+
reportPath,
|
|
640
|
+
contentHash,
|
|
641
|
+
status,
|
|
642
|
+
producedBy
|
|
643
|
+
};
|
|
644
|
+
}
|
|
433
645
|
async function writeQaGateCompletionArtifact(config, gate, input) {
|
|
434
646
|
const payload = asRecord(input, "QA gate completion payload");
|
|
435
647
|
const status = normalizeQaGateStatus(requiredString(payload, "status"));
|
|
@@ -585,6 +797,133 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
585
797
|
producedBy
|
|
586
798
|
};
|
|
587
799
|
}
|
|
800
|
+
async function synthesizeQaCycleVerdictPayload(config, payload, status) {
|
|
801
|
+
const qaPlan = await readQaGateArtifact(config, "qa_plan");
|
|
802
|
+
const qaRegression = await readQaGateArtifact(config, "qa_regression");
|
|
803
|
+
const qaApi = await readQaGateArtifact(config, "qa_api");
|
|
804
|
+
const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
|
|
805
|
+
const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
|
|
806
|
+
const qaFindings = await readQaGateArtifact(config, "qa_findings");
|
|
807
|
+
const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
|
|
808
|
+
const payloadEvidence = qaEvidenceSource(payload);
|
|
809
|
+
const merged = { ...payload };
|
|
810
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
811
|
+
if (checklist.length === 0) {
|
|
812
|
+
const plannedChecklist = qaStringArray(qaPlan, "checklist");
|
|
813
|
+
merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
|
|
814
|
+
}
|
|
815
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
816
|
+
if (testedAcceptanceCriteria.length === 0) {
|
|
817
|
+
const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
|
|
818
|
+
const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
|
|
819
|
+
const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
|
|
820
|
+
if (acceptanceCriteria.length > 0) {
|
|
821
|
+
merged.tested_acceptance_criteria = acceptanceCriteria;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const payloadCommands = qaArray(payload, "commands", payloadEvidence);
|
|
825
|
+
const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
|
|
826
|
+
const inheritedDiagnosticCommandNotes = status === "pass"
|
|
827
|
+
? qaNonPassingCommandNotes(inheritedCommands)
|
|
828
|
+
: [];
|
|
829
|
+
merged.commands = dedupeUnknownArray([
|
|
830
|
+
...payloadCommands,
|
|
831
|
+
...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
|
|
832
|
+
]);
|
|
833
|
+
merged.browser_checks = dedupeUnknownArray([
|
|
834
|
+
...qaArray(payload, "browser_checks", payloadEvidence),
|
|
835
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
|
|
836
|
+
]);
|
|
837
|
+
merged.sidecars = dedupeUnknownArray([
|
|
838
|
+
...qaArray(payload, "sidecars", payloadEvidence),
|
|
839
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
|
|
840
|
+
]);
|
|
841
|
+
const notes = dedupeStrings([
|
|
842
|
+
optionalString(payload, "notes"),
|
|
843
|
+
optionalString(payloadEvidence, "notes"),
|
|
844
|
+
...evidenceGates.map((gate) => optionalString(gate, "notes")),
|
|
845
|
+
...inheritedDiagnosticCommandNotes
|
|
846
|
+
]);
|
|
847
|
+
if (notes.length > 0) {
|
|
848
|
+
merged.notes = notes.join("\n");
|
|
849
|
+
}
|
|
850
|
+
merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
|
|
851
|
+
merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
|
|
852
|
+
return merged;
|
|
853
|
+
}
|
|
854
|
+
async function readQaGateArtifact(config, gate) {
|
|
855
|
+
const ownerFields = artifactOwnerFields(config);
|
|
856
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
857
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
858
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
859
|
+
const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
|
|
860
|
+
const content = await readOptionalFile(artifactPath);
|
|
861
|
+
return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
|
|
862
|
+
}
|
|
863
|
+
function dedupeUnknownArray(values) {
|
|
864
|
+
const seen = new Set();
|
|
865
|
+
const deduped = [];
|
|
866
|
+
for (const value of values) {
|
|
867
|
+
const key = stableStringify(value);
|
|
868
|
+
if (seen.has(key)) {
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
seen.add(key);
|
|
872
|
+
deduped.push(value);
|
|
873
|
+
}
|
|
874
|
+
return deduped;
|
|
875
|
+
}
|
|
876
|
+
function isQaPassingCommandEvidence(value) {
|
|
877
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
const record = value;
|
|
881
|
+
return record.exit_code === 0 && normalizedString(record.status) === "pass";
|
|
882
|
+
}
|
|
883
|
+
function qaNonPassingCommandNotes(values) {
|
|
884
|
+
return values
|
|
885
|
+
.filter((value) => !isQaPassingCommandEvidence(value))
|
|
886
|
+
.map((value, index) => {
|
|
887
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
888
|
+
return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
|
|
889
|
+
}
|
|
890
|
+
const record = value;
|
|
891
|
+
const command = optionalString(record, "command") ?? "<missing command>";
|
|
892
|
+
const status = optionalString(record, "status") ?? "<missing status>";
|
|
893
|
+
const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
|
|
894
|
+
? String(record.exit_code)
|
|
895
|
+
: "<missing exit_code>";
|
|
896
|
+
const summary = optionalString(record, "summary");
|
|
897
|
+
return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
function mergeQaObjectArraysById(primary, fallback) {
|
|
901
|
+
const seen = new Set();
|
|
902
|
+
const merged = [];
|
|
903
|
+
for (const value of [...primary, ...fallback]) {
|
|
904
|
+
const id = optionalString(value, "id");
|
|
905
|
+
const key = id ? `id:${id}` : stableStringify(value);
|
|
906
|
+
if (seen.has(key)) {
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
seen.add(key);
|
|
910
|
+
merged.push(value);
|
|
911
|
+
}
|
|
912
|
+
return merged;
|
|
913
|
+
}
|
|
914
|
+
function dedupeStrings(values) {
|
|
915
|
+
const seen = new Set();
|
|
916
|
+
const deduped = [];
|
|
917
|
+
for (const value of values) {
|
|
918
|
+
const trimmed = value?.trim();
|
|
919
|
+
if (!trimmed || seen.has(trimmed)) {
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
seen.add(trimmed);
|
|
923
|
+
deduped.push(trimmed);
|
|
924
|
+
}
|
|
925
|
+
return deduped;
|
|
926
|
+
}
|
|
588
927
|
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
589
928
|
if (sidecars.length === 0) {
|
|
590
929
|
return [];
|
|
@@ -638,19 +977,28 @@ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, o
|
|
|
638
977
|
}
|
|
639
978
|
return validated;
|
|
640
979
|
}
|
|
641
|
-
export async function readCompletionArtifact(worktreePath, workItemId, phase) {
|
|
642
|
-
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
|
|
643
|
-
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
|
|
980
|
+
export async function readCompletionArtifact(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
981
|
+
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId);
|
|
982
|
+
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId);
|
|
644
983
|
const verdictJson = await readOptionalFile(artifactPath);
|
|
645
984
|
if (verdictJson === null) {
|
|
646
985
|
return null;
|
|
647
986
|
}
|
|
648
987
|
const parsed = parseJsonObject(verdictJson, artifactPath);
|
|
649
988
|
const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
|
|
989
|
+
const artifactRoot = path.dirname(artifactPath);
|
|
990
|
+
const implementationPlanJson = phase === "architect"
|
|
991
|
+
? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
|
|
992
|
+
: null;
|
|
993
|
+
const qaPlanJson = phase === "architect"
|
|
994
|
+
? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
|
|
995
|
+
: null;
|
|
650
996
|
return {
|
|
651
997
|
repoPath: path.resolve(worktreePath),
|
|
652
998
|
path: artifactPath,
|
|
653
999
|
verdictJson,
|
|
1000
|
+
implementationPlanJson,
|
|
1001
|
+
qaPlanJson,
|
|
654
1002
|
report,
|
|
655
1003
|
producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
|
|
656
1004
|
status: optionalString(parsed, "status") ?? "unknown",
|
|
@@ -687,6 +1035,8 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
687
1035
|
let producedBy;
|
|
688
1036
|
let developCycle = false;
|
|
689
1037
|
let qaCycle = false;
|
|
1038
|
+
let debugCycle = false;
|
|
1039
|
+
let architectCycle = false;
|
|
690
1040
|
for (let index = 1; index < argv.length; index += 1) {
|
|
691
1041
|
const arg = argv[index];
|
|
692
1042
|
if (arg === "--worktree") {
|
|
@@ -708,7 +1058,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
708
1058
|
const rawPhase = argv[++index];
|
|
709
1059
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
710
1060
|
if (!parsedPhase) {
|
|
711
|
-
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa." };
|
|
1061
|
+
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, architect, debug-repro, debug-root-cause, debug-fix, debug-regression." };
|
|
712
1062
|
}
|
|
713
1063
|
phase = parsedPhase;
|
|
714
1064
|
}
|
|
@@ -721,6 +1071,12 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
721
1071
|
else if (arg === "--qa-cycle") {
|
|
722
1072
|
qaCycle = true;
|
|
723
1073
|
}
|
|
1074
|
+
else if (arg === "--debug-cycle") {
|
|
1075
|
+
debugCycle = true;
|
|
1076
|
+
}
|
|
1077
|
+
else if (arg === "--architect-cycle") {
|
|
1078
|
+
architectCycle = true;
|
|
1079
|
+
}
|
|
724
1080
|
else {
|
|
725
1081
|
return { ok: false, error: `Unknown mcp argument: ${arg}` };
|
|
726
1082
|
}
|
|
@@ -728,12 +1084,21 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
728
1084
|
if (!worktreePath?.trim() || !workItemId?.trim()) {
|
|
729
1085
|
return { ok: false, error: "mcp requires --worktree and --work-item-id." };
|
|
730
1086
|
}
|
|
731
|
-
if (developCycle
|
|
1087
|
+
if ([developCycle, qaCycle, debugCycle, architectCycle].filter(Boolean).length > 1) {
|
|
732
1088
|
return { ok: false, error: "mcp accepts only one cycle mode." };
|
|
733
1089
|
}
|
|
1090
|
+
if (developCycle && !isDevelopCompletionPhase(phase)) {
|
|
1091
|
+
return { ok: false, error: "mcp --develop-cycle requires --phase implementation, self-review, or validation." };
|
|
1092
|
+
}
|
|
734
1093
|
if (qaCycle && phase !== "qa") {
|
|
735
1094
|
return { ok: false, error: "mcp --qa-cycle requires --phase qa." };
|
|
736
1095
|
}
|
|
1096
|
+
if (debugCycle && !isDebugCompletionPhase(phase)) {
|
|
1097
|
+
return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
|
|
1098
|
+
}
|
|
1099
|
+
if (architectCycle && phase !== "architect") {
|
|
1100
|
+
return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
|
|
1101
|
+
}
|
|
737
1102
|
return {
|
|
738
1103
|
ok: true,
|
|
739
1104
|
config: {
|
|
@@ -745,7 +1110,9 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
745
1110
|
producedBy,
|
|
746
1111
|
redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
|
|
747
1112
|
developCycle,
|
|
748
|
-
qaCycle
|
|
1113
|
+
qaCycle,
|
|
1114
|
+
debugCycle,
|
|
1115
|
+
architectCycle
|
|
749
1116
|
}
|
|
750
1117
|
};
|
|
751
1118
|
}
|
|
@@ -848,9 +1215,16 @@ function toolDefinition(name, phase, qaGate = null) {
|
|
|
848
1215
|
};
|
|
849
1216
|
}
|
|
850
1217
|
function nextPhaseAfter(phase) {
|
|
1218
|
+
if (!isDevelopCompletionPhase(phase)) {
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
851
1221
|
const index = completionPhaseOrder.indexOf(phase);
|
|
852
1222
|
return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
|
|
853
1223
|
}
|
|
1224
|
+
function nextDebugPhaseAfter(phase) {
|
|
1225
|
+
const index = debugCompletionPhaseOrder.indexOf(phase);
|
|
1226
|
+
return index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
|
|
1227
|
+
}
|
|
854
1228
|
function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
855
1229
|
if (completedPhase === "qa") {
|
|
856
1230
|
if (status === "blocked") {
|
|
@@ -864,6 +1238,10 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
|
864
1238
|
if (status !== "pass") {
|
|
865
1239
|
return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
|
|
866
1240
|
}
|
|
1241
|
+
if (isDebugCompletionPhase(completedPhase)) {
|
|
1242
|
+
const debugNext = isDebugCompletionPhase(nextPhase) ? nextPhase : null;
|
|
1243
|
+
return completionToolNextInstructionForDebugGate(completedPhase, status, debugNext);
|
|
1244
|
+
}
|
|
867
1245
|
if (nextPhase === "self_review") {
|
|
868
1246
|
return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
|
|
869
1247
|
}
|
|
@@ -891,6 +1269,21 @@ function completionToolNextInstructionForQaGate(completedGate, status, nextGate)
|
|
|
891
1269
|
: `${completedGate} accepted. Continue to ${nextGate}.`;
|
|
892
1270
|
return `${prefix} ${qaGateInstruction(nextGate)}`;
|
|
893
1271
|
}
|
|
1272
|
+
function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
|
|
1273
|
+
if (status !== "pass") {
|
|
1274
|
+
return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
|
|
1275
|
+
}
|
|
1276
|
+
if (nextGate === "debug_root_cause") {
|
|
1277
|
+
return "Debug repro accepted. Begin root-cause analysis: connect the observed failure to code evidence and call forge.complete_debug_root_cause.";
|
|
1278
|
+
}
|
|
1279
|
+
if (nextGate === "debug_fix") {
|
|
1280
|
+
return "Debug root cause accepted. Apply the smallest safe fix with regression coverage where practical, then call forge.complete_debug_fix.";
|
|
1281
|
+
}
|
|
1282
|
+
if (nextGate === "debug_regression") {
|
|
1283
|
+
return "Debug fix accepted. Prove the original repro is negative, run targeted regression checks, and call forge.complete_debug_regression.";
|
|
1284
|
+
}
|
|
1285
|
+
return "Debug regression accepted. Forge has the structured evidence needed to move this bug fix to QA.";
|
|
1286
|
+
}
|
|
894
1287
|
function qaGateInstruction(gate) {
|
|
895
1288
|
switch (gate) {
|
|
896
1289
|
case "qa_regression":
|
|
@@ -915,6 +1308,9 @@ function toolDescription(phase, qaGate = null) {
|
|
|
915
1308
|
? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
|
|
916
1309
|
: `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
|
|
917
1310
|
}
|
|
1311
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1312
|
+
return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
|
|
1313
|
+
}
|
|
918
1314
|
if (phase === "implementation") {
|
|
919
1315
|
return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
|
|
920
1316
|
}
|
|
@@ -924,6 +1320,9 @@ function toolDescription(phase, qaGate = null) {
|
|
|
924
1320
|
if (phase === "qa") {
|
|
925
1321
|
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.";
|
|
926
1322
|
}
|
|
1323
|
+
if (phase === "architect") {
|
|
1324
|
+
return "Complete Forge Architect planning by writing implementation.plan.json, qa.plan.json, architect.verdict.json, and architect.report.md with a pending-approval plan or explicit revision/blocker reasons.";
|
|
1325
|
+
}
|
|
927
1326
|
return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
|
|
928
1327
|
}
|
|
929
1328
|
function toolInputSchema(phase, qaGate = null) {
|
|
@@ -948,6 +1347,9 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
948
1347
|
if (qaGate && qaGate !== "qa_verdict") {
|
|
949
1348
|
return qaGateToolInputSchema(qaGate);
|
|
950
1349
|
}
|
|
1350
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1351
|
+
return debugToolInputSchema(phase, baseProperties);
|
|
1352
|
+
}
|
|
951
1353
|
if (phase === "implementation") {
|
|
952
1354
|
return objectToolSchema({
|
|
953
1355
|
...baseProperties,
|
|
@@ -970,7 +1372,7 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
970
1372
|
},
|
|
971
1373
|
tests_added_or_updated: {
|
|
972
1374
|
type: "array",
|
|
973
|
-
description: "Required for pass. Use an object explaining why no tests were needed for
|
|
1375
|
+
description: "Required for pass. Use an object explaining why no tests were needed for documentation/config-only work if empty would be ambiguous.",
|
|
974
1376
|
items: evidenceObjectSchema(["path", "summary"], {
|
|
975
1377
|
path: { type: "string" },
|
|
976
1378
|
summary: { type: "string" }
|
|
@@ -1001,6 +1403,11 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1001
1403
|
},
|
|
1002
1404
|
next_phase_context: {
|
|
1003
1405
|
description: "Required for pass. Context the self-review phase should carry forward."
|
|
1406
|
+
},
|
|
1407
|
+
resolved_findings: {
|
|
1408
|
+
type: "array",
|
|
1409
|
+
description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
|
|
1410
|
+
items: resolvedFindingSchema()
|
|
1004
1411
|
}
|
|
1005
1412
|
}, ["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.");
|
|
1006
1413
|
}
|
|
@@ -1136,6 +1543,83 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1136
1543
|
}
|
|
1137
1544
|
}, ["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.");
|
|
1138
1545
|
}
|
|
1546
|
+
if (phase === "architect") {
|
|
1547
|
+
return objectToolSchema({
|
|
1548
|
+
status: {
|
|
1549
|
+
type: "string",
|
|
1550
|
+
enum: ["pass", "needs_revision", "blocked"],
|
|
1551
|
+
description: "Use pass only for a complete pending-approval Architect plan. Use needs_revision for fixable story/design/repo gaps, or blocked for missing required context or decisions."
|
|
1552
|
+
},
|
|
1553
|
+
summary: { type: "string", description: "Concise Architect plan verdict summary." },
|
|
1554
|
+
implementation_plan: {
|
|
1555
|
+
type: "object",
|
|
1556
|
+
description: "Required for pass. Top-level fields inside this object must include schema_version, plan_id, phases, tasks, dependencies, and phase_gates.",
|
|
1557
|
+
additionalProperties: true,
|
|
1558
|
+
properties: {
|
|
1559
|
+
schema_version: { type: "number", enum: [1] },
|
|
1560
|
+
plan_id: { type: "string" },
|
|
1561
|
+
phases: {
|
|
1562
|
+
type: "array",
|
|
1563
|
+
description: "Required top-level implementation_plan.phases array.",
|
|
1564
|
+
items: { type: "object", additionalProperties: true }
|
|
1565
|
+
},
|
|
1566
|
+
tasks: {
|
|
1567
|
+
type: "array",
|
|
1568
|
+
description: "Required Develop child task list. Each task must include id and a non-empty tests array, and must require a focused repo/product implementation change. Do not put parent-story QA/sign-off, child-count/materialization inventory, parent plan artifact checks, or final acceptance proof here; those belong in qa_plan.final_qa.",
|
|
1569
|
+
items: { type: "object", additionalProperties: true }
|
|
1570
|
+
},
|
|
1571
|
+
dependencies: {
|
|
1572
|
+
type: "array",
|
|
1573
|
+
description: "Required dependency edge array. Use [] when no task dependencies exist. Each edge should use before and after task ids; from/to are accepted only as aliases and normalized before writing artifacts.",
|
|
1574
|
+
items: {
|
|
1575
|
+
type: "object",
|
|
1576
|
+
additionalProperties: true,
|
|
1577
|
+
properties: {
|
|
1578
|
+
id: { type: "string" },
|
|
1579
|
+
before: { type: "string", description: "Task id that must complete first." },
|
|
1580
|
+
after: { type: "string", description: "Task id that depends on before." },
|
|
1581
|
+
from: { type: "string", description: "Alias for before." },
|
|
1582
|
+
to: { type: "string", description: "Alias for after." },
|
|
1583
|
+
type: { type: "string" },
|
|
1584
|
+
rationale: { type: "string" }
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
},
|
|
1588
|
+
phase_gates: {
|
|
1589
|
+
type: "array",
|
|
1590
|
+
description: "Required non-empty phase gate array.",
|
|
1591
|
+
items: { type: "object", additionalProperties: true }
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
},
|
|
1595
|
+
qa_plan: {
|
|
1596
|
+
type: "object",
|
|
1597
|
+
description: "Required for pass. Must include schema_version: 1 and non-empty final_qa coverage.",
|
|
1598
|
+
additionalProperties: true,
|
|
1599
|
+
properties: {
|
|
1600
|
+
schema_version: { type: "number", enum: [1] },
|
|
1601
|
+
final_qa: {
|
|
1602
|
+
type: "array",
|
|
1603
|
+
description: "Required non-empty final QA coverage array.",
|
|
1604
|
+
items: { type: "object", additionalProperties: true }
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
},
|
|
1608
|
+
report_markdown: { type: "string", description: "Human-readable Architect report. Use report as an alias only when the client cannot send report_markdown." },
|
|
1609
|
+
report: { type: "string", description: "Alias for report_markdown." },
|
|
1610
|
+
blockers: baseProperties.blockers,
|
|
1611
|
+
revision_questions: {
|
|
1612
|
+
type: "array",
|
|
1613
|
+
description: "Required for needs_revision when blockers are not used.",
|
|
1614
|
+
items: { type: "string" }
|
|
1615
|
+
},
|
|
1616
|
+
revision_reasons: {
|
|
1617
|
+
type: "array",
|
|
1618
|
+
description: "Concrete reasons that prevent pass status.",
|
|
1619
|
+
items: { type: "string" }
|
|
1620
|
+
}
|
|
1621
|
+
}, ["status", "summary"], "For status pass, include implementation_plan and qa_plan at the top level of the MCP tool arguments. implementation_plan.phases must be a top-level array inside implementation_plan, not nested under another object.");
|
|
1622
|
+
}
|
|
1139
1623
|
return objectToolSchema({
|
|
1140
1624
|
...baseProperties,
|
|
1141
1625
|
commands: {
|
|
@@ -1160,6 +1644,84 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1160
1644
|
ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
|
|
1161
1645
|
}, ["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.");
|
|
1162
1646
|
}
|
|
1647
|
+
function debugToolInputSchema(phase, baseProperties) {
|
|
1648
|
+
if (phase === "debug_repro") {
|
|
1649
|
+
return objectToolSchema({
|
|
1650
|
+
...baseProperties,
|
|
1651
|
+
repro_steps: {
|
|
1652
|
+
type: "array",
|
|
1653
|
+
description: "Required for pass. Minimal reproduction or characterization steps.",
|
|
1654
|
+
items: { type: "string" }
|
|
1655
|
+
},
|
|
1656
|
+
observed: { type: "string", description: "Observed failure behavior." },
|
|
1657
|
+
expected: { type: "string", description: "Expected behavior." },
|
|
1658
|
+
evidence: {
|
|
1659
|
+
type: "array",
|
|
1660
|
+
description: "Required for pass. Concrete logs, commands, screenshots, file refs, or characterization notes.",
|
|
1661
|
+
items: { type: "object", additionalProperties: true }
|
|
1662
|
+
}
|
|
1663
|
+
}, ["status", "summary"], "For status pass, include repro_steps, observed, expected, and evidence.");
|
|
1664
|
+
}
|
|
1665
|
+
if (phase === "debug_root_cause") {
|
|
1666
|
+
return objectToolSchema({
|
|
1667
|
+
...baseProperties,
|
|
1668
|
+
root_cause: { type: "string", description: "Likely root cause tied to code or config evidence." },
|
|
1669
|
+
evidence: {
|
|
1670
|
+
type: "array",
|
|
1671
|
+
description: "Required for pass. Concrete code/log/test evidence supporting the root cause.",
|
|
1672
|
+
items: { type: "object", additionalProperties: true }
|
|
1673
|
+
},
|
|
1674
|
+
eliminated_alternatives: {
|
|
1675
|
+
type: "array",
|
|
1676
|
+
description: "Required for pass. Plausible alternatives checked or ruled out.",
|
|
1677
|
+
items: { type: "object", additionalProperties: true }
|
|
1678
|
+
},
|
|
1679
|
+
risk_area: { type: "string", description: "Subsystem or behavior most at risk from the fix." }
|
|
1680
|
+
}, ["status", "summary"], "For status pass, include root_cause, evidence, eliminated_alternatives, and risk_area.");
|
|
1681
|
+
}
|
|
1682
|
+
if (phase === "debug_fix") {
|
|
1683
|
+
return objectToolSchema({
|
|
1684
|
+
...baseProperties,
|
|
1685
|
+
changed_files: {
|
|
1686
|
+
type: "array",
|
|
1687
|
+
description: "Required for pass. Files changed by the bug fix.",
|
|
1688
|
+
items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
|
|
1689
|
+
},
|
|
1690
|
+
tests_added_or_updated: {
|
|
1691
|
+
type: "array",
|
|
1692
|
+
description: "Required for pass. Regression tests or checks added/updated, or justify omission in remaining_risks.",
|
|
1693
|
+
items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
|
|
1694
|
+
},
|
|
1695
|
+
remaining_risks: {
|
|
1696
|
+
type: "array",
|
|
1697
|
+
description: "Required for pass, even when empty. Include why no regression coverage changed when tests_added_or_updated is empty.",
|
|
1698
|
+
items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
|
|
1699
|
+
},
|
|
1700
|
+
resolved_findings: {
|
|
1701
|
+
type: "array",
|
|
1702
|
+
description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
|
|
1703
|
+
items: resolvedFindingSchema()
|
|
1704
|
+
}
|
|
1705
|
+
}, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
|
|
1706
|
+
}
|
|
1707
|
+
return objectToolSchema({
|
|
1708
|
+
...baseProperties,
|
|
1709
|
+
commands: {
|
|
1710
|
+
type: "array",
|
|
1711
|
+
description: "Required for pass. Targeted regression/repro-negative commands with exit_code: 0 and status: pass.",
|
|
1712
|
+
items: commandEvidenceSchema()
|
|
1713
|
+
},
|
|
1714
|
+
required_regressions: {
|
|
1715
|
+
type: "array",
|
|
1716
|
+
description: "Required for pass. Repro-negative and regression coverage checklist.",
|
|
1717
|
+
items: { type: "string" }
|
|
1718
|
+
},
|
|
1719
|
+
build_status: { type: "string", description: "Required for pass. pass or not_applicable." },
|
|
1720
|
+
test_status: { type: "string", description: "Required for pass. pass or not_applicable." },
|
|
1721
|
+
repro_negative: { type: "boolean", description: "Required for pass and must be true after the original repro no longer fails." },
|
|
1722
|
+
qa_ready: { type: "boolean", description: "Required for pass and must be true." }
|
|
1723
|
+
}, ["status", "summary"], "For status pass, commands must include at least one passing deterministic debug regression check, repro_negative must be true, and qa_ready must be true.");
|
|
1724
|
+
}
|
|
1163
1725
|
function qaGateToolInputSchema(gate) {
|
|
1164
1726
|
const baseProperties = {
|
|
1165
1727
|
status: {
|
|
@@ -1245,6 +1807,29 @@ function commandEvidenceSchema() {
|
|
|
1245
1807
|
summary: { type: "string" }
|
|
1246
1808
|
});
|
|
1247
1809
|
}
|
|
1810
|
+
function resolvedFindingSchema() {
|
|
1811
|
+
return evidenceObjectSchema([
|
|
1812
|
+
"qa_id",
|
|
1813
|
+
"status",
|
|
1814
|
+
"summary",
|
|
1815
|
+
"evidence",
|
|
1816
|
+
"changed_files",
|
|
1817
|
+
"tests_added_or_updated",
|
|
1818
|
+
"regression_evidence"
|
|
1819
|
+
], {
|
|
1820
|
+
qa_id: { type: "string" },
|
|
1821
|
+
status: {
|
|
1822
|
+
type: "string",
|
|
1823
|
+
enum: [...resolvedFindingStatusValues],
|
|
1824
|
+
description: "Use resolved when fixed, disputed when the QA finding is incorrect/out-of-scope, or not_reproduced when retesting cannot reproduce it."
|
|
1825
|
+
},
|
|
1826
|
+
summary: { type: "string" },
|
|
1827
|
+
evidence: { description: "Concrete evidence for the resolution." },
|
|
1828
|
+
changed_files: { type: "array", description: "Required and non-empty when status is resolved." },
|
|
1829
|
+
tests_added_or_updated: { type: "array", description: "Required and non-empty when status is resolved." },
|
|
1830
|
+
regression_evidence: { description: "Concrete regression evidence for this QA finding." }
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1248
1833
|
function acceptanceCriteriaSchema() {
|
|
1249
1834
|
return {
|
|
1250
1835
|
type: "array",
|
|
@@ -1279,6 +1864,13 @@ function normalizeCompletionStatus(value, phase) {
|
|
|
1279
1864
|
}
|
|
1280
1865
|
return canonical;
|
|
1281
1866
|
}
|
|
1867
|
+
if (phase === "architect") {
|
|
1868
|
+
const canonical = normalized === "needsrevision" ? "needs_revision" : normalized;
|
|
1869
|
+
if (canonical !== "pass" && canonical !== "needs_revision" && canonical !== "blocked") {
|
|
1870
|
+
throw new Error("Architect status must be pass, needs_revision, or blocked.");
|
|
1871
|
+
}
|
|
1872
|
+
return canonical;
|
|
1873
|
+
}
|
|
1282
1874
|
const canonical = normalized === "blocked" || normalized === "needsrework"
|
|
1283
1875
|
? "needs_rework"
|
|
1284
1876
|
: normalized === "failed"
|
|
@@ -1306,11 +1898,19 @@ function validatePayloadShape(phase, payload) {
|
|
|
1306
1898
|
validateQaPayloadShape(payload, status);
|
|
1307
1899
|
return;
|
|
1308
1900
|
}
|
|
1901
|
+
if (phase === "architect") {
|
|
1902
|
+
validateArchitectPayloadShape(payload, status);
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1309
1905
|
const pass = status === "pass";
|
|
1310
1906
|
if (!pass) {
|
|
1311
1907
|
requireNonPassEvidence(payload);
|
|
1312
1908
|
return;
|
|
1313
1909
|
}
|
|
1910
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1911
|
+
validateDebugPayloadShape(phase, payload);
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1314
1914
|
if (phase === "implementation") {
|
|
1315
1915
|
requireArray(payload, "plan");
|
|
1316
1916
|
requireArray(payload, "changed_files");
|
|
@@ -1321,6 +1921,7 @@ function validatePayloadShape(phase, payload) {
|
|
|
1321
1921
|
requireField(payload, "next_phase_context");
|
|
1322
1922
|
requireImplementationEvidence(payload);
|
|
1323
1923
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
1924
|
+
validateResolvedFindings(payload);
|
|
1324
1925
|
return;
|
|
1325
1926
|
}
|
|
1326
1927
|
if (phase === "self_review") {
|
|
@@ -1348,6 +1949,52 @@ function validatePayloadShape(phase, payload) {
|
|
|
1348
1949
|
requirePassingCommands(payload);
|
|
1349
1950
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
1350
1951
|
}
|
|
1952
|
+
function validateDebugPayloadShape(phase, payload) {
|
|
1953
|
+
if (phase === "debug_repro") {
|
|
1954
|
+
requireArray(payload, "repro_steps");
|
|
1955
|
+
requireField(payload, "observed");
|
|
1956
|
+
requireField(payload, "expected");
|
|
1957
|
+
requireArray(payload, "evidence");
|
|
1958
|
+
requireNonEmptyArray(payload, "repro_steps");
|
|
1959
|
+
requireNonEmptyArray(payload, "evidence");
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
if (phase === "debug_root_cause") {
|
|
1963
|
+
requireField(payload, "root_cause");
|
|
1964
|
+
requireArray(payload, "evidence");
|
|
1965
|
+
requireArray(payload, "eliminated_alternatives");
|
|
1966
|
+
requireField(payload, "risk_area");
|
|
1967
|
+
requireNonEmptyArray(payload, "evidence");
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (phase === "debug_fix") {
|
|
1971
|
+
requireArray(payload, "changed_files");
|
|
1972
|
+
requireArray(payload, "tests_added_or_updated");
|
|
1973
|
+
requireArray(payload, "remaining_risks");
|
|
1974
|
+
requireNonEmptyArray(payload, "changed_files");
|
|
1975
|
+
if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
|
|
1976
|
+
throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
|
|
1977
|
+
}
|
|
1978
|
+
validateResolvedFindings(payload);
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
requireArray(payload, "commands");
|
|
1982
|
+
requireArray(payload, "required_regressions");
|
|
1983
|
+
requireField(payload, "build_status");
|
|
1984
|
+
requireField(payload, "test_status");
|
|
1985
|
+
requireBoolean(payload, "repro_negative");
|
|
1986
|
+
requireBoolean(payload, "qa_ready");
|
|
1987
|
+
if (payload.repro_negative !== true) {
|
|
1988
|
+
throw new Error("debug_regression pass requires repro_negative: true.");
|
|
1989
|
+
}
|
|
1990
|
+
if (payload.qa_ready !== true) {
|
|
1991
|
+
throw new Error("debug_regression pass requires qa_ready: true.");
|
|
1992
|
+
}
|
|
1993
|
+
requireAllowedStatus(payload, "build_status", ["pass", "not_applicable"]);
|
|
1994
|
+
requireAllowedStatus(payload, "test_status", ["pass", "not_applicable"]);
|
|
1995
|
+
requireNonEmptyArray(payload, "required_regressions");
|
|
1996
|
+
requirePassingCommands(payload);
|
|
1997
|
+
}
|
|
1351
1998
|
function validateQaGatePayloadShape(gate, payload, status) {
|
|
1352
1999
|
requireField(payload, "summary");
|
|
1353
2000
|
if (status === "skipped") {
|
|
@@ -1374,6 +2021,7 @@ function validateQaGatePayloadShape(gate, payload, status) {
|
|
|
1374
2021
|
if (commands.length === 0) {
|
|
1375
2022
|
throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
|
|
1376
2023
|
}
|
|
2024
|
+
validateQaGatePassingCommands(commands);
|
|
1377
2025
|
}
|
|
1378
2026
|
if (gate === "qa_manual_web" && status === "pass") {
|
|
1379
2027
|
const browserChecks = qaArray(payload, "browser_checks");
|
|
@@ -1383,6 +2031,297 @@ function validateQaGatePayloadShape(gate, payload, status) {
|
|
|
1383
2031
|
}
|
|
1384
2032
|
}
|
|
1385
2033
|
}
|
|
2034
|
+
function validateQaGatePassingCommands(commands) {
|
|
2035
|
+
for (const [index, command] of commands.entries()) {
|
|
2036
|
+
requireEvidenceRecord(command, `commands[${index}]`);
|
|
2037
|
+
const record = command;
|
|
2038
|
+
requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
|
|
2039
|
+
requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
|
|
2040
|
+
requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
|
|
2041
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
2042
|
+
throw new Error(`commands[${index}].exit_code is required.`);
|
|
2043
|
+
}
|
|
2044
|
+
if (record.exit_code !== 0) {
|
|
2045
|
+
throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
|
|
2046
|
+
}
|
|
2047
|
+
if (normalizedString(record.status) !== "pass") {
|
|
2048
|
+
throw new Error(`commands[${index}].status must be pass when QA gate status is pass.`);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
function validateArchitectPayloadShape(payload, status) {
|
|
2053
|
+
if (status !== "pass") {
|
|
2054
|
+
requireArchitectNonPassEvidence(payload);
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
2058
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
2059
|
+
requireNumericOne(implementationPlan, "schema_version", "implementation_plan.schema_version");
|
|
2060
|
+
requireNonEmptyStringRecord(implementationPlan, "plan_id", "implementation_plan.");
|
|
2061
|
+
requireArchitectArray(implementationPlan, "phases", payload);
|
|
2062
|
+
requireArchitectArray(implementationPlan, "tasks", payload);
|
|
2063
|
+
requireArchitectArray(implementationPlan, "dependencies", payload, { allowEmpty: true });
|
|
2064
|
+
requireArchitectArray(implementationPlan, "phase_gates", payload);
|
|
2065
|
+
if (arrayLength(implementationPlan, "tasks") === 0) {
|
|
2066
|
+
throw new Error("implementation_plan.tasks: expected non-empty array; correction: include at least one task with id and tests.");
|
|
2067
|
+
}
|
|
2068
|
+
const taskIds = new Set();
|
|
2069
|
+
for (const task of implementationPlan.tasks) {
|
|
2070
|
+
if (!task || typeof task !== "object" || Array.isArray(task)) {
|
|
2071
|
+
throw new Error("implementation_plan.tasks[]: expected object entries; correction: each task must be an object with id and tests.");
|
|
2072
|
+
}
|
|
2073
|
+
const taskRecord = task;
|
|
2074
|
+
requireNonEmptyStringRecord(taskRecord, "id", "implementation_plan.tasks[].");
|
|
2075
|
+
const taskId = optionalString(taskRecord, "id") ?? "";
|
|
2076
|
+
if (taskIds.has(taskId)) {
|
|
2077
|
+
throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
|
|
2078
|
+
}
|
|
2079
|
+
taskIds.add(taskId);
|
|
2080
|
+
validateArchitectTaskDevelopScope(taskRecord, taskId);
|
|
2081
|
+
requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
|
|
2082
|
+
if (arrayLength(taskRecord, "tests") === 0) {
|
|
2083
|
+
throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
validateAndNormalizeArchitectDependencies(implementationPlan, taskIds);
|
|
2087
|
+
requireNumericOne(qaPlan, "schema_version", "qa_plan.schema_version");
|
|
2088
|
+
requireArchitectArray(qaPlan, "final_qa", payload, { path: "qa_plan.final_qa" });
|
|
2089
|
+
if (arrayLength(qaPlan, "final_qa") === 0) {
|
|
2090
|
+
throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
function validateArchitectTaskDevelopScope(task, taskId) {
|
|
2094
|
+
const explicitScope = firstOptionalString(task, ["execution_scope", "scope", "task_scope", "task_type", "lane", "kind", "type"]);
|
|
2095
|
+
if (isStoryQaScope(explicitScope)) {
|
|
2096
|
+
throw new Error(`implementation_plan.tasks[${taskId}]: story QA/sign-off work must not be materialized as a Develop child task; correction: move this coverage to qa_plan.final_qa and keep implementation_plan.tasks limited to repo/product changes.`);
|
|
2097
|
+
}
|
|
2098
|
+
if (looksLikeStoryLevelVerificationTask(architectTaskScopeText(task))) {
|
|
2099
|
+
throw new Error(`implementation_plan.tasks[${taskId}]: parent-story materialization or sign-off verification belongs in qa_plan.final_qa, not implementation_plan.tasks; correction: keep child tasks focused on implementation work and put child-count/parent-plan artifact checks in final QA coverage.`);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
function firstOptionalString(record, keys) {
|
|
2103
|
+
for (const key of keys) {
|
|
2104
|
+
const value = optionalString(record, key);
|
|
2105
|
+
if (value) {
|
|
2106
|
+
return value;
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
return null;
|
|
2110
|
+
}
|
|
2111
|
+
function isStoryQaScope(value) {
|
|
2112
|
+
if (!value) {
|
|
2113
|
+
return false;
|
|
2114
|
+
}
|
|
2115
|
+
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
2116
|
+
return [
|
|
2117
|
+
"qa",
|
|
2118
|
+
"story_qa",
|
|
2119
|
+
"final_qa",
|
|
2120
|
+
"sign_off",
|
|
2121
|
+
"signoff",
|
|
2122
|
+
"story_sign_off",
|
|
2123
|
+
"story_verification",
|
|
2124
|
+
"story_validation"
|
|
2125
|
+
].includes(normalized);
|
|
2126
|
+
}
|
|
2127
|
+
function architectTaskScopeText(task) {
|
|
2128
|
+
const parts = [];
|
|
2129
|
+
for (const key of ["id", "title", "summary", "phase", "phase_id"]) {
|
|
2130
|
+
appendOptionalString(parts, task, key);
|
|
2131
|
+
}
|
|
2132
|
+
for (const key of ["acceptance_criteria", "expected_paths", "touched_surfaces", "implementation_notes"]) {
|
|
2133
|
+
appendOptionalStringArray(parts, task, key);
|
|
2134
|
+
}
|
|
2135
|
+
const tests = task.tests;
|
|
2136
|
+
if (Array.isArray(tests)) {
|
|
2137
|
+
for (const test of tests) {
|
|
2138
|
+
if (!test || typeof test !== "object" || Array.isArray(test)) {
|
|
2139
|
+
continue;
|
|
2140
|
+
}
|
|
2141
|
+
const record = test;
|
|
2142
|
+
for (const key of ["id", "command", "purpose", "target", "expected"]) {
|
|
2143
|
+
appendOptionalString(parts, record, key);
|
|
2144
|
+
}
|
|
2145
|
+
appendOptionalStringArray(parts, record, "steps");
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
return parts.join(" ");
|
|
2149
|
+
}
|
|
2150
|
+
function appendOptionalString(parts, record, key) {
|
|
2151
|
+
const value = optionalString(record, key);
|
|
2152
|
+
if (value) {
|
|
2153
|
+
parts.push(value);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
function appendOptionalStringArray(parts, record, key) {
|
|
2157
|
+
const value = record[key];
|
|
2158
|
+
if (!Array.isArray(value)) {
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
for (const entry of value) {
|
|
2162
|
+
if (typeof entry === "string" && entry.trim().length > 0) {
|
|
2163
|
+
parts.push(entry.trim());
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
function looksLikeStoryLevelVerificationTask(text) {
|
|
2168
|
+
if (!text.trim()) {
|
|
2169
|
+
return false;
|
|
2170
|
+
}
|
|
2171
|
+
const normalized = text.toLowerCase();
|
|
2172
|
+
const verificationWords = [
|
|
2173
|
+
"verify",
|
|
2174
|
+
"verification",
|
|
2175
|
+
"validate",
|
|
2176
|
+
"validation",
|
|
2177
|
+
"inspect",
|
|
2178
|
+
"audit",
|
|
2179
|
+
"check",
|
|
2180
|
+
"confirm",
|
|
2181
|
+
"prove",
|
|
2182
|
+
"evidence",
|
|
2183
|
+
"sign-off",
|
|
2184
|
+
"sign off"
|
|
2185
|
+
];
|
|
2186
|
+
if (!verificationWords.some((word) => normalized.includes(word))) {
|
|
2187
|
+
return false;
|
|
2188
|
+
}
|
|
2189
|
+
return [
|
|
2190
|
+
"materialization",
|
|
2191
|
+
"materialized child",
|
|
2192
|
+
"child inventory",
|
|
2193
|
+
"child work-item",
|
|
2194
|
+
"child work item",
|
|
2195
|
+
"created task count",
|
|
2196
|
+
"created_task_count",
|
|
2197
|
+
"parent plan",
|
|
2198
|
+
"parent story",
|
|
2199
|
+
"story sign-off",
|
|
2200
|
+
"story sign off",
|
|
2201
|
+
".forge/work-items",
|
|
2202
|
+
".forge\\work-items",
|
|
2203
|
+
".forge/stories",
|
|
2204
|
+
".forge\\stories",
|
|
2205
|
+
"implementation.plan.json",
|
|
2206
|
+
"qa.plan.json",
|
|
2207
|
+
"architect plan artifact",
|
|
2208
|
+
"child spec"
|
|
2209
|
+
].some((needle) => normalized.includes(needle));
|
|
2210
|
+
}
|
|
2211
|
+
function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
|
|
2212
|
+
const dependencies = implementationPlan.dependencies;
|
|
2213
|
+
const edges = new Map();
|
|
2214
|
+
for (const taskId of taskIds) {
|
|
2215
|
+
edges.set(taskId, []);
|
|
2216
|
+
}
|
|
2217
|
+
const normalizedDependencies = [];
|
|
2218
|
+
for (const [index, dependency] of dependencies.entries()) {
|
|
2219
|
+
if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
|
|
2220
|
+
throw new Error("implementation_plan.dependencies[]: expected object entries; correction: each dependency must be an object with before and after task ids.");
|
|
2221
|
+
}
|
|
2222
|
+
const record = dependency;
|
|
2223
|
+
const before = architectDependencyEndpoint(record, index, "before", "from");
|
|
2224
|
+
const after = architectDependencyEndpoint(record, index, "after", "to");
|
|
2225
|
+
if (!taskIds.has(before) || !taskIds.has(after)) {
|
|
2226
|
+
throw new Error("implementation_plan.dependencies: dependency references an unknown task id.");
|
|
2227
|
+
}
|
|
2228
|
+
const normalized = stripUndefined({
|
|
2229
|
+
...record,
|
|
2230
|
+
before,
|
|
2231
|
+
after,
|
|
2232
|
+
from: undefined,
|
|
2233
|
+
to: undefined
|
|
2234
|
+
});
|
|
2235
|
+
normalizedDependencies.push(normalized);
|
|
2236
|
+
edges.get(before)?.push(after);
|
|
2237
|
+
}
|
|
2238
|
+
implementationPlan.dependencies = normalizedDependencies;
|
|
2239
|
+
if (hasArchitectDependencyCycle(edges)) {
|
|
2240
|
+
throw new Error("implementation_plan.dependencies: expected acyclic graph; correction: remove or revise cyclic task dependencies.");
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
function architectDependencyEndpoint(dependency, index, canonicalKey, aliasKey) {
|
|
2244
|
+
const canonical = optionalString(dependency, canonicalKey);
|
|
2245
|
+
const alias = optionalString(dependency, aliasKey);
|
|
2246
|
+
if (canonical && alias && canonical !== alias) {
|
|
2247
|
+
throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: conflicts with ${aliasKey}; correction: use one task id for ${canonicalKey}.`);
|
|
2248
|
+
}
|
|
2249
|
+
const value = canonical ?? alias;
|
|
2250
|
+
if (!value) {
|
|
2251
|
+
throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: expected string; correction: use before and after task ids for each dependency.`);
|
|
2252
|
+
}
|
|
2253
|
+
return value;
|
|
2254
|
+
}
|
|
2255
|
+
function hasArchitectDependencyCycle(edges) {
|
|
2256
|
+
const visiting = new Set();
|
|
2257
|
+
const visited = new Set();
|
|
2258
|
+
const visit = (node) => {
|
|
2259
|
+
if (visited.has(node)) {
|
|
2260
|
+
return false;
|
|
2261
|
+
}
|
|
2262
|
+
if (visiting.has(node)) {
|
|
2263
|
+
return true;
|
|
2264
|
+
}
|
|
2265
|
+
visiting.add(node);
|
|
2266
|
+
for (const next of edges.get(node) ?? []) {
|
|
2267
|
+
if (visit(next)) {
|
|
2268
|
+
return true;
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
visiting.delete(node);
|
|
2272
|
+
visited.add(node);
|
|
2273
|
+
return false;
|
|
2274
|
+
};
|
|
2275
|
+
for (const node of edges.keys()) {
|
|
2276
|
+
if (visit(node)) {
|
|
2277
|
+
return true;
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
return false;
|
|
2281
|
+
}
|
|
2282
|
+
function requireArchitectNonPassEvidence(payload) {
|
|
2283
|
+
const evidenceKeys = ["blockers", "revision_questions", "revision_reasons", "known_gaps", "findings", "remaining_risks", "failures"];
|
|
2284
|
+
const hasEvidence = evidenceKeys.some((key) => {
|
|
2285
|
+
const value = payload[key];
|
|
2286
|
+
if (typeof value === "string") {
|
|
2287
|
+
return value.trim().length > 0;
|
|
2288
|
+
}
|
|
2289
|
+
if (Array.isArray(value)) {
|
|
2290
|
+
return value.length > 0;
|
|
2291
|
+
}
|
|
2292
|
+
return Boolean(value && typeof value === "object");
|
|
2293
|
+
});
|
|
2294
|
+
if (!hasEvidence) {
|
|
2295
|
+
throw new Error("status: non-pass Architect completion requires blockers, revision_questions, revision_reasons, findings, failures, or known_gaps; correction: explain why the plan cannot pass.");
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
function requireArchitectArray(source, key, rootPayload, options = {}) {
|
|
2299
|
+
const pathLabel = options.path ?? `implementation_plan.${key}`;
|
|
2300
|
+
if (!Array.isArray(source[key])) {
|
|
2301
|
+
const nestedHint = architectNestedArrayHint(rootPayload, key);
|
|
2302
|
+
throw new Error(`${pathLabel}: expected array${key === "phases" ? " as a top-level field inside implementation_plan" : ""}; correction: ${nestedHint ?? `set ${pathLabel} to an array${options.allowEmpty ? " (use [] when none exist)" : " with at least one entry"}.`}`);
|
|
2303
|
+
}
|
|
2304
|
+
if (!options.allowEmpty && arrayLength(source, key) === 0) {
|
|
2305
|
+
throw new Error(`${pathLabel}: expected non-empty array; correction: include at least one entry.`);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
function architectNestedArrayHint(rootPayload, key) {
|
|
2309
|
+
if (Array.isArray(rootPayload[key])) {
|
|
2310
|
+
return `move top-level ${key} into implementation_plan.${key}.`;
|
|
2311
|
+
}
|
|
2312
|
+
const implementationPlan = rootPayload.implementation_plan;
|
|
2313
|
+
if (!implementationPlan || typeof implementationPlan !== "object" || Array.isArray(implementationPlan)) {
|
|
2314
|
+
return null;
|
|
2315
|
+
}
|
|
2316
|
+
const plan = implementationPlan;
|
|
2317
|
+
for (const container of ["plan", "dag", "implementation_dag", "implementationPlan", "implementation_plan"]) {
|
|
2318
|
+
const nested = plan[container];
|
|
2319
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested) && Array.isArray(nested[key])) {
|
|
2320
|
+
return `move implementation_plan.${container}.${key} to implementation_plan.${key}.`;
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
return null;
|
|
2324
|
+
}
|
|
1386
2325
|
function validateQaPayloadShape(payload, status) {
|
|
1387
2326
|
const evidenceSource = qaEvidenceSource(payload);
|
|
1388
2327
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
@@ -1571,6 +2510,10 @@ ${summary}
|
|
|
1571
2510
|
}
|
|
1572
2511
|
function parseStrictCompletionPhase(value) {
|
|
1573
2512
|
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
2513
|
+
const debugPhase = normalizeDebugCompletionPhase(normalized);
|
|
2514
|
+
if (debugPhase) {
|
|
2515
|
+
return debugPhase;
|
|
2516
|
+
}
|
|
1574
2517
|
if (normalized === "implementation" || normalized === "implement" || normalized === "develop") {
|
|
1575
2518
|
return "implementation";
|
|
1576
2519
|
}
|
|
@@ -1583,8 +2526,48 @@ function parseStrictCompletionPhase(value) {
|
|
|
1583
2526
|
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
1584
2527
|
return "qa";
|
|
1585
2528
|
}
|
|
2529
|
+
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
2530
|
+
return "architect";
|
|
2531
|
+
}
|
|
1586
2532
|
return null;
|
|
1587
2533
|
}
|
|
2534
|
+
function normalizeDebugCompletionPhase(value) {
|
|
2535
|
+
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
2536
|
+
switch (normalized) {
|
|
2537
|
+
case "debug_repro":
|
|
2538
|
+
case "debugrepro":
|
|
2539
|
+
case "repro":
|
|
2540
|
+
case "reproduce":
|
|
2541
|
+
return "debug_repro";
|
|
2542
|
+
case "debug_root_cause":
|
|
2543
|
+
case "debugrootcause":
|
|
2544
|
+
case "root_cause":
|
|
2545
|
+
case "rootcause":
|
|
2546
|
+
return "debug_root_cause";
|
|
2547
|
+
case "debug_fix":
|
|
2548
|
+
case "debugfix":
|
|
2549
|
+
case "fix":
|
|
2550
|
+
return "debug_fix";
|
|
2551
|
+
case "debug_regression":
|
|
2552
|
+
case "debugregression":
|
|
2553
|
+
case "regression":
|
|
2554
|
+
return "debug_regression";
|
|
2555
|
+
default:
|
|
2556
|
+
return null;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
function isDevelopCompletionPhase(phase) {
|
|
2560
|
+
return phase === "implementation" || phase === "self_review" || phase === "validation";
|
|
2561
|
+
}
|
|
2562
|
+
function isDebugCompletionPhase(phase) {
|
|
2563
|
+
return debugCompletionPhaseOrder.includes(phase);
|
|
2564
|
+
}
|
|
2565
|
+
function asDebugCompletionPhase(phase) {
|
|
2566
|
+
if (isDebugCompletionPhase(phase)) {
|
|
2567
|
+
return phase;
|
|
2568
|
+
}
|
|
2569
|
+
throw new Error(`Expected Debug completion phase, got ${phase}.`);
|
|
2570
|
+
}
|
|
1588
2571
|
function parseCompletionRedactionSecrets(raw) {
|
|
1589
2572
|
if (!raw?.trim()) {
|
|
1590
2573
|
return [];
|
|
@@ -1614,11 +2597,28 @@ function phaseFromToolName(toolName) {
|
|
|
1614
2597
|
if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
|
|
1615
2598
|
return "qa";
|
|
1616
2599
|
}
|
|
2600
|
+
if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
|
|
2601
|
+
return "architect";
|
|
2602
|
+
}
|
|
2603
|
+
const debugGate = debugGateFromToolName(toolName);
|
|
2604
|
+
if (debugGate) {
|
|
2605
|
+
return debugGate;
|
|
2606
|
+
}
|
|
1617
2607
|
if (qaGateFromToolName(toolName)) {
|
|
1618
2608
|
return "qa";
|
|
1619
2609
|
}
|
|
1620
2610
|
throw new Error(`Unknown Forge completion tool: ${toolName}`);
|
|
1621
2611
|
}
|
|
2612
|
+
function debugGateFromToolName(toolName) {
|
|
2613
|
+
const normalized = toolName.trim().toLowerCase();
|
|
2614
|
+
for (const gate of debugCompletionPhaseOrder) {
|
|
2615
|
+
const base = debugGateToolNames[gate];
|
|
2616
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
2617
|
+
return gate;
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
return null;
|
|
2621
|
+
}
|
|
1622
2622
|
function qaGateFromToolName(toolName) {
|
|
1623
2623
|
const normalized = toolName.trim().toLowerCase();
|
|
1624
2624
|
for (const gate of qaCompletionGateOrder) {
|
|
@@ -1696,17 +2696,42 @@ function requireArray(payload, key) {
|
|
|
1696
2696
|
throw new Error(`${key} must be an array.`);
|
|
1697
2697
|
}
|
|
1698
2698
|
}
|
|
2699
|
+
function requireNonEmptyArray(payload, key) {
|
|
2700
|
+
requireArray(payload, key);
|
|
2701
|
+
if (arrayLength(payload, key) === 0) {
|
|
2702
|
+
throw new Error(`${key} must include at least one value.`);
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
function arrayLength(payload, key) {
|
|
2706
|
+
const value = payload[key];
|
|
2707
|
+
return Array.isArray(value) ? value.length : 0;
|
|
2708
|
+
}
|
|
2709
|
+
function requireAllowedStatus(payload, key, allowed) {
|
|
2710
|
+
const value = normalizedString(optionalString(payload, key));
|
|
2711
|
+
if (!allowed.includes(value)) {
|
|
2712
|
+
throw new Error(`${key} must be one of ${allowed.join(", ")}.`);
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
1699
2715
|
function requireObject(payload, key) {
|
|
1700
2716
|
const value = payload[key];
|
|
1701
2717
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1702
2718
|
throw new Error(`${key} must be an object.`);
|
|
1703
2719
|
}
|
|
1704
2720
|
}
|
|
2721
|
+
function requiredRecord(payload, key) {
|
|
2722
|
+
requireObject(payload, key);
|
|
2723
|
+
return payload[key];
|
|
2724
|
+
}
|
|
1705
2725
|
function requireBoolean(payload, key) {
|
|
1706
2726
|
if (typeof payload[key] !== "boolean") {
|
|
1707
2727
|
throw new Error(`${key} must be a boolean.`);
|
|
1708
2728
|
}
|
|
1709
2729
|
}
|
|
2730
|
+
function requireNumericOne(payload, key, pathLabel) {
|
|
2731
|
+
if (payload[key] !== 1) {
|
|
2732
|
+
throw new Error(`${pathLabel}: expected numeric 1; correction: set ${pathLabel} to 1.`);
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
1710
2735
|
function requireSatisfiedAcceptanceCriteria(payload) {
|
|
1711
2736
|
const criteria = payload.acceptance_criteria;
|
|
1712
2737
|
if (!Array.isArray(criteria)) {
|
|
@@ -1724,6 +2749,37 @@ function requireSatisfiedAcceptanceCriteria(payload) {
|
|
|
1724
2749
|
}
|
|
1725
2750
|
}
|
|
1726
2751
|
}
|
|
2752
|
+
function validateResolvedFindings(payload) {
|
|
2753
|
+
const resolvedFindings = payload.resolved_findings;
|
|
2754
|
+
if (resolvedFindings === undefined || resolvedFindings === null) {
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2757
|
+
if (!Array.isArray(resolvedFindings)) {
|
|
2758
|
+
throw new Error("resolved_findings must be an array.");
|
|
2759
|
+
}
|
|
2760
|
+
for (const [index, finding] of resolvedFindings.entries()) {
|
|
2761
|
+
const prefix = `resolved_findings[${index}].`;
|
|
2762
|
+
if (!finding || typeof finding !== "object" || Array.isArray(finding)) {
|
|
2763
|
+
throw new Error(`resolved_findings[${index}] must be an object.`);
|
|
2764
|
+
}
|
|
2765
|
+
const record = finding;
|
|
2766
|
+
requireNonEmptyStringRecord(record, "qa_id", prefix);
|
|
2767
|
+
requireKnownStringRecord(record, "status", resolvedFindingStatuses, prefix);
|
|
2768
|
+
requireNonEmptyStringRecord(record, "summary", prefix);
|
|
2769
|
+
requireSubstantiveValueRecord(record, "evidence", prefix);
|
|
2770
|
+
requireArrayRecord(record, "changed_files", prefix);
|
|
2771
|
+
requireArrayRecord(record, "tests_added_or_updated", prefix);
|
|
2772
|
+
requireSubstantiveValueRecord(record, "regression_evidence", prefix);
|
|
2773
|
+
if (normalizedString(record.status) === "resolved") {
|
|
2774
|
+
if (record.changed_files.length === 0) {
|
|
2775
|
+
throw new Error(`${prefix}changed_files must include at least one changed file for resolved QA findings.`);
|
|
2776
|
+
}
|
|
2777
|
+
if (record.tests_added_or_updated.length === 0) {
|
|
2778
|
+
throw new Error(`${prefix}tests_added_or_updated must include at least one regression test for resolved QA findings.`);
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
1727
2783
|
function qaEvidenceSource(payload) {
|
|
1728
2784
|
const evidence = payload.evidence;
|
|
1729
2785
|
return evidence && typeof evidence === "object" && !Array.isArray(evidence)
|
|
@@ -1773,6 +2829,11 @@ function requireNonEmptyStringRecord(source, key, prefix = "") {
|
|
|
1773
2829
|
throw new Error(`${prefix}${key} is required.`);
|
|
1774
2830
|
}
|
|
1775
2831
|
}
|
|
2832
|
+
function requireArrayRecord(source, key, prefix = "") {
|
|
2833
|
+
if (!Array.isArray(source[key])) {
|
|
2834
|
+
throw new Error(`${prefix}${key} must be an array.`);
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
1776
2837
|
function requireKnownStringRecord(source, key, allowed, prefix = "") {
|
|
1777
2838
|
const value = normalizedString(optionalString(source, key));
|
|
1778
2839
|
if (!allowed.has(value)) {
|
|
@@ -1790,6 +2851,11 @@ function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
|
|
|
1790
2851
|
}
|
|
1791
2852
|
}
|
|
1792
2853
|
}
|
|
2854
|
+
function requireSubstantiveValueRecord(source, key, prefix = "") {
|
|
2855
|
+
if (!hasSubstantiveJsonValue(source[key])) {
|
|
2856
|
+
throw new Error(`${prefix}${key} must include at least one concrete evidence reference or summary.`);
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
1793
2859
|
function requireSubstantiveEvidenceRecord(source, prefix = "") {
|
|
1794
2860
|
const evidence = source.evidence;
|
|
1795
2861
|
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
@@ -1872,7 +2938,7 @@ function parseJsonObject(content, label) {
|
|
|
1872
2938
|
return asRecord(JSON.parse(content), label);
|
|
1873
2939
|
}
|
|
1874
2940
|
catch (error) {
|
|
1875
|
-
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}
|
|
2941
|
+
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`, { cause: error });
|
|
1876
2942
|
}
|
|
1877
2943
|
}
|
|
1878
2944
|
function parseOptionalJsonObject(content, label) {
|