@loucompanion/forge-bridge 0.1.1-dev.718bdb0018b9 → 0.1.1-dev.895607f4402a
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 +39 -8
- package/dist/session/internals.d.ts +1 -1
- package/dist/session/phase-completion.d.ts +27 -2
- package/dist/session/phase-completion.js +831 -25
- package/dist/session/provider-cli/provider-cli.service.js +339 -131
- package/dist/session/session.service.js +6 -1
- package/dist/session/session.types.d.ts +23 -0
- package/dist/session/session.types.js +29 -0
- 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 +30 -1
- package/dist/worktree/worktree.service.js +5 -3
- package/dist/worktree/worktree.types.d.ts +2 -0
- package/package.json +8 -3
|
@@ -7,6 +7,45 @@ import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from ".
|
|
|
7
7
|
const toolServerName = "forge";
|
|
8
8
|
const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
|
|
9
9
|
const completionPhaseOrder = ["implementation", "self_review", "validation"];
|
|
10
|
+
export const debugCompletionPhaseOrder = [
|
|
11
|
+
"debug_repro",
|
|
12
|
+
"debug_root_cause",
|
|
13
|
+
"debug_fix",
|
|
14
|
+
"debug_regression"
|
|
15
|
+
];
|
|
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
|
+
};
|
|
10
49
|
const qaBlockerReasons = new Set([
|
|
11
50
|
"missing_app_launch_target",
|
|
12
51
|
"inconclusive",
|
|
@@ -34,6 +73,22 @@ const phaseFiles = {
|
|
|
34
73
|
qa: {
|
|
35
74
|
jsonFileName: "qa.verdict.json",
|
|
36
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"
|
|
37
92
|
}
|
|
38
93
|
};
|
|
39
94
|
export function completionPhaseFromDispatch(dispatch) {
|
|
@@ -41,6 +96,10 @@ export function completionPhaseFromDispatch(dispatch) {
|
|
|
41
96
|
}
|
|
42
97
|
export function normalizeCompletionPhase(value) {
|
|
43
98
|
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
99
|
+
const debugPhase = normalizeDebugCompletionPhase(normalized);
|
|
100
|
+
if (debugPhase) {
|
|
101
|
+
return debugPhase;
|
|
102
|
+
}
|
|
44
103
|
if (normalized === "self_review" || normalized === "selfreview" || normalized === "review") {
|
|
45
104
|
return "self_review";
|
|
46
105
|
}
|
|
@@ -61,7 +120,28 @@ export function usesDevelopCycleCompletion(dispatch) {
|
|
|
61
120
|
&& lane === "develop"
|
|
62
121
|
&& completionPhaseFromDispatch(dispatch) === "implementation";
|
|
63
122
|
}
|
|
123
|
+
export function usesQaCycleCompletion(dispatch) {
|
|
124
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
125
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
126
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
127
|
+
return kind === "qa"
|
|
128
|
+
&& role === "qa"
|
|
129
|
+
&& lane === "qa"
|
|
130
|
+
&& completionPhaseFromDispatch(dispatch) === "qa";
|
|
131
|
+
}
|
|
132
|
+
export function usesDebugCycleCompletion(dispatch) {
|
|
133
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
134
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
135
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
136
|
+
return kind === "debug"
|
|
137
|
+
&& role === "debug"
|
|
138
|
+
&& lane === "debug"
|
|
139
|
+
&& isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
|
|
140
|
+
}
|
|
64
141
|
export function completionToolNamesForPhase(phase) {
|
|
142
|
+
if (isDebugCompletionPhase(phase)) {
|
|
143
|
+
return completionToolNamesForDebugGate(phase);
|
|
144
|
+
}
|
|
65
145
|
const base = phase === "implementation"
|
|
66
146
|
? "complete_implementation"
|
|
67
147
|
: phase === "self_review"
|
|
@@ -71,12 +151,29 @@ export function completionToolNamesForPhase(phase) {
|
|
|
71
151
|
: "complete_qa";
|
|
72
152
|
return [`forge.${base}`, base];
|
|
73
153
|
}
|
|
154
|
+
export function completionToolNamesForQaGate(gate) {
|
|
155
|
+
const base = qaGateToolNames[gate];
|
|
156
|
+
return [`forge.${base}`, base];
|
|
157
|
+
}
|
|
158
|
+
export function completionToolNamesForDebugGate(gate) {
|
|
159
|
+
const base = debugGateToolNames[gate];
|
|
160
|
+
return [`forge.${base}`, base];
|
|
161
|
+
}
|
|
74
162
|
export function expectedCompletionToolName(phase) {
|
|
75
163
|
return completionToolNamesForPhase(phase)[0];
|
|
76
164
|
}
|
|
165
|
+
export function expectedQaCompletionToolName(gate) {
|
|
166
|
+
return completionToolNamesForQaGate(gate)[0];
|
|
167
|
+
}
|
|
168
|
+
export function expectedDebugCompletionToolName(gate) {
|
|
169
|
+
return completionToolNamesForDebugGate(gate)[0];
|
|
170
|
+
}
|
|
77
171
|
export function artifactRootFor(worktreePath, workItemId) {
|
|
78
172
|
return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
|
|
79
173
|
}
|
|
174
|
+
function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
175
|
+
return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
|
|
176
|
+
}
|
|
80
177
|
export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
|
|
81
178
|
return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].jsonFileName);
|
|
82
179
|
}
|
|
@@ -87,12 +184,18 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
87
184
|
const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
|
|
88
185
|
const worktreePath = path.resolve(options.worktree.worktreePath);
|
|
89
186
|
const workItemId = options.dispatch.workItem.id;
|
|
187
|
+
const ownerType = normalizedOwnerType(options.dispatch.cycleRun.ownerType) ?? "work_item";
|
|
188
|
+
const ownerId = options.dispatch.cycleRun.ownerId ?? workItemId;
|
|
90
189
|
const mcpArgs = buildCompletionToolServerArgs({
|
|
91
190
|
worktreePath,
|
|
92
191
|
workItemId,
|
|
192
|
+
ownerType,
|
|
193
|
+
ownerId,
|
|
93
194
|
phase,
|
|
94
195
|
producedBy: `${options.dispatch.execution.provider}-cli`,
|
|
95
|
-
developCycle: usesDevelopCycleCompletion(options.dispatch)
|
|
196
|
+
developCycle: usesDevelopCycleCompletion(options.dispatch),
|
|
197
|
+
qaCycle: usesQaCycleCompletion(options.dispatch),
|
|
198
|
+
debugCycle: usesDebugCycleCompletion(options.dispatch)
|
|
96
199
|
});
|
|
97
200
|
const runtime = {
|
|
98
201
|
serverName: toolServerName,
|
|
@@ -118,9 +221,19 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
118
221
|
"--produced-by",
|
|
119
222
|
config.producedBy ?? "forge-completion-tool"
|
|
120
223
|
];
|
|
224
|
+
if (config.ownerType || config.ownerId) {
|
|
225
|
+
args.push("--owner-type", normalizedOwnerType(config.ownerType) ?? "work_item");
|
|
226
|
+
args.push("--owner-id", config.ownerId ?? config.workItemId);
|
|
227
|
+
}
|
|
121
228
|
if (config.developCycle) {
|
|
122
229
|
args.push("--develop-cycle");
|
|
123
230
|
}
|
|
231
|
+
if (config.qaCycle) {
|
|
232
|
+
args.push("--qa-cycle");
|
|
233
|
+
}
|
|
234
|
+
if (config.debugCycle) {
|
|
235
|
+
args.push("--debug-cycle");
|
|
236
|
+
}
|
|
124
237
|
return args;
|
|
125
238
|
}
|
|
126
239
|
export function codexMcpConfigArgs(runtime) {
|
|
@@ -169,31 +282,72 @@ Complete the dev cycle in order inside this same provider session:
|
|
|
169
282
|
Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
|
|
170
283
|
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.`;
|
|
171
284
|
}
|
|
285
|
+
export function completionPromptForQaCycle(currentGate = "qa_plan") {
|
|
286
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
287
|
+
Do not write QA artifacts by hand. The tool server enforces QA gate order and starts this run at \`${currentGate}\`.
|
|
288
|
+
Complete the QA cycle in order inside this same provider session:
|
|
289
|
+
1. Call \`forge.complete_qa_plan(...)\` after the QA test plan/checklist is ready.
|
|
290
|
+
2. Follow the returned regression instruction, then call \`forge.complete_qa_regression(...)\`.
|
|
291
|
+
3. Follow the returned API/backend instruction, then call \`forge.complete_qa_api(...)\`.
|
|
292
|
+
4. Follow the returned manual web instruction, then call \`forge.complete_qa_manual_web(...)\`.
|
|
293
|
+
5. Follow the returned bug reproduction instruction, then call \`forge.complete_qa_bug_repro(...)\`.
|
|
294
|
+
6. Follow the returned findings instruction, then call \`forge.complete_qa_findings(...)\`.
|
|
295
|
+
7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
|
|
296
|
+
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.
|
|
297
|
+
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\`.
|
|
298
|
+
The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
|
|
299
|
+
}
|
|
300
|
+
export function completionPromptForDebugCycle(currentGate = "debug_repro") {
|
|
301
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
302
|
+
Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
|
|
303
|
+
Complete the Debug cycle in order inside this same provider session:
|
|
304
|
+
1. Reproduce or tightly characterize the bug, then call \`forge.complete_debug_repro(...)\`.
|
|
305
|
+
2. Follow the returned root-cause instruction, isolate evidence, then call \`forge.complete_debug_root_cause(...)\`.
|
|
306
|
+
3. Follow the returned fix instruction, apply the smallest safe fix with regression coverage when practical, then call \`forge.complete_debug_fix(...)\`.
|
|
307
|
+
4. Follow the returned regression instruction, prove the original repro is negative and the fix is QA-ready, then call \`forge.complete_debug_regression(...)\`.
|
|
308
|
+
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.
|
|
309
|
+
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.`;
|
|
310
|
+
}
|
|
172
311
|
export function completionToolNamesForServer(config) {
|
|
173
|
-
return config.
|
|
174
|
-
?
|
|
175
|
-
:
|
|
312
|
+
return config.debugCycle
|
|
313
|
+
? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
|
|
314
|
+
: config.qaCycle
|
|
315
|
+
? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
|
|
316
|
+
: config.developCycle
|
|
317
|
+
? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
|
|
318
|
+
: completionToolNamesForPhase(config.phase);
|
|
176
319
|
}
|
|
177
320
|
export class CompletionToolServerSession {
|
|
178
321
|
config;
|
|
179
322
|
currentPhase;
|
|
323
|
+
currentQaGate = "qa_plan";
|
|
180
324
|
completed = false;
|
|
181
325
|
constructor(config) {
|
|
182
326
|
this.config = config;
|
|
183
327
|
this.currentPhase = config.phase;
|
|
184
328
|
}
|
|
185
329
|
get instructions() {
|
|
186
|
-
return this.config.
|
|
187
|
-
?
|
|
188
|
-
:
|
|
330
|
+
return this.config.debugCycle
|
|
331
|
+
? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
|
|
332
|
+
: this.config.qaCycle
|
|
333
|
+
? completionPromptForQaCycle(this.currentQaGate)
|
|
334
|
+
: this.config.developCycle
|
|
335
|
+
? completionPromptForDevelopCycle(this.currentPhase)
|
|
336
|
+
: completionPromptForPhase(this.currentPhase);
|
|
189
337
|
}
|
|
190
338
|
toolDefinitions() {
|
|
191
|
-
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name)));
|
|
339
|
+
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
|
|
192
340
|
}
|
|
193
341
|
async callTool(toolName, input) {
|
|
194
342
|
const requestedPhase = phaseFromToolName(toolName);
|
|
195
343
|
if (this.completed) {
|
|
196
|
-
throw new Error("Forge
|
|
344
|
+
throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
|
|
345
|
+
}
|
|
346
|
+
if (this.config.debugCycle) {
|
|
347
|
+
return await this.callDebugGateTool(toolName, requestedPhase, input);
|
|
348
|
+
}
|
|
349
|
+
if (this.config.qaCycle) {
|
|
350
|
+
return await this.callQaGateTool(toolName, requestedPhase, input);
|
|
197
351
|
}
|
|
198
352
|
if (requestedPhase !== this.currentPhase) {
|
|
199
353
|
throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
|
|
@@ -229,10 +383,91 @@ export class CompletionToolServerSession {
|
|
|
229
383
|
}
|
|
230
384
|
};
|
|
231
385
|
}
|
|
386
|
+
async callDebugGateTool(toolName, requestedPhase, input) {
|
|
387
|
+
const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
|
|
388
|
+
if (!isDebugCompletionPhase(requestedPhase)) {
|
|
389
|
+
throw new Error(`Tool ${toolName} is not available in Debug-cycle mode. Current Forge Debug gate is ${currentDebugGate}.`);
|
|
390
|
+
}
|
|
391
|
+
if (requestedPhase !== currentDebugGate) {
|
|
392
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge Debug gate is ${currentDebugGate}.`);
|
|
393
|
+
}
|
|
394
|
+
const completedGate = currentDebugGate;
|
|
395
|
+
const reference = await writeCompletionArtifact({ ...this.config, phase: completedGate }, toolName, input);
|
|
396
|
+
const status = reference.status.trim().toLowerCase();
|
|
397
|
+
const pass = status === "pass";
|
|
398
|
+
const nextGate = pass ? this.advanceAfterDebugGate(completedGate) : completedGate;
|
|
399
|
+
const nextPhase = nextGate ?? "qa_ready";
|
|
400
|
+
const nextInstruction = completionToolNextInstructionForDebugGate(completedGate, status, nextGate);
|
|
401
|
+
const reason = pass
|
|
402
|
+
? `${completedGate} artifact accepted${nextGate ? `; continue to ${nextGate}.` : "; Debug regression is QA-ready."}`
|
|
403
|
+
: `${completedGate} artifact recorded with status ${status}; rework is required before Debug progression.`;
|
|
404
|
+
return {
|
|
405
|
+
content: [
|
|
406
|
+
{
|
|
407
|
+
type: "text",
|
|
408
|
+
text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
|
|
409
|
+
}
|
|
410
|
+
],
|
|
411
|
+
structuredContent: {
|
|
412
|
+
...reference,
|
|
413
|
+
gate: {
|
|
414
|
+
phase: completedGate,
|
|
415
|
+
status,
|
|
416
|
+
allowed: pass,
|
|
417
|
+
reason
|
|
418
|
+
},
|
|
419
|
+
nextPhase,
|
|
420
|
+
nextInstruction
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
async callQaGateTool(toolName, requestedPhase, input) {
|
|
425
|
+
const requestedGate = qaGateFromToolName(toolName);
|
|
426
|
+
if (requestedPhase !== "qa" || !requestedGate) {
|
|
427
|
+
throw new Error(`Tool ${toolName} is not available in QA-cycle mode. Current Forge QA gate is ${this.currentQaGate}.`);
|
|
428
|
+
}
|
|
429
|
+
if (requestedGate !== this.currentQaGate) {
|
|
430
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge QA gate is ${this.currentQaGate}.`);
|
|
431
|
+
}
|
|
432
|
+
const completedGate = this.currentQaGate;
|
|
433
|
+
const reference = await writeCompletionArtifact({ ...this.config, phase: "qa" }, toolName, input);
|
|
434
|
+
const status = reference.status.trim().toLowerCase();
|
|
435
|
+
const nextGate = this.advanceAfterQaGate(completedGate);
|
|
436
|
+
const nextPhase = nextGate ? "qa" : "sign_off_ready";
|
|
437
|
+
const nextInstruction = completionToolNextInstructionForQaGate(completedGate, status, nextGate);
|
|
438
|
+
const allowed = status !== "blocked";
|
|
439
|
+
const reason = nextGate
|
|
440
|
+
? `${completedGate} artifact accepted; continue to ${nextGate}.`
|
|
441
|
+
: `QA verdict artifact accepted with status ${status}.`;
|
|
442
|
+
return {
|
|
443
|
+
content: [
|
|
444
|
+
{
|
|
445
|
+
type: "text",
|
|
446
|
+
text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
|
|
447
|
+
}
|
|
448
|
+
],
|
|
449
|
+
structuredContent: {
|
|
450
|
+
...reference,
|
|
451
|
+
gate: {
|
|
452
|
+
phase: "qa",
|
|
453
|
+
qaGate: completedGate,
|
|
454
|
+
status,
|
|
455
|
+
allowed,
|
|
456
|
+
reason
|
|
457
|
+
},
|
|
458
|
+
nextPhase,
|
|
459
|
+
nextInstruction
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
232
463
|
advanceAfterPass(phase) {
|
|
233
464
|
if (phase === "qa") {
|
|
465
|
+
this.completed = true;
|
|
234
466
|
return "sign_off_ready";
|
|
235
467
|
}
|
|
468
|
+
if (isDebugCompletionPhase(phase)) {
|
|
469
|
+
return nextDebugPhaseAfter(phase) ?? "qa_ready";
|
|
470
|
+
}
|
|
236
471
|
const next = nextPhaseAfter(phase);
|
|
237
472
|
if (!this.config.developCycle) {
|
|
238
473
|
return next ?? "dev_test_ready";
|
|
@@ -244,12 +479,36 @@ export class CompletionToolServerSession {
|
|
|
244
479
|
this.completed = true;
|
|
245
480
|
return "dev_test_ready";
|
|
246
481
|
}
|
|
482
|
+
advanceAfterQaGate(gate) {
|
|
483
|
+
const index = qaCompletionGateOrder.indexOf(gate);
|
|
484
|
+
const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
|
|
485
|
+
if (next) {
|
|
486
|
+
this.currentQaGate = next;
|
|
487
|
+
return next;
|
|
488
|
+
}
|
|
489
|
+
this.completed = true;
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
advanceAfterDebugGate(gate) {
|
|
493
|
+
const index = debugCompletionPhaseOrder.indexOf(gate);
|
|
494
|
+
const next = index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
|
|
495
|
+
if (next) {
|
|
496
|
+
this.currentPhase = next;
|
|
497
|
+
return next;
|
|
498
|
+
}
|
|
499
|
+
this.completed = true;
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
247
502
|
}
|
|
248
503
|
export async function writeCompletionArtifact(config, toolName, input) {
|
|
249
504
|
const phase = phaseFromToolName(toolName);
|
|
250
505
|
if (phase !== config.phase) {
|
|
251
506
|
throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
|
|
252
507
|
}
|
|
508
|
+
const qaGate = qaGateFromToolName(toolName);
|
|
509
|
+
if (qaGate && qaGate !== "qa_verdict") {
|
|
510
|
+
return await writeQaGateCompletionArtifact(config, qaGate, input);
|
|
511
|
+
}
|
|
253
512
|
const payload = asRecord(input, "completion payload");
|
|
254
513
|
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
255
514
|
const validationPayload = { ...payload, status };
|
|
@@ -266,6 +525,8 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
266
525
|
const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
|
|
267
526
|
phase,
|
|
268
527
|
workItemId: config.workItemId,
|
|
528
|
+
ownerType: config.ownerType,
|
|
529
|
+
ownerId: config.ownerId,
|
|
269
530
|
producedBy,
|
|
270
531
|
status
|
|
271
532
|
});
|
|
@@ -284,6 +545,51 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
284
545
|
return {
|
|
285
546
|
phase,
|
|
286
547
|
workItemId: config.workItemId,
|
|
548
|
+
...artifactReferenceOwnerFields(config),
|
|
549
|
+
repoPath: path.resolve(config.worktreePath),
|
|
550
|
+
path: artifactPath,
|
|
551
|
+
reportPath,
|
|
552
|
+
contentHash,
|
|
553
|
+
status,
|
|
554
|
+
producedBy
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
async function writeQaGateCompletionArtifact(config, gate, input) {
|
|
558
|
+
const payload = asRecord(input, "QA gate completion payload");
|
|
559
|
+
const status = normalizeQaGateStatus(requiredString(payload, "status"));
|
|
560
|
+
validateQaGatePayloadShape(gate, payload, status);
|
|
561
|
+
validateOptionalWorkItemId(payload, config.workItemId);
|
|
562
|
+
const redactedPayload = redactSecrets({ ...payload, status }, config.redactionSecrets ?? []);
|
|
563
|
+
const producedBy = optionalString(redactedPayload, "produced_by")
|
|
564
|
+
?? optionalString(redactedPayload, "producedBy")
|
|
565
|
+
?? config.producedBy
|
|
566
|
+
?? "forge-completion-tool";
|
|
567
|
+
const ownerFields = artifactOwnerFields(config);
|
|
568
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
569
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
570
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
571
|
+
const fileName = qaGateFileNames[gate];
|
|
572
|
+
const artifactPath = path.join(artifactRoot, fileName);
|
|
573
|
+
const reportPath = path.join(artifactRoot, "qa.report.md");
|
|
574
|
+
const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
|
|
575
|
+
gate,
|
|
576
|
+
workItemId: config.workItemId,
|
|
577
|
+
ownerType: config.ownerType,
|
|
578
|
+
ownerId: config.ownerId,
|
|
579
|
+
producedBy,
|
|
580
|
+
status
|
|
581
|
+
});
|
|
582
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
583
|
+
const artifact = {
|
|
584
|
+
...artifactWithoutHash,
|
|
585
|
+
content_hash: contentHash
|
|
586
|
+
};
|
|
587
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
588
|
+
await writeFileAtomic(artifactPath, jsonText(artifact));
|
|
589
|
+
return {
|
|
590
|
+
phase: "qa",
|
|
591
|
+
workItemId: config.workItemId,
|
|
592
|
+
...artifactReferenceOwnerFields(config),
|
|
287
593
|
repoPath: path.resolve(config.worktreePath),
|
|
288
594
|
path: artifactPath,
|
|
289
595
|
reportPath,
|
|
@@ -293,19 +599,28 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
293
599
|
};
|
|
294
600
|
}
|
|
295
601
|
async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
296
|
-
const
|
|
602
|
+
const ownerFields = artifactOwnerFields(config);
|
|
603
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
604
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
605
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
297
606
|
const planPath = path.join(artifactRoot, "qa.plan.json");
|
|
298
607
|
const evidencePath = path.join(artifactRoot, "qa.evidence.json");
|
|
299
608
|
const findingsPath = path.join(artifactRoot, "qa.findings.json");
|
|
300
609
|
const verdictPath = path.join(artifactRoot, "qa.verdict.json");
|
|
301
610
|
const reportPath = path.join(artifactRoot, "qa.report.md");
|
|
302
|
-
const relativeEvidencePath =
|
|
303
|
-
|
|
304
|
-
|
|
611
|
+
const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
|
|
612
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
|
|
613
|
+
: qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
|
|
614
|
+
const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
|
|
615
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
|
|
616
|
+
: qaArtifactRelativePath(config.workItemId, "qa.findings.json");
|
|
617
|
+
const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
|
|
618
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
|
|
619
|
+
: qaArtifactRelativePath(config.workItemId, "qa.report.md");
|
|
305
620
|
const evidenceSource = qaEvidenceSource(payload);
|
|
306
621
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
307
622
|
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
308
|
-
const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource));
|
|
623
|
+
const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
|
|
309
624
|
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
310
625
|
const findings = qaObjectArray(payload, "findings");
|
|
311
626
|
const candidateBugs = qaObjectArray(payload, "candidate_bugs");
|
|
@@ -317,12 +632,14 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
317
632
|
const planJson = jsonText({
|
|
318
633
|
schema_version: 1,
|
|
319
634
|
phase: "qa",
|
|
635
|
+
...ownerFields,
|
|
320
636
|
checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
|
|
321
637
|
acceptance_criteria: testedAcceptanceCriteria
|
|
322
638
|
});
|
|
323
639
|
const evidenceJson = jsonText({
|
|
324
640
|
schema_version: 1,
|
|
325
641
|
phase: "qa",
|
|
642
|
+
...ownerFields,
|
|
326
643
|
commands,
|
|
327
644
|
browser_checks: browserChecks,
|
|
328
645
|
sidecars,
|
|
@@ -331,6 +648,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
331
648
|
const findingsJson = jsonText({
|
|
332
649
|
schema_version: 1,
|
|
333
650
|
phase: "qa",
|
|
651
|
+
...ownerFields,
|
|
334
652
|
findings,
|
|
335
653
|
candidate_bugs: candidateBugs
|
|
336
654
|
});
|
|
@@ -350,6 +668,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
350
668
|
const verdictWithoutHash = {
|
|
351
669
|
schema_version: numberOrDefault(payload.schema_version, 1),
|
|
352
670
|
phase: "qa",
|
|
671
|
+
...ownerFields,
|
|
353
672
|
status,
|
|
354
673
|
summary: requiredString(payload, "summary"),
|
|
355
674
|
tested_acceptance_criteria: testedAcceptanceCriteria,
|
|
@@ -381,6 +700,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
381
700
|
return {
|
|
382
701
|
phase: "qa",
|
|
383
702
|
workItemId: config.workItemId,
|
|
703
|
+
...artifactReferenceOwnerFields(config),
|
|
384
704
|
repoPath: path.resolve(config.worktreePath),
|
|
385
705
|
path: verdictPath,
|
|
386
706
|
reportPath,
|
|
@@ -389,11 +709,13 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
389
709
|
producedBy
|
|
390
710
|
};
|
|
391
711
|
}
|
|
392
|
-
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars) {
|
|
712
|
+
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
393
713
|
if (sidecars.length === 0) {
|
|
394
714
|
return [];
|
|
395
715
|
}
|
|
396
|
-
const artifactRoot =
|
|
716
|
+
const artifactRoot = ownerType && ownerId
|
|
717
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
718
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
397
719
|
const qaSidecarRoot = path.resolve(artifactRoot, "qa");
|
|
398
720
|
const validated = [];
|
|
399
721
|
for (const [index, sidecar] of sidecars.entries()) {
|
|
@@ -412,7 +734,7 @@ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars) {
|
|
|
412
734
|
}
|
|
413
735
|
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
414
736
|
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
415
|
-
throw new Error(`sidecars[${index}].path must be under
|
|
737
|
+
throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
|
|
416
738
|
}
|
|
417
739
|
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
418
740
|
if (isNodeError(error) && error.code === "ENOENT") {
|
|
@@ -484,8 +806,12 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
484
806
|
let worktreePath = null;
|
|
485
807
|
let workItemId = null;
|
|
486
808
|
let phase = "implementation";
|
|
809
|
+
let ownerType;
|
|
810
|
+
let ownerId;
|
|
487
811
|
let producedBy;
|
|
488
812
|
let developCycle = false;
|
|
813
|
+
let qaCycle = false;
|
|
814
|
+
let debugCycle = false;
|
|
489
815
|
for (let index = 1; index < argv.length; index += 1) {
|
|
490
816
|
const arg = argv[index];
|
|
491
817
|
if (arg === "--worktree") {
|
|
@@ -494,11 +820,20 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
494
820
|
else if (arg === "--work-item-id") {
|
|
495
821
|
workItemId = argv[++index] ?? null;
|
|
496
822
|
}
|
|
823
|
+
else if (arg === "--owner-type") {
|
|
824
|
+
ownerType = normalizedOwnerType(argv[++index]);
|
|
825
|
+
if (!ownerType) {
|
|
826
|
+
return { ok: false, error: "mcp --owner-type must be work_item or story." };
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
else if (arg === "--owner-id") {
|
|
830
|
+
ownerId = argv[++index] ?? undefined;
|
|
831
|
+
}
|
|
497
832
|
else if (arg === "--phase") {
|
|
498
833
|
const rawPhase = argv[++index];
|
|
499
834
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
500
835
|
if (!parsedPhase) {
|
|
501
|
-
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa." };
|
|
836
|
+
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, debug-repro, debug-root-cause, debug-fix, debug-regression." };
|
|
502
837
|
}
|
|
503
838
|
phase = parsedPhase;
|
|
504
839
|
}
|
|
@@ -508,6 +843,12 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
508
843
|
else if (arg === "--develop-cycle") {
|
|
509
844
|
developCycle = true;
|
|
510
845
|
}
|
|
846
|
+
else if (arg === "--qa-cycle") {
|
|
847
|
+
qaCycle = true;
|
|
848
|
+
}
|
|
849
|
+
else if (arg === "--debug-cycle") {
|
|
850
|
+
debugCycle = true;
|
|
851
|
+
}
|
|
511
852
|
else {
|
|
512
853
|
return { ok: false, error: `Unknown mcp argument: ${arg}` };
|
|
513
854
|
}
|
|
@@ -515,15 +856,31 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
515
856
|
if (!worktreePath?.trim() || !workItemId?.trim()) {
|
|
516
857
|
return { ok: false, error: "mcp requires --worktree and --work-item-id." };
|
|
517
858
|
}
|
|
859
|
+
if ([developCycle, qaCycle, debugCycle].filter(Boolean).length > 1) {
|
|
860
|
+
return { ok: false, error: "mcp accepts only one cycle mode." };
|
|
861
|
+
}
|
|
862
|
+
if (developCycle && !isDevelopCompletionPhase(phase)) {
|
|
863
|
+
return { ok: false, error: "mcp --develop-cycle requires --phase implementation, self-review, or validation." };
|
|
864
|
+
}
|
|
865
|
+
if (qaCycle && phase !== "qa") {
|
|
866
|
+
return { ok: false, error: "mcp --qa-cycle requires --phase qa." };
|
|
867
|
+
}
|
|
868
|
+
if (debugCycle && !isDebugCompletionPhase(phase)) {
|
|
869
|
+
return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
|
|
870
|
+
}
|
|
518
871
|
return {
|
|
519
872
|
ok: true,
|
|
520
873
|
config: {
|
|
521
874
|
worktreePath: path.resolve(worktreePath),
|
|
522
875
|
workItemId,
|
|
876
|
+
ownerType,
|
|
877
|
+
ownerId,
|
|
523
878
|
phase,
|
|
524
879
|
producedBy,
|
|
525
880
|
redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
|
|
526
|
-
developCycle
|
|
881
|
+
developCycle,
|
|
882
|
+
qaCycle,
|
|
883
|
+
debugCycle
|
|
527
884
|
}
|
|
528
885
|
};
|
|
529
886
|
}
|
|
@@ -618,17 +975,24 @@ async function handleJsonRpcRequest(session, method, params) {
|
|
|
618
975
|
}
|
|
619
976
|
throw new Error(`Unsupported MCP method: ${method}`);
|
|
620
977
|
}
|
|
621
|
-
function toolDefinition(name, phase) {
|
|
978
|
+
function toolDefinition(name, phase, qaGate = null) {
|
|
622
979
|
return {
|
|
623
980
|
name,
|
|
624
|
-
description: toolDescription(phase),
|
|
625
|
-
inputSchema: toolInputSchema(phase)
|
|
981
|
+
description: toolDescription(phase, qaGate),
|
|
982
|
+
inputSchema: toolInputSchema(phase, qaGate)
|
|
626
983
|
};
|
|
627
984
|
}
|
|
628
985
|
function nextPhaseAfter(phase) {
|
|
986
|
+
if (!isDevelopCompletionPhase(phase)) {
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
629
989
|
const index = completionPhaseOrder.indexOf(phase);
|
|
630
990
|
return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
|
|
631
991
|
}
|
|
992
|
+
function nextDebugPhaseAfter(phase) {
|
|
993
|
+
const index = debugCompletionPhaseOrder.indexOf(phase);
|
|
994
|
+
return index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
|
|
995
|
+
}
|
|
632
996
|
function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
633
997
|
if (completedPhase === "qa") {
|
|
634
998
|
if (status === "blocked") {
|
|
@@ -642,6 +1006,10 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
|
642
1006
|
if (status !== "pass") {
|
|
643
1007
|
return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
|
|
644
1008
|
}
|
|
1009
|
+
if (isDebugCompletionPhase(completedPhase)) {
|
|
1010
|
+
const debugNext = isDebugCompletionPhase(nextPhase) ? nextPhase : null;
|
|
1011
|
+
return completionToolNextInstructionForDebugGate(completedPhase, status, debugNext);
|
|
1012
|
+
}
|
|
645
1013
|
if (nextPhase === "self_review") {
|
|
646
1014
|
return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
|
|
647
1015
|
}
|
|
@@ -650,7 +1018,67 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
|
650
1018
|
}
|
|
651
1019
|
return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
|
|
652
1020
|
}
|
|
653
|
-
function
|
|
1021
|
+
function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
|
|
1022
|
+
if (!nextGate) {
|
|
1023
|
+
if (status === "blocked") {
|
|
1024
|
+
return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
|
|
1025
|
+
}
|
|
1026
|
+
if (status === "needs_rework") {
|
|
1027
|
+
return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
|
|
1028
|
+
}
|
|
1029
|
+
return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
|
|
1030
|
+
}
|
|
1031
|
+
const skipped = status === "skipped";
|
|
1032
|
+
const blocked = status === "blocked";
|
|
1033
|
+
const prefix = blocked
|
|
1034
|
+
? `${completedGate} recorded a blocker. Continue through ${nextGate} and mark downstream gates skipped if the blocker prevents evidence collection.`
|
|
1035
|
+
: skipped
|
|
1036
|
+
? `${completedGate} skipped. Continue to ${nextGate}.`
|
|
1037
|
+
: `${completedGate} accepted. Continue to ${nextGate}.`;
|
|
1038
|
+
return `${prefix} ${qaGateInstruction(nextGate)}`;
|
|
1039
|
+
}
|
|
1040
|
+
function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
|
|
1041
|
+
if (status !== "pass") {
|
|
1042
|
+
return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
|
|
1043
|
+
}
|
|
1044
|
+
if (nextGate === "debug_root_cause") {
|
|
1045
|
+
return "Debug repro accepted. Begin root-cause analysis: connect the observed failure to code evidence and call forge.complete_debug_root_cause.";
|
|
1046
|
+
}
|
|
1047
|
+
if (nextGate === "debug_fix") {
|
|
1048
|
+
return "Debug root cause accepted. Apply the smallest safe fix with regression coverage where practical, then call forge.complete_debug_fix.";
|
|
1049
|
+
}
|
|
1050
|
+
if (nextGate === "debug_regression") {
|
|
1051
|
+
return "Debug fix accepted. Prove the original repro is negative, run targeted regression checks, and call forge.complete_debug_regression.";
|
|
1052
|
+
}
|
|
1053
|
+
return "Debug regression accepted. Forge has the structured evidence needed to move this bug fix to QA.";
|
|
1054
|
+
}
|
|
1055
|
+
function qaGateInstruction(gate) {
|
|
1056
|
+
switch (gate) {
|
|
1057
|
+
case "qa_regression":
|
|
1058
|
+
return "Run or explicitly skip applicable regression/build/lint/typecheck/e2e checks, then call forge.complete_qa_regression.";
|
|
1059
|
+
case "qa_api":
|
|
1060
|
+
return "Run or explicitly skip applicable API/backend contract checks, then call forge.complete_qa_api.";
|
|
1061
|
+
case "qa_manual_web":
|
|
1062
|
+
return "Run or explicitly skip applicable browser/manual web checks, then call forge.complete_qa_manual_web.";
|
|
1063
|
+
case "qa_bug_repro":
|
|
1064
|
+
return "Reproduce or explicitly skip relevant defects/candidate bugs, then call forge.complete_qa_bug_repro.";
|
|
1065
|
+
case "qa_findings":
|
|
1066
|
+
return "Normalize observations into findings and candidate bugs, then call forge.complete_qa_findings.";
|
|
1067
|
+
case "qa_verdict":
|
|
1068
|
+
return "Assemble the final QA verdict from all prior gate evidence, then call forge.complete_qa_verdict.";
|
|
1069
|
+
case "qa_plan":
|
|
1070
|
+
return "Prepare the QA checklist, then call forge.complete_qa_plan.";
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
function toolDescription(phase, qaGate = null) {
|
|
1074
|
+
if (qaGate) {
|
|
1075
|
+
return qaGate === "qa_verdict"
|
|
1076
|
+
? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
|
|
1077
|
+
: `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
|
|
1078
|
+
}
|
|
1079
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1080
|
+
return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
|
|
1081
|
+
}
|
|
654
1082
|
if (phase === "implementation") {
|
|
655
1083
|
return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
|
|
656
1084
|
}
|
|
@@ -662,7 +1090,7 @@ function toolDescription(phase) {
|
|
|
662
1090
|
}
|
|
663
1091
|
return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
|
|
664
1092
|
}
|
|
665
|
-
function toolInputSchema(phase) {
|
|
1093
|
+
function toolInputSchema(phase, qaGate = null) {
|
|
666
1094
|
const baseProperties = {
|
|
667
1095
|
status: {
|
|
668
1096
|
type: "string",
|
|
@@ -681,6 +1109,12 @@ function toolInputSchema(phase) {
|
|
|
681
1109
|
})
|
|
682
1110
|
}
|
|
683
1111
|
};
|
|
1112
|
+
if (qaGate && qaGate !== "qa_verdict") {
|
|
1113
|
+
return qaGateToolInputSchema(qaGate);
|
|
1114
|
+
}
|
|
1115
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1116
|
+
return debugToolInputSchema(phase, baseProperties);
|
|
1117
|
+
}
|
|
684
1118
|
if (phase === "implementation") {
|
|
685
1119
|
return objectToolSchema({
|
|
686
1120
|
...baseProperties,
|
|
@@ -893,6 +1327,144 @@ function toolInputSchema(phase) {
|
|
|
893
1327
|
ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
|
|
894
1328
|
}, ["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.");
|
|
895
1329
|
}
|
|
1330
|
+
function debugToolInputSchema(phase, baseProperties) {
|
|
1331
|
+
if (phase === "debug_repro") {
|
|
1332
|
+
return objectToolSchema({
|
|
1333
|
+
...baseProperties,
|
|
1334
|
+
repro_steps: {
|
|
1335
|
+
type: "array",
|
|
1336
|
+
description: "Required for pass. Minimal reproduction or characterization steps.",
|
|
1337
|
+
items: { type: "string" }
|
|
1338
|
+
},
|
|
1339
|
+
observed: { type: "string", description: "Observed failure behavior." },
|
|
1340
|
+
expected: { type: "string", description: "Expected behavior." },
|
|
1341
|
+
evidence: {
|
|
1342
|
+
type: "array",
|
|
1343
|
+
description: "Required for pass. Concrete logs, commands, screenshots, file refs, or characterization notes.",
|
|
1344
|
+
items: { type: "object", additionalProperties: true }
|
|
1345
|
+
}
|
|
1346
|
+
}, ["status", "summary"], "For status pass, include repro_steps, observed, expected, and evidence.");
|
|
1347
|
+
}
|
|
1348
|
+
if (phase === "debug_root_cause") {
|
|
1349
|
+
return objectToolSchema({
|
|
1350
|
+
...baseProperties,
|
|
1351
|
+
root_cause: { type: "string", description: "Likely root cause tied to code or config evidence." },
|
|
1352
|
+
evidence: {
|
|
1353
|
+
type: "array",
|
|
1354
|
+
description: "Required for pass. Concrete code/log/test evidence supporting the root cause.",
|
|
1355
|
+
items: { type: "object", additionalProperties: true }
|
|
1356
|
+
},
|
|
1357
|
+
eliminated_alternatives: {
|
|
1358
|
+
type: "array",
|
|
1359
|
+
description: "Required for pass. Plausible alternatives checked or ruled out.",
|
|
1360
|
+
items: { type: "object", additionalProperties: true }
|
|
1361
|
+
},
|
|
1362
|
+
risk_area: { type: "string", description: "Subsystem or behavior most at risk from the fix." }
|
|
1363
|
+
}, ["status", "summary"], "For status pass, include root_cause, evidence, eliminated_alternatives, and risk_area.");
|
|
1364
|
+
}
|
|
1365
|
+
if (phase === "debug_fix") {
|
|
1366
|
+
return objectToolSchema({
|
|
1367
|
+
...baseProperties,
|
|
1368
|
+
changed_files: {
|
|
1369
|
+
type: "array",
|
|
1370
|
+
description: "Required for pass. Files changed by the bug fix.",
|
|
1371
|
+
items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
|
|
1372
|
+
},
|
|
1373
|
+
tests_added_or_updated: {
|
|
1374
|
+
type: "array",
|
|
1375
|
+
description: "Required for pass. Regression tests or checks added/updated, or justify omission in remaining_risks.",
|
|
1376
|
+
items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
|
|
1377
|
+
},
|
|
1378
|
+
remaining_risks: {
|
|
1379
|
+
type: "array",
|
|
1380
|
+
description: "Required for pass, even when empty. Include why no regression coverage changed when tests_added_or_updated is empty.",
|
|
1381
|
+
items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
|
|
1382
|
+
},
|
|
1383
|
+
resolved_findings: {
|
|
1384
|
+
type: "array",
|
|
1385
|
+
description: "Required for QA rework. Entries should include qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.",
|
|
1386
|
+
items: { type: "object", additionalProperties: true }
|
|
1387
|
+
}
|
|
1388
|
+
}, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
|
|
1389
|
+
}
|
|
1390
|
+
return objectToolSchema({
|
|
1391
|
+
...baseProperties,
|
|
1392
|
+
commands: {
|
|
1393
|
+
type: "array",
|
|
1394
|
+
description: "Required for pass. Targeted regression/repro-negative commands with exit_code: 0 and status: pass.",
|
|
1395
|
+
items: commandEvidenceSchema()
|
|
1396
|
+
},
|
|
1397
|
+
required_regressions: {
|
|
1398
|
+
type: "array",
|
|
1399
|
+
description: "Required for pass. Repro-negative and regression coverage checklist.",
|
|
1400
|
+
items: { type: "string" }
|
|
1401
|
+
},
|
|
1402
|
+
build_status: { type: "string", description: "Required for pass. pass or not_applicable." },
|
|
1403
|
+
test_status: { type: "string", description: "Required for pass. pass or not_applicable." },
|
|
1404
|
+
repro_negative: { type: "boolean", description: "Required for pass and must be true after the original repro no longer fails." },
|
|
1405
|
+
qa_ready: { type: "boolean", description: "Required for pass and must be true." }
|
|
1406
|
+
}, ["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.");
|
|
1407
|
+
}
|
|
1408
|
+
function qaGateToolInputSchema(gate) {
|
|
1409
|
+
const baseProperties = {
|
|
1410
|
+
status: {
|
|
1411
|
+
type: "string",
|
|
1412
|
+
enum: ["pass", "skipped", "blocked"],
|
|
1413
|
+
description: "Use pass when this QA gate produced evidence, skipped when not applicable, or blocked when a prerequisite prevents this gate."
|
|
1414
|
+
},
|
|
1415
|
+
summary: { type: "string", description: "Concise QA gate summary." },
|
|
1416
|
+
reason: { type: "string", description: "Required for skipped or blocked gates." },
|
|
1417
|
+
blocker_reason: {
|
|
1418
|
+
type: "string",
|
|
1419
|
+
enum: [...qaBlockerReasons],
|
|
1420
|
+
description: "Required when status is blocked."
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1423
|
+
if (gate === "qa_plan") {
|
|
1424
|
+
return objectToolSchema({
|
|
1425
|
+
...baseProperties,
|
|
1426
|
+
checklist: {
|
|
1427
|
+
type: "array",
|
|
1428
|
+
description: "Required for pass. QA checklist entries planned for this work item.",
|
|
1429
|
+
items: { type: "string" }
|
|
1430
|
+
},
|
|
1431
|
+
acceptance_criteria: {
|
|
1432
|
+
type: "array",
|
|
1433
|
+
description: "Acceptance criteria selected for QA.",
|
|
1434
|
+
items: { type: "string" }
|
|
1435
|
+
}
|
|
1436
|
+
}, ["status", "summary"], "Record the QA plan gate. Pass requires a non-empty checklist.");
|
|
1437
|
+
}
|
|
1438
|
+
if (gate === "qa_findings") {
|
|
1439
|
+
return objectToolSchema({
|
|
1440
|
+
...baseProperties,
|
|
1441
|
+
findings: {
|
|
1442
|
+
type: "array",
|
|
1443
|
+
description: "Normalized in-scope and out-of-scope QA findings.",
|
|
1444
|
+
items: { type: "object", additionalProperties: true }
|
|
1445
|
+
},
|
|
1446
|
+
candidate_bugs: {
|
|
1447
|
+
type: "array",
|
|
1448
|
+
description: "Out-of-scope or follow-up candidates.",
|
|
1449
|
+
items: { type: "object", additionalProperties: true }
|
|
1450
|
+
}
|
|
1451
|
+
}, ["status", "summary"], "Record normalized QA findings. Pass or skipped requires findings and candidate_bugs arrays, even when empty.");
|
|
1452
|
+
}
|
|
1453
|
+
return objectToolSchema({
|
|
1454
|
+
...baseProperties,
|
|
1455
|
+
commands: {
|
|
1456
|
+
type: "array",
|
|
1457
|
+
description: "Command evidence for this gate when applicable.",
|
|
1458
|
+
items: commandEvidenceSchema()
|
|
1459
|
+
},
|
|
1460
|
+
browser_checks: {
|
|
1461
|
+
type: "array",
|
|
1462
|
+
description: "Browser/manual evidence for this gate when applicable.",
|
|
1463
|
+
items: { type: "object", additionalProperties: true }
|
|
1464
|
+
},
|
|
1465
|
+
notes: { type: "string", description: "Evidence notes for this gate." }
|
|
1466
|
+
}, ["status", "summary"], "Record this QA evidence gate. Use skipped with a reason when the gate is not applicable.");
|
|
1467
|
+
}
|
|
896
1468
|
function objectToolSchema(properties, required, description) {
|
|
897
1469
|
return {
|
|
898
1470
|
type: "object",
|
|
@@ -962,6 +1534,13 @@ function normalizeCompletionStatus(value, phase) {
|
|
|
962
1534
|
}
|
|
963
1535
|
return canonical;
|
|
964
1536
|
}
|
|
1537
|
+
function normalizeQaGateStatus(value) {
|
|
1538
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
1539
|
+
if (normalized !== "pass" && normalized !== "skipped" && normalized !== "blocked") {
|
|
1540
|
+
throw new Error("QA gate status must be pass, skipped, or blocked.");
|
|
1541
|
+
}
|
|
1542
|
+
return normalized;
|
|
1543
|
+
}
|
|
965
1544
|
function validatePayloadShape(phase, payload) {
|
|
966
1545
|
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
967
1546
|
if (!status) {
|
|
@@ -977,6 +1556,10 @@ function validatePayloadShape(phase, payload) {
|
|
|
977
1556
|
requireNonPassEvidence(payload);
|
|
978
1557
|
return;
|
|
979
1558
|
}
|
|
1559
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1560
|
+
validateDebugPayloadShape(phase, payload);
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
980
1563
|
if (phase === "implementation") {
|
|
981
1564
|
requireArray(payload, "plan");
|
|
982
1565
|
requireArray(payload, "changed_files");
|
|
@@ -1014,6 +1597,86 @@ function validatePayloadShape(phase, payload) {
|
|
|
1014
1597
|
requirePassingCommands(payload);
|
|
1015
1598
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
1016
1599
|
}
|
|
1600
|
+
function validateDebugPayloadShape(phase, payload) {
|
|
1601
|
+
if (phase === "debug_repro") {
|
|
1602
|
+
requireArray(payload, "repro_steps");
|
|
1603
|
+
requireField(payload, "observed");
|
|
1604
|
+
requireField(payload, "expected");
|
|
1605
|
+
requireArray(payload, "evidence");
|
|
1606
|
+
requireNonEmptyArray(payload, "repro_steps");
|
|
1607
|
+
requireNonEmptyArray(payload, "evidence");
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
if (phase === "debug_root_cause") {
|
|
1611
|
+
requireField(payload, "root_cause");
|
|
1612
|
+
requireArray(payload, "evidence");
|
|
1613
|
+
requireArray(payload, "eliminated_alternatives");
|
|
1614
|
+
requireField(payload, "risk_area");
|
|
1615
|
+
requireNonEmptyArray(payload, "evidence");
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
if (phase === "debug_fix") {
|
|
1619
|
+
requireArray(payload, "changed_files");
|
|
1620
|
+
requireArray(payload, "tests_added_or_updated");
|
|
1621
|
+
requireArray(payload, "remaining_risks");
|
|
1622
|
+
requireNonEmptyArray(payload, "changed_files");
|
|
1623
|
+
if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
|
|
1624
|
+
throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
|
|
1625
|
+
}
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
requireArray(payload, "commands");
|
|
1629
|
+
requireArray(payload, "required_regressions");
|
|
1630
|
+
requireField(payload, "build_status");
|
|
1631
|
+
requireField(payload, "test_status");
|
|
1632
|
+
requireBoolean(payload, "repro_negative");
|
|
1633
|
+
requireBoolean(payload, "qa_ready");
|
|
1634
|
+
if (payload.repro_negative !== true) {
|
|
1635
|
+
throw new Error("debug_regression pass requires repro_negative: true.");
|
|
1636
|
+
}
|
|
1637
|
+
if (payload.qa_ready !== true) {
|
|
1638
|
+
throw new Error("debug_regression pass requires qa_ready: true.");
|
|
1639
|
+
}
|
|
1640
|
+
requireAllowedStatus(payload, "build_status", ["pass", "not_applicable"]);
|
|
1641
|
+
requireAllowedStatus(payload, "test_status", ["pass", "not_applicable"]);
|
|
1642
|
+
requireNonEmptyArray(payload, "required_regressions");
|
|
1643
|
+
requirePassingCommands(payload);
|
|
1644
|
+
}
|
|
1645
|
+
function validateQaGatePayloadShape(gate, payload, status) {
|
|
1646
|
+
requireField(payload, "summary");
|
|
1647
|
+
if (status === "skipped") {
|
|
1648
|
+
requireField(payload, "reason");
|
|
1649
|
+
}
|
|
1650
|
+
if (status === "blocked") {
|
|
1651
|
+
const reason = normalizedString(optionalString(payload, "blocker_reason"));
|
|
1652
|
+
if (!qaBlockerReasons.has(reason)) {
|
|
1653
|
+
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.");
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
if (gate === "qa_plan" && status === "pass") {
|
|
1657
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
1658
|
+
if (checklist.length === 0) {
|
|
1659
|
+
throw new Error("complete_qa_plan pass requires checklist with at least one entry.");
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
if (gate === "qa_findings" && status !== "blocked") {
|
|
1663
|
+
qaObjectArray(payload, "findings");
|
|
1664
|
+
qaObjectArray(payload, "candidate_bugs");
|
|
1665
|
+
}
|
|
1666
|
+
if ((gate === "qa_regression" || gate === "qa_api") && status === "pass") {
|
|
1667
|
+
const commands = qaArray(payload, "commands");
|
|
1668
|
+
if (commands.length === 0) {
|
|
1669
|
+
throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
if (gate === "qa_manual_web" && status === "pass") {
|
|
1673
|
+
const browserChecks = qaArray(payload, "browser_checks");
|
|
1674
|
+
const notes = optionalString(payload, "notes");
|
|
1675
|
+
if (browserChecks.length === 0 && !notes) {
|
|
1676
|
+
throw new Error(`${qaGateToolNames[gate]} pass requires browser_checks or notes.`);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1017
1680
|
function validateQaPayloadShape(payload, status) {
|
|
1018
1681
|
const evidenceSource = qaEvidenceSource(payload);
|
|
1019
1682
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
@@ -1153,10 +1816,36 @@ function normalizeArtifactPayload(payload, defaults) {
|
|
|
1153
1816
|
normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
|
|
1154
1817
|
normalized.phase = defaults.phase;
|
|
1155
1818
|
normalized.work_item_id = defaults.workItemId;
|
|
1819
|
+
const ownerFields = artifactOwnerFields(defaults);
|
|
1820
|
+
Object.assign(normalized, ownerFields);
|
|
1156
1821
|
normalized.status = defaults.status;
|
|
1157
1822
|
normalized.produced_by = defaults.producedBy;
|
|
1158
1823
|
return normalized;
|
|
1159
1824
|
}
|
|
1825
|
+
function normalizeQaGateArtifactPayload(payload, defaults) {
|
|
1826
|
+
const normalized = stripUndefined({ ...payload });
|
|
1827
|
+
delete normalized.report_markdown;
|
|
1828
|
+
delete normalized.content_hash;
|
|
1829
|
+
delete normalized.contentHash;
|
|
1830
|
+
normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
|
|
1831
|
+
normalized.phase = "qa";
|
|
1832
|
+
normalized.gate = defaults.gate;
|
|
1833
|
+
normalized.work_item_id = defaults.workItemId;
|
|
1834
|
+
const ownerFields = artifactOwnerFields(defaults);
|
|
1835
|
+
Object.assign(normalized, ownerFields);
|
|
1836
|
+
normalized.status = defaults.status;
|
|
1837
|
+
normalized.produced_by = defaults.producedBy;
|
|
1838
|
+
if (defaults.gate === "qa_plan") {
|
|
1839
|
+
const checklist = qaStringArray(normalized, "checklist");
|
|
1840
|
+
normalized.checklist = checklist.length > 0 ? checklist : defaultQaChecklist(defaults.status);
|
|
1841
|
+
normalized.acceptance_criteria = qaStringArray(normalized, "acceptance_criteria");
|
|
1842
|
+
}
|
|
1843
|
+
if (defaults.gate === "qa_findings") {
|
|
1844
|
+
normalized.findings = qaObjectArray(normalized, "findings");
|
|
1845
|
+
normalized.candidate_bugs = qaObjectArray(normalized, "candidate_bugs");
|
|
1846
|
+
}
|
|
1847
|
+
return normalized;
|
|
1848
|
+
}
|
|
1160
1849
|
function reportMarkdown(payload, phase, status, producedBy, contentHash) {
|
|
1161
1850
|
const explicit = optionalString(payload, "report_markdown");
|
|
1162
1851
|
if (explicit) {
|
|
@@ -1176,6 +1865,10 @@ ${summary}
|
|
|
1176
1865
|
}
|
|
1177
1866
|
function parseStrictCompletionPhase(value) {
|
|
1178
1867
|
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
1868
|
+
const debugPhase = normalizeDebugCompletionPhase(normalized);
|
|
1869
|
+
if (debugPhase) {
|
|
1870
|
+
return debugPhase;
|
|
1871
|
+
}
|
|
1179
1872
|
if (normalized === "implementation" || normalized === "implement" || normalized === "develop") {
|
|
1180
1873
|
return "implementation";
|
|
1181
1874
|
}
|
|
@@ -1190,6 +1883,43 @@ function parseStrictCompletionPhase(value) {
|
|
|
1190
1883
|
}
|
|
1191
1884
|
return null;
|
|
1192
1885
|
}
|
|
1886
|
+
function normalizeDebugCompletionPhase(value) {
|
|
1887
|
+
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
1888
|
+
switch (normalized) {
|
|
1889
|
+
case "debug_repro":
|
|
1890
|
+
case "debugrepro":
|
|
1891
|
+
case "repro":
|
|
1892
|
+
case "reproduce":
|
|
1893
|
+
return "debug_repro";
|
|
1894
|
+
case "debug_root_cause":
|
|
1895
|
+
case "debugrootcause":
|
|
1896
|
+
case "root_cause":
|
|
1897
|
+
case "rootcause":
|
|
1898
|
+
return "debug_root_cause";
|
|
1899
|
+
case "debug_fix":
|
|
1900
|
+
case "debugfix":
|
|
1901
|
+
case "fix":
|
|
1902
|
+
return "debug_fix";
|
|
1903
|
+
case "debug_regression":
|
|
1904
|
+
case "debugregression":
|
|
1905
|
+
case "regression":
|
|
1906
|
+
return "debug_regression";
|
|
1907
|
+
default:
|
|
1908
|
+
return null;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
function isDevelopCompletionPhase(phase) {
|
|
1912
|
+
return phase === "implementation" || phase === "self_review" || phase === "validation";
|
|
1913
|
+
}
|
|
1914
|
+
function isDebugCompletionPhase(phase) {
|
|
1915
|
+
return debugCompletionPhaseOrder.includes(phase);
|
|
1916
|
+
}
|
|
1917
|
+
function asDebugCompletionPhase(phase) {
|
|
1918
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1919
|
+
return phase;
|
|
1920
|
+
}
|
|
1921
|
+
throw new Error(`Expected Debug completion phase, got ${phase}.`);
|
|
1922
|
+
}
|
|
1193
1923
|
function parseCompletionRedactionSecrets(raw) {
|
|
1194
1924
|
if (!raw?.trim()) {
|
|
1195
1925
|
return [];
|
|
@@ -1219,8 +1949,35 @@ function phaseFromToolName(toolName) {
|
|
|
1219
1949
|
if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
|
|
1220
1950
|
return "qa";
|
|
1221
1951
|
}
|
|
1952
|
+
const debugGate = debugGateFromToolName(toolName);
|
|
1953
|
+
if (debugGate) {
|
|
1954
|
+
return debugGate;
|
|
1955
|
+
}
|
|
1956
|
+
if (qaGateFromToolName(toolName)) {
|
|
1957
|
+
return "qa";
|
|
1958
|
+
}
|
|
1222
1959
|
throw new Error(`Unknown Forge completion tool: ${toolName}`);
|
|
1223
1960
|
}
|
|
1961
|
+
function debugGateFromToolName(toolName) {
|
|
1962
|
+
const normalized = toolName.trim().toLowerCase();
|
|
1963
|
+
for (const gate of debugCompletionPhaseOrder) {
|
|
1964
|
+
const base = debugGateToolNames[gate];
|
|
1965
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
1966
|
+
return gate;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
return null;
|
|
1970
|
+
}
|
|
1971
|
+
function qaGateFromToolName(toolName) {
|
|
1972
|
+
const normalized = toolName.trim().toLowerCase();
|
|
1973
|
+
for (const gate of qaCompletionGateOrder) {
|
|
1974
|
+
const base = qaGateToolNames[gate];
|
|
1975
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
1976
|
+
return gate;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
return null;
|
|
1980
|
+
}
|
|
1224
1981
|
function requireField(payload, key) {
|
|
1225
1982
|
if (payload[key] === undefined || payload[key] === null || (typeof payload[key] === "string" && payload[key].trim().length === 0)) {
|
|
1226
1983
|
throw new Error(`${key} is required.`);
|
|
@@ -1288,6 +2045,22 @@ function requireArray(payload, key) {
|
|
|
1288
2045
|
throw new Error(`${key} must be an array.`);
|
|
1289
2046
|
}
|
|
1290
2047
|
}
|
|
2048
|
+
function requireNonEmptyArray(payload, key) {
|
|
2049
|
+
requireArray(payload, key);
|
|
2050
|
+
if (arrayLength(payload, key) === 0) {
|
|
2051
|
+
throw new Error(`${key} must include at least one value.`);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
function arrayLength(payload, key) {
|
|
2055
|
+
const value = payload[key];
|
|
2056
|
+
return Array.isArray(value) ? value.length : 0;
|
|
2057
|
+
}
|
|
2058
|
+
function requireAllowedStatus(payload, key, allowed) {
|
|
2059
|
+
const value = normalizedString(optionalString(payload, key));
|
|
2060
|
+
if (!allowed.includes(value)) {
|
|
2061
|
+
throw new Error(`${key} must be one of ${allowed.join(", ")}.`);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
1291
2064
|
function requireObject(payload, key) {
|
|
1292
2065
|
const value = payload[key];
|
|
1293
2066
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -1406,6 +2179,36 @@ function hasSubstantiveJsonValue(value) {
|
|
|
1406
2179
|
function normalizedString(value) {
|
|
1407
2180
|
return typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
|
|
1408
2181
|
}
|
|
2182
|
+
function normalizedOwnerType(value) {
|
|
2183
|
+
const normalized = normalizedString(value);
|
|
2184
|
+
return normalized === "work_item" || normalized === "story" ? normalized : undefined;
|
|
2185
|
+
}
|
|
2186
|
+
function artifactOwnerFields(config) {
|
|
2187
|
+
const ownerType = normalizedOwnerType(config.ownerType);
|
|
2188
|
+
const ownerId = config.ownerId?.trim();
|
|
2189
|
+
if (!ownerType && !ownerId) {
|
|
2190
|
+
return {};
|
|
2191
|
+
}
|
|
2192
|
+
return {
|
|
2193
|
+
owner_type: ownerType ?? "work_item",
|
|
2194
|
+
owner_id: ownerId || config.workItemId
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
function artifactReferenceOwnerFields(config) {
|
|
2198
|
+
const owner = artifactOwnerFields(config);
|
|
2199
|
+
return owner.owner_type && owner.owner_id
|
|
2200
|
+
? { ownerType: owner.owner_type, ownerId: owner.owner_id }
|
|
2201
|
+
: {};
|
|
2202
|
+
}
|
|
2203
|
+
function artifactOwnerDirectory(ownerType) {
|
|
2204
|
+
return normalizedOwnerType(ownerType) === "story" ? "stories" : "work-items";
|
|
2205
|
+
}
|
|
2206
|
+
function artifactOwnerRelativeRoot(ownerType, ownerId, workItemId) {
|
|
2207
|
+
const owner = artifactOwnerFields({ workItemId, ownerType, ownerId });
|
|
2208
|
+
return owner.owner_type && owner.owner_id
|
|
2209
|
+
? [".forge", artifactOwnerDirectory(owner.owner_type), owner.owner_id, "artifacts"].join("/")
|
|
2210
|
+
: [".forge", "work-items", workItemId, "artifacts"].join("/");
|
|
2211
|
+
}
|
|
1409
2212
|
function defaultQaChecklist(status) {
|
|
1410
2213
|
return [
|
|
1411
2214
|
"Review the validated work item against acceptance criteria.",
|
|
@@ -1416,6 +2219,9 @@ function defaultQaChecklist(status) {
|
|
|
1416
2219
|
function qaArtifactRelativePath(workItemId, fileName) {
|
|
1417
2220
|
return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
|
|
1418
2221
|
}
|
|
2222
|
+
function qaArtifactRelativePathForOwner(ownerType, ownerId, fileName) {
|
|
2223
|
+
return [".forge", artifactOwnerDirectory(ownerType), ownerId, "artifacts", fileName].join("/");
|
|
2224
|
+
}
|
|
1419
2225
|
function jsonText(value) {
|
|
1420
2226
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
1421
2227
|
}
|
|
@@ -1431,7 +2237,7 @@ function parseJsonObject(content, label) {
|
|
|
1431
2237
|
return asRecord(JSON.parse(content), label);
|
|
1432
2238
|
}
|
|
1433
2239
|
catch (error) {
|
|
1434
|
-
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}
|
|
2240
|
+
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`, { cause: error });
|
|
1435
2241
|
}
|
|
1436
2242
|
}
|
|
1437
2243
|
function parseOptionalJsonObject(content, label) {
|