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

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 (199) hide show
  1. package/CHANGELOG.md +129 -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 +1214 -584
  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 +5 -2
  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 +143 -0
  21. package/dist/worker/console/chat/browser-routes.js +148 -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 +78 -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 +213 -1181
  33. package/dist/worker/console/chat/routes.js +212 -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 +48 -0
  40. package/dist/worker/console/chat/session-mode.js +218 -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-D_fNbWc_.js} +1 -1
  50. package/dist/worker/console/static/assets/{arc-DCPjC19G.js → arc-_HuqLW6m.js} +1 -1
  51. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP5n9jmG.js → architectureDiagram-T3A2C74G-Djy2qY1m.js} +1 -1
  52. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CmaWi_Wg.js → blockDiagram-VBNYF7ZC-bB-1TSyY.js} +1 -1
  53. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-Ct5tcMfZ.js → c4Diagram-5PPSVZJV-C5Hmghrz.js} +1 -1
  54. package/dist/worker/console/static/assets/channel-C0A6UEIa.js +1 -0
  55. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-C0osm1Zf.js → chunk-2GRJ4B5K-Duyl39oy.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CDWviED7.js → chunk-2Q5K7J3B-x6mdnSyf.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-mtSk1-j7.js → chunk-5RXB4S5H-WlBuFj7W.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D5J9PHC7.js → chunk-5VM5RSS4-CTF429HX.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Begg4WAa.js → chunk-6Q2QTUOP-Dwd6r-SF.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-sF1AEy7T.js → chunk-GF5L2VYU-C-Br82D1.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BqbsWXZb.js → chunk-JWPE2WC7-CuGp0DeE.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BKJrPcJ6.js → chunk-KBJHAD2P-CJhFX6Lh.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CcwNMRho.js → chunk-RYQCIY6F-DpGOa_jT.js} +1 -1
  64. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BEJi3iIs.js → chunk-XXDRQBXY-DXJaTynd.js} +1 -1
  65. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DzguNppy.js +1 -0
  66. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DzguNppy.js +1 -0
  67. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-DRLgTvVu.js → cose-bilkent-JH36ORCC-CRBZWjE4.js} +1 -1
  68. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BoAVG3cs.js → cynefin-VYW2F7L2-BdXcxtlb.js} +1 -1
  69. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bzol06hR.js → cynefinDiagram-MW4NZA55-07UKsOts.js} +1 -1
  70. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UmliJM77.js → dagre-VZM6K2ZE-D38p9__S.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BWLcJkFo.js → diagram-7IWD3JNH-nSiBNNjH.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CgApOm-O.js → diagram-B4RE2ZJO-BFOTot5D.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Bh3RgTLs.js → diagram-LBJQPF4R-DypKaZ6k.js} +1 -1
  74. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-Dsh-D5nE.js → diagram-Q27KOJAE-B6yoOQeW.js} +1 -1
  75. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Dkbbpcpb.js → diagram-UB23O5K3-BHYS6viX.js} +1 -1
  76. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-3EBaA3t3.js → ebnfDiagram-BXEA7PRR-D2VD5Snj.js} +1 -1
  77. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bq2Dc5ok.js → erDiagram-JOGREHBK-E98mWxN1.js} +1 -1
  78. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-De7y55ha.js → flowDiagram-UKHOOZJN-CT1zxhqP.js} +1 -1
  79. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-fLiDbQRh.js → ganttDiagram-PKOTCBZU-6GcngNcy.js} +1 -1
  80. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Du617mp1.js → gitGraphDiagram-DS77QQ5N-DCL6NvKe.js} +1 -1
  81. package/dist/worker/console/static/assets/index-24jp3aBh.js +468 -0
  82. package/dist/worker/console/static/assets/index-CY6y6I8M.css +1 -0
  83. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BwmlF-XP.js → infoDiagram-6WML65LV-C0627e-9.js} +1 -1
  84. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-zsGtS59u.js → ishikawaDiagram-WSZJBQD7-C_u3P6Ik.js} +1 -1
  85. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DTTPTe3f.js → journeyDiagram-NVQOT4AX-DPDPv9HL.js} +1 -1
  86. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BkXg9aP-.js → kanban-definition-27J2QSJJ-D3OHdWiO.js} +1 -1
  87. package/dist/worker/console/static/assets/{linear-7U2ue5IE.js → linear-DI88zD7n.js} +1 -1
  88. package/dist/worker/console/static/assets/{mermaid.core-BUuGHmWO.js → mermaid.core-p2l8IrWY.js} +5 -5
  89. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-7q6fX0Sv.js → mindmap-definition-FAOFIHXS-B1bAX-oS.js} +1 -1
  90. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkQPJN55.js → pegDiagram-VL7TDLO6-DwOnWxdD.js} +1 -1
  91. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CCal_tfX.js → pieDiagram-7S7Q4E2Y-xRkTXnJC.js} +1 -1
  92. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B7bpxyAZ.js → quadrantDiagram-CIZ2JOQS-_9UC5Zio.js} +1 -1
  93. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CJmww7D-.js → railroadDiagram-AXF67PYL-CgEUirWJ.js} +1 -1
  94. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D6ldWJEv.js → requirementDiagram-LRYGKXZP-Czbpwiyz.js} +1 -1
  95. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-VGh33I9e.js → sankeyDiagram-W5VNT64P-_j7_KiqY.js} +1 -1
  96. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CCkDrIjU.js → sequenceDiagram-SI44F4Z6-BNJa6Hyi.js} +1 -1
  97. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-NC8f5Otb.js → sizeCapture-X5ZJPWSS-DedpKtOc.js} +1 -1
  98. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-_92ezZdF.js → stateDiagram-OKZ733FA-BBvthr7c.js} +1 -1
  99. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BABVQnfM.js +1 -0
  100. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BdUCGtbP.js → swimlanes-SLNWSIFB-Ds7umgEM.js} +2 -2
  101. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D0qsLaxR.js +8 -0
  102. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CdD1H8ia.js → timeline-definition-Z64GVDOM-CKa-p1nF.js} +1 -1
  103. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-Dkdk3oFo.js → vennDiagram-T6HMQDX7-LoUNR56o.js} +1 -1
  104. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CKu2uPPY.js → wardleyDiagram-T6FBY63Y-Bqbl6ym7.js} +1 -1
  105. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-ClqAnuG8.js → xychartDiagram-ELKLHX3M-B8dUg4lf.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-design-policy.js +2 -1
  158. package/dist/workflows/dag/frontend-durable-tools.js +193 -0
  159. package/dist/workflows/dag/frontend-execution-groups.js +24 -0
  160. package/dist/workflows/dag/frontend-implementation-contract.js +13 -0
  161. package/dist/workflows/dag/frontend-input-projection.js +76 -0
  162. package/dist/workflows/dag/frontend-plan-completeness.js +186 -0
  163. package/dist/workflows/dag/frontend-plan-recovery-policy.js +18 -0
  164. package/dist/workflows/dag/frontend-plan-render.js +10 -3
  165. package/dist/workflows/dag/frontend-recovery-controller.js +7 -7
  166. package/dist/workflows/dag/frontend-recovery-lineage.js +13 -0
  167. package/dist/workflows/dag/frontend-recovery-run.js +4 -0
  168. package/dist/workflows/dag/frontend-review-scopes.js +139 -0
  169. package/dist/workflows/dag/frontend-session-budget.js +249 -0
  170. package/dist/workflows/dag/frontend-shadow-dual-write.js +20 -2
  171. package/dist/workflows/dag/frontend-shape-facts.js +12 -2
  172. package/dist/workflows/dag/frontend-shape.js +30 -1
  173. package/dist/workflows/dag/frontend-test-execution-evidence.js +3 -2
  174. package/dist/workflows/dag/frontend-typed-event-store.js +11 -0
  175. package/dist/workflows/dag/init-hybrid.js +16 -10
  176. package/dist/workflows/dag/node-execution.js +38 -155
  177. package/dist/workflows/dag/prompt.js +4 -0
  178. package/dist/workflows/dag/rerun-plan.js +7 -1
  179. package/dist/workflows/dag/runner.js +26 -1
  180. package/dist/workflows/dag/types.js +6 -0
  181. package/docs/operations/README.md +1 -0
  182. package/docs/templates/frontend-design-contract.md +4 -4
  183. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  184. package/docs/templates/frontend-implementation-dag.json +5 -5
  185. package/package.json +6 -3
  186. package/skills/frontend-contract/SKILL.md +2 -1
  187. package/skills/frontend-contract/references/contract-protocol.md +19 -3
  188. package/skills/frontend-design-review/SKILL.md +12 -11
  189. package/skills/frontend-plan/SKILL.md +9 -2
  190. package/skills/frontend-plan/references/decision-contract.md +18 -5
  191. package/skills/frontend-review/SKILL.md +10 -11
  192. package/skills/frontend-scout/references/scout-evidence.md +4 -0
  193. package/dist/worker/console/static/assets/channel-DAS07MdS.js +0 -1
  194. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cwk-5jiW.js +0 -1
  195. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cwk-5jiW.js +0 -1
  196. package/dist/worker/console/static/assets/index-C0O48S_P.js +0 -449
  197. package/dist/worker/console/static/assets/index-CzKf4U8P.css +0 -1
  198. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DyJg5gzW.js +0 -1
  199. 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,7 +2286,7 @@ 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.",
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 assembles them from the ledger together with these optional remaining fields, publishes the canonical editable patch on a target-surface fact, and commits the terminal. A successful terminal commit occurs exactly once. If validation fails, correct only the reported facts and retry finalize.",
2186
2290
  promptSnippet: "Commit the finalize_plan terminal (ledger fields + optional residualRisks / realIntegrationGap).",
2187
2291
  parameters: Type.Object({
2188
2292
  residualRisks: optionalStringArray,
@@ -2197,6 +2301,9 @@ export async function createFrontendPlanLedgerTools(input) {
2197
2301
  // when the plan did not re-declare them. The contract node is the
2198
2302
  // sole synthesis point; the plan inherits by requirement id.
2199
2303
  const contractInheritance = await loadContractRequirementInheritance(input.runDir);
2304
+ const missingData = collectFrontendPlanPhaseMissingFacts({ phase: "global-mock-data", requirementIds: input.requirementIds ?? [...contractInheritance.keys()], committedFacts: committed }).filter(f => f.kind === "data-flow");
2305
+ if (missingData.length)
2306
+ return planToolReceipt({ ok: false, kind: "finalize_plan", code: "PLAN_DATA_FLOW_INCOMPLETE", error: missingData.map(f => f.reason).join("; ") });
2200
2307
  const fragment = assemblePlanPatchFromCommittedFacts(committed, contractInheritance) ?? {};
2201
2308
  const patch = {
2202
2309
  ...fragment,
@@ -2352,23 +2459,99 @@ export async function createFrontendPlanLedgerTools(input) {
2352
2459
  }
2353
2460
  },
2354
2461
  });
2355
- return {
2356
- customTools: [
2462
+ const readPlanFactsTool = defineTool({
2463
+ name: "read_plan_facts",
2464
+ label: "read_plan_facts",
2465
+ 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.",
2466
+ parameters: Type.Object({
2467
+ kind: Type.String(), entryId: Type.Optional(Type.String()), eventId: Type.Optional(Type.String()),
2468
+ field: Type.Optional(Type.String()), offset: Type.Optional(Type.Integer({ minimum: 0 })),
2469
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
2470
+ stringOffset: Type.Optional(Type.Integer({ minimum: 0 })),
2471
+ expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })),
2472
+ }, { additionalProperties: false }),
2473
+ async execute(_id, params) {
2474
+ const reply = (details) => ({ content: [{ type: "text", text: JSON.stringify(details) }], details });
2475
+ if (params.expectedRevision !== undefined && params.expectedRevision !== store.revision) {
2476
+ return reply({ ok: false, revision: store.revision, error: "ledger revision changed; restart pagination" });
2477
+ }
2478
+ let records = readCommittedEvents(store, attemptId).filter(record => {
2479
+ const fact = record.fact;
2480
+ return fact.kind === params.kind && (!params.eventId || record.eventId === params.eventId) &&
2481
+ (!params.entryId || fact.entry?.id === params.entryId);
2482
+ });
2483
+ // Full-entry corrections and registries are last-wins. Other kinds
2484
+ // remain an ordered event log (state removals must remain visible).
2485
+ if (params.entryId || params.kind === "state-registry")
2486
+ records = records.slice(-1);
2487
+ let values = records.map(record => ({ eventId: record.eventId, ...record.fact }));
2488
+ if (params.field) {
2489
+ values = records.flatMap(record => {
2490
+ let value = record.fact;
2491
+ for (const key of params.field.split(".")) {
2492
+ if (!value || typeof value !== "object" || !Object.hasOwn(value, key))
2493
+ return [];
2494
+ value = value[key];
2495
+ }
2496
+ return Array.isArray(value) ? value : [value];
2497
+ });
2498
+ }
2499
+ const offset = params.offset ?? 0;
2500
+ if (params.stringOffset !== undefined) {
2501
+ const value = values[offset];
2502
+ if (typeof value !== "string")
2503
+ return reply({ ok: false, error: "stringOffset requires a string field item" });
2504
+ // Unicode code points prevent a page boundary splitting a surrogate pair.
2505
+ const characters = Array.from(value);
2506
+ const chunk = characters.slice(params.stringOffset, params.stringOffset + 1000).join("");
2507
+ const next = params.stringOffset + Array.from(chunk).length;
2508
+ return reply({ ok: true, revision: store.revision, offset, stringOffset: params.stringOffset,
2509
+ chunk, totalCharacters: characters.length, nextStringOffset: next < characters.length ? next : null });
2510
+ }
2511
+ const items = [];
2512
+ let bytes = 0;
2513
+ for (const value of values.slice(offset, offset + (params.limit ?? 10))) {
2514
+ const size = Buffer.byteLength(JSON.stringify(value));
2515
+ if (bytes + size > 12_000)
2516
+ break;
2517
+ bytes += size;
2518
+ items.push(value);
2519
+ }
2520
+ if (!items.length && offset < values.length)
2521
+ return reply({
2522
+ ok: false, revision: store.revision, total: values.length, offset,
2523
+ 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",
2524
+ fields: typeof values[offset] === "object" && values[offset] !== null ? Object.keys(values[offset]) : [],
2525
+ });
2526
+ return reply({ ok: true, revision: store.revision, total: values.length, offset, items,
2527
+ nextOffset: offset + items.length < values.length ? offset + items.length : null });
2528
+ },
2529
+ });
2530
+ const durable = await createDurableFrontendTools({
2531
+ file: path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"), attemptId, store: input.store,
2532
+ setWorkingStore: next => { store = next; },
2533
+ binding: { sourceBinding: input.sourceBinding, skeleton: input.skeleton, requirementIds: input.requirementIds, writeSet: input.writeSetPatterns, declaredUiStateIds: input.declaredUiStateIds, citations: input.componentNewSourceReferences, contractInheritance },
2534
+ tools: [
2357
2535
  recordRouteSelectionTool,
2358
2536
  recordComponentChoiceTool,
2359
2537
  recordStateRegistryTool,
2360
2538
  recordStateFlowTool,
2361
2539
  recordDataFlowTool,
2362
2540
  recordMockApiTool,
2541
+ recordMockEndpointTool,
2363
2542
  recordDesignDeviationTool,
2364
2543
  recordDependencyTool,
2365
2544
  recordPlanRequirementTool,
2545
+ recordPlanGroupCoverageTool,
2366
2546
  recordPlanVerificationTargetTool,
2367
2547
  recordPlanEvidenceGapTool,
2368
2548
  adoptStagedFactTool,
2369
2549
  finalizePlanTool,
2370
2550
  ],
2371
- adoptCommittedFacts: async (records) => {
2551
+ });
2552
+ return {
2553
+ customTools: [...durable.customTools, { ...readPlanFactsTool, execute: async (...args) => { await durable.flush(); return readPlanFactsTool.execute(...args); } }],
2554
+ adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
2372
2555
  for (const record of records) {
2373
2556
  if (record.phase !== "committed")
2374
2557
  continue;
@@ -2427,16 +2610,13 @@ export async function createFrontendPlanLedgerTools(input) {
2427
2610
  throw new Error(`frontend plan shard fact merge failed: ${result.error}`);
2428
2611
  }
2429
2612
  }
2430
- },
2613
+ }),
2431
2614
  setActiveRequirementScope: (requirementIds) => {
2432
2615
  activeRequirementScope = [
2433
2616
  ...new Set(requirementIds.filter((id) => id.trim().length > 0)),
2434
2617
  ];
2435
2618
  },
2436
- flush: async () => {
2437
- const committed = readCommittedEvents(store, attemptId);
2438
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"), committed);
2439
- },
2619
+ flush: durable.flush,
2440
2620
  committedFactCount: () => readCommittedEvents(store, attemptId).length,
2441
2621
  committedRequirementIds: () => {
2442
2622
  const ids = new Set();
@@ -2538,8 +2718,11 @@ export async function createFrontendContractTools(input) {
2538
2718
  ]);
2539
2719
  const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
2540
2720
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
2541
- const store = input.store;
2721
+ let store = input.store;
2542
2722
  const attemptId = input.attemptId;
2723
+ let activeScope = null;
2724
+ const committedRequirementIds = () => new Set(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "requirement").map(r => String(r.fact.id)));
2725
+ 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
2726
  const receipt = (details) => ({
2544
2727
  content: [{ type: "text", text: JSON.stringify(details) }],
2545
2728
  details,
@@ -2589,12 +2772,24 @@ export async function createFrontendContractTools(input) {
2589
2772
  label: name,
2590
2773
  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
2774
  promptSnippet: `Commit 1-5 origin=contract ${kind} facts (up to 5 per message).`,
2592
- parameters: Type.Object({}, { additionalProperties: true }),
2775
+ parameters: Type.Object({
2776
+ text: Type.String({ minLength: 1 }),
2777
+ requirementIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
2778
+ sourceFragmentIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
2779
+ ...(kind === "constraint" ? { category: Type.Optional(Type.Enum({ constraint: "constraint", "non-goal": "non-goal", risk: "risk" })) } : {}),
2780
+ ...(kind === "handoff-intent" ? { taskKind: Type.Literal("frontend-test"), blocking: Type.Optional(Type.Boolean()) } : {}),
2781
+ }, { additionalProperties: false }),
2593
2782
  async execute(_toolCallId, params) {
2783
+ const data = params;
2784
+ if (!data.text?.trim())
2785
+ return receipt({ ok: false, code: "TOOL_SCHEMA_INVALID", error: `${name}: text must be non-empty` });
2786
+ const knownFragments = new Set([...(input.canonicalRequirements?.values() ?? [])].flatMap(r => r.sourceFragmentIds));
2787
+ if (data.requirementIds?.some(id => !input.canonicalRequirements?.has(id)) || data.sourceFragmentIds?.some(id => !knownFragments.has(id)))
2788
+ return receipt({ ok: false, code: "CONTRACT_REFERENCE_UNKNOWN", error: `${name}: reference is outside the frozen source inventory` });
2594
2789
  const result = await adoptContractFact(kind, {
2790
+ ...(params ?? {}),
2595
2791
  kind,
2596
2792
  origin: "contract",
2597
- ...(params ?? {}),
2598
2793
  });
2599
2794
  return receipt(result);
2600
2795
  },
@@ -2608,9 +2803,9 @@ export async function createFrontendContractTools(input) {
2608
2803
  const recordRequirementTool = defineTool({
2609
2804
  name: "record_requirement",
2610
2805
  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.',
2806
+ 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
2807
  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 }),
2808
+ 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
2809
  async execute(_toolCallId, params) {
2615
2810
  const id = typeof params?.id === "string" ? params.id.trim() : "";
2616
2811
  if (!id) {
@@ -2620,6 +2815,8 @@ export async function createFrontendContractTools(input) {
2620
2815
  error: "record_requirement requires the canonical requirement id",
2621
2816
  });
2622
2817
  }
2818
+ if (activeScope && !activeScope.has(id))
2819
+ return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: `requirement ${id} is outside the complete input scope of this session` });
2623
2820
  const canonical = input.canonicalRequirements?.get(id);
2624
2821
  if (!canonical) {
2625
2822
  const known = [...(input.canonicalRequirements?.keys() ?? [])];
@@ -2629,6 +2826,14 @@ export async function createFrontendContractTools(input) {
2629
2826
  error: `record_requirement id "${id}" is not a canonical ledger requirement; canonical ids are: ${known.join(", ") || "(none)"}`,
2630
2827
  });
2631
2828
  }
2829
+ if (params.execution) {
2830
+ try {
2831
+ 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 }]);
2832
+ }
2833
+ catch (error) {
2834
+ return receipt({ ok: false, code: "EXECUTION_GROUP_CONFLICT", error: String(error) });
2835
+ }
2836
+ }
2632
2837
  const result = await adoptContractFact("requirement", {
2633
2838
  kind: "requirement",
2634
2839
  origin: "contract",
@@ -2636,6 +2841,7 @@ export async function createFrontendContractTools(input) {
2636
2841
  id,
2637
2842
  text: canonical.text,
2638
2843
  sourceFragmentIds: canonical.sourceFragmentIds,
2844
+ ...(params.execution ? { execution: params.execution } : {}),
2639
2845
  });
2640
2846
  return receipt(result);
2641
2847
  },
@@ -2715,15 +2921,17 @@ export async function createFrontendContractTools(input) {
2715
2921
  const recordRequiredDeliverablesTool = defineTool({
2716
2922
  name: "record_required_deliverables",
2717
2923
  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({
2924
+ 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.',
2925
+ parameters: Type.Object({ replace: Type.Optional(Type.Boolean()), items: Type.Array(Type.Object({
2720
2926
  path: Type.String(), requirementId: Type.String(), sourceFragmentId: Type.String(),
2721
2927
  }, { additionalProperties: false })) }, { additionalProperties: false }),
2722
2928
  async execute(_toolCallId, params) {
2723
2929
  try {
2724
- const declaration = validateFrontendRequiredDeliverables(params, input.canonicalRequirements ?? new Map());
2930
+ const declaration = validateFrontendRequiredDeliverables({ items: params.items }, input.canonicalRequirements ?? new Map());
2931
+ const previous = params.replace ? [] : readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "required-deliverables").at(-1)?.fact.items;
2932
+ const items = [...new Map([...(Array.isArray(previous) ? previous : []), ...declaration.items].map(item => [JSON.stringify(item), item])).values()];
2725
2933
  return receipt(await adoptContractFact("required-deliverables", {
2726
- kind: "required-deliverables", origin: "contract", ...declaration,
2934
+ kind: "required-deliverables", origin: "contract", items,
2727
2935
  }));
2728
2936
  }
2729
2937
  catch (error) {
@@ -2779,7 +2987,7 @@ export async function createFrontendContractTools(input) {
2779
2987
  const finalizeContractTool = defineTool({
2780
2988
  name: "finalize_contract",
2781
2989
  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.",
2990
+ 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
2991
  promptSnippet: "Commit the contract-finalized terminal (disposition + optional blockingOwner).",
2784
2992
  parameters: Type.Object({
2785
2993
  disposition: Type.Enum({
@@ -2811,6 +3019,9 @@ export async function createFrontendContractTools(input) {
2811
3019
  error: "call record_required_deliverables with the complete source-bound inventory (or items:[] when none) before finalizing",
2812
3020
  });
2813
3021
  }
3022
+ const missing = [...(input.canonicalRequirements?.keys() ?? [])].filter(id => !committedRequirementIds().has(id) || (activeScope !== null && !completedScopeRequirementIds().has(id)));
3023
+ if (disposition !== "blocked" && missing.length)
3024
+ return receipt({ ok: false, code: "CONTRACT_REQUIREMENT_COVERAGE_MISSING", error: `Confirm all complete source obligations before finalizing: ${missing.join(", ")}` });
2814
3025
  const blockedOwner = mapContractBlockedOwner({
2815
3026
  disposition: disposition ?? "",
2816
3027
  blockingOwner,
@@ -2831,19 +3042,41 @@ export async function createFrontendContractTools(input) {
2831
3042
  return receipt(result);
2832
3043
  },
2833
3044
  });
2834
- return {
2835
- customTools: [
3045
+ const completeScopeTool = defineTool({
3046
+ name: "complete_contract_scope", label: "complete_contract_scope",
3047
+ 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.",
3048
+ parameters: Type.Object({ requirementIds: Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }) }, { additionalProperties: false }),
3049
+ async execute(_id, params) {
3050
+ const ids = params.requirementIds;
3051
+ if (activeScope === null || ids.length !== activeScope.size || ids.some(id => !activeScope?.has(id) || !committedRequirementIds().has(id)))
3052
+ return receipt({ ok: false, code: "CONTRACT_SCOPE_INCOMPLETE", error: "Complete exactly the active scope after confirming all its obligations" });
3053
+ return receipt(await adoptContractFact("contract-scope-completed", { kind: "contract-scope-completed", origin: "contract", requirementIds: ids }));
3054
+ },
3055
+ });
3056
+ const durable = await createDurableFrontendTools({
3057
+ file: path.join(input.runDir, input.nodeId, "contract-typed-facts.jsonl"), attemptId, store: input.store,
3058
+ setWorkingStore: next => { store = next; }, binding: { canonicalRequirements: input.canonicalRequirements, sourceDigest: input.sourceDigest },
3059
+ tools: [
2836
3060
  ...recordTools,
2837
3061
  recordRequirementTool,
2838
3062
  recordEvidenceExpectationTool,
2839
3063
  recordUiStateTool,
2840
3064
  recordRequiredDeliverablesTool,
2841
3065
  recordOpenspecSelectionTool,
3066
+ completeScopeTool,
2842
3067
  finalizeContractTool,
2843
3068
  ],
3069
+ });
3070
+ return {
3071
+ customTools: durable.customTools,
3072
+ inputRequirements: () => [...(input.canonicalRequirements ?? [])].map(([id, value]) => ({ id, text: value.text, sourceFragmentIds: [...value.sourceFragmentIds] })),
3073
+ completedScopeRequirementIds,
3074
+ setActiveRequirementScope: ids => { activeScope = ids === null ? null : new Set(ids); },
3075
+ committedRequirementIds,
3076
+ committedFacts: () => readCommittedEvents(input.store, attemptId),
2844
3077
  flush: async () => {
2845
- const committed = readCommittedEvents(store, attemptId);
2846
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "contract-typed-facts.jsonl"), committed);
3078
+ await durable.flush();
3079
+ const committed = readCommittedEvents(input.store, attemptId);
2847
3080
  await writeJsonAtomic(path.join(input.runDir, input.nodeId, "frontend-task-contract-vNext.json"), buildFrontendTaskContractVNext(committed));
2848
3081
  },
2849
3082
  };
@@ -2895,7 +3128,7 @@ export async function createFrontendScoutEvidenceTools(input) {
2895
3128
  ]);
2896
3129
  const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
2897
3130
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
2898
- const store = input.store;
3131
+ let store = input.store;
2899
3132
  const attemptId = input.attemptId;
2900
3133
  const stringArray = Type.Array(Type.String({}));
2901
3134
  const optionalString = Type.Optional(Type.String({}));
@@ -2909,6 +3142,18 @@ export async function createFrontendScoutEvidenceTools(input) {
2909
3142
  });
2910
3143
  const sourceDeclaredPaths = (input.sourceDeclaredPaths ?? []).map((value) => value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""));
2911
3144
  const hasSourceDeclarations = input.sourceDeclaredPaths !== undefined;
3145
+ let activeScope;
3146
+ const scopeIdentity = (ids) => createHash("sha256").update(JSON.stringify([...ids].sort())).digest("hex");
3147
+ const latestScopes = () => {
3148
+ const byRequirement = new Map();
3149
+ for (const record of readCommittedEvents(store, attemptId))
3150
+ if (record.fact.kind === "scout-scope" && Array.isArray(record.fact.requirementIds)) {
3151
+ for (const id of record.fact.requirementIds)
3152
+ if (typeof id === "string")
3153
+ byRequirement.set(id, record.fact);
3154
+ }
3155
+ return byRequirement;
3156
+ };
2912
3157
  const isSourceDeclared = (candidate) => {
2913
3158
  const normalized = candidate.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
2914
3159
  return sourceDeclaredPaths.some((declared) => declared === normalized || declared.startsWith(`${normalized}/`));
@@ -3001,6 +3246,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3001
3246
  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
3247
  promptSnippet: "Commit an origin=scout target-surface fact.",
3003
3248
  parameters: Type.Object({
3249
+ scopeId: Type.Optional(Type.String({ minLength: 1 })),
3004
3250
  completeness: scoutCompleteness,
3005
3251
  entrypoint: optionalString,
3006
3252
  routeOrMount: optionalString,
@@ -3011,6 +3257,8 @@ export async function createFrontendScoutEvidenceTools(input) {
3011
3257
  unresolvedPaths: stringArray,
3012
3258
  }, { additionalProperties: false }),
3013
3259
  async execute(_toolCallId, params) {
3260
+ if (input.requirementIds && (!activeScope?.length || params.scopeId !== scopeIdentity(activeScope)))
3261
+ return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: "Use exactly the runtime Scout scopeId; discovery may complete only the supplied obligations" });
3014
3262
  const implementationPaths = params?.implementationPaths ?? [];
3015
3263
  const testPaths = params?.testPaths ?? [];
3016
3264
  const pathEvidence = await enrichScoutPathEvidence([
@@ -3018,7 +3266,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3018
3266
  ...implementationPaths,
3019
3267
  ...testPaths,
3020
3268
  ]);
3021
- const result = await adoptScoutFact("target-surface", {
3269
+ const surface = {
3022
3270
  kind: "target-surface",
3023
3271
  origin: "scout",
3024
3272
  completeness: params?.completeness ?? "blocked",
@@ -3031,7 +3279,27 @@ export async function createFrontendScoutEvidenceTools(input) {
3031
3279
  unresolvedPaths: params?.unresolvedPaths ?? [],
3032
3280
  ...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}),
3033
3281
  ...(pathEvidence.length > 0 ? { pathEvidence } : {}),
3034
- });
3282
+ };
3283
+ if (activeScope) {
3284
+ const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-shadow-dual-write.js");
3285
+ if (surface.completeness === "complete") {
3286
+ const check = readCompleteScoutTargetSurface([{ phase: "committed", fact: surface }]);
3287
+ if (!check.ok)
3288
+ return receipt({ ok: false, code: "SCOUT_SCOPE_INCOMPLETE", error: check.reason });
3289
+ }
3290
+ const saved = await adoptScoutFact("scout-scope", { kind: "scout-scope", origin: "scout", id: params.scopeId, requirementIds: activeScope, surface });
3291
+ if (!saved.ok)
3292
+ return receipt(saved);
3293
+ const current = latestScopes();
3294
+ if (input.requirementIds?.every(id => current.get(id)?.surface?.completeness === "complete")) {
3295
+ const surfaces = [...new Set(input.requirementIds.map(id => current.get(id)))].map(f => f.surface);
3296
+ const union = (key) => [...new Set(surfaces.flatMap(s => Array.isArray(s[key]) ? s[key] : []))];
3297
+ const entries = [...new Set(surfaces.map(s => String(s.entrypoint ?? "")).filter(Boolean))];
3298
+ return receipt(await adoptScoutFact("target-surface", { kind: "target-surface", origin: "scout", completeness: "complete", entrypoint: entries[0] ?? "", implementationPaths: [...new Set([...entries, ...union("implementationPaths")])], testPaths: union("testPaths"), allowedPathConflicts: union("allowedPathConflicts"), unresolvedPaths: union("unresolvedPaths"), routeOrMount: [...new Set(surfaces.map(s => s.routeOrMount).filter(Boolean))].join("\n"), dataSource: [...new Set(surfaces.map(s => s.dataSource).filter(Boolean))].join("\n"), pathEvidence: surfaces.flatMap(s => s.pathEvidence ?? []), ...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}) }));
3299
+ }
3300
+ return receipt(saved);
3301
+ }
3302
+ const result = await adoptScoutFact("target-surface", surface);
3035
3303
  return receipt(result);
3036
3304
  },
3037
3305
  });
@@ -3055,10 +3323,30 @@ export async function createFrontendScoutEvidenceTools(input) {
3055
3323
  return receipt(result);
3056
3324
  },
3057
3325
  });
3326
+ const durable = await createDurableFrontendTools({
3327
+ file: path.join(input.runDir, input.nodeId, "scout-typed-facts.jsonl"), attemptId, store: input.store,
3328
+ setWorkingStore: next => { store = next; }, binding: { requirementIds: input.requirementIds, sourceDeclaredPaths: input.sourceDeclaredPaths, sourceDigest: input.sourceDigest, workspaceRoot: input.workspaceRoot },
3329
+ tools: [recordTargetSurfaceTool, recordDesignEvidenceTool],
3330
+ validateRestored: async (records) => {
3331
+ if (!input.workspaceRoot)
3332
+ return;
3333
+ for (const record of records) {
3334
+ const evidence = record.fact.kind === "scout-scope" ? record.fact.surface?.pathEvidence : record.fact.pathEvidence;
3335
+ if (!Array.isArray(evidence))
3336
+ continue;
3337
+ for (const previous of evidence) {
3338
+ if (!isRecordObject(previous) || typeof previous.path !== "string")
3339
+ throw Error("scout path evidence is malformed");
3340
+ const current = (await enrichScoutPathEvidence([previous.path]))[0];
3341
+ if (!current || current.sha256 !== previous.sha256 || current.fresh !== previous.fresh)
3342
+ throw Error(`scout evidence drift: ${previous.path}; refresh Scout before reusing facts`);
3343
+ }
3344
+ }
3345
+ },
3346
+ });
3058
3347
  return {
3059
- customTools: [recordTargetSurfaceTool, recordDesignEvidenceTool],
3060
- committedFacts: () => readCommittedEvents(store, attemptId),
3061
- adoptCommittedFacts: async (records) => {
3348
+ ...durable,
3349
+ adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
3062
3350
  for (const record of records) {
3063
3351
  if (record.phase !== "committed")
3064
3352
  continue;
@@ -3072,11 +3360,16 @@ export async function createFrontendScoutEvidenceTools(input) {
3072
3360
  throw new Error(`frontend scout shard fact merge failed: ${result.error}`);
3073
3361
  }
3074
3362
  }
3363
+ }),
3364
+ setActiveScope: ids => {
3365
+ if (!ids.length || ids.some(id => !input.requirementIds?.includes(id)))
3366
+ throw Error("FRONTEND_INPUT_SCOPE_VIOLATION");
3367
+ activeScope = [...new Set(ids)];
3368
+ return scopeIdentity(activeScope);
3075
3369
  },
3076
- flush: async () => {
3077
- const committed = readCommittedEvents(store, attemptId);
3078
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "scout-typed-facts.jsonl"), committed);
3079
- },
3370
+ completedRequirementIds: () => new Set([...latestScopes()].filter(([, fact]) => fact.surface?.completeness === "complete").map(([id]) => id)),
3371
+ completedScopeFacts: () => [...new Set(latestScopes().values())].filter(f => f.surface?.completeness === "complete"),
3372
+ committedFacts: () => readCommittedEvents(store, attemptId),
3080
3373
  };
3081
3374
  }
3082
3375
  export function buildDagPiUserMessage(task, persona, step) {
@@ -3327,15 +3620,14 @@ async function runFrontendReviewTerminalShadow(input) {
3327
3620
  return input.mapped;
3328
3621
  const { compareTypedReviewToLegacyJsonVerdict } = await import("../workflows/dag/frontend-review-context.js");
3329
3622
  const { parseJsonReviewVerdict } = await import("../workflows/dag/output-protocol.js");
3330
- const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
3331
3623
  let typedKinds = [];
3332
3624
  try {
3333
- const content = await readFile(sessionEventsPath, "utf8");
3334
- typedKinds = scanReviewTerminalKindsFromSessionEvents(content);
3625
+ await input.tools?.flush();
3626
+ input.tools?.scopeProtocol?.assertComplete();
3627
+ typedKinds = input.tools?.scopeProtocol?.committedFacts().filter(r => ["approve_review", "request_review_changes"].includes(String(r.fact.kind))).map(r => String(r.fact.kind)) ?? [];
3335
3628
  }
3336
- catch {
3337
- // Missing/unreadable session log fail-closed at zero terminal facts.
3338
- typedKinds = [];
3629
+ catch (error) {
3630
+ return { ...input.mapped, ok: false, failureCategory: "frontend-ledger-invalid", stderr: `FRONTEND_REVIEW_SCOPE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}` };
3339
3631
  }
3340
3632
  let legacyVerdict;
3341
3633
  try {
@@ -3361,13 +3653,7 @@ async function runFrontendReviewTerminalShadow(input) {
3361
3653
  reason: `typed review equivalence comparison crashed: ${error instanceof Error ? error.message : String(error)}`,
3362
3654
  };
3363
3655
  }
3364
- // Audit-only flush + artifact. Neither blocks the node.
3365
- try {
3366
- await input.tools?.flush?.();
3367
- }
3368
- catch {
3369
- // best-effort
3370
- }
3656
+ // The durable ledger was validated above; this artifact is audit-only.
3371
3657
  try {
3372
3658
  await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-review-status.json", {
3373
3659
  schemaVersion: 1,
@@ -3409,23 +3695,16 @@ async function runFrontendDesignTerminalShadow(input) {
3409
3695
  // diagnosed as an omitted terminal tool call.
3410
3696
  if (!input.mapped.ok)
3411
3697
  return input.mapped;
3412
- const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
3413
3698
  let typedKinds = [];
3414
3699
  try {
3415
- const content = await readFile(sessionEventsPath, "utf8");
3416
- typedKinds = scanDesignTerminalKindsFromSessionEvents(content);
3700
+ await input.tools?.flush();
3701
+ input.tools?.scopeProtocol?.assertComplete();
3702
+ typedKinds = input.tools?.scopeProtocol?.committedFacts().filter(r => ["approve_design", "request_design_changes"].includes(String(r.fact.kind))).map(r => String(r.fact.kind)) ?? [];
3417
3703
  }
3418
- catch {
3419
- // Missing/unreadable session log fail-closed at zero terminal facts.
3420
- typedKinds = [];
3421
- }
3422
- // Audit-only flush + artifact. Neither blocks the node.
3423
- try {
3424
- await input.tools?.flush?.();
3425
- }
3426
- catch {
3427
- // best-effort
3704
+ catch (error) {
3705
+ return { ...input.mapped, ok: false, failureCategory: "frontend-ledger-invalid", stderr: `FRONTEND_REVIEW_SCOPE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}` };
3428
3706
  }
3707
+ // The durable ledger was validated above; this artifact is audit-only.
3429
3708
  try {
3430
3709
  await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-design-status.json", {
3431
3710
  schemaVersion: 1,
@@ -3458,6 +3737,7 @@ const FRONTEND_PLAN_SEGMENTS = [
3458
3737
  id: "coverage",
3459
3738
  toolNames: new Set([
3460
3739
  "record_plan_requirement",
3740
+ "record_plan_group_coverage",
3461
3741
  "record_plan_verification_target",
3462
3742
  "record_plan_evidence_gap",
3463
3743
  "adopt_staged_fact",
@@ -3474,13 +3754,14 @@ const FRONTEND_PLAN_SEGMENTS = [
3474
3754
  toolNames: new Set(["record_state_registry", "adopt_staged_fact"]),
3475
3755
  instruction: [
3476
3756
  "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.",
3757
+ "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
3758
  "Do not call finalize_plan; it is not available in this phase.",
3479
3759
  ].join(" "),
3480
3760
  },
3481
3761
  {
3482
3762
  id: "ux-local",
3483
3763
  toolNames: new Set([
3764
+ "record_state_registry",
3484
3765
  "record_component_choice",
3485
3766
  "record_state_flow",
3486
3767
  "record_plan_verification_target",
@@ -3488,7 +3769,7 @@ const FRONTEND_PLAN_SEGMENTS = [
3488
3769
  ]),
3489
3770
  instruction: [
3490
3771
  "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.",
3772
+ "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
3773
  "Do not call finalize_plan; it is not available in this phase.",
3493
3774
  ].join(" "),
3494
3775
  },
@@ -3502,7 +3783,7 @@ const FRONTEND_PLAN_SEGMENTS = [
3502
3783
  },
3503
3784
  {
3504
3785
  id: "global-mock-data",
3505
- toolNames: new Set(["record_data_flow", "record_mock_api", "adopt_staged_fact"]),
3786
+ toolNames: new Set(["record_data_flow", "record_mock_api", "record_mock_endpoint", "adopt_staged_fact"]),
3506
3787
  instruction: [
3507
3788
  "PLAN PHASE — global Mock/API and data policy.",
3508
3789
  "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.",
@@ -3525,173 +3806,6 @@ const FRONTEND_PLAN_SEGMENTS = [
3525
3806
  ].join(" "),
3526
3807
  },
3527
3808
  ];
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
3809
  /** Estimate calls conservatively: requirement + one VT, with a second VT
3696
3810
  * reserved for behaviour-required requirements. Explicit declarations win. */
3697
3811
  export function estimateFrontendPlanRequirementRecordCalls(fact) {
@@ -3733,42 +3847,47 @@ export function batchFrontendPlanRequirements(input) {
3733
3847
  }
3734
3848
  const FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS = 10;
3735
3849
  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
3850
+ // A single registry owns global names; large UX work uses bounded serial
3851
+ // scopes against that registry. Keep a safety bound for adaptive retries
3739
3852
  // without letting the old 32-session ceiling skip finalize.
3740
3853
  const FRONTEND_PLAN_BATCH_MAX_SESSIONS = 128;
3741
3854
  const FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS = 8;
3742
3855
  const FRONTEND_PLAN_SMALL_MAX_ESTIMATED_CALLS = 24;
3743
- async function mapWithConcurrency(items, limit, worker) {
3856
+ async function mapWithConcurrency(items, limit, worker, shouldReduceConcurrency) {
3744
3857
  const results = new Array(items.length);
3745
3858
  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);
3859
+ let concurrency = Math.max(1, limit);
3860
+ const running = new Map();
3861
+ try {
3862
+ while (nextIndex < items.length || running.size > 0) {
3863
+ while (nextIndex < items.length && running.size < concurrency) {
3864
+ const index = nextIndex++;
3865
+ running.set(index, worker(items[index], index).then(result => ({ index, result })));
3866
+ }
3867
+ const { index, result } = await Promise.race(running.values());
3868
+ running.delete(index);
3869
+ results[index] = result;
3870
+ // Drain existing work; only pending shards use the reduced cap.
3871
+ // Failed shards remain failed and receive no extra retry allowance.
3872
+ if (shouldReduceConcurrency(result))
3873
+ concurrency = Math.max(1, Math.floor(concurrency / 2));
3753
3874
  }
3754
- }));
3875
+ }
3876
+ catch (error) {
3877
+ await Promise.allSettled(running.values());
3878
+ throw error;
3879
+ }
3755
3880
  return results;
3756
3881
  }
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)}…`;
3882
+ function compactPromptString(value, _maxChars) {
3883
+ return typeof value === "string" && value.trim().length ? value.trim() : undefined;
3764
3884
  }
3765
- function compactPromptStringArray(value, maxEntries = 12, maxChars = 180) {
3885
+ function compactPromptStringArray(value, _maxEntries = 12, maxChars = 180) {
3766
3886
  if (!Array.isArray(value))
3767
3887
  return [];
3768
3888
  return value
3769
3889
  .map((item) => compactPromptString(item, maxChars))
3770
- .filter((item) => item !== undefined)
3771
- .slice(0, maxEntries);
3890
+ .filter((item) => item !== undefined);
3772
3891
  }
3773
3892
  function countFrontendPlanTargetSurfaces(basePrompt) {
3774
3893
  const match = /<frontend_plan_input>[\s\S]*?<\/frontend_plan_input>/.exec(basePrompt);
@@ -3841,7 +3960,7 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3841
3960
  }
3842
3961
  }
3843
3962
  if (!payload || !Array.isArray(payload.requirements))
3844
- return basePrompt;
3963
+ throw Error("FRONTEND_INPUT_INVALID: plan inventory is not parseable");
3845
3964
  const requirementsById = new Map();
3846
3965
  for (const value of payload.requirements) {
3847
3966
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -3853,7 +3972,7 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3853
3972
  }
3854
3973
  const requirements = slice.map((id) => requirementsById.get(id));
3855
3974
  if (requirements.some((requirement) => requirement === undefined)) {
3856
- return basePrompt;
3975
+ throw Error(`FRONTEND_INPUT_SCOPE_MISSING: ${slice.filter(id => !requirementsById.has(id)).join(", ")}`);
3857
3976
  }
3858
3977
  const compactRequirements = requirements.map((requirement) => ({
3859
3978
  id: compactPromptString(requirement.id, 80),
@@ -3870,26 +3989,28 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3870
3989
  if (!value || typeof value !== "object" || Array.isArray(value))
3871
3990
  return [];
3872
3991
  const target = value;
3873
- const targetRequirementIds = compactPromptStringArray(target.requirementIds, 20, 80);
3992
+ if (options.verificationTargetIds && !options.verificationTargetIds.includes(String(target.id)))
3993
+ return [];
3994
+ const targetRequirementIds = planFactStringList(target.requirementIds);
3874
3995
  const relatedRequirementIds = targetRequirementIds.filter((id) => sliceSet.has(id));
3875
3996
  if (relatedRequirementIds.length === 0)
3876
3997
  return [];
3877
3998
  return [
3878
3999
  {
3879
- ...(compactPromptString(target.id, 80)
3880
- ? { id: compactPromptString(target.id, 80) }
4000
+ ...(typeof target.id === "string"
4001
+ ? { id: target.id }
3881
4002
  : {}),
3882
- ...(compactPromptString(target.commandId, 80)
3883
- ? { commandId: compactPromptString(target.commandId, 80) }
4003
+ ...(typeof target.commandId === "string"
4004
+ ? { commandId: target.commandId }
3884
4005
  : {}),
3885
4006
  ...(compactPromptString(target.commandLabel, 180)
3886
4007
  ? { commandLabel: compactPromptString(target.commandLabel, 180) }
3887
4008
  : {}),
3888
- ...(compactPromptString(target.file, 180)
3889
- ? { file: compactPromptString(target.file, 180) }
4009
+ ...(typeof target.file === "string"
4010
+ ? { file: target.file }
3890
4011
  : {}),
3891
4012
  requirementIds: relatedRequirementIds,
3892
- uiStates: compactPromptStringArray(target.uiStates, 12, 100),
4013
+ uiStates: planFactStringList(target.uiStates),
3893
4014
  },
3894
4015
  ];
3895
4016
  })
@@ -3941,7 +4062,7 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3941
4062
  if (!value || typeof value !== "object" || Array.isArray(value))
3942
4063
  return [];
3943
4064
  const state = value;
3944
- const id = compactPromptString(state.id, 80);
4065
+ const id = typeof state.id === "string" ? state.id : undefined;
3945
4066
  if (!id)
3946
4067
  return [];
3947
4068
  return [{
@@ -3961,6 +4082,9 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3961
4082
  ? payload.committedUx
3962
4083
  : undefined;
3963
4084
  const compactPayload = {
4085
+ inputManifest: { ...projectFrontendInputScope({ ...payload, requirements: [...requirementsById.values()] }, slice).inputManifest, semantics: options.includeRequirementText === false ? "navigation-only" : "full" },
4086
+ constraints: payload.constraints,
4087
+ executionGroups: Array.isArray(payload.executionGroups) ? payload.executionGroups.filter(g => isRecordObject(g) && Array.isArray(g.requirementIds) && g.requirementIds.some(id => slice.includes(String(id)))) : [],
3964
4088
  requirements: compactRequirements,
3965
4089
  requiredDeliverables: Array.isArray(payload.requiredDeliverables) ? payload.requiredDeliverables : [],
3966
4090
  ...(compactTargetSurface.length > 0
@@ -4038,9 +4162,9 @@ function compactFrontendPlanLedgerContext(input) {
4038
4162
  compactFacts.push({
4039
4163
  kind: fact.kind,
4040
4164
  entry: {
4041
- id: compactPromptString(entry.id, 80),
4042
- implementationTargets: compactPromptStringArray(entry.implementationTargets, 12, 180),
4043
- verificationTargetIds: compactPromptStringArray(entry.verificationTargetIds, 12, 80),
4165
+ id: entry.id,
4166
+ implementationTargets: planFactStringList(entry.implementationTargets).slice(0, 12),
4167
+ verificationTargetIds: planFactStringList(entry.verificationTargetIds).slice(0, 12),
4044
4168
  ...(compactPromptString(entry.expectedOutcome, 240)
4045
4169
  ? { expectedOutcome: compactPromptString(entry.expectedOutcome, 240) }
4046
4170
  : {}),
@@ -4054,17 +4178,19 @@ function compactFrontendPlanLedgerContext(input) {
4054
4178
  if (fact.kind === "plan-verification-target") {
4055
4179
  if (!entry)
4056
4180
  continue;
4057
- const requirementIds = compactPromptStringArray(entry.requirementIds, 20, 80);
4058
- if (!requirementIds.some((id) => slice.has(id)))
4181
+ // Scope against the complete canonical binding before compacting.
4182
+ // A shared target may bind a late requirement beyond the preview cap.
4183
+ const requirementIds = planFactStringList(entry.requirementIds).filter((id) => slice.has(id));
4184
+ if (requirementIds.length === 0)
4059
4185
  continue;
4060
4186
  compactFacts.push({
4061
4187
  kind: fact.kind,
4062
4188
  entry: {
4063
- id: compactPromptString(entry.id, 80),
4064
- commandId: compactPromptString(entry.commandId, 80),
4065
- file: compactPromptString(entry.file, 180),
4189
+ id: entry.id,
4190
+ commandId: entry.commandId,
4191
+ file: entry.file,
4066
4192
  requirementIds,
4067
- uiStates: compactPromptStringArray(entry.uiStates, 12, 100),
4193
+ uiStates: planFactStringList(entry.uiStates).slice(0, 12),
4068
4194
  },
4069
4195
  });
4070
4196
  continue;
@@ -4082,7 +4208,7 @@ function compactFrontendPlanLedgerContext(input) {
4082
4208
  covers: compactPromptStringArray(item.covers, 40, 120),
4083
4209
  evidencePath: compactPromptString(item.evidencePath, 180),
4084
4210
  }];
4085
- }).slice(0, 24)
4211
+ })
4086
4212
  : [];
4087
4213
  if (choices.length > 0)
4088
4214
  compactFacts.push({ kind: fact.kind, uiComponentChoices: choices });
@@ -4091,16 +4217,16 @@ function compactFrontendPlanLedgerContext(input) {
4091
4217
  if (fact.kind === "state-registry") {
4092
4218
  compactFacts.push({
4093
4219
  kind: fact.kind,
4094
- uiStateNames: compactPromptStringArray(fact.uiStateNames, 40, 100),
4095
- interactionNames: compactPromptStringArray(fact.interactionNames, 60, 120),
4220
+ uiStateNames: planFactStringList(fact.uiStateNames).slice(0, 40),
4221
+ interactionNames: planFactStringList(fact.interactionNames).slice(0, 60),
4096
4222
  });
4097
4223
  continue;
4098
4224
  }
4099
4225
  if (fact.kind === "state-flow") {
4100
4226
  compactFacts.push({
4101
4227
  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) : [],
4228
+ uiStates: Array.isArray(fact.uiStates) ? fact.uiStates : [],
4229
+ interactions: Array.isArray(fact.interactions) ? fact.interactions : [],
4104
4230
  removeUiStateNames: compactPromptStringArray(fact.removeUiStateNames, 24, 100),
4105
4231
  removeInteractionNames: compactPromptStringArray(fact.removeInteractionNames, 24, 100),
4106
4232
  });
@@ -4123,7 +4249,7 @@ function compactFrontendPlanLedgerContext(input) {
4123
4249
  mockApi: {
4124
4250
  strategy: compactPromptString(mockApi.strategy, 40),
4125
4251
  activation: compactPromptString(mockApi.activation, 180),
4126
- endpoints: Array.isArray(mockApi.endpoints) ? mockApi.endpoints.slice(0, 24) : [],
4252
+ endpoints: Array.isArray(mockApi.endpoints) ? mockApi.endpoints : [],
4127
4253
  },
4128
4254
  });
4129
4255
  continue;
@@ -4144,17 +4270,332 @@ function compactFrontendPlanLedgerContext(input) {
4144
4270
  return "";
4145
4271
  const priorityFacts = compactFacts.filter((fact) => fact.kind === "plan-requirement" || fact.kind === "plan-verification-target");
4146
4272
  const otherFacts = compactFacts.filter((fact) => fact.kind !== "plan-requirement" && fact.kind !== "plan-verification-target");
4273
+ let previewBytes = 0;
4147
4274
  const boundedFacts = [
4148
4275
  ...priorityFacts.slice(0, 64),
4149
4276
  ...otherFacts.slice(-32),
4150
- ].slice(0, 96);
4277
+ ].slice(0, 96).filter(fact => {
4278
+ const bytes = Buffer.byteLength(JSON.stringify(fact));
4279
+ if (previewBytes + bytes > 24_000)
4280
+ return false;
4281
+ previewBytes += bytes;
4282
+ return true;
4283
+ });
4151
4284
  return [
4152
4285
  "<frontend_plan_ledger>",
4153
- "Committed plan facts from earlier sessions. Treat these as authoritative; correct them only with the allowed replacement/removal fields.",
4286
+ "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.",
4287
+ JSON.stringify({ complete: false, matchingFacts: compactFacts.length, omittedFacts: compactFacts.length - boundedFacts.length }),
4154
4288
  JSON.stringify(boundedFacts),
4155
4289
  "</frontend_plan_ledger>",
4156
4290
  ].join("\n");
4157
4291
  }
4292
+ export async function runFrontendReviewSegmentedSessions(input) {
4293
+ const protocol = input.tools.scopeProtocol;
4294
+ const terminalKinds = input.phase === "review" ? ["approve_review", "request_review_changes"] : ["approve_design", "request_design_changes"];
4295
+ const terminal = () => protocol.committedFacts().some(r => terminalKinds.includes(String(r.fact.kind)));
4296
+ const targetBytes = input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES;
4297
+ const queue = packFrontendInputUnits(input.inventory.scopes.filter(s => !protocol.completedScopeIds().has(s.id)), { targetBytes, maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits }).map(scopes => ({ scopes, repairs: 0 }));
4298
+ if (!queue.length)
4299
+ queue.push({ scopes: [], repairs: 0 });
4300
+ let calls = 0;
4301
+ 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 };
4302
+ for (let index = 0; index < queue.length; index++) {
4303
+ await input.tools.flush();
4304
+ await input.inventory.validate();
4305
+ if (terminal()) {
4306
+ protocol.assertComplete();
4307
+ return last;
4308
+ }
4309
+ const item = queue[index];
4310
+ const scopes = item.scopes.filter(s => !protocol.completedScopeIds().has(s.id));
4311
+ protocol.setActiveScope(scopes.map(s => s.id));
4312
+ const finalScope = input.inventory.scopes.every(s => protocol.completedScopeIds().has(s.id) || scopes.some(current => current.id === s.id));
4313
+ const customTools = finalScope ? input.customTools : input.customTools.filter(t => !terminalKinds.includes(String(t.name)));
4314
+ const prompt = `${input.basePrompt}\n<frontend_review_scope>\n${JSON.stringify({ semantics: "full", inventoryDigest: input.inventory.digest, scopes, previouslyCompleted: [...protocol.completedScopeIds()], savedFindings: protocol.committedFacts().filter(r => String(r.fact.kind).endsWith("-finding")).map(r => ({ id: r.fact.id, finding: r.fact.finding })) })}\n</frontend_review_scope>\nReview the complete supplied scopes, saving each finding immediately. Call complete_review_scope for each exact id only after all its independent permissions, thresholds, errors and evidence have been checked. ${finalScope ? "After every scope is complete, make one independent overall approve/request_changes decision; persisted findings cannot be omitted." : "More scopes remain. Do not finalize or reread already completed scopes unless resolving a cross-scope issue."}`;
4315
+ if (Buffer.byteLength(prompt) > targetBytes && scopes.length > 1) {
4316
+ const at = Math.ceil(scopes.length / 2);
4317
+ queue.splice(index, 1, { scopes: scopes.slice(0, at), repairs: 0 }, { scopes: scopes.slice(at), repairs: 0 });
4318
+ index--;
4319
+ continue;
4320
+ }
4321
+ if (++calls > 128)
4322
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_RECOVERY_EXHAUSTED: session quota reached" };
4323
+ last = await observeFrontendSession({ ...input.observation, phase: `${input.phase}/scope`, scopeIds: scopes.map(s => s.id), prompt, userMessage: input.sessionOptions.userMessage, customTools,
4324
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${calls}.json` : undefined,
4325
+ committedCount: () => protocol.committedFacts().length, durableCommittedCount: () => protocol.committedFacts().length,
4326
+ }, observer => input.piStepFn({ ...input.sessionOptions, prompt, onAttemptObservation: observer, writerToolPolicy: { requireSdk: true, customTools } }));
4327
+ await input.tools.flush();
4328
+ await input.inventory.validate();
4329
+ if (last.timedOut || /interrupt|termination-unconfirmed|budget_breach/.test(last.failureCategory))
4330
+ return { ...last, ok: false };
4331
+ const capacity = readWriterThinkingExhaustionEvidence(last).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(last.failureCategory);
4332
+ const missing = scopes.filter(s => !protocol.completedScopeIds().has(s.id));
4333
+ if (capacity && (missing.length || !terminal() && finalScope)) {
4334
+ if (missing.length === 1 && scopes.length === 1 || !missing.length && !scopes.length)
4335
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_INPUT_UNIT_TOO_LARGE: complete review unit or terminal exhausted" };
4336
+ const at = Math.ceil(missing.length / 2);
4337
+ const smaller = missing.length ? [missing.slice(0, at), missing.slice(at)].filter(s => s.length) : [[]];
4338
+ queue.splice(index, 1, ...smaller.map(scopes => ({ scopes, repairs: 0 })));
4339
+ index--;
4340
+ continue;
4341
+ }
4342
+ if (!last.ok && !capacity)
4343
+ return last;
4344
+ if (missing.length || finalScope && !terminal()) {
4345
+ if (item.repairs >= 1)
4346
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_SCOPE_INCOMPLETE: required checkpoint or verdict missing" };
4347
+ queue.splice(index, 1, { scopes: missing, repairs: item.repairs + 1 });
4348
+ index--;
4349
+ }
4350
+ }
4351
+ protocol.assertComplete();
4352
+ return terminal() ? { ...last, ok: true, failureCategory: "success" } : { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_SCOPE_INCOMPLETE: missing independent verdict" };
4353
+ }
4354
+ export async function runFrontendScoutSegmentedSessions(input) {
4355
+ const inventory = parseFrontendInputBlock(input.basePrompt, "scout");
4356
+ if (!inventory)
4357
+ throw Error("FRONTEND_INPUT_MISSING: Scout compiled inventory unavailable");
4358
+ 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)) }));
4359
+ const targetBytes = input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES;
4360
+ const queue = packFrontendInputUnits(units, { targetBytes, maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits }).map(batch => ({ groups: batch, repairs: 0 }));
4361
+ 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 };
4362
+ let calls = 0;
4363
+ for (let index = 0; index < queue.length; index++) {
4364
+ await input.tools.flush();
4365
+ const item = queue[index];
4366
+ const completed = input.tools.completedRequirementIds();
4367
+ const groups = item.groups.filter(g => g.requirementIds.some(id => !completed.has(id)));
4368
+ if (!groups.length)
4369
+ continue;
4370
+ const ids = groups.flatMap(g => g.requirementIds);
4371
+ const scopeId = input.tools.setActiveScope(ids);
4372
+ const prompt = input.basePrompt.replace(inventory.block, `<frontend_scout_input>\n${JSON.stringify(projectFrontendInputScope(inventory.payload, ids))}\n</frontend_scout_input>`) +
4373
+ `\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` +
4374
+ JSON.stringify({ completedScopePaths: input.tools.completedScopeFacts().map(f => ({ id: f.id, requirementIds: f.requirementIds, paths: f.surface?.implementationPaths })) });
4375
+ 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 }; })));
4376
+ if (envelopeBytes > targetBytes && groups.length > 1) {
4377
+ const at = Math.ceil(groups.length / 2);
4378
+ queue.splice(index, 1, { groups: groups.slice(0, at), repairs: 0 }, { groups: groups.slice(at), repairs: 0 });
4379
+ index--;
4380
+ continue;
4381
+ }
4382
+ if (++calls > 128)
4383
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "SCOUT_RECOVERY_EXHAUSTED: shared session quota reached" };
4384
+ last = await observeFrontendSession({ ...input.observation, phase: "scout/scope", scopeIds: ids, prompt, userMessage: input.sessionOptions.userMessage, customTools: input.customTools,
4385
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${calls}.json` : undefined,
4386
+ committedCount: () => input.tools.committedFacts().length, durableCommittedCount: () => input.tools.committedFacts().length,
4387
+ }, observer => input.piStepFn({ ...input.sessionOptions, prompt, onAttemptObservation: observer, writerToolPolicy: { requireSdk: true, customTools: input.customTools } }));
4388
+ await input.tools.flush();
4389
+ const missing = groups.filter(g => g.requirementIds.some(id => !input.tools.completedRequirementIds().has(id)));
4390
+ if (last.timedOut || /interrupt|termination-unconfirmed|budget_breach/.test(last.failureCategory))
4391
+ return { ...last, ok: false };
4392
+ const capacity = readWriterThinkingExhaustionEvidence(last).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(last.failureCategory);
4393
+ if (capacity && missing.length) {
4394
+ if (missing.length === 1 && groups.length === 1)
4395
+ 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` };
4396
+ const at = Math.ceil(missing.length / 2);
4397
+ queue.splice(index, 1, ...[missing.slice(0, at), missing.slice(at)].filter(batch => batch.length).map(batch => ({ groups: batch, repairs: 0 })));
4398
+ index--;
4399
+ continue;
4400
+ }
4401
+ if (!last.ok && !capacity)
4402
+ return last;
4403
+ if (missing.length) {
4404
+ if (item.repairs >= 1)
4405
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "SCOUT_SCOPE_INCOMPLETE: unresolved discovery remains after local correction" };
4406
+ queue.splice(index, 1, { groups: missing, repairs: item.repairs + 1 });
4407
+ index--;
4408
+ continue;
4409
+ }
4410
+ }
4411
+ await input.tools.flush();
4412
+ const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-shadow-dual-write.js");
4413
+ const closure = readCompleteScoutTargetSurface(input.tools.committedFacts());
4414
+ return closure.ok ? { ...last, ok: true, failureCategory: "success" } : { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: closure.reason };
4415
+ }
4416
+ export async function runFrontendContractSegmentedSessions(input) {
4417
+ // Build from the frozen runtime inventory if the caller has not rendered it yet.
4418
+ const basePrompt = parseFrontendInputBlock(input.basePrompt, "contract") ? input.basePrompt : input.basePrompt +
4419
+ `\n<frontend_contract_input>\nFrozen complete source obligations.\n${JSON.stringify({ requirements: input.tools.inputRequirements() })}\n</frontend_contract_input>`;
4420
+ const inventory = parseFrontendInputBlock(basePrompt, "contract");
4421
+ const pending = inventory.payload.requirements.filter(r => !input.tools.completedScopeRequirementIds().has(r.id));
4422
+ const batches = packFrontendInputUnits(pending, { targetBytes: input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes, maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits });
4423
+ if (!batches.length)
4424
+ batches.push([]);
4425
+ let last;
4426
+ let invocation = 0;
4427
+ const maxSessions = batches.length * 3 + 2;
4428
+ const terminal = () => input.tools.committedFacts().some(r => r.fact.kind === "contract-finalized");
4429
+ try {
4430
+ for (let index = 0; index < batches.length; index += 1) {
4431
+ let scopeIds = batches[index].map(r => r.id);
4432
+ const finalScope = index === batches.length - 1;
4433
+ for (let repair = 0; repair < 2; repair += 1) {
4434
+ // Confirmed IDs remain visible until the model commits a scope checkpoint.
4435
+ const completed = input.tools.completedScopeRequirementIds();
4436
+ scopeIds = scopeIds.filter(id => !completed.has(id));
4437
+ input.tools.setActiveRequirementScope(scopeIds);
4438
+ const groupIndex = collectFrontendExecutionGroups(input.tools.committedFacts().filter(r => r.fact.kind === "requirement").map(r => ({ id: String(r.fact.id), execution: r.fact.execution })));
4439
+ const shared = input.tools.committedFacts().filter(r => !["requirement", "contract-finalized", "contract-scope-completed"].includes(String(r.fact.kind))).map(r => r.fact);
4440
+ const prompt = projectFrontendContractPrompt(basePrompt, scopeIds) +
4441
+ `\nCONTRACT SCOPE: analyze only ${scopeIds.join(", ") || "(all scopes complete; verify global facts and terminal)"}. Each obligation is complete. Submit small records immediately, then call complete_contract_scope after ALL decisions for this scope. ` +
4442
+ (finalScope ? "After complete scope coverage and source-bound deliverables, call finalize_contract. Correct rejected calls and retry." : "Do not finalize; subsequent complete scopes remain.") +
4443
+ `\n<committed_contract_facts>\n${JSON.stringify({ facts: shared, executionGroups: groupIndex })}\n</committed_contract_facts>`;
4444
+ const customTools = input.tools.customTools.filter(t => finalScope || t.name !== "finalize_contract");
4445
+ invocation += 1;
4446
+ if (invocation > maxSessions)
4447
+ return { ...last, ok: false, failureCategory: "invalid-output", stderr: "CONTRACT_RECOVERY_EXHAUSTED: session quota exceeded" };
4448
+ last = await observeFrontendSession({
4449
+ ...input.observation, phase: "contract/scope", scopeIds, prompt, userMessage: input.sessionOptions.userMessage, customTools,
4450
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${invocation}.json` : undefined,
4451
+ committedCount: () => input.tools.committedFacts().length, durableCommittedCount: () => input.tools.committedFacts().length,
4452
+ }, observer => input.piStepFn({ ...input.sessionOptions, prompt, onAttemptObservation: observer, writerToolPolicy: { requireSdk: true, customTools } }));
4453
+ await input.tools.flush();
4454
+ if (last.timedOut || /interrupt|termination-unconfirmed|budget_breach/.test(last.failureCategory))
4455
+ return { ...last, ok: false };
4456
+ const capacityExhausted = readWriterThinkingExhaustionEvidence(last).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(last.failureCategory);
4457
+ if (capacityExhausted && !last.timedOut) {
4458
+ const missing = scopeIds.filter(id => !input.tools.completedScopeRequirementIds().has(id));
4459
+ if (!missing.length) {
4460
+ if (finalScope && !terminal()) {
4461
+ if (!scopeIds.length)
4462
+ return { ...last, ok: false, failureCategory: "invalid-output", stderr: `${last.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: contract terminal exhausted; unchanged retry disabled` };
4463
+ batches.push([]);
4464
+ }
4465
+ break;
4466
+ }
4467
+ if (missing.length === 1 && scopeIds.length > 1) {
4468
+ batches.splice(index, 1, inventory.payload.requirements.filter(r => missing.includes(r.id)));
4469
+ index -= 1;
4470
+ break;
4471
+ }
4472
+ if (missing.length <= 1)
4473
+ 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` };
4474
+ const smaller = packFrontendInputUnits(inventory.payload.requirements.filter(r => missing.includes(r.id)), { maxUnits: Math.ceil(missing.length / 2) });
4475
+ batches.splice(index, 1, ...smaller);
4476
+ index -= 1;
4477
+ break;
4478
+ }
4479
+ if (!last.ok)
4480
+ return last;
4481
+ const missing = scopeIds.filter(id => !input.tools.completedScopeRequirementIds().has(id));
4482
+ if (!missing.length && (!finalScope || terminal()))
4483
+ break;
4484
+ if (repair === 1)
4485
+ return { ...last, ok: false, failureCategory: "invalid-output", stderr: `CONTRACT_SCOPE_INCOMPLETE: ${missing.join(", ") || "missing finalize_contract"}` };
4486
+ }
4487
+ }
4488
+ return last;
4489
+ }
4490
+ finally {
4491
+ input.tools.setActiveRequirementScope(null);
4492
+ }
4493
+ }
4494
+ /** One workload view for both the outer parallel map and inner session queue.
4495
+ * A declared execution group is an indivisible unit during initial packing. */
4496
+ function buildFrontendPlanWorkload(input) {
4497
+ const allRequirementIds = input.requirementIds;
4498
+ const compiledInput = parseFrontendInputBlock(input.basePrompt, "plan")?.payload;
4499
+ const fullUnits = new Map(compiledInput?.requirements.map(r => [r.id, r]) ?? []);
4500
+ const declaredGroups = Array.isArray(compiledInput?.executionGroups)
4501
+ ? compiledInput.executionGroups.filter(isRecordObject) : [];
4502
+ const workGroups = declaredGroups.map(g => ({
4503
+ id: String(g.id), kind: String(g.kind),
4504
+ requirementIds: Array.isArray(g.requirementIds)
4505
+ ? g.requirementIds.filter((id) => typeof id === "string" && allRequirementIds.includes(id)) : [],
4506
+ })).filter(g => g.requirementIds.length);
4507
+ const groupedIds = new Set(workGroups.flatMap(g => g.requirementIds));
4508
+ for (const id of allRequirementIds) {
4509
+ if (!groupedIds.has(id))
4510
+ workGroups.push({ id, kind: "unclassified", requirementIds: [id] });
4511
+ }
4512
+ const workCost = (ids) => 1 + ids.reduce((total, id) => total + Math.max(1, (input.requirementCosts?.get(id) ?? 2) - 1), 0);
4513
+ const policy = input.sessionOptions.frontendExecutionPolicy;
4514
+ const targetBytes = policy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES;
4515
+ const buildWorkBatches = (ids) => {
4516
+ const work = workGroups.flatMap((g, index) => {
4517
+ const members = g.requirementIds.filter(id => ids.includes(id));
4518
+ return members.length ? [{ id: `${index}:${g.id}`, requirementIds: members,
4519
+ requirements: members.map(id => fullUnits.get(id) ?? { id }), estimatedCalls: workCost(members) }] : [];
4520
+ });
4521
+ // Pack complete work units, not the repeated request scaffold. Deducting
4522
+ // fixed context/tools can leave a one-byte budget and force one AC per
4523
+ // session without reducing that overhead. The SDK checks the actual
4524
+ // request against model capacity; capacity recovery splits unfinished work.
4525
+ return packFrontendInputUnits(work, {
4526
+ targetBytes,
4527
+ maxUnits: policy?.maxScopeUnits ?? 4,
4528
+ maxCost: FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS, cost: g => g.estimatedCalls,
4529
+ }).map(batch => batch.flatMap(g => g.requirementIds));
4530
+ };
4531
+ // This renderer also carries the Plan's evolving UX checkpoint on resume.
4532
+ // Only the Contract/Scout-derived input participates in ownership binding.
4533
+ const { committedUx: _committedUx, ...frozenInput } = compiledInput ?? {};
4534
+ return {
4535
+ frozenInput, workGroups, buildWorkBatches,
4536
+ compactEligible: input.requirementCosts !== undefined &&
4537
+ workGroups.length <= FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS &&
4538
+ workGroups.reduce((total, group) => total + workCost(group.requirementIds), 0) <= FRONTEND_PLAN_SMALL_MAX_ESTIMATED_CALLS &&
4539
+ countFrontendPlanTargetSurfaces(input.basePrompt) === 1,
4540
+ };
4541
+ }
4542
+ const frontendPlanCoverageLayoutSchema = z.object({
4543
+ schemaVersion: z.literal(1),
4544
+ bindingSha256: z.string(),
4545
+ coverageBatches: z.array(z.array(z.string().min(1)).min(1)).min(1),
4546
+ layoutSha256: z.string(),
4547
+ }).strict();
4548
+ /** Freeze ledger ownership before any provider call. Session packing remains
4549
+ * adaptive inside each owner, but retry instructions cannot rename its scope. */
4550
+ async function loadOrCreateFrontendPlanCoverageLayout(input) {
4551
+ const fileName = "coverage-layout.json";
4552
+ const file = path.join(input.runDir, input.nodeId, fileName);
4553
+ const bindingSha256 = sha256OfCanonicalJson(input.binding);
4554
+ let raw;
4555
+ try {
4556
+ raw = await readFile(file, "utf8");
4557
+ }
4558
+ catch (error) {
4559
+ if (error.code !== "ENOENT")
4560
+ throw error;
4561
+ }
4562
+ let layout;
4563
+ if (raw !== undefined) {
4564
+ layout = frontendPlanCoverageLayoutSchema.parse(JSON.parse(raw));
4565
+ }
4566
+ else {
4567
+ // Losing the ownership receipt must never repartition acknowledged facts.
4568
+ for (const relative of ["plan-typed-facts.jsonl", "parallel"]) {
4569
+ const existing = await stat(path.join(input.runDir, input.nodeId, relative)).catch(error => {
4570
+ if (error.code !== "ENOENT")
4571
+ throw error;
4572
+ return undefined;
4573
+ });
4574
+ if (existing && (existing.isDirectory() || existing.size > 0)) {
4575
+ throw Error("FRONTEND_PLAN_LAYOUT_MISSING: preserve the existing ledger and restart its owning phase");
4576
+ }
4577
+ }
4578
+ const descriptor = {
4579
+ schemaVersion: 1, bindingSha256,
4580
+ coverageBatches: input.workload.compactEligible ? [input.requirementIds] : input.workload.buildWorkBatches(input.requirementIds),
4581
+ };
4582
+ layout = { ...descriptor, layoutSha256: sha256OfCanonicalJson(descriptor) };
4583
+ }
4584
+ const { layoutSha256, ...descriptor } = layout;
4585
+ if (layout.bindingSha256 !== bindingSha256 || layoutSha256 !== sha256OfCanonicalJson(descriptor)) {
4586
+ throw Error("FRONTEND_PLAN_LAYOUT_MISMATCH: frozen coverage ownership or input binding changed");
4587
+ }
4588
+ const members = layout.coverageBatches.flat();
4589
+ const owners = new Map(layout.coverageBatches.flatMap((batch, index) => batch.map(id => [id, index])));
4590
+ if (members.length !== owners.size || members.length !== input.requirementIds.length ||
4591
+ input.requirementIds.some(id => !owners.has(id)) ||
4592
+ input.workload.workGroups.some(group => new Set(group.requirementIds.map(id => owners.get(id))).size !== 1)) {
4593
+ throw Error("FRONTEND_PLAN_LAYOUT_INVALID: coverage must partition complete execution groups exactly once");
4594
+ }
4595
+ if (raw === undefined)
4596
+ await writeDagNodeJsonArtifact(input.runDir, input.nodeId, fileName, layout);
4597
+ return layout.coverageBatches;
4598
+ }
4158
4599
  export async function runFrontendPlanSegmentedSessions(input) {
4159
4600
  const queue = [];
4160
4601
  const coverageSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "coverage");
@@ -4163,10 +4604,10 @@ export async function runFrontendPlanSegmentedSessions(input) {
4163
4604
  const globalMockDataSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "global-mock-data");
4164
4605
  const finalizeSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "finalize");
4165
4606
  const allRequirementIds = input.requirementIds ?? [];
4166
- const buildPhasePrompt = (segment, missing = []) => {
4607
+ const buildPhasePrompt = (segment, missing = [], scopeIds = allRequirementIds) => {
4167
4608
  const compact = allRequirementIds.length > 0
4168
- ? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds, {
4169
- includeRequirementText: segment.id === "global-mock-data" || segment.id === "global-dependency-deviation",
4609
+ ? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, scopeIds, {
4610
+ includeRequirementText: segment.id === "global-mock-data",
4170
4611
  includeVerificationTargets: false,
4171
4612
  includeDesignEvidence: segment.id === "global-dependency-deviation" || segment.id === "finalize",
4172
4613
  includeChecklist: false,
@@ -4182,8 +4623,9 @@ export async function runFrontendPlanSegmentedSessions(input) {
4182
4623
  const ledger = input.committedFacts
4183
4624
  ? compactFrontendPlanLedgerContext({
4184
4625
  committedFacts: input.committedFacts(),
4185
- requirementIds: allRequirementIds,
4626
+ requirementIds: scopeIds,
4186
4627
  kinds,
4628
+ ...(segment.id === "global-mock-data" ? { scopedKinds: ["state-flow"] } : {}),
4187
4629
  })
4188
4630
  : "";
4189
4631
  return [
@@ -4221,13 +4663,13 @@ export async function runFrontendPlanSegmentedSessions(input) {
4221
4663
  "component-choice",
4222
4664
  "state-flow",
4223
4665
  ],
4224
- scopedKinds: ["component-choice", "state-flow"],
4666
+ // Include shared state bindings from other scopes; page full values before correcting.
4225
4667
  })
4226
4668
  : "";
4227
4669
  return [
4228
4670
  compactFrontendPlanPromptForRequirementSlice(input.basePrompt, slice),
4229
4671
  uxSegment.instruction,
4230
- `GLOBAL UX SCOPE: process the complete requirement set together: ${slice.join(", ")}. Do not split or rename one behavior by requirement id.`,
4672
+ `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
4673
  ledger,
4232
4674
  ...(missing.length > 0
4233
4675
  ? [
@@ -4251,6 +4693,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4251
4693
  : "";
4252
4694
  const compact = allRequirementIds.length > 0
4253
4695
  ? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds, {
4696
+ includeRequirementText: false,
4254
4697
  includeVerificationTargets: false,
4255
4698
  includeDesignEvidence: false,
4256
4699
  includeChecklist: false,
@@ -4296,7 +4739,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4296
4739
  : []),
4297
4740
  ].filter(Boolean).join("\n\n");
4298
4741
  };
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.";
4742
+ 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
4743
  const buildCompactFinalizePrompt = (missing = []) => [buildPhasePrompt(finalizeSegment, missing), compactFinalizeInstruction].join("\n\n");
4301
4744
  const mapPlannerExhaustion = (r, committedAnyFacts) => isPlannerThinkingExhausted(r, committedAnyFacts)
4302
4745
  ? {
@@ -4340,8 +4783,10 @@ export async function runFrontendPlanSegmentedSessions(input) {
4340
4783
  tokensUsed: 0,
4341
4784
  };
4342
4785
  }
4343
- const estimatedCalls = (input.requirementIds ?? []).reduce((total, id) => total + Math.max(1, input.requirementCosts?.get(id) ?? 2), 0);
4344
- const targetSurfaceCount = countFrontendPlanTargetSurfaces(input.basePrompt);
4786
+ const { workGroups, buildWorkBatches, compactEligible } = buildFrontendPlanWorkload({
4787
+ basePrompt: input.basePrompt, requirementIds: allRequirementIds,
4788
+ requirementCosts: input.requirementCosts, sessionOptions: input.sessionOptions,
4789
+ });
4345
4790
  // Small, single-surface requests do not benefit from six isolated Pi
4346
4791
  // sessions. Keep the typed ledger as the authority, but let one local
4347
4792
  // session establish requirement/UX facts and one final session establish
@@ -4349,14 +4794,11 @@ export async function runFrontendPlanSegmentedSessions(input) {
4349
4794
  // for larger plans and for the unscoped compatibility path.
4350
4795
  const useCompactSmallPlan = requirementIdsProvided &&
4351
4796
  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;
4797
+ compactEligible;
4357
4798
  if (useCompactSmallPlan) {
4358
4799
  const compactLocalTools = new Set([
4359
4800
  "record_plan_requirement",
4801
+ "record_plan_group_coverage",
4360
4802
  "record_plan_verification_target",
4361
4803
  "record_plan_evidence_gap",
4362
4804
  "record_state_registry",
@@ -4384,13 +4826,8 @@ export async function runFrontendPlanSegmentedSessions(input) {
4384
4826
  });
4385
4827
  }
4386
4828
  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({
4829
+ const completeBatches = buildWorkBatches(coverageWorkIds);
4830
+ completeBatches.forEach((slice, batchIndex) => queue.push({
4394
4831
  id: `coverage-batch-${batchIndex + 1}`,
4395
4832
  toolNames: coverageSegment.toolNames,
4396
4833
  coverageSlice: slice,
@@ -4409,28 +4846,27 @@ export async function runFrontendPlanSegmentedSessions(input) {
4409
4846
  prompt: buildUxRegistryPrompt(),
4410
4847
  });
4411
4848
  }
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
- });
4849
+ const uxWorkIds = workGroups.filter(g => !["constraint", "exclusion"].includes(g.kind) || g.requirementIds.some(id => input.behaviorRequiredRequirementIds?.includes(id))).flatMap(g => g.requirementIds);
4850
+ const uxBatches = requirementIdsProvided ? buildWorkBatches(uxWorkIds) : [[]];
4851
+ uxBatches.forEach((slice, batchIndex) => queue.push({
4852
+ id: `ux-local-${batchIndex + 1}`,
4853
+ toolNames: requirementIdsProvided ? uxSegment.toolNames : new Set([...(uxSegment.toolNames ?? []), "record_state_registry"]),
4854
+ ...(slice.length ? { requirementSlice: slice } : {}),
4855
+ prompt: slice.length ? buildUxPrompt(slice) : buildPhasePrompt(uxSegment),
4856
+ }));
4426
4857
  for (const segment of FRONTEND_PLAN_SEGMENTS) {
4427
4858
  if (["coverage", "ux-registry", "ux-local", "finalize"].includes(segment.id))
4428
4859
  continue;
4429
- queue.push({
4430
- id: segment.id,
4431
- toolNames: segment.toolNames,
4432
- prompt: buildPhasePrompt(segment),
4433
- });
4860
+ if (segment.id === "global-mock-data" && requirementIdsProvided) {
4861
+ buildWorkBatches(allRequirementIds).forEach((slice, i) => queue.push({ id: `global-mock-data-${i + 1}`, toolNames: segment.toolNames, requirementSlice: slice, prompt: buildPhasePrompt(segment, [], slice) }));
4862
+ }
4863
+ else {
4864
+ queue.push({
4865
+ id: segment.id,
4866
+ toolNames: segment.toolNames,
4867
+ prompt: buildPhasePrompt(segment),
4868
+ });
4869
+ }
4434
4870
  }
4435
4871
  queue.push({
4436
4872
  id: finalizeSegment.id,
@@ -4441,8 +4877,30 @@ export async function runFrontendPlanSegmentedSessions(input) {
4441
4877
  let accumulated;
4442
4878
  let index = 0;
4443
4879
  let invocationCount = 0;
4880
+ let lastDurableCount = input.committedFactCount();
4444
4881
  while (index < queue.length) {
4445
4882
  const session = queue[index];
4883
+ if (input.attempt > 1 && input.committedFacts && session.id === "ux-registry" &&
4884
+ collectFrontendPlanPhaseMissingFacts({ phase: "ux-registry", requirementIds: allRequirementIds, committedFacts: input.committedFacts() }).length === 0) {
4885
+ index += 1;
4886
+ continue;
4887
+ }
4888
+ // Reuse complete UX scopes on node retry. Require explicit per-requirement
4889
+ // ownership; an unrelated/global fact must not prove a slice complete.
4890
+ if (input.attempt > 1 && input.committedFacts && session.id.startsWith("ux-local-") && session.requirementSlice?.length) {
4891
+ const facts = input.committedFacts();
4892
+ const ownedIds = new Set(facts.flatMap(value => {
4893
+ const fact = committedFactFromPlanRecord(value);
4894
+ return fact ? planFactStringList(fact.scopeRequirementIds) : [];
4895
+ }));
4896
+ const complete = session.requirementSlice.every(id => ownedIds.has(id)) &&
4897
+ collectFrontendPlanPhaseMissingFacts({ phase: "ux-local", requirementIds: session.requirementSlice,
4898
+ committedFacts: facts, behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds }).length === 0;
4899
+ if (complete) {
4900
+ index += 1;
4901
+ continue;
4902
+ }
4903
+ }
4446
4904
  const remaining = (session.coverageOnly ? session.coverageSlice ?? [] : []).filter((id) => !input.committedRequirementIds?.().has(id));
4447
4905
  const preexistingMissing = session.coverageOnly && session.coverageSlice && input.committedFacts
4448
4906
  ? collectFrontendPlanMissingFacts({
@@ -4460,7 +4918,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4460
4918
  }
4461
4919
  let prompt = session.prompt;
4462
4920
  if (session.coverageOnly && session.coverageSlice) {
4463
- const promptSlice = remaining.length > 0 ? remaining : session.coverageSlice;
4921
+ const promptSlice = [...new Set([...remaining, ...preexistingMissing.flatMap(item => item.requirementIds)])];
4464
4922
  prompt = buildCoveragePrompt(promptSlice, session.missingFacts ?? preexistingMissing);
4465
4923
  }
4466
4924
  else if (session.id === "compact-local") {
@@ -4480,45 +4938,68 @@ export async function runFrontendPlanSegmentedSessions(input) {
4480
4938
  // Global phases and finalize also consume the latest committed ledger;
4481
4939
  // constructing their prompt only when the session starts prevents a
4482
4940
  // stale queue entry from dropping facts written by earlier phases.
4483
- const segment = FRONTEND_PLAN_SEGMENTS.find((candidate) => candidate.id === session.id);
4941
+ const segment = FRONTEND_PLAN_SEGMENTS.find((candidate) => candidate.id === session.id || (candidate.id === "global-mock-data" && session.id.startsWith("global-mock-data-")));
4484
4942
  if (segment) {
4485
4943
  prompt =
4486
4944
  session.id === "finalize" && useCompactSmallPlan
4487
4945
  ? buildCompactFinalizePrompt(session.missingFacts)
4488
- : buildPhasePrompt(segment, session.missingFacts);
4946
+ : buildPhasePrompt(segment, session.missingFacts, session.requirementSlice ?? allRequirementIds);
4489
4947
  }
4490
4948
  }
4949
+ const atomicFocus = session.atomicRecovery ? session.missingFacts?.[0] : undefined;
4950
+ if (atomicFocus) {
4951
+ prompt = [
4952
+ compactFrontendPlanPromptForRequirementSlice(input.basePrompt, atomicFocus.requirementIds, {
4953
+ includeChecklist: false,
4954
+ ...(atomicFocus.kind === "plan-verification-target" && atomicFocus.id
4955
+ ? { verificationTargetIds: [atomicFocus.id] } : {}),
4956
+ }),
4957
+ `ATOMIC FACT: ${atomicFocus.kind}${atomicFocus.id ? ` ${atomicFocus.id}` : ""}`,
4958
+ atomicFocus.reason,
4959
+ "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.",
4960
+ ].join("\n\n");
4961
+ }
4491
4962
  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
- : []);
4963
+ input.setActiveRequirementScope?.(session.coverageOnly ? session.coverageSlice ?? [] : session.requirementSlice ?? []);
4497
4964
  if (invocationCount >= FRONTEND_PLAN_BATCH_MAX_SESSIONS)
4498
4965
  break;
4499
4966
  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
- : {}),
4967
+ const atomicTools = {
4968
+ "plan-requirement": "record_plan_requirement", "plan-verification-target": "record_plan_verification_target",
4969
+ "state-registry": "record_state_registry", "component-choice": "record_component_choice",
4970
+ "state-flow": "record_state_flow", "data-flow": "record_data_flow", "mock-api": "record_mock_api",
4971
+ };
4972
+ const customTools = input.segmentCustomTools(atomicFocus ? new Set([atomicTools[atomicFocus.kind]]) : session.toolNames);
4973
+ const result = await observeFrontendSession({
4974
+ ...input.observation, phase: `plan/${session.id}`, scopeIds: session.requirementSlice ?? session.coverageSlice ?? allRequirementIds,
4975
+ prompt, userMessage: input.sessionOptions.userMessage, customTools, committedCount: input.committedFactCount, durableCommittedCount: () => lastDurableCount,
4976
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${invocationCount}.json` : undefined,
4977
+ }, async (observer) => {
4978
+ const result = await input.piStepFn({
4979
+ ...input.sessionOptions,
4980
+ onAttemptObservation: observer,
4981
+ prompt,
4982
+ ...(customTools.length > 0
4983
+ ? {
4984
+ writerToolPolicy: {
4985
+ requireSdk: true,
4986
+ customTools,
4987
+ },
4988
+ }
4989
+ : {}),
4990
+ });
4991
+ try {
4992
+ await input.flushLedger();
4993
+ lastDurableCount = input.committedFactCount();
4994
+ }
4995
+ catch {
4996
+ // best-effort: the node-level flush runs again after the attempt
4997
+ }
4998
+ return result;
4512
4999
  });
4513
5000
  accumulated = accumulated
4514
5001
  ? combineSequentialPiResults(accumulated, result)
4515
5002
  : result;
4516
- try {
4517
- await input.flushLedger();
4518
- }
4519
- catch {
4520
- // best-effort: the node-level flush runs again after the attempt
4521
- }
4522
5003
  const committedAfter = input.committedFactCount();
4523
5004
  const committedFactsOnlySuccess = session.id !== "finalize" &&
4524
5005
  !(result.assistantText ?? "").trim() &&
@@ -4565,10 +5046,10 @@ export async function runFrontendPlanSegmentedSessions(input) {
4565
5046
  committedFacts: input.committedFacts(),
4566
5047
  behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds,
4567
5048
  })
4568
- : session.id === "global-mock-data" && allRequirementIds.length > 0 && input.committedFacts
5049
+ : session.id.startsWith("global-mock-data") && allRequirementIds.length > 0 && input.committedFacts
4569
5050
  ? collectFrontendPlanPhaseMissingFacts({
4570
5051
  phase: "global-mock-data",
4571
- requirementIds: allRequirementIds,
5052
+ requirementIds: session.requirementSlice ?? allRequirementIds,
4572
5053
  committedFacts: input.committedFacts(),
4573
5054
  })
4574
5055
  : [];
@@ -4605,7 +5086,96 @@ export async function runFrontendPlanSegmentedSessions(input) {
4605
5086
  ? buildCompactFinalizePrompt(missing)
4606
5087
  : isUxLocalSession
4607
5088
  ? buildUxPrompt(session.requirementSlice ?? [], missing)
4608
- : buildPhasePrompt(globalMockDataSegment, missing);
5089
+ : buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds);
5090
+ const recovery = classifyFrontendPlanRecovery({ ...result, stopReason: readWriterThinkingExhaustionEvidence(result).stopReason });
5091
+ if (recovery === "stop")
5092
+ return { ...result, ok: false };
5093
+ if ((recovery === "output" || (session.atomicRecovery && result.ok)) && input.committedFacts &&
5094
+ !(missingPhaseFacts.length === 1 && missingPhaseFacts[0].kind === "plan-requirement") &&
5095
+ (session.coverageOnly || isUxRegistrySession || isUxLocalSession || session.id.startsWith("global-mock-data"))) {
5096
+ if (missingPhaseFacts.length === 0) {
5097
+ // A complete validated slice does not need a successful prose turn.
5098
+ // This never accepts finalize or bypasses the final contract validator.
5099
+ accumulated = { ...accumulated, ok: true, failureCategory: "success" };
5100
+ index += 1;
5101
+ continue;
5102
+ }
5103
+ const missingIds = [...new Set(missingPhaseFacts.flatMap(item => item.requirementIds))];
5104
+ const scope = session.coverageSlice ?? session.requirementSlice;
5105
+ if (scope && missingIds.length < scope.length) {
5106
+ queue[index] = { ...session, missingFacts: missingPhaseFacts,
5107
+ ...(session.coverageOnly ? { coverageSlice: missingIds } : { requirementSlice: missingIds }),
5108
+ retryCount: 0 };
5109
+ continue;
5110
+ }
5111
+ if (scope && missingIds.length > 1 && (session.coverageOnly || isUxLocalSession)) {
5112
+ const half = Math.ceil(missingIds.length / 2);
5113
+ queue.splice(index, 1, ...[missingIds.slice(0, half), missingIds.slice(half)].map(ids => ({
5114
+ ...session, retryCount: 0,
5115
+ ...(session.coverageOnly ? { coverageSlice: ids } : { requirementSlice: ids }),
5116
+ missingFacts: missingPhaseFacts.filter(item => item.requirementIds.some(id => ids.includes(id))),
5117
+ })));
5118
+ continue;
5119
+ }
5120
+ const focusStillMissing = atomicFocus && missingPhaseFacts.some(item => item.kind === atomicFocus.kind && item.id === atomicFocus.id &&
5121
+ item.requirementIds.join("\0") === atomicFocus.requirementIds.join("\0"));
5122
+ const retries = focusStillMissing ? (session.retryCount ?? 0) + 1 : 0;
5123
+ if (retries < 2) {
5124
+ queue[index] = { ...session, atomicRecovery: true, missingFacts: missingPhaseFacts, retryCount: retries };
5125
+ continue;
5126
+ }
5127
+ return { ...accumulated, ok: false, failureCategory: OUTPUT_LIMIT_RETRY_CATEGORY,
5128
+ 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() };
5129
+ }
5130
+ const capacityExhausted = recovery === "output" || recovery === "context";
5131
+ if (capacityExhausted && !result.timedOut) {
5132
+ 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);
5133
+ 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)];
5134
+ const splitScope = (ids, allowMemberSplit) => {
5135
+ const groups = workGroups.map(g => g.requirementIds.filter(id => ids.includes(id))).filter(g => g.length);
5136
+ if (groups.length > 1) {
5137
+ const half = Math.ceil(groups.length / 2);
5138
+ return [groups.slice(0, half).flat(), groups.slice(half).flat()];
5139
+ }
5140
+ if (allowMemberSplit && ids.length > 1) {
5141
+ const half = Math.ceil(ids.length / 2);
5142
+ return [ids.slice(0, half), ids.slice(half)];
5143
+ }
5144
+ return [];
5145
+ };
5146
+ if (isCompactLocalSession) {
5147
+ let scopes = splitScope(missingCoverageIds, true);
5148
+ if (!scopes.length && missingCoverageIds.length) {
5149
+ if (missingCoverageIds.length === allRequirementIds.length && committedAfter === committedBefore)
5150
+ return failure();
5151
+ scopes = [missingCoverageIds];
5152
+ }
5153
+ // UX ownership is independent of coverage completion. Preserve all groups.
5154
+ const recovery = scopes.map((slice, i) => ({ id: `coverage-capacity-${invocationCount}-${i}`, coverageOnly: true, coverageSlice: slice, toolNames: coverageSegment.toolNames, prompt: buildCoveragePrompt(slice) }));
5155
+ recovery.push({ id: "ux-registry", toolNames: uxRegistrySegment.toolNames, prompt: buildUxRegistryPrompt() });
5156
+ recovery.push(...buildWorkBatches([...allRequirementIds]).map((slice, i) => ({ id: `ux-local-capacity-${invocationCount}-${i}`, requirementSlice: slice, toolNames: uxSegment.toolNames, prompt: buildUxPrompt(slice) })));
5157
+ queue.splice(index, 1, ...recovery);
5158
+ continue;
5159
+ }
5160
+ if (session.coverageOnly || isUxLocalSession) {
5161
+ const missingIds = session.coverageOnly ? missingCoverageIds : [...new Set(missingPhase.flatMap(f => f.requirementIds))];
5162
+ if (!missingIds.length) {
5163
+ index += 1;
5164
+ continue;
5165
+ }
5166
+ const scopes = splitScope(missingIds, Boolean(session.coverageOnly));
5167
+ if (scopes.length) {
5168
+ 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 })));
5169
+ continue;
5170
+ }
5171
+ const originalIds = session.coverageSlice ?? session.requirementSlice ?? [];
5172
+ if ((missingIds.length < originalIds.length || committedAfter > committedBefore) && (session.retryCount ?? 0) < 2) {
5173
+ queue[index] = { ...session, ...(session.coverageOnly ? { coverageSlice: missingIds } : { requirementSlice: missingIds }), missingFacts: missingPhaseFacts, retryCount: (session.retryCount ?? 0) + 1 };
5174
+ continue;
5175
+ }
5176
+ }
5177
+ return failure();
5178
+ }
4609
5179
  if (result.ok) {
4610
5180
  if (missingPhaseFacts.length === 0) {
4611
5181
  index += 1;
@@ -4649,24 +5219,6 @@ export async function runFrontendPlanSegmentedSessions(input) {
4649
5219
  index += 1;
4650
5220
  continue;
4651
5221
  }
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
5222
  // Option 5: a multi-requirement coverage batch that failed with ZERO
4671
5223
  // new facts and no provider stderr is the upfront-reasoning burn —
4672
5224
  // halve the slice and retry instead of failing the attempt. The compact
@@ -4681,20 +5233,24 @@ export async function runFrontendPlanSegmentedSessions(input) {
4681
5233
  : undefined;
4682
5234
  const zeroProgressBurn = coverageSlice !== undefined &&
4683
5235
  coverageSlice.length > 1 &&
4684
- committedAfter === committedBefore &&
4685
- !(result.assistantText ?? "").trim() &&
4686
- !result.stderr.trim() &&
5236
+ (capacityExhausted || (["empty-output", "unknown"].includes(result.failureCategory) && committedAfter === committedBefore && !(result.assistantText ?? "").trim() && !result.stderr.trim())) &&
4687
5237
  !result.timedOut;
4688
5238
  if (zeroProgressBurn && coverageSlice) {
4689
- const half = Math.ceil(coverageSlice.length / 2);
4690
- const firstSlice = coverageSlice.slice(0, half);
4691
- const secondSlice = coverageSlice.slice(half);
5239
+ const missingIds = input.committedFacts ? new Set(collectFrontendPlanMissingFacts({ requirementIds: coverageSlice, committedFacts: input.committedFacts() }).flatMap(f => f.requirementIds)) : undefined;
5240
+ const unfinished = coverageSlice.filter(id => !missingIds || missingIds.has(id));
5241
+ if (unfinished.length <= 1)
5242
+ 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);
5243
+ const half = Math.ceil(unfinished.length / 2);
5244
+ const firstSlice = unfinished.slice(0, half);
5245
+ const secondSlice = unfinished.slice(half);
4692
5246
  if (isCompactLocalSession) {
5247
+ 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
5248
  // The split halves leave compact mode: continue them as ordinary
4694
5249
  // coverage sessions so every downstream ladder branch applies.
4695
5250
  queue.splice(index, 1, {
4696
5251
  ...session,
4697
5252
  id: `coverage-compact-split-1`,
5253
+ toolNames: coverageSegment.toolNames, retryCount: 0,
4698
5254
  coverageOnly: true,
4699
5255
  coverageSlice: firstSlice,
4700
5256
  requirementSlice: firstSlice,
@@ -4702,6 +5258,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4702
5258
  }, {
4703
5259
  ...session,
4704
5260
  id: `coverage-compact-split-2`,
5261
+ toolNames: coverageSegment.toolNames, retryCount: 0,
4705
5262
  coverageOnly: true,
4706
5263
  coverageSlice: secondSlice,
4707
5264
  requirementSlice: secondSlice,
@@ -4720,7 +5277,9 @@ export async function runFrontendPlanSegmentedSessions(input) {
4720
5277
  });
4721
5278
  continue;
4722
5279
  }
4723
- if ((session.coverageOnly || isUxRegistrySession || isUxLocalSession || session.id === "global-mock-data") &&
5280
+ if (readWriterThinkingExhaustionEvidence(result).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(result.failureCategory))
5281
+ 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);
5282
+ if ((session.coverageOnly || isUxRegistrySession || isUxLocalSession || session.id.startsWith("global-mock-data")) &&
4724
5283
  missingPhaseFacts.length > 0 &&
4725
5284
  !result.stderr.trim() &&
4726
5285
  !result.timedOut &&
@@ -4852,14 +5411,27 @@ async function runFrontendScoutParallelSessions(input) {
4852
5411
  ...input.sessionOptions,
4853
5412
  sessionEventsPath: path.join(input.runDir, shardNodeId, "session-events.jsonl"),
4854
5413
  };
4855
- shardResult = await input.piStepFn({
5414
+ const prompt = `${input.basePrompt}\n\n${shard.instruction}`;
5415
+ const tools = [...customTools, ...(input.readBudgetTools ?? [])];
5416
+ shardResult = await observeFrontendSession({
5417
+ ...input.observation,
5418
+ phase: `scout/parallel/${shard.id}`,
5419
+ scopeIds: [shard.id],
5420
+ prompt,
5421
+ userMessage: sessionOptions.userMessage,
5422
+ customTools: tools,
5423
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${shard.id}.json` : undefined,
5424
+ committedCount: () => shardTools?.committedFacts().length ?? 0,
5425
+ durableCommittedCount: () => shardTools?.committedFacts().length ?? 0,
5426
+ }, observer => input.piStepFn({
4856
5427
  ...sessionOptions,
4857
- prompt: `${input.basePrompt}\n\n${shard.instruction}`,
5428
+ onAttemptObservation: observer,
5429
+ prompt,
4858
5430
  writerToolPolicy: {
4859
5431
  requireSdk: true,
4860
- customTools: [...customTools, ...(input.readBudgetTools ?? [])],
5432
+ customTools: tools,
4861
5433
  },
4862
- });
5434
+ }));
4863
5435
  await shardTools.flush();
4864
5436
  return { shard, result: shardResult, tools: shardTools };
4865
5437
  }
@@ -5035,6 +5607,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5035
5607
  let writerToolPolicy;
5036
5608
  let reviewTerminalTools;
5037
5609
  let designTerminalTools;
5610
+ let reviewInventory;
5611
+ let designInventory;
5038
5612
  let planLedgerTools;
5039
5613
  let contractTools;
5040
5614
  let scoutEvidenceTools;
@@ -5128,6 +5702,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5128
5702
  const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5129
5703
  const store = createTypedEventStore();
5130
5704
  reviewTerminalTools = await createFrontendReviewTerminalTools({
5705
+ inventory: reviewInventory = await loadFrontendReviewScopes(meta.runDir, "review", meta.spec?.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES),
5706
+ inputDigest: reviewInventory?.digest ?? createHash("sha256").update(input.prompt).digest("hex"),
5131
5707
  attemptId: `${meta.runId}:${input.task.id}`,
5132
5708
  store,
5133
5709
  runDir: meta.runDir,
@@ -5153,6 +5729,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5153
5729
  const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5154
5730
  const store = createTypedEventStore();
5155
5731
  designTerminalTools = await createFrontendDesignTerminalTools({
5732
+ inventory: designInventory = await loadFrontendReviewScopes(meta.runDir, "design", meta.spec?.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES),
5733
+ inputDigest: designInventory?.digest ?? createHash("sha256").update(input.prompt).digest("hex"),
5156
5734
  attemptId: `${meta.runId}:${input.task.id}`,
5157
5735
  store,
5158
5736
  runDir: meta.runDir,
@@ -5178,6 +5756,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5178
5756
  const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5179
5757
  const store = createTypedEventStore();
5180
5758
  contractTools = await createFrontendContractTools({
5759
+ sourceDigest: (meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined),
5181
5760
  attemptId: `${meta.runId}:${input.task.id}`,
5182
5761
  store,
5183
5762
  runDir: meta.runDir,
@@ -5211,6 +5790,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5211
5790
  spec: meta.spec,
5212
5791
  });
5213
5792
  scoutEvidenceTools = await createFrontendScoutEvidenceTools({
5793
+ requirementIds: parseFrontendInputBlock(input.prompt, "scout")?.payload.requirements.map(r => r.id),
5794
+ sourceDigest: (meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined),
5214
5795
  attemptId: `${meta.runId}:${input.task.id}`,
5215
5796
  store,
5216
5797
  runDir: meta.runDir,
@@ -5368,8 +5949,11 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5368
5949
  activeTools: toolNames,
5369
5950
  });
5370
5951
  const piSessionOptions = {
5952
+ reserveProviderRequest: input.reserveProviderRequest,
5953
+ frontendExecutionPolicy: meta.spec?.frontendExecutionPolicy,
5371
5954
  attachedFiles: [],
5372
5955
  modelConfig,
5956
+ outputLimitRecovery: { terminalToolNames: ["finalize_plan", "finalize_contract", "approve_design", "request_design_changes", "approve_review", "request_review_changes"] },
5373
5957
  repoRoot: input.cwd,
5374
5958
  step,
5375
5959
  toolNames,
@@ -5409,6 +5993,15 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5409
5993
  workspaceRoot: input.cwd,
5410
5994
  sourceDeclaredPaths: scoutSourceDeclaredPaths,
5411
5995
  readBudgetTools: readBudgetTools?.customTools,
5996
+ observation: {
5997
+ artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`),
5998
+ runId: meta.runId,
5999
+ nodeId: input.task.id,
6000
+ taskId: meta.spec?.sourceBinding?.taskId,
6001
+ attempt: input.attempt ?? 1,
6002
+ model: input.model,
6003
+ sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined,
6004
+ },
5412
6005
  });
5413
6006
  }
5414
6007
  else if (isFrontendPlanLedgerNode(input.task) && planLedgerTools) {
@@ -5448,6 +6041,16 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5448
6041
  // finalize reducer. This avoids revision races while shortening the
5449
6042
  // longest coverage phase for large plans.
5450
6043
  const runPlanSessions = (options) => runFrontendPlanSegmentedSessions({
6044
+ observation: options.observation ?? {
6045
+ artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`),
6046
+ runId: meta.runId,
6047
+ nodeId: input.task.id,
6048
+ taskId: meta.spec?.sourceBinding?.taskId,
6049
+ attempt: input.attempt ?? 1,
6050
+ model: input.model,
6051
+ sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined,
6052
+ contractDigest: meta.spec?.taskContractBinding?.canonicalHash,
6053
+ },
5451
6054
  piStepFn,
5452
6055
  sessionOptions: options.sessionOptions,
5453
6056
  basePrompt: options.basePrompt ?? input.prompt,
@@ -5469,154 +6072,162 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5469
6072
  ? options.ledgerTools.customTools
5470
6073
  : options.ledgerTools.customTools.filter((tool) => typeof tool === "object" &&
5471
6074
  tool !== null &&
5472
- toolNames.has(tool.name)),
6075
+ (toolNames.has(tool.name) || tool.name === "read_plan_facts")),
5473
6076
  flushLedger: () => options.ledgerTools.flush(),
5474
6077
  });
5475
6078
  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,
6079
+ const workload = buildFrontendPlanWorkload({
6080
+ basePrompt: input.prompt, requirementIds: planRequirementIds,
6081
+ requirementCosts: planRequirementCosts, sessionOptions: piSessionOptions,
6082
+ });
6083
+ let coverageBatches;
6084
+ try {
6085
+ coverageBatches = await loadOrCreateFrontendPlanCoverageLayout({
6086
+ runDir: meta.runDir, nodeId: input.task.id, workload, requirementIds: planRequirementIds,
6087
+ binding: {
6088
+ runId: meta.runId, nodeId: input.task.id, sourceBinding: meta.spec.sourceBinding,
6089
+ skeleton: input.task.structuredContractOutput?.skeleton, writeSet: input.task.writeSet,
6090
+ policy: meta.spec.frontendExecutionPolicy, requirements: [...planRequirementCosts],
6091
+ compiledInput: workload.frozenInput, workGroups: workload.workGroups,
6092
+ },
5488
6093
  });
5489
6094
  }
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(),
6095
+ catch (error) {
6096
+ return { ok: false, stdout: "", durationMs: Date.now() - started, failureCategory: "tool-policy",
6097
+ stderr: `frontend plan coverage layout unavailable: ${error instanceof Error ? error.message : String(error)}` };
6098
+ }
6099
+ if (coverageBatches.length > 1) {
6100
+ const shardResults = await mapWithConcurrency(coverageBatches, piSessionOptions.frontendExecutionPolicy?.maxCoverageConcurrency ?? FRONTEND_PLAN_COVERAGE_MAX_CONCURRENCY, async (slice, index) => {
6101
+ let shardTools;
6102
+ let shardResult;
6103
+ try {
6104
+ const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
6105
+ shardTools = await createFrontendPlanLedgerTools({
6106
+ attemptId: `${meta.runId}:${input.task.id}:parallel:${index + 1}`,
6107
+ store: createTypedEventStore(),
6108
+ runDir: meta.runDir,
6109
+ nodeId: `${input.task.id}/parallel/coverage-${index + 1}`,
6110
+ skeleton: input.task.structuredContractOutput?.skeleton,
6111
+ sourceBinding: meta.spec.sourceBinding,
6112
+ requirementIds: slice,
6113
+ writeSetPatterns: input.task.writeSet,
6114
+ canonicalVerificationTargetIds: await resolveFrontendCanonicalVerificationTargetIds({
5506
6115
  runDir: meta.runDir,
5507
- nodeId: `${input.task.id}/parallel/coverage-${index + 1}`,
5508
- skeleton: input.task.structuredContractOutput?.skeleton,
6116
+ }),
6117
+ componentNewSourceReferences: await resolveFrontendPlanNewComponentSourceReferences({
6118
+ cwd: input.cwd,
5509
6119
  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"),
6120
+ }),
6121
+ });
6122
+ const shardSessionOptions = {
6123
+ ...piSessionOptions,
6124
+ sessionEventsPath: path.join(meta.runDir, input.task.id, "parallel", `coverage-${index + 1}`, "session-events.jsonl"),
5595
6125
  };
6126
+ shardResult = await runPlanSessions({
6127
+ ledgerTools: shardTools,
6128
+ sessionOptions: shardSessionOptions,
6129
+ requirementIds: slice,
6130
+ 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.`,
6131
+ parallelCoverageOnly: true,
6132
+ observation: {
6133
+ artifactPath: path.join(meta.runDir, input.task.id, "parallel", `coverage-${index + 1}`, "session-budget", `attempt-${input.attempt ?? 1}`),
6134
+ runId: meta.runId,
6135
+ nodeId: `${input.task.id}/parallel/coverage-${index + 1}`,
6136
+ taskId: meta.spec?.sourceBinding?.taskId,
6137
+ attempt: input.attempt ?? 1,
6138
+ model: input.model,
6139
+ sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined,
6140
+ contractDigest: meta.spec?.taskContractBinding?.canonicalHash,
6141
+ },
6142
+ });
6143
+ await shardTools.flush();
6144
+ return { index, result: shardResult, tools: shardTools };
5596
6145
  }
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(),
6146
+ catch (error) {
6147
+ const crashMessage = `frontend plan coverage shard ${index + 1} crashed: ${error instanceof Error ? error.message : String(error)}`;
6148
+ return {
6149
+ index,
6150
+ tools: shardTools,
6151
+ result: shardResult
6152
+ ? {
6153
+ ...shardResult,
6154
+ ok: false,
6155
+ failureCategory: shardResult.ok
6156
+ ? "invalid-output"
6157
+ : shardResult.failureCategory,
6158
+ stderr: [shardResult.stderr, crashMessage]
6159
+ .filter(Boolean)
6160
+ .join("\n"),
6161
+ }
6162
+ : {
6163
+ ok: false,
6164
+ assistantText: "",
6165
+ command: [],
6166
+ durationMs: 0,
6167
+ exitCode: null,
6168
+ failureCategory: "tool-policy",
6169
+ modelDisplay: "unknown",
6170
+ parsedEvents: 0,
6171
+ stderr: crashMessage,
6172
+ stdout: "",
6173
+ timedOut: false,
6174
+ attemptedModels: [],
6175
+ fallbackUsed: false,
6176
+ tokensUsed: 0,
6177
+ },
5602
6178
  };
5603
6179
  }
5604
- else {
5605
- const reducerResult = await runPlanSessions({
5606
- ledgerTools: planLedgerTools,
5607
- sessionOptions: piSessionOptions,
5608
- });
5609
- result = combineSequentialPiResults(coverageResult, reducerResult);
5610
- }
6180
+ }, shard => shard.result.failureCategory === "rate-limit");
6181
+ const coverageResult = aggregateParallelPiResults(shardResults.map((shard) => shard.result));
6182
+ let mergeFailure;
6183
+ try {
6184
+ // Flatten every shard's facts FIRST, then pre-reduce: shards
6185
+ // partition requirements but a frozen verification target can
6186
+ // span shards, so same-id targets must merge across shards
6187
+ // before adoption, not per shard.
6188
+ await planLedgerTools.adoptCommittedFacts(reduceParallelCoverageShardRecords(shardResults
6189
+ .filter((item) => item.result.ok && item.tools)
6190
+ .flatMap((shard) => normalizeParallelCoverageShardRecords(shard.tools.committedFacts(), shard.index + 1))));
6191
+ }
6192
+ catch (error) {
6193
+ mergeFailure = error;
6194
+ }
6195
+ const failedShard = shardResults.find((shard) => !shard.result.ok);
6196
+ if (mergeFailure) {
6197
+ result = {
6198
+ ...coverageResult,
6199
+ ok: false,
6200
+ failureCategory: "invalid-output",
6201
+ stderr: [
6202
+ coverageResult.stderr,
6203
+ `frontend plan coverage shard merge failed: ${mergeFailure instanceof Error ? mergeFailure.message : String(mergeFailure)}`,
6204
+ ]
6205
+ .filter(Boolean)
6206
+ .join("\n"),
6207
+ };
6208
+ }
6209
+ else if (failedShard) {
6210
+ result = {
6211
+ ...coverageResult,
6212
+ ok: false,
6213
+ stderr: `${coverageResult.stderr}\nfrontend plan coverage shard ${failedShard.index + 1} failed before reduce`.trim(),
6214
+ };
5611
6215
  }
5612
6216
  else {
5613
- result = await runPlanSessions({
6217
+ const reducerResult = await runPlanSessions({
5614
6218
  ledgerTools: planLedgerTools,
5615
6219
  sessionOptions: piSessionOptions,
5616
- compactSmallPlan: true,
5617
6220
  });
6221
+ result = combineSequentialPiResults(coverageResult, reducerResult);
5618
6222
  }
5619
6223
  }
6224
+ else {
6225
+ result = await runPlanSessions({
6226
+ ledgerTools: planLedgerTools,
6227
+ sessionOptions: piSessionOptions,
6228
+ compactSmallPlan: true,
6229
+ });
6230
+ }
5620
6231
  }
5621
6232
  else {
5622
6233
  result = await runPlanSessions({
@@ -5626,12 +6237,31 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5626
6237
  });
5627
6238
  }
5628
6239
  }
6240
+ else if (contractTools) {
6241
+ result = await runFrontendContractSegmentedSessions({
6242
+ piStepFn, sessionOptions: piSessionOptions, basePrompt: input.prompt, tools: contractTools,
6243
+ 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 },
6244
+ });
6245
+ }
6246
+ else if ((reviewInventory && reviewTerminalTools) || (designInventory && designTerminalTools)) {
6247
+ result = await runFrontendReviewSegmentedSessions({ piStepFn, sessionOptions: piSessionOptions, basePrompt: input.prompt, inventory: (reviewInventory ?? designInventory), tools: (reviewTerminalTools ?? designTerminalTools), customTools: writerToolPolicy.customTools, phase: reviewInventory ? "review" : "design",
6248
+ 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 } });
6249
+ }
6250
+ else if (scoutEvidenceTools && parseFrontendInputBlock(input.prompt, "scout")) {
6251
+ result = await runFrontendScoutSegmentedSessions({ piStepFn, sessionOptions: piSessionOptions, basePrompt: input.prompt, tools: scoutEvidenceTools, customTools: writerToolPolicy.customTools,
6252
+ 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 } });
6253
+ }
5629
6254
  else {
5630
- result = await piStepFn({
6255
+ result = await observeFrontendSession({
6256
+ artifactPath: input.task.id.startsWith("frontend-") ? path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}.json`) : undefined,
6257
+ phase: input.task.id, prompt: input.prompt, userMessage: piSessionOptions.userMessage, customTools: writerToolPolicy?.customTools,
6258
+ 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,
6259
+ }, observer => piStepFn({
5631
6260
  ...piSessionOptions,
6261
+ onAttemptObservation: observer,
5632
6262
  prompt: input.prompt,
5633
6263
  ...(writerToolPolicy ? { writerToolPolicy } : {}),
5634
- });
6264
+ }));
5635
6265
  }
5636
6266
  try {
5637
6267
  const verificationCommandFiles = collectFrontendVerificationCommandFiles(input.prompt);
@@ -5796,8 +6426,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5796
6426
  try {
5797
6427
  await scoutEvidenceTools?.flush?.();
5798
6428
  }
5799
- catch {
5800
- // best-effort flush
6429
+ catch (error) {
6430
+ 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
6431
  }
5802
6432
  if (mapped.ok) {
5803
6433
  const { checkCommittedOriginFacts, readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");