@loucompanion/forge-bridge 0.1.1-dev.cada6731ecc7 → 0.1.1-dev.d71662695396

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,33 @@ export const qaCompletionGateOrder = [
22
22
  "qa_findings",
23
23
  "qa_verdict"
24
24
  ];
25
+ export const draftCompletionGateOrder = [
26
+ "draft_intent",
27
+ "draft_repo_context",
28
+ "draft_graph_context",
29
+ "draft_product_scope",
30
+ "draft_work_order_plan",
31
+ "draft_critic",
32
+ "draft_proposal"
33
+ ];
34
+ const draftGateFileNames = {
35
+ draft_intent: "draft.intent.json",
36
+ draft_repo_context: "draft.repo-context.json",
37
+ draft_graph_context: "draft.graph-context.json",
38
+ draft_product_scope: "draft.product-scope.json",
39
+ draft_work_order_plan: "draft.work-order-plan.json",
40
+ draft_critic: "draft.critic.json",
41
+ draft_proposal: "draft.proposal.json"
42
+ };
43
+ const draftGateToolNames = {
44
+ draft_intent: "complete_draft_intent",
45
+ draft_repo_context: "complete_draft_repo_context",
46
+ draft_graph_context: "complete_draft_graph_context",
47
+ draft_product_scope: "complete_draft_product_scope",
48
+ draft_work_order_plan: "complete_draft_work_order_plan",
49
+ draft_critic: "complete_draft_critic",
50
+ draft_proposal: "complete_draft_proposal"
51
+ };
25
52
  const qaGateFileNames = {
26
53
  qa_plan: "qa.plan.json",
27
54
  qa_regression: "qa.regression.json",
@@ -95,6 +122,10 @@ const phaseFiles = {
95
122
  architect: {
96
123
  jsonFileName: "architect.verdict.json",
97
124
  reportFileName: "architect.report.md"
125
+ },
126
+ draft: {
127
+ jsonFileName: "draft.proposal.json",
128
+ reportFileName: "draft.report.md"
98
129
  }
99
130
  };
100
131
  export function completionPhaseFromDispatch(dispatch) {
@@ -118,6 +149,9 @@ export function normalizeCompletionPhase(value) {
118
149
  if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
119
150
  return "architect";
120
151
  }
152
+ if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
153
+ return "draft";
154
+ }
121
155
  return "implementation";
122
156
  }
123
157
  export function usesDevelopCycleCompletion(dispatch) {
@@ -154,6 +188,9 @@ export function usesArchitectCycleCompletion(dispatch) {
154
188
  return kind === "architect" || role === "architect" || lane === "architect";
155
189
  }
156
190
  export function completionToolNamesForPhase(phase) {
191
+ if (phase === "draft") {
192
+ return completionToolNamesForDraftGate("draft_proposal");
193
+ }
157
194
  if (isDebugCompletionPhase(phase)) {
158
195
  return completionToolNamesForDebugGate(phase);
159
196
  }
@@ -176,6 +213,10 @@ export function completionToolNamesForDebugGate(gate) {
176
213
  const base = debugGateToolNames[gate];
177
214
  return [`forge.${base}`, base];
178
215
  }
216
+ export function completionToolNamesForDraftGate(gate) {
217
+ const base = draftGateToolNames[gate];
218
+ return [`forge.${base}`, base];
219
+ }
179
220
  export function expectedCompletionToolName(phase) {
180
221
  return completionToolNamesForPhase(phase)[0];
181
222
  }
@@ -185,6 +226,9 @@ export function expectedQaCompletionToolName(gate) {
185
226
  export function expectedDebugCompletionToolName(gate) {
186
227
  return completionToolNamesForDebugGate(gate)[0];
187
228
  }
229
+ export function expectedDraftCompletionToolName(gate) {
230
+ return completionToolNamesForDraftGate(gate)[0];
231
+ }
188
232
  export function artifactRootFor(worktreePath, workItemId) {
189
233
  return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
190
234
  }
@@ -219,7 +263,8 @@ export async function prepareCompletionToolRuntime(options) {
219
263
  developCycle: usesDevelopCycleCompletion(options.dispatch),
220
264
  qaCycle: usesQaCycleCompletion(options.dispatch),
221
265
  debugCycle: usesDebugCycleCompletion(options.dispatch),
222
- architectCycle: usesArchitectCycleCompletion(options.dispatch)
266
+ architectCycle: usesArchitectCycleCompletion(options.dispatch),
267
+ draftCycle: options.dispatch.execution.lane.trim().toLowerCase() === "draft" || options.dispatch.cycleRun.kind.trim().toLowerCase() === "draft"
223
268
  });
224
269
  const runtime = {
225
270
  serverName: toolServerName,
@@ -261,6 +306,9 @@ export function buildCompletionToolServerArgs(config) {
261
306
  if (config.architectCycle) {
262
307
  args.push("--architect-cycle");
263
308
  }
309
+ if (config.draftCycle) {
310
+ args.push("--draft-cycle");
311
+ }
264
312
  return args;
265
313
  }
266
314
  export function codexMcpConfigArgs(runtime) {
@@ -300,6 +348,7 @@ Do not write Architect artifacts by hand. When the plan is complete, call \`${to
300
348
  That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
301
349
  The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
302
350
  \`{ "status": "pass", "summary": "...", "implementation_plan": { "schema_version": 1, "plan_id": "plan-...", "phases": [{ "id": "phase-1", "tasks": ["task-1"], "gate": "gate-1" }], "tasks": [{ "id": "task-1", "tests": [{ "command": "npm test" }] }], "dependencies": [], "phase_gates": [{ "id": "gate-1", "phase": "phase-1" }] }, "qa_plan": { "schema_version": 1, "final_qa": [{ "id": "qa-1", "coverage": "..." }] }, "report_markdown": "# Architect Report" }\`.
351
+ \`implementation_plan.tasks\` are Develop child tasks only: each task must require a focused repo/product implementation change. Put parent-story QA, sign-off, child-count/materialization inventory, parent plan artifact checks, and final integrated acceptance proof in \`qa_plan.final_qa\`, not as child Develop tasks.
303
352
  Dependency entries use canonical \`before\` and \`after\` task ids, for example \`{ "before": "task-1", "after": "task-2" }\`. Forge accepts \`from\`/\`to\` only as aliases and normalizes them before writing artifacts.
304
353
  Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
305
354
  Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
@@ -334,6 +383,19 @@ Use \`status: "skipped"\` with a reason for gates that are not applicable. If a
334
383
  The final verdict tool writes the canonical QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`qa.verdict.json\`, and \`qa.report.md\`.
335
384
  The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
336
385
  }
386
+ export function completionPromptForDraftCycle(currentGate = "draft_intent") {
387
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
388
+ Do not finish Draft by prose or raw JSON. The tool server enforces Draft gate order and starts at \`${currentGate}\`.
389
+ Complete the Draft cycle in order inside this same provider session:
390
+ 1. Call \`forge.complete_draft_intent(...)\` after interpreting user intent and non-goals.
391
+ 2. Follow the returned repo-context instruction, inspect selected project/repo assumptions, then call \`forge.complete_draft_repo_context(...)\`.
392
+ 3. Follow the returned graph-context instruction, account for DAG/dependency placement, then call \`forge.complete_draft_graph_context(...)\`.
393
+ 4. Follow the returned product-scope instruction, choose product scope for the selected project, then call \`forge.complete_draft_product_scope(...)\`.
394
+ 5. Follow the returned work-order-plan instruction, draft Work Order fields and candidate feature boundaries, then call \`forge.complete_draft_work_order_plan(...)\`.
395
+ 6. Follow the returned critic instruction, critique literalism, wrong-project scope, and decomposition quality, then call \`forge.complete_draft_critic(...)\`.
396
+ 7. Follow the returned proposal instruction, then call \`forge.complete_draft_proposal(...)\` with the final Work Order JSON in \`draft_json\`.
397
+ The final proposal tool writes \`draft.proposal.json\` under \`.forge/work-items/{workItemId}/artifacts/\`. Reporting Draft completion in prose is not enough.`;
398
+ }
337
399
  export function completionPromptForDebugCycle(currentGate = "debug_repro") {
338
400
  return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
339
401
  Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
@@ -346,33 +408,38 @@ Each tool writes a canonical Debug artifact under \`.forge/work-items/{workItemI
346
408
  Use a non-pass status with explicit blockers when the current Debug gate cannot pass. Reporting Debug completion in prose is not enough; the relevant MCP completion tool is the transition request.`;
347
409
  }
348
410
  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);
411
+ return config.draftCycle
412
+ ? draftCompletionGateOrder.flatMap((gate) => completionToolNamesForDraftGate(gate))
413
+ : config.debugCycle
414
+ ? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
415
+ : config.qaCycle
416
+ ? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
417
+ : config.developCycle
418
+ ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
419
+ : config.architectCycle
420
+ ? completionToolNamesForPhase("architect")
421
+ : completionToolNamesForPhase(config.phase);
358
422
  }
359
423
  export class CompletionToolServerSession {
360
424
  config;
361
425
  currentPhase;
362
426
  currentQaGate = "qa_plan";
427
+ currentDraftGate = "draft_intent";
363
428
  completed = false;
364
429
  constructor(config) {
365
430
  this.config = config;
366
431
  this.currentPhase = config.phase;
367
432
  }
368
433
  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);
434
+ return this.config.draftCycle
435
+ ? completionPromptForDraftCycle(this.currentDraftGate)
436
+ : this.config.debugCycle
437
+ ? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
438
+ : this.config.qaCycle
439
+ ? completionPromptForQaCycle(this.currentQaGate)
440
+ : this.config.developCycle
441
+ ? completionPromptForDevelopCycle(this.currentPhase)
442
+ : completionPromptForPhase(this.currentPhase);
376
443
  }
377
444
  toolDefinitions() {
378
445
  return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
@@ -382,6 +449,9 @@ export class CompletionToolServerSession {
382
449
  if (this.completed) {
383
450
  throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
384
451
  }
452
+ if (this.config.draftCycle) {
453
+ return await this.callDraftGateTool(toolName, requestedPhase, input);
454
+ }
385
455
  if (this.config.debugCycle) {
386
456
  return await this.callDebugGateTool(toolName, requestedPhase, input);
387
457
  }
@@ -422,6 +492,30 @@ export class CompletionToolServerSession {
422
492
  }
423
493
  };
424
494
  }
495
+ async callDraftGateTool(toolName, requestedPhase, input) {
496
+ const requestedGate = draftGateFromToolName(toolName);
497
+ if (requestedPhase !== "draft" || !requestedGate) {
498
+ throw new Error(`Tool ${toolName} is not available in Draft-cycle mode. Current Forge Draft gate is ${this.currentDraftGate}.`);
499
+ }
500
+ if (requestedGate !== this.currentDraftGate) {
501
+ throw new Error(`Tool ${toolName} is out of order. Current Forge Draft gate is ${this.currentDraftGate}.`);
502
+ }
503
+ const completedGate = this.currentDraftGate;
504
+ const reference = await writeDraftGateCompletionArtifact(this.config, completedGate, toolName, input);
505
+ const status = reference.status.trim().toLowerCase();
506
+ const nextGate = this.advanceAfterDraftGate(completedGate);
507
+ const nextPhase = nextGate ? "draft" : "sign_off_ready";
508
+ const nextInstruction = completionToolNextInstructionForDraftGate(completedGate, status, nextGate);
509
+ return {
510
+ content: [{ type: "text", text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}` }],
511
+ structuredContent: {
512
+ ...reference,
513
+ gate: { phase: "draft", draftGate: completedGate, status, allowed: status !== "blocked", reason: nextGate ? `${completedGate} artifact accepted; continue to ${nextGate}.` : `Draft proposal artifact accepted with status ${status}.` },
514
+ nextPhase,
515
+ nextInstruction
516
+ }
517
+ };
518
+ }
425
519
  async callDebugGateTool(toolName, requestedPhase, input) {
426
520
  const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
427
521
  if (!isDebugCompletionPhase(requestedPhase)) {
@@ -518,6 +612,16 @@ export class CompletionToolServerSession {
518
612
  this.completed = true;
519
613
  return "dev_test_ready";
520
614
  }
615
+ advanceAfterDraftGate(gate) {
616
+ const index = draftCompletionGateOrder.indexOf(gate);
617
+ const next = index >= 0 ? draftCompletionGateOrder[index + 1] ?? null : null;
618
+ if (next) {
619
+ this.currentDraftGate = next;
620
+ return next;
621
+ }
622
+ this.completed = true;
623
+ return null;
624
+ }
521
625
  advanceAfterQaGate(gate) {
522
626
  const index = qaCompletionGateOrder.indexOf(gate);
523
627
  const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
@@ -539,6 +643,43 @@ export class CompletionToolServerSession {
539
643
  return null;
540
644
  }
541
645
  }
646
+ async function writeDraftGateCompletionArtifact(config, gate, toolName, input) {
647
+ const payload = asRecord(input, "Draft completion payload");
648
+ const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
649
+ if (gate === "draft_proposal") {
650
+ const draftJson = asRecord(payload["draft_json"], "draft_json");
651
+ for (const key of ["title", "summary", "objective", "planningPacket", "decompositionContext"]) {
652
+ if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
653
+ throw new Error(`draft_json.${key}: expected non-empty string`);
654
+ }
655
+ }
656
+ }
657
+ const redactedPayload = redactSecrets({ ...payload, status, phase: "draft", gate, tool_name: toolName }, config.redactionSecrets ?? []);
658
+ const root = artifactRootFor(config.worktreePath, config.workItemId);
659
+ await fs.mkdir(root, { recursive: true });
660
+ const artifactPath = path.join(root, draftGateFileNames[gate]);
661
+ const reportPath = gate === "draft_proposal" ? path.join(root, phaseFiles.draft.reportFileName) : path.join(root, `${gate}.md`);
662
+ const verdictJson = jsonText(redactedPayload);
663
+ const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}\n\n${optionalString(redactedPayload, "summary") ?? "Draft gate recorded."}\n`;
664
+ await writeFileAtomic(artifactPath, verdictJson);
665
+ await writeFileAtomic(reportPath, report);
666
+ return {
667
+ phase: "draft",
668
+ workItemId: config.workItemId,
669
+ ownerType: config.ownerType,
670
+ ownerId: config.ownerId,
671
+ repoPath: path.resolve(config.worktreePath),
672
+ path: artifactPath,
673
+ reportPath,
674
+ contentHash: sha256(verdictJson),
675
+ status,
676
+ producedBy: config.producedBy ?? "forge-completion-tool"
677
+ };
678
+ }
679
+ function normalizeDraftStatus(value) {
680
+ const normalized = value.trim().toLowerCase();
681
+ return normalized === "blocked" || normalized === "needs_revision" || normalized === "needs_rework" ? normalized : "pass";
682
+ }
542
683
  export async function writeCompletionArtifact(config, toolName, input) {
543
684
  const phase = phaseFromToolName(toolName);
544
685
  if (phase !== config.phase) {
@@ -550,7 +691,10 @@ export async function writeCompletionArtifact(config, toolName, input) {
550
691
  }
551
692
  const payload = asRecord(input, "completion payload");
552
693
  const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
553
- const validationPayload = { ...payload, status };
694
+ let validationPayload = { ...payload, status };
695
+ if (config.qaCycle && qaGate === "qa_verdict") {
696
+ validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
697
+ }
554
698
  validatePayloadShape(phase, validationPayload);
555
699
  validateOptionalWorkItemId(validationPayload, config.workItemId);
556
700
  const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
@@ -793,6 +937,133 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
793
937
  producedBy
794
938
  };
795
939
  }
940
+ async function synthesizeQaCycleVerdictPayload(config, payload, status) {
941
+ const qaPlan = await readQaGateArtifact(config, "qa_plan");
942
+ const qaRegression = await readQaGateArtifact(config, "qa_regression");
943
+ const qaApi = await readQaGateArtifact(config, "qa_api");
944
+ const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
945
+ const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
946
+ const qaFindings = await readQaGateArtifact(config, "qa_findings");
947
+ const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
948
+ const payloadEvidence = qaEvidenceSource(payload);
949
+ const merged = { ...payload };
950
+ const checklist = qaStringArray(payload, "checklist");
951
+ if (checklist.length === 0) {
952
+ const plannedChecklist = qaStringArray(qaPlan, "checklist");
953
+ merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
954
+ }
955
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
956
+ if (testedAcceptanceCriteria.length === 0) {
957
+ const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
958
+ const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
959
+ const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
960
+ if (acceptanceCriteria.length > 0) {
961
+ merged.tested_acceptance_criteria = acceptanceCriteria;
962
+ }
963
+ }
964
+ const payloadCommands = qaArray(payload, "commands", payloadEvidence);
965
+ const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
966
+ const inheritedDiagnosticCommandNotes = status === "pass"
967
+ ? qaNonPassingCommandNotes(inheritedCommands)
968
+ : [];
969
+ merged.commands = dedupeUnknownArray([
970
+ ...payloadCommands,
971
+ ...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
972
+ ]);
973
+ merged.browser_checks = dedupeUnknownArray([
974
+ ...qaArray(payload, "browser_checks", payloadEvidence),
975
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
976
+ ]);
977
+ merged.sidecars = dedupeUnknownArray([
978
+ ...qaArray(payload, "sidecars", payloadEvidence),
979
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
980
+ ]);
981
+ const notes = dedupeStrings([
982
+ optionalString(payload, "notes"),
983
+ optionalString(payloadEvidence, "notes"),
984
+ ...evidenceGates.map((gate) => optionalString(gate, "notes")),
985
+ ...inheritedDiagnosticCommandNotes
986
+ ]);
987
+ if (notes.length > 0) {
988
+ merged.notes = notes.join("\n");
989
+ }
990
+ merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
991
+ merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
992
+ return merged;
993
+ }
994
+ async function readQaGateArtifact(config, gate) {
995
+ const ownerFields = artifactOwnerFields(config);
996
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
997
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
998
+ : artifactRootFor(config.worktreePath, config.workItemId);
999
+ const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
1000
+ const content = await readOptionalFile(artifactPath);
1001
+ return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
1002
+ }
1003
+ function dedupeUnknownArray(values) {
1004
+ const seen = new Set();
1005
+ const deduped = [];
1006
+ for (const value of values) {
1007
+ const key = stableStringify(value);
1008
+ if (seen.has(key)) {
1009
+ continue;
1010
+ }
1011
+ seen.add(key);
1012
+ deduped.push(value);
1013
+ }
1014
+ return deduped;
1015
+ }
1016
+ function isQaPassingCommandEvidence(value) {
1017
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1018
+ return false;
1019
+ }
1020
+ const record = value;
1021
+ return record.exit_code === 0 && normalizedString(record.status) === "pass";
1022
+ }
1023
+ function qaNonPassingCommandNotes(values) {
1024
+ return values
1025
+ .filter((value) => !isQaPassingCommandEvidence(value))
1026
+ .map((value, index) => {
1027
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1028
+ return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
1029
+ }
1030
+ const record = value;
1031
+ const command = optionalString(record, "command") ?? "<missing command>";
1032
+ const status = optionalString(record, "status") ?? "<missing status>";
1033
+ const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
1034
+ ? String(record.exit_code)
1035
+ : "<missing exit_code>";
1036
+ const summary = optionalString(record, "summary");
1037
+ return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
1038
+ });
1039
+ }
1040
+ function mergeQaObjectArraysById(primary, fallback) {
1041
+ const seen = new Set();
1042
+ const merged = [];
1043
+ for (const value of [...primary, ...fallback]) {
1044
+ const id = optionalString(value, "id");
1045
+ const key = id ? `id:${id}` : stableStringify(value);
1046
+ if (seen.has(key)) {
1047
+ continue;
1048
+ }
1049
+ seen.add(key);
1050
+ merged.push(value);
1051
+ }
1052
+ return merged;
1053
+ }
1054
+ function dedupeStrings(values) {
1055
+ const seen = new Set();
1056
+ const deduped = [];
1057
+ for (const value of values) {
1058
+ const trimmed = value?.trim();
1059
+ if (!trimmed || seen.has(trimmed)) {
1060
+ continue;
1061
+ }
1062
+ seen.add(trimmed);
1063
+ deduped.push(trimmed);
1064
+ }
1065
+ return deduped;
1066
+ }
796
1067
  async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
797
1068
  if (sidecars.length === 0) {
798
1069
  return [];
@@ -855,10 +1126,19 @@ export async function readCompletionArtifact(worktreePath, workItemId, phase, ow
855
1126
  }
856
1127
  const parsed = parseJsonObject(verdictJson, artifactPath);
857
1128
  const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
1129
+ const artifactRoot = path.dirname(artifactPath);
1130
+ const implementationPlanJson = phase === "architect"
1131
+ ? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
1132
+ : null;
1133
+ const qaPlanJson = phase === "architect"
1134
+ ? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
1135
+ : null;
858
1136
  return {
859
1137
  repoPath: path.resolve(worktreePath),
860
1138
  path: artifactPath,
861
1139
  verdictJson,
1140
+ implementationPlanJson,
1141
+ qaPlanJson,
862
1142
  report,
863
1143
  producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
864
1144
  status: optionalString(parsed, "status") ?? "unknown",
@@ -897,6 +1177,7 @@ export function parseCompletionMcpArgs(argv) {
897
1177
  let qaCycle = false;
898
1178
  let debugCycle = false;
899
1179
  let architectCycle = false;
1180
+ let draftCycle = false;
900
1181
  for (let index = 1; index < argv.length; index += 1) {
901
1182
  const arg = argv[index];
902
1183
  if (arg === "--worktree") {
@@ -918,7 +1199,7 @@ export function parseCompletionMcpArgs(argv) {
918
1199
  const rawPhase = argv[++index];
919
1200
  const parsedPhase = parseStrictCompletionPhase(rawPhase);
920
1201
  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." };
1202
+ return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, architect, draft, debug-repro, debug-root-cause, debug-fix, debug-regression." };
922
1203
  }
923
1204
  phase = parsedPhase;
924
1205
  }
@@ -937,6 +1218,9 @@ export function parseCompletionMcpArgs(argv) {
937
1218
  else if (arg === "--architect-cycle") {
938
1219
  architectCycle = true;
939
1220
  }
1221
+ else if (arg === "--draft-cycle") {
1222
+ draftCycle = true;
1223
+ }
940
1224
  else {
941
1225
  return { ok: false, error: `Unknown mcp argument: ${arg}` };
942
1226
  }
@@ -944,7 +1228,7 @@ export function parseCompletionMcpArgs(argv) {
944
1228
  if (!worktreePath?.trim() || !workItemId?.trim()) {
945
1229
  return { ok: false, error: "mcp requires --worktree and --work-item-id." };
946
1230
  }
947
- if ([developCycle, qaCycle, debugCycle, architectCycle].filter(Boolean).length > 1) {
1231
+ if ([developCycle, qaCycle, debugCycle, architectCycle, draftCycle].filter(Boolean).length > 1) {
948
1232
  return { ok: false, error: "mcp accepts only one cycle mode." };
949
1233
  }
950
1234
  if (developCycle && !isDevelopCompletionPhase(phase)) {
@@ -959,6 +1243,9 @@ export function parseCompletionMcpArgs(argv) {
959
1243
  if (architectCycle && phase !== "architect") {
960
1244
  return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
961
1245
  }
1246
+ if (draftCycle && phase !== "draft") {
1247
+ return { ok: false, error: "mcp --draft-cycle requires --phase draft." };
1248
+ }
962
1249
  return {
963
1250
  ok: true,
964
1251
  config: {
@@ -972,7 +1259,8 @@ export function parseCompletionMcpArgs(argv) {
972
1259
  developCycle,
973
1260
  qaCycle,
974
1261
  debugCycle,
975
- architectCycle
1262
+ architectCycle,
1263
+ draftCycle
976
1264
  }
977
1265
  };
978
1266
  }
@@ -1110,6 +1398,24 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
1110
1398
  }
1111
1399
  return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
1112
1400
  }
1401
+ function completionToolNextInstructionForDraftGate(completedGate, status, nextGate) {
1402
+ if (status === "blocked") {
1403
+ return `Stay in ${completedGate}; record the blocker and retry the same Draft gate when unblocked.`;
1404
+ }
1405
+ if (!nextGate) {
1406
+ return "Draft proposal accepted. Forge has the structured Work Order draft JSON needed to populate the form.";
1407
+ }
1408
+ const instructions = {
1409
+ draft_intent: "Capture intent/non-goals, then call forge.complete_draft_intent.",
1410
+ draft_repo_context: "Inspect selected repo/project assumptions, then call forge.complete_draft_repo_context.",
1411
+ draft_graph_context: "Account for DAG dependencies and placement, then call forge.complete_draft_graph_context.",
1412
+ draft_product_scope: "Choose product scope for the selected project, then call forge.complete_draft_product_scope.",
1413
+ draft_work_order_plan: "Draft Work Order fields and candidate Feature boundaries, then call forge.complete_draft_work_order_plan.",
1414
+ draft_critic: "Critique literalism/wrong-project scope/decomposition risk, then call forge.complete_draft_critic.",
1415
+ draft_proposal: "Submit final draft_json through forge.complete_draft_proposal."
1416
+ };
1417
+ return `${completedGate} accepted. Continue to ${nextGate}. ${instructions[nextGate]}`;
1418
+ }
1113
1419
  function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
1114
1420
  if (!nextGate) {
1115
1421
  if (status === "blocked") {
@@ -1425,7 +1731,7 @@ function toolInputSchema(phase, qaGate = null) {
1425
1731
  },
1426
1732
  tasks: {
1427
1733
  type: "array",
1428
- description: "Required task list. Each task must include id and a non-empty tests array.",
1734
+ description: "Required Develop child task list. Each task must include id and a non-empty tests array, and must require a focused repo/product implementation change. Do not put parent-story QA/sign-off, child-count/materialization inventory, parent plan artifact checks, or final acceptance proof here; those belong in qa_plan.final_qa.",
1429
1735
  items: { type: "object", additionalProperties: true }
1430
1736
  },
1431
1737
  dependencies: {
@@ -1881,6 +2187,7 @@ function validateQaGatePayloadShape(gate, payload, status) {
1881
2187
  if (commands.length === 0) {
1882
2188
  throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
1883
2189
  }
2190
+ validateQaGatePassingCommands(commands);
1884
2191
  }
1885
2192
  if (gate === "qa_manual_web" && status === "pass") {
1886
2193
  const browserChecks = qaArray(payload, "browser_checks");
@@ -1890,6 +2197,24 @@ function validateQaGatePayloadShape(gate, payload, status) {
1890
2197
  }
1891
2198
  }
1892
2199
  }
2200
+ function validateQaGatePassingCommands(commands) {
2201
+ for (const [index, command] of commands.entries()) {
2202
+ requireEvidenceRecord(command, `commands[${index}]`);
2203
+ const record = command;
2204
+ requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
2205
+ requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
2206
+ requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
2207
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
2208
+ throw new Error(`commands[${index}].exit_code is required.`);
2209
+ }
2210
+ if (record.exit_code !== 0) {
2211
+ throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
2212
+ }
2213
+ if (normalizedString(record.status) !== "pass") {
2214
+ throw new Error(`commands[${index}].status must be pass when QA gate status is pass.`);
2215
+ }
2216
+ }
2217
+ }
1893
2218
  function validateArchitectPayloadShape(payload, status) {
1894
2219
  if (status !== "pass") {
1895
2220
  requireArchitectNonPassEvidence(payload);
@@ -1918,6 +2243,7 @@ function validateArchitectPayloadShape(payload, status) {
1918
2243
  throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
1919
2244
  }
1920
2245
  taskIds.add(taskId);
2246
+ validateArchitectTaskDevelopScope(taskRecord, taskId);
1921
2247
  requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
1922
2248
  if (arrayLength(taskRecord, "tests") === 0) {
1923
2249
  throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
@@ -1930,6 +2256,124 @@ function validateArchitectPayloadShape(payload, status) {
1930
2256
  throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
1931
2257
  }
1932
2258
  }
2259
+ function validateArchitectTaskDevelopScope(task, taskId) {
2260
+ const explicitScope = firstOptionalString(task, ["execution_scope", "scope", "task_scope", "task_type", "lane", "kind", "type"]);
2261
+ if (isStoryQaScope(explicitScope)) {
2262
+ throw new Error(`implementation_plan.tasks[${taskId}]: story QA/sign-off work must not be materialized as a Develop child task; correction: move this coverage to qa_plan.final_qa and keep implementation_plan.tasks limited to repo/product changes.`);
2263
+ }
2264
+ if (looksLikeStoryLevelVerificationTask(architectTaskScopeText(task))) {
2265
+ throw new Error(`implementation_plan.tasks[${taskId}]: parent-story materialization or sign-off verification belongs in qa_plan.final_qa, not implementation_plan.tasks; correction: keep child tasks focused on implementation work and put child-count/parent-plan artifact checks in final QA coverage.`);
2266
+ }
2267
+ }
2268
+ function firstOptionalString(record, keys) {
2269
+ for (const key of keys) {
2270
+ const value = optionalString(record, key);
2271
+ if (value) {
2272
+ return value;
2273
+ }
2274
+ }
2275
+ return null;
2276
+ }
2277
+ function isStoryQaScope(value) {
2278
+ if (!value) {
2279
+ return false;
2280
+ }
2281
+ const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
2282
+ return [
2283
+ "qa",
2284
+ "story_qa",
2285
+ "final_qa",
2286
+ "sign_off",
2287
+ "signoff",
2288
+ "story_sign_off",
2289
+ "story_verification",
2290
+ "story_validation"
2291
+ ].includes(normalized);
2292
+ }
2293
+ function architectTaskScopeText(task) {
2294
+ const parts = [];
2295
+ for (const key of ["id", "title", "summary", "phase", "phase_id"]) {
2296
+ appendOptionalString(parts, task, key);
2297
+ }
2298
+ for (const key of ["acceptance_criteria", "expected_paths", "touched_surfaces", "implementation_notes"]) {
2299
+ appendOptionalStringArray(parts, task, key);
2300
+ }
2301
+ const tests = task.tests;
2302
+ if (Array.isArray(tests)) {
2303
+ for (const test of tests) {
2304
+ if (!test || typeof test !== "object" || Array.isArray(test)) {
2305
+ continue;
2306
+ }
2307
+ const record = test;
2308
+ for (const key of ["id", "command", "purpose", "target", "expected"]) {
2309
+ appendOptionalString(parts, record, key);
2310
+ }
2311
+ appendOptionalStringArray(parts, record, "steps");
2312
+ }
2313
+ }
2314
+ return parts.join(" ");
2315
+ }
2316
+ function appendOptionalString(parts, record, key) {
2317
+ const value = optionalString(record, key);
2318
+ if (value) {
2319
+ parts.push(value);
2320
+ }
2321
+ }
2322
+ function appendOptionalStringArray(parts, record, key) {
2323
+ const value = record[key];
2324
+ if (!Array.isArray(value)) {
2325
+ return;
2326
+ }
2327
+ for (const entry of value) {
2328
+ if (typeof entry === "string" && entry.trim().length > 0) {
2329
+ parts.push(entry.trim());
2330
+ }
2331
+ }
2332
+ }
2333
+ function looksLikeStoryLevelVerificationTask(text) {
2334
+ if (!text.trim()) {
2335
+ return false;
2336
+ }
2337
+ const normalized = text.toLowerCase();
2338
+ const verificationWords = [
2339
+ "verify",
2340
+ "verification",
2341
+ "validate",
2342
+ "validation",
2343
+ "inspect",
2344
+ "audit",
2345
+ "check",
2346
+ "confirm",
2347
+ "prove",
2348
+ "evidence",
2349
+ "sign-off",
2350
+ "sign off"
2351
+ ];
2352
+ if (!verificationWords.some((word) => normalized.includes(word))) {
2353
+ return false;
2354
+ }
2355
+ return [
2356
+ "materialization",
2357
+ "materialized child",
2358
+ "child inventory",
2359
+ "child work-item",
2360
+ "child work item",
2361
+ "created task count",
2362
+ "created_task_count",
2363
+ "parent plan",
2364
+ "parent story",
2365
+ "story sign-off",
2366
+ "story sign off",
2367
+ ".forge/work-items",
2368
+ ".forge\\work-items",
2369
+ ".forge/stories",
2370
+ ".forge\\stories",
2371
+ "implementation.plan.json",
2372
+ "qa.plan.json",
2373
+ "architect plan artifact",
2374
+ "child spec"
2375
+ ].some((needle) => normalized.includes(needle));
2376
+ }
1933
2377
  function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
1934
2378
  const dependencies = implementationPlan.dependencies;
1935
2379
  const edges = new Map();
@@ -2251,6 +2695,9 @@ function parseStrictCompletionPhase(value) {
2251
2695
  if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
2252
2696
  return "architect";
2253
2697
  }
2698
+ if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
2699
+ return "draft";
2700
+ }
2254
2701
  return null;
2255
2702
  }
2256
2703
  function normalizeDebugCompletionPhase(value) {
@@ -2322,6 +2769,9 @@ function phaseFromToolName(toolName) {
2322
2769
  if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
2323
2770
  return "architect";
2324
2771
  }
2772
+ if (draftGateFromToolName(toolName)) {
2773
+ return "draft";
2774
+ }
2325
2775
  const debugGate = debugGateFromToolName(toolName);
2326
2776
  if (debugGate) {
2327
2777
  return debugGate;
@@ -2331,6 +2781,16 @@ function phaseFromToolName(toolName) {
2331
2781
  }
2332
2782
  throw new Error(`Unknown Forge completion tool: ${toolName}`);
2333
2783
  }
2784
+ function draftGateFromToolName(toolName) {
2785
+ const normalized = toolName.trim().toLowerCase();
2786
+ for (const gate of draftCompletionGateOrder) {
2787
+ const base = draftGateToolNames[gate];
2788
+ if (normalized === `forge.${base}` || normalized === base) {
2789
+ return gate;
2790
+ }
2791
+ }
2792
+ return null;
2793
+ }
2334
2794
  function debugGateFromToolName(toolName) {
2335
2795
  const normalized = toolName.trim().toLowerCase();
2336
2796
  for (const gate of debugCompletionPhaseOrder) {