@loucompanion/forge-bridge 0.1.1-dev.ac486debe210 → 0.1.1-dev.c5b751411a2b
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 +68 -9
- package/dist/bridge/bridge.types.d.ts +9 -0
- package/dist/cli/local-config.js +18 -1
- package/dist/session/internals.d.ts +1 -1
- package/dist/session/phase-completion.d.ts +30 -2
- package/dist/session/phase-completion.js +1486 -22
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +3 -0
- package/dist/session/provider-cli/provider-cli.service.js +882 -153
- 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 -4
- package/dist/session/session.types.d.ts +48 -0
- package/dist/session/session.types.js +108 -2
- 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 +78 -1
- package/dist/worktree/worktree.service.js +5 -3
- package/dist/worktree/worktree.types.d.ts +2 -0
- package/package.json +8 -3
|
@@ -3,9 +3,60 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { redactSecrets } from "../shared/redaction/redaction.js";
|
|
6
|
+
import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
|
|
6
7
|
const toolServerName = "forge";
|
|
7
8
|
const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
|
|
8
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
|
+
];
|
|
16
|
+
export const qaCompletionGateOrder = [
|
|
17
|
+
"qa_plan",
|
|
18
|
+
"qa_regression",
|
|
19
|
+
"qa_api",
|
|
20
|
+
"qa_manual_web",
|
|
21
|
+
"qa_bug_repro",
|
|
22
|
+
"qa_findings",
|
|
23
|
+
"qa_verdict"
|
|
24
|
+
];
|
|
25
|
+
const qaGateFileNames = {
|
|
26
|
+
qa_plan: "qa.plan.json",
|
|
27
|
+
qa_regression: "qa.regression.json",
|
|
28
|
+
qa_api: "qa.api.json",
|
|
29
|
+
qa_manual_web: "qa.manual-web.json",
|
|
30
|
+
qa_bug_repro: "qa.bug-repro.json",
|
|
31
|
+
qa_findings: "qa.findings.json",
|
|
32
|
+
qa_verdict: "qa.verdict.json"
|
|
33
|
+
};
|
|
34
|
+
const qaGateToolNames = {
|
|
35
|
+
qa_plan: "complete_qa_plan",
|
|
36
|
+
qa_regression: "complete_qa_regression",
|
|
37
|
+
qa_api: "complete_qa_api",
|
|
38
|
+
qa_manual_web: "complete_qa_manual_web",
|
|
39
|
+
qa_bug_repro: "complete_qa_bug_repro",
|
|
40
|
+
qa_findings: "complete_qa_findings",
|
|
41
|
+
qa_verdict: "complete_qa_verdict"
|
|
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
|
+
};
|
|
49
|
+
const qaBlockerReasons = new Set([
|
|
50
|
+
"missing_app_launch_target",
|
|
51
|
+
"inconclusive",
|
|
52
|
+
"environment_failure",
|
|
53
|
+
"missing_validation_evidence",
|
|
54
|
+
"missing_worktree",
|
|
55
|
+
"runner_failure"
|
|
56
|
+
]);
|
|
57
|
+
const qaFindingSeverities = new Set(["critical", "major", "minor"]);
|
|
58
|
+
const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
|
|
59
|
+
const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
|
|
9
60
|
const phaseFiles = {
|
|
10
61
|
implementation: {
|
|
11
62
|
jsonFileName: "implementation.completeness.json",
|
|
@@ -18,6 +69,30 @@ const phaseFiles = {
|
|
|
18
69
|
validation: {
|
|
19
70
|
jsonFileName: "validation.verdict.json",
|
|
20
71
|
reportFileName: "validation.report.md"
|
|
72
|
+
},
|
|
73
|
+
qa: {
|
|
74
|
+
jsonFileName: "qa.verdict.json",
|
|
75
|
+
reportFileName: "qa.report.md"
|
|
76
|
+
},
|
|
77
|
+
debug_repro: {
|
|
78
|
+
jsonFileName: "debug.repro.json",
|
|
79
|
+
reportFileName: "debug.report.md"
|
|
80
|
+
},
|
|
81
|
+
debug_root_cause: {
|
|
82
|
+
jsonFileName: "debug.root-cause.json",
|
|
83
|
+
reportFileName: "debug.report.md"
|
|
84
|
+
},
|
|
85
|
+
debug_fix: {
|
|
86
|
+
jsonFileName: "debug.fix.json",
|
|
87
|
+
reportFileName: "debug.report.md"
|
|
88
|
+
},
|
|
89
|
+
debug_regression: {
|
|
90
|
+
jsonFileName: "debug.regression.json",
|
|
91
|
+
reportFileName: "debug.report.md"
|
|
92
|
+
},
|
|
93
|
+
architect: {
|
|
94
|
+
jsonFileName: "architect.verdict.json",
|
|
95
|
+
reportFileName: "architect.report.md"
|
|
21
96
|
}
|
|
22
97
|
};
|
|
23
98
|
export function completionPhaseFromDispatch(dispatch) {
|
|
@@ -25,12 +100,22 @@ export function completionPhaseFromDispatch(dispatch) {
|
|
|
25
100
|
}
|
|
26
101
|
export function normalizeCompletionPhase(value) {
|
|
27
102
|
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
103
|
+
const debugPhase = normalizeDebugCompletionPhase(normalized);
|
|
104
|
+
if (debugPhase) {
|
|
105
|
+
return debugPhase;
|
|
106
|
+
}
|
|
28
107
|
if (normalized === "self_review" || normalized === "selfreview" || normalized === "review") {
|
|
29
108
|
return "self_review";
|
|
30
109
|
}
|
|
31
110
|
if (normalized === "validate" || normalized === "validation") {
|
|
32
111
|
return "validation";
|
|
33
112
|
}
|
|
113
|
+
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
114
|
+
return "qa";
|
|
115
|
+
}
|
|
116
|
+
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
117
|
+
return "architect";
|
|
118
|
+
}
|
|
34
119
|
return "implementation";
|
|
35
120
|
}
|
|
36
121
|
export function usesDevelopCycleCompletion(dispatch) {
|
|
@@ -42,20 +127,68 @@ export function usesDevelopCycleCompletion(dispatch) {
|
|
|
42
127
|
&& lane === "develop"
|
|
43
128
|
&& completionPhaseFromDispatch(dispatch) === "implementation";
|
|
44
129
|
}
|
|
130
|
+
export function usesQaCycleCompletion(dispatch) {
|
|
131
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
132
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
133
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
134
|
+
return kind === "qa"
|
|
135
|
+
&& role === "qa"
|
|
136
|
+
&& lane === "qa"
|
|
137
|
+
&& completionPhaseFromDispatch(dispatch) === "qa";
|
|
138
|
+
}
|
|
139
|
+
export function usesDebugCycleCompletion(dispatch) {
|
|
140
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
141
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
142
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
143
|
+
return kind === "debug"
|
|
144
|
+
&& role === "debug"
|
|
145
|
+
&& lane === "debug"
|
|
146
|
+
&& isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
|
|
147
|
+
}
|
|
148
|
+
export function usesArchitectCycleCompletion(dispatch) {
|
|
149
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
150
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
151
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
152
|
+
return kind === "architect" || role === "architect" || lane === "architect";
|
|
153
|
+
}
|
|
45
154
|
export function completionToolNamesForPhase(phase) {
|
|
155
|
+
if (isDebugCompletionPhase(phase)) {
|
|
156
|
+
return completionToolNamesForDebugGate(phase);
|
|
157
|
+
}
|
|
46
158
|
const base = phase === "implementation"
|
|
47
159
|
? "complete_implementation"
|
|
48
160
|
: phase === "self_review"
|
|
49
161
|
? "complete_self_review"
|
|
50
|
-
: "
|
|
162
|
+
: phase === "validation"
|
|
163
|
+
? "complete_validation"
|
|
164
|
+
: phase === "architect"
|
|
165
|
+
? "complete_architect_plan"
|
|
166
|
+
: "complete_qa";
|
|
167
|
+
return [`forge.${base}`, base];
|
|
168
|
+
}
|
|
169
|
+
export function completionToolNamesForQaGate(gate) {
|
|
170
|
+
const base = qaGateToolNames[gate];
|
|
171
|
+
return [`forge.${base}`, base];
|
|
172
|
+
}
|
|
173
|
+
export function completionToolNamesForDebugGate(gate) {
|
|
174
|
+
const base = debugGateToolNames[gate];
|
|
51
175
|
return [`forge.${base}`, base];
|
|
52
176
|
}
|
|
53
177
|
export function expectedCompletionToolName(phase) {
|
|
54
178
|
return completionToolNamesForPhase(phase)[0];
|
|
55
179
|
}
|
|
180
|
+
export function expectedQaCompletionToolName(gate) {
|
|
181
|
+
return completionToolNamesForQaGate(gate)[0];
|
|
182
|
+
}
|
|
183
|
+
export function expectedDebugCompletionToolName(gate) {
|
|
184
|
+
return completionToolNamesForDebugGate(gate)[0];
|
|
185
|
+
}
|
|
56
186
|
export function artifactRootFor(worktreePath, workItemId) {
|
|
57
187
|
return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
|
|
58
188
|
}
|
|
189
|
+
function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
190
|
+
return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
|
|
191
|
+
}
|
|
59
192
|
export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
|
|
60
193
|
return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].jsonFileName);
|
|
61
194
|
}
|
|
@@ -66,12 +199,19 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
66
199
|
const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
|
|
67
200
|
const worktreePath = path.resolve(options.worktree.worktreePath);
|
|
68
201
|
const workItemId = options.dispatch.workItem.id;
|
|
202
|
+
const ownerType = normalizedOwnerType(options.dispatch.cycleRun.ownerType) ?? "work_item";
|
|
203
|
+
const ownerId = options.dispatch.cycleRun.ownerId ?? workItemId;
|
|
69
204
|
const mcpArgs = buildCompletionToolServerArgs({
|
|
70
205
|
worktreePath,
|
|
71
206
|
workItemId,
|
|
207
|
+
ownerType,
|
|
208
|
+
ownerId,
|
|
72
209
|
phase,
|
|
73
210
|
producedBy: `${options.dispatch.execution.provider}-cli`,
|
|
74
|
-
developCycle: usesDevelopCycleCompletion(options.dispatch)
|
|
211
|
+
developCycle: usesDevelopCycleCompletion(options.dispatch),
|
|
212
|
+
qaCycle: usesQaCycleCompletion(options.dispatch),
|
|
213
|
+
debugCycle: usesDebugCycleCompletion(options.dispatch),
|
|
214
|
+
architectCycle: usesArchitectCycleCompletion(options.dispatch)
|
|
75
215
|
});
|
|
76
216
|
const runtime = {
|
|
77
217
|
serverName: toolServerName,
|
|
@@ -97,9 +237,22 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
97
237
|
"--produced-by",
|
|
98
238
|
config.producedBy ?? "forge-completion-tool"
|
|
99
239
|
];
|
|
240
|
+
if (config.ownerType || config.ownerId) {
|
|
241
|
+
args.push("--owner-type", normalizedOwnerType(config.ownerType) ?? "work_item");
|
|
242
|
+
args.push("--owner-id", config.ownerId ?? config.workItemId);
|
|
243
|
+
}
|
|
100
244
|
if (config.developCycle) {
|
|
101
245
|
args.push("--develop-cycle");
|
|
102
246
|
}
|
|
247
|
+
if (config.qaCycle) {
|
|
248
|
+
args.push("--qa-cycle");
|
|
249
|
+
}
|
|
250
|
+
if (config.debugCycle) {
|
|
251
|
+
args.push("--debug-cycle");
|
|
252
|
+
}
|
|
253
|
+
if (config.architectCycle) {
|
|
254
|
+
args.push("--architect-cycle");
|
|
255
|
+
}
|
|
103
256
|
return args;
|
|
104
257
|
}
|
|
105
258
|
export function codexMcpConfigArgs(runtime) {
|
|
@@ -126,6 +279,20 @@ export function completionPromptForPhase(phase) {
|
|
|
126
279
|
const toolName = expectedCompletionToolName(phase);
|
|
127
280
|
const alias = completionToolNamesForPhase(phase).find((name) => name !== toolName);
|
|
128
281
|
const artifactName = phaseFiles[phase].jsonFileName;
|
|
282
|
+
if (phase === "qa") {
|
|
283
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
284
|
+
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.
|
|
285
|
+
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\`.
|
|
286
|
+
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.
|
|
287
|
+
Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
|
|
288
|
+
}
|
|
289
|
+
if (phase === "architect") {
|
|
290
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
291
|
+
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.
|
|
292
|
+
That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
|
|
293
|
+
Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
|
|
294
|
+
Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
|
|
295
|
+
}
|
|
129
296
|
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
130
297
|
Do not write phase artifacts by hand. When the phase is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the required evidence payload.
|
|
131
298
|
That tool writes \`.forge/work-items/{workItemId}/artifacts/${artifactName}\` and returns the artifact reference that Forge will mirror through the existing phase-artifact pipeline.
|
|
@@ -141,31 +308,74 @@ Complete the dev cycle in order inside this same provider session:
|
|
|
141
308
|
Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
|
|
142
309
|
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.`;
|
|
143
310
|
}
|
|
311
|
+
export function completionPromptForQaCycle(currentGate = "qa_plan") {
|
|
312
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
313
|
+
Do not write QA artifacts by hand. The tool server enforces QA gate order and starts this run at \`${currentGate}\`.
|
|
314
|
+
Complete the QA cycle in order inside this same provider session:
|
|
315
|
+
1. Call \`forge.complete_qa_plan(...)\` after the QA test plan/checklist is ready.
|
|
316
|
+
2. Follow the returned regression instruction, then call \`forge.complete_qa_regression(...)\`.
|
|
317
|
+
3. Follow the returned API/backend instruction, then call \`forge.complete_qa_api(...)\`.
|
|
318
|
+
4. Follow the returned manual web instruction, then call \`forge.complete_qa_manual_web(...)\`.
|
|
319
|
+
5. Follow the returned bug reproduction instruction, then call \`forge.complete_qa_bug_repro(...)\`.
|
|
320
|
+
6. Follow the returned findings instruction, then call \`forge.complete_qa_findings(...)\`.
|
|
321
|
+
7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
|
|
322
|
+
Use \`status: "skipped"\` with a reason for gates that are not applicable. If a gate is blocked, keep moving through later gates as skipped due to the blocker and finish with a blocked verdict.
|
|
323
|
+
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\`.
|
|
324
|
+
The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
|
|
325
|
+
}
|
|
326
|
+
export function completionPromptForDebugCycle(currentGate = "debug_repro") {
|
|
327
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
328
|
+
Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
|
|
329
|
+
Complete the Debug cycle in order inside this same provider session:
|
|
330
|
+
1. Reproduce or tightly characterize the bug, then call \`forge.complete_debug_repro(...)\`.
|
|
331
|
+
2. Follow the returned root-cause instruction, isolate evidence, then call \`forge.complete_debug_root_cause(...)\`.
|
|
332
|
+
3. Follow the returned fix instruction, apply the smallest safe fix with regression coverage when practical, then call \`forge.complete_debug_fix(...)\`.
|
|
333
|
+
4. Follow the returned regression instruction, prove the original repro is negative and the fix is QA-ready, then call \`forge.complete_debug_regression(...)\`.
|
|
334
|
+
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.
|
|
335
|
+
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.`;
|
|
336
|
+
}
|
|
144
337
|
export function completionToolNamesForServer(config) {
|
|
145
|
-
return config.
|
|
146
|
-
?
|
|
147
|
-
:
|
|
338
|
+
return config.debugCycle
|
|
339
|
+
? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
|
|
340
|
+
: config.qaCycle
|
|
341
|
+
? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
|
|
342
|
+
: config.developCycle
|
|
343
|
+
? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
|
|
344
|
+
: config.architectCycle
|
|
345
|
+
? completionToolNamesForPhase("architect")
|
|
346
|
+
: completionToolNamesForPhase(config.phase);
|
|
148
347
|
}
|
|
149
348
|
export class CompletionToolServerSession {
|
|
150
349
|
config;
|
|
151
350
|
currentPhase;
|
|
351
|
+
currentQaGate = "qa_plan";
|
|
152
352
|
completed = false;
|
|
153
353
|
constructor(config) {
|
|
154
354
|
this.config = config;
|
|
155
355
|
this.currentPhase = config.phase;
|
|
156
356
|
}
|
|
157
357
|
get instructions() {
|
|
158
|
-
return this.config.
|
|
159
|
-
?
|
|
160
|
-
:
|
|
358
|
+
return this.config.debugCycle
|
|
359
|
+
? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
|
|
360
|
+
: this.config.qaCycle
|
|
361
|
+
? completionPromptForQaCycle(this.currentQaGate)
|
|
362
|
+
: this.config.developCycle
|
|
363
|
+
? completionPromptForDevelopCycle(this.currentPhase)
|
|
364
|
+
: completionPromptForPhase(this.currentPhase);
|
|
161
365
|
}
|
|
162
366
|
toolDefinitions() {
|
|
163
|
-
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name)));
|
|
367
|
+
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
|
|
164
368
|
}
|
|
165
369
|
async callTool(toolName, input) {
|
|
166
370
|
const requestedPhase = phaseFromToolName(toolName);
|
|
167
371
|
if (this.completed) {
|
|
168
|
-
throw new Error("Forge
|
|
372
|
+
throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
|
|
373
|
+
}
|
|
374
|
+
if (this.config.debugCycle) {
|
|
375
|
+
return await this.callDebugGateTool(toolName, requestedPhase, input);
|
|
376
|
+
}
|
|
377
|
+
if (this.config.qaCycle) {
|
|
378
|
+
return await this.callQaGateTool(toolName, requestedPhase, input);
|
|
169
379
|
}
|
|
170
380
|
if (requestedPhase !== this.currentPhase) {
|
|
171
381
|
throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
|
|
@@ -201,7 +411,91 @@ export class CompletionToolServerSession {
|
|
|
201
411
|
}
|
|
202
412
|
};
|
|
203
413
|
}
|
|
414
|
+
async callDebugGateTool(toolName, requestedPhase, input) {
|
|
415
|
+
const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
|
|
416
|
+
if (!isDebugCompletionPhase(requestedPhase)) {
|
|
417
|
+
throw new Error(`Tool ${toolName} is not available in Debug-cycle mode. Current Forge Debug gate is ${currentDebugGate}.`);
|
|
418
|
+
}
|
|
419
|
+
if (requestedPhase !== currentDebugGate) {
|
|
420
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge Debug gate is ${currentDebugGate}.`);
|
|
421
|
+
}
|
|
422
|
+
const completedGate = currentDebugGate;
|
|
423
|
+
const reference = await writeCompletionArtifact({ ...this.config, phase: completedGate }, toolName, input);
|
|
424
|
+
const status = reference.status.trim().toLowerCase();
|
|
425
|
+
const pass = status === "pass";
|
|
426
|
+
const nextGate = pass ? this.advanceAfterDebugGate(completedGate) : completedGate;
|
|
427
|
+
const nextPhase = nextGate ?? "qa_ready";
|
|
428
|
+
const nextInstruction = completionToolNextInstructionForDebugGate(completedGate, status, nextGate);
|
|
429
|
+
const reason = pass
|
|
430
|
+
? `${completedGate} artifact accepted${nextGate ? `; continue to ${nextGate}.` : "; Debug regression is QA-ready."}`
|
|
431
|
+
: `${completedGate} artifact recorded with status ${status}; rework is required before Debug progression.`;
|
|
432
|
+
return {
|
|
433
|
+
content: [
|
|
434
|
+
{
|
|
435
|
+
type: "text",
|
|
436
|
+
text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
|
|
437
|
+
}
|
|
438
|
+
],
|
|
439
|
+
structuredContent: {
|
|
440
|
+
...reference,
|
|
441
|
+
gate: {
|
|
442
|
+
phase: completedGate,
|
|
443
|
+
status,
|
|
444
|
+
allowed: pass,
|
|
445
|
+
reason
|
|
446
|
+
},
|
|
447
|
+
nextPhase,
|
|
448
|
+
nextInstruction
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
async callQaGateTool(toolName, requestedPhase, input) {
|
|
453
|
+
const requestedGate = qaGateFromToolName(toolName);
|
|
454
|
+
if (requestedPhase !== "qa" || !requestedGate) {
|
|
455
|
+
throw new Error(`Tool ${toolName} is not available in QA-cycle mode. Current Forge QA gate is ${this.currentQaGate}.`);
|
|
456
|
+
}
|
|
457
|
+
if (requestedGate !== this.currentQaGate) {
|
|
458
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge QA gate is ${this.currentQaGate}.`);
|
|
459
|
+
}
|
|
460
|
+
const completedGate = this.currentQaGate;
|
|
461
|
+
const reference = await writeCompletionArtifact({ ...this.config, phase: "qa" }, toolName, input);
|
|
462
|
+
const status = reference.status.trim().toLowerCase();
|
|
463
|
+
const nextGate = this.advanceAfterQaGate(completedGate);
|
|
464
|
+
const nextPhase = nextGate ? "qa" : "sign_off_ready";
|
|
465
|
+
const nextInstruction = completionToolNextInstructionForQaGate(completedGate, status, nextGate);
|
|
466
|
+
const allowed = status !== "blocked";
|
|
467
|
+
const reason = nextGate
|
|
468
|
+
? `${completedGate} artifact accepted; continue to ${nextGate}.`
|
|
469
|
+
: `QA verdict artifact accepted with status ${status}.`;
|
|
470
|
+
return {
|
|
471
|
+
content: [
|
|
472
|
+
{
|
|
473
|
+
type: "text",
|
|
474
|
+
text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
|
|
475
|
+
}
|
|
476
|
+
],
|
|
477
|
+
structuredContent: {
|
|
478
|
+
...reference,
|
|
479
|
+
gate: {
|
|
480
|
+
phase: "qa",
|
|
481
|
+
qaGate: completedGate,
|
|
482
|
+
status,
|
|
483
|
+
allowed,
|
|
484
|
+
reason
|
|
485
|
+
},
|
|
486
|
+
nextPhase,
|
|
487
|
+
nextInstruction
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
}
|
|
204
491
|
advanceAfterPass(phase) {
|
|
492
|
+
if (phase === "qa") {
|
|
493
|
+
this.completed = true;
|
|
494
|
+
return "sign_off_ready";
|
|
495
|
+
}
|
|
496
|
+
if (isDebugCompletionPhase(phase)) {
|
|
497
|
+
return nextDebugPhaseAfter(phase) ?? "qa_ready";
|
|
498
|
+
}
|
|
205
499
|
const next = nextPhaseAfter(phase);
|
|
206
500
|
if (!this.config.developCycle) {
|
|
207
501
|
return next ?? "dev_test_ready";
|
|
@@ -213,14 +507,38 @@ export class CompletionToolServerSession {
|
|
|
213
507
|
this.completed = true;
|
|
214
508
|
return "dev_test_ready";
|
|
215
509
|
}
|
|
510
|
+
advanceAfterQaGate(gate) {
|
|
511
|
+
const index = qaCompletionGateOrder.indexOf(gate);
|
|
512
|
+
const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
|
|
513
|
+
if (next) {
|
|
514
|
+
this.currentQaGate = next;
|
|
515
|
+
return next;
|
|
516
|
+
}
|
|
517
|
+
this.completed = true;
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
advanceAfterDebugGate(gate) {
|
|
521
|
+
const index = debugCompletionPhaseOrder.indexOf(gate);
|
|
522
|
+
const next = index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
|
|
523
|
+
if (next) {
|
|
524
|
+
this.currentPhase = next;
|
|
525
|
+
return next;
|
|
526
|
+
}
|
|
527
|
+
this.completed = true;
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
216
530
|
}
|
|
217
531
|
export async function writeCompletionArtifact(config, toolName, input) {
|
|
218
532
|
const phase = phaseFromToolName(toolName);
|
|
219
533
|
if (phase !== config.phase) {
|
|
220
534
|
throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
|
|
221
535
|
}
|
|
536
|
+
const qaGate = qaGateFromToolName(toolName);
|
|
537
|
+
if (qaGate && qaGate !== "qa_verdict") {
|
|
538
|
+
return await writeQaGateCompletionArtifact(config, qaGate, input);
|
|
539
|
+
}
|
|
222
540
|
const payload = asRecord(input, "completion payload");
|
|
223
|
-
const status = normalizeCompletionStatus(requiredString(payload, "status"));
|
|
541
|
+
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
224
542
|
const validationPayload = { ...payload, status };
|
|
225
543
|
validatePayloadShape(phase, validationPayload);
|
|
226
544
|
validateOptionalWorkItemId(validationPayload, config.workItemId);
|
|
@@ -229,9 +547,17 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
229
547
|
?? optionalString(redactedPayload, "producedBy")
|
|
230
548
|
?? config.producedBy
|
|
231
549
|
?? "forge-completion-tool";
|
|
550
|
+
if (phase === "qa") {
|
|
551
|
+
return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
552
|
+
}
|
|
553
|
+
if (phase === "architect") {
|
|
554
|
+
return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
555
|
+
}
|
|
232
556
|
const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
|
|
233
557
|
phase,
|
|
234
558
|
workItemId: config.workItemId,
|
|
559
|
+
ownerType: config.ownerType,
|
|
560
|
+
ownerId: config.ownerId,
|
|
235
561
|
producedBy,
|
|
236
562
|
status
|
|
237
563
|
});
|
|
@@ -250,6 +576,93 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
250
576
|
return {
|
|
251
577
|
phase,
|
|
252
578
|
workItemId: config.workItemId,
|
|
579
|
+
...artifactReferenceOwnerFields(config),
|
|
580
|
+
repoPath: path.resolve(config.worktreePath),
|
|
581
|
+
path: artifactPath,
|
|
582
|
+
reportPath,
|
|
583
|
+
contentHash,
|
|
584
|
+
status,
|
|
585
|
+
producedBy
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
|
|
589
|
+
const ownerFields = artifactOwnerFields(config);
|
|
590
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
591
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
592
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
593
|
+
const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
|
|
594
|
+
const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
|
|
595
|
+
const verdictPath = path.join(artifactRoot, "architect.verdict.json");
|
|
596
|
+
const reportPath = path.join(artifactRoot, "architect.report.md");
|
|
597
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
598
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
599
|
+
const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
|
|
600
|
+
const artifactWithoutHash = normalizeArtifactPayload(payload, {
|
|
601
|
+
phase: "architect",
|
|
602
|
+
workItemId: config.workItemId,
|
|
603
|
+
ownerType: config.ownerType,
|
|
604
|
+
ownerId: config.ownerId,
|
|
605
|
+
producedBy,
|
|
606
|
+
status
|
|
607
|
+
});
|
|
608
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
609
|
+
const verdict = {
|
|
610
|
+
...artifactWithoutHash,
|
|
611
|
+
content_hash: contentHash
|
|
612
|
+
};
|
|
613
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
614
|
+
await writeFileAtomic(implementationPlanPath, jsonText(implementationPlan));
|
|
615
|
+
await writeFileAtomic(qaPlanPath, jsonText(qaPlan));
|
|
616
|
+
await writeFileAtomic(verdictPath, jsonText(verdict));
|
|
617
|
+
await writeFileAtomic(reportPath, `${report.trim()}\n`);
|
|
618
|
+
return {
|
|
619
|
+
phase: "architect",
|
|
620
|
+
workItemId: config.workItemId,
|
|
621
|
+
...artifactReferenceOwnerFields(config),
|
|
622
|
+
repoPath: path.resolve(config.worktreePath),
|
|
623
|
+
path: verdictPath,
|
|
624
|
+
reportPath,
|
|
625
|
+
contentHash,
|
|
626
|
+
status,
|
|
627
|
+
producedBy
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
async function writeQaGateCompletionArtifact(config, gate, input) {
|
|
631
|
+
const payload = asRecord(input, "QA gate completion payload");
|
|
632
|
+
const status = normalizeQaGateStatus(requiredString(payload, "status"));
|
|
633
|
+
validateQaGatePayloadShape(gate, payload, status);
|
|
634
|
+
validateOptionalWorkItemId(payload, config.workItemId);
|
|
635
|
+
const redactedPayload = redactSecrets({ ...payload, status }, config.redactionSecrets ?? []);
|
|
636
|
+
const producedBy = optionalString(redactedPayload, "produced_by")
|
|
637
|
+
?? optionalString(redactedPayload, "producedBy")
|
|
638
|
+
?? config.producedBy
|
|
639
|
+
?? "forge-completion-tool";
|
|
640
|
+
const ownerFields = artifactOwnerFields(config);
|
|
641
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
642
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
643
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
644
|
+
const fileName = qaGateFileNames[gate];
|
|
645
|
+
const artifactPath = path.join(artifactRoot, fileName);
|
|
646
|
+
const reportPath = path.join(artifactRoot, "qa.report.md");
|
|
647
|
+
const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
|
|
648
|
+
gate,
|
|
649
|
+
workItemId: config.workItemId,
|
|
650
|
+
ownerType: config.ownerType,
|
|
651
|
+
ownerId: config.ownerId,
|
|
652
|
+
producedBy,
|
|
653
|
+
status
|
|
654
|
+
});
|
|
655
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
656
|
+
const artifact = {
|
|
657
|
+
...artifactWithoutHash,
|
|
658
|
+
content_hash: contentHash
|
|
659
|
+
};
|
|
660
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
661
|
+
await writeFileAtomic(artifactPath, jsonText(artifact));
|
|
662
|
+
return {
|
|
663
|
+
phase: "qa",
|
|
664
|
+
workItemId: config.workItemId,
|
|
665
|
+
...artifactReferenceOwnerFields(config),
|
|
253
666
|
repoPath: path.resolve(config.worktreePath),
|
|
254
667
|
path: artifactPath,
|
|
255
668
|
reportPath,
|
|
@@ -258,6 +671,170 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
258
671
|
producedBy
|
|
259
672
|
};
|
|
260
673
|
}
|
|
674
|
+
async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
675
|
+
const ownerFields = artifactOwnerFields(config);
|
|
676
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
677
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
678
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
679
|
+
const planPath = path.join(artifactRoot, "qa.plan.json");
|
|
680
|
+
const evidencePath = path.join(artifactRoot, "qa.evidence.json");
|
|
681
|
+
const findingsPath = path.join(artifactRoot, "qa.findings.json");
|
|
682
|
+
const verdictPath = path.join(artifactRoot, "qa.verdict.json");
|
|
683
|
+
const reportPath = path.join(artifactRoot, "qa.report.md");
|
|
684
|
+
const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
|
|
685
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
|
|
686
|
+
: qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
|
|
687
|
+
const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
|
|
688
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
|
|
689
|
+
: qaArtifactRelativePath(config.workItemId, "qa.findings.json");
|
|
690
|
+
const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
|
|
691
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
|
|
692
|
+
: qaArtifactRelativePath(config.workItemId, "qa.report.md");
|
|
693
|
+
const evidenceSource = qaEvidenceSource(payload);
|
|
694
|
+
const commands = qaArray(payload, "commands", evidenceSource);
|
|
695
|
+
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
696
|
+
const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
|
|
697
|
+
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
698
|
+
const findings = qaObjectArray(payload, "findings");
|
|
699
|
+
const candidateBugs = qaObjectArray(payload, "candidate_bugs");
|
|
700
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
701
|
+
const explicitFindingIds = qaStringArray(payload, "finding_ids");
|
|
702
|
+
const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
|
|
703
|
+
const blockerReason = optionalString(payload, "blocker_reason");
|
|
704
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
705
|
+
const planJson = jsonText({
|
|
706
|
+
schema_version: 1,
|
|
707
|
+
phase: "qa",
|
|
708
|
+
...ownerFields,
|
|
709
|
+
checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
|
|
710
|
+
acceptance_criteria: testedAcceptanceCriteria
|
|
711
|
+
});
|
|
712
|
+
const evidenceJson = jsonText({
|
|
713
|
+
schema_version: 1,
|
|
714
|
+
phase: "qa",
|
|
715
|
+
...ownerFields,
|
|
716
|
+
commands,
|
|
717
|
+
browser_checks: browserChecks,
|
|
718
|
+
sidecars,
|
|
719
|
+
notes
|
|
720
|
+
});
|
|
721
|
+
const findingsJson = jsonText({
|
|
722
|
+
schema_version: 1,
|
|
723
|
+
phase: "qa",
|
|
724
|
+
...ownerFields,
|
|
725
|
+
findings,
|
|
726
|
+
candidate_bugs: candidateBugs
|
|
727
|
+
});
|
|
728
|
+
const findingIds = status === "pass"
|
|
729
|
+
? []
|
|
730
|
+
: explicitFindingIds.length > 0
|
|
731
|
+
? explicitFindingIds
|
|
732
|
+
: findings
|
|
733
|
+
.filter((finding) => normalizedString(finding.scope) === "in_scope")
|
|
734
|
+
.map((finding) => optionalString(finding, "id"))
|
|
735
|
+
.filter((id) => Boolean(id));
|
|
736
|
+
const candidateBugIds = explicitCandidateBugIds.length > 0
|
|
737
|
+
? explicitCandidateBugIds
|
|
738
|
+
: candidateBugs
|
|
739
|
+
.map((candidateBug) => optionalString(candidateBug, "id"))
|
|
740
|
+
.filter((id) => Boolean(id));
|
|
741
|
+
const verdictWithoutHash = {
|
|
742
|
+
schema_version: numberOrDefault(payload.schema_version, 1),
|
|
743
|
+
phase: "qa",
|
|
744
|
+
...ownerFields,
|
|
745
|
+
status,
|
|
746
|
+
summary: requiredString(payload, "summary"),
|
|
747
|
+
tested_acceptance_criteria: testedAcceptanceCriteria,
|
|
748
|
+
finding_ids: findingIds,
|
|
749
|
+
candidate_bug_ids: candidateBugIds,
|
|
750
|
+
blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
|
|
751
|
+
evidence_path: relativeEvidencePath,
|
|
752
|
+
findings_path: relativeFindingsPath,
|
|
753
|
+
report_path: relativeReportPath,
|
|
754
|
+
produced_by: producedBy
|
|
755
|
+
};
|
|
756
|
+
const contentHash = sha256(stableStringify({
|
|
757
|
+
plan: JSON.parse(planJson),
|
|
758
|
+
evidence: JSON.parse(evidenceJson),
|
|
759
|
+
findings: JSON.parse(findingsJson),
|
|
760
|
+
verdict: verdictWithoutHash
|
|
761
|
+
}));
|
|
762
|
+
const verdictJson = jsonText({
|
|
763
|
+
...verdictWithoutHash,
|
|
764
|
+
content_hash: contentHash
|
|
765
|
+
});
|
|
766
|
+
const finalReport = reportMarkdown(payload, "qa", status, producedBy, contentHash);
|
|
767
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
768
|
+
await writeFileAtomic(planPath, planJson);
|
|
769
|
+
await writeFileAtomic(evidencePath, evidenceJson);
|
|
770
|
+
await writeFileAtomic(findingsPath, findingsJson);
|
|
771
|
+
await writeFileAtomic(verdictPath, verdictJson);
|
|
772
|
+
await writeFileAtomic(reportPath, finalReport);
|
|
773
|
+
return {
|
|
774
|
+
phase: "qa",
|
|
775
|
+
workItemId: config.workItemId,
|
|
776
|
+
...artifactReferenceOwnerFields(config),
|
|
777
|
+
repoPath: path.resolve(config.worktreePath),
|
|
778
|
+
path: verdictPath,
|
|
779
|
+
reportPath,
|
|
780
|
+
contentHash,
|
|
781
|
+
status,
|
|
782
|
+
producedBy
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
786
|
+
if (sidecars.length === 0) {
|
|
787
|
+
return [];
|
|
788
|
+
}
|
|
789
|
+
const artifactRoot = ownerType && ownerId
|
|
790
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
791
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
792
|
+
const qaSidecarRoot = path.resolve(artifactRoot, "qa");
|
|
793
|
+
const validated = [];
|
|
794
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
795
|
+
if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
|
|
796
|
+
throw new Error(`sidecars[${index}] must be an object.`);
|
|
797
|
+
}
|
|
798
|
+
const record = sidecar;
|
|
799
|
+
const sidecarPath = optionalString(record, "path");
|
|
800
|
+
const contentHash = optionalString(record, "content_hash");
|
|
801
|
+
const bytes = record.bytes;
|
|
802
|
+
if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
|
|
803
|
+
throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
|
|
804
|
+
}
|
|
805
|
+
if (path.isAbsolute(sidecarPath)) {
|
|
806
|
+
throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
|
|
807
|
+
}
|
|
808
|
+
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
809
|
+
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
810
|
+
throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
|
|
811
|
+
}
|
|
812
|
+
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
813
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
814
|
+
throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
|
|
815
|
+
}
|
|
816
|
+
throw error;
|
|
817
|
+
});
|
|
818
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
819
|
+
throw new Error(`sidecars[${index}].path must reference a regular file.`);
|
|
820
|
+
}
|
|
821
|
+
await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
|
|
822
|
+
if (stat.size !== bytes) {
|
|
823
|
+
throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
|
|
824
|
+
}
|
|
825
|
+
const actualHash = await sha256File(fullPath);
|
|
826
|
+
if (actualHash !== contentHash.trim().toLowerCase()) {
|
|
827
|
+
throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
|
|
828
|
+
}
|
|
829
|
+
validated.push({
|
|
830
|
+
...record,
|
|
831
|
+
path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
|
|
832
|
+
content_hash: actualHash,
|
|
833
|
+
bytes: stat.size
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
return validated;
|
|
837
|
+
}
|
|
261
838
|
export async function readCompletionArtifact(worktreePath, workItemId, phase) {
|
|
262
839
|
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
|
|
263
840
|
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
|
|
@@ -302,8 +879,13 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
302
879
|
let worktreePath = null;
|
|
303
880
|
let workItemId = null;
|
|
304
881
|
let phase = "implementation";
|
|
882
|
+
let ownerType;
|
|
883
|
+
let ownerId;
|
|
305
884
|
let producedBy;
|
|
306
885
|
let developCycle = false;
|
|
886
|
+
let qaCycle = false;
|
|
887
|
+
let debugCycle = false;
|
|
888
|
+
let architectCycle = false;
|
|
307
889
|
for (let index = 1; index < argv.length; index += 1) {
|
|
308
890
|
const arg = argv[index];
|
|
309
891
|
if (arg === "--worktree") {
|
|
@@ -312,11 +894,20 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
312
894
|
else if (arg === "--work-item-id") {
|
|
313
895
|
workItemId = argv[++index] ?? null;
|
|
314
896
|
}
|
|
897
|
+
else if (arg === "--owner-type") {
|
|
898
|
+
ownerType = normalizedOwnerType(argv[++index]);
|
|
899
|
+
if (!ownerType) {
|
|
900
|
+
return { ok: false, error: "mcp --owner-type must be work_item or story." };
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
else if (arg === "--owner-id") {
|
|
904
|
+
ownerId = argv[++index] ?? undefined;
|
|
905
|
+
}
|
|
315
906
|
else if (arg === "--phase") {
|
|
316
907
|
const rawPhase = argv[++index];
|
|
317
908
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
318
909
|
if (!parsedPhase) {
|
|
319
|
-
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation." };
|
|
910
|
+
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." };
|
|
320
911
|
}
|
|
321
912
|
phase = parsedPhase;
|
|
322
913
|
}
|
|
@@ -326,6 +917,15 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
326
917
|
else if (arg === "--develop-cycle") {
|
|
327
918
|
developCycle = true;
|
|
328
919
|
}
|
|
920
|
+
else if (arg === "--qa-cycle") {
|
|
921
|
+
qaCycle = true;
|
|
922
|
+
}
|
|
923
|
+
else if (arg === "--debug-cycle") {
|
|
924
|
+
debugCycle = true;
|
|
925
|
+
}
|
|
926
|
+
else if (arg === "--architect-cycle") {
|
|
927
|
+
architectCycle = true;
|
|
928
|
+
}
|
|
329
929
|
else {
|
|
330
930
|
return { ok: false, error: `Unknown mcp argument: ${arg}` };
|
|
331
931
|
}
|
|
@@ -333,15 +933,35 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
333
933
|
if (!worktreePath?.trim() || !workItemId?.trim()) {
|
|
334
934
|
return { ok: false, error: "mcp requires --worktree and --work-item-id." };
|
|
335
935
|
}
|
|
936
|
+
if ([developCycle, qaCycle, debugCycle, architectCycle].filter(Boolean).length > 1) {
|
|
937
|
+
return { ok: false, error: "mcp accepts only one cycle mode." };
|
|
938
|
+
}
|
|
939
|
+
if (developCycle && !isDevelopCompletionPhase(phase)) {
|
|
940
|
+
return { ok: false, error: "mcp --develop-cycle requires --phase implementation, self-review, or validation." };
|
|
941
|
+
}
|
|
942
|
+
if (qaCycle && phase !== "qa") {
|
|
943
|
+
return { ok: false, error: "mcp --qa-cycle requires --phase qa." };
|
|
944
|
+
}
|
|
945
|
+
if (debugCycle && !isDebugCompletionPhase(phase)) {
|
|
946
|
+
return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
|
|
947
|
+
}
|
|
948
|
+
if (architectCycle && phase !== "architect") {
|
|
949
|
+
return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
|
|
950
|
+
}
|
|
336
951
|
return {
|
|
337
952
|
ok: true,
|
|
338
953
|
config: {
|
|
339
954
|
worktreePath: path.resolve(worktreePath),
|
|
340
955
|
workItemId,
|
|
956
|
+
ownerType,
|
|
957
|
+
ownerId,
|
|
341
958
|
phase,
|
|
342
959
|
producedBy,
|
|
343
960
|
redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
|
|
344
|
-
developCycle
|
|
961
|
+
developCycle,
|
|
962
|
+
qaCycle,
|
|
963
|
+
debugCycle,
|
|
964
|
+
architectCycle
|
|
345
965
|
}
|
|
346
966
|
};
|
|
347
967
|
}
|
|
@@ -436,21 +1056,41 @@ async function handleJsonRpcRequest(session, method, params) {
|
|
|
436
1056
|
}
|
|
437
1057
|
throw new Error(`Unsupported MCP method: ${method}`);
|
|
438
1058
|
}
|
|
439
|
-
function toolDefinition(name, phase) {
|
|
1059
|
+
function toolDefinition(name, phase, qaGate = null) {
|
|
440
1060
|
return {
|
|
441
1061
|
name,
|
|
442
|
-
description: toolDescription(phase),
|
|
443
|
-
inputSchema: toolInputSchema(phase)
|
|
1062
|
+
description: toolDescription(phase, qaGate),
|
|
1063
|
+
inputSchema: toolInputSchema(phase, qaGate)
|
|
444
1064
|
};
|
|
445
1065
|
}
|
|
446
1066
|
function nextPhaseAfter(phase) {
|
|
1067
|
+
if (!isDevelopCompletionPhase(phase)) {
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
447
1070
|
const index = completionPhaseOrder.indexOf(phase);
|
|
448
1071
|
return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
|
|
449
1072
|
}
|
|
1073
|
+
function nextDebugPhaseAfter(phase) {
|
|
1074
|
+
const index = debugCompletionPhaseOrder.indexOf(phase);
|
|
1075
|
+
return index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
|
|
1076
|
+
}
|
|
450
1077
|
function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
1078
|
+
if (completedPhase === "qa") {
|
|
1079
|
+
if (status === "blocked") {
|
|
1080
|
+
return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
|
|
1081
|
+
}
|
|
1082
|
+
if (status === "needs_rework") {
|
|
1083
|
+
return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
|
|
1084
|
+
}
|
|
1085
|
+
return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
|
|
1086
|
+
}
|
|
451
1087
|
if (status !== "pass") {
|
|
452
1088
|
return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
|
|
453
1089
|
}
|
|
1090
|
+
if (isDebugCompletionPhase(completedPhase)) {
|
|
1091
|
+
const debugNext = isDebugCompletionPhase(nextPhase) ? nextPhase : null;
|
|
1092
|
+
return completionToolNextInstructionForDebugGate(completedPhase, status, debugNext);
|
|
1093
|
+
}
|
|
454
1094
|
if (nextPhase === "self_review") {
|
|
455
1095
|
return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
|
|
456
1096
|
}
|
|
@@ -459,16 +1099,79 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
|
459
1099
|
}
|
|
460
1100
|
return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
|
|
461
1101
|
}
|
|
462
|
-
function
|
|
1102
|
+
function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
|
|
1103
|
+
if (!nextGate) {
|
|
1104
|
+
if (status === "blocked") {
|
|
1105
|
+
return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
|
|
1106
|
+
}
|
|
1107
|
+
if (status === "needs_rework") {
|
|
1108
|
+
return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
|
|
1109
|
+
}
|
|
1110
|
+
return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
|
|
1111
|
+
}
|
|
1112
|
+
const skipped = status === "skipped";
|
|
1113
|
+
const blocked = status === "blocked";
|
|
1114
|
+
const prefix = blocked
|
|
1115
|
+
? `${completedGate} recorded a blocker. Continue through ${nextGate} and mark downstream gates skipped if the blocker prevents evidence collection.`
|
|
1116
|
+
: skipped
|
|
1117
|
+
? `${completedGate} skipped. Continue to ${nextGate}.`
|
|
1118
|
+
: `${completedGate} accepted. Continue to ${nextGate}.`;
|
|
1119
|
+
return `${prefix} ${qaGateInstruction(nextGate)}`;
|
|
1120
|
+
}
|
|
1121
|
+
function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
|
|
1122
|
+
if (status !== "pass") {
|
|
1123
|
+
return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
|
|
1124
|
+
}
|
|
1125
|
+
if (nextGate === "debug_root_cause") {
|
|
1126
|
+
return "Debug repro accepted. Begin root-cause analysis: connect the observed failure to code evidence and call forge.complete_debug_root_cause.";
|
|
1127
|
+
}
|
|
1128
|
+
if (nextGate === "debug_fix") {
|
|
1129
|
+
return "Debug root cause accepted. Apply the smallest safe fix with regression coverage where practical, then call forge.complete_debug_fix.";
|
|
1130
|
+
}
|
|
1131
|
+
if (nextGate === "debug_regression") {
|
|
1132
|
+
return "Debug fix accepted. Prove the original repro is negative, run targeted regression checks, and call forge.complete_debug_regression.";
|
|
1133
|
+
}
|
|
1134
|
+
return "Debug regression accepted. Forge has the structured evidence needed to move this bug fix to QA.";
|
|
1135
|
+
}
|
|
1136
|
+
function qaGateInstruction(gate) {
|
|
1137
|
+
switch (gate) {
|
|
1138
|
+
case "qa_regression":
|
|
1139
|
+
return "Run or explicitly skip applicable regression/build/lint/typecheck/e2e checks, then call forge.complete_qa_regression.";
|
|
1140
|
+
case "qa_api":
|
|
1141
|
+
return "Run or explicitly skip applicable API/backend contract checks, then call forge.complete_qa_api.";
|
|
1142
|
+
case "qa_manual_web":
|
|
1143
|
+
return "Run or explicitly skip applicable browser/manual web checks, then call forge.complete_qa_manual_web.";
|
|
1144
|
+
case "qa_bug_repro":
|
|
1145
|
+
return "Reproduce or explicitly skip relevant defects/candidate bugs, then call forge.complete_qa_bug_repro.";
|
|
1146
|
+
case "qa_findings":
|
|
1147
|
+
return "Normalize observations into findings and candidate bugs, then call forge.complete_qa_findings.";
|
|
1148
|
+
case "qa_verdict":
|
|
1149
|
+
return "Assemble the final QA verdict from all prior gate evidence, then call forge.complete_qa_verdict.";
|
|
1150
|
+
case "qa_plan":
|
|
1151
|
+
return "Prepare the QA checklist, then call forge.complete_qa_plan.";
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
function toolDescription(phase, qaGate = null) {
|
|
1155
|
+
if (qaGate) {
|
|
1156
|
+
return qaGate === "qa_verdict"
|
|
1157
|
+
? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
|
|
1158
|
+
: `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
|
|
1159
|
+
}
|
|
1160
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1161
|
+
return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
|
|
1162
|
+
}
|
|
463
1163
|
if (phase === "implementation") {
|
|
464
1164
|
return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
|
|
465
1165
|
}
|
|
466
1166
|
if (phase === "self_review") {
|
|
467
1167
|
return "Complete Forge self-review by writing self-review.report.json with diff review, findings, security, edge-case, design, deterministic-comparison, fixes, and remaining-risk evidence.";
|
|
468
1168
|
}
|
|
1169
|
+
if (phase === "qa") {
|
|
1170
|
+
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.";
|
|
1171
|
+
}
|
|
469
1172
|
return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
|
|
470
1173
|
}
|
|
471
|
-
function toolInputSchema(phase) {
|
|
1174
|
+
function toolInputSchema(phase, qaGate = null) {
|
|
472
1175
|
const baseProperties = {
|
|
473
1176
|
status: {
|
|
474
1177
|
type: "string",
|
|
@@ -487,6 +1190,12 @@ function toolInputSchema(phase) {
|
|
|
487
1190
|
})
|
|
488
1191
|
}
|
|
489
1192
|
};
|
|
1193
|
+
if (qaGate && qaGate !== "qa_verdict") {
|
|
1194
|
+
return qaGateToolInputSchema(qaGate);
|
|
1195
|
+
}
|
|
1196
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1197
|
+
return debugToolInputSchema(phase, baseProperties);
|
|
1198
|
+
}
|
|
490
1199
|
if (phase === "implementation") {
|
|
491
1200
|
return objectToolSchema({
|
|
492
1201
|
...baseProperties,
|
|
@@ -588,6 +1297,93 @@ function toolInputSchema(phase) {
|
|
|
588
1297
|
}
|
|
589
1298
|
}, ["status", "summary"], "For status pass, include reviewed_diff, findings, security_review, edge_case_review, design_review, deterministic_comparison, fixes_applied, and remaining_risks.");
|
|
590
1299
|
}
|
|
1300
|
+
if (phase === "qa") {
|
|
1301
|
+
return objectToolSchema({
|
|
1302
|
+
...baseProperties,
|
|
1303
|
+
status: {
|
|
1304
|
+
type: "string",
|
|
1305
|
+
enum: ["pass", "needs_rework", "blocked"],
|
|
1306
|
+
description: "Use pass only when QA evidence satisfies acceptance criteria, needs_rework for actionable in-scope defects, or blocked for an inconclusive QA blocker."
|
|
1307
|
+
},
|
|
1308
|
+
checklist: {
|
|
1309
|
+
type: "array",
|
|
1310
|
+
description: "QA plan/checklist. Forge writes this to qa.plan.json; a default checklist is generated if omitted.",
|
|
1311
|
+
items: { type: "string" }
|
|
1312
|
+
},
|
|
1313
|
+
tested_acceptance_criteria: {
|
|
1314
|
+
type: "array",
|
|
1315
|
+
description: "Required and non-empty for pass/needs_rework. Criteria actually tested by QA.",
|
|
1316
|
+
items: { type: "string" }
|
|
1317
|
+
},
|
|
1318
|
+
commands: {
|
|
1319
|
+
type: "array",
|
|
1320
|
+
description: "Command evidence written to qa.evidence.json.",
|
|
1321
|
+
items: commandEvidenceSchema()
|
|
1322
|
+
},
|
|
1323
|
+
browser_checks: {
|
|
1324
|
+
type: "array",
|
|
1325
|
+
description: "Browser/manual UI checks with URL or route, action, observed result, screenshot/trace references, console/network notes, and status.",
|
|
1326
|
+
items: evidenceObjectSchema(["summary", "status"], {
|
|
1327
|
+
summary: { type: "string" },
|
|
1328
|
+
url: { type: "string" },
|
|
1329
|
+
route: { type: "string" },
|
|
1330
|
+
status: { type: "string" },
|
|
1331
|
+
screenshot: { type: "string" },
|
|
1332
|
+
trace: { type: "string" }
|
|
1333
|
+
})
|
|
1334
|
+
},
|
|
1335
|
+
sidecars: {
|
|
1336
|
+
type: "array",
|
|
1337
|
+
description: "Optional QA sidecar files already written under .forge/work-items/<id>/artifacts/qa/ with path/content_hash/bytes metadata.",
|
|
1338
|
+
items: evidenceObjectSchema(["path", "content_hash", "bytes"], {
|
|
1339
|
+
path: { type: "string" },
|
|
1340
|
+
content_hash: { type: "string" },
|
|
1341
|
+
bytes: { type: "number" }
|
|
1342
|
+
})
|
|
1343
|
+
},
|
|
1344
|
+
notes: { type: "string", description: "Additional QA evidence notes." },
|
|
1345
|
+
evidence: {
|
|
1346
|
+
type: "object",
|
|
1347
|
+
description: "Optional wrapper containing commands, browser_checks, and notes when a client prefers nested evidence."
|
|
1348
|
+
},
|
|
1349
|
+
findings: {
|
|
1350
|
+
type: "array",
|
|
1351
|
+
description: "QA findings. needs_rework requires at least one actionable in_scope finding.",
|
|
1352
|
+
items: evidenceObjectSchema(["id", "severity", "scope", "title"], {
|
|
1353
|
+
id: { type: "string" },
|
|
1354
|
+
severity: { type: "string", enum: ["critical", "major", "minor"] },
|
|
1355
|
+
scope: { type: "string", enum: ["in_scope", "out_of_scope", "unclear"] },
|
|
1356
|
+
title: { type: "string" },
|
|
1357
|
+
acceptance_criteria: { type: "array", items: { type: "string" } },
|
|
1358
|
+
repro_steps: { type: "array", items: { type: "string" } },
|
|
1359
|
+
expected: { type: "string" },
|
|
1360
|
+
actual: { type: "string" },
|
|
1361
|
+
evidence: { type: "object" },
|
|
1362
|
+
likely_area: { type: "array", items: { type: "string" } },
|
|
1363
|
+
required_fix: { type: "string" },
|
|
1364
|
+
required_regression: { type: "string" }
|
|
1365
|
+
})
|
|
1366
|
+
},
|
|
1367
|
+
candidate_bugs: {
|
|
1368
|
+
type: "array",
|
|
1369
|
+
description: "Out-of-scope/follow-up bug candidates. They do not block the current item unless attached as in-scope findings.",
|
|
1370
|
+
items: evidenceObjectSchema(["id", "title", "suggested_action"], {
|
|
1371
|
+
id: { type: "string" },
|
|
1372
|
+
title: { type: "string" },
|
|
1373
|
+
severity: { type: "string" },
|
|
1374
|
+
scope_reason: { type: "string" },
|
|
1375
|
+
repro_steps: { type: "array", items: { type: "string" } },
|
|
1376
|
+
evidence: { type: "object" },
|
|
1377
|
+
suggested_action: { type: "string", enum: ["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"] }
|
|
1378
|
+
})
|
|
1379
|
+
},
|
|
1380
|
+
blocker_reason: {
|
|
1381
|
+
type: "string",
|
|
1382
|
+
enum: [...qaBlockerReasons],
|
|
1383
|
+
description: "Required when status is blocked."
|
|
1384
|
+
}
|
|
1385
|
+
}, ["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.");
|
|
1386
|
+
}
|
|
591
1387
|
return objectToolSchema({
|
|
592
1388
|
...baseProperties,
|
|
593
1389
|
commands: {
|
|
@@ -612,6 +1408,144 @@ function toolInputSchema(phase) {
|
|
|
612
1408
|
ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
|
|
613
1409
|
}, ["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.");
|
|
614
1410
|
}
|
|
1411
|
+
function debugToolInputSchema(phase, baseProperties) {
|
|
1412
|
+
if (phase === "debug_repro") {
|
|
1413
|
+
return objectToolSchema({
|
|
1414
|
+
...baseProperties,
|
|
1415
|
+
repro_steps: {
|
|
1416
|
+
type: "array",
|
|
1417
|
+
description: "Required for pass. Minimal reproduction or characterization steps.",
|
|
1418
|
+
items: { type: "string" }
|
|
1419
|
+
},
|
|
1420
|
+
observed: { type: "string", description: "Observed failure behavior." },
|
|
1421
|
+
expected: { type: "string", description: "Expected behavior." },
|
|
1422
|
+
evidence: {
|
|
1423
|
+
type: "array",
|
|
1424
|
+
description: "Required for pass. Concrete logs, commands, screenshots, file refs, or characterization notes.",
|
|
1425
|
+
items: { type: "object", additionalProperties: true }
|
|
1426
|
+
}
|
|
1427
|
+
}, ["status", "summary"], "For status pass, include repro_steps, observed, expected, and evidence.");
|
|
1428
|
+
}
|
|
1429
|
+
if (phase === "debug_root_cause") {
|
|
1430
|
+
return objectToolSchema({
|
|
1431
|
+
...baseProperties,
|
|
1432
|
+
root_cause: { type: "string", description: "Likely root cause tied to code or config evidence." },
|
|
1433
|
+
evidence: {
|
|
1434
|
+
type: "array",
|
|
1435
|
+
description: "Required for pass. Concrete code/log/test evidence supporting the root cause.",
|
|
1436
|
+
items: { type: "object", additionalProperties: true }
|
|
1437
|
+
},
|
|
1438
|
+
eliminated_alternatives: {
|
|
1439
|
+
type: "array",
|
|
1440
|
+
description: "Required for pass. Plausible alternatives checked or ruled out.",
|
|
1441
|
+
items: { type: "object", additionalProperties: true }
|
|
1442
|
+
},
|
|
1443
|
+
risk_area: { type: "string", description: "Subsystem or behavior most at risk from the fix." }
|
|
1444
|
+
}, ["status", "summary"], "For status pass, include root_cause, evidence, eliminated_alternatives, and risk_area.");
|
|
1445
|
+
}
|
|
1446
|
+
if (phase === "debug_fix") {
|
|
1447
|
+
return objectToolSchema({
|
|
1448
|
+
...baseProperties,
|
|
1449
|
+
changed_files: {
|
|
1450
|
+
type: "array",
|
|
1451
|
+
description: "Required for pass. Files changed by the bug fix.",
|
|
1452
|
+
items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
|
|
1453
|
+
},
|
|
1454
|
+
tests_added_or_updated: {
|
|
1455
|
+
type: "array",
|
|
1456
|
+
description: "Required for pass. Regression tests or checks added/updated, or justify omission in remaining_risks.",
|
|
1457
|
+
items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
|
|
1458
|
+
},
|
|
1459
|
+
remaining_risks: {
|
|
1460
|
+
type: "array",
|
|
1461
|
+
description: "Required for pass, even when empty. Include why no regression coverage changed when tests_added_or_updated is empty.",
|
|
1462
|
+
items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
|
|
1463
|
+
},
|
|
1464
|
+
resolved_findings: {
|
|
1465
|
+
type: "array",
|
|
1466
|
+
description: "Required for QA rework. Entries should include qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.",
|
|
1467
|
+
items: { type: "object", additionalProperties: true }
|
|
1468
|
+
}
|
|
1469
|
+
}, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
|
|
1470
|
+
}
|
|
1471
|
+
return objectToolSchema({
|
|
1472
|
+
...baseProperties,
|
|
1473
|
+
commands: {
|
|
1474
|
+
type: "array",
|
|
1475
|
+
description: "Required for pass. Targeted regression/repro-negative commands with exit_code: 0 and status: pass.",
|
|
1476
|
+
items: commandEvidenceSchema()
|
|
1477
|
+
},
|
|
1478
|
+
required_regressions: {
|
|
1479
|
+
type: "array",
|
|
1480
|
+
description: "Required for pass. Repro-negative and regression coverage checklist.",
|
|
1481
|
+
items: { type: "string" }
|
|
1482
|
+
},
|
|
1483
|
+
build_status: { type: "string", description: "Required for pass. pass or not_applicable." },
|
|
1484
|
+
test_status: { type: "string", description: "Required for pass. pass or not_applicable." },
|
|
1485
|
+
repro_negative: { type: "boolean", description: "Required for pass and must be true after the original repro no longer fails." },
|
|
1486
|
+
qa_ready: { type: "boolean", description: "Required for pass and must be true." }
|
|
1487
|
+
}, ["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.");
|
|
1488
|
+
}
|
|
1489
|
+
function qaGateToolInputSchema(gate) {
|
|
1490
|
+
const baseProperties = {
|
|
1491
|
+
status: {
|
|
1492
|
+
type: "string",
|
|
1493
|
+
enum: ["pass", "skipped", "blocked"],
|
|
1494
|
+
description: "Use pass when this QA gate produced evidence, skipped when not applicable, or blocked when a prerequisite prevents this gate."
|
|
1495
|
+
},
|
|
1496
|
+
summary: { type: "string", description: "Concise QA gate summary." },
|
|
1497
|
+
reason: { type: "string", description: "Required for skipped or blocked gates." },
|
|
1498
|
+
blocker_reason: {
|
|
1499
|
+
type: "string",
|
|
1500
|
+
enum: [...qaBlockerReasons],
|
|
1501
|
+
description: "Required when status is blocked."
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
if (gate === "qa_plan") {
|
|
1505
|
+
return objectToolSchema({
|
|
1506
|
+
...baseProperties,
|
|
1507
|
+
checklist: {
|
|
1508
|
+
type: "array",
|
|
1509
|
+
description: "Required for pass. QA checklist entries planned for this work item.",
|
|
1510
|
+
items: { type: "string" }
|
|
1511
|
+
},
|
|
1512
|
+
acceptance_criteria: {
|
|
1513
|
+
type: "array",
|
|
1514
|
+
description: "Acceptance criteria selected for QA.",
|
|
1515
|
+
items: { type: "string" }
|
|
1516
|
+
}
|
|
1517
|
+
}, ["status", "summary"], "Record the QA plan gate. Pass requires a non-empty checklist.");
|
|
1518
|
+
}
|
|
1519
|
+
if (gate === "qa_findings") {
|
|
1520
|
+
return objectToolSchema({
|
|
1521
|
+
...baseProperties,
|
|
1522
|
+
findings: {
|
|
1523
|
+
type: "array",
|
|
1524
|
+
description: "Normalized in-scope and out-of-scope QA findings.",
|
|
1525
|
+
items: { type: "object", additionalProperties: true }
|
|
1526
|
+
},
|
|
1527
|
+
candidate_bugs: {
|
|
1528
|
+
type: "array",
|
|
1529
|
+
description: "Out-of-scope or follow-up candidates.",
|
|
1530
|
+
items: { type: "object", additionalProperties: true }
|
|
1531
|
+
}
|
|
1532
|
+
}, ["status", "summary"], "Record normalized QA findings. Pass or skipped requires findings and candidate_bugs arrays, even when empty.");
|
|
1533
|
+
}
|
|
1534
|
+
return objectToolSchema({
|
|
1535
|
+
...baseProperties,
|
|
1536
|
+
commands: {
|
|
1537
|
+
type: "array",
|
|
1538
|
+
description: "Command evidence for this gate when applicable.",
|
|
1539
|
+
items: commandEvidenceSchema()
|
|
1540
|
+
},
|
|
1541
|
+
browser_checks: {
|
|
1542
|
+
type: "array",
|
|
1543
|
+
description: "Browser/manual evidence for this gate when applicable.",
|
|
1544
|
+
items: { type: "object", additionalProperties: true }
|
|
1545
|
+
},
|
|
1546
|
+
notes: { type: "string", description: "Evidence notes for this gate." }
|
|
1547
|
+
}, ["status", "summary"], "Record this QA evidence gate. Use skipped with a reason when the gate is not applicable.");
|
|
1548
|
+
}
|
|
615
1549
|
function objectToolSchema(properties, required, description) {
|
|
616
1550
|
return {
|
|
617
1551
|
type: "object",
|
|
@@ -662,8 +1596,22 @@ function reviewSectionProperties() {
|
|
|
662
1596
|
notes: { type: "string" }
|
|
663
1597
|
};
|
|
664
1598
|
}
|
|
665
|
-
function normalizeCompletionStatus(value) {
|
|
1599
|
+
function normalizeCompletionStatus(value, phase) {
|
|
666
1600
|
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
1601
|
+
if (phase === "qa") {
|
|
1602
|
+
const canonical = normalized === "needsrework" ? "needs_rework" : normalized;
|
|
1603
|
+
if (canonical !== "pass" && canonical !== "needs_rework" && canonical !== "blocked") {
|
|
1604
|
+
throw new Error("QA status must be pass, needs_rework, or blocked.");
|
|
1605
|
+
}
|
|
1606
|
+
return canonical;
|
|
1607
|
+
}
|
|
1608
|
+
if (phase === "architect") {
|
|
1609
|
+
const canonical = normalized === "needsrevision" ? "needs_revision" : normalized;
|
|
1610
|
+
if (canonical !== "pass" && canonical !== "needs_revision" && canonical !== "blocked") {
|
|
1611
|
+
throw new Error("Architect status must be pass, needs_revision, or blocked.");
|
|
1612
|
+
}
|
|
1613
|
+
return canonical;
|
|
1614
|
+
}
|
|
667
1615
|
const canonical = normalized === "blocked" || normalized === "needsrework"
|
|
668
1616
|
? "needs_rework"
|
|
669
1617
|
: normalized === "failed"
|
|
@@ -674,17 +1622,36 @@ function normalizeCompletionStatus(value) {
|
|
|
674
1622
|
}
|
|
675
1623
|
return canonical;
|
|
676
1624
|
}
|
|
1625
|
+
function normalizeQaGateStatus(value) {
|
|
1626
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
1627
|
+
if (normalized !== "pass" && normalized !== "skipped" && normalized !== "blocked") {
|
|
1628
|
+
throw new Error("QA gate status must be pass, skipped, or blocked.");
|
|
1629
|
+
}
|
|
1630
|
+
return normalized;
|
|
1631
|
+
}
|
|
677
1632
|
function validatePayloadShape(phase, payload) {
|
|
678
|
-
const status = normalizeCompletionStatus(requiredString(payload, "status"));
|
|
1633
|
+
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
679
1634
|
if (!status) {
|
|
680
1635
|
throw new Error("status is required.");
|
|
681
1636
|
}
|
|
682
|
-
const pass = status === "pass";
|
|
683
1637
|
requireField(payload, "summary");
|
|
1638
|
+
if (phase === "qa") {
|
|
1639
|
+
validateQaPayloadShape(payload, status);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
if (phase === "architect") {
|
|
1643
|
+
validateArchitectPayloadShape(payload, status);
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
const pass = status === "pass";
|
|
684
1647
|
if (!pass) {
|
|
685
1648
|
requireNonPassEvidence(payload);
|
|
686
1649
|
return;
|
|
687
1650
|
}
|
|
1651
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1652
|
+
validateDebugPayloadShape(phase, payload);
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
688
1655
|
if (phase === "implementation") {
|
|
689
1656
|
requireArray(payload, "plan");
|
|
690
1657
|
requireArray(payload, "changed_files");
|
|
@@ -722,6 +1689,241 @@ function validatePayloadShape(phase, payload) {
|
|
|
722
1689
|
requirePassingCommands(payload);
|
|
723
1690
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
724
1691
|
}
|
|
1692
|
+
function validateDebugPayloadShape(phase, payload) {
|
|
1693
|
+
if (phase === "debug_repro") {
|
|
1694
|
+
requireArray(payload, "repro_steps");
|
|
1695
|
+
requireField(payload, "observed");
|
|
1696
|
+
requireField(payload, "expected");
|
|
1697
|
+
requireArray(payload, "evidence");
|
|
1698
|
+
requireNonEmptyArray(payload, "repro_steps");
|
|
1699
|
+
requireNonEmptyArray(payload, "evidence");
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
if (phase === "debug_root_cause") {
|
|
1703
|
+
requireField(payload, "root_cause");
|
|
1704
|
+
requireArray(payload, "evidence");
|
|
1705
|
+
requireArray(payload, "eliminated_alternatives");
|
|
1706
|
+
requireField(payload, "risk_area");
|
|
1707
|
+
requireNonEmptyArray(payload, "evidence");
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
if (phase === "debug_fix") {
|
|
1711
|
+
requireArray(payload, "changed_files");
|
|
1712
|
+
requireArray(payload, "tests_added_or_updated");
|
|
1713
|
+
requireArray(payload, "remaining_risks");
|
|
1714
|
+
requireNonEmptyArray(payload, "changed_files");
|
|
1715
|
+
if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
|
|
1716
|
+
throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
|
|
1717
|
+
}
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
requireArray(payload, "commands");
|
|
1721
|
+
requireArray(payload, "required_regressions");
|
|
1722
|
+
requireField(payload, "build_status");
|
|
1723
|
+
requireField(payload, "test_status");
|
|
1724
|
+
requireBoolean(payload, "repro_negative");
|
|
1725
|
+
requireBoolean(payload, "qa_ready");
|
|
1726
|
+
if (payload.repro_negative !== true) {
|
|
1727
|
+
throw new Error("debug_regression pass requires repro_negative: true.");
|
|
1728
|
+
}
|
|
1729
|
+
if (payload.qa_ready !== true) {
|
|
1730
|
+
throw new Error("debug_regression pass requires qa_ready: true.");
|
|
1731
|
+
}
|
|
1732
|
+
requireAllowedStatus(payload, "build_status", ["pass", "not_applicable"]);
|
|
1733
|
+
requireAllowedStatus(payload, "test_status", ["pass", "not_applicable"]);
|
|
1734
|
+
requireNonEmptyArray(payload, "required_regressions");
|
|
1735
|
+
requirePassingCommands(payload);
|
|
1736
|
+
}
|
|
1737
|
+
function validateQaGatePayloadShape(gate, payload, status) {
|
|
1738
|
+
requireField(payload, "summary");
|
|
1739
|
+
if (status === "skipped") {
|
|
1740
|
+
requireField(payload, "reason");
|
|
1741
|
+
}
|
|
1742
|
+
if (status === "blocked") {
|
|
1743
|
+
const reason = normalizedString(optionalString(payload, "blocker_reason"));
|
|
1744
|
+
if (!qaBlockerReasons.has(reason)) {
|
|
1745
|
+
throw new Error("QA gate blocked requires blocker_reason to be one of missing_app_launch_target, inconclusive, environment_failure, missing_validation_evidence, missing_worktree, or runner_failure.");
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
if (gate === "qa_plan" && status === "pass") {
|
|
1749
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
1750
|
+
if (checklist.length === 0) {
|
|
1751
|
+
throw new Error("complete_qa_plan pass requires checklist with at least one entry.");
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
if (gate === "qa_findings" && status !== "blocked") {
|
|
1755
|
+
qaObjectArray(payload, "findings");
|
|
1756
|
+
qaObjectArray(payload, "candidate_bugs");
|
|
1757
|
+
}
|
|
1758
|
+
if ((gate === "qa_regression" || gate === "qa_api") && status === "pass") {
|
|
1759
|
+
const commands = qaArray(payload, "commands");
|
|
1760
|
+
if (commands.length === 0) {
|
|
1761
|
+
throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
if (gate === "qa_manual_web" && status === "pass") {
|
|
1765
|
+
const browserChecks = qaArray(payload, "browser_checks");
|
|
1766
|
+
const notes = optionalString(payload, "notes");
|
|
1767
|
+
if (browserChecks.length === 0 && !notes) {
|
|
1768
|
+
throw new Error(`${qaGateToolNames[gate]} pass requires browser_checks or notes.`);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
function validateArchitectPayloadShape(payload, status) {
|
|
1773
|
+
if (status !== "pass") {
|
|
1774
|
+
requireNonPassEvidence(payload);
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
1778
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
1779
|
+
requireArray(implementationPlan, "phases");
|
|
1780
|
+
requireArray(implementationPlan, "tasks");
|
|
1781
|
+
requireArray(implementationPlan, "dependencies");
|
|
1782
|
+
requireArray(implementationPlan, "phase_gates");
|
|
1783
|
+
if (arrayLength(implementationPlan, "tasks") === 0) {
|
|
1784
|
+
throw new Error("implementation_plan.tasks must contain at least one task.");
|
|
1785
|
+
}
|
|
1786
|
+
for (const task of implementationPlan.tasks) {
|
|
1787
|
+
if (!task || typeof task !== "object" || Array.isArray(task)) {
|
|
1788
|
+
throw new Error("implementation_plan.tasks entries must be objects.");
|
|
1789
|
+
}
|
|
1790
|
+
const taskRecord = task;
|
|
1791
|
+
requireField(taskRecord, "id");
|
|
1792
|
+
requireArray(taskRecord, "tests");
|
|
1793
|
+
if (arrayLength(taskRecord, "tests") === 0) {
|
|
1794
|
+
throw new Error("implementation_plan task tests must not be empty.");
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
requireArray(qaPlan, "final_qa");
|
|
1798
|
+
if (arrayLength(qaPlan, "final_qa") === 0) {
|
|
1799
|
+
throw new Error("qa_plan.final_qa must contain at least one coverage item.");
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
function validateQaPayloadShape(payload, status) {
|
|
1803
|
+
const evidenceSource = qaEvidenceSource(payload);
|
|
1804
|
+
const commands = qaArray(payload, "commands", evidenceSource);
|
|
1805
|
+
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
1806
|
+
const sidecars = qaArray(payload, "sidecars", evidenceSource);
|
|
1807
|
+
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
1808
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
1809
|
+
const findings = qaObjectArray(payload, "findings");
|
|
1810
|
+
const candidateBugs = qaObjectArray(payload, "candidate_bugs");
|
|
1811
|
+
const explicitFindingIds = qaStringArray(payload, "finding_ids");
|
|
1812
|
+
const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
|
|
1813
|
+
if (payload.schema_version !== undefined && payload.schema_version !== 1) {
|
|
1814
|
+
throw new Error("QA schema_version must be numeric 1 when provided.");
|
|
1815
|
+
}
|
|
1816
|
+
if (status === "pass" || status === "needs_rework") {
|
|
1817
|
+
if (testedAcceptanceCriteria.length === 0) {
|
|
1818
|
+
throw new Error("QA pass/needs_rework requires tested_acceptance_criteria with at least one criterion.");
|
|
1819
|
+
}
|
|
1820
|
+
if (commands.length === 0 && browserChecks.length === 0 && notes.length === 0) {
|
|
1821
|
+
throw new Error("QA pass/needs_rework requires at least one command, browser check, or evidence note.");
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
for (const [index, command] of commands.entries()) {
|
|
1825
|
+
requireEvidenceRecord(command, `commands[${index}]`);
|
|
1826
|
+
const record = command;
|
|
1827
|
+
requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
|
|
1828
|
+
requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
|
|
1829
|
+
requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
|
|
1830
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
1831
|
+
throw new Error(`commands[${index}].exit_code is required.`);
|
|
1832
|
+
}
|
|
1833
|
+
if (status === "pass" && record.exit_code !== 0) {
|
|
1834
|
+
throw new Error(`commands[${index}].exit_code must be 0 when QA status is pass.`);
|
|
1835
|
+
}
|
|
1836
|
+
if (status === "pass" && normalizedString(record.status) !== "pass") {
|
|
1837
|
+
throw new Error(`commands[${index}].status must be pass when QA status is pass.`);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
for (const [index, browserCheck] of browserChecks.entries()) {
|
|
1841
|
+
requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
|
|
1842
|
+
const record = browserCheck;
|
|
1843
|
+
requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
|
|
1844
|
+
requireNonEmptyStringRecord(record, "status", `browser_checks[${index}].`);
|
|
1845
|
+
const checkStatus = optionalString(record, "status");
|
|
1846
|
+
if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
|
|
1847
|
+
throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
|
|
1848
|
+
}
|
|
1849
|
+
if (!optionalString(record, "url") && !optionalString(record, "route")) {
|
|
1850
|
+
throw new Error(`browser_checks[${index}] must include url or route.`);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
1854
|
+
requireEvidenceRecord(sidecar, `sidecars[${index}]`);
|
|
1855
|
+
const record = sidecar;
|
|
1856
|
+
requireNonEmptyStringRecord(record, "path", `sidecars[${index}].`);
|
|
1857
|
+
requireNonEmptyStringRecord(record, "content_hash", `sidecars[${index}].`);
|
|
1858
|
+
if (typeof record.bytes !== "number" || !Number.isFinite(record.bytes) || record.bytes <= 0) {
|
|
1859
|
+
throw new Error(`sidecars[${index}].bytes must be greater than 0.`);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
for (const [index, finding] of findings.entries()) {
|
|
1863
|
+
requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
|
|
1864
|
+
requireNonEmptyStringRecord(finding, "title", `findings[${index}].`);
|
|
1865
|
+
requireKnownStringRecord(finding, "severity", qaFindingSeverities, `findings[${index}].`);
|
|
1866
|
+
requireKnownStringRecord(finding, "scope", qaFindingScopes, `findings[${index}].`);
|
|
1867
|
+
if (normalizedString(finding.scope) === "in_scope") {
|
|
1868
|
+
validateActionableQaFinding(finding, index);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
for (const [index, candidateBug] of candidateBugs.entries()) {
|
|
1872
|
+
requireNonEmptyStringRecord(candidateBug, "id", `candidate_bugs[${index}].`);
|
|
1873
|
+
requireNonEmptyStringRecord(candidateBug, "title", `candidate_bugs[${index}].`);
|
|
1874
|
+
requireKnownStringRecord(candidateBug, "suggested_action", qaCandidateBugActions, `candidate_bugs[${index}].`);
|
|
1875
|
+
}
|
|
1876
|
+
const inScopeFindingIds = findings
|
|
1877
|
+
.filter((finding) => normalizedString(finding.scope) === "in_scope")
|
|
1878
|
+
.map((finding) => optionalString(finding, "id"))
|
|
1879
|
+
.filter((id) => Boolean(id));
|
|
1880
|
+
for (const findingId of explicitFindingIds) {
|
|
1881
|
+
if (!inScopeFindingIds.includes(findingId)) {
|
|
1882
|
+
throw new Error(`QA finding_ids contains '${findingId}', but findings has no matching in-scope finding.`);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
const candidateBugIds = candidateBugs
|
|
1886
|
+
.map((candidateBug) => optionalString(candidateBug, "id"))
|
|
1887
|
+
.filter((id) => Boolean(id));
|
|
1888
|
+
for (const candidateBugId of explicitCandidateBugIds) {
|
|
1889
|
+
if (!candidateBugIds.includes(candidateBugId)) {
|
|
1890
|
+
throw new Error(`QA candidate_bug_ids contains '${candidateBugId}', but candidate_bugs has no matching candidate bug.`);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
if (status === "pass") {
|
|
1894
|
+
if (explicitFindingIds.length > 0 || inScopeFindingIds.length > 0) {
|
|
1895
|
+
throw new Error("QA pass must not include in-scope finding_ids or in-scope findings.");
|
|
1896
|
+
}
|
|
1897
|
+
if (optionalString(payload, "blocker_reason")) {
|
|
1898
|
+
throw new Error("QA pass must not include blocker_reason.");
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
if (status === "needs_rework") {
|
|
1902
|
+
if (inScopeFindingIds.length === 0) {
|
|
1903
|
+
throw new Error("QA needs_rework requires at least one actionable in-scope finding.");
|
|
1904
|
+
}
|
|
1905
|
+
if (optionalString(payload, "blocker_reason")) {
|
|
1906
|
+
throw new Error("QA needs_rework must not include blocker_reason.");
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
if (status === "blocked") {
|
|
1910
|
+
const reason = normalizedString(optionalString(payload, "blocker_reason"));
|
|
1911
|
+
if (!qaBlockerReasons.has(reason)) {
|
|
1912
|
+
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.");
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
function validateActionableQaFinding(finding, index) {
|
|
1917
|
+
const prefix = `findings[${index}].`;
|
|
1918
|
+
requireNonEmptyStringArrayRecord(finding, "acceptance_criteria", prefix);
|
|
1919
|
+
requireNonEmptyStringArrayRecord(finding, "repro_steps", prefix);
|
|
1920
|
+
requireNonEmptyStringRecord(finding, "expected", prefix);
|
|
1921
|
+
requireNonEmptyStringRecord(finding, "actual", prefix);
|
|
1922
|
+
requireSubstantiveEvidenceRecord(finding, prefix);
|
|
1923
|
+
requireNonEmptyStringArrayRecord(finding, "likely_area", prefix);
|
|
1924
|
+
requireNonEmptyStringRecord(finding, "required_fix", prefix);
|
|
1925
|
+
requireNonEmptyStringRecord(finding, "required_regression", prefix);
|
|
1926
|
+
}
|
|
725
1927
|
function validateOptionalWorkItemId(payload, expectedWorkItemId) {
|
|
726
1928
|
const actual = optionalString(payload, "work_item_id") ?? optionalString(payload, "workItemId");
|
|
727
1929
|
if (actual && actual !== expectedWorkItemId) {
|
|
@@ -736,10 +1938,36 @@ function normalizeArtifactPayload(payload, defaults) {
|
|
|
736
1938
|
normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
|
|
737
1939
|
normalized.phase = defaults.phase;
|
|
738
1940
|
normalized.work_item_id = defaults.workItemId;
|
|
1941
|
+
const ownerFields = artifactOwnerFields(defaults);
|
|
1942
|
+
Object.assign(normalized, ownerFields);
|
|
739
1943
|
normalized.status = defaults.status;
|
|
740
1944
|
normalized.produced_by = defaults.producedBy;
|
|
741
1945
|
return normalized;
|
|
742
1946
|
}
|
|
1947
|
+
function normalizeQaGateArtifactPayload(payload, defaults) {
|
|
1948
|
+
const normalized = stripUndefined({ ...payload });
|
|
1949
|
+
delete normalized.report_markdown;
|
|
1950
|
+
delete normalized.content_hash;
|
|
1951
|
+
delete normalized.contentHash;
|
|
1952
|
+
normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
|
|
1953
|
+
normalized.phase = "qa";
|
|
1954
|
+
normalized.gate = defaults.gate;
|
|
1955
|
+
normalized.work_item_id = defaults.workItemId;
|
|
1956
|
+
const ownerFields = artifactOwnerFields(defaults);
|
|
1957
|
+
Object.assign(normalized, ownerFields);
|
|
1958
|
+
normalized.status = defaults.status;
|
|
1959
|
+
normalized.produced_by = defaults.producedBy;
|
|
1960
|
+
if (defaults.gate === "qa_plan") {
|
|
1961
|
+
const checklist = qaStringArray(normalized, "checklist");
|
|
1962
|
+
normalized.checklist = checklist.length > 0 ? checklist : defaultQaChecklist(defaults.status);
|
|
1963
|
+
normalized.acceptance_criteria = qaStringArray(normalized, "acceptance_criteria");
|
|
1964
|
+
}
|
|
1965
|
+
if (defaults.gate === "qa_findings") {
|
|
1966
|
+
normalized.findings = qaObjectArray(normalized, "findings");
|
|
1967
|
+
normalized.candidate_bugs = qaObjectArray(normalized, "candidate_bugs");
|
|
1968
|
+
}
|
|
1969
|
+
return normalized;
|
|
1970
|
+
}
|
|
743
1971
|
function reportMarkdown(payload, phase, status, producedBy, contentHash) {
|
|
744
1972
|
const explicit = optionalString(payload, "report_markdown");
|
|
745
1973
|
if (explicit) {
|
|
@@ -759,6 +1987,10 @@ ${summary}
|
|
|
759
1987
|
}
|
|
760
1988
|
function parseStrictCompletionPhase(value) {
|
|
761
1989
|
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
1990
|
+
const debugPhase = normalizeDebugCompletionPhase(normalized);
|
|
1991
|
+
if (debugPhase) {
|
|
1992
|
+
return debugPhase;
|
|
1993
|
+
}
|
|
762
1994
|
if (normalized === "implementation" || normalized === "implement" || normalized === "develop") {
|
|
763
1995
|
return "implementation";
|
|
764
1996
|
}
|
|
@@ -768,8 +2000,51 @@ function parseStrictCompletionPhase(value) {
|
|
|
768
2000
|
if (normalized === "validate" || normalized === "validation") {
|
|
769
2001
|
return "validation";
|
|
770
2002
|
}
|
|
2003
|
+
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
2004
|
+
return "qa";
|
|
2005
|
+
}
|
|
2006
|
+
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
2007
|
+
return "architect";
|
|
2008
|
+
}
|
|
771
2009
|
return null;
|
|
772
2010
|
}
|
|
2011
|
+
function normalizeDebugCompletionPhase(value) {
|
|
2012
|
+
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
2013
|
+
switch (normalized) {
|
|
2014
|
+
case "debug_repro":
|
|
2015
|
+
case "debugrepro":
|
|
2016
|
+
case "repro":
|
|
2017
|
+
case "reproduce":
|
|
2018
|
+
return "debug_repro";
|
|
2019
|
+
case "debug_root_cause":
|
|
2020
|
+
case "debugrootcause":
|
|
2021
|
+
case "root_cause":
|
|
2022
|
+
case "rootcause":
|
|
2023
|
+
return "debug_root_cause";
|
|
2024
|
+
case "debug_fix":
|
|
2025
|
+
case "debugfix":
|
|
2026
|
+
case "fix":
|
|
2027
|
+
return "debug_fix";
|
|
2028
|
+
case "debug_regression":
|
|
2029
|
+
case "debugregression":
|
|
2030
|
+
case "regression":
|
|
2031
|
+
return "debug_regression";
|
|
2032
|
+
default:
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
function isDevelopCompletionPhase(phase) {
|
|
2037
|
+
return phase === "implementation" || phase === "self_review" || phase === "validation";
|
|
2038
|
+
}
|
|
2039
|
+
function isDebugCompletionPhase(phase) {
|
|
2040
|
+
return debugCompletionPhaseOrder.includes(phase);
|
|
2041
|
+
}
|
|
2042
|
+
function asDebugCompletionPhase(phase) {
|
|
2043
|
+
if (isDebugCompletionPhase(phase)) {
|
|
2044
|
+
return phase;
|
|
2045
|
+
}
|
|
2046
|
+
throw new Error(`Expected Debug completion phase, got ${phase}.`);
|
|
2047
|
+
}
|
|
773
2048
|
function parseCompletionRedactionSecrets(raw) {
|
|
774
2049
|
if (!raw?.trim()) {
|
|
775
2050
|
return [];
|
|
@@ -796,8 +2071,41 @@ function phaseFromToolName(toolName) {
|
|
|
796
2071
|
if (normalized === "forge.complete_validation" || normalized === "complete_validation") {
|
|
797
2072
|
return "validation";
|
|
798
2073
|
}
|
|
2074
|
+
if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
|
|
2075
|
+
return "qa";
|
|
2076
|
+
}
|
|
2077
|
+
if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
|
|
2078
|
+
return "architect";
|
|
2079
|
+
}
|
|
2080
|
+
const debugGate = debugGateFromToolName(toolName);
|
|
2081
|
+
if (debugGate) {
|
|
2082
|
+
return debugGate;
|
|
2083
|
+
}
|
|
2084
|
+
if (qaGateFromToolName(toolName)) {
|
|
2085
|
+
return "qa";
|
|
2086
|
+
}
|
|
799
2087
|
throw new Error(`Unknown Forge completion tool: ${toolName}`);
|
|
800
2088
|
}
|
|
2089
|
+
function debugGateFromToolName(toolName) {
|
|
2090
|
+
const normalized = toolName.trim().toLowerCase();
|
|
2091
|
+
for (const gate of debugCompletionPhaseOrder) {
|
|
2092
|
+
const base = debugGateToolNames[gate];
|
|
2093
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
2094
|
+
return gate;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
return null;
|
|
2098
|
+
}
|
|
2099
|
+
function qaGateFromToolName(toolName) {
|
|
2100
|
+
const normalized = toolName.trim().toLowerCase();
|
|
2101
|
+
for (const gate of qaCompletionGateOrder) {
|
|
2102
|
+
const base = qaGateToolNames[gate];
|
|
2103
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
2104
|
+
return gate;
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
return null;
|
|
2108
|
+
}
|
|
801
2109
|
function requireField(payload, key) {
|
|
802
2110
|
if (payload[key] === undefined || payload[key] === null || (typeof payload[key] === "string" && payload[key].trim().length === 0)) {
|
|
803
2111
|
throw new Error(`${key} is required.`);
|
|
@@ -865,12 +2173,32 @@ function requireArray(payload, key) {
|
|
|
865
2173
|
throw new Error(`${key} must be an array.`);
|
|
866
2174
|
}
|
|
867
2175
|
}
|
|
2176
|
+
function requireNonEmptyArray(payload, key) {
|
|
2177
|
+
requireArray(payload, key);
|
|
2178
|
+
if (arrayLength(payload, key) === 0) {
|
|
2179
|
+
throw new Error(`${key} must include at least one value.`);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
function arrayLength(payload, key) {
|
|
2183
|
+
const value = payload[key];
|
|
2184
|
+
return Array.isArray(value) ? value.length : 0;
|
|
2185
|
+
}
|
|
2186
|
+
function requireAllowedStatus(payload, key, allowed) {
|
|
2187
|
+
const value = normalizedString(optionalString(payload, key));
|
|
2188
|
+
if (!allowed.includes(value)) {
|
|
2189
|
+
throw new Error(`${key} must be one of ${allowed.join(", ")}.`);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
868
2192
|
function requireObject(payload, key) {
|
|
869
2193
|
const value = payload[key];
|
|
870
2194
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
871
2195
|
throw new Error(`${key} must be an object.`);
|
|
872
2196
|
}
|
|
873
2197
|
}
|
|
2198
|
+
function requiredRecord(payload, key) {
|
|
2199
|
+
requireObject(payload, key);
|
|
2200
|
+
return payload[key];
|
|
2201
|
+
}
|
|
874
2202
|
function requireBoolean(payload, key) {
|
|
875
2203
|
if (typeof payload[key] !== "boolean") {
|
|
876
2204
|
throw new Error(`${key} must be a boolean.`);
|
|
@@ -893,6 +2221,142 @@ function requireSatisfiedAcceptanceCriteria(payload) {
|
|
|
893
2221
|
}
|
|
894
2222
|
}
|
|
895
2223
|
}
|
|
2224
|
+
function qaEvidenceSource(payload) {
|
|
2225
|
+
const evidence = payload.evidence;
|
|
2226
|
+
return evidence && typeof evidence === "object" && !Array.isArray(evidence)
|
|
2227
|
+
? evidence
|
|
2228
|
+
: {};
|
|
2229
|
+
}
|
|
2230
|
+
function qaArray(payload, key, fallback = {}) {
|
|
2231
|
+
const value = payload[key] ?? fallback[key];
|
|
2232
|
+
if (value === undefined || value === null) {
|
|
2233
|
+
return [];
|
|
2234
|
+
}
|
|
2235
|
+
if (!Array.isArray(value)) {
|
|
2236
|
+
throw new Error(`${key} must be an array.`);
|
|
2237
|
+
}
|
|
2238
|
+
return value;
|
|
2239
|
+
}
|
|
2240
|
+
function qaObjectArray(payload, key) {
|
|
2241
|
+
return qaArray(payload, key).map((entry, index) => {
|
|
2242
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
2243
|
+
throw new Error(`${key}[${index}] must be an object.`);
|
|
2244
|
+
}
|
|
2245
|
+
return entry;
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
function qaStringArray(payload, key) {
|
|
2249
|
+
const value = payload[key];
|
|
2250
|
+
if (value === undefined || value === null) {
|
|
2251
|
+
return [];
|
|
2252
|
+
}
|
|
2253
|
+
if (!Array.isArray(value)) {
|
|
2254
|
+
throw new Error(`${key} must be an array.`);
|
|
2255
|
+
}
|
|
2256
|
+
return value.map((entry, index) => {
|
|
2257
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
2258
|
+
throw new Error(`${key}[${index}] must be a non-empty string.`);
|
|
2259
|
+
}
|
|
2260
|
+
return entry;
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
function requireEvidenceRecord(value, label) {
|
|
2264
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2265
|
+
throw new Error(`${label} must be an object.`);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
function requireNonEmptyStringRecord(source, key, prefix = "") {
|
|
2269
|
+
if (!optionalString(source, key)) {
|
|
2270
|
+
throw new Error(`${prefix}${key} is required.`);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
function requireKnownStringRecord(source, key, allowed, prefix = "") {
|
|
2274
|
+
const value = normalizedString(optionalString(source, key));
|
|
2275
|
+
if (!allowed.has(value)) {
|
|
2276
|
+
throw new Error(`${prefix}${key} must be one of ${[...allowed].sort().join(", ")}.`);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
|
|
2280
|
+
const value = source[key];
|
|
2281
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
2282
|
+
throw new Error(`${prefix}${key} must include at least one value.`);
|
|
2283
|
+
}
|
|
2284
|
+
for (const [index, entry] of value.entries()) {
|
|
2285
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
2286
|
+
throw new Error(`${prefix}${key}[${index}] must be a non-empty string.`);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
function requireSubstantiveEvidenceRecord(source, prefix = "") {
|
|
2291
|
+
const evidence = source.evidence;
|
|
2292
|
+
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
2293
|
+
throw new Error(`${prefix}evidence must be an object.`);
|
|
2294
|
+
}
|
|
2295
|
+
if (!Object.values(evidence).some(hasSubstantiveJsonValue)) {
|
|
2296
|
+
throw new Error(`${prefix}evidence must include at least one concrete evidence reference or summary.`);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
function hasSubstantiveJsonValue(value) {
|
|
2300
|
+
if (typeof value === "string") {
|
|
2301
|
+
return value.trim().length > 0;
|
|
2302
|
+
}
|
|
2303
|
+
if (Array.isArray(value)) {
|
|
2304
|
+
return value.length > 0;
|
|
2305
|
+
}
|
|
2306
|
+
if (value && typeof value === "object") {
|
|
2307
|
+
return Object.keys(value).length > 0;
|
|
2308
|
+
}
|
|
2309
|
+
return false;
|
|
2310
|
+
}
|
|
2311
|
+
function normalizedString(value) {
|
|
2312
|
+
return typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
|
|
2313
|
+
}
|
|
2314
|
+
function normalizedOwnerType(value) {
|
|
2315
|
+
const normalized = normalizedString(value);
|
|
2316
|
+
return normalized === "work_item" || normalized === "story" ? normalized : undefined;
|
|
2317
|
+
}
|
|
2318
|
+
function artifactOwnerFields(config) {
|
|
2319
|
+
const ownerType = normalizedOwnerType(config.ownerType);
|
|
2320
|
+
const ownerId = config.ownerId?.trim();
|
|
2321
|
+
if (!ownerType && !ownerId) {
|
|
2322
|
+
return {};
|
|
2323
|
+
}
|
|
2324
|
+
return {
|
|
2325
|
+
owner_type: ownerType ?? "work_item",
|
|
2326
|
+
owner_id: ownerId || config.workItemId
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
function artifactReferenceOwnerFields(config) {
|
|
2330
|
+
const owner = artifactOwnerFields(config);
|
|
2331
|
+
return owner.owner_type && owner.owner_id
|
|
2332
|
+
? { ownerType: owner.owner_type, ownerId: owner.owner_id }
|
|
2333
|
+
: {};
|
|
2334
|
+
}
|
|
2335
|
+
function artifactOwnerDirectory(ownerType) {
|
|
2336
|
+
return normalizedOwnerType(ownerType) === "story" ? "stories" : "work-items";
|
|
2337
|
+
}
|
|
2338
|
+
function artifactOwnerRelativeRoot(ownerType, ownerId, workItemId) {
|
|
2339
|
+
const owner = artifactOwnerFields({ workItemId, ownerType, ownerId });
|
|
2340
|
+
return owner.owner_type && owner.owner_id
|
|
2341
|
+
? [".forge", artifactOwnerDirectory(owner.owner_type), owner.owner_id, "artifacts"].join("/")
|
|
2342
|
+
: [".forge", "work-items", workItemId, "artifacts"].join("/");
|
|
2343
|
+
}
|
|
2344
|
+
function defaultQaChecklist(status) {
|
|
2345
|
+
return [
|
|
2346
|
+
"Review the validated work item against acceptance criteria.",
|
|
2347
|
+
"Inspect implementation, self-review, and validation evidence when available.",
|
|
2348
|
+
status === "blocked" ? "Record the blocker reason preventing a conclusive QA verdict." : "Record concrete QA evidence and findings."
|
|
2349
|
+
];
|
|
2350
|
+
}
|
|
2351
|
+
function qaArtifactRelativePath(workItemId, fileName) {
|
|
2352
|
+
return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
|
|
2353
|
+
}
|
|
2354
|
+
function qaArtifactRelativePathForOwner(ownerType, ownerId, fileName) {
|
|
2355
|
+
return [".forge", artifactOwnerDirectory(ownerType), ownerId, "artifacts", fileName].join("/");
|
|
2356
|
+
}
|
|
2357
|
+
function jsonText(value) {
|
|
2358
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
2359
|
+
}
|
|
896
2360
|
function fallbackReport(parsed, phase, artifactPath) {
|
|
897
2361
|
return `# Forge ${phase.replace(/_/g, " ")} completion
|
|
898
2362
|
|
|
@@ -905,7 +2369,7 @@ function parseJsonObject(content, label) {
|
|
|
905
2369
|
return asRecord(JSON.parse(content), label);
|
|
906
2370
|
}
|
|
907
2371
|
catch (error) {
|
|
908
|
-
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}
|
|
2372
|
+
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`, { cause: error });
|
|
909
2373
|
}
|
|
910
2374
|
}
|
|
911
2375
|
function parseOptionalJsonObject(content, label) {
|