@loucompanion/forge-bridge 0.1.1-dev.eee62899e922 → 0.1.3-dev.04a84914da09
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/bridge.service.js +7 -4
- package/dist/cli/local-config.js +14 -1
- package/dist/session/phase-completion.d.ts +23 -1
- package/dist/session/phase-completion.js +693 -22
- package/dist/session/provider-cli/provider-cli.service.d.ts +8 -0
- package/dist/session/provider-cli/provider-cli.service.js +496 -56
- package/dist/session/session.types.d.ts +4 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.js +39 -0
- package/package.json +1 -1
|
@@ -22,6 +22,60 @@ 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
|
+
export const decomposeCompletionGateOrder = [
|
|
35
|
+
"decompose_subject_intake",
|
|
36
|
+
"decompose_context_review",
|
|
37
|
+
"decompose_breakdown_strategy",
|
|
38
|
+
"decompose_child_items",
|
|
39
|
+
"decompose_dependency_graph",
|
|
40
|
+
"decompose_critic",
|
|
41
|
+
"decompose_proposal"
|
|
42
|
+
];
|
|
43
|
+
const decomposeGateFileNames = {
|
|
44
|
+
decompose_subject_intake: "decompose.subject-intake.json",
|
|
45
|
+
decompose_context_review: "decompose.context-review.json",
|
|
46
|
+
decompose_breakdown_strategy: "decompose.breakdown-strategy.json",
|
|
47
|
+
decompose_child_items: "decompose.child-items.json",
|
|
48
|
+
decompose_dependency_graph: "decompose.dependency-graph.json",
|
|
49
|
+
decompose_critic: "decompose.critic.json",
|
|
50
|
+
decompose_proposal: "decompose.proposal.json"
|
|
51
|
+
};
|
|
52
|
+
const decomposeGateToolNames = {
|
|
53
|
+
decompose_subject_intake: "complete_decompose_subject_intake",
|
|
54
|
+
decompose_context_review: "complete_decompose_context_review",
|
|
55
|
+
decompose_breakdown_strategy: "complete_decompose_breakdown_strategy",
|
|
56
|
+
decompose_child_items: "complete_decompose_child_items",
|
|
57
|
+
decompose_dependency_graph: "complete_decompose_dependency_graph",
|
|
58
|
+
decompose_critic: "complete_decompose_critic",
|
|
59
|
+
decompose_proposal: "complete_decompose_proposal"
|
|
60
|
+
};
|
|
61
|
+
const draftGateFileNames = {
|
|
62
|
+
draft_intent: "draft.intent.json",
|
|
63
|
+
draft_repo_context: "draft.repo-context.json",
|
|
64
|
+
draft_graph_context: "draft.graph-context.json",
|
|
65
|
+
draft_product_scope: "draft.product-scope.json",
|
|
66
|
+
draft_work_order_plan: "draft.work-order-plan.json",
|
|
67
|
+
draft_critic: "draft.critic.json",
|
|
68
|
+
draft_proposal: "draft.proposal.json"
|
|
69
|
+
};
|
|
70
|
+
const draftGateToolNames = {
|
|
71
|
+
draft_intent: "complete_draft_intent",
|
|
72
|
+
draft_repo_context: "complete_draft_repo_context",
|
|
73
|
+
draft_graph_context: "complete_draft_graph_context",
|
|
74
|
+
draft_product_scope: "complete_draft_product_scope",
|
|
75
|
+
draft_work_order_plan: "complete_draft_work_order_plan",
|
|
76
|
+
draft_critic: "complete_draft_critic",
|
|
77
|
+
draft_proposal: "complete_draft_proposal"
|
|
78
|
+
};
|
|
25
79
|
const qaGateFileNames = {
|
|
26
80
|
qa_plan: "qa.plan.json",
|
|
27
81
|
qa_regression: "qa.regression.json",
|
|
@@ -95,6 +149,14 @@ const phaseFiles = {
|
|
|
95
149
|
architect: {
|
|
96
150
|
jsonFileName: "architect.verdict.json",
|
|
97
151
|
reportFileName: "architect.report.md"
|
|
152
|
+
},
|
|
153
|
+
draft: {
|
|
154
|
+
jsonFileName: "draft.proposal.json",
|
|
155
|
+
reportFileName: "draft.report.md"
|
|
156
|
+
},
|
|
157
|
+
decompose: {
|
|
158
|
+
jsonFileName: "decompose.proposal.json",
|
|
159
|
+
reportFileName: "decompose.report.md"
|
|
98
160
|
}
|
|
99
161
|
};
|
|
100
162
|
export function completionPhaseFromDispatch(dispatch) {
|
|
@@ -118,6 +180,12 @@ export function normalizeCompletionPhase(value) {
|
|
|
118
180
|
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
119
181
|
return "architect";
|
|
120
182
|
}
|
|
183
|
+
if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
|
|
184
|
+
return "draft";
|
|
185
|
+
}
|
|
186
|
+
if (normalized === "decompose" || normalized === "decomposition" || normalized === "work_order_decomposition" || normalized === "feature_decomposition") {
|
|
187
|
+
return "decompose";
|
|
188
|
+
}
|
|
121
189
|
return "implementation";
|
|
122
190
|
}
|
|
123
191
|
export function usesDevelopCycleCompletion(dispatch) {
|
|
@@ -154,6 +222,12 @@ export function usesArchitectCycleCompletion(dispatch) {
|
|
|
154
222
|
return kind === "architect" || role === "architect" || lane === "architect";
|
|
155
223
|
}
|
|
156
224
|
export function completionToolNamesForPhase(phase) {
|
|
225
|
+
if (phase === "draft") {
|
|
226
|
+
return completionToolNamesForDraftGate("draft_proposal");
|
|
227
|
+
}
|
|
228
|
+
if (phase === "decompose") {
|
|
229
|
+
return completionToolNamesForDecomposeGate("decompose_proposal");
|
|
230
|
+
}
|
|
157
231
|
if (isDebugCompletionPhase(phase)) {
|
|
158
232
|
return completionToolNamesForDebugGate(phase);
|
|
159
233
|
}
|
|
@@ -176,6 +250,14 @@ export function completionToolNamesForDebugGate(gate) {
|
|
|
176
250
|
const base = debugGateToolNames[gate];
|
|
177
251
|
return [`forge.${base}`, base];
|
|
178
252
|
}
|
|
253
|
+
export function completionToolNamesForDraftGate(gate) {
|
|
254
|
+
const base = draftGateToolNames[gate];
|
|
255
|
+
return [`forge.${base}`, base];
|
|
256
|
+
}
|
|
257
|
+
export function completionToolNamesForDecomposeGate(gate) {
|
|
258
|
+
const base = decomposeGateToolNames[gate];
|
|
259
|
+
return [`forge.${base}`, base];
|
|
260
|
+
}
|
|
179
261
|
export function expectedCompletionToolName(phase) {
|
|
180
262
|
return completionToolNamesForPhase(phase)[0];
|
|
181
263
|
}
|
|
@@ -185,6 +267,12 @@ export function expectedQaCompletionToolName(gate) {
|
|
|
185
267
|
export function expectedDebugCompletionToolName(gate) {
|
|
186
268
|
return completionToolNamesForDebugGate(gate)[0];
|
|
187
269
|
}
|
|
270
|
+
export function expectedDraftCompletionToolName(gate) {
|
|
271
|
+
return completionToolNamesForDraftGate(gate)[0];
|
|
272
|
+
}
|
|
273
|
+
export function expectedDecomposeCompletionToolName(gate) {
|
|
274
|
+
return completionToolNamesForDecomposeGate(gate)[0];
|
|
275
|
+
}
|
|
188
276
|
export function artifactRootFor(worktreePath, workItemId) {
|
|
189
277
|
return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
|
|
190
278
|
}
|
|
@@ -219,7 +307,12 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
219
307
|
developCycle: usesDevelopCycleCompletion(options.dispatch),
|
|
220
308
|
qaCycle: usesQaCycleCompletion(options.dispatch),
|
|
221
309
|
debugCycle: usesDebugCycleCompletion(options.dispatch),
|
|
222
|
-
architectCycle: usesArchitectCycleCompletion(options.dispatch)
|
|
310
|
+
architectCycle: usesArchitectCycleCompletion(options.dispatch),
|
|
311
|
+
draftCycle: (phase === "draft"
|
|
312
|
+
|| options.dispatch.execution.lane.trim().toLowerCase() === "draft"
|
|
313
|
+
|| options.dispatch.cycleRun.kind.trim().toLowerCase() === "draft")
|
|
314
|
+
&& !isDecomposeDispatch(options.dispatch),
|
|
315
|
+
decomposeCycle: isDecomposeDispatch(options.dispatch)
|
|
223
316
|
});
|
|
224
317
|
const runtime = {
|
|
225
318
|
serverName: toolServerName,
|
|
@@ -261,6 +354,12 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
261
354
|
if (config.architectCycle) {
|
|
262
355
|
args.push("--architect-cycle");
|
|
263
356
|
}
|
|
357
|
+
if (config.draftCycle) {
|
|
358
|
+
args.push("--draft-cycle");
|
|
359
|
+
}
|
|
360
|
+
if (config.decomposeCycle) {
|
|
361
|
+
args.push("--decompose-cycle");
|
|
362
|
+
}
|
|
264
363
|
return args;
|
|
265
364
|
}
|
|
266
365
|
export function codexMcpConfigArgs(runtime) {
|
|
@@ -300,6 +399,7 @@ Do not write Architect artifacts by hand. When the plan is complete, call \`${to
|
|
|
300
399
|
That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
|
|
301
400
|
The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
|
|
302
401
|
\`{ "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" }\`.
|
|
402
|
+
\`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.
|
|
303
403
|
Dependency entries use canonical \`before\` and \`after\` task ids, for example \`{ "before": "task-1", "after": "task-2" }\`. Forge accepts \`from\`/\`to\` only as aliases and normalizes them before writing artifacts.
|
|
304
404
|
Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
|
|
305
405
|
Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
|
|
@@ -334,6 +434,35 @@ Use \`status: "skipped"\` with a reason for gates that are not applicable. If a
|
|
|
334
434
|
The final verdict tool writes the canonical QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`qa.verdict.json\`, and \`qa.report.md\`.
|
|
335
435
|
The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
|
|
336
436
|
}
|
|
437
|
+
export function completionPromptForDraftCycle(currentGate = "draft_intent") {
|
|
438
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
439
|
+
Do not finish Draft by prose or raw JSON. The tool server enforces Draft gate order and starts at \`${currentGate}\`.
|
|
440
|
+
Complete the Draft cycle in order inside this same provider session:
|
|
441
|
+
1. Call \`forge.complete_draft_intent(...)\` after interpreting user intent and non-goals.
|
|
442
|
+
2. Follow the returned repo-context instruction, inspect selected project/repo assumptions, then call \`forge.complete_draft_repo_context(...)\`.
|
|
443
|
+
3. Follow the returned graph-context instruction, account for DAG/dependency placement, then call \`forge.complete_draft_graph_context(...)\`.
|
|
444
|
+
4. Follow the returned product-scope instruction, choose product scope for the selected project, then call \`forge.complete_draft_product_scope(...)\`.
|
|
445
|
+
5. Follow the returned work-order-plan instruction, draft the requested final JSON shape and any candidate boundaries, then call \`forge.complete_draft_work_order_plan(...)\`.
|
|
446
|
+
6. Follow the returned critic instruction, critique literalism, wrong-project scope, and decomposition quality, then call \`forge.complete_draft_critic(...)\`.
|
|
447
|
+
7. Follow the returned proposal instruction, then call \`forge.complete_draft_proposal(...)\` with the final Draft JSON payload in \`draft_json\`.
|
|
448
|
+
The final proposal tool writes \`draft.proposal.json\` under \`.forge/work-items/{workItemId}/artifacts/\`. Reporting Draft completion in prose is not enough.`;
|
|
449
|
+
}
|
|
450
|
+
export function completionPromptForDecomposeCycle(currentGate = "decompose_subject_intake") {
|
|
451
|
+
return `# Forge Decompose completion cycle
|
|
452
|
+
You must complete the Decompose gates in order using the Forge MCP tools. Reporting progress in prose is not enough.
|
|
453
|
+
|
|
454
|
+
Current gate: ${currentGate}
|
|
455
|
+
|
|
456
|
+
Required order:
|
|
457
|
+
1. Call \`forge.complete_decompose_subject_intake(...)\` after identifying the Work Order/Feature subject, scope, and non-goals.
|
|
458
|
+
2. Follow the returned context-review instruction, inspect injected project/repo/dependency context, then call \`forge.complete_decompose_context_review(...)\`.
|
|
459
|
+
3. Follow the returned breakdown-strategy instruction, choose safe Feature/Story boundaries, then call \`forge.complete_decompose_breakdown_strategy(...)\`.
|
|
460
|
+
4. Follow the returned child-items instruction, draft candidate child items, then call \`forge.complete_decompose_child_items(...)\`.
|
|
461
|
+
5. Follow the returned dependency-graph instruction, validate keys/dependencies/order, then call \`forge.complete_decompose_dependency_graph(...)\`.
|
|
462
|
+
6. Follow the returned critic instruction, critique scope, DAG safety, manual reconciliation risk, and missing acceptance detail, then call \`forge.complete_decompose_critic(...)\`.
|
|
463
|
+
7. Follow the returned proposal instruction, then call \`forge.complete_decompose_proposal(...)\` with final JSON in \`proposal_json\`.
|
|
464
|
+
The final proposal tool writes \`decompose.proposal.json\` under \`.forge/work-items/{workItemId}/artifacts/\`. The proposal_json object must contain summary, optional blockedReason, and candidates.`;
|
|
465
|
+
}
|
|
337
466
|
export function completionPromptForDebugCycle(currentGate = "debug_repro") {
|
|
338
467
|
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
339
468
|
Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
|
|
@@ -346,33 +475,43 @@ Each tool writes a canonical Debug artifact under \`.forge/work-items/{workItemI
|
|
|
346
475
|
Use a non-pass status with explicit blockers when the current Debug gate cannot pass. Reporting Debug completion in prose is not enough; the relevant MCP completion tool is the transition request.`;
|
|
347
476
|
}
|
|
348
477
|
export function completionToolNamesForServer(config) {
|
|
349
|
-
return config.
|
|
350
|
-
?
|
|
351
|
-
: config.
|
|
352
|
-
?
|
|
353
|
-
: config.
|
|
354
|
-
?
|
|
355
|
-
: config.
|
|
356
|
-
?
|
|
357
|
-
:
|
|
478
|
+
return config.decomposeCycle
|
|
479
|
+
? decomposeCompletionGateOrder.flatMap((gate) => completionToolNamesForDecomposeGate(gate))
|
|
480
|
+
: config.draftCycle
|
|
481
|
+
? draftCompletionGateOrder.flatMap((gate) => completionToolNamesForDraftGate(gate))
|
|
482
|
+
: config.debugCycle
|
|
483
|
+
? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
|
|
484
|
+
: config.qaCycle
|
|
485
|
+
? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
|
|
486
|
+
: config.developCycle
|
|
487
|
+
? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
|
|
488
|
+
: config.architectCycle
|
|
489
|
+
? completionToolNamesForPhase("architect")
|
|
490
|
+
: completionToolNamesForPhase(config.phase);
|
|
358
491
|
}
|
|
359
492
|
export class CompletionToolServerSession {
|
|
360
493
|
config;
|
|
361
494
|
currentPhase;
|
|
362
495
|
currentQaGate = "qa_plan";
|
|
496
|
+
currentDraftGate = "draft_intent";
|
|
497
|
+
currentDecomposeGate = "decompose_subject_intake";
|
|
363
498
|
completed = false;
|
|
364
499
|
constructor(config) {
|
|
365
500
|
this.config = config;
|
|
366
501
|
this.currentPhase = config.phase;
|
|
367
502
|
}
|
|
368
503
|
get instructions() {
|
|
369
|
-
return this.config.
|
|
370
|
-
?
|
|
371
|
-
: this.config.
|
|
372
|
-
?
|
|
373
|
-
: this.config.
|
|
374
|
-
?
|
|
375
|
-
:
|
|
504
|
+
return this.config.decomposeCycle
|
|
505
|
+
? completionPromptForDecomposeCycle(this.currentDecomposeGate)
|
|
506
|
+
: this.config.draftCycle
|
|
507
|
+
? completionPromptForDraftCycle(this.currentDraftGate)
|
|
508
|
+
: this.config.debugCycle
|
|
509
|
+
? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
|
|
510
|
+
: this.config.qaCycle
|
|
511
|
+
? completionPromptForQaCycle(this.currentQaGate)
|
|
512
|
+
: this.config.developCycle
|
|
513
|
+
? completionPromptForDevelopCycle(this.currentPhase)
|
|
514
|
+
: completionPromptForPhase(this.currentPhase);
|
|
376
515
|
}
|
|
377
516
|
toolDefinitions() {
|
|
378
517
|
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
|
|
@@ -382,6 +521,12 @@ export class CompletionToolServerSession {
|
|
|
382
521
|
if (this.completed) {
|
|
383
522
|
throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
|
|
384
523
|
}
|
|
524
|
+
if (this.config.decomposeCycle) {
|
|
525
|
+
return await this.callDecomposeGateTool(toolName, requestedPhase, input);
|
|
526
|
+
}
|
|
527
|
+
if (this.config.draftCycle) {
|
|
528
|
+
return await this.callDraftGateTool(toolName, requestedPhase, input);
|
|
529
|
+
}
|
|
385
530
|
if (this.config.debugCycle) {
|
|
386
531
|
return await this.callDebugGateTool(toolName, requestedPhase, input);
|
|
387
532
|
}
|
|
@@ -422,6 +567,62 @@ export class CompletionToolServerSession {
|
|
|
422
567
|
}
|
|
423
568
|
};
|
|
424
569
|
}
|
|
570
|
+
async callDecomposeGateTool(toolName, requestedPhase, input) {
|
|
571
|
+
const requestedGate = decomposeGateFromToolName(toolName);
|
|
572
|
+
if (requestedPhase !== "decompose" || !requestedGate) {
|
|
573
|
+
throw new Error(`Tool ${toolName} is not available in Decompose-cycle mode. Current Forge Decompose gate is ${this.currentDecomposeGate}.`);
|
|
574
|
+
}
|
|
575
|
+
if (requestedGate !== this.currentDecomposeGate) {
|
|
576
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge Decompose gate is ${this.currentDecomposeGate}.`);
|
|
577
|
+
}
|
|
578
|
+
const completedGate = this.currentDecomposeGate;
|
|
579
|
+
const reference = await writeDecomposeGateCompletionArtifact(this.config, completedGate, toolName, input);
|
|
580
|
+
const status = reference.status.trim().toLowerCase();
|
|
581
|
+
const pass = status === "pass";
|
|
582
|
+
const nextGate = pass ? this.advanceAfterDecomposeGate(completedGate) : completedGate;
|
|
583
|
+
const nextPhase = pass
|
|
584
|
+
? (nextGate ? "decompose" : "sign_off_ready")
|
|
585
|
+
: "decompose";
|
|
586
|
+
const nextInstruction = completionToolNextInstructionForDecomposeGate(completedGate, status, nextGate);
|
|
587
|
+
const reason = pass
|
|
588
|
+
? nextGate
|
|
589
|
+
? `${completedGate} artifact accepted; continue to ${nextGate}.`
|
|
590
|
+
: `Decompose proposal artifact accepted with status ${status}.`
|
|
591
|
+
: `${completedGate} artifact recorded with status ${status}; rework is required before continuing.`;
|
|
592
|
+
return {
|
|
593
|
+
content: [{ type: "text", text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}` }],
|
|
594
|
+
structuredContent: {
|
|
595
|
+
...reference,
|
|
596
|
+
gate: { phase: "decompose", decomposeGate: completedGate, status, allowed: pass, reason },
|
|
597
|
+
nextPhase,
|
|
598
|
+
nextInstruction
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
async callDraftGateTool(toolName, requestedPhase, input) {
|
|
603
|
+
const requestedGate = draftGateFromToolName(toolName);
|
|
604
|
+
if (requestedPhase !== "draft" || !requestedGate) {
|
|
605
|
+
throw new Error(`Tool ${toolName} is not available in Draft-cycle mode. Current Forge Draft gate is ${this.currentDraftGate}.`);
|
|
606
|
+
}
|
|
607
|
+
if (requestedGate !== this.currentDraftGate) {
|
|
608
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge Draft gate is ${this.currentDraftGate}.`);
|
|
609
|
+
}
|
|
610
|
+
const completedGate = this.currentDraftGate;
|
|
611
|
+
const reference = await writeDraftGateCompletionArtifact(this.config, completedGate, toolName, input);
|
|
612
|
+
const status = reference.status.trim().toLowerCase();
|
|
613
|
+
const nextGate = this.advanceAfterDraftGate(completedGate);
|
|
614
|
+
const nextPhase = nextGate ? "draft" : "sign_off_ready";
|
|
615
|
+
const nextInstruction = completionToolNextInstructionForDraftGate(completedGate, status, nextGate);
|
|
616
|
+
return {
|
|
617
|
+
content: [{ type: "text", text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}` }],
|
|
618
|
+
structuredContent: {
|
|
619
|
+
...reference,
|
|
620
|
+
gate: { phase: "draft", draftGate: completedGate, status, allowed: status !== "blocked", reason: nextGate ? `${completedGate} artifact accepted; continue to ${nextGate}.` : `Draft proposal artifact accepted with status ${status}.` },
|
|
621
|
+
nextPhase,
|
|
622
|
+
nextInstruction
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
}
|
|
425
626
|
async callDebugGateTool(toolName, requestedPhase, input) {
|
|
426
627
|
const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
|
|
427
628
|
if (!isDebugCompletionPhase(requestedPhase)) {
|
|
@@ -518,6 +719,26 @@ export class CompletionToolServerSession {
|
|
|
518
719
|
this.completed = true;
|
|
519
720
|
return "dev_test_ready";
|
|
520
721
|
}
|
|
722
|
+
advanceAfterDecomposeGate(gate) {
|
|
723
|
+
const index = decomposeCompletionGateOrder.indexOf(gate);
|
|
724
|
+
const next = index >= 0 ? decomposeCompletionGateOrder[index + 1] ?? null : null;
|
|
725
|
+
if (next) {
|
|
726
|
+
this.currentDecomposeGate = next;
|
|
727
|
+
return next;
|
|
728
|
+
}
|
|
729
|
+
this.completed = true;
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
advanceAfterDraftGate(gate) {
|
|
733
|
+
const index = draftCompletionGateOrder.indexOf(gate);
|
|
734
|
+
const next = index >= 0 ? draftCompletionGateOrder[index + 1] ?? null : null;
|
|
735
|
+
if (next) {
|
|
736
|
+
this.currentDraftGate = next;
|
|
737
|
+
return next;
|
|
738
|
+
}
|
|
739
|
+
this.completed = true;
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
521
742
|
advanceAfterQaGate(gate) {
|
|
522
743
|
const index = qaCompletionGateOrder.indexOf(gate);
|
|
523
744
|
const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
|
|
@@ -539,6 +760,89 @@ export class CompletionToolServerSession {
|
|
|
539
760
|
return null;
|
|
540
761
|
}
|
|
541
762
|
}
|
|
763
|
+
async function writeDecomposeGateCompletionArtifact(config, gate, toolName, input) {
|
|
764
|
+
const payload = asRecord(input, "Decompose completion payload");
|
|
765
|
+
const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
|
|
766
|
+
if (gate === "decompose_proposal") {
|
|
767
|
+
const proposalJson = asRecord(payload["proposal_json"] ?? payload["decomposition_json"], "proposal_json");
|
|
768
|
+
if (!Array.isArray(proposalJson["candidates"])) {
|
|
769
|
+
throw new Error("proposal_json.candidates: expected array");
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const redactedPayload = redactSecrets({ ...payload, status, phase: "decompose", gate, tool_name: toolName }, config.redactionSecrets ?? []);
|
|
773
|
+
const root = artifactRootFor(config.worktreePath, config.workItemId);
|
|
774
|
+
await fs.mkdir(root, { recursive: true });
|
|
775
|
+
const artifactPath = path.join(root, decomposeGateFileNames[gate]);
|
|
776
|
+
const reportPath = gate === "decompose_proposal" ? path.join(root, phaseFiles.decompose.reportFileName) : path.join(root, `${gate}.md`);
|
|
777
|
+
const verdictJson = jsonText(redactedPayload);
|
|
778
|
+
const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}
|
|
779
|
+
|
|
780
|
+
${optionalString(redactedPayload, "summary") ?? "Decompose gate recorded."}
|
|
781
|
+
`;
|
|
782
|
+
await writeFileAtomic(artifactPath, verdictJson);
|
|
783
|
+
await writeFileAtomic(reportPath, report);
|
|
784
|
+
return {
|
|
785
|
+
phase: "decompose",
|
|
786
|
+
workItemId: config.workItemId,
|
|
787
|
+
ownerType: config.ownerType,
|
|
788
|
+
ownerId: config.ownerId,
|
|
789
|
+
repoPath: path.resolve(config.worktreePath),
|
|
790
|
+
path: artifactPath,
|
|
791
|
+
reportPath,
|
|
792
|
+
contentHash: sha256(verdictJson),
|
|
793
|
+
status,
|
|
794
|
+
producedBy: config.producedBy ?? "forge-completion-tool"
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
async function writeDraftGateCompletionArtifact(config, gate, toolName, input) {
|
|
798
|
+
const payload = asRecord(input, "Draft completion payload");
|
|
799
|
+
const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
|
|
800
|
+
if (gate === "draft_proposal") {
|
|
801
|
+
const draftJson = asRecord(payload["draft_json"], "draft_json");
|
|
802
|
+
validateDraftProposalJson(draftJson);
|
|
803
|
+
}
|
|
804
|
+
const redactedPayload = redactSecrets({ ...payload, status, phase: "draft", gate, tool_name: toolName }, config.redactionSecrets ?? []);
|
|
805
|
+
const root = artifactRootFor(config.worktreePath, config.workItemId);
|
|
806
|
+
await fs.mkdir(root, { recursive: true });
|
|
807
|
+
const artifactPath = path.join(root, draftGateFileNames[gate]);
|
|
808
|
+
const reportPath = gate === "draft_proposal" ? path.join(root, phaseFiles.draft.reportFileName) : path.join(root, `${gate}.md`);
|
|
809
|
+
const verdictJson = jsonText(redactedPayload);
|
|
810
|
+
const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}\n\n${optionalString(redactedPayload, "summary") ?? "Draft gate recorded."}\n`;
|
|
811
|
+
await writeFileAtomic(artifactPath, verdictJson);
|
|
812
|
+
await writeFileAtomic(reportPath, report);
|
|
813
|
+
return {
|
|
814
|
+
phase: "draft",
|
|
815
|
+
workItemId: config.workItemId,
|
|
816
|
+
ownerType: config.ownerType,
|
|
817
|
+
ownerId: config.ownerId,
|
|
818
|
+
repoPath: path.resolve(config.worktreePath),
|
|
819
|
+
path: artifactPath,
|
|
820
|
+
reportPath,
|
|
821
|
+
contentHash: sha256(verdictJson),
|
|
822
|
+
status,
|
|
823
|
+
producedBy: config.producedBy ?? "forge-completion-tool"
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
function normalizeDraftStatus(value) {
|
|
827
|
+
const normalized = value.trim().toLowerCase();
|
|
828
|
+
return normalized === "blocked" || normalized === "needs_revision" || normalized === "needs_rework" ? normalized : "pass";
|
|
829
|
+
}
|
|
830
|
+
function validateDraftProposalJson(draftJson) {
|
|
831
|
+
if (isDecompositionDraftJson(draftJson)) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
for (const key of ["title", "summary", "objective", "planningPacket", "decompositionContext"]) {
|
|
835
|
+
if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
|
|
836
|
+
throw new Error(`draft_json.${key}: expected non-empty string`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
function isDecompositionDraftJson(draftJson) {
|
|
841
|
+
const summary = draftJson["summary"];
|
|
842
|
+
return typeof summary === "string"
|
|
843
|
+
&& summary.trim().length > 0
|
|
844
|
+
&& Array.isArray(draftJson["candidates"]);
|
|
845
|
+
}
|
|
542
846
|
export async function writeCompletionArtifact(config, toolName, input) {
|
|
543
847
|
const phase = phaseFromToolName(toolName);
|
|
544
848
|
if (phase !== config.phase) {
|
|
@@ -550,7 +854,10 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
550
854
|
}
|
|
551
855
|
const payload = asRecord(input, "completion payload");
|
|
552
856
|
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
553
|
-
|
|
857
|
+
let validationPayload = { ...payload, status };
|
|
858
|
+
if (config.qaCycle && qaGate === "qa_verdict") {
|
|
859
|
+
validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
|
|
860
|
+
}
|
|
554
861
|
validatePayloadShape(phase, validationPayload);
|
|
555
862
|
validateOptionalWorkItemId(validationPayload, config.workItemId);
|
|
556
863
|
const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
|
|
@@ -793,6 +1100,133 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
793
1100
|
producedBy
|
|
794
1101
|
};
|
|
795
1102
|
}
|
|
1103
|
+
async function synthesizeQaCycleVerdictPayload(config, payload, status) {
|
|
1104
|
+
const qaPlan = await readQaGateArtifact(config, "qa_plan");
|
|
1105
|
+
const qaRegression = await readQaGateArtifact(config, "qa_regression");
|
|
1106
|
+
const qaApi = await readQaGateArtifact(config, "qa_api");
|
|
1107
|
+
const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
|
|
1108
|
+
const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
|
|
1109
|
+
const qaFindings = await readQaGateArtifact(config, "qa_findings");
|
|
1110
|
+
const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
|
|
1111
|
+
const payloadEvidence = qaEvidenceSource(payload);
|
|
1112
|
+
const merged = { ...payload };
|
|
1113
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
1114
|
+
if (checklist.length === 0) {
|
|
1115
|
+
const plannedChecklist = qaStringArray(qaPlan, "checklist");
|
|
1116
|
+
merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
|
|
1117
|
+
}
|
|
1118
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
1119
|
+
if (testedAcceptanceCriteria.length === 0) {
|
|
1120
|
+
const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
|
|
1121
|
+
const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
|
|
1122
|
+
const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
|
|
1123
|
+
if (acceptanceCriteria.length > 0) {
|
|
1124
|
+
merged.tested_acceptance_criteria = acceptanceCriteria;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
const payloadCommands = qaArray(payload, "commands", payloadEvidence);
|
|
1128
|
+
const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
|
|
1129
|
+
const inheritedDiagnosticCommandNotes = status === "pass"
|
|
1130
|
+
? qaNonPassingCommandNotes(inheritedCommands)
|
|
1131
|
+
: [];
|
|
1132
|
+
merged.commands = dedupeUnknownArray([
|
|
1133
|
+
...payloadCommands,
|
|
1134
|
+
...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
|
|
1135
|
+
]);
|
|
1136
|
+
merged.browser_checks = dedupeUnknownArray([
|
|
1137
|
+
...qaArray(payload, "browser_checks", payloadEvidence),
|
|
1138
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
|
|
1139
|
+
]);
|
|
1140
|
+
merged.sidecars = dedupeUnknownArray([
|
|
1141
|
+
...qaArray(payload, "sidecars", payloadEvidence),
|
|
1142
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
|
|
1143
|
+
]);
|
|
1144
|
+
const notes = dedupeStrings([
|
|
1145
|
+
optionalString(payload, "notes"),
|
|
1146
|
+
optionalString(payloadEvidence, "notes"),
|
|
1147
|
+
...evidenceGates.map((gate) => optionalString(gate, "notes")),
|
|
1148
|
+
...inheritedDiagnosticCommandNotes
|
|
1149
|
+
]);
|
|
1150
|
+
if (notes.length > 0) {
|
|
1151
|
+
merged.notes = notes.join("\n");
|
|
1152
|
+
}
|
|
1153
|
+
merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
|
|
1154
|
+
merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
|
|
1155
|
+
return merged;
|
|
1156
|
+
}
|
|
1157
|
+
async function readQaGateArtifact(config, gate) {
|
|
1158
|
+
const ownerFields = artifactOwnerFields(config);
|
|
1159
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
1160
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
1161
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
1162
|
+
const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
|
|
1163
|
+
const content = await readOptionalFile(artifactPath);
|
|
1164
|
+
return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
|
|
1165
|
+
}
|
|
1166
|
+
function dedupeUnknownArray(values) {
|
|
1167
|
+
const seen = new Set();
|
|
1168
|
+
const deduped = [];
|
|
1169
|
+
for (const value of values) {
|
|
1170
|
+
const key = stableStringify(value);
|
|
1171
|
+
if (seen.has(key)) {
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
seen.add(key);
|
|
1175
|
+
deduped.push(value);
|
|
1176
|
+
}
|
|
1177
|
+
return deduped;
|
|
1178
|
+
}
|
|
1179
|
+
function isQaPassingCommandEvidence(value) {
|
|
1180
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1181
|
+
return false;
|
|
1182
|
+
}
|
|
1183
|
+
const record = value;
|
|
1184
|
+
return record.exit_code === 0 && normalizedString(record.status) === "pass";
|
|
1185
|
+
}
|
|
1186
|
+
function qaNonPassingCommandNotes(values) {
|
|
1187
|
+
return values
|
|
1188
|
+
.filter((value) => !isQaPassingCommandEvidence(value))
|
|
1189
|
+
.map((value, index) => {
|
|
1190
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1191
|
+
return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
|
|
1192
|
+
}
|
|
1193
|
+
const record = value;
|
|
1194
|
+
const command = optionalString(record, "command") ?? "<missing command>";
|
|
1195
|
+
const status = optionalString(record, "status") ?? "<missing status>";
|
|
1196
|
+
const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
|
|
1197
|
+
? String(record.exit_code)
|
|
1198
|
+
: "<missing exit_code>";
|
|
1199
|
+
const summary = optionalString(record, "summary");
|
|
1200
|
+
return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
function mergeQaObjectArraysById(primary, fallback) {
|
|
1204
|
+
const seen = new Set();
|
|
1205
|
+
const merged = [];
|
|
1206
|
+
for (const value of [...primary, ...fallback]) {
|
|
1207
|
+
const id = optionalString(value, "id");
|
|
1208
|
+
const key = id ? `id:${id}` : stableStringify(value);
|
|
1209
|
+
if (seen.has(key)) {
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
seen.add(key);
|
|
1213
|
+
merged.push(value);
|
|
1214
|
+
}
|
|
1215
|
+
return merged;
|
|
1216
|
+
}
|
|
1217
|
+
function dedupeStrings(values) {
|
|
1218
|
+
const seen = new Set();
|
|
1219
|
+
const deduped = [];
|
|
1220
|
+
for (const value of values) {
|
|
1221
|
+
const trimmed = value?.trim();
|
|
1222
|
+
if (!trimmed || seen.has(trimmed)) {
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
seen.add(trimmed);
|
|
1226
|
+
deduped.push(trimmed);
|
|
1227
|
+
}
|
|
1228
|
+
return deduped;
|
|
1229
|
+
}
|
|
796
1230
|
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
797
1231
|
if (sidecars.length === 0) {
|
|
798
1232
|
return [];
|
|
@@ -855,10 +1289,19 @@ export async function readCompletionArtifact(worktreePath, workItemId, phase, ow
|
|
|
855
1289
|
}
|
|
856
1290
|
const parsed = parseJsonObject(verdictJson, artifactPath);
|
|
857
1291
|
const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
|
|
1292
|
+
const artifactRoot = path.dirname(artifactPath);
|
|
1293
|
+
const implementationPlanJson = phase === "architect"
|
|
1294
|
+
? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
|
|
1295
|
+
: null;
|
|
1296
|
+
const qaPlanJson = phase === "architect"
|
|
1297
|
+
? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
|
|
1298
|
+
: null;
|
|
858
1299
|
return {
|
|
859
1300
|
repoPath: path.resolve(worktreePath),
|
|
860
1301
|
path: artifactPath,
|
|
861
1302
|
verdictJson,
|
|
1303
|
+
implementationPlanJson,
|
|
1304
|
+
qaPlanJson,
|
|
862
1305
|
report,
|
|
863
1306
|
producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
|
|
864
1307
|
status: optionalString(parsed, "status") ?? "unknown",
|
|
@@ -897,6 +1340,8 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
897
1340
|
let qaCycle = false;
|
|
898
1341
|
let debugCycle = false;
|
|
899
1342
|
let architectCycle = false;
|
|
1343
|
+
let draftCycle = false;
|
|
1344
|
+
let decomposeCycle = false;
|
|
900
1345
|
for (let index = 1; index < argv.length; index += 1) {
|
|
901
1346
|
const arg = argv[index];
|
|
902
1347
|
if (arg === "--worktree") {
|
|
@@ -918,7 +1363,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
918
1363
|
const rawPhase = argv[++index];
|
|
919
1364
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
920
1365
|
if (!parsedPhase) {
|
|
921
|
-
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, architect, debug-repro, debug-root-cause, debug-fix, debug-regression." };
|
|
1366
|
+
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." };
|
|
922
1367
|
}
|
|
923
1368
|
phase = parsedPhase;
|
|
924
1369
|
}
|
|
@@ -937,6 +1382,12 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
937
1382
|
else if (arg === "--architect-cycle") {
|
|
938
1383
|
architectCycle = true;
|
|
939
1384
|
}
|
|
1385
|
+
else if (arg === "--draft-cycle") {
|
|
1386
|
+
draftCycle = true;
|
|
1387
|
+
}
|
|
1388
|
+
else if (arg === "--decompose-cycle") {
|
|
1389
|
+
decomposeCycle = true;
|
|
1390
|
+
}
|
|
940
1391
|
else {
|
|
941
1392
|
return { ok: false, error: `Unknown mcp argument: ${arg}` };
|
|
942
1393
|
}
|
|
@@ -944,7 +1395,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
944
1395
|
if (!worktreePath?.trim() || !workItemId?.trim()) {
|
|
945
1396
|
return { ok: false, error: "mcp requires --worktree and --work-item-id." };
|
|
946
1397
|
}
|
|
947
|
-
if ([developCycle, qaCycle, debugCycle, architectCycle].filter(Boolean).length > 1) {
|
|
1398
|
+
if ([developCycle, qaCycle, debugCycle, architectCycle, draftCycle, decomposeCycle].filter(Boolean).length > 1) {
|
|
948
1399
|
return { ok: false, error: "mcp accepts only one cycle mode." };
|
|
949
1400
|
}
|
|
950
1401
|
if (developCycle && !isDevelopCompletionPhase(phase)) {
|
|
@@ -959,6 +1410,12 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
959
1410
|
if (architectCycle && phase !== "architect") {
|
|
960
1411
|
return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
|
|
961
1412
|
}
|
|
1413
|
+
if (draftCycle && phase !== "draft") {
|
|
1414
|
+
return { ok: false, error: "mcp --draft-cycle requires --phase draft." };
|
|
1415
|
+
}
|
|
1416
|
+
if (decomposeCycle && phase !== "decompose") {
|
|
1417
|
+
return { ok: false, error: "mcp --decompose-cycle requires --phase decompose." };
|
|
1418
|
+
}
|
|
962
1419
|
return {
|
|
963
1420
|
ok: true,
|
|
964
1421
|
config: {
|
|
@@ -972,7 +1429,9 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
972
1429
|
developCycle,
|
|
973
1430
|
qaCycle,
|
|
974
1431
|
debugCycle,
|
|
975
|
-
architectCycle
|
|
1432
|
+
architectCycle,
|
|
1433
|
+
draftCycle,
|
|
1434
|
+
decomposeCycle
|
|
976
1435
|
}
|
|
977
1436
|
};
|
|
978
1437
|
}
|
|
@@ -1110,6 +1569,24 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
|
1110
1569
|
}
|
|
1111
1570
|
return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
|
|
1112
1571
|
}
|
|
1572
|
+
function completionToolNextInstructionForDraftGate(completedGate, status, nextGate) {
|
|
1573
|
+
if (status === "blocked") {
|
|
1574
|
+
return `Stay in ${completedGate}; record the blocker and retry the same Draft gate when unblocked.`;
|
|
1575
|
+
}
|
|
1576
|
+
if (!nextGate) {
|
|
1577
|
+
return "Draft proposal accepted. Forge has the structured Work Order draft JSON needed to populate the form.";
|
|
1578
|
+
}
|
|
1579
|
+
const instructions = {
|
|
1580
|
+
draft_intent: "Capture intent/non-goals, then call forge.complete_draft_intent.",
|
|
1581
|
+
draft_repo_context: "Inspect selected repo/project assumptions, then call forge.complete_draft_repo_context.",
|
|
1582
|
+
draft_graph_context: "Account for DAG dependencies and placement, then call forge.complete_draft_graph_context.",
|
|
1583
|
+
draft_product_scope: "Choose product scope for the selected project, then call forge.complete_draft_product_scope.",
|
|
1584
|
+
draft_work_order_plan: "Draft Work Order fields and candidate Feature boundaries, then call forge.complete_draft_work_order_plan.",
|
|
1585
|
+
draft_critic: "Critique literalism/wrong-project scope/decomposition risk, then call forge.complete_draft_critic.",
|
|
1586
|
+
draft_proposal: "Submit final draft_json through forge.complete_draft_proposal."
|
|
1587
|
+
};
|
|
1588
|
+
return `${completedGate} accepted. Continue to ${nextGate}. ${instructions[nextGate]}`;
|
|
1589
|
+
}
|
|
1113
1590
|
function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
|
|
1114
1591
|
if (!nextGate) {
|
|
1115
1592
|
if (status === "blocked") {
|
|
@@ -1129,6 +1606,24 @@ function completionToolNextInstructionForQaGate(completedGate, status, nextGate)
|
|
|
1129
1606
|
: `${completedGate} accepted. Continue to ${nextGate}.`;
|
|
1130
1607
|
return `${prefix} ${qaGateInstruction(nextGate)}`;
|
|
1131
1608
|
}
|
|
1609
|
+
function completionToolNextInstructionForDecomposeGate(completedGate, status, nextGate) {
|
|
1610
|
+
if (status !== "pass") {
|
|
1611
|
+
return `Stay in ${completedGate}, address the recorded blockers/rework, then call ${expectedDecomposeCompletionToolName(completedGate)} again.`;
|
|
1612
|
+
}
|
|
1613
|
+
if (!nextGate) {
|
|
1614
|
+
return "Decompose proposal accepted. Forge has the structured child-item proposal JSON needed to populate the proposal row.";
|
|
1615
|
+
}
|
|
1616
|
+
const nextInstructions = {
|
|
1617
|
+
decompose_subject_intake: "Capture subject/scope, then call forge.complete_decompose_subject_intake.",
|
|
1618
|
+
decompose_context_review: "Review project/repo/dependency context, then call forge.complete_decompose_context_review.",
|
|
1619
|
+
decompose_breakdown_strategy: "Choose safe child boundaries, then call forge.complete_decompose_breakdown_strategy.",
|
|
1620
|
+
decompose_child_items: "Draft child candidates, then call forge.complete_decompose_child_items.",
|
|
1621
|
+
decompose_dependency_graph: "Validate dependency DAG, then call forge.complete_decompose_dependency_graph.",
|
|
1622
|
+
decompose_critic: "Critique the decomposition, then call forge.complete_decompose_critic.",
|
|
1623
|
+
decompose_proposal: "Submit final proposal_json through forge.complete_decompose_proposal."
|
|
1624
|
+
};
|
|
1625
|
+
return `${completedGate} accepted. ${nextInstructions[nextGate]}`;
|
|
1626
|
+
}
|
|
1132
1627
|
function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
|
|
1133
1628
|
if (status !== "pass") {
|
|
1134
1629
|
return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
|
|
@@ -1425,7 +1920,7 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1425
1920
|
},
|
|
1426
1921
|
tasks: {
|
|
1427
1922
|
type: "array",
|
|
1428
|
-
description: "Required task list. Each task must include id and a non-empty tests array.",
|
|
1923
|
+
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.",
|
|
1429
1924
|
items: { type: "object", additionalProperties: true }
|
|
1430
1925
|
},
|
|
1431
1926
|
dependencies: {
|
|
@@ -1881,6 +2376,7 @@ function validateQaGatePayloadShape(gate, payload, status) {
|
|
|
1881
2376
|
if (commands.length === 0) {
|
|
1882
2377
|
throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
|
|
1883
2378
|
}
|
|
2379
|
+
validateQaGatePassingCommands(commands);
|
|
1884
2380
|
}
|
|
1885
2381
|
if (gate === "qa_manual_web" && status === "pass") {
|
|
1886
2382
|
const browserChecks = qaArray(payload, "browser_checks");
|
|
@@ -1890,6 +2386,24 @@ function validateQaGatePayloadShape(gate, payload, status) {
|
|
|
1890
2386
|
}
|
|
1891
2387
|
}
|
|
1892
2388
|
}
|
|
2389
|
+
function validateQaGatePassingCommands(commands) {
|
|
2390
|
+
for (const [index, command] of commands.entries()) {
|
|
2391
|
+
requireEvidenceRecord(command, `commands[${index}]`);
|
|
2392
|
+
const record = command;
|
|
2393
|
+
requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
|
|
2394
|
+
requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
|
|
2395
|
+
requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
|
|
2396
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
2397
|
+
throw new Error(`commands[${index}].exit_code is required.`);
|
|
2398
|
+
}
|
|
2399
|
+
if (record.exit_code !== 0) {
|
|
2400
|
+
throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
|
|
2401
|
+
}
|
|
2402
|
+
if (normalizedString(record.status) !== "pass") {
|
|
2403
|
+
throw new Error(`commands[${index}].status must be pass when QA gate status is pass.`);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
1893
2407
|
function validateArchitectPayloadShape(payload, status) {
|
|
1894
2408
|
if (status !== "pass") {
|
|
1895
2409
|
requireArchitectNonPassEvidence(payload);
|
|
@@ -1918,6 +2432,7 @@ function validateArchitectPayloadShape(payload, status) {
|
|
|
1918
2432
|
throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
|
|
1919
2433
|
}
|
|
1920
2434
|
taskIds.add(taskId);
|
|
2435
|
+
validateArchitectTaskDevelopScope(taskRecord, taskId);
|
|
1921
2436
|
requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
|
|
1922
2437
|
if (arrayLength(taskRecord, "tests") === 0) {
|
|
1923
2438
|
throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
|
|
@@ -1930,6 +2445,124 @@ function validateArchitectPayloadShape(payload, status) {
|
|
|
1930
2445
|
throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
|
|
1931
2446
|
}
|
|
1932
2447
|
}
|
|
2448
|
+
function validateArchitectTaskDevelopScope(task, taskId) {
|
|
2449
|
+
const explicitScope = firstOptionalString(task, ["execution_scope", "scope", "task_scope", "task_type", "lane", "kind", "type"]);
|
|
2450
|
+
if (isStoryQaScope(explicitScope)) {
|
|
2451
|
+
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.`);
|
|
2452
|
+
}
|
|
2453
|
+
if (looksLikeStoryLevelVerificationTask(architectTaskScopeText(task))) {
|
|
2454
|
+
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.`);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
function firstOptionalString(record, keys) {
|
|
2458
|
+
for (const key of keys) {
|
|
2459
|
+
const value = optionalString(record, key);
|
|
2460
|
+
if (value) {
|
|
2461
|
+
return value;
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
return null;
|
|
2465
|
+
}
|
|
2466
|
+
function isStoryQaScope(value) {
|
|
2467
|
+
if (!value) {
|
|
2468
|
+
return false;
|
|
2469
|
+
}
|
|
2470
|
+
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
2471
|
+
return [
|
|
2472
|
+
"qa",
|
|
2473
|
+
"story_qa",
|
|
2474
|
+
"final_qa",
|
|
2475
|
+
"sign_off",
|
|
2476
|
+
"signoff",
|
|
2477
|
+
"story_sign_off",
|
|
2478
|
+
"story_verification",
|
|
2479
|
+
"story_validation"
|
|
2480
|
+
].includes(normalized);
|
|
2481
|
+
}
|
|
2482
|
+
function architectTaskScopeText(task) {
|
|
2483
|
+
const parts = [];
|
|
2484
|
+
for (const key of ["id", "title", "summary", "phase", "phase_id"]) {
|
|
2485
|
+
appendOptionalString(parts, task, key);
|
|
2486
|
+
}
|
|
2487
|
+
for (const key of ["acceptance_criteria", "expected_paths", "touched_surfaces", "implementation_notes"]) {
|
|
2488
|
+
appendOptionalStringArray(parts, task, key);
|
|
2489
|
+
}
|
|
2490
|
+
const tests = task.tests;
|
|
2491
|
+
if (Array.isArray(tests)) {
|
|
2492
|
+
for (const test of tests) {
|
|
2493
|
+
if (!test || typeof test !== "object" || Array.isArray(test)) {
|
|
2494
|
+
continue;
|
|
2495
|
+
}
|
|
2496
|
+
const record = test;
|
|
2497
|
+
for (const key of ["id", "command", "purpose", "target", "expected"]) {
|
|
2498
|
+
appendOptionalString(parts, record, key);
|
|
2499
|
+
}
|
|
2500
|
+
appendOptionalStringArray(parts, record, "steps");
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
return parts.join(" ");
|
|
2504
|
+
}
|
|
2505
|
+
function appendOptionalString(parts, record, key) {
|
|
2506
|
+
const value = optionalString(record, key);
|
|
2507
|
+
if (value) {
|
|
2508
|
+
parts.push(value);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
function appendOptionalStringArray(parts, record, key) {
|
|
2512
|
+
const value = record[key];
|
|
2513
|
+
if (!Array.isArray(value)) {
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
for (const entry of value) {
|
|
2517
|
+
if (typeof entry === "string" && entry.trim().length > 0) {
|
|
2518
|
+
parts.push(entry.trim());
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
function looksLikeStoryLevelVerificationTask(text) {
|
|
2523
|
+
if (!text.trim()) {
|
|
2524
|
+
return false;
|
|
2525
|
+
}
|
|
2526
|
+
const normalized = text.toLowerCase();
|
|
2527
|
+
const verificationWords = [
|
|
2528
|
+
"verify",
|
|
2529
|
+
"verification",
|
|
2530
|
+
"validate",
|
|
2531
|
+
"validation",
|
|
2532
|
+
"inspect",
|
|
2533
|
+
"audit",
|
|
2534
|
+
"check",
|
|
2535
|
+
"confirm",
|
|
2536
|
+
"prove",
|
|
2537
|
+
"evidence",
|
|
2538
|
+
"sign-off",
|
|
2539
|
+
"sign off"
|
|
2540
|
+
];
|
|
2541
|
+
if (!verificationWords.some((word) => normalized.includes(word))) {
|
|
2542
|
+
return false;
|
|
2543
|
+
}
|
|
2544
|
+
return [
|
|
2545
|
+
"materialization",
|
|
2546
|
+
"materialized child",
|
|
2547
|
+
"child inventory",
|
|
2548
|
+
"child work-item",
|
|
2549
|
+
"child work item",
|
|
2550
|
+
"created task count",
|
|
2551
|
+
"created_task_count",
|
|
2552
|
+
"parent plan",
|
|
2553
|
+
"parent story",
|
|
2554
|
+
"story sign-off",
|
|
2555
|
+
"story sign off",
|
|
2556
|
+
".forge/work-items",
|
|
2557
|
+
".forge\\work-items",
|
|
2558
|
+
".forge/stories",
|
|
2559
|
+
".forge\\stories",
|
|
2560
|
+
"implementation.plan.json",
|
|
2561
|
+
"qa.plan.json",
|
|
2562
|
+
"architect plan artifact",
|
|
2563
|
+
"child spec"
|
|
2564
|
+
].some((needle) => normalized.includes(needle));
|
|
2565
|
+
}
|
|
1933
2566
|
function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
|
|
1934
2567
|
const dependencies = implementationPlan.dependencies;
|
|
1935
2568
|
const edges = new Map();
|
|
@@ -2251,6 +2884,12 @@ function parseStrictCompletionPhase(value) {
|
|
|
2251
2884
|
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
2252
2885
|
return "architect";
|
|
2253
2886
|
}
|
|
2887
|
+
if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
|
|
2888
|
+
return "draft";
|
|
2889
|
+
}
|
|
2890
|
+
if (normalized === "decompose" || normalized === "decomposition" || normalized === "work_order_decomposition" || normalized === "feature_decomposition") {
|
|
2891
|
+
return "decompose";
|
|
2892
|
+
}
|
|
2254
2893
|
return null;
|
|
2255
2894
|
}
|
|
2256
2895
|
function normalizeDebugCompletionPhase(value) {
|
|
@@ -2322,6 +2961,12 @@ function phaseFromToolName(toolName) {
|
|
|
2322
2961
|
if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
|
|
2323
2962
|
return "architect";
|
|
2324
2963
|
}
|
|
2964
|
+
if (draftGateFromToolName(toolName)) {
|
|
2965
|
+
return "draft";
|
|
2966
|
+
}
|
|
2967
|
+
if (decomposeGateFromToolName(toolName)) {
|
|
2968
|
+
return "decompose";
|
|
2969
|
+
}
|
|
2325
2970
|
const debugGate = debugGateFromToolName(toolName);
|
|
2326
2971
|
if (debugGate) {
|
|
2327
2972
|
return debugGate;
|
|
@@ -2331,6 +2976,32 @@ function phaseFromToolName(toolName) {
|
|
|
2331
2976
|
}
|
|
2332
2977
|
throw new Error(`Unknown Forge completion tool: ${toolName}`);
|
|
2333
2978
|
}
|
|
2979
|
+
function draftGateFromToolName(toolName) {
|
|
2980
|
+
const normalized = toolName.trim().toLowerCase();
|
|
2981
|
+
for (const gate of draftCompletionGateOrder) {
|
|
2982
|
+
const base = draftGateToolNames[gate];
|
|
2983
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
2984
|
+
return gate;
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
return null;
|
|
2988
|
+
}
|
|
2989
|
+
function decomposeGateFromToolName(toolName) {
|
|
2990
|
+
for (const gate of decomposeCompletionGateOrder) {
|
|
2991
|
+
const base = decomposeGateToolNames[gate];
|
|
2992
|
+
if (toolName === base || toolName === `forge.${base}`) {
|
|
2993
|
+
return gate;
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
return null;
|
|
2997
|
+
}
|
|
2998
|
+
function isDecomposeDispatch(dispatch) {
|
|
2999
|
+
const draftKind = dispatch.draft?.kind?.trim().toLowerCase();
|
|
3000
|
+
return dispatch.execution.lane.trim().toLowerCase() === "decompose"
|
|
3001
|
+
|| dispatch.session.lane.trim().toLowerCase() === "decompose"
|
|
3002
|
+
|| draftKind === "work_order_decomposition"
|
|
3003
|
+
|| draftKind === "feature_decomposition";
|
|
3004
|
+
}
|
|
2334
3005
|
function debugGateFromToolName(toolName) {
|
|
2335
3006
|
const normalized = toolName.trim().toLowerCase();
|
|
2336
3007
|
for (const gate of debugCompletionPhaseOrder) {
|