@loucompanion/forge-bridge 0.1.1-dev.f599637d804e → 0.1.3-dev.2c3feac3af65

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",
@@ -59,6 +113,44 @@ const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
59
113
  const qaFindingSeverities = new Set(["critical", "major", "minor"]);
60
114
  const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
61
115
  const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
116
+ const qaScopeSurfaces = new Set([
117
+ "domain_contract",
118
+ "backend_persistence",
119
+ "api_route",
120
+ "ui_browser",
121
+ "infra_config",
122
+ "data_migration",
123
+ "test_only",
124
+ "documentation"
125
+ ]);
126
+ const qaScopeEvidence = new Set([
127
+ "release_build",
128
+ "unit_tests",
129
+ "console_harness",
130
+ "source_scope_review",
131
+ "api_http",
132
+ "browser_manual",
133
+ "migration_check",
134
+ "config_check"
135
+ ]);
136
+ const qaScopeSources = new Set(["user", "draft", "decompose", "architect", "heuristic", "system"]);
137
+ const qaScopeAuthorities = new Set(["user", "decompose", "architect", "system"]);
138
+ const qaScopeConfidence = new Set(["explicit", "inferred"]);
139
+ const qaScopeFields = new Set([
140
+ "schema_version",
141
+ "surface",
142
+ "required_evidence",
143
+ "not_applicable",
144
+ "browser_required",
145
+ "api_required",
146
+ "source",
147
+ "authority",
148
+ "confidence",
149
+ "confirmed_by_user",
150
+ "locked",
151
+ "derived_from",
152
+ "rationale"
153
+ ]);
62
154
  const phaseFiles = {
63
155
  implementation: {
64
156
  jsonFileName: "implementation.completeness.json",
@@ -95,6 +187,14 @@ const phaseFiles = {
95
187
  architect: {
96
188
  jsonFileName: "architect.verdict.json",
97
189
  reportFileName: "architect.report.md"
190
+ },
191
+ draft: {
192
+ jsonFileName: "draft.proposal.json",
193
+ reportFileName: "draft.report.md"
194
+ },
195
+ decompose: {
196
+ jsonFileName: "decompose.proposal.json",
197
+ reportFileName: "decompose.report.md"
98
198
  }
99
199
  };
100
200
  export function completionPhaseFromDispatch(dispatch) {
@@ -118,6 +218,12 @@ export function normalizeCompletionPhase(value) {
118
218
  if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
119
219
  return "architect";
120
220
  }
221
+ if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
222
+ return "draft";
223
+ }
224
+ if (normalized === "decompose" || normalized === "decomposition" || normalized === "work_order_decomposition" || normalized === "feature_decomposition") {
225
+ return "decompose";
226
+ }
121
227
  return "implementation";
122
228
  }
123
229
  export function usesDevelopCycleCompletion(dispatch) {
@@ -154,6 +260,12 @@ export function usesArchitectCycleCompletion(dispatch) {
154
260
  return kind === "architect" || role === "architect" || lane === "architect";
155
261
  }
156
262
  export function completionToolNamesForPhase(phase) {
263
+ if (phase === "draft") {
264
+ return completionToolNamesForDraftGate("draft_proposal");
265
+ }
266
+ if (phase === "decompose") {
267
+ return completionToolNamesForDecomposeGate("decompose_proposal");
268
+ }
157
269
  if (isDebugCompletionPhase(phase)) {
158
270
  return completionToolNamesForDebugGate(phase);
159
271
  }
@@ -176,6 +288,14 @@ export function completionToolNamesForDebugGate(gate) {
176
288
  const base = debugGateToolNames[gate];
177
289
  return [`forge.${base}`, base];
178
290
  }
291
+ export function completionToolNamesForDraftGate(gate) {
292
+ const base = draftGateToolNames[gate];
293
+ return [`forge.${base}`, base];
294
+ }
295
+ export function completionToolNamesForDecomposeGate(gate) {
296
+ const base = decomposeGateToolNames[gate];
297
+ return [`forge.${base}`, base];
298
+ }
179
299
  export function expectedCompletionToolName(phase) {
180
300
  return completionToolNamesForPhase(phase)[0];
181
301
  }
@@ -185,6 +305,12 @@ export function expectedQaCompletionToolName(gate) {
185
305
  export function expectedDebugCompletionToolName(gate) {
186
306
  return completionToolNamesForDebugGate(gate)[0];
187
307
  }
308
+ export function expectedDraftCompletionToolName(gate) {
309
+ return completionToolNamesForDraftGate(gate)[0];
310
+ }
311
+ export function expectedDecomposeCompletionToolName(gate) {
312
+ return completionToolNamesForDecomposeGate(gate)[0];
313
+ }
188
314
  export function artifactRootFor(worktreePath, workItemId) {
189
315
  return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
190
316
  }
@@ -219,7 +345,12 @@ export async function prepareCompletionToolRuntime(options) {
219
345
  developCycle: usesDevelopCycleCompletion(options.dispatch),
220
346
  qaCycle: usesQaCycleCompletion(options.dispatch),
221
347
  debugCycle: usesDebugCycleCompletion(options.dispatch),
222
- architectCycle: usesArchitectCycleCompletion(options.dispatch)
348
+ architectCycle: usesArchitectCycleCompletion(options.dispatch),
349
+ draftCycle: (phase === "draft"
350
+ || options.dispatch.execution.lane.trim().toLowerCase() === "draft"
351
+ || options.dispatch.cycleRun.kind.trim().toLowerCase() === "draft")
352
+ && !isDecomposeDispatch(options.dispatch),
353
+ decomposeCycle: isDecomposeDispatch(options.dispatch)
223
354
  });
224
355
  const runtime = {
225
356
  serverName: toolServerName,
@@ -261,6 +392,12 @@ export function buildCompletionToolServerArgs(config) {
261
392
  if (config.architectCycle) {
262
393
  args.push("--architect-cycle");
263
394
  }
395
+ if (config.draftCycle) {
396
+ args.push("--draft-cycle");
397
+ }
398
+ if (config.decomposeCycle) {
399
+ args.push("--decompose-cycle");
400
+ }
264
401
  return args;
265
402
  }
266
403
  export function codexMcpConfigArgs(runtime) {
@@ -292,6 +429,7 @@ export function completionPromptForPhase(phase) {
292
429
  Do not write QA artifacts by hand. When QA is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the required structured QA payload.
293
430
  That tool writes the durable QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`${artifactName}\`, and \`qa.report.md\`.
294
431
  Use \`status: "pass"\` only when the validated changes satisfy acceptance criteria with concrete evidence. Use \`status: "needs_rework"\` with actionable in-scope findings, or \`status: "blocked"\` with a concrete blocker_reason.
432
+ Include \`policy_warnings\` for non-blocking Forge policy or scope mismatches that should remain visible after QA.
295
433
  Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
296
434
  }
297
435
  if (phase === "architect") {
@@ -299,7 +437,8 @@ Forge will mirror only the tool-written QA artifacts; reporting QA completion in
299
437
  Do not write Architect artifacts by hand. When the plan is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the full structured Architect planning payload.
300
438
  That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
301
439
  The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
302
- \`{ "status": "pass", "summary": "...", "implementation_plan": { "schema_version": 1, "plan_id": "plan-...", "phases": [{ "id": "phase-1", "tasks": ["task-1"], "gate": "gate-1" }], "tasks": [{ "id": "task-1", "tests": [{ "command": "npm test" }] }], "dependencies": [], "phase_gates": [{ "id": "gate-1", "phase": "phase-1" }] }, "qa_plan": { "schema_version": 1, "final_qa": [{ "id": "qa-1", "coverage": "..." }] }, "report_markdown": "# Architect Report" }\`.
440
+ \`{ "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", "verification_scope": { "schema_version": 1, "surface": ["domain_contract"], "required_evidence": ["unit_tests"], "not_applicable": ["browser_manual", "api_http"], "browser_required": false, "api_required": false, "rationale": "Backend-only child task." }, "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" }\`.
441
+ \`implementation_plan.tasks\` are Develop child tasks only: each task should include \`verification_scope\` describing task-level QA evidence, and 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
442
  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
443
  Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
305
444
  Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
@@ -332,8 +471,38 @@ Complete the QA cycle in order inside this same provider session:
332
471
  7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
333
472
  Use \`status: "skipped"\` with a reason for gates that are not applicable. If a gate is blocked, keep moving through later gates as skipped due to the blocker and finish with a blocked verdict.
334
473
  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\`.
474
+ Include \`policy_warnings\` in the final verdict for non-blocking Forge policy or scope mismatches that should remain visible after QA.
335
475
  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
476
  }
477
+ export function completionPromptForDraftCycle(currentGate = "draft_intent") {
478
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
479
+ Do not finish Draft by prose or raw JSON. The tool server enforces Draft gate order and starts at \`${currentGate}\`.
480
+ Complete the Draft cycle in order inside this same provider session:
481
+ 1. Call \`forge.complete_draft_intent(...)\` after interpreting user intent and non-goals.
482
+ 2. Follow the returned repo-context instruction, inspect selected project/repo assumptions, then call \`forge.complete_draft_repo_context(...)\`.
483
+ 3. Follow the returned graph-context instruction, account for DAG/dependency placement, then call \`forge.complete_draft_graph_context(...)\`.
484
+ 4. Follow the returned product-scope instruction, choose product scope for the selected project, then call \`forge.complete_draft_product_scope(...)\`.
485
+ 5. Follow the returned draft-plan instruction, draft the requested final JSON shape for the current Draft kind, then call \`forge.complete_draft_work_order_plan(...)\`.
486
+ 6. Follow the returned critic instruction, critique literalism, wrong-project scope, and decomposition quality, then call \`forge.complete_draft_critic(...)\`.
487
+ 7. Follow the returned proposal instruction, then call \`forge.complete_draft_proposal(...)\` with the final Draft JSON payload in \`draft_json\`.
488
+ The final proposal tool writes \`draft.proposal.json\` under \`.forge/work-items/{workItemId}/artifacts/\`. Reporting Draft completion in prose is not enough.`;
489
+ }
490
+ export function completionPromptForDecomposeCycle(currentGate = "decompose_subject_intake") {
491
+ return `# Forge Decompose completion cycle
492
+ You must complete the Decompose gates in order using the Forge MCP tools. Reporting progress in prose is not enough.
493
+
494
+ Current gate: ${currentGate}
495
+
496
+ Required order:
497
+ 1. Call \`forge.complete_decompose_subject_intake(...)\` after identifying the Work Order/Feature subject, scope, and non-goals.
498
+ 2. Follow the returned context-review instruction, inspect injected project/repo/dependency context, then call \`forge.complete_decompose_context_review(...)\`.
499
+ 3. Follow the returned breakdown-strategy instruction, choose safe Feature/Story boundaries, then call \`forge.complete_decompose_breakdown_strategy(...)\`.
500
+ 4. Follow the returned child-items instruction, draft candidate child items, then call \`forge.complete_decompose_child_items(...)\`.
501
+ 5. Follow the returned dependency-graph instruction, validate keys/dependencies/order, then call \`forge.complete_decompose_dependency_graph(...)\`.
502
+ 6. Follow the returned critic instruction, critique scope, DAG safety, manual reconciliation risk, and missing acceptance detail, then call \`forge.complete_decompose_critic(...)\`.
503
+ 7. Follow the returned proposal instruction, then call \`forge.complete_decompose_proposal(...)\` with final JSON in \`proposal_json\`.
504
+ 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. Work Order-to-Feature candidates may include \`estimated_qa_scope\`; Feature-to-Story candidates should include \`qa_scope\` with schema_version 1.`;
505
+ }
337
506
  export function completionPromptForDebugCycle(currentGate = "debug_repro") {
338
507
  return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
339
508
  Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
@@ -346,42 +515,58 @@ Each tool writes a canonical Debug artifact under \`.forge/work-items/{workItemI
346
515
  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
516
  }
348
517
  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);
518
+ return config.decomposeCycle
519
+ ? decomposeCompletionGateOrder.flatMap((gate) => completionToolNamesForDecomposeGate(gate))
520
+ : config.draftCycle
521
+ ? draftCompletionGateOrder.flatMap((gate) => completionToolNamesForDraftGate(gate))
522
+ : config.debugCycle
523
+ ? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
524
+ : config.qaCycle
525
+ ? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
526
+ : config.developCycle
527
+ ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
528
+ : config.architectCycle
529
+ ? completionToolNamesForPhase("architect")
530
+ : completionToolNamesForPhase(config.phase);
358
531
  }
359
532
  export class CompletionToolServerSession {
360
533
  config;
361
534
  currentPhase;
362
535
  currentQaGate = "qa_plan";
536
+ currentDraftGate = "draft_intent";
537
+ currentDecomposeGate = "decompose_subject_intake";
363
538
  completed = false;
364
539
  constructor(config) {
365
540
  this.config = config;
366
541
  this.currentPhase = config.phase;
367
542
  }
368
543
  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);
544
+ return this.config.decomposeCycle
545
+ ? completionPromptForDecomposeCycle(this.currentDecomposeGate)
546
+ : this.config.draftCycle
547
+ ? completionPromptForDraftCycle(this.currentDraftGate)
548
+ : this.config.debugCycle
549
+ ? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
550
+ : this.config.qaCycle
551
+ ? completionPromptForQaCycle(this.currentQaGate)
552
+ : this.config.developCycle
553
+ ? completionPromptForDevelopCycle(this.currentPhase)
554
+ : completionPromptForPhase(this.currentPhase);
376
555
  }
377
556
  toolDefinitions() {
378
- return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
557
+ return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name), decomposeGateFromToolName(name)));
379
558
  }
380
559
  async callTool(toolName, input) {
381
560
  const requestedPhase = phaseFromToolName(toolName);
382
561
  if (this.completed) {
383
562
  throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
384
563
  }
564
+ if (this.config.decomposeCycle) {
565
+ return await this.callDecomposeGateTool(toolName, requestedPhase, input);
566
+ }
567
+ if (this.config.draftCycle) {
568
+ return await this.callDraftGateTool(toolName, requestedPhase, input);
569
+ }
385
570
  if (this.config.debugCycle) {
386
571
  return await this.callDebugGateTool(toolName, requestedPhase, input);
387
572
  }
@@ -422,6 +607,62 @@ export class CompletionToolServerSession {
422
607
  }
423
608
  };
424
609
  }
610
+ async callDecomposeGateTool(toolName, requestedPhase, input) {
611
+ const requestedGate = decomposeGateFromToolName(toolName);
612
+ if (requestedPhase !== "decompose" || !requestedGate) {
613
+ throw new Error(`Tool ${toolName} is not available in Decompose-cycle mode. Current Forge Decompose gate is ${this.currentDecomposeGate}.`);
614
+ }
615
+ if (requestedGate !== this.currentDecomposeGate) {
616
+ throw new Error(`Tool ${toolName} is out of order. Current Forge Decompose gate is ${this.currentDecomposeGate}.`);
617
+ }
618
+ const completedGate = this.currentDecomposeGate;
619
+ const reference = await writeDecomposeGateCompletionArtifact(this.config, completedGate, toolName, input);
620
+ const status = reference.status.trim().toLowerCase();
621
+ const pass = status === "pass";
622
+ const nextGate = pass ? this.advanceAfterDecomposeGate(completedGate) : completedGate;
623
+ const nextPhase = pass
624
+ ? (nextGate ? "decompose" : "sign_off_ready")
625
+ : "decompose";
626
+ const nextInstruction = completionToolNextInstructionForDecomposeGate(completedGate, status, nextGate);
627
+ const reason = pass
628
+ ? nextGate
629
+ ? `${completedGate} artifact accepted; continue to ${nextGate}.`
630
+ : `Decompose proposal artifact accepted with status ${status}.`
631
+ : `${completedGate} artifact recorded with status ${status}; rework is required before continuing.`;
632
+ return {
633
+ content: [{ type: "text", text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}` }],
634
+ structuredContent: {
635
+ ...reference,
636
+ gate: { phase: "decompose", decomposeGate: completedGate, status, allowed: pass, reason },
637
+ nextPhase,
638
+ nextInstruction
639
+ }
640
+ };
641
+ }
642
+ async callDraftGateTool(toolName, requestedPhase, input) {
643
+ const requestedGate = draftGateFromToolName(toolName);
644
+ if (requestedPhase !== "draft" || !requestedGate) {
645
+ throw new Error(`Tool ${toolName} is not available in Draft-cycle mode. Current Forge Draft gate is ${this.currentDraftGate}.`);
646
+ }
647
+ if (requestedGate !== this.currentDraftGate) {
648
+ throw new Error(`Tool ${toolName} is out of order. Current Forge Draft gate is ${this.currentDraftGate}.`);
649
+ }
650
+ const completedGate = this.currentDraftGate;
651
+ const reference = await writeDraftGateCompletionArtifact(this.config, completedGate, toolName, input);
652
+ const status = reference.status.trim().toLowerCase();
653
+ const nextGate = this.advanceAfterDraftGate(completedGate);
654
+ const nextPhase = nextGate ? "draft" : "sign_off_ready";
655
+ const nextInstruction = completionToolNextInstructionForDraftGate(completedGate, status, nextGate);
656
+ return {
657
+ content: [{ type: "text", text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}` }],
658
+ structuredContent: {
659
+ ...reference,
660
+ gate: { phase: "draft", draftGate: completedGate, status, allowed: status !== "blocked", reason: nextGate ? `${completedGate} artifact accepted; continue to ${nextGate}.` : `Draft proposal artifact accepted with status ${status}.` },
661
+ nextPhase,
662
+ nextInstruction
663
+ }
664
+ };
665
+ }
425
666
  async callDebugGateTool(toolName, requestedPhase, input) {
426
667
  const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
427
668
  if (!isDebugCompletionPhase(requestedPhase)) {
@@ -518,6 +759,26 @@ export class CompletionToolServerSession {
518
759
  this.completed = true;
519
760
  return "dev_test_ready";
520
761
  }
762
+ advanceAfterDecomposeGate(gate) {
763
+ const index = decomposeCompletionGateOrder.indexOf(gate);
764
+ const next = index >= 0 ? decomposeCompletionGateOrder[index + 1] ?? null : null;
765
+ if (next) {
766
+ this.currentDecomposeGate = next;
767
+ return next;
768
+ }
769
+ this.completed = true;
770
+ return null;
771
+ }
772
+ advanceAfterDraftGate(gate) {
773
+ const index = draftCompletionGateOrder.indexOf(gate);
774
+ const next = index >= 0 ? draftCompletionGateOrder[index + 1] ?? null : null;
775
+ if (next) {
776
+ this.currentDraftGate = next;
777
+ return next;
778
+ }
779
+ this.completed = true;
780
+ return null;
781
+ }
521
782
  advanceAfterQaGate(gate) {
522
783
  const index = qaCompletionGateOrder.indexOf(gate);
523
784
  const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
@@ -539,6 +800,132 @@ export class CompletionToolServerSession {
539
800
  return null;
540
801
  }
541
802
  }
803
+ async function writeDecomposeGateCompletionArtifact(config, gate, toolName, input) {
804
+ const payload = asRecord(input, "Decompose completion payload");
805
+ const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
806
+ if (gate === "decompose_proposal") {
807
+ const proposalJson = asRecord(payload["proposal_json"] ?? payload["decomposition_json"], "proposal_json");
808
+ validateDecomposeProposalJson(proposalJson);
809
+ }
810
+ const redactedPayload = redactSecrets({ ...payload, status, phase: "decompose", gate, tool_name: toolName }, config.redactionSecrets ?? []);
811
+ const root = artifactRootFor(config.worktreePath, config.workItemId);
812
+ await fs.mkdir(root, { recursive: true });
813
+ const artifactPath = path.join(root, decomposeGateFileNames[gate]);
814
+ const reportPath = gate === "decompose_proposal" ? path.join(root, phaseFiles.decompose.reportFileName) : path.join(root, `${gate}.md`);
815
+ const verdictJson = jsonText(redactedPayload);
816
+ const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}
817
+
818
+ ${optionalString(redactedPayload, "summary") ?? "Decompose gate recorded."}
819
+ `;
820
+ await writeFileAtomic(artifactPath, verdictJson);
821
+ await writeFileAtomic(reportPath, report);
822
+ return {
823
+ phase: "decompose",
824
+ workItemId: config.workItemId,
825
+ ownerType: config.ownerType,
826
+ ownerId: config.ownerId,
827
+ repoPath: path.resolve(config.worktreePath),
828
+ path: artifactPath,
829
+ reportPath,
830
+ contentHash: sha256(verdictJson),
831
+ status,
832
+ producedBy: config.producedBy ?? "forge-completion-tool"
833
+ };
834
+ }
835
+ async function writeDraftGateCompletionArtifact(config, gate, toolName, input) {
836
+ const payload = asRecord(input, "Draft completion payload");
837
+ const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
838
+ if (gate === "draft_proposal") {
839
+ const draftJson = asRecord(payload["draft_json"], "draft_json");
840
+ validateDraftProposalJson(draftJson);
841
+ }
842
+ const redactedPayload = redactSecrets({ ...payload, status, phase: "draft", gate, tool_name: toolName }, config.redactionSecrets ?? []);
843
+ const root = artifactRootFor(config.worktreePath, config.workItemId);
844
+ await fs.mkdir(root, { recursive: true });
845
+ const artifactPath = path.join(root, draftGateFileNames[gate]);
846
+ const reportPath = gate === "draft_proposal" ? path.join(root, phaseFiles.draft.reportFileName) : path.join(root, `${gate}.md`);
847
+ const verdictJson = jsonText(redactedPayload);
848
+ const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}\n\n${optionalString(redactedPayload, "summary") ?? "Draft gate recorded."}\n`;
849
+ await writeFileAtomic(artifactPath, verdictJson);
850
+ await writeFileAtomic(reportPath, report);
851
+ return {
852
+ phase: "draft",
853
+ workItemId: config.workItemId,
854
+ ownerType: config.ownerType,
855
+ ownerId: config.ownerId,
856
+ repoPath: path.resolve(config.worktreePath),
857
+ path: artifactPath,
858
+ reportPath,
859
+ contentHash: sha256(verdictJson),
860
+ status,
861
+ producedBy: config.producedBy ?? "forge-completion-tool"
862
+ };
863
+ }
864
+ function normalizeDraftStatus(value) {
865
+ const normalized = value.trim().toLowerCase();
866
+ return normalized === "blocked" || normalized === "needs_revision" || normalized === "needs_rework" ? normalized : "pass";
867
+ }
868
+ function validateDraftProposalJson(draftJson) {
869
+ if (draftJson.suggested_qa_scope !== undefined && draftJson.suggested_qa_scope !== null) {
870
+ if (!draftJson.suggested_qa_scope || typeof draftJson.suggested_qa_scope !== "object" || Array.isArray(draftJson.suggested_qa_scope)) {
871
+ throw new Error("draft_json.suggested_qa_scope: expected object when present.");
872
+ }
873
+ validateQaScopeRecord(draftJson.suggested_qa_scope, "draft_json.suggested_qa_scope");
874
+ }
875
+ if (isDecompositionDraftJson(draftJson)) {
876
+ return;
877
+ }
878
+ if (isWorkItemDraftJson(draftJson)) {
879
+ for (const key of ["title", "description", "acceptanceCriteria"]) {
880
+ if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
881
+ throw new Error(`draft_json.${key}: expected non-empty string`);
882
+ }
883
+ }
884
+ return;
885
+ }
886
+ for (const key of ["title", "summary", "objective", "planningPacket", "decompositionContext"]) {
887
+ if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
888
+ throw new Error(`draft_json.${key}: expected non-empty string`);
889
+ }
890
+ }
891
+ }
892
+ function isDecompositionDraftJson(draftJson) {
893
+ const summary = draftJson["summary"];
894
+ return typeof summary === "string"
895
+ && summary.trim().length > 0
896
+ && Array.isArray(draftJson["candidates"]);
897
+ }
898
+ function isWorkItemDraftJson(draftJson) {
899
+ return typeof draftJson.title === "string"
900
+ && typeof draftJson.description === "string"
901
+ && typeof draftJson.acceptanceCriteria === "string";
902
+ }
903
+ function validateDecomposeProposalJson(proposalJson) {
904
+ const candidates = proposalJson.candidates;
905
+ if (!Array.isArray(candidates)) {
906
+ throw new Error("proposal_json.candidates: expected array");
907
+ }
908
+ for (const [index, candidate] of candidates.entries()) {
909
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
910
+ throw new Error(`proposal_json.candidates[${index}]: expected object.`);
911
+ }
912
+ const record = candidate;
913
+ validateOptionalCandidateQaScope(record, "estimated_qa_scope", `proposal_json.candidates[${index}].estimated_qa_scope`);
914
+ validateOptionalCandidateQaScope(record, "estimatedQaScope", `proposal_json.candidates[${index}].estimatedQaScope`);
915
+ validateOptionalCandidateQaScope(record, "qa_scope", `proposal_json.candidates[${index}].qa_scope`);
916
+ validateOptionalCandidateQaScope(record, "qaScope", `proposal_json.candidates[${index}].qaScope`);
917
+ }
918
+ }
919
+ function validateOptionalCandidateQaScope(candidate, key, pathLabel) {
920
+ const value = candidate[key];
921
+ if (value === undefined || value === null) {
922
+ return;
923
+ }
924
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
925
+ throw new Error(`${pathLabel}: expected object when present.`);
926
+ }
927
+ validateQaScopeRecord(value, pathLabel);
928
+ }
542
929
  export async function writeCompletionArtifact(config, toolName, input) {
543
930
  const phase = phaseFromToolName(toolName);
544
931
  if (phase !== config.phase) {
@@ -550,7 +937,10 @@ export async function writeCompletionArtifact(config, toolName, input) {
550
937
  }
551
938
  const payload = asRecord(input, "completion payload");
552
939
  const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
553
- const validationPayload = { ...payload, status };
940
+ let validationPayload = { ...payload, status };
941
+ if (config.qaCycle && qaGate === "qa_verdict") {
942
+ validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
943
+ }
554
944
  validatePayloadShape(phase, validationPayload);
555
945
  validateOptionalWorkItemId(validationPayload, config.workItemId);
556
946
  const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
@@ -711,6 +1101,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
711
1101
  const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
712
1102
  const explicitFindingIds = qaStringArray(payload, "finding_ids");
713
1103
  const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
1104
+ const policyWarnings = qaPolicyWarnings(payload);
714
1105
  const blockerReason = optionalString(payload, "blocker_reason");
715
1106
  const checklist = qaStringArray(payload, "checklist");
716
1107
  const planJson = jsonText({
@@ -758,6 +1149,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
758
1149
  tested_acceptance_criteria: testedAcceptanceCriteria,
759
1150
  finding_ids: findingIds,
760
1151
  candidate_bug_ids: candidateBugIds,
1152
+ policy_warnings: policyWarnings,
761
1153
  blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
762
1154
  evidence_path: relativeEvidencePath,
763
1155
  findings_path: relativeFindingsPath,
@@ -793,6 +1185,133 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
793
1185
  producedBy
794
1186
  };
795
1187
  }
1188
+ async function synthesizeQaCycleVerdictPayload(config, payload, status) {
1189
+ const qaPlan = await readQaGateArtifact(config, "qa_plan");
1190
+ const qaRegression = await readQaGateArtifact(config, "qa_regression");
1191
+ const qaApi = await readQaGateArtifact(config, "qa_api");
1192
+ const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
1193
+ const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
1194
+ const qaFindings = await readQaGateArtifact(config, "qa_findings");
1195
+ const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
1196
+ const payloadEvidence = qaEvidenceSource(payload);
1197
+ const merged = { ...payload };
1198
+ const checklist = qaStringArray(payload, "checklist");
1199
+ if (checklist.length === 0) {
1200
+ const plannedChecklist = qaStringArray(qaPlan, "checklist");
1201
+ merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
1202
+ }
1203
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
1204
+ if (testedAcceptanceCriteria.length === 0) {
1205
+ const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
1206
+ const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
1207
+ const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
1208
+ if (acceptanceCriteria.length > 0) {
1209
+ merged.tested_acceptance_criteria = acceptanceCriteria;
1210
+ }
1211
+ }
1212
+ const payloadCommands = qaArray(payload, "commands", payloadEvidence);
1213
+ const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
1214
+ const inheritedDiagnosticCommandNotes = status === "pass"
1215
+ ? qaNonPassingCommandNotes(inheritedCommands)
1216
+ : [];
1217
+ merged.commands = dedupeUnknownArray([
1218
+ ...payloadCommands,
1219
+ ...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
1220
+ ]);
1221
+ merged.browser_checks = dedupeUnknownArray([
1222
+ ...qaArray(payload, "browser_checks", payloadEvidence),
1223
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
1224
+ ]);
1225
+ merged.sidecars = dedupeUnknownArray([
1226
+ ...qaArray(payload, "sidecars", payloadEvidence),
1227
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
1228
+ ]);
1229
+ const notes = dedupeStrings([
1230
+ optionalString(payload, "notes"),
1231
+ optionalString(payloadEvidence, "notes"),
1232
+ ...evidenceGates.map((gate) => optionalString(gate, "notes")),
1233
+ ...inheritedDiagnosticCommandNotes
1234
+ ]);
1235
+ if (notes.length > 0) {
1236
+ merged.notes = notes.join("\n");
1237
+ }
1238
+ merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
1239
+ merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
1240
+ return merged;
1241
+ }
1242
+ async function readQaGateArtifact(config, gate) {
1243
+ const ownerFields = artifactOwnerFields(config);
1244
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
1245
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
1246
+ : artifactRootFor(config.worktreePath, config.workItemId);
1247
+ const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
1248
+ const content = await readOptionalFile(artifactPath);
1249
+ return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
1250
+ }
1251
+ function dedupeUnknownArray(values) {
1252
+ const seen = new Set();
1253
+ const deduped = [];
1254
+ for (const value of values) {
1255
+ const key = stableStringify(value);
1256
+ if (seen.has(key)) {
1257
+ continue;
1258
+ }
1259
+ seen.add(key);
1260
+ deduped.push(value);
1261
+ }
1262
+ return deduped;
1263
+ }
1264
+ function isQaPassingCommandEvidence(value) {
1265
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1266
+ return false;
1267
+ }
1268
+ const record = value;
1269
+ return record.exit_code === 0 && normalizedString(record.status) === "pass";
1270
+ }
1271
+ function qaNonPassingCommandNotes(values) {
1272
+ return values
1273
+ .filter((value) => !isQaPassingCommandEvidence(value))
1274
+ .map((value, index) => {
1275
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1276
+ return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
1277
+ }
1278
+ const record = value;
1279
+ const command = optionalString(record, "command") ?? "<missing command>";
1280
+ const status = optionalString(record, "status") ?? "<missing status>";
1281
+ const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
1282
+ ? String(record.exit_code)
1283
+ : "<missing exit_code>";
1284
+ const summary = optionalString(record, "summary");
1285
+ return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
1286
+ });
1287
+ }
1288
+ function mergeQaObjectArraysById(primary, fallback) {
1289
+ const seen = new Set();
1290
+ const merged = [];
1291
+ for (const value of [...primary, ...fallback]) {
1292
+ const id = optionalString(value, "id");
1293
+ const key = id ? `id:${id}` : stableStringify(value);
1294
+ if (seen.has(key)) {
1295
+ continue;
1296
+ }
1297
+ seen.add(key);
1298
+ merged.push(value);
1299
+ }
1300
+ return merged;
1301
+ }
1302
+ function dedupeStrings(values) {
1303
+ const seen = new Set();
1304
+ const deduped = [];
1305
+ for (const value of values) {
1306
+ const trimmed = value?.trim();
1307
+ if (!trimmed || seen.has(trimmed)) {
1308
+ continue;
1309
+ }
1310
+ seen.add(trimmed);
1311
+ deduped.push(trimmed);
1312
+ }
1313
+ return deduped;
1314
+ }
796
1315
  async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
797
1316
  if (sidecars.length === 0) {
798
1317
  return [];
@@ -855,10 +1374,19 @@ export async function readCompletionArtifact(worktreePath, workItemId, phase, ow
855
1374
  }
856
1375
  const parsed = parseJsonObject(verdictJson, artifactPath);
857
1376
  const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
1377
+ const artifactRoot = path.dirname(artifactPath);
1378
+ const implementationPlanJson = phase === "architect"
1379
+ ? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
1380
+ : null;
1381
+ const qaPlanJson = phase === "architect"
1382
+ ? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
1383
+ : null;
858
1384
  return {
859
1385
  repoPath: path.resolve(worktreePath),
860
1386
  path: artifactPath,
861
1387
  verdictJson,
1388
+ implementationPlanJson,
1389
+ qaPlanJson,
862
1390
  report,
863
1391
  producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
864
1392
  status: optionalString(parsed, "status") ?? "unknown",
@@ -897,6 +1425,8 @@ export function parseCompletionMcpArgs(argv) {
897
1425
  let qaCycle = false;
898
1426
  let debugCycle = false;
899
1427
  let architectCycle = false;
1428
+ let draftCycle = false;
1429
+ let decomposeCycle = false;
900
1430
  for (let index = 1; index < argv.length; index += 1) {
901
1431
  const arg = argv[index];
902
1432
  if (arg === "--worktree") {
@@ -918,7 +1448,7 @@ export function parseCompletionMcpArgs(argv) {
918
1448
  const rawPhase = argv[++index];
919
1449
  const parsedPhase = parseStrictCompletionPhase(rawPhase);
920
1450
  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." };
1451
+ 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
1452
  }
923
1453
  phase = parsedPhase;
924
1454
  }
@@ -937,6 +1467,12 @@ export function parseCompletionMcpArgs(argv) {
937
1467
  else if (arg === "--architect-cycle") {
938
1468
  architectCycle = true;
939
1469
  }
1470
+ else if (arg === "--draft-cycle") {
1471
+ draftCycle = true;
1472
+ }
1473
+ else if (arg === "--decompose-cycle") {
1474
+ decomposeCycle = true;
1475
+ }
940
1476
  else {
941
1477
  return { ok: false, error: `Unknown mcp argument: ${arg}` };
942
1478
  }
@@ -944,7 +1480,7 @@ export function parseCompletionMcpArgs(argv) {
944
1480
  if (!worktreePath?.trim() || !workItemId?.trim()) {
945
1481
  return { ok: false, error: "mcp requires --worktree and --work-item-id." };
946
1482
  }
947
- if ([developCycle, qaCycle, debugCycle, architectCycle].filter(Boolean).length > 1) {
1483
+ if ([developCycle, qaCycle, debugCycle, architectCycle, draftCycle, decomposeCycle].filter(Boolean).length > 1) {
948
1484
  return { ok: false, error: "mcp accepts only one cycle mode." };
949
1485
  }
950
1486
  if (developCycle && !isDevelopCompletionPhase(phase)) {
@@ -959,6 +1495,12 @@ export function parseCompletionMcpArgs(argv) {
959
1495
  if (architectCycle && phase !== "architect") {
960
1496
  return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
961
1497
  }
1498
+ if (draftCycle && phase !== "draft") {
1499
+ return { ok: false, error: "mcp --draft-cycle requires --phase draft." };
1500
+ }
1501
+ if (decomposeCycle && phase !== "decompose") {
1502
+ return { ok: false, error: "mcp --decompose-cycle requires --phase decompose." };
1503
+ }
962
1504
  return {
963
1505
  ok: true,
964
1506
  config: {
@@ -972,7 +1514,9 @@ export function parseCompletionMcpArgs(argv) {
972
1514
  developCycle,
973
1515
  qaCycle,
974
1516
  debugCycle,
975
- architectCycle
1517
+ architectCycle,
1518
+ draftCycle,
1519
+ decomposeCycle
976
1520
  }
977
1521
  };
978
1522
  }
@@ -1067,11 +1611,11 @@ async function handleJsonRpcRequest(session, method, params) {
1067
1611
  }
1068
1612
  throw new Error(`Unsupported MCP method: ${method}`);
1069
1613
  }
1070
- function toolDefinition(name, phase, qaGate = null) {
1614
+ function toolDefinition(name, phase, qaGate = null, decomposeGate = null) {
1071
1615
  return {
1072
1616
  name,
1073
- description: toolDescription(phase, qaGate),
1074
- inputSchema: toolInputSchema(phase, qaGate)
1617
+ description: toolDescription(phase, qaGate, decomposeGate),
1618
+ inputSchema: toolInputSchema(phase, qaGate, decomposeGate)
1075
1619
  };
1076
1620
  }
1077
1621
  function nextPhaseAfter(phase) {
@@ -1110,6 +1654,24 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
1110
1654
  }
1111
1655
  return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
1112
1656
  }
1657
+ function completionToolNextInstructionForDraftGate(completedGate, status, nextGate) {
1658
+ if (status === "blocked") {
1659
+ return `Stay in ${completedGate}; record the blocker and retry the same Draft gate when unblocked.`;
1660
+ }
1661
+ if (!nextGate) {
1662
+ return "Draft proposal accepted. Forge has the structured Work Order draft JSON needed to populate the form.";
1663
+ }
1664
+ const instructions = {
1665
+ draft_intent: "Capture intent/non-goals, then call forge.complete_draft_intent.",
1666
+ draft_repo_context: "Inspect selected repo/project assumptions, then call forge.complete_draft_repo_context.",
1667
+ draft_graph_context: "Account for DAG dependencies and placement, then call forge.complete_draft_graph_context.",
1668
+ draft_product_scope: "Choose product scope for the selected project, then call forge.complete_draft_product_scope.",
1669
+ draft_work_order_plan: "Draft the current Draft kind's plan fields, then call forge.complete_draft_work_order_plan.",
1670
+ draft_critic: "Critique literalism/wrong-project scope/decomposition risk, then call forge.complete_draft_critic.",
1671
+ draft_proposal: "Submit final draft_json through forge.complete_draft_proposal."
1672
+ };
1673
+ return `${completedGate} accepted. Continue to ${nextGate}. ${instructions[nextGate]}`;
1674
+ }
1113
1675
  function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
1114
1676
  if (!nextGate) {
1115
1677
  if (status === "blocked") {
@@ -1129,6 +1691,24 @@ function completionToolNextInstructionForQaGate(completedGate, status, nextGate)
1129
1691
  : `${completedGate} accepted. Continue to ${nextGate}.`;
1130
1692
  return `${prefix} ${qaGateInstruction(nextGate)}`;
1131
1693
  }
1694
+ function completionToolNextInstructionForDecomposeGate(completedGate, status, nextGate) {
1695
+ if (status !== "pass") {
1696
+ return `Stay in ${completedGate}, address the recorded blockers/rework, then call ${expectedDecomposeCompletionToolName(completedGate)} again.`;
1697
+ }
1698
+ if (!nextGate) {
1699
+ return "Decompose proposal accepted. Forge has the structured child-item proposal JSON needed to populate the proposal row.";
1700
+ }
1701
+ const nextInstructions = {
1702
+ decompose_subject_intake: "Capture subject/scope, then call forge.complete_decompose_subject_intake.",
1703
+ decompose_context_review: "Review project/repo/dependency context, then call forge.complete_decompose_context_review.",
1704
+ decompose_breakdown_strategy: "Choose safe child boundaries, then call forge.complete_decompose_breakdown_strategy.",
1705
+ decompose_child_items: "Draft child candidates, then call forge.complete_decompose_child_items.",
1706
+ decompose_dependency_graph: "Validate dependency DAG, then call forge.complete_decompose_dependency_graph.",
1707
+ decompose_critic: "Critique the decomposition, then call forge.complete_decompose_critic.",
1708
+ decompose_proposal: "Submit final proposal_json through forge.complete_decompose_proposal."
1709
+ };
1710
+ return `${completedGate} accepted. ${nextInstructions[nextGate]}`;
1711
+ }
1132
1712
  function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
1133
1713
  if (status !== "pass") {
1134
1714
  return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
@@ -1162,12 +1742,17 @@ function qaGateInstruction(gate) {
1162
1742
  return "Prepare the QA checklist, then call forge.complete_qa_plan.";
1163
1743
  }
1164
1744
  }
1165
- function toolDescription(phase, qaGate = null) {
1745
+ function toolDescription(phase, qaGate = null, decomposeGate = null) {
1166
1746
  if (qaGate) {
1167
1747
  return qaGate === "qa_verdict"
1168
1748
  ? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
1169
1749
  : `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
1170
1750
  }
1751
+ if (decomposeGate) {
1752
+ return decomposeGate === "decompose_proposal"
1753
+ ? "Complete the Forge Decompose proposal by writing decompose.proposal.json with candidate child items and optional structured QA scopes."
1754
+ : `Complete the ${decomposeGate.replace(/_/g, " ")} Decompose gate by writing ${decomposeGateFileNames[decomposeGate]} before continuing.`;
1755
+ }
1171
1756
  if (isDebugCompletionPhase(phase)) {
1172
1757
  return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
1173
1758
  }
@@ -1185,7 +1770,7 @@ function toolDescription(phase, qaGate = null) {
1185
1770
  }
1186
1771
  return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
1187
1772
  }
1188
- function toolInputSchema(phase, qaGate = null) {
1773
+ function toolInputSchema(phase, qaGate = null, decomposeGate = null) {
1189
1774
  const baseProperties = {
1190
1775
  status: {
1191
1776
  type: "string",
@@ -1207,6 +1792,9 @@ function toolInputSchema(phase, qaGate = null) {
1207
1792
  if (qaGate && qaGate !== "qa_verdict") {
1208
1793
  return qaGateToolInputSchema(qaGate);
1209
1794
  }
1795
+ if (decomposeGate) {
1796
+ return decomposeGateToolInputSchema(decomposeGate, baseProperties);
1797
+ }
1210
1798
  if (isDebugCompletionPhase(phase)) {
1211
1799
  return debugToolInputSchema(phase, baseProperties);
1212
1800
  }
@@ -1232,7 +1820,7 @@ function toolInputSchema(phase, qaGate = null) {
1232
1820
  },
1233
1821
  tests_added_or_updated: {
1234
1822
  type: "array",
1235
- description: "Required for pass. Use an object explaining why no tests were needed for docs/config-only work if empty would be ambiguous.",
1823
+ description: "Required for pass. Use an object explaining why no tests were needed for documentation/config-only work if empty would be ambiguous.",
1236
1824
  items: evidenceObjectSchema(["path", "summary"], {
1237
1825
  path: { type: "string" },
1238
1826
  summary: { type: "string" }
@@ -1396,6 +1984,16 @@ function toolInputSchema(phase, qaGate = null) {
1396
1984
  suggested_action: { type: "string", enum: ["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"] }
1397
1985
  })
1398
1986
  },
1987
+ policy_warnings: {
1988
+ type: "array",
1989
+ description: "Non-blocking Forge policy or scope warnings surfaced with the QA verdict.",
1990
+ items: evidenceObjectSchema(["code", "message"], {
1991
+ code: { type: "string" },
1992
+ message: { type: "string" },
1993
+ severity: { type: "string" },
1994
+ evidence: { type: "object" }
1995
+ })
1996
+ },
1399
1997
  blocker_reason: {
1400
1998
  type: "string",
1401
1999
  enum: [...qaBlockerReasons],
@@ -1425,8 +2023,20 @@ function toolInputSchema(phase, qaGate = null) {
1425
2023
  },
1426
2024
  tasks: {
1427
2025
  type: "array",
1428
- description: "Required task list. Each task must include id and a non-empty tests array.",
1429
- items: { type: "object", additionalProperties: true }
2026
+ description: "Required Develop child task list. Each task must include id, a non-empty tests array, and should include verification_scope for task-level QA evidence. 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.",
2027
+ items: {
2028
+ type: "object",
2029
+ additionalProperties: true,
2030
+ properties: {
2031
+ id: { type: "string" },
2032
+ title: { type: "string" },
2033
+ verification_scope: qaScopeSchema("Task-level QA/verification scope for the materialized child task."),
2034
+ tests: {
2035
+ type: "array",
2036
+ items: { type: "object", additionalProperties: true }
2037
+ }
2038
+ }
2039
+ }
1430
2040
  },
1431
2041
  dependencies: {
1432
2042
  type: "array",
@@ -1504,6 +2114,65 @@ function toolInputSchema(phase, qaGate = null) {
1504
2114
  ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
1505
2115
  }, ["status", "summary"], "For status pass, commands must include at least one passing deterministic command, acceptance criteria entries must use status: satisfied plus evidence, and ready_for_dev_test must be true.");
1506
2116
  }
2117
+ function decomposeGateToolInputSchema(gate, baseProperties) {
2118
+ if (gate !== "decompose_proposal") {
2119
+ return objectToolSchema({
2120
+ ...baseProperties,
2121
+ findings: {
2122
+ type: "array",
2123
+ description: "Optional issues, constraints, or risks discovered in this Decompose gate.",
2124
+ items: { type: "object", additionalProperties: true }
2125
+ }
2126
+ }, ["status", "summary"], `Record the ${gate.replace(/_/g, " ")} Decompose gate before continuing.`);
2127
+ }
2128
+ const candidateSchema = {
2129
+ type: "object",
2130
+ additionalProperties: true,
2131
+ properties: {
2132
+ origin: { type: "string" },
2133
+ key: { type: "string" },
2134
+ title: { type: "string" },
2135
+ summary: { type: "string" },
2136
+ description: { type: "string" },
2137
+ acceptanceCriteria: { type: "string" },
2138
+ planningPacket: { type: "string" },
2139
+ sortOrder: { type: "number" },
2140
+ points: { type: "number" },
2141
+ dependsOn: { type: "array", items: { type: "string" } },
2142
+ estimatedQaScope: qaScopeSchema("Work Order-to-Feature planning QA estimate. This is not dispatch-authoritative QA scope."),
2143
+ estimated_qa_scope: qaScopeSchema("Work Order-to-Feature planning QA estimate. Snake_case alias for estimatedQaScope."),
2144
+ qaScope: qaScopeSchema("Feature-to-Story QA scope that becomes Story-level QA scope after proposal approval."),
2145
+ qa_scope: qaScopeSchema("Feature-to-Story QA scope. Snake_case alias for qaScope.")
2146
+ }
2147
+ };
2148
+ const proposalSchema = {
2149
+ type: "object",
2150
+ additionalProperties: true,
2151
+ description: "Final Decompose proposal JSON. Must include candidates, and may include blockedReason.",
2152
+ properties: {
2153
+ summary: { type: "string" },
2154
+ blockedReason: { type: "string" },
2155
+ blocked_reason: { type: "string" },
2156
+ candidates: {
2157
+ type: "array",
2158
+ items: candidateSchema
2159
+ }
2160
+ }
2161
+ };
2162
+ return objectToolSchema({
2163
+ ...baseProperties,
2164
+ status: {
2165
+ type: "string",
2166
+ enum: ["pass", "needs_rework", "blocked"],
2167
+ description: "Use pass only when the Decompose proposal is ready for user sign-off."
2168
+ },
2169
+ proposal_json: proposalSchema,
2170
+ decomposition_json: {
2171
+ ...proposalSchema,
2172
+ description: "Alias for proposal_json when a client cannot send proposal_json."
2173
+ }
2174
+ }, ["status", "summary"], "For the final Decompose proposal, include proposal_json with a candidates array. Work Order-to-Feature candidates may include estimatedQaScope/estimated_qa_scope; Feature-to-Story candidates may include qaScope/qa_scope.");
2175
+ }
1507
2176
  function debugToolInputSchema(phase, baseProperties) {
1508
2177
  if (phase === "debug_repro") {
1509
2178
  return objectToolSchema({
@@ -1659,6 +2328,36 @@ function evidenceObjectSchema(required, properties) {
1659
2328
  required: [...required]
1660
2329
  };
1661
2330
  }
2331
+ function qaScopeSchema(description) {
2332
+ return {
2333
+ type: "object",
2334
+ additionalProperties: false,
2335
+ description,
2336
+ properties: {
2337
+ schema_version: { type: "number", enum: [1] },
2338
+ surface: { type: "array", items: { type: "string", enum: [...qaScopeSurfaces] } },
2339
+ required_evidence: { type: "array", items: { type: "string", enum: [...qaScopeEvidence] } },
2340
+ not_applicable: { type: "array", items: { type: "string", enum: [...qaScopeEvidence] } },
2341
+ browser_required: { type: "boolean" },
2342
+ api_required: { type: "boolean" },
2343
+ source: { type: "string", enum: [...qaScopeSources] },
2344
+ authority: { type: "string", enum: [...qaScopeAuthorities] },
2345
+ confidence: { type: "string", enum: [...qaScopeConfidence] },
2346
+ confirmed_by_user: { type: "boolean" },
2347
+ locked: { type: "boolean" },
2348
+ derived_from: {
2349
+ type: "object",
2350
+ additionalProperties: false,
2351
+ properties: {
2352
+ work_item_id: { type: "string" },
2353
+ mode: { type: "string" }
2354
+ }
2355
+ },
2356
+ rationale: { type: "string" }
2357
+ },
2358
+ required: ["schema_version", "required_evidence", "not_applicable"]
2359
+ };
2360
+ }
1662
2361
  function commandEvidenceSchema() {
1663
2362
  return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
1664
2363
  command: { type: "string" },
@@ -1881,6 +2580,7 @@ function validateQaGatePayloadShape(gate, payload, status) {
1881
2580
  if (commands.length === 0) {
1882
2581
  throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
1883
2582
  }
2583
+ validateQaGatePassingCommands(commands);
1884
2584
  }
1885
2585
  if (gate === "qa_manual_web" && status === "pass") {
1886
2586
  const browserChecks = qaArray(payload, "browser_checks");
@@ -1890,6 +2590,24 @@ function validateQaGatePayloadShape(gate, payload, status) {
1890
2590
  }
1891
2591
  }
1892
2592
  }
2593
+ function validateQaGatePassingCommands(commands) {
2594
+ for (const [index, command] of commands.entries()) {
2595
+ requireEvidenceRecord(command, `commands[${index}]`);
2596
+ const record = command;
2597
+ requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
2598
+ requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
2599
+ requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
2600
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
2601
+ throw new Error(`commands[${index}].exit_code is required.`);
2602
+ }
2603
+ if (record.exit_code !== 0) {
2604
+ throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
2605
+ }
2606
+ if (normalizedString(record.status) !== "pass") {
2607
+ throw new Error(`commands[${index}].status must be pass when QA gate status is pass.`);
2608
+ }
2609
+ }
2610
+ }
1893
2611
  function validateArchitectPayloadShape(payload, status) {
1894
2612
  if (status !== "pass") {
1895
2613
  requireArchitectNonPassEvidence(payload);
@@ -1918,6 +2636,8 @@ function validateArchitectPayloadShape(payload, status) {
1918
2636
  throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
1919
2637
  }
1920
2638
  taskIds.add(taskId);
2639
+ validateArchitectTaskDevelopScope(taskRecord, taskId);
2640
+ validateArchitectVerificationScope(taskRecord, taskId);
1921
2641
  requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
1922
2642
  if (arrayLength(taskRecord, "tests") === 0) {
1923
2643
  throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
@@ -1930,6 +2650,241 @@ function validateArchitectPayloadShape(payload, status) {
1930
2650
  throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
1931
2651
  }
1932
2652
  }
2653
+ function validateArchitectTaskDevelopScope(task, taskId) {
2654
+ const explicitScope = firstOptionalString(task, ["execution_scope", "scope", "task_scope", "task_type", "lane", "kind", "type"]);
2655
+ if (isStoryQaScope(explicitScope)) {
2656
+ 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.`);
2657
+ }
2658
+ if (looksLikeStoryLevelVerificationTask(architectTaskScopeText(task))) {
2659
+ 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.`);
2660
+ }
2661
+ }
2662
+ function validateArchitectVerificationScope(task, taskId) {
2663
+ const value = task.verification_scope;
2664
+ if (value === undefined || value === null) {
2665
+ return;
2666
+ }
2667
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2668
+ throw new Error(`implementation_plan.tasks[${taskId}].verification_scope: expected object when present.`);
2669
+ }
2670
+ validateQaScopeRecord(value, `implementation_plan.tasks[${taskId}].verification_scope`);
2671
+ }
2672
+ function validateQaScopeRecord(scope, pathLabel) {
2673
+ for (const key of Object.keys(scope)) {
2674
+ if (!qaScopeFields.has(key)) {
2675
+ throw new Error(`${pathLabel}.${key}: unsupported QA scope field.`);
2676
+ }
2677
+ }
2678
+ if (scope.schema_version !== 1) {
2679
+ throw new Error(`${pathLabel}.schema_version must be numeric 1.`);
2680
+ }
2681
+ const surface = qaScopeStringArray(scope, "surface", pathLabel, qaScopeSurfaces);
2682
+ const requiredEvidence = qaScopeStringArray(scope, "required_evidence", pathLabel, qaScopeEvidence);
2683
+ const notApplicable = qaScopeStringArray(scope, "not_applicable", pathLabel, qaScopeEvidence);
2684
+ const requiredSet = new Set(requiredEvidence);
2685
+ for (const evidence of notApplicable) {
2686
+ if (requiredSet.has(evidence)) {
2687
+ throw new Error(`${pathLabel}: '${evidence}' cannot be both required and not applicable.`);
2688
+ }
2689
+ }
2690
+ const browserRequired = requiredSet.has("browser_manual");
2691
+ const apiRequired = requiredSet.has("api_http");
2692
+ validateOptionalBoolean(scope, "browser_required", pathLabel, browserRequired);
2693
+ validateOptionalBoolean(scope, "api_required", pathLabel, apiRequired);
2694
+ validateOptionalBoolean(scope, "confirmed_by_user", pathLabel);
2695
+ validateOptionalBoolean(scope, "locked", pathLabel);
2696
+ if (surface.includes("ui_browser") && !browserRequired) {
2697
+ throw new Error(`${pathLabel}.surface ui_browser requires browser_manual evidence.`);
2698
+ }
2699
+ if (surface.includes("api_route") && !apiRequired) {
2700
+ throw new Error(`${pathLabel}.surface api_route requires api_http evidence.`);
2701
+ }
2702
+ validateOptionalKnownString(scope, "source", pathLabel, qaScopeSources);
2703
+ validateOptionalKnownString(scope, "authority", pathLabel, qaScopeAuthorities);
2704
+ validateOptionalKnownString(scope, "confidence", pathLabel, qaScopeConfidence);
2705
+ if (scope.rationale !== undefined && scope.rationale !== null && typeof scope.rationale !== "string") {
2706
+ throw new Error(`${pathLabel}.rationale must be a string.`);
2707
+ }
2708
+ validateOptionalQaScopeDerivedFrom(scope, pathLabel);
2709
+ }
2710
+ function qaScopeStringArray(scope, key, pathLabel, allowed) {
2711
+ const value = scope[key];
2712
+ if (value === undefined || value === null) {
2713
+ return [];
2714
+ }
2715
+ if (!Array.isArray(value)) {
2716
+ throw new Error(`${pathLabel}.${key} must be an array.`);
2717
+ }
2718
+ const normalized = [];
2719
+ for (const [index, entry] of value.entries()) {
2720
+ if (typeof entry !== "string" || entry.trim().length === 0) {
2721
+ throw new Error(`${pathLabel}.${key}[${index}] must be a non-empty string.`);
2722
+ }
2723
+ const candidate = entry.trim().toLowerCase();
2724
+ if (!allowed.has(candidate)) {
2725
+ throw new Error(`${pathLabel}.${key}[${index}] value '${candidate}' is unsupported.`);
2726
+ }
2727
+ if (!normalized.includes(candidate)) {
2728
+ normalized.push(candidate);
2729
+ }
2730
+ }
2731
+ return normalized;
2732
+ }
2733
+ function validateOptionalBoolean(scope, key, pathLabel, expected) {
2734
+ const value = scope[key];
2735
+ if (value === undefined || value === null) {
2736
+ return;
2737
+ }
2738
+ if (typeof value !== "boolean") {
2739
+ throw new Error(`${pathLabel}.${key} must be boolean.`);
2740
+ }
2741
+ if (expected !== undefined && value !== expected) {
2742
+ throw new Error(`${pathLabel}.${key} must match required_evidence.`);
2743
+ }
2744
+ }
2745
+ function validateOptionalKnownString(scope, key, pathLabel, allowed) {
2746
+ const value = scope[key];
2747
+ if (value === undefined || value === null) {
2748
+ return;
2749
+ }
2750
+ if (typeof value !== "string" || value.trim().length === 0) {
2751
+ throw new Error(`${pathLabel}.${key} must be a non-empty string.`);
2752
+ }
2753
+ const normalized = value.trim().toLowerCase();
2754
+ if (!allowed.has(normalized)) {
2755
+ throw new Error(`${pathLabel}.${key} value '${normalized}' is unsupported.`);
2756
+ }
2757
+ }
2758
+ function validateOptionalQaScopeDerivedFrom(scope, pathLabel) {
2759
+ const value = scope.derived_from;
2760
+ if (value === undefined || value === null) {
2761
+ return;
2762
+ }
2763
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2764
+ throw new Error(`${pathLabel}.derived_from must be an object.`);
2765
+ }
2766
+ const derivedFrom = value;
2767
+ for (const key of Object.keys(derivedFrom)) {
2768
+ if (key !== "work_item_id" && key !== "mode") {
2769
+ throw new Error(`${pathLabel}.derived_from.${key}: unsupported QA scope provenance field.`);
2770
+ }
2771
+ }
2772
+ if (derivedFrom.work_item_id !== undefined && derivedFrom.work_item_id !== null && typeof derivedFrom.work_item_id !== "string") {
2773
+ throw new Error(`${pathLabel}.derived_from.work_item_id must be a string.`);
2774
+ }
2775
+ if (derivedFrom.mode !== undefined && derivedFrom.mode !== null && typeof derivedFrom.mode !== "string") {
2776
+ throw new Error(`${pathLabel}.derived_from.mode must be a string.`);
2777
+ }
2778
+ }
2779
+ function firstOptionalString(record, keys) {
2780
+ for (const key of keys) {
2781
+ const value = optionalString(record, key);
2782
+ if (value) {
2783
+ return value;
2784
+ }
2785
+ }
2786
+ return null;
2787
+ }
2788
+ function isStoryQaScope(value) {
2789
+ if (!value) {
2790
+ return false;
2791
+ }
2792
+ const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
2793
+ return [
2794
+ "qa",
2795
+ "story_qa",
2796
+ "final_qa",
2797
+ "sign_off",
2798
+ "signoff",
2799
+ "story_sign_off",
2800
+ "story_verification",
2801
+ "story_validation"
2802
+ ].includes(normalized);
2803
+ }
2804
+ function architectTaskScopeText(task) {
2805
+ const parts = [];
2806
+ for (const key of ["id", "title", "summary", "phase", "phase_id"]) {
2807
+ appendOptionalString(parts, task, key);
2808
+ }
2809
+ for (const key of ["acceptance_criteria", "expected_paths", "touched_surfaces", "implementation_notes"]) {
2810
+ appendOptionalStringArray(parts, task, key);
2811
+ }
2812
+ const tests = task.tests;
2813
+ if (Array.isArray(tests)) {
2814
+ for (const test of tests) {
2815
+ if (!test || typeof test !== "object" || Array.isArray(test)) {
2816
+ continue;
2817
+ }
2818
+ const record = test;
2819
+ for (const key of ["id", "command", "purpose", "target", "expected"]) {
2820
+ appendOptionalString(parts, record, key);
2821
+ }
2822
+ appendOptionalStringArray(parts, record, "steps");
2823
+ }
2824
+ }
2825
+ return parts.join(" ");
2826
+ }
2827
+ function appendOptionalString(parts, record, key) {
2828
+ const value = optionalString(record, key);
2829
+ if (value) {
2830
+ parts.push(value);
2831
+ }
2832
+ }
2833
+ function appendOptionalStringArray(parts, record, key) {
2834
+ const value = record[key];
2835
+ if (!Array.isArray(value)) {
2836
+ return;
2837
+ }
2838
+ for (const entry of value) {
2839
+ if (typeof entry === "string" && entry.trim().length > 0) {
2840
+ parts.push(entry.trim());
2841
+ }
2842
+ }
2843
+ }
2844
+ function looksLikeStoryLevelVerificationTask(text) {
2845
+ if (!text.trim()) {
2846
+ return false;
2847
+ }
2848
+ const normalized = text.toLowerCase();
2849
+ const verificationWords = [
2850
+ "verify",
2851
+ "verification",
2852
+ "validate",
2853
+ "validation",
2854
+ "inspect",
2855
+ "audit",
2856
+ "check",
2857
+ "confirm",
2858
+ "prove",
2859
+ "evidence",
2860
+ "sign-off",
2861
+ "sign off"
2862
+ ];
2863
+ if (!verificationWords.some((word) => normalized.includes(word))) {
2864
+ return false;
2865
+ }
2866
+ return [
2867
+ "materialization",
2868
+ "materialized child",
2869
+ "child inventory",
2870
+ "child work-item",
2871
+ "child work item",
2872
+ "created task count",
2873
+ "created_task_count",
2874
+ "parent plan",
2875
+ "parent story",
2876
+ "story sign-off",
2877
+ "story sign off",
2878
+ ".forge/work-items",
2879
+ ".forge\\work-items",
2880
+ ".forge/stories",
2881
+ ".forge\\stories",
2882
+ "implementation.plan.json",
2883
+ "qa.plan.json",
2884
+ "architect plan artifact",
2885
+ "child spec"
2886
+ ].some((needle) => normalized.includes(needle));
2887
+ }
1933
2888
  function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
1934
2889
  const dependencies = implementationPlan.dependencies;
1935
2890
  const edges = new Map();
@@ -2055,6 +3010,7 @@ function validateQaPayloadShape(payload, status) {
2055
3010
  const candidateBugs = qaObjectArray(payload, "candidate_bugs");
2056
3011
  const explicitFindingIds = qaStringArray(payload, "finding_ids");
2057
3012
  const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
3013
+ qaPolicyWarnings(payload);
2058
3014
  if (payload.schema_version !== undefined && payload.schema_version !== 1) {
2059
3015
  throw new Error("QA schema_version must be numeric 1 when provided.");
2060
3016
  }
@@ -2251,6 +3207,12 @@ function parseStrictCompletionPhase(value) {
2251
3207
  if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
2252
3208
  return "architect";
2253
3209
  }
3210
+ if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
3211
+ return "draft";
3212
+ }
3213
+ if (normalized === "decompose" || normalized === "decomposition" || normalized === "work_order_decomposition" || normalized === "feature_decomposition") {
3214
+ return "decompose";
3215
+ }
2254
3216
  return null;
2255
3217
  }
2256
3218
  function normalizeDebugCompletionPhase(value) {
@@ -2322,6 +3284,12 @@ function phaseFromToolName(toolName) {
2322
3284
  if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
2323
3285
  return "architect";
2324
3286
  }
3287
+ if (draftGateFromToolName(toolName)) {
3288
+ return "draft";
3289
+ }
3290
+ if (decomposeGateFromToolName(toolName)) {
3291
+ return "decompose";
3292
+ }
2325
3293
  const debugGate = debugGateFromToolName(toolName);
2326
3294
  if (debugGate) {
2327
3295
  return debugGate;
@@ -2331,6 +3299,32 @@ function phaseFromToolName(toolName) {
2331
3299
  }
2332
3300
  throw new Error(`Unknown Forge completion tool: ${toolName}`);
2333
3301
  }
3302
+ function draftGateFromToolName(toolName) {
3303
+ const normalized = toolName.trim().toLowerCase();
3304
+ for (const gate of draftCompletionGateOrder) {
3305
+ const base = draftGateToolNames[gate];
3306
+ if (normalized === `forge.${base}` || normalized === base) {
3307
+ return gate;
3308
+ }
3309
+ }
3310
+ return null;
3311
+ }
3312
+ function decomposeGateFromToolName(toolName) {
3313
+ for (const gate of decomposeCompletionGateOrder) {
3314
+ const base = decomposeGateToolNames[gate];
3315
+ if (toolName === base || toolName === `forge.${base}`) {
3316
+ return gate;
3317
+ }
3318
+ }
3319
+ return null;
3320
+ }
3321
+ function isDecomposeDispatch(dispatch) {
3322
+ const draftKind = dispatch.draft?.kind?.trim().toLowerCase();
3323
+ return dispatch.execution.lane.trim().toLowerCase() === "decompose"
3324
+ || dispatch.session.lane.trim().toLowerCase() === "decompose"
3325
+ || draftKind === "work_order_decomposition"
3326
+ || draftKind === "feature_decomposition";
3327
+ }
2334
3328
  function debugGateFromToolName(toolName) {
2335
3329
  const normalized = toolName.trim().toLowerCase();
2336
3330
  for (const gate of debugCompletionPhaseOrder) {
@@ -2526,6 +3520,27 @@ function qaObjectArray(payload, key) {
2526
3520
  return entry;
2527
3521
  });
2528
3522
  }
3523
+ function qaPolicyWarnings(payload) {
3524
+ return qaArray(payload, "policy_warnings").map((entry, index) => {
3525
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
3526
+ throw new Error(`policy_warnings[${index}] must be an object.`);
3527
+ }
3528
+ const warning = entry;
3529
+ const prefix = `policy_warnings[${index}].`;
3530
+ requireNonEmptyStringRecord(warning, "code", prefix);
3531
+ requireNonEmptyStringRecord(warning, "message", prefix);
3532
+ const normalized = {
3533
+ ...warning,
3534
+ code: optionalString(warning, "code"),
3535
+ message: optionalString(warning, "message")
3536
+ };
3537
+ if (warning.severity !== undefined && warning.severity !== null) {
3538
+ requireNonEmptyStringRecord(warning, "severity", prefix);
3539
+ normalized.severity = optionalString(warning, "severity");
3540
+ }
3541
+ return normalized;
3542
+ });
3543
+ }
2529
3544
  function qaStringArray(payload, key) {
2530
3545
  const value = payload[key];
2531
3546
  if (value === undefined || value === null) {