@loucompanion/forge-bridge 0.1.1-dev.895607f4402a → 0.1.1-dev.9219f8477b8a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,33 @@ export const qaCompletionGateOrder = [
22
22
  "qa_findings",
23
23
  "qa_verdict"
24
24
  ];
25
+ export const draftCompletionGateOrder = [
26
+ "draft_intent",
27
+ "draft_repo_context",
28
+ "draft_graph_context",
29
+ "draft_product_scope",
30
+ "draft_work_order_plan",
31
+ "draft_critic",
32
+ "draft_proposal"
33
+ ];
34
+ const draftGateFileNames = {
35
+ draft_intent: "draft.intent.json",
36
+ draft_repo_context: "draft.repo-context.json",
37
+ draft_graph_context: "draft.graph-context.json",
38
+ draft_product_scope: "draft.product-scope.json",
39
+ draft_work_order_plan: "draft.work-order-plan.json",
40
+ draft_critic: "draft.critic.json",
41
+ draft_proposal: "draft.proposal.json"
42
+ };
43
+ const draftGateToolNames = {
44
+ draft_intent: "complete_draft_intent",
45
+ draft_repo_context: "complete_draft_repo_context",
46
+ draft_graph_context: "complete_draft_graph_context",
47
+ draft_product_scope: "complete_draft_product_scope",
48
+ draft_work_order_plan: "complete_draft_work_order_plan",
49
+ draft_critic: "complete_draft_critic",
50
+ draft_proposal: "complete_draft_proposal"
51
+ };
25
52
  const qaGateFileNames = {
26
53
  qa_plan: "qa.plan.json",
27
54
  qa_regression: "qa.regression.json",
@@ -54,6 +81,8 @@ const qaBlockerReasons = new Set([
54
81
  "missing_worktree",
55
82
  "runner_failure"
56
83
  ]);
84
+ const resolvedFindingStatusValues = ["resolved", "disputed", "not_reproduced"];
85
+ const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
57
86
  const qaFindingSeverities = new Set(["critical", "major", "minor"]);
58
87
  const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
59
88
  const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
@@ -89,6 +118,14 @@ const phaseFiles = {
89
118
  debug_regression: {
90
119
  jsonFileName: "debug.regression.json",
91
120
  reportFileName: "debug.report.md"
121
+ },
122
+ architect: {
123
+ jsonFileName: "architect.verdict.json",
124
+ reportFileName: "architect.report.md"
125
+ },
126
+ draft: {
127
+ jsonFileName: "draft.proposal.json",
128
+ reportFileName: "draft.report.md"
92
129
  }
93
130
  };
94
131
  export function completionPhaseFromDispatch(dispatch) {
@@ -109,6 +146,12 @@ export function normalizeCompletionPhase(value) {
109
146
  if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
110
147
  return "qa";
111
148
  }
149
+ if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
150
+ return "architect";
151
+ }
152
+ if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
153
+ return "draft";
154
+ }
112
155
  return "implementation";
113
156
  }
114
157
  export function usesDevelopCycleCompletion(dispatch) {
@@ -138,7 +181,16 @@ export function usesDebugCycleCompletion(dispatch) {
138
181
  && lane === "debug"
139
182
  && isDebugCompletionPhase(completionPhaseFromDispatch(dispatch));
140
183
  }
184
+ export function usesArchitectCycleCompletion(dispatch) {
185
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
186
+ const role = dispatch.execution.role.trim().toLowerCase();
187
+ const lane = dispatch.execution.lane.trim().toLowerCase();
188
+ return kind === "architect" || role === "architect" || lane === "architect";
189
+ }
141
190
  export function completionToolNamesForPhase(phase) {
191
+ if (phase === "draft") {
192
+ return completionToolNamesForDraftGate("draft_proposal");
193
+ }
142
194
  if (isDebugCompletionPhase(phase)) {
143
195
  return completionToolNamesForDebugGate(phase);
144
196
  }
@@ -148,7 +200,9 @@ export function completionToolNamesForPhase(phase) {
148
200
  ? "complete_self_review"
149
201
  : phase === "validation"
150
202
  ? "complete_validation"
151
- : "complete_qa";
203
+ : phase === "architect"
204
+ ? "complete_architect_plan"
205
+ : "complete_qa";
152
206
  return [`forge.${base}`, base];
153
207
  }
154
208
  export function completionToolNamesForQaGate(gate) {
@@ -159,6 +213,10 @@ export function completionToolNamesForDebugGate(gate) {
159
213
  const base = debugGateToolNames[gate];
160
214
  return [`forge.${base}`, base];
161
215
  }
216
+ export function completionToolNamesForDraftGate(gate) {
217
+ const base = draftGateToolNames[gate];
218
+ return [`forge.${base}`, base];
219
+ }
162
220
  export function expectedCompletionToolName(phase) {
163
221
  return completionToolNamesForPhase(phase)[0];
164
222
  }
@@ -168,17 +226,26 @@ export function expectedQaCompletionToolName(gate) {
168
226
  export function expectedDebugCompletionToolName(gate) {
169
227
  return completionToolNamesForDebugGate(gate)[0];
170
228
  }
229
+ export function expectedDraftCompletionToolName(gate) {
230
+ return completionToolNamesForDraftGate(gate)[0];
231
+ }
171
232
  export function artifactRootFor(worktreePath, workItemId) {
172
233
  return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
173
234
  }
174
- function artifactRootForOwner(worktreePath, ownerType, ownerId) {
235
+ export function artifactRootForOwner(worktreePath, ownerType, ownerId) {
175
236
  return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
176
237
  }
177
- export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
178
- return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].jsonFileName);
238
+ export function completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId) {
239
+ const artifactRoot = ownerType && ownerId
240
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
241
+ : artifactRootFor(worktreePath, workItemId);
242
+ return path.join(artifactRoot, phaseFiles[phase].jsonFileName);
179
243
  }
180
- export function completionArtifactReportPath(worktreePath, workItemId, phase) {
181
- return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].reportFileName);
244
+ export function completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId) {
245
+ const artifactRoot = ownerType && ownerId
246
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
247
+ : artifactRootFor(worktreePath, workItemId);
248
+ return path.join(artifactRoot, phaseFiles[phase].reportFileName);
182
249
  }
183
250
  export async function prepareCompletionToolRuntime(options) {
184
251
  const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
@@ -195,7 +262,9 @@ export async function prepareCompletionToolRuntime(options) {
195
262
  producedBy: `${options.dispatch.execution.provider}-cli`,
196
263
  developCycle: usesDevelopCycleCompletion(options.dispatch),
197
264
  qaCycle: usesQaCycleCompletion(options.dispatch),
198
- debugCycle: usesDebugCycleCompletion(options.dispatch)
265
+ debugCycle: usesDebugCycleCompletion(options.dispatch),
266
+ architectCycle: usesArchitectCycleCompletion(options.dispatch),
267
+ draftCycle: options.dispatch.execution.lane.trim().toLowerCase() === "draft" || options.dispatch.cycleRun.kind.trim().toLowerCase() === "draft"
199
268
  });
200
269
  const runtime = {
201
270
  serverName: toolServerName,
@@ -234,6 +303,12 @@ export function buildCompletionToolServerArgs(config) {
234
303
  if (config.debugCycle) {
235
304
  args.push("--debug-cycle");
236
305
  }
306
+ if (config.architectCycle) {
307
+ args.push("--architect-cycle");
308
+ }
309
+ if (config.draftCycle) {
310
+ args.push("--draft-cycle");
311
+ }
237
312
  return args;
238
313
  }
239
314
  export function codexMcpConfigArgs(runtime) {
@@ -266,6 +341,17 @@ Do not write QA artifacts by hand. When QA is complete, call \`${toolName}(...)\
266
341
  That tool writes the durable QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`${artifactName}\`, and \`qa.report.md\`.
267
342
  Use \`status: "pass"\` only when the validated changes satisfy acceptance criteria with concrete evidence. Use \`status: "needs_rework"\` with actionable in-scope findings, or \`status: "blocked"\` with a concrete blocker_reason.
268
343
  Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
344
+ }
345
+ if (phase === "architect") {
346
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
347
+ Do not write Architect artifacts by hand. When the plan is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the full structured Architect planning payload.
348
+ That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
349
+ The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
350
+ \`{ "status": "pass", "summary": "...", "implementation_plan": { "schema_version": 1, "plan_id": "plan-...", "phases": [{ "id": "phase-1", "tasks": ["task-1"], "gate": "gate-1" }], "tasks": [{ "id": "task-1", "tests": [{ "command": "npm test" }] }], "dependencies": [], "phase_gates": [{ "id": "gate-1", "phase": "phase-1" }] }, "qa_plan": { "schema_version": 1, "final_qa": [{ "id": "qa-1", "coverage": "..." }] }, "report_markdown": "# Architect Report" }\`.
351
+ \`implementation_plan.tasks\` are Develop child tasks only: each task must require a focused repo/product implementation change. Put parent-story QA, sign-off, child-count/materialization inventory, parent plan artifact checks, and final integrated acceptance proof in \`qa_plan.final_qa\`, not as child Develop tasks.
352
+ Dependency entries use canonical \`before\` and \`after\` task ids, for example \`{ "before": "task-1", "after": "task-2" }\`. Forge accepts \`from\`/\`to\` only as aliases and normalizes them before writing artifacts.
353
+ Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
354
+ Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
269
355
  }
270
356
  return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
271
357
  Do not write phase artifacts by hand. When the phase is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the required evidence payload.
@@ -297,6 +383,19 @@ Use \`status: "skipped"\` with a reason for gates that are not applicable. If a
297
383
  The final verdict tool writes the canonical QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`qa.verdict.json\`, and \`qa.report.md\`.
298
384
  The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
299
385
  }
386
+ export function completionPromptForDraftCycle(currentGate = "draft_intent") {
387
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
388
+ Do not finish Draft by prose or raw JSON. The tool server enforces Draft gate order and starts at \`${currentGate}\`.
389
+ Complete the Draft cycle in order inside this same provider session:
390
+ 1. Call \`forge.complete_draft_intent(...)\` after interpreting user intent and non-goals.
391
+ 2. Follow the returned repo-context instruction, inspect selected project/repo assumptions, then call \`forge.complete_draft_repo_context(...)\`.
392
+ 3. Follow the returned graph-context instruction, account for DAG/dependency placement, then call \`forge.complete_draft_graph_context(...)\`.
393
+ 4. Follow the returned product-scope instruction, choose product scope for the selected project, then call \`forge.complete_draft_product_scope(...)\`.
394
+ 5. Follow the returned work-order-plan instruction, draft Work Order fields and candidate feature boundaries, then call \`forge.complete_draft_work_order_plan(...)\`.
395
+ 6. Follow the returned critic instruction, critique literalism, wrong-project scope, and decomposition quality, then call \`forge.complete_draft_critic(...)\`.
396
+ 7. Follow the returned proposal instruction, then call \`forge.complete_draft_proposal(...)\` with the final Work Order JSON in \`draft_json\`.
397
+ The final proposal tool writes \`draft.proposal.json\` under \`.forge/work-items/{workItemId}/artifacts/\`. Reporting Draft completion in prose is not enough.`;
398
+ }
300
399
  export function completionPromptForDebugCycle(currentGate = "debug_repro") {
301
400
  return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
302
401
  Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
@@ -309,31 +408,38 @@ Each tool writes a canonical Debug artifact under \`.forge/work-items/{workItemI
309
408
  Use a non-pass status with explicit blockers when the current Debug gate cannot pass. Reporting Debug completion in prose is not enough; the relevant MCP completion tool is the transition request.`;
310
409
  }
311
410
  export function completionToolNamesForServer(config) {
312
- return config.debugCycle
313
- ? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
314
- : config.qaCycle
315
- ? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
316
- : config.developCycle
317
- ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
318
- : 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);
319
422
  }
320
423
  export class CompletionToolServerSession {
321
424
  config;
322
425
  currentPhase;
323
426
  currentQaGate = "qa_plan";
427
+ currentDraftGate = "draft_intent";
324
428
  completed = false;
325
429
  constructor(config) {
326
430
  this.config = config;
327
431
  this.currentPhase = config.phase;
328
432
  }
329
433
  get instructions() {
330
- return this.config.debugCycle
331
- ? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
332
- : this.config.qaCycle
333
- ? completionPromptForQaCycle(this.currentQaGate)
334
- : this.config.developCycle
335
- ? completionPromptForDevelopCycle(this.currentPhase)
336
- : 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);
337
443
  }
338
444
  toolDefinitions() {
339
445
  return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
@@ -343,6 +449,9 @@ export class CompletionToolServerSession {
343
449
  if (this.completed) {
344
450
  throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
345
451
  }
452
+ if (this.config.draftCycle) {
453
+ return await this.callDraftGateTool(toolName, requestedPhase, input);
454
+ }
346
455
  if (this.config.debugCycle) {
347
456
  return await this.callDebugGateTool(toolName, requestedPhase, input);
348
457
  }
@@ -383,6 +492,30 @@ export class CompletionToolServerSession {
383
492
  }
384
493
  };
385
494
  }
495
+ async callDraftGateTool(toolName, requestedPhase, input) {
496
+ const requestedGate = draftGateFromToolName(toolName);
497
+ if (requestedPhase !== "draft" || !requestedGate) {
498
+ throw new Error(`Tool ${toolName} is not available in Draft-cycle mode. Current Forge Draft gate is ${this.currentDraftGate}.`);
499
+ }
500
+ if (requestedGate !== this.currentDraftGate) {
501
+ throw new Error(`Tool ${toolName} is out of order. Current Forge Draft gate is ${this.currentDraftGate}.`);
502
+ }
503
+ const completedGate = this.currentDraftGate;
504
+ const reference = await writeDraftGateCompletionArtifact(this.config, completedGate, toolName, input);
505
+ const status = reference.status.trim().toLowerCase();
506
+ const nextGate = this.advanceAfterDraftGate(completedGate);
507
+ const nextPhase = nextGate ? "draft" : "sign_off_ready";
508
+ const nextInstruction = completionToolNextInstructionForDraftGate(completedGate, status, nextGate);
509
+ return {
510
+ content: [{ type: "text", text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}` }],
511
+ structuredContent: {
512
+ ...reference,
513
+ gate: { phase: "draft", draftGate: completedGate, status, allowed: status !== "blocked", reason: nextGate ? `${completedGate} artifact accepted; continue to ${nextGate}.` : `Draft proposal artifact accepted with status ${status}.` },
514
+ nextPhase,
515
+ nextInstruction
516
+ }
517
+ };
518
+ }
386
519
  async callDebugGateTool(toolName, requestedPhase, input) {
387
520
  const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
388
521
  if (!isDebugCompletionPhase(requestedPhase)) {
@@ -479,6 +612,16 @@ export class CompletionToolServerSession {
479
612
  this.completed = true;
480
613
  return "dev_test_ready";
481
614
  }
615
+ advanceAfterDraftGate(gate) {
616
+ const index = draftCompletionGateOrder.indexOf(gate);
617
+ const next = index >= 0 ? draftCompletionGateOrder[index + 1] ?? null : null;
618
+ if (next) {
619
+ this.currentDraftGate = next;
620
+ return next;
621
+ }
622
+ this.completed = true;
623
+ return null;
624
+ }
482
625
  advanceAfterQaGate(gate) {
483
626
  const index = qaCompletionGateOrder.indexOf(gate);
484
627
  const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
@@ -500,6 +643,43 @@ export class CompletionToolServerSession {
500
643
  return null;
501
644
  }
502
645
  }
646
+ async function writeDraftGateCompletionArtifact(config, gate, toolName, input) {
647
+ const payload = asRecord(input, "Draft completion payload");
648
+ const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
649
+ if (gate === "draft_proposal") {
650
+ const draftJson = asRecord(payload["draft_json"], "draft_json");
651
+ for (const key of ["title", "summary", "objective", "planningPacket", "decompositionContext"]) {
652
+ if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
653
+ throw new Error(`draft_json.${key}: expected non-empty string`);
654
+ }
655
+ }
656
+ }
657
+ const redactedPayload = redactSecrets({ ...payload, status, phase: "draft", gate, tool_name: toolName }, config.redactionSecrets ?? []);
658
+ const root = artifactRootFor(config.worktreePath, config.workItemId);
659
+ await fs.mkdir(root, { recursive: true });
660
+ const artifactPath = path.join(root, draftGateFileNames[gate]);
661
+ const reportPath = gate === "draft_proposal" ? path.join(root, phaseFiles.draft.reportFileName) : path.join(root, `${gate}.md`);
662
+ const verdictJson = jsonText(redactedPayload);
663
+ const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}\n\n${optionalString(redactedPayload, "summary") ?? "Draft gate recorded."}\n`;
664
+ await writeFileAtomic(artifactPath, verdictJson);
665
+ await writeFileAtomic(reportPath, report);
666
+ return {
667
+ phase: "draft",
668
+ workItemId: config.workItemId,
669
+ ownerType: config.ownerType,
670
+ ownerId: config.ownerId,
671
+ repoPath: path.resolve(config.worktreePath),
672
+ path: artifactPath,
673
+ reportPath,
674
+ contentHash: sha256(verdictJson),
675
+ status,
676
+ producedBy: config.producedBy ?? "forge-completion-tool"
677
+ };
678
+ }
679
+ function normalizeDraftStatus(value) {
680
+ const normalized = value.trim().toLowerCase();
681
+ return normalized === "blocked" || normalized === "needs_revision" || normalized === "needs_rework" ? normalized : "pass";
682
+ }
503
683
  export async function writeCompletionArtifact(config, toolName, input) {
504
684
  const phase = phaseFromToolName(toolName);
505
685
  if (phase !== config.phase) {
@@ -511,7 +691,10 @@ export async function writeCompletionArtifact(config, toolName, input) {
511
691
  }
512
692
  const payload = asRecord(input, "completion payload");
513
693
  const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
514
- const validationPayload = { ...payload, status };
694
+ let validationPayload = { ...payload, status };
695
+ if (config.qaCycle && qaGate === "qa_verdict") {
696
+ validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
697
+ }
515
698
  validatePayloadShape(phase, validationPayload);
516
699
  validateOptionalWorkItemId(validationPayload, config.workItemId);
517
700
  const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
@@ -522,6 +705,9 @@ export async function writeCompletionArtifact(config, toolName, input) {
522
705
  if (phase === "qa") {
523
706
  return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
524
707
  }
708
+ if (phase === "architect") {
709
+ return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
710
+ }
525
711
  const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
526
712
  phase,
527
713
  workItemId: config.workItemId,
@@ -554,6 +740,48 @@ export async function writeCompletionArtifact(config, toolName, input) {
554
740
  producedBy
555
741
  };
556
742
  }
743
+ async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
744
+ const ownerFields = artifactOwnerFields(config);
745
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
746
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
747
+ : artifactRootFor(config.worktreePath, config.workItemId);
748
+ const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
749
+ const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
750
+ const verdictPath = path.join(artifactRoot, "architect.verdict.json");
751
+ const reportPath = path.join(artifactRoot, "architect.report.md");
752
+ const implementationPlan = requiredRecord(payload, "implementation_plan");
753
+ const qaPlan = requiredRecord(payload, "qa_plan");
754
+ const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
755
+ const artifactWithoutHash = normalizeArtifactPayload(payload, {
756
+ phase: "architect",
757
+ workItemId: config.workItemId,
758
+ ownerType: config.ownerType,
759
+ ownerId: config.ownerId,
760
+ producedBy,
761
+ status
762
+ });
763
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
764
+ const verdict = {
765
+ ...artifactWithoutHash,
766
+ content_hash: contentHash
767
+ };
768
+ await fs.mkdir(artifactRoot, { recursive: true });
769
+ await writeFileAtomic(implementationPlanPath, jsonText(implementationPlan));
770
+ await writeFileAtomic(qaPlanPath, jsonText(qaPlan));
771
+ await writeFileAtomic(verdictPath, jsonText(verdict));
772
+ await writeFileAtomic(reportPath, `${report.trim()}\n`);
773
+ return {
774
+ phase: "architect",
775
+ workItemId: config.workItemId,
776
+ ...artifactReferenceOwnerFields(config),
777
+ repoPath: path.resolve(config.worktreePath),
778
+ path: verdictPath,
779
+ reportPath,
780
+ contentHash,
781
+ status,
782
+ producedBy
783
+ };
784
+ }
557
785
  async function writeQaGateCompletionArtifact(config, gate, input) {
558
786
  const payload = asRecord(input, "QA gate completion payload");
559
787
  const status = normalizeQaGateStatus(requiredString(payload, "status"));
@@ -709,6 +937,133 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
709
937
  producedBy
710
938
  };
711
939
  }
940
+ async function synthesizeQaCycleVerdictPayload(config, payload, status) {
941
+ const qaPlan = await readQaGateArtifact(config, "qa_plan");
942
+ const qaRegression = await readQaGateArtifact(config, "qa_regression");
943
+ const qaApi = await readQaGateArtifact(config, "qa_api");
944
+ const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
945
+ const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
946
+ const qaFindings = await readQaGateArtifact(config, "qa_findings");
947
+ const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
948
+ const payloadEvidence = qaEvidenceSource(payload);
949
+ const merged = { ...payload };
950
+ const checklist = qaStringArray(payload, "checklist");
951
+ if (checklist.length === 0) {
952
+ const plannedChecklist = qaStringArray(qaPlan, "checklist");
953
+ merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
954
+ }
955
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
956
+ if (testedAcceptanceCriteria.length === 0) {
957
+ const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
958
+ const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
959
+ const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
960
+ if (acceptanceCriteria.length > 0) {
961
+ merged.tested_acceptance_criteria = acceptanceCriteria;
962
+ }
963
+ }
964
+ const payloadCommands = qaArray(payload, "commands", payloadEvidence);
965
+ const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
966
+ const inheritedDiagnosticCommandNotes = status === "pass"
967
+ ? qaNonPassingCommandNotes(inheritedCommands)
968
+ : [];
969
+ merged.commands = dedupeUnknownArray([
970
+ ...payloadCommands,
971
+ ...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
972
+ ]);
973
+ merged.browser_checks = dedupeUnknownArray([
974
+ ...qaArray(payload, "browser_checks", payloadEvidence),
975
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
976
+ ]);
977
+ merged.sidecars = dedupeUnknownArray([
978
+ ...qaArray(payload, "sidecars", payloadEvidence),
979
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
980
+ ]);
981
+ const notes = dedupeStrings([
982
+ optionalString(payload, "notes"),
983
+ optionalString(payloadEvidence, "notes"),
984
+ ...evidenceGates.map((gate) => optionalString(gate, "notes")),
985
+ ...inheritedDiagnosticCommandNotes
986
+ ]);
987
+ if (notes.length > 0) {
988
+ merged.notes = notes.join("\n");
989
+ }
990
+ merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
991
+ merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
992
+ return merged;
993
+ }
994
+ async function readQaGateArtifact(config, gate) {
995
+ const ownerFields = artifactOwnerFields(config);
996
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
997
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
998
+ : artifactRootFor(config.worktreePath, config.workItemId);
999
+ const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
1000
+ const content = await readOptionalFile(artifactPath);
1001
+ return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
1002
+ }
1003
+ function dedupeUnknownArray(values) {
1004
+ const seen = new Set();
1005
+ const deduped = [];
1006
+ for (const value of values) {
1007
+ const key = stableStringify(value);
1008
+ if (seen.has(key)) {
1009
+ continue;
1010
+ }
1011
+ seen.add(key);
1012
+ deduped.push(value);
1013
+ }
1014
+ return deduped;
1015
+ }
1016
+ function isQaPassingCommandEvidence(value) {
1017
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1018
+ return false;
1019
+ }
1020
+ const record = value;
1021
+ return record.exit_code === 0 && normalizedString(record.status) === "pass";
1022
+ }
1023
+ function qaNonPassingCommandNotes(values) {
1024
+ return values
1025
+ .filter((value) => !isQaPassingCommandEvidence(value))
1026
+ .map((value, index) => {
1027
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1028
+ return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
1029
+ }
1030
+ const record = value;
1031
+ const command = optionalString(record, "command") ?? "<missing command>";
1032
+ const status = optionalString(record, "status") ?? "<missing status>";
1033
+ const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
1034
+ ? String(record.exit_code)
1035
+ : "<missing exit_code>";
1036
+ const summary = optionalString(record, "summary");
1037
+ return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
1038
+ });
1039
+ }
1040
+ function mergeQaObjectArraysById(primary, fallback) {
1041
+ const seen = new Set();
1042
+ const merged = [];
1043
+ for (const value of [...primary, ...fallback]) {
1044
+ const id = optionalString(value, "id");
1045
+ const key = id ? `id:${id}` : stableStringify(value);
1046
+ if (seen.has(key)) {
1047
+ continue;
1048
+ }
1049
+ seen.add(key);
1050
+ merged.push(value);
1051
+ }
1052
+ return merged;
1053
+ }
1054
+ function dedupeStrings(values) {
1055
+ const seen = new Set();
1056
+ const deduped = [];
1057
+ for (const value of values) {
1058
+ const trimmed = value?.trim();
1059
+ if (!trimmed || seen.has(trimmed)) {
1060
+ continue;
1061
+ }
1062
+ seen.add(trimmed);
1063
+ deduped.push(trimmed);
1064
+ }
1065
+ return deduped;
1066
+ }
712
1067
  async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
713
1068
  if (sidecars.length === 0) {
714
1069
  return [];
@@ -762,19 +1117,28 @@ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, o
762
1117
  }
763
1118
  return validated;
764
1119
  }
765
- export async function readCompletionArtifact(worktreePath, workItemId, phase) {
766
- const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
767
- const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
1120
+ export async function readCompletionArtifact(worktreePath, workItemId, phase, ownerType, ownerId) {
1121
+ const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId);
1122
+ const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId);
768
1123
  const verdictJson = await readOptionalFile(artifactPath);
769
1124
  if (verdictJson === null) {
770
1125
  return null;
771
1126
  }
772
1127
  const parsed = parseJsonObject(verdictJson, artifactPath);
773
1128
  const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
1129
+ const artifactRoot = path.dirname(artifactPath);
1130
+ const implementationPlanJson = phase === "architect"
1131
+ ? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
1132
+ : null;
1133
+ const qaPlanJson = phase === "architect"
1134
+ ? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
1135
+ : null;
774
1136
  return {
775
1137
  repoPath: path.resolve(worktreePath),
776
1138
  path: artifactPath,
777
1139
  verdictJson,
1140
+ implementationPlanJson,
1141
+ qaPlanJson,
778
1142
  report,
779
1143
  producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
780
1144
  status: optionalString(parsed, "status") ?? "unknown",
@@ -812,6 +1176,8 @@ export function parseCompletionMcpArgs(argv) {
812
1176
  let developCycle = false;
813
1177
  let qaCycle = false;
814
1178
  let debugCycle = false;
1179
+ let architectCycle = false;
1180
+ let draftCycle = false;
815
1181
  for (let index = 1; index < argv.length; index += 1) {
816
1182
  const arg = argv[index];
817
1183
  if (arg === "--worktree") {
@@ -833,7 +1199,7 @@ export function parseCompletionMcpArgs(argv) {
833
1199
  const rawPhase = argv[++index];
834
1200
  const parsedPhase = parseStrictCompletionPhase(rawPhase);
835
1201
  if (!parsedPhase) {
836
- return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, debug-repro, debug-root-cause, debug-fix, debug-regression." };
1202
+ return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, architect, draft, debug-repro, debug-root-cause, debug-fix, debug-regression." };
837
1203
  }
838
1204
  phase = parsedPhase;
839
1205
  }
@@ -849,6 +1215,12 @@ export function parseCompletionMcpArgs(argv) {
849
1215
  else if (arg === "--debug-cycle") {
850
1216
  debugCycle = true;
851
1217
  }
1218
+ else if (arg === "--architect-cycle") {
1219
+ architectCycle = true;
1220
+ }
1221
+ else if (arg === "--draft-cycle") {
1222
+ draftCycle = true;
1223
+ }
852
1224
  else {
853
1225
  return { ok: false, error: `Unknown mcp argument: ${arg}` };
854
1226
  }
@@ -856,7 +1228,7 @@ export function parseCompletionMcpArgs(argv) {
856
1228
  if (!worktreePath?.trim() || !workItemId?.trim()) {
857
1229
  return { ok: false, error: "mcp requires --worktree and --work-item-id." };
858
1230
  }
859
- if ([developCycle, qaCycle, debugCycle].filter(Boolean).length > 1) {
1231
+ if ([developCycle, qaCycle, debugCycle, architectCycle, draftCycle].filter(Boolean).length > 1) {
860
1232
  return { ok: false, error: "mcp accepts only one cycle mode." };
861
1233
  }
862
1234
  if (developCycle && !isDevelopCompletionPhase(phase)) {
@@ -868,6 +1240,12 @@ export function parseCompletionMcpArgs(argv) {
868
1240
  if (debugCycle && !isDebugCompletionPhase(phase)) {
869
1241
  return { ok: false, error: "mcp --debug-cycle requires --phase debug-repro, debug-root-cause, debug-fix, or debug-regression." };
870
1242
  }
1243
+ if (architectCycle && phase !== "architect") {
1244
+ return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
1245
+ }
1246
+ if (draftCycle && phase !== "draft") {
1247
+ return { ok: false, error: "mcp --draft-cycle requires --phase draft." };
1248
+ }
871
1249
  return {
872
1250
  ok: true,
873
1251
  config: {
@@ -880,7 +1258,9 @@ export function parseCompletionMcpArgs(argv) {
880
1258
  redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
881
1259
  developCycle,
882
1260
  qaCycle,
883
- debugCycle
1261
+ debugCycle,
1262
+ architectCycle,
1263
+ draftCycle
884
1264
  }
885
1265
  };
886
1266
  }
@@ -1018,6 +1398,24 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
1018
1398
  }
1019
1399
  return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
1020
1400
  }
1401
+ function completionToolNextInstructionForDraftGate(completedGate, status, nextGate) {
1402
+ if (status === "blocked") {
1403
+ return `Stay in ${completedGate}; record the blocker and retry the same Draft gate when unblocked.`;
1404
+ }
1405
+ if (!nextGate) {
1406
+ return "Draft proposal accepted. Forge has the structured Work Order draft JSON needed to populate the form.";
1407
+ }
1408
+ const instructions = {
1409
+ draft_intent: "Capture intent/non-goals, then call forge.complete_draft_intent.",
1410
+ draft_repo_context: "Inspect selected repo/project assumptions, then call forge.complete_draft_repo_context.",
1411
+ draft_graph_context: "Account for DAG dependencies and placement, then call forge.complete_draft_graph_context.",
1412
+ draft_product_scope: "Choose product scope for the selected project, then call forge.complete_draft_product_scope.",
1413
+ draft_work_order_plan: "Draft Work Order fields and candidate Feature boundaries, then call forge.complete_draft_work_order_plan.",
1414
+ draft_critic: "Critique literalism/wrong-project scope/decomposition risk, then call forge.complete_draft_critic.",
1415
+ draft_proposal: "Submit final draft_json through forge.complete_draft_proposal."
1416
+ };
1417
+ return `${completedGate} accepted. Continue to ${nextGate}. ${instructions[nextGate]}`;
1418
+ }
1021
1419
  function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
1022
1420
  if (!nextGate) {
1023
1421
  if (status === "blocked") {
@@ -1088,6 +1486,9 @@ function toolDescription(phase, qaGate = null) {
1088
1486
  if (phase === "qa") {
1089
1487
  return "Complete Forge QA by writing qa.plan.json, qa.evidence.json, qa.findings.json, qa.verdict.json, and qa.report.md with structured pass, needs-rework, or blocked evidence.";
1090
1488
  }
1489
+ if (phase === "architect") {
1490
+ return "Complete Forge Architect planning by writing implementation.plan.json, qa.plan.json, architect.verdict.json, and architect.report.md with a pending-approval plan or explicit revision/blocker reasons.";
1491
+ }
1091
1492
  return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
1092
1493
  }
1093
1494
  function toolInputSchema(phase, qaGate = null) {
@@ -1137,7 +1538,7 @@ function toolInputSchema(phase, qaGate = null) {
1137
1538
  },
1138
1539
  tests_added_or_updated: {
1139
1540
  type: "array",
1140
- description: "Required for pass. Use an object explaining why no tests were needed for docs/config-only work if empty would be ambiguous.",
1541
+ description: "Required for pass. Use an object explaining why no tests were needed for documentation/config-only work if empty would be ambiguous.",
1141
1542
  items: evidenceObjectSchema(["path", "summary"], {
1142
1543
  path: { type: "string" },
1143
1544
  summary: { type: "string" }
@@ -1168,6 +1569,11 @@ function toolInputSchema(phase, qaGate = null) {
1168
1569
  },
1169
1570
  next_phase_context: {
1170
1571
  description: "Required for pass. Context the self-review phase should carry forward."
1572
+ },
1573
+ resolved_findings: {
1574
+ type: "array",
1575
+ description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
1576
+ items: resolvedFindingSchema()
1171
1577
  }
1172
1578
  }, ["status", "summary"], "For status pass, include plan, changed_files, tests_added_or_updated, docs_added_or_updated, acceptance_criteria, known_gaps, and next_phase_context. Acceptance criteria entries must use status: satisfied plus evidence.");
1173
1579
  }
@@ -1303,6 +1709,83 @@ function toolInputSchema(phase, qaGate = null) {
1303
1709
  }
1304
1710
  }, ["status", "summary"], "For pass, include tested_acceptance_criteria plus concrete command/browser/note evidence. For needs_rework, include at least one actionable in_scope finding. For blocked, include blocker_reason.");
1305
1711
  }
1712
+ if (phase === "architect") {
1713
+ return objectToolSchema({
1714
+ status: {
1715
+ type: "string",
1716
+ enum: ["pass", "needs_revision", "blocked"],
1717
+ description: "Use pass only for a complete pending-approval Architect plan. Use needs_revision for fixable story/design/repo gaps, or blocked for missing required context or decisions."
1718
+ },
1719
+ summary: { type: "string", description: "Concise Architect plan verdict summary." },
1720
+ implementation_plan: {
1721
+ type: "object",
1722
+ description: "Required for pass. Top-level fields inside this object must include schema_version, plan_id, phases, tasks, dependencies, and phase_gates.",
1723
+ additionalProperties: true,
1724
+ properties: {
1725
+ schema_version: { type: "number", enum: [1] },
1726
+ plan_id: { type: "string" },
1727
+ phases: {
1728
+ type: "array",
1729
+ description: "Required top-level implementation_plan.phases array.",
1730
+ items: { type: "object", additionalProperties: true }
1731
+ },
1732
+ tasks: {
1733
+ type: "array",
1734
+ description: "Required Develop child task list. Each task must include id and a non-empty tests array, and must require a focused repo/product implementation change. Do not put parent-story QA/sign-off, child-count/materialization inventory, parent plan artifact checks, or final acceptance proof here; those belong in qa_plan.final_qa.",
1735
+ items: { type: "object", additionalProperties: true }
1736
+ },
1737
+ dependencies: {
1738
+ type: "array",
1739
+ description: "Required dependency edge array. Use [] when no task dependencies exist. Each edge should use before and after task ids; from/to are accepted only as aliases and normalized before writing artifacts.",
1740
+ items: {
1741
+ type: "object",
1742
+ additionalProperties: true,
1743
+ properties: {
1744
+ id: { type: "string" },
1745
+ before: { type: "string", description: "Task id that must complete first." },
1746
+ after: { type: "string", description: "Task id that depends on before." },
1747
+ from: { type: "string", description: "Alias for before." },
1748
+ to: { type: "string", description: "Alias for after." },
1749
+ type: { type: "string" },
1750
+ rationale: { type: "string" }
1751
+ }
1752
+ }
1753
+ },
1754
+ phase_gates: {
1755
+ type: "array",
1756
+ description: "Required non-empty phase gate array.",
1757
+ items: { type: "object", additionalProperties: true }
1758
+ }
1759
+ }
1760
+ },
1761
+ qa_plan: {
1762
+ type: "object",
1763
+ description: "Required for pass. Must include schema_version: 1 and non-empty final_qa coverage.",
1764
+ additionalProperties: true,
1765
+ properties: {
1766
+ schema_version: { type: "number", enum: [1] },
1767
+ final_qa: {
1768
+ type: "array",
1769
+ description: "Required non-empty final QA coverage array.",
1770
+ items: { type: "object", additionalProperties: true }
1771
+ }
1772
+ }
1773
+ },
1774
+ report_markdown: { type: "string", description: "Human-readable Architect report. Use report as an alias only when the client cannot send report_markdown." },
1775
+ report: { type: "string", description: "Alias for report_markdown." },
1776
+ blockers: baseProperties.blockers,
1777
+ revision_questions: {
1778
+ type: "array",
1779
+ description: "Required for needs_revision when blockers are not used.",
1780
+ items: { type: "string" }
1781
+ },
1782
+ revision_reasons: {
1783
+ type: "array",
1784
+ description: "Concrete reasons that prevent pass status.",
1785
+ items: { type: "string" }
1786
+ }
1787
+ }, ["status", "summary"], "For status pass, include implementation_plan and qa_plan at the top level of the MCP tool arguments. implementation_plan.phases must be a top-level array inside implementation_plan, not nested under another object.");
1788
+ }
1306
1789
  return objectToolSchema({
1307
1790
  ...baseProperties,
1308
1791
  commands: {
@@ -1382,8 +1865,8 @@ function debugToolInputSchema(phase, baseProperties) {
1382
1865
  },
1383
1866
  resolved_findings: {
1384
1867
  type: "array",
1385
- description: "Required for QA rework. Entries should include qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.",
1386
- items: { type: "object", additionalProperties: true }
1868
+ description: "Required for QA rework. Each entry must use status resolved, disputed, or not_reproduced.",
1869
+ items: resolvedFindingSchema()
1387
1870
  }
1388
1871
  }, ["status", "summary"], "For status pass, include changed_files, tests_added_or_updated, and remaining_risks.");
1389
1872
  }
@@ -1490,6 +1973,29 @@ function commandEvidenceSchema() {
1490
1973
  summary: { type: "string" }
1491
1974
  });
1492
1975
  }
1976
+ function resolvedFindingSchema() {
1977
+ return evidenceObjectSchema([
1978
+ "qa_id",
1979
+ "status",
1980
+ "summary",
1981
+ "evidence",
1982
+ "changed_files",
1983
+ "tests_added_or_updated",
1984
+ "regression_evidence"
1985
+ ], {
1986
+ qa_id: { type: "string" },
1987
+ status: {
1988
+ type: "string",
1989
+ enum: [...resolvedFindingStatusValues],
1990
+ description: "Use resolved when fixed, disputed when the QA finding is incorrect/out-of-scope, or not_reproduced when retesting cannot reproduce it."
1991
+ },
1992
+ summary: { type: "string" },
1993
+ evidence: { description: "Concrete evidence for the resolution." },
1994
+ changed_files: { type: "array", description: "Required and non-empty when status is resolved." },
1995
+ tests_added_or_updated: { type: "array", description: "Required and non-empty when status is resolved." },
1996
+ regression_evidence: { description: "Concrete regression evidence for this QA finding." }
1997
+ });
1998
+ }
1493
1999
  function acceptanceCriteriaSchema() {
1494
2000
  return {
1495
2001
  type: "array",
@@ -1524,6 +2030,13 @@ function normalizeCompletionStatus(value, phase) {
1524
2030
  }
1525
2031
  return canonical;
1526
2032
  }
2033
+ if (phase === "architect") {
2034
+ const canonical = normalized === "needsrevision" ? "needs_revision" : normalized;
2035
+ if (canonical !== "pass" && canonical !== "needs_revision" && canonical !== "blocked") {
2036
+ throw new Error("Architect status must be pass, needs_revision, or blocked.");
2037
+ }
2038
+ return canonical;
2039
+ }
1527
2040
  const canonical = normalized === "blocked" || normalized === "needsrework"
1528
2041
  ? "needs_rework"
1529
2042
  : normalized === "failed"
@@ -1551,6 +2064,10 @@ function validatePayloadShape(phase, payload) {
1551
2064
  validateQaPayloadShape(payload, status);
1552
2065
  return;
1553
2066
  }
2067
+ if (phase === "architect") {
2068
+ validateArchitectPayloadShape(payload, status);
2069
+ return;
2070
+ }
1554
2071
  const pass = status === "pass";
1555
2072
  if (!pass) {
1556
2073
  requireNonPassEvidence(payload);
@@ -1570,6 +2087,7 @@ function validatePayloadShape(phase, payload) {
1570
2087
  requireField(payload, "next_phase_context");
1571
2088
  requireImplementationEvidence(payload);
1572
2089
  requireSatisfiedAcceptanceCriteria(payload);
2090
+ validateResolvedFindings(payload);
1573
2091
  return;
1574
2092
  }
1575
2093
  if (phase === "self_review") {
@@ -1623,6 +2141,7 @@ function validateDebugPayloadShape(phase, payload) {
1623
2141
  if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
1624
2142
  throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
1625
2143
  }
2144
+ validateResolvedFindings(payload);
1626
2145
  return;
1627
2146
  }
1628
2147
  requireArray(payload, "commands");
@@ -1668,6 +2187,7 @@ function validateQaGatePayloadShape(gate, payload, status) {
1668
2187
  if (commands.length === 0) {
1669
2188
  throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
1670
2189
  }
2190
+ validateQaGatePassingCommands(commands);
1671
2191
  }
1672
2192
  if (gate === "qa_manual_web" && status === "pass") {
1673
2193
  const browserChecks = qaArray(payload, "browser_checks");
@@ -1677,6 +2197,297 @@ function validateQaGatePayloadShape(gate, payload, status) {
1677
2197
  }
1678
2198
  }
1679
2199
  }
2200
+ function validateQaGatePassingCommands(commands) {
2201
+ for (const [index, command] of commands.entries()) {
2202
+ requireEvidenceRecord(command, `commands[${index}]`);
2203
+ const record = command;
2204
+ requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
2205
+ requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
2206
+ requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
2207
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
2208
+ throw new Error(`commands[${index}].exit_code is required.`);
2209
+ }
2210
+ if (record.exit_code !== 0) {
2211
+ throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
2212
+ }
2213
+ if (normalizedString(record.status) !== "pass") {
2214
+ throw new Error(`commands[${index}].status must be pass when QA gate status is pass.`);
2215
+ }
2216
+ }
2217
+ }
2218
+ function validateArchitectPayloadShape(payload, status) {
2219
+ if (status !== "pass") {
2220
+ requireArchitectNonPassEvidence(payload);
2221
+ return;
2222
+ }
2223
+ const implementationPlan = requiredRecord(payload, "implementation_plan");
2224
+ const qaPlan = requiredRecord(payload, "qa_plan");
2225
+ requireNumericOne(implementationPlan, "schema_version", "implementation_plan.schema_version");
2226
+ requireNonEmptyStringRecord(implementationPlan, "plan_id", "implementation_plan.");
2227
+ requireArchitectArray(implementationPlan, "phases", payload);
2228
+ requireArchitectArray(implementationPlan, "tasks", payload);
2229
+ requireArchitectArray(implementationPlan, "dependencies", payload, { allowEmpty: true });
2230
+ requireArchitectArray(implementationPlan, "phase_gates", payload);
2231
+ if (arrayLength(implementationPlan, "tasks") === 0) {
2232
+ throw new Error("implementation_plan.tasks: expected non-empty array; correction: include at least one task with id and tests.");
2233
+ }
2234
+ const taskIds = new Set();
2235
+ for (const task of implementationPlan.tasks) {
2236
+ if (!task || typeof task !== "object" || Array.isArray(task)) {
2237
+ throw new Error("implementation_plan.tasks[]: expected object entries; correction: each task must be an object with id and tests.");
2238
+ }
2239
+ const taskRecord = task;
2240
+ requireNonEmptyStringRecord(taskRecord, "id", "implementation_plan.tasks[].");
2241
+ const taskId = optionalString(taskRecord, "id") ?? "";
2242
+ if (taskIds.has(taskId)) {
2243
+ throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
2244
+ }
2245
+ taskIds.add(taskId);
2246
+ validateArchitectTaskDevelopScope(taskRecord, taskId);
2247
+ requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
2248
+ if (arrayLength(taskRecord, "tests") === 0) {
2249
+ throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
2250
+ }
2251
+ }
2252
+ validateAndNormalizeArchitectDependencies(implementationPlan, taskIds);
2253
+ requireNumericOne(qaPlan, "schema_version", "qa_plan.schema_version");
2254
+ requireArchitectArray(qaPlan, "final_qa", payload, { path: "qa_plan.final_qa" });
2255
+ if (arrayLength(qaPlan, "final_qa") === 0) {
2256
+ throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
2257
+ }
2258
+ }
2259
+ function validateArchitectTaskDevelopScope(task, taskId) {
2260
+ const explicitScope = firstOptionalString(task, ["execution_scope", "scope", "task_scope", "task_type", "lane", "kind", "type"]);
2261
+ if (isStoryQaScope(explicitScope)) {
2262
+ throw new Error(`implementation_plan.tasks[${taskId}]: story QA/sign-off work must not be materialized as a Develop child task; correction: move this coverage to qa_plan.final_qa and keep implementation_plan.tasks limited to repo/product changes.`);
2263
+ }
2264
+ if (looksLikeStoryLevelVerificationTask(architectTaskScopeText(task))) {
2265
+ throw new Error(`implementation_plan.tasks[${taskId}]: parent-story materialization or sign-off verification belongs in qa_plan.final_qa, not implementation_plan.tasks; correction: keep child tasks focused on implementation work and put child-count/parent-plan artifact checks in final QA coverage.`);
2266
+ }
2267
+ }
2268
+ function firstOptionalString(record, keys) {
2269
+ for (const key of keys) {
2270
+ const value = optionalString(record, key);
2271
+ if (value) {
2272
+ return value;
2273
+ }
2274
+ }
2275
+ return null;
2276
+ }
2277
+ function isStoryQaScope(value) {
2278
+ if (!value) {
2279
+ return false;
2280
+ }
2281
+ const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
2282
+ return [
2283
+ "qa",
2284
+ "story_qa",
2285
+ "final_qa",
2286
+ "sign_off",
2287
+ "signoff",
2288
+ "story_sign_off",
2289
+ "story_verification",
2290
+ "story_validation"
2291
+ ].includes(normalized);
2292
+ }
2293
+ function architectTaskScopeText(task) {
2294
+ const parts = [];
2295
+ for (const key of ["id", "title", "summary", "phase", "phase_id"]) {
2296
+ appendOptionalString(parts, task, key);
2297
+ }
2298
+ for (const key of ["acceptance_criteria", "expected_paths", "touched_surfaces", "implementation_notes"]) {
2299
+ appendOptionalStringArray(parts, task, key);
2300
+ }
2301
+ const tests = task.tests;
2302
+ if (Array.isArray(tests)) {
2303
+ for (const test of tests) {
2304
+ if (!test || typeof test !== "object" || Array.isArray(test)) {
2305
+ continue;
2306
+ }
2307
+ const record = test;
2308
+ for (const key of ["id", "command", "purpose", "target", "expected"]) {
2309
+ appendOptionalString(parts, record, key);
2310
+ }
2311
+ appendOptionalStringArray(parts, record, "steps");
2312
+ }
2313
+ }
2314
+ return parts.join(" ");
2315
+ }
2316
+ function appendOptionalString(parts, record, key) {
2317
+ const value = optionalString(record, key);
2318
+ if (value) {
2319
+ parts.push(value);
2320
+ }
2321
+ }
2322
+ function appendOptionalStringArray(parts, record, key) {
2323
+ const value = record[key];
2324
+ if (!Array.isArray(value)) {
2325
+ return;
2326
+ }
2327
+ for (const entry of value) {
2328
+ if (typeof entry === "string" && entry.trim().length > 0) {
2329
+ parts.push(entry.trim());
2330
+ }
2331
+ }
2332
+ }
2333
+ function looksLikeStoryLevelVerificationTask(text) {
2334
+ if (!text.trim()) {
2335
+ return false;
2336
+ }
2337
+ const normalized = text.toLowerCase();
2338
+ const verificationWords = [
2339
+ "verify",
2340
+ "verification",
2341
+ "validate",
2342
+ "validation",
2343
+ "inspect",
2344
+ "audit",
2345
+ "check",
2346
+ "confirm",
2347
+ "prove",
2348
+ "evidence",
2349
+ "sign-off",
2350
+ "sign off"
2351
+ ];
2352
+ if (!verificationWords.some((word) => normalized.includes(word))) {
2353
+ return false;
2354
+ }
2355
+ return [
2356
+ "materialization",
2357
+ "materialized child",
2358
+ "child inventory",
2359
+ "child work-item",
2360
+ "child work item",
2361
+ "created task count",
2362
+ "created_task_count",
2363
+ "parent plan",
2364
+ "parent story",
2365
+ "story sign-off",
2366
+ "story sign off",
2367
+ ".forge/work-items",
2368
+ ".forge\\work-items",
2369
+ ".forge/stories",
2370
+ ".forge\\stories",
2371
+ "implementation.plan.json",
2372
+ "qa.plan.json",
2373
+ "architect plan artifact",
2374
+ "child spec"
2375
+ ].some((needle) => normalized.includes(needle));
2376
+ }
2377
+ function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
2378
+ const dependencies = implementationPlan.dependencies;
2379
+ const edges = new Map();
2380
+ for (const taskId of taskIds) {
2381
+ edges.set(taskId, []);
2382
+ }
2383
+ const normalizedDependencies = [];
2384
+ for (const [index, dependency] of dependencies.entries()) {
2385
+ if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
2386
+ throw new Error("implementation_plan.dependencies[]: expected object entries; correction: each dependency must be an object with before and after task ids.");
2387
+ }
2388
+ const record = dependency;
2389
+ const before = architectDependencyEndpoint(record, index, "before", "from");
2390
+ const after = architectDependencyEndpoint(record, index, "after", "to");
2391
+ if (!taskIds.has(before) || !taskIds.has(after)) {
2392
+ throw new Error("implementation_plan.dependencies: dependency references an unknown task id.");
2393
+ }
2394
+ const normalized = stripUndefined({
2395
+ ...record,
2396
+ before,
2397
+ after,
2398
+ from: undefined,
2399
+ to: undefined
2400
+ });
2401
+ normalizedDependencies.push(normalized);
2402
+ edges.get(before)?.push(after);
2403
+ }
2404
+ implementationPlan.dependencies = normalizedDependencies;
2405
+ if (hasArchitectDependencyCycle(edges)) {
2406
+ throw new Error("implementation_plan.dependencies: expected acyclic graph; correction: remove or revise cyclic task dependencies.");
2407
+ }
2408
+ }
2409
+ function architectDependencyEndpoint(dependency, index, canonicalKey, aliasKey) {
2410
+ const canonical = optionalString(dependency, canonicalKey);
2411
+ const alias = optionalString(dependency, aliasKey);
2412
+ if (canonical && alias && canonical !== alias) {
2413
+ throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: conflicts with ${aliasKey}; correction: use one task id for ${canonicalKey}.`);
2414
+ }
2415
+ const value = canonical ?? alias;
2416
+ if (!value) {
2417
+ throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: expected string; correction: use before and after task ids for each dependency.`);
2418
+ }
2419
+ return value;
2420
+ }
2421
+ function hasArchitectDependencyCycle(edges) {
2422
+ const visiting = new Set();
2423
+ const visited = new Set();
2424
+ const visit = (node) => {
2425
+ if (visited.has(node)) {
2426
+ return false;
2427
+ }
2428
+ if (visiting.has(node)) {
2429
+ return true;
2430
+ }
2431
+ visiting.add(node);
2432
+ for (const next of edges.get(node) ?? []) {
2433
+ if (visit(next)) {
2434
+ return true;
2435
+ }
2436
+ }
2437
+ visiting.delete(node);
2438
+ visited.add(node);
2439
+ return false;
2440
+ };
2441
+ for (const node of edges.keys()) {
2442
+ if (visit(node)) {
2443
+ return true;
2444
+ }
2445
+ }
2446
+ return false;
2447
+ }
2448
+ function requireArchitectNonPassEvidence(payload) {
2449
+ const evidenceKeys = ["blockers", "revision_questions", "revision_reasons", "known_gaps", "findings", "remaining_risks", "failures"];
2450
+ const hasEvidence = evidenceKeys.some((key) => {
2451
+ const value = payload[key];
2452
+ if (typeof value === "string") {
2453
+ return value.trim().length > 0;
2454
+ }
2455
+ if (Array.isArray(value)) {
2456
+ return value.length > 0;
2457
+ }
2458
+ return Boolean(value && typeof value === "object");
2459
+ });
2460
+ if (!hasEvidence) {
2461
+ throw new Error("status: non-pass Architect completion requires blockers, revision_questions, revision_reasons, findings, failures, or known_gaps; correction: explain why the plan cannot pass.");
2462
+ }
2463
+ }
2464
+ function requireArchitectArray(source, key, rootPayload, options = {}) {
2465
+ const pathLabel = options.path ?? `implementation_plan.${key}`;
2466
+ if (!Array.isArray(source[key])) {
2467
+ const nestedHint = architectNestedArrayHint(rootPayload, key);
2468
+ throw new Error(`${pathLabel}: expected array${key === "phases" ? " as a top-level field inside implementation_plan" : ""}; correction: ${nestedHint ?? `set ${pathLabel} to an array${options.allowEmpty ? " (use [] when none exist)" : " with at least one entry"}.`}`);
2469
+ }
2470
+ if (!options.allowEmpty && arrayLength(source, key) === 0) {
2471
+ throw new Error(`${pathLabel}: expected non-empty array; correction: include at least one entry.`);
2472
+ }
2473
+ }
2474
+ function architectNestedArrayHint(rootPayload, key) {
2475
+ if (Array.isArray(rootPayload[key])) {
2476
+ return `move top-level ${key} into implementation_plan.${key}.`;
2477
+ }
2478
+ const implementationPlan = rootPayload.implementation_plan;
2479
+ if (!implementationPlan || typeof implementationPlan !== "object" || Array.isArray(implementationPlan)) {
2480
+ return null;
2481
+ }
2482
+ const plan = implementationPlan;
2483
+ for (const container of ["plan", "dag", "implementation_dag", "implementationPlan", "implementation_plan"]) {
2484
+ const nested = plan[container];
2485
+ if (nested && typeof nested === "object" && !Array.isArray(nested) && Array.isArray(nested[key])) {
2486
+ return `move implementation_plan.${container}.${key} to implementation_plan.${key}.`;
2487
+ }
2488
+ }
2489
+ return null;
2490
+ }
1680
2491
  function validateQaPayloadShape(payload, status) {
1681
2492
  const evidenceSource = qaEvidenceSource(payload);
1682
2493
  const commands = qaArray(payload, "commands", evidenceSource);
@@ -1881,6 +2692,12 @@ function parseStrictCompletionPhase(value) {
1881
2692
  if (normalized === "qa" || normalized === "quality_assurance" || normalized === "qualityassurance") {
1882
2693
  return "qa";
1883
2694
  }
2695
+ if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
2696
+ return "architect";
2697
+ }
2698
+ if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
2699
+ return "draft";
2700
+ }
1884
2701
  return null;
1885
2702
  }
1886
2703
  function normalizeDebugCompletionPhase(value) {
@@ -1949,6 +2766,12 @@ function phaseFromToolName(toolName) {
1949
2766
  if (normalized === "forge.complete_qa" || normalized === "complete_qa") {
1950
2767
  return "qa";
1951
2768
  }
2769
+ if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
2770
+ return "architect";
2771
+ }
2772
+ if (draftGateFromToolName(toolName)) {
2773
+ return "draft";
2774
+ }
1952
2775
  const debugGate = debugGateFromToolName(toolName);
1953
2776
  if (debugGate) {
1954
2777
  return debugGate;
@@ -1958,6 +2781,16 @@ function phaseFromToolName(toolName) {
1958
2781
  }
1959
2782
  throw new Error(`Unknown Forge completion tool: ${toolName}`);
1960
2783
  }
2784
+ function draftGateFromToolName(toolName) {
2785
+ const normalized = toolName.trim().toLowerCase();
2786
+ for (const gate of draftCompletionGateOrder) {
2787
+ const base = draftGateToolNames[gate];
2788
+ if (normalized === `forge.${base}` || normalized === base) {
2789
+ return gate;
2790
+ }
2791
+ }
2792
+ return null;
2793
+ }
1961
2794
  function debugGateFromToolName(toolName) {
1962
2795
  const normalized = toolName.trim().toLowerCase();
1963
2796
  for (const gate of debugCompletionPhaseOrder) {
@@ -2067,11 +2900,20 @@ function requireObject(payload, key) {
2067
2900
  throw new Error(`${key} must be an object.`);
2068
2901
  }
2069
2902
  }
2903
+ function requiredRecord(payload, key) {
2904
+ requireObject(payload, key);
2905
+ return payload[key];
2906
+ }
2070
2907
  function requireBoolean(payload, key) {
2071
2908
  if (typeof payload[key] !== "boolean") {
2072
2909
  throw new Error(`${key} must be a boolean.`);
2073
2910
  }
2074
2911
  }
2912
+ function requireNumericOne(payload, key, pathLabel) {
2913
+ if (payload[key] !== 1) {
2914
+ throw new Error(`${pathLabel}: expected numeric 1; correction: set ${pathLabel} to 1.`);
2915
+ }
2916
+ }
2075
2917
  function requireSatisfiedAcceptanceCriteria(payload) {
2076
2918
  const criteria = payload.acceptance_criteria;
2077
2919
  if (!Array.isArray(criteria)) {
@@ -2089,6 +2931,37 @@ function requireSatisfiedAcceptanceCriteria(payload) {
2089
2931
  }
2090
2932
  }
2091
2933
  }
2934
+ function validateResolvedFindings(payload) {
2935
+ const resolvedFindings = payload.resolved_findings;
2936
+ if (resolvedFindings === undefined || resolvedFindings === null) {
2937
+ return;
2938
+ }
2939
+ if (!Array.isArray(resolvedFindings)) {
2940
+ throw new Error("resolved_findings must be an array.");
2941
+ }
2942
+ for (const [index, finding] of resolvedFindings.entries()) {
2943
+ const prefix = `resolved_findings[${index}].`;
2944
+ if (!finding || typeof finding !== "object" || Array.isArray(finding)) {
2945
+ throw new Error(`resolved_findings[${index}] must be an object.`);
2946
+ }
2947
+ const record = finding;
2948
+ requireNonEmptyStringRecord(record, "qa_id", prefix);
2949
+ requireKnownStringRecord(record, "status", resolvedFindingStatuses, prefix);
2950
+ requireNonEmptyStringRecord(record, "summary", prefix);
2951
+ requireSubstantiveValueRecord(record, "evidence", prefix);
2952
+ requireArrayRecord(record, "changed_files", prefix);
2953
+ requireArrayRecord(record, "tests_added_or_updated", prefix);
2954
+ requireSubstantiveValueRecord(record, "regression_evidence", prefix);
2955
+ if (normalizedString(record.status) === "resolved") {
2956
+ if (record.changed_files.length === 0) {
2957
+ throw new Error(`${prefix}changed_files must include at least one changed file for resolved QA findings.`);
2958
+ }
2959
+ if (record.tests_added_or_updated.length === 0) {
2960
+ throw new Error(`${prefix}tests_added_or_updated must include at least one regression test for resolved QA findings.`);
2961
+ }
2962
+ }
2963
+ }
2964
+ }
2092
2965
  function qaEvidenceSource(payload) {
2093
2966
  const evidence = payload.evidence;
2094
2967
  return evidence && typeof evidence === "object" && !Array.isArray(evidence)
@@ -2138,6 +3011,11 @@ function requireNonEmptyStringRecord(source, key, prefix = "") {
2138
3011
  throw new Error(`${prefix}${key} is required.`);
2139
3012
  }
2140
3013
  }
3014
+ function requireArrayRecord(source, key, prefix = "") {
3015
+ if (!Array.isArray(source[key])) {
3016
+ throw new Error(`${prefix}${key} must be an array.`);
3017
+ }
3018
+ }
2141
3019
  function requireKnownStringRecord(source, key, allowed, prefix = "") {
2142
3020
  const value = normalizedString(optionalString(source, key));
2143
3021
  if (!allowed.has(value)) {
@@ -2155,6 +3033,11 @@ function requireNonEmptyStringArrayRecord(source, key, prefix = "") {
2155
3033
  }
2156
3034
  }
2157
3035
  }
3036
+ function requireSubstantiveValueRecord(source, key, prefix = "") {
3037
+ if (!hasSubstantiveJsonValue(source[key])) {
3038
+ throw new Error(`${prefix}${key} must include at least one concrete evidence reference or summary.`);
3039
+ }
3040
+ }
2158
3041
  function requireSubstantiveEvidenceRecord(source, prefix = "") {
2159
3042
  const evidence = source.evidence;
2160
3043
  if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {