@tea-agent/loop-agent 0.43.0-next.1 → 0.43.0-next.11

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 (207) hide show
  1. package/CHANGELOG.md +144 -9
  2. package/dist/application/dag/run-dag.js +5 -0
  3. package/dist/application/evaluation/budget.js +19 -1
  4. package/dist/application/task-lifecycle/advance.js +17 -4
  5. package/dist/application/task-lifecycle/observe.js +43 -29
  6. package/dist/application/task-lifecycle/plan-transitions.js +5 -4
  7. package/dist/build-stamp.json +3 -3
  8. package/dist/executors/dag-pi-executor.js +1237 -586
  9. package/dist/executors/pi-executor.js +22 -1
  10. package/dist/executors/pi-sdk-executor.js +140 -39
  11. package/dist/executors/shell-executor.js +8 -4
  12. package/dist/shared/frontend-execution-policy.js +23 -0
  13. package/dist/task/config-types.js +4 -0
  14. package/dist/task/source-prepare/ledger-reconciliation.js +2 -2
  15. package/dist/task/source-prepare/ledger-review.js +6 -9
  16. package/dist/task/source-prepare/semantic-intake.js +16 -26
  17. package/dist/task/source-prepare/source-fidelity-pi.js +28 -9
  18. package/dist/task/source-references.js +48 -23
  19. package/dist/worker/console/chat/assistant-content.js +23 -2
  20. package/dist/worker/console/chat/browser-policy.js +113 -0
  21. package/dist/worker/console/chat/browser-routes.js +140 -0
  22. package/dist/worker/console/chat/explore-tools.js +13 -0
  23. package/dist/worker/console/chat/pi-runtime/custom-tools/ask-user-question.js +44 -0
  24. package/dist/worker/console/chat/pi-runtime/custom-tools/explore-tools.js +202 -0
  25. package/dist/worker/console/chat/pi-runtime/custom-tools/goal-tools.js +79 -0
  26. package/dist/worker/console/chat/pi-runtime/custom-tools/operator-tools.js +86 -0
  27. package/dist/worker/console/chat/pi-runtime/custom-tools/todo-write.js +38 -0
  28. package/dist/worker/console/chat/pi-runtime/inspection-types.js +28 -0
  29. package/dist/worker/console/chat/pi-runtime/inspection.js +439 -0
  30. package/dist/worker/console/chat/pi-runtime/sdk-bindings.js +153 -0
  31. package/dist/worker/console/chat/pi-runtime/sdk-session.js +5 -0
  32. package/dist/worker/console/chat/pi-runtime.js +207 -1178
  33. package/dist/worker/console/chat/routes.js +208 -71
  34. package/dist/worker/console/chat/scm-routes.js +217 -0
  35. package/dist/worker/console/chat/scm-service.js +283 -0
  36. package/dist/worker/console/chat/scm-tools.js +111 -0
  37. package/dist/worker/console/chat/sdd-data-alignment.js +65 -9
  38. package/dist/worker/console/chat/session-catalog.js +32 -0
  39. package/dist/worker/console/chat/session-mode-view.js +49 -0
  40. package/dist/worker/console/chat/session-mode.js +219 -0
  41. package/dist/worker/console/chat/session-store.js +22 -6
  42. package/dist/worker/console/chat/shortcuts.js +6 -0
  43. package/dist/worker/console/chat/terminal-routes.js +216 -0
  44. package/dist/worker/console/chat/terminal-sessions.js +348 -0
  45. package/dist/worker/console/chat/terminal-tools.js +211 -0
  46. package/dist/worker/console/chat/tool-preview.js +11 -0
  47. package/dist/worker/console/chat/tools.js +2 -0
  48. package/dist/worker/console/server.js +2 -28
  49. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-B0Q0nClf.js → abnfDiagram-N423BO3Z-CTSK0xbU.js} +1 -1
  50. package/dist/worker/console/static/assets/{arc-DCPjC19G.js → arc-_2MfctFP.js} +1 -1
  51. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP5n9jmG.js → architectureDiagram-T3A2C74G-DsDe2wTg.js} +1 -1
  52. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CmaWi_Wg.js → blockDiagram-VBNYF7ZC-Dtz_779U.js} +1 -1
  53. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-Ct5tcMfZ.js → c4Diagram-5PPSVZJV-LaULi-gw.js} +1 -1
  54. package/dist/worker/console/static/assets/channel-CqS4yBWT.js +1 -0
  55. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-C0osm1Zf.js → chunk-2GRJ4B5K-DuHBqqX8.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CDWviED7.js → chunk-2Q5K7J3B-BZgZs1dn.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-mtSk1-j7.js → chunk-5RXB4S5H-tzwmlIO_.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D5J9PHC7.js → chunk-5VM5RSS4-CeYrLkSn.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Begg4WAa.js → chunk-6Q2QTUOP-C92cgilm.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-sF1AEy7T.js → chunk-GF5L2VYU-XOXpGC0F.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BqbsWXZb.js → chunk-JWPE2WC7-CGHbJyCH.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BKJrPcJ6.js → chunk-KBJHAD2P-Bs2-LTzf.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CcwNMRho.js → chunk-RYQCIY6F-rN7up7E4.js} +1 -1
  64. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BEJi3iIs.js → chunk-XXDRQBXY-B_fWjKpR.js} +1 -1
  65. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DVOxNOq0.js +1 -0
  66. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DVOxNOq0.js +1 -0
  67. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-DRLgTvVu.js → cose-bilkent-JH36ORCC-DE3SdJvB.js} +1 -1
  68. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BoAVG3cs.js → cynefin-VYW2F7L2-B-4yuRAX.js} +1 -1
  69. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bzol06hR.js → cynefinDiagram-MW4NZA55-TiCPFuO8.js} +1 -1
  70. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UmliJM77.js → dagre-VZM6K2ZE-7crzLMzA.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BWLcJkFo.js → diagram-7IWD3JNH-Cvr-x6Fd.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CgApOm-O.js → diagram-B4RE2ZJO-BzcRHUjH.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Bh3RgTLs.js → diagram-LBJQPF4R-BjkAXilh.js} +1 -1
  74. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-Dsh-D5nE.js → diagram-Q27KOJAE-BkzyATK7.js} +1 -1
  75. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Dkbbpcpb.js → diagram-UB23O5K3-rZuTarYP.js} +1 -1
  76. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-3EBaA3t3.js → ebnfDiagram-BXEA7PRR-CAYENaN1.js} +1 -1
  77. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bq2Dc5ok.js → erDiagram-JOGREHBK-DCEam0Uq.js} +1 -1
  78. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-De7y55ha.js → flowDiagram-UKHOOZJN-e_dH4wVO.js} +1 -1
  79. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-fLiDbQRh.js → ganttDiagram-PKOTCBZU-CvZa2zOK.js} +1 -1
  80. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Du617mp1.js → gitGraphDiagram-DS77QQ5N-CbT5TuKp.js} +1 -1
  81. package/dist/worker/console/static/assets/index-CUPeoE8W.js +468 -0
  82. package/dist/worker/console/static/assets/index-SAANIK8g.css +1 -0
  83. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BwmlF-XP.js → infoDiagram-6WML65LV-BWzHQa_Z.js} +1 -1
  84. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-zsGtS59u.js → ishikawaDiagram-WSZJBQD7-BTjlovtU.js} +1 -1
  85. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DTTPTe3f.js → journeyDiagram-NVQOT4AX-DkvQoCc1.js} +1 -1
  86. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BkXg9aP-.js → kanban-definition-27J2QSJJ-CHSzGi3W.js} +1 -1
  87. package/dist/worker/console/static/assets/{linear-7U2ue5IE.js → linear-DZMkTCWH.js} +1 -1
  88. package/dist/worker/console/static/assets/{mermaid.core-BUuGHmWO.js → mermaid.core-BIMhty7A.js} +5 -5
  89. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-7q6fX0Sv.js → mindmap-definition-FAOFIHXS-Dx-tyMl3.js} +1 -1
  90. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkQPJN55.js → pegDiagram-VL7TDLO6-C6G34rbY.js} +1 -1
  91. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CCal_tfX.js → pieDiagram-7S7Q4E2Y-CHK9XOfm.js} +1 -1
  92. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B7bpxyAZ.js → quadrantDiagram-CIZ2JOQS-CDARkI0w.js} +1 -1
  93. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CJmww7D-.js → railroadDiagram-AXF67PYL-DiuUe0ZQ.js} +1 -1
  94. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D6ldWJEv.js → requirementDiagram-LRYGKXZP-RE61UR56.js} +1 -1
  95. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-VGh33I9e.js → sankeyDiagram-W5VNT64P-KDNLHMEG.js} +1 -1
  96. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CCkDrIjU.js → sequenceDiagram-SI44F4Z6-CeVgXnD9.js} +1 -1
  97. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-NC8f5Otb.js → sizeCapture-X5ZJPWSS-CXwGyjjG.js} +1 -1
  98. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-_92ezZdF.js → stateDiagram-OKZ733FA-BjTfozab.js} +1 -1
  99. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BXoYsyr2.js +1 -0
  100. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BdUCGtbP.js → swimlanes-SLNWSIFB-BaXOB8GT.js} +2 -2
  101. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DfKliS4e.js +8 -0
  102. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CdD1H8ia.js → timeline-definition-Z64GVDOM-C3r2hqUU.js} +1 -1
  103. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-Dkdk3oFo.js → vennDiagram-T6HMQDX7-MWKgl1S9.js} +1 -1
  104. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CKu2uPPY.js → wardleyDiagram-T6FBY63Y-C9iFcZPk.js} +1 -1
  105. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-ClqAnuG8.js → xychartDiagram-ELKLHX3M-DqnM24Jp.js} +1 -1
  106. package/dist/worker/console/static/index.html +2 -2
  107. package/dist/worker/console/static-src/operator-chat/active-turn-tail.js +10 -0
  108. package/dist/worker/console/static-src/operator-chat/chat-link.js +94 -0
  109. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +122 -10
  110. package/dist/worker/console/static-src/operator-chat/compaction-message.js +7 -0
  111. package/dist/worker/console/static-src/operator-chat/pending-user-message.js +27 -15
  112. package/dist/worker/console/static-src/operator-chat/process-label.js +41 -0
  113. package/dist/worker/console/static-src/operator-chat/turn-group-equality.js +13 -0
  114. package/dist/worker/console/static-src/operator-chat/turn-process-disclosure.js +10 -0
  115. package/dist/worker/console/static-src/operator-chat/use-searchable-hidden.js +51 -0
  116. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +44 -24
  117. package/dist/worker/console/static-src/operator-chat/useChatStream.js +18 -2
  118. package/dist/worker/console/static-src/operator-chat/useChatThread.js +41 -8
  119. package/dist/worker/console/static-src/operator-chat/useComposer.js +26 -11
  120. package/dist/worker/console/static-src/operator-chat/user-turn-anchor-equality.js +21 -0
  121. package/dist/worker/console/workspace-context.js +15 -1
  122. package/dist/worker/observe/node-transparency.js +81 -72
  123. package/dist/worker/observe/routes.js +20 -1
  124. package/dist/worker/observe/static/dag-inspector-humanize.js +3 -0
  125. package/dist/worker/observe/static/dom.js +20 -1
  126. package/dist/worker/observe/static/format-pool.d.ts +2 -0
  127. package/dist/worker/observe/static/format-pool.js +6 -0
  128. package/dist/worker/observe/static/format.js +7 -0
  129. package/dist/worker/observe/static/inspect-workspace.js +34 -7
  130. package/dist/worker/observe/static/inspector-submission.js +32 -0
  131. package/dist/worker/observe/static/kpi.js +1 -0
  132. package/dist/worker/observe/static/relations.js +2 -0
  133. package/dist/worker/observe/static/router.js +13 -0
  134. package/dist/worker/observe/static/run-processing.js +2 -0
  135. package/dist/worker/observe/static/shell-chrome.js +36 -3
  136. package/dist/worker/observe/static/state.js +35 -2
  137. package/dist/worker/observe/static/styles.css +263 -39
  138. package/dist/worker/observe/static/task-failure-labels.d.ts +4 -0
  139. package/dist/worker/observe/static/task-failure-labels.js +67 -0
  140. package/dist/worker/observe/static/task-history.js +12 -0
  141. package/dist/worker/observe/static/views/batch.js +6 -13
  142. package/dist/worker/observe/static/views/dag-graph.js +50 -3
  143. package/dist/worker/observe/static/views/dag-inspector.js +742 -268
  144. package/dist/worker/observe/static/views/dag-trajectory.js +3 -0
  145. package/dist/worker/observe/static/views/dag.d.ts +6 -0
  146. package/dist/worker/observe/static/views/dag.js +49 -11
  147. package/dist/worker/observe/static/views/dags.js +2 -0
  148. package/dist/worker/observe/static/views/dashboard.js +21 -12
  149. package/dist/worker/observe/static/views/failures.js +21 -11
  150. package/dist/worker/observe/static/views/feature.js +11 -29
  151. package/dist/worker/observe/static/views/pool.js +37 -28
  152. package/dist/worker/observe/static/views/run.js +48 -5
  153. package/dist/worker/observe/static/views/session-timeline.js +189 -240
  154. package/dist/worker/observe/static/views/task.js +81 -62
  155. package/dist/workflows/dag/budget-enforcement.js +53 -3
  156. package/dist/workflows/dag/frontend-capacity.js +9 -0
  157. package/dist/workflows/dag/frontend-closeout.js +1 -1
  158. package/dist/workflows/dag/frontend-design-policy.js +2 -1
  159. package/dist/workflows/dag/frontend-durable-tools.js +193 -0
  160. package/dist/workflows/dag/frontend-execution-groups.js +24 -0
  161. package/dist/workflows/dag/frontend-implementation-contract.js +13 -0
  162. package/dist/workflows/dag/frontend-input-projection.js +76 -0
  163. package/dist/workflows/dag/frontend-plan-completeness.js +186 -0
  164. package/dist/workflows/dag/frontend-plan-recovery-policy.js +18 -0
  165. package/dist/workflows/dag/frontend-plan-render.js +10 -3
  166. package/dist/workflows/dag/frontend-prewrite-gate.js +3 -3
  167. package/dist/workflows/dag/frontend-recovery-controller.js +7 -7
  168. package/dist/workflows/dag/frontend-recovery-lineage.js +13 -0
  169. package/dist/workflows/dag/frontend-recovery-run.js +4 -0
  170. package/dist/workflows/dag/frontend-review-context.js +2 -1
  171. package/dist/workflows/dag/frontend-review-scopes.js +139 -0
  172. package/dist/workflows/dag/frontend-session-budget.js +249 -0
  173. package/dist/workflows/dag/frontend-shadow-dual-write.js +20 -2
  174. package/dist/workflows/dag/frontend-shape-facts.js +12 -2
  175. package/dist/workflows/dag/frontend-shape.js +30 -1
  176. package/dist/workflows/dag/frontend-test-execution-evidence.js +3 -2
  177. package/dist/workflows/dag/frontend-typed-event-store.js +11 -0
  178. package/dist/workflows/dag/frontend-verification-trace.js +1 -1
  179. package/dist/workflows/dag/init-hybrid.js +65 -167
  180. package/dist/workflows/dag/node-execution.js +38 -155
  181. package/dist/workflows/dag/prompt.js +4 -0
  182. package/dist/workflows/dag/rerun-plan.js +7 -1
  183. package/dist/workflows/dag/runner.js +26 -1
  184. package/dist/workflows/dag/types.js +6 -0
  185. package/docs/operations/README.md +1 -0
  186. package/docs/templates/frontend-design-contract.md +4 -4
  187. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  188. package/docs/templates/frontend-implementation-dag.json +14 -13
  189. package/package.json +6 -3
  190. package/skills/frontend-bounded-implement/SKILL.md +3 -3
  191. package/skills/frontend-bounded-implement/references/code-standards.md +3 -3
  192. package/skills/frontend-contract/SKILL.md +2 -1
  193. package/skills/frontend-contract/references/contract-protocol.md +22 -4
  194. package/skills/frontend-design-review/SKILL.md +22 -16
  195. package/skills/frontend-design-review/references/review-checklist.md +7 -7
  196. package/skills/frontend-plan/SKILL.md +21 -2
  197. package/skills/frontend-plan/references/decision-contract.md +42 -23
  198. package/skills/frontend-review/SKILL.md +22 -29
  199. package/skills/frontend-review/references/review-findings.md +24 -43
  200. package/skills/frontend-scout/references/scout-evidence.md +4 -0
  201. package/dist/worker/console/static/assets/channel-DAS07MdS.js +0 -1
  202. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cwk-5jiW.js +0 -1
  203. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cwk-5jiW.js +0 -1
  204. package/dist/worker/console/static/assets/index-C0O48S_P.js +0 -449
  205. package/dist/worker/console/static/assets/index-CzKf4U8P.css +0 -1
  206. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DyJg5gzW.js +0 -1
  207. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B1cw_0Uy.js +0 -8
@@ -1,3 +1,13 @@
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
+ import { classifyFrontendPlanRecovery } from "../workflows/dag/frontend-plan-recovery-policy.js";
4
+ import { collectFrontendExecutionGroups, frontendExecutionSchema } from "../workflows/dag/frontend-execution-groups.js";
5
+ import { FRONTEND_SCOPE_TARGET_BYTES, packFrontendInputUnits, parseFrontendInputBlock, projectFrontendContractPrompt, projectFrontendInputScope } from "../workflows/dag/frontend-input-projection.js";
6
+ import { createDurableFrontendTools } from "../workflows/dag/frontend-durable-tools.js";
7
+ import { sha256OfCanonicalJson } from "../task/contract/hash.js";
8
+ import { z } from "zod";
9
+ import { createFrontendReviewScopeProtocol, loadFrontendReviewScopes } from "../workflows/dag/frontend-review-scopes.js";
10
+ import { observeFrontendSession } from "../workflows/dag/frontend-session-budget.js";
1
11
  import path from "node:path";
2
12
  import { createHash, randomUUID } from "node:crypto";
3
13
  import { readFile, stat } from "node:fs/promises";
@@ -454,6 +464,7 @@ export const FRONTEND_CONTRACT_RECORD_TOOL_NAMES = [
454
464
  "record_split_proposal",
455
465
  "record_ui_state",
456
466
  "record_required_deliverables",
467
+ "complete_contract_scope",
457
468
  ];
458
469
  export const FRONTEND_CONTRACT_TERMINAL_TOOL_NAMES = [
459
470
  "finalize_contract",
@@ -471,9 +482,11 @@ export const FRONTEND_PLAN_RECORD_TOOL_NAMES = [
471
482
  "record_state_flow",
472
483
  "record_data_flow",
473
484
  "record_mock_api",
485
+ "record_mock_endpoint",
474
486
  "record_design_deviation",
475
487
  "record_dependency",
476
488
  "record_plan_requirement",
489
+ "record_plan_group_coverage",
477
490
  "record_plan_verification_target",
478
491
  "record_plan_evidence_gap",
479
492
  ];
@@ -548,6 +561,8 @@ export function resolveDagPiToolNames(task) {
548
561
  if (isFrontendReviewTypedTerminalNode(task)) {
549
562
  return [
550
563
  ...DAG_PI_READONLY_TOOLS,
564
+ "complete_review_scope",
565
+ "record_review_finding",
551
566
  "approve_review",
552
567
  "request_review_changes",
553
568
  ];
@@ -555,6 +570,8 @@ export function resolveDagPiToolNames(task) {
555
570
  if (isFrontendDesignTypedTerminalNode(task)) {
556
571
  return [
557
572
  ...DAG_PI_READONLY_TOOLS,
573
+ "complete_review_scope",
574
+ "record_design_finding",
558
575
  "approve_design",
559
576
  "request_design_changes",
560
577
  ];
@@ -580,6 +597,7 @@ export function resolveDagPiToolNames(task) {
580
597
  ...FRONTEND_PLAN_RECORD_TOOL_NAMES,
581
598
  ...FRONTEND_PLAN_TERMINAL_TOOL_NAMES,
582
599
  ...FRONTEND_PLAN_ADOPT_TOOL_NAMES,
600
+ "read_plan_facts",
583
601
  ];
584
602
  }
585
603
  if ((task.readSet?.length ?? 0) > 0) {
@@ -759,21 +777,35 @@ export async function createFrontendReviewTerminalTools(input) {
759
777
  ]);
760
778
  const { approveReviewFactSchema, readCommittedEvents, requestReviewChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
761
779
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
762
- const store = input.store;
780
+ let store = input.store;
763
781
  const attemptId = input.attemptId;
764
782
  const findingSchema = Type.Object({
765
- severity: Type.String({
766
- description: "Critical | Important | Minor | Info",
767
- }),
768
- file: Type.Optional(Type.String({})),
769
- line: Type.Optional(Type.Number({})),
770
- issue: Type.String({}),
771
- requiredChange: Type.Optional(Type.String({})),
783
+ severity: Type.Enum({ Critical: "Critical", Important: "Important", Minor: "Minor", Info: "Info" }),
784
+ file: Type.Optional(Type.String({ minLength: 1 })),
785
+ line: Type.Optional(Type.Integer({ minimum: 1 })),
786
+ issue: Type.String({ minLength: 1 }),
787
+ requiredChange: Type.Optional(Type.String({ minLength: 1 })),
772
788
  }, { additionalProperties: false });
789
+ const scopeProtocol = createFrontendReviewScopeProtocol({ phase: "review", inventory: input.inventory, getStore: () => store, attemptId });
790
+ const savedFindings = () => [...new Map(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "review-finding").map(r => [r.fact.id, r.fact.finding])).values()];
791
+ const allFindings = (direct) => [...new Map([...savedFindings(), ...(Array.isArray(direct) ? direct : [])].map(finding => [JSON.stringify(finding), finding])).values()];
792
+ const recordFindingTool = defineTool({
793
+ name: "record_review_finding", label: "record_review_finding",
794
+ description: "Save one finding with a stable id. Submit findings incrementally, then finalize without repeating the findings array. Saved blocking findings cannot be omitted from approval; correct a finding explicitly with replace:true.",
795
+ parameters: Type.Object({ id: Type.String({ minLength: 1 }), finding: findingSchema }, { additionalProperties: false }),
796
+ async execute(_callId, params) {
797
+ const fact = { kind: "review-finding", id: params.id, finding: params.finding };
798
+ const requestId = `${attemptId}:finding:${randomUUID()}`;
799
+ const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
800
+ const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
801
+ const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
802
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
803
+ },
804
+ });
773
805
  const approveParameters = Type.Object({
774
- findings: Type.Array(findingSchema, {
806
+ findings: Type.Optional(Type.Array(findingSchema, {
775
807
  description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
776
- }),
808
+ })),
777
809
  }, { additionalProperties: false });
778
810
  const requestParameters = Type.Object({
779
811
  issueCategory: Type.Enum({
@@ -783,16 +815,17 @@ export async function createFrontendReviewTerminalTools(input) {
783
815
  "contract-requirement-gap": "contract-requirement-gap",
784
816
  "unknown": "unknown",
785
817
  }, { description: "Typed issue category (five-value enum)" }),
786
- evidenceRefs: Type.Array(Type.String({}), {
787
- description: "Evidence refs (paths or artifact ids); at least one",
788
- }),
789
- findings: Type.Array(findingSchema, {
790
- description: "At least one finding",
818
+ evidenceRefs: Type.Array(Type.String({ minLength: 1 }), {
819
+ description: "Evidence refs (paths or artifact ids); at least one", minItems: 1,
791
820
  }),
821
+ findings: Type.Optional(Type.Array(findingSchema, {
822
+ description: "At least one finding", minItems: 1,
823
+ })),
792
824
  }, { additionalProperties: false });
793
825
  async function adoptReviewFact(kind, fact) {
794
826
  const requestId = randomUUID();
795
827
  try {
828
+ scopeProtocol.assertComplete();
796
829
  const parsed = kind === "approve_review"
797
830
  ? approveReviewFactSchema.parse(fact)
798
831
  : requestReviewChangesFactSchema.parse(fact);
@@ -854,7 +887,7 @@ export async function createFrontendReviewTerminalTools(input) {
854
887
  return adoptReviewFact("approve_review", {
855
888
  kind: "approve_review",
856
889
  verdict: "approve_review",
857
- findings: params?.findings ?? [],
890
+ findings: allFindings(params?.findings),
858
891
  });
859
892
  },
860
893
  });
@@ -870,17 +903,15 @@ export async function createFrontendReviewTerminalTools(input) {
870
903
  verdict: "request_review_changes",
871
904
  issueCategory: params?.issueCategory,
872
905
  evidenceRefs: params?.evidenceRefs,
873
- findings: params?.findings,
906
+ findings: allFindings(params?.findings),
874
907
  });
875
908
  },
876
909
  });
877
- return {
878
- customTools: [approveReviewTool, requestReviewChangesTool],
879
- flush: async () => {
880
- const committed = readCommittedEvents(store, attemptId);
881
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "review-typed-facts.jsonl"), committed);
882
- },
883
- };
910
+ const durable = await createDurableFrontendTools({
911
+ file: path.join(input.runDir, input.nodeId, "review-typed-facts.jsonl"), attemptId, store: input.store,
912
+ binding: { inputDigest: input.inputDigest, scopeDigest: input.inventory?.digest }, setWorkingStore: next => { store = next; }, tools: [...scopeProtocol.customTools, recordFindingTool, approveReviewTool, requestReviewChangesTool], validateRestored: async () => { await input.inventory?.validate(); },
913
+ });
914
+ return { ...durable, scopeProtocol };
884
915
  }
885
916
  /**
886
917
  * M8: build the two committed typed design terminal tools (approve_design /
@@ -897,21 +928,35 @@ export async function createFrontendDesignTerminalTools(input) {
897
928
  ]);
898
929
  const { approveDesignFactSchema, readCommittedEvents, requestDesignChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
899
930
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
900
- const store = input.store;
931
+ let store = input.store;
901
932
  const attemptId = input.attemptId;
902
933
  const findingSchema = Type.Object({
903
- severity: Type.String({
904
- description: "Critical | Important | Minor | Info",
905
- }),
906
- file: Type.Optional(Type.String({})),
907
- line: Type.Optional(Type.Number({})),
908
- issue: Type.String({}),
909
- requiredChange: Type.Optional(Type.String({})),
934
+ severity: Type.Enum({ Critical: "Critical", Important: "Important", Minor: "Minor", Info: "Info" }),
935
+ file: Type.Optional(Type.String({ minLength: 1 })),
936
+ line: Type.Optional(Type.Integer({ minimum: 1 })),
937
+ issue: Type.String({ minLength: 1 }),
938
+ requiredChange: Type.Optional(Type.String({ minLength: 1 })),
910
939
  }, { additionalProperties: false });
940
+ const scopeProtocol = createFrontendReviewScopeProtocol({ phase: "design", inventory: input.inventory, getStore: () => store, attemptId });
941
+ const savedFindings = () => [...new Map(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "design-finding").map(r => [r.fact.id, r.fact.finding])).values()];
942
+ const allFindings = (direct) => [...new Map([...savedFindings(), ...(Array.isArray(direct) ? direct : [])].map(finding => [JSON.stringify(finding), finding])).values()];
943
+ const recordFindingTool = defineTool({
944
+ name: "record_design_finding", label: "record_design_finding",
945
+ description: "Save one finding with a stable id. Submit findings incrementally, then finalize without repeating the findings array. Saved blocking findings cannot be omitted from approval; correct a finding explicitly with replace:true.",
946
+ parameters: Type.Object({ id: Type.String({ minLength: 1 }), finding: findingSchema }, { additionalProperties: false }),
947
+ async execute(_callId, params) {
948
+ const fact = { kind: "design-finding", id: params.id, finding: params.finding };
949
+ const requestId = `${attemptId}:finding:${randomUUID()}`;
950
+ const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
951
+ const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
952
+ const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
953
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
954
+ },
955
+ });
911
956
  const approveParameters = Type.Object({
912
- findings: Type.Array(findingSchema, {
957
+ findings: Type.Optional(Type.Array(findingSchema, {
913
958
  description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
914
- }),
959
+ })),
915
960
  }, { additionalProperties: false });
916
961
  const requestParameters = Type.Object({
917
962
  issueCategory: Type.Enum({
@@ -921,16 +966,17 @@ export async function createFrontendDesignTerminalTools(input) {
921
966
  "contract-requirement-gap": "contract-requirement-gap",
922
967
  "unknown": "unknown",
923
968
  }, { description: "Typed issue category (five-value enum)" }),
924
- evidenceRefs: Type.Array(Type.String({}), {
925
- description: "Evidence refs (paths or artifact ids); at least one",
926
- }),
927
- findings: Type.Array(findingSchema, {
928
- description: "At least one finding",
969
+ evidenceRefs: Type.Array(Type.String({ minLength: 1 }), {
970
+ description: "Evidence refs (paths or artifact ids); at least one", minItems: 1,
929
971
  }),
972
+ findings: Type.Optional(Type.Array(findingSchema, {
973
+ description: "At least one finding", minItems: 1,
974
+ })),
930
975
  }, { additionalProperties: false });
931
976
  async function adoptDesignFact(kind, fact) {
932
977
  const requestId = randomUUID();
933
978
  try {
979
+ scopeProtocol.assertComplete();
934
980
  const parsed = kind === "approve_design"
935
981
  ? approveDesignFactSchema.parse(fact)
936
982
  : requestDesignChangesFactSchema.parse(fact);
@@ -992,7 +1038,7 @@ export async function createFrontendDesignTerminalTools(input) {
992
1038
  return adoptDesignFact("approve_design", {
993
1039
  kind: "approve_design",
994
1040
  verdict: "approve_design",
995
- findings: params?.findings ?? [],
1041
+ findings: allFindings(params?.findings),
996
1042
  });
997
1043
  },
998
1044
  });
@@ -1008,17 +1054,15 @@ export async function createFrontendDesignTerminalTools(input) {
1008
1054
  verdict: "request_design_changes",
1009
1055
  issueCategory: params?.issueCategory,
1010
1056
  evidenceRefs: params?.evidenceRefs,
1011
- findings: params?.findings,
1057
+ findings: allFindings(params?.findings),
1012
1058
  });
1013
1059
  },
1014
1060
  });
1015
- return {
1016
- customTools: [approveDesignTool, requestDesignChangesTool],
1017
- flush: async () => {
1018
- const committed = readCommittedEvents(store, attemptId);
1019
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "design-typed-facts.jsonl"), committed);
1020
- },
1021
- };
1061
+ const durable = await createDurableFrontendTools({
1062
+ file: path.join(input.runDir, input.nodeId, "design-typed-facts.jsonl"), attemptId, store: input.store,
1063
+ binding: { inputDigest: input.inputDigest, scopeDigest: input.inventory?.digest }, setWorkingStore: next => { store = next; }, tools: [...scopeProtocol.customTools, recordFindingTool, approveDesignTool, requestDesignChangesTool], validateRestored: async () => { await input.inventory?.validate(); },
1064
+ });
1065
+ return { ...durable, scopeProtocol };
1022
1066
  }
1023
1067
  /**
1024
1068
  * Source fidelity ledger (AC-005/AC-006): load the contract node's committed
@@ -1063,6 +1107,8 @@ async function loadContractRequirementInheritance(runDir) {
1063
1107
  : undefined;
1064
1108
  if (sourceFragmentIds || sourceRefs) {
1065
1109
  byId.set(id, {
1110
+ ...(typeof recordFact.text === "string" ? { text: recordFact.text } : {}),
1111
+ ...(recordFact.execution !== undefined ? { execution: frontendExecutionSchema.parse(recordFact.execution) } : {}),
1066
1112
  ...(sourceFragmentIds ? { sourceFragmentIds } : {}),
1067
1113
  ...(sourceRefs ? { sourceRefs } : {}),
1068
1114
  });
@@ -1228,26 +1274,12 @@ export async function createFrontendPlanLedgerTools(input) {
1228
1274
  const { loadTypedEventStore, readCommittedEvents, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
1229
1275
  const { adoptStagedFact, adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
1230
1276
  const { assemblePlanPatchFromCommittedFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
1231
- const store = input.store;
1277
+ let store = input.store;
1232
1278
  const attemptId = input.attemptId;
1233
1279
  let activeRequirementScope = [];
1234
1280
  const scopedRequirementIds = () => [...activeRequirementScope];
1235
- // A retry creates a fresh executor-local store, but the plan ledger is the
1236
- // cross-attempt authority. Restore the committed prefix before registering
1237
- // tools; otherwise the first flush of a retry can overwrite facts that the
1238
- // previous attempt had already committed. The on-disk file contains only
1239
- // committed records, so loading it is also fail-closed with respect to
1240
- // staged/quarantined facts.
1241
- const persisted = await loadTypedEventStore(path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"));
1242
- if (persisted.records.length > 0) {
1243
- const existingEventIds = new Set(store.records.map((record) => record.eventId));
1244
- for (const record of persisted.records) {
1245
- if (!existingEventIds.has(record.eventId)) {
1246
- store.records.push(record);
1247
- }
1248
- }
1249
- store.revision = Math.max(store.revision, persisted.revision);
1250
- }
1281
+ const contractInheritance = await loadContractRequirementInheritance(input.runDir);
1282
+ const executionGroups = collectFrontendExecutionGroups([...contractInheritance].map(([id, r]) => ({ id, ...r })));
1251
1283
  const stringArray = Type.Array(Type.String({}));
1252
1284
  const optionalString = Type.Optional(Type.String({}));
1253
1285
  const optionalStringArray = Type.Optional(stringArray);
@@ -1282,9 +1314,7 @@ export async function createFrontendPlanLedgerTools(input) {
1282
1314
  verificationTargetIds: stringArray,
1283
1315
  }, { additionalProperties: false });
1284
1316
  const mockEndpointSchema = Type.Object({
1285
- method: Type.String({
1286
- description: "GET | POST | PUT | PATCH | DELETE | HEAD | OPTIONS",
1287
- }),
1317
+ method: Type.Enum({ GET: "GET", POST: "POST", PUT: "PUT", PATCH: "PATCH", DELETE: "DELETE", HEAD: "HEAD", OPTIONS: "OPTIONS" }),
1288
1318
  path: Type.String({}),
1289
1319
  fixture: optionalString,
1290
1320
  consumer: optionalString,
@@ -1346,9 +1376,9 @@ export async function createFrontendPlanLedgerTools(input) {
1346
1376
  specReference: Type.Optional(Type.Object({
1347
1377
  path: Type.String({}),
1348
1378
  section: Type.String({}),
1349
- line: Type.Optional(Type.Number({})),
1379
+ line: Type.Optional(Type.Integer({ minimum: 1 })),
1350
1380
  }, { additionalProperties: false })),
1351
- rationale: Type.String({}),
1381
+ rationale: Type.Optional(Type.String({ minLength: 1 })),
1352
1382
  covers: Type.Optional(Type.Array(Type.String({}), {
1353
1383
  description: "UI state and/or interaction names this single component choice covers (one choice may cover many ids).",
1354
1384
  })),
@@ -1787,6 +1817,44 @@ export async function createFrontendPlanLedgerTools(input) {
1787
1817
  });
1788
1818
  }
1789
1819
  }
1820
+ // Named entries are last-wins at assembly. A scoped correction may
1821
+ // replace its own bindings, but must retain other scopes' coverage.
1822
+ const scope = new Set(scopedRequirementIds());
1823
+ const committed = readCommittedEvents(store, attemptId).map(event => event.fact);
1824
+ const targetOwners = new Map();
1825
+ for (const fact of committed) {
1826
+ if (fact.kind === "plan-verification-target" && isRecordObject(fact.entry) && typeof fact.entry.id === "string") {
1827
+ targetOwners.set(fact.entry.id, stringList(fact.entry.requirementIds));
1828
+ }
1829
+ }
1830
+ for (const [field, removals] of [["uiStates", removeUiStateNames], ["interactions", removeInteractionNames]]) {
1831
+ const live = new Map();
1832
+ for (const fact of committed) {
1833
+ if (fact.kind !== "state-flow")
1834
+ continue;
1835
+ for (const name of stringList(fact[field === "uiStates" ? "removeUiStateNames" : "removeInteractionNames"]))
1836
+ live.delete(name);
1837
+ for (const entry of Array.isArray(fact[field]) ? fact[field] : []) {
1838
+ if (isRecordObject(entry) && typeof entry.name === "string")
1839
+ live.set(entry.name, entry);
1840
+ }
1841
+ }
1842
+ const foreignBindings = (entry) => scope.size === 0 ? [] : stringList(entry.verificationTargetIds).filter(id => {
1843
+ const owners = targetOwners.get(id);
1844
+ return !owners?.length || owners.some(owner => !scope.has(owner));
1845
+ });
1846
+ for (const name of removals) {
1847
+ const previous = live.get(name);
1848
+ if (previous && foreignBindings(previous).length)
1849
+ return planToolReceipt({ ok: false, kind: "state-flow",
1850
+ error: `Cannot remove shared ${name} from this requirement scope; retain it and update its scoped bindings instead` });
1851
+ }
1852
+ for (const entry of field === "uiStates" ? uiStates : interactions) {
1853
+ const previous = live.get(String(entry.name));
1854
+ if (previous)
1855
+ entry.verificationTargetIds = [...new Set([...foreignBindings(previous), ...stringList(entry.verificationTargetIds)])];
1856
+ }
1857
+ }
1790
1858
  const result = await adoptPlanFact("state-flow", `${attemptId}:record_state_flow:${randomUUID()}`, {
1791
1859
  kind: "state-flow",
1792
1860
  origin: "plan",
@@ -1805,13 +1873,14 @@ export async function createFrontendPlanLedgerTools(input) {
1805
1873
  label: "record_data_flow",
1806
1874
  description: "Record interaction/endpoint data flow as an origin=plan data-flow fact. Example: {\"interactions\": [\"<interaction name>\"], \"endpoints\": [\"GET <path>\"]}",
1807
1875
  promptSnippet: "Record the plan data-flow fact.",
1808
- parameters: Type.Object({ interactions: stringArray, endpoints: stringArray }, { additionalProperties: false }),
1876
+ parameters: Type.Object({ interactions: stringArray, endpoints: stringArray, replace: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
1809
1877
  async execute(_toolCallId, params) {
1878
+ const previous = params.replace ? undefined : readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "data-flow").at(-1)?.fact;
1810
1879
  const result = await adoptPlanFact("data-flow", `${attemptId}:record_data_flow:${randomUUID()}`, {
1811
1880
  kind: "data-flow",
1812
1881
  origin: "plan",
1813
- interactions: stringList(params?.interactions),
1814
- endpoints: stringList(params?.endpoints),
1882
+ interactions: [...new Set([...stringList(previous?.interactions), ...stringList(params?.interactions)])],
1883
+ endpoints: [...new Set([...stringList(previous?.endpoints), ...stringList(params?.endpoints)])],
1815
1884
  });
1816
1885
  return planToolReceipt(result);
1817
1886
  },
@@ -1839,6 +1908,16 @@ export async function createFrontendPlanLedgerTools(input) {
1839
1908
  return planToolReceipt(result);
1840
1909
  },
1841
1910
  });
1911
+ const recordMockEndpointTool = defineTool({
1912
+ name: "record_mock_endpoint", label: "record_mock_endpoint",
1913
+ description: "Record one Mock/API endpoint. First record_mock_api with the policy and endpoints: []; then submit each endpoint separately. Never regenerate the whole endpoint collection. Use replace:true to revise an existing method/path, or replace:true plus remove:true to withdraw it.",
1914
+ parameters: Type.Object({ endpoint: mockEndpointSchema, remove: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
1915
+ async execute(_callId, params) {
1916
+ if (!readCommittedEvents(store, attemptId).some(r => r.fact.kind === "mock-api"))
1917
+ return planToolReceipt({ ok: false, kind: "mock-endpoint", code: "MOCK_POLICY_MISSING", error: "Record the mock policy before its endpoints" });
1918
+ return planToolReceipt(await adoptPlanFact("mock-endpoint", `${attemptId}:endpoint:${randomUUID()}`, { kind: "mock-endpoint", origin: "plan", endpoint: params.endpoint, ...(params.remove ? { removed: true } : {}) }));
1919
+ },
1920
+ });
1842
1921
  const recordDesignDeviationTool = defineTool({
1843
1922
  name: "record_design_deviation",
1844
1923
  label: "record_design_deviation",
@@ -1914,6 +1993,8 @@ export async function createFrontendPlanLedgerTools(input) {
1914
1993
  // canonical-coverage gate rejects with no in-node cure. Reject here
1915
1994
  // and name the allowed ids.
1916
1995
  const id = typeof entry.id === "string" ? entry.id : "";
1996
+ if (activeRequirementScope.length && !activeRequirementScope.includes(id))
1997
+ return planToolReceipt({ ok: false, kind: "plan-requirement", code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: `Requirement ${id} is outside this session` });
1917
1998
  if (id &&
1918
1999
  input.requirementIds &&
1919
2000
  input.requirementIds.length > 0 &&
@@ -1963,6 +2044,29 @@ export async function createFrontendPlanLedgerTools(input) {
1963
2044
  return planToolReceipt(result);
1964
2045
  },
1965
2046
  });
2047
+ const recordPlanGroupCoverageTool = defineTool({
2048
+ name: "record_plan_group_coverage", label: "record_plan_group_coverage",
2049
+ 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 }),
2051
+ async execute(callId, params, signal, onUpdate, ctx) {
2052
+ const group = executionGroups.find(g => g.id === params.id && g.kind !== "unclassified");
2053
+ if (!group || (activeRequirementScope.length && group.requirementIds.some(id => !activeRequirementScope.includes(id))))
2054
+ return planToolReceipt({ ok: false, kind: "plan-requirement", code: "EXECUTION_GROUP_SCOPE_INVALID", error: "A known complete group must be present in this session; submit individual member records when the group spans scopes" });
2055
+ let last;
2056
+ for (const id of group.requirementIds) {
2057
+ const existing = readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "plan-requirement" && r.fact.entry?.id === id).at(-1)?.fact.entry;
2058
+ if (existing && !params.replace) {
2059
+ if (JSON.stringify(existing.implementationTargets) !== JSON.stringify(params.implementationTargets) || JSON.stringify(existing.verificationTargetIds) !== JSON.stringify(params.verificationTargetIds))
2060
+ return planToolReceipt({ ok: false, kind: "plan-requirement", code: "FACT_IDENTITY_CONFLICT", error: `${id}: existing coverage differs; use replace:true to revise explicitly` });
2061
+ continue;
2062
+ }
2063
+ last = await recordPlanRequirementTool.execute(`${callId}:${id}`, { entry: { id, implementationTargets: params.implementationTargets, verificationTargetIds: params.verificationTargetIds }, ...(params.replace ? { replace: true } : {}) }, signal, onUpdate, ctx);
2064
+ if (!last.details?.ok)
2065
+ return last;
2066
+ }
2067
+ return planToolReceipt(await adoptPlanFact("plan-group-coverage", `${attemptId}:group:${randomUUID()}`, { kind: "plan-group-coverage", origin: "plan", id: group.id, requirementIds: group.requirementIds }));
2068
+ },
2069
+ });
1966
2070
  const recordPlanVerificationTargetTool = defineTool({
1967
2071
  name: "record_plan_verification_target",
1968
2072
  label: "record_plan_verification_target",
@@ -2182,9 +2286,13 @@ export async function createFrontendPlanLedgerTools(input) {
2182
2286
  const finalizePlanTool = defineTool({
2183
2287
  name: "finalize_plan",
2184
2288
  label: "finalize_plan",
2185
- description: "Commit the finalize_plan terminal. Requirements, verification targets, and evidence gaps (optional) were already committed incrementally through record_plan_requirement / record_plan_verification_target / record_plan_evidence_gap; finalize_plan assembles them from the ledger together with these optional remaining fields, publishes the canonical editable patch on a target-surface fact, and commits the terminal. Call exactly once.",
2186
- promptSnippet: "Commit the finalize_plan terminal (ledger fields + optional residualRisks / realIntegrationGap).",
2289
+ description: "Commit the finalize_plan terminal. Requirements, verification targets, and evidence gaps (optional) were already committed incrementally through record_plan_requirement / record_plan_verification_target / record_plan_evidence_gap; finalize_plan requires implementationSteps: [{requirementIds, action}] covering every frozen requirement (shared steps allowed), and assembles them with the ledger and optional remaining fields, publishes the canonical editable patch on a target-surface fact, and commits the terminal. A successful terminal commit occurs exactly once. If validation fails, correct only the reported facts and retry finalize.",
2290
+ promptSnippet: "Commit finalize_plan with implementationSteps: [{requirementIds, action}] covering every frozen requirement; optional residualRisks / realIntegrationGap.",
2187
2291
  parameters: Type.Object({
2292
+ implementationSteps: Type.Optional(Type.Array(Type.Object({
2293
+ requirementIds: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
2294
+ action: Type.String({ minLength: 1 }),
2295
+ }, { additionalProperties: false }))),
2188
2296
  residualRisks: optionalStringArray,
2189
2297
  realIntegrationGap: optionalString,
2190
2298
  }, { additionalProperties: false }),
@@ -2197,9 +2305,27 @@ export async function createFrontendPlanLedgerTools(input) {
2197
2305
  // when the plan did not re-declare them. The contract node is the
2198
2306
  // sole synthesis point; the plan inherits by requirement id.
2199
2307
  const contractInheritance = await loadContractRequirementInheritance(input.runDir);
2308
+ const missingData = collectFrontendPlanPhaseMissingFacts({ phase: "global-mock-data", requirementIds: input.requirementIds ?? [...contractInheritance.keys()], committedFacts: committed }).filter(f => f.kind === "data-flow");
2309
+ if (missingData.length)
2310
+ return planToolReceipt({ ok: false, kind: "finalize_plan", code: "PLAN_DATA_FLOW_INCOMPLETE", error: missingData.map(f => f.reason).join("; ") });
2200
2311
  const fragment = assemblePlanPatchFromCommittedFacts(committed, contractInheritance) ?? {};
2312
+ const requiredIds = new Set([
2313
+ ...(input.requirementIds ?? []),
2314
+ ...contractInheritance.keys(),
2315
+ ...(Array.isArray(fragment.requirements) ? fragment.requirements.flatMap((entry) => isRecordObject(entry) && typeof entry.id === "string" ? [entry.id] : []) : []),
2316
+ ]);
2317
+ const steps = params?.implementationSteps ?? [];
2318
+ const coveredIds = new Set(steps.flatMap(step => step.requirementIds));
2319
+ const missingRequirementIds = [...requiredIds].filter(id => !coveredIds.has(id));
2320
+ const unknownRequirementIds = [...coveredIds].filter(id => !requiredIds.has(id));
2321
+ if (missingRequirementIds.length || unknownRequirementIds.length || steps.some(step => !step.action.trim())) {
2322
+ return planToolReceipt({ ok: false, kind: "finalize_plan", code: "PLAN_IMPLEMENTATION_STEPS_INCOMPLETE",
2323
+ error: `Submit finalize_plan with non-empty implementationSteps: [{requirementIds: [exact frozen IDs], action: "what to change and where"}]. Cover every requirement; shared steps may cover several IDs. Missing: ${missingRequirementIds.join(", ") || "none"}; unknown: ${unknownRequirementIds.join(", ") || "none"}. Actions must not be blank. Do not add a test plan.`,
2324
+ });
2325
+ }
2201
2326
  const patch = {
2202
2327
  ...fragment,
2328
+ ...(steps.length ? { implementationSteps: steps.map(step => `[${[...new Set(step.requirementIds)].join(", ")}] ${step.action.trim()}`) } : {}),
2203
2329
  ...(params?.residualRisks
2204
2330
  ? { residualRisks: params.residualRisks }
2205
2331
  : {}),
@@ -2352,23 +2478,99 @@ export async function createFrontendPlanLedgerTools(input) {
2352
2478
  }
2353
2479
  },
2354
2480
  });
2355
- return {
2356
- customTools: [
2481
+ const readPlanFactsTool = defineTool({
2482
+ name: "read_plan_facts",
2483
+ label: "read_plan_facts",
2484
+ description: "Read the LIVE committed plan ledger without repository access. Filter by kind and optional entryId or eventId. Use field (dot path, e.g. entry.requirementIds or uiStates) to page large arrays/strings. offset/limit paginate results; follow nextOffset until null before replacing any full-array fact. expectedRevision pins all pages to one ledger revision; restart if it changes. No writes.",
2485
+ parameters: Type.Object({
2486
+ kind: Type.String(), entryId: Type.Optional(Type.String()), eventId: Type.Optional(Type.String()),
2487
+ field: Type.Optional(Type.String()), offset: Type.Optional(Type.Integer({ minimum: 0 })),
2488
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
2489
+ stringOffset: Type.Optional(Type.Integer({ minimum: 0 })),
2490
+ expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })),
2491
+ }, { additionalProperties: false }),
2492
+ async execute(_id, params) {
2493
+ const reply = (details) => ({ content: [{ type: "text", text: JSON.stringify(details) }], details });
2494
+ if (params.expectedRevision !== undefined && params.expectedRevision !== store.revision) {
2495
+ return reply({ ok: false, revision: store.revision, error: "ledger revision changed; restart pagination" });
2496
+ }
2497
+ let records = readCommittedEvents(store, attemptId).filter(record => {
2498
+ const fact = record.fact;
2499
+ return fact.kind === params.kind && (!params.eventId || record.eventId === params.eventId) &&
2500
+ (!params.entryId || fact.entry?.id === params.entryId);
2501
+ });
2502
+ // Full-entry corrections and registries are last-wins. Other kinds
2503
+ // remain an ordered event log (state removals must remain visible).
2504
+ if (params.entryId || params.kind === "state-registry")
2505
+ records = records.slice(-1);
2506
+ let values = records.map(record => ({ eventId: record.eventId, ...record.fact }));
2507
+ if (params.field) {
2508
+ values = records.flatMap(record => {
2509
+ let value = record.fact;
2510
+ for (const key of params.field.split(".")) {
2511
+ if (!value || typeof value !== "object" || !Object.hasOwn(value, key))
2512
+ return [];
2513
+ value = value[key];
2514
+ }
2515
+ return Array.isArray(value) ? value : [value];
2516
+ });
2517
+ }
2518
+ const offset = params.offset ?? 0;
2519
+ if (params.stringOffset !== undefined) {
2520
+ const value = values[offset];
2521
+ if (typeof value !== "string")
2522
+ return reply({ ok: false, error: "stringOffset requires a string field item" });
2523
+ // Unicode code points prevent a page boundary splitting a surrogate pair.
2524
+ const characters = Array.from(value);
2525
+ const chunk = characters.slice(params.stringOffset, params.stringOffset + 1000).join("");
2526
+ const next = params.stringOffset + Array.from(chunk).length;
2527
+ return reply({ ok: true, revision: store.revision, offset, stringOffset: params.stringOffset,
2528
+ chunk, totalCharacters: characters.length, nextStringOffset: next < characters.length ? next : null });
2529
+ }
2530
+ const items = [];
2531
+ let bytes = 0;
2532
+ for (const value of values.slice(offset, offset + (params.limit ?? 10))) {
2533
+ const size = Buffer.byteLength(JSON.stringify(value));
2534
+ if (bytes + size > 12_000)
2535
+ break;
2536
+ bytes += size;
2537
+ items.push(value);
2538
+ }
2539
+ if (!items.length && offset < values.length)
2540
+ return reply({
2541
+ ok: false, revision: store.revision, total: values.length, offset,
2542
+ error: "item exceeds the page budget; select an eventId/entryId and a narrower field path; for a string item set stringOffset: 0 and follow nextStringOffset",
2543
+ fields: typeof values[offset] === "object" && values[offset] !== null ? Object.keys(values[offset]) : [],
2544
+ });
2545
+ return reply({ ok: true, revision: store.revision, total: values.length, offset, items,
2546
+ nextOffset: offset + items.length < values.length ? offset + items.length : null });
2547
+ },
2548
+ });
2549
+ const durable = await createDurableFrontendTools({
2550
+ file: path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"), attemptId, store: input.store,
2551
+ setWorkingStore: next => { store = next; },
2552
+ binding: { sourceBinding: input.sourceBinding, skeleton: input.skeleton, requirementIds: input.requirementIds, writeSet: input.writeSetPatterns, declaredUiStateIds: input.declaredUiStateIds, citations: input.componentNewSourceReferences, contractInheritance },
2553
+ tools: [
2357
2554
  recordRouteSelectionTool,
2358
2555
  recordComponentChoiceTool,
2359
2556
  recordStateRegistryTool,
2360
2557
  recordStateFlowTool,
2361
2558
  recordDataFlowTool,
2362
2559
  recordMockApiTool,
2560
+ recordMockEndpointTool,
2363
2561
  recordDesignDeviationTool,
2364
2562
  recordDependencyTool,
2365
2563
  recordPlanRequirementTool,
2564
+ recordPlanGroupCoverageTool,
2366
2565
  recordPlanVerificationTargetTool,
2367
2566
  recordPlanEvidenceGapTool,
2368
2567
  adoptStagedFactTool,
2369
2568
  finalizePlanTool,
2370
2569
  ],
2371
- adoptCommittedFacts: async (records) => {
2570
+ });
2571
+ return {
2572
+ customTools: [...durable.customTools, { ...readPlanFactsTool, execute: async (...args) => { await durable.flush(); return readPlanFactsTool.execute(...args); } }],
2573
+ adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
2372
2574
  for (const record of records) {
2373
2575
  if (record.phase !== "committed")
2374
2576
  continue;
@@ -2427,16 +2629,13 @@ export async function createFrontendPlanLedgerTools(input) {
2427
2629
  throw new Error(`frontend plan shard fact merge failed: ${result.error}`);
2428
2630
  }
2429
2631
  }
2430
- },
2632
+ }),
2431
2633
  setActiveRequirementScope: (requirementIds) => {
2432
2634
  activeRequirementScope = [
2433
2635
  ...new Set(requirementIds.filter((id) => id.trim().length > 0)),
2434
2636
  ];
2435
2637
  },
2436
- flush: async () => {
2437
- const committed = readCommittedEvents(store, attemptId);
2438
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"), committed);
2439
- },
2638
+ flush: durable.flush,
2440
2639
  committedFactCount: () => readCommittedEvents(store, attemptId).length,
2441
2640
  committedRequirementIds: () => {
2442
2641
  const ids = new Set();
@@ -2538,8 +2737,11 @@ export async function createFrontendContractTools(input) {
2538
2737
  ]);
2539
2738
  const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
2540
2739
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
2541
- const store = input.store;
2740
+ let store = input.store;
2542
2741
  const attemptId = input.attemptId;
2742
+ let activeScope = null;
2743
+ const committedRequirementIds = () => new Set(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "requirement").map(r => String(r.fact.id)));
2744
+ const completedScopeRequirementIds = () => new Set(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "contract-scope-completed").flatMap(r => Array.isArray(r.fact.requirementIds) ? r.fact.requirementIds.filter((id) => typeof id === "string") : []));
2543
2745
  const receipt = (details) => ({
2544
2746
  content: [{ type: "text", text: JSON.stringify(details) }],
2545
2747
  details,
@@ -2589,12 +2791,24 @@ export async function createFrontendContractTools(input) {
2589
2791
  label: name,
2590
2792
  description: `Commit an origin=contract ${kind} fact. IMPORTANT: submit incrementally — batch up to 5 record_* calls per message, starting from the FIRST message; never attempt to emit the whole contract in one response (a single large dump will be truncated and rejected). Every message must make progress by committing at least one record_* fact.`,
2591
2793
  promptSnippet: `Commit 1-5 origin=contract ${kind} facts (up to 5 per message).`,
2592
- parameters: Type.Object({}, { additionalProperties: true }),
2794
+ parameters: Type.Object({
2795
+ text: Type.String({ minLength: 1 }),
2796
+ requirementIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
2797
+ sourceFragmentIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
2798
+ ...(kind === "constraint" ? { category: Type.Optional(Type.Enum({ constraint: "constraint", "non-goal": "non-goal", risk: "risk" })) } : {}),
2799
+ ...(kind === "handoff-intent" ? { taskKind: Type.Literal("frontend-test"), blocking: Type.Optional(Type.Boolean()) } : {}),
2800
+ }, { additionalProperties: false }),
2593
2801
  async execute(_toolCallId, params) {
2802
+ const data = params;
2803
+ if (!data.text?.trim())
2804
+ return receipt({ ok: false, code: "TOOL_SCHEMA_INVALID", error: `${name}: text must be non-empty` });
2805
+ const knownFragments = new Set([...(input.canonicalRequirements?.values() ?? [])].flatMap(r => r.sourceFragmentIds));
2806
+ if (data.requirementIds?.some(id => !input.canonicalRequirements?.has(id)) || data.sourceFragmentIds?.some(id => !knownFragments.has(id)))
2807
+ return receipt({ ok: false, code: "CONTRACT_REFERENCE_UNKNOWN", error: `${name}: reference is outside the frozen source inventory` });
2594
2808
  const result = await adoptContractFact(kind, {
2809
+ ...(params ?? {}),
2595
2810
  kind,
2596
2811
  origin: "contract",
2597
- ...(params ?? {}),
2598
2812
  });
2599
2813
  return receipt(result);
2600
2814
  },
@@ -2608,9 +2822,9 @@ export async function createFrontendContractTools(input) {
2608
2822
  const recordRequirementTool = defineTool({
2609
2823
  name: "record_requirement",
2610
2824
  label: "record_requirement",
2611
- description: 'Confirm one canonical ledger requirement (origin=contract requirement fact). Pass ONLY the canonical id listed in the <frontend_contract_input> inventory, e.g. {"id": "AC-001"} — the runtime commits the authoritative text and sourceFragmentIds from the frozen ledger. Never pass text/statement/sourceFragmentIds yourself: free-form rewrites and JSON-stringified fragment arrays are rejected. IMPORTANT: batch up to 5 record_* calls per message, starting from the FIRST message.',
2825
+ description: 'Confirm one canonical ledger requirement (origin=contract requirement fact). Pass the canonical id and optional execution:{groupId,kind,summary} only. Reuse a group only when its behavior and all permission/threshold/error conditions agree; retain separate groups for differences. Constraints/exclusions do not require invented UI. The canonical id is listed in the <frontend_contract_input> inventory, e.g. {"id": "AC-001"} — the runtime commits the authoritative text and sourceFragmentIds from the frozen ledger. Never pass text/statement/sourceFragmentIds yourself: free-form rewrites and JSON-stringified fragment arrays are rejected. IMPORTANT: batch up to 5 record_* calls per message, starting from the FIRST message.',
2612
2826
  promptSnippet: "Confirm 1-5 canonical requirements by id (up to 5 per message).",
2613
- parameters: Type.Object({ id: Type.String({ description: "Canonical ledger requirement id (e.g. AC-001)" }) }, { additionalProperties: false }),
2827
+ parameters: Type.Object({ id: Type.String({ description: "Canonical ledger requirement id (e.g. AC-001)" }), execution: Type.Optional(Type.Object({ groupId: Type.String({ minLength: 1 }), kind: Type.Union([Type.Literal("behavior"), Type.Literal("constraint"), Type.Literal("exclusion")]), summary: Type.String({ minLength: 1 }) }, { additionalProperties: false })) }, { additionalProperties: false }),
2614
2828
  async execute(_toolCallId, params) {
2615
2829
  const id = typeof params?.id === "string" ? params.id.trim() : "";
2616
2830
  if (!id) {
@@ -2620,6 +2834,8 @@ export async function createFrontendContractTools(input) {
2620
2834
  error: "record_requirement requires the canonical requirement id",
2621
2835
  });
2622
2836
  }
2837
+ if (activeScope && !activeScope.has(id))
2838
+ return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: `requirement ${id} is outside the complete input scope of this session` });
2623
2839
  const canonical = input.canonicalRequirements?.get(id);
2624
2840
  if (!canonical) {
2625
2841
  const known = [...(input.canonicalRequirements?.keys() ?? [])];
@@ -2629,6 +2845,14 @@ export async function createFrontendContractTools(input) {
2629
2845
  error: `record_requirement id "${id}" is not a canonical ledger requirement; canonical ids are: ${known.join(", ") || "(none)"}`,
2630
2846
  });
2631
2847
  }
2848
+ if (params.execution) {
2849
+ try {
2850
+ collectFrontendExecutionGroups([...readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "requirement" && r.fact.id !== id).map(r => ({ id: String(r.fact.id), execution: r.fact.execution })), { id, execution: params.execution }]);
2851
+ }
2852
+ catch (error) {
2853
+ return receipt({ ok: false, code: "EXECUTION_GROUP_CONFLICT", error: String(error) });
2854
+ }
2855
+ }
2632
2856
  const result = await adoptContractFact("requirement", {
2633
2857
  kind: "requirement",
2634
2858
  origin: "contract",
@@ -2636,6 +2860,7 @@ export async function createFrontendContractTools(input) {
2636
2860
  id,
2637
2861
  text: canonical.text,
2638
2862
  sourceFragmentIds: canonical.sourceFragmentIds,
2863
+ ...(params.execution ? { execution: params.execution } : {}),
2639
2864
  });
2640
2865
  return receipt(result);
2641
2866
  },
@@ -2715,15 +2940,17 @@ export async function createFrontendContractTools(input) {
2715
2940
  const recordRequiredDeliverablesTool = defineTool({
2716
2941
  name: "record_required_deliverables",
2717
2942
  label: "record_required_deliverables",
2718
- description: 'Declare the complete source-required file deliverables as {items:[{path,requirementId,sourceFragmentId}]}. Interpret obligations from the original source, including lists/tables: permissions (allowedPaths/only allowed to modify), prohibitions, examples and read-only references are NOT delivery obligations. Each path must appear exactly in its frozen requirement-bound source fragment. Submit {items:[]} explicitly if no files are mandatory. A correction replaces the whole inventory. Required before finalize_contract ready.',
2719
- parameters: Type.Object({ items: Type.Array(Type.Object({
2943
+ description: 'Declare the complete source-required file deliverables as {items:[{path,requirementId,sourceFragmentId}]}. Interpret obligations from the original source, including lists/tables: permissions (allowedPaths/only allowed to modify), prohibitions, examples and read-only references are NOT delivery obligations. Each path must appear exactly in its frozen requirement-bound source fragment. Submit {items:[]} explicitly if no files are mandatory. Each call appends complete source-bound items; replace:true explicitly replaces the inventory. Required before finalize_contract ready.',
2944
+ parameters: Type.Object({ replace: Type.Optional(Type.Boolean()), items: Type.Array(Type.Object({
2720
2945
  path: Type.String(), requirementId: Type.String(), sourceFragmentId: Type.String(),
2721
2946
  }, { additionalProperties: false })) }, { additionalProperties: false }),
2722
2947
  async execute(_toolCallId, params) {
2723
2948
  try {
2724
- const declaration = validateFrontendRequiredDeliverables(params, input.canonicalRequirements ?? new Map());
2949
+ const declaration = validateFrontendRequiredDeliverables({ items: params.items }, input.canonicalRequirements ?? new Map());
2950
+ const previous = params.replace ? [] : readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "required-deliverables").at(-1)?.fact.items;
2951
+ const items = [...new Map([...(Array.isArray(previous) ? previous : []), ...declaration.items].map(item => [JSON.stringify(item), item])).values()];
2725
2952
  return receipt(await adoptContractFact("required-deliverables", {
2726
- kind: "required-deliverables", origin: "contract", ...declaration,
2953
+ kind: "required-deliverables", origin: "contract", items,
2727
2954
  }));
2728
2955
  }
2729
2956
  catch (error) {
@@ -2779,7 +3006,7 @@ export async function createFrontendContractTools(input) {
2779
3006
  const finalizeContractTool = defineTool({
2780
3007
  name: "finalize_contract",
2781
3008
  label: "finalize_contract",
2782
- description: "Commit the contract-finalized terminal fact with a disposition of ready | ready-with-assumptions | blocked (blocked requires blockingOwner). Call exactly once.",
3009
+ description: "Commit the contract-finalized terminal fact with a disposition of ready | ready-with-assumptions | blocked (blocked requires blockingOwner). A successful terminal commit occurs exactly once. If validation fails, correct only the reported facts and retry finalize.",
2783
3010
  promptSnippet: "Commit the contract-finalized terminal (disposition + optional blockingOwner).",
2784
3011
  parameters: Type.Object({
2785
3012
  disposition: Type.Enum({
@@ -2811,6 +3038,9 @@ export async function createFrontendContractTools(input) {
2811
3038
  error: "call record_required_deliverables with the complete source-bound inventory (or items:[] when none) before finalizing",
2812
3039
  });
2813
3040
  }
3041
+ const missing = [...(input.canonicalRequirements?.keys() ?? [])].filter(id => !committedRequirementIds().has(id) || (activeScope !== null && !completedScopeRequirementIds().has(id)));
3042
+ if (disposition !== "blocked" && missing.length)
3043
+ return receipt({ ok: false, code: "CONTRACT_REQUIREMENT_COVERAGE_MISSING", error: `Confirm all complete source obligations before finalizing: ${missing.join(", ")}` });
2814
3044
  const blockedOwner = mapContractBlockedOwner({
2815
3045
  disposition: disposition ?? "",
2816
3046
  blockingOwner,
@@ -2831,19 +3061,41 @@ export async function createFrontendContractTools(input) {
2831
3061
  return receipt(result);
2832
3062
  },
2833
3063
  });
2834
- return {
2835
- customTools: [
3064
+ const completeScopeTool = defineTool({
3065
+ name: "complete_contract_scope", label: "complete_contract_scope",
3066
+ description: "After recording all requirements AND their evidence, constraints, questions and deliverables for this session, mark the scope complete. Confirming an ID alone does not complete its analysis. Do this before finalize_contract.",
3067
+ parameters: Type.Object({ requirementIds: Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }) }, { additionalProperties: false }),
3068
+ async execute(_id, params) {
3069
+ const ids = params.requirementIds;
3070
+ if (activeScope === null || ids.length !== activeScope.size || ids.some(id => !activeScope?.has(id) || !committedRequirementIds().has(id)))
3071
+ return receipt({ ok: false, code: "CONTRACT_SCOPE_INCOMPLETE", error: "Complete exactly the active scope after confirming all its obligations" });
3072
+ return receipt(await adoptContractFact("contract-scope-completed", { kind: "contract-scope-completed", origin: "contract", requirementIds: ids }));
3073
+ },
3074
+ });
3075
+ const durable = await createDurableFrontendTools({
3076
+ file: path.join(input.runDir, input.nodeId, "contract-typed-facts.jsonl"), attemptId, store: input.store,
3077
+ setWorkingStore: next => { store = next; }, binding: { canonicalRequirements: input.canonicalRequirements, sourceDigest: input.sourceDigest },
3078
+ tools: [
2836
3079
  ...recordTools,
2837
3080
  recordRequirementTool,
2838
3081
  recordEvidenceExpectationTool,
2839
3082
  recordUiStateTool,
2840
3083
  recordRequiredDeliverablesTool,
2841
3084
  recordOpenspecSelectionTool,
3085
+ completeScopeTool,
2842
3086
  finalizeContractTool,
2843
3087
  ],
3088
+ });
3089
+ return {
3090
+ customTools: durable.customTools,
3091
+ inputRequirements: () => [...(input.canonicalRequirements ?? [])].map(([id, value]) => ({ id, text: value.text, sourceFragmentIds: [...value.sourceFragmentIds] })),
3092
+ completedScopeRequirementIds,
3093
+ setActiveRequirementScope: ids => { activeScope = ids === null ? null : new Set(ids); },
3094
+ committedRequirementIds,
3095
+ committedFacts: () => readCommittedEvents(input.store, attemptId),
2844
3096
  flush: async () => {
2845
- const committed = readCommittedEvents(store, attemptId);
2846
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "contract-typed-facts.jsonl"), committed);
3097
+ await durable.flush();
3098
+ const committed = readCommittedEvents(input.store, attemptId);
2847
3099
  await writeJsonAtomic(path.join(input.runDir, input.nodeId, "frontend-task-contract-vNext.json"), buildFrontendTaskContractVNext(committed));
2848
3100
  },
2849
3101
  };
@@ -2895,7 +3147,7 @@ export async function createFrontendScoutEvidenceTools(input) {
2895
3147
  ]);
2896
3148
  const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
2897
3149
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
2898
- const store = input.store;
3150
+ let store = input.store;
2899
3151
  const attemptId = input.attemptId;
2900
3152
  const stringArray = Type.Array(Type.String({}));
2901
3153
  const optionalString = Type.Optional(Type.String({}));
@@ -2909,6 +3161,18 @@ export async function createFrontendScoutEvidenceTools(input) {
2909
3161
  });
2910
3162
  const sourceDeclaredPaths = (input.sourceDeclaredPaths ?? []).map((value) => value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""));
2911
3163
  const hasSourceDeclarations = input.sourceDeclaredPaths !== undefined;
3164
+ let activeScope;
3165
+ const scopeIdentity = (ids) => createHash("sha256").update(JSON.stringify([...ids].sort())).digest("hex");
3166
+ const latestScopes = () => {
3167
+ const byRequirement = new Map();
3168
+ for (const record of readCommittedEvents(store, attemptId))
3169
+ if (record.fact.kind === "scout-scope" && Array.isArray(record.fact.requirementIds)) {
3170
+ for (const id of record.fact.requirementIds)
3171
+ if (typeof id === "string")
3172
+ byRequirement.set(id, record.fact);
3173
+ }
3174
+ return byRequirement;
3175
+ };
2912
3176
  const isSourceDeclared = (candidate) => {
2913
3177
  const normalized = candidate.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
2914
3178
  return sourceDeclaredPaths.some((declared) => declared === normalized || declared.startsWith(`${normalized}/`));
@@ -3001,6 +3265,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3001
3265
  description: "Commit an origin=scout target-surface fact with complete/blocked discovery status. A complete surface needs a proven target path and no unresolved paths; blocked surfaces name the unresolved paths instead of guessing. Example: {\"completeness\": \"complete\", \"entrypoint\": \"<file>\", \"implementationPaths\": [\"<dir or file>\"], \"testPaths\": [\"<file>\"], \"allowedPathConflicts\": [], \"unresolvedPaths\": []}",
3002
3266
  promptSnippet: "Commit an origin=scout target-surface fact.",
3003
3267
  parameters: Type.Object({
3268
+ scopeId: Type.Optional(Type.String({ minLength: 1 })),
3004
3269
  completeness: scoutCompleteness,
3005
3270
  entrypoint: optionalString,
3006
3271
  routeOrMount: optionalString,
@@ -3011,6 +3276,8 @@ export async function createFrontendScoutEvidenceTools(input) {
3011
3276
  unresolvedPaths: stringArray,
3012
3277
  }, { additionalProperties: false }),
3013
3278
  async execute(_toolCallId, params) {
3279
+ if (input.requirementIds && (!activeScope?.length || params.scopeId !== scopeIdentity(activeScope)))
3280
+ return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: "Use exactly the runtime Scout scopeId; discovery may complete only the supplied obligations" });
3014
3281
  const implementationPaths = params?.implementationPaths ?? [];
3015
3282
  const testPaths = params?.testPaths ?? [];
3016
3283
  const pathEvidence = await enrichScoutPathEvidence([
@@ -3018,7 +3285,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3018
3285
  ...implementationPaths,
3019
3286
  ...testPaths,
3020
3287
  ]);
3021
- const result = await adoptScoutFact("target-surface", {
3288
+ const surface = {
3022
3289
  kind: "target-surface",
3023
3290
  origin: "scout",
3024
3291
  completeness: params?.completeness ?? "blocked",
@@ -3031,7 +3298,27 @@ export async function createFrontendScoutEvidenceTools(input) {
3031
3298
  unresolvedPaths: params?.unresolvedPaths ?? [],
3032
3299
  ...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}),
3033
3300
  ...(pathEvidence.length > 0 ? { pathEvidence } : {}),
3034
- });
3301
+ };
3302
+ if (activeScope) {
3303
+ const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-shadow-dual-write.js");
3304
+ if (surface.completeness === "complete") {
3305
+ const check = readCompleteScoutTargetSurface([{ phase: "committed", fact: surface }]);
3306
+ if (!check.ok)
3307
+ return receipt({ ok: false, code: "SCOUT_SCOPE_INCOMPLETE", error: check.reason });
3308
+ }
3309
+ const saved = await adoptScoutFact("scout-scope", { kind: "scout-scope", origin: "scout", id: params.scopeId, requirementIds: activeScope, surface });
3310
+ if (!saved.ok)
3311
+ return receipt(saved);
3312
+ const current = latestScopes();
3313
+ if (input.requirementIds?.every(id => current.get(id)?.surface?.completeness === "complete")) {
3314
+ const surfaces = [...new Set(input.requirementIds.map(id => current.get(id)))].map(f => f.surface);
3315
+ const union = (key) => [...new Set(surfaces.flatMap(s => Array.isArray(s[key]) ? s[key] : []))];
3316
+ const entries = [...new Set(surfaces.map(s => String(s.entrypoint ?? "")).filter(Boolean))];
3317
+ return receipt(await adoptScoutFact("target-surface", { kind: "target-surface", origin: "scout", completeness: "complete", entrypoint: entries[0] ?? "", implementationPaths: [...new Set([...entries, ...union("implementationPaths")])], testPaths: union("testPaths"), allowedPathConflicts: union("allowedPathConflicts"), unresolvedPaths: union("unresolvedPaths"), routeOrMount: [...new Set(surfaces.map(s => s.routeOrMount).filter(Boolean))].join("\n"), dataSource: [...new Set(surfaces.map(s => s.dataSource).filter(Boolean))].join("\n"), pathEvidence: surfaces.flatMap(s => s.pathEvidence ?? []), ...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}) }));
3318
+ }
3319
+ return receipt(saved);
3320
+ }
3321
+ const result = await adoptScoutFact("target-surface", surface);
3035
3322
  return receipt(result);
3036
3323
  },
3037
3324
  });
@@ -3055,10 +3342,30 @@ export async function createFrontendScoutEvidenceTools(input) {
3055
3342
  return receipt(result);
3056
3343
  },
3057
3344
  });
3345
+ const durable = await createDurableFrontendTools({
3346
+ file: path.join(input.runDir, input.nodeId, "scout-typed-facts.jsonl"), attemptId, store: input.store,
3347
+ setWorkingStore: next => { store = next; }, binding: { requirementIds: input.requirementIds, sourceDeclaredPaths: input.sourceDeclaredPaths, sourceDigest: input.sourceDigest, workspaceRoot: input.workspaceRoot },
3348
+ tools: [recordTargetSurfaceTool, recordDesignEvidenceTool],
3349
+ validateRestored: async (records) => {
3350
+ if (!input.workspaceRoot)
3351
+ return;
3352
+ for (const record of records) {
3353
+ const evidence = record.fact.kind === "scout-scope" ? record.fact.surface?.pathEvidence : record.fact.pathEvidence;
3354
+ if (!Array.isArray(evidence))
3355
+ continue;
3356
+ for (const previous of evidence) {
3357
+ if (!isRecordObject(previous) || typeof previous.path !== "string")
3358
+ throw Error("scout path evidence is malformed");
3359
+ const current = (await enrichScoutPathEvidence([previous.path]))[0];
3360
+ if (!current || current.sha256 !== previous.sha256 || current.fresh !== previous.fresh)
3361
+ throw Error(`scout evidence drift: ${previous.path}; refresh Scout before reusing facts`);
3362
+ }
3363
+ }
3364
+ },
3365
+ });
3058
3366
  return {
3059
- customTools: [recordTargetSurfaceTool, recordDesignEvidenceTool],
3060
- committedFacts: () => readCommittedEvents(store, attemptId),
3061
- adoptCommittedFacts: async (records) => {
3367
+ ...durable,
3368
+ adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
3062
3369
  for (const record of records) {
3063
3370
  if (record.phase !== "committed")
3064
3371
  continue;
@@ -3072,11 +3379,16 @@ export async function createFrontendScoutEvidenceTools(input) {
3072
3379
  throw new Error(`frontend scout shard fact merge failed: ${result.error}`);
3073
3380
  }
3074
3381
  }
3382
+ }),
3383
+ setActiveScope: ids => {
3384
+ if (!ids.length || ids.some(id => !input.requirementIds?.includes(id)))
3385
+ throw Error("FRONTEND_INPUT_SCOPE_VIOLATION");
3386
+ activeScope = [...new Set(ids)];
3387
+ return scopeIdentity(activeScope);
3075
3388
  },
3076
- flush: async () => {
3077
- const committed = readCommittedEvents(store, attemptId);
3078
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "scout-typed-facts.jsonl"), committed);
3079
- },
3389
+ completedRequirementIds: () => new Set([...latestScopes()].filter(([, fact]) => fact.surface?.completeness === "complete").map(([id]) => id)),
3390
+ completedScopeFacts: () => [...new Set(latestScopes().values())].filter(f => f.surface?.completeness === "complete"),
3391
+ committedFacts: () => readCommittedEvents(store, attemptId),
3080
3392
  };
3081
3393
  }
3082
3394
  export function buildDagPiUserMessage(task, persona, step) {
@@ -3327,15 +3639,14 @@ async function runFrontendReviewTerminalShadow(input) {
3327
3639
  return input.mapped;
3328
3640
  const { compareTypedReviewToLegacyJsonVerdict } = await import("../workflows/dag/frontend-review-context.js");
3329
3641
  const { parseJsonReviewVerdict } = await import("../workflows/dag/output-protocol.js");
3330
- const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
3331
3642
  let typedKinds = [];
3332
3643
  try {
3333
- const content = await readFile(sessionEventsPath, "utf8");
3334
- typedKinds = scanReviewTerminalKindsFromSessionEvents(content);
3644
+ await input.tools?.flush();
3645
+ input.tools?.scopeProtocol?.assertComplete();
3646
+ typedKinds = input.tools?.scopeProtocol?.committedFacts().filter(r => ["approve_review", "request_review_changes"].includes(String(r.fact.kind))).map(r => String(r.fact.kind)) ?? [];
3335
3647
  }
3336
- catch {
3337
- // Missing/unreadable session log fail-closed at zero terminal facts.
3338
- typedKinds = [];
3648
+ catch (error) {
3649
+ return { ...input.mapped, ok: false, failureCategory: "frontend-ledger-invalid", stderr: `FRONTEND_REVIEW_SCOPE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}` };
3339
3650
  }
3340
3651
  let legacyVerdict;
3341
3652
  try {
@@ -3361,13 +3672,7 @@ async function runFrontendReviewTerminalShadow(input) {
3361
3672
  reason: `typed review equivalence comparison crashed: ${error instanceof Error ? error.message : String(error)}`,
3362
3673
  };
3363
3674
  }
3364
- // Audit-only flush + artifact. Neither blocks the node.
3365
- try {
3366
- await input.tools?.flush?.();
3367
- }
3368
- catch {
3369
- // best-effort
3370
- }
3675
+ // The durable ledger was validated above; this artifact is audit-only.
3371
3676
  try {
3372
3677
  await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-review-status.json", {
3373
3678
  schemaVersion: 1,
@@ -3409,23 +3714,16 @@ async function runFrontendDesignTerminalShadow(input) {
3409
3714
  // diagnosed as an omitted terminal tool call.
3410
3715
  if (!input.mapped.ok)
3411
3716
  return input.mapped;
3412
- const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
3413
3717
  let typedKinds = [];
3414
3718
  try {
3415
- const content = await readFile(sessionEventsPath, "utf8");
3416
- typedKinds = scanDesignTerminalKindsFromSessionEvents(content);
3417
- }
3418
- catch {
3419
- // Missing/unreadable session log → fail-closed at zero terminal facts.
3420
- typedKinds = [];
3719
+ await input.tools?.flush();
3720
+ input.tools?.scopeProtocol?.assertComplete();
3721
+ typedKinds = input.tools?.scopeProtocol?.committedFacts().filter(r => ["approve_design", "request_design_changes"].includes(String(r.fact.kind))).map(r => String(r.fact.kind)) ?? [];
3421
3722
  }
3422
- // Audit-only flush + artifact. Neither blocks the node.
3423
- try {
3424
- await input.tools?.flush?.();
3425
- }
3426
- catch {
3427
- // best-effort
3723
+ catch (error) {
3724
+ return { ...input.mapped, ok: false, failureCategory: "frontend-ledger-invalid", stderr: `FRONTEND_REVIEW_SCOPE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}` };
3428
3725
  }
3726
+ // The durable ledger was validated above; this artifact is audit-only.
3429
3727
  try {
3430
3728
  await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-design-status.json", {
3431
3729
  schemaVersion: 1,
@@ -3458,6 +3756,7 @@ const FRONTEND_PLAN_SEGMENTS = [
3458
3756
  id: "coverage",
3459
3757
  toolNames: new Set([
3460
3758
  "record_plan_requirement",
3759
+ "record_plan_group_coverage",
3461
3760
  "record_plan_verification_target",
3462
3761
  "record_plan_evidence_gap",
3463
3762
  "adopt_staged_fact",
@@ -3474,13 +3773,14 @@ const FRONTEND_PLAN_SEGMENTS = [
3474
3773
  toolNames: new Set(["record_state_registry", "adopt_staged_fact"]),
3475
3774
  instruction: [
3476
3775
  "PLAN PHASE — global UX vocabulary.",
3477
- "Review ALL frozen requirements together and call record_state_registry exactly once with the complete UI-state and interaction vocabulary. UI states use declaredUiStates ids when present. Interaction names are stable kebab-case behavior domains; merge requirements that describe the same behavior instead of renaming it per AC slice. Empty arrays explicitly declare that no UX vocabulary applies. Do not record component choices or state-flow details in this phase.",
3776
+ "Bootstrap the global UX vocabulary from the execution-group index and authoritative declared states. This is navigation, not permission to decide unseen behavior. Detailed complete scopes may extend the registry with replace:true while preserving live names. UI states use declaredUiStates ids when present. Interaction names are stable kebab-case behavior domains; merge requirements that describe the same behavior instead of renaming it per AC slice. Empty arrays explicitly declare that no UX vocabulary applies. Do not record component choices or state-flow details in this phase.",
3478
3777
  "Do not call finalize_plan; it is not available in this phase.",
3479
3778
  ].join(" "),
3480
3779
  },
3481
3780
  {
3482
3781
  id: "ux-local",
3483
3782
  toolNames: new Set([
3783
+ "record_state_registry",
3484
3784
  "record_component_choice",
3485
3785
  "record_state_flow",
3486
3786
  "record_plan_verification_target",
@@ -3488,7 +3788,7 @@ const FRONTEND_PLAN_SEGMENTS = [
3488
3788
  ]),
3489
3789
  instruction: [
3490
3790
  "PLAN PHASE — global UX decisions.",
3491
- "Requirements and verification targets are already committed in the ledger. Review the complete requirement set and the committed global UX registry together, then record each component choice, UI state and interaction. Bind each applicable state to its verificationTargetIds; the runtime derives the reverse VT.uiStates relation. If a VT requires correction, record_plan_verification_target with replace:true is available after declaring its states; preserve its requirement coverage. Multiple requirements describing one behavior share one registry name and state-flow entry. Cross-cutting data flow belongs to the global Mock/data phase. Do not record routes, Mock/API policy, dependencies, or design deviations here.",
3791
+ "Requirements and verification targets are already committed in the ledger; do not re-record unchanged facts. Review the current complete execution-group scope and the committed global UX registry together, then record each component choice, UI state and interaction exactly once. Bind each applicable state to its verificationTargetIds; the runtime derives the reverse VT.uiStates relation. If a VT requires correction, record_plan_verification_target with replace:true is available after declaring its states; preserve its requirement coverage. Multiple requirements describing one behavior share one registry name and state-flow entry; never repeat or rename it per AC. Cross-cutting data flow belongs to the global Mock/data phase. Do not record routes, Mock/API policy, dependencies, or design deviations here.",
3492
3792
  "Do not call finalize_plan; it is not available in this phase.",
3493
3793
  ].join(" "),
3494
3794
  },
@@ -3502,7 +3802,7 @@ const FRONTEND_PLAN_SEGMENTS = [
3502
3802
  },
3503
3803
  {
3504
3804
  id: "global-mock-data",
3505
- toolNames: new Set(["record_data_flow", "record_mock_api", "adopt_staged_fact"]),
3805
+ toolNames: new Set(["record_data_flow", "record_mock_api", "record_mock_endpoint", "adopt_staged_fact"]),
3506
3806
  instruction: [
3507
3807
  "PLAN PHASE — global Mock/API and data policy.",
3508
3808
  "Record the cross-cutting interaction-to-endpoint data flow and Mock/API strategy only. Keep this decision set separate from route, component, state, dependency, and deviation facts. Do not call finalize_plan.",
@@ -3521,177 +3821,10 @@ const FRONTEND_PLAN_SEGMENTS = [
3521
3821
  toolNames: null,
3522
3822
  instruction: [
3523
3823
  "PLAN PHASE — finalize.",
3524
- "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.",
3824
+ "All record_* tools are available only for a narrowly named correction if the finalize receipt reports missing or invalid facts. Otherwise call finalize_plan with implementationSteps: [{requirementIds, action}] covering every frozen requirement. Describe concrete changes within approved paths, including what constraints preserve; shared steps may cover related IDs. Do not add tests or a test plan.",
3525
3825
  ].join(" "),
3526
3826
  },
3527
3827
  ];
3528
- function committedFactFromPlanRecord(value) {
3529
- if (!value || typeof value !== "object" || Array.isArray(value))
3530
- return undefined;
3531
- const record = value;
3532
- if (record.phase !== undefined && record.phase !== "committed")
3533
- return undefined;
3534
- const fact = record.fact;
3535
- return fact && typeof fact === "object" && !Array.isArray(fact)
3536
- ? fact
3537
- : typeof record.kind === "string"
3538
- ? record
3539
- : undefined;
3540
- }
3541
- function planFactStringList(value) {
3542
- if (!Array.isArray(value))
3543
- return [];
3544
- return value.filter((item) => typeof item === "string" && item.trim().length > 0);
3545
- }
3546
- function planFactScopeIntersects(fact, requirementIds) {
3547
- return planFactStringList(fact.scopeRequirementIds).some((id) => requirementIds.has(id));
3548
- }
3549
- /** Compute the authoritative coverage queue from the committed plan ledger. */
3550
- export function collectFrontendPlanMissingFacts(input) {
3551
- const requirements = new Map();
3552
- const standaloneEvidenceGaps = new Set();
3553
- const verificationTargetIds = new Set();
3554
- const verificationTargetRequirements = new Map();
3555
- for (const value of input.committedFacts) {
3556
- const fact = committedFactFromPlanRecord(value);
3557
- if (!fact || fact.origin !== "plan")
3558
- continue;
3559
- if (fact.kind === "plan-requirement" && fact.entry && typeof fact.entry === "object") {
3560
- const entry = fact.entry;
3561
- if (typeof entry.id === "string" && entry.id.trim())
3562
- requirements.set(entry.id, entry);
3563
- }
3564
- if (fact.kind === "plan-verification-target" && fact.entry && typeof fact.entry === "object") {
3565
- const entry = fact.entry;
3566
- const id = entry.id;
3567
- if (typeof id === "string" && id.trim()) {
3568
- verificationTargetIds.add(id);
3569
- verificationTargetRequirements.set(id, new Set(Array.isArray(entry.requirementIds)
3570
- ? entry.requirementIds.filter((value) => typeof value === "string")
3571
- : []));
3572
- }
3573
- }
3574
- if (fact.kind === "plan-evidence-gap" && fact.entry && typeof fact.entry === "object") {
3575
- const entry = fact.entry;
3576
- const requirementId = entry.requirementId;
3577
- const description = entry.description;
3578
- if (typeof requirementId === "string" && requirementId.trim() && typeof description === "string" && description.trim()) {
3579
- standaloneEvidenceGaps.add(requirementId);
3580
- }
3581
- }
3582
- }
3583
- const missing = [];
3584
- for (const id of input.requirementIds) {
3585
- const entry = requirements.get(id);
3586
- if (!entry) {
3587
- missing.push({
3588
- kind: "plan-requirement",
3589
- id,
3590
- requirementIds: [id],
3591
- reason: `requirement ${id} has no committed plan-requirement fact`,
3592
- });
3593
- continue;
3594
- }
3595
- const targetIds = Array.isArray(entry.verificationTargetIds)
3596
- ? entry.verificationTargetIds.filter((value) => typeof value === "string" && value.trim().length > 0)
3597
- : [];
3598
- const gap = entry.evidenceGap && typeof entry.evidenceGap === "object"
3599
- ? entry.evidenceGap
3600
- : undefined;
3601
- const hasEvidenceGap = (typeof gap?.description === "string" && gap.description.trim().length > 0) ||
3602
- standaloneEvidenceGaps.has(id);
3603
- if (targetIds.length === 0 && !hasEvidenceGap) {
3604
- missing.push({
3605
- kind: "plan-verification-target",
3606
- requirementIds: [id],
3607
- reason: `requirement ${id} declares neither a verification target nor a non-empty evidenceGap`,
3608
- });
3609
- continue;
3610
- }
3611
- for (const targetId of targetIds) {
3612
- if (!verificationTargetIds.has(targetId) ||
3613
- !verificationTargetRequirements.get(targetId)?.has(id)) {
3614
- missing.push({
3615
- kind: "plan-verification-target",
3616
- id: targetId,
3617
- requirementIds: [id],
3618
- reason: `requirement ${id} references verification target ${targetId}, but that target is not committed`,
3619
- });
3620
- }
3621
- }
3622
- }
3623
- return missing;
3624
- }
3625
- /** Completeness checks for phases whose facts are committed incrementally. */
3626
- export function collectFrontendPlanPhaseMissingFacts(input) {
3627
- const facts = input.committedFacts
3628
- .map(committedFactFromPlanRecord)
3629
- .filter((fact) => Boolean(fact && fact.origin === "plan"));
3630
- if (input.phase === "ux-registry") {
3631
- return facts.some((fact) => fact.kind === "state-registry")
3632
- ? []
3633
- : [
3634
- {
3635
- kind: "state-registry",
3636
- requirementIds: [...input.requirementIds],
3637
- reason: "global UX vocabulary phase has no committed state-registry fact",
3638
- },
3639
- ];
3640
- }
3641
- if (input.phase === "ux-local") {
3642
- const needsUx = input.requirementIds.some((id) => input.behaviorRequiredRequirementIds?.includes(id));
3643
- if (!needsUx)
3644
- return [];
3645
- const requirementSlice = new Set(input.requirementIds);
3646
- const scopedFacts = facts.filter((fact) => planFactScopeIntersects(fact, requirementSlice));
3647
- const hasChoice = scopedFacts.some((fact) => fact.kind === "component-choice" &&
3648
- Array.isArray(fact.uiComponentChoices) &&
3649
- fact.uiComponentChoices.length > 0);
3650
- const canonicalStateFlow = collectCanonicalStateFlowNames(scopedFacts);
3651
- const hasStateFlow = canonicalStateFlow.uiStateNames.size > 0 ||
3652
- canonicalStateFlow.interactionNames.size > 0;
3653
- const missing = [];
3654
- if (!hasChoice) {
3655
- missing.push({
3656
- kind: "component-choice",
3657
- requirementIds: [...input.requirementIds],
3658
- reason: "behaviour-required UX slice has no committed component-choice fact",
3659
- });
3660
- }
3661
- if (!hasStateFlow) {
3662
- missing.push({
3663
- kind: "state-flow",
3664
- requirementIds: [...input.requirementIds],
3665
- reason: "behaviour-required UX slice has no committed state-flow fact",
3666
- });
3667
- }
3668
- return missing;
3669
- }
3670
- const hasMockApi = facts.some((fact) => fact.kind === "mock-api");
3671
- const liveInteractions = collectCanonicalStateFlowNames(input.committedFacts).interactionNames;
3672
- const coveredInteractions = new Set(facts
3673
- .filter((fact) => fact.kind === "data-flow")
3674
- .flatMap((fact) => planFactStringList(fact.interactions)));
3675
- const missing = [];
3676
- if (!hasMockApi) {
3677
- missing.push({
3678
- kind: "mock-api",
3679
- requirementIds: [...input.requirementIds],
3680
- reason: "global Mock/data phase has no committed mock-api fact",
3681
- });
3682
- }
3683
- for (const interaction of liveInteractions) {
3684
- if (coveredInteractions.has(interaction))
3685
- continue;
3686
- missing.push({
3687
- kind: "data-flow",
3688
- id: interaction,
3689
- requirementIds: [...input.requirementIds],
3690
- reason: `interaction ${interaction} has no committed data-flow fact`,
3691
- });
3692
- }
3693
- return missing;
3694
- }
3695
3828
  /** Estimate calls conservatively: requirement + one VT, with a second VT
3696
3829
  * reserved for behaviour-required requirements. Explicit declarations win. */
3697
3830
  export function estimateFrontendPlanRequirementRecordCalls(fact) {
@@ -3733,42 +3866,47 @@ export function batchFrontendPlanRequirements(input) {
3733
3866
  }
3734
3867
  const FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS = 10;
3735
3868
  const FRONTEND_PLAN_COVERAGE_MAX_CONCURRENCY = 4;
3736
- // A large requirement set creates parallel coverage shards; UX remains one
3737
- // global decision session so behavior names and component/state facts are not
3738
- // reinvented per AC batch. Keep a safety bound for adaptive coverage retries
3869
+ // A single registry owns global names; large UX work uses bounded serial
3870
+ // scopes against that registry. Keep a safety bound for adaptive retries
3739
3871
  // without letting the old 32-session ceiling skip finalize.
3740
3872
  const FRONTEND_PLAN_BATCH_MAX_SESSIONS = 128;
3741
3873
  const FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS = 8;
3742
3874
  const FRONTEND_PLAN_SMALL_MAX_ESTIMATED_CALLS = 24;
3743
- async function mapWithConcurrency(items, limit, worker) {
3875
+ async function mapWithConcurrency(items, limit, worker, shouldReduceConcurrency) {
3744
3876
  const results = new Array(items.length);
3745
3877
  let nextIndex = 0;
3746
- const workerCount = Math.min(Math.max(1, limit), items.length);
3747
- await Promise.all(Array.from({ length: workerCount }, async () => {
3748
- while (true) {
3749
- const index = nextIndex++;
3750
- if (index >= items.length)
3751
- return;
3752
- results[index] = await worker(items[index], index);
3878
+ let concurrency = Math.max(1, limit);
3879
+ const running = new Map();
3880
+ try {
3881
+ while (nextIndex < items.length || running.size > 0) {
3882
+ while (nextIndex < items.length && running.size < concurrency) {
3883
+ const index = nextIndex++;
3884
+ running.set(index, worker(items[index], index).then(result => ({ index, result })));
3885
+ }
3886
+ const { index, result } = await Promise.race(running.values());
3887
+ running.delete(index);
3888
+ results[index] = result;
3889
+ // Drain existing work; only pending shards use the reduced cap.
3890
+ // Failed shards remain failed and receive no extra retry allowance.
3891
+ if (shouldReduceConcurrency(result))
3892
+ concurrency = Math.max(1, Math.floor(concurrency / 2));
3753
3893
  }
3754
- }));
3894
+ }
3895
+ catch (error) {
3896
+ await Promise.allSettled(running.values());
3897
+ throw error;
3898
+ }
3755
3899
  return results;
3756
3900
  }
3757
- function compactPromptString(value, maxChars) {
3758
- if (typeof value !== "string" || value.trim().length === 0)
3759
- return undefined;
3760
- const normalized = value.trim();
3761
- return normalized.length <= maxChars
3762
- ? normalized
3763
- : `${normalized.slice(0, maxChars - 1)}…`;
3901
+ function compactPromptString(value, _maxChars) {
3902
+ return typeof value === "string" && value.trim().length ? value.trim() : undefined;
3764
3903
  }
3765
- function compactPromptStringArray(value, maxEntries = 12, maxChars = 180) {
3904
+ function compactPromptStringArray(value, _maxEntries = 12, maxChars = 180) {
3766
3905
  if (!Array.isArray(value))
3767
3906
  return [];
3768
3907
  return value
3769
3908
  .map((item) => compactPromptString(item, maxChars))
3770
- .filter((item) => item !== undefined)
3771
- .slice(0, maxEntries);
3909
+ .filter((item) => item !== undefined);
3772
3910
  }
3773
3911
  function countFrontendPlanTargetSurfaces(basePrompt) {
3774
3912
  const match = /<frontend_plan_input>[\s\S]*?<\/frontend_plan_input>/.exec(basePrompt);
@@ -3841,7 +3979,7 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3841
3979
  }
3842
3980
  }
3843
3981
  if (!payload || !Array.isArray(payload.requirements))
3844
- return basePrompt;
3982
+ throw Error("FRONTEND_INPUT_INVALID: plan inventory is not parseable");
3845
3983
  const requirementsById = new Map();
3846
3984
  for (const value of payload.requirements) {
3847
3985
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -3853,7 +3991,7 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3853
3991
  }
3854
3992
  const requirements = slice.map((id) => requirementsById.get(id));
3855
3993
  if (requirements.some((requirement) => requirement === undefined)) {
3856
- return basePrompt;
3994
+ throw Error(`FRONTEND_INPUT_SCOPE_MISSING: ${slice.filter(id => !requirementsById.has(id)).join(", ")}`);
3857
3995
  }
3858
3996
  const compactRequirements = requirements.map((requirement) => ({
3859
3997
  id: compactPromptString(requirement.id, 80),
@@ -3870,26 +4008,28 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3870
4008
  if (!value || typeof value !== "object" || Array.isArray(value))
3871
4009
  return [];
3872
4010
  const target = value;
3873
- const targetRequirementIds = compactPromptStringArray(target.requirementIds, 20, 80);
4011
+ if (options.verificationTargetIds && !options.verificationTargetIds.includes(String(target.id)))
4012
+ return [];
4013
+ const targetRequirementIds = planFactStringList(target.requirementIds);
3874
4014
  const relatedRequirementIds = targetRequirementIds.filter((id) => sliceSet.has(id));
3875
4015
  if (relatedRequirementIds.length === 0)
3876
4016
  return [];
3877
4017
  return [
3878
4018
  {
3879
- ...(compactPromptString(target.id, 80)
3880
- ? { id: compactPromptString(target.id, 80) }
4019
+ ...(typeof target.id === "string"
4020
+ ? { id: target.id }
3881
4021
  : {}),
3882
- ...(compactPromptString(target.commandId, 80)
3883
- ? { commandId: compactPromptString(target.commandId, 80) }
4022
+ ...(typeof target.commandId === "string"
4023
+ ? { commandId: target.commandId }
3884
4024
  : {}),
3885
4025
  ...(compactPromptString(target.commandLabel, 180)
3886
4026
  ? { commandLabel: compactPromptString(target.commandLabel, 180) }
3887
4027
  : {}),
3888
- ...(compactPromptString(target.file, 180)
3889
- ? { file: compactPromptString(target.file, 180) }
4028
+ ...(typeof target.file === "string"
4029
+ ? { file: target.file }
3890
4030
  : {}),
3891
4031
  requirementIds: relatedRequirementIds,
3892
- uiStates: compactPromptStringArray(target.uiStates, 12, 100),
4032
+ uiStates: planFactStringList(target.uiStates),
3893
4033
  },
3894
4034
  ];
3895
4035
  })
@@ -3941,7 +4081,7 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3941
4081
  if (!value || typeof value !== "object" || Array.isArray(value))
3942
4082
  return [];
3943
4083
  const state = value;
3944
- const id = compactPromptString(state.id, 80);
4084
+ const id = typeof state.id === "string" ? state.id : undefined;
3945
4085
  if (!id)
3946
4086
  return [];
3947
4087
  return [{
@@ -3961,6 +4101,9 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3961
4101
  ? payload.committedUx
3962
4102
  : undefined;
3963
4103
  const compactPayload = {
4104
+ inputManifest: { ...projectFrontendInputScope({ ...payload, requirements: [...requirementsById.values()] }, slice).inputManifest, semantics: options.includeRequirementText === false ? "navigation-only" : "full" },
4105
+ constraints: payload.constraints,
4106
+ executionGroups: Array.isArray(payload.executionGroups) ? payload.executionGroups.filter(g => isRecordObject(g) && Array.isArray(g.requirementIds) && g.requirementIds.some(id => slice.includes(String(id)))) : [],
3964
4107
  requirements: compactRequirements,
3965
4108
  requiredDeliverables: Array.isArray(payload.requiredDeliverables) ? payload.requiredDeliverables : [],
3966
4109
  ...(compactTargetSurface.length > 0
@@ -4038,9 +4181,9 @@ function compactFrontendPlanLedgerContext(input) {
4038
4181
  compactFacts.push({
4039
4182
  kind: fact.kind,
4040
4183
  entry: {
4041
- id: compactPromptString(entry.id, 80),
4042
- implementationTargets: compactPromptStringArray(entry.implementationTargets, 12, 180),
4043
- verificationTargetIds: compactPromptStringArray(entry.verificationTargetIds, 12, 80),
4184
+ id: entry.id,
4185
+ implementationTargets: planFactStringList(entry.implementationTargets).slice(0, 12),
4186
+ verificationTargetIds: planFactStringList(entry.verificationTargetIds).slice(0, 12),
4044
4187
  ...(compactPromptString(entry.expectedOutcome, 240)
4045
4188
  ? { expectedOutcome: compactPromptString(entry.expectedOutcome, 240) }
4046
4189
  : {}),
@@ -4054,17 +4197,19 @@ function compactFrontendPlanLedgerContext(input) {
4054
4197
  if (fact.kind === "plan-verification-target") {
4055
4198
  if (!entry)
4056
4199
  continue;
4057
- const requirementIds = compactPromptStringArray(entry.requirementIds, 20, 80);
4058
- if (!requirementIds.some((id) => slice.has(id)))
4200
+ // Scope against the complete canonical binding before compacting.
4201
+ // A shared target may bind a late requirement beyond the preview cap.
4202
+ const requirementIds = planFactStringList(entry.requirementIds).filter((id) => slice.has(id));
4203
+ if (requirementIds.length === 0)
4059
4204
  continue;
4060
4205
  compactFacts.push({
4061
4206
  kind: fact.kind,
4062
4207
  entry: {
4063
- id: compactPromptString(entry.id, 80),
4064
- commandId: compactPromptString(entry.commandId, 80),
4065
- file: compactPromptString(entry.file, 180),
4208
+ id: entry.id,
4209
+ commandId: entry.commandId,
4210
+ file: entry.file,
4066
4211
  requirementIds,
4067
- uiStates: compactPromptStringArray(entry.uiStates, 12, 100),
4212
+ uiStates: planFactStringList(entry.uiStates).slice(0, 12),
4068
4213
  },
4069
4214
  });
4070
4215
  continue;
@@ -4082,7 +4227,7 @@ function compactFrontendPlanLedgerContext(input) {
4082
4227
  covers: compactPromptStringArray(item.covers, 40, 120),
4083
4228
  evidencePath: compactPromptString(item.evidencePath, 180),
4084
4229
  }];
4085
- }).slice(0, 24)
4230
+ })
4086
4231
  : [];
4087
4232
  if (choices.length > 0)
4088
4233
  compactFacts.push({ kind: fact.kind, uiComponentChoices: choices });
@@ -4091,16 +4236,16 @@ function compactFrontendPlanLedgerContext(input) {
4091
4236
  if (fact.kind === "state-registry") {
4092
4237
  compactFacts.push({
4093
4238
  kind: fact.kind,
4094
- uiStateNames: compactPromptStringArray(fact.uiStateNames, 40, 100),
4095
- interactionNames: compactPromptStringArray(fact.interactionNames, 60, 120),
4239
+ uiStateNames: planFactStringList(fact.uiStateNames).slice(0, 40),
4240
+ interactionNames: planFactStringList(fact.interactionNames).slice(0, 60),
4096
4241
  });
4097
4242
  continue;
4098
4243
  }
4099
4244
  if (fact.kind === "state-flow") {
4100
4245
  compactFacts.push({
4101
4246
  kind: fact.kind,
4102
- uiStates: Array.isArray(fact.uiStates) ? fact.uiStates.slice(0, 24) : [],
4103
- interactions: Array.isArray(fact.interactions) ? fact.interactions.slice(0, 24) : [],
4247
+ uiStates: Array.isArray(fact.uiStates) ? fact.uiStates : [],
4248
+ interactions: Array.isArray(fact.interactions) ? fact.interactions : [],
4104
4249
  removeUiStateNames: compactPromptStringArray(fact.removeUiStateNames, 24, 100),
4105
4250
  removeInteractionNames: compactPromptStringArray(fact.removeInteractionNames, 24, 100),
4106
4251
  });
@@ -4123,7 +4268,7 @@ function compactFrontendPlanLedgerContext(input) {
4123
4268
  mockApi: {
4124
4269
  strategy: compactPromptString(mockApi.strategy, 40),
4125
4270
  activation: compactPromptString(mockApi.activation, 180),
4126
- endpoints: Array.isArray(mockApi.endpoints) ? mockApi.endpoints.slice(0, 24) : [],
4271
+ endpoints: Array.isArray(mockApi.endpoints) ? mockApi.endpoints : [],
4127
4272
  },
4128
4273
  });
4129
4274
  continue;
@@ -4144,17 +4289,334 @@ function compactFrontendPlanLedgerContext(input) {
4144
4289
  return "";
4145
4290
  const priorityFacts = compactFacts.filter((fact) => fact.kind === "plan-requirement" || fact.kind === "plan-verification-target");
4146
4291
  const otherFacts = compactFacts.filter((fact) => fact.kind !== "plan-requirement" && fact.kind !== "plan-verification-target");
4292
+ let previewBytes = 0;
4147
4293
  const boundedFacts = [
4148
4294
  ...priorityFacts.slice(0, 64),
4149
4295
  ...otherFacts.slice(-32),
4150
- ].slice(0, 96);
4296
+ ].slice(0, 96).filter(fact => {
4297
+ const bytes = Buffer.byteLength(JSON.stringify(fact));
4298
+ if (previewBytes + bytes > 24_000)
4299
+ return false;
4300
+ previewBytes += bytes;
4301
+ return true;
4302
+ });
4151
4303
  return [
4152
4304
  "<frontend_plan_ledger>",
4153
- "Committed plan facts from earlier sessions. Treat these as authoritative; correct them only with the allowed replacement/removal fields.",
4305
+ "Preview of committed plan facts; not a replacement payload. Fields and lists may be omitted or shortened. Use read_plan_facts with kind, entryId/eventId and optional field to retrieve complete values in bounded pages before corrections; preserve all other bindings. Absence from this preview is not absence from the ledger.",
4306
+ JSON.stringify({ complete: false, matchingFacts: compactFacts.length, omittedFacts: compactFacts.length - boundedFacts.length }),
4154
4307
  JSON.stringify(boundedFacts),
4155
4308
  "</frontend_plan_ledger>",
4156
4309
  ].join("\n");
4157
4310
  }
4311
+ export async function runFrontendReviewSegmentedSessions(input) {
4312
+ const protocol = input.tools.scopeProtocol;
4313
+ const terminalKinds = input.phase === "review" ? ["approve_review", "request_review_changes"] : ["approve_design", "request_design_changes"];
4314
+ const terminal = () => protocol.committedFacts().some(r => terminalKinds.includes(String(r.fact.kind)));
4315
+ const targetBytes = input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES;
4316
+ const queue = packFrontendInputUnits(input.inventory.scopes.filter(s => !protocol.completedScopeIds().has(s.id)), { targetBytes, maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits }).map(scopes => ({ scopes, repairs: 0 }));
4317
+ if (!queue.length)
4318
+ queue.push({ scopes: [], repairs: 0 });
4319
+ let calls = 0;
4320
+ let last = { ok: true, assistantText: "", command: [], durationMs: 0, exitCode: 0, failureCategory: "success", modelDisplay: "unknown", parsedEvents: 0, stderr: "", stdout: "", timedOut: false, attemptedModels: [], fallbackUsed: false, tokensUsed: 0 };
4321
+ for (let index = 0; index < queue.length; index++) {
4322
+ await input.tools.flush();
4323
+ await input.inventory.validate();
4324
+ if (terminal()) {
4325
+ protocol.assertComplete();
4326
+ return last;
4327
+ }
4328
+ const item = queue[index];
4329
+ const scopes = item.scopes.filter(s => !protocol.completedScopeIds().has(s.id));
4330
+ protocol.setActiveScope(scopes.map(s => s.id));
4331
+ const finalScope = input.inventory.scopes.every(s => protocol.completedScopeIds().has(s.id) || scopes.some(current => current.id === s.id));
4332
+ const customTools = finalScope ? input.customTools : input.customTools.filter(t => !terminalKinds.includes(String(t.name)));
4333
+ const prompt = `${input.basePrompt}\n<frontend_review_scope>\n${JSON.stringify({ semantics: "full", inventoryDigest: input.inventory.digest, scopes, previouslyCompleted: [...protocol.completedScopeIds()], savedFindings: protocol.committedFacts().filter(r => String(r.fact.kind).endsWith("-finding")).map(r => ({ id: r.fact.id, finding: r.fact.finding })) })}\n</frontend_review_scope>\nReview the complete supplied scopes, saving each finding immediately. Call complete_review_scope for each exact id only after all its independent permissions, thresholds, errors and evidence have been checked. ${finalScope ? "After every scope is complete, make one independent overall approve/request_changes decision; persisted findings cannot be omitted." : "More scopes remain. Do not finalize or reread already completed scopes unless resolving a cross-scope issue."}`;
4334
+ if (Buffer.byteLength(prompt) > targetBytes && scopes.length > 1) {
4335
+ const at = Math.ceil(scopes.length / 2);
4336
+ queue.splice(index, 1, { scopes: scopes.slice(0, at), repairs: 0 }, { scopes: scopes.slice(at), repairs: 0 });
4337
+ index--;
4338
+ continue;
4339
+ }
4340
+ 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."}`;
4341
+ const userMessage = `${input.sessionOptions.userMessage}\n\n${completionInstruction}`;
4342
+ if (++calls > 128)
4343
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_RECOVERY_EXHAUSTED: session quota reached" };
4344
+ last = await observeFrontendSession({ ...input.observation, phase: `${input.phase}/scope`, scopeIds: scopes.map(s => s.id), prompt, userMessage, customTools,
4345
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${calls}.json` : undefined,
4346
+ committedCount: () => protocol.committedFacts().length, durableCommittedCount: () => protocol.committedFacts().length,
4347
+ }, observer => input.piStepFn({ ...input.sessionOptions, prompt, userMessage, onAttemptObservation: observer, writerToolPolicy: { requireSdk: true, customTools } }));
4348
+ await input.tools.flush();
4349
+ await input.inventory.validate();
4350
+ if (last.timedOut || /interrupt|termination-unconfirmed|budget_breach/.test(last.failureCategory))
4351
+ return { ...last, ok: false };
4352
+ const capacity = readWriterThinkingExhaustionEvidence(last).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(last.failureCategory);
4353
+ const missing = scopes.filter(s => !protocol.completedScopeIds().has(s.id));
4354
+ if (capacity && (missing.length || !terminal() && finalScope)) {
4355
+ if (missing.length === 1 && scopes.length === 1 || !missing.length && !scopes.length)
4356
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_INPUT_UNIT_TOO_LARGE: complete review unit or terminal exhausted" };
4357
+ const at = Math.ceil(missing.length / 2);
4358
+ const smaller = missing.length ? [missing.slice(0, at), missing.slice(at)].filter(s => s.length) : [[]];
4359
+ queue.splice(index, 1, ...smaller.map(scopes => ({ scopes, repairs: 0 })));
4360
+ index--;
4361
+ continue;
4362
+ }
4363
+ if (!last.ok && !capacity)
4364
+ return last;
4365
+ if (missing.length || finalScope && !terminal()) {
4366
+ if (item.repairs >= 1)
4367
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_SCOPE_INCOMPLETE: required checkpoint or verdict missing" };
4368
+ queue.splice(index, 1, { scopes: missing, repairs: item.repairs + 1 });
4369
+ index--;
4370
+ }
4371
+ }
4372
+ protocol.assertComplete();
4373
+ return terminal() ? { ...last, ok: true, failureCategory: "success" } : { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_SCOPE_INCOMPLETE: missing independent verdict" };
4374
+ }
4375
+ export async function runFrontendScoutSegmentedSessions(input) {
4376
+ const inventory = parseFrontendInputBlock(input.basePrompt, "scout");
4377
+ if (!inventory)
4378
+ throw Error("FRONTEND_INPUT_MISSING: Scout compiled inventory unavailable");
4379
+ const units = collectFrontendExecutionGroups(inventory.payload.requirements).map(group => ({ ...group, id: `${group.kind === "unclassified" ? "requirement" : "group"}:${group.id}`, requirements: inventory.payload.requirements.filter(r => group.requirementIds.includes(r.id)) }));
4380
+ const targetBytes = input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES;
4381
+ const queue = packFrontendInputUnits(units, { targetBytes, maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits }).map(batch => ({ groups: batch, repairs: 0 }));
4382
+ let last = { ok: true, assistantText: "", command: [], durationMs: 0, exitCode: 0, failureCategory: "success", modelDisplay: input.sessionOptions.modelConfig?.model ?? "unknown", parsedEvents: 0, stderr: "", stdout: "", timedOut: false, attemptedModels: [], fallbackUsed: false, tokensUsed: 0 };
4383
+ let calls = 0;
4384
+ for (let index = 0; index < queue.length; index++) {
4385
+ await input.tools.flush();
4386
+ const item = queue[index];
4387
+ const completed = input.tools.completedRequirementIds();
4388
+ const groups = item.groups.filter(g => g.requirementIds.some(id => !completed.has(id)));
4389
+ if (!groups.length)
4390
+ continue;
4391
+ const ids = groups.flatMap(g => g.requirementIds);
4392
+ const scopeId = input.tools.setActiveScope(ids);
4393
+ const prompt = input.basePrompt.replace(inventory.block, `<frontend_scout_input>\n${JSON.stringify(projectFrontendInputScope(inventory.payload, ids))}\n</frontend_scout_input>`) +
4394
+ `\nSCOUT SCOPE ${scopeId}: discover the related surfaces for exactly these complete obligations: ${ids.join(", ")}. Reuse proven paths from completed scope navigation; do not reread their content unless relevant new evidence is needed. Submit record_target_surface with scopeId="${scopeId}" after all discovery/design evidence for this scope. completeness=complete closes only this scope; runtime merges all scopes.\n` +
4395
+ JSON.stringify({ completedScopePaths: input.tools.completedScopeFacts().map(f => ({ id: f.id, requirementIds: f.requirementIds, paths: f.surface?.implementationPaths })) });
4396
+ const envelopeBytes = Buffer.byteLength(prompt + input.sessionOptions.userMessage + JSON.stringify(input.customTools.map(t => { const tool = t; return { name: tool.name, description: tool.description, parameters: tool.parameters }; })));
4397
+ if (envelopeBytes > targetBytes && groups.length > 1) {
4398
+ const at = Math.ceil(groups.length / 2);
4399
+ queue.splice(index, 1, { groups: groups.slice(0, at), repairs: 0 }, { groups: groups.slice(at), repairs: 0 });
4400
+ index--;
4401
+ continue;
4402
+ }
4403
+ if (++calls > 128)
4404
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "SCOUT_RECOVERY_EXHAUSTED: shared session quota reached" };
4405
+ last = await observeFrontendSession({ ...input.observation, phase: "scout/scope", scopeIds: ids, prompt, userMessage: input.sessionOptions.userMessage, customTools: input.customTools,
4406
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${calls}.json` : undefined,
4407
+ committedCount: () => input.tools.committedFacts().length, durableCommittedCount: () => input.tools.committedFacts().length,
4408
+ }, observer => input.piStepFn({ ...input.sessionOptions, prompt, onAttemptObservation: observer, writerToolPolicy: { requireSdk: true, customTools: input.customTools } }));
4409
+ await input.tools.flush();
4410
+ const missing = groups.filter(g => g.requirementIds.some(id => !input.tools.completedRequirementIds().has(id)));
4411
+ if (last.timedOut || /interrupt|termination-unconfirmed|budget_breach/.test(last.failureCategory))
4412
+ return { ...last, ok: false };
4413
+ const capacity = readWriterThinkingExhaustionEvidence(last).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(last.failureCategory);
4414
+ if (capacity && missing.length) {
4415
+ if (missing.length === 1 && groups.length === 1)
4416
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: `${last.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: Scout ${missing[0].id}; refine the complete source unit; unchanged retries disabled` };
4417
+ const at = Math.ceil(missing.length / 2);
4418
+ queue.splice(index, 1, ...[missing.slice(0, at), missing.slice(at)].filter(batch => batch.length).map(batch => ({ groups: batch, repairs: 0 })));
4419
+ index--;
4420
+ continue;
4421
+ }
4422
+ if (!last.ok && !capacity)
4423
+ return last;
4424
+ if (missing.length) {
4425
+ if (item.repairs >= 1)
4426
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "SCOUT_SCOPE_INCOMPLETE: unresolved discovery remains after local correction" };
4427
+ queue.splice(index, 1, { groups: missing, repairs: item.repairs + 1 });
4428
+ index--;
4429
+ continue;
4430
+ }
4431
+ }
4432
+ await input.tools.flush();
4433
+ const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-shadow-dual-write.js");
4434
+ const closure = readCompleteScoutTargetSurface(input.tools.committedFacts());
4435
+ return closure.ok ? { ...last, ok: true, failureCategory: "success" } : { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: closure.reason };
4436
+ }
4437
+ export async function runFrontendContractSegmentedSessions(input) {
4438
+ // Build from the frozen runtime inventory if the caller has not rendered it yet.
4439
+ const basePrompt = parseFrontendInputBlock(input.basePrompt, "contract") ? input.basePrompt : input.basePrompt +
4440
+ `\n<frontend_contract_input>\nFrozen complete source obligations.\n${JSON.stringify({ requirements: input.tools.inputRequirements() })}\n</frontend_contract_input>`;
4441
+ const inventory = parseFrontendInputBlock(basePrompt, "contract");
4442
+ const pending = inventory.payload.requirements.filter(r => !input.tools.completedScopeRequirementIds().has(r.id));
4443
+ const batches = packFrontendInputUnits(pending, { targetBytes: input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes, maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits });
4444
+ if (!batches.length)
4445
+ batches.push([]);
4446
+ let last;
4447
+ let invocation = 0;
4448
+ const maxSessions = batches.length * 3 + 2;
4449
+ const terminal = () => input.tools.committedFacts().some(r => r.fact.kind === "contract-finalized");
4450
+ try {
4451
+ for (let index = 0; index < batches.length; index += 1) {
4452
+ let scopeIds = batches[index].map(r => r.id);
4453
+ const finalScope = index === batches.length - 1;
4454
+ for (let repair = 0; repair < 2; repair += 1) {
4455
+ // Confirmed IDs remain visible until the model commits a scope checkpoint.
4456
+ const completed = input.tools.completedScopeRequirementIds();
4457
+ scopeIds = scopeIds.filter(id => !completed.has(id));
4458
+ input.tools.setActiveRequirementScope(scopeIds);
4459
+ const groupIndex = collectFrontendExecutionGroups(input.tools.committedFacts().filter(r => r.fact.kind === "requirement").map(r => ({ id: String(r.fact.id), execution: r.fact.execution })));
4460
+ const shared = input.tools.committedFacts().filter(r => !["requirement", "contract-finalized", "contract-scope-completed"].includes(String(r.fact.kind))).map(r => r.fact);
4461
+ const prompt = projectFrontendContractPrompt(basePrompt, scopeIds) +
4462
+ `\nCONTRACT SCOPE: analyze only ${scopeIds.join(", ") || "(all scopes complete; verify global facts and terminal)"}. Each obligation is complete. Submit small records immediately, then call complete_contract_scope after ALL decisions for this scope. ` +
4463
+ (finalScope ? "After complete scope coverage and source-bound deliverables, call finalize_contract. Correct rejected calls and retry." : "Do not finalize; subsequent complete scopes remain.") +
4464
+ `\n<committed_contract_facts>\n${JSON.stringify({ facts: shared, executionGroups: groupIndex })}\n</committed_contract_facts>`;
4465
+ const customTools = input.tools.customTools.filter(t => finalScope || t.name !== "finalize_contract");
4466
+ invocation += 1;
4467
+ if (invocation > maxSessions)
4468
+ return { ...last, ok: false, failureCategory: "invalid-output", stderr: "CONTRACT_RECOVERY_EXHAUSTED: session quota exceeded" };
4469
+ last = await observeFrontendSession({
4470
+ ...input.observation, phase: "contract/scope", scopeIds, prompt, userMessage: input.sessionOptions.userMessage, customTools,
4471
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${invocation}.json` : undefined,
4472
+ committedCount: () => input.tools.committedFacts().length, durableCommittedCount: () => input.tools.committedFacts().length,
4473
+ }, observer => input.piStepFn({ ...input.sessionOptions, prompt, onAttemptObservation: observer, writerToolPolicy: { requireSdk: true, customTools } }));
4474
+ await input.tools.flush();
4475
+ if (last.timedOut || /interrupt|termination-unconfirmed|budget_breach/.test(last.failureCategory))
4476
+ return { ...last, ok: false };
4477
+ const capacityExhausted = readWriterThinkingExhaustionEvidence(last).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(last.failureCategory);
4478
+ if (capacityExhausted && !last.timedOut) {
4479
+ const missing = scopeIds.filter(id => !input.tools.completedScopeRequirementIds().has(id));
4480
+ if (!missing.length) {
4481
+ if (finalScope && !terminal()) {
4482
+ if (!scopeIds.length)
4483
+ return { ...last, ok: false, failureCategory: "invalid-output", stderr: `${last.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: contract terminal exhausted; unchanged retry disabled` };
4484
+ batches.push([]);
4485
+ }
4486
+ break;
4487
+ }
4488
+ if (missing.length === 1 && scopeIds.length > 1) {
4489
+ batches.splice(index, 1, inventory.payload.requirements.filter(r => missing.includes(r.id)));
4490
+ index -= 1;
4491
+ break;
4492
+ }
4493
+ if (missing.length <= 1)
4494
+ return { ...last, ok: false, stderr: `${last.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: ${missing[0] ?? "contract terminal"}; atom could not complete; refine the source without dropping conditions` };
4495
+ const smaller = packFrontendInputUnits(inventory.payload.requirements.filter(r => missing.includes(r.id)), { maxUnits: Math.ceil(missing.length / 2) });
4496
+ batches.splice(index, 1, ...smaller);
4497
+ index -= 1;
4498
+ break;
4499
+ }
4500
+ if (!last.ok)
4501
+ return last;
4502
+ const missing = scopeIds.filter(id => !input.tools.completedScopeRequirementIds().has(id));
4503
+ if (!missing.length && (!finalScope || terminal()))
4504
+ break;
4505
+ if (repair === 1)
4506
+ return { ...last, ok: false, failureCategory: "invalid-output", stderr: `CONTRACT_SCOPE_INCOMPLETE: ${missing.join(", ") || "missing finalize_contract"}` };
4507
+ }
4508
+ }
4509
+ return last;
4510
+ }
4511
+ finally {
4512
+ input.tools.setActiveRequirementScope(null);
4513
+ }
4514
+ }
4515
+ /** One workload view for both the outer parallel map and inner session queue.
4516
+ * A declared execution group is an indivisible unit during initial packing. */
4517
+ function buildFrontendPlanWorkload(input) {
4518
+ const allRequirementIds = input.requirementIds;
4519
+ const compiledInput = parseFrontendInputBlock(input.basePrompt, "plan")?.payload;
4520
+ const fullUnits = new Map(compiledInput?.requirements.map(r => [r.id, r]) ?? []);
4521
+ const declaredGroups = Array.isArray(compiledInput?.executionGroups)
4522
+ ? compiledInput.executionGroups.filter(isRecordObject) : [];
4523
+ const workGroups = declaredGroups.map(g => ({
4524
+ id: String(g.id), kind: String(g.kind),
4525
+ requirementIds: Array.isArray(g.requirementIds)
4526
+ ? g.requirementIds.filter((id) => typeof id === "string" && allRequirementIds.includes(id)) : [],
4527
+ })).filter(g => g.requirementIds.length);
4528
+ const groupedIds = new Set(workGroups.flatMap(g => g.requirementIds));
4529
+ for (const id of allRequirementIds) {
4530
+ if (!groupedIds.has(id))
4531
+ workGroups.push({ id, kind: "unclassified", requirementIds: [id] });
4532
+ }
4533
+ const workCost = (ids) => 1 + ids.reduce((total, id) => total + Math.max(1, (input.requirementCosts?.get(id) ?? 2) - 1), 0);
4534
+ const policy = input.sessionOptions.frontendExecutionPolicy;
4535
+ const targetBytes = policy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES;
4536
+ const buildWorkBatches = (ids) => {
4537
+ const work = workGroups.flatMap((g, index) => {
4538
+ const members = g.requirementIds.filter(id => ids.includes(id));
4539
+ return members.length ? [{ id: `${index}:${g.id}`, requirementIds: members,
4540
+ requirements: members.map(id => fullUnits.get(id) ?? { id }), estimatedCalls: workCost(members) }] : [];
4541
+ });
4542
+ // Pack complete work units, not the repeated request scaffold. Deducting
4543
+ // fixed context/tools can leave a one-byte budget and force one AC per
4544
+ // session without reducing that overhead. The SDK checks the actual
4545
+ // request against model capacity; capacity recovery splits unfinished work.
4546
+ return packFrontendInputUnits(work, {
4547
+ targetBytes,
4548
+ maxUnits: policy?.maxScopeUnits ?? 4,
4549
+ maxCost: FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS, cost: g => g.estimatedCalls,
4550
+ }).map(batch => batch.flatMap(g => g.requirementIds));
4551
+ };
4552
+ // This renderer also carries the Plan's evolving UX checkpoint on resume.
4553
+ // Only the Contract/Scout-derived input participates in ownership binding.
4554
+ const { committedUx: _committedUx, ...frozenInput } = compiledInput ?? {};
4555
+ return {
4556
+ frozenInput, workGroups, buildWorkBatches,
4557
+ compactEligible: input.requirementCosts !== undefined &&
4558
+ workGroups.length <= FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS &&
4559
+ workGroups.reduce((total, group) => total + workCost(group.requirementIds), 0) <= FRONTEND_PLAN_SMALL_MAX_ESTIMATED_CALLS &&
4560
+ countFrontendPlanTargetSurfaces(input.basePrompt) === 1,
4561
+ };
4562
+ }
4563
+ const frontendPlanCoverageLayoutSchema = z.object({
4564
+ schemaVersion: z.literal(1),
4565
+ bindingSha256: z.string(),
4566
+ coverageBatches: z.array(z.array(z.string().min(1)).min(1)).min(1),
4567
+ layoutSha256: z.string(),
4568
+ }).strict();
4569
+ /** Freeze ledger ownership before any provider call. Session packing remains
4570
+ * adaptive inside each owner, but retry instructions cannot rename its scope. */
4571
+ async function loadOrCreateFrontendPlanCoverageLayout(input) {
4572
+ const fileName = "coverage-layout.json";
4573
+ const file = path.join(input.runDir, input.nodeId, fileName);
4574
+ const bindingSha256 = sha256OfCanonicalJson(input.binding);
4575
+ let raw;
4576
+ try {
4577
+ raw = await readFile(file, "utf8");
4578
+ }
4579
+ catch (error) {
4580
+ if (error.code !== "ENOENT")
4581
+ throw error;
4582
+ }
4583
+ let layout;
4584
+ if (raw !== undefined) {
4585
+ layout = frontendPlanCoverageLayoutSchema.parse(JSON.parse(raw));
4586
+ }
4587
+ else {
4588
+ // Losing the ownership receipt must never repartition acknowledged facts.
4589
+ for (const relative of ["plan-typed-facts.jsonl", "parallel"]) {
4590
+ const existing = await stat(path.join(input.runDir, input.nodeId, relative)).catch(error => {
4591
+ if (error.code !== "ENOENT")
4592
+ throw error;
4593
+ return undefined;
4594
+ });
4595
+ if (existing && (existing.isDirectory() || existing.size > 0)) {
4596
+ throw Error("FRONTEND_PLAN_LAYOUT_MISSING: preserve the existing ledger and restart its owning phase");
4597
+ }
4598
+ }
4599
+ const descriptor = {
4600
+ schemaVersion: 1, bindingSha256,
4601
+ coverageBatches: input.workload.compactEligible ? [input.requirementIds] : input.workload.buildWorkBatches(input.requirementIds),
4602
+ };
4603
+ layout = { ...descriptor, layoutSha256: sha256OfCanonicalJson(descriptor) };
4604
+ }
4605
+ const { layoutSha256, ...descriptor } = layout;
4606
+ if (layout.bindingSha256 !== bindingSha256 || layoutSha256 !== sha256OfCanonicalJson(descriptor)) {
4607
+ throw Error("FRONTEND_PLAN_LAYOUT_MISMATCH: frozen coverage ownership or input binding changed");
4608
+ }
4609
+ const members = layout.coverageBatches.flat();
4610
+ const owners = new Map(layout.coverageBatches.flatMap((batch, index) => batch.map(id => [id, index])));
4611
+ if (members.length !== owners.size || members.length !== input.requirementIds.length ||
4612
+ input.requirementIds.some(id => !owners.has(id)) ||
4613
+ input.workload.workGroups.some(group => new Set(group.requirementIds.map(id => owners.get(id))).size !== 1)) {
4614
+ throw Error("FRONTEND_PLAN_LAYOUT_INVALID: coverage must partition complete execution groups exactly once");
4615
+ }
4616
+ if (raw === undefined)
4617
+ await writeDagNodeJsonArtifact(input.runDir, input.nodeId, fileName, layout);
4618
+ return layout.coverageBatches;
4619
+ }
4158
4620
  export async function runFrontendPlanSegmentedSessions(input) {
4159
4621
  const queue = [];
4160
4622
  const coverageSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "coverage");
@@ -4163,10 +4625,10 @@ export async function runFrontendPlanSegmentedSessions(input) {
4163
4625
  const globalMockDataSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "global-mock-data");
4164
4626
  const finalizeSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "finalize");
4165
4627
  const allRequirementIds = input.requirementIds ?? [];
4166
- const buildPhasePrompt = (segment, missing = []) => {
4628
+ const buildPhasePrompt = (segment, missing = [], scopeIds = allRequirementIds) => {
4167
4629
  const compact = allRequirementIds.length > 0
4168
- ? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds, {
4169
- includeRequirementText: segment.id === "global-mock-data" || segment.id === "global-dependency-deviation",
4630
+ ? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, scopeIds, {
4631
+ includeRequirementText: segment.id === "global-mock-data" || segment.id === "finalize",
4170
4632
  includeVerificationTargets: false,
4171
4633
  includeDesignEvidence: segment.id === "global-dependency-deviation" || segment.id === "finalize",
4172
4634
  includeChecklist: false,
@@ -4182,8 +4644,9 @@ export async function runFrontendPlanSegmentedSessions(input) {
4182
4644
  const ledger = input.committedFacts
4183
4645
  ? compactFrontendPlanLedgerContext({
4184
4646
  committedFacts: input.committedFacts(),
4185
- requirementIds: allRequirementIds,
4647
+ requirementIds: scopeIds,
4186
4648
  kinds,
4649
+ ...(segment.id === "global-mock-data" ? { scopedKinds: ["state-flow"] } : {}),
4187
4650
  })
4188
4651
  : "";
4189
4652
  return [
@@ -4221,13 +4684,13 @@ export async function runFrontendPlanSegmentedSessions(input) {
4221
4684
  "component-choice",
4222
4685
  "state-flow",
4223
4686
  ],
4224
- scopedKinds: ["component-choice", "state-flow"],
4687
+ // Include shared state bindings from other scopes; page full values before correcting.
4225
4688
  })
4226
4689
  : "";
4227
4690
  return [
4228
4691
  compactFrontendPlanPromptForRequirementSlice(input.basePrompt, slice),
4229
4692
  uxSegment.instruction,
4230
- `GLOBAL UX SCOPE: process the complete requirement set together: ${slice.join(", ")}. Do not split or rename one behavior by requirement id.`,
4693
+ `UX SCOPE: process these complete behavior groups together: ${slice.join(", ")}. Reuse shared registry/component ownership; do not rename a behavior by requirement id. Constraint/exclusion groups do not create UI; preserve genuine verification gaps.`,
4231
4694
  ledger,
4232
4695
  ...(missing.length > 0
4233
4696
  ? [
@@ -4251,6 +4714,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4251
4714
  : "";
4252
4715
  const compact = allRequirementIds.length > 0
4253
4716
  ? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds, {
4717
+ includeRequirementText: false,
4254
4718
  includeVerificationTargets: false,
4255
4719
  includeDesignEvidence: false,
4256
4720
  includeChecklist: false,
@@ -4296,7 +4760,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4296
4760
  : []),
4297
4761
  ].filter(Boolean).join("\n\n");
4298
4762
  };
4299
- 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 exactly once.";
4763
+ 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.";
4300
4764
  const buildCompactFinalizePrompt = (missing = []) => [buildPhasePrompt(finalizeSegment, missing), compactFinalizeInstruction].join("\n\n");
4301
4765
  const mapPlannerExhaustion = (r, committedAnyFacts) => isPlannerThinkingExhausted(r, committedAnyFacts)
4302
4766
  ? {
@@ -4340,8 +4804,10 @@ export async function runFrontendPlanSegmentedSessions(input) {
4340
4804
  tokensUsed: 0,
4341
4805
  };
4342
4806
  }
4343
- const estimatedCalls = (input.requirementIds ?? []).reduce((total, id) => total + Math.max(1, input.requirementCosts?.get(id) ?? 2), 0);
4344
- const targetSurfaceCount = countFrontendPlanTargetSurfaces(input.basePrompt);
4807
+ const { workGroups, buildWorkBatches, compactEligible } = buildFrontendPlanWorkload({
4808
+ basePrompt: input.basePrompt, requirementIds: allRequirementIds,
4809
+ requirementCosts: input.requirementCosts, sessionOptions: input.sessionOptions,
4810
+ });
4345
4811
  // Small, single-surface requests do not benefit from six isolated Pi
4346
4812
  // sessions. Keep the typed ledger as the authority, but let one local
4347
4813
  // session establish requirement/UX facts and one final session establish
@@ -4349,14 +4815,11 @@ export async function runFrontendPlanSegmentedSessions(input) {
4349
4815
  // for larger plans and for the unscoped compatibility path.
4350
4816
  const useCompactSmallPlan = requirementIdsProvided &&
4351
4817
  input.compactSmallPlan === true &&
4352
- input.requirementCosts !== undefined &&
4353
- estimatedCalls > 12 &&
4354
- (input.requirementIds?.length ?? 0) <= FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS &&
4355
- estimatedCalls <= FRONTEND_PLAN_SMALL_MAX_ESTIMATED_CALLS &&
4356
- targetSurfaceCount === 1;
4818
+ compactEligible;
4357
4819
  if (useCompactSmallPlan) {
4358
4820
  const compactLocalTools = new Set([
4359
4821
  "record_plan_requirement",
4822
+ "record_plan_group_coverage",
4360
4823
  "record_plan_verification_target",
4361
4824
  "record_plan_evidence_gap",
4362
4825
  "record_state_registry",
@@ -4384,13 +4847,8 @@ export async function runFrontendPlanSegmentedSessions(input) {
4384
4847
  });
4385
4848
  }
4386
4849
  else if (coverageWorkIds.length > 0) {
4387
- const coverageBatches = batchFrontendPlanRequirements({
4388
- requirementIds: coverageWorkIds,
4389
- maxEstimatedRecordCalls: FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS,
4390
- maxRequirements: 4,
4391
- requirementCosts: input.requirementCosts,
4392
- });
4393
- coverageBatches.forEach((slice, batchIndex) => queue.push({
4850
+ const completeBatches = buildWorkBatches(coverageWorkIds);
4851
+ completeBatches.forEach((slice, batchIndex) => queue.push({
4394
4852
  id: `coverage-batch-${batchIndex + 1}`,
4395
4853
  toolNames: coverageSegment.toolNames,
4396
4854
  coverageSlice: slice,
@@ -4409,28 +4867,27 @@ export async function runFrontendPlanSegmentedSessions(input) {
4409
4867
  prompt: buildUxRegistryPrompt(),
4410
4868
  });
4411
4869
  }
4412
- const uxSlice = requirementIdsProvided ? [...allRequirementIds] : [];
4413
- queue.push({
4414
- id: "ux-local-1",
4415
- // Compatibility path for an unreadable requirement inventory: there is
4416
- // no safe all-requirements registry phase, so the unscoped UX session
4417
- // must establish its registry before recording state flow.
4418
- toolNames: requirementIdsProvided
4419
- ? uxSegment.toolNames
4420
- : new Set([...(uxSegment.toolNames ?? []), "record_state_registry"]),
4421
- ...(uxSlice.length > 0 ? { requirementSlice: uxSlice } : {}),
4422
- prompt: uxSlice.length > 0
4423
- ? buildUxPrompt(uxSlice)
4424
- : buildPhasePrompt(uxSegment),
4425
- });
4870
+ const uxWorkIds = workGroups.filter(g => !["constraint", "exclusion"].includes(g.kind) || g.requirementIds.some(id => input.behaviorRequiredRequirementIds?.includes(id))).flatMap(g => g.requirementIds);
4871
+ const uxBatches = requirementIdsProvided ? buildWorkBatches(uxWorkIds) : [[]];
4872
+ uxBatches.forEach((slice, batchIndex) => queue.push({
4873
+ id: `ux-local-${batchIndex + 1}`,
4874
+ toolNames: requirementIdsProvided ? uxSegment.toolNames : new Set([...(uxSegment.toolNames ?? []), "record_state_registry"]),
4875
+ ...(slice.length ? { requirementSlice: slice } : {}),
4876
+ prompt: slice.length ? buildUxPrompt(slice) : buildPhasePrompt(uxSegment),
4877
+ }));
4426
4878
  for (const segment of FRONTEND_PLAN_SEGMENTS) {
4427
4879
  if (["coverage", "ux-registry", "ux-local", "finalize"].includes(segment.id))
4428
4880
  continue;
4429
- queue.push({
4430
- id: segment.id,
4431
- toolNames: segment.toolNames,
4432
- prompt: buildPhasePrompt(segment),
4433
- });
4881
+ if (segment.id === "global-mock-data" && requirementIdsProvided) {
4882
+ buildWorkBatches(allRequirementIds).forEach((slice, i) => queue.push({ id: `global-mock-data-${i + 1}`, toolNames: segment.toolNames, requirementSlice: slice, prompt: buildPhasePrompt(segment, [], slice) }));
4883
+ }
4884
+ else {
4885
+ queue.push({
4886
+ id: segment.id,
4887
+ toolNames: segment.toolNames,
4888
+ prompt: buildPhasePrompt(segment),
4889
+ });
4890
+ }
4434
4891
  }
4435
4892
  queue.push({
4436
4893
  id: finalizeSegment.id,
@@ -4441,8 +4898,30 @@ export async function runFrontendPlanSegmentedSessions(input) {
4441
4898
  let accumulated;
4442
4899
  let index = 0;
4443
4900
  let invocationCount = 0;
4901
+ let lastDurableCount = input.committedFactCount();
4444
4902
  while (index < queue.length) {
4445
4903
  const session = queue[index];
4904
+ if (input.attempt > 1 && input.committedFacts && session.id === "ux-registry" &&
4905
+ collectFrontendPlanPhaseMissingFacts({ phase: "ux-registry", requirementIds: allRequirementIds, committedFacts: input.committedFacts() }).length === 0) {
4906
+ index += 1;
4907
+ continue;
4908
+ }
4909
+ // Reuse complete UX scopes on node retry. Require explicit per-requirement
4910
+ // ownership; an unrelated/global fact must not prove a slice complete.
4911
+ if (input.attempt > 1 && input.committedFacts && session.id.startsWith("ux-local-") && session.requirementSlice?.length) {
4912
+ const facts = input.committedFacts();
4913
+ const ownedIds = new Set(facts.flatMap(value => {
4914
+ const fact = committedFactFromPlanRecord(value);
4915
+ return fact ? planFactStringList(fact.scopeRequirementIds) : [];
4916
+ }));
4917
+ const complete = session.requirementSlice.every(id => ownedIds.has(id)) &&
4918
+ collectFrontendPlanPhaseMissingFacts({ phase: "ux-local", requirementIds: session.requirementSlice,
4919
+ committedFacts: facts, behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds }).length === 0;
4920
+ if (complete) {
4921
+ index += 1;
4922
+ continue;
4923
+ }
4924
+ }
4446
4925
  const remaining = (session.coverageOnly ? session.coverageSlice ?? [] : []).filter((id) => !input.committedRequirementIds?.().has(id));
4447
4926
  const preexistingMissing = session.coverageOnly && session.coverageSlice && input.committedFacts
4448
4927
  ? collectFrontendPlanMissingFacts({
@@ -4460,7 +4939,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4460
4939
  }
4461
4940
  let prompt = session.prompt;
4462
4941
  if (session.coverageOnly && session.coverageSlice) {
4463
- const promptSlice = remaining.length > 0 ? remaining : session.coverageSlice;
4942
+ const promptSlice = [...new Set([...remaining, ...preexistingMissing.flatMap(item => item.requirementIds)])];
4464
4943
  prompt = buildCoveragePrompt(promptSlice, session.missingFacts ?? preexistingMissing);
4465
4944
  }
4466
4945
  else if (session.id === "compact-local") {
@@ -4480,45 +4959,68 @@ export async function runFrontendPlanSegmentedSessions(input) {
4480
4959
  // Global phases and finalize also consume the latest committed ledger;
4481
4960
  // constructing their prompt only when the session starts prevents a
4482
4961
  // stale queue entry from dropping facts written by earlier phases.
4483
- const segment = FRONTEND_PLAN_SEGMENTS.find((candidate) => candidate.id === session.id);
4962
+ const segment = FRONTEND_PLAN_SEGMENTS.find((candidate) => candidate.id === session.id || (candidate.id === "global-mock-data" && session.id.startsWith("global-mock-data-")));
4484
4963
  if (segment) {
4485
4964
  prompt =
4486
4965
  session.id === "finalize" && useCompactSmallPlan
4487
4966
  ? buildCompactFinalizePrompt(session.missingFacts)
4488
- : buildPhasePrompt(segment, session.missingFacts);
4967
+ : buildPhasePrompt(segment, session.missingFacts, session.requirementSlice ?? allRequirementIds);
4489
4968
  }
4490
4969
  }
4970
+ const atomicFocus = session.atomicRecovery ? session.missingFacts?.[0] : undefined;
4971
+ if (atomicFocus) {
4972
+ prompt = [
4973
+ compactFrontendPlanPromptForRequirementSlice(input.basePrompt, atomicFocus.requirementIds, {
4974
+ includeChecklist: false,
4975
+ ...(atomicFocus.kind === "plan-verification-target" && atomicFocus.id
4976
+ ? { verificationTargetIds: [atomicFocus.id] } : {}),
4977
+ }),
4978
+ `ATOMIC FACT: ${atomicFocus.kind}${atomicFocus.id ? ` ${atomicFocus.id}` : ""}`,
4979
+ atomicFocus.reason,
4980
+ "Commit ONLY this missing fact with one record_* call, then end this session. Other facts are queued separately. Preserve canonical IDs and shared bindings; use read_plan_facts for existing values. Do not summarize or plan the entire requirement. Capacity exhaustion is not evidence of a requirement gap.",
4981
+ ].join("\n\n");
4982
+ }
4491
4983
  const committedBefore = input.committedFactCount();
4492
- input.setActiveRequirementScope?.(session.coverageOnly ||
4493
- session.id === "compact-local" ||
4494
- session.id.startsWith("ux-local-")
4495
- ? session.coverageSlice ?? session.requirementSlice ?? []
4496
- : []);
4984
+ input.setActiveRequirementScope?.(session.coverageOnly ? session.coverageSlice ?? [] : session.requirementSlice ?? []);
4497
4985
  if (invocationCount >= FRONTEND_PLAN_BATCH_MAX_SESSIONS)
4498
4986
  break;
4499
4987
  invocationCount += 1;
4500
- const customTools = input.segmentCustomTools(session.toolNames);
4501
- const result = await input.piStepFn({
4502
- ...input.sessionOptions,
4503
- prompt,
4504
- ...(customTools.length > 0
4505
- ? {
4506
- writerToolPolicy: {
4507
- requireSdk: true,
4508
- customTools,
4509
- },
4510
- }
4511
- : {}),
4988
+ const atomicTools = {
4989
+ "plan-requirement": "record_plan_requirement", "plan-verification-target": "record_plan_verification_target",
4990
+ "state-registry": "record_state_registry", "component-choice": "record_component_choice",
4991
+ "state-flow": "record_state_flow", "data-flow": "record_data_flow", "mock-api": "record_mock_api",
4992
+ };
4993
+ const customTools = input.segmentCustomTools(atomicFocus ? new Set([atomicTools[atomicFocus.kind]]) : session.toolNames);
4994
+ const result = await observeFrontendSession({
4995
+ ...input.observation, phase: `plan/${session.id}`, scopeIds: session.requirementSlice ?? session.coverageSlice ?? allRequirementIds,
4996
+ prompt, userMessage: input.sessionOptions.userMessage, customTools, committedCount: input.committedFactCount, durableCommittedCount: () => lastDurableCount,
4997
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${invocationCount}.json` : undefined,
4998
+ }, async (observer) => {
4999
+ const result = await input.piStepFn({
5000
+ ...input.sessionOptions,
5001
+ onAttemptObservation: observer,
5002
+ prompt,
5003
+ ...(customTools.length > 0
5004
+ ? {
5005
+ writerToolPolicy: {
5006
+ requireSdk: true,
5007
+ customTools,
5008
+ },
5009
+ }
5010
+ : {}),
5011
+ });
5012
+ try {
5013
+ await input.flushLedger();
5014
+ lastDurableCount = input.committedFactCount();
5015
+ }
5016
+ catch {
5017
+ // best-effort: the node-level flush runs again after the attempt
5018
+ }
5019
+ return result;
4512
5020
  });
4513
5021
  accumulated = accumulated
4514
5022
  ? combineSequentialPiResults(accumulated, result)
4515
5023
  : result;
4516
- try {
4517
- await input.flushLedger();
4518
- }
4519
- catch {
4520
- // best-effort: the node-level flush runs again after the attempt
4521
- }
4522
5024
  const committedAfter = input.committedFactCount();
4523
5025
  const committedFactsOnlySuccess = session.id !== "finalize" &&
4524
5026
  !(result.assistantText ?? "").trim() &&
@@ -4565,10 +5067,10 @@ export async function runFrontendPlanSegmentedSessions(input) {
4565
5067
  committedFacts: input.committedFacts(),
4566
5068
  behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds,
4567
5069
  })
4568
- : session.id === "global-mock-data" && allRequirementIds.length > 0 && input.committedFacts
5070
+ : session.id.startsWith("global-mock-data") && allRequirementIds.length > 0 && input.committedFacts
4569
5071
  ? collectFrontendPlanPhaseMissingFacts({
4570
5072
  phase: "global-mock-data",
4571
- requirementIds: allRequirementIds,
5073
+ requirementIds: session.requirementSlice ?? allRequirementIds,
4572
5074
  committedFacts: input.committedFacts(),
4573
5075
  })
4574
5076
  : [];
@@ -4605,7 +5107,96 @@ export async function runFrontendPlanSegmentedSessions(input) {
4605
5107
  ? buildCompactFinalizePrompt(missing)
4606
5108
  : isUxLocalSession
4607
5109
  ? buildUxPrompt(session.requirementSlice ?? [], missing)
4608
- : buildPhasePrompt(globalMockDataSegment, missing);
5110
+ : buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds);
5111
+ const recovery = classifyFrontendPlanRecovery({ ...result, stopReason: readWriterThinkingExhaustionEvidence(result).stopReason });
5112
+ if (recovery === "stop")
5113
+ return { ...result, ok: false };
5114
+ if ((recovery === "output" || (session.atomicRecovery && result.ok)) && input.committedFacts &&
5115
+ !(missingPhaseFacts.length === 1 && missingPhaseFacts[0].kind === "plan-requirement") &&
5116
+ (session.coverageOnly || isUxRegistrySession || isUxLocalSession || session.id.startsWith("global-mock-data"))) {
5117
+ if (missingPhaseFacts.length === 0) {
5118
+ // A complete validated slice does not need a successful prose turn.
5119
+ // This never accepts finalize or bypasses the final contract validator.
5120
+ accumulated = { ...accumulated, ok: true, failureCategory: "success" };
5121
+ index += 1;
5122
+ continue;
5123
+ }
5124
+ const missingIds = [...new Set(missingPhaseFacts.flatMap(item => item.requirementIds))];
5125
+ const scope = session.coverageSlice ?? session.requirementSlice;
5126
+ if (scope && missingIds.length < scope.length) {
5127
+ queue[index] = { ...session, missingFacts: missingPhaseFacts,
5128
+ ...(session.coverageOnly ? { coverageSlice: missingIds } : { requirementSlice: missingIds }),
5129
+ retryCount: 0 };
5130
+ continue;
5131
+ }
5132
+ if (scope && missingIds.length > 1 && (session.coverageOnly || isUxLocalSession)) {
5133
+ const half = Math.ceil(missingIds.length / 2);
5134
+ queue.splice(index, 1, ...[missingIds.slice(0, half), missingIds.slice(half)].map(ids => ({
5135
+ ...session, retryCount: 0,
5136
+ ...(session.coverageOnly ? { coverageSlice: ids } : { requirementSlice: ids }),
5137
+ missingFacts: missingPhaseFacts.filter(item => item.requirementIds.some(id => ids.includes(id))),
5138
+ })));
5139
+ continue;
5140
+ }
5141
+ const focusStillMissing = atomicFocus && missingPhaseFacts.some(item => item.kind === atomicFocus.kind && item.id === atomicFocus.id &&
5142
+ item.requirementIds.join("\0") === atomicFocus.requirementIds.join("\0"));
5143
+ const retries = focusStillMissing ? (session.retryCount ?? 0) + 1 : 0;
5144
+ if (retries < 2) {
5145
+ queue[index] = { ...session, atomicRecovery: true, missingFacts: missingPhaseFacts, retryCount: retries };
5146
+ continue;
5147
+ }
5148
+ return { ...accumulated, ok: false, failureCategory: OUTPUT_LIMIT_RETRY_CATEGORY,
5149
+ stderr: `${accumulated.stderr}\nfrontend plan capacity recovery exhausted: preserve committed facts; phase=${session.id}; scope=${missingIds.join(",")}; strategy=atomic-fact; missing=${JSON.stringify(missingPhaseFacts)}`.trim() };
5150
+ }
5151
+ const capacityExhausted = recovery === "output" || recovery === "context";
5152
+ if (capacityExhausted && !result.timedOut) {
5153
+ const failure = () => mapPlannerExhaustion({ ...result, ok: false, stderr: `${result.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: ${session.id}; no smaller complete scope can finish; unchanged retries are disabled` }, committedAfter > committedBefore);
5154
+ const missingCoverageIds = input.committedFacts ? [...new Set(collectFrontendPlanMissingFacts({ requirementIds: session.coverageSlice ?? session.requirementSlice ?? allRequirementIds, committedFacts: input.committedFacts() }).flatMap(f => f.requirementIds))] : [...(session.coverageSlice ?? session.requirementSlice ?? allRequirementIds)];
5155
+ const splitScope = (ids, allowMemberSplit) => {
5156
+ const groups = workGroups.map(g => g.requirementIds.filter(id => ids.includes(id))).filter(g => g.length);
5157
+ if (groups.length > 1) {
5158
+ const half = Math.ceil(groups.length / 2);
5159
+ return [groups.slice(0, half).flat(), groups.slice(half).flat()];
5160
+ }
5161
+ if (allowMemberSplit && ids.length > 1) {
5162
+ const half = Math.ceil(ids.length / 2);
5163
+ return [ids.slice(0, half), ids.slice(half)];
5164
+ }
5165
+ return [];
5166
+ };
5167
+ if (isCompactLocalSession) {
5168
+ let scopes = splitScope(missingCoverageIds, true);
5169
+ if (!scopes.length && missingCoverageIds.length) {
5170
+ if (missingCoverageIds.length === allRequirementIds.length && committedAfter === committedBefore)
5171
+ return failure();
5172
+ scopes = [missingCoverageIds];
5173
+ }
5174
+ // UX ownership is independent of coverage completion. Preserve all groups.
5175
+ const recovery = scopes.map((slice, i) => ({ id: `coverage-capacity-${invocationCount}-${i}`, coverageOnly: true, coverageSlice: slice, toolNames: coverageSegment.toolNames, prompt: buildCoveragePrompt(slice) }));
5176
+ recovery.push({ id: "ux-registry", toolNames: uxRegistrySegment.toolNames, prompt: buildUxRegistryPrompt() });
5177
+ recovery.push(...buildWorkBatches([...allRequirementIds]).map((slice, i) => ({ id: `ux-local-capacity-${invocationCount}-${i}`, requirementSlice: slice, toolNames: uxSegment.toolNames, prompt: buildUxPrompt(slice) })));
5178
+ queue.splice(index, 1, ...recovery);
5179
+ continue;
5180
+ }
5181
+ if (session.coverageOnly || isUxLocalSession) {
5182
+ const missingIds = session.coverageOnly ? missingCoverageIds : [...new Set(missingPhase.flatMap(f => f.requirementIds))];
5183
+ if (!missingIds.length) {
5184
+ index += 1;
5185
+ continue;
5186
+ }
5187
+ const scopes = splitScope(missingIds, Boolean(session.coverageOnly));
5188
+ if (scopes.length) {
5189
+ queue.splice(index, 1, ...scopes.map(slice => ({ ...session, ...(session.coverageOnly ? { coverageSlice: slice } : { requirementSlice: slice }), missingFacts: missingPhaseFacts.filter(f => f.requirementIds.some(id => slice.includes(id))), retryCount: 0 })));
5190
+ continue;
5191
+ }
5192
+ const originalIds = session.coverageSlice ?? session.requirementSlice ?? [];
5193
+ if ((missingIds.length < originalIds.length || committedAfter > committedBefore) && (session.retryCount ?? 0) < 2) {
5194
+ queue[index] = { ...session, ...(session.coverageOnly ? { coverageSlice: missingIds } : { requirementSlice: missingIds }), missingFacts: missingPhaseFacts, retryCount: (session.retryCount ?? 0) + 1 };
5195
+ continue;
5196
+ }
5197
+ }
5198
+ return failure();
5199
+ }
4609
5200
  if (result.ok) {
4610
5201
  if (missingPhaseFacts.length === 0) {
4611
5202
  index += 1;
@@ -4649,24 +5240,6 @@ export async function runFrontendPlanSegmentedSessions(input) {
4649
5240
  index += 1;
4650
5241
  continue;
4651
5242
  }
4652
- // A length-stopped, fact-less compact session is the planner variant of
4653
- // writer-thinking-exhausted. Give the same scope exactly one tool-first
4654
- // retry before the split below re-batches the requirements, because one
4655
- // reinforced full-scope pass is cheaper than re-planning split halves.
4656
- const plannerThinkingBurn = isCompactLocalSession &&
4657
- committedAfter === committedBefore &&
4658
- !(result.assistantText ?? "").trim() &&
4659
- !result.stderr.trim() &&
4660
- !result.timedOut &&
4661
- readWriterThinkingExhaustionEvidence(result).stopReason === "length";
4662
- if (plannerThinkingBurn && (session.retryCount ?? 0) < 1) {
4663
- queue[index] = {
4664
- ...session,
4665
- retryCount: (session.retryCount ?? 0) + 1,
4666
- prompt: buildCompactLocalPrompt(),
4667
- };
4668
- continue;
4669
- }
4670
5243
  // Option 5: a multi-requirement coverage batch that failed with ZERO
4671
5244
  // new facts and no provider stderr is the upfront-reasoning burn —
4672
5245
  // halve the slice and retry instead of failing the attempt. The compact
@@ -4681,20 +5254,24 @@ export async function runFrontendPlanSegmentedSessions(input) {
4681
5254
  : undefined;
4682
5255
  const zeroProgressBurn = coverageSlice !== undefined &&
4683
5256
  coverageSlice.length > 1 &&
4684
- committedAfter === committedBefore &&
4685
- !(result.assistantText ?? "").trim() &&
4686
- !result.stderr.trim() &&
5257
+ (capacityExhausted || (["empty-output", "unknown"].includes(result.failureCategory) && committedAfter === committedBefore && !(result.assistantText ?? "").trim() && !result.stderr.trim())) &&
4687
5258
  !result.timedOut;
4688
5259
  if (zeroProgressBurn && coverageSlice) {
4689
- const half = Math.ceil(coverageSlice.length / 2);
4690
- const firstSlice = coverageSlice.slice(0, half);
4691
- const secondSlice = coverageSlice.slice(half);
5260
+ const missingIds = input.committedFacts ? new Set(collectFrontendPlanMissingFacts({ requirementIds: coverageSlice, committedFacts: input.committedFacts() }).flatMap(f => f.requirementIds)) : undefined;
5261
+ const unfinished = coverageSlice.filter(id => !missingIds || missingIds.has(id));
5262
+ if (unfinished.length <= 1)
5263
+ return mapPlannerExhaustion({ ...result, ok: false, stderr: `${result.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: ${unfinished[0] ?? session.id}; no smaller complete scope can finish` }, committedAfter > committedBefore);
5264
+ const half = Math.ceil(unfinished.length / 2);
5265
+ const firstSlice = unfinished.slice(0, half);
5266
+ const secondSlice = unfinished.slice(half);
4692
5267
  if (isCompactLocalSession) {
5268
+ queue.splice(index + 1, 0, { id: "ux-registry", toolNames: uxRegistrySegment.toolNames, prompt: buildUxRegistryPrompt() }, ...buildWorkBatches([...firstSlice, ...secondSlice]).map((slice, i) => ({ id: `ux-local-recovery-${i}`, toolNames: uxSegment.toolNames, requirementSlice: slice, prompt: buildUxPrompt(slice) })));
4693
5269
  // The split halves leave compact mode: continue them as ordinary
4694
5270
  // coverage sessions so every downstream ladder branch applies.
4695
5271
  queue.splice(index, 1, {
4696
5272
  ...session,
4697
5273
  id: `coverage-compact-split-1`,
5274
+ toolNames: coverageSegment.toolNames, retryCount: 0,
4698
5275
  coverageOnly: true,
4699
5276
  coverageSlice: firstSlice,
4700
5277
  requirementSlice: firstSlice,
@@ -4702,6 +5279,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4702
5279
  }, {
4703
5280
  ...session,
4704
5281
  id: `coverage-compact-split-2`,
5282
+ toolNames: coverageSegment.toolNames, retryCount: 0,
4705
5283
  coverageOnly: true,
4706
5284
  coverageSlice: secondSlice,
4707
5285
  requirementSlice: secondSlice,
@@ -4720,7 +5298,9 @@ export async function runFrontendPlanSegmentedSessions(input) {
4720
5298
  });
4721
5299
  continue;
4722
5300
  }
4723
- if ((session.coverageOnly || isUxRegistrySession || isUxLocalSession || session.id === "global-mock-data") &&
5301
+ if (readWriterThinkingExhaustionEvidence(result).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(result.failureCategory))
5302
+ return mapPlannerExhaustion({ ...result, ok: false, stderr: `${result.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: ${session.id}; refine the remaining complete scope; unchanged retries are disabled` }, committedAfter > committedBefore);
5303
+ if ((session.coverageOnly || isUxRegistrySession || isUxLocalSession || session.id.startsWith("global-mock-data")) &&
4724
5304
  missingPhaseFacts.length > 0 &&
4725
5305
  !result.stderr.trim() &&
4726
5306
  !result.timedOut &&
@@ -4852,14 +5432,27 @@ async function runFrontendScoutParallelSessions(input) {
4852
5432
  ...input.sessionOptions,
4853
5433
  sessionEventsPath: path.join(input.runDir, shardNodeId, "session-events.jsonl"),
4854
5434
  };
4855
- shardResult = await input.piStepFn({
5435
+ const prompt = `${input.basePrompt}\n\n${shard.instruction}`;
5436
+ const tools = [...customTools, ...(input.readBudgetTools ?? [])];
5437
+ shardResult = await observeFrontendSession({
5438
+ ...input.observation,
5439
+ phase: `scout/parallel/${shard.id}`,
5440
+ scopeIds: [shard.id],
5441
+ prompt,
5442
+ userMessage: sessionOptions.userMessage,
5443
+ customTools: tools,
5444
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${shard.id}.json` : undefined,
5445
+ committedCount: () => shardTools?.committedFacts().length ?? 0,
5446
+ durableCommittedCount: () => shardTools?.committedFacts().length ?? 0,
5447
+ }, observer => input.piStepFn({
4856
5448
  ...sessionOptions,
4857
- prompt: `${input.basePrompt}\n\n${shard.instruction}`,
5449
+ onAttemptObservation: observer,
5450
+ prompt,
4858
5451
  writerToolPolicy: {
4859
5452
  requireSdk: true,
4860
- customTools: [...customTools, ...(input.readBudgetTools ?? [])],
5453
+ customTools: tools,
4861
5454
  },
4862
- });
5455
+ }));
4863
5456
  await shardTools.flush();
4864
5457
  return { shard, result: shardResult, tools: shardTools };
4865
5458
  }
@@ -5035,6 +5628,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5035
5628
  let writerToolPolicy;
5036
5629
  let reviewTerminalTools;
5037
5630
  let designTerminalTools;
5631
+ let reviewInventory;
5632
+ let designInventory;
5038
5633
  let planLedgerTools;
5039
5634
  let contractTools;
5040
5635
  let scoutEvidenceTools;
@@ -5128,6 +5723,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5128
5723
  const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5129
5724
  const store = createTypedEventStore();
5130
5725
  reviewTerminalTools = await createFrontendReviewTerminalTools({
5726
+ inventory: reviewInventory = await loadFrontendReviewScopes(meta.runDir, "review", meta.spec?.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES),
5727
+ inputDigest: reviewInventory?.digest ?? createHash("sha256").update(input.prompt).digest("hex"),
5131
5728
  attemptId: `${meta.runId}:${input.task.id}`,
5132
5729
  store,
5133
5730
  runDir: meta.runDir,
@@ -5153,6 +5750,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5153
5750
  const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5154
5751
  const store = createTypedEventStore();
5155
5752
  designTerminalTools = await createFrontendDesignTerminalTools({
5753
+ inventory: designInventory = await loadFrontendReviewScopes(meta.runDir, "design", meta.spec?.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES),
5754
+ inputDigest: designInventory?.digest ?? createHash("sha256").update(input.prompt).digest("hex"),
5156
5755
  attemptId: `${meta.runId}:${input.task.id}`,
5157
5756
  store,
5158
5757
  runDir: meta.runDir,
@@ -5178,6 +5777,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5178
5777
  const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5179
5778
  const store = createTypedEventStore();
5180
5779
  contractTools = await createFrontendContractTools({
5780
+ sourceDigest: (meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined),
5181
5781
  attemptId: `${meta.runId}:${input.task.id}`,
5182
5782
  store,
5183
5783
  runDir: meta.runDir,
@@ -5211,6 +5811,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5211
5811
  spec: meta.spec,
5212
5812
  });
5213
5813
  scoutEvidenceTools = await createFrontendScoutEvidenceTools({
5814
+ requirementIds: parseFrontendInputBlock(input.prompt, "scout")?.payload.requirements.map(r => r.id),
5815
+ sourceDigest: (meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined),
5214
5816
  attemptId: `${meta.runId}:${input.task.id}`,
5215
5817
  store,
5216
5818
  runDir: meta.runDir,
@@ -5368,8 +5970,11 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5368
5970
  activeTools: toolNames,
5369
5971
  });
5370
5972
  const piSessionOptions = {
5973
+ reserveProviderRequest: input.reserveProviderRequest,
5974
+ frontendExecutionPolicy: meta.spec?.frontendExecutionPolicy,
5371
5975
  attachedFiles: [],
5372
5976
  modelConfig,
5977
+ outputLimitRecovery: { terminalToolNames: ["finalize_plan", "finalize_contract", "approve_design", "request_design_changes", "approve_review", "request_review_changes"] },
5373
5978
  repoRoot: input.cwd,
5374
5979
  step,
5375
5980
  toolNames,
@@ -5409,6 +6014,15 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5409
6014
  workspaceRoot: input.cwd,
5410
6015
  sourceDeclaredPaths: scoutSourceDeclaredPaths,
5411
6016
  readBudgetTools: readBudgetTools?.customTools,
6017
+ observation: {
6018
+ artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`),
6019
+ runId: meta.runId,
6020
+ nodeId: input.task.id,
6021
+ taskId: meta.spec?.sourceBinding?.taskId,
6022
+ attempt: input.attempt ?? 1,
6023
+ model: input.model,
6024
+ sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined,
6025
+ },
5412
6026
  });
5413
6027
  }
5414
6028
  else if (isFrontendPlanLedgerNode(input.task) && planLedgerTools) {
@@ -5448,6 +6062,16 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5448
6062
  // finalize reducer. This avoids revision races while shortening the
5449
6063
  // longest coverage phase for large plans.
5450
6064
  const runPlanSessions = (options) => runFrontendPlanSegmentedSessions({
6065
+ observation: options.observation ?? {
6066
+ artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`),
6067
+ runId: meta.runId,
6068
+ nodeId: input.task.id,
6069
+ taskId: meta.spec?.sourceBinding?.taskId,
6070
+ attempt: input.attempt ?? 1,
6071
+ model: input.model,
6072
+ sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined,
6073
+ contractDigest: meta.spec?.taskContractBinding?.canonicalHash,
6074
+ },
5451
6075
  piStepFn,
5452
6076
  sessionOptions: options.sessionOptions,
5453
6077
  basePrompt: options.basePrompt ?? input.prompt,
@@ -5469,154 +6093,162 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5469
6093
  ? options.ledgerTools.customTools
5470
6094
  : options.ledgerTools.customTools.filter((tool) => typeof tool === "object" &&
5471
6095
  tool !== null &&
5472
- toolNames.has(tool.name)),
6096
+ (toolNames.has(tool.name) || tool.name === "read_plan_facts")),
5473
6097
  flushLedger: () => options.ledgerTools.flush(),
5474
6098
  });
5475
6099
  if (planRequirementIds.length > 1) {
5476
- const estimatedPlanCalls = planRequirementIds.reduce((total, id) => total + Math.max(1, planRequirementCosts.get(id) ?? 2), 0);
5477
- const compactEligibleBeforeSharding = planRequirementIds.length <= FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS &&
5478
- estimatedPlanCalls > 12 &&
5479
- estimatedPlanCalls <= FRONTEND_PLAN_SMALL_MAX_ESTIMATED_CALLS &&
5480
- countFrontendPlanTargetSurfaces(input.prompt) === 1;
5481
- if (compactEligibleBeforeSharding) {
5482
- // Decide the small-plan topology before creating coverage shards.
5483
- // Sharding first would make the compact two-session path unreachable.
5484
- result = await runPlanSessions({
5485
- ledgerTools: planLedgerTools,
5486
- sessionOptions: piSessionOptions,
5487
- compactSmallPlan: true,
6100
+ const workload = buildFrontendPlanWorkload({
6101
+ basePrompt: input.prompt, requirementIds: planRequirementIds,
6102
+ requirementCosts: planRequirementCosts, sessionOptions: piSessionOptions,
6103
+ });
6104
+ let coverageBatches;
6105
+ try {
6106
+ coverageBatches = await loadOrCreateFrontendPlanCoverageLayout({
6107
+ runDir: meta.runDir, nodeId: input.task.id, workload, requirementIds: planRequirementIds,
6108
+ binding: {
6109
+ runId: meta.runId, nodeId: input.task.id, sourceBinding: meta.spec.sourceBinding,
6110
+ skeleton: input.task.structuredContractOutput?.skeleton, writeSet: input.task.writeSet,
6111
+ policy: meta.spec.frontendExecutionPolicy, requirements: [...planRequirementCosts],
6112
+ compiledInput: workload.frozenInput, workGroups: workload.workGroups,
6113
+ },
5488
6114
  });
5489
6115
  }
5490
- else {
5491
- const coverageBatches = batchFrontendPlanRequirements({
5492
- requirementIds: planRequirementIds,
5493
- maxEstimatedRecordCalls: FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS,
5494
- maxRequirements: 4,
5495
- requirementCosts: planRequirementCosts,
5496
- });
5497
- if (coverageBatches.length > 1) {
5498
- const shardResults = await mapWithConcurrency(coverageBatches, FRONTEND_PLAN_COVERAGE_MAX_CONCURRENCY, async (slice, index) => {
5499
- let shardTools;
5500
- let shardResult;
5501
- try {
5502
- const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5503
- shardTools = await createFrontendPlanLedgerTools({
5504
- attemptId: `${meta.runId}:${input.task.id}:parallel:${index + 1}`,
5505
- store: createTypedEventStore(),
6116
+ catch (error) {
6117
+ return { ok: false, stdout: "", durationMs: Date.now() - started, failureCategory: "tool-policy",
6118
+ stderr: `frontend plan coverage layout unavailable: ${error instanceof Error ? error.message : String(error)}` };
6119
+ }
6120
+ if (coverageBatches.length > 1) {
6121
+ const shardResults = await mapWithConcurrency(coverageBatches, piSessionOptions.frontendExecutionPolicy?.maxCoverageConcurrency ?? FRONTEND_PLAN_COVERAGE_MAX_CONCURRENCY, async (slice, index) => {
6122
+ let shardTools;
6123
+ let shardResult;
6124
+ try {
6125
+ const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
6126
+ shardTools = await createFrontendPlanLedgerTools({
6127
+ attemptId: `${meta.runId}:${input.task.id}:parallel:${index + 1}`,
6128
+ store: createTypedEventStore(),
6129
+ runDir: meta.runDir,
6130
+ nodeId: `${input.task.id}/parallel/coverage-${index + 1}`,
6131
+ skeleton: input.task.structuredContractOutput?.skeleton,
6132
+ sourceBinding: meta.spec.sourceBinding,
6133
+ requirementIds: slice,
6134
+ writeSetPatterns: input.task.writeSet,
6135
+ canonicalVerificationTargetIds: await resolveFrontendCanonicalVerificationTargetIds({
5506
6136
  runDir: meta.runDir,
5507
- nodeId: `${input.task.id}/parallel/coverage-${index + 1}`,
5508
- skeleton: input.task.structuredContractOutput?.skeleton,
6137
+ }),
6138
+ componentNewSourceReferences: await resolveFrontendPlanNewComponentSourceReferences({
6139
+ cwd: input.cwd,
5509
6140
  sourceBinding: meta.spec.sourceBinding,
5510
- requirementIds: slice,
5511
- writeSetPatterns: input.task.writeSet,
5512
- canonicalVerificationTargetIds: await resolveFrontendCanonicalVerificationTargetIds({
5513
- runDir: meta.runDir,
5514
- }),
5515
- componentNewSourceReferences: await resolveFrontendPlanNewComponentSourceReferences({
5516
- cwd: input.cwd,
5517
- sourceBinding: meta.spec.sourceBinding,
5518
- }),
5519
- });
5520
- const shardSessionOptions = {
5521
- ...piSessionOptions,
5522
- sessionEventsPath: path.join(meta.runDir, input.task.id, "parallel", `coverage-${index + 1}`, "session-events.jsonl"),
5523
- };
5524
- shardResult = await runPlanSessions({
5525
- ledgerTools: shardTools,
5526
- sessionOptions: shardSessionOptions,
5527
- requirementIds: slice,
5528
- basePrompt: `${input.prompt}\n\nPARALLEL COVERAGE SHARD ${index + 1}: use the frozen canonical behavior verification-target ids listed in the record_plan_verification_target tool description — do not prefix ids with a shard namespace or invent variant ids; identical cross-shard targets are deduped, divergent ones fail the merge.`,
5529
- parallelCoverageOnly: true,
5530
- });
5531
- await shardTools.flush();
5532
- return { index, result: shardResult, tools: shardTools };
5533
- }
5534
- catch (error) {
5535
- const crashMessage = `frontend plan coverage shard ${index + 1} crashed: ${error instanceof Error ? error.message : String(error)}`;
5536
- return {
5537
- index,
5538
- tools: shardTools,
5539
- result: shardResult
5540
- ? {
5541
- ...shardResult,
5542
- ok: false,
5543
- failureCategory: shardResult.ok
5544
- ? "invalid-output"
5545
- : shardResult.failureCategory,
5546
- stderr: [shardResult.stderr, crashMessage]
5547
- .filter(Boolean)
5548
- .join("\n"),
5549
- }
5550
- : {
5551
- ok: false,
5552
- assistantText: "",
5553
- command: [],
5554
- durationMs: 0,
5555
- exitCode: null,
5556
- failureCategory: "tool-policy",
5557
- modelDisplay: "unknown",
5558
- parsedEvents: 0,
5559
- stderr: crashMessage,
5560
- stdout: "",
5561
- timedOut: false,
5562
- attemptedModels: [],
5563
- fallbackUsed: false,
5564
- tokensUsed: 0,
5565
- },
5566
- };
5567
- }
5568
- });
5569
- const coverageResult = aggregateParallelPiResults(shardResults.map((shard) => shard.result));
5570
- let mergeFailure;
5571
- try {
5572
- // Flatten every shard's facts FIRST, then pre-reduce: shards
5573
- // partition requirements but a frozen verification target can
5574
- // span shards, so same-id targets must merge across shards
5575
- // before adoption, not per shard.
5576
- await planLedgerTools.adoptCommittedFacts(reduceParallelCoverageShardRecords(shardResults
5577
- .filter((item) => item.result.ok && item.tools)
5578
- .flatMap((shard) => normalizeParallelCoverageShardRecords(shard.tools.committedFacts(), shard.index + 1))));
5579
- }
5580
- catch (error) {
5581
- mergeFailure = error;
5582
- }
5583
- const failedShard = shardResults.find((shard) => !shard.result.ok);
5584
- if (mergeFailure) {
5585
- result = {
5586
- ...coverageResult,
5587
- ok: false,
5588
- failureCategory: "invalid-output",
5589
- stderr: [
5590
- coverageResult.stderr,
5591
- `frontend plan coverage shard merge failed: ${mergeFailure instanceof Error ? mergeFailure.message : String(mergeFailure)}`,
5592
- ]
5593
- .filter(Boolean)
5594
- .join("\n"),
6141
+ }),
6142
+ });
6143
+ const shardSessionOptions = {
6144
+ ...piSessionOptions,
6145
+ sessionEventsPath: path.join(meta.runDir, input.task.id, "parallel", `coverage-${index + 1}`, "session-events.jsonl"),
5595
6146
  };
6147
+ shardResult = await runPlanSessions({
6148
+ ledgerTools: shardTools,
6149
+ sessionOptions: shardSessionOptions,
6150
+ requirementIds: slice,
6151
+ basePrompt: `${input.prompt}\n\nPARALLEL COVERAGE SHARD ${index + 1}: use the frozen canonical behavior verification-target ids listed in the record_plan_verification_target tool description — do not prefix ids with a shard namespace or invent variant ids; identical cross-shard targets are deduped, divergent ones fail the merge.`,
6152
+ parallelCoverageOnly: true,
6153
+ observation: {
6154
+ artifactPath: path.join(meta.runDir, input.task.id, "parallel", `coverage-${index + 1}`, "session-budget", `attempt-${input.attempt ?? 1}`),
6155
+ runId: meta.runId,
6156
+ nodeId: `${input.task.id}/parallel/coverage-${index + 1}`,
6157
+ taskId: meta.spec?.sourceBinding?.taskId,
6158
+ attempt: input.attempt ?? 1,
6159
+ model: input.model,
6160
+ sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined,
6161
+ contractDigest: meta.spec?.taskContractBinding?.canonicalHash,
6162
+ },
6163
+ });
6164
+ await shardTools.flush();
6165
+ return { index, result: shardResult, tools: shardTools };
5596
6166
  }
5597
- else if (failedShard) {
5598
- result = {
5599
- ...coverageResult,
5600
- ok: false,
5601
- stderr: `${coverageResult.stderr}\nfrontend plan coverage shard ${failedShard.index + 1} failed before reduce`.trim(),
6167
+ catch (error) {
6168
+ const crashMessage = `frontend plan coverage shard ${index + 1} crashed: ${error instanceof Error ? error.message : String(error)}`;
6169
+ return {
6170
+ index,
6171
+ tools: shardTools,
6172
+ result: shardResult
6173
+ ? {
6174
+ ...shardResult,
6175
+ ok: false,
6176
+ failureCategory: shardResult.ok
6177
+ ? "invalid-output"
6178
+ : shardResult.failureCategory,
6179
+ stderr: [shardResult.stderr, crashMessage]
6180
+ .filter(Boolean)
6181
+ .join("\n"),
6182
+ }
6183
+ : {
6184
+ ok: false,
6185
+ assistantText: "",
6186
+ command: [],
6187
+ durationMs: 0,
6188
+ exitCode: null,
6189
+ failureCategory: "tool-policy",
6190
+ modelDisplay: "unknown",
6191
+ parsedEvents: 0,
6192
+ stderr: crashMessage,
6193
+ stdout: "",
6194
+ timedOut: false,
6195
+ attemptedModels: [],
6196
+ fallbackUsed: false,
6197
+ tokensUsed: 0,
6198
+ },
5602
6199
  };
5603
6200
  }
5604
- else {
5605
- const reducerResult = await runPlanSessions({
5606
- ledgerTools: planLedgerTools,
5607
- sessionOptions: piSessionOptions,
5608
- });
5609
- result = combineSequentialPiResults(coverageResult, reducerResult);
5610
- }
6201
+ }, shard => shard.result.failureCategory === "rate-limit");
6202
+ const coverageResult = aggregateParallelPiResults(shardResults.map((shard) => shard.result));
6203
+ let mergeFailure;
6204
+ try {
6205
+ // Flatten every shard's facts FIRST, then pre-reduce: shards
6206
+ // partition requirements but a frozen verification target can
6207
+ // span shards, so same-id targets must merge across shards
6208
+ // before adoption, not per shard.
6209
+ await planLedgerTools.adoptCommittedFacts(reduceParallelCoverageShardRecords(shardResults
6210
+ .filter((item) => item.result.ok && item.tools)
6211
+ .flatMap((shard) => normalizeParallelCoverageShardRecords(shard.tools.committedFacts(), shard.index + 1))));
6212
+ }
6213
+ catch (error) {
6214
+ mergeFailure = error;
6215
+ }
6216
+ const failedShard = shardResults.find((shard) => !shard.result.ok);
6217
+ if (mergeFailure) {
6218
+ result = {
6219
+ ...coverageResult,
6220
+ ok: false,
6221
+ failureCategory: "invalid-output",
6222
+ stderr: [
6223
+ coverageResult.stderr,
6224
+ `frontend plan coverage shard merge failed: ${mergeFailure instanceof Error ? mergeFailure.message : String(mergeFailure)}`,
6225
+ ]
6226
+ .filter(Boolean)
6227
+ .join("\n"),
6228
+ };
6229
+ }
6230
+ else if (failedShard) {
6231
+ result = {
6232
+ ...coverageResult,
6233
+ ok: false,
6234
+ stderr: `${coverageResult.stderr}\nfrontend plan coverage shard ${failedShard.index + 1} failed before reduce`.trim(),
6235
+ };
5611
6236
  }
5612
6237
  else {
5613
- result = await runPlanSessions({
6238
+ const reducerResult = await runPlanSessions({
5614
6239
  ledgerTools: planLedgerTools,
5615
6240
  sessionOptions: piSessionOptions,
5616
- compactSmallPlan: true,
5617
6241
  });
6242
+ result = combineSequentialPiResults(coverageResult, reducerResult);
5618
6243
  }
5619
6244
  }
6245
+ else {
6246
+ result = await runPlanSessions({
6247
+ ledgerTools: planLedgerTools,
6248
+ sessionOptions: piSessionOptions,
6249
+ compactSmallPlan: true,
6250
+ });
6251
+ }
5620
6252
  }
5621
6253
  else {
5622
6254
  result = await runPlanSessions({
@@ -5626,12 +6258,31 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5626
6258
  });
5627
6259
  }
5628
6260
  }
6261
+ else if (contractTools) {
6262
+ result = await runFrontendContractSegmentedSessions({
6263
+ piStepFn, sessionOptions: piSessionOptions, basePrompt: input.prompt, tools: contractTools,
6264
+ observation: { artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`), runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId, attempt: input.attempt ?? 1, model: input.model, sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined },
6265
+ });
6266
+ }
6267
+ else if ((reviewInventory && reviewTerminalTools) || (designInventory && designTerminalTools)) {
6268
+ result = await runFrontendReviewSegmentedSessions({ piStepFn, sessionOptions: piSessionOptions, basePrompt: input.prompt, inventory: (reviewInventory ?? designInventory), tools: (reviewTerminalTools ?? designTerminalTools), customTools: writerToolPolicy.customTools, phase: reviewInventory ? "review" : "design",
6269
+ observation: { artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`), runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId, attempt: input.attempt ?? 1, model: input.model, contractDigest: (reviewInventory ?? designInventory).digest } });
6270
+ }
6271
+ else if (scoutEvidenceTools && parseFrontendInputBlock(input.prompt, "scout")) {
6272
+ result = await runFrontendScoutSegmentedSessions({ piStepFn, sessionOptions: piSessionOptions, basePrompt: input.prompt, tools: scoutEvidenceTools, customTools: writerToolPolicy.customTools,
6273
+ observation: { artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`), runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId, attempt: input.attempt ?? 1, model: input.model, sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined } });
6274
+ }
5629
6275
  else {
5630
- result = await piStepFn({
6276
+ result = await observeFrontendSession({
6277
+ artifactPath: input.task.id.startsWith("frontend-") ? path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}.json`) : undefined,
6278
+ phase: input.task.id, prompt: input.prompt, userMessage: piSessionOptions.userMessage, customTools: writerToolPolicy?.customTools,
6279
+ runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId, attempt: input.attempt ?? 1, model: input.model, sourceDigest: (meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined), contractDigest: meta.spec?.taskContractBinding?.canonicalHash,
6280
+ }, observer => piStepFn({
5631
6281
  ...piSessionOptions,
6282
+ onAttemptObservation: observer,
5632
6283
  prompt: input.prompt,
5633
6284
  ...(writerToolPolicy ? { writerToolPolicy } : {}),
5634
- });
6285
+ }));
5635
6286
  }
5636
6287
  try {
5637
6288
  const verificationCommandFiles = collectFrontendVerificationCommandFiles(input.prompt);
@@ -5796,8 +6447,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5796
6447
  try {
5797
6448
  await scoutEvidenceTools?.flush?.();
5798
6449
  }
5799
- catch {
5800
- // best-effort flush
6450
+ catch (error) {
6451
+ 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)}` };
5801
6452
  }
5802
6453
  if (mapped.ok) {
5803
6454
  const { checkCommittedOriginFacts, readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");