@tea-agent/loop-agent 0.43.0-next.9 → 0.43.0

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 (287) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +31 -2309
  3. package/README.md +7 -0
  4. package/dist/application/dag/args.js +8 -0
  5. package/dist/application/task-lifecycle/advance.js +38 -7
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/cli/command-definitions.js +2 -0
  8. package/dist/cli/program.js +3 -1
  9. package/dist/cli/update/init-surface-notifier.js +2 -0
  10. package/dist/cli/update/policy.js +2 -0
  11. package/dist/commands/client-recovery.js +20 -55
  12. package/dist/commands/cursor-prompt.js +2 -0
  13. package/dist/commands/dag-approve.js +2 -0
  14. package/dist/commands/dag-final-verification.js +2 -0
  15. package/dist/commands/dag-init-hybrid.js +2 -0
  16. package/dist/commands/dag-reconcile-tasks.js +2 -0
  17. package/dist/commands/dag-reject.js +2 -0
  18. package/dist/commands/dag-report.js +2 -0
  19. package/dist/commands/dag-rerun.js +2 -0
  20. package/dist/commands/dag-resume.js +2 -0
  21. package/dist/commands/dag-workflow-compile.js +2 -0
  22. package/dist/commands/dag-workflow-plan.js +2 -0
  23. package/dist/commands/dag-workflow-validate.js +2 -0
  24. package/dist/commands/delegate.js +2 -0
  25. package/dist/commands/examples.js +2 -0
  26. package/dist/commands/import-prd.js +2 -0
  27. package/dist/commands/init-model-catalog.js +2 -0
  28. package/dist/commands/init.js +19 -13
  29. package/dist/commands/instructions.js +2 -0
  30. package/dist/commands/knowledge.js +2 -0
  31. package/dist/commands/loop-benchmark.js +2 -0
  32. package/dist/commands/pi-prompt.js +2 -0
  33. package/dist/commands/pi-reuse-benchmark.js +2 -0
  34. package/dist/commands/promote-run.js +2 -0
  35. package/dist/commands/stats.js +1 -1
  36. package/dist/commands/study-init.js +2 -0
  37. package/dist/commands/task-advance.js +34 -0
  38. package/dist/commands/task-source-prepare.js +2 -0
  39. package/dist/commands/worktree-create.js +2 -0
  40. package/dist/commands/worktree-remove.js +2 -0
  41. package/dist/executors/dag-pi-executor.js +1650 -198
  42. package/dist/executors/pi-executor.js +85 -3
  43. package/dist/executors/pi-sdk-executor.js +18 -0
  44. package/dist/executors/shell-executor.js +93 -7
  45. package/dist/executors/shell-verification.js +3 -0
  46. package/dist/executors/shell-write-guard.js +27 -32
  47. package/dist/governance/exec-plans.js +6 -3
  48. package/dist/infrastructure/console/app-data.js +6 -0
  49. package/dist/shared/artifacts-core.js +2 -0
  50. package/dist/shared/backend-dogfood-preflight.js +47 -0
  51. package/dist/shared/dag-failure-category.js +3 -0
  52. package/dist/shared/git-progress.js +2 -0
  53. package/dist/shared/one-shot-prompt-args.js +2 -0
  54. package/dist/shared/operator/capabilities.js +180 -0
  55. package/dist/shared/package-metadata.js +6 -4
  56. package/dist/shared/pi-context-pressure/extension.js +26 -16
  57. package/dist/shared/pi-context-pressure/sift-bridge.js +1 -1
  58. package/dist/shared/pi-provider-recovery.js +182 -0
  59. package/dist/shared/pi-retry-settings.js +40 -0
  60. package/dist/shared/reference-context.js +2 -0
  61. package/dist/task/config-types.js +29 -0
  62. package/dist/task/contract/project.js +9 -0
  63. package/dist/task/contract/recover.js +6 -0
  64. package/dist/task/contract/schema.js +17 -0
  65. package/dist/task/source-prepare/artifact-meta.js +9 -2
  66. package/dist/task/source-prepare/build-draft.js +60 -0
  67. package/dist/task/source-prepare/fragment-inventory.js +4 -1
  68. package/dist/task/source-prepare/parse-intent.js +36 -11
  69. package/dist/task/task-demand-routing.js +5 -2
  70. package/dist/worker/console/chat/browser-automation.js +523 -0
  71. package/dist/worker/console/chat/browser-policy.js +6 -36
  72. package/dist/worker/console/chat/browser-routes.js +188 -56
  73. package/dist/worker/console/chat/explore-tools.js +5 -2
  74. package/dist/worker/console/chat/pi-mode-loop-isolation.js +155 -0
  75. package/dist/worker/console/chat/pi-runtime/custom-tools/ask-user-question.js +44 -0
  76. package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +184 -0
  77. package/dist/worker/console/chat/pi-runtime/custom-tools/explore-tools.js +202 -0
  78. package/dist/worker/console/chat/pi-runtime/custom-tools/goal-tools.js +79 -0
  79. package/dist/worker/console/chat/pi-runtime/custom-tools/operator-tools.js +94 -0
  80. package/dist/worker/console/chat/pi-runtime/custom-tools/scheduled-goal-tools.js +22 -0
  81. package/dist/worker/console/chat/pi-runtime/custom-tools/todo-write.js +38 -0
  82. package/dist/worker/console/chat/pi-runtime/inspection-types.js +28 -0
  83. package/dist/worker/console/chat/pi-runtime/inspection.js +439 -0
  84. package/dist/worker/console/chat/pi-runtime/progressive-tools.js +195 -0
  85. package/dist/worker/console/chat/pi-runtime/sdk-bindings.js +153 -0
  86. package/dist/worker/console/chat/pi-runtime/sdk-session.js +5 -0
  87. package/dist/worker/console/chat/pi-runtime.js +208 -1138
  88. package/dist/worker/console/chat/routes.js +27 -7
  89. package/dist/worker/console/chat/scheduled-goal-booking.js +59 -0
  90. package/dist/worker/console/chat/scheduled-goal-delivery.js +27 -0
  91. package/dist/worker/console/chat/scheduled-goal-request.js +190 -0
  92. package/dist/worker/console/chat/sdd-data-alignment.js +1 -1
  93. package/dist/worker/console/chat/session-mode-view.js +5 -4
  94. package/dist/worker/console/chat/session-mode.js +18 -12
  95. package/dist/worker/console/chat/session-store.js +42 -9
  96. package/dist/worker/console/chat/tool-preview.js +115 -0
  97. package/dist/worker/console/chat/tools.js +10 -0
  98. package/dist/worker/console/chat/turn-order.js +13 -0
  99. package/dist/worker/console/chat/turn-process.js +32 -30
  100. package/dist/worker/console/console-update-and-init.js +1 -19
  101. package/dist/worker/console/console-update-runtime.js +5 -20
  102. package/dist/worker/console/operator-actions.js +63 -1
  103. package/dist/worker/console/prd-intake-bridge.js +54 -1
  104. package/dist/worker/console/routes.js +8 -0
  105. package/dist/worker/console/scheduled-goal-host.js +98 -0
  106. package/dist/worker/console/scheduled-goal-operation-adapter.js +127 -0
  107. package/dist/worker/console/server.js +24 -1
  108. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-D_fNbWc_.js → abnfDiagram-N423BO3Z-8-j6y-sd.js} +1 -1
  109. package/dist/worker/console/static/assets/{arc-_HuqLW6m.js → arc-eoQiMvuk.js} +1 -1
  110. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-Djy2qY1m.js → architectureDiagram-T3A2C74G-C4A3uMcI.js} +1 -1
  111. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-bB-1TSyY.js → blockDiagram-VBNYF7ZC-D4zD2F-Q.js} +1 -1
  112. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-C5Hmghrz.js → c4Diagram-5PPSVZJV-j1RkJziL.js} +1 -1
  113. package/dist/worker/console/static/assets/channel-ChE7y-cx.js +1 -0
  114. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-Duyl39oy.js → chunk-2GRJ4B5K-YBHmhik1.js} +1 -1
  115. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-x6mdnSyf.js → chunk-2Q5K7J3B-COfWyo9P.js} +1 -1
  116. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-WlBuFj7W.js → chunk-5RXB4S5H-I99OUkHY.js} +1 -1
  117. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CTF429HX.js → chunk-5VM5RSS4-XKxoJNJ4.js} +1 -1
  118. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Dwd6r-SF.js → chunk-6Q2QTUOP-DLT_cYx1.js} +1 -1
  119. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C-Br82D1.js → chunk-GF5L2VYU-CxcKZ9mV.js} +1 -1
  120. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CuGp0DeE.js → chunk-JWPE2WC7-V1EqXdjY.js} +1 -1
  121. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-CJhFX6Lh.js → chunk-KBJHAD2P-DebTtdFp.js} +1 -1
  122. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DpGOa_jT.js → chunk-RYQCIY6F-B-ivNRDf.js} +1 -1
  123. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DXJaTynd.js → chunk-XXDRQBXY-DC_Ds11b.js} +1 -1
  124. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C01TCf2X.js +1 -0
  125. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C01TCf2X.js +1 -0
  126. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CRBZWjE4.js → cose-bilkent-JH36ORCC-sWEqKwIb.js} +1 -1
  127. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BdXcxtlb.js → cynefin-VYW2F7L2-BXa_dcu4.js} +1 -1
  128. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-07UKsOts.js → cynefinDiagram-MW4NZA55-C-Mle84F.js} +1 -1
  129. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-D38p9__S.js → dagre-VZM6K2ZE-CXPDBITe.js} +1 -1
  130. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-nSiBNNjH.js → diagram-7IWD3JNH-CC-WJQfa.js} +1 -1
  131. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BFOTot5D.js → diagram-B4RE2ZJO-CDAV5Vs4.js} +1 -1
  132. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DypKaZ6k.js → diagram-LBJQPF4R-CmFiAcNz.js} +1 -1
  133. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-B6yoOQeW.js → diagram-Q27KOJAE-DPBZHuyn.js} +1 -1
  134. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BHYS6viX.js → diagram-UB23O5K3-jUlm_Ds3.js} +1 -1
  135. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-D2VD5Snj.js → ebnfDiagram-BXEA7PRR-DWhQ3mfY.js} +1 -1
  136. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-E98mWxN1.js → erDiagram-JOGREHBK-Tr2gMqet.js} +1 -1
  137. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CT1zxhqP.js → flowDiagram-UKHOOZJN-DJjVQHPA.js} +1 -1
  138. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-6GcngNcy.js → ganttDiagram-PKOTCBZU-D74dQ4u0.js} +1 -1
  139. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DCL6NvKe.js → gitGraphDiagram-DS77QQ5N-DwW0tZ0X.js} +1 -1
  140. package/dist/worker/console/static/assets/index-BWkIfcrK.css +1 -0
  141. package/dist/worker/console/static/assets/index-Cdkvw_H6.js +469 -0
  142. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-C0627e-9.js → infoDiagram-6WML65LV-ILCbxJyb.js} +1 -1
  143. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-C_u3P6Ik.js → ishikawaDiagram-WSZJBQD7-DeuWBQ89.js} +1 -1
  144. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DPDPv9HL.js → journeyDiagram-NVQOT4AX-CF5ih8Fk.js} +1 -1
  145. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-D3OHdWiO.js → kanban-definition-27J2QSJJ-C7yOSuRO.js} +1 -1
  146. package/dist/worker/console/static/assets/{linear-DI88zD7n.js → linear-BDZ9riWi.js} +1 -1
  147. package/dist/worker/console/static/assets/{mermaid.core-p2l8IrWY.js → mermaid.core-7pKqYtpZ.js} +5 -5
  148. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-B1bAX-oS.js → mindmap-definition-FAOFIHXS-LeJDybSU.js} +1 -1
  149. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DwOnWxdD.js → pegDiagram-VL7TDLO6-BJvT3pMD.js} +1 -1
  150. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-xRkTXnJC.js → pieDiagram-7S7Q4E2Y-_rGqpMan.js} +1 -1
  151. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-_9UC5Zio.js → quadrantDiagram-CIZ2JOQS-DPcSv5aA.js} +1 -1
  152. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CgEUirWJ.js → railroadDiagram-AXF67PYL-Drxx4hkJ.js} +1 -1
  153. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-Czbpwiyz.js → requirementDiagram-LRYGKXZP-BCTUdU4z.js} +1 -1
  154. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-_j7_KiqY.js → sankeyDiagram-W5VNT64P-B6wzbZmB.js} +1 -1
  155. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BNJa6Hyi.js → sequenceDiagram-SI44F4Z6-BGt8d3QQ.js} +1 -1
  156. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DedpKtOc.js → sizeCapture-X5ZJPWSS-7nlhJKo7.js} +1 -1
  157. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BBvthr7c.js → stateDiagram-OKZ733FA-Cv2stqMA.js} +1 -1
  158. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DC7V5vcq.js +1 -0
  159. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Ds7umgEM.js → swimlanes-SLNWSIFB-qDAo4Yc1.js} +2 -2
  160. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bwy4QUTO.js +8 -0
  161. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CKa-p1nF.js → timeline-definition-Z64GVDOM-CKbDNKTr.js} +1 -1
  162. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-LoUNR56o.js → vennDiagram-T6HMQDX7-DI-9EHic.js} +1 -1
  163. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-Bqbl6ym7.js → wardleyDiagram-T6FBY63Y-BrzzRDpX.js} +1 -1
  164. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-B8dUg4lf.js → xychartDiagram-ELKLHX3M-BclH5hGh.js} +1 -1
  165. package/dist/worker/console/static/index.html +2 -2
  166. package/dist/worker/console/static-src/operator-chat/chat-scroll-position.js +24 -0
  167. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +41 -11
  168. package/dist/worker/console/static-src/operator-chat/compaction-message.js +3 -17
  169. package/dist/worker/console/static-src/operator-chat/composer-session-ledger.js +192 -0
  170. package/dist/worker/console/static-src/operator-chat/pending-user-message.js +9 -1
  171. package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +15 -5
  172. package/dist/worker/console/static-src/operator-chat/timeline-merge.js +29 -0
  173. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +92 -38
  174. package/dist/worker/console/static-src/operator-chat/useChatStream.js +145 -70
  175. package/dist/worker/console/static-src/operator-chat/useChatThread.js +38 -55
  176. package/dist/worker/console/static-src/operator-chat/useComposer.js +182 -69
  177. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +2 -0
  178. package/dist/worker/console/static-src/prd-file-import.js +3 -1
  179. package/dist/worker/console/workspace-context.js +22 -0
  180. package/dist/worker/console/workspace-initialization.js +209 -0
  181. package/dist/worker/delivery/git-transaction.js +2 -0
  182. package/dist/worker/feature/fullstack-validate.js +2 -0
  183. package/dist/worker/loop-agent/loop-agent-client.js +8 -2
  184. package/dist/worker/materialize/harness-task-materializer.js +4 -1
  185. package/dist/worker/metrics/projector.js +1 -1
  186. package/dist/worker/observability/event-store.js +4 -1
  187. package/dist/worker/observability/read-model.js +4 -1
  188. package/dist/worker/observe/node-transparency.js +23 -1
  189. package/dist/worker/observe/routes.js +4 -0
  190. package/dist/worker/observe/static/operator-chrome.js +3 -1
  191. package/dist/worker/observe/static/styles.css +7 -4
  192. package/dist/worker/observe/static/views/dag-inspector.js +5 -19
  193. package/dist/worker/outcomes/declared-artifacts.js +2 -0
  194. package/dist/worker/preflight.js +3 -0
  195. package/dist/worker/scheduler/scheduled-goal-dispatch.js +117 -0
  196. package/dist/worker/scheduler/scheduled-goal-evidence.js +167 -0
  197. package/dist/worker/scheduler/scheduled-goal-recovery.js +62 -0
  198. package/dist/worker/scheduler/scheduled-goal-store.js +699 -0
  199. package/dist/worker/scheduler/scheduled-goal-supervisor.js +132 -0
  200. package/dist/worker/scheduler/scheduled-goal-time.js +102 -0
  201. package/dist/worker/scheduler/scheduled-goal-types.js +95 -0
  202. package/dist/worker/task-spec/validate.js +4 -1
  203. package/dist/workflows/dag/backend-test-markdown-workflow.js +6 -24
  204. package/dist/workflows/dag/backend-test-plan-protocol.js +82 -5
  205. package/dist/workflows/dag/dag-retry-schema.js +11 -0
  206. package/dist/workflows/dag/decision-envelope.js +2 -0
  207. package/dist/workflows/dag/dynamic-runtime/shared.js +2 -2
  208. package/dist/workflows/dag/frontend-closeout.js +3 -1
  209. package/dist/workflows/dag/frontend-committed-facts.js +461 -0
  210. package/dist/workflows/dag/frontend-durable-tools.js +15 -3
  211. package/dist/workflows/dag/frontend-implementation-contract.js +369 -12
  212. package/dist/workflows/dag/frontend-plan-canary.js +53 -0
  213. package/dist/workflows/dag/frontend-plan-decision-contract.js +803 -0
  214. package/dist/workflows/dag/frontend-plan-render.js +0 -2
  215. package/dist/workflows/dag/frontend-provider-capability-matrix.js +8 -61
  216. package/dist/workflows/dag/frontend-recovery-run.js +57 -0
  217. package/dist/workflows/dag/frontend-repair.js +55 -76
  218. package/dist/workflows/dag/frontend-review-context.js +42 -68
  219. package/dist/workflows/dag/frontend-review-scopes.js +2 -2
  220. package/dist/workflows/dag/frontend-risk.js +92 -10
  221. package/dist/workflows/dag/frontend-session-budget.js +117 -3
  222. package/dist/workflows/dag/frontend-shape.js +11 -55
  223. package/dist/workflows/dag/frontend-test-execution-evidence.js +35 -10
  224. package/dist/workflows/dag/frontend-typed-event-store.js +32 -25
  225. package/dist/workflows/dag/frontend-verification-trace.js +33 -54
  226. package/dist/workflows/dag/frontend-writer-admission.js +3 -47
  227. package/dist/workflows/dag/frontend-writer-status.js +0 -23
  228. package/dist/workflows/dag/init-hybrid.js +47 -19
  229. package/dist/workflows/dag/interrupt-request.js +2 -0
  230. package/dist/workflows/dag/lifecycle.js +11 -2
  231. package/dist/workflows/dag/node-execution.js +50 -13
  232. package/dist/workflows/dag/reconcile-run.js +2 -0
  233. package/dist/workflows/dag/repair-artifact.js +3 -1
  234. package/dist/workflows/dag/report.js +2 -0
  235. package/dist/workflows/dag/rerun-plan.js +10 -0
  236. package/dist/workflows/dag/rerun-task.js +79 -1
  237. package/dist/workflows/dag/retry-policy.js +18 -0
  238. package/dist/workflows/dag/scheduler.js +2 -4
  239. package/dist/workflows/dag/types.js +44 -6
  240. package/dist/workflows/dag/validate.js +4 -0
  241. package/docs/README.md +1 -0
  242. package/docs/architecture/runtime-boundaries.md +3 -3
  243. package/docs/governance/README.md +1 -0
  244. package/docs/init-surface.manifest.json +1 -0
  245. package/docs/operations/README.md +2 -0
  246. package/docs/templates/README.md +1 -1
  247. package/docs/templates/agent-dag.schema.json +2 -2
  248. package/docs/templates/backend-test-dag.json +14 -10
  249. package/docs/templates/frontend-implementation-contract.schema.json +0 -7
  250. package/package.json +9 -3
  251. package/skills/frontend-bounded-implement/references/code-standards.md +3 -3
  252. package/skills/frontend-contract/references/contract-protocol.md +3 -1
  253. package/skills/frontend-plan/SKILL.md +8 -6
  254. package/skills/frontend-plan/references/decision-contract.md +3 -3
  255. package/skills/frontend-review/SKILL.md +18 -28
  256. package/skills/frontend-review/references/review-findings.md +8 -3
  257. package/dist/commands/new-task.js +0 -5
  258. package/dist/commands/status.js +0 -57
  259. package/dist/commands/task-contract.js +0 -269
  260. package/dist/shared/prompts.js +0 -26
  261. package/dist/task/config.js +0 -2
  262. package/dist/task/contract/validate-draft.js +0 -106
  263. package/dist/task/goal.js +0 -3
  264. package/dist/task/index.js +0 -12
  265. package/dist/task/lifecycle.js +0 -1
  266. package/dist/task/operator/capabilities.js +0 -6
  267. package/dist/task/operator/envelope.js +0 -2
  268. package/dist/task/operator/index.js +0 -5
  269. package/dist/task/operator/registry.js +0 -2
  270. package/dist/task/operator/types.js +0 -1
  271. package/dist/task/paths.js +0 -1
  272. package/dist/task/source-state.js +0 -1
  273. package/dist/task/subagent-guidance.js +0 -1
  274. package/dist/worker/console/chat/sift-bridge.js +0 -1
  275. package/dist/worker/console/index.js +0 -20
  276. package/dist/worker/console/static/assets/channel-C0A6UEIa.js +0 -1
  277. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DzguNppy.js +0 -1
  278. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DzguNppy.js +0 -1
  279. package/dist/worker/console/static/assets/index-24jp3aBh.js +0 -468
  280. package/dist/worker/console/static/assets/index-CY6y6I8M.css +0 -1
  281. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BABVQnfM.js +0 -1
  282. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D0qsLaxR.js +0 -8
  283. package/dist/worker/scheduler/index.js +0 -25
  284. package/dist/workflows/dag/facts.js +0 -4
  285. package/dist/workflows/dag/frontend-shadow-dual-write.js +0 -975
  286. package/dist/workflows/dag/index.js +0 -6
  287. package/dist/workflows/dynamic/index.js +0 -7
@@ -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
  }
@@ -2070,7 +2243,7 @@ export async function createFrontendPlanLedgerTools(input) {
2070
2243
  const recordPlanVerificationTargetTool = defineTool({
2071
2244
  name: "record_plan_verification_target",
2072
2245
  label: "record_plan_verification_target",
2073
- description: "Commit one plan verification target entry (origin=plan plan-verification-target fact). Reference a frozen verification command by commandId (see the frozen command directory in your prompt: static commands are project-wide checks traced by file and command only; behavior commands need a test file whose describe/it/test title contains the target id). For behavior targets, call once per distinct behavior, not mechanically once per requirement: one target may cover multiple related requirementIds. For a correction, re-submit the same id with replace=true; the ledger compiles the latest replacement. A behavior target id is the stable trace token that implementation must place in a real describe/it/test title. Entry carries id, commandId, file, requirementIds, and uiStates; optional scope (unit | component | integration) is display-only. Free-form symbol text is not accepted. 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\": \"VT-DASHBOARD-SHELL\", \"commandId\": \"<frozen behavior command id>\", \"file\": \"<test file>\", \"requirementIds\": [\"AC-001\", \"AC-002\"], \"uiStates\": []}}" +
2246
+ description: "Commit one plan verification target entry (origin=plan plan-verification-target fact). Reference a frozen verification command by commandId (see the frozen command directory in your prompt: static commands are project-wide checks traced by file and command only; behavior commands bind an existing affected test file; preserve its test names). For behavior targets, call once per distinct behavior, not mechanically once per requirement: one target may cover multiple related requirementIds. For a correction, re-submit the same id with replace=true; the ledger compiles the latest replacement. A target id identifies a contract entry only; never require it in test names. Results bind by frozen command and test file. Entry carries id, commandId, file, requirementIds, and uiStates; optional scope (unit | component | integration) is display-only. Free-form symbol text is not accepted. 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\": \"VT-DASHBOARD-SHELL\", \"commandId\": \"<frozen behavior command id>\", \"file\": \"<test file>\", \"requirementIds\": [\"AC-001\", \"AC-002\"], \"uiStates\": []}}" +
2074
2247
  (input.canonicalVerificationTargetIds &&
2075
2248
  input.canonicalVerificationTargetIds.length > 0
2076
2249
  ? ` Frozen canonical behavior target ids (use exactly for behavior targets): ${input.canonicalVerificationTargetIds.join(", ")}.`
@@ -2549,6 +2722,9 @@ export async function createFrontendPlanLedgerTools(input) {
2549
2722
  finalizePlanTool,
2550
2723
  ],
2551
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");
2552
2728
  return {
2553
2729
  customTools: [...durable.customTools, { ...readPlanFactsTool, execute: async (...args) => { await durable.flush(); return readPlanFactsTool.execute(...args); } }],
2554
2730
  adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
@@ -2617,6 +2793,17 @@ export async function createFrontendPlanLedgerTools(input) {
2617
2793
  ];
2618
2794
  },
2619
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
+ {}),
2620
2807
  committedFactCount: () => readCommittedEvents(store, attemptId).length,
2621
2808
  committedRequirementIds: () => {
2622
2809
  const ids = new Set();
@@ -2633,6 +2820,674 @@ export async function createFrontendPlanLedgerTools(input) {
2633
2820
  committedFacts: () => readCommittedEvents(store, attemptId),
2634
2821
  };
2635
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
+ }
2636
3491
  function isRecordObject(value) {
2637
3492
  return typeof value === "object" && value !== null && !Array.isArray(value);
2638
3493
  }
@@ -2846,6 +3701,52 @@ export async function createFrontendContractTools(input) {
2846
3701
  return receipt(result);
2847
3702
  },
2848
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
+ });
2849
3750
  // Authoritative UI state declarations: the contract node extracts the
2850
3751
  // PRD/reference UI-state table into structured facts so the planner binds
2851
3752
  // uiStates to declared ids instead of inventing names (dogfood
@@ -3059,6 +3960,7 @@ export async function createFrontendContractTools(input) {
3059
3960
  tools: [
3060
3961
  ...recordTools,
3061
3962
  recordRequirementTool,
3963
+ recordRequirementExecutionTool,
3062
3964
  recordEvidenceExpectationTool,
3063
3965
  recordUiStateTool,
3064
3966
  recordRequiredDeliverablesTool,
@@ -3067,9 +3969,23 @@ export async function createFrontendContractTools(input) {
3067
3969
  finalizeContractTool,
3068
3970
  ],
3069
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");
3070
3975
  return {
3071
3976
  customTools: durable.customTools,
3072
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
+ },
3073
3989
  completedScopeRequirementIds,
3074
3990
  setActiveRequirementScope: ids => { activeScope = ids === null ? null : new Set(ids); },
3075
3991
  committedRequirementIds,
@@ -3251,6 +4167,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3251
4167
  entrypoint: optionalString,
3252
4168
  routeOrMount: optionalString,
3253
4169
  implementationPaths: stringArray,
4170
+ proposedPaths: Type.Optional(stringArray),
3254
4171
  testPaths: stringArray,
3255
4172
  dataSource: optionalString,
3256
4173
  allowedPathConflicts: stringArray,
@@ -3260,12 +4177,18 @@ export async function createFrontendScoutEvidenceTools(input) {
3260
4177
  if (input.requirementIds && (!activeScope?.length || params.scopeId !== scopeIdentity(activeScope)))
3261
4178
  return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: "Use exactly the runtime Scout scopeId; discovery may complete only the supplied obligations" });
3262
4179
  const implementationPaths = params?.implementationPaths ?? [];
4180
+ const proposedPaths = params?.proposedPaths ?? [];
3263
4181
  const testPaths = params?.testPaths ?? [];
3264
4182
  const pathEvidence = await enrichScoutPathEvidence([
3265
4183
  ...(params?.entrypoint ? [params.entrypoint] : []),
3266
4184
  ...implementationPaths,
3267
4185
  ...testPaths,
3268
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
+ }
3269
4192
  const surface = {
3270
4193
  kind: "target-surface",
3271
4194
  origin: "scout",
@@ -3273,6 +4196,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3273
4196
  entrypoint: params?.entrypoint ?? "",
3274
4197
  routeOrMount: params?.routeOrMount ?? "",
3275
4198
  implementationPaths,
4199
+ ...(proposedPaths.length > 0 ? { proposedPaths } : {}),
3276
4200
  testPaths,
3277
4201
  dataSource: params?.dataSource ?? "",
3278
4202
  allowedPathConflicts: params?.allowedPathConflicts ?? [],
@@ -3281,7 +4205,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3281
4205
  ...(pathEvidence.length > 0 ? { pathEvidence } : {}),
3282
4206
  };
3283
4207
  if (activeScope) {
3284
- const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-shadow-dual-write.js");
4208
+ const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-committed-facts.js");
3285
4209
  if (surface.completeness === "complete") {
3286
4210
  const check = readCompleteScoutTargetSurface([{ phase: "committed", fact: surface }]);
3287
4211
  if (!check.ok)
@@ -3295,7 +4219,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3295
4219
  const surfaces = [...new Set(input.requirementIds.map(id => current.get(id)))].map(f => f.surface);
3296
4220
  const union = (key) => [...new Set(surfaces.flatMap(s => Array.isArray(s[key]) ? s[key] : []))];
3297
4221
  const entries = [...new Set(surfaces.map(s => String(s.entrypoint ?? "")).filter(Boolean))];
3298
- 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 } : {}) }));
3299
4223
  }
3300
4224
  return receipt(saved);
3301
4225
  }
@@ -3605,21 +4529,15 @@ const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
3605
4529
  readGitStatusPorcelain,
3606
4530
  recoverRootNulArtifact,
3607
4531
  };
3608
- /**
3609
- * M5 shadow pass for `frontend-review-pi`: extract the committed typed terminal
3610
- * facts from session events, parse the legacy JSON verdict from the response
3611
- * text, compare them (audit-only), and persist the audit artifact. Missing
3612
- * typed terminal facts fail the node closed (AC-001); a shadow mismatch never
3613
- * 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.
3614
4534
  */
3615
- async function runFrontendReviewTerminalShadow(input) {
4535
+ async function validateFrontendReviewTerminal(input) {
3616
4536
  // A provider/executor failure (in particular context-overflow) is already
3617
4537
  // authoritative. Do not rewrite it to review-terminal-missing merely
3618
4538
  // because no terminal tool could be submitted after the failed call.
3619
4539
  if (!input.mapped.ok)
3620
4540
  return input.mapped;
3621
- const { compareTypedReviewToLegacyJsonVerdict } = await import("../workflows/dag/frontend-review-context.js");
3622
- const { parseJsonReviewVerdict } = await import("../workflows/dag/output-protocol.js");
3623
4541
  let typedKinds = [];
3624
4542
  try {
3625
4543
  await input.tools?.flush();
@@ -3629,43 +4547,6 @@ async function runFrontendReviewTerminalShadow(input) {
3629
4547
  catch (error) {
3630
4548
  return { ...input.mapped, ok: false, failureCategory: "frontend-ledger-invalid", stderr: `FRONTEND_REVIEW_SCOPE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}` };
3631
4549
  }
3632
- let legacyVerdict;
3633
- try {
3634
- const parsed = parseJsonReviewVerdict(input.mapped.assistantText ?? input.mapped.stdout);
3635
- if (parsed.ok)
3636
- legacyVerdict = parsed.verdict;
3637
- }
3638
- catch {
3639
- legacyVerdict = undefined;
3640
- }
3641
- let comparison;
3642
- try {
3643
- comparison = compareTypedReviewToLegacyJsonVerdict({
3644
- typedKinds,
3645
- legacyVerdict,
3646
- });
3647
- }
3648
- catch (error) {
3649
- comparison = {
3650
- typedVerdict: undefined,
3651
- legacyVerdict: undefined,
3652
- match: false,
3653
- reason: `typed review equivalence comparison crashed: ${error instanceof Error ? error.message : String(error)}`,
3654
- };
3655
- }
3656
- // The durable ledger was validated above; this artifact is audit-only.
3657
- try {
3658
- await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-review-status.json", {
3659
- schemaVersion: 1,
3660
- nodeId: input.task.id,
3661
- typedKinds,
3662
- legacyVerdict,
3663
- comparison,
3664
- });
3665
- }
3666
- catch {
3667
- // best-effort audit artifact
3668
- }
3669
4550
  if (typedKinds.length === 0) {
3670
4551
  return {
3671
4552
  ...input.mapped,
@@ -3681,16 +4562,8 @@ async function runFrontendReviewTerminalShadow(input) {
3681
4562
  }
3682
4563
  return input.mapped;
3683
4564
  }
3684
- /**
3685
- * M8 shadow pass for `frontend-design-review-pi`: extract the committed typed
3686
- * design terminal facts from session events, flush them, and persist the audit
3687
- * artifact. The design review's legacy output was a first-line
3688
- * `VERDICT: pass|request-revision` text protocol (not a JSON verdict), so
3689
- * there is no JSON equivalence comparison here. Missing typed terminal facts
3690
- * fail the node closed; writer admission later reads the committed
3691
- * `design-typed-facts.jsonl` as the only authoritative verdict.
3692
- */
3693
- async function runFrontendDesignTerminalShadow(input) {
4565
+ /** Validate the typed design terminal and complete design scopes. */
4566
+ async function validateFrontendDesignTerminal(input) {
3694
4567
  // See the review counterpart above: a failed provider call cannot be
3695
4568
  // diagnosed as an omitted terminal tool call.
3696
4569
  if (!input.mapped.ok)
@@ -3704,17 +4577,6 @@ async function runFrontendDesignTerminalShadow(input) {
3704
4577
  catch (error) {
3705
4578
  return { ...input.mapped, ok: false, failureCategory: "frontend-ledger-invalid", stderr: `FRONTEND_REVIEW_SCOPE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}` };
3706
4579
  }
3707
- // The durable ledger was validated above; this artifact is audit-only.
3708
- try {
3709
- await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-design-status.json", {
3710
- schemaVersion: 1,
3711
- nodeId: input.task.id,
3712
- typedKinds,
3713
- });
3714
- }
3715
- catch {
3716
- // best-effort audit artifact
3717
- }
3718
4580
  if (typedKinds.length === 0) {
3719
4581
  return {
3720
4582
  ...input.mapped,
@@ -3744,7 +4606,7 @@ const FRONTEND_PLAN_SEGMENTS = [
3744
4606
  ]),
3745
4607
  instruction: [
3746
4608
  "PLAN PHASE — requirement coverage only.",
3747
- "Your ONLY job: for every frozen requirement, emit record_plan_requirement (requirement → implementation files) and record_plan_verification_target facts (verification target bound to requirement ids and files). Group related requirements under one non-static behavior target when one observable test behavior proves them together; do not mechanically create one target per requirement. A non-static target id is the stable machine trace token; never submit prose as a symbol. Do NOT record components, UI states, mock, dependency, or routes — a follow-up session owns those.",
4609
+ "Your ONLY job: for every frozen requirement, emit record_plan_requirement (requirement → implementation files) and record_plan_verification_target facts (verification target bound to requirement ids and files). Group related requirements under one non-static behavior target when one observable test behavior proves them together; do not mechanically create one target per requirement. Target ids identify contract entries, not test-title markers. Reuse affected existing test files and their names; do not add tests or rename titles just to carry generated ids. Never submit prose as a symbol. Do NOT record components, UI states, mock, dependency, or routes — a follow-up session owns those.",
3748
4610
  "A coverage session is complete only when EVERY requirement assigned to this session (the full inventory, or the exact COVERAGE BATCH / shard list when present) has committed coverage facts: a record_plan_requirement entry plus verification targets, or a committed evidence gap. Keep committing in batches of up to 4 record_* calls per assistant message until then; do not write a concluding summary while any assigned requirement is still uncommitted — an early stop strands the remainder into a MISSING-FACT repair session and doubles the sessions needed.",
3749
4611
  "If a requirement genuinely cannot have a verification target, record a non-empty record_plan_evidence_gap. Do not call finalize_plan; it is not available in this phase.",
3750
4612
  ].join(" "),
@@ -3797,15 +4659,185 @@ const FRONTEND_PLAN_SEGMENTS = [
3797
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.",
3798
4660
  ].join(" "),
3799
4661
  },
3800
- {
3801
- id: "finalize",
3802
- toolNames: null,
3803
- instruction: [
3804
- "PLAN PHASE — finalize.",
3805
- "All record_* tools are available only for a narrowly named correction if the finalize receipt reports missing or invalid facts. Otherwise call finalize_plan exactly once with no extra fields.",
3806
- ].join(" "),
3807
- },
3808
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
+ }
3809
4841
  /** Estimate calls conservatively: requirement + one VT, with a second VT
3810
4842
  * reserved for behaviour-required requirements. Explicit declarations win. */
3811
4843
  export function estimateFrontendPlanRequirementRecordCalls(fact) {
@@ -4032,6 +5064,7 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
4032
5064
  ? { routeOrMount: compactPromptString(surface.routeOrMount, 180) }
4033
5065
  : {}),
4034
5066
  implementationPaths: compactPromptStringArray(surface.implementationPaths),
5067
+ proposedPaths: compactPromptStringArray(surface.proposedPaths),
4035
5068
  testPaths: compactPromptStringArray(surface.testPaths),
4036
5069
  ...(compactPromptString(surface.dataSource, 180)
4037
5070
  ? { dataSource: compactPromptString(surface.dataSource, 180) }
@@ -4263,7 +5296,7 @@ function compactFrontendPlanLedgerContext(input) {
4263
5296
  continue;
4264
5297
  }
4265
5298
  if (fact.kind === "target-surface") {
4266
- 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) });
4267
5300
  }
4268
5301
  }
4269
5302
  if (compactFacts.length === 0)
@@ -4311,19 +5344,21 @@ export async function runFrontendReviewSegmentedSessions(input) {
4311
5344
  protocol.setActiveScope(scopes.map(s => s.id));
4312
5345
  const finalScope = input.inventory.scopes.every(s => protocol.completedScopeIds().has(s.id) || scopes.some(current => current.id === s.id));
4313
5346
  const customTools = finalScope ? input.customTools : input.customTools.filter(t => !terminalKinds.includes(String(t.name)));
4314
- 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."}`;
4315
5348
  if (Buffer.byteLength(prompt) > targetBytes && scopes.length > 1) {
4316
5349
  const at = Math.ceil(scopes.length / 2);
4317
5350
  queue.splice(index, 1, { scopes: scopes.slice(0, at), repairs: 0 }, { scopes: scopes.slice(at), repairs: 0 });
4318
5351
  index--;
4319
5352
  continue;
4320
5353
  }
5354
+ const completionInstruction = `${item.repairs ? "REPAIR: the prior session did not commit all required checkpoints/verdict. Do not repeat the review narrative. " : ""}Complete these exact runtime scope IDs using complete_review_scope: ${JSON.stringify(scopes.map(scope => scope.id))}. These are scope IDs, not node IDs. ${finalScope ? `After those checkpoints succeed, call exactly one terminal tool: ${terminalKinds.join(" or ")}. A prose conclusion is not a committed verdict.` : "More scopes remain; do not submit an overall verdict yet."}`;
5355
+ const userMessage = `${input.sessionOptions.userMessage}\n\n${completionInstruction}`;
4321
5356
  if (++calls > 128)
4322
5357
  return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_RECOVERY_EXHAUSTED: session quota reached" };
4323
- last = await observeFrontendSession({ ...input.observation, phase: `${input.phase}/scope`, scopeIds: scopes.map(s => s.id), prompt, userMessage: input.sessionOptions.userMessage, customTools,
5358
+ last = await observeFrontendSession({ ...input.observation, phase: `${input.phase}/scope`, scopeIds: scopes.map(s => s.id), prompt, userMessage, customTools,
4324
5359
  artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${calls}.json` : undefined,
4325
5360
  committedCount: () => protocol.committedFacts().length, durableCommittedCount: () => protocol.committedFacts().length,
4326
- }, observer => input.piStepFn({ ...input.sessionOptions, prompt, onAttemptObservation: observer, writerToolPolicy: { requireSdk: true, customTools } }));
5361
+ }, observer => input.piStepFn({ ...input.sessionOptions, prompt, userMessage, onAttemptObservation: observer, writerToolPolicy: { requireSdk: true, customTools } }));
4327
5362
  await input.tools.flush();
4328
5363
  await input.inventory.validate();
4329
5364
  if (last.timedOut || /interrupt|termination-unconfirmed|budget_breach/.test(last.failureCategory))
@@ -4339,8 +5374,30 @@ export async function runFrontendReviewSegmentedSessions(input) {
4339
5374
  index--;
4340
5375
  continue;
4341
5376
  }
4342
- 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
+ }
4343
5399
  return last;
5400
+ }
4344
5401
  if (missing.length || finalScope && !terminal()) {
4345
5402
  if (item.repairs >= 1)
4346
5403
  return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_SCOPE_INCOMPLETE: required checkpoint or verdict missing" };
@@ -4409,11 +5466,15 @@ export async function runFrontendScoutSegmentedSessions(input) {
4409
5466
  }
4410
5467
  }
4411
5468
  await input.tools.flush();
4412
- const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-shadow-dual-write.js");
5469
+ const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-committed-facts.js");
4413
5470
  const closure = readCompleteScoutTargetSurface(input.tools.committedFacts());
4414
5471
  return closure.ok ? { ...last, ok: true, failureCategory: "success" } : { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: closure.reason };
4415
5472
  }
4416
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();
4417
5478
  // Build from the frozen runtime inventory if the caller has not rendered it yet.
4418
5479
  const basePrompt = parseFrontendInputBlock(input.basePrompt, "contract") ? input.basePrompt : input.basePrompt +
4419
5480
  `\n<frontend_contract_input>\nFrozen complete source obligations.\n${JSON.stringify({ requirements: input.tools.inputRequirements() })}\n</frontend_contract_input>`;
@@ -4438,13 +5499,14 @@ export async function runFrontendContractSegmentedSessions(input) {
4438
5499
  const groupIndex = collectFrontendExecutionGroups(input.tools.committedFacts().filter(r => r.fact.kind === "requirement").map(r => ({ id: String(r.fact.id), execution: r.fact.execution })));
4439
5500
  const shared = input.tools.committedFacts().filter(r => !["requirement", "contract-finalized", "contract-scope-completed"].includes(String(r.fact.kind))).map(r => r.fact);
4440
5501
  const prompt = projectFrontendContractPrompt(basePrompt, scopeIds) +
4441
- `\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. ` +
4442
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.") +
4443
5504
  `\n<committed_contract_facts>\n${JSON.stringify({ facts: shared, executionGroups: groupIndex })}\n</committed_contract_facts>`;
4444
5505
  const customTools = input.tools.customTools.filter(t => finalScope || t.name !== "finalize_contract");
4445
5506
  invocation += 1;
4446
5507
  if (invocation > maxSessions)
4447
5508
  return { ...last, ok: false, failureCategory: "invalid-output", stderr: "CONTRACT_RECOVERY_EXHAUSTED: session quota exceeded" };
5509
+ const committedBefore = input.tools.committedFacts().length;
4448
5510
  last = await observeFrontendSession({
4449
5511
  ...input.observation, phase: "contract/scope", scopeIds, prompt, userMessage: input.sessionOptions.userMessage, customTools,
4450
5512
  artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${invocation}.json` : undefined,
@@ -4476,8 +5538,17 @@ export async function runFrontendContractSegmentedSessions(input) {
4476
5538
  index -= 1;
4477
5539
  break;
4478
5540
  }
4479
- 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)
4480
5549
  return last;
5550
+ if (committedFactsOnlySuccess)
5551
+ last = { ...last, ok: true, failureCategory: "success" };
4481
5552
  const missing = scopeIds.filter(id => !input.tools.completedScopeRequirementIds().has(id));
4482
5553
  if (!missing.length && (!finalScope || terminal()))
4483
5554
  break;
@@ -4512,16 +5583,18 @@ function buildFrontendPlanWorkload(input) {
4512
5583
  const workCost = (ids) => 1 + ids.reduce((total, id) => total + Math.max(1, (input.requirementCosts?.get(id) ?? 2) - 1), 0);
4513
5584
  const policy = input.sessionOptions.frontendExecutionPolicy;
4514
5585
  const targetBytes = policy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES;
4515
- const userMessageBytes = Buffer.byteLength(input.sessionOptions.userMessage ?? "");
4516
5586
  const buildWorkBatches = (ids) => {
4517
5587
  const work = workGroups.flatMap((g, index) => {
4518
5588
  const members = g.requirementIds.filter(id => ids.includes(id));
4519
5589
  return members.length ? [{ id: `${index}:${g.id}`, requirementIds: members,
4520
5590
  requirements: members.map(id => fullUnits.get(id) ?? { id }), estimatedCalls: workCost(members) }] : [];
4521
5591
  });
4522
- const scaffoldBytes = Buffer.byteLength(input.basePrompt.replace(/<frontend_plan_input>[\s\S]*?<\/frontend_plan_input>/, JSON.stringify({ ...compiledInput, requirements: [] }))) + userMessageBytes + Buffer.byteLength(JSON.stringify(input.coverageTools));
5592
+ // Pack complete work units, not the repeated request scaffold. Deducting
5593
+ // fixed context/tools can leave a one-byte budget and force one AC per
5594
+ // session without reducing that overhead. The SDK checks the actual
5595
+ // request against model capacity; capacity recovery splits unfinished work.
4523
5596
  return packFrontendInputUnits(work, {
4524
- targetBytes: Math.max(1, targetBytes - scaffoldBytes),
5597
+ targetBytes,
4525
5598
  maxUnits: policy?.maxScopeUnits ?? 4,
4526
5599
  maxCost: FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS, cost: g => g.estimatedCalls,
4527
5600
  }).map(batch => batch.flatMap(g => g.requirementIds));
@@ -4533,11 +5606,31 @@ function buildFrontendPlanWorkload(input) {
4533
5606
  frozenInput, workGroups, buildWorkBatches,
4534
5607
  compactEligible: input.requirementCosts !== undefined &&
4535
5608
  workGroups.length <= FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS &&
4536
- Buffer.byteLength(input.basePrompt) + userMessageBytes + Buffer.byteLength(JSON.stringify(input.allTools)) <= targetBytes * 2 &&
4537
5609
  workGroups.reduce((total, group) => total + workCost(group.requirementIds), 0) <= FRONTEND_PLAN_SMALL_MAX_ESTIMATED_CALLS &&
4538
5610
  countFrontendPlanTargetSurfaces(input.basePrompt) === 1,
4539
5611
  };
4540
5612
  }
5613
+ /**
5614
+ * Requirement ids the plan coverage layout must own, in first-commit order.
5615
+ *
5616
+ * The contract ledger is append-only and `record_requirement` is incremental, so
5617
+ * the same id can legitimately be committed more than once. Deduplicating here is
5618
+ * load-bearing: `buildFrontendPlanWorkload` seeds one `unclassified` work group per
5619
+ * list entry without re-checking membership, so a repeated id produced two groups,
5620
+ * `buildWorkBatches` emitted the id twice, and the coverage-layout validator then
5621
+ * rejected the layout the runtime had derived itself (members 8 !== owners 7) as
5622
+ * FRONTEND_PLAN_LAYOUT_INVALID with failureCategory tool-policy - killing the run
5623
+ * at the plan node over a completely ordinary ledger.
5624
+ */
5625
+ export function collectFrontendPlanRequirementIds(contractFacts) {
5626
+ return [
5627
+ ...new Set(contractFacts
5628
+ .filter((record) => record.fact?.kind ===
5629
+ "requirement")
5630
+ .map((record) => record.fact?.id)
5631
+ .filter((id) => typeof id === "string")),
5632
+ ];
5633
+ }
4541
5634
  const frontendPlanCoverageLayoutSchema = z.object({
4542
5635
  schemaVersion: z.literal(1),
4543
5636
  bindingSha256: z.string(),
@@ -4601,12 +5694,11 @@ export async function runFrontendPlanSegmentedSessions(input) {
4601
5694
  const uxRegistrySegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "ux-registry");
4602
5695
  const uxSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "ux-local");
4603
5696
  const globalMockDataSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "global-mock-data");
4604
- const finalizeSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "finalize");
4605
5697
  const allRequirementIds = input.requirementIds ?? [];
4606
5698
  const buildPhasePrompt = (segment, missing = [], scopeIds = allRequirementIds) => {
4607
5699
  const compact = allRequirementIds.length > 0
4608
5700
  ? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, scopeIds, {
4609
- includeRequirementText: segment.id === "global-mock-data",
5701
+ includeRequirementText: segment.id === "global-mock-data" || segment.id === "finalize",
4610
5702
  includeVerificationTargets: false,
4611
5703
  includeDesignEvidence: segment.id === "global-dependency-deviation" || segment.id === "finalize",
4612
5704
  includeChecklist: false,
@@ -4727,7 +5819,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4727
5819
  return [
4728
5820
  compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds),
4729
5821
  "PLAN PHASE — compact local planning for a small frontend request.",
4730
- "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.",
4731
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.",
4732
5824
  ledger,
4733
5825
  ...(missing.length > 0
@@ -4738,8 +5830,6 @@ export async function runFrontendPlanSegmentedSessions(input) {
4738
5830
  : []),
4739
5831
  ].filter(Boolean).join("\n\n");
4740
5832
  };
4741
- 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.";
4742
- const buildCompactFinalizePrompt = (missing = []) => [buildPhasePrompt(finalizeSegment, missing), compactFinalizeInstruction].join("\n\n");
4743
5833
  const mapPlannerExhaustion = (r, committedAnyFacts) => isPlannerThinkingExhausted(r, committedAnyFacts)
4744
5834
  ? {
4745
5835
  ...r,
@@ -4782,12 +5872,80 @@ export async function runFrontendPlanSegmentedSessions(input) {
4782
5872
  tokensUsed: 0,
4783
5873
  };
4784
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);
4785
5878
  const { workGroups, buildWorkBatches, compactEligible } = buildFrontendPlanWorkload({
4786
5879
  basePrompt: input.basePrompt, requirementIds: allRequirementIds,
4787
5880
  requirementCosts: input.requirementCosts, sessionOptions: input.sessionOptions,
4788
- coverageTools: input.segmentCustomTools(coverageSegment.toolNames),
4789
- allTools: input.segmentCustomTools(null),
4790
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;
4791
5949
  // Small, single-surface requests do not benefit from six isolated Pi
4792
5950
  // sessions. Keep the typed ledger as the authority, but let one local
4793
5951
  // session establish requirement/UX facts and one final session establish
@@ -4805,6 +5963,12 @@ export async function runFrontendPlanSegmentedSessions(input) {
4805
5963
  "record_state_registry",
4806
5964
  "record_component_choice",
4807
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",
4808
5972
  "adopt_staged_fact",
4809
5973
  ]);
4810
5974
  queue.push({
@@ -4813,11 +5977,6 @@ export async function runFrontendPlanSegmentedSessions(input) {
4813
5977
  requirementSlice: [...allRequirementIds],
4814
5978
  prompt: buildCompactLocalPrompt(),
4815
5979
  });
4816
- queue.push({
4817
- id: "finalize",
4818
- toolNames: null,
4819
- prompt: buildCompactFinalizePrompt(),
4820
- });
4821
5980
  }
4822
5981
  else if (!requirementIdsProvided) {
4823
5982
  queue.push({
@@ -4856,10 +6015,25 @@ export async function runFrontendPlanSegmentedSessions(input) {
4856
6015
  prompt: slice.length ? buildUxPrompt(slice) : buildPhasePrompt(uxSegment),
4857
6016
  }));
4858
6017
  for (const segment of FRONTEND_PLAN_SEGMENTS) {
4859
- 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")
4860
6034
  continue;
4861
6035
  if (segment.id === "global-mock-data" && requirementIdsProvided) {
4862
- 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) }));
4863
6037
  }
4864
6038
  else {
4865
6039
  queue.push({
@@ -4869,11 +6043,6 @@ export async function runFrontendPlanSegmentedSessions(input) {
4869
6043
  });
4870
6044
  }
4871
6045
  }
4872
- queue.push({
4873
- id: finalizeSegment.id,
4874
- toolNames: finalizeSegment.toolNames,
4875
- prompt: buildPhasePrompt(finalizeSegment),
4876
- });
4877
6046
  }
4878
6047
  let accumulated;
4879
6048
  let index = 0;
@@ -4881,6 +6050,8 @@ export async function runFrontendPlanSegmentedSessions(input) {
4881
6050
  let lastDurableCount = input.committedFactCount();
4882
6051
  while (index < queue.length) {
4883
6052
  const session = queue[index];
6053
+ if (!session)
6054
+ break;
4884
6055
  if (input.attempt > 1 && input.committedFacts && session.id === "ux-registry" &&
4885
6056
  collectFrontendPlanPhaseMissingFacts({ phase: "ux-registry", requirementIds: allRequirementIds, committedFacts: input.committedFacts() }).length === 0) {
4886
6057
  index += 1;
@@ -4917,6 +6088,32 @@ export async function runFrontendPlanSegmentedSessions(input) {
4917
6088
  index += 1;
4918
6089
  continue;
4919
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
+ }
4920
6117
  let prompt = session.prompt;
4921
6118
  if (session.coverageOnly && session.coverageSlice) {
4922
6119
  const promptSlice = [...new Set([...remaining, ...preexistingMissing.flatMap(item => item.requirementIds)])];
@@ -4936,15 +6133,16 @@ export async function runFrontendPlanSegmentedSessions(input) {
4936
6133
  : buildPhasePrompt(uxSegment, session.missingFacts);
4937
6134
  }
4938
6135
  else {
4939
- // Global phases and finalize also consume the latest committed ledger;
6136
+ // Global phases consume the latest committed ledger;
4940
6137
  // constructing their prompt only when the session starts prevents a
4941
6138
  // stale queue entry from dropping facts written by earlier phases.
4942
6139
  const segment = FRONTEND_PLAN_SEGMENTS.find((candidate) => candidate.id === session.id || (candidate.id === "global-mock-data" && session.id.startsWith("global-mock-data-")));
4943
- if (segment) {
4944
- prompt =
4945
- session.id === "finalize" && useCompactSmallPlan
4946
- ? buildCompactFinalizePrompt(session.missingFacts)
4947
- : 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);
4948
6146
  }
4949
6147
  }
4950
6148
  const atomicFocus = session.atomicRecovery ? session.missingFacts?.[0] : undefined;
@@ -4971,17 +6169,6 @@ export async function runFrontendPlanSegmentedSessions(input) {
4971
6169
  "state-flow": "record_state_flow", "data-flow": "record_data_flow", "mock-api": "record_mock_api",
4972
6170
  };
4973
6171
  const customTools = input.segmentCustomTools(atomicFocus ? new Set([atomicTools[atomicFocus.kind]]) : session.toolNames);
4974
- const scopeForPacking = session.coverageSlice ?? session.requirementSlice;
4975
- const envelopeBytes = Buffer.byteLength(prompt) + Buffer.byteLength(input.sessionOptions.userMessage ?? "") + Buffer.byteLength(JSON.stringify(customTools));
4976
- if (envelopeBytes > (input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES) && scopeForPacking && session.id !== "compact-local") {
4977
- const groups = workGroups.map(g => g.requirementIds.filter(id => scopeForPacking.includes(id))).filter(g => g.length);
4978
- if (groups.length > 1) {
4979
- const half = Math.ceil(groups.length / 2);
4980
- queue.splice(index, 1, ...[groups.slice(0, half).flat(), groups.slice(half).flat()].map(slice => ({ ...session, ...(session.coverageOnly ? { coverageSlice: slice } : { requirementSlice: slice }) })));
4981
- invocationCount -= 1;
4982
- continue;
4983
- }
4984
- }
4985
6172
  const result = await observeFrontendSession({
4986
6173
  ...input.observation, phase: `plan/${session.id}`, scopeIds: session.requirementSlice ?? session.coverageSlice ?? allRequirementIds,
4987
6174
  prompt, userMessage: input.sessionOptions.userMessage, customTools, committedCount: input.committedFactCount, durableCommittedCount: () => lastDurableCount,
@@ -5013,8 +6200,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
5013
6200
  ? combineSequentialPiResults(accumulated, result)
5014
6201
  : result;
5015
6202
  const committedAfter = input.committedFactCount();
5016
- const committedFactsOnlySuccess = session.id !== "finalize" &&
5017
- !(result.assistantText ?? "").trim() &&
6203
+ const committedFactsOnlySuccess = !(result.assistantText ?? "").trim() &&
5018
6204
  !result.stderr.trim() &&
5019
6205
  !result.timedOut &&
5020
6206
  committedAfter > committedBefore;
@@ -5094,16 +6280,14 @@ export async function runFrontendPlanSegmentedSessions(input) {
5094
6280
  ? buildCompactLocalPrompt(missing)
5095
6281
  : isUxRegistrySession
5096
6282
  ? buildUxRegistryPrompt(missing)
5097
- : session.id === "finalize" && useCompactSmallPlan
5098
- ? buildCompactFinalizePrompt(missing)
5099
- : isUxLocalSession
5100
- ? buildUxPrompt(session.requirementSlice ?? [], missing)
5101
- : buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds);
6283
+ : isUxLocalSession
6284
+ ? buildUxPrompt(session.requirementSlice ?? [], missing)
6285
+ : buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds);
5102
6286
  const recovery = classifyFrontendPlanRecovery({ ...result, stopReason: readWriterThinkingExhaustionEvidence(result).stopReason });
5103
6287
  if (recovery === "stop")
5104
6288
  return { ...result, ok: false };
5105
6289
  if ((recovery === "output" || (session.atomicRecovery && result.ok)) && input.committedFacts &&
5106
- !(missingPhaseFacts.length === 1 && missingPhaseFacts[0].kind === "plan-requirement") &&
6290
+ !(missingPhaseFacts.length === 1 && missingPhaseFacts[0]?.kind === "plan-requirement") &&
5107
6291
  (session.coverageOnly || isUxRegistrySession || isUxLocalSession || session.id.startsWith("global-mock-data"))) {
5108
6292
  if (missingPhaseFacts.length === 0) {
5109
6293
  // A complete validated slice does not need a successful prose turn.
@@ -5329,6 +6513,91 @@ export async function runFrontendPlanSegmentedSessions(input) {
5329
6513
  failureCategory: "invalid-output",
5330
6514
  };
5331
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
+ }
5332
6601
  return mapPlannerExhaustion(accumulated ?? {
5333
6602
  ok: false,
5334
6603
  stdout: "",
@@ -5622,6 +6891,10 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5622
6891
  let reviewInventory;
5623
6892
  let designInventory;
5624
6893
  let planLedgerTools;
6894
+ let decisionPlanTools;
6895
+ let parentDecisionSnapshot;
6896
+ let decisionPlanAuthority;
6897
+ const useDecisionPlan = process.env.HARNESS_FRONTEND_PLAN_DECISION === "1";
5625
6898
  let contractTools;
5626
6899
  let scoutEvidenceTools;
5627
6900
  let scoutSourceDeclaredPaths;
@@ -5721,9 +6994,23 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5721
6994
  runDir: meta.runDir,
5722
6995
  nodeId: input.task.id,
5723
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
+ }
5724
7010
  writerToolPolicy = {
5725
7011
  requireSdk: true,
5726
7012
  customTools: reviewTerminalTools.customTools,
7013
+ requireAllowlistedCustomTools: true,
5727
7014
  };
5728
7015
  }
5729
7016
  catch (error) {
@@ -5748,9 +7035,22 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5748
7035
  runDir: meta.runDir,
5749
7036
  nodeId: input.task.id,
5750
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
+ }
5751
7050
  writerToolPolicy = {
5752
7051
  requireSdk: true,
5753
7052
  customTools: designTerminalTools.customTools,
7053
+ requireAllowlistedCustomTools: true,
5754
7054
  };
5755
7055
  }
5756
7056
  catch (error) {
@@ -5781,6 +7081,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5781
7081
  writerToolPolicy = {
5782
7082
  requireSdk: true,
5783
7083
  customTools: contractTools.customTools,
7084
+ requireAllowlistedCustomTools: true,
5784
7085
  };
5785
7086
  }
5786
7087
  catch (error) {
@@ -5814,6 +7115,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5814
7115
  writerToolPolicy = {
5815
7116
  requireSdk: true,
5816
7117
  customTools: scoutEvidenceTools.customTools,
7118
+ requireAllowlistedCustomTools: true,
5817
7119
  };
5818
7120
  }
5819
7121
  catch (error) {
@@ -5830,30 +7132,66 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5830
7132
  try {
5831
7133
  const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5832
7134
  const store = createTypedEventStore();
5833
- planLedgerTools = await createFrontendPlanLedgerTools({
5834
- attemptId: `${meta.runId}:${input.task.id}`,
5835
- store,
5836
- runDir: meta.runDir,
5837
- nodeId: input.task.id,
5838
- skeleton: input.task.structuredContractOutput?.skeleton,
5839
- sourceBinding: meta.spec.sourceBinding,
5840
- writeSetPatterns: input.task.writeSet,
5841
- componentNewSourceReferences: await resolveFrontendPlanNewComponentSourceReferences({
5842
- cwd: input.cwd,
7135
+ if (useDecisionPlan) {
7136
+ const authority = await resolveFrontendDecisionAuthority({
7137
+ runDir: meta.runDir,
5843
7138
  sourceBinding: meta.spec.sourceBinding,
5844
- }),
5845
- 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,
5846
7152
  runDir: meta.runDir,
5847
- }),
5848
- 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,
5849
7172
  runDir: meta.runDir,
5850
- }),
5851
- workspaceRoot: input.cwd,
5852
- });
5853
- writerToolPolicy = {
5854
- requireSdk: true,
5855
- customTools: planLedgerTools.customTools,
5856
- };
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
+ }
5857
7195
  }
5858
7196
  catch (error) {
5859
7197
  return {
@@ -5872,6 +7210,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5872
7210
  budget: input.task.readBudget,
5873
7211
  });
5874
7212
  writerToolPolicy = {
7213
+ ...(writerToolPolicy ?? {}),
5875
7214
  requireSdk: true,
5876
7215
  customTools: [
5877
7216
  ...(writerToolPolicy?.customTools ?? []),
@@ -6016,6 +7355,136 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6016
7355
  },
6017
7356
  });
6018
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
+ }
6019
7488
  else if (isFrontendPlanLedgerNode(input.task) && planLedgerTools) {
6020
7489
  // Frontend-only split: independent coverage map sessions feed a single
6021
7490
  // reducer (UX decisions -> global policy -> finalize), mirroring the
@@ -6025,7 +7494,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6025
7494
  const planRequirementCosts = new Map();
6026
7495
  const behaviorRequiredRequirementIds = [];
6027
7496
  try {
6028
- const { readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
7497
+ const { readCommittedOriginFacts } = await import("../workflows/dag/frontend-committed-facts.js");
6029
7498
  const contractFacts = await readCommittedOriginFacts(meta.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl");
6030
7499
  // Only requirement facts: the contract ledger also carries
6031
7500
  // constraints (CON-*), evidence expectations (EV-*), handoff
@@ -6033,11 +7502,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6033
7502
  // (SPLIT-*) that all have ids — feeding those into the coverage
6034
7503
  // batches made the model record non-frozen plan-requirement ids
6035
7504
  // that finalize's canonical-coverage gate then rejected (r-ext2).
6036
- planRequirementIds = contractFacts
6037
- .filter((record) => record.fact
6038
- ?.kind === "requirement")
6039
- .map((record) => record.fact?.id)
6040
- .filter((id) => typeof id === "string");
7505
+ planRequirementIds = collectFrontendPlanRequirementIds(contractFacts);
6041
7506
  for (const fact of resolveFrontendContractRequirements(contractFacts.map((record) => record.fact))) {
6042
7507
  if (fact.evidence.behavior === "required")
6043
7508
  behaviorRequiredRequirementIds.push(fact.id);
@@ -6078,6 +7543,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6078
7543
  : {}),
6079
7544
  committedRequirementIds: () => options.ledgerTools.committedRequirementIds(),
6080
7545
  committedFacts: () => options.ledgerTools.committedFacts(),
7546
+ finalizePlan: (params) => options.ledgerTools.finalizePlan(params),
6081
7547
  behaviorRequiredRequirementIds,
6082
7548
  setActiveRequirementScope: (requirementIds) => options.ledgerTools.setActiveRequirementScope(requirementIds),
6083
7549
  segmentCustomTools: (toolNames) => toolNames === null
@@ -6088,12 +7554,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6088
7554
  flushLedger: () => options.ledgerTools.flush(),
6089
7555
  });
6090
7556
  if (planRequirementIds.length > 1) {
6091
- const coverageTools = FRONTEND_PLAN_SEGMENTS.find(segment => segment.id === "coverage").toolNames;
6092
7557
  const workload = buildFrontendPlanWorkload({
6093
7558
  basePrompt: input.prompt, requirementIds: planRequirementIds,
6094
7559
  requirementCosts: planRequirementCosts, sessionOptions: piSessionOptions,
6095
- allTools: planLedgerTools.customTools,
6096
- coverageTools: planLedgerTools.customTools.filter(tool => isRecordObject(tool) && coverageTools.has(String(tool.name))),
6097
7560
  });
6098
7561
  let coverageBatches;
6099
7562
  try {
@@ -6383,12 +7846,12 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6383
7846
  if (readBudgetIssues.length > 0) {
6384
7847
  const hasTypedFrontendTerminal = isFrontendReviewTypedTerminalNode(input.task) ||
6385
7848
  isFrontendDesignTypedTerminalNode(input.task);
6386
- // 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
6387
7850
  // design/review node has a successful typed terminal, telemetry is
6388
- // diagnostic only; the terminal shadow below still fails closed when the
7851
+ // diagnostic only; the typed terminal validation below still fails closed when the
6389
7852
  // fact is missing or conflicting.
6390
7853
  if (mapped.ok && hasTypedFrontendTerminal) {
6391
- // Continue to the typed terminal shadow validation below.
7854
+ // Continue to the typed terminal validation below.
6392
7855
  }
6393
7856
  else {
6394
7857
  // Read-budget telemetry is diagnostic only when the provider/executor
@@ -6414,7 +7877,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6414
7877
  }
6415
7878
  }
6416
7879
  if (isFrontendReviewTypedTerminalNode(input.task)) {
6417
- return await runFrontendReviewTerminalShadow({
7880
+ return await validateFrontendReviewTerminal({
6418
7881
  task: input.task,
6419
7882
  meta,
6420
7883
  mapped,
@@ -6422,7 +7885,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6422
7885
  });
6423
7886
  }
6424
7887
  if (isFrontendDesignTypedTerminalNode(input.task)) {
6425
- return await runFrontendDesignTerminalShadow({
7888
+ return await validateFrontendDesignTerminal({
6426
7889
  task: input.task,
6427
7890
  meta,
6428
7891
  mapped,
@@ -6445,7 +7908,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6445
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)}` };
6446
7909
  }
6447
7910
  if (mapped.ok) {
6448
- const { checkCommittedOriginFacts, readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
7911
+ const { checkCommittedOriginFacts, readCommittedOriginFacts } = await import("../workflows/dag/frontend-committed-facts.js");
6449
7912
  const scoutFacts = await readCommittedOriginFacts(meta.runDir, input.task.id, "scout-typed-facts.jsonl");
6450
7913
  const completeness = checkCommittedOriginFacts({
6451
7914
  records: scoutFacts,
@@ -6677,15 +8140,15 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6677
8140
  let factDerivedStatus;
6678
8141
  if (mapped.ok && input.task.writerOutcomePolicy) {
6679
8142
  if (isFrontendFactsWriter(input.task)) {
6680
- // AC-001/AC-003: facts-derived status. The first line is never read;
6681
- // 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.
6682
8145
  try {
6683
8146
  const [writerStatus, contractModule, traceModule] = await Promise.all([
6684
8147
  import("../workflows/dag/frontend-writer-status.js"),
6685
8148
  import("../workflows/dag/frontend-implementation-contract.js"),
6686
8149
  import("../workflows/dag/frontend-verification-trace.js"),
6687
8150
  ]);
6688
- const { collectFrontendWriterFacts, deriveFrontendWriterStatus, computeFailureFingerprint, compareFactStatusToLegacyOutcome, } = writerStatus;
8151
+ const { collectFrontendWriterFacts, deriveFrontendWriterStatus, computeFailureFingerprint, } = writerStatus;
6689
8152
  const contractPath = path.join(meta.runDir, "contracts", "frontend-implementation-contract.json");
6690
8153
  const contractRaw = JSON.parse(await readFile(contractPath, "utf8"));
6691
8154
  const parsedContract = contractModule.frontendImplementationContractSchema.safeParse(contractRaw);
@@ -6716,19 +8179,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6716
8179
  wallTimeMs: Date.now() - started,
6717
8180
  rounds: 1,
6718
8181
  writeAttempts: attempt,
6719
- firstLineText: mapped.assistantText,
6720
8182
  });
6721
8183
  const derived = deriveFrontendWriterStatus(facts);
6722
8184
  factDerivedStatus = derived.status;
6723
- const legacyValidation = validateWriterImplementationOutcome(mapped.assistantText || mapped.stdout, changeManifestChangedFiles ?? [], {
6724
- requireChangedFiles: false,
6725
- allowMissingChangedOutcomeWhenDiffPresent: false,
6726
- });
6727
- const legacyOutcome = legacyValidation.ok
6728
- ? legacyValidation.outcome
6729
- : legacyValidation.reason.includes("blocked")
6730
- ? "blocked"
6731
- : "missing";
6732
8185
  await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "fact-implementation-status.json", {
6733
8186
  schemaVersion: 1,
6734
8187
  nodeId: input.task.id,
@@ -6745,7 +8198,6 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
6745
8198
  rounds: facts.rounds,
6746
8199
  writeAttempts: facts.writeAttempts,
6747
8200
  failureFingerprint: computeFailureFingerprint(facts),
6748
- shadow: compareFactStatusToLegacyOutcome(derived.status, legacyOutcome),
6749
8201
  });
6750
8202
  if (derived.status !== "changed" &&
6751
8203
  derived.status !== "already-satisfied") {