@tea-agent/loop-agent 0.43.0-next.18 → 0.43.0-next.19

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.
Files changed (161) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +60 -2308
  3. package/README.md +7 -0
  4. package/dist/application/task-lifecycle/advance.js +38 -7
  5. package/dist/build-stamp.json +3 -3
  6. package/dist/commands/init.js +13 -9
  7. package/dist/commands/task-advance.js +32 -0
  8. package/dist/executors/dag-pi-executor.js +1615 -188
  9. package/dist/executors/pi-executor.js +85 -3
  10. package/dist/executors/pi-sdk-executor.js +18 -0
  11. package/dist/executors/shell-executor.js +92 -7
  12. package/dist/shared/backend-dogfood-preflight.js +47 -0
  13. package/dist/shared/dag-failure-category.js +3 -0
  14. package/dist/shared/operator/capabilities.js +180 -0
  15. package/dist/task/config-types.js +29 -0
  16. package/dist/task/contract/project.js +9 -0
  17. package/dist/task/contract/schema.js +17 -0
  18. package/dist/task/source-prepare/build-draft.js +60 -0
  19. package/dist/worker/console/chat/pi-mode-loop-isolation.js +155 -0
  20. package/dist/worker/console/chat/pi-runtime/custom-tools/operator-tools.js +24 -16
  21. package/dist/worker/console/chat/pi-runtime/custom-tools/scheduled-goal-tools.js +22 -0
  22. package/dist/worker/console/chat/pi-runtime/progressive-tools.js +195 -0
  23. package/dist/worker/console/chat/pi-runtime.js +86 -27
  24. package/dist/worker/console/chat/routes.js +19 -0
  25. package/dist/worker/console/chat/scheduled-goal-booking.js +59 -0
  26. package/dist/worker/console/chat/scheduled-goal-delivery.js +27 -0
  27. package/dist/worker/console/chat/scheduled-goal-request.js +190 -0
  28. package/dist/worker/console/chat/session-mode.js +7 -3
  29. package/dist/worker/console/chat/session-store.js +42 -9
  30. package/dist/worker/console/chat/tool-preview.js +115 -0
  31. package/dist/worker/console/chat/turn-order.js +13 -0
  32. package/dist/worker/console/chat/turn-process.js +32 -30
  33. package/dist/worker/console/operator-actions.js +50 -0
  34. package/dist/worker/console/prd-intake-bridge.js +54 -1
  35. package/dist/worker/console/scheduled-goal-host.js +98 -0
  36. package/dist/worker/console/scheduled-goal-operation-adapter.js +127 -0
  37. package/dist/worker/console/server.js +15 -0
  38. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-B_UDknhR.js → abnfDiagram-N423BO3Z-8-j6y-sd.js} +1 -1
  39. package/dist/worker/console/static/assets/{arc-D6hU0drN.js → arc-eoQiMvuk.js} +1 -1
  40. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DmVikdRK.js → architectureDiagram-T3A2C74G-C4A3uMcI.js} +1 -1
  41. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DydABCRg.js → blockDiagram-VBNYF7ZC-D4zD2F-Q.js} +1 -1
  42. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DrXKC2Jl.js → c4Diagram-5PPSVZJV-j1RkJziL.js} +1 -1
  43. package/dist/worker/console/static/assets/channel-ChE7y-cx.js +1 -0
  44. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D2-qFxr7.js → chunk-2GRJ4B5K-YBHmhik1.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-ChfeF41P.js → chunk-2Q5K7J3B-COfWyo9P.js} +1 -1
  46. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Drwrz55x.js → chunk-5RXB4S5H-I99OUkHY.js} +1 -1
  47. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CAQGErL7.js → chunk-5VM5RSS4-XKxoJNJ4.js} +1 -1
  48. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DHO38w5U.js → chunk-6Q2QTUOP-DLT_cYx1.js} +1 -1
  49. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-EYIN0EOI.js → chunk-GF5L2VYU-CxcKZ9mV.js} +1 -1
  50. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CrFLKqkT.js → chunk-JWPE2WC7-V1EqXdjY.js} +1 -1
  51. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DMchffoo.js → chunk-KBJHAD2P-DebTtdFp.js} +1 -1
  52. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DaSvtKQa.js → chunk-RYQCIY6F-B-ivNRDf.js} +1 -1
  53. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DcW8W9Qj.js → chunk-XXDRQBXY-DC_Ds11b.js} +1 -1
  54. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C01TCf2X.js +1 -0
  55. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C01TCf2X.js +1 -0
  56. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BUirrMEi.js → cose-bilkent-JH36ORCC-sWEqKwIb.js} +1 -1
  57. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BXzO8iXR.js → cynefin-VYW2F7L2-BXa_dcu4.js} +1 -1
  58. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DAtgaxIn.js → cynefinDiagram-MW4NZA55-C-Mle84F.js} +1 -1
  59. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C1re9Noh.js → dagre-VZM6K2ZE-CXPDBITe.js} +1 -1
  60. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-N8Kj08J9.js → diagram-7IWD3JNH-CC-WJQfa.js} +1 -1
  61. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-D6BpSLs3.js → diagram-B4RE2ZJO-CDAV5Vs4.js} +1 -1
  62. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C2Vlgnl4.js → diagram-LBJQPF4R-CmFiAcNz.js} +1 -1
  63. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-CMbqtVLS.js → diagram-Q27KOJAE-DPBZHuyn.js} +1 -1
  64. package/dist/worker/console/static/assets/{diagram-UB23O5K3-A770Eb-0.js → diagram-UB23O5K3-jUlm_Ds3.js} +1 -1
  65. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-LV2w_2pT.js → ebnfDiagram-BXEA7PRR-DWhQ3mfY.js} +1 -1
  66. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-bwqf56ah.js → erDiagram-JOGREHBK-Tr2gMqet.js} +1 -1
  67. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BnVtHhZh.js → flowDiagram-UKHOOZJN-DJjVQHPA.js} +1 -1
  68. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BGVToEqc.js → ganttDiagram-PKOTCBZU-D74dQ4u0.js} +1 -1
  69. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DL2l7Vne.js → gitGraphDiagram-DS77QQ5N-DwW0tZ0X.js} +1 -1
  70. package/dist/worker/console/static/assets/index-BWkIfcrK.css +1 -0
  71. package/dist/worker/console/static/assets/{index-B_V4wvXs.js → index-Cdkvw_H6.js} +97 -97
  72. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BWIpubOx.js → infoDiagram-6WML65LV-ILCbxJyb.js} +1 -1
  73. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DjYBD8vv.js → ishikawaDiagram-WSZJBQD7-DeuWBQ89.js} +1 -1
  74. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-C0_mBaHb.js → journeyDiagram-NVQOT4AX-CF5ih8Fk.js} +1 -1
  75. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Cy71zbX6.js → kanban-definition-27J2QSJJ-C7yOSuRO.js} +1 -1
  76. package/dist/worker/console/static/assets/{linear-Dt3_w3Vn.js → linear-BDZ9riWi.js} +1 -1
  77. package/dist/worker/console/static/assets/{mermaid.core-DnNOlfEP.js → mermaid.core-7pKqYtpZ.js} +5 -5
  78. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DAbTspwq.js → mindmap-definition-FAOFIHXS-LeJDybSU.js} +1 -1
  79. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-NNiM141p.js → pegDiagram-VL7TDLO6-BJvT3pMD.js} +1 -1
  80. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CN80Z1Do.js → pieDiagram-7S7Q4E2Y-_rGqpMan.js} +1 -1
  81. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bxyfh0JU.js → quadrantDiagram-CIZ2JOQS-DPcSv5aA.js} +1 -1
  82. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BlWnC79M.js → railroadDiagram-AXF67PYL-Drxx4hkJ.js} +1 -1
  83. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BrEu4P_e.js → requirementDiagram-LRYGKXZP-BCTUdU4z.js} +1 -1
  84. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Bt70igBr.js → sankeyDiagram-W5VNT64P-B6wzbZmB.js} +1 -1
  85. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-C8DQR-r9.js → sequenceDiagram-SI44F4Z6-BGt8d3QQ.js} +1 -1
  86. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DEMinJAT.js → sizeCapture-X5ZJPWSS-7nlhJKo7.js} +1 -1
  87. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-DDdDBzQc.js → stateDiagram-OKZ733FA-Cv2stqMA.js} +1 -1
  88. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DC7V5vcq.js +1 -0
  89. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-G2wcBs3T.js → swimlanes-SLNWSIFB-qDAo4Yc1.js} +2 -2
  90. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bwy4QUTO.js +8 -0
  91. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Dzn4g2gJ.js → timeline-definition-Z64GVDOM-CKbDNKTr.js} +1 -1
  92. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-5v2rP9oO.js → vennDiagram-T6HMQDX7-DI-9EHic.js} +1 -1
  93. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-B4EOx7ew.js → wardleyDiagram-T6FBY63Y-BrzzRDpX.js} +1 -1
  94. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CQ1v8eYP.js → xychartDiagram-ELKLHX3M-BclH5hGh.js} +1 -1
  95. package/dist/worker/console/static/index.html +2 -2
  96. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +41 -11
  97. package/dist/worker/console/static-src/operator-chat/compaction-message.js +3 -17
  98. package/dist/worker/console/static-src/operator-chat/timeline-merge.js +29 -0
  99. package/dist/worker/console/static-src/operator-chat/useChatThread.js +2 -5
  100. package/dist/worker/console/workspace-context.js +11 -0
  101. package/dist/worker/observe/static/operator-chrome.js +3 -1
  102. package/dist/worker/scheduler/scheduled-goal-dispatch.js +117 -0
  103. package/dist/worker/scheduler/scheduled-goal-evidence.js +167 -0
  104. package/dist/worker/scheduler/scheduled-goal-recovery.js +62 -0
  105. package/dist/worker/scheduler/scheduled-goal-store.js +699 -0
  106. package/dist/worker/scheduler/scheduled-goal-supervisor.js +132 -0
  107. package/dist/worker/scheduler/scheduled-goal-time.js +102 -0
  108. package/dist/worker/scheduler/scheduled-goal-types.js +95 -0
  109. package/dist/workflows/dag/backend-test-markdown-workflow.js +6 -24
  110. package/dist/workflows/dag/backend-test-plan-protocol.js +82 -5
  111. package/dist/workflows/dag/dag-retry-schema.js +11 -0
  112. package/dist/workflows/dag/frontend-closeout.js +4 -2
  113. package/dist/workflows/dag/frontend-committed-facts.js +461 -0
  114. package/dist/workflows/dag/frontend-durable-tools.js +15 -3
  115. package/dist/workflows/dag/frontend-implementation-contract.js +365 -8
  116. package/dist/workflows/dag/frontend-plan-canary.js +53 -0
  117. package/dist/workflows/dag/frontend-plan-decision-contract.js +803 -0
  118. package/dist/workflows/dag/frontend-plan-render.js +0 -2
  119. package/dist/workflows/dag/frontend-prewrite-gate.js +3 -3
  120. package/dist/workflows/dag/frontend-provider-capability-matrix.js +8 -61
  121. package/dist/workflows/dag/frontend-recovery-run.js +57 -0
  122. package/dist/workflows/dag/frontend-review-context.js +43 -70
  123. package/dist/workflows/dag/frontend-risk.js +92 -10
  124. package/dist/workflows/dag/frontend-session-budget.js +117 -3
  125. package/dist/workflows/dag/frontend-shape.js +11 -55
  126. package/dist/workflows/dag/frontend-test-execution-evidence.js +35 -10
  127. package/dist/workflows/dag/frontend-typed-event-store.js +32 -25
  128. package/dist/workflows/dag/frontend-verification-trace.js +11 -2
  129. package/dist/workflows/dag/frontend-writer-admission.js +3 -47
  130. package/dist/workflows/dag/frontend-writer-status.js +0 -23
  131. package/dist/workflows/dag/init-hybrid.js +182 -185
  132. package/dist/workflows/dag/lifecycle.js +7 -2
  133. package/dist/workflows/dag/node-execution.js +48 -13
  134. package/dist/workflows/dag/rerun-plan.js +10 -0
  135. package/dist/workflows/dag/rerun-task.js +77 -1
  136. package/dist/workflows/dag/retry-policy.js +18 -0
  137. package/dist/workflows/dag/scheduler.js +2 -4
  138. package/dist/workflows/dag/types.js +23 -0
  139. package/docs/README.md +1 -0
  140. package/docs/init-surface.manifest.json +1 -0
  141. package/docs/operations/README.md +2 -0
  142. package/docs/templates/README.md +1 -1
  143. package/docs/templates/agent-dag.schema.json +2 -2
  144. package/docs/templates/backend-test-dag.json +14 -10
  145. package/docs/templates/frontend-implementation-contract.schema.json +0 -7
  146. package/docs/templates/frontend-implementation-dag.json +8 -9
  147. package/package.json +6 -3
  148. package/skills/frontend-bounded-implement/SKILL.md +3 -4
  149. package/skills/frontend-design-review/SKILL.md +7 -12
  150. package/skills/frontend-design-review/references/review-checklist.md +7 -7
  151. package/skills/frontend-plan/SKILL.md +8 -18
  152. package/skills/frontend-plan/references/decision-contract.md +19 -26
  153. package/skills/frontend-review/SKILL.md +21 -25
  154. package/skills/frontend-review/references/review-findings.md +48 -24
  155. package/dist/worker/console/static/assets/channel-Drecd94a.js +0 -1
  156. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-8Udu0t8-.js +0 -1
  157. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-8Udu0t8-.js +0 -1
  158. package/dist/worker/console/static/assets/index-DcudonhZ.css +0 -1
  159. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-CyCiS0Lt.js +0 -1
  160. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-U0lhi6N0.js +0 -8
  161. package/dist/workflows/dag/frontend-shadow-dual-write.js +0 -975
@@ -1,5 +1,3 @@
1
- import { collectFrontendPlanMissingFacts, collectFrontendPlanPhaseMissingFacts, committedFactFromPlanRecord, planFactStringList, planFactScopeIntersects } from "../workflows/dag/frontend-plan-completeness.js";
2
- export { collectFrontendPlanMissingFacts, collectFrontendPlanPhaseMissingFacts } from "../workflows/dag/frontend-plan-completeness.js";
3
1
  import { classifyFrontendPlanRecovery } from "../workflows/dag/frontend-plan-recovery-policy.js";
4
2
  import { collectFrontendExecutionGroups, frontendExecutionSchema } from "../workflows/dag/frontend-execution-groups.js";
5
3
  import { FRONTEND_SCOPE_TARGET_BYTES, packFrontendInputUnits, parseFrontendInputBlock, projectFrontendContractPrompt, projectFrontendInputScope } from "../workflows/dag/frontend-input-projection.js";
@@ -282,7 +280,17 @@ export const WRITER_BUDGET_EXHAUSTED_CATEGORY = "writer-budget-exhausted";
282
280
  export const WRITER_TOKEN_BUDGET = 2_000_000;
283
281
  export function allowsMissingChangedWriterOutcomeRecovery(task) {
284
282
  return (isBackendTestCompletenessRetryCandidate(task) ||
285
- isBackendTestPytestCollectionRepairOutcomeRecoveryCandidate(task));
283
+ isBackendTestPytestCollectionRepairOutcomeRecoveryCandidate(task) ||
284
+ // N5 can persist a valid incremental edit but omit the prose status line.
285
+ // Recover only the missing marker with a measured non-empty diff; the
286
+ // existing write guard and downstream backend validation still apply.
287
+ (task.id === "review-and-revise-backend-md-cases-pi" &&
288
+ task.executor === "pi" &&
289
+ task.role === "implementer" &&
290
+ task.toolProfile === "write" &&
291
+ task.writePolicy === "exclusive" &&
292
+ (task.writeSet?.length ?? 0) > 0 &&
293
+ task.writerOutcomePolicy?.type === "implementation-outcome-v1"));
286
294
  }
287
295
  function readWriterThinkingExhaustionEvidence(result) {
288
296
  const wider = result;
@@ -458,12 +466,16 @@ export function resolveDagPiStepName(task) {
458
466
  export const FRONTEND_CONTRACT_RECORD_TOOL_NAMES = [
459
467
  "record_requirement",
460
468
  "record_constraint",
469
+ // Model-owned execution grouping on already-confirmed canonical requirements.
470
+ "record_requirement_execution",
461
471
  "record_evidence_expectation",
462
472
  "record_handoff_intent",
463
473
  "record_open_question",
464
474
  "record_split_proposal",
465
475
  "record_ui_state",
466
476
  "record_required_deliverables",
477
+ // OpenSpec disposition selection when the task declares spec roots.
478
+ "record_openspec_selection",
467
479
  "complete_contract_scope",
468
480
  ];
469
481
  export const FRONTEND_CONTRACT_TERMINAL_TOOL_NAMES = [
@@ -492,6 +504,27 @@ export const FRONTEND_PLAN_RECORD_TOOL_NAMES = [
492
504
  ];
493
505
  export const FRONTEND_PLAN_TERMINAL_TOOL_NAMES = ["finalize_plan"];
494
506
  export const FRONTEND_PLAN_ADOPT_TOOL_NAMES = ["adopt_staged_fact"];
507
+ /** Experimental decision-path Plan toolset (HARNESS_FRONTEND_PLAN_DECISION=1).
508
+ * These are the tool names `createFrontendPlanDecisionTools` registers as
509
+ * custom tools; the SDK activates a custom tool only when its name is in the
510
+ * session allowlist returned by `resolveDagPiToolNames`, so the decision path
511
+ * must return this exact set instead of the relationship-path plan tools.
512
+ * `finalize_decision` is finalize-loop-owned: the model calls it in-session
513
+ * after committing facts and repairs any pre-validation findings until it
514
+ * succeeds; the executor still invokes it after the session (an identical
515
+ * replay of a successful terminal) as the backstop. It is listed so durable
516
+ * activation/consistency checks see the full toolset. */
517
+ export const FRONTEND_DECISION_PLAN_TOOL_NAMES = [
518
+ "record_module_placement",
519
+ "record_reuse_decision",
520
+ "record_state_ownership",
521
+ "record_data_flow",
522
+ "retract_data_flow",
523
+ "record_api_mock_boundary",
524
+ "record_verification_focus",
525
+ "record_dependency",
526
+ "finalize_decision",
527
+ ];
495
528
  /** M5: `frontend-review-pi` emits its authoritative terminal verdict through
496
529
  * committed typed tools instead of the legacy JSON verdict parse. */
497
530
  export function isFrontendReviewTypedTerminalNode(task) {
@@ -549,9 +582,28 @@ export async function acceptCommittedTypedTerminalFact(runDir, nodeId) {
549
582
  if (!binding)
550
583
  return false;
551
584
  try {
552
- const { readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
553
- const records = await readCommittedOriginFacts(runDir, nodeId, binding.file);
554
- return records.some((record) => record.fact.kind === binding.kind);
585
+ const { readCommittedOriginFacts } = await import("../workflows/dag/frontend-committed-facts.js");
586
+ // The experimental decision Plan path commits its terminal into
587
+ // plan-decision-facts.jsonl (kind finalize_decision) instead of the
588
+ // relationship ledger plan-typed-facts.jsonl (kind finalize_plan). Accept
589
+ // either ledger for the plan node so a successful decision finalize with
590
+ // no trailing narrative text is not reclassified as empty-output.
591
+ const candidates = [binding];
592
+ if (nodeId === "frontend-plan-pi") {
593
+ candidates.push({ file: "plan-decision-facts.jsonl", kind: "finalize_decision" });
594
+ }
595
+ for (const candidate of candidates) {
596
+ try {
597
+ const records = await readCommittedOriginFacts(runDir, nodeId, candidate.file);
598
+ if (records.some((record) => record.fact.kind === candidate.kind)) {
599
+ return true;
600
+ }
601
+ }
602
+ catch {
603
+ // Missing alternative ledger is expected on the relationship path.
604
+ }
605
+ }
606
+ return false;
555
607
  }
556
608
  catch {
557
609
  return false;
@@ -561,6 +613,10 @@ export function resolveDagPiToolNames(task) {
561
613
  if (isFrontendReviewTypedTerminalNode(task)) {
562
614
  return [
563
615
  ...DAG_PI_READONLY_TOOLS,
616
+ // complete_review_scope comes from createFrontendReviewScopeProtocol;
617
+ // the Pi SDK activates custom tools only when their name is in this
618
+ // allowlist, and omitting it silently drops the checkpoint tool the
619
+ // segmented runner instructs the model to call before any terminal.
564
620
  "complete_review_scope",
565
621
  "record_review_finding",
566
622
  "approve_review",
@@ -570,6 +626,7 @@ export function resolveDagPiToolNames(task) {
570
626
  if (isFrontendDesignTypedTerminalNode(task)) {
571
627
  return [
572
628
  ...DAG_PI_READONLY_TOOLS,
629
+ // Same scope-checkpoint activation rule as the review node above.
573
630
  "complete_review_scope",
574
631
  "record_design_finding",
575
632
  "approve_design",
@@ -590,6 +647,16 @@ export function resolveDagPiToolNames(task) {
590
647
  return [...DAG_PI_READONLY_TOOLS, ...FRONTEND_SCOUT_EVIDENCE_TOOL_NAMES];
591
648
  }
592
649
  if (isFrontendPlanLedgerNode(task)) {
650
+ if (process.env.HARNESS_FRONTEND_PLAN_DECISION === "1") {
651
+ // Experimental decision path: expose exactly the decision toolset that
652
+ // createFrontendPlanDecisionTools registers. Relationship-path plan
653
+ // tools (record_route_selection … finalize_plan/adopt_staged_fact) are
654
+ // not present in that toolset, so returning them here would leave the
655
+ // decision tools unactivated (Pi SDK activates custom tools only when
656
+ // their name is in this allowlist) — the planner then cannot record
657
+ // module placements and finalize always reports unplaced-requirement.
658
+ return [...FRONTEND_DECISION_PLAN_TOOL_NAMES];
659
+ }
593
660
  return [
594
661
  // Plan is a decision-only node. Contract/scout own source and repository
595
662
  // discovery; omitting read tools prevents a planner from spending its
@@ -1116,6 +1183,112 @@ async function loadContractRequirementInheritance(runDir) {
1116
1183
  }
1117
1184
  return byId;
1118
1185
  }
1186
+ export async function resolveFrontendDecisionAuthority(input) {
1187
+ const inheritance = await loadContractRequirementInheritance(input.runDir);
1188
+ if (inheritance.size === 0)
1189
+ return undefined;
1190
+ const requirements = [...inheritance].map(([id, record]) => ({ id, expectedOutcome: record.text ?? id }));
1191
+ const executionGroups = collectFrontendExecutionGroups([...inheritance].map(([id, record]) => ({ id, execution: record.execution })));
1192
+ const behaviorGroups = executionGroups
1193
+ .filter(group => group.kind === "behavior")
1194
+ .map(group => ({ id: group.id, requirementIds: group.requirementIds, minimumEvidenceLevel: "mounted" }));
1195
+ // Contract `execution` is model-authored and optional (record_requirement
1196
+ // accepts it only when the contract model decided to attach a group). The
1197
+ // decision authority must be deterministic, so every requirement that did not
1198
+ // receive a behavior execution group still gets its own fallback group —
1199
+ // otherwise a contract run without execution metadata leaves all requirements
1200
+ // unplaced (r3: every attempt failed unplaced-requirement because behavior
1201
+ // groups were empty and the model's placement ids were unknown-behavior-group).
1202
+ {
1203
+ const groupedRequirementIds = new Set(behaviorGroups.flatMap(group => group.requirementIds));
1204
+ for (const requirement of requirements) {
1205
+ if (!groupedRequirementIds.has(requirement.id)) {
1206
+ behaviorGroups.push({
1207
+ id: requirement.id,
1208
+ requirementIds: [requirement.id],
1209
+ minimumEvidenceLevel: "mounted",
1210
+ });
1211
+ }
1212
+ }
1213
+ }
1214
+ // The frozen command directory (friendly ids like
1215
+ // verify-repo-quick-verification-2) is the vocabulary both the model and the
1216
+ // relationship plan reference. Freeze those ids here — never the opaque
1217
+ // canonicalKeys of the verification bundle (they encode cwd+args and are not
1218
+ // the ids `record_verification_focus` accepts; freezing them makes every
1219
+ // finalize fail with unknown-command for a model that correctly follows the
1220
+ // PRD/H-C vocabulary).
1221
+ const { deriveFrontendVerifyCommandDirectoryFromRun, deriveRequirementOwnershipFromRun } = await import("../workflows/dag/frontend-implementation-contract.js");
1222
+ const verifyDirectory = await deriveFrontendVerifyCommandDirectoryFromRun(input.runDir);
1223
+ const requirementOwnership = await deriveRequirementOwnershipFromRun(input.runDir);
1224
+ const { deriveFrontendInteractionIdsFromRun, deriveFrontendAllowedMockStrategiesFromRun } = await import("../workflows/dag/frontend-implementation-contract.js");
1225
+ const interactionIds = await deriveFrontendInteractionIdsFromRun(input.runDir);
1226
+ const allowedMockStrategies = await deriveFrontendAllowedMockStrategiesFromRun(input.runDir);
1227
+ const commandIds = new Set();
1228
+ for (const entry of verifyDirectory)
1229
+ commandIds.add(entry.commandId);
1230
+ const requirementSha256 = input.sourceBinding?.schemaVersion === 2
1231
+ ? (input.sourceBinding.inputDigest ?? "")
1232
+ : "";
1233
+ if (!/^[a-f0-9]{64}$/.test(requirementSha256))
1234
+ return undefined;
1235
+ return {
1236
+ sourceBinding: { requirementSha256 },
1237
+ observedRequirementSha256: requirementSha256,
1238
+ requirements,
1239
+ behaviorGroups,
1240
+ allowedPaths: input.task.allowedPaths ?? [],
1241
+ forbiddenPaths: input.task.forbiddenPaths ?? [],
1242
+ commands: [...commandIds].map(id => ({ id })),
1243
+ requirementOwnership,
1244
+ interactionIds: interactionIds.length > 0 ? interactionIds : undefined,
1245
+ allowedMockStrategies: allowedMockStrategies.length > 0 ? allowedMockStrategies : undefined,
1246
+ };
1247
+ }
1248
+ /** Decision-path Plan prompt. The relationship `subtask_prompt`
1249
+ * (record_state_registry / record_plan_verification_target / finalize_plan) must
1250
+ * NOT be reused: it names tools the decision toolset does not expose and never
1251
+ * states the behavior-group ids the decision schema requires. This prompt
1252
+ * injects the frozen authority so the model can fill `modulePlacements[].id` and
1253
+ * `verificationFocus[].behaviorGroupId` correctly. */
1254
+ export function buildFrontendDecisionPlanPrompt(authority) {
1255
+ return [
1256
+ "DECISION PLAN (experimental): record semantic decisions only. The runtime expands behavior-group membership and derives the per-AC relationship table; do not write requirement-to-file coverage yourself.",
1257
+ "Frozen requirements (cover each exactly once through its behavior group):",
1258
+ JSON.stringify(authority.requirements),
1259
+ "Behavior groups — use these exact ids for modulePlacements[].id and verificationFocus[].behaviorGroupId:",
1260
+ JSON.stringify(authority.behaviorGroups),
1261
+ "Frozen verification command ids — use one for verificationFocus[].commandId:",
1262
+ JSON.stringify(authority.commands.map(command => command.id)),
1263
+ [
1264
+ "Record with these typed tools (each takes an `entry` field):",
1265
+ '- record_module_placement: {"id": "<behavior group id>", "paths": ["<repo-relative file>"]}',
1266
+ '- record_state_ownership: {"state": "<name>", "owner": "<behavior group id>", "applicable": true, "expectedBehavior": "<behavior>"} (use notApplicableReason for a non-applicable state)',
1267
+ '- record_verification_focus: {"id": "VT-...", "behaviorGroupId": "<behavior group id>", "file": "<test file>", "commandId": "<frozen command id>", "evidenceLevel": "unit|mounted|real-integration"}',
1268
+ '- record_reuse_decision: {"symbol": "<component>", "decision": "reuse-existing|new", "evidence": ["<path>"], "purpose": "<state or interaction name it covers>", "covers": ["<extra ids>"]} (set stylingStrategy once on the first row when the task mandates a style contract)',
1269
+ '- record_api_mock_boundary: {"boundary": "<METHOD /path>", "mode": "real|mock|not-needed", "evidence": "<why>"} (boundary is the identity: same boundary + replace:true flips its mode; real/mock also carry fixture + consumer)',
1270
+ '- record_data_flow: {"interaction": "<name>", "source": "<behavior group id>", "trigger": "<event>", "expectedBehavior": "<behavior>"}',
1271
+ '- record_dependency: {"name": "<dependency or none>"}',
1272
+ ].join("\n"),
1273
+ "Every frozen requirement must belong to a behavior group, and every behavior group needs a module placement; a requirement with no placement fails finalize.",
1274
+ ...(authority.interactionIds?.length ? [
1275
+ `Frozen interaction id vocabulary — record_data_flow interaction names MUST be exactly these ids (renames are deterministic finalize rejects): [${authority.interactionIds.join(", ")}].`,
1276
+ ] : []),
1277
+ ...(authority.allowedMockStrategies?.length ? [
1278
+ `Frozen Mock strategy allowance — the contract mockApi.strategy must be one of [${authority.allowedMockStrategies.join(", ")}] (mode real→native, mock→browser-intercept, all boundaries not-needed→not-needed).${authority.allowedMockStrategies.length === 1 && authority.allowedMockStrategies[0] === "not-needed" ? ` This run allows ONLY "not-needed": record every remote boundary with mode "not-needed" (the run has no deterministic Mock verification; preserve the real integration gap) — mode "mock" or "real" is a deterministic finalize reject.` : ""}`,
1279
+ ] : []),
1280
+ ...(authority.requirementOwnership?.length ? [
1281
+ "Requirement-file ownership is machine-enforced at finalize (violations are deterministic rejects, not review opinions):",
1282
+ ...authority.requirementOwnership.map(entry => [
1283
+ `- ${entry.requirementIds.join(", ")}:`,
1284
+ entry.implementationFiles?.length ? ` implementation files allowed: [${entry.implementationFiles.join(", ")}]` : "",
1285
+ entry.verificationFiles?.length ? ` verification files allowed: [${entry.verificationFiles.join(", ")}]` : "",
1286
+ ].filter(Boolean).join("\n")),
1287
+ ] : []),
1288
+ "The requirements, execution constraints and prohibited wordings in the supplied input are authoritative review criteria, not suggestions: a component the task mandates by name must be recorded as its own decision=new reuse decision (never merged into an existing component), implementationTargets must only reference requirement-authorized files, and stylingStrategy must restate the mandated style contract verbatim without promising anything the input forbids.",
1289
+ "When all required facts are committed, call finalize_decision. On a failed finalize receipt, fix the listed record_* facts (resubmit a corrected row with the same identity and replace:true to replace it) and call finalize_decision again until it succeeds. Return no Markdown narrative.",
1290
+ ].join("\n\n");
1291
+ }
1119
1292
  /**
1120
1293
  * Resolve task-source citations from the source-fidelity ledger before the
1121
1294
  * planner starts. The planner names a frozen requirement id and one of its
@@ -1273,7 +1446,7 @@ export async function createFrontendPlanLedgerTools(input) {
1273
1446
  ]);
1274
1447
  const { loadTypedEventStore, readCommittedEvents, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
1275
1448
  const { adoptStagedFact, adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
1276
- const { assemblePlanPatchFromCommittedFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
1449
+ const { assemblePlanPatchFromCommittedFacts } = await import("../workflows/dag/frontend-committed-facts.js");
1277
1450
  let store = input.store;
1278
1451
  const attemptId = input.attemptId;
1279
1452
  let activeRequirementScope = [];
@@ -1289,7 +1462,7 @@ export async function createFrontendPlanLedgerTools(input) {
1289
1462
  description: "Optional. When omitted, the runtime derives it from the contract requirement. Prefer omitting it to keep this tool call small.",
1290
1463
  })),
1291
1464
  implementationTargets: stringArray,
1292
- verificationTargetIds: stringArray,
1465
+ verificationTargetIds: Type.Optional(stringArray),
1293
1466
  evidenceGap: Type.Optional(Type.Object({
1294
1467
  requirementId: optionalString,
1295
1468
  description: Type.String({}),
@@ -1403,7 +1576,7 @@ export async function createFrontendPlanLedgerTools(input) {
1403
1576
  });
1404
1577
  async function adoptPlanFact(kind, requestId, fact) {
1405
1578
  // A+B (AC-005): a provider-capability fact kind reaching the plan ledger
1406
- // is out of route — it belongs to the shadow provider capability channel,
1579
+ // is out of route — it belongs to the provider capability channel,
1407
1580
  // not the plan decision ledger. Route it through the frozen seven-kind
1408
1581
  // matrix and fail closed to `unsupported-provider-capability` instead of
1409
1582
  // silently widening the plan catalog.
@@ -1953,7 +2126,7 @@ export async function createFrontendPlanLedgerTools(input) {
1953
2126
  const recordPlanRequirementTool = defineTool({
1954
2127
  name: "record_plan_requirement",
1955
2128
  label: "record_plan_requirement",
1956
- description: "Commit one plan requirement entry (origin=plan plan-requirement fact). Call once per requirement. For a correction, re-submit the same id with replace=true; the ledger compiles the latest replacement. Entry carries id, implementationTargets, verificationTargetIds, and optional expectedOutcome (omit it — the runtime derives the outcome text from the contract requirement). IMPORTANT: batch up to 4 record_* calls per assistant message; never batch more than 4 — a larger single message risks output truncation under a small output window. Example: {\"entry\": {\"id\": \"<AC-XXX>\", \"implementationTargets\": [\"<deliverable file>\"], \"verificationTargetIds\": [\"<VT-XXX>\"]}}",
2129
+ description: "Commit one plan requirement entry (origin=plan plan-requirement fact). Call once per requirement. Verification targets are authoritative in record_plan_verification_target; omit verificationTargetIds here unless repairing legacy input. For a correction, re-submit the same id with replace=true; the ledger compiles the latest replacement. Entry carries id, implementationTargets, and optional expectedOutcome (omit it — the runtime derives the outcome text from the contract requirement). IMPORTANT: batch up to 4 record_* calls per assistant message; never batch more than 4 — a larger single message risks output truncation under a small output window. Example: {\"entry\": {\"id\": \"<AC-XXX>\", \"implementationTargets\": [\"<deliverable file>\"]}}",
1957
2130
  promptSnippet: "Commit 1-4 plan requirement entries (up to 4 per message).",
1958
2131
  parameters: Type.Object({
1959
2132
  entry: requirementSchema,
@@ -2047,7 +2220,7 @@ export async function createFrontendPlanLedgerTools(input) {
2047
2220
  const recordPlanGroupCoverageTool = defineTool({
2048
2221
  name: "record_plan_group_coverage", label: "record_plan_group_coverage",
2049
2222
  description: "Submit shared implementation/verification references for one declared execution group. Runtime expands to every canonical member and retains its full outcome and source bindings. A shared VT must actually verify each independent condition. Use per-requirement records for differences; never create UI for constraints or exclusions. replace:true explicitly revises the group.",
2050
- parameters: Type.Object({ id: Type.String({ minLength: 1 }), implementationTargets: stringArray, verificationTargetIds: stringArray, replace: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
2223
+ parameters: Type.Object({ id: Type.String({ minLength: 1 }), implementationTargets: stringArray, verificationTargetIds: Type.Optional(stringArray), replace: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
2051
2224
  async execute(callId, params, signal, onUpdate, ctx) {
2052
2225
  const group = executionGroups.find(g => g.id === params.id && g.kind !== "unclassified");
2053
2226
  if (!group || (activeRequirementScope.length && group.requirementIds.some(id => !activeRequirementScope.includes(id))))
@@ -2056,11 +2229,11 @@ export async function createFrontendPlanLedgerTools(input) {
2056
2229
  for (const id of group.requirementIds) {
2057
2230
  const existing = readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "plan-requirement" && r.fact.entry?.id === id).at(-1)?.fact.entry;
2058
2231
  if (existing && !params.replace) {
2059
- if (JSON.stringify(existing.implementationTargets) !== JSON.stringify(params.implementationTargets) || JSON.stringify(existing.verificationTargetIds) !== JSON.stringify(params.verificationTargetIds))
2232
+ if (JSON.stringify(existing.implementationTargets) !== JSON.stringify(params.implementationTargets))
2060
2233
  return planToolReceipt({ ok: false, kind: "plan-requirement", code: "FACT_IDENTITY_CONFLICT", error: `${id}: existing coverage differs; use replace:true to revise explicitly` });
2061
2234
  continue;
2062
2235
  }
2063
- last = await recordPlanRequirementTool.execute(`${callId}:${id}`, { entry: { id, implementationTargets: params.implementationTargets, verificationTargetIds: params.verificationTargetIds }, ...(params.replace ? { replace: true } : {}) }, signal, onUpdate, ctx);
2236
+ last = await recordPlanRequirementTool.execute(`${callId}:${id}`, { entry: { id, implementationTargets: params.implementationTargets, ...(params.verificationTargetIds ? { verificationTargetIds: params.verificationTargetIds } : {}) }, ...(params.replace ? { replace: true } : {}) }, signal, onUpdate, ctx);
2064
2237
  if (!last.details?.ok)
2065
2238
  return last;
2066
2239
  }
@@ -2286,13 +2459,9 @@ export async function createFrontendPlanLedgerTools(input) {
2286
2459
  const finalizePlanTool = defineTool({
2287
2460
  name: "finalize_plan",
2288
2461
  label: "finalize_plan",
2289
- description: "Commit the finalize_plan terminal. Requirements, verification targets, and evidence gaps (optional) were already committed incrementally through record_plan_requirement / record_plan_verification_target / record_plan_evidence_gap; finalize_plan requires implementationSteps: [{requirementIds, action}] covering every frozen requirement (shared steps allowed), and assembles them with the ledger and optional remaining fields, publishes the canonical editable patch on a target-surface fact, and commits the terminal. A successful terminal commit occurs exactly once. If validation fails, correct only the reported facts and retry finalize.",
2290
- promptSnippet: "Commit finalize_plan with implementationSteps: [{requirementIds, action}] covering every frozen requirement; optional residualRisks / realIntegrationGap.",
2462
+ description: "Commit the finalize_plan terminal. Requirements, verification targets, and evidence gaps (optional) were already committed incrementally through record_plan_requirement / record_plan_verification_target / record_plan_evidence_gap; finalize_plan assembles them from the ledger together with these optional remaining fields, publishes the canonical editable patch on a target-surface fact, and commits the terminal. A successful terminal commit occurs exactly once. If validation fails, correct only the reported facts and retry finalize.",
2463
+ promptSnippet: "Commit the finalize_plan terminal (ledger fields + optional residualRisks / realIntegrationGap).",
2291
2464
  parameters: Type.Object({
2292
- implementationSteps: Type.Optional(Type.Array(Type.Object({
2293
- requirementIds: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
2294
- action: Type.String({ minLength: 1 }),
2295
- }, { additionalProperties: false }))),
2296
2465
  residualRisks: optionalStringArray,
2297
2466
  realIntegrationGap: optionalString,
2298
2467
  }, { additionalProperties: false }),
@@ -2309,23 +2478,8 @@ export async function createFrontendPlanLedgerTools(input) {
2309
2478
  if (missingData.length)
2310
2479
  return planToolReceipt({ ok: false, kind: "finalize_plan", code: "PLAN_DATA_FLOW_INCOMPLETE", error: missingData.map(f => f.reason).join("; ") });
2311
2480
  const fragment = assemblePlanPatchFromCommittedFacts(committed, contractInheritance) ?? {};
2312
- const requiredIds = new Set([
2313
- ...(input.requirementIds ?? []),
2314
- ...contractInheritance.keys(),
2315
- ...(Array.isArray(fragment.requirements) ? fragment.requirements.flatMap((entry) => isRecordObject(entry) && typeof entry.id === "string" ? [entry.id] : []) : []),
2316
- ]);
2317
- const steps = params?.implementationSteps ?? [];
2318
- const coveredIds = new Set(steps.flatMap(step => step.requirementIds));
2319
- const missingRequirementIds = [...requiredIds].filter(id => !coveredIds.has(id));
2320
- const unknownRequirementIds = [...coveredIds].filter(id => !requiredIds.has(id));
2321
- if (missingRequirementIds.length || unknownRequirementIds.length || steps.some(step => !step.action.trim())) {
2322
- return planToolReceipt({ ok: false, kind: "finalize_plan", code: "PLAN_IMPLEMENTATION_STEPS_INCOMPLETE",
2323
- error: `Submit finalize_plan with non-empty implementationSteps: [{requirementIds: [exact frozen IDs], action: "what to change and where"}]. Cover every requirement; shared steps may cover several IDs. Missing: ${missingRequirementIds.join(", ") || "none"}; unknown: ${unknownRequirementIds.join(", ") || "none"}. Actions must not be blank. Do not add a test plan.`,
2324
- });
2325
- }
2326
2481
  const patch = {
2327
2482
  ...fragment,
2328
- ...(steps.length ? { implementationSteps: steps.map(step => `[${[...new Set(step.requirementIds)].join(", ")}] ${step.action.trim()}`) } : {}),
2329
2483
  ...(params?.residualRisks
2330
2484
  ? { residualRisks: params.residualRisks }
2331
2485
  : {}),
@@ -2568,6 +2722,9 @@ export async function createFrontendPlanLedgerTools(input) {
2568
2722
  finalizePlanTool,
2569
2723
  ],
2570
2724
  });
2725
+ const durableFinalizePlanTool = durable.customTools.find((tool) => typeof tool === "object" && tool !== null && tool.name === "finalize_plan");
2726
+ if (!durableFinalizePlanTool)
2727
+ throw new Error("frontend plan durable finalize tool unavailable");
2571
2728
  return {
2572
2729
  customTools: [...durable.customTools, { ...readPlanFactsTool, execute: async (...args) => { await durable.flush(); return readPlanFactsTool.execute(...args); } }],
2573
2730
  adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
@@ -2636,6 +2793,17 @@ export async function createFrontendPlanLedgerTools(input) {
2636
2793
  ];
2637
2794
  },
2638
2795
  flush: durable.flush,
2796
+ finalizePlan: (params) => durableFinalizePlanTool.execute(`${attemptId}:auto-finalize-plan`, {
2797
+ ...(params?.residualRisks
2798
+ ? { residualRisks: [...params.residualRisks] }
2799
+ : {}),
2800
+ ...(params?.realIntegrationGap
2801
+ ? { realIntegrationGap: params.realIntegrationGap }
2802
+ : {}),
2803
+ }, undefined, undefined,
2804
+ // The wrapper passes this through to the typed tool; finalize itself
2805
+ // does not inspect the extension context.
2806
+ {}),
2639
2807
  committedFactCount: () => readCommittedEvents(store, attemptId).length,
2640
2808
  committedRequirementIds: () => {
2641
2809
  const ids = new Set();
@@ -2652,6 +2820,674 @@ export async function createFrontendPlanLedgerTools(input) {
2652
2820
  committedFacts: () => readCommittedEvents(store, attemptId),
2653
2821
  };
2654
2822
  }
2823
+ /** Translate derived-patch validation findings into decision-channel
2824
+ * vocabulary. Each zod issue path names a COMPILED patch array, but the model
2825
+ * authored record_* facts — so quote the offending entry's identity and name
2826
+ * the tool that owns it (r14: "uiComponentChoices.0.specReference.section:
2827
+ * Required" is unactionable when the model has never heard of specReference). */
2828
+ export function translateDecisionPatchFindings(message, decision) {
2829
+ const body = message.replace(/^invalid-output:\s*/, "");
2830
+ return body
2831
+ .split(/;\s*/)
2832
+ .map(issue => {
2833
+ const choice = issue.match(/uiComponentChoices\.(\d+)\.(.+)/);
2834
+ if (choice) {
2835
+ const row = decision.reuseDecisions[Number(choice[1])];
2836
+ const base = row
2837
+ ? `record_reuse_decision purpose "${row.purpose}" (component ${row.symbol}, decision ${row.decision}): ${issue}`
2838
+ : `record_reuse_decision row ${choice[1]}: ${issue}`;
2839
+ const field = choice[2];
2840
+ return field?.startsWith("specReference")
2841
+ ? `${base} — pass specSection (the AC id / task-source section it implements) on that entry, or use decision "reuse-existing"`
2842
+ : base;
2843
+ }
2844
+ const focus = issue.match(/verificationTargets\.(\d+)\.(.+)/);
2845
+ if (focus) {
2846
+ const row = decision.verificationFocus[Number(focus[1])];
2847
+ if (!row)
2848
+ return `record_verification_focus row ${focus[1]}: ${issue}`;
2849
+ return `record_verification_focus ${row.id}: ${issue} — behavior targets derive uiStates from record_state_ownership rows whose owner equals behaviorGroupId "${row.behaviorGroupId}"; record one for this group if missing`;
2850
+ }
2851
+ const state = issue.match(/uiStates\.(\d+)\.(.+)/);
2852
+ if (state) {
2853
+ const row = decision.stateOwnership[Number(state[1])];
2854
+ return row
2855
+ ? `record_state_ownership "${row.state}" (owner ${row.owner}): ${issue}`
2856
+ : `record_state_ownership row ${state[1]}: ${issue}`;
2857
+ }
2858
+ const interaction = issue.match(/interactions\.(\d+)\.(.+)/);
2859
+ if (interaction) {
2860
+ const row = decision.dataFlows[Number(interaction[1])];
2861
+ return row
2862
+ ? `record_data_flow "${row.interaction}": ${issue}`
2863
+ : `record_data_flow row ${interaction[1]}: ${issue}`;
2864
+ }
2865
+ const requirement = issue.match(/requirements\.(\d+)\.(.+)/);
2866
+ if (requirement) {
2867
+ return `record_module_placement row ${requirement[1]}: ${issue}`;
2868
+ }
2869
+ return issue;
2870
+ })
2871
+ .join("; ");
2872
+ }
2873
+ /** Read the recovery child's read-only parent decision snapshot (written by
2874
+ * the recovery continuation). Absent file → undefined (fresh plan). */
2875
+ export async function readParentDecisionSnapshot(runDir, nodeId) {
2876
+ const snapshotPath = path.join(runDir, nodeId, "parent-decision-snapshot.json");
2877
+ let raw;
2878
+ try {
2879
+ raw = await readFile(snapshotPath, "utf8");
2880
+ }
2881
+ catch (error) {
2882
+ if (error.code === "ENOENT")
2883
+ return undefined;
2884
+ throw error;
2885
+ }
2886
+ const parsed = JSON.parse(raw);
2887
+ if (!Array.isArray(parsed.facts))
2888
+ throw new Error(`parent decision snapshot malformed: ${snapshotPath}`);
2889
+ return parsed;
2890
+ }
2891
+ const decisionKindToToolName = (kind) => `record_${kind.replaceAll("-", "_")}`;
2892
+ /** D (local design repair): replay the parent's committed decision facts
2893
+ * through the child's durable tools — the normal commit entry with the
2894
+ * child's own binding; the finalize terminal is never inherited. Idempotent:
2895
+ * stable per-fact callIds make retries replay receipts instead of duplicating. */
2896
+ export async function replayParentDecisionSnapshot(snapshot, tools) {
2897
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
2898
+ for (const [index, fact] of snapshot.facts.entries()) {
2899
+ const toolName = decisionKindToToolName(fact.kind);
2900
+ const tool = byName.get(toolName);
2901
+ if (!tool) {
2902
+ throw new Error(`parent decision replay: no tool for fact kind "${fact.kind}"`);
2903
+ }
2904
+ const receipt = await tool.execute(`parent-replay:${index}`, {
2905
+ entry: fact.entry,
2906
+ });
2907
+ if (receipt.details?.ok !== true) {
2908
+ throw new Error(`parent decision replay failed for ${toolName} (identity ${JSON.stringify(fact.entry.purpose ??
2909
+ fact.entry.state ??
2910
+ fact.entry.interaction ??
2911
+ fact.entry.id ??
2912
+ fact.entry.boundary ??
2913
+ "?")}): ${receipt.details?.error ?? "unknown error"}`);
2914
+ }
2915
+ }
2916
+ }
2917
+ export async function createFrontendPlanDecisionTools(input) {
2918
+ const [{ Type }, { defineTool }, { assembleDecisionContractFromFacts, assembleExperimentPlanContract, buildFrontendPlanRelationshipPatch }] = await Promise.all([
2919
+ import("typebox"),
2920
+ import("@earendil-works/pi-coding-agent"),
2921
+ import("../workflows/dag/frontend-plan-decision-contract.js"),
2922
+ ]);
2923
+ const { readCommittedEvents } = await import("../workflows/dag/frontend-typed-event-store.js");
2924
+ const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
2925
+ const { createDurableFrontendTools } = await import("../workflows/dag/frontend-durable-tools.js");
2926
+ let store = input.store;
2927
+ const { attemptId, authority } = input;
2928
+ const optionalStringArray = Type.Optional(Type.Array(Type.String({ minLength: 1 })));
2929
+ const optionalString = Type.Optional(Type.String({ minLength: 1 }));
2930
+ // In-session pre-validation of the derived relationship patch. The
2931
+ // post-session bridge runs the identical analysis; running it first inside
2932
+ // finalize_decision turns findings into a bounded in-session correction
2933
+ // (fix facts, finalize again) instead of an attempt burn. Skipped when the
2934
+ // caller does not supply the runtime skeleton/sourceBinding.
2935
+ let requiredDeliverablesCache;
2936
+ const prevalidateDerivedPatch = async () => {
2937
+ if (!input.skeleton || !input.sourceBinding)
2938
+ return { ok: true, canonicalSha256: "", canonical: {} };
2939
+ const committed = readCommittedEvents(store, attemptId);
2940
+ const decision = assembleDecisionContractFromFacts(committed.map(record => record.fact));
2941
+ requiredDeliverablesCache ??= (async () => {
2942
+ const map = new Map();
2943
+ try {
2944
+ const { readTypedEventStoreFromJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
2945
+ const contractRecords = await readTypedEventStoreFromJsonl(path.join(input.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
2946
+ for (const record of [...contractRecords].reverse()) {
2947
+ const fact = record.fact;
2948
+ if (!fact || fact.origin !== "contract" || fact.kind !== "required-deliverables")
2949
+ continue;
2950
+ for (const item of Array.isArray(fact.items) ? fact.items : []) {
2951
+ if (!item || typeof item !== "object" || Array.isArray(item))
2952
+ continue;
2953
+ const requirementId = item.requirementId;
2954
+ const file = item.path;
2955
+ if (typeof requirementId !== "string" || typeof file !== "string")
2956
+ continue;
2957
+ const paths = map.get(requirementId) ?? [];
2958
+ paths.push(file);
2959
+ map.set(requirementId, paths);
2960
+ }
2961
+ break;
2962
+ }
2963
+ }
2964
+ catch {
2965
+ // Missing/unreadable contract ledger → no deliverable obligations.
2966
+ }
2967
+ return map;
2968
+ })();
2969
+ const concreteWriteSet = [
2970
+ ...new Set([
2971
+ ...decision.modulePlacements.flatMap(item => item.paths),
2972
+ ...decision.verificationFocus.map(item => item.file),
2973
+ ]),
2974
+ ];
2975
+ const { applyFrontendContractMergePatch, analyzeFrontendPlanPatchCandidate, FrontendContractFailure, PlanPolicyPrecheckFailure, serializeDeterministicJson, } = await import("../workflows/dag/frontend-implementation-contract.js");
2976
+ try {
2977
+ const patch = buildFrontendPlanRelationshipPatch({
2978
+ decision,
2979
+ authority,
2980
+ concreteWriteSet,
2981
+ requiredDeliverables: await requiredDeliverablesCache,
2982
+ });
2983
+ const merged = applyFrontendContractMergePatch(input.skeleton, patch);
2984
+ const analysis = await analyzeFrontendPlanPatchCandidate({
2985
+ runDir: input.runDir,
2986
+ rawContractText: serializeDeterministicJson(merged),
2987
+ sourceBinding: input.sourceBinding,
2988
+ });
2989
+ return {
2990
+ ok: true,
2991
+ canonicalSha256: createHash("sha256")
2992
+ .update(serializeDeterministicJson(analysis.canonical))
2993
+ .digest("hex"),
2994
+ canonical: analysis.canonical,
2995
+ };
2996
+ }
2997
+ catch (error) {
2998
+ if (error instanceof PlanPolicyPrecheckFailure) {
2999
+ // Template the fix: every uncovered interaction/state maps to a
3000
+ // fill-in record_reuse_decision row (the decision channel's
3001
+ // component-choice tool).
3002
+ const suggestions = error.findings
3003
+ .filter(finding => finding.code === "ui-design-coverage-missing" && finding.path)
3004
+ .map(finding => ({
3005
+ tool: "record_reuse_decision",
3006
+ args: {
3007
+ entry: {
3008
+ symbol: "<name the existing or new component>",
3009
+ decision: "reuse-existing",
3010
+ evidence: ["<existing repo file that proves this reuse>"],
3011
+ purpose: finding.path,
3012
+ },
3013
+ },
3014
+ }));
3015
+ const suggestionBlock = suggestions.length > 0
3016
+ ? ` Suggested record_* calls (copy, fill component, submit): ${JSON.stringify(suggestions)}`
3017
+ : "";
3018
+ return {
3019
+ ok: false,
3020
+ error: `finalize_decision pre-validation failed (fix the listed plan facts, then call finalize_decision again): ${error.message}${suggestionBlock}`,
3021
+ };
3022
+ }
3023
+ // FrontendContractFailure messages carry zod issue paths and are
3024
+ // translated into record_* vocabulary; the bridge compiler throws
3025
+ // decision-state-unbindable / decision-styling-strategy-conflict
3026
+ // diagnostics that are already model-fixable as written.
3027
+ const message = error instanceof Error ? error.message : String(error);
3028
+ const translated = error instanceof FrontendContractFailure
3029
+ ? translateDecisionPatchFindings(message, decision)
3030
+ : message;
3031
+ return {
3032
+ ok: false,
3033
+ error: `finalize_decision pre-validation failed (fix the listed facts with the named record_* tools — resubmit a corrected row with the same identity and replace:true to replace it — then call finalize_decision again): ${translated}`,
3034
+ };
3035
+ }
3036
+ };
3037
+ const planToolReceipt = (details) => ({
3038
+ content: [{ type: "text", text: JSON.stringify(details) }],
3039
+ details,
3040
+ });
3041
+ async function adoptDecisionFact(kind, requestId, fact) {
3042
+ try {
3043
+ const staged = stageTypedEventFact({
3044
+ store,
3045
+ requestId,
3046
+ attemptId,
3047
+ fact: fact,
3048
+ });
3049
+ const committed = await adoptTypedEventFact({
3050
+ store,
3051
+ requestId,
3052
+ attemptId,
3053
+ fact: fact,
3054
+ eventId: staged.eventId,
3055
+ expectedRevision: store.revision,
3056
+ });
3057
+ return { ok: true, kind, eventId: committed.eventId, revision: committed.revision, error: "" };
3058
+ }
3059
+ catch (error) {
3060
+ return {
3061
+ ok: false,
3062
+ kind,
3063
+ code: error?.code,
3064
+ error: error instanceof Error ? error.message : String(error),
3065
+ };
3066
+ }
3067
+ }
3068
+ const record = (name, label, description, entrySchema, gate) => defineTool({
3069
+ name,
3070
+ label,
3071
+ description,
3072
+ promptSnippet: `Record ${label}.`,
3073
+ parameters: Type.Object({ entry: entrySchema }, { additionalProperties: false }),
3074
+ async execute(_toolCallId, params) {
3075
+ const entry = params?.entry;
3076
+ const kind = name.replace("record_", "").replace(/_/g, "-");
3077
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
3078
+ return planToolReceipt({ ok: false, kind, error: `${name} requires a non-empty entry object` });
3079
+ }
3080
+ if (gate) {
3081
+ const rejected = await gate(entry);
3082
+ if (rejected)
3083
+ return rejected;
3084
+ }
3085
+ const result = await adoptDecisionFact(kind, `${attemptId}:${name}:${randomUUID()}`, { kind, origin: "plan", entry });
3086
+ return planToolReceipt(result);
3087
+ },
3088
+ });
3089
+ const recordModulePlacementTool = record("record_module_placement", "module placement", "Record one module placement: which behavior-group/module id maps to which concrete files. Example: {\"entry\": {\"id\": \"page\", \"paths\": [\"src/page.tsx\"]}}", Type.Object({ id: Type.String({ minLength: 1 }), paths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }) }, { additionalProperties: false }));
3090
+ const recordReuseDecisionTool = record("record_reuse_decision", "reuse decision", "Record one component decision: which component serves which UI state/interaction (purpose). decision \"reuse-existing\" = an existing repo component (evidence[0] = the repo file that proves it); \"specified\" = the frontend spec mandates it; \"new\" = the task source mandates a bounded addition — specified/new require specSection naming the spec/task-source declaration they implement (e.g. an AC id). Set stylingStrategy once (on the first call) when the task mandates a style contract. Resubmit the same purpose with replace:true to correct a row. Example: {\"entry\": {\"symbol\": \"Spinner\", \"decision\": \"reuse-existing\", \"evidence\": [\"src/ui/Spinner.tsx\"], \"purpose\": \"loading\"}} or {\"entry\": {\"symbol\": \"SmokeCounter\", \"decision\": \"new\", \"evidence\": [\"需求.md\"], \"purpose\": \"increment\", \"specSection\": \"AC-FE-002\", \"stylingStrategy\": \"compact, stable, animation-free single card\"}}", Type.Object({
3091
+ symbol: Type.String({ minLength: 1 }),
3092
+ decision: Type.Union([Type.Literal("specified"), Type.Literal("reuse-existing"), Type.Literal("new")]),
3093
+ evidence: Type.Array(Type.String()),
3094
+ specSection: Type.Optional(Type.String({ minLength: 1 })),
3095
+ purpose: Type.String({ minLength: 1 }),
3096
+ covers: optionalStringArray,
3097
+ rationale: optionalString,
3098
+ stylingStrategy: optionalString,
3099
+ }, { additionalProperties: false }), async (entry) => {
3100
+ // specified/new compile into a canonical specReference whose .section
3101
+ // the contract schema mandates; without the section the row can only
3102
+ // fail at finalize, so demand it here where the model can still act.
3103
+ if (entry.decision === "specified") {
3104
+ const evidenceList = Array.isArray(entry.evidence) ? entry.evidence : [];
3105
+ const evidencePath = typeof evidenceList[0] === "string" ? evidenceList[0] : "";
3106
+ const candidates = input.componentSpecCandidatePaths ?? [];
3107
+ if (candidates.length === 0) {
3108
+ return planToolReceipt({
3109
+ ok: false,
3110
+ kind: "reuse-decision",
3111
+ error: `record_reuse_decision specified-requires-spec-candidates: decision "specified" means a declared component spec mandates this choice, but this task declares no component spec candidates — use decision "new" (task-source-mandated addition, evidence[0] = the task-source file) or "reuse-existing"`,
3112
+ });
3113
+ }
3114
+ if (evidencePath && !candidates.includes(evidencePath)) {
3115
+ return planToolReceipt({
3116
+ ok: false,
3117
+ kind: "reuse-decision",
3118
+ error: `record_reuse_decision specified-spec-reference-outside-candidates: evidence[0] "${evidencePath}" is not a declared component spec candidate; declared candidates: [${candidates.join(", ")}]`,
3119
+ });
3120
+ }
3121
+ }
3122
+ if ((entry.decision === "specified" || entry.decision === "new") &&
3123
+ !(typeof entry.specSection === "string" && entry.specSection.trim())) {
3124
+ return planToolReceipt({
3125
+ ok: false,
3126
+ kind: "reuse-decision",
3127
+ error: `record_reuse_decision spec-section-required: decision "${entry.decision}" must name the spec/task-source declaration it implements via specSection (e.g. an AC id); use decision "reuse-existing" for existing repo conventions. Example: {"entry": {"symbol": "SmokeCounter", "decision": "new", "evidence": ["src/components/SmokeCounter.tsx"], "purpose": "render the counter card", "specSection": "AC-FE-001"}}`,
3128
+ });
3129
+ }
3130
+ return undefined;
3131
+ });
3132
+ const recordStateOwnershipTool = record("record_state_ownership", "state ownership", "Record one UI state and its owner, applicability and expected behavior. owner must equal the behaviorGroupId of the record_verification_focus entries it supports; every behavior group referenced by a verification focus needs at least one state row or finalize fails. Resubmit the same state name with replace:true to correct a row. Example: {\"entry\": {\"state\": \"loading\", \"owner\": \"page\", \"applicable\": true, \"expectedBehavior\": \"render loading\"}}", Type.Object({
3133
+ state: Type.String({ minLength: 1 }),
3134
+ owner: Type.String({ minLength: 1 }),
3135
+ applicable: Type.Boolean(),
3136
+ expectedBehavior: optionalString,
3137
+ notApplicableReason: optionalString,
3138
+ }, { additionalProperties: false }));
3139
+ const frozenInteractionIds = authority.interactionIds ?? [];
3140
+ const recordDataFlowTool = record("record_data_flow", "data flow", "Record one interaction data flow: source behavior group, trigger and expected behavior. One row per interaction name; resubmit the same interaction name to replace it. When a frozen interaction id vocabulary is declared, the interaction name MUST be one of the declared ids — wrong-named records are rejected at submission and stale ones are retracted via retract_data_flow. Example: {\"entry\": {\"interaction\": \"load\", \"source\": \"page\", \"trigger\": \"submit query\", \"expectedBehavior\": \"show loading then results\"}}", Type.Object({
3141
+ interaction: Type.String({ minLength: 1 }),
3142
+ source: Type.String({ minLength: 1 }),
3143
+ trigger: Type.String({ minLength: 1 }),
3144
+ expectedBehavior: Type.String({ minLength: 1 }),
3145
+ }, { additionalProperties: false }), frozenInteractionIds.length > 0
3146
+ ? async (entry) => {
3147
+ const name = typeof entry.interaction === "string" ? entry.interaction : "";
3148
+ if (name && !frozenInteractionIds.includes(name)) {
3149
+ return planToolReceipt({
3150
+ ok: false,
3151
+ kind: "data-flow",
3152
+ code: "INTERACTION_ID_NOT_DECLARED",
3153
+ error: `interaction "${name}" is not in the frozen vocabulary; allowed: [${frozenInteractionIds.join(", ")}]. Submit the declared id. To remove an already-recorded wrong interaction, call retract_data_flow with {"interaction": "${name}"}.`,
3154
+ });
3155
+ }
3156
+ return undefined;
3157
+ }
3158
+ : undefined);
3159
+ const retractDataFlowTool = defineTool({
3160
+ name: "retract_data_flow",
3161
+ label: "retract data flow",
3162
+ description: "Retract a previously recorded interaction data flow (e.g. one recorded under a wrong interaction id). The retracted record and every fact keyed to that interaction name stop participating in the compiled plan. Declared vocabulary ids cannot be retracted. Example: {\"interaction\": \"increment-counter\", \"reason\": \"recorded under a wrong id\"}",
3163
+ promptSnippet: "Retract one interaction data flow by interaction name.",
3164
+ parameters: Type.Object({
3165
+ interaction: Type.String({ minLength: 1 }),
3166
+ reason: Type.Optional(Type.String({ minLength: 1 })),
3167
+ }, { additionalProperties: false }),
3168
+ async execute(_toolCallId, params) {
3169
+ const interaction = typeof params?.interaction === "string" ? params.interaction : "";
3170
+ if (!interaction) {
3171
+ return planToolReceipt({ ok: false, kind: "data-flow", error: "retract_data_flow requires a non-empty interaction" });
3172
+ }
3173
+ if (frozenInteractionIds.includes(interaction)) {
3174
+ return planToolReceipt({ ok: false, kind: "data-flow", error: `interaction "${interaction}" is a declared vocabulary id and cannot be retracted` });
3175
+ }
3176
+ const result = await adoptDecisionFact("data-flow", `${attemptId}:retract_data_flow:${randomUUID()}`, { kind: "data-flow", origin: "plan", entry: { interaction, retracted: true, ...(typeof params?.reason === "string" ? { reason: params.reason } : {}) } });
3177
+ return planToolReceipt(result);
3178
+ },
3179
+ });
3180
+ const recordApiMockBoundaryTool = record("record_api_mock_boundary", "API/Mock boundary", "Record one API boundary and its mode (real/mock/not-needed) with evidence. The boundary is the stable identity: resubmit the same boundary with replace:true to change its mode (e.g. mock→real); the old decision is replaced, not appended. Real/mock endpoints require fixture (the test fixture path) and consumer (the implementation file that calls the API) — design policy rejects endpoints without them. Example: {\"entry\": {\"boundary\": \"GET /items\", \"mode\": \"mock\", \"evidence\": \"fixture only\", \"fixture\": \"test/fixtures/items.ts\", \"consumer\": \"src/page.tsx\"}}", Type.Object({
3181
+ boundary: Type.String({ minLength: 1 }),
3182
+ mode: Type.Union([Type.Literal("real"), Type.Literal("mock"), Type.Literal("not-needed")]),
3183
+ evidence: Type.String({ minLength: 1 }),
3184
+ fixture: optionalString,
3185
+ consumer: optionalString,
3186
+ }, { additionalProperties: false }), async (entry) => {
3187
+ // Design policy hard-requires fixture + consumer on every derived
3188
+ // endpoint (non-not-needed strategies); demand them here where the
3189
+ // model can still act instead of failing the compile post-session.
3190
+ if ((entry.mode === "real" || entry.mode === "mock") &&
3191
+ (!(typeof entry.fixture === "string" && entry.fixture.trim()) ||
3192
+ !(typeof entry.consumer === "string" && entry.consumer.trim()))) {
3193
+ return planToolReceipt({
3194
+ ok: false,
3195
+ kind: "api-mock-boundary",
3196
+ error: `record_api_mock_boundary fixture-and-consumer-required: ${entry.mode} boundary "${entry.boundary}" needs fixture (the test fixture path) and consumer (the implementation file that calls the API). Example: {"entry": {"boundary": "${entry.boundary}", "mode": "${entry.mode}", "evidence": "<why>", "fixture": "test/fixtures/items.ts", "consumer": "src/page.tsx"}}`,
3197
+ });
3198
+ }
3199
+ return undefined;
3200
+ });
3201
+ const recordVerificationFocusTool = record("record_verification_focus", "verification focus", "Record one behavior-group verification focus: which test file/command proves which behavior group at what evidence level. Every behaviorGroupId must have at least one record_state_ownership row whose owner equals it — declare one UI state per behavior group before finalizing. Example: {\"entry\": {\"id\": \"VT-1\", \"behaviorGroupId\": \"page\", \"file\": \"test/page.test.tsx\", \"commandId\": \"test\", \"evidenceLevel\": \"mounted\"}}", Type.Object({
3202
+ id: Type.String({ minLength: 1 }),
3203
+ behaviorGroupId: Type.String({ minLength: 1 }),
3204
+ file: Type.String({ minLength: 1 }),
3205
+ commandId: Type.String({ minLength: 1 }),
3206
+ evidenceLevel: Type.Union([Type.Literal("unit"), Type.Literal("mounted"), Type.Literal("real-integration")]),
3207
+ boundary: optionalString,
3208
+ }, { additionalProperties: false }), async (entry) => {
3209
+ // Mirror the relationship record_plan_verification_target boundary so
3210
+ // protocol errors surface in-node (bounded correction) instead of
3211
+ // burning attempts on an immutable committed fact that finalize rejects.
3212
+ // Duplicate/identity enforcement lives in the durable wrapper (FACT_IDENTITY_CONFLICT
3213
+ // unless replace:true) and the compile collapses same-id records to the
3214
+ // latest replacement, so no duplicate gate is needed here.
3215
+ const verificationCommandId = typeof entry.commandId === "string" ? entry.commandId.trim() : "";
3216
+ if (!verificationCommandId) {
3217
+ return planToolReceipt({
3218
+ ok: false,
3219
+ kind: "verification-focus",
3220
+ error: `record_verification_focus entry.commandId is required (received ${JSON.stringify(entry.commandId ?? null)}); pick one id from the frozen command directory in your prompt`,
3221
+ });
3222
+ }
3223
+ const { deriveFrontendVerifyCommandDirectoryFromRun, isFrontendTestFilePath } = await import("../workflows/dag/frontend-implementation-contract.js");
3224
+ const verifyDirectory = await deriveFrontendVerifyCommandDirectoryFromRun(input.runDir);
3225
+ if (verifyDirectory.length > 0) {
3226
+ const directoryEntry = verifyDirectory.find((candidate) => candidate.commandId === verificationCommandId);
3227
+ if (!directoryEntry) {
3228
+ return planToolReceipt({
3229
+ ok: false,
3230
+ kind: "verification-focus",
3231
+ error: `record_verification_focus verification-target-unknown-command: unknown commandId "${verificationCommandId}"; available frozen commands: [${verifyDirectory.map((candidate) => `${candidate.commandId} (${candidate.mode}: ${candidate.label})`).join(", ")}]`,
3232
+ });
3233
+ }
3234
+ if (directoryEntry.mode === "behavior" &&
3235
+ typeof entry.file === "string" &&
3236
+ !isFrontendTestFilePath(entry.file)) {
3237
+ return planToolReceipt({
3238
+ ok: false,
3239
+ kind: "verification-focus",
3240
+ error: `record_verification_focus verification-target-phase-mismatch: behavior command "${directoryEntry.label}" (${directoryEntry.commandId}) must bind a test file (__tests__/, tests?/, e2e/, cypress/, *.test.*, *.spec.*, *.cy.*); received file "${entry.file}"`,
3241
+ });
3242
+ }
3243
+ }
3244
+ // A behavior verification target derives its contract uiStates solely
3245
+ // from the committed state-ownership rows whose owner equals its
3246
+ // behaviorGroupId, and the relationship patch rejects a behavior
3247
+ // target with empty uiStates whenever the plan declares any state or
3248
+ // interaction. That rejection currently surfaces only in the
3249
+ // post-session bridge and burns the whole attempt, so enforce the
3250
+ // pairing here as a bounded in-session correction. A ledger with no
3251
+ // ownership and no data-flow facts stays exempt (pure-logic plan),
3252
+ // mirroring the contract-level exemption.
3253
+ const committedLedger = readCommittedEvents(store, attemptId);
3254
+ const ownershipRows = committedLedger.filter((event) => event.fact.kind === "state-ownership");
3255
+ const hasInteractionFacts = committedLedger.some((event) => event.fact.kind === "data-flow");
3256
+ if ((ownershipRows.length > 0 || hasInteractionFacts) &&
3257
+ !ownershipRows.some((event) => event.fact.entry?.owner ===
3258
+ entry.behaviorGroupId)) {
3259
+ return planToolReceipt({
3260
+ ok: false,
3261
+ kind: "verification-focus",
3262
+ error: `record_verification_focus undeclared-behavior-group-state: behavior group "${entry.behaviorGroupId}" (verification target ${entry.id}) has no record_state_ownership row; finalize derives each behavior target's UI states from ownership rows whose owner equals the behaviorGroupId, so record at least one for this group: {"state": "<name>", "owner": "${entry.behaviorGroupId}", "applicable": true, "expectedBehavior": "<what the state does>"}`,
3263
+ });
3264
+ }
3265
+ return undefined;
3266
+ });
3267
+ const recordDependencyTool = record("record_dependency", "dependency", "Record one dependency name. Example: {\"entry\": {\"name\": \"none\"}}", Type.Object({ name: Type.String({ minLength: 1 }) }, { additionalProperties: false }));
3268
+ const finalizeDecisionTool = defineTool({
3269
+ name: "finalize_decision",
3270
+ label: "finalize_decision",
3271
+ description: "Compile the committed decision facts into a decision contract, pre-validate the derived canonical contract, and commit the terminal. Fails closed on any safety finding or validation finding; correct the reported record_* facts and call finalize_decision again.",
3272
+ promptSnippet: "Compile and finalize the committed decision facts.",
3273
+ parameters: Type.Object({}, { additionalProperties: false }),
3274
+ async execute(_toolCallId) {
3275
+ const committed = readCommittedEvents(store, attemptId);
3276
+ const decision = assembleDecisionContractFromFacts(committed.map(record => record.fact));
3277
+ const expectedWriteSet = [
3278
+ ...decision.modulePlacements.flatMap(item => item.paths),
3279
+ ...decision.verificationFocus.map(item => item.file),
3280
+ ];
3281
+ const derived = assembleExperimentPlanContract({
3282
+ decision,
3283
+ authority,
3284
+ concreteWriteSet: expectedWriteSet,
3285
+ });
3286
+ if (derived.findings.length > 0) {
3287
+ return planToolReceipt({
3288
+ ok: false,
3289
+ kind: "finalize_decision",
3290
+ code: "DECISION_SAFETY_FINDINGS",
3291
+ error: `decision safety projection failed: ${derived.findings.join("; ")}`,
3292
+ });
3293
+ }
3294
+ const prevalidation = await prevalidateDerivedPatch();
3295
+ if (!prevalidation.ok) {
3296
+ return planToolReceipt({
3297
+ ok: false,
3298
+ kind: "finalize_decision",
3299
+ code: "DECISION_PREVALIDATION_FAILED",
3300
+ error: prevalidation.error,
3301
+ });
3302
+ }
3303
+ // Recovery-mode guard: a repair child that finalizes a plan identical
3304
+ // to its parent's has not performed the repair the findings demand —
3305
+ // the identical plan is exactly what admission rejected. Both hashes
3306
+ // use the same deterministic serializer over the canonical contract,
3307
+ // so only a real semantic change passes.
3308
+ // Recovery-mode guard: a repair child must demonstrably close the
3309
+ // parent findings before the run re-enters design review.
3310
+ if (input.parentDecisionSnapshot) {
3311
+ const { readParentCanonical, parentCanonicalContentSha256 } = await import("../workflows/dag/frontend-implementation-contract.js");
3312
+ const { extractRepairAssertions, evaluateRepairClosure, decisionIdentitySha256 } = await import("../workflows/dag/frontend-plan-decision-contract.js");
3313
+ const parentRunDir = path.join(input.runDir, "..", input.parentDecisionSnapshot.parentRunId);
3314
+ const parentCanonical = await readParentCanonical(parentRunDir);
3315
+ // 1. identical-plan guard. Decisions, not prose: a rationale-only
3316
+ // rewrite is not a repair (smoke r27 cleared a raw byte-hash guard
3317
+ // by editing two rationale strings and changing nothing else). The
3318
+ // byte hash stays as the fallback when the parent canonical cannot
3319
+ // be read.
3320
+ const parentSha = await parentCanonicalContentSha256(parentRunDir);
3321
+ const parentDecisions = parentCanonical
3322
+ ? decisionIdentitySha256(parentCanonical)
3323
+ : undefined;
3324
+ const childDecisions = decisionIdentitySha256(prevalidation.canonical);
3325
+ if (parentDecisions
3326
+ ? parentDecisions === childDecisions
3327
+ : parentSha !== undefined && parentSha === prevalidation.canonicalSha256) {
3328
+ return planToolReceipt({
3329
+ ok: false,
3330
+ kind: "finalize_decision",
3331
+ code: "DECISION_REPAIR_NO_CHANGE",
3332
+ error: "recovery finalize blocked: every structured decision row is identical to the parent run's (rationale-only edits do not count as a repair), but the review findings require changes. Correct the flagged rows (same identity, replace:true), then call finalize_decision again.",
3333
+ });
3334
+ }
3335
+ // 2. per-finding closure: every deterministic assertion extracted
3336
+ // from the parent findings must hold on the child canonical.
3337
+ const findings = input.parentDecisionSnapshot.designFindings ?? [];
3338
+ if (parentCanonical && findings.length > 0) {
3339
+ const assertions = extractRepairAssertions(findings, parentCanonical);
3340
+ const closure = evaluateRepairClosure(assertions, prevalidation.canonical, parentCanonical);
3341
+ if (!closure.closed) {
3342
+ return planToolReceipt({
3343
+ ok: false,
3344
+ kind: "finalize_decision",
3345
+ code: "DECISION_REPAIR_NOT_CLOSED",
3346
+ error: `recovery finalize blocked: ${closure.unmet.length} finding(s) are still not closed — ${closure.unmet.join("; ")}. Correct the flagged rows (same identity, replace:true), then call finalize_decision again.`,
3347
+ });
3348
+ }
3349
+ }
3350
+ }
3351
+ const result = await adoptDecisionFact("finalize_decision", `${attemptId}:finalize_decision:${randomUUID()}`, { kind: "finalize_decision", origin: "plan", findings: [] });
3352
+ return planToolReceipt(result);
3353
+ },
3354
+ });
3355
+ const durable = await createDurableFrontendTools({
3356
+ file: path.join(input.runDir, input.nodeId, "plan-decision-facts.jsonl"),
3357
+ attemptId,
3358
+ store: input.store,
3359
+ setWorkingStore: next => { store = next; },
3360
+ binding: { authority },
3361
+ tools: [
3362
+ recordModulePlacementTool,
3363
+ recordReuseDecisionTool,
3364
+ recordStateOwnershipTool,
3365
+ recordDataFlowTool,
3366
+ retractDataFlowTool,
3367
+ recordApiMockBoundaryTool,
3368
+ recordVerificationFocusTool,
3369
+ recordDependencyTool,
3370
+ finalizeDecisionTool,
3371
+ ],
3372
+ });
3373
+ const durableFinalizeDecisionTool = durable.customTools.find((tool) => typeof tool === "object" && tool !== null && tool.name === "finalize_decision");
3374
+ if (!durableFinalizeDecisionTool)
3375
+ throw new Error("frontend plan decision durable finalize tool unavailable");
3376
+ return {
3377
+ customTools: durable.customTools,
3378
+ flush: durable.flush,
3379
+ committedFacts: () => readCommittedEvents(store, attemptId),
3380
+ finalizeDecision: () => durableFinalizeDecisionTool.execute(`${attemptId}:auto-finalize-decision`, {}, undefined, undefined, {}),
3381
+ };
3382
+ }
3383
+ /**
3384
+ * Decision → relationship bridge: after a successful decision `finalize`
3385
+ * (kind finalize_decision committed in plan-decision-facts.jsonl) the plan
3386
+ * node ALSO publishes the relationship-shaped plan ledger
3387
+ * (`<runDir>/<nodeId>/plan-typed-facts.jsonl`) carrying a committed
3388
+ * `target-surface` patch fact + a `finalize_plan` terminal fact derived from
3389
+ * the committed decision facts. The unchanged R1 self-check
3390
+ * (`validateFrontendPlanPatchNodeOutput`) and every downstream shell
3391
+ * (`buildContractFromCommittedFacts`) read ONLY that ledger, so without this
3392
+ * bridge every decision run fails deterministically with "frontend plan
3393
+ * ledger missing" → invalid-output on all attempts.
3394
+ *
3395
+ * The patch is compiled by `buildFrontendPlanRelationshipPatch` and front-loads
3396
+ * the exact validation the relationship finalize receipt runs (merge onto the
3397
+ * runtime skeleton + analyzeFrontendPlanPatchCandidate). When the derived
3398
+ * canonical fails, this returns a fixable error and the executor turns it into
3399
+ * an invalid-output so the node retry ladder restarts the session with the
3400
+ * diagnostics instead of writing a ledger that R1 would reject.
3401
+ */
3402
+ export async function bridgeFrontendPlanDecisionToRelationshipLedger(input) {
3403
+ const { assembleDecisionContractFromFacts, buildFrontendPlanRelationshipPatch, } = await import("../workflows/dag/frontend-plan-decision-contract.js");
3404
+ const committed = input.facts
3405
+ .filter(record => record.phase === "committed")
3406
+ .map(record => record.fact);
3407
+ const decision = assembleDecisionContractFromFacts(committed);
3408
+ const concreteWriteSet = [
3409
+ ...new Set([
3410
+ ...decision.modulePlacements.flatMap(item => item.paths),
3411
+ ...decision.verificationFocus.map(item => item.file),
3412
+ ]),
3413
+ ];
3414
+ // Contract-declared deliverable obligations (the same ledger the analyzer
3415
+ // extracts `requiredDeliverables` from): bind each path to its requirement
3416
+ // so the derived canonical contract plans every contract-mandated file.
3417
+ const requiredDeliverables = new Map();
3418
+ try {
3419
+ const contractRecords = await readTypedEventStoreFromJsonl(path.join(input.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
3420
+ for (const record of [...contractRecords].reverse()) {
3421
+ const fact = record.fact;
3422
+ if (!fact || fact.origin !== "contract" || fact.kind !== "required-deliverables")
3423
+ continue;
3424
+ for (const item of Array.isArray(fact.items) ? fact.items : []) {
3425
+ if (!item || typeof item !== "object" || Array.isArray(item))
3426
+ continue;
3427
+ const requirementId = item.requirementId;
3428
+ const file = item.path;
3429
+ if (typeof requirementId !== "string" || typeof file !== "string")
3430
+ continue;
3431
+ const paths = requiredDeliverables.get(requirementId) ?? [];
3432
+ paths.push(file);
3433
+ requiredDeliverables.set(requirementId, paths);
3434
+ }
3435
+ break;
3436
+ }
3437
+ }
3438
+ catch {
3439
+ // Missing/unreadable contract ledger → no deliverable obligations to merge.
3440
+ }
3441
+ if (!input.skeleton || !input.sourceBinding) {
3442
+ return { ok: false, error: "frontend decision bridge requires the runtime skeleton and sourceBinding" };
3443
+ }
3444
+ let patch;
3445
+ try {
3446
+ const { analyzeFrontendPlanPatchCandidate, applyFrontendContractMergePatch, serializeDeterministicJson, } = await import("../workflows/dag/frontend-implementation-contract.js");
3447
+ patch = buildFrontendPlanRelationshipPatch({
3448
+ decision,
3449
+ authority: input.authority,
3450
+ concreteWriteSet,
3451
+ requiredDeliverables,
3452
+ });
3453
+ const merged = applyFrontendContractMergePatch(input.skeleton, patch);
3454
+ await analyzeFrontendPlanPatchCandidate({
3455
+ runDir: input.runDir,
3456
+ rawContractText: serializeDeterministicJson(merged),
3457
+ sourceBinding: input.sourceBinding,
3458
+ });
3459
+ }
3460
+ catch (error) {
3461
+ const raw = error instanceof Error ? error.message : String(error);
3462
+ return {
3463
+ ok: false,
3464
+ // This path should be rare now that finalize_decision pre-validates
3465
+ // the same patch in-session; keep the retry-prompt diagnostics in
3466
+ // decision-channel vocabulary regardless.
3467
+ error: `frontend decision → relationship patch failed pre-validation (correct the reported decision facts, then the retry ladder restarts the session): ${translateDecisionPatchFindings(raw, decision)}`,
3468
+ };
3469
+ }
3470
+ const { createTypedEventStore, stageTypedEventRecord, commitTypedEventRecord, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
3471
+ const file = path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl");
3472
+ const store = createTypedEventStore();
3473
+ const adopt = (kind, patchValue, revision) => {
3474
+ const eventId = `decision-bridge:${input.attemptId}:${kind}`;
3475
+ const fact = { kind, origin: "plan", patch: patchValue };
3476
+ stageTypedEventRecord(store, {
3477
+ eventId,
3478
+ requestId: eventId,
3479
+ attemptId: input.attemptId,
3480
+ fact,
3481
+ });
3482
+ return commitTypedEventRecord(store, eventId, revision);
3483
+ };
3484
+ adopt("target-surface", patch, 1);
3485
+ adopt("finalize_plan", patch, 2);
3486
+ // The ledger is derived wholesale from the committed decision facts, so a
3487
+ // re-run rewrites it atomically (no incremental append semantics needed).
3488
+ await writeTypedEventStoreJsonl(file, store.records.filter(record => record.phase === "committed"));
3489
+ return { ok: true, patch };
3490
+ }
2655
3491
  function isRecordObject(value) {
2656
3492
  return typeof value === "object" && value !== null && !Array.isArray(value);
2657
3493
  }
@@ -2865,6 +3701,52 @@ export async function createFrontendContractTools(input) {
2865
3701
  return receipt(result);
2866
3702
  },
2867
3703
  });
3704
+ // Requirement identity/text/source are runtime-owned and seeded before the
3705
+ // model starts. Execution grouping is still a model decision, so expose it
3706
+ // as a small typed update instead of forcing the model to re-submit the same
3707
+ // requirement just to attach execution metadata.
3708
+ const recordRequirementExecutionTool = defineTool({
3709
+ name: "record_requirement_execution",
3710
+ label: "record_requirement_execution",
3711
+ description: "Attach execution ownership to already-confirmed canonical requirements.",
3712
+ promptSnippet: "Record execution group metadata for confirmed requirements.",
3713
+ parameters: Type.Object({
3714
+ requirementIds: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, uniqueItems: true }),
3715
+ execution: Type.Object({
3716
+ groupId: Type.String({ minLength: 1 }),
3717
+ kind: Type.Union([Type.Literal("behavior"), Type.Literal("constraint"), Type.Literal("exclusion")]),
3718
+ summary: Type.String({ minLength: 1 }),
3719
+ }, { additionalProperties: false }),
3720
+ }, { additionalProperties: false }),
3721
+ async execute(callId, params) {
3722
+ const ids = params.requirementIds;
3723
+ if (activeScope && ids.some((id) => !activeScope?.has(id)))
3724
+ return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: "execution metadata references a requirement outside the active contract scope" });
3725
+ const canonical = input.canonicalRequirements;
3726
+ const unknown = ids.filter((id) => !canonical?.has(id));
3727
+ if (unknown.length > 0)
3728
+ return receipt({ ok: false, code: "CONTRACT_REFERENCE_UNKNOWN", error: `record_requirement_execution references unknown canonical requirements: ${unknown.join(", ")}` });
3729
+ const execution = frontendExecutionSchema.parse(params.execution);
3730
+ const existing = readCommittedEvents(store, attemptId)
3731
+ .filter((record) => record.fact.kind === "requirement")
3732
+ .map((record) => ({ id: String(record.fact.id), execution: record.fact.execution }))
3733
+ .filter((unit) => !ids.includes(unit.id));
3734
+ try {
3735
+ collectFrontendExecutionGroups([...existing, ...ids.map((id) => ({ id, execution }))]);
3736
+ }
3737
+ catch (error) {
3738
+ return receipt({ ok: false, code: "EXECUTION_GROUP_CONFLICT", error: error instanceof Error ? error.message : String(error) });
3739
+ }
3740
+ let result = { ok: true };
3741
+ for (const id of ids) {
3742
+ const requirement = canonical.get(id);
3743
+ result = await adoptContractFact("requirement", { kind: "requirement", origin: "contract", disposition: "explicit", id, text: requirement.text, sourceFragmentIds: requirement.sourceFragmentIds, execution });
3744
+ if (result.ok !== true)
3745
+ return receipt(result);
3746
+ }
3747
+ return receipt({ ...result, requestId: callId });
3748
+ },
3749
+ });
2868
3750
  // Authoritative UI state declarations: the contract node extracts the
2869
3751
  // PRD/reference UI-state table into structured facts so the planner binds
2870
3752
  // uiStates to declared ids instead of inventing names (dogfood
@@ -3078,6 +3960,7 @@ export async function createFrontendContractTools(input) {
3078
3960
  tools: [
3079
3961
  ...recordTools,
3080
3962
  recordRequirementTool,
3963
+ recordRequirementExecutionTool,
3081
3964
  recordEvidenceExpectationTool,
3082
3965
  recordUiStateTool,
3083
3966
  recordRequiredDeliverablesTool,
@@ -3086,9 +3969,23 @@ export async function createFrontendContractTools(input) {
3086
3969
  finalizeContractTool,
3087
3970
  ],
3088
3971
  });
3972
+ const durableRecordRequirementTool = durable.customTools.find((tool) => typeof tool === "object" && tool !== null && tool.name === "record_requirement");
3973
+ if (!durableRecordRequirementTool)
3974
+ throw new Error("frontend contract durable requirement tool unavailable");
3089
3975
  return {
3090
3976
  customTools: durable.customTools,
3091
3977
  inputRequirements: () => [...(input.canonicalRequirements ?? [])].map(([id, value]) => ({ id, text: value.text, sourceFragmentIds: [...value.sourceFragmentIds] })),
3978
+ seedCanonicalRequirements: async () => {
3979
+ for (const id of input.canonicalRequirements?.keys() ?? []) {
3980
+ if (committedRequirementIds().has(id))
3981
+ continue;
3982
+ const receipt = await durableRecordRequirementTool.execute(`${attemptId}:seed-requirement:${id}`, { id }, undefined, undefined, {});
3983
+ if (receipt.details?.ok !== true) {
3984
+ throw new Error(`runtime requirement seed rejected for ${id}`);
3985
+ }
3986
+ }
3987
+ await durable.flush();
3988
+ },
3092
3989
  completedScopeRequirementIds,
3093
3990
  setActiveRequirementScope: ids => { activeScope = ids === null ? null : new Set(ids); },
3094
3991
  committedRequirementIds,
@@ -3270,6 +4167,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3270
4167
  entrypoint: optionalString,
3271
4168
  routeOrMount: optionalString,
3272
4169
  implementationPaths: stringArray,
4170
+ proposedPaths: Type.Optional(stringArray),
3273
4171
  testPaths: stringArray,
3274
4172
  dataSource: optionalString,
3275
4173
  allowedPathConflicts: stringArray,
@@ -3279,12 +4177,18 @@ export async function createFrontendScoutEvidenceTools(input) {
3279
4177
  if (input.requirementIds && (!activeScope?.length || params.scopeId !== scopeIdentity(activeScope)))
3280
4178
  return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: "Use exactly the runtime Scout scopeId; discovery may complete only the supplied obligations" });
3281
4179
  const implementationPaths = params?.implementationPaths ?? [];
4180
+ const proposedPaths = params?.proposedPaths ?? [];
3282
4181
  const testPaths = params?.testPaths ?? [];
3283
4182
  const pathEvidence = await enrichScoutPathEvidence([
3284
4183
  ...(params?.entrypoint ? [params.entrypoint] : []),
3285
4184
  ...implementationPaths,
3286
4185
  ...testPaths,
3287
4186
  ]);
4187
+ for (const proposedPath of proposedPaths) {
4188
+ if (!pathEvidence.some((item) => item.path === proposedPath)) {
4189
+ pathEvidence.push({ path: proposedPath, sha256: "0".repeat(64), fresh: false, sourceDeclared: false, proposed: true });
4190
+ }
4191
+ }
3288
4192
  const surface = {
3289
4193
  kind: "target-surface",
3290
4194
  origin: "scout",
@@ -3292,6 +4196,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3292
4196
  entrypoint: params?.entrypoint ?? "",
3293
4197
  routeOrMount: params?.routeOrMount ?? "",
3294
4198
  implementationPaths,
4199
+ ...(proposedPaths.length > 0 ? { proposedPaths } : {}),
3295
4200
  testPaths,
3296
4201
  dataSource: params?.dataSource ?? "",
3297
4202
  allowedPathConflicts: params?.allowedPathConflicts ?? [],
@@ -3300,7 +4205,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3300
4205
  ...(pathEvidence.length > 0 ? { pathEvidence } : {}),
3301
4206
  };
3302
4207
  if (activeScope) {
3303
- const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-shadow-dual-write.js");
4208
+ const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-committed-facts.js");
3304
4209
  if (surface.completeness === "complete") {
3305
4210
  const check = readCompleteScoutTargetSurface([{ phase: "committed", fact: surface }]);
3306
4211
  if (!check.ok)
@@ -3314,7 +4219,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3314
4219
  const surfaces = [...new Set(input.requirementIds.map(id => current.get(id)))].map(f => f.surface);
3315
4220
  const union = (key) => [...new Set(surfaces.flatMap(s => Array.isArray(s[key]) ? s[key] : []))];
3316
4221
  const entries = [...new Set(surfaces.map(s => String(s.entrypoint ?? "")).filter(Boolean))];
3317
- return receipt(await adoptScoutFact("target-surface", { kind: "target-surface", origin: "scout", completeness: "complete", entrypoint: entries[0] ?? "", implementationPaths: [...new Set([...entries, ...union("implementationPaths")])], testPaths: union("testPaths"), allowedPathConflicts: union("allowedPathConflicts"), unresolvedPaths: union("unresolvedPaths"), routeOrMount: [...new Set(surfaces.map(s => s.routeOrMount).filter(Boolean))].join("\n"), dataSource: [...new Set(surfaces.map(s => s.dataSource).filter(Boolean))].join("\n"), pathEvidence: surfaces.flatMap(s => s.pathEvidence ?? []), ...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}) }));
4222
+ return receipt(await adoptScoutFact("target-surface", { kind: "target-surface", origin: "scout", completeness: "complete", entrypoint: entries[0] ?? "", implementationPaths: [...new Set([...entries, ...union("implementationPaths")])], proposedPaths: union("proposedPaths"), testPaths: union("testPaths"), allowedPathConflicts: union("allowedPathConflicts"), unresolvedPaths: union("unresolvedPaths"), routeOrMount: [...new Set(surfaces.map(s => s.routeOrMount).filter(Boolean))].join("\n"), dataSource: [...new Set(surfaces.map(s => s.dataSource).filter(Boolean))].join("\n"), pathEvidence: surfaces.flatMap(s => s.pathEvidence ?? []), ...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}) }));
3318
4223
  }
3319
4224
  return receipt(saved);
3320
4225
  }
@@ -3624,21 +4529,15 @@ const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
3624
4529
  readGitStatusPorcelain,
3625
4530
  recoverRootNulArtifact,
3626
4531
  };
3627
- /**
3628
- * M5 shadow pass for `frontend-review-pi`: extract the committed typed terminal
3629
- * facts from session events, parse the legacy JSON verdict from the response
3630
- * text, compare them (audit-only), and persist the audit artifact. Missing
3631
- * typed terminal facts fail the node closed (AC-001); a shadow mismatch never
3632
- * blocks the node.
4532
+ /** Validate the typed review terminal and complete review scopes. The typed
4533
+ * terminal is authoritative; legacy JSON verdict parsing is intentionally gone.
3633
4534
  */
3634
- async function runFrontendReviewTerminalShadow(input) {
4535
+ async function validateFrontendReviewTerminal(input) {
3635
4536
  // A provider/executor failure (in particular context-overflow) is already
3636
4537
  // authoritative. Do not rewrite it to review-terminal-missing merely
3637
4538
  // because no terminal tool could be submitted after the failed call.
3638
4539
  if (!input.mapped.ok)
3639
4540
  return input.mapped;
3640
- const { compareTypedReviewToLegacyJsonVerdict } = await import("../workflows/dag/frontend-review-context.js");
3641
- const { parseJsonReviewVerdict } = await import("../workflows/dag/output-protocol.js");
3642
4541
  let typedKinds = [];
3643
4542
  try {
3644
4543
  await input.tools?.flush();
@@ -3648,43 +4547,6 @@ async function runFrontendReviewTerminalShadow(input) {
3648
4547
  catch (error) {
3649
4548
  return { ...input.mapped, ok: false, failureCategory: "frontend-ledger-invalid", stderr: `FRONTEND_REVIEW_SCOPE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}` };
3650
4549
  }
3651
- let legacyVerdict;
3652
- try {
3653
- const parsed = parseJsonReviewVerdict(input.mapped.assistantText ?? input.mapped.stdout);
3654
- if (parsed.ok)
3655
- legacyVerdict = parsed.verdict;
3656
- }
3657
- catch {
3658
- legacyVerdict = undefined;
3659
- }
3660
- let comparison;
3661
- try {
3662
- comparison = compareTypedReviewToLegacyJsonVerdict({
3663
- typedKinds,
3664
- legacyVerdict,
3665
- });
3666
- }
3667
- catch (error) {
3668
- comparison = {
3669
- typedVerdict: undefined,
3670
- legacyVerdict: undefined,
3671
- match: false,
3672
- reason: `typed review equivalence comparison crashed: ${error instanceof Error ? error.message : String(error)}`,
3673
- };
3674
- }
3675
- // The durable ledger was validated above; this artifact is audit-only.
3676
- try {
3677
- await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-review-status.json", {
3678
- schemaVersion: 1,
3679
- nodeId: input.task.id,
3680
- typedKinds,
3681
- legacyVerdict,
3682
- comparison,
3683
- });
3684
- }
3685
- catch {
3686
- // best-effort audit artifact
3687
- }
3688
4550
  if (typedKinds.length === 0) {
3689
4551
  return {
3690
4552
  ...input.mapped,
@@ -3700,16 +4562,8 @@ async function runFrontendReviewTerminalShadow(input) {
3700
4562
  }
3701
4563
  return input.mapped;
3702
4564
  }
3703
- /**
3704
- * M8 shadow pass for `frontend-design-review-pi`: extract the committed typed
3705
- * design terminal facts from session events, flush them, and persist the audit
3706
- * artifact. The design review's legacy output was a first-line
3707
- * `VERDICT: pass|request-revision` text protocol (not a JSON verdict), so
3708
- * there is no JSON equivalence comparison here. Missing typed terminal facts
3709
- * fail the node closed; writer admission later reads the committed
3710
- * `design-typed-facts.jsonl` as the only authoritative verdict.
3711
- */
3712
- async function runFrontendDesignTerminalShadow(input) {
4565
+ /** Validate the typed design terminal and complete design scopes. */
4566
+ async function validateFrontendDesignTerminal(input) {
3713
4567
  // See the review counterpart above: a failed provider call cannot be
3714
4568
  // diagnosed as an omitted terminal tool call.
3715
4569
  if (!input.mapped.ok)
@@ -3723,17 +4577,6 @@ async function runFrontendDesignTerminalShadow(input) {
3723
4577
  catch (error) {
3724
4578
  return { ...input.mapped, ok: false, failureCategory: "frontend-ledger-invalid", stderr: `FRONTEND_REVIEW_SCOPE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}` };
3725
4579
  }
3726
- // The durable ledger was validated above; this artifact is audit-only.
3727
- try {
3728
- await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-design-status.json", {
3729
- schemaVersion: 1,
3730
- nodeId: input.task.id,
3731
- typedKinds,
3732
- });
3733
- }
3734
- catch {
3735
- // best-effort audit artifact
3736
- }
3737
4580
  if (typedKinds.length === 0) {
3738
4581
  return {
3739
4582
  ...input.mapped,
@@ -3816,15 +4659,185 @@ const FRONTEND_PLAN_SEGMENTS = [
3816
4659
  "Record only dependency policy and design-evidence conflicts. Do not record route, Mock/data, or requirement-local UX facts. Do not call finalize_plan.",
3817
4660
  ].join(" "),
3818
4661
  },
3819
- {
3820
- id: "finalize",
3821
- toolNames: null,
3822
- instruction: [
3823
- "PLAN PHASE — finalize.",
3824
- "All record_* tools are available only for a narrowly named correction if the finalize receipt reports missing or invalid facts. Otherwise call finalize_plan with implementationSteps: [{requirementIds, action}] covering every frozen requirement. Describe concrete changes within approved paths, including what constraints preserve; shared steps may cover related IDs. Do not add tests or a test plan.",
3825
- ].join(" "),
3826
- },
3827
4662
  ];
4663
+ function committedFactFromPlanRecord(value) {
4664
+ if (!value || typeof value !== "object" || Array.isArray(value))
4665
+ return undefined;
4666
+ const record = value;
4667
+ if (record.phase !== undefined && record.phase !== "committed")
4668
+ return undefined;
4669
+ const fact = record.fact;
4670
+ return fact && typeof fact === "object" && !Array.isArray(fact)
4671
+ ? fact
4672
+ : typeof record.kind === "string"
4673
+ ? record
4674
+ : undefined;
4675
+ }
4676
+ function planFactStringList(value) {
4677
+ if (!Array.isArray(value))
4678
+ return [];
4679
+ return value.filter((item) => typeof item === "string" && item.trim().length > 0);
4680
+ }
4681
+ function planFactScopeIntersects(fact, requirementIds) {
4682
+ return planFactStringList(fact.scopeRequirementIds).some((id) => requirementIds.has(id));
4683
+ }
4684
+ /** Compute the authoritative coverage queue from the committed plan ledger. */
4685
+ export function collectFrontendPlanMissingFacts(input) {
4686
+ const requirements = new Map();
4687
+ const standaloneEvidenceGaps = new Set();
4688
+ const verificationTargetIds = new Set();
4689
+ const verificationTargetRequirements = new Map();
4690
+ for (const value of input.committedFacts) {
4691
+ const fact = committedFactFromPlanRecord(value);
4692
+ if (!fact || fact.origin !== "plan")
4693
+ continue;
4694
+ if (fact.kind === "plan-requirement" && fact.entry && typeof fact.entry === "object") {
4695
+ const entry = fact.entry;
4696
+ if (typeof entry.id === "string" && entry.id.trim())
4697
+ requirements.set(entry.id, entry);
4698
+ }
4699
+ if (fact.kind === "plan-verification-target" && fact.entry && typeof fact.entry === "object") {
4700
+ const entry = fact.entry;
4701
+ const id = entry.id;
4702
+ if (typeof id === "string" && id.trim()) {
4703
+ verificationTargetIds.add(id);
4704
+ verificationTargetRequirements.set(id, new Set(Array.isArray(entry.requirementIds)
4705
+ ? entry.requirementIds.filter((value) => typeof value === "string")
4706
+ : []));
4707
+ }
4708
+ }
4709
+ if (fact.kind === "plan-evidence-gap" && fact.entry && typeof fact.entry === "object") {
4710
+ const entry = fact.entry;
4711
+ const requirementId = entry.requirementId;
4712
+ const description = entry.description;
4713
+ if (typeof requirementId === "string" && requirementId.trim() && typeof description === "string" && description.trim()) {
4714
+ standaloneEvidenceGaps.add(requirementId);
4715
+ }
4716
+ }
4717
+ }
4718
+ const missing = [];
4719
+ for (const id of input.requirementIds) {
4720
+ const entry = requirements.get(id);
4721
+ if (!entry) {
4722
+ missing.push({
4723
+ kind: "plan-requirement",
4724
+ id,
4725
+ requirementIds: [id],
4726
+ reason: `requirement ${id} has no committed plan-requirement fact`,
4727
+ });
4728
+ continue;
4729
+ }
4730
+ // Verification targets are the single authoritative direction. The
4731
+ // legacy requirement-side list is accepted only as a fallback while
4732
+ // resuming older ledgers; new plans derive it from target.requirementIds.
4733
+ const derivedTargetIds = [...verificationTargetRequirements.entries()]
4734
+ .filter(([, requirementIds]) => requirementIds.has(id))
4735
+ .map(([targetId]) => targetId);
4736
+ const legacyTargetIds = Array.isArray(entry.verificationTargetIds)
4737
+ ? entry.verificationTargetIds.filter((value) => typeof value === "string" && value.trim().length > 0)
4738
+ : [];
4739
+ const targetIds = derivedTargetIds.length > 0 ? derivedTargetIds : legacyTargetIds;
4740
+ const gap = entry.evidenceGap && typeof entry.evidenceGap === "object"
4741
+ ? entry.evidenceGap
4742
+ : undefined;
4743
+ const hasEvidenceGap = (typeof gap?.description === "string" && gap.description.trim().length > 0) ||
4744
+ standaloneEvidenceGaps.has(id);
4745
+ if (targetIds.length === 0 && !hasEvidenceGap) {
4746
+ missing.push({
4747
+ kind: "plan-verification-target",
4748
+ requirementIds: [id],
4749
+ reason: `requirement ${id} declares neither a verification target nor a non-empty evidenceGap`,
4750
+ });
4751
+ continue;
4752
+ }
4753
+ for (const targetId of targetIds) {
4754
+ if (!verificationTargetIds.has(targetId) ||
4755
+ !verificationTargetRequirements.get(targetId)?.has(id)) {
4756
+ missing.push({
4757
+ kind: "plan-verification-target",
4758
+ id: targetId,
4759
+ requirementIds: [id],
4760
+ reason: `requirement ${id} references verification target ${targetId}, but that target is not committed`,
4761
+ });
4762
+ }
4763
+ }
4764
+ }
4765
+ return missing;
4766
+ }
4767
+ /** Completeness checks for phases whose facts are committed incrementally. */
4768
+ export function collectFrontendPlanPhaseMissingFacts(input) {
4769
+ const facts = input.committedFacts
4770
+ .map(committedFactFromPlanRecord)
4771
+ .filter((fact) => Boolean(fact && fact.origin === "plan"));
4772
+ if (input.phase === "ux-registry") {
4773
+ return facts.some((fact) => fact.kind === "state-registry")
4774
+ ? []
4775
+ : [
4776
+ {
4777
+ kind: "state-registry",
4778
+ requirementIds: [...input.requirementIds],
4779
+ reason: "global UX vocabulary phase has no committed state-registry fact",
4780
+ },
4781
+ ];
4782
+ }
4783
+ if (input.phase === "ux-local") {
4784
+ const missing = [];
4785
+ // Evaluate each behaviour requirement independently. A fact scoped to AC-1
4786
+ // must not accidentally satisfy AC-2 merely because both ids share one
4787
+ // UX session; shared facts remain valid when they explicitly list both ids.
4788
+ for (const requirementId of input.requirementIds) {
4789
+ if (!input.behaviorRequiredRequirementIds?.includes(requirementId))
4790
+ continue;
4791
+ const scopedFacts = facts.filter((fact) => planFactScopeIntersects(fact, new Set([requirementId])));
4792
+ const hasChoice = scopedFacts.some((fact) => fact.kind === "component-choice" &&
4793
+ Array.isArray(fact.uiComponentChoices) &&
4794
+ fact.uiComponentChoices.length > 0);
4795
+ const canonicalStateFlow = collectCanonicalStateFlowNames(scopedFacts);
4796
+ const hasStateFlow = canonicalStateFlow.uiStateNames.size > 0 ||
4797
+ canonicalStateFlow.interactionNames.size > 0;
4798
+ if (!hasChoice) {
4799
+ missing.push({
4800
+ kind: "component-choice",
4801
+ requirementIds: [requirementId],
4802
+ reason: "behaviour-required UX slice has no committed component-choice fact",
4803
+ });
4804
+ }
4805
+ if (!hasStateFlow) {
4806
+ missing.push({
4807
+ kind: "state-flow",
4808
+ requirementIds: [requirementId],
4809
+ reason: "behaviour-required UX slice has no committed state-flow fact",
4810
+ });
4811
+ }
4812
+ }
4813
+ return missing;
4814
+ }
4815
+ const hasMockApi = facts.some((fact) => fact.kind === "mock-api");
4816
+ const allInteractions = collectCanonicalStateFlowNames(input.committedFacts).interactionNames;
4817
+ const liveInteractions = new Set([...collectCanonicalStateFlowNames(facts.filter(f => !planFactStringList(f.scopeRequirementIds).length || planFactScopeIntersects(f, new Set(input.requirementIds)))).interactionNames].filter(name => allInteractions.has(name)));
4818
+ const coveredInteractions = new Set(facts
4819
+ .filter((fact) => fact.kind === "data-flow")
4820
+ .flatMap((fact) => planFactStringList(fact.interactions)));
4821
+ const missing = [];
4822
+ if (!hasMockApi) {
4823
+ missing.push({
4824
+ kind: "mock-api",
4825
+ requirementIds: [...input.requirementIds],
4826
+ reason: "global Mock/data phase has no committed mock-api fact",
4827
+ });
4828
+ }
4829
+ for (const interaction of liveInteractions) {
4830
+ if (coveredInteractions.has(interaction))
4831
+ continue;
4832
+ missing.push({
4833
+ kind: "data-flow",
4834
+ id: interaction,
4835
+ requirementIds: [...input.requirementIds],
4836
+ reason: `interaction ${interaction} has no committed data-flow fact`,
4837
+ });
4838
+ }
4839
+ return missing;
4840
+ }
3828
4841
  /** Estimate calls conservatively: requirement + one VT, with a second VT
3829
4842
  * reserved for behaviour-required requirements. Explicit declarations win. */
3830
4843
  export function estimateFrontendPlanRequirementRecordCalls(fact) {
@@ -4051,6 +5064,7 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
4051
5064
  ? { routeOrMount: compactPromptString(surface.routeOrMount, 180) }
4052
5065
  : {}),
4053
5066
  implementationPaths: compactPromptStringArray(surface.implementationPaths),
5067
+ proposedPaths: compactPromptStringArray(surface.proposedPaths),
4054
5068
  testPaths: compactPromptStringArray(surface.testPaths),
4055
5069
  ...(compactPromptString(surface.dataSource, 180)
4056
5070
  ? { dataSource: compactPromptString(surface.dataSource, 180) }
@@ -4282,7 +5296,7 @@ function compactFrontendPlanLedgerContext(input) {
4282
5296
  continue;
4283
5297
  }
4284
5298
  if (fact.kind === "target-surface") {
4285
- compactFacts.push({ kind: fact.kind, routes: compactPromptStringArray(fact.routes, 24, 180) });
5299
+ compactFacts.push({ kind: fact.kind, routes: compactPromptStringArray(fact.routes, 24, 180), implementationPaths: compactPromptStringArray(fact.implementationPaths, 24, 180), proposedPaths: compactPromptStringArray(fact.proposedPaths, 24, 180), testPaths: compactPromptStringArray(fact.testPaths, 24, 180) });
4286
5300
  }
4287
5301
  }
4288
5302
  if (compactFacts.length === 0)
@@ -4330,7 +5344,7 @@ export async function runFrontendReviewSegmentedSessions(input) {
4330
5344
  protocol.setActiveScope(scopes.map(s => s.id));
4331
5345
  const finalScope = input.inventory.scopes.every(s => protocol.completedScopeIds().has(s.id) || scopes.some(current => current.id === s.id));
4332
5346
  const customTools = finalScope ? input.customTools : input.customTools.filter(t => !terminalKinds.includes(String(t.name)));
4333
- const prompt = `${input.basePrompt}\n<frontend_review_scope>\n${JSON.stringify({ semantics: "full", inventoryDigest: input.inventory.digest, scopes, previouslyCompleted: [...protocol.completedScopeIds()], savedFindings: protocol.committedFacts().filter(r => String(r.fact.kind).endsWith("-finding")).map(r => ({ id: r.fact.id, finding: r.fact.finding })) })}\n</frontend_review_scope>\nReview the complete supplied scopes, saving each finding immediately. Call complete_review_scope for each exact id only after all its independent permissions, thresholds, errors and evidence have been checked. ${finalScope ? "After every scope is complete, make one independent overall approve/request_changes decision; persisted findings cannot be omitted." : "More scopes remain. Do not finalize or reread already completed scopes unless resolving a cross-scope issue."}`;
5347
+ const prompt = `${input.basePrompt}\n<frontend_review_authority>\nThe Contract acceptance criteria, constraints, required deliverables, UI-state declarations, and verification expectations in the supplied input are authoritative. Review the actual diff and evidence against those facts; do not replace them with a Plan-derived interpretation.\n</frontend_review_authority>\n<frontend_review_scope>\n${JSON.stringify({ semantics: "full", inventoryDigest: input.inventory.digest, scopes, previouslyCompleted: [...protocol.completedScopeIds()], savedFindings: protocol.committedFacts().filter(r => String(r.fact.kind).endsWith("-finding")).map(r => ({ id: r.fact.id, finding: r.fact.finding })) })}\n</frontend_review_scope>\nReview the complete supplied scopes, saving each finding immediately. Call complete_review_scope for each exact id only after all its independent permissions, thresholds, errors and evidence have been checked. ${finalScope ? "After every scope is complete, make one independent overall approve/request_changes decision; persisted findings cannot be omitted." : "More scopes remain. Do not finalize or reread already completed scopes unless resolving a cross-scope issue."}`;
4334
5348
  if (Buffer.byteLength(prompt) > targetBytes && scopes.length > 1) {
4335
5349
  const at = Math.ceil(scopes.length / 2);
4336
5350
  queue.splice(index, 1, { scopes: scopes.slice(0, at), repairs: 0 }, { scopes: scopes.slice(at), repairs: 0 });
@@ -4360,8 +5374,30 @@ export async function runFrontendReviewSegmentedSessions(input) {
4360
5374
  index--;
4361
5375
  continue;
4362
5376
  }
4363
- if (!last.ok && !capacity)
5377
+ if (!last.ok && !capacity) {
5378
+ // Terminal-only completion: this protocol finishes through durable
5379
+ // tools (complete_review_scope -> approve_review/request_review_changes),
5380
+ // so the model can end its turn with no closing prose and the step
5381
+ // classifies as empty-output even though the review is complete
5382
+ // (smoke r27/r31/r32 committed the terminal and still reported
5383
+ // empty-output; the node then replayed the committed fact in an
5384
+ // extra attempt). Accept the segment here instead, so a completed
5385
+ // review neither spends a replay attempt nor depends on the retry
5386
+ // ladder, and a misclassified category (smoke r28 read a finding id
5387
+ // containing UNAUTHORIZED as an auth error, which is not retryable)
5388
+ // can no longer turn a committed review into a node failure.
5389
+ if (last.failureCategory === "empty-output" && terminal() && !missing.length) {
5390
+ try {
5391
+ await input.tools.flush();
5392
+ protocol.assertComplete();
5393
+ return { ...last, ok: true, failureCategory: "success" };
5394
+ }
5395
+ catch {
5396
+ // Unfinished scope checkpoints still fail this segment.
5397
+ }
5398
+ }
4364
5399
  return last;
5400
+ }
4365
5401
  if (missing.length || finalScope && !terminal()) {
4366
5402
  if (item.repairs >= 1)
4367
5403
  return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_SCOPE_INCOMPLETE: required checkpoint or verdict missing" };
@@ -4430,11 +5466,15 @@ export async function runFrontendScoutSegmentedSessions(input) {
4430
5466
  }
4431
5467
  }
4432
5468
  await input.tools.flush();
4433
- const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-shadow-dual-write.js");
5469
+ const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-committed-facts.js");
4434
5470
  const closure = readCompleteScoutTargetSurface(input.tools.committedFacts());
4435
5471
  return closure.ok ? { ...last, ok: true, failureCategory: "success" } : { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: closure.reason };
4436
5472
  }
4437
5473
  export async function runFrontendContractSegmentedSessions(input) {
5474
+ // Requirement identity, text, and source fragments are frozen in the
5475
+ // runtime ledger. Seed those facts once; model sessions spend their budget
5476
+ // on execution grouping, evidence expectations, and genuine decisions.
5477
+ await input.tools.seedCanonicalRequirements();
4438
5478
  // Build from the frozen runtime inventory if the caller has not rendered it yet.
4439
5479
  const basePrompt = parseFrontendInputBlock(input.basePrompt, "contract") ? input.basePrompt : input.basePrompt +
4440
5480
  `\n<frontend_contract_input>\nFrozen complete source obligations.\n${JSON.stringify({ requirements: input.tools.inputRequirements() })}\n</frontend_contract_input>`;
@@ -4459,13 +5499,14 @@ export async function runFrontendContractSegmentedSessions(input) {
4459
5499
  const groupIndex = collectFrontendExecutionGroups(input.tools.committedFacts().filter(r => r.fact.kind === "requirement").map(r => ({ id: String(r.fact.id), execution: r.fact.execution })));
4460
5500
  const shared = input.tools.committedFacts().filter(r => !["requirement", "contract-finalized", "contract-scope-completed"].includes(String(r.fact.kind))).map(r => r.fact);
4461
5501
  const prompt = projectFrontendContractPrompt(basePrompt, scopeIds) +
4462
- `\nCONTRACT SCOPE: analyze only ${scopeIds.join(", ") || "(all scopes complete; verify global facts and terminal)"}. Each obligation is complete. Submit small records immediately, then call complete_contract_scope after ALL decisions for this scope. ` +
5502
+ `\nCONTRACT SCOPE: analyze only ${scopeIds.join(", ") || "(all scopes complete; verify global facts and terminal)"}. Requirement identity/text/source fragments are already committed by runtime; do not call record_requirement. Use record_requirement_execution for model-owned execution grouping, then submit decisions and evidence records. Call complete_contract_scope after ALL decisions for this scope. ` +
4463
5503
  (finalScope ? "After complete scope coverage and source-bound deliverables, call finalize_contract. Correct rejected calls and retry." : "Do not finalize; subsequent complete scopes remain.") +
4464
5504
  `\n<committed_contract_facts>\n${JSON.stringify({ facts: shared, executionGroups: groupIndex })}\n</committed_contract_facts>`;
4465
5505
  const customTools = input.tools.customTools.filter(t => finalScope || t.name !== "finalize_contract");
4466
5506
  invocation += 1;
4467
5507
  if (invocation > maxSessions)
4468
5508
  return { ...last, ok: false, failureCategory: "invalid-output", stderr: "CONTRACT_RECOVERY_EXHAUSTED: session quota exceeded" };
5509
+ const committedBefore = input.tools.committedFacts().length;
4469
5510
  last = await observeFrontendSession({
4470
5511
  ...input.observation, phase: "contract/scope", scopeIds, prompt, userMessage: input.sessionOptions.userMessage, customTools,
4471
5512
  artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${invocation}.json` : undefined,
@@ -4497,8 +5538,17 @@ export async function runFrontendContractSegmentedSessions(input) {
4497
5538
  index -= 1;
4498
5539
  break;
4499
5540
  }
4500
- if (!last.ok)
5541
+ // A session that ends with blank assistant text but committed new
5542
+ // facts is not a node failure: small-output models legitimately
5543
+ // stop after their tool calls. Continue so the scope/repair checks
5544
+ // below decide, instead of burning a full node retry.
5545
+ const committedFactsOnlySuccess = !(last.assistantText ?? "").trim() &&
5546
+ !last.stderr.trim() &&
5547
+ input.tools.committedFacts().length > committedBefore;
5548
+ if (!last.ok && !committedFactsOnlySuccess)
4501
5549
  return last;
5550
+ if (committedFactsOnlySuccess)
5551
+ last = { ...last, ok: true, failureCategory: "success" };
4502
5552
  const missing = scopeIds.filter(id => !input.tools.completedScopeRequirementIds().has(id));
4503
5553
  if (!missing.length && (!finalScope || terminal()))
4504
5554
  break;
@@ -4644,7 +5694,6 @@ export async function runFrontendPlanSegmentedSessions(input) {
4644
5694
  const uxRegistrySegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "ux-registry");
4645
5695
  const uxSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "ux-local");
4646
5696
  const globalMockDataSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "global-mock-data");
4647
- const finalizeSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "finalize");
4648
5697
  const allRequirementIds = input.requirementIds ?? [];
4649
5698
  const buildPhasePrompt = (segment, missing = [], scopeIds = allRequirementIds) => {
4650
5699
  const compact = allRequirementIds.length > 0
@@ -4770,7 +5819,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4770
5819
  return [
4771
5820
  compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds),
4772
5821
  "PLAN PHASE — compact local planning for a small frontend request.",
4773
- "Review all listed requirements together and record_state_registry first with one global UX vocabulary. Then record every plan-requirement and verification-target fact, followed by the component-choice and state-flow facts needed by the observable UX. Do not read the repository or task source; use only the committed input above. Do not call finalize_plan in this session.",
5822
+ "Review all listed requirements together and record_state_registry first with one global UX vocabulary. Then record every plan-requirement and verification-target fact, followed by component/state-flow facts and the minimal route, Mock/data, dependency, and design-deviation policy facts needed by the observable behavior. Do not read the repository or task source; use only the committed input above. Do not call finalize_plan in this session.",
4774
5823
  "TOOL-FIRST: your first assistant actions must be record_* tool calls, at most 2-3 facts per message. Do not draft the whole analysis before recording; if a fact is uncertain, record it with an evidence gap instead of reasoning longer.",
4775
5824
  ledger,
4776
5825
  ...(missing.length > 0
@@ -4781,8 +5830,6 @@ export async function runFrontendPlanSegmentedSessions(input) {
4781
5830
  : []),
4782
5831
  ].filter(Boolean).join("\n\n");
4783
5832
  };
4784
- const compactFinalizeInstruction = "This is a small-request compact pass. Reconcile the committed local facts with route, data-flow, Mock/API, dependency and deviation policy, then call finalize_plan; correct rejected facts and retry until exactly one successful terminal commit.";
4785
- const buildCompactFinalizePrompt = (missing = []) => [buildPhasePrompt(finalizeSegment, missing), compactFinalizeInstruction].join("\n\n");
4786
5833
  const mapPlannerExhaustion = (r, committedAnyFacts) => isPlannerThinkingExhausted(r, committedAnyFacts)
4787
5834
  ? {
4788
5835
  ...r,
@@ -4825,10 +5872,80 @@ export async function runFrontendPlanSegmentedSessions(input) {
4825
5872
  tokensUsed: 0,
4826
5873
  };
4827
5874
  }
5875
+ const compiledInput = parseFrontendInputBlock(input.basePrompt, "plan")?.payload;
5876
+ const fullUnits = new Map(compiledInput?.requirements.map(r => [r.id, r]) ?? []);
5877
+ const workCost = (ids) => 1 + ids.reduce((total, id) => total + Math.max(1, (input.requirementCosts?.get(id) ?? 2) - 1), 0);
4828
5878
  const { workGroups, buildWorkBatches, compactEligible } = buildFrontendPlanWorkload({
4829
5879
  basePrompt: input.basePrompt, requirementIds: allRequirementIds,
4830
5880
  requirementCosts: input.requirementCosts, sessionOptions: input.sessionOptions,
4831
5881
  });
5882
+ // Data decisions are grouped by observable data domain. Requirements that
5883
+ // mention the same endpoint/resource or execution group share one session;
5884
+ // unrelated domains remain isolated. This prevents the old requirement-by-
5885
+ // requirement repetition while preserving the packer's size bound.
5886
+ const buildDataBatches = (ids) => {
5887
+ const selected = new Set(ids);
5888
+ const domains = new Map();
5889
+ for (const [index, group] of workGroups.entries()) {
5890
+ const members = group.requirementIds.filter((id) => selected.has(id));
5891
+ if (!members.length)
5892
+ continue;
5893
+ const texts = members.map((id) => String(fullUnits.get(id)?.text ?? ""));
5894
+ const endpoint = texts
5895
+ .map((text) => text.match(/\b(?:GET|POST|PUT|PATCH|DELETE)\s+(\/[^\s,;.)]+)/i)?.[1])
5896
+ .find(Boolean);
5897
+ const resource = endpoint
5898
+ ? endpoint.split("/").filter(Boolean).slice(0, 2).join("/")
5899
+ : undefined;
5900
+ const domain = resource ? `endpoint:${resource}` : `group:${group.id}`;
5901
+ const current = domains.get(domain);
5902
+ if (current) {
5903
+ current.requirementIds.push(...members);
5904
+ current.requirements.push(...members.map((id) => fullUnits.get(id) ?? { id }));
5905
+ current.estimatedCalls = workCost(current.requirementIds);
5906
+ }
5907
+ else {
5908
+ domains.set(domain, {
5909
+ id: `${index}:${domain}`,
5910
+ requirementIds: [...members],
5911
+ requirements: members.map((id) => fullUnits.get(id) ?? { id }),
5912
+ estimatedCalls: workCost(members),
5913
+ });
5914
+ }
5915
+ }
5916
+ const work = [...domains.values()];
5917
+ const scaffoldBytes = Buffer.byteLength(input.basePrompt.replace(/<frontend_plan_input>[\s\S]*?<\/frontend_plan_input>/, JSON.stringify({ ...compiledInput, requirements: [] }))) + Buffer.byteLength(JSON.stringify(input.segmentCustomTools(globalMockDataSegment.toolNames)));
5918
+ return packFrontendInputUnits(work, {
5919
+ targetBytes: Math.max(1, (input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES) - scaffoldBytes),
5920
+ maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits ?? 4,
5921
+ maxCost: FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS,
5922
+ cost: (group) => group.estimatedCalls,
5923
+ }).map((batch) => batch.flatMap((group) => group.requirementIds));
5924
+ };
5925
+ // Dependency/deviation is optional policy. For a pure local UI request with
5926
+ // no dependency, package, design-conflict, or OpenSpec signal, opening a
5927
+ // dedicated model session only produces an empty policy fact. Keep the
5928
+ // session when the prompt or ledger contains any such signal so this is a
5929
+ // conservative skip, not a blanket removal of the gate.
5930
+ const dependencyDeviationNeeded = /(?:dependenc|package\.json|npm\s+(?:install|i)|yarn\s+add|pnpm\s+add|openspec|design\s+conflict|规范冲突|依赖)/i.test(input.basePrompt) ||
5931
+ Boolean(input.committedFacts?.().some((record) => {
5932
+ const fact = committedFactFromPlanRecord(record);
5933
+ return fact?.kind === "dependency" || fact?.kind === "design-deviation";
5934
+ }));
5935
+ const globalPolicyToolNames = new Set([
5936
+ "record_route_selection",
5937
+ "record_dependency",
5938
+ "record_design_deviation",
5939
+ "adopt_staged_fact",
5940
+ ]);
5941
+ const globalPolicyPrompt = [
5942
+ "PLAN PHASE — global implementation policy.",
5943
+ "Use the Scout target surface to record the selected route(s), then record dependency policy and design-evidence conflicts only when they are relevant. Do not record requirement-local UX or Mock/data facts. Do not call finalize_plan.",
5944
+ dependencyDeviationNeeded
5945
+ ? "Dependency/design policy signals are present; inspect them and commit the minimal policy facts needed."
5946
+ : "No dependency/design-conflict signal was found; do not invent a policy fact.",
5947
+ ].join(" ");
5948
+ let globalPolicyQueued = false;
4832
5949
  // Small, single-surface requests do not benefit from six isolated Pi
4833
5950
  // sessions. Keep the typed ledger as the authority, but let one local
4834
5951
  // session establish requirement/UX facts and one final session establish
@@ -4846,6 +5963,12 @@ export async function runFrontendPlanSegmentedSessions(input) {
4846
5963
  "record_state_registry",
4847
5964
  "record_component_choice",
4848
5965
  "record_state_flow",
5966
+ "record_route_selection",
5967
+ "record_data_flow",
5968
+ "record_mock_api",
5969
+ "record_mock_endpoint",
5970
+ "record_dependency",
5971
+ "record_design_deviation",
4849
5972
  "adopt_staged_fact",
4850
5973
  ]);
4851
5974
  queue.push({
@@ -4854,11 +5977,6 @@ export async function runFrontendPlanSegmentedSessions(input) {
4854
5977
  requirementSlice: [...allRequirementIds],
4855
5978
  prompt: buildCompactLocalPrompt(),
4856
5979
  });
4857
- queue.push({
4858
- id: "finalize",
4859
- toolNames: null,
4860
- prompt: buildCompactFinalizePrompt(),
4861
- });
4862
5980
  }
4863
5981
  else if (!requirementIdsProvided) {
4864
5982
  queue.push({
@@ -4897,10 +6015,25 @@ export async function runFrontendPlanSegmentedSessions(input) {
4897
6015
  prompt: slice.length ? buildUxPrompt(slice) : buildPhasePrompt(uxSegment),
4898
6016
  }));
4899
6017
  for (const segment of FRONTEND_PLAN_SEGMENTS) {
4900
- if (["coverage", "ux-registry", "ux-local", "finalize"].includes(segment.id))
6018
+ if (["coverage", "ux-registry", "ux-local"].includes(segment.id))
6019
+ continue;
6020
+ if (segment.id === "global-dependency-deviation" && !dependencyDeviationNeeded)
6021
+ continue;
6022
+ if (segment.id === "global-route") {
6023
+ if (globalPolicyQueued)
6024
+ continue;
6025
+ globalPolicyQueued = true;
6026
+ queue.push({
6027
+ id: "global-policy",
6028
+ toolNames: globalPolicyToolNames,
6029
+ prompt: `${buildPhasePrompt(segment)}\n\n${globalPolicyPrompt}`,
6030
+ });
6031
+ continue;
6032
+ }
6033
+ if (segment.id === "global-dependency-deviation")
4901
6034
  continue;
4902
6035
  if (segment.id === "global-mock-data" && requirementIdsProvided) {
4903
- buildWorkBatches(allRequirementIds).forEach((slice, i) => queue.push({ id: `global-mock-data-${i + 1}`, toolNames: segment.toolNames, requirementSlice: slice, prompt: buildPhasePrompt(segment, [], slice) }));
6036
+ buildDataBatches(allRequirementIds).forEach((slice, i) => queue.push({ id: `global-mock-data-${i + 1}`, toolNames: segment.toolNames, requirementSlice: slice, prompt: buildPhasePrompt(segment, [], slice) }));
4904
6037
  }
4905
6038
  else {
4906
6039
  queue.push({
@@ -4910,11 +6043,6 @@ export async function runFrontendPlanSegmentedSessions(input) {
4910
6043
  });
4911
6044
  }
4912
6045
  }
4913
- queue.push({
4914
- id: finalizeSegment.id,
4915
- toolNames: finalizeSegment.toolNames,
4916
- prompt: buildPhasePrompt(finalizeSegment),
4917
- });
4918
6046
  }
4919
6047
  let accumulated;
4920
6048
  let index = 0;
@@ -4960,6 +6088,32 @@ export async function runFrontendPlanSegmentedSessions(input) {
4960
6088
  index += 1;
4961
6089
  continue;
4962
6090
  }
6091
+ // Resume only unfinished UX scopes. The registry and local decisions are
6092
+ // durable facts; replaying a completed scope wastes a model session and
6093
+ // can make a previously valid shared decision look like a duplicate.
6094
+ if (session.id === "ux-registry" &&
6095
+ input.committedFacts &&
6096
+ !collectFrontendPlanPhaseMissingFacts({
6097
+ phase: "ux-registry",
6098
+ requirementIds: allRequirementIds,
6099
+ committedFacts: input.committedFacts(),
6100
+ }).length) {
6101
+ index += 1;
6102
+ continue;
6103
+ }
6104
+ if (session.id.startsWith("ux-local-") &&
6105
+ session.requirementSlice &&
6106
+ (input.behaviorRequiredRequirementIds?.length ?? 0) > 0 &&
6107
+ input.committedFacts &&
6108
+ !collectFrontendPlanPhaseMissingFacts({
6109
+ phase: "ux-local",
6110
+ requirementIds: session.requirementSlice,
6111
+ committedFacts: input.committedFacts(),
6112
+ behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds,
6113
+ }).length) {
6114
+ index += 1;
6115
+ continue;
6116
+ }
4963
6117
  let prompt = session.prompt;
4964
6118
  if (session.coverageOnly && session.coverageSlice) {
4965
6119
  const promptSlice = [...new Set([...remaining, ...preexistingMissing.flatMap(item => item.requirementIds)])];
@@ -4979,15 +6133,16 @@ export async function runFrontendPlanSegmentedSessions(input) {
4979
6133
  : buildPhasePrompt(uxSegment, session.missingFacts);
4980
6134
  }
4981
6135
  else {
4982
- // Global phases and finalize also consume the latest committed ledger;
6136
+ // Global phases consume the latest committed ledger;
4983
6137
  // constructing their prompt only when the session starts prevents a
4984
6138
  // stale queue entry from dropping facts written by earlier phases.
4985
6139
  const segment = FRONTEND_PLAN_SEGMENTS.find((candidate) => candidate.id === session.id || (candidate.id === "global-mock-data" && session.id.startsWith("global-mock-data-")));
4986
- if (segment) {
4987
- prompt =
4988
- session.id === "finalize" && useCompactSmallPlan
4989
- ? buildCompactFinalizePrompt(session.missingFacts)
4990
- : buildPhasePrompt(segment, session.missingFacts, session.requirementSlice ?? allRequirementIds);
6140
+ if (session.id === "global-policy") {
6141
+ const routePrompt = buildPhasePrompt(FRONTEND_PLAN_SEGMENTS.find((candidate) => candidate.id === "global-route"));
6142
+ prompt = `${routePrompt}\n\n${globalPolicyPrompt}`;
6143
+ }
6144
+ else if (segment) {
6145
+ prompt = buildPhasePrompt(segment, session.missingFacts, session.requirementSlice ?? allRequirementIds);
4991
6146
  }
4992
6147
  }
4993
6148
  const atomicFocus = session.atomicRecovery ? session.missingFacts?.[0] : undefined;
@@ -5045,8 +6200,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
5045
6200
  ? combineSequentialPiResults(accumulated, result)
5046
6201
  : result;
5047
6202
  const committedAfter = input.committedFactCount();
5048
- const committedFactsOnlySuccess = session.id !== "finalize" &&
5049
- !(result.assistantText ?? "").trim() &&
6203
+ const committedFactsOnlySuccess = !(result.assistantText ?? "").trim() &&
5050
6204
  !result.stderr.trim() &&
5051
6205
  !result.timedOut &&
5052
6206
  committedAfter > committedBefore;
@@ -5126,11 +6280,9 @@ export async function runFrontendPlanSegmentedSessions(input) {
5126
6280
  ? buildCompactLocalPrompt(missing)
5127
6281
  : isUxRegistrySession
5128
6282
  ? buildUxRegistryPrompt(missing)
5129
- : session.id === "finalize" && useCompactSmallPlan
5130
- ? buildCompactFinalizePrompt(missing)
5131
- : isUxLocalSession
5132
- ? buildUxPrompt(session.requirementSlice ?? [], missing)
5133
- : buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds);
6283
+ : isUxLocalSession
6284
+ ? buildUxPrompt(session.requirementSlice ?? [], missing)
6285
+ : buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds);
5134
6286
  const recovery = classifyFrontendPlanRecovery({ ...result, stopReason: readWriterThinkingExhaustionEvidence(result).stopReason });
5135
6287
  if (recovery === "stop")
5136
6288
  return { ...result, ok: false };
@@ -5361,6 +6513,91 @@ export async function runFrontendPlanSegmentedSessions(input) {
5361
6513
  failureCategory: "invalid-output",
5362
6514
  };
5363
6515
  }
6516
+ if (!input.parallelCoverageOnly && input.finalizePlan) {
6517
+ // Preserve optional closeout context if an earlier correction or future
6518
+ // ledger producer committed it. The old model-only finalize session was
6519
+ // the only writer of these fields; runtime-owned finalization must not
6520
+ // silently erase them when they are already available.
6521
+ const optionalPlanFields = {};
6522
+ for (const record of input.committedFacts?.() ?? []) {
6523
+ const fact = committedFactFromPlanRecord(record);
6524
+ if (!fact)
6525
+ continue;
6526
+ if (Array.isArray(fact.residualRisks)) {
6527
+ optionalPlanFields.residualRisks = fact.residualRisks.filter((item) => typeof item === "string" && item.trim().length > 0);
6528
+ }
6529
+ if (typeof fact.realIntegrationGap === "string" && fact.realIntegrationGap.trim()) {
6530
+ optionalPlanFields.realIntegrationGap = fact.realIntegrationGap;
6531
+ }
6532
+ }
6533
+ let finalizeResult = await input.finalizePlan(optionalPlanFields);
6534
+ const finalizeDetails = finalizeResult && typeof finalizeResult === "object"
6535
+ ? (finalizeResult.details ?? finalizeResult)
6536
+ : finalizeResult;
6537
+ if (!finalizeDetails ||
6538
+ typeof finalizeDetails !== "object" ||
6539
+ finalizeDetails.ok !== true) {
6540
+ // Normal plans never open a finalize model session. Keep the old
6541
+ // correction/recovery semantic only for a rejected deterministic
6542
+ // compile: give the planner one bounded repair pass, then retry the
6543
+ // same runtime authority. The correction prompt explicitly forbids
6544
+ // calling finalize_plan, so terminal ownership remains deterministic.
6545
+ const correctionPrompt = [
6546
+ input.basePrompt,
6547
+ "PLAN FINALIZE CORRECTION — the runtime compile rejected the committed ledger.",
6548
+ `Runtime error: ${String(finalizeDetails?.error ?? "plan finalize rejected")}`,
6549
+ "Repair only the reported facts with the typed record_* tools. Do not call finalize_plan; the runtime will retry it after this correction.",
6550
+ input.committedFacts ? compactFrontendPlanLedgerContext({ committedFacts: input.committedFacts(), requirementIds: input.requirementIds ?? [], kinds: ["plan-requirement", "plan-verification-target", "state-registry", "component-choice", "state-flow", "data-flow", "mock-api", "design-deviation", "dependency", "target-surface"] }) : "",
6551
+ ].filter(Boolean).join("\n\n");
6552
+ input.setActiveRequirementScope?.([]);
6553
+ const correctionTools = input.segmentCustomTools(null);
6554
+ const correction = await input.piStepFn({
6555
+ ...input.sessionOptions,
6556
+ prompt: correctionPrompt,
6557
+ writerToolPolicy: { requireSdk: true, customTools: correctionTools },
6558
+ });
6559
+ try {
6560
+ await input.flushLedger();
6561
+ }
6562
+ catch { /* node-level flush retries below */ }
6563
+ accumulated = accumulated
6564
+ ? combineSequentialPiResults(accumulated, correction)
6565
+ : correction;
6566
+ if (correction.ok) {
6567
+ finalizeResult = await input.finalizePlan(optionalPlanFields);
6568
+ }
6569
+ const retriedDetails = finalizeResult && typeof finalizeResult === "object"
6570
+ ? (finalizeResult.details ?? finalizeResult)
6571
+ : finalizeResult;
6572
+ if (retriedDetails && typeof retriedDetails === "object" && retriedDetails.ok === true) {
6573
+ return mapPlannerExhaustion(accumulated ?? { ok: true, stdout: "", stderr: "", assistantText: "", command: [], durationMs: 0, exitCode: 0, failureCategory: "success", modelDisplay: "runtime-finalize", parsedEvents: 0, timedOut: false, attemptedModels: [], fallbackUsed: false, tokensUsed: 0 }, true);
6574
+ }
6575
+ const detail = retriedDetails && typeof retriedDetails === "object"
6576
+ ? String(retriedDetails.error ?? "plan finalize rejected")
6577
+ : "plan finalize rejected";
6578
+ return {
6579
+ ...(accumulated ?? {
6580
+ ok: false,
6581
+ stdout: "",
6582
+ stderr: "",
6583
+ assistantText: "",
6584
+ command: [],
6585
+ durationMs: 0,
6586
+ exitCode: null,
6587
+ failureCategory: "invalid-output",
6588
+ modelDisplay: "unknown",
6589
+ parsedEvents: 0,
6590
+ timedOut: false,
6591
+ attemptedModels: [],
6592
+ fallbackUsed: false,
6593
+ tokensUsed: 0,
6594
+ }),
6595
+ ok: false,
6596
+ stderr: `${accumulated?.stderr ?? ""}\nfrontend plan deterministic finalize failed: ${detail}`.trim(),
6597
+ failureCategory: "invalid-output",
6598
+ };
6599
+ }
6600
+ }
5364
6601
  return mapPlannerExhaustion(accumulated ?? {
5365
6602
  ok: false,
5366
6603
  stdout: "",
@@ -5654,6 +6891,10 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5654
6891
  let reviewInventory;
5655
6892
  let designInventory;
5656
6893
  let planLedgerTools;
6894
+ let decisionPlanTools;
6895
+ let parentDecisionSnapshot;
6896
+ let decisionPlanAuthority;
6897
+ const useDecisionPlan = process.env.HARNESS_FRONTEND_PLAN_DECISION === "1";
5657
6898
  let contractTools;
5658
6899
  let scoutEvidenceTools;
5659
6900
  let scoutSourceDeclaredPaths;
@@ -5753,9 +6994,23 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5753
6994
  runDir: meta.runDir,
5754
6995
  nodeId: input.task.id,
5755
6996
  });
6997
+ // Pre-session protocol self-check: an absent index silently disables
6998
+ // the scope-checkpoint protocol (inventory-less terminals approve
6999
+ // vacuously), so a broken upstream must fail here, before any model
7000
+ // call, instead of degrading the review.
7001
+ if (!reviewInventory) {
7002
+ return {
7003
+ ok: false,
7004
+ stdout: "",
7005
+ stderr: "pi review terminal tool policy unavailable before Pi execution: contracts/frontend-implementation-contract.index.json missing; the review-context gate must materialize the canonical contract index before this node starts",
7006
+ failureCategory: "tool-policy",
7007
+ durationMs: Date.now() - started,
7008
+ };
7009
+ }
5756
7010
  writerToolPolicy = {
5757
7011
  requireSdk: true,
5758
7012
  customTools: reviewTerminalTools.customTools,
7013
+ requireAllowlistedCustomTools: true,
5759
7014
  };
5760
7015
  }
5761
7016
  catch (error) {
@@ -5780,9 +7035,22 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5780
7035
  runDir: meta.runDir,
5781
7036
  nodeId: input.task.id,
5782
7037
  });
7038
+ // Same pre-session self-check as the review terminal above: without
7039
+ // the index the scope checkpoints disappear and the design verdict
7040
+ // loses its binding to the reviewed slices.
7041
+ if (!designInventory) {
7042
+ return {
7043
+ ok: false,
7044
+ stdout: "",
7045
+ stderr: "pi design terminal tool policy unavailable before Pi execution: contracts/frontend-implementation-contract.index.json missing; the design-policy shell must materialize the canonical contract index before this node starts",
7046
+ failureCategory: "tool-policy",
7047
+ durationMs: Date.now() - started,
7048
+ };
7049
+ }
5783
7050
  writerToolPolicy = {
5784
7051
  requireSdk: true,
5785
7052
  customTools: designTerminalTools.customTools,
7053
+ requireAllowlistedCustomTools: true,
5786
7054
  };
5787
7055
  }
5788
7056
  catch (error) {
@@ -5813,6 +7081,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5813
7081
  writerToolPolicy = {
5814
7082
  requireSdk: true,
5815
7083
  customTools: contractTools.customTools,
7084
+ requireAllowlistedCustomTools: true,
5816
7085
  };
5817
7086
  }
5818
7087
  catch (error) {
@@ -5846,6 +7115,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5846
7115
  writerToolPolicy = {
5847
7116
  requireSdk: true,
5848
7117
  customTools: scoutEvidenceTools.customTools,
7118
+ requireAllowlistedCustomTools: true,
5849
7119
  };
5850
7120
  }
5851
7121
  catch (error) {
@@ -5862,30 +7132,66 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5862
7132
  try {
5863
7133
  const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5864
7134
  const store = createTypedEventStore();
5865
- planLedgerTools = await createFrontendPlanLedgerTools({
5866
- attemptId: `${meta.runId}:${input.task.id}`,
5867
- store,
5868
- runDir: meta.runDir,
5869
- nodeId: input.task.id,
5870
- skeleton: input.task.structuredContractOutput?.skeleton,
5871
- sourceBinding: meta.spec.sourceBinding,
5872
- writeSetPatterns: input.task.writeSet,
5873
- componentNewSourceReferences: await resolveFrontendPlanNewComponentSourceReferences({
5874
- cwd: input.cwd,
7135
+ if (useDecisionPlan) {
7136
+ const authority = await resolveFrontendDecisionAuthority({
7137
+ runDir: meta.runDir,
5875
7138
  sourceBinding: meta.spec.sourceBinding,
5876
- }),
5877
- declaredUiStateIds: await resolveFrontendDeclaredUiStateIds({
7139
+ task: input.task,
7140
+ });
7141
+ if (!authority)
7142
+ throw new Error("frontend decision authority unavailable (missing contract inheritance or source binding)");
7143
+ decisionPlanAuthority = authority;
7144
+ if (useDecisionPlan) {
7145
+ // Recovery child: read the parent snapshot before the tools so
7146
+ // the replay and the identical-plan finalize guard share it.
7147
+ parentDecisionSnapshot = await readParentDecisionSnapshot(meta.runDir, input.task.id);
7148
+ }
7149
+ decisionPlanTools = await createFrontendPlanDecisionTools({
7150
+ attemptId: `${meta.runId}:${input.task.id}`,
7151
+ store,
5878
7152
  runDir: meta.runDir,
5879
- }),
5880
- canonicalVerificationTargetIds: await resolveFrontendCanonicalVerificationTargetIds({
7153
+ nodeId: input.task.id,
7154
+ authority,
7155
+ ...(useDecisionPlan && parentDecisionSnapshot
7156
+ ? { parentDecisionSnapshot }
7157
+ : {}),
7158
+ componentSpecCandidatePaths: meta.spec.tasks.find((task) => task.id === "frontend-design-policy-shell")?.shell?.frontendDesignPolicy?.componentSpecCandidatePaths ?? [],
7159
+ skeleton: input.task.structuredContractOutput?.skeleton,
7160
+ sourceBinding: meta.spec.sourceBinding,
7161
+ });
7162
+ writerToolPolicy = {
7163
+ requireSdk: true,
7164
+ customTools: decisionPlanTools.customTools,
7165
+ requireAllowlistedCustomTools: true,
7166
+ };
7167
+ }
7168
+ else {
7169
+ planLedgerTools = await createFrontendPlanLedgerTools({
7170
+ attemptId: `${meta.runId}:${input.task.id}`,
7171
+ store,
5881
7172
  runDir: meta.runDir,
5882
- }),
5883
- workspaceRoot: input.cwd,
5884
- });
5885
- writerToolPolicy = {
5886
- requireSdk: true,
5887
- customTools: planLedgerTools.customTools,
5888
- };
7173
+ nodeId: input.task.id,
7174
+ skeleton: input.task.structuredContractOutput?.skeleton,
7175
+ sourceBinding: meta.spec.sourceBinding,
7176
+ writeSetPatterns: input.task.writeSet,
7177
+ componentNewSourceReferences: await resolveFrontendPlanNewComponentSourceReferences({
7178
+ cwd: input.cwd,
7179
+ sourceBinding: meta.spec.sourceBinding,
7180
+ }),
7181
+ declaredUiStateIds: await resolveFrontendDeclaredUiStateIds({
7182
+ runDir: meta.runDir,
7183
+ }),
7184
+ canonicalVerificationTargetIds: await resolveFrontendCanonicalVerificationTargetIds({
7185
+ runDir: meta.runDir,
7186
+ }),
7187
+ workspaceRoot: input.cwd,
7188
+ });
7189
+ writerToolPolicy = {
7190
+ requireSdk: true,
7191
+ customTools: planLedgerTools.customTools,
7192
+ requireAllowlistedCustomTools: true,
7193
+ };
7194
+ }
5889
7195
  }
5890
7196
  catch (error) {
5891
7197
  return {
@@ -5904,6 +7210,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5904
7210
  budget: input.task.readBudget,
5905
7211
  });
5906
7212
  writerToolPolicy = {
7213
+ ...(writerToolPolicy ?? {}),
5907
7214
  requireSdk: true,
5908
7215
  customTools: [
5909
7216
  ...(writerToolPolicy?.customTools ?? []),
@@ -6048,6 +7355,136 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6048
7355
  },
6049
7356
  });
6050
7357
  }
7358
+ else if (isFrontendPlanLedgerNode(input.task) && decisionPlanTools) {
7359
+ // Experimental decision path: one session records semantic decisions;
7360
+ // the runtime finalizes and derives the safety projection afterwards.
7361
+ // Use a decision-specific prompt: the relationship subtask_prompt names
7362
+ // tools this toolset does not expose and never states behavior-group ids,
7363
+ // so reusing it leaves every requirement unplaced.
7364
+ // D (local design repair): a recovery child replays the parent run's
7365
+ // committed decision facts through its own durable tools BEFORE the model
7366
+ // session starts. The model then only corrects the rows the design review
7367
+ // flagged (same identity + replace:true) instead of re-deriving the plan.
7368
+ if (parentDecisionSnapshot && parentDecisionSnapshot.facts.length > 0) {
7369
+ try {
7370
+ await replayParentDecisionSnapshot(parentDecisionSnapshot, decisionPlanTools.customTools);
7371
+ }
7372
+ catch (error) {
7373
+ return {
7374
+ ok: false,
7375
+ stdout: "",
7376
+ stderr: `parent decision replay failed before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
7377
+ failureCategory: "invalid-output",
7378
+ durationMs: Date.now() - started,
7379
+ };
7380
+ }
7381
+ }
7382
+ const decisionPrompt = decisionPlanAuthority
7383
+ ? (() => {
7384
+ const base = buildFrontendDecisionPlanPrompt(decisionPlanAuthority);
7385
+ // Preserve node-execution's retry guidance (appended to
7386
+ // input.prompt as a <retry_instruction> tail) so a failed
7387
+ // decision attempt tells the next session exactly which
7388
+ // decision facts to repair instead of replaying the identical
7389
+ // under-declared plan. The relationship body of input.prompt is
7390
+ // deliberately dropped (its tools do not exist in this toolset).
7391
+ if ((input.attempt ?? 1) > 1) {
7392
+ const marker = input.prompt.indexOf("<retry_instruction>");
7393
+ if (marker >= 0) {
7394
+ const tail = input.prompt.slice(marker).trim();
7395
+ if (tail.length > 0) {
7396
+ return `${base}\n\n${tail}`;
7397
+ }
7398
+ }
7399
+ }
7400
+ if (parentDecisionSnapshot && parentDecisionSnapshot.facts.length > 0) {
7401
+ const identityOf = (entry) => String(entry.purpose ??
7402
+ entry.state ??
7403
+ entry.interaction ??
7404
+ entry.id ??
7405
+ entry.boundary ??
7406
+ "?");
7407
+ const ledger = parentDecisionSnapshot.facts
7408
+ .map((fact) => `- ${fact.kind}: ${identityOf(fact.entry)}`)
7409
+ .join("\n");
7410
+ return `${base}\n\n<parent_decision_repair>\nYour ledger is pre-loaded with the parent run's committed decision facts (replayed by the runtime before this session):\n${ledger}\nThe <rerun_feedback> findings name the rows that blocked admission. Correct ONLY those rows: resubmit the corrected entry with the SAME natural identity and replace:true. Do not restate unrelated rows and do not add rows the findings do not require. Then call finalize_decision.\n</parent_decision_repair>`;
7411
+ }
7412
+ return base;
7413
+ })()
7414
+ : input.prompt;
7415
+ const decisionResult = await piStepFn({
7416
+ ...piSessionOptions,
7417
+ prompt: decisionPrompt,
7418
+ writerToolPolicy: { requireSdk: true, customTools: decisionPlanTools.customTools },
7419
+ });
7420
+ try {
7421
+ await decisionPlanTools.flush();
7422
+ }
7423
+ catch { /* node-level flush retries below */ }
7424
+ const finalizeResult = await decisionPlanTools.finalizeDecision();
7425
+ const finalizeDetails = finalizeResult && typeof finalizeResult === "object"
7426
+ ? (finalizeResult.details ?? finalizeResult)
7427
+ : finalizeResult;
7428
+ if (finalizeDetails && typeof finalizeDetails === "object" && finalizeDetails.ok === true) {
7429
+ // Bridge the finalized decisions into the relationship-shaped plan
7430
+ // ledger. The node-level R1 self-check and all downstream shells
7431
+ // compile ONLY plan-typed-facts.jsonl; without this write the whole
7432
+ // decision run fails "frontend plan ledger missing" at R1 on every
7433
+ // attempt. Validation failures surface as invalid-output so the
7434
+ // retry ladder restarts the session with the diagnostics.
7435
+ try {
7436
+ const bridge = await bridgeFrontendPlanDecisionToRelationshipLedger({
7437
+ runDir: meta.runDir,
7438
+ nodeId: input.task.id,
7439
+ attemptId: `${meta.runId}:${input.task.id}`,
7440
+ facts: decisionPlanTools.committedFacts(),
7441
+ authority: decisionPlanAuthority,
7442
+ skeleton: input.task.structuredContractOutput?.skeleton,
7443
+ sourceBinding: meta.spec.sourceBinding,
7444
+ });
7445
+ if (!bridge.ok) {
7446
+ throw new Error(bridge.error);
7447
+ }
7448
+ result = decisionResult;
7449
+ }
7450
+ catch (error) {
7451
+ result = {
7452
+ ok: false,
7453
+ stdout: decisionResult.stdout ?? "",
7454
+ stderr: `${decisionResult.stderr ?? ""}\n${error instanceof Error ? error.message : String(error)}`.trim(),
7455
+ failureCategory: "invalid-output",
7456
+ durationMs: Date.now() - started,
7457
+ modelDisplay: decisionResult.modelDisplay,
7458
+ parsedEvents: decisionResult.parsedEvents,
7459
+ timedOut: decisionResult.timedOut,
7460
+ attemptedModels: decisionResult.attemptedModels,
7461
+ fallbackUsed: decisionResult.fallbackUsed,
7462
+ tokensUsed: decisionResult.tokensUsed,
7463
+ assistantText: decisionResult.assistantText ?? "",
7464
+ command: decisionResult.command ?? [],
7465
+ exitCode: decisionResult.exitCode,
7466
+ };
7467
+ }
7468
+ }
7469
+ else {
7470
+ result = {
7471
+ ok: false,
7472
+ stdout: decisionResult.stdout ?? "",
7473
+ stderr: `${decisionResult.stderr ?? ""}\nfrontend decision finalize failed: ${String(finalizeDetails?.error ?? "decision finalize rejected")}`.trim(),
7474
+ failureCategory: "invalid-output",
7475
+ durationMs: Date.now() - started,
7476
+ modelDisplay: decisionResult.modelDisplay,
7477
+ parsedEvents: decisionResult.parsedEvents,
7478
+ timedOut: decisionResult.timedOut,
7479
+ attemptedModels: decisionResult.attemptedModels,
7480
+ fallbackUsed: decisionResult.fallbackUsed,
7481
+ tokensUsed: decisionResult.tokensUsed,
7482
+ assistantText: decisionResult.assistantText ?? "",
7483
+ command: decisionResult.command ?? [],
7484
+ exitCode: decisionResult.exitCode,
7485
+ };
7486
+ }
7487
+ }
6051
7488
  else if (isFrontendPlanLedgerNode(input.task) && planLedgerTools) {
6052
7489
  // Frontend-only split: independent coverage map sessions feed a single
6053
7490
  // reducer (UX decisions -> global policy -> finalize), mirroring the
@@ -6057,7 +7494,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6057
7494
  const planRequirementCosts = new Map();
6058
7495
  const behaviorRequiredRequirementIds = [];
6059
7496
  try {
6060
- const { readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
7497
+ const { readCommittedOriginFacts } = await import("../workflows/dag/frontend-committed-facts.js");
6061
7498
  const contractFacts = await readCommittedOriginFacts(meta.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl");
6062
7499
  // Only requirement facts: the contract ledger also carries
6063
7500
  // constraints (CON-*), evidence expectations (EV-*), handoff
@@ -6106,6 +7543,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6106
7543
  : {}),
6107
7544
  committedRequirementIds: () => options.ledgerTools.committedRequirementIds(),
6108
7545
  committedFacts: () => options.ledgerTools.committedFacts(),
7546
+ finalizePlan: (params) => options.ledgerTools.finalizePlan(params),
6109
7547
  behaviorRequiredRequirementIds,
6110
7548
  setActiveRequirementScope: (requirementIds) => options.ledgerTools.setActiveRequirementScope(requirementIds),
6111
7549
  segmentCustomTools: (toolNames) => toolNames === null
@@ -6408,12 +7846,12 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6408
7846
  if (readBudgetIssues.length > 0) {
6409
7847
  const hasTypedFrontendTerminal = isFrontendReviewTypedTerminalNode(input.task) ||
6410
7848
  isFrontendDesignTypedTerminalNode(input.task);
6411
- // A stale/generated DAG may still carry the legacy read budget. Once a
7849
+ // A stale/generated DAG may still carry an older read budget. Once a
6412
7850
  // design/review node has a successful typed terminal, telemetry is
6413
- // diagnostic only; the terminal shadow below still fails closed when the
7851
+ // diagnostic only; the typed terminal validation below still fails closed when the
6414
7852
  // fact is missing or conflicting.
6415
7853
  if (mapped.ok && hasTypedFrontendTerminal) {
6416
- // Continue to the typed terminal shadow validation below.
7854
+ // Continue to the typed terminal validation below.
6417
7855
  }
6418
7856
  else {
6419
7857
  // Read-budget telemetry is diagnostic only when the provider/executor
@@ -6439,7 +7877,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6439
7877
  }
6440
7878
  }
6441
7879
  if (isFrontendReviewTypedTerminalNode(input.task)) {
6442
- return await runFrontendReviewTerminalShadow({
7880
+ return await validateFrontendReviewTerminal({
6443
7881
  task: input.task,
6444
7882
  meta,
6445
7883
  mapped,
@@ -6447,7 +7885,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6447
7885
  });
6448
7886
  }
6449
7887
  if (isFrontendDesignTypedTerminalNode(input.task)) {
6450
- return await runFrontendDesignTerminalShadow({
7888
+ return await validateFrontendDesignTerminal({
6451
7889
  task: input.task,
6452
7890
  meta,
6453
7891
  mapped,
@@ -6470,7 +7908,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6470
7908
  return { ...mapped, ok: false, failureCategory: mapped.ok ? "frontend-ledger-invalid" : mapped.failureCategory, stderr: `${mapped.stderr}\nFRONTEND_LEDGER_INTEGRITY_INVALID: ${error instanceof Error ? error.message : String(error)}` };
6471
7909
  }
6472
7910
  if (mapped.ok) {
6473
- const { checkCommittedOriginFacts, readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
7911
+ const { checkCommittedOriginFacts, readCommittedOriginFacts } = await import("../workflows/dag/frontend-committed-facts.js");
6474
7912
  const scoutFacts = await readCommittedOriginFacts(meta.runDir, input.task.id, "scout-typed-facts.jsonl");
6475
7913
  const completeness = checkCommittedOriginFacts({
6476
7914
  records: scoutFacts,
@@ -6702,15 +8140,15 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6702
8140
  let factDerivedStatus;
6703
8141
  if (mapped.ok && input.task.writerOutcomePolicy) {
6704
8142
  if (isFrontendFactsWriter(input.task)) {
6705
- // AC-001/AC-003: facts-derived status. The first line is never read;
6706
- // the legacy validator still runs for the shadow comparison only.
8143
+ // AC-001/AC-003: facts-derived status. The response text is never
8144
+ // consulted for frontend writer completion.
6707
8145
  try {
6708
8146
  const [writerStatus, contractModule, traceModule] = await Promise.all([
6709
8147
  import("../workflows/dag/frontend-writer-status.js"),
6710
8148
  import("../workflows/dag/frontend-implementation-contract.js"),
6711
8149
  import("../workflows/dag/frontend-verification-trace.js"),
6712
8150
  ]);
6713
- const { collectFrontendWriterFacts, deriveFrontendWriterStatus, computeFailureFingerprint, compareFactStatusToLegacyOutcome, } = writerStatus;
8151
+ const { collectFrontendWriterFacts, deriveFrontendWriterStatus, computeFailureFingerprint, } = writerStatus;
6714
8152
  const contractPath = path.join(meta.runDir, "contracts", "frontend-implementation-contract.json");
6715
8153
  const contractRaw = JSON.parse(await readFile(contractPath, "utf8"));
6716
8154
  const parsedContract = contractModule.frontendImplementationContractSchema.safeParse(contractRaw);
@@ -6741,19 +8179,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6741
8179
  wallTimeMs: Date.now() - started,
6742
8180
  rounds: 1,
6743
8181
  writeAttempts: attempt,
6744
- firstLineText: mapped.assistantText,
6745
8182
  });
6746
8183
  const derived = deriveFrontendWriterStatus(facts);
6747
8184
  factDerivedStatus = derived.status;
6748
- const legacyValidation = validateWriterImplementationOutcome(mapped.assistantText || mapped.stdout, changeManifestChangedFiles ?? [], {
6749
- requireChangedFiles: false,
6750
- allowMissingChangedOutcomeWhenDiffPresent: false,
6751
- });
6752
- const legacyOutcome = legacyValidation.ok
6753
- ? legacyValidation.outcome
6754
- : legacyValidation.reason.includes("blocked")
6755
- ? "blocked"
6756
- : "missing";
6757
8185
  await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "fact-implementation-status.json", {
6758
8186
  schemaVersion: 1,
6759
8187
  nodeId: input.task.id,
@@ -6770,7 +8198,6 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6770
8198
  rounds: facts.rounds,
6771
8199
  writeAttempts: facts.writeAttempts,
6772
8200
  failureFingerprint: computeFailureFingerprint(facts),
6773
- shadow: compareFactStatusToLegacyOutcome(derived.status, legacyOutcome),
6774
8201
  });
6775
8202
  if (derived.status !== "changed" &&
6776
8203
  derived.status !== "already-satisfied") {