@loucompanion/forge-bridge 0.1.1-dev.e52b249f07a8 → 0.1.1-dev.e646c8fd6b9b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/bridge.service.d.ts +1 -0
- package/dist/bridge/bridge.service.js +184 -24
- package/dist/bridge/bridge.types.d.ts +4 -2
- package/dist/cli/cli.service.js +225 -10
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +18 -3
- package/dist/config.js +1 -1
- package/dist/session/internals.d.ts +1 -1
- package/dist/session/phase-completion.d.ts +30 -5
- package/dist/session/phase-completion.js +1337 -28
- package/dist/session/provider-cli/provider-cli.base.d.ts +3 -1
- package/dist/session/provider-cli/provider-cli.service.d.ts +9 -1
- package/dist/session/provider-cli/provider-cli.service.js +1198 -236
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/session.service.js +47 -5
- package/dist/session/session.types.d.ts +92 -0
- package/dist/session/session.types.js +166 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +1 -0
- package/dist/worktree/artifact-injector.js +78 -1
- package/dist/worktree/worktree.service.d.ts +1 -0
- package/dist/worktree/worktree.service.js +132 -6
- package/dist/worktree/worktree.types.d.ts +8 -1
- package/package.json +8 -3
|
@@ -7,6 +7,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",
|
|
@@ -15,6 +54,8 @@ const qaBlockerReasons = new Set([
|
|
|
15
54
|
"missing_worktree",
|
|
16
55
|
"runner_failure"
|
|
17
56
|
]);
|
|
57
|
+
const resolvedFindingStatusValues = ["resolved", "disputed", "not_reproduced"];
|
|
58
|
+
const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
|
|
18
59
|
const qaFindingSeverities = new Set(["critical", "major", "minor"]);
|
|
19
60
|
const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
|
|
20
61
|
const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
|
|
@@ -34,6 +75,26 @@ const phaseFiles = {
|
|
|
34
75
|
qa: {
|
|
35
76
|
jsonFileName: "qa.verdict.json",
|
|
36
77
|
reportFileName: "qa.report.md"
|
|
78
|
+
},
|
|
79
|
+
debug_repro: {
|
|
80
|
+
jsonFileName: "debug.repro.json",
|
|
81
|
+
reportFileName: "debug.report.md"
|
|
82
|
+
},
|
|
83
|
+
debug_root_cause: {
|
|
84
|
+
jsonFileName: "debug.root-cause.json",
|
|
85
|
+
reportFileName: "debug.report.md"
|
|
86
|
+
},
|
|
87
|
+
debug_fix: {
|
|
88
|
+
jsonFileName: "debug.fix.json",
|
|
89
|
+
reportFileName: "debug.report.md"
|
|
90
|
+
},
|
|
91
|
+
debug_regression: {
|
|
92
|
+
jsonFileName: "debug.regression.json",
|
|
93
|
+
reportFileName: "debug.report.md"
|
|
94
|
+
},
|
|
95
|
+
architect: {
|
|
96
|
+
jsonFileName: "architect.verdict.json",
|
|
97
|
+
reportFileName: "architect.report.md"
|
|
37
98
|
}
|
|
38
99
|
};
|
|
39
100
|
export function completionPhaseFromDispatch(dispatch) {
|
|
@@ -41,6 +102,10 @@ export function completionPhaseFromDispatch(dispatch) {
|
|
|
41
102
|
}
|
|
42
103
|
export function normalizeCompletionPhase(value) {
|
|
43
104
|
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
105
|
+
const debugPhase = normalizeDebugCompletionPhase(normalized);
|
|
106
|
+
if (debugPhase) {
|
|
107
|
+
return debugPhase;
|
|
108
|
+
}
|
|
44
109
|
if (normalized === "self_review" || normalized === "selfreview" || normalized === "review") {
|
|
45
110
|
return "self_review";
|
|
46
111
|
}
|
|
@@ -50,6 +115,9 @@ export function normalizeCompletionPhase(value) {
|
|
|
50
115
|
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
51
116
|
return "qa";
|
|
52
117
|
}
|
|
118
|
+
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
119
|
+
return "architect";
|
|
120
|
+
}
|
|
53
121
|
return "implementation";
|
|
54
122
|
}
|
|
55
123
|
export function usesDevelopCycleCompletion(dispatch) {
|
|
@@ -61,30 +129,79 @@ export function usesDevelopCycleCompletion(dispatch) {
|
|
|
61
129
|
&& lane === "develop"
|
|
62
130
|
&& completionPhaseFromDispatch(dispatch) === "implementation";
|
|
63
131
|
}
|
|
132
|
+
export function usesQaCycleCompletion(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 === "qa"
|
|
137
|
+
&& role === "qa"
|
|
138
|
+
&& lane === "qa"
|
|
139
|
+
&& completionPhaseFromDispatch(dispatch) === "qa";
|
|
140
|
+
}
|
|
141
|
+
export function usesDebugCycleCompletion(dispatch) {
|
|
142
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
143
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
144
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
145
|
+
return kind === "debug"
|
|
146
|
+
&& role === "debug"
|
|
147
|
+
&& lane === "debug"
|
|
148
|
+
&& isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
|
|
149
|
+
}
|
|
150
|
+
export function usesArchitectCycleCompletion(dispatch) {
|
|
151
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
152
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
153
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
154
|
+
return kind === "architect" || role === "architect" || lane === "architect";
|
|
155
|
+
}
|
|
64
156
|
export function completionToolNamesForPhase(phase) {
|
|
157
|
+
if (isDebugCompletionPhase(phase)) {
|
|
158
|
+
return completionToolNamesForDebugGate(phase);
|
|
159
|
+
}
|
|
65
160
|
const base = phase === "implementation"
|
|
66
161
|
? "complete_implementation"
|
|
67
162
|
: phase === "self_review"
|
|
68
163
|
? "complete_self_review"
|
|
69
164
|
: phase === "validation"
|
|
70
165
|
? "complete_validation"
|
|
71
|
-
: "
|
|
166
|
+
: phase === "architect"
|
|
167
|
+
? "complete_architect_plan"
|
|
168
|
+
: "complete_qa";
|
|
169
|
+
return [`forge.${base}`, base];
|
|
170
|
+
}
|
|
171
|
+
export function completionToolNamesForQaGate(gate) {
|
|
172
|
+
const base = qaGateToolNames[gate];
|
|
173
|
+
return [`forge.${base}`, base];
|
|
174
|
+
}
|
|
175
|
+
export function completionToolNamesForDebugGate(gate) {
|
|
176
|
+
const base = debugGateToolNames[gate];
|
|
72
177
|
return [`forge.${base}`, base];
|
|
73
178
|
}
|
|
74
179
|
export function expectedCompletionToolName(phase) {
|
|
75
180
|
return completionToolNamesForPhase(phase)[0];
|
|
76
181
|
}
|
|
182
|
+
export function expectedQaCompletionToolName(gate) {
|
|
183
|
+
return completionToolNamesForQaGate(gate)[0];
|
|
184
|
+
}
|
|
185
|
+
export function expectedDebugCompletionToolName(gate) {
|
|
186
|
+
return completionToolNamesForDebugGate(gate)[0];
|
|
187
|
+
}
|
|
77
188
|
export function artifactRootFor(worktreePath, workItemId) {
|
|
78
189
|
return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
|
|
79
190
|
}
|
|
80
|
-
function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
191
|
+
export function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
81
192
|
return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
|
|
82
193
|
}
|
|
83
|
-
export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
|
|
84
|
-
|
|
194
|
+
export function completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
195
|
+
const artifactRoot = ownerType && ownerId
|
|
196
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
197
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
198
|
+
return path.join(artifactRoot, phaseFiles[phase].jsonFileName);
|
|
85
199
|
}
|
|
86
|
-
export function completionArtifactReportPath(worktreePath, workItemId, phase) {
|
|
87
|
-
|
|
200
|
+
export function completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
201
|
+
const artifactRoot = ownerType && ownerId
|
|
202
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
203
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
204
|
+
return path.join(artifactRoot, phaseFiles[phase].reportFileName);
|
|
88
205
|
}
|
|
89
206
|
export async function prepareCompletionToolRuntime(options) {
|
|
90
207
|
const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
|
|
@@ -99,7 +216,10 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
99
216
|
ownerId,
|
|
100
217
|
phase,
|
|
101
218
|
producedBy: `${options.dispatch.execution.provider}-cli`,
|
|
102
|
-
developCycle: usesDevelopCycleCompletion(options.dispatch)
|
|
219
|
+
developCycle: usesDevelopCycleCompletion(options.dispatch),
|
|
220
|
+
qaCycle: usesQaCycleCompletion(options.dispatch),
|
|
221
|
+
debugCycle: usesDebugCycleCompletion(options.dispatch),
|
|
222
|
+
architectCycle: usesArchitectCycleCompletion(options.dispatch)
|
|
103
223
|
});
|
|
104
224
|
const runtime = {
|
|
105
225
|
serverName: toolServerName,
|
|
@@ -132,6 +252,15 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
132
252
|
if (config.developCycle) {
|
|
133
253
|
args.push("--develop-cycle");
|
|
134
254
|
}
|
|
255
|
+
if (config.qaCycle) {
|
|
256
|
+
args.push("--qa-cycle");
|
|
257
|
+
}
|
|
258
|
+
if (config.debugCycle) {
|
|
259
|
+
args.push("--debug-cycle");
|
|
260
|
+
}
|
|
261
|
+
if (config.architectCycle) {
|
|
262
|
+
args.push("--architect-cycle");
|
|
263
|
+
}
|
|
135
264
|
return args;
|
|
136
265
|
}
|
|
137
266
|
export function codexMcpConfigArgs(runtime) {
|
|
@@ -164,6 +293,16 @@ Do not write QA artifacts by hand. When QA is complete, call \`${toolName}(...)\
|
|
|
164
293
|
That tool writes the durable QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`${artifactName}\`, and \`qa.report.md\`.
|
|
165
294
|
Use \`status: "pass"\` only when the validated changes satisfy acceptance criteria with concrete evidence. Use \`status: "needs_rework"\` with actionable in-scope findings, or \`status: "blocked"\` with a concrete blocker_reason.
|
|
166
295
|
Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
|
|
296
|
+
}
|
|
297
|
+
if (phase === "architect") {
|
|
298
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
299
|
+
Do not write Architect artifacts by hand. When the plan is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the full structured Architect planning payload.
|
|
300
|
+
That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
|
|
301
|
+
The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
|
|
302
|
+
\`{ "status": "pass", "summary": "...", "implementation_plan": { "schema_version": 1, "plan_id": "plan-...", "phases": [{ "id": "phase-1", "tasks": ["task-1"], "gate": "gate-1" }], "tasks": [{ "id": "task-1", "tests": [{ "command": "npm test" }] }], "dependencies": [], "phase_gates": [{ "id": "gate-1", "phase": "phase-1" }] }, "qa_plan": { "schema_version": 1, "final_qa": [{ "id": "qa-1", "coverage": "..." }] }, "report_markdown": "# Architect Report" }\`.
|
|
303
|
+
Dependency entries use canonical \`before\` and \`after\` task ids, for example \`{ "before": "task-1", "after": "task-2" }\`. Forge accepts \`from\`/\`to\` only as aliases and normalizes them before writing artifacts.
|
|
304
|
+
Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
|
|
305
|
+
Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
|
|
167
306
|
}
|
|
168
307
|
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
169
308
|
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.
|
|
@@ -180,31 +319,74 @@ Complete the dev cycle in order inside this same provider session:
|
|
|
180
319
|
Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
|
|
181
320
|
If the current phase cannot pass, call the current phase tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
|
|
182
321
|
}
|
|
322
|
+
export function completionPromptForQaCycle(currentGate = "qa_plan") {
|
|
323
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
324
|
+
Do not write QA artifacts by hand. The tool server enforces QA gate order and starts this run at \`${currentGate}\`.
|
|
325
|
+
Complete the QA cycle in order inside this same provider session:
|
|
326
|
+
1. Call \`forge.complete_qa_plan(...)\` after the QA test plan/checklist is ready.
|
|
327
|
+
2. Follow the returned regression instruction, then call \`forge.complete_qa_regression(...)\`.
|
|
328
|
+
3. Follow the returned API/backend instruction, then call \`forge.complete_qa_api(...)\`.
|
|
329
|
+
4. Follow the returned manual web instruction, then call \`forge.complete_qa_manual_web(...)\`.
|
|
330
|
+
5. Follow the returned bug reproduction instruction, then call \`forge.complete_qa_bug_repro(...)\`.
|
|
331
|
+
6. Follow the returned findings instruction, then call \`forge.complete_qa_findings(...)\`.
|
|
332
|
+
7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
|
|
333
|
+
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.
|
|
334
|
+
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\`.
|
|
335
|
+
The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
|
|
336
|
+
}
|
|
337
|
+
export function completionPromptForDebugCycle(currentGate = "debug_repro") {
|
|
338
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
339
|
+
Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
|
|
340
|
+
Complete the Debug cycle in order inside this same provider session:
|
|
341
|
+
1. Reproduce or tightly characterize the bug, then call \`forge.complete_debug_repro(...)\`.
|
|
342
|
+
2. Follow the returned root-cause instruction, isolate evidence, then call \`forge.complete_debug_root_cause(...)\`.
|
|
343
|
+
3. Follow the returned fix instruction, apply the smallest safe fix with regression coverage when practical, then call \`forge.complete_debug_fix(...)\`.
|
|
344
|
+
4. Follow the returned regression instruction, prove the original repro is negative and the fix is QA-ready, then call \`forge.complete_debug_regression(...)\`.
|
|
345
|
+
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.
|
|
346
|
+
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.`;
|
|
347
|
+
}
|
|
183
348
|
export function completionToolNamesForServer(config) {
|
|
184
|
-
return config.
|
|
185
|
-
?
|
|
186
|
-
:
|
|
349
|
+
return config.debugCycle
|
|
350
|
+
? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
|
|
351
|
+
: config.qaCycle
|
|
352
|
+
? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
|
|
353
|
+
: config.developCycle
|
|
354
|
+
? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
|
|
355
|
+
: config.architectCycle
|
|
356
|
+
? completionToolNamesForPhase("architect")
|
|
357
|
+
: completionToolNamesForPhase(config.phase);
|
|
187
358
|
}
|
|
188
359
|
export class CompletionToolServerSession {
|
|
189
360
|
config;
|
|
190
361
|
currentPhase;
|
|
362
|
+
currentQaGate = "qa_plan";
|
|
191
363
|
completed = false;
|
|
192
364
|
constructor(config) {
|
|
193
365
|
this.config = config;
|
|
194
366
|
this.currentPhase = config.phase;
|
|
195
367
|
}
|
|
196
368
|
get instructions() {
|
|
197
|
-
return this.config.
|
|
198
|
-
?
|
|
199
|
-
:
|
|
369
|
+
return this.config.debugCycle
|
|
370
|
+
? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
|
|
371
|
+
: this.config.qaCycle
|
|
372
|
+
? completionPromptForQaCycle(this.currentQaGate)
|
|
373
|
+
: this.config.developCycle
|
|
374
|
+
? completionPromptForDevelopCycle(this.currentPhase)
|
|
375
|
+
: completionPromptForPhase(this.currentPhase);
|
|
200
376
|
}
|
|
201
377
|
toolDefinitions() {
|
|
202
|
-
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name)));
|
|
378
|
+
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
|
|
203
379
|
}
|
|
204
380
|
async callTool(toolName, input) {
|
|
205
381
|
const requestedPhase = phaseFromToolName(toolName);
|
|
206
382
|
if (this.completed) {
|
|
207
|
-
throw new Error("Forge
|
|
383
|
+
throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
|
|
384
|
+
}
|
|
385
|
+
if (this.config.debugCycle) {
|
|
386
|
+
return await this.callDebugGateTool(toolName, requestedPhase, input);
|
|
387
|
+
}
|
|
388
|
+
if (this.config.qaCycle) {
|
|
389
|
+
return await this.callQaGateTool(toolName, requestedPhase, input);
|
|
208
390
|
}
|
|
209
391
|
if (requestedPhase !== this.currentPhase) {
|
|
210
392
|
throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
|
|
@@ -240,10 +422,91 @@ export class CompletionToolServerSession {
|
|
|
240
422
|
}
|
|
241
423
|
};
|
|
242
424
|
}
|
|
425
|
+
async callDebugGateTool(toolName, requestedPhase, input) {
|
|
426
|
+
const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
|
|
427
|
+
if (!isDebugCompletionPhase(requestedPhase)) {
|
|
428
|
+
throw new Error(`Tool ${toolName} is not available in Debug-cycle mode. Current Forge Debug gate is ${currentDebugGate}.`);
|
|
429
|
+
}
|
|
430
|
+
if (requestedPhase !== currentDebugGate) {
|
|
431
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge Debug gate is ${currentDebugGate}.`);
|
|
432
|
+
}
|
|
433
|
+
const completedGate = currentDebugGate;
|
|
434
|
+
const reference = await writeCompletionArtifact({ ...this.config, phase: completedGate }, toolName, input);
|
|
435
|
+
const status = reference.status.trim().toLowerCase();
|
|
436
|
+
const pass = status === "pass";
|
|
437
|
+
const nextGate = pass ? this.advanceAfterDebugGate(completedGate) : completedGate;
|
|
438
|
+
const nextPhase = nextGate ?? "qa_ready";
|
|
439
|
+
const nextInstruction = completionToolNextInstructionForDebugGate(completedGate, status, nextGate);
|
|
440
|
+
const reason = pass
|
|
441
|
+
? `${completedGate} artifact accepted${nextGate ? `; continue to ${nextGate}.` : "; Debug regression is QA-ready."}`
|
|
442
|
+
: `${completedGate} artifact recorded with status ${status}; rework is required before Debug progression.`;
|
|
443
|
+
return {
|
|
444
|
+
content: [
|
|
445
|
+
{
|
|
446
|
+
type: "text",
|
|
447
|
+
text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
|
|
448
|
+
}
|
|
449
|
+
],
|
|
450
|
+
structuredContent: {
|
|
451
|
+
...reference,
|
|
452
|
+
gate: {
|
|
453
|
+
phase: completedGate,
|
|
454
|
+
status,
|
|
455
|
+
allowed: pass,
|
|
456
|
+
reason
|
|
457
|
+
},
|
|
458
|
+
nextPhase,
|
|
459
|
+
nextInstruction
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
async callQaGateTool(toolName, requestedPhase, input) {
|
|
464
|
+
const requestedGate = qaGateFromToolName(toolName);
|
|
465
|
+
if (requestedPhase !== "qa" || !requestedGate) {
|
|
466
|
+
throw new Error(`Tool ${toolName} is not available in QA-cycle mode. Current Forge QA gate is ${this.currentQaGate}.`);
|
|
467
|
+
}
|
|
468
|
+
if (requestedGate !== this.currentQaGate) {
|
|
469
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge QA gate is ${this.currentQaGate}.`);
|
|
470
|
+
}
|
|
471
|
+
const completedGate = this.currentQaGate;
|
|
472
|
+
const reference = await writeCompletionArtifact({ ...this.config, phase: "qa" }, toolName, input);
|
|
473
|
+
const status = reference.status.trim().toLowerCase();
|
|
474
|
+
const nextGate = this.advanceAfterQaGate(completedGate);
|
|
475
|
+
const nextPhase = nextGate ? "qa" : "sign_off_ready";
|
|
476
|
+
const nextInstruction = completionToolNextInstructionForQaGate(completedGate, status, nextGate);
|
|
477
|
+
const allowed = status !== "blocked";
|
|
478
|
+
const reason = nextGate
|
|
479
|
+
? `${completedGate} artifact accepted; continue to ${nextGate}.`
|
|
480
|
+
: `QA verdict artifact accepted with status ${status}.`;
|
|
481
|
+
return {
|
|
482
|
+
content: [
|
|
483
|
+
{
|
|
484
|
+
type: "text",
|
|
485
|
+
text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
|
|
486
|
+
}
|
|
487
|
+
],
|
|
488
|
+
structuredContent: {
|
|
489
|
+
...reference,
|
|
490
|
+
gate: {
|
|
491
|
+
phase: "qa",
|
|
492
|
+
qaGate: completedGate,
|
|
493
|
+
status,
|
|
494
|
+
allowed,
|
|
495
|
+
reason
|
|
496
|
+
},
|
|
497
|
+
nextPhase,
|
|
498
|
+
nextInstruction
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
}
|
|
243
502
|
advanceAfterPass(phase) {
|
|
244
503
|
if (phase === "qa") {
|
|
504
|
+
this.completed = true;
|
|
245
505
|
return "sign_off_ready";
|
|
246
506
|
}
|
|
507
|
+
if (isDebugCompletionPhase(phase)) {
|
|
508
|
+
return nextDebugPhaseAfter(phase) ?? "qa_ready";
|
|
509
|
+
}
|
|
247
510
|
const next = nextPhaseAfter(phase);
|
|
248
511
|
if (!this.config.developCycle) {
|
|
249
512
|
return next ?? "dev_test_ready";
|
|
@@ -255,15 +518,42 @@ export class CompletionToolServerSession {
|
|
|
255
518
|
this.completed = true;
|
|
256
519
|
return "dev_test_ready";
|
|
257
520
|
}
|
|
521
|
+
advanceAfterQaGate(gate) {
|
|
522
|
+
const index = qaCompletionGateOrder.indexOf(gate);
|
|
523
|
+
const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
|
|
524
|
+
if (next) {
|
|
525
|
+
this.currentQaGate = next;
|
|
526
|
+
return next;
|
|
527
|
+
}
|
|
528
|
+
this.completed = true;
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
advanceAfterDebugGate(gate) {
|
|
532
|
+
const index = debugCompletionPhaseOrder.indexOf(gate);
|
|
533
|
+
const next = index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
|
|
534
|
+
if (next) {
|
|
535
|
+
this.currentPhase = next;
|
|
536
|
+
return next;
|
|
537
|
+
}
|
|
538
|
+
this.completed = true;
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
258
541
|
}
|
|
259
542
|
export async function writeCompletionArtifact(config, toolName, input) {
|
|
260
543
|
const phase = phaseFromToolName(toolName);
|
|
261
544
|
if (phase !== config.phase) {
|
|
262
545
|
throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
|
|
263
546
|
}
|
|
547
|
+
const qaGate = qaGateFromToolName(toolName);
|
|
548
|
+
if (qaGate && qaGate !== "qa_verdict") {
|
|
549
|
+
return await writeQaGateCompletionArtifact(config, qaGate, input);
|
|
550
|
+
}
|
|
264
551
|
const payload = asRecord(input, "completion payload");
|
|
265
552
|
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
266
|
-
|
|
553
|
+
let validationPayload = { ...payload, status };
|
|
554
|
+
if (config.qaCycle && qaGate === "qa_verdict") {
|
|
555
|
+
validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
|
|
556
|
+
}
|
|
267
557
|
validatePayloadShape(phase, validationPayload);
|
|
268
558
|
validateOptionalWorkItemId(validationPayload, config.workItemId);
|
|
269
559
|
const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
|
|
@@ -274,6 +564,9 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
274
564
|
if (phase === "qa") {
|
|
275
565
|
return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
276
566
|
}
|
|
567
|
+
if (phase === "architect") {
|
|
568
|
+
return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
569
|
+
}
|
|
277
570
|
const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
|
|
278
571
|
phase,
|
|
279
572
|
workItemId: config.workItemId,
|
|
@@ -306,6 +599,92 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
306
599
|
producedBy
|
|
307
600
|
};
|
|
308
601
|
}
|
|
602
|
+
async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
|
|
603
|
+
const ownerFields = artifactOwnerFields(config);
|
|
604
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
605
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
606
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
607
|
+
const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
|
|
608
|
+
const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
|
|
609
|
+
const verdictPath = path.join(artifactRoot, "architect.verdict.json");
|
|
610
|
+
const reportPath = path.join(artifactRoot, "architect.report.md");
|
|
611
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
612
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
613
|
+
const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
|
|
614
|
+
const artifactWithoutHash = normalizeArtifactPayload(payload, {
|
|
615
|
+
phase: "architect",
|
|
616
|
+
workItemId: config.workItemId,
|
|
617
|
+
ownerType: config.ownerType,
|
|
618
|
+
ownerId: config.ownerId,
|
|
619
|
+
producedBy,
|
|
620
|
+
status
|
|
621
|
+
});
|
|
622
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
623
|
+
const verdict = {
|
|
624
|
+
...artifactWithoutHash,
|
|
625
|
+
content_hash: contentHash
|
|
626
|
+
};
|
|
627
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
628
|
+
await writeFileAtomic(implementationPlanPath, jsonText(implementationPlan));
|
|
629
|
+
await writeFileAtomic(qaPlanPath, jsonText(qaPlan));
|
|
630
|
+
await writeFileAtomic(verdictPath, jsonText(verdict));
|
|
631
|
+
await writeFileAtomic(reportPath, `${report.trim()}\n`);
|
|
632
|
+
return {
|
|
633
|
+
phase: "architect",
|
|
634
|
+
workItemId: config.workItemId,
|
|
635
|
+
...artifactReferenceOwnerFields(config),
|
|
636
|
+
repoPath: path.resolve(config.worktreePath),
|
|
637
|
+
path: verdictPath,
|
|
638
|
+
reportPath,
|
|
639
|
+
contentHash,
|
|
640
|
+
status,
|
|
641
|
+
producedBy
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
async function writeQaGateCompletionArtifact(config, gate, input) {
|
|
645
|
+
const payload = asRecord(input, "QA gate completion payload");
|
|
646
|
+
const status = normalizeQaGateStatus(requiredString(payload, "status"));
|
|
647
|
+
validateQaGatePayloadShape(gate, payload, status);
|
|
648
|
+
validateOptionalWorkItemId(payload, config.workItemId);
|
|
649
|
+
const redactedPayload = redactSecrets({ ...payload, status }, config.redactionSecrets ?? []);
|
|
650
|
+
const producedBy = optionalString(redactedPayload, "produced_by")
|
|
651
|
+
?? optionalString(redactedPayload, "producedBy")
|
|
652
|
+
?? config.producedBy
|
|
653
|
+
?? "forge-completion-tool";
|
|
654
|
+
const ownerFields = artifactOwnerFields(config);
|
|
655
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
656
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
657
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
658
|
+
const fileName = qaGateFileNames[gate];
|
|
659
|
+
const artifactPath = path.join(artifactRoot, fileName);
|
|
660
|
+
const reportPath = path.join(artifactRoot, "qa.report.md");
|
|
661
|
+
const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
|
|
662
|
+
gate,
|
|
663
|
+
workItemId: config.workItemId,
|
|
664
|
+
ownerType: config.ownerType,
|
|
665
|
+
ownerId: config.ownerId,
|
|
666
|
+
producedBy,
|
|
667
|
+
status
|
|
668
|
+
});
|
|
669
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
670
|
+
const artifact = {
|
|
671
|
+
...artifactWithoutHash,
|
|
672
|
+
content_hash: contentHash
|
|
673
|
+
};
|
|
674
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
675
|
+
await writeFileAtomic(artifactPath, jsonText(artifact));
|
|
676
|
+
return {
|
|
677
|
+
phase: "qa",
|
|
678
|
+
workItemId: config.workItemId,
|
|
679
|
+
...artifactReferenceOwnerFields(config),
|
|
680
|
+
repoPath: path.resolve(config.worktreePath),
|
|
681
|
+
path: artifactPath,
|
|
682
|
+
reportPath,
|
|
683
|
+
contentHash,
|
|
684
|
+
status,
|
|
685
|
+
producedBy
|
|
686
|
+
};
|
|
687
|
+
}
|
|
309
688
|
async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
310
689
|
const ownerFields = artifactOwnerFields(config);
|
|
311
690
|
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
@@ -417,6 +796,133 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
417
796
|
producedBy
|
|
418
797
|
};
|
|
419
798
|
}
|
|
799
|
+
async function synthesizeQaCycleVerdictPayload(config, payload, status) {
|
|
800
|
+
const qaPlan = await readQaGateArtifact(config, "qa_plan");
|
|
801
|
+
const qaRegression = await readQaGateArtifact(config, "qa_regression");
|
|
802
|
+
const qaApi = await readQaGateArtifact(config, "qa_api");
|
|
803
|
+
const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
|
|
804
|
+
const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
|
|
805
|
+
const qaFindings = await readQaGateArtifact(config, "qa_findings");
|
|
806
|
+
const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
|
|
807
|
+
const payloadEvidence = qaEvidenceSource(payload);
|
|
808
|
+
const merged = { ...payload };
|
|
809
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
810
|
+
if (checklist.length === 0) {
|
|
811
|
+
const plannedChecklist = qaStringArray(qaPlan, "checklist");
|
|
812
|
+
merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
|
|
813
|
+
}
|
|
814
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
815
|
+
if (testedAcceptanceCriteria.length === 0) {
|
|
816
|
+
const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
|
|
817
|
+
const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
|
|
818
|
+
const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
|
|
819
|
+
if (acceptanceCriteria.length > 0) {
|
|
820
|
+
merged.tested_acceptance_criteria = acceptanceCriteria;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
const payloadCommands = qaArray(payload, "commands", payloadEvidence);
|
|
824
|
+
const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
|
|
825
|
+
const inheritedDiagnosticCommandNotes = status === "pass"
|
|
826
|
+
? qaNonPassingCommandNotes(inheritedCommands)
|
|
827
|
+
: [];
|
|
828
|
+
merged.commands = dedupeUnknownArray([
|
|
829
|
+
...payloadCommands,
|
|
830
|
+
...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
|
|
831
|
+
]);
|
|
832
|
+
merged.browser_checks = dedupeUnknownArray([
|
|
833
|
+
...qaArray(payload, "browser_checks", payloadEvidence),
|
|
834
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
|
|
835
|
+
]);
|
|
836
|
+
merged.sidecars = dedupeUnknownArray([
|
|
837
|
+
...qaArray(payload, "sidecars", payloadEvidence),
|
|
838
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
|
|
839
|
+
]);
|
|
840
|
+
const notes = dedupeStrings([
|
|
841
|
+
optionalString(payload, "notes"),
|
|
842
|
+
optionalString(payloadEvidence, "notes"),
|
|
843
|
+
...evidenceGates.map((gate) => optionalString(gate, "notes")),
|
|
844
|
+
...inheritedDiagnosticCommandNotes
|
|
845
|
+
]);
|
|
846
|
+
if (notes.length > 0) {
|
|
847
|
+
merged.notes = notes.join("\n");
|
|
848
|
+
}
|
|
849
|
+
merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
|
|
850
|
+
merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
|
|
851
|
+
return merged;
|
|
852
|
+
}
|
|
853
|
+
async function readQaGateArtifact(config, gate) {
|
|
854
|
+
const ownerFields = artifactOwnerFields(config);
|
|
855
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
856
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
857
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
858
|
+
const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
|
|
859
|
+
const content = await readOptionalFile(artifactPath);
|
|
860
|
+
return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
|
|
861
|
+
}
|
|
862
|
+
function dedupeUnknownArray(values) {
|
|
863
|
+
const seen = new Set();
|
|
864
|
+
const deduped = [];
|
|
865
|
+
for (const value of values) {
|
|
866
|
+
const key = stableStringify(value);
|
|
867
|
+
if (seen.has(key)) {
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
seen.add(key);
|
|
871
|
+
deduped.push(value);
|
|
872
|
+
}
|
|
873
|
+
return deduped;
|
|
874
|
+
}
|
|
875
|
+
function isQaPassingCommandEvidence(value) {
|
|
876
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
const record = value;
|
|
880
|
+
return record.exit_code === 0 && normalizedString(record.status) === "pass";
|
|
881
|
+
}
|
|
882
|
+
function qaNonPassingCommandNotes(values) {
|
|
883
|
+
return values
|
|
884
|
+
.filter((value) => !isQaPassingCommandEvidence(value))
|
|
885
|
+
.map((value, index) => {
|
|
886
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
887
|
+
return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
|
|
888
|
+
}
|
|
889
|
+
const record = value;
|
|
890
|
+
const command = optionalString(record, "command") ?? "<missing command>";
|
|
891
|
+
const status = optionalString(record, "status") ?? "<missing status>";
|
|
892
|
+
const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
|
|
893
|
+
? String(record.exit_code)
|
|
894
|
+
: "<missing exit_code>";
|
|
895
|
+
const summary = optionalString(record, "summary");
|
|
896
|
+
return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
function mergeQaObjectArraysById(primary, fallback) {
|
|
900
|
+
const seen = new Set();
|
|
901
|
+
const merged = [];
|
|
902
|
+
for (const value of [...primary, ...fallback]) {
|
|
903
|
+
const id = optionalString(value, "id");
|
|
904
|
+
const key = id ? `id:${id}` : stableStringify(value);
|
|
905
|
+
if (seen.has(key)) {
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
seen.add(key);
|
|
909
|
+
merged.push(value);
|
|
910
|
+
}
|
|
911
|
+
return merged;
|
|
912
|
+
}
|
|
913
|
+
function dedupeStrings(values) {
|
|
914
|
+
const seen = new Set();
|
|
915
|
+
const deduped = [];
|
|
916
|
+
for (const value of values) {
|
|
917
|
+
const trimmed = value?.trim();
|
|
918
|
+
if (!trimmed || seen.has(trimmed)) {
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
seen.add(trimmed);
|
|
922
|
+
deduped.push(trimmed);
|
|
923
|
+
}
|
|
924
|
+
return deduped;
|
|
925
|
+
}
|
|
420
926
|
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
421
927
|
if (sidecars.length === 0) {
|
|
422
928
|
return [];
|
|
@@ -470,19 +976,28 @@ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, o
|
|
|
470
976
|
}
|
|
471
977
|
return validated;
|
|
472
978
|
}
|
|
473
|
-
export async function readCompletionArtifact(worktreePath, workItemId, phase) {
|
|
474
|
-
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
|
|
475
|
-
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
|
|
979
|
+
export async function readCompletionArtifact(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
980
|
+
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId);
|
|
981
|
+
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId);
|
|
476
982
|
const verdictJson = await readOptionalFile(artifactPath);
|
|
477
983
|
if (verdictJson === null) {
|
|
478
984
|
return null;
|
|
479
985
|
}
|
|
480
986
|
const parsed = parseJsonObject(verdictJson, artifactPath);
|
|
481
987
|
const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
|
|
988
|
+
const artifactRoot = path.dirname(artifactPath);
|
|
989
|
+
const implementationPlanJson = phase === "architect"
|
|
990
|
+
? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
|
|
991
|
+
: null;
|
|
992
|
+
const qaPlanJson = phase === "architect"
|
|
993
|
+
? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
|
|
994
|
+
: null;
|
|
482
995
|
return {
|
|
483
996
|
repoPath: path.resolve(worktreePath),
|
|
484
997
|
path: artifactPath,
|
|
485
998
|
verdictJson,
|
|
999
|
+
implementationPlanJson,
|
|
1000
|
+
qaPlanJson,
|
|
486
1001
|
report,
|
|
487
1002
|
producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
|
|
488
1003
|
status: optionalString(parsed, "status") ?? "unknown",
|
|
@@ -518,6 +1033,9 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
518
1033
|
let ownerId;
|
|
519
1034
|
let producedBy;
|
|
520
1035
|
let developCycle = false;
|
|
1036
|
+
let qaCycle = false;
|
|
1037
|
+
let debugCycle = false;
|
|
1038
|
+
let architectCycle = false;
|
|
521
1039
|
for (let index = 1; index < argv.length; index += 1) {
|
|
522
1040
|
const arg = argv[index];
|
|
523
1041
|
if (arg === "--worktree") {
|
|
@@ -539,7 +1057,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
539
1057
|
const rawPhase = argv[++index];
|
|
540
1058
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
541
1059
|
if (!parsedPhase) {
|
|
542
|
-
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa." };
|
|
1060
|
+
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." };
|
|
543
1061
|
}
|
|
544
1062
|
phase = parsedPhase;
|
|
545
1063
|
}
|
|
@@ -549,6 +1067,15 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
549
1067
|
else if (arg === "--develop-cycle") {
|
|
550
1068
|
developCycle = true;
|
|
551
1069
|
}
|
|
1070
|
+
else if (arg === "--qa-cycle") {
|
|
1071
|
+
qaCycle = true;
|
|
1072
|
+
}
|
|
1073
|
+
else if (arg === "--debug-cycle") {
|
|
1074
|
+
debugCycle = true;
|
|
1075
|
+
}
|
|
1076
|
+
else if (arg === "--architect-cycle") {
|
|
1077
|
+
architectCycle = true;
|
|
1078
|
+
}
|
|
552
1079
|
else {
|
|
553
1080
|
return { ok: false, error: `Unknown mcp argument: ${arg}` };
|
|
554
1081
|
}
|
|
@@ -556,6 +1083,21 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
556
1083
|
if (!worktreePath?.trim() || !workItemId?.trim()) {
|
|
557
1084
|
return { ok: false, error: "mcp requires --worktree and --work-item-id." };
|
|
558
1085
|
}
|
|
1086
|
+
if ([developCycle, qaCycle, debugCycle, architectCycle].filter(Boolean).length > 1) {
|
|
1087
|
+
return { ok: false, error: "mcp accepts only one cycle mode." };
|
|
1088
|
+
}
|
|
1089
|
+
if (developCycle && !isDevelopCompletionPhase(phase)) {
|
|
1090
|
+
return { ok: false, error: "mcp --develop-cycle requires --phase implementation, self-review, or validation." };
|
|
1091
|
+
}
|
|
1092
|
+
if (qaCycle && phase !== "qa") {
|
|
1093
|
+
return { ok: false, error: "mcp --qa-cycle requires --phase qa." };
|
|
1094
|
+
}
|
|
1095
|
+
if (debugCycle && !isDebugCompletionPhase(phase)) {
|
|
1096
|
+
return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
|
|
1097
|
+
}
|
|
1098
|
+
if (architectCycle && phase !== "architect") {
|
|
1099
|
+
return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
|
|
1100
|
+
}
|
|
559
1101
|
return {
|
|
560
1102
|
ok: true,
|
|
561
1103
|
config: {
|
|
@@ -566,7 +1108,10 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
566
1108
|
phase,
|
|
567
1109
|
producedBy,
|
|
568
1110
|
redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
|
|
569
|
-
developCycle
|
|
1111
|
+
developCycle,
|
|
1112
|
+
qaCycle,
|
|
1113
|
+
debugCycle,
|
|
1114
|
+
architectCycle
|
|
570
1115
|
}
|
|
571
1116
|
};
|
|
572
1117
|
}
|
|
@@ -661,17 +1206,24 @@ async function handleJsonRpcRequest(session, method, params) {
|
|
|
661
1206
|
}
|
|
662
1207
|
throw new Error(`Unsupported MCP method: ${method}`);
|
|
663
1208
|
}
|
|
664
|
-
function toolDefinition(name, phase) {
|
|
1209
|
+
function toolDefinition(name, phase, qaGate = null) {
|
|
665
1210
|
return {
|
|
666
1211
|
name,
|
|
667
|
-
description: toolDescription(phase),
|
|
668
|
-
inputSchema: toolInputSchema(phase)
|
|
1212
|
+
description: toolDescription(phase, qaGate),
|
|
1213
|
+
inputSchema: toolInputSchema(phase, qaGate)
|
|
669
1214
|
};
|
|
670
1215
|
}
|
|
671
1216
|
function nextPhaseAfter(phase) {
|
|
1217
|
+
if (!isDevelopCompletionPhase(phase)) {
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
672
1220
|
const index = completionPhaseOrder.indexOf(phase);
|
|
673
1221
|
return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
|
|
674
1222
|
}
|
|
1223
|
+
function nextDebugPhaseAfter(phase) {
|
|
1224
|
+
const index = debugCompletionPhaseOrder.indexOf(phase);
|
|
1225
|
+
return index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
|
|
1226
|
+
}
|
|
675
1227
|
function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
676
1228
|
if (completedPhase === "qa") {
|
|
677
1229
|
if (status === "blocked") {
|
|
@@ -685,6 +1237,10 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
|
685
1237
|
if (status !== "pass") {
|
|
686
1238
|
return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
|
|
687
1239
|
}
|
|
1240
|
+
if (isDebugCompletionPhase(completedPhase)) {
|
|
1241
|
+
const debugNext = isDebugCompletionPhase(nextPhase) ? nextPhase : null;
|
|
1242
|
+
return completionToolNextInstructionForDebugGate(completedPhase, status, debugNext);
|
|
1243
|
+
}
|
|
688
1244
|
if (nextPhase === "self_review") {
|
|
689
1245
|
return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
|
|
690
1246
|
}
|
|
@@ -693,7 +1249,67 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
|
693
1249
|
}
|
|
694
1250
|
return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
|
|
695
1251
|
}
|
|
696
|
-
function
|
|
1252
|
+
function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
|
|
1253
|
+
if (!nextGate) {
|
|
1254
|
+
if (status === "blocked") {
|
|
1255
|
+
return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
|
|
1256
|
+
}
|
|
1257
|
+
if (status === "needs_rework") {
|
|
1258
|
+
return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
|
|
1259
|
+
}
|
|
1260
|
+
return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
|
|
1261
|
+
}
|
|
1262
|
+
const skipped = status === "skipped";
|
|
1263
|
+
const blocked = status === "blocked";
|
|
1264
|
+
const prefix = blocked
|
|
1265
|
+
? `${completedGate} recorded a blocker. Continue through ${nextGate} and mark downstream gates skipped if the blocker prevents evidence collection.`
|
|
1266
|
+
: skipped
|
|
1267
|
+
? `${completedGate} skipped. Continue to ${nextGate}.`
|
|
1268
|
+
: `${completedGate} accepted. Continue to ${nextGate}.`;
|
|
1269
|
+
return `${prefix} ${qaGateInstruction(nextGate)}`;
|
|
1270
|
+
}
|
|
1271
|
+
function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
|
|
1272
|
+
if (status !== "pass") {
|
|
1273
|
+
return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
|
|
1274
|
+
}
|
|
1275
|
+
if (nextGate === "debug_root_cause") {
|
|
1276
|
+
return "Debug repro accepted. Begin root-cause analysis: connect the observed failure to code evidence and call forge.complete_debug_root_cause.";
|
|
1277
|
+
}
|
|
1278
|
+
if (nextGate === "debug_fix") {
|
|
1279
|
+
return "Debug root cause accepted. Apply the smallest safe fix with regression coverage where practical, then call forge.complete_debug_fix.";
|
|
1280
|
+
}
|
|
1281
|
+
if (nextGate === "debug_regression") {
|
|
1282
|
+
return "Debug fix accepted. Prove the original repro is negative, run targeted regression checks, and call forge.complete_debug_regression.";
|
|
1283
|
+
}
|
|
1284
|
+
return "Debug regression accepted. Forge has the structured evidence needed to move this bug fix to QA.";
|
|
1285
|
+
}
|
|
1286
|
+
function qaGateInstruction(gate) {
|
|
1287
|
+
switch (gate) {
|
|
1288
|
+
case "qa_regression":
|
|
1289
|
+
return "Run or explicitly skip applicable regression/build/lint/typecheck/e2e checks, then call forge.complete_qa_regression.";
|
|
1290
|
+
case "qa_api":
|
|
1291
|
+
return "Run or explicitly skip applicable API/backend contract checks, then call forge.complete_qa_api.";
|
|
1292
|
+
case "qa_manual_web":
|
|
1293
|
+
return "Run or explicitly skip applicable browser/manual web checks, then call forge.complete_qa_manual_web.";
|
|
1294
|
+
case "qa_bug_repro":
|
|
1295
|
+
return "Reproduce or explicitly skip relevant defects/candidate bugs, then call forge.complete_qa_bug_repro.";
|
|
1296
|
+
case "qa_findings":
|
|
1297
|
+
return "Normalize observations into findings and candidate bugs, then call forge.complete_qa_findings.";
|
|
1298
|
+
case "qa_verdict":
|
|
1299
|
+
return "Assemble the final QA verdict from all prior gate evidence, then call forge.complete_qa_verdict.";
|
|
1300
|
+
case "qa_plan":
|
|
1301
|
+
return "Prepare the QA checklist, then call forge.complete_qa_plan.";
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
function toolDescription(phase, qaGate = null) {
|
|
1305
|
+
if (qaGate) {
|
|
1306
|
+
return qaGate === "qa_verdict"
|
|
1307
|
+
? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
|
|
1308
|
+
: `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
|
|
1309
|
+
}
|
|
1310
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1311
|
+
return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
|
|
1312
|
+
}
|
|
697
1313
|
if (phase === "implementation") {
|
|
698
1314
|
return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
|
|
699
1315
|
}
|
|
@@ -703,9 +1319,12 @@ function toolDescription(phase) {
|
|
|
703
1319
|
if (phase === "qa") {
|
|
704
1320
|
return "Complete Forge QA by writing qa.plan.json, qa.evidence.json, qa.findings.json, qa.verdict.json, and qa.report.md with structured pass, needs-rework, or blocked evidence.";
|
|
705
1321
|
}
|
|
1322
|
+
if (phase === "architect") {
|
|
1323
|
+
return "Complete Forge Architect planning by writing implementation.plan.json, qa.plan.json, architect.verdict.json, and architect.report.md with a pending-approval plan or explicit revision/blocker reasons.";
|
|
1324
|
+
}
|
|
706
1325
|
return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
|
|
707
1326
|
}
|
|
708
|
-
function toolInputSchema(phase) {
|
|
1327
|
+
function toolInputSchema(phase, qaGate = null) {
|
|
709
1328
|
const baseProperties = {
|
|
710
1329
|
status: {
|
|
711
1330
|
type: "string",
|
|
@@ -724,6 +1343,12 @@ function toolInputSchema(phase) {
|
|
|
724
1343
|
})
|
|
725
1344
|
}
|
|
726
1345
|
};
|
|
1346
|
+
if (qaGate && qaGate !== "qa_verdict") {
|
|
1347
|
+
return qaGateToolInputSchema(qaGate);
|
|
1348
|
+
}
|
|
1349
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1350
|
+
return debugToolInputSchema(phase, baseProperties);
|
|
1351
|
+
}
|
|
727
1352
|
if (phase === "implementation") {
|
|
728
1353
|
return objectToolSchema({
|
|
729
1354
|
...baseProperties,
|
|
@@ -746,7 +1371,7 @@ function toolInputSchema(phase) {
|
|
|
746
1371
|
},
|
|
747
1372
|
tests_added_or_updated: {
|
|
748
1373
|
type: "array",
|
|
749
|
-
description: "Required for pass. Use an object explaining why no tests were needed for
|
|
1374
|
+
description: "Required for pass. Use an object explaining why no tests were needed for documentation/config-only work if empty would be ambiguous.",
|
|
750
1375
|
items: evidenceObjectSchema(["path", "summary"], {
|
|
751
1376
|
path: { type: "string" },
|
|
752
1377
|
summary: { type: "string" }
|
|
@@ -777,6 +1402,11 @@ function toolInputSchema(phase) {
|
|
|
777
1402
|
},
|
|
778
1403
|
next_phase_context: {
|
|
779
1404
|
description: "Required for pass. Context the self-review phase should carry forward."
|
|
1405
|
+
},
|
|
1406
|
+
resolved_findings: {
|
|
1407
|
+
type: "array",
|
|
1408
|
+
description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
|
|
1409
|
+
items: resolvedFindingSchema()
|
|
780
1410
|
}
|
|
781
1411
|
}, ["status", "summary"], "For status pass, include plan, changed_files, tests_added_or_updated, docs_added_or_updated, acceptance_criteria, known_gaps, and next_phase_context. Acceptance criteria entries must use status: satisfied plus evidence.");
|
|
782
1412
|
}
|
|
@@ -912,6 +1542,83 @@ function toolInputSchema(phase) {
|
|
|
912
1542
|
}
|
|
913
1543
|
}, ["status", "summary"], "For pass, include tested_acceptance_criteria plus concrete command/browser/note evidence. For needs_rework, include at least one actionable in_scope finding. For blocked, include blocker_reason.");
|
|
914
1544
|
}
|
|
1545
|
+
if (phase === "architect") {
|
|
1546
|
+
return objectToolSchema({
|
|
1547
|
+
status: {
|
|
1548
|
+
type: "string",
|
|
1549
|
+
enum: ["pass", "needs_revision", "blocked"],
|
|
1550
|
+
description: "Use pass only for a complete pending-approval Architect plan. Use needs_revision for fixable story/design/repo gaps, or blocked for missing required context or decisions."
|
|
1551
|
+
},
|
|
1552
|
+
summary: { type: "string", description: "Concise Architect plan verdict summary." },
|
|
1553
|
+
implementation_plan: {
|
|
1554
|
+
type: "object",
|
|
1555
|
+
description: "Required for pass. Top-level fields inside this object must include schema_version, plan_id, phases, tasks, dependencies, and phase_gates.",
|
|
1556
|
+
additionalProperties: true,
|
|
1557
|
+
properties: {
|
|
1558
|
+
schema_version: { type: "number", enum: [1] },
|
|
1559
|
+
plan_id: { type: "string" },
|
|
1560
|
+
phases: {
|
|
1561
|
+
type: "array",
|
|
1562
|
+
description: "Required top-level implementation_plan.phases array.",
|
|
1563
|
+
items: { type: "object", additionalProperties: true }
|
|
1564
|
+
},
|
|
1565
|
+
tasks: {
|
|
1566
|
+
type: "array",
|
|
1567
|
+
description: "Required task list. Each task must include id and a non-empty tests array.",
|
|
1568
|
+
items: { type: "object", additionalProperties: true }
|
|
1569
|
+
},
|
|
1570
|
+
dependencies: {
|
|
1571
|
+
type: "array",
|
|
1572
|
+
description: "Required dependency edge array. Use [] when no task dependencies exist. Each edge should use before and after task ids; from/to are accepted only as aliases and normalized before writing artifacts.",
|
|
1573
|
+
items: {
|
|
1574
|
+
type: "object",
|
|
1575
|
+
additionalProperties: true,
|
|
1576
|
+
properties: {
|
|
1577
|
+
id: { type: "string" },
|
|
1578
|
+
before: { type: "string", description: "Task id that must complete first." },
|
|
1579
|
+
after: { type: "string", description: "Task id that depends on before." },
|
|
1580
|
+
from: { type: "string", description: "Alias for before." },
|
|
1581
|
+
to: { type: "string", description: "Alias for after." },
|
|
1582
|
+
type: { type: "string" },
|
|
1583
|
+
rationale: { type: "string" }
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
},
|
|
1587
|
+
phase_gates: {
|
|
1588
|
+
type: "array",
|
|
1589
|
+
description: "Required non-empty phase gate array.",
|
|
1590
|
+
items: { type: "object", additionalProperties: true }
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
},
|
|
1594
|
+
qa_plan: {
|
|
1595
|
+
type: "object",
|
|
1596
|
+
description: "Required for pass. Must include schema_version: 1 and non-empty final_qa coverage.",
|
|
1597
|
+
additionalProperties: true,
|
|
1598
|
+
properties: {
|
|
1599
|
+
schema_version: { type: "number", enum: [1] },
|
|
1600
|
+
final_qa: {
|
|
1601
|
+
type: "array",
|
|
1602
|
+
description: "Required non-empty final QA coverage array.",
|
|
1603
|
+
items: { type: "object", additionalProperties: true }
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
},
|
|
1607
|
+
report_markdown: { type: "string", description: "Human-readable Architect report. Use report as an alias only when the client cannot send report_markdown." },
|
|
1608
|
+
report: { type: "string", description: "Alias for report_markdown." },
|
|
1609
|
+
blockers: baseProperties.blockers,
|
|
1610
|
+
revision_questions: {
|
|
1611
|
+
type: "array",
|
|
1612
|
+
description: "Required for needs_revision when blockers are not used.",
|
|
1613
|
+
items: { type: "string" }
|
|
1614
|
+
},
|
|
1615
|
+
revision_reasons: {
|
|
1616
|
+
type: "array",
|
|
1617
|
+
description: "Concrete reasons that prevent pass status.",
|
|
1618
|
+
items: { type: "string" }
|
|
1619
|
+
}
|
|
1620
|
+
}, ["status", "summary"], "For status pass, include implementation_plan and qa_plan at the top level of the MCP tool arguments. implementation_plan.phases must be a top-level array inside implementation_plan, not nested under another object.");
|
|
1621
|
+
}
|
|
915
1622
|
return objectToolSchema({
|
|
916
1623
|
...baseProperties,
|
|
917
1624
|
commands: {
|
|
@@ -936,6 +1643,144 @@ function toolInputSchema(phase) {
|
|
|
936
1643
|
ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
|
|
937
1644
|
}, ["status", "summary"], "For status pass, commands must include at least one passing deterministic command, acceptance criteria entries must use status: satisfied plus evidence, and ready_for_dev_test must be true.");
|
|
938
1645
|
}
|
|
1646
|
+
function debugToolInputSchema(phase, baseProperties) {
|
|
1647
|
+
if (phase === "debug_repro") {
|
|
1648
|
+
return objectToolSchema({
|
|
1649
|
+
...baseProperties,
|
|
1650
|
+
repro_steps: {
|
|
1651
|
+
type: "array",
|
|
1652
|
+
description: "Required for pass. Minimal reproduction or characterization steps.",
|
|
1653
|
+
items: { type: "string" }
|
|
1654
|
+
},
|
|
1655
|
+
observed: { type: "string", description: "Observed failure behavior." },
|
|
1656
|
+
expected: { type: "string", description: "Expected behavior." },
|
|
1657
|
+
evidence: {
|
|
1658
|
+
type: "array",
|
|
1659
|
+
description: "Required for pass. Concrete logs, commands, screenshots, file refs, or characterization notes.",
|
|
1660
|
+
items: { type: "object", additionalProperties: true }
|
|
1661
|
+
}
|
|
1662
|
+
}, ["status", "summary"], "For status pass, include repro_steps, observed, expected, and evidence.");
|
|
1663
|
+
}
|
|
1664
|
+
if (phase === "debug_root_cause") {
|
|
1665
|
+
return objectToolSchema({
|
|
1666
|
+
...baseProperties,
|
|
1667
|
+
root_cause: { type: "string", description: "Likely root cause tied to code or config evidence." },
|
|
1668
|
+
evidence: {
|
|
1669
|
+
type: "array",
|
|
1670
|
+
description: "Required for pass. Concrete code/log/test evidence supporting the root cause.",
|
|
1671
|
+
items: { type: "object", additionalProperties: true }
|
|
1672
|
+
},
|
|
1673
|
+
eliminated_alternatives: {
|
|
1674
|
+
type: "array",
|
|
1675
|
+
description: "Required for pass. Plausible alternatives checked or ruled out.",
|
|
1676
|
+
items: { type: "object", additionalProperties: true }
|
|
1677
|
+
},
|
|
1678
|
+
risk_area: { type: "string", description: "Subsystem or behavior most at risk from the fix." }
|
|
1679
|
+
}, ["status", "summary"], "For status pass, include root_cause, evidence, eliminated_alternatives, and risk_area.");
|
|
1680
|
+
}
|
|
1681
|
+
if (phase === "debug_fix") {
|
|
1682
|
+
return objectToolSchema({
|
|
1683
|
+
...baseProperties,
|
|
1684
|
+
changed_files: {
|
|
1685
|
+
type: "array",
|
|
1686
|
+
description: "Required for pass. Files changed by the bug fix.",
|
|
1687
|
+
items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
|
|
1688
|
+
},
|
|
1689
|
+
tests_added_or_updated: {
|
|
1690
|
+
type: "array",
|
|
1691
|
+
description: "Required for pass. Regression tests or checks added/updated, or justify omission in remaining_risks.",
|
|
1692
|
+
items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
|
|
1693
|
+
},
|
|
1694
|
+
remaining_risks: {
|
|
1695
|
+
type: "array",
|
|
1696
|
+
description: "Required for pass, even when empty. Include why no regression coverage changed when tests_added_or_updated is empty.",
|
|
1697
|
+
items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
|
|
1698
|
+
},
|
|
1699
|
+
resolved_findings: {
|
|
1700
|
+
type: "array",
|
|
1701
|
+
description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
|
|
1702
|
+
items: resolvedFindingSchema()
|
|
1703
|
+
}
|
|
1704
|
+
}, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
|
|
1705
|
+
}
|
|
1706
|
+
return objectToolSchema({
|
|
1707
|
+
...baseProperties,
|
|
1708
|
+
commands: {
|
|
1709
|
+
type: "array",
|
|
1710
|
+
description: "Required for pass. Targeted regression/repro-negative commands with exit_code: 0 and status: pass.",
|
|
1711
|
+
items: commandEvidenceSchema()
|
|
1712
|
+
},
|
|
1713
|
+
required_regressions: {
|
|
1714
|
+
type: "array",
|
|
1715
|
+
description: "Required for pass. Repro-negative and regression coverage checklist.",
|
|
1716
|
+
items: { type: "string" }
|
|
1717
|
+
},
|
|
1718
|
+
build_status: { type: "string", description: "Required for pass. pass or not_applicable." },
|
|
1719
|
+
test_status: { type: "string", description: "Required for pass. pass or not_applicable." },
|
|
1720
|
+
repro_negative: { type: "boolean", description: "Required for pass and must be true after the original repro no longer fails." },
|
|
1721
|
+
qa_ready: { type: "boolean", description: "Required for pass and must be true." }
|
|
1722
|
+
}, ["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.");
|
|
1723
|
+
}
|
|
1724
|
+
function qaGateToolInputSchema(gate) {
|
|
1725
|
+
const baseProperties = {
|
|
1726
|
+
status: {
|
|
1727
|
+
type: "string",
|
|
1728
|
+
enum: ["pass", "skipped", "blocked"],
|
|
1729
|
+
description: "Use pass when this QA gate produced evidence, skipped when not applicable, or blocked when a prerequisite prevents this gate."
|
|
1730
|
+
},
|
|
1731
|
+
summary: { type: "string", description: "Concise QA gate summary." },
|
|
1732
|
+
reason: { type: "string", description: "Required for skipped or blocked gates." },
|
|
1733
|
+
blocker_reason: {
|
|
1734
|
+
type: "string",
|
|
1735
|
+
enum: [...qaBlockerReasons],
|
|
1736
|
+
description: "Required when status is blocked."
|
|
1737
|
+
}
|
|
1738
|
+
};
|
|
1739
|
+
if (gate === "qa_plan") {
|
|
1740
|
+
return objectToolSchema({
|
|
1741
|
+
...baseProperties,
|
|
1742
|
+
checklist: {
|
|
1743
|
+
type: "array",
|
|
1744
|
+
description: "Required for pass. QA checklist entries planned for this work item.",
|
|
1745
|
+
items: { type: "string" }
|
|
1746
|
+
},
|
|
1747
|
+
acceptance_criteria: {
|
|
1748
|
+
type: "array",
|
|
1749
|
+
description: "Acceptance criteria selected for QA.",
|
|
1750
|
+
items: { type: "string" }
|
|
1751
|
+
}
|
|
1752
|
+
}, ["status", "summary"], "Record the QA plan gate. Pass requires a non-empty checklist.");
|
|
1753
|
+
}
|
|
1754
|
+
if (gate === "qa_findings") {
|
|
1755
|
+
return objectToolSchema({
|
|
1756
|
+
...baseProperties,
|
|
1757
|
+
findings: {
|
|
1758
|
+
type: "array",
|
|
1759
|
+
description: "Normalized in-scope and out-of-scope QA findings.",
|
|
1760
|
+
items: { type: "object", additionalProperties: true }
|
|
1761
|
+
},
|
|
1762
|
+
candidate_bugs: {
|
|
1763
|
+
type: "array",
|
|
1764
|
+
description: "Out-of-scope or follow-up candidates.",
|
|
1765
|
+
items: { type: "object", additionalProperties: true }
|
|
1766
|
+
}
|
|
1767
|
+
}, ["status", "summary"], "Record normalized QA findings. Pass or skipped requires findings and candidate_bugs arrays, even when empty.");
|
|
1768
|
+
}
|
|
1769
|
+
return objectToolSchema({
|
|
1770
|
+
...baseProperties,
|
|
1771
|
+
commands: {
|
|
1772
|
+
type: "array",
|
|
1773
|
+
description: "Command evidence for this gate when applicable.",
|
|
1774
|
+
items: commandEvidenceSchema()
|
|
1775
|
+
},
|
|
1776
|
+
browser_checks: {
|
|
1777
|
+
type: "array",
|
|
1778
|
+
description: "Browser/manual evidence for this gate when applicable.",
|
|
1779
|
+
items: { type: "object", additionalProperties: true }
|
|
1780
|
+
},
|
|
1781
|
+
notes: { type: "string", description: "Evidence notes for this gate." }
|
|
1782
|
+
}, ["status", "summary"], "Record this QA evidence gate. Use skipped with a reason when the gate is not applicable.");
|
|
1783
|
+
}
|
|
939
1784
|
function objectToolSchema(properties, required, description) {
|
|
940
1785
|
return {
|
|
941
1786
|
type: "object",
|
|
@@ -961,6 +1806,29 @@ function commandEvidenceSchema() {
|
|
|
961
1806
|
summary: { type: "string" }
|
|
962
1807
|
});
|
|
963
1808
|
}
|
|
1809
|
+
function resolvedFindingSchema() {
|
|
1810
|
+
return evidenceObjectSchema([
|
|
1811
|
+
"qa_id",
|
|
1812
|
+
"status",
|
|
1813
|
+
"summary",
|
|
1814
|
+
"evidence",
|
|
1815
|
+
"changed_files",
|
|
1816
|
+
"tests_added_or_updated",
|
|
1817
|
+
"regression_evidence"
|
|
1818
|
+
], {
|
|
1819
|
+
qa_id: { type: "string" },
|
|
1820
|
+
status: {
|
|
1821
|
+
type: "string",
|
|
1822
|
+
enum: [...resolvedFindingStatusValues],
|
|
1823
|
+
description: "Use resolved when fixed, disputed when the QA finding is incorrect/out-of-scope, or not_reproduced when retesting cannot reproduce it."
|
|
1824
|
+
},
|
|
1825
|
+
summary: { type: "string" },
|
|
1826
|
+
evidence: { description: "Concrete evidence for the resolution." },
|
|
1827
|
+
changed_files: { type: "array", description: "Required and non-empty when status is resolved." },
|
|
1828
|
+
tests_added_or_updated: { type: "array", description: "Required and non-empty when status is resolved." },
|
|
1829
|
+
regression_evidence: { description: "Concrete regression evidence for this QA finding." }
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
964
1832
|
function acceptanceCriteriaSchema() {
|
|
965
1833
|
return {
|
|
966
1834
|
type: "array",
|
|
@@ -995,6 +1863,13 @@ function normalizeCompletionStatus(value, phase) {
|
|
|
995
1863
|
}
|
|
996
1864
|
return canonical;
|
|
997
1865
|
}
|
|
1866
|
+
if (phase === "architect") {
|
|
1867
|
+
const canonical = normalized === "needsrevision" ? "needs_revision" : normalized;
|
|
1868
|
+
if (canonical !== "pass" && canonical !== "needs_revision" && canonical !== "blocked") {
|
|
1869
|
+
throw new Error("Architect status must be pass, needs_revision, or blocked.");
|
|
1870
|
+
}
|
|
1871
|
+
return canonical;
|
|
1872
|
+
}
|
|
998
1873
|
const canonical = normalized === "blocked" || normalized === "needsrework"
|
|
999
1874
|
? "needs_rework"
|
|
1000
1875
|
: normalized === "failed"
|
|
@@ -1005,6 +1880,13 @@ function normalizeCompletionStatus(value, phase) {
|
|
|
1005
1880
|
}
|
|
1006
1881
|
return canonical;
|
|
1007
1882
|
}
|
|
1883
|
+
function normalizeQaGateStatus(value) {
|
|
1884
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
1885
|
+
if (normalized !== "pass" && normalized !== "skipped" && normalized !== "blocked") {
|
|
1886
|
+
throw new Error("QA gate status must be pass, skipped, or blocked.");
|
|
1887
|
+
}
|
|
1888
|
+
return normalized;
|
|
1889
|
+
}
|
|
1008
1890
|
function validatePayloadShape(phase, payload) {
|
|
1009
1891
|
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
1010
1892
|
if (!status) {
|
|
@@ -1015,11 +1897,19 @@ function validatePayloadShape(phase, payload) {
|
|
|
1015
1897
|
validateQaPayloadShape(payload, status);
|
|
1016
1898
|
return;
|
|
1017
1899
|
}
|
|
1900
|
+
if (phase === "architect") {
|
|
1901
|
+
validateArchitectPayloadShape(payload, status);
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1018
1904
|
const pass = status === "pass";
|
|
1019
1905
|
if (!pass) {
|
|
1020
1906
|
requireNonPassEvidence(payload);
|
|
1021
1907
|
return;
|
|
1022
1908
|
}
|
|
1909
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1910
|
+
validateDebugPayloadShape(phase, payload);
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1023
1913
|
if (phase === "implementation") {
|
|
1024
1914
|
requireArray(payload, "plan");
|
|
1025
1915
|
requireArray(payload, "changed_files");
|
|
@@ -1030,6 +1920,7 @@ function validatePayloadShape(phase, payload) {
|
|
|
1030
1920
|
requireField(payload, "next_phase_context");
|
|
1031
1921
|
requireImplementationEvidence(payload);
|
|
1032
1922
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
1923
|
+
validateResolvedFindings(payload);
|
|
1033
1924
|
return;
|
|
1034
1925
|
}
|
|
1035
1926
|
if (phase === "self_review") {
|
|
@@ -1057,6 +1948,260 @@ function validatePayloadShape(phase, payload) {
|
|
|
1057
1948
|
requirePassingCommands(payload);
|
|
1058
1949
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
1059
1950
|
}
|
|
1951
|
+
function validateDebugPayloadShape(phase, payload) {
|
|
1952
|
+
if (phase === "debug_repro") {
|
|
1953
|
+
requireArray(payload, "repro_steps");
|
|
1954
|
+
requireField(payload, "observed");
|
|
1955
|
+
requireField(payload, "expected");
|
|
1956
|
+
requireArray(payload, "evidence");
|
|
1957
|
+
requireNonEmptyArray(payload, "repro_steps");
|
|
1958
|
+
requireNonEmptyArray(payload, "evidence");
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
if (phase === "debug_root_cause") {
|
|
1962
|
+
requireField(payload, "root_cause");
|
|
1963
|
+
requireArray(payload, "evidence");
|
|
1964
|
+
requireArray(payload, "eliminated_alternatives");
|
|
1965
|
+
requireField(payload, "risk_area");
|
|
1966
|
+
requireNonEmptyArray(payload, "evidence");
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
if (phase === "debug_fix") {
|
|
1970
|
+
requireArray(payload, "changed_files");
|
|
1971
|
+
requireArray(payload, "tests_added_or_updated");
|
|
1972
|
+
requireArray(payload, "remaining_risks");
|
|
1973
|
+
requireNonEmptyArray(payload, "changed_files");
|
|
1974
|
+
if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
|
|
1975
|
+
throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
|
|
1976
|
+
}
|
|
1977
|
+
validateResolvedFindings(payload);
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
requireArray(payload, "commands");
|
|
1981
|
+
requireArray(payload, "required_regressions");
|
|
1982
|
+
requireField(payload, "build_status");
|
|
1983
|
+
requireField(payload, "test_status");
|
|
1984
|
+
requireBoolean(payload, "repro_negative");
|
|
1985
|
+
requireBoolean(payload, "qa_ready");
|
|
1986
|
+
if (payload.repro_negative !== true) {
|
|
1987
|
+
throw new Error("debug_regression pass requires repro_negative: true.");
|
|
1988
|
+
}
|
|
1989
|
+
if (payload.qa_ready !== true) {
|
|
1990
|
+
throw new Error("debug_regression pass requires qa_ready: true.");
|
|
1991
|
+
}
|
|
1992
|
+
requireAllowedStatus(payload, "build_status", ["pass", "not_applicable"]);
|
|
1993
|
+
requireAllowedStatus(payload, "test_status", ["pass", "not_applicable"]);
|
|
1994
|
+
requireNonEmptyArray(payload, "required_regressions");
|
|
1995
|
+
requirePassingCommands(payload);
|
|
1996
|
+
}
|
|
1997
|
+
function validateQaGatePayloadShape(gate, payload, status) {
|
|
1998
|
+
requireField(payload, "summary");
|
|
1999
|
+
if (status === "skipped") {
|
|
2000
|
+
requireField(payload, "reason");
|
|
2001
|
+
}
|
|
2002
|
+
if (status === "blocked") {
|
|
2003
|
+
const reason = normalizedString(optionalString(payload, "blocker_reason"));
|
|
2004
|
+
if (!qaBlockerReasons.has(reason)) {
|
|
2005
|
+
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.");
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
if (gate === "qa_plan" && status === "pass") {
|
|
2009
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
2010
|
+
if (checklist.length === 0) {
|
|
2011
|
+
throw new Error("complete_qa_plan pass requires checklist with at least one entry.");
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
if (gate === "qa_findings" && status !== "blocked") {
|
|
2015
|
+
qaObjectArray(payload, "findings");
|
|
2016
|
+
qaObjectArray(payload, "candidate_bugs");
|
|
2017
|
+
}
|
|
2018
|
+
if ((gate === "qa_regression" || gate === "qa_api") && status === "pass") {
|
|
2019
|
+
const commands = qaArray(payload, "commands");
|
|
2020
|
+
if (commands.length === 0) {
|
|
2021
|
+
throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
|
|
2022
|
+
}
|
|
2023
|
+
validateQaGatePassingCommands(commands);
|
|
2024
|
+
}
|
|
2025
|
+
if (gate === "qa_manual_web" && status === "pass") {
|
|
2026
|
+
const browserChecks = qaArray(payload, "browser_checks");
|
|
2027
|
+
const notes = optionalString(payload, "notes");
|
|
2028
|
+
if (browserChecks.length === 0 && !notes) {
|
|
2029
|
+
throw new Error(`${qaGateToolNames[gate]} pass requires browser_checks or notes.`);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
function validateQaGatePassingCommands(commands) {
|
|
2034
|
+
for (const [index, command] of commands.entries()) {
|
|
2035
|
+
requireEvidenceRecord(command, `commands[${index}]`);
|
|
2036
|
+
const record = command;
|
|
2037
|
+
requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
|
|
2038
|
+
requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
|
|
2039
|
+
requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
|
|
2040
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
2041
|
+
throw new Error(`commands[${index}].exit_code is required.`);
|
|
2042
|
+
}
|
|
2043
|
+
if (record.exit_code !== 0) {
|
|
2044
|
+
throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
|
|
2045
|
+
}
|
|
2046
|
+
if (normalizedString(record.status) !== "pass") {
|
|
2047
|
+
throw new Error(`commands[${index}].status must be pass when QA gate status is pass.`);
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
function validateArchitectPayloadShape(payload, status) {
|
|
2052
|
+
if (status !== "pass") {
|
|
2053
|
+
requireArchitectNonPassEvidence(payload);
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
2057
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
2058
|
+
requireNumericOne(implementationPlan, "schema_version", "implementation_plan.schema_version");
|
|
2059
|
+
requireNonEmptyStringRecord(implementationPlan, "plan_id", "implementation_plan.");
|
|
2060
|
+
requireArchitectArray(implementationPlan, "phases", payload);
|
|
2061
|
+
requireArchitectArray(implementationPlan, "tasks", payload);
|
|
2062
|
+
requireArchitectArray(implementationPlan, "dependencies", payload, { allowEmpty: true });
|
|
2063
|
+
requireArchitectArray(implementationPlan, "phase_gates", payload);
|
|
2064
|
+
if (arrayLength(implementationPlan, "tasks") === 0) {
|
|
2065
|
+
throw new Error("implementation_plan.tasks: expected non-empty array; correction: include at least one task with id and tests.");
|
|
2066
|
+
}
|
|
2067
|
+
const taskIds = new Set();
|
|
2068
|
+
for (const task of implementationPlan.tasks) {
|
|
2069
|
+
if (!task || typeof task !== "object" || Array.isArray(task)) {
|
|
2070
|
+
throw new Error("implementation_plan.tasks[]: expected object entries; correction: each task must be an object with id and tests.");
|
|
2071
|
+
}
|
|
2072
|
+
const taskRecord = task;
|
|
2073
|
+
requireNonEmptyStringRecord(taskRecord, "id", "implementation_plan.tasks[].");
|
|
2074
|
+
const taskId = optionalString(taskRecord, "id") ?? "";
|
|
2075
|
+
if (taskIds.has(taskId)) {
|
|
2076
|
+
throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
|
|
2077
|
+
}
|
|
2078
|
+
taskIds.add(taskId);
|
|
2079
|
+
requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
|
|
2080
|
+
if (arrayLength(taskRecord, "tests") === 0) {
|
|
2081
|
+
throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
validateAndNormalizeArchitectDependencies(implementationPlan, taskIds);
|
|
2085
|
+
requireNumericOne(qaPlan, "schema_version", "qa_plan.schema_version");
|
|
2086
|
+
requireArchitectArray(qaPlan, "final_qa", payload, { path: "qa_plan.final_qa" });
|
|
2087
|
+
if (arrayLength(qaPlan, "final_qa") === 0) {
|
|
2088
|
+
throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
|
|
2092
|
+
const dependencies = implementationPlan.dependencies;
|
|
2093
|
+
const edges = new Map();
|
|
2094
|
+
for (const taskId of taskIds) {
|
|
2095
|
+
edges.set(taskId, []);
|
|
2096
|
+
}
|
|
2097
|
+
const normalizedDependencies = [];
|
|
2098
|
+
for (const [index, dependency] of dependencies.entries()) {
|
|
2099
|
+
if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
|
|
2100
|
+
throw new Error("implementation_plan.dependencies[]: expected object entries; correction: each dependency must be an object with before and after task ids.");
|
|
2101
|
+
}
|
|
2102
|
+
const record = dependency;
|
|
2103
|
+
const before = architectDependencyEndpoint(record, index, "before", "from");
|
|
2104
|
+
const after = architectDependencyEndpoint(record, index, "after", "to");
|
|
2105
|
+
if (!taskIds.has(before) || !taskIds.has(after)) {
|
|
2106
|
+
throw new Error("implementation_plan.dependencies: dependency references an unknown task id.");
|
|
2107
|
+
}
|
|
2108
|
+
const normalized = stripUndefined({
|
|
2109
|
+
...record,
|
|
2110
|
+
before,
|
|
2111
|
+
after,
|
|
2112
|
+
from: undefined,
|
|
2113
|
+
to: undefined
|
|
2114
|
+
});
|
|
2115
|
+
normalizedDependencies.push(normalized);
|
|
2116
|
+
edges.get(before)?.push(after);
|
|
2117
|
+
}
|
|
2118
|
+
implementationPlan.dependencies = normalizedDependencies;
|
|
2119
|
+
if (hasArchitectDependencyCycle(edges)) {
|
|
2120
|
+
throw new Error("implementation_plan.dependencies: expected acyclic graph; correction: remove or revise cyclic task dependencies.");
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
function architectDependencyEndpoint(dependency, index, canonicalKey, aliasKey) {
|
|
2124
|
+
const canonical = optionalString(dependency, canonicalKey);
|
|
2125
|
+
const alias = optionalString(dependency, aliasKey);
|
|
2126
|
+
if (canonical && alias && canonical !== alias) {
|
|
2127
|
+
throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: conflicts with ${aliasKey}; correction: use one task id for ${canonicalKey}.`);
|
|
2128
|
+
}
|
|
2129
|
+
const value = canonical ?? alias;
|
|
2130
|
+
if (!value) {
|
|
2131
|
+
throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: expected string; correction: use before and after task ids for each dependency.`);
|
|
2132
|
+
}
|
|
2133
|
+
return value;
|
|
2134
|
+
}
|
|
2135
|
+
function hasArchitectDependencyCycle(edges) {
|
|
2136
|
+
const visiting = new Set();
|
|
2137
|
+
const visited = new Set();
|
|
2138
|
+
const visit = (node) => {
|
|
2139
|
+
if (visited.has(node)) {
|
|
2140
|
+
return false;
|
|
2141
|
+
}
|
|
2142
|
+
if (visiting.has(node)) {
|
|
2143
|
+
return true;
|
|
2144
|
+
}
|
|
2145
|
+
visiting.add(node);
|
|
2146
|
+
for (const next of edges.get(node) ?? []) {
|
|
2147
|
+
if (visit(next)) {
|
|
2148
|
+
return true;
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
visiting.delete(node);
|
|
2152
|
+
visited.add(node);
|
|
2153
|
+
return false;
|
|
2154
|
+
};
|
|
2155
|
+
for (const node of edges.keys()) {
|
|
2156
|
+
if (visit(node)) {
|
|
2157
|
+
return true;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
return false;
|
|
2161
|
+
}
|
|
2162
|
+
function requireArchitectNonPassEvidence(payload) {
|
|
2163
|
+
const evidenceKeys = ["blockers", "revision_questions", "revision_reasons", "known_gaps", "findings", "remaining_risks", "failures"];
|
|
2164
|
+
const hasEvidence = evidenceKeys.some((key) => {
|
|
2165
|
+
const value = payload[key];
|
|
2166
|
+
if (typeof value === "string") {
|
|
2167
|
+
return value.trim().length > 0;
|
|
2168
|
+
}
|
|
2169
|
+
if (Array.isArray(value)) {
|
|
2170
|
+
return value.length > 0;
|
|
2171
|
+
}
|
|
2172
|
+
return Boolean(value && typeof value === "object");
|
|
2173
|
+
});
|
|
2174
|
+
if (!hasEvidence) {
|
|
2175
|
+
throw new Error("status: non-pass Architect completion requires blockers, revision_questions, revision_reasons, findings, failures, or known_gaps; correction: explain why the plan cannot pass.");
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
function requireArchitectArray(source, key, rootPayload, options = {}) {
|
|
2179
|
+
const pathLabel = options.path ?? `implementation_plan.${key}`;
|
|
2180
|
+
if (!Array.isArray(source[key])) {
|
|
2181
|
+
const nestedHint = architectNestedArrayHint(rootPayload, key);
|
|
2182
|
+
throw new Error(`${pathLabel}: expected array${key === "phases" ? " as a top-level field inside implementation_plan" : ""}; correction: ${nestedHint ?? `set ${pathLabel} to an array${options.allowEmpty ? " (use [] when none exist)" : " with at least one entry"}.`}`);
|
|
2183
|
+
}
|
|
2184
|
+
if (!options.allowEmpty && arrayLength(source, key) === 0) {
|
|
2185
|
+
throw new Error(`${pathLabel}: expected non-empty array; correction: include at least one entry.`);
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
function architectNestedArrayHint(rootPayload, key) {
|
|
2189
|
+
if (Array.isArray(rootPayload[key])) {
|
|
2190
|
+
return `move top-level ${key} into implementation_plan.${key}.`;
|
|
2191
|
+
}
|
|
2192
|
+
const implementationPlan = rootPayload.implementation_plan;
|
|
2193
|
+
if (!implementationPlan || typeof implementationPlan !== "object" || Array.isArray(implementationPlan)) {
|
|
2194
|
+
return null;
|
|
2195
|
+
}
|
|
2196
|
+
const plan = implementationPlan;
|
|
2197
|
+
for (const container of ["plan", "dag", "implementation_dag", "implementationPlan", "implementation_plan"]) {
|
|
2198
|
+
const nested = plan[container];
|
|
2199
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested) && Array.isArray(nested[key])) {
|
|
2200
|
+
return `move implementation_plan.${container}.${key} to implementation_plan.${key}.`;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
return null;
|
|
2204
|
+
}
|
|
1060
2205
|
function validateQaPayloadShape(payload, status) {
|
|
1061
2206
|
const evidenceSource = qaEvidenceSource(payload);
|
|
1062
2207
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
@@ -1202,6 +2347,30 @@ function normalizeArtifactPayload(payload, defaults) {
|
|
|
1202
2347
|
normalized.produced_by = defaults.producedBy;
|
|
1203
2348
|
return normalized;
|
|
1204
2349
|
}
|
|
2350
|
+
function normalizeQaGateArtifactPayload(payload, defaults) {
|
|
2351
|
+
const normalized = stripUndefined({ ...payload });
|
|
2352
|
+
delete normalized.report_markdown;
|
|
2353
|
+
delete normalized.content_hash;
|
|
2354
|
+
delete normalized.contentHash;
|
|
2355
|
+
normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
|
|
2356
|
+
normalized.phase = "qa";
|
|
2357
|
+
normalized.gate = defaults.gate;
|
|
2358
|
+
normalized.work_item_id = defaults.workItemId;
|
|
2359
|
+
const ownerFields = artifactOwnerFields(defaults);
|
|
2360
|
+
Object.assign(normalized, ownerFields);
|
|
2361
|
+
normalized.status = defaults.status;
|
|
2362
|
+
normalized.produced_by = defaults.producedBy;
|
|
2363
|
+
if (defaults.gate === "qa_plan") {
|
|
2364
|
+
const checklist = qaStringArray(normalized, "checklist");
|
|
2365
|
+
normalized.checklist = checklist.length > 0 ? checklist : defaultQaChecklist(defaults.status);
|
|
2366
|
+
normalized.acceptance_criteria = qaStringArray(normalized, "acceptance_criteria");
|
|
2367
|
+
}
|
|
2368
|
+
if (defaults.gate === "qa_findings") {
|
|
2369
|
+
normalized.findings = qaObjectArray(normalized, "findings");
|
|
2370
|
+
normalized.candidate_bugs = qaObjectArray(normalized, "candidate_bugs");
|
|
2371
|
+
}
|
|
2372
|
+
return normalized;
|
|
2373
|
+
}
|
|
1205
2374
|
function reportMarkdown(payload, phase, status, producedBy, contentHash) {
|
|
1206
2375
|
const explicit = optionalString(payload, "report_markdown");
|
|
1207
2376
|
if (explicit) {
|
|
@@ -1221,6 +2390,10 @@ ${summary}
|
|
|
1221
2390
|
}
|
|
1222
2391
|
function parseStrictCompletionPhase(value) {
|
|
1223
2392
|
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
2393
|
+
const debugPhase = normalizeDebugCompletionPhase(normalized);
|
|
2394
|
+
if (debugPhase) {
|
|
2395
|
+
return debugPhase;
|
|
2396
|
+
}
|
|
1224
2397
|
if (normalized === "implementation" || normalized === "implement" || normalized === "develop") {
|
|
1225
2398
|
return "implementation";
|
|
1226
2399
|
}
|
|
@@ -1233,8 +2406,48 @@ function parseStrictCompletionPhase(value) {
|
|
|
1233
2406
|
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
1234
2407
|
return "qa";
|
|
1235
2408
|
}
|
|
2409
|
+
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
2410
|
+
return "architect";
|
|
2411
|
+
}
|
|
1236
2412
|
return null;
|
|
1237
2413
|
}
|
|
2414
|
+
function normalizeDebugCompletionPhase(value) {
|
|
2415
|
+
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
2416
|
+
switch (normalized) {
|
|
2417
|
+
case "debug_repro":
|
|
2418
|
+
case "debugrepro":
|
|
2419
|
+
case "repro":
|
|
2420
|
+
case "reproduce":
|
|
2421
|
+
return "debug_repro";
|
|
2422
|
+
case "debug_root_cause":
|
|
2423
|
+
case "debugrootcause":
|
|
2424
|
+
case "root_cause":
|
|
2425
|
+
case "rootcause":
|
|
2426
|
+
return "debug_root_cause";
|
|
2427
|
+
case "debug_fix":
|
|
2428
|
+
case "debugfix":
|
|
2429
|
+
case "fix":
|
|
2430
|
+
return "debug_fix";
|
|
2431
|
+
case "debug_regression":
|
|
2432
|
+
case "debugregression":
|
|
2433
|
+
case "regression":
|
|
2434
|
+
return "debug_regression";
|
|
2435
|
+
default:
|
|
2436
|
+
return null;
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
function isDevelopCompletionPhase(phase) {
|
|
2440
|
+
return phase === "implementation" || phase === "self_review" || phase === "validation";
|
|
2441
|
+
}
|
|
2442
|
+
function isDebugCompletionPhase(phase) {
|
|
2443
|
+
return debugCompletionPhaseOrder.includes(phase);
|
|
2444
|
+
}
|
|
2445
|
+
function asDebugCompletionPhase(phase) {
|
|
2446
|
+
if (isDebugCompletionPhase(phase)) {
|
|
2447
|
+
return phase;
|
|
2448
|
+
}
|
|
2449
|
+
throw new Error(`Expected Debug completion phase, got ${phase}.`);
|
|
2450
|
+
}
|
|
1238
2451
|
function parseCompletionRedactionSecrets(raw) {
|
|
1239
2452
|
if (!raw?.trim()) {
|
|
1240
2453
|
return [];
|
|
@@ -1264,8 +2477,38 @@ function phaseFromToolName(toolName) {
|
|
|
1264
2477
|
if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
|
|
1265
2478
|
return "qa";
|
|
1266
2479
|
}
|
|
2480
|
+
if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
|
|
2481
|
+
return "architect";
|
|
2482
|
+
}
|
|
2483
|
+
const debugGate = debugGateFromToolName(toolName);
|
|
2484
|
+
if (debugGate) {
|
|
2485
|
+
return debugGate;
|
|
2486
|
+
}
|
|
2487
|
+
if (qaGateFromToolName(toolName)) {
|
|
2488
|
+
return "qa";
|
|
2489
|
+
}
|
|
1267
2490
|
throw new Error(`Unknown Forge completion tool: ${toolName}`);
|
|
1268
2491
|
}
|
|
2492
|
+
function debugGateFromToolName(toolName) {
|
|
2493
|
+
const normalized = toolName.trim().toLowerCase();
|
|
2494
|
+
for (const gate of debugCompletionPhaseOrder) {
|
|
2495
|
+
const base = debugGateToolNames[gate];
|
|
2496
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
2497
|
+
return gate;
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
return null;
|
|
2501
|
+
}
|
|
2502
|
+
function qaGateFromToolName(toolName) {
|
|
2503
|
+
const normalized = toolName.trim().toLowerCase();
|
|
2504
|
+
for (const gate of qaCompletionGateOrder) {
|
|
2505
|
+
const base = qaGateToolNames[gate];
|
|
2506
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
2507
|
+
return gate;
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
return null;
|
|
2511
|
+
}
|
|
1269
2512
|
function requireField(payload, key) {
|
|
1270
2513
|
if (payload[key] === undefined || payload[key] === null || (typeof payload[key] === "string" && payload[key].trim().length === 0)) {
|
|
1271
2514
|
throw new Error(`${key} is required.`);
|
|
@@ -1333,17 +2576,42 @@ function requireArray(payload, key) {
|
|
|
1333
2576
|
throw new Error(`${key} must be an array.`);
|
|
1334
2577
|
}
|
|
1335
2578
|
}
|
|
2579
|
+
function requireNonEmptyArray(payload, key) {
|
|
2580
|
+
requireArray(payload, key);
|
|
2581
|
+
if (arrayLength(payload, key) === 0) {
|
|
2582
|
+
throw new Error(`${key} must include at least one value.`);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
function arrayLength(payload, key) {
|
|
2586
|
+
const value = payload[key];
|
|
2587
|
+
return Array.isArray(value) ? value.length : 0;
|
|
2588
|
+
}
|
|
2589
|
+
function requireAllowedStatus(payload, key, allowed) {
|
|
2590
|
+
const value = normalizedString(optionalString(payload, key));
|
|
2591
|
+
if (!allowed.includes(value)) {
|
|
2592
|
+
throw new Error(`${key} must be one of ${allowed.join(", ")}.`);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
1336
2595
|
function requireObject(payload, key) {
|
|
1337
2596
|
const value = payload[key];
|
|
1338
2597
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1339
2598
|
throw new Error(`${key} must be an object.`);
|
|
1340
2599
|
}
|
|
1341
2600
|
}
|
|
2601
|
+
function requiredRecord(payload, key) {
|
|
2602
|
+
requireObject(payload, key);
|
|
2603
|
+
return payload[key];
|
|
2604
|
+
}
|
|
1342
2605
|
function requireBoolean(payload, key) {
|
|
1343
2606
|
if (typeof payload[key] !== "boolean") {
|
|
1344
2607
|
throw new Error(`${key} must be a boolean.`);
|
|
1345
2608
|
}
|
|
1346
2609
|
}
|
|
2610
|
+
function requireNumericOne(payload, key, pathLabel) {
|
|
2611
|
+
if (payload[key] !== 1) {
|
|
2612
|
+
throw new Error(`${pathLabel}: expected numeric 1; correction: set ${pathLabel} to 1.`);
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
1347
2615
|
function requireSatisfiedAcceptanceCriteria(payload) {
|
|
1348
2616
|
const criteria = payload.acceptance_criteria;
|
|
1349
2617
|
if (!Array.isArray(criteria)) {
|
|
@@ -1361,6 +2629,37 @@ function requireSatisfiedAcceptanceCriteria(payload) {
|
|
|
1361
2629
|
}
|
|
1362
2630
|
}
|
|
1363
2631
|
}
|
|
2632
|
+
function validateResolvedFindings(payload) {
|
|
2633
|
+
const resolvedFindings = payload.resolved_findings;
|
|
2634
|
+
if (resolvedFindings === undefined || resolvedFindings === null) {
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
if (!Array.isArray(resolvedFindings)) {
|
|
2638
|
+
throw new Error("resolved_findings must be an array.");
|
|
2639
|
+
}
|
|
2640
|
+
for (const [index, finding] of resolvedFindings.entries()) {
|
|
2641
|
+
const prefix = `resolved_findings[${index}].`;
|
|
2642
|
+
if (!finding || typeof finding !== "object" || Array.isArray(finding)) {
|
|
2643
|
+
throw new Error(`resolved_findings[${index}] must be an object.`);
|
|
2644
|
+
}
|
|
2645
|
+
const record = finding;
|
|
2646
|
+
requireNonEmptyStringRecord(record, "qa_id", prefix);
|
|
2647
|
+
requireKnownStringRecord(record, "status", resolvedFindingStatuses, prefix);
|
|
2648
|
+
requireNonEmptyStringRecord(record, "summary", prefix);
|
|
2649
|
+
requireSubstantiveValueRecord(record, "evidence", prefix);
|
|
2650
|
+
requireArrayRecord(record, "changed_files", prefix);
|
|
2651
|
+
requireArrayRecord(record, "tests_added_or_updated", prefix);
|
|
2652
|
+
requireSubstantiveValueRecord(record, "regression_evidence", prefix);
|
|
2653
|
+
if (normalizedString(record.status) === "resolved") {
|
|
2654
|
+
if (record.changed_files.length === 0) {
|
|
2655
|
+
throw new Error(`${prefix}changed_files must include at least one changed file for resolved QA findings.`);
|
|
2656
|
+
}
|
|
2657
|
+
if (record.tests_added_or_updated.length === 0) {
|
|
2658
|
+
throw new Error(`${prefix}tests_added_or_updated must include at least one regression test for resolved QA findings.`);
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
1364
2663
|
function qaEvidenceSource(payload) {
|
|
1365
2664
|
const evidence = payload.evidence;
|
|
1366
2665
|
return evidence && typeof evidence === "object" && !Array.isArray(evidence)
|
|
@@ -1410,6 +2709,11 @@ function requireNonEmptyStringRecord(source, key, prefix = "") {
|
|
|
1410
2709
|
throw new Error(`${prefix}${key} is required.`);
|
|
1411
2710
|
}
|
|
1412
2711
|
}
|
|
2712
|
+
function requireArrayRecord(source, key, prefix = "") {
|
|
2713
|
+
if (!Array.isArray(source[key])) {
|
|
2714
|
+
throw new Error(`${prefix}${key} must be an array.`);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
1413
2717
|
function requireKnownStringRecord(source, key, allowed, prefix = "") {
|
|
1414
2718
|
const value = normalizedString(optionalString(source, key));
|
|
1415
2719
|
if (!allowed.has(value)) {
|
|
@@ -1427,6 +2731,11 @@ function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
|
|
|
1427
2731
|
}
|
|
1428
2732
|
}
|
|
1429
2733
|
}
|
|
2734
|
+
function requireSubstantiveValueRecord(source, key, prefix = "") {
|
|
2735
|
+
if (!hasSubstantiveJsonValue(source[key])) {
|
|
2736
|
+
throw new Error(`${prefix}${key} must include at least one concrete evidence reference or summary.`);
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
1430
2739
|
function requireSubstantiveEvidenceRecord(source, prefix = "") {
|
|
1431
2740
|
const evidence = source.evidence;
|
|
1432
2741
|
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
@@ -1509,7 +2818,7 @@ function parseJsonObject(content, label) {
|
|
|
1509
2818
|
return asRecord(JSON.parse(content), label);
|
|
1510
2819
|
}
|
|
1511
2820
|
catch (error) {
|
|
1512
|
-
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}
|
|
2821
|
+
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`, { cause: error });
|
|
1513
2822
|
}
|
|
1514
2823
|
}
|
|
1515
2824
|
function parseOptionalJsonObject(content, label) {
|