@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.
@@ -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.debugCycle
350
- ? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
351
- : config.qaCycle
352
- ? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
353
- : config.developCycle
354
- ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
355
- : config.architectCycle
356
- ? completionToolNamesForPhase("architect")
357
- : completionToolNamesForPhase(config.phase);
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.debugCycle
370
- ? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
371
- : this.config.qaCycle
372
- ? completionPromptForQaCycle(this.currentQaGate)
373
- : this.config.developCycle
374
- ? completionPromptForDevelopCycle(this.currentPhase)
375
- : completionPromptForPhase(this.currentPhase);
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
- const validationPayload = { ...payload, status };
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) {