@loucompanion/forge-bridge 0.1.1-dev.caea7216568f → 0.1.1-dev.d71662695396
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 +213 -25
- package/dist/bridge/bridge.types.d.ts +13 -2
- package/dist/cli/cli.service.js +225 -10
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +36 -4
- package/dist/config.js +1 -1
- package/dist/session/internals.d.ts +1 -1
- package/dist/session/phase-completion.d.ts +45 -5
- package/dist/session/phase-completion.js +1800 -33
- package/dist/session/provider-cli/provider-cli.base.d.ts +3 -1
- package/dist/session/provider-cli/provider-cli.service.d.ts +16 -1
- package/dist/session/provider-cli/provider-cli.service.js +1680 -272
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/qa-sidecar-validation.d.ts +3 -0
- package/dist/session/qa-sidecar-validation.js +44 -0
- package/dist/session/session.service.js +47 -5
- package/dist/session/session.types.d.ts +125 -0
- package/dist/session/session.types.js +250 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +1 -0
- package/dist/worktree/artifact-injector.js +117 -1
- package/dist/worktree/worktree.service.d.ts +1 -0
- package/dist/worktree/worktree.service.js +132 -6
- package/dist/worktree/worktree.types.d.ts +8 -1
- package/package.json +8 -3
|
@@ -3,9 +3,76 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { redactSecrets } from "../shared/redaction/redaction.js";
|
|
6
|
+
import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
|
|
6
7
|
const toolServerName = "forge";
|
|
7
8
|
const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
|
|
8
9
|
const completionPhaseOrder = ["implementation", "self_review", "validation"];
|
|
10
|
+
export const debugCompletionPhaseOrder = [
|
|
11
|
+
"debug_repro",
|
|
12
|
+
"debug_root_cause",
|
|
13
|
+
"debug_fix",
|
|
14
|
+
"debug_regression"
|
|
15
|
+
];
|
|
16
|
+
export const qaCompletionGateOrder = [
|
|
17
|
+
"qa_plan",
|
|
18
|
+
"qa_regression",
|
|
19
|
+
"qa_api",
|
|
20
|
+
"qa_manual_web",
|
|
21
|
+
"qa_bug_repro",
|
|
22
|
+
"qa_findings",
|
|
23
|
+
"qa_verdict"
|
|
24
|
+
];
|
|
25
|
+
export const draftCompletionGateOrder = [
|
|
26
|
+
"draft_intent",
|
|
27
|
+
"draft_repo_context",
|
|
28
|
+
"draft_graph_context",
|
|
29
|
+
"draft_product_scope",
|
|
30
|
+
"draft_work_order_plan",
|
|
31
|
+
"draft_critic",
|
|
32
|
+
"draft_proposal"
|
|
33
|
+
];
|
|
34
|
+
const draftGateFileNames = {
|
|
35
|
+
draft_intent: "draft.intent.json",
|
|
36
|
+
draft_repo_context: "draft.repo-context.json",
|
|
37
|
+
draft_graph_context: "draft.graph-context.json",
|
|
38
|
+
draft_product_scope: "draft.product-scope.json",
|
|
39
|
+
draft_work_order_plan: "draft.work-order-plan.json",
|
|
40
|
+
draft_critic: "draft.critic.json",
|
|
41
|
+
draft_proposal: "draft.proposal.json"
|
|
42
|
+
};
|
|
43
|
+
const draftGateToolNames = {
|
|
44
|
+
draft_intent: "complete_draft_intent",
|
|
45
|
+
draft_repo_context: "complete_draft_repo_context",
|
|
46
|
+
draft_graph_context: "complete_draft_graph_context",
|
|
47
|
+
draft_product_scope: "complete_draft_product_scope",
|
|
48
|
+
draft_work_order_plan: "complete_draft_work_order_plan",
|
|
49
|
+
draft_critic: "complete_draft_critic",
|
|
50
|
+
draft_proposal: "complete_draft_proposal"
|
|
51
|
+
};
|
|
52
|
+
const qaGateFileNames = {
|
|
53
|
+
qa_plan: "qa.plan.json",
|
|
54
|
+
qa_regression: "qa.regression.json",
|
|
55
|
+
qa_api: "qa.api.json",
|
|
56
|
+
qa_manual_web: "qa.manual-web.json",
|
|
57
|
+
qa_bug_repro: "qa.bug-repro.json",
|
|
58
|
+
qa_findings: "qa.findings.json",
|
|
59
|
+
qa_verdict: "qa.verdict.json"
|
|
60
|
+
};
|
|
61
|
+
const qaGateToolNames = {
|
|
62
|
+
qa_plan: "complete_qa_plan",
|
|
63
|
+
qa_regression: "complete_qa_regression",
|
|
64
|
+
qa_api: "complete_qa_api",
|
|
65
|
+
qa_manual_web: "complete_qa_manual_web",
|
|
66
|
+
qa_bug_repro: "complete_qa_bug_repro",
|
|
67
|
+
qa_findings: "complete_qa_findings",
|
|
68
|
+
qa_verdict: "complete_qa_verdict"
|
|
69
|
+
};
|
|
70
|
+
const debugGateToolNames = {
|
|
71
|
+
debug_repro: "complete_debug_repro",
|
|
72
|
+
debug_root_cause: "complete_debug_root_cause",
|
|
73
|
+
debug_fix: "complete_debug_fix",
|
|
74
|
+
debug_regression: "complete_debug_regression"
|
|
75
|
+
};
|
|
9
76
|
const qaBlockerReasons = new Set([
|
|
10
77
|
"missing_app_launch_target",
|
|
11
78
|
"inconclusive",
|
|
@@ -14,6 +81,8 @@ const qaBlockerReasons = new Set([
|
|
|
14
81
|
"missing_worktree",
|
|
15
82
|
"runner_failure"
|
|
16
83
|
]);
|
|
84
|
+
const resolvedFindingStatusValues = ["resolved", "disputed", "not_reproduced"];
|
|
85
|
+
const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
|
|
17
86
|
const qaFindingSeverities = new Set(["critical", "major", "minor"]);
|
|
18
87
|
const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
|
|
19
88
|
const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
|
|
@@ -33,6 +102,30 @@ const phaseFiles = {
|
|
|
33
102
|
qa: {
|
|
34
103
|
jsonFileName: "qa.verdict.json",
|
|
35
104
|
reportFileName: "qa.report.md"
|
|
105
|
+
},
|
|
106
|
+
debug_repro: {
|
|
107
|
+
jsonFileName: "debug.repro.json",
|
|
108
|
+
reportFileName: "debug.report.md"
|
|
109
|
+
},
|
|
110
|
+
debug_root_cause: {
|
|
111
|
+
jsonFileName: "debug.root-cause.json",
|
|
112
|
+
reportFileName: "debug.report.md"
|
|
113
|
+
},
|
|
114
|
+
debug_fix: {
|
|
115
|
+
jsonFileName: "debug.fix.json",
|
|
116
|
+
reportFileName: "debug.report.md"
|
|
117
|
+
},
|
|
118
|
+
debug_regression: {
|
|
119
|
+
jsonFileName: "debug.regression.json",
|
|
120
|
+
reportFileName: "debug.report.md"
|
|
121
|
+
},
|
|
122
|
+
architect: {
|
|
123
|
+
jsonFileName: "architect.verdict.json",
|
|
124
|
+
reportFileName: "architect.report.md"
|
|
125
|
+
},
|
|
126
|
+
draft: {
|
|
127
|
+
jsonFileName: "draft.proposal.json",
|
|
128
|
+
reportFileName: "draft.report.md"
|
|
36
129
|
}
|
|
37
130
|
};
|
|
38
131
|
export function completionPhaseFromDispatch(dispatch) {
|
|
@@ -40,6 +133,10 @@ export function completionPhaseFromDispatch(dispatch) {
|
|
|
40
133
|
}
|
|
41
134
|
export function normalizeCompletionPhase(value) {
|
|
42
135
|
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
136
|
+
const debugPhase = normalizeDebugCompletionPhase(normalized);
|
|
137
|
+
if (debugPhase) {
|
|
138
|
+
return debugPhase;
|
|
139
|
+
}
|
|
43
140
|
if (normalized === "self_review" || normalized === "selfreview" || normalized === "review") {
|
|
44
141
|
return "self_review";
|
|
45
142
|
}
|
|
@@ -49,6 +146,12 @@ export function normalizeCompletionPhase(value) {
|
|
|
49
146
|
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
50
147
|
return "qa";
|
|
51
148
|
}
|
|
149
|
+
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
150
|
+
return "architect";
|
|
151
|
+
}
|
|
152
|
+
if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
|
|
153
|
+
return "draft";
|
|
154
|
+
}
|
|
52
155
|
return "implementation";
|
|
53
156
|
}
|
|
54
157
|
export function usesDevelopCycleCompletion(dispatch) {
|
|
@@ -60,38 +163,108 @@ export function usesDevelopCycleCompletion(dispatch) {
|
|
|
60
163
|
&& lane === "develop"
|
|
61
164
|
&& completionPhaseFromDispatch(dispatch) === "implementation";
|
|
62
165
|
}
|
|
166
|
+
export function usesQaCycleCompletion(dispatch) {
|
|
167
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
168
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
169
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
170
|
+
return kind === "qa"
|
|
171
|
+
&& role === "qa"
|
|
172
|
+
&& lane === "qa"
|
|
173
|
+
&& completionPhaseFromDispatch(dispatch) === "qa";
|
|
174
|
+
}
|
|
175
|
+
export function usesDebugCycleCompletion(dispatch) {
|
|
176
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
177
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
178
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
179
|
+
return kind === "debug"
|
|
180
|
+
&& role === "debug"
|
|
181
|
+
&& lane === "debug"
|
|
182
|
+
&& isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
|
|
183
|
+
}
|
|
184
|
+
export function usesArchitectCycleCompletion(dispatch) {
|
|
185
|
+
const kind = dispatch.cycleRun.kind.trim().toLowerCase();
|
|
186
|
+
const role = dispatch.execution.role.trim().toLowerCase();
|
|
187
|
+
const lane = dispatch.execution.lane.trim().toLowerCase();
|
|
188
|
+
return kind === "architect" || role === "architect" || lane === "architect";
|
|
189
|
+
}
|
|
63
190
|
export function completionToolNamesForPhase(phase) {
|
|
191
|
+
if (phase === "draft") {
|
|
192
|
+
return completionToolNamesForDraftGate("draft_proposal");
|
|
193
|
+
}
|
|
194
|
+
if (isDebugCompletionPhase(phase)) {
|
|
195
|
+
return completionToolNamesForDebugGate(phase);
|
|
196
|
+
}
|
|
64
197
|
const base = phase === "implementation"
|
|
65
198
|
? "complete_implementation"
|
|
66
199
|
: phase === "self_review"
|
|
67
200
|
? "complete_self_review"
|
|
68
201
|
: phase === "validation"
|
|
69
202
|
? "complete_validation"
|
|
70
|
-
: "
|
|
203
|
+
: phase === "architect"
|
|
204
|
+
? "complete_architect_plan"
|
|
205
|
+
: "complete_qa";
|
|
206
|
+
return [`forge.${base}`, base];
|
|
207
|
+
}
|
|
208
|
+
export function completionToolNamesForQaGate(gate) {
|
|
209
|
+
const base = qaGateToolNames[gate];
|
|
210
|
+
return [`forge.${base}`, base];
|
|
211
|
+
}
|
|
212
|
+
export function completionToolNamesForDebugGate(gate) {
|
|
213
|
+
const base = debugGateToolNames[gate];
|
|
214
|
+
return [`forge.${base}`, base];
|
|
215
|
+
}
|
|
216
|
+
export function completionToolNamesForDraftGate(gate) {
|
|
217
|
+
const base = draftGateToolNames[gate];
|
|
71
218
|
return [`forge.${base}`, base];
|
|
72
219
|
}
|
|
73
220
|
export function expectedCompletionToolName(phase) {
|
|
74
221
|
return completionToolNamesForPhase(phase)[0];
|
|
75
222
|
}
|
|
223
|
+
export function expectedQaCompletionToolName(gate) {
|
|
224
|
+
return completionToolNamesForQaGate(gate)[0];
|
|
225
|
+
}
|
|
226
|
+
export function expectedDebugCompletionToolName(gate) {
|
|
227
|
+
return completionToolNamesForDebugGate(gate)[0];
|
|
228
|
+
}
|
|
229
|
+
export function expectedDraftCompletionToolName(gate) {
|
|
230
|
+
return completionToolNamesForDraftGate(gate)[0];
|
|
231
|
+
}
|
|
76
232
|
export function artifactRootFor(worktreePath, workItemId) {
|
|
77
233
|
return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
|
|
78
234
|
}
|
|
79
|
-
export function
|
|
80
|
-
return path.join(
|
|
235
|
+
export function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
236
|
+
return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
|
|
237
|
+
}
|
|
238
|
+
export function completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
239
|
+
const artifactRoot = ownerType && ownerId
|
|
240
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
241
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
242
|
+
return path.join(artifactRoot, phaseFiles[phase].jsonFileName);
|
|
81
243
|
}
|
|
82
|
-
export function completionArtifactReportPath(worktreePath, workItemId, phase) {
|
|
83
|
-
|
|
244
|
+
export function completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
245
|
+
const artifactRoot = ownerType && ownerId
|
|
246
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
247
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
248
|
+
return path.join(artifactRoot, phaseFiles[phase].reportFileName);
|
|
84
249
|
}
|
|
85
250
|
export async function prepareCompletionToolRuntime(options) {
|
|
86
251
|
const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
|
|
87
252
|
const worktreePath = path.resolve(options.worktree.worktreePath);
|
|
88
253
|
const workItemId = options.dispatch.workItem.id;
|
|
254
|
+
const ownerType = normalizedOwnerType(options.dispatch.cycleRun.ownerType) ?? "work_item";
|
|
255
|
+
const ownerId = options.dispatch.cycleRun.ownerId ?? workItemId;
|
|
89
256
|
const mcpArgs = buildCompletionToolServerArgs({
|
|
90
257
|
worktreePath,
|
|
91
258
|
workItemId,
|
|
259
|
+
ownerType,
|
|
260
|
+
ownerId,
|
|
92
261
|
phase,
|
|
93
262
|
producedBy: `${options.dispatch.execution.provider}-cli`,
|
|
94
|
-
developCycle: usesDevelopCycleCompletion(options.dispatch)
|
|
263
|
+
developCycle: usesDevelopCycleCompletion(options.dispatch),
|
|
264
|
+
qaCycle: usesQaCycleCompletion(options.dispatch),
|
|
265
|
+
debugCycle: usesDebugCycleCompletion(options.dispatch),
|
|
266
|
+
architectCycle: usesArchitectCycleCompletion(options.dispatch),
|
|
267
|
+
draftCycle: options.dispatch.execution.lane.trim().toLowerCase() === "draft" || options.dispatch.cycleRun.kind.trim().toLowerCase() === "draft"
|
|
95
268
|
});
|
|
96
269
|
const runtime = {
|
|
97
270
|
serverName: toolServerName,
|
|
@@ -117,9 +290,25 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
117
290
|
"--produced-by",
|
|
118
291
|
config.producedBy ?? "forge-completion-tool"
|
|
119
292
|
];
|
|
293
|
+
if (config.ownerType || config.ownerId) {
|
|
294
|
+
args.push("--owner-type", normalizedOwnerType(config.ownerType) ?? "work_item");
|
|
295
|
+
args.push("--owner-id", config.ownerId ?? config.workItemId);
|
|
296
|
+
}
|
|
120
297
|
if (config.developCycle) {
|
|
121
298
|
args.push("--develop-cycle");
|
|
122
299
|
}
|
|
300
|
+
if (config.qaCycle) {
|
|
301
|
+
args.push("--qa-cycle");
|
|
302
|
+
}
|
|
303
|
+
if (config.debugCycle) {
|
|
304
|
+
args.push("--debug-cycle");
|
|
305
|
+
}
|
|
306
|
+
if (config.architectCycle) {
|
|
307
|
+
args.push("--architect-cycle");
|
|
308
|
+
}
|
|
309
|
+
if (config.draftCycle) {
|
|
310
|
+
args.push("--draft-cycle");
|
|
311
|
+
}
|
|
123
312
|
return args;
|
|
124
313
|
}
|
|
125
314
|
export function codexMcpConfigArgs(runtime) {
|
|
@@ -152,6 +341,17 @@ Do not write QA artifacts by hand. When QA is complete, call \`${toolName}(...)\
|
|
|
152
341
|
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\`.
|
|
153
342
|
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.
|
|
154
343
|
Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
|
|
344
|
+
}
|
|
345
|
+
if (phase === "architect") {
|
|
346
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
347
|
+
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.
|
|
348
|
+
That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
|
|
349
|
+
The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
|
|
350
|
+
\`{ "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" }\`.
|
|
351
|
+
\`implementation_plan.tasks\` are Develop child tasks only: each task must require a focused repo/product implementation change. Put parent-story QA, sign-off, child-count/materialization inventory, parent plan artifact checks, and final integrated acceptance proof in \`qa_plan.final_qa\`, not as child Develop tasks.
|
|
352
|
+
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.
|
|
353
|
+
Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
|
|
354
|
+
Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
|
|
155
355
|
}
|
|
156
356
|
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
157
357
|
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.
|
|
@@ -168,31 +368,95 @@ Complete the dev cycle in order inside this same provider session:
|
|
|
168
368
|
Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
|
|
169
369
|
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.`;
|
|
170
370
|
}
|
|
371
|
+
export function completionPromptForQaCycle(currentGate = "qa_plan") {
|
|
372
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
373
|
+
Do not write QA artifacts by hand. The tool server enforces QA gate order and starts this run at \`${currentGate}\`.
|
|
374
|
+
Complete the QA cycle in order inside this same provider session:
|
|
375
|
+
1. Call \`forge.complete_qa_plan(...)\` after the QA test plan/checklist is ready.
|
|
376
|
+
2. Follow the returned regression instruction, then call \`forge.complete_qa_regression(...)\`.
|
|
377
|
+
3. Follow the returned API/backend instruction, then call \`forge.complete_qa_api(...)\`.
|
|
378
|
+
4. Follow the returned manual web instruction, then call \`forge.complete_qa_manual_web(...)\`.
|
|
379
|
+
5. Follow the returned bug reproduction instruction, then call \`forge.complete_qa_bug_repro(...)\`.
|
|
380
|
+
6. Follow the returned findings instruction, then call \`forge.complete_qa_findings(...)\`.
|
|
381
|
+
7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
|
|
382
|
+
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.
|
|
383
|
+
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\`.
|
|
384
|
+
The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
|
|
385
|
+
}
|
|
386
|
+
export function completionPromptForDraftCycle(currentGate = "draft_intent") {
|
|
387
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
388
|
+
Do not finish Draft by prose or raw JSON. The tool server enforces Draft gate order and starts at \`${currentGate}\`.
|
|
389
|
+
Complete the Draft cycle in order inside this same provider session:
|
|
390
|
+
1. Call \`forge.complete_draft_intent(...)\` after interpreting user intent and non-goals.
|
|
391
|
+
2. Follow the returned repo-context instruction, inspect selected project/repo assumptions, then call \`forge.complete_draft_repo_context(...)\`.
|
|
392
|
+
3. Follow the returned graph-context instruction, account for DAG/dependency placement, then call \`forge.complete_draft_graph_context(...)\`.
|
|
393
|
+
4. Follow the returned product-scope instruction, choose product scope for the selected project, then call \`forge.complete_draft_product_scope(...)\`.
|
|
394
|
+
5. Follow the returned work-order-plan instruction, draft Work Order fields and candidate feature boundaries, then call \`forge.complete_draft_work_order_plan(...)\`.
|
|
395
|
+
6. Follow the returned critic instruction, critique literalism, wrong-project scope, and decomposition quality, then call \`forge.complete_draft_critic(...)\`.
|
|
396
|
+
7. Follow the returned proposal instruction, then call \`forge.complete_draft_proposal(...)\` with the final Work Order JSON in \`draft_json\`.
|
|
397
|
+
The final proposal tool writes \`draft.proposal.json\` under \`.forge/work-items/{workItemId}/artifacts/\`. Reporting Draft completion in prose is not enough.`;
|
|
398
|
+
}
|
|
399
|
+
export function completionPromptForDebugCycle(currentGate = "debug_repro") {
|
|
400
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
401
|
+
Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
|
|
402
|
+
Complete the Debug cycle in order inside this same provider session:
|
|
403
|
+
1. Reproduce or tightly characterize the bug, then call \`forge.complete_debug_repro(...)\`.
|
|
404
|
+
2. Follow the returned root-cause instruction, isolate evidence, then call \`forge.complete_debug_root_cause(...)\`.
|
|
405
|
+
3. Follow the returned fix instruction, apply the smallest safe fix with regression coverage when practical, then call \`forge.complete_debug_fix(...)\`.
|
|
406
|
+
4. Follow the returned regression instruction, prove the original repro is negative and the fix is QA-ready, then call \`forge.complete_debug_regression(...)\`.
|
|
407
|
+
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.
|
|
408
|
+
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.`;
|
|
409
|
+
}
|
|
171
410
|
export function completionToolNamesForServer(config) {
|
|
172
|
-
return config.
|
|
173
|
-
?
|
|
174
|
-
:
|
|
411
|
+
return config.draftCycle
|
|
412
|
+
? draftCompletionGateOrder.flatMap((gate) => completionToolNamesForDraftGate(gate))
|
|
413
|
+
: config.debugCycle
|
|
414
|
+
? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
|
|
415
|
+
: config.qaCycle
|
|
416
|
+
? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
|
|
417
|
+
: config.developCycle
|
|
418
|
+
? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
|
|
419
|
+
: config.architectCycle
|
|
420
|
+
? completionToolNamesForPhase("architect")
|
|
421
|
+
: completionToolNamesForPhase(config.phase);
|
|
175
422
|
}
|
|
176
423
|
export class CompletionToolServerSession {
|
|
177
424
|
config;
|
|
178
425
|
currentPhase;
|
|
426
|
+
currentQaGate = "qa_plan";
|
|
427
|
+
currentDraftGate = "draft_intent";
|
|
179
428
|
completed = false;
|
|
180
429
|
constructor(config) {
|
|
181
430
|
this.config = config;
|
|
182
431
|
this.currentPhase = config.phase;
|
|
183
432
|
}
|
|
184
433
|
get instructions() {
|
|
185
|
-
return this.config.
|
|
186
|
-
?
|
|
187
|
-
:
|
|
434
|
+
return this.config.draftCycle
|
|
435
|
+
? completionPromptForDraftCycle(this.currentDraftGate)
|
|
436
|
+
: this.config.debugCycle
|
|
437
|
+
? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
|
|
438
|
+
: this.config.qaCycle
|
|
439
|
+
? completionPromptForQaCycle(this.currentQaGate)
|
|
440
|
+
: this.config.developCycle
|
|
441
|
+
? completionPromptForDevelopCycle(this.currentPhase)
|
|
442
|
+
: completionPromptForPhase(this.currentPhase);
|
|
188
443
|
}
|
|
189
444
|
toolDefinitions() {
|
|
190
|
-
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name)));
|
|
445
|
+
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
|
|
191
446
|
}
|
|
192
447
|
async callTool(toolName, input) {
|
|
193
448
|
const requestedPhase = phaseFromToolName(toolName);
|
|
194
449
|
if (this.completed) {
|
|
195
|
-
throw new Error("Forge
|
|
450
|
+
throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
|
|
451
|
+
}
|
|
452
|
+
if (this.config.draftCycle) {
|
|
453
|
+
return await this.callDraftGateTool(toolName, requestedPhase, input);
|
|
454
|
+
}
|
|
455
|
+
if (this.config.debugCycle) {
|
|
456
|
+
return await this.callDebugGateTool(toolName, requestedPhase, input);
|
|
457
|
+
}
|
|
458
|
+
if (this.config.qaCycle) {
|
|
459
|
+
return await this.callQaGateTool(toolName, requestedPhase, input);
|
|
196
460
|
}
|
|
197
461
|
if (requestedPhase !== this.currentPhase) {
|
|
198
462
|
throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
|
|
@@ -228,10 +492,115 @@ export class CompletionToolServerSession {
|
|
|
228
492
|
}
|
|
229
493
|
};
|
|
230
494
|
}
|
|
495
|
+
async callDraftGateTool(toolName, requestedPhase, input) {
|
|
496
|
+
const requestedGate = draftGateFromToolName(toolName);
|
|
497
|
+
if (requestedPhase !== "draft" || !requestedGate) {
|
|
498
|
+
throw new Error(`Tool ${toolName} is not available in Draft-cycle mode. Current Forge Draft gate is ${this.currentDraftGate}.`);
|
|
499
|
+
}
|
|
500
|
+
if (requestedGate !== this.currentDraftGate) {
|
|
501
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge Draft gate is ${this.currentDraftGate}.`);
|
|
502
|
+
}
|
|
503
|
+
const completedGate = this.currentDraftGate;
|
|
504
|
+
const reference = await writeDraftGateCompletionArtifact(this.config, completedGate, toolName, input);
|
|
505
|
+
const status = reference.status.trim().toLowerCase();
|
|
506
|
+
const nextGate = this.advanceAfterDraftGate(completedGate);
|
|
507
|
+
const nextPhase = nextGate ? "draft" : "sign_off_ready";
|
|
508
|
+
const nextInstruction = completionToolNextInstructionForDraftGate(completedGate, status, nextGate);
|
|
509
|
+
return {
|
|
510
|
+
content: [{ type: "text", text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}` }],
|
|
511
|
+
structuredContent: {
|
|
512
|
+
...reference,
|
|
513
|
+
gate: { phase: "draft", draftGate: completedGate, status, allowed: status !== "blocked", reason: nextGate ? `${completedGate} artifact accepted; continue to ${nextGate}.` : `Draft proposal artifact accepted with status ${status}.` },
|
|
514
|
+
nextPhase,
|
|
515
|
+
nextInstruction
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
async callDebugGateTool(toolName, requestedPhase, input) {
|
|
520
|
+
const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
|
|
521
|
+
if (!isDebugCompletionPhase(requestedPhase)) {
|
|
522
|
+
throw new Error(`Tool ${toolName} is not available in Debug-cycle mode. Current Forge Debug gate is ${currentDebugGate}.`);
|
|
523
|
+
}
|
|
524
|
+
if (requestedPhase !== currentDebugGate) {
|
|
525
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge Debug gate is ${currentDebugGate}.`);
|
|
526
|
+
}
|
|
527
|
+
const completedGate = currentDebugGate;
|
|
528
|
+
const reference = await writeCompletionArtifact({ ...this.config, phase: completedGate }, toolName, input);
|
|
529
|
+
const status = reference.status.trim().toLowerCase();
|
|
530
|
+
const pass = status === "pass";
|
|
531
|
+
const nextGate = pass ? this.advanceAfterDebugGate(completedGate) : completedGate;
|
|
532
|
+
const nextPhase = nextGate ?? "qa_ready";
|
|
533
|
+
const nextInstruction = completionToolNextInstructionForDebugGate(completedGate, status, nextGate);
|
|
534
|
+
const reason = pass
|
|
535
|
+
? `${completedGate} artifact accepted${nextGate ? `; continue to ${nextGate}.` : "; Debug regression is QA-ready."}`
|
|
536
|
+
: `${completedGate} artifact recorded with status ${status}; rework is required before Debug progression.`;
|
|
537
|
+
return {
|
|
538
|
+
content: [
|
|
539
|
+
{
|
|
540
|
+
type: "text",
|
|
541
|
+
text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
|
|
542
|
+
}
|
|
543
|
+
],
|
|
544
|
+
structuredContent: {
|
|
545
|
+
...reference,
|
|
546
|
+
gate: {
|
|
547
|
+
phase: completedGate,
|
|
548
|
+
status,
|
|
549
|
+
allowed: pass,
|
|
550
|
+
reason
|
|
551
|
+
},
|
|
552
|
+
nextPhase,
|
|
553
|
+
nextInstruction
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
async callQaGateTool(toolName, requestedPhase, input) {
|
|
558
|
+
const requestedGate = qaGateFromToolName(toolName);
|
|
559
|
+
if (requestedPhase !== "qa" || !requestedGate) {
|
|
560
|
+
throw new Error(`Tool ${toolName} is not available in QA-cycle mode. Current Forge QA gate is ${this.currentQaGate}.`);
|
|
561
|
+
}
|
|
562
|
+
if (requestedGate !== this.currentQaGate) {
|
|
563
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge QA gate is ${this.currentQaGate}.`);
|
|
564
|
+
}
|
|
565
|
+
const completedGate = this.currentQaGate;
|
|
566
|
+
const reference = await writeCompletionArtifact({ ...this.config, phase: "qa" }, toolName, input);
|
|
567
|
+
const status = reference.status.trim().toLowerCase();
|
|
568
|
+
const nextGate = this.advanceAfterQaGate(completedGate);
|
|
569
|
+
const nextPhase = nextGate ? "qa" : "sign_off_ready";
|
|
570
|
+
const nextInstruction = completionToolNextInstructionForQaGate(completedGate, status, nextGate);
|
|
571
|
+
const allowed = status !== "blocked";
|
|
572
|
+
const reason = nextGate
|
|
573
|
+
? `${completedGate} artifact accepted; continue to ${nextGate}.`
|
|
574
|
+
: `QA verdict artifact accepted with status ${status}.`;
|
|
575
|
+
return {
|
|
576
|
+
content: [
|
|
577
|
+
{
|
|
578
|
+
type: "text",
|
|
579
|
+
text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
|
|
580
|
+
}
|
|
581
|
+
],
|
|
582
|
+
structuredContent: {
|
|
583
|
+
...reference,
|
|
584
|
+
gate: {
|
|
585
|
+
phase: "qa",
|
|
586
|
+
qaGate: completedGate,
|
|
587
|
+
status,
|
|
588
|
+
allowed,
|
|
589
|
+
reason
|
|
590
|
+
},
|
|
591
|
+
nextPhase,
|
|
592
|
+
nextInstruction
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
}
|
|
231
596
|
advanceAfterPass(phase) {
|
|
232
597
|
if (phase === "qa") {
|
|
598
|
+
this.completed = true;
|
|
233
599
|
return "sign_off_ready";
|
|
234
600
|
}
|
|
601
|
+
if (isDebugCompletionPhase(phase)) {
|
|
602
|
+
return nextDebugPhaseAfter(phase) ?? "qa_ready";
|
|
603
|
+
}
|
|
235
604
|
const next = nextPhaseAfter(phase);
|
|
236
605
|
if (!this.config.developCycle) {
|
|
237
606
|
return next ?? "dev_test_ready";
|
|
@@ -243,15 +612,89 @@ export class CompletionToolServerSession {
|
|
|
243
612
|
this.completed = true;
|
|
244
613
|
return "dev_test_ready";
|
|
245
614
|
}
|
|
615
|
+
advanceAfterDraftGate(gate) {
|
|
616
|
+
const index = draftCompletionGateOrder.indexOf(gate);
|
|
617
|
+
const next = index >= 0 ? draftCompletionGateOrder[index + 1] ?? null : null;
|
|
618
|
+
if (next) {
|
|
619
|
+
this.currentDraftGate = next;
|
|
620
|
+
return next;
|
|
621
|
+
}
|
|
622
|
+
this.completed = true;
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
advanceAfterQaGate(gate) {
|
|
626
|
+
const index = qaCompletionGateOrder.indexOf(gate);
|
|
627
|
+
const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
|
|
628
|
+
if (next) {
|
|
629
|
+
this.currentQaGate = next;
|
|
630
|
+
return next;
|
|
631
|
+
}
|
|
632
|
+
this.completed = true;
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
advanceAfterDebugGate(gate) {
|
|
636
|
+
const index = debugCompletionPhaseOrder.indexOf(gate);
|
|
637
|
+
const next = index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
|
|
638
|
+
if (next) {
|
|
639
|
+
this.currentPhase = next;
|
|
640
|
+
return next;
|
|
641
|
+
}
|
|
642
|
+
this.completed = true;
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
async function writeDraftGateCompletionArtifact(config, gate, toolName, input) {
|
|
647
|
+
const payload = asRecord(input, "Draft completion payload");
|
|
648
|
+
const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
|
|
649
|
+
if (gate === "draft_proposal") {
|
|
650
|
+
const draftJson = asRecord(payload["draft_json"], "draft_json");
|
|
651
|
+
for (const key of ["title", "summary", "objective", "planningPacket", "decompositionContext"]) {
|
|
652
|
+
if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
|
|
653
|
+
throw new Error(`draft_json.${key}: expected non-empty string`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const redactedPayload = redactSecrets({ ...payload, status, phase: "draft", gate, tool_name: toolName }, config.redactionSecrets ?? []);
|
|
658
|
+
const root = artifactRootFor(config.worktreePath, config.workItemId);
|
|
659
|
+
await fs.mkdir(root, { recursive: true });
|
|
660
|
+
const artifactPath = path.join(root, draftGateFileNames[gate]);
|
|
661
|
+
const reportPath = gate === "draft_proposal" ? path.join(root, phaseFiles.draft.reportFileName) : path.join(root, `${gate}.md`);
|
|
662
|
+
const verdictJson = jsonText(redactedPayload);
|
|
663
|
+
const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}\n\n${optionalString(redactedPayload, "summary") ?? "Draft gate recorded."}\n`;
|
|
664
|
+
await writeFileAtomic(artifactPath, verdictJson);
|
|
665
|
+
await writeFileAtomic(reportPath, report);
|
|
666
|
+
return {
|
|
667
|
+
phase: "draft",
|
|
668
|
+
workItemId: config.workItemId,
|
|
669
|
+
ownerType: config.ownerType,
|
|
670
|
+
ownerId: config.ownerId,
|
|
671
|
+
repoPath: path.resolve(config.worktreePath),
|
|
672
|
+
path: artifactPath,
|
|
673
|
+
reportPath,
|
|
674
|
+
contentHash: sha256(verdictJson),
|
|
675
|
+
status,
|
|
676
|
+
producedBy: config.producedBy ?? "forge-completion-tool"
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
function normalizeDraftStatus(value) {
|
|
680
|
+
const normalized = value.trim().toLowerCase();
|
|
681
|
+
return normalized === "blocked" || normalized === "needs_revision" || normalized === "needs_rework" ? normalized : "pass";
|
|
246
682
|
}
|
|
247
683
|
export async function writeCompletionArtifact(config, toolName, input) {
|
|
248
684
|
const phase = phaseFromToolName(toolName);
|
|
249
685
|
if (phase !== config.phase) {
|
|
250
686
|
throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
|
|
251
687
|
}
|
|
688
|
+
const qaGate = qaGateFromToolName(toolName);
|
|
689
|
+
if (qaGate && qaGate !== "qa_verdict") {
|
|
690
|
+
return await writeQaGateCompletionArtifact(config, qaGate, input);
|
|
691
|
+
}
|
|
252
692
|
const payload = asRecord(input, "completion payload");
|
|
253
693
|
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
254
|
-
|
|
694
|
+
let validationPayload = { ...payload, status };
|
|
695
|
+
if (config.qaCycle && qaGate === "qa_verdict") {
|
|
696
|
+
validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
|
|
697
|
+
}
|
|
255
698
|
validatePayloadShape(phase, validationPayload);
|
|
256
699
|
validateOptionalWorkItemId(validationPayload, config.workItemId);
|
|
257
700
|
const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
|
|
@@ -262,9 +705,14 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
262
705
|
if (phase === "qa") {
|
|
263
706
|
return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
264
707
|
}
|
|
708
|
+
if (phase === "architect") {
|
|
709
|
+
return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
710
|
+
}
|
|
265
711
|
const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
|
|
266
712
|
phase,
|
|
267
713
|
workItemId: config.workItemId,
|
|
714
|
+
ownerType: config.ownerType,
|
|
715
|
+
ownerId: config.ownerId,
|
|
268
716
|
producedBy,
|
|
269
717
|
status
|
|
270
718
|
});
|
|
@@ -283,6 +731,93 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
283
731
|
return {
|
|
284
732
|
phase,
|
|
285
733
|
workItemId: config.workItemId,
|
|
734
|
+
...artifactReferenceOwnerFields(config),
|
|
735
|
+
repoPath: path.resolve(config.worktreePath),
|
|
736
|
+
path: artifactPath,
|
|
737
|
+
reportPath,
|
|
738
|
+
contentHash,
|
|
739
|
+
status,
|
|
740
|
+
producedBy
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
|
|
744
|
+
const ownerFields = artifactOwnerFields(config);
|
|
745
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
746
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
747
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
748
|
+
const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
|
|
749
|
+
const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
|
|
750
|
+
const verdictPath = path.join(artifactRoot, "architect.verdict.json");
|
|
751
|
+
const reportPath = path.join(artifactRoot, "architect.report.md");
|
|
752
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
753
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
754
|
+
const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
|
|
755
|
+
const artifactWithoutHash = normalizeArtifactPayload(payload, {
|
|
756
|
+
phase: "architect",
|
|
757
|
+
workItemId: config.workItemId,
|
|
758
|
+
ownerType: config.ownerType,
|
|
759
|
+
ownerId: config.ownerId,
|
|
760
|
+
producedBy,
|
|
761
|
+
status
|
|
762
|
+
});
|
|
763
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
764
|
+
const verdict = {
|
|
765
|
+
...artifactWithoutHash,
|
|
766
|
+
content_hash: contentHash
|
|
767
|
+
};
|
|
768
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
769
|
+
await writeFileAtomic(implementationPlanPath, jsonText(implementationPlan));
|
|
770
|
+
await writeFileAtomic(qaPlanPath, jsonText(qaPlan));
|
|
771
|
+
await writeFileAtomic(verdictPath, jsonText(verdict));
|
|
772
|
+
await writeFileAtomic(reportPath, `${report.trim()}\n`);
|
|
773
|
+
return {
|
|
774
|
+
phase: "architect",
|
|
775
|
+
workItemId: config.workItemId,
|
|
776
|
+
...artifactReferenceOwnerFields(config),
|
|
777
|
+
repoPath: path.resolve(config.worktreePath),
|
|
778
|
+
path: verdictPath,
|
|
779
|
+
reportPath,
|
|
780
|
+
contentHash,
|
|
781
|
+
status,
|
|
782
|
+
producedBy
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
async function writeQaGateCompletionArtifact(config, gate, input) {
|
|
786
|
+
const payload = asRecord(input, "QA gate completion payload");
|
|
787
|
+
const status = normalizeQaGateStatus(requiredString(payload, "status"));
|
|
788
|
+
validateQaGatePayloadShape(gate, payload, status);
|
|
789
|
+
validateOptionalWorkItemId(payload, config.workItemId);
|
|
790
|
+
const redactedPayload = redactSecrets({ ...payload, status }, config.redactionSecrets ?? []);
|
|
791
|
+
const producedBy = optionalString(redactedPayload, "produced_by")
|
|
792
|
+
?? optionalString(redactedPayload, "producedBy")
|
|
793
|
+
?? config.producedBy
|
|
794
|
+
?? "forge-completion-tool";
|
|
795
|
+
const ownerFields = artifactOwnerFields(config);
|
|
796
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
797
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
798
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
799
|
+
const fileName = qaGateFileNames[gate];
|
|
800
|
+
const artifactPath = path.join(artifactRoot, fileName);
|
|
801
|
+
const reportPath = path.join(artifactRoot, "qa.report.md");
|
|
802
|
+
const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
|
|
803
|
+
gate,
|
|
804
|
+
workItemId: config.workItemId,
|
|
805
|
+
ownerType: config.ownerType,
|
|
806
|
+
ownerId: config.ownerId,
|
|
807
|
+
producedBy,
|
|
808
|
+
status
|
|
809
|
+
});
|
|
810
|
+
const contentHash = sha256(stableStringify(artifactWithoutHash));
|
|
811
|
+
const artifact = {
|
|
812
|
+
...artifactWithoutHash,
|
|
813
|
+
content_hash: contentHash
|
|
814
|
+
};
|
|
815
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
816
|
+
await writeFileAtomic(artifactPath, jsonText(artifact));
|
|
817
|
+
return {
|
|
818
|
+
phase: "qa",
|
|
819
|
+
workItemId: config.workItemId,
|
|
820
|
+
...artifactReferenceOwnerFields(config),
|
|
286
821
|
repoPath: path.resolve(config.worktreePath),
|
|
287
822
|
path: artifactPath,
|
|
288
823
|
reportPath,
|
|
@@ -292,18 +827,28 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
292
827
|
};
|
|
293
828
|
}
|
|
294
829
|
async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
295
|
-
const
|
|
830
|
+
const ownerFields = artifactOwnerFields(config);
|
|
831
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
832
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
833
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
296
834
|
const planPath = path.join(artifactRoot, "qa.plan.json");
|
|
297
835
|
const evidencePath = path.join(artifactRoot, "qa.evidence.json");
|
|
298
836
|
const findingsPath = path.join(artifactRoot, "qa.findings.json");
|
|
299
837
|
const verdictPath = path.join(artifactRoot, "qa.verdict.json");
|
|
300
838
|
const reportPath = path.join(artifactRoot, "qa.report.md");
|
|
301
|
-
const relativeEvidencePath =
|
|
302
|
-
|
|
303
|
-
|
|
839
|
+
const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
|
|
840
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
|
|
841
|
+
: qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
|
|
842
|
+
const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
|
|
843
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
|
|
844
|
+
: qaArtifactRelativePath(config.workItemId, "qa.findings.json");
|
|
845
|
+
const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
|
|
846
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
|
|
847
|
+
: qaArtifactRelativePath(config.workItemId, "qa.report.md");
|
|
304
848
|
const evidenceSource = qaEvidenceSource(payload);
|
|
305
849
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
306
850
|
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
851
|
+
const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
|
|
307
852
|
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
308
853
|
const findings = qaObjectArray(payload, "findings");
|
|
309
854
|
const candidateBugs = qaObjectArray(payload, "candidate_bugs");
|
|
@@ -315,19 +860,23 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
315
860
|
const planJson = jsonText({
|
|
316
861
|
schema_version: 1,
|
|
317
862
|
phase: "qa",
|
|
863
|
+
...ownerFields,
|
|
318
864
|
checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
|
|
319
865
|
acceptance_criteria: testedAcceptanceCriteria
|
|
320
866
|
});
|
|
321
867
|
const evidenceJson = jsonText({
|
|
322
868
|
schema_version: 1,
|
|
323
869
|
phase: "qa",
|
|
870
|
+
...ownerFields,
|
|
324
871
|
commands,
|
|
325
872
|
browser_checks: browserChecks,
|
|
873
|
+
sidecars,
|
|
326
874
|
notes
|
|
327
875
|
});
|
|
328
876
|
const findingsJson = jsonText({
|
|
329
877
|
schema_version: 1,
|
|
330
878
|
phase: "qa",
|
|
879
|
+
...ownerFields,
|
|
331
880
|
findings,
|
|
332
881
|
candidate_bugs: candidateBugs
|
|
333
882
|
});
|
|
@@ -347,6 +896,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
347
896
|
const verdictWithoutHash = {
|
|
348
897
|
schema_version: numberOrDefault(payload.schema_version, 1),
|
|
349
898
|
phase: "qa",
|
|
899
|
+
...ownerFields,
|
|
350
900
|
status,
|
|
351
901
|
summary: requiredString(payload, "summary"),
|
|
352
902
|
tested_acceptance_criteria: testedAcceptanceCriteria,
|
|
@@ -378,6 +928,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
378
928
|
return {
|
|
379
929
|
phase: "qa",
|
|
380
930
|
workItemId: config.workItemId,
|
|
931
|
+
...artifactReferenceOwnerFields(config),
|
|
381
932
|
repoPath: path.resolve(config.worktreePath),
|
|
382
933
|
path: verdictPath,
|
|
383
934
|
reportPath,
|
|
@@ -386,19 +937,208 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
386
937
|
producedBy
|
|
387
938
|
};
|
|
388
939
|
}
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
const
|
|
940
|
+
async function synthesizeQaCycleVerdictPayload(config, payload, status) {
|
|
941
|
+
const qaPlan = await readQaGateArtifact(config, "qa_plan");
|
|
942
|
+
const qaRegression = await readQaGateArtifact(config, "qa_regression");
|
|
943
|
+
const qaApi = await readQaGateArtifact(config, "qa_api");
|
|
944
|
+
const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
|
|
945
|
+
const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
|
|
946
|
+
const qaFindings = await readQaGateArtifact(config, "qa_findings");
|
|
947
|
+
const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
|
|
948
|
+
const payloadEvidence = qaEvidenceSource(payload);
|
|
949
|
+
const merged = { ...payload };
|
|
950
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
951
|
+
if (checklist.length === 0) {
|
|
952
|
+
const plannedChecklist = qaStringArray(qaPlan, "checklist");
|
|
953
|
+
merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
|
|
954
|
+
}
|
|
955
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
956
|
+
if (testedAcceptanceCriteria.length === 0) {
|
|
957
|
+
const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
|
|
958
|
+
const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
|
|
959
|
+
const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
|
|
960
|
+
if (acceptanceCriteria.length > 0) {
|
|
961
|
+
merged.tested_acceptance_criteria = acceptanceCriteria;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
const payloadCommands = qaArray(payload, "commands", payloadEvidence);
|
|
965
|
+
const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
|
|
966
|
+
const inheritedDiagnosticCommandNotes = status === "pass"
|
|
967
|
+
? qaNonPassingCommandNotes(inheritedCommands)
|
|
968
|
+
: [];
|
|
969
|
+
merged.commands = dedupeUnknownArray([
|
|
970
|
+
...payloadCommands,
|
|
971
|
+
...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
|
|
972
|
+
]);
|
|
973
|
+
merged.browser_checks = dedupeUnknownArray([
|
|
974
|
+
...qaArray(payload, "browser_checks", payloadEvidence),
|
|
975
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
|
|
976
|
+
]);
|
|
977
|
+
merged.sidecars = dedupeUnknownArray([
|
|
978
|
+
...qaArray(payload, "sidecars", payloadEvidence),
|
|
979
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
|
|
980
|
+
]);
|
|
981
|
+
const notes = dedupeStrings([
|
|
982
|
+
optionalString(payload, "notes"),
|
|
983
|
+
optionalString(payloadEvidence, "notes"),
|
|
984
|
+
...evidenceGates.map((gate) => optionalString(gate, "notes")),
|
|
985
|
+
...inheritedDiagnosticCommandNotes
|
|
986
|
+
]);
|
|
987
|
+
if (notes.length > 0) {
|
|
988
|
+
merged.notes = notes.join("\n");
|
|
989
|
+
}
|
|
990
|
+
merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
|
|
991
|
+
merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
|
|
992
|
+
return merged;
|
|
993
|
+
}
|
|
994
|
+
async function readQaGateArtifact(config, gate) {
|
|
995
|
+
const ownerFields = artifactOwnerFields(config);
|
|
996
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
997
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
998
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
999
|
+
const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
|
|
1000
|
+
const content = await readOptionalFile(artifactPath);
|
|
1001
|
+
return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
|
|
1002
|
+
}
|
|
1003
|
+
function dedupeUnknownArray(values) {
|
|
1004
|
+
const seen = new Set();
|
|
1005
|
+
const deduped = [];
|
|
1006
|
+
for (const value of values) {
|
|
1007
|
+
const key = stableStringify(value);
|
|
1008
|
+
if (seen.has(key)) {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
seen.add(key);
|
|
1012
|
+
deduped.push(value);
|
|
1013
|
+
}
|
|
1014
|
+
return deduped;
|
|
1015
|
+
}
|
|
1016
|
+
function isQaPassingCommandEvidence(value) {
|
|
1017
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
const record = value;
|
|
1021
|
+
return record.exit_code === 0 && normalizedString(record.status) === "pass";
|
|
1022
|
+
}
|
|
1023
|
+
function qaNonPassingCommandNotes(values) {
|
|
1024
|
+
return values
|
|
1025
|
+
.filter((value) => !isQaPassingCommandEvidence(value))
|
|
1026
|
+
.map((value, index) => {
|
|
1027
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1028
|
+
return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
|
|
1029
|
+
}
|
|
1030
|
+
const record = value;
|
|
1031
|
+
const command = optionalString(record, "command") ?? "<missing command>";
|
|
1032
|
+
const status = optionalString(record, "status") ?? "<missing status>";
|
|
1033
|
+
const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
|
|
1034
|
+
? String(record.exit_code)
|
|
1035
|
+
: "<missing exit_code>";
|
|
1036
|
+
const summary = optionalString(record, "summary");
|
|
1037
|
+
return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
function mergeQaObjectArraysById(primary, fallback) {
|
|
1041
|
+
const seen = new Set();
|
|
1042
|
+
const merged = [];
|
|
1043
|
+
for (const value of [...primary, ...fallback]) {
|
|
1044
|
+
const id = optionalString(value, "id");
|
|
1045
|
+
const key = id ? `id:${id}` : stableStringify(value);
|
|
1046
|
+
if (seen.has(key)) {
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
seen.add(key);
|
|
1050
|
+
merged.push(value);
|
|
1051
|
+
}
|
|
1052
|
+
return merged;
|
|
1053
|
+
}
|
|
1054
|
+
function dedupeStrings(values) {
|
|
1055
|
+
const seen = new Set();
|
|
1056
|
+
const deduped = [];
|
|
1057
|
+
for (const value of values) {
|
|
1058
|
+
const trimmed = value?.trim();
|
|
1059
|
+
if (!trimmed || seen.has(trimmed)) {
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
seen.add(trimmed);
|
|
1063
|
+
deduped.push(trimmed);
|
|
1064
|
+
}
|
|
1065
|
+
return deduped;
|
|
1066
|
+
}
|
|
1067
|
+
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
1068
|
+
if (sidecars.length === 0) {
|
|
1069
|
+
return [];
|
|
1070
|
+
}
|
|
1071
|
+
const artifactRoot = ownerType && ownerId
|
|
1072
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
1073
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
1074
|
+
const qaSidecarRoot = path.resolve(artifactRoot, "qa");
|
|
1075
|
+
const validated = [];
|
|
1076
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
1077
|
+
if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
|
|
1078
|
+
throw new Error(`sidecars[${index}] must be an object.`);
|
|
1079
|
+
}
|
|
1080
|
+
const record = sidecar;
|
|
1081
|
+
const sidecarPath = optionalString(record, "path");
|
|
1082
|
+
const contentHash = optionalString(record, "content_hash");
|
|
1083
|
+
const bytes = record.bytes;
|
|
1084
|
+
if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
|
|
1085
|
+
throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
|
|
1086
|
+
}
|
|
1087
|
+
if (path.isAbsolute(sidecarPath)) {
|
|
1088
|
+
throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
|
|
1089
|
+
}
|
|
1090
|
+
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
1091
|
+
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
1092
|
+
throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
|
|
1093
|
+
}
|
|
1094
|
+
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
1095
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
1096
|
+
throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
|
|
1097
|
+
}
|
|
1098
|
+
throw error;
|
|
1099
|
+
});
|
|
1100
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
1101
|
+
throw new Error(`sidecars[${index}].path must reference a regular file.`);
|
|
1102
|
+
}
|
|
1103
|
+
await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
|
|
1104
|
+
if (stat.size !== bytes) {
|
|
1105
|
+
throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
|
|
1106
|
+
}
|
|
1107
|
+
const actualHash = await sha256File(fullPath);
|
|
1108
|
+
if (actualHash !== contentHash.trim().toLowerCase()) {
|
|
1109
|
+
throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
|
|
1110
|
+
}
|
|
1111
|
+
validated.push({
|
|
1112
|
+
...record,
|
|
1113
|
+
path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
|
|
1114
|
+
content_hash: actualHash,
|
|
1115
|
+
bytes: stat.size
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
return validated;
|
|
1119
|
+
}
|
|
1120
|
+
export async function readCompletionArtifact(worktreePath, workItemId, phase, ownerType, ownerId) {
|
|
1121
|
+
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId);
|
|
1122
|
+
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId);
|
|
392
1123
|
const verdictJson = await readOptionalFile(artifactPath);
|
|
393
1124
|
if (verdictJson === null) {
|
|
394
1125
|
return null;
|
|
395
1126
|
}
|
|
396
1127
|
const parsed = parseJsonObject(verdictJson, artifactPath);
|
|
397
1128
|
const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
|
|
1129
|
+
const artifactRoot = path.dirname(artifactPath);
|
|
1130
|
+
const implementationPlanJson = phase === "architect"
|
|
1131
|
+
? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
|
|
1132
|
+
: null;
|
|
1133
|
+
const qaPlanJson = phase === "architect"
|
|
1134
|
+
? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
|
|
1135
|
+
: null;
|
|
398
1136
|
return {
|
|
399
1137
|
repoPath: path.resolve(worktreePath),
|
|
400
1138
|
path: artifactPath,
|
|
401
1139
|
verdictJson,
|
|
1140
|
+
implementationPlanJson,
|
|
1141
|
+
qaPlanJson,
|
|
402
1142
|
report,
|
|
403
1143
|
producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
|
|
404
1144
|
status: optionalString(parsed, "status") ?? "unknown",
|
|
@@ -430,8 +1170,14 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
430
1170
|
let worktreePath = null;
|
|
431
1171
|
let workItemId = null;
|
|
432
1172
|
let phase = "implementation";
|
|
1173
|
+
let ownerType;
|
|
1174
|
+
let ownerId;
|
|
433
1175
|
let producedBy;
|
|
434
1176
|
let developCycle = false;
|
|
1177
|
+
let qaCycle = false;
|
|
1178
|
+
let debugCycle = false;
|
|
1179
|
+
let architectCycle = false;
|
|
1180
|
+
let draftCycle = false;
|
|
435
1181
|
for (let index = 1; index < argv.length; index += 1) {
|
|
436
1182
|
const arg = argv[index];
|
|
437
1183
|
if (arg === "--worktree") {
|
|
@@ -440,11 +1186,20 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
440
1186
|
else if (arg === "--work-item-id") {
|
|
441
1187
|
workItemId = argv[++index] ?? null;
|
|
442
1188
|
}
|
|
1189
|
+
else if (arg === "--owner-type") {
|
|
1190
|
+
ownerType = normalizedOwnerType(argv[++index]);
|
|
1191
|
+
if (!ownerType) {
|
|
1192
|
+
return { ok: false, error: "mcp --owner-type must be work_item or story." };
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
else if (arg === "--owner-id") {
|
|
1196
|
+
ownerId = argv[++index] ?? undefined;
|
|
1197
|
+
}
|
|
443
1198
|
else if (arg === "--phase") {
|
|
444
1199
|
const rawPhase = argv[++index];
|
|
445
1200
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
446
1201
|
if (!parsedPhase) {
|
|
447
|
-
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa." };
|
|
1202
|
+
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, architect, draft, debug-repro, debug-root-cause, debug-fix, debug-regression." };
|
|
448
1203
|
}
|
|
449
1204
|
phase = parsedPhase;
|
|
450
1205
|
}
|
|
@@ -454,6 +1209,18 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
454
1209
|
else if (arg === "--develop-cycle") {
|
|
455
1210
|
developCycle = true;
|
|
456
1211
|
}
|
|
1212
|
+
else if (arg === "--qa-cycle") {
|
|
1213
|
+
qaCycle = true;
|
|
1214
|
+
}
|
|
1215
|
+
else if (arg === "--debug-cycle") {
|
|
1216
|
+
debugCycle = true;
|
|
1217
|
+
}
|
|
1218
|
+
else if (arg === "--architect-cycle") {
|
|
1219
|
+
architectCycle = true;
|
|
1220
|
+
}
|
|
1221
|
+
else if (arg === "--draft-cycle") {
|
|
1222
|
+
draftCycle = true;
|
|
1223
|
+
}
|
|
457
1224
|
else {
|
|
458
1225
|
return { ok: false, error: `Unknown mcp argument: ${arg}` };
|
|
459
1226
|
}
|
|
@@ -461,15 +1228,39 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
461
1228
|
if (!worktreePath?.trim() || !workItemId?.trim()) {
|
|
462
1229
|
return { ok: false, error: "mcp requires --worktree and --work-item-id." };
|
|
463
1230
|
}
|
|
1231
|
+
if ([developCycle, qaCycle, debugCycle, architectCycle, draftCycle].filter(Boolean).length > 1) {
|
|
1232
|
+
return { ok: false, error: "mcp accepts only one cycle mode." };
|
|
1233
|
+
}
|
|
1234
|
+
if (developCycle && !isDevelopCompletionPhase(phase)) {
|
|
1235
|
+
return { ok: false, error: "mcp --develop-cycle requires --phase implementation, self-review, or validation." };
|
|
1236
|
+
}
|
|
1237
|
+
if (qaCycle && phase !== "qa") {
|
|
1238
|
+
return { ok: false, error: "mcp --qa-cycle requires --phase qa." };
|
|
1239
|
+
}
|
|
1240
|
+
if (debugCycle && !isDebugCompletionPhase(phase)) {
|
|
1241
|
+
return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
|
|
1242
|
+
}
|
|
1243
|
+
if (architectCycle && phase !== "architect") {
|
|
1244
|
+
return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
|
|
1245
|
+
}
|
|
1246
|
+
if (draftCycle && phase !== "draft") {
|
|
1247
|
+
return { ok: false, error: "mcp --draft-cycle requires --phase draft." };
|
|
1248
|
+
}
|
|
464
1249
|
return {
|
|
465
1250
|
ok: true,
|
|
466
1251
|
config: {
|
|
467
1252
|
worktreePath: path.resolve(worktreePath),
|
|
468
1253
|
workItemId,
|
|
1254
|
+
ownerType,
|
|
1255
|
+
ownerId,
|
|
469
1256
|
phase,
|
|
470
1257
|
producedBy,
|
|
471
1258
|
redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
|
|
472
|
-
developCycle
|
|
1259
|
+
developCycle,
|
|
1260
|
+
qaCycle,
|
|
1261
|
+
debugCycle,
|
|
1262
|
+
architectCycle,
|
|
1263
|
+
draftCycle
|
|
473
1264
|
}
|
|
474
1265
|
};
|
|
475
1266
|
}
|
|
@@ -564,17 +1355,24 @@ async function handleJsonRpcRequest(session, method, params) {
|
|
|
564
1355
|
}
|
|
565
1356
|
throw new Error(`Unsupported MCP method: ${method}`);
|
|
566
1357
|
}
|
|
567
|
-
function toolDefinition(name, phase) {
|
|
1358
|
+
function toolDefinition(name, phase, qaGate = null) {
|
|
568
1359
|
return {
|
|
569
1360
|
name,
|
|
570
|
-
description: toolDescription(phase),
|
|
571
|
-
inputSchema: toolInputSchema(phase)
|
|
1361
|
+
description: toolDescription(phase, qaGate),
|
|
1362
|
+
inputSchema: toolInputSchema(phase, qaGate)
|
|
572
1363
|
};
|
|
573
1364
|
}
|
|
574
1365
|
function nextPhaseAfter(phase) {
|
|
1366
|
+
if (!isDevelopCompletionPhase(phase)) {
|
|
1367
|
+
return null;
|
|
1368
|
+
}
|
|
575
1369
|
const index = completionPhaseOrder.indexOf(phase);
|
|
576
1370
|
return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
|
|
577
1371
|
}
|
|
1372
|
+
function nextDebugPhaseAfter(phase) {
|
|
1373
|
+
const index = debugCompletionPhaseOrder.indexOf(phase);
|
|
1374
|
+
return index >= 0 ? debugCompletionPhaseOrder[index + 1] ?? null : null;
|
|
1375
|
+
}
|
|
578
1376
|
function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
579
1377
|
if (completedPhase === "qa") {
|
|
580
1378
|
if (status === "blocked") {
|
|
@@ -588,6 +1386,10 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
|
588
1386
|
if (status !== "pass") {
|
|
589
1387
|
return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
|
|
590
1388
|
}
|
|
1389
|
+
if (isDebugCompletionPhase(completedPhase)) {
|
|
1390
|
+
const debugNext = isDebugCompletionPhase(nextPhase) ? nextPhase : null;
|
|
1391
|
+
return completionToolNextInstructionForDebugGate(completedPhase, status, debugNext);
|
|
1392
|
+
}
|
|
591
1393
|
if (nextPhase === "self_review") {
|
|
592
1394
|
return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
|
|
593
1395
|
}
|
|
@@ -596,7 +1398,85 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
|
596
1398
|
}
|
|
597
1399
|
return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
|
|
598
1400
|
}
|
|
599
|
-
function
|
|
1401
|
+
function completionToolNextInstructionForDraftGate(completedGate, status, nextGate) {
|
|
1402
|
+
if (status === "blocked") {
|
|
1403
|
+
return `Stay in ${completedGate}; record the blocker and retry the same Draft gate when unblocked.`;
|
|
1404
|
+
}
|
|
1405
|
+
if (!nextGate) {
|
|
1406
|
+
return "Draft proposal accepted. Forge has the structured Work Order draft JSON needed to populate the form.";
|
|
1407
|
+
}
|
|
1408
|
+
const instructions = {
|
|
1409
|
+
draft_intent: "Capture intent/non-goals, then call forge.complete_draft_intent.",
|
|
1410
|
+
draft_repo_context: "Inspect selected repo/project assumptions, then call forge.complete_draft_repo_context.",
|
|
1411
|
+
draft_graph_context: "Account for DAG dependencies and placement, then call forge.complete_draft_graph_context.",
|
|
1412
|
+
draft_product_scope: "Choose product scope for the selected project, then call forge.complete_draft_product_scope.",
|
|
1413
|
+
draft_work_order_plan: "Draft Work Order fields and candidate Feature boundaries, then call forge.complete_draft_work_order_plan.",
|
|
1414
|
+
draft_critic: "Critique literalism/wrong-project scope/decomposition risk, then call forge.complete_draft_critic.",
|
|
1415
|
+
draft_proposal: "Submit final draft_json through forge.complete_draft_proposal."
|
|
1416
|
+
};
|
|
1417
|
+
return `${completedGate} accepted. Continue to ${nextGate}. ${instructions[nextGate]}`;
|
|
1418
|
+
}
|
|
1419
|
+
function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
|
|
1420
|
+
if (!nextGate) {
|
|
1421
|
+
if (status === "blocked") {
|
|
1422
|
+
return "QA blocked. Forge recorded the blocker reason and will surface the run for human attention.";
|
|
1423
|
+
}
|
|
1424
|
+
if (status === "needs_rework") {
|
|
1425
|
+
return "QA findings recorded. Forge has the structured evidence needed to route this work back for implementation rework.";
|
|
1426
|
+
}
|
|
1427
|
+
return "QA accepted. Forge has the structured evidence needed to move this work item to Sign-Off.";
|
|
1428
|
+
}
|
|
1429
|
+
const skipped = status === "skipped";
|
|
1430
|
+
const blocked = status === "blocked";
|
|
1431
|
+
const prefix = blocked
|
|
1432
|
+
? `${completedGate} recorded a blocker. Continue through ${nextGate} and mark downstream gates skipped if the blocker prevents evidence collection.`
|
|
1433
|
+
: skipped
|
|
1434
|
+
? `${completedGate} skipped. Continue to ${nextGate}.`
|
|
1435
|
+
: `${completedGate} accepted. Continue to ${nextGate}.`;
|
|
1436
|
+
return `${prefix} ${qaGateInstruction(nextGate)}`;
|
|
1437
|
+
}
|
|
1438
|
+
function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
|
|
1439
|
+
if (status !== "pass") {
|
|
1440
|
+
return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
|
|
1441
|
+
}
|
|
1442
|
+
if (nextGate === "debug_root_cause") {
|
|
1443
|
+
return "Debug repro accepted. Begin root-cause analysis: connect the observed failure to code evidence and call forge.complete_debug_root_cause.";
|
|
1444
|
+
}
|
|
1445
|
+
if (nextGate === "debug_fix") {
|
|
1446
|
+
return "Debug root cause accepted. Apply the smallest safe fix with regression coverage where practical, then call forge.complete_debug_fix.";
|
|
1447
|
+
}
|
|
1448
|
+
if (nextGate === "debug_regression") {
|
|
1449
|
+
return "Debug fix accepted. Prove the original repro is negative, run targeted regression checks, and call forge.complete_debug_regression.";
|
|
1450
|
+
}
|
|
1451
|
+
return "Debug regression accepted. Forge has the structured evidence needed to move this bug fix to QA.";
|
|
1452
|
+
}
|
|
1453
|
+
function qaGateInstruction(gate) {
|
|
1454
|
+
switch (gate) {
|
|
1455
|
+
case "qa_regression":
|
|
1456
|
+
return "Run or explicitly skip applicable regression/build/lint/typecheck/e2e checks, then call forge.complete_qa_regression.";
|
|
1457
|
+
case "qa_api":
|
|
1458
|
+
return "Run or explicitly skip applicable API/backend contract checks, then call forge.complete_qa_api.";
|
|
1459
|
+
case "qa_manual_web":
|
|
1460
|
+
return "Run or explicitly skip applicable browser/manual web checks, then call forge.complete_qa_manual_web.";
|
|
1461
|
+
case "qa_bug_repro":
|
|
1462
|
+
return "Reproduce or explicitly skip relevant defects/candidate bugs, then call forge.complete_qa_bug_repro.";
|
|
1463
|
+
case "qa_findings":
|
|
1464
|
+
return "Normalize observations into findings and candidate bugs, then call forge.complete_qa_findings.";
|
|
1465
|
+
case "qa_verdict":
|
|
1466
|
+
return "Assemble the final QA verdict from all prior gate evidence, then call forge.complete_qa_verdict.";
|
|
1467
|
+
case "qa_plan":
|
|
1468
|
+
return "Prepare the QA checklist, then call forge.complete_qa_plan.";
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
function toolDescription(phase, qaGate = null) {
|
|
1472
|
+
if (qaGate) {
|
|
1473
|
+
return qaGate === "qa_verdict"
|
|
1474
|
+
? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
|
|
1475
|
+
: `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
|
|
1476
|
+
}
|
|
1477
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1478
|
+
return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
|
|
1479
|
+
}
|
|
600
1480
|
if (phase === "implementation") {
|
|
601
1481
|
return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
|
|
602
1482
|
}
|
|
@@ -606,9 +1486,12 @@ function toolDescription(phase) {
|
|
|
606
1486
|
if (phase === "qa") {
|
|
607
1487
|
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.";
|
|
608
1488
|
}
|
|
1489
|
+
if (phase === "architect") {
|
|
1490
|
+
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.";
|
|
1491
|
+
}
|
|
609
1492
|
return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
|
|
610
1493
|
}
|
|
611
|
-
function toolInputSchema(phase) {
|
|
1494
|
+
function toolInputSchema(phase, qaGate = null) {
|
|
612
1495
|
const baseProperties = {
|
|
613
1496
|
status: {
|
|
614
1497
|
type: "string",
|
|
@@ -627,6 +1510,12 @@ function toolInputSchema(phase) {
|
|
|
627
1510
|
})
|
|
628
1511
|
}
|
|
629
1512
|
};
|
|
1513
|
+
if (qaGate && qaGate !== "qa_verdict") {
|
|
1514
|
+
return qaGateToolInputSchema(qaGate);
|
|
1515
|
+
}
|
|
1516
|
+
if (isDebugCompletionPhase(phase)) {
|
|
1517
|
+
return debugToolInputSchema(phase, baseProperties);
|
|
1518
|
+
}
|
|
630
1519
|
if (phase === "implementation") {
|
|
631
1520
|
return objectToolSchema({
|
|
632
1521
|
...baseProperties,
|
|
@@ -649,7 +1538,7 @@ function toolInputSchema(phase) {
|
|
|
649
1538
|
},
|
|
650
1539
|
tests_added_or_updated: {
|
|
651
1540
|
type: "array",
|
|
652
|
-
description: "Required for pass. Use an object explaining why no tests were needed for
|
|
1541
|
+
description: "Required for pass. Use an object explaining why no tests were needed for documentation/config-only work if empty would be ambiguous.",
|
|
653
1542
|
items: evidenceObjectSchema(["path", "summary"], {
|
|
654
1543
|
path: { type: "string" },
|
|
655
1544
|
summary: { type: "string" }
|
|
@@ -680,6 +1569,11 @@ function toolInputSchema(phase) {
|
|
|
680
1569
|
},
|
|
681
1570
|
next_phase_context: {
|
|
682
1571
|
description: "Required for pass. Context the self-review phase should carry forward."
|
|
1572
|
+
},
|
|
1573
|
+
resolved_findings: {
|
|
1574
|
+
type: "array",
|
|
1575
|
+
description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
|
|
1576
|
+
items: resolvedFindingSchema()
|
|
683
1577
|
}
|
|
684
1578
|
}, ["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.");
|
|
685
1579
|
}
|
|
@@ -753,15 +1647,25 @@ function toolInputSchema(phase) {
|
|
|
753
1647
|
},
|
|
754
1648
|
browser_checks: {
|
|
755
1649
|
type: "array",
|
|
756
|
-
description: "Browser/manual UI checks with URL, action, observed result, screenshot/trace references, console/network notes, and status
|
|
757
|
-
items: evidenceObjectSchema(["summary"], {
|
|
1650
|
+
description: "Browser/manual UI checks with URL or route, action, observed result, screenshot/trace references, console/network notes, and status.",
|
|
1651
|
+
items: evidenceObjectSchema(["summary", "status"], {
|
|
758
1652
|
summary: { type: "string" },
|
|
759
1653
|
url: { type: "string" },
|
|
1654
|
+
route: { type: "string" },
|
|
760
1655
|
status: { type: "string" },
|
|
761
1656
|
screenshot: { type: "string" },
|
|
762
1657
|
trace: { type: "string" }
|
|
763
1658
|
})
|
|
764
1659
|
},
|
|
1660
|
+
sidecars: {
|
|
1661
|
+
type: "array",
|
|
1662
|
+
description: "Optional QA sidecar files already written under .forge/work-items/<id>/artifacts/qa/ with path/content_hash/bytes metadata.",
|
|
1663
|
+
items: evidenceObjectSchema(["path", "content_hash", "bytes"], {
|
|
1664
|
+
path: { type: "string" },
|
|
1665
|
+
content_hash: { type: "string" },
|
|
1666
|
+
bytes: { type: "number" }
|
|
1667
|
+
})
|
|
1668
|
+
},
|
|
765
1669
|
notes: { type: "string", description: "Additional QA evidence notes." },
|
|
766
1670
|
evidence: {
|
|
767
1671
|
type: "object",
|
|
@@ -805,6 +1709,83 @@ function toolInputSchema(phase) {
|
|
|
805
1709
|
}
|
|
806
1710
|
}, ["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.");
|
|
807
1711
|
}
|
|
1712
|
+
if (phase === "architect") {
|
|
1713
|
+
return objectToolSchema({
|
|
1714
|
+
status: {
|
|
1715
|
+
type: "string",
|
|
1716
|
+
enum: ["pass", "needs_revision", "blocked"],
|
|
1717
|
+
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."
|
|
1718
|
+
},
|
|
1719
|
+
summary: { type: "string", description: "Concise Architect plan verdict summary." },
|
|
1720
|
+
implementation_plan: {
|
|
1721
|
+
type: "object",
|
|
1722
|
+
description: "Required for pass. Top-level fields inside this object must include schema_version, plan_id, phases, tasks, dependencies, and phase_gates.",
|
|
1723
|
+
additionalProperties: true,
|
|
1724
|
+
properties: {
|
|
1725
|
+
schema_version: { type: "number", enum: [1] },
|
|
1726
|
+
plan_id: { type: "string" },
|
|
1727
|
+
phases: {
|
|
1728
|
+
type: "array",
|
|
1729
|
+
description: "Required top-level implementation_plan.phases array.",
|
|
1730
|
+
items: { type: "object", additionalProperties: true }
|
|
1731
|
+
},
|
|
1732
|
+
tasks: {
|
|
1733
|
+
type: "array",
|
|
1734
|
+
description: "Required Develop child task list. Each task must include id and a non-empty tests array, and must require a focused repo/product implementation change. Do not put parent-story QA/sign-off, child-count/materialization inventory, parent plan artifact checks, or final acceptance proof here; those belong in qa_plan.final_qa.",
|
|
1735
|
+
items: { type: "object", additionalProperties: true }
|
|
1736
|
+
},
|
|
1737
|
+
dependencies: {
|
|
1738
|
+
type: "array",
|
|
1739
|
+
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.",
|
|
1740
|
+
items: {
|
|
1741
|
+
type: "object",
|
|
1742
|
+
additionalProperties: true,
|
|
1743
|
+
properties: {
|
|
1744
|
+
id: { type: "string" },
|
|
1745
|
+
before: { type: "string", description: "Task id that must complete first." },
|
|
1746
|
+
after: { type: "string", description: "Task id that depends on before." },
|
|
1747
|
+
from: { type: "string", description: "Alias for before." },
|
|
1748
|
+
to: { type: "string", description: "Alias for after." },
|
|
1749
|
+
type: { type: "string" },
|
|
1750
|
+
rationale: { type: "string" }
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
},
|
|
1754
|
+
phase_gates: {
|
|
1755
|
+
type: "array",
|
|
1756
|
+
description: "Required non-empty phase gate array.",
|
|
1757
|
+
items: { type: "object", additionalProperties: true }
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
},
|
|
1761
|
+
qa_plan: {
|
|
1762
|
+
type: "object",
|
|
1763
|
+
description: "Required for pass. Must include schema_version: 1 and non-empty final_qa coverage.",
|
|
1764
|
+
additionalProperties: true,
|
|
1765
|
+
properties: {
|
|
1766
|
+
schema_version: { type: "number", enum: [1] },
|
|
1767
|
+
final_qa: {
|
|
1768
|
+
type: "array",
|
|
1769
|
+
description: "Required non-empty final QA coverage array.",
|
|
1770
|
+
items: { type: "object", additionalProperties: true }
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
},
|
|
1774
|
+
report_markdown: { type: "string", description: "Human-readable Architect report. Use report as an alias only when the client cannot send report_markdown." },
|
|
1775
|
+
report: { type: "string", description: "Alias for report_markdown." },
|
|
1776
|
+
blockers: baseProperties.blockers,
|
|
1777
|
+
revision_questions: {
|
|
1778
|
+
type: "array",
|
|
1779
|
+
description: "Required for needs_revision when blockers are not used.",
|
|
1780
|
+
items: { type: "string" }
|
|
1781
|
+
},
|
|
1782
|
+
revision_reasons: {
|
|
1783
|
+
type: "array",
|
|
1784
|
+
description: "Concrete reasons that prevent pass status.",
|
|
1785
|
+
items: { type: "string" }
|
|
1786
|
+
}
|
|
1787
|
+
}, ["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.");
|
|
1788
|
+
}
|
|
808
1789
|
return objectToolSchema({
|
|
809
1790
|
...baseProperties,
|
|
810
1791
|
commands: {
|
|
@@ -829,6 +1810,144 @@ function toolInputSchema(phase) {
|
|
|
829
1810
|
ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
|
|
830
1811
|
}, ["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.");
|
|
831
1812
|
}
|
|
1813
|
+
function debugToolInputSchema(phase, baseProperties) {
|
|
1814
|
+
if (phase === "debug_repro") {
|
|
1815
|
+
return objectToolSchema({
|
|
1816
|
+
...baseProperties,
|
|
1817
|
+
repro_steps: {
|
|
1818
|
+
type: "array",
|
|
1819
|
+
description: "Required for pass. Minimal reproduction or characterization steps.",
|
|
1820
|
+
items: { type: "string" }
|
|
1821
|
+
},
|
|
1822
|
+
observed: { type: "string", description: "Observed failure behavior." },
|
|
1823
|
+
expected: { type: "string", description: "Expected behavior." },
|
|
1824
|
+
evidence: {
|
|
1825
|
+
type: "array",
|
|
1826
|
+
description: "Required for pass. Concrete logs, commands, screenshots, file refs, or characterization notes.",
|
|
1827
|
+
items: { type: "object", additionalProperties: true }
|
|
1828
|
+
}
|
|
1829
|
+
}, ["status", "summary"], "For status pass, include repro_steps, observed, expected, and evidence.");
|
|
1830
|
+
}
|
|
1831
|
+
if (phase === "debug_root_cause") {
|
|
1832
|
+
return objectToolSchema({
|
|
1833
|
+
...baseProperties,
|
|
1834
|
+
root_cause: { type: "string", description: "Likely root cause tied to code or config evidence." },
|
|
1835
|
+
evidence: {
|
|
1836
|
+
type: "array",
|
|
1837
|
+
description: "Required for pass. Concrete code/log/test evidence supporting the root cause.",
|
|
1838
|
+
items: { type: "object", additionalProperties: true }
|
|
1839
|
+
},
|
|
1840
|
+
eliminated_alternatives: {
|
|
1841
|
+
type: "array",
|
|
1842
|
+
description: "Required for pass. Plausible alternatives checked or ruled out.",
|
|
1843
|
+
items: { type: "object", additionalProperties: true }
|
|
1844
|
+
},
|
|
1845
|
+
risk_area: { type: "string", description: "Subsystem or behavior most at risk from the fix." }
|
|
1846
|
+
}, ["status", "summary"], "For status pass, include root_cause, evidence, eliminated_alternatives, and risk_area.");
|
|
1847
|
+
}
|
|
1848
|
+
if (phase === "debug_fix") {
|
|
1849
|
+
return objectToolSchema({
|
|
1850
|
+
...baseProperties,
|
|
1851
|
+
changed_files: {
|
|
1852
|
+
type: "array",
|
|
1853
|
+
description: "Required for pass. Files changed by the bug fix.",
|
|
1854
|
+
items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
|
|
1855
|
+
},
|
|
1856
|
+
tests_added_or_updated: {
|
|
1857
|
+
type: "array",
|
|
1858
|
+
description: "Required for pass. Regression tests or checks added/updated, or justify omission in remaining_risks.",
|
|
1859
|
+
items: evidenceObjectSchema(["path", "summary"], { path: { type: "string" }, summary: { type: "string" } })
|
|
1860
|
+
},
|
|
1861
|
+
remaining_risks: {
|
|
1862
|
+
type: "array",
|
|
1863
|
+
description: "Required for pass, even when empty. Include why no regression coverage changed when tests_added_or_updated is empty.",
|
|
1864
|
+
items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
|
|
1865
|
+
},
|
|
1866
|
+
resolved_findings: {
|
|
1867
|
+
type: "array",
|
|
1868
|
+
description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
|
|
1869
|
+
items: resolvedFindingSchema()
|
|
1870
|
+
}
|
|
1871
|
+
}, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
|
|
1872
|
+
}
|
|
1873
|
+
return objectToolSchema({
|
|
1874
|
+
...baseProperties,
|
|
1875
|
+
commands: {
|
|
1876
|
+
type: "array",
|
|
1877
|
+
description: "Required for pass. Targeted regression/repro-negative commands with exit_code: 0 and status: pass.",
|
|
1878
|
+
items: commandEvidenceSchema()
|
|
1879
|
+
},
|
|
1880
|
+
required_regressions: {
|
|
1881
|
+
type: "array",
|
|
1882
|
+
description: "Required for pass. Repro-negative and regression coverage checklist.",
|
|
1883
|
+
items: { type: "string" }
|
|
1884
|
+
},
|
|
1885
|
+
build_status: { type: "string", description: "Required for pass. pass or not_applicable." },
|
|
1886
|
+
test_status: { type: "string", description: "Required for pass. pass or not_applicable." },
|
|
1887
|
+
repro_negative: { type: "boolean", description: "Required for pass and must be true after the original repro no longer fails." },
|
|
1888
|
+
qa_ready: { type: "boolean", description: "Required for pass and must be true." }
|
|
1889
|
+
}, ["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.");
|
|
1890
|
+
}
|
|
1891
|
+
function qaGateToolInputSchema(gate) {
|
|
1892
|
+
const baseProperties = {
|
|
1893
|
+
status: {
|
|
1894
|
+
type: "string",
|
|
1895
|
+
enum: ["pass", "skipped", "blocked"],
|
|
1896
|
+
description: "Use pass when this QA gate produced evidence, skipped when not applicable, or blocked when a prerequisite prevents this gate."
|
|
1897
|
+
},
|
|
1898
|
+
summary: { type: "string", description: "Concise QA gate summary." },
|
|
1899
|
+
reason: { type: "string", description: "Required for skipped or blocked gates." },
|
|
1900
|
+
blocker_reason: {
|
|
1901
|
+
type: "string",
|
|
1902
|
+
enum: [...qaBlockerReasons],
|
|
1903
|
+
description: "Required when status is blocked."
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
if (gate === "qa_plan") {
|
|
1907
|
+
return objectToolSchema({
|
|
1908
|
+
...baseProperties,
|
|
1909
|
+
checklist: {
|
|
1910
|
+
type: "array",
|
|
1911
|
+
description: "Required for pass. QA checklist entries planned for this work item.",
|
|
1912
|
+
items: { type: "string" }
|
|
1913
|
+
},
|
|
1914
|
+
acceptance_criteria: {
|
|
1915
|
+
type: "array",
|
|
1916
|
+
description: "Acceptance criteria selected for QA.",
|
|
1917
|
+
items: { type: "string" }
|
|
1918
|
+
}
|
|
1919
|
+
}, ["status", "summary"], "Record the QA plan gate. Pass requires a non-empty checklist.");
|
|
1920
|
+
}
|
|
1921
|
+
if (gate === "qa_findings") {
|
|
1922
|
+
return objectToolSchema({
|
|
1923
|
+
...baseProperties,
|
|
1924
|
+
findings: {
|
|
1925
|
+
type: "array",
|
|
1926
|
+
description: "Normalized in-scope and out-of-scope QA findings.",
|
|
1927
|
+
items: { type: "object", additionalProperties: true }
|
|
1928
|
+
},
|
|
1929
|
+
candidate_bugs: {
|
|
1930
|
+
type: "array",
|
|
1931
|
+
description: "Out-of-scope or follow-up candidates.",
|
|
1932
|
+
items: { type: "object", additionalProperties: true }
|
|
1933
|
+
}
|
|
1934
|
+
}, ["status", "summary"], "Record normalized QA findings. Pass or skipped requires findings and candidate_bugs arrays, even when empty.");
|
|
1935
|
+
}
|
|
1936
|
+
return objectToolSchema({
|
|
1937
|
+
...baseProperties,
|
|
1938
|
+
commands: {
|
|
1939
|
+
type: "array",
|
|
1940
|
+
description: "Command evidence for this gate when applicable.",
|
|
1941
|
+
items: commandEvidenceSchema()
|
|
1942
|
+
},
|
|
1943
|
+
browser_checks: {
|
|
1944
|
+
type: "array",
|
|
1945
|
+
description: "Browser/manual evidence for this gate when applicable.",
|
|
1946
|
+
items: { type: "object", additionalProperties: true }
|
|
1947
|
+
},
|
|
1948
|
+
notes: { type: "string", description: "Evidence notes for this gate." }
|
|
1949
|
+
}, ["status", "summary"], "Record this QA evidence gate. Use skipped with a reason when the gate is not applicable.");
|
|
1950
|
+
}
|
|
832
1951
|
function objectToolSchema(properties, required, description) {
|
|
833
1952
|
return {
|
|
834
1953
|
type: "object",
|
|
@@ -854,6 +1973,29 @@ function commandEvidenceSchema() {
|
|
|
854
1973
|
summary: { type: "string" }
|
|
855
1974
|
});
|
|
856
1975
|
}
|
|
1976
|
+
function resolvedFindingSchema() {
|
|
1977
|
+
return evidenceObjectSchema([
|
|
1978
|
+
"qa_id",
|
|
1979
|
+
"status",
|
|
1980
|
+
"summary",
|
|
1981
|
+
"evidence",
|
|
1982
|
+
"changed_files",
|
|
1983
|
+
"tests_added_or_updated",
|
|
1984
|
+
"regression_evidence"
|
|
1985
|
+
], {
|
|
1986
|
+
qa_id: { type: "string" },
|
|
1987
|
+
status: {
|
|
1988
|
+
type: "string",
|
|
1989
|
+
enum: [...resolvedFindingStatusValues],
|
|
1990
|
+
description: "Use resolved when fixed, disputed when the QA finding is incorrect/out-of-scope, or not_reproduced when retesting cannot reproduce it."
|
|
1991
|
+
},
|
|
1992
|
+
summary: { type: "string" },
|
|
1993
|
+
evidence: { description: "Concrete evidence for the resolution." },
|
|
1994
|
+
changed_files: { type: "array", description: "Required and non-empty when status is resolved." },
|
|
1995
|
+
tests_added_or_updated: { type: "array", description: "Required and non-empty when status is resolved." },
|
|
1996
|
+
regression_evidence: { description: "Concrete regression evidence for this QA finding." }
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
857
1999
|
function acceptanceCriteriaSchema() {
|
|
858
2000
|
return {
|
|
859
2001
|
type: "array",
|
|
@@ -888,6 +2030,13 @@ function normalizeCompletionStatus(value, phase) {
|
|
|
888
2030
|
}
|
|
889
2031
|
return canonical;
|
|
890
2032
|
}
|
|
2033
|
+
if (phase === "architect") {
|
|
2034
|
+
const canonical = normalized === "needsrevision" ? "needs_revision" : normalized;
|
|
2035
|
+
if (canonical !== "pass" && canonical !== "needs_revision" && canonical !== "blocked") {
|
|
2036
|
+
throw new Error("Architect status must be pass, needs_revision, or blocked.");
|
|
2037
|
+
}
|
|
2038
|
+
return canonical;
|
|
2039
|
+
}
|
|
891
2040
|
const canonical = normalized === "blocked" || normalized === "needsrework"
|
|
892
2041
|
? "needs_rework"
|
|
893
2042
|
: normalized === "failed"
|
|
@@ -898,6 +2047,13 @@ function normalizeCompletionStatus(value, phase) {
|
|
|
898
2047
|
}
|
|
899
2048
|
return canonical;
|
|
900
2049
|
}
|
|
2050
|
+
function normalizeQaGateStatus(value) {
|
|
2051
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
2052
|
+
if (normalized !== "pass" && normalized !== "skipped" && normalized !== "blocked") {
|
|
2053
|
+
throw new Error("QA gate status must be pass, skipped, or blocked.");
|
|
2054
|
+
}
|
|
2055
|
+
return normalized;
|
|
2056
|
+
}
|
|
901
2057
|
function validatePayloadShape(phase, payload) {
|
|
902
2058
|
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
903
2059
|
if (!status) {
|
|
@@ -908,11 +2064,19 @@ function validatePayloadShape(phase, payload) {
|
|
|
908
2064
|
validateQaPayloadShape(payload, status);
|
|
909
2065
|
return;
|
|
910
2066
|
}
|
|
2067
|
+
if (phase === "architect") {
|
|
2068
|
+
validateArchitectPayloadShape(payload, status);
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
911
2071
|
const pass = status === "pass";
|
|
912
2072
|
if (!pass) {
|
|
913
2073
|
requireNonPassEvidence(payload);
|
|
914
2074
|
return;
|
|
915
2075
|
}
|
|
2076
|
+
if (isDebugCompletionPhase(phase)) {
|
|
2077
|
+
validateDebugPayloadShape(phase, payload);
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
916
2080
|
if (phase === "implementation") {
|
|
917
2081
|
requireArray(payload, "plan");
|
|
918
2082
|
requireArray(payload, "changed_files");
|
|
@@ -923,6 +2087,7 @@ function validatePayloadShape(phase, payload) {
|
|
|
923
2087
|
requireField(payload, "next_phase_context");
|
|
924
2088
|
requireImplementationEvidence(payload);
|
|
925
2089
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
2090
|
+
validateResolvedFindings(payload);
|
|
926
2091
|
return;
|
|
927
2092
|
}
|
|
928
2093
|
if (phase === "self_review") {
|
|
@@ -950,10 +2115,384 @@ function validatePayloadShape(phase, payload) {
|
|
|
950
2115
|
requirePassingCommands(payload);
|
|
951
2116
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
952
2117
|
}
|
|
2118
|
+
function validateDebugPayloadShape(phase, payload) {
|
|
2119
|
+
if (phase === "debug_repro") {
|
|
2120
|
+
requireArray(payload, "repro_steps");
|
|
2121
|
+
requireField(payload, "observed");
|
|
2122
|
+
requireField(payload, "expected");
|
|
2123
|
+
requireArray(payload, "evidence");
|
|
2124
|
+
requireNonEmptyArray(payload, "repro_steps");
|
|
2125
|
+
requireNonEmptyArray(payload, "evidence");
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
if (phase === "debug_root_cause") {
|
|
2129
|
+
requireField(payload, "root_cause");
|
|
2130
|
+
requireArray(payload, "evidence");
|
|
2131
|
+
requireArray(payload, "eliminated_alternatives");
|
|
2132
|
+
requireField(payload, "risk_area");
|
|
2133
|
+
requireNonEmptyArray(payload, "evidence");
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
if (phase === "debug_fix") {
|
|
2137
|
+
requireArray(payload, "changed_files");
|
|
2138
|
+
requireArray(payload, "tests_added_or_updated");
|
|
2139
|
+
requireArray(payload, "remaining_risks");
|
|
2140
|
+
requireNonEmptyArray(payload, "changed_files");
|
|
2141
|
+
if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
|
|
2142
|
+
throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
|
|
2143
|
+
}
|
|
2144
|
+
validateResolvedFindings(payload);
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
requireArray(payload, "commands");
|
|
2148
|
+
requireArray(payload, "required_regressions");
|
|
2149
|
+
requireField(payload, "build_status");
|
|
2150
|
+
requireField(payload, "test_status");
|
|
2151
|
+
requireBoolean(payload, "repro_negative");
|
|
2152
|
+
requireBoolean(payload, "qa_ready");
|
|
2153
|
+
if (payload.repro_negative !== true) {
|
|
2154
|
+
throw new Error("debug_regression pass requires repro_negative: true.");
|
|
2155
|
+
}
|
|
2156
|
+
if (payload.qa_ready !== true) {
|
|
2157
|
+
throw new Error("debug_regression pass requires qa_ready: true.");
|
|
2158
|
+
}
|
|
2159
|
+
requireAllowedStatus(payload, "build_status", ["pass", "not_applicable"]);
|
|
2160
|
+
requireAllowedStatus(payload, "test_status", ["pass", "not_applicable"]);
|
|
2161
|
+
requireNonEmptyArray(payload, "required_regressions");
|
|
2162
|
+
requirePassingCommands(payload);
|
|
2163
|
+
}
|
|
2164
|
+
function validateQaGatePayloadShape(gate, payload, status) {
|
|
2165
|
+
requireField(payload, "summary");
|
|
2166
|
+
if (status === "skipped") {
|
|
2167
|
+
requireField(payload, "reason");
|
|
2168
|
+
}
|
|
2169
|
+
if (status === "blocked") {
|
|
2170
|
+
const reason = normalizedString(optionalString(payload, "blocker_reason"));
|
|
2171
|
+
if (!qaBlockerReasons.has(reason)) {
|
|
2172
|
+
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.");
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
if (gate === "qa_plan" && status === "pass") {
|
|
2176
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
2177
|
+
if (checklist.length === 0) {
|
|
2178
|
+
throw new Error("complete_qa_plan pass requires checklist with at least one entry.");
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
if (gate === "qa_findings" && status !== "blocked") {
|
|
2182
|
+
qaObjectArray(payload, "findings");
|
|
2183
|
+
qaObjectArray(payload, "candidate_bugs");
|
|
2184
|
+
}
|
|
2185
|
+
if ((gate === "qa_regression" || gate === "qa_api") && status === "pass") {
|
|
2186
|
+
const commands = qaArray(payload, "commands");
|
|
2187
|
+
if (commands.length === 0) {
|
|
2188
|
+
throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
|
|
2189
|
+
}
|
|
2190
|
+
validateQaGatePassingCommands(commands);
|
|
2191
|
+
}
|
|
2192
|
+
if (gate === "qa_manual_web" && status === "pass") {
|
|
2193
|
+
const browserChecks = qaArray(payload, "browser_checks");
|
|
2194
|
+
const notes = optionalString(payload, "notes");
|
|
2195
|
+
if (browserChecks.length === 0 && !notes) {
|
|
2196
|
+
throw new Error(`${qaGateToolNames[gate]} pass requires browser_checks or notes.`);
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
function validateQaGatePassingCommands(commands) {
|
|
2201
|
+
for (const [index, command] of commands.entries()) {
|
|
2202
|
+
requireEvidenceRecord(command, `commands[${index}]`);
|
|
2203
|
+
const record = command;
|
|
2204
|
+
requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
|
|
2205
|
+
requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
|
|
2206
|
+
requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
|
|
2207
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
2208
|
+
throw new Error(`commands[${index}].exit_code is required.`);
|
|
2209
|
+
}
|
|
2210
|
+
if (record.exit_code !== 0) {
|
|
2211
|
+
throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
|
|
2212
|
+
}
|
|
2213
|
+
if (normalizedString(record.status) !== "pass") {
|
|
2214
|
+
throw new Error(`commands[${index}].status must be pass when QA gate status is pass.`);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
function validateArchitectPayloadShape(payload, status) {
|
|
2219
|
+
if (status !== "pass") {
|
|
2220
|
+
requireArchitectNonPassEvidence(payload);
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
2224
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
2225
|
+
requireNumericOne(implementationPlan, "schema_version", "implementation_plan.schema_version");
|
|
2226
|
+
requireNonEmptyStringRecord(implementationPlan, "plan_id", "implementation_plan.");
|
|
2227
|
+
requireArchitectArray(implementationPlan, "phases", payload);
|
|
2228
|
+
requireArchitectArray(implementationPlan, "tasks", payload);
|
|
2229
|
+
requireArchitectArray(implementationPlan, "dependencies", payload, { allowEmpty: true });
|
|
2230
|
+
requireArchitectArray(implementationPlan, "phase_gates", payload);
|
|
2231
|
+
if (arrayLength(implementationPlan, "tasks") === 0) {
|
|
2232
|
+
throw new Error("implementation_plan.tasks: expected non-empty array; correction: include at least one task with id and tests.");
|
|
2233
|
+
}
|
|
2234
|
+
const taskIds = new Set();
|
|
2235
|
+
for (const task of implementationPlan.tasks) {
|
|
2236
|
+
if (!task || typeof task !== "object" || Array.isArray(task)) {
|
|
2237
|
+
throw new Error("implementation_plan.tasks[]: expected object entries; correction: each task must be an object with id and tests.");
|
|
2238
|
+
}
|
|
2239
|
+
const taskRecord = task;
|
|
2240
|
+
requireNonEmptyStringRecord(taskRecord, "id", "implementation_plan.tasks[].");
|
|
2241
|
+
const taskId = optionalString(taskRecord, "id") ?? "";
|
|
2242
|
+
if (taskIds.has(taskId)) {
|
|
2243
|
+
throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
|
|
2244
|
+
}
|
|
2245
|
+
taskIds.add(taskId);
|
|
2246
|
+
validateArchitectTaskDevelopScope(taskRecord, taskId);
|
|
2247
|
+
requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
|
|
2248
|
+
if (arrayLength(taskRecord, "tests") === 0) {
|
|
2249
|
+
throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
validateAndNormalizeArchitectDependencies(implementationPlan, taskIds);
|
|
2253
|
+
requireNumericOne(qaPlan, "schema_version", "qa_plan.schema_version");
|
|
2254
|
+
requireArchitectArray(qaPlan, "final_qa", payload, { path: "qa_plan.final_qa" });
|
|
2255
|
+
if (arrayLength(qaPlan, "final_qa") === 0) {
|
|
2256
|
+
throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
function validateArchitectTaskDevelopScope(task, taskId) {
|
|
2260
|
+
const explicitScope = firstOptionalString(task, ["execution_scope", "scope", "task_scope", "task_type", "lane", "kind", "type"]);
|
|
2261
|
+
if (isStoryQaScope(explicitScope)) {
|
|
2262
|
+
throw new Error(`implementation_plan.tasks[${taskId}]: story QA/sign-off work must not be materialized as a Develop child task; correction: move this coverage to qa_plan.final_qa and keep implementation_plan.tasks limited to repo/product changes.`);
|
|
2263
|
+
}
|
|
2264
|
+
if (looksLikeStoryLevelVerificationTask(architectTaskScopeText(task))) {
|
|
2265
|
+
throw new Error(`implementation_plan.tasks[${taskId}]: parent-story materialization or sign-off verification belongs in qa_plan.final_qa, not implementation_plan.tasks; correction: keep child tasks focused on implementation work and put child-count/parent-plan artifact checks in final QA coverage.`);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
function firstOptionalString(record, keys) {
|
|
2269
|
+
for (const key of keys) {
|
|
2270
|
+
const value = optionalString(record, key);
|
|
2271
|
+
if (value) {
|
|
2272
|
+
return value;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
return null;
|
|
2276
|
+
}
|
|
2277
|
+
function isStoryQaScope(value) {
|
|
2278
|
+
if (!value) {
|
|
2279
|
+
return false;
|
|
2280
|
+
}
|
|
2281
|
+
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
2282
|
+
return [
|
|
2283
|
+
"qa",
|
|
2284
|
+
"story_qa",
|
|
2285
|
+
"final_qa",
|
|
2286
|
+
"sign_off",
|
|
2287
|
+
"signoff",
|
|
2288
|
+
"story_sign_off",
|
|
2289
|
+
"story_verification",
|
|
2290
|
+
"story_validation"
|
|
2291
|
+
].includes(normalized);
|
|
2292
|
+
}
|
|
2293
|
+
function architectTaskScopeText(task) {
|
|
2294
|
+
const parts = [];
|
|
2295
|
+
for (const key of ["id", "title", "summary", "phase", "phase_id"]) {
|
|
2296
|
+
appendOptionalString(parts, task, key);
|
|
2297
|
+
}
|
|
2298
|
+
for (const key of ["acceptance_criteria", "expected_paths", "touched_surfaces", "implementation_notes"]) {
|
|
2299
|
+
appendOptionalStringArray(parts, task, key);
|
|
2300
|
+
}
|
|
2301
|
+
const tests = task.tests;
|
|
2302
|
+
if (Array.isArray(tests)) {
|
|
2303
|
+
for (const test of tests) {
|
|
2304
|
+
if (!test || typeof test !== "object" || Array.isArray(test)) {
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
const record = test;
|
|
2308
|
+
for (const key of ["id", "command", "purpose", "target", "expected"]) {
|
|
2309
|
+
appendOptionalString(parts, record, key);
|
|
2310
|
+
}
|
|
2311
|
+
appendOptionalStringArray(parts, record, "steps");
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
return parts.join(" ");
|
|
2315
|
+
}
|
|
2316
|
+
function appendOptionalString(parts, record, key) {
|
|
2317
|
+
const value = optionalString(record, key);
|
|
2318
|
+
if (value) {
|
|
2319
|
+
parts.push(value);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
function appendOptionalStringArray(parts, record, key) {
|
|
2323
|
+
const value = record[key];
|
|
2324
|
+
if (!Array.isArray(value)) {
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
for (const entry of value) {
|
|
2328
|
+
if (typeof entry === "string" && entry.trim().length > 0) {
|
|
2329
|
+
parts.push(entry.trim());
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
function looksLikeStoryLevelVerificationTask(text) {
|
|
2334
|
+
if (!text.trim()) {
|
|
2335
|
+
return false;
|
|
2336
|
+
}
|
|
2337
|
+
const normalized = text.toLowerCase();
|
|
2338
|
+
const verificationWords = [
|
|
2339
|
+
"verify",
|
|
2340
|
+
"verification",
|
|
2341
|
+
"validate",
|
|
2342
|
+
"validation",
|
|
2343
|
+
"inspect",
|
|
2344
|
+
"audit",
|
|
2345
|
+
"check",
|
|
2346
|
+
"confirm",
|
|
2347
|
+
"prove",
|
|
2348
|
+
"evidence",
|
|
2349
|
+
"sign-off",
|
|
2350
|
+
"sign off"
|
|
2351
|
+
];
|
|
2352
|
+
if (!verificationWords.some((word) => normalized.includes(word))) {
|
|
2353
|
+
return false;
|
|
2354
|
+
}
|
|
2355
|
+
return [
|
|
2356
|
+
"materialization",
|
|
2357
|
+
"materialized child",
|
|
2358
|
+
"child inventory",
|
|
2359
|
+
"child work-item",
|
|
2360
|
+
"child work item",
|
|
2361
|
+
"created task count",
|
|
2362
|
+
"created_task_count",
|
|
2363
|
+
"parent plan",
|
|
2364
|
+
"parent story",
|
|
2365
|
+
"story sign-off",
|
|
2366
|
+
"story sign off",
|
|
2367
|
+
".forge/work-items",
|
|
2368
|
+
".forge\\work-items",
|
|
2369
|
+
".forge/stories",
|
|
2370
|
+
".forge\\stories",
|
|
2371
|
+
"implementation.plan.json",
|
|
2372
|
+
"qa.plan.json",
|
|
2373
|
+
"architect plan artifact",
|
|
2374
|
+
"child spec"
|
|
2375
|
+
].some((needle) => normalized.includes(needle));
|
|
2376
|
+
}
|
|
2377
|
+
function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
|
|
2378
|
+
const dependencies = implementationPlan.dependencies;
|
|
2379
|
+
const edges = new Map();
|
|
2380
|
+
for (const taskId of taskIds) {
|
|
2381
|
+
edges.set(taskId, []);
|
|
2382
|
+
}
|
|
2383
|
+
const normalizedDependencies = [];
|
|
2384
|
+
for (const [index, dependency] of dependencies.entries()) {
|
|
2385
|
+
if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
|
|
2386
|
+
throw new Error("implementation_plan.dependencies[]: expected object entries; correction: each dependency must be an object with before and after task ids.");
|
|
2387
|
+
}
|
|
2388
|
+
const record = dependency;
|
|
2389
|
+
const before = architectDependencyEndpoint(record, index, "before", "from");
|
|
2390
|
+
const after = architectDependencyEndpoint(record, index, "after", "to");
|
|
2391
|
+
if (!taskIds.has(before) || !taskIds.has(after)) {
|
|
2392
|
+
throw new Error("implementation_plan.dependencies: dependency references an unknown task id.");
|
|
2393
|
+
}
|
|
2394
|
+
const normalized = stripUndefined({
|
|
2395
|
+
...record,
|
|
2396
|
+
before,
|
|
2397
|
+
after,
|
|
2398
|
+
from: undefined,
|
|
2399
|
+
to: undefined
|
|
2400
|
+
});
|
|
2401
|
+
normalizedDependencies.push(normalized);
|
|
2402
|
+
edges.get(before)?.push(after);
|
|
2403
|
+
}
|
|
2404
|
+
implementationPlan.dependencies = normalizedDependencies;
|
|
2405
|
+
if (hasArchitectDependencyCycle(edges)) {
|
|
2406
|
+
throw new Error("implementation_plan.dependencies: expected acyclic graph; correction: remove or revise cyclic task dependencies.");
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
function architectDependencyEndpoint(dependency, index, canonicalKey, aliasKey) {
|
|
2410
|
+
const canonical = optionalString(dependency, canonicalKey);
|
|
2411
|
+
const alias = optionalString(dependency, aliasKey);
|
|
2412
|
+
if (canonical && alias && canonical !== alias) {
|
|
2413
|
+
throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: conflicts with ${aliasKey}; correction: use one task id for ${canonicalKey}.`);
|
|
2414
|
+
}
|
|
2415
|
+
const value = canonical ?? alias;
|
|
2416
|
+
if (!value) {
|
|
2417
|
+
throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: expected string; correction: use before and after task ids for each dependency.`);
|
|
2418
|
+
}
|
|
2419
|
+
return value;
|
|
2420
|
+
}
|
|
2421
|
+
function hasArchitectDependencyCycle(edges) {
|
|
2422
|
+
const visiting = new Set();
|
|
2423
|
+
const visited = new Set();
|
|
2424
|
+
const visit = (node) => {
|
|
2425
|
+
if (visited.has(node)) {
|
|
2426
|
+
return false;
|
|
2427
|
+
}
|
|
2428
|
+
if (visiting.has(node)) {
|
|
2429
|
+
return true;
|
|
2430
|
+
}
|
|
2431
|
+
visiting.add(node);
|
|
2432
|
+
for (const next of edges.get(node) ?? []) {
|
|
2433
|
+
if (visit(next)) {
|
|
2434
|
+
return true;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
visiting.delete(node);
|
|
2438
|
+
visited.add(node);
|
|
2439
|
+
return false;
|
|
2440
|
+
};
|
|
2441
|
+
for (const node of edges.keys()) {
|
|
2442
|
+
if (visit(node)) {
|
|
2443
|
+
return true;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
return false;
|
|
2447
|
+
}
|
|
2448
|
+
function requireArchitectNonPassEvidence(payload) {
|
|
2449
|
+
const evidenceKeys = ["blockers", "revision_questions", "revision_reasons", "known_gaps", "findings", "remaining_risks", "failures"];
|
|
2450
|
+
const hasEvidence = evidenceKeys.some((key) => {
|
|
2451
|
+
const value = payload[key];
|
|
2452
|
+
if (typeof value === "string") {
|
|
2453
|
+
return value.trim().length > 0;
|
|
2454
|
+
}
|
|
2455
|
+
if (Array.isArray(value)) {
|
|
2456
|
+
return value.length > 0;
|
|
2457
|
+
}
|
|
2458
|
+
return Boolean(value && typeof value === "object");
|
|
2459
|
+
});
|
|
2460
|
+
if (!hasEvidence) {
|
|
2461
|
+
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.");
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
function requireArchitectArray(source, key, rootPayload, options = {}) {
|
|
2465
|
+
const pathLabel = options.path ?? `implementation_plan.${key}`;
|
|
2466
|
+
if (!Array.isArray(source[key])) {
|
|
2467
|
+
const nestedHint = architectNestedArrayHint(rootPayload, key);
|
|
2468
|
+
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"}.`}`);
|
|
2469
|
+
}
|
|
2470
|
+
if (!options.allowEmpty && arrayLength(source, key) === 0) {
|
|
2471
|
+
throw new Error(`${pathLabel}: expected non-empty array; correction: include at least one entry.`);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
function architectNestedArrayHint(rootPayload, key) {
|
|
2475
|
+
if (Array.isArray(rootPayload[key])) {
|
|
2476
|
+
return `move top-level ${key} into implementation_plan.${key}.`;
|
|
2477
|
+
}
|
|
2478
|
+
const implementationPlan = rootPayload.implementation_plan;
|
|
2479
|
+
if (!implementationPlan || typeof implementationPlan !== "object" || Array.isArray(implementationPlan)) {
|
|
2480
|
+
return null;
|
|
2481
|
+
}
|
|
2482
|
+
const plan = implementationPlan;
|
|
2483
|
+
for (const container of ["plan", "dag", "implementation_dag", "implementationPlan", "implementation_plan"]) {
|
|
2484
|
+
const nested = plan[container];
|
|
2485
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested) && Array.isArray(nested[key])) {
|
|
2486
|
+
return `move implementation_plan.${container}.${key} to implementation_plan.${key}.`;
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
return null;
|
|
2490
|
+
}
|
|
953
2491
|
function validateQaPayloadShape(payload, status) {
|
|
954
2492
|
const evidenceSource = qaEvidenceSource(payload);
|
|
955
2493
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
956
2494
|
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
2495
|
+
const sidecars = qaArray(payload, "sidecars", evidenceSource);
|
|
957
2496
|
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
958
2497
|
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
959
2498
|
const findings = qaObjectArray(payload, "findings");
|
|
@@ -991,10 +2530,23 @@ function validateQaPayloadShape(payload, status) {
|
|
|
991
2530
|
requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
|
|
992
2531
|
const record = browserCheck;
|
|
993
2532
|
requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
|
|
2533
|
+
requireNonEmptyStringRecord(record, "status", `browser_checks[${index}].`);
|
|
994
2534
|
const checkStatus = optionalString(record, "status");
|
|
995
2535
|
if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
|
|
996
2536
|
throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
|
|
997
2537
|
}
|
|
2538
|
+
if (!optionalString(record, "url") && !optionalString(record, "route")) {
|
|
2539
|
+
throw new Error(`browser_checks[${index}] must include url or route.`);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
2543
|
+
requireEvidenceRecord(sidecar, `sidecars[${index}]`);
|
|
2544
|
+
const record = sidecar;
|
|
2545
|
+
requireNonEmptyStringRecord(record, "path", `sidecars[${index}].`);
|
|
2546
|
+
requireNonEmptyStringRecord(record, "content_hash", `sidecars[${index}].`);
|
|
2547
|
+
if (typeof record.bytes !== "number" || !Number.isFinite(record.bytes) || record.bytes <= 0) {
|
|
2548
|
+
throw new Error(`sidecars[${index}].bytes must be greater than 0.`);
|
|
2549
|
+
}
|
|
998
2550
|
}
|
|
999
2551
|
for (const [index, finding] of findings.entries()) {
|
|
1000
2552
|
requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
|
|
@@ -1075,8 +2627,34 @@ function normalizeArtifactPayload(payload, defaults) {
|
|
|
1075
2627
|
normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
|
|
1076
2628
|
normalized.phase = defaults.phase;
|
|
1077
2629
|
normalized.work_item_id = defaults.workItemId;
|
|
2630
|
+
const ownerFields = artifactOwnerFields(defaults);
|
|
2631
|
+
Object.assign(normalized, ownerFields);
|
|
2632
|
+
normalized.status = defaults.status;
|
|
2633
|
+
normalized.produced_by = defaults.producedBy;
|
|
2634
|
+
return normalized;
|
|
2635
|
+
}
|
|
2636
|
+
function normalizeQaGateArtifactPayload(payload, defaults) {
|
|
2637
|
+
const normalized = stripUndefined({ ...payload });
|
|
2638
|
+
delete normalized.report_markdown;
|
|
2639
|
+
delete normalized.content_hash;
|
|
2640
|
+
delete normalized.contentHash;
|
|
2641
|
+
normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
|
|
2642
|
+
normalized.phase = "qa";
|
|
2643
|
+
normalized.gate = defaults.gate;
|
|
2644
|
+
normalized.work_item_id = defaults.workItemId;
|
|
2645
|
+
const ownerFields = artifactOwnerFields(defaults);
|
|
2646
|
+
Object.assign(normalized, ownerFields);
|
|
1078
2647
|
normalized.status = defaults.status;
|
|
1079
2648
|
normalized.produced_by = defaults.producedBy;
|
|
2649
|
+
if (defaults.gate === "qa_plan") {
|
|
2650
|
+
const checklist = qaStringArray(normalized, "checklist");
|
|
2651
|
+
normalized.checklist = checklist.length > 0 ? checklist : defaultQaChecklist(defaults.status);
|
|
2652
|
+
normalized.acceptance_criteria = qaStringArray(normalized, "acceptance_criteria");
|
|
2653
|
+
}
|
|
2654
|
+
if (defaults.gate === "qa_findings") {
|
|
2655
|
+
normalized.findings = qaObjectArray(normalized, "findings");
|
|
2656
|
+
normalized.candidate_bugs = qaObjectArray(normalized, "candidate_bugs");
|
|
2657
|
+
}
|
|
1080
2658
|
return normalized;
|
|
1081
2659
|
}
|
|
1082
2660
|
function reportMarkdown(payload, phase, status, producedBy, contentHash) {
|
|
@@ -1098,6 +2676,10 @@ ${summary}
|
|
|
1098
2676
|
}
|
|
1099
2677
|
function parseStrictCompletionPhase(value) {
|
|
1100
2678
|
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
2679
|
+
const debugPhase = normalizeDebugCompletionPhase(normalized);
|
|
2680
|
+
if (debugPhase) {
|
|
2681
|
+
return debugPhase;
|
|
2682
|
+
}
|
|
1101
2683
|
if (normalized === "implementation" || normalized === "implement" || normalized === "develop") {
|
|
1102
2684
|
return "implementation";
|
|
1103
2685
|
}
|
|
@@ -1110,8 +2692,51 @@ function parseStrictCompletionPhase(value) {
|
|
|
1110
2692
|
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
1111
2693
|
return "qa";
|
|
1112
2694
|
}
|
|
2695
|
+
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
2696
|
+
return "architect";
|
|
2697
|
+
}
|
|
2698
|
+
if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
|
|
2699
|
+
return "draft";
|
|
2700
|
+
}
|
|
1113
2701
|
return null;
|
|
1114
2702
|
}
|
|
2703
|
+
function normalizeDebugCompletionPhase(value) {
|
|
2704
|
+
const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
|
|
2705
|
+
switch (normalized) {
|
|
2706
|
+
case "debug_repro":
|
|
2707
|
+
case "debugrepro":
|
|
2708
|
+
case "repro":
|
|
2709
|
+
case "reproduce":
|
|
2710
|
+
return "debug_repro";
|
|
2711
|
+
case "debug_root_cause":
|
|
2712
|
+
case "debugrootcause":
|
|
2713
|
+
case "root_cause":
|
|
2714
|
+
case "rootcause":
|
|
2715
|
+
return "debug_root_cause";
|
|
2716
|
+
case "debug_fix":
|
|
2717
|
+
case "debugfix":
|
|
2718
|
+
case "fix":
|
|
2719
|
+
return "debug_fix";
|
|
2720
|
+
case "debug_regression":
|
|
2721
|
+
case "debugregression":
|
|
2722
|
+
case "regression":
|
|
2723
|
+
return "debug_regression";
|
|
2724
|
+
default:
|
|
2725
|
+
return null;
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
function isDevelopCompletionPhase(phase) {
|
|
2729
|
+
return phase === "implementation" || phase === "self_review" || phase === "validation";
|
|
2730
|
+
}
|
|
2731
|
+
function isDebugCompletionPhase(phase) {
|
|
2732
|
+
return debugCompletionPhaseOrder.includes(phase);
|
|
2733
|
+
}
|
|
2734
|
+
function asDebugCompletionPhase(phase) {
|
|
2735
|
+
if (isDebugCompletionPhase(phase)) {
|
|
2736
|
+
return phase;
|
|
2737
|
+
}
|
|
2738
|
+
throw new Error(`Expected Debug completion phase, got ${phase}.`);
|
|
2739
|
+
}
|
|
1115
2740
|
function parseCompletionRedactionSecrets(raw) {
|
|
1116
2741
|
if (!raw?.trim()) {
|
|
1117
2742
|
return [];
|
|
@@ -1141,8 +2766,51 @@ function phaseFromToolName(toolName) {
|
|
|
1141
2766
|
if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
|
|
1142
2767
|
return "qa";
|
|
1143
2768
|
}
|
|
2769
|
+
if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
|
|
2770
|
+
return "architect";
|
|
2771
|
+
}
|
|
2772
|
+
if (draftGateFromToolName(toolName)) {
|
|
2773
|
+
return "draft";
|
|
2774
|
+
}
|
|
2775
|
+
const debugGate = debugGateFromToolName(toolName);
|
|
2776
|
+
if (debugGate) {
|
|
2777
|
+
return debugGate;
|
|
2778
|
+
}
|
|
2779
|
+
if (qaGateFromToolName(toolName)) {
|
|
2780
|
+
return "qa";
|
|
2781
|
+
}
|
|
1144
2782
|
throw new Error(`Unknown Forge completion tool: ${toolName}`);
|
|
1145
2783
|
}
|
|
2784
|
+
function draftGateFromToolName(toolName) {
|
|
2785
|
+
const normalized = toolName.trim().toLowerCase();
|
|
2786
|
+
for (const gate of draftCompletionGateOrder) {
|
|
2787
|
+
const base = draftGateToolNames[gate];
|
|
2788
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
2789
|
+
return gate;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
return null;
|
|
2793
|
+
}
|
|
2794
|
+
function debugGateFromToolName(toolName) {
|
|
2795
|
+
const normalized = toolName.trim().toLowerCase();
|
|
2796
|
+
for (const gate of debugCompletionPhaseOrder) {
|
|
2797
|
+
const base = debugGateToolNames[gate];
|
|
2798
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
2799
|
+
return gate;
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
return null;
|
|
2803
|
+
}
|
|
2804
|
+
function qaGateFromToolName(toolName) {
|
|
2805
|
+
const normalized = toolName.trim().toLowerCase();
|
|
2806
|
+
for (const gate of qaCompletionGateOrder) {
|
|
2807
|
+
const base = qaGateToolNames[gate];
|
|
2808
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
2809
|
+
return gate;
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
return null;
|
|
2813
|
+
}
|
|
1146
2814
|
function requireField(payload, key) {
|
|
1147
2815
|
if (payload[key] === undefined || payload[key] === null || (typeof payload[key] === "string" && payload[key].trim().length === 0)) {
|
|
1148
2816
|
throw new Error(`${key} is required.`);
|
|
@@ -1210,17 +2878,42 @@ function requireArray(payload, key) {
|
|
|
1210
2878
|
throw new Error(`${key} must be an array.`);
|
|
1211
2879
|
}
|
|
1212
2880
|
}
|
|
2881
|
+
function requireNonEmptyArray(payload, key) {
|
|
2882
|
+
requireArray(payload, key);
|
|
2883
|
+
if (arrayLength(payload, key) === 0) {
|
|
2884
|
+
throw new Error(`${key} must include at least one value.`);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
function arrayLength(payload, key) {
|
|
2888
|
+
const value = payload[key];
|
|
2889
|
+
return Array.isArray(value) ? value.length : 0;
|
|
2890
|
+
}
|
|
2891
|
+
function requireAllowedStatus(payload, key, allowed) {
|
|
2892
|
+
const value = normalizedString(optionalString(payload, key));
|
|
2893
|
+
if (!allowed.includes(value)) {
|
|
2894
|
+
throw new Error(`${key} must be one of ${allowed.join(", ")}.`);
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
1213
2897
|
function requireObject(payload, key) {
|
|
1214
2898
|
const value = payload[key];
|
|
1215
2899
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1216
2900
|
throw new Error(`${key} must be an object.`);
|
|
1217
2901
|
}
|
|
1218
2902
|
}
|
|
2903
|
+
function requiredRecord(payload, key) {
|
|
2904
|
+
requireObject(payload, key);
|
|
2905
|
+
return payload[key];
|
|
2906
|
+
}
|
|
1219
2907
|
function requireBoolean(payload, key) {
|
|
1220
2908
|
if (typeof payload[key] !== "boolean") {
|
|
1221
2909
|
throw new Error(`${key} must be a boolean.`);
|
|
1222
2910
|
}
|
|
1223
2911
|
}
|
|
2912
|
+
function requireNumericOne(payload, key, pathLabel) {
|
|
2913
|
+
if (payload[key] !== 1) {
|
|
2914
|
+
throw new Error(`${pathLabel}: expected numeric 1; correction: set ${pathLabel} to 1.`);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
1224
2917
|
function requireSatisfiedAcceptanceCriteria(payload) {
|
|
1225
2918
|
const criteria = payload.acceptance_criteria;
|
|
1226
2919
|
if (!Array.isArray(criteria)) {
|
|
@@ -1238,6 +2931,37 @@ function requireSatisfiedAcceptanceCriteria(payload) {
|
|
|
1238
2931
|
}
|
|
1239
2932
|
}
|
|
1240
2933
|
}
|
|
2934
|
+
function validateResolvedFindings(payload) {
|
|
2935
|
+
const resolvedFindings = payload.resolved_findings;
|
|
2936
|
+
if (resolvedFindings === undefined || resolvedFindings === null) {
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
if (!Array.isArray(resolvedFindings)) {
|
|
2940
|
+
throw new Error("resolved_findings must be an array.");
|
|
2941
|
+
}
|
|
2942
|
+
for (const [index, finding] of resolvedFindings.entries()) {
|
|
2943
|
+
const prefix = `resolved_findings[${index}].`;
|
|
2944
|
+
if (!finding || typeof finding !== "object" || Array.isArray(finding)) {
|
|
2945
|
+
throw new Error(`resolved_findings[${index}] must be an object.`);
|
|
2946
|
+
}
|
|
2947
|
+
const record = finding;
|
|
2948
|
+
requireNonEmptyStringRecord(record, "qa_id", prefix);
|
|
2949
|
+
requireKnownStringRecord(record, "status", resolvedFindingStatuses, prefix);
|
|
2950
|
+
requireNonEmptyStringRecord(record, "summary", prefix);
|
|
2951
|
+
requireSubstantiveValueRecord(record, "evidence", prefix);
|
|
2952
|
+
requireArrayRecord(record, "changed_files", prefix);
|
|
2953
|
+
requireArrayRecord(record, "tests_added_or_updated", prefix);
|
|
2954
|
+
requireSubstantiveValueRecord(record, "regression_evidence", prefix);
|
|
2955
|
+
if (normalizedString(record.status) === "resolved") {
|
|
2956
|
+
if (record.changed_files.length === 0) {
|
|
2957
|
+
throw new Error(`${prefix}changed_files must include at least one changed file for resolved QA findings.`);
|
|
2958
|
+
}
|
|
2959
|
+
if (record.tests_added_or_updated.length === 0) {
|
|
2960
|
+
throw new Error(`${prefix}tests_added_or_updated must include at least one regression test for resolved QA findings.`);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
1241
2965
|
function qaEvidenceSource(payload) {
|
|
1242
2966
|
const evidence = payload.evidence;
|
|
1243
2967
|
return evidence && typeof evidence === "object" && !Array.isArray(evidence)
|
|
@@ -1287,6 +3011,11 @@ function requireNonEmptyStringRecord(source, key, prefix = "") {
|
|
|
1287
3011
|
throw new Error(`${prefix}${key} is required.`);
|
|
1288
3012
|
}
|
|
1289
3013
|
}
|
|
3014
|
+
function requireArrayRecord(source, key, prefix = "") {
|
|
3015
|
+
if (!Array.isArray(source[key])) {
|
|
3016
|
+
throw new Error(`${prefix}${key} must be an array.`);
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
1290
3019
|
function requireKnownStringRecord(source, key, allowed, prefix = "") {
|
|
1291
3020
|
const value = normalizedString(optionalString(source, key));
|
|
1292
3021
|
if (!allowed.has(value)) {
|
|
@@ -1304,6 +3033,11 @@ function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
|
|
|
1304
3033
|
}
|
|
1305
3034
|
}
|
|
1306
3035
|
}
|
|
3036
|
+
function requireSubstantiveValueRecord(source, key, prefix = "") {
|
|
3037
|
+
if (!hasSubstantiveJsonValue(source[key])) {
|
|
3038
|
+
throw new Error(`${prefix}${key} must include at least one concrete evidence reference or summary.`);
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
1307
3041
|
function requireSubstantiveEvidenceRecord(source, prefix = "") {
|
|
1308
3042
|
const evidence = source.evidence;
|
|
1309
3043
|
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
@@ -1328,6 +3062,36 @@ function hasSubstantiveJsonValue(value) {
|
|
|
1328
3062
|
function normalizedString(value) {
|
|
1329
3063
|
return typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
|
|
1330
3064
|
}
|
|
3065
|
+
function normalizedOwnerType(value) {
|
|
3066
|
+
const normalized = normalizedString(value);
|
|
3067
|
+
return normalized === "work_item" || normalized === "story" ? normalized : undefined;
|
|
3068
|
+
}
|
|
3069
|
+
function artifactOwnerFields(config) {
|
|
3070
|
+
const ownerType = normalizedOwnerType(config.ownerType);
|
|
3071
|
+
const ownerId = config.ownerId?.trim();
|
|
3072
|
+
if (!ownerType && !ownerId) {
|
|
3073
|
+
return {};
|
|
3074
|
+
}
|
|
3075
|
+
return {
|
|
3076
|
+
owner_type: ownerType ?? "work_item",
|
|
3077
|
+
owner_id: ownerId || config.workItemId
|
|
3078
|
+
};
|
|
3079
|
+
}
|
|
3080
|
+
function artifactReferenceOwnerFields(config) {
|
|
3081
|
+
const owner = artifactOwnerFields(config);
|
|
3082
|
+
return owner.owner_type && owner.owner_id
|
|
3083
|
+
? { ownerType: owner.owner_type, ownerId: owner.owner_id }
|
|
3084
|
+
: {};
|
|
3085
|
+
}
|
|
3086
|
+
function artifactOwnerDirectory(ownerType) {
|
|
3087
|
+
return normalizedOwnerType(ownerType) === "story" ? "stories" : "work-items";
|
|
3088
|
+
}
|
|
3089
|
+
function artifactOwnerRelativeRoot(ownerType, ownerId, workItemId) {
|
|
3090
|
+
const owner = artifactOwnerFields({ workItemId, ownerType, ownerId });
|
|
3091
|
+
return owner.owner_type && owner.owner_id
|
|
3092
|
+
? [".forge", artifactOwnerDirectory(owner.owner_type), owner.owner_id, "artifacts"].join("/")
|
|
3093
|
+
: [".forge", "work-items", workItemId, "artifacts"].join("/");
|
|
3094
|
+
}
|
|
1331
3095
|
function defaultQaChecklist(status) {
|
|
1332
3096
|
return [
|
|
1333
3097
|
"Review the validated work item against acceptance criteria.",
|
|
@@ -1338,6 +3102,9 @@ function defaultQaChecklist(status) {
|
|
|
1338
3102
|
function qaArtifactRelativePath(workItemId, fileName) {
|
|
1339
3103
|
return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
|
|
1340
3104
|
}
|
|
3105
|
+
function qaArtifactRelativePathForOwner(ownerType, ownerId, fileName) {
|
|
3106
|
+
return [".forge", artifactOwnerDirectory(ownerType), ownerId, "artifacts", fileName].join("/");
|
|
3107
|
+
}
|
|
1341
3108
|
function jsonText(value) {
|
|
1342
3109
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
1343
3110
|
}
|
|
@@ -1353,7 +3120,7 @@ function parseJsonObject(content, label) {
|
|
|
1353
3120
|
return asRecord(JSON.parse(content), label);
|
|
1354
3121
|
}
|
|
1355
3122
|
catch (error) {
|
|
1356
|
-
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}
|
|
3123
|
+
throw new Error(`${label} is not valid JSON: ${errorMessage(error)}`, { cause: error });
|
|
1357
3124
|
}
|
|
1358
3125
|
}
|
|
1359
3126
|
function parseOptionalJsonObject(content, label) {
|