@loucompanion/forge-bridge 0.1.1-dev.895607f4402a → 0.1.1-dev.9219f8477b8a
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 +147 -18
- package/dist/bridge/bridge.types.d.ts +4 -2
- package/dist/cli/cli.service.js +225 -10
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +18 -3
- package/dist/config.js +1 -1
- package/dist/session/phase-completion.d.ts +19 -4
- package/dist/session/phase-completion.js +914 -31
- 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 +1104 -116
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/session.service.js +41 -4
- package/dist/session/session.types.d.ts +77 -0
- package/dist/session/session.types.js +143 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.js +91 -4
- package/dist/worktree/worktree.service.d.ts +1 -0
- package/dist/worktree/worktree.service.js +127 -3
- package/dist/worktree/worktree.types.d.ts +6 -1
- package/package.json +1 -1
|
@@ -22,6 +22,33 @@ export const qaCompletionGateOrder = [
|
|
|
22
22
|
"qa_findings",
|
|
23
23
|
"qa_verdict"
|
|
24
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
|
+
};
|
|
25
52
|
const qaGateFileNames = {
|
|
26
53
|
qa_plan: "qa.plan.json",
|
|
27
54
|
qa_regression: "qa.regression.json",
|
|
@@ -54,6 +81,8 @@ const qaBlockerReasons = new Set([
|
|
|
54
81
|
"missing_worktree",
|
|
55
82
|
"runner_failure"
|
|
56
83
|
]);
|
|
84
|
+
const resolvedFindingStatusValues = ["resolved", "disputed", "not_reproduced"];
|
|
85
|
+
const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
|
|
57
86
|
const qaFindingSeverities = new Set(["critical", "major", "minor"]);
|
|
58
87
|
const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
|
|
59
88
|
const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
|
|
@@ -89,6 +118,14 @@ const phaseFiles = {
|
|
|
89
118
|
debug_regression: {
|
|
90
119
|
jsonFileName: "debug.regression.json",
|
|
91
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"
|
|
92
129
|
}
|
|
93
130
|
};
|
|
94
131
|
export function completionPhaseFromDispatch(dispatch) {
|
|
@@ -109,6 +146,12 @@ export function normalizeCompletionPhase(value) {
|
|
|
109
146
|
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
110
147
|
return "qa";
|
|
111
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
|
+
}
|
|
112
155
|
return "implementation";
|
|
113
156
|
}
|
|
114
157
|
export function usesDevelopCycleCompletion(dispatch) {
|
|
@@ -138,7 +181,16 @@ export function usesDebugCycleCompletion(dispatch) {
|
|
|
138
181
|
&& lane === "debug"
|
|
139
182
|
&& isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
|
|
140
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
|
+
}
|
|
141
190
|
export function completionToolNamesForPhase(phase) {
|
|
191
|
+
if (phase === "draft") {
|
|
192
|
+
return completionToolNamesForDraftGate("draft_proposal");
|
|
193
|
+
}
|
|
142
194
|
if (isDebugCompletionPhase(phase)) {
|
|
143
195
|
return completionToolNamesForDebugGate(phase);
|
|
144
196
|
}
|
|
@@ -148,7 +200,9 @@ export function completionToolNamesForPhase(phase) {
|
|
|
148
200
|
? "complete_self_review"
|
|
149
201
|
: phase === "validation"
|
|
150
202
|
? "complete_validation"
|
|
151
|
-
: "
|
|
203
|
+
: phase === "architect"
|
|
204
|
+
? "complete_architect_plan"
|
|
205
|
+
: "complete_qa";
|
|
152
206
|
return [`forge.${base}`, base];
|
|
153
207
|
}
|
|
154
208
|
export function completionToolNamesForQaGate(gate) {
|
|
@@ -159,6 +213,10 @@ export function completionToolNamesForDebugGate(gate) {
|
|
|
159
213
|
const base = debugGateToolNames[gate];
|
|
160
214
|
return [`forge.${base}`, base];
|
|
161
215
|
}
|
|
216
|
+
export function completionToolNamesForDraftGate(gate) {
|
|
217
|
+
const base = draftGateToolNames[gate];
|
|
218
|
+
return [`forge.${base}`, base];
|
|
219
|
+
}
|
|
162
220
|
export function expectedCompletionToolName(phase) {
|
|
163
221
|
return completionToolNamesForPhase(phase)[0];
|
|
164
222
|
}
|
|
@@ -168,17 +226,26 @@ export function expectedQaCompletionToolName(gate) {
|
|
|
168
226
|
export function expectedDebugCompletionToolName(gate) {
|
|
169
227
|
return completionToolNamesForDebugGate(gate)[0];
|
|
170
228
|
}
|
|
229
|
+
export function expectedDraftCompletionToolName(gate) {
|
|
230
|
+
return completionToolNamesForDraftGate(gate)[0];
|
|
231
|
+
}
|
|
171
232
|
export function artifactRootFor(worktreePath, workItemId) {
|
|
172
233
|
return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
|
|
173
234
|
}
|
|
174
|
-
function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
235
|
+
export function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
175
236
|
return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
|
|
176
237
|
}
|
|
177
|
-
export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
|
|
178
|
-
|
|
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);
|
|
179
243
|
}
|
|
180
|
-
export function completionArtifactReportPath(worktreePath, workItemId, phase) {
|
|
181
|
-
|
|
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);
|
|
182
249
|
}
|
|
183
250
|
export async function prepareCompletionToolRuntime(options) {
|
|
184
251
|
const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
|
|
@@ -195,7 +262,9 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
195
262
|
producedBy: `${options.dispatch.execution.provider}-cli`,
|
|
196
263
|
developCycle: usesDevelopCycleCompletion(options.dispatch),
|
|
197
264
|
qaCycle: usesQaCycleCompletion(options.dispatch),
|
|
198
|
-
debugCycle: usesDebugCycleCompletion(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"
|
|
199
268
|
});
|
|
200
269
|
const runtime = {
|
|
201
270
|
serverName: toolServerName,
|
|
@@ -234,6 +303,12 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
234
303
|
if (config.debugCycle) {
|
|
235
304
|
args.push("--debug-cycle");
|
|
236
305
|
}
|
|
306
|
+
if (config.architectCycle) {
|
|
307
|
+
args.push("--architect-cycle");
|
|
308
|
+
}
|
|
309
|
+
if (config.draftCycle) {
|
|
310
|
+
args.push("--draft-cycle");
|
|
311
|
+
}
|
|
237
312
|
return args;
|
|
238
313
|
}
|
|
239
314
|
export function codexMcpConfigArgs(runtime) {
|
|
@@ -266,6 +341,17 @@ Do not write QA artifacts by hand. When QA is complete, call \`${toolName}(...)\
|
|
|
266
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\`.
|
|
267
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.
|
|
268
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.`;
|
|
269
355
|
}
|
|
270
356
|
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
271
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.
|
|
@@ -297,6 +383,19 @@ Use \`status: "skipped"\` with a reason for gates that are not applicable. If a
|
|
|
297
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\`.
|
|
298
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.`;
|
|
299
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
|
+
}
|
|
300
399
|
export function completionPromptForDebugCycle(currentGate = "debug_repro") {
|
|
301
400
|
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
302
401
|
Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
|
|
@@ -309,31 +408,38 @@ Each tool writes a canonical Debug artifact under \`.forge/work-items/{workItemI
|
|
|
309
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.`;
|
|
310
409
|
}
|
|
311
410
|
export function completionToolNamesForServer(config) {
|
|
312
|
-
return config.
|
|
313
|
-
?
|
|
314
|
-
: config.
|
|
315
|
-
?
|
|
316
|
-
: config.
|
|
317
|
-
?
|
|
318
|
-
:
|
|
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);
|
|
319
422
|
}
|
|
320
423
|
export class CompletionToolServerSession {
|
|
321
424
|
config;
|
|
322
425
|
currentPhase;
|
|
323
426
|
currentQaGate = "qa_plan";
|
|
427
|
+
currentDraftGate = "draft_intent";
|
|
324
428
|
completed = false;
|
|
325
429
|
constructor(config) {
|
|
326
430
|
this.config = config;
|
|
327
431
|
this.currentPhase = config.phase;
|
|
328
432
|
}
|
|
329
433
|
get instructions() {
|
|
330
|
-
return this.config.
|
|
331
|
-
?
|
|
332
|
-
: this.config.
|
|
333
|
-
?
|
|
334
|
-
: this.config.
|
|
335
|
-
?
|
|
336
|
-
:
|
|
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);
|
|
337
443
|
}
|
|
338
444
|
toolDefinitions() {
|
|
339
445
|
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
|
|
@@ -343,6 +449,9 @@ export class CompletionToolServerSession {
|
|
|
343
449
|
if (this.completed) {
|
|
344
450
|
throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
|
|
345
451
|
}
|
|
452
|
+
if (this.config.draftCycle) {
|
|
453
|
+
return await this.callDraftGateTool(toolName, requestedPhase, input);
|
|
454
|
+
}
|
|
346
455
|
if (this.config.debugCycle) {
|
|
347
456
|
return await this.callDebugGateTool(toolName, requestedPhase, input);
|
|
348
457
|
}
|
|
@@ -383,6 +492,30 @@ export class CompletionToolServerSession {
|
|
|
383
492
|
}
|
|
384
493
|
};
|
|
385
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
|
+
}
|
|
386
519
|
async callDebugGateTool(toolName, requestedPhase, input) {
|
|
387
520
|
const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
|
|
388
521
|
if (!isDebugCompletionPhase(requestedPhase)) {
|
|
@@ -479,6 +612,16 @@ export class CompletionToolServerSession {
|
|
|
479
612
|
this.completed = true;
|
|
480
613
|
return "dev_test_ready";
|
|
481
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
|
+
}
|
|
482
625
|
advanceAfterQaGate(gate) {
|
|
483
626
|
const index = qaCompletionGateOrder.indexOf(gate);
|
|
484
627
|
const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
|
|
@@ -500,6 +643,43 @@ export class CompletionToolServerSession {
|
|
|
500
643
|
return null;
|
|
501
644
|
}
|
|
502
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";
|
|
682
|
+
}
|
|
503
683
|
export async function writeCompletionArtifact(config, toolName, input) {
|
|
504
684
|
const phase = phaseFromToolName(toolName);
|
|
505
685
|
if (phase !== config.phase) {
|
|
@@ -511,7 +691,10 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
511
691
|
}
|
|
512
692
|
const payload = asRecord(input, "completion payload");
|
|
513
693
|
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
514
|
-
|
|
694
|
+
let validationPayload = { ...payload, status };
|
|
695
|
+
if (config.qaCycle && qaGate === "qa_verdict") {
|
|
696
|
+
validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
|
|
697
|
+
}
|
|
515
698
|
validatePayloadShape(phase, validationPayload);
|
|
516
699
|
validateOptionalWorkItemId(validationPayload, config.workItemId);
|
|
517
700
|
const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
|
|
@@ -522,6 +705,9 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
522
705
|
if (phase === "qa") {
|
|
523
706
|
return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
524
707
|
}
|
|
708
|
+
if (phase === "architect") {
|
|
709
|
+
return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
|
|
710
|
+
}
|
|
525
711
|
const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
|
|
526
712
|
phase,
|
|
527
713
|
workItemId: config.workItemId,
|
|
@@ -554,6 +740,48 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
554
740
|
producedBy
|
|
555
741
|
};
|
|
556
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
|
+
}
|
|
557
785
|
async function writeQaGateCompletionArtifact(config, gate, input) {
|
|
558
786
|
const payload = asRecord(input, "QA gate completion payload");
|
|
559
787
|
const status = normalizeQaGateStatus(requiredString(payload, "status"));
|
|
@@ -709,6 +937,133 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
709
937
|
producedBy
|
|
710
938
|
};
|
|
711
939
|
}
|
|
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
|
+
}
|
|
712
1067
|
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
713
1068
|
if (sidecars.length === 0) {
|
|
714
1069
|
return [];
|
|
@@ -762,19 +1117,28 @@ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, o
|
|
|
762
1117
|
}
|
|
763
1118
|
return validated;
|
|
764
1119
|
}
|
|
765
|
-
export async function readCompletionArtifact(worktreePath, workItemId, phase) {
|
|
766
|
-
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
|
|
767
|
-
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
|
|
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);
|
|
768
1123
|
const verdictJson = await readOptionalFile(artifactPath);
|
|
769
1124
|
if (verdictJson === null) {
|
|
770
1125
|
return null;
|
|
771
1126
|
}
|
|
772
1127
|
const parsed = parseJsonObject(verdictJson, artifactPath);
|
|
773
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;
|
|
774
1136
|
return {
|
|
775
1137
|
repoPath: path.resolve(worktreePath),
|
|
776
1138
|
path: artifactPath,
|
|
777
1139
|
verdictJson,
|
|
1140
|
+
implementationPlanJson,
|
|
1141
|
+
qaPlanJson,
|
|
778
1142
|
report,
|
|
779
1143
|
producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
|
|
780
1144
|
status: optionalString(parsed, "status") ?? "unknown",
|
|
@@ -812,6 +1176,8 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
812
1176
|
let developCycle = false;
|
|
813
1177
|
let qaCycle = false;
|
|
814
1178
|
let debugCycle = false;
|
|
1179
|
+
let architectCycle = false;
|
|
1180
|
+
let draftCycle = false;
|
|
815
1181
|
for (let index = 1; index < argv.length; index += 1) {
|
|
816
1182
|
const arg = argv[index];
|
|
817
1183
|
if (arg === "--worktree") {
|
|
@@ -833,7 +1199,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
833
1199
|
const rawPhase = argv[++index];
|
|
834
1200
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
835
1201
|
if (!parsedPhase) {
|
|
836
|
-
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, debug-repro, debug-root-cause, debug-fix, debug-regression." };
|
|
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." };
|
|
837
1203
|
}
|
|
838
1204
|
phase = parsedPhase;
|
|
839
1205
|
}
|
|
@@ -849,6 +1215,12 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
849
1215
|
else if (arg === "--debug-cycle") {
|
|
850
1216
|
debugCycle = true;
|
|
851
1217
|
}
|
|
1218
|
+
else if (arg === "--architect-cycle") {
|
|
1219
|
+
architectCycle = true;
|
|
1220
|
+
}
|
|
1221
|
+
else if (arg === "--draft-cycle") {
|
|
1222
|
+
draftCycle = true;
|
|
1223
|
+
}
|
|
852
1224
|
else {
|
|
853
1225
|
return { ok: false, error: `Unknown mcp argument: ${arg}` };
|
|
854
1226
|
}
|
|
@@ -856,7 +1228,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
856
1228
|
if (!worktreePath?.trim() || !workItemId?.trim()) {
|
|
857
1229
|
return { ok: false, error: "mcp requires --worktree and --work-item-id." };
|
|
858
1230
|
}
|
|
859
|
-
if ([developCycle, qaCycle, debugCycle].filter(Boolean).length > 1) {
|
|
1231
|
+
if ([developCycle, qaCycle, debugCycle, architectCycle, draftCycle].filter(Boolean).length > 1) {
|
|
860
1232
|
return { ok: false, error: "mcp accepts only one cycle mode." };
|
|
861
1233
|
}
|
|
862
1234
|
if (developCycle && !isDevelopCompletionPhase(phase)) {
|
|
@@ -868,6 +1240,12 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
868
1240
|
if (debugCycle && !isDebugCompletionPhase(phase)) {
|
|
869
1241
|
return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
|
|
870
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
|
+
}
|
|
871
1249
|
return {
|
|
872
1250
|
ok: true,
|
|
873
1251
|
config: {
|
|
@@ -880,7 +1258,9 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
880
1258
|
redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
|
|
881
1259
|
developCycle,
|
|
882
1260
|
qaCycle,
|
|
883
|
-
debugCycle
|
|
1261
|
+
debugCycle,
|
|
1262
|
+
architectCycle,
|
|
1263
|
+
draftCycle
|
|
884
1264
|
}
|
|
885
1265
|
};
|
|
886
1266
|
}
|
|
@@ -1018,6 +1398,24 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
|
1018
1398
|
}
|
|
1019
1399
|
return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
|
|
1020
1400
|
}
|
|
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
|
+
}
|
|
1021
1419
|
function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
|
|
1022
1420
|
if (!nextGate) {
|
|
1023
1421
|
if (status === "blocked") {
|
|
@@ -1088,6 +1486,9 @@ function toolDescription(phase, qaGate = null) {
|
|
|
1088
1486
|
if (phase === "qa") {
|
|
1089
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.";
|
|
1090
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
|
+
}
|
|
1091
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.";
|
|
1092
1493
|
}
|
|
1093
1494
|
function toolInputSchema(phase, qaGate = null) {
|
|
@@ -1137,7 +1538,7 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1137
1538
|
},
|
|
1138
1539
|
tests_added_or_updated: {
|
|
1139
1540
|
type: "array",
|
|
1140
|
-
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.",
|
|
1141
1542
|
items: evidenceObjectSchema(["path", "summary"], {
|
|
1142
1543
|
path: { type: "string" },
|
|
1143
1544
|
summary: { type: "string" }
|
|
@@ -1168,6 +1569,11 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1168
1569
|
},
|
|
1169
1570
|
next_phase_context: {
|
|
1170
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()
|
|
1171
1577
|
}
|
|
1172
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.");
|
|
1173
1579
|
}
|
|
@@ -1303,6 +1709,83 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1303
1709
|
}
|
|
1304
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.");
|
|
1305
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
|
+
}
|
|
1306
1789
|
return objectToolSchema({
|
|
1307
1790
|
...baseProperties,
|
|
1308
1791
|
commands: {
|
|
@@ -1382,8 +1865,8 @@ function debugToolInputSchema(phase, baseProperties) {
|
|
|
1382
1865
|
},
|
|
1383
1866
|
resolved_findings: {
|
|
1384
1867
|
type: "array",
|
|
1385
|
-
description: "Required for QA rework.
|
|
1386
|
-
items:
|
|
1868
|
+
description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
|
|
1869
|
+
items: resolvedFindingSchema()
|
|
1387
1870
|
}
|
|
1388
1871
|
}, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
|
|
1389
1872
|
}
|
|
@@ -1490,6 +1973,29 @@ function commandEvidenceSchema() {
|
|
|
1490
1973
|
summary: { type: "string" }
|
|
1491
1974
|
});
|
|
1492
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
|
+
}
|
|
1493
1999
|
function acceptanceCriteriaSchema() {
|
|
1494
2000
|
return {
|
|
1495
2001
|
type: "array",
|
|
@@ -1524,6 +2030,13 @@ function normalizeCompletionStatus(value, phase) {
|
|
|
1524
2030
|
}
|
|
1525
2031
|
return canonical;
|
|
1526
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
|
+
}
|
|
1527
2040
|
const canonical = normalized === "blocked" || normalized === "needsrework"
|
|
1528
2041
|
? "needs_rework"
|
|
1529
2042
|
: normalized === "failed"
|
|
@@ -1551,6 +2064,10 @@ function validatePayloadShape(phase, payload) {
|
|
|
1551
2064
|
validateQaPayloadShape(payload, status);
|
|
1552
2065
|
return;
|
|
1553
2066
|
}
|
|
2067
|
+
if (phase === "architect") {
|
|
2068
|
+
validateArchitectPayloadShape(payload, status);
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
1554
2071
|
const pass = status === "pass";
|
|
1555
2072
|
if (!pass) {
|
|
1556
2073
|
requireNonPassEvidence(payload);
|
|
@@ -1570,6 +2087,7 @@ function validatePayloadShape(phase, payload) {
|
|
|
1570
2087
|
requireField(payload, "next_phase_context");
|
|
1571
2088
|
requireImplementationEvidence(payload);
|
|
1572
2089
|
requireSatisfiedAcceptanceCriteria(payload);
|
|
2090
|
+
validateResolvedFindings(payload);
|
|
1573
2091
|
return;
|
|
1574
2092
|
}
|
|
1575
2093
|
if (phase === "self_review") {
|
|
@@ -1623,6 +2141,7 @@ function validateDebugPayloadShape(phase, payload) {
|
|
|
1623
2141
|
if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
|
|
1624
2142
|
throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
|
|
1625
2143
|
}
|
|
2144
|
+
validateResolvedFindings(payload);
|
|
1626
2145
|
return;
|
|
1627
2146
|
}
|
|
1628
2147
|
requireArray(payload, "commands");
|
|
@@ -1668,6 +2187,7 @@ function validateQaGatePayloadShape(gate, payload, status) {
|
|
|
1668
2187
|
if (commands.length === 0) {
|
|
1669
2188
|
throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
|
|
1670
2189
|
}
|
|
2190
|
+
validateQaGatePassingCommands(commands);
|
|
1671
2191
|
}
|
|
1672
2192
|
if (gate === "qa_manual_web" && status === "pass") {
|
|
1673
2193
|
const browserChecks = qaArray(payload, "browser_checks");
|
|
@@ -1677,6 +2197,297 @@ function validateQaGatePayloadShape(gate, payload, status) {
|
|
|
1677
2197
|
}
|
|
1678
2198
|
}
|
|
1679
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
|
+
}
|
|
1680
2491
|
function validateQaPayloadShape(payload, status) {
|
|
1681
2492
|
const evidenceSource = qaEvidenceSource(payload);
|
|
1682
2493
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
@@ -1881,6 +2692,12 @@ function parseStrictCompletionPhase(value) {
|
|
|
1881
2692
|
if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
|
|
1882
2693
|
return "qa";
|
|
1883
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
|
+
}
|
|
1884
2701
|
return null;
|
|
1885
2702
|
}
|
|
1886
2703
|
function normalizeDebugCompletionPhase(value) {
|
|
@@ -1949,6 +2766,12 @@ function phaseFromToolName(toolName) {
|
|
|
1949
2766
|
if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
|
|
1950
2767
|
return "qa";
|
|
1951
2768
|
}
|
|
2769
|
+
if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
|
|
2770
|
+
return "architect";
|
|
2771
|
+
}
|
|
2772
|
+
if (draftGateFromToolName(toolName)) {
|
|
2773
|
+
return "draft";
|
|
2774
|
+
}
|
|
1952
2775
|
const debugGate = debugGateFromToolName(toolName);
|
|
1953
2776
|
if (debugGate) {
|
|
1954
2777
|
return debugGate;
|
|
@@ -1958,6 +2781,16 @@ function phaseFromToolName(toolName) {
|
|
|
1958
2781
|
}
|
|
1959
2782
|
throw new Error(`Unknown Forge completion tool: ${toolName}`);
|
|
1960
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
|
+
}
|
|
1961
2794
|
function debugGateFromToolName(toolName) {
|
|
1962
2795
|
const normalized = toolName.trim().toLowerCase();
|
|
1963
2796
|
for (const gate of debugCompletionPhaseOrder) {
|
|
@@ -2067,11 +2900,20 @@ function requireObject(payload, key) {
|
|
|
2067
2900
|
throw new Error(`${key} must be an object.`);
|
|
2068
2901
|
}
|
|
2069
2902
|
}
|
|
2903
|
+
function requiredRecord(payload, key) {
|
|
2904
|
+
requireObject(payload, key);
|
|
2905
|
+
return payload[key];
|
|
2906
|
+
}
|
|
2070
2907
|
function requireBoolean(payload, key) {
|
|
2071
2908
|
if (typeof payload[key] !== "boolean") {
|
|
2072
2909
|
throw new Error(`${key} must be a boolean.`);
|
|
2073
2910
|
}
|
|
2074
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
|
+
}
|
|
2075
2917
|
function requireSatisfiedAcceptanceCriteria(payload) {
|
|
2076
2918
|
const criteria = payload.acceptance_criteria;
|
|
2077
2919
|
if (!Array.isArray(criteria)) {
|
|
@@ -2089,6 +2931,37 @@ function requireSatisfiedAcceptanceCriteria(payload) {
|
|
|
2089
2931
|
}
|
|
2090
2932
|
}
|
|
2091
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
|
+
}
|
|
2092
2965
|
function qaEvidenceSource(payload) {
|
|
2093
2966
|
const evidence = payload.evidence;
|
|
2094
2967
|
return evidence && typeof evidence === "object" && !Array.isArray(evidence)
|
|
@@ -2138,6 +3011,11 @@ function requireNonEmptyStringRecord(source, key, prefix = "") {
|
|
|
2138
3011
|
throw new Error(`${prefix}${key} is required.`);
|
|
2139
3012
|
}
|
|
2140
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
|
+
}
|
|
2141
3019
|
function requireKnownStringRecord(source, key, allowed, prefix = "") {
|
|
2142
3020
|
const value = normalizedString(optionalString(source, key));
|
|
2143
3021
|
if (!allowed.has(value)) {
|
|
@@ -2155,6 +3033,11 @@ function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
|
|
|
2155
3033
|
}
|
|
2156
3034
|
}
|
|
2157
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
|
+
}
|
|
2158
3041
|
function requireSubstantiveEvidenceRecord(source, prefix = "") {
|
|
2159
3042
|
const evidence = source.evidence;
|
|
2160
3043
|
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|