@tea-agent/loop-agent 0.41.1 → 0.42.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 (302) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +397 -5
  3. package/dist/adapters/context-transfer/optional-pi-handoff.js +31 -0
  4. package/dist/adapters/context-transfer/pi-session.js +61 -0
  5. package/dist/application/dag/generate-task-dag.js +75 -19
  6. package/dist/application/dag/run-dag.js +41 -0
  7. package/dist/application/task-lifecycle/advance.js +24 -5
  8. package/dist/application/task-lifecycle/observe.js +171 -17
  9. package/dist/application/task-lifecycle/plan-transitions.js +42 -7
  10. package/dist/application/task-lifecycle/recommendations.js +13 -2
  11. package/dist/build-stamp.json +3 -3
  12. package/dist/cli/command-definitions.js +19 -0
  13. package/dist/cli/program.js +29 -2
  14. package/dist/commands/client-recovery.js +3 -0
  15. package/dist/commands/dag-artifact.js +284 -0
  16. package/dist/commands/dag-context.js +184 -0
  17. package/dist/commands/dag-follow-up.js +138 -0
  18. package/dist/commands/dag-rerun-task.js +2 -0
  19. package/dist/commands/dag-rerun.js +206 -1
  20. package/dist/commands/init.js +27 -1
  21. package/dist/commands/task-advance.js +19 -0
  22. package/dist/executors/dag-pi-executor.js +4013 -86
  23. package/dist/executors/pi-executor.js +15 -4
  24. package/dist/executors/pi-read-budget-policy.js +239 -0
  25. package/dist/executors/pi-sdk-executor.js +3 -0
  26. package/dist/executors/shell-executor.js +978 -188
  27. package/dist/executors/shell-write-guard.js +7 -0
  28. package/dist/{worker → infrastructure}/console/app-data.js +4 -0
  29. package/dist/infrastructure/console/artifact-revision-store.js +430 -0
  30. package/dist/infrastructure/console/context-export-store.js +160 -0
  31. package/dist/infrastructure/console/dir-lock.js +132 -0
  32. package/dist/infrastructure/console/operation-store.js +417 -0
  33. package/dist/infrastructure/harness/artifact-store.js +10 -1
  34. package/dist/infrastructure/harness/atomic-write.js +12 -2
  35. package/dist/shared/context-transfer/artifact-revision.js +172 -0
  36. package/dist/shared/context-transfer.js +418 -0
  37. package/dist/shared/dag-failure-category.js +12 -0
  38. package/dist/shared/openspec-spec.js +70 -4
  39. package/dist/shared/operator/capabilities.js +21 -0
  40. package/dist/shared/operator/safe-run-summary.js +1 -0
  41. package/dist/shared/path-safety.js +93 -0
  42. package/dist/shared/preview.js +28 -4
  43. package/dist/task/config-types.js +107 -6
  44. package/dist/task/contract/adopt.js +4 -0
  45. package/dist/task/contract/import-revision.js +4 -0
  46. package/dist/task/contract/project.js +3 -0
  47. package/dist/task/contract/schema.js +2 -1
  48. package/dist/task/frontend-project-capability.js +203 -20
  49. package/dist/task/runtime.js +5 -2
  50. package/dist/task/source-prepare/build-draft.js +3 -3
  51. package/dist/task/source-prepare/fragment-inventory.js +64 -25
  52. package/dist/task/source-prepare/prepare.js +126 -21
  53. package/dist/task/source-prepare/semantic-intake.js +6 -2
  54. package/dist/task/source-references.js +22 -1
  55. package/dist/task/task-demand-routing.js +3 -0
  56. package/dist/worker/console/chat/chat-event-store.js +2 -2
  57. package/dist/worker/console/chat/model-resolver.js +7 -4
  58. package/dist/worker/console/chat/pi-runtime.js +86 -5
  59. package/dist/worker/console/chat/resource-preferences-store.js +1 -1
  60. package/dist/worker/console/chat/routes.js +136 -53
  61. package/dist/worker/console/chat/sdd-data-alignment.js +222 -0
  62. package/dist/worker/console/chat/session-catalog.js +2 -0
  63. package/dist/worker/console/chat/session-store.js +43 -1
  64. package/dist/worker/console/chat/shortcuts.js +18 -2
  65. package/dist/worker/console/chat/user-questions.js +1 -1
  66. package/dist/worker/console/console-update-runtime.js +1 -1
  67. package/dist/worker/console/context-transfer-diagnostics.js +198 -0
  68. package/dist/worker/console/dag-confirmation.js +1 -1
  69. package/dist/worker/console/dag-execution-receipt.js +1 -1
  70. package/dist/worker/console/doctor.js +1 -1
  71. package/dist/worker/console/draft-store.js +1 -1
  72. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  73. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  74. package/dist/worker/console/human-gate-token.js +1 -1
  75. package/dist/worker/console/index.js +6 -3
  76. package/dist/worker/console/interview/assessment.js +1 -1
  77. package/dist/worker/console/interview/session.js +1 -1
  78. package/dist/worker/console/operation-runner.js +45 -4
  79. package/dist/worker/console/operation-sse.js +1 -1
  80. package/dist/worker/console/operation-wait.js +1 -1
  81. package/dist/worker/console/operator-actions.js +273 -10
  82. package/dist/worker/console/operator-surface-health.js +1 -1
  83. package/dist/worker/console/operator-user-error.js +169 -0
  84. package/dist/worker/console/pi-plugins.js +57 -0
  85. package/dist/worker/console/pi-readiness.js +3 -2
  86. package/dist/worker/console/routes.js +637 -8
  87. package/dist/worker/console/security.js +35 -0
  88. package/dist/worker/console/server.js +29 -4
  89. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-D7oU983t.js → abnfDiagram-N423BO3Z-CcS17TBr.js} +1 -1
  90. package/dist/worker/console/static/assets/{arc-BrKSRdFS.js → arc-COptKq2S.js} +1 -1
  91. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CiXV7L56.js → architectureDiagram-T3A2C74G-h4LKMHjP.js} +1 -1
  92. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-D_Hs_gIG.js → blockDiagram-VBNYF7ZC-COA1MOH0.js} +1 -1
  93. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DbM-Lqqh.js → c4Diagram-5PPSVZJV-DJUf0QPm.js} +1 -1
  94. package/dist/worker/console/static/assets/channel-DdBCaOJ6.js +1 -0
  95. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D1M8uqJw.js → chunk-2GRJ4B5K-mtWfKrUX.js} +1 -1
  96. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-4D3RFHT6.js → chunk-2Q5K7J3B-B8pXsxDQ.js} +1 -1
  97. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CdwpY_n9.js → chunk-5RXB4S5H-ipKzByl1.js} +1 -1
  98. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BLeLZ9PG.js → chunk-5VM5RSS4-DYi3Ald_.js} +1 -1
  99. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-CeRNEMvc.js → chunk-6Q2QTUOP-DQtGYoty.js} +1 -1
  100. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CjkHP8Vy.js → chunk-GF5L2VYU-BU0qS2YV.js} +1 -1
  101. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-B54ZUQY-.js → chunk-JWPE2WC7-DVH9dIGE.js} +1 -1
  102. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-4bKaMVmx.js → chunk-KBJHAD2P-Dt60SmgM.js} +1 -1
  103. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Cyjfznu_.js → chunk-RYQCIY6F-BoaUGuEY.js} +1 -1
  104. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CDN-zGFV.js → chunk-XXDRQBXY-_WHFDTp2.js} +1 -1
  105. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DZFra1GO.js +1 -0
  106. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DZFra1GO.js +1 -0
  107. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-HxJmz411.js → cose-bilkent-JH36ORCC-BxkbTIRd.js} +1 -1
  108. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DtSt5K2w.js → cynefin-VYW2F7L2-Bf2UVnoG.js} +1 -1
  109. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-VtkSpRmG.js → cynefinDiagram-MW4NZA55-Bo23q1J_.js} +1 -1
  110. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-Bj4DT4du.js → dagre-VZM6K2ZE-Dd_UU49i.js} +1 -1
  111. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Bpuk6BmP.js → diagram-7IWD3JNH-ebUa1a9y.js} +1 -1
  112. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BHYGv_yO.js → diagram-B4RE2ZJO-BucthU8r.js} +1 -1
  113. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DEx3WwnT.js → diagram-LBJQPF4R-BgqNpAkm.js} +1 -1
  114. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-Cj0dS5e8.js → diagram-Q27KOJAE-DC4q5NGa.js} +1 -1
  115. package/dist/worker/console/static/assets/{diagram-UB23O5K3-B97h8l21.js → diagram-UB23O5K3-CBqCaMeb.js} +1 -1
  116. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DMSFAhBd.js → ebnfDiagram-BXEA7PRR-CGtnbQ3-.js} +1 -1
  117. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DhVOCbt6.js → erDiagram-JOGREHBK-CG3LUao5.js} +1 -1
  118. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-GlakITWL.js → flowDiagram-UKHOOZJN-D2ZVgoFS.js} +1 -1
  119. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-VxPpu9eQ.js → ganttDiagram-PKOTCBZU-DoYFZiKt.js} +1 -1
  120. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bs_e5DRT.js → gitGraphDiagram-DS77QQ5N-CKoP1s6j.js} +1 -1
  121. package/dist/worker/console/static/assets/index-CAZ2fC_X.css +1 -0
  122. package/dist/worker/console/static/assets/index-vbTcFnFs.js +449 -0
  123. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BXYuU4Uw.js → infoDiagram-6WML65LV-Duofv8p2.js} +1 -1
  124. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-C34erCbU.js → ishikawaDiagram-WSZJBQD7-D2nlCkA1.js} +1 -1
  125. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DZCAiOU3.js → journeyDiagram-NVQOT4AX-Dd4IHdus.js} +1 -1
  126. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-C9gaTdUm.js → kanban-definition-27J2QSJJ-Bi20AOdt.js} +1 -1
  127. package/dist/worker/console/static/assets/{linear-CvI0Z17U.js → linear-D59YJ9kB.js} +1 -1
  128. package/dist/worker/console/static/assets/{mermaid.core-DMMSIx7j.js → mermaid.core-Bl13LpNa.js} +5 -5
  129. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BKKP0EO6.js → mindmap-definition-FAOFIHXS-C6DLP5eY.js} +1 -1
  130. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-C3LiFbl5.js → pegDiagram-VL7TDLO6-BjO4cy64.js} +1 -1
  131. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-Bb3Om2Dz.js → pieDiagram-7S7Q4E2Y-D7i2qZTG.js} +1 -1
  132. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DNthMjzc.js → quadrantDiagram-CIZ2JOQS-Dx8o_h0y.js} +1 -1
  133. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-miFsr4Tv.js → railroadDiagram-AXF67PYL-B55orI_t.js} +1 -1
  134. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-yXUncmn6.js → requirementDiagram-LRYGKXZP-zWaehp6f.js} +1 -1
  135. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DLeWKLak.js → sankeyDiagram-W5VNT64P-CcA-pjvD.js} +1 -1
  136. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CI3he1R3.js → sequenceDiagram-SI44F4Z6-BOFbzNHI.js} +1 -1
  137. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B7jfDkS5.js → sizeCapture-X5ZJPWSS-BjAejah1.js} +1 -1
  138. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BmrZod7J.js → stateDiagram-OKZ733FA-DXYwxJwZ.js} +1 -1
  139. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Clg3V9t1.js +1 -0
  140. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CDOlHVSY.js → swimlanes-SLNWSIFB-OYiOch8n.js} +2 -2
  141. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DX1dxAAW.js +8 -0
  142. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Cz-XX1gy.js → timeline-definition-Z64GVDOM-cZfH3nmU.js} +1 -1
  143. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-caV4cI8C.js → vennDiagram-T6HMQDX7-BDE7b3E1.js} +1 -1
  144. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-dImPlnIO.js → wardleyDiagram-T6FBY63Y-kyyGy9WJ.js} +1 -1
  145. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CtRYVJcn.js → xychartDiagram-ELKLHX3M-CJj6VTog.js} +1 -1
  146. package/dist/worker/console/static/index.html +7 -2
  147. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  148. package/dist/worker/console/static-src/app/useOperatorActions.js +3 -2
  149. package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -5
  150. package/dist/worker/console/static-src/operator-chat/input-history.js +8 -6
  151. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +10 -0
  152. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +258 -23
  153. package/dist/worker/console/static-src/operator-chat/useChatThread.js +112 -4
  154. package/dist/worker/console/static-src/operator-chat/useComposer.js +13 -4
  155. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +29 -6
  156. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +8 -2
  157. package/dist/worker/console/static-src/shell/console-update-reload.js +52 -0
  158. package/dist/worker/console/static-src/shell/workspace-route.js +11 -0
  159. package/dist/worker/console/workspace-context.js +142 -2
  160. package/dist/worker/console/workspace-registry.js +2 -2
  161. package/dist/worker/continuation/worker-continuation.js +278 -0
  162. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  163. package/dist/worker/observe/health.js +1 -1
  164. package/dist/worker/observe/node-transparency.js +572 -0
  165. package/dist/worker/observe/routes.js +55 -1
  166. package/dist/worker/observe/static/api.js +69 -5
  167. package/dist/worker/observe/static/dag-context-reason-labels.d.ts +9 -0
  168. package/dist/worker/observe/static/dag-context-reason-labels.js +120 -0
  169. package/dist/worker/observe/static/dag-helpers.js +4 -2
  170. package/dist/worker/observe/static/dag-node-purpose.js +5 -0
  171. package/dist/worker/observe/static/inspect-workspace.js +23 -0
  172. package/dist/worker/observe/static/kpi.js +2 -2
  173. package/dist/worker/observe/static/operator-chrome.d.ts +10 -2
  174. package/dist/worker/observe/static/operator-chrome.js +37 -23
  175. package/dist/worker/observe/static/relations.js +2 -2
  176. package/dist/worker/observe/static/router.d.ts +12 -1
  177. package/dist/worker/observe/static/router.js +48 -6
  178. package/dist/worker/observe/static/run-processing.js +4 -2
  179. package/dist/worker/observe/static/shell-chrome.js +2 -2
  180. package/dist/worker/observe/static/state.js +8 -1
  181. package/dist/worker/observe/static/styles.css +1092 -86
  182. package/dist/worker/observe/static/views/dag-inspector.js +1228 -64
  183. package/dist/worker/observe/static/views/dag.js +10 -2
  184. package/dist/worker/observe/static/views/dags.js +3 -1
  185. package/dist/worker/observe/static/views/dashboard.js +14 -6
  186. package/dist/worker/observe/static/views/pool.js +7 -2
  187. package/dist/worker/observe/static/views/run.js +12 -3
  188. package/dist/worker/observe/static/views/session-timeline.js +142 -18
  189. package/dist/worker/observe/static/views/task.js +7 -2
  190. package/dist/worker/outcomes/adapters.js +19 -0
  191. package/dist/worker/outcomes/types.js +1 -0
  192. package/dist/worker/pool/attempt-lease.js +97 -66
  193. package/dist/worker/pool/begin-attempt-with-lease.js +1 -0
  194. package/dist/worker/pool/reconcile.js +46 -1
  195. package/dist/worker/pool/run-store.js +2 -0
  196. package/dist/worker/pool/state-projection.js +2 -0
  197. package/dist/worker/task-spec/workflow-routing.js +8 -3
  198. package/dist/workflows/dag/artifact-bindings.js +149 -0
  199. package/dist/workflows/dag/artifact-revision-schema-registry.js +31 -0
  200. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +215 -28
  201. package/dist/workflows/dag/backend-test-markdown-workflow.js +13 -1
  202. package/dist/workflows/dag/backend-test-plan-protocol.js +106 -0
  203. package/dist/workflows/dag/backend-test-scenario-param.js +172 -25
  204. package/dist/workflows/dag/backend-test-scenario-partitions.js +62 -1
  205. package/dist/workflows/dag/backend-test-writer-completeness.js +104 -12
  206. package/dist/workflows/dag/budget-enforcement.js +10 -0
  207. package/dist/workflows/dag/context-receipt.js +305 -0
  208. package/dist/workflows/dag/context-transfer/context-bundle.js +423 -0
  209. package/dist/workflows/dag/context-transfer/operator-actions.js +61 -0
  210. package/dist/workflows/dag/context-transfer/renderers.js +93 -0
  211. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  212. package/dist/workflows/dag/dag-retry-schema.js +138 -0
  213. package/dist/workflows/dag/frontend-closeout.js +221 -0
  214. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  215. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  216. package/dist/workflows/dag/frontend-implementation-contract.js +1237 -192
  217. package/dist/workflows/dag/frontend-plan-render.js +2 -1
  218. package/dist/workflows/dag/frontend-prewrite-gate.js +256 -350
  219. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  220. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  221. package/dist/workflows/dag/frontend-recovery-controller.js +226 -0
  222. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  223. package/dist/workflows/dag/frontend-recovery-plan.js +21 -10
  224. package/dist/workflows/dag/frontend-recovery-run.js +166 -34
  225. package/dist/workflows/dag/frontend-repair.js +1 -432
  226. package/dist/workflows/dag/frontend-review-context.js +261 -15
  227. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  228. package/dist/workflows/dag/frontend-shadow-dual-write.js +941 -0
  229. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  230. package/dist/workflows/dag/frontend-shape-facts.js +419 -0
  231. package/dist/workflows/dag/frontend-shape.js +435 -0
  232. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  233. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  234. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  235. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  236. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  237. package/dist/workflows/dag/frontend-verification-trace.js +252 -25
  238. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  239. package/dist/workflows/dag/frontend-writer-admission.js +319 -0
  240. package/dist/workflows/dag/frontend-writer-rollback.js +32 -0
  241. package/dist/workflows/dag/frontend-writer-status.js +256 -0
  242. package/dist/workflows/dag/init-hybrid.js +1174 -562
  243. package/dist/workflows/dag/interrupt-request.js +7 -0
  244. package/dist/workflows/dag/node-execution.js +940 -31
  245. package/dist/workflows/dag/path-safety.js +1 -0
  246. package/dist/workflows/dag/prompt.js +99 -7
  247. package/dist/workflows/dag/recovery-lease.js +170 -0
  248. package/dist/workflows/dag/report.js +37 -1
  249. package/dist/workflows/dag/rerun-feedback.js +315 -1
  250. package/dist/workflows/dag/rerun-plan.js +568 -3
  251. package/dist/workflows/dag/rerun-run.js +642 -18
  252. package/dist/workflows/dag/rerun-task.js +251 -13
  253. package/dist/workflows/dag/retry-policy.js +219 -104
  254. package/dist/workflows/dag/runner.js +596 -121
  255. package/dist/workflows/dag/scheduler.js +133 -20
  256. package/dist/workflows/dag/skill-snapshot.js +17 -0
  257. package/dist/workflows/dag/types.js +426 -22
  258. package/dist/workflows/dag/validate.js +28 -12
  259. package/docs/architecture/runtime-boundaries.md +6 -6
  260. package/docs/architecture/worker-and-feature.md +1 -1
  261. package/docs/examples/README.md +5 -0
  262. package/docs/init-surface.manifest.json +30 -12
  263. package/docs/skills/vetted-skill-registry.md +4 -2
  264. package/docs/templates/README.md +2 -0
  265. package/docs/templates/agent-dag-report.schema.json +8 -2
  266. package/docs/templates/agent-dag.schema.json +33 -1
  267. package/docs/templates/backend-test-dag.json +23 -20
  268. package/docs/templates/frontend-implementation-contract.schema.json +4 -1
  269. package/docs/templates/frontend-implementation-dag.json +89 -0
  270. package/docs/templates/product-line/task.yaml +1 -1
  271. package/docs/templates/spec-registry.schema.json +45 -0
  272. package/harness.json +1 -1
  273. package/package.json +6 -3
  274. package/skills/frontend-bounded-implement/SKILL.md +15 -14
  275. package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
  276. package/skills/frontend-contract/SKILL.md +23 -0
  277. package/skills/frontend-contract/references/contract-protocol.md +34 -0
  278. package/skills/frontend-design-review/SKILL.md +22 -41
  279. package/skills/frontend-plan/SKILL.md +26 -0
  280. package/skills/frontend-plan/references/decision-contract.md +37 -0
  281. package/skills/frontend-plan/references/design-decisions.md +17 -0
  282. package/skills/frontend-review/SKILL.md +20 -15
  283. package/skills/frontend-review/references/review-findings.md +6 -7
  284. package/skills/frontend-scout/SKILL.md +25 -0
  285. package/skills/frontend-scout/references/design-evidence.md +16 -0
  286. package/skills/frontend-scout/references/scout-evidence.md +23 -0
  287. package/skills/frontend-verification/SKILL.md +1 -1
  288. package/skills/loop-agent/references/command-reference.md +7 -0
  289. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  290. package/dist/worker/console/operation-store.js +0 -169
  291. package/dist/worker/console/static/assets/channel-D1xajGpe.js +0 -1
  292. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BmuGTfRr.js +0 -1
  293. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BmuGTfRr.js +0 -1
  294. package/dist/worker/console/static/assets/index-BLzcAykx.js +0 -451
  295. package/dist/worker/console/static/assets/index-Cs9JUSPs.css +0 -1
  296. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-D9ykKEWP.js +0 -1
  297. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BvPOXALT.js +0 -8
  298. package/skills/frontend-implementation/SKILL.md +0 -52
  299. package/skills/frontend-implementation/references/code-standards.md +0 -33
  300. package/skills/frontend-implementation/references/design-spec.md +0 -56
  301. package/skills/frontend-implementation/references/node-contracts.md +0 -31
  302. /package/dist/{worker → infrastructure}/console/repo-fingerprint.js +0 -0
@@ -6,16 +6,21 @@ import { pathMatchesPattern } from "../../shared/git-progress.js";
6
6
  import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
7
7
  import { recordDecisionEnvelopeForNode, shouldPauseOnHumanEscalation, writeHumanEscalationArtifacts, } from "./decision-envelope.js";
8
8
  import { FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT, FRONTEND_WRITER_NODE_IDS, isFrontendWriterAuthorized, readFrontendPrewriteResult, } from "./scheduler.js";
9
+ import { collectVerificationCommandFiles, } from "./frontend-writer-admission.js";
9
10
  import { writeNodeRecord, writeNodeSkillArtifacts } from "./run-store.js";
10
11
  import { resolveContextPolicy } from "./context-policy.js";
11
12
  import { buildDagNodePromptEnvelope, formatConvergenceFeedbackBlock, } from "./prompt.js";
12
13
  import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
14
+ import { materializeDeclaredArtifactFacts } from "./artifact-bindings.js";
13
15
  import { buildOutputLimitRecoverySection, loadBackendTestWriterProgressForRetry, } from "./backend-test-writer-completeness.js";
14
- import { computeBackoffDelayMs, isCanonicalFinalVerifyShellRetryCandidate, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, VERIFY_SHELL_RETRY_POLICY, } from "./retry-policy.js";
16
+ import { computeBackoffDelayMs, isCanonicalFinalVerifyShellRetryCandidate, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, projectFrontendNodeProtocolFailureReason, resolveFrontendPlanBackupRoute, resolveFrontendPlanRetryStep, VERIFY_SHELL_RETRY_POLICY, } from "./retry-policy.js";
15
17
  import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
16
18
  import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, parseJsonReviewVerdict, validateOutputProtocol, } from "./output-protocol.js";
17
19
  import { getStructuredContractValidator } from "./contract-output-registry.js";
18
20
  import "./contract-validator-registrations.js";
21
+ import { computeNormalizedFailureFingerprint } from "./frontend-recovery-lineage.js";
22
+ import { parseLedgerJson } from "../../task/source-prepare/ledger.js";
23
+ import { readTypedEventStoreFromJsonl } from "./frontend-typed-event-store.js";
19
24
  import { allowedRepairReadPaths, auditRepairAttemptToolUse, buildStructuredOutputRepairPrompt, freezeStructuredOutputRepairContext, hasNonEmptyStructuredCandidate, isFrontendStructuredRepairSchemaId, isStructuredRepairableFailureCategory, persistStructuredAttemptRaw, sessionEventsByteLength, GOVERNANCE_BLOCKED_CATEGORY, STRUCTURED_REPAIR_EXHAUSTED_CATEGORY, } from "./structured-output-repair.js";
20
25
  import { readFrontendCanonicalCandidate } from "./frontend-implementation-contract.js";
21
26
  import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
@@ -42,7 +47,11 @@ function canonicalApprovalJson(value) {
42
47
  export function parseAndValidateFinalWriteSetApproval(input) {
43
48
  const binding = input.task.finalWriteSetApproval;
44
49
  if (!binding) {
45
- return { ok: false, reason: "missing final write-set approval binding", approvalSourceNodeId: "(missing)" };
50
+ return {
51
+ ok: false,
52
+ reason: "missing final write-set approval binding",
53
+ approvalSourceNodeId: "(missing)",
54
+ };
46
55
  }
47
56
  const source = input.state.nodes[binding.approvalSourceNodeId];
48
57
  if (source?.status !== "FINISHED" || parseProcessVerdict(source) !== "pass") {
@@ -53,20 +62,44 @@ export function parseAndValidateFinalWriteSetApproval(input) {
53
62
  };
54
63
  }
55
64
  const raw = canonicalNodeOutput(source);
56
- const blocks = [...raw.matchAll(/```FINAL_WRITE_SET_APPROVAL_JSON\s*\r?\n([\s\S]*?)\r?\n```/g)];
65
+ const blocks = [
66
+ ...raw.matchAll(/```FINAL_WRITE_SET_APPROVAL_JSON\s*\r?\n([\s\S]*?)\r?\n```/g),
67
+ ];
57
68
  if (blocks.length !== 1) {
58
- return { ok: false, reason: `expected exactly one FINAL_WRITE_SET_APPROVAL_JSON block, found ${blocks.length}`, approvalSourceNodeId: binding.approvalSourceNodeId };
69
+ return {
70
+ ok: false,
71
+ reason: `expected exactly one FINAL_WRITE_SET_APPROVAL_JSON block, found ${blocks.length}`,
72
+ approvalSourceNodeId: binding.approvalSourceNodeId,
73
+ };
59
74
  }
60
75
  let approval;
61
76
  try {
62
77
  approval = JSON.parse(blocks[0][1]);
63
78
  }
64
79
  catch {
65
- return { ok: false, reason: "final write-set approval is not valid JSON", approvalSourceNodeId: binding.approvalSourceNodeId };
80
+ return {
81
+ ok: false,
82
+ reason: "final write-set approval is not valid JSON",
83
+ approvalSourceNodeId: binding.approvalSourceNodeId,
84
+ };
66
85
  }
67
- const required = ["schemaVersion", "writerNodeId", "approvalSourceNodeId", "auditedPlanNodeId", "approvedWriteSet", "taskContractSha256", "auditedPlanSha256", "approvalDigest"];
68
- if (Object.keys(approval).length !== required.length || required.some((key) => !(key in approval))) {
69
- return { ok: false, reason: "final write-set approval has an invalid schema", approvalSourceNodeId: binding.approvalSourceNodeId };
86
+ const required = [
87
+ "schemaVersion",
88
+ "writerNodeId",
89
+ "approvalSourceNodeId",
90
+ "auditedPlanNodeId",
91
+ "approvedWriteSet",
92
+ "taskContractSha256",
93
+ "auditedPlanSha256",
94
+ "approvalDigest",
95
+ ];
96
+ if (Object.keys(approval).length !== required.length ||
97
+ required.some((key) => !(key in approval))) {
98
+ return {
99
+ ok: false,
100
+ reason: "final write-set approval has an invalid schema",
101
+ approvalSourceNodeId: binding.approvalSourceNodeId,
102
+ };
70
103
  }
71
104
  const approved = approval.approvedWriteSet;
72
105
  if (approval.schemaVersion !== 1 ||
@@ -79,12 +112,20 @@ export function parseAndValidateFinalWriteSetApproval(input) {
79
112
  typeof approval.taskContractSha256 !== "string" ||
80
113
  typeof approval.auditedPlanSha256 !== "string" ||
81
114
  typeof approval.approvalDigest !== "string") {
82
- return { ok: false, reason: "final write-set approval binding or field types are invalid", approvalSourceNodeId: binding.approvalSourceNodeId };
115
+ return {
116
+ ok: false,
117
+ reason: "final write-set approval binding or field types are invalid",
118
+ approvalSourceNodeId: binding.approvalSourceNodeId,
119
+ };
83
120
  }
84
121
  if (!/^[a-f0-9]{64}$/.test(approval.taskContractSha256) ||
85
122
  !/^[a-f0-9]{64}$/.test(approval.auditedPlanSha256) ||
86
123
  !/^[a-f0-9]{64}$/.test(approval.approvalDigest)) {
87
- return { ok: false, reason: "final write-set approval digest fields are invalid", approvalSourceNodeId: binding.approvalSourceNodeId };
124
+ return {
125
+ ok: false,
126
+ reason: "final write-set approval digest fields are invalid",
127
+ approvalSourceNodeId: binding.approvalSourceNodeId,
128
+ };
88
129
  }
89
130
  const canonicalPayload = { ...approval };
90
131
  delete canonicalPayload.approvalDigest;
@@ -92,33 +133,70 @@ export function parseAndValidateFinalWriteSetApproval(input) {
92
133
  .update(canonicalApprovalJson(canonicalPayload))
93
134
  .digest("hex");
94
135
  if (approval.approvalDigest !== expectedDigest) {
95
- return { ok: false, reason: "final write-set approval digest mismatch", approvalSourceNodeId: binding.approvalSourceNodeId };
136
+ return {
137
+ ok: false,
138
+ reason: "final write-set approval digest mismatch",
139
+ approvalSourceNodeId: binding.approvalSourceNodeId,
140
+ };
96
141
  }
97
142
  const expectedTaskDigest = input.spec.taskContractBinding?.canonicalHash;
98
143
  const plan = input.state.nodes[binding.auditedPlanNodeId];
99
144
  const expectedPlanDigest = createHash("sha256")
100
145
  .update(canonicalNodeOutput(plan))
101
146
  .digest("hex");
102
- if (!expectedTaskDigest || approval.taskContractSha256 !== expectedTaskDigest || approval.auditedPlanSha256 !== expectedPlanDigest) {
103
- return { ok: false, reason: "final write-set approval is stale for the task contract or audited plan", approvalSourceNodeId: binding.approvalSourceNodeId };
147
+ if (!expectedTaskDigest ||
148
+ approval.taskContractSha256 !== expectedTaskDigest ||
149
+ approval.auditedPlanSha256 !== expectedPlanDigest) {
150
+ return {
151
+ ok: false,
152
+ reason: "final write-set approval is stale for the task contract or audited plan",
153
+ approvalSourceNodeId: binding.approvalSourceNodeId,
154
+ };
104
155
  }
105
156
  const effectiveWriteSet = approved;
106
- if (effectiveWriteSet.length === 0 || new Set(effectiveWriteSet).size !== effectiveWriteSet.length) {
107
- return { ok: false, reason: "final write-set approval must contain a non-empty ordered unique path set", approvalSourceNodeId: binding.approvalSourceNodeId };
157
+ if (effectiveWriteSet.length === 0 ||
158
+ new Set(effectiveWriteSet).size !== effectiveWriteSet.length) {
159
+ return {
160
+ ok: false,
161
+ reason: "final write-set approval must contain a non-empty ordered unique path set",
162
+ approvalSourceNodeId: binding.approvalSourceNodeId,
163
+ };
108
164
  }
109
165
  for (const entry of effectiveWriteSet) {
110
166
  const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
111
- if (!normalized || normalized === "." || normalized === ".." || normalized.includes("*") || normalized.includes("?") || normalized.includes("REPLACE/") || normalized.includes("PLACEHOLDER")) {
112
- return { ok: false, reason: `final write-set approval contains a broad or placeholder path: ${entry}`, approvalSourceNodeId: binding.approvalSourceNodeId };
167
+ if (!normalized ||
168
+ normalized === "." ||
169
+ normalized === ".." ||
170
+ normalized.includes("*") ||
171
+ normalized.includes("?") ||
172
+ normalized.includes("REPLACE/") ||
173
+ normalized.includes("PLACEHOLDER")) {
174
+ return {
175
+ ok: false,
176
+ reason: `final write-set approval contains a broad or placeholder path: ${entry}`,
177
+ approvalSourceNodeId: binding.approvalSourceNodeId,
178
+ };
113
179
  }
114
180
  if (!input.task.allowedPaths.some((allowed) => pathMatchesPattern(normalized, allowed))) {
115
- return { ok: false, reason: `final write-set approval exceeds allowedPaths: ${entry}`, approvalSourceNodeId: binding.approvalSourceNodeId };
181
+ return {
182
+ ok: false,
183
+ reason: `final write-set approval exceeds allowedPaths: ${entry}`,
184
+ approvalSourceNodeId: binding.approvalSourceNodeId,
185
+ };
116
186
  }
117
187
  if (input.task.forbiddenPaths.some((forbidden) => pathMatchesPattern(normalized, forbidden))) {
118
- return { ok: false, reason: `final write-set approval overlaps forbiddenPaths: ${entry}`, approvalSourceNodeId: binding.approvalSourceNodeId };
188
+ return {
189
+ ok: false,
190
+ reason: `final write-set approval overlaps forbiddenPaths: ${entry}`,
191
+ approvalSourceNodeId: binding.approvalSourceNodeId,
192
+ };
119
193
  }
120
194
  }
121
- return { ok: true, effectiveWriteSet, approvalDigest: approval.approvalDigest };
195
+ return {
196
+ ok: true,
197
+ effectiveWriteSet,
198
+ approvalDigest: approval.approvalDigest,
199
+ };
122
200
  }
123
201
  export function buildNodePrompt(spec, task, upstream, options) {
124
202
  const policy = resolveContextPolicy(spec);
@@ -126,6 +204,7 @@ export function buildNodePrompt(spec, task, upstream, options) {
126
204
  spec,
127
205
  task,
128
206
  upstream,
207
+ runDir: options?.runDir,
129
208
  resolvedSkills: policy.resolveSkills(spec, task),
130
209
  maxUpstreamChars: policy.resolveMaxUpstreamChars(task),
131
210
  projectGovernanceContext: options?.projectGovernanceContext,
@@ -162,9 +241,518 @@ function frontendStructuredArtifactRetryGuidance(schemaId) {
162
241
  "The contract JSON fields are the complete implementation plan. Do not emit a separate plan document, Markdown headings, bullets, plan prose, explanations, raw JSON, or any other fenced block.",
163
242
  ];
164
243
  }
165
- function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths, recoveryDiagnostics) {
244
+ const FRONTEND_PLAN_NODE_ID = "frontend-plan-pi";
245
+ const COMPACT_RETRY_SECTION_MAX_CHARS = 1_200;
246
+ const COMPACT_RETRY_TASK_MAX_CHARS = 4_800;
247
+ /** Keep alias/field-shape remediation out of the initial Plan prompt. */
248
+ function frontendPlanValidationRetryGuidance(reason) {
249
+ const guidance = [];
250
+ if (/notApplicableReason|uiState.*reason/i.test(reason)) {
251
+ guidance.push("For this retry, use uiStates[].notApplicableReason for a non-applicable state; omit expectedBehavior instead of supplying an empty value.");
252
+ }
253
+ if (/interaction.*(?:\bid\b|\bname\b)|requires non-empty interaction\.name/i.test(reason)) {
254
+ guidance.push("For this retry, use interactions[].name with non-empty trigger and expectedBehavior.");
255
+ }
256
+ if (/verificationTargets.*uiStates|uiStates.*verificationTargets/i.test(reason)) {
257
+ guidance.push("For this retry, provide verificationTargets[].uiStates as an array; use [] when the target has no named UI state.");
258
+ }
259
+ return guidance;
260
+ }
261
+ function compactRetryText(text, maxChars) {
262
+ if (text.length <= maxChars)
263
+ return text;
264
+ const headChars = Math.floor(maxChars * 0.78);
265
+ const tailChars = Math.max(0, maxChars - headChars - 52);
266
+ return `${text.slice(0, headChars)}\n… [omitted after context-overflow] …\n${text.slice(-tailChars)}`;
267
+ }
268
+ function compactRetrySection(prompt, name, maxChars) {
269
+ const match = prompt.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
270
+ return compactRetryText(match?.[1]?.trim() ?? "(not available)", maxChars);
271
+ }
272
+ /**
273
+ * A context-overflow retry must not resend the full resolved skills, governance
274
+ * payload, repeated source excerpts, and upstream previews that caused the
275
+ * previous session to overflow. Keep the immutable node contract plus bounded
276
+ * objective/criteria/task excerpts; canonical artifacts remain readable by
277
+ * pointer from the retained task instructions.
278
+ */
279
+ export function buildContextOverflowRetryPrompt(task, basePrompt) {
280
+ return [
281
+ "<compact_retry_context>",
282
+ "This is a fresh, compact retry after provider context-overflow. The original full prompt remains audit evidence but is intentionally not resent.",
283
+ "Do not re-read task sources, upstream stdout, skills, or full diff artifacts that are already represented by canonical artifacts. Start from the smallest retained artifact and expand only the exact file or diff fragment needed.",
284
+ "</compact_retry_context>",
285
+ `<dag_objective>\n${compactRetrySection(basePrompt, "dag_objective", COMPACT_RETRY_SECTION_MAX_CHARS)}\n</dag_objective>`,
286
+ `<success_criteria>\n${compactRetrySection(basePrompt, "success_criteria", COMPACT_RETRY_SECTION_MAX_CHARS)}\n</success_criteria>`,
287
+ `<global_constraints>\n${compactRetrySection(basePrompt, "global_constraints", COMPACT_RETRY_SECTION_MAX_CHARS)}\n</global_constraints>`,
288
+ `<node_contract>\n${compactRetrySection(basePrompt, "node_contract", COMPACT_RETRY_SECTION_MAX_CHARS)}\n</node_contract>`,
289
+ `<upstream_context>\n${compactRetrySection(basePrompt, "upstream_context", COMPACT_RETRY_SECTION_MAX_CHARS)}\n</upstream_context>`,
290
+ [
291
+ "<task>",
292
+ `Original task id: ${task.id}`,
293
+ compactRetrySection(basePrompt, "task", COMPACT_RETRY_TASK_MAX_CHARS),
294
+ "</task>",
295
+ ].join("\n"),
296
+ ].join("\n\n");
297
+ }
298
+ function isFrontendPlanLadderTask(task) {
299
+ return task.id === FRONTEND_PLAN_NODE_ID;
300
+ }
301
+ /** Contract node consumes the compiled ledger input; local predicate avoids a
302
+ * dag-pi-executor import cycle. */
303
+ function isFrontendContractTypedNode(task) {
304
+ return task.id === "frontend-contract-pi";
305
+ }
306
+ /** Resolve the source-fidelity ledger path from a v2 source binding, refusing
307
+ * any path that escapes the workspace root (mirrors dag-pi-executor). */
308
+ function resolveFrontendLedgerPath(sourceBinding, cwd) {
309
+ if (!sourceBinding || sourceBinding.schemaVersion !== 2) {
310
+ throw new Error(`frontend-contract-input-unavailable: sourceBinding v2 ledger required (got ${sourceBinding?.schemaVersion ?? "none"})`);
311
+ }
312
+ const absolutePath = path.resolve(cwd, sourceBinding.ledgerPath);
313
+ const workspaceRoot = path.resolve(cwd);
314
+ if (absolutePath !== workspaceRoot &&
315
+ !absolutePath.startsWith(`${workspaceRoot}${path.sep}`)) {
316
+ throw new Error(`frontend-contract-input-unavailable: ledger path escapes workspace root: ${sourceBinding.ledgerPath}`);
317
+ }
318
+ return absolutePath;
319
+ }
320
+ /** record_* submissions observed for the contract node in its session events. */
321
+ const FRONTEND_CONTRACT_RECORD_TOOL_NAMES_LOCAL = new Set([
322
+ "record_requirement",
323
+ "record_constraint",
324
+ "record_evidence_expectation",
325
+ "record_handoff_intent",
326
+ "record_open_question",
327
+ "record_split_proposal",
328
+ ]);
329
+ async function countContractRecordSubmissions(runDir, nodeId) {
330
+ const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
331
+ let count = 0;
332
+ try {
333
+ for (const line of (await readFile(eventsPath, "utf8")).split("\n")) {
334
+ if (!line.trim())
335
+ continue;
336
+ let event;
337
+ try {
338
+ event = JSON.parse(line);
339
+ }
340
+ catch {
341
+ continue;
342
+ }
343
+ if (event !== null &&
344
+ typeof event === "object" &&
345
+ event.type === "tool_execution_start" &&
346
+ typeof event.toolName === "string" &&
347
+ FRONTEND_CONTRACT_RECORD_TOOL_NAMES_LOCAL.has(event.toolName)) {
348
+ count += 1;
349
+ }
350
+ }
351
+ }
352
+ catch {
353
+ // Missing events file = zero submissions.
354
+ }
355
+ return count;
356
+ }
357
+ /**
358
+ * Read the committed plan ledger snapshot for the ladder's "no new committed
359
+ * fact" check. The digest is over the sorted committed payload hashes, and
360
+ * `hasTerminalFact` reflects a committed `finalize_plan` terminal fact.
361
+ */
362
+ async function readFrontendPlanCommittedSnapshot(runDir, nodeId) {
363
+ const records = await readTypedEventStoreFromJsonl(path.join(runDir, nodeId, "plan-typed-facts.jsonl"));
364
+ const committed = records.filter((record) => record.phase === "committed");
365
+ const digest = createHash("sha256")
366
+ .update(committed
367
+ .map((record) => record.payloadSha256)
368
+ .sort()
369
+ .join("\n"))
370
+ .digest("hex");
371
+ const hasTerminalFact = committed.some((record) => record.fact.kind === "finalize_plan");
372
+ return { digest, hasTerminalFact };
373
+ }
374
+ function planInputRecord(value) {
375
+ return typeof value === "object" && value !== null && !Array.isArray(value);
376
+ }
377
+ function planInputText(value, maxChars = 240) {
378
+ if (typeof value !== "string" || value.trim().length === 0)
379
+ return undefined;
380
+ const normalized = value.trim();
381
+ return normalized.length <= maxChars
382
+ ? normalized
383
+ : `${normalized.slice(0, maxChars - 1)}…`;
384
+ }
385
+ function planInputStrings(value) {
386
+ return Array.isArray(value)
387
+ ? value.filter((item) => typeof item === "string")
388
+ : [];
389
+ }
390
+ /** Input bound for the planner evidence block; protects the model input budget. */
391
+ const FRONTEND_PLAN_INPUT_MAX_CHARS = 12_000;
392
+ const FRONTEND_PLAN_INPUT_CAP_LADDER = [
393
+ { text: 240, array: 40 },
394
+ { text: 120, array: 20 },
395
+ { text: 60, array: 10 },
396
+ { text: 24, array: 4 },
397
+ ];
398
+ /**
399
+ * Frozen requirement→PRD citation map for the plan review checklist: lets the
400
+ * model declare sourceRequirementIds whose section/line match the component
401
+ * purpose, so the runtime-derived specReference survives reviewer scrutiny
402
+ * (r12: 4 of 9 decision=new choices cited a mismatched PRD section).
403
+ */
404
+ async function resolveComponentSourceCitations(spec, cwd) {
405
+ const binding = spec.sourceBinding;
406
+ if (!binding || binding.schemaVersion !== 2 || !binding.ledgerPath)
407
+ return new Map();
408
+ const absolutePath = path.resolve(cwd, binding.ledgerPath);
409
+ const workspaceRoot = path.resolve(cwd);
410
+ if (absolutePath !== workspaceRoot &&
411
+ !absolutePath.startsWith(`${workspaceRoot}${path.sep}`))
412
+ return new Map();
413
+ try {
414
+ const ledger = JSON.parse(await readFile(absolutePath, "utf8"));
415
+ const fragmentsById = new Map((ledger.fragments ?? []).map((fragment) => [fragment.id, fragment]));
416
+ const references = new Map();
417
+ for (const requirement of ledger.canonicalRequirements ?? []) {
418
+ const id = typeof requirement.id === "string" ? requirement.id : "";
419
+ if (!id)
420
+ continue;
421
+ const citations = (requirement.sourceFragmentIds ?? [])
422
+ .map((fragmentId) => fragmentsById.get(fragmentId))
423
+ .filter((fragment) => fragment !== undefined)
424
+ .map((fragment) => ({
425
+ fragmentId: fragment.id,
426
+ path: typeof fragment.path === "string" ? fragment.path : "",
427
+ section: typeof fragment.headingPath === "string"
428
+ ? fragment.headingPath
429
+ : "",
430
+ line: typeof fragment.lineRange?.start === "number"
431
+ ? fragment.lineRange.start
432
+ : undefined,
433
+ }));
434
+ if (citations.length > 0)
435
+ references.set(id, citations);
436
+ }
437
+ return references;
438
+ }
439
+ catch {
440
+ return new Map();
441
+ }
442
+ }
443
+ /** Input bound for the contract node's compiled ledger block. */
444
+ const FRONTEND_CONTRACT_INPUT_MAX_CHARS = 12_000;
445
+ /**
446
+ * Render the contract node's complete-but-bounded ledger handoff. The
447
+ * source-fidelity ledger already extracted canonical requirements with source
448
+ * spans; the contract node confirms and commits them incrementally through
449
+ * record_requirement instead of re-reading the raw source (extreme-environment:
450
+ * a small output window cannot absorb a full source re-read).
451
+ *
452
+ * Same shape guarantees as the plan input block: always valid JSON under the
453
+ * char bound, ids never drop, texts degrade through the cap ladder.
454
+ */
455
+ export function renderFrontendContractInputContext(input) {
456
+ // Fragment bindings are ids, not prose: they are the one thing the contract
457
+ // must never lose. r6 regression — the last-resort degradation dropped
458
+ // sourceFragmentIds entirely, the model (correctly refusing to invent ids)
459
+ // committed empty bindings, and the plan compile failed the ledger-binding
460
+ // gate for every requirement. Bindings therefore bypass the cap ladder and
461
+ // every degradation level; only requirement TEXTS and fragment CONTEXT
462
+ // (path/headingPath) may degrade. Fragment context is rendered only for
463
+ // fragments actually referenced by a requirement and shrinks first.
464
+ const referencedFragmentIds = new Set(input.canonicalRequirements.flatMap((requirement) => planInputStrings(requirement.sourceFragmentIds)));
465
+ const referencedFragments = input.fragments.filter((fragment) => referencedFragmentIds.has(fragment.id));
466
+ const serializeAtCap = (cap) => JSON.stringify({
467
+ requirements: input.canonicalRequirements.map((requirement) => ({
468
+ id: requirement.id,
469
+ text: planInputText(requirement.text, cap.text),
470
+ sourceFragmentIds: planInputStrings(requirement.sourceFragmentIds),
471
+ })),
472
+ fragments: referencedFragments.map((fragment) => ({
473
+ id: fragment.id,
474
+ path: planInputText(fragment.path, 200),
475
+ headingPath: planInputText(fragment.headingPath, 120),
476
+ lineRange: fragment.lineRange,
477
+ })),
478
+ });
479
+ let serialized = serializeAtCap(FRONTEND_PLAN_INPUT_CAP_LADDER[0]);
480
+ for (const cap of FRONTEND_PLAN_INPUT_CAP_LADDER.slice(1)) {
481
+ if (serialized.length <= FRONTEND_CONTRACT_INPUT_MAX_CHARS)
482
+ break;
483
+ serialized = serializeAtCap(cap);
484
+ }
485
+ if (serialized.length > FRONTEND_CONTRACT_INPUT_MAX_CHARS) {
486
+ // Last resort: keep every requirement id AND its fragment bindings,
487
+ // degrade texts, and shrink referenced fragment context first (halve,
488
+ // then drop context fields, then drop the fragment list entirely).
489
+ // Requirement ids and sourceFragmentIds are never dropped.
490
+ let fragments = referencedFragments.map((fragment) => ({
491
+ id: fragment.id,
492
+ path: planInputText(fragment.path, 120),
493
+ }));
494
+ let requirements = input.canonicalRequirements.map((requirement) => {
495
+ const sourceFragmentIds = planInputStrings(requirement.sourceFragmentIds);
496
+ return {
497
+ id: requirement.id,
498
+ text: "(truncated)",
499
+ // Empty bindings carry no information; omit them so the payload
500
+ // stays inside the char bound when no requirement is bound.
501
+ ...(sourceFragmentIds.length > 0 ? { sourceFragmentIds } : {}),
502
+ };
503
+ });
504
+ let bounded = JSON.stringify({ degraded: "requirement-texts-truncated", requirements, fragments });
505
+ while (bounded.length > FRONTEND_CONTRACT_INPUT_MAX_CHARS && fragments.length > 0) {
506
+ fragments = fragments.slice(0, Math.floor(fragments.length / 2));
507
+ bounded = JSON.stringify({
508
+ degraded: "requirement-texts-truncated",
509
+ requirements,
510
+ fragments,
511
+ });
512
+ }
513
+ serialized = bounded;
514
+ }
515
+ return [
516
+ "<frontend_contract_input>",
517
+ "Canonical requirements extracted by the source-fidelity ledger, compiled by the runner. Treat them as the authoritative requirement inventory: confirm and commit each requirement through record_requirement (one per tool call); the ledger already binds source fragments, so do NOT re-read the raw source files.",
518
+ serialized,
519
+ "</frontend_contract_input>",
520
+ ].join("\n");
521
+ }
522
+ export async function buildFrontendContractInputContext(input) {
523
+ const ledger = parseLedgerJson(await readFile(input.ledgerPath, "utf8"));
524
+ return renderFrontendContractInputContext({
525
+ canonicalRequirements: ledger.canonicalRequirements.map((requirement) => ({
526
+ id: requirement.id,
527
+ text: requirement.text,
528
+ sourceFragmentIds: requirement.sourceFragmentIds ?? [],
529
+ })),
530
+ fragments: ledger.fragments.map((fragment) => ({
531
+ id: fragment.id,
532
+ path: fragment.path,
533
+ headingPath: fragment.headingPath,
534
+ lineRange: fragment.lineRange,
535
+ })),
536
+ });
537
+ }
538
+ /**
539
+ * Render the planner's complete-but-bounded evidence handoff from committed
540
+ * typed facts. It deliberately excludes upstream response prose and artifact
541
+ * paths: Contract and Scout have already established these facts, so Plan
542
+ * should decide and commit rather than spend another model turn reading them.
543
+ *
544
+ * The block is always valid JSON under the char bound: field texts shrink
545
+ * through a cap ladder before any fact is dropped, and the last-resort
546
+ * fallback keeps every requirement id (with `text: "(truncated)"`) while
547
+ * declaring the degradation, so the planner records targeted evidence gaps
548
+ * instead of receiving a silently corrupted tail.
549
+ */
550
+ export function renderFrontendPlanInputContext(input) {
551
+ const committedFacts = (records) => records.flatMap((record) => record.phase === "committed" && planInputRecord(record.fact)
552
+ ? [record.fact]
553
+ : []);
554
+ const contractFacts = committedFacts(input.contractRecords);
555
+ const scoutFacts = committedFacts(input.scoutRecords);
556
+ const requirements = contractFacts
557
+ .filter((fact) => fact.kind === "requirement" && fact.origin === "contract")
558
+ .map((fact) => ({
559
+ id: planInputText(fact.id, 80),
560
+ text: planInputText(fact.text),
561
+ sourceFragmentIds: planInputStrings(fact.sourceFragmentIds),
562
+ }))
563
+ .filter((fact) => fact.id !== undefined);
564
+ const targetSurface = scoutFacts
565
+ .filter((fact) => fact.kind === "target-surface" && fact.origin === "scout")
566
+ .map((fact) => ({
567
+ completeness: fact.completeness,
568
+ entrypoint: fact.entrypoint,
569
+ routeOrMount: fact.routeOrMount,
570
+ implementationPaths: fact.implementationPaths,
571
+ testPaths: fact.testPaths,
572
+ dataSource: fact.dataSource,
573
+ allowedPathConflicts: fact.allowedPathConflicts,
574
+ unresolvedPaths: fact.unresolvedPaths,
575
+ }));
576
+ const designEvidence = scoutFacts
577
+ .filter((fact) => fact.kind === "design-evidence" && fact.origin === "scout")
578
+ .map((fact) => ({
579
+ source: fact.source,
580
+ paths: fact.paths,
581
+ conflicts: fact.conflicts,
582
+ }));
583
+ // Reviewer-rubric scaffold: the design reviewer re-runs the design-policy
584
+ // checks on the committed facts, so publish the checklist to the producer.
585
+ // Requirements whose contract evidence expects behavioural verification are
586
+ // enumerated explicitly — those are the slots the reviewer finds missing
587
+ // when the plan models interactions ad hoc (r8/r9 findings).
588
+ const behaviorRequiredIds = requirements
589
+ .filter((requirement) => {
590
+ const fact = contractFacts.find((candidate) => candidate.kind === "requirement" &&
591
+ candidate.origin === "contract" &&
592
+ candidate.id === requirement.id);
593
+ const evidence = fact?.evidence;
594
+ return evidence?.behavior === "required";
595
+ })
596
+ .map((requirement) => requirement.id);
597
+ const serializeAtCap = (cap) => JSON.stringify({
598
+ requirements: requirements.map((requirement) => ({
599
+ id: requirement.id,
600
+ text: planInputText(requirement.text, cap.text),
601
+ sourceFragmentIds: planInputStrings(requirement.sourceFragmentIds).slice(0, cap.array),
602
+ })),
603
+ targetSurface: targetSurface.map((surface) => ({
604
+ completeness: planInputText(surface.completeness, 32),
605
+ entrypoint: planInputText(surface.entrypoint, cap.text),
606
+ routeOrMount: planInputText(surface.routeOrMount, cap.text),
607
+ implementationPaths: planInputStrings(surface.implementationPaths).slice(0, cap.array),
608
+ testPaths: planInputStrings(surface.testPaths).slice(0, cap.array),
609
+ dataSource: planInputText(surface.dataSource, cap.text),
610
+ allowedPathConflicts: planInputStrings(surface.allowedPathConflicts).slice(0, cap.array),
611
+ unresolvedPaths: planInputStrings(surface.unresolvedPaths).slice(0, cap.array),
612
+ })),
613
+ designEvidence: designEvidence.map((evidence) => ({
614
+ source: planInputText(evidence.source, cap.text),
615
+ paths: planInputStrings(evidence.paths).slice(0, cap.array),
616
+ conflicts: planInputStrings(evidence.conflicts).slice(0, cap.array),
617
+ })),
618
+ });
619
+ let serialized = serializeAtCap(FRONTEND_PLAN_INPUT_CAP_LADDER[0]);
620
+ for (const cap of FRONTEND_PLAN_INPUT_CAP_LADDER.slice(1)) {
621
+ if (serialized.length <= FRONTEND_PLAN_INPUT_MAX_CHARS)
622
+ break;
623
+ serialized = serializeAtCap(cap);
624
+ }
625
+ if (serialized.length > FRONTEND_PLAN_INPUT_MAX_CHARS) {
626
+ // Last resort: keep every requirement id (ids are short and the plan
627
+ // prompt separately lists them) but drop their texts, shrink scout facts
628
+ // to the minimum, and declare the degradation instead of corrupting JSON.
629
+ let fallback = {
630
+ degraded: "requirement-texts-truncated",
631
+ requirements: requirements.map((requirement) => ({
632
+ id: requirement.id,
633
+ text: "(truncated)",
634
+ })),
635
+ targetSurface: targetSurface.map((surface) => ({
636
+ completeness: planInputText(surface.completeness, 32),
637
+ implementationPaths: planInputStrings(surface.implementationPaths).slice(0, FRONTEND_PLAN_INPUT_CAP_LADDER[3].array),
638
+ })),
639
+ designEvidence: [],
640
+ };
641
+ let bounded = JSON.stringify(fallback);
642
+ let keep = fallback.requirements.length;
643
+ while (bounded.length > FRONTEND_PLAN_INPUT_MAX_CHARS &&
644
+ keep > 0) {
645
+ keep = Math.max(0, Math.floor(keep / 2));
646
+ fallback = { ...fallback, requirements: fallback.requirements.slice(0, keep) };
647
+ bounded = JSON.stringify({
648
+ ...fallback,
649
+ requirementIdsTruncated: keep < requirements.length,
650
+ });
651
+ }
652
+ serialized = bounded;
653
+ }
654
+ const checklistLines = [
655
+ "1. Every interaction you record needs a uiComponentChoices entry whose purpose equals the interaction name, or one decision=reuse-existing choice covering behavioural interactions.",
656
+ "2. Every applicable UI state needs a purpose-matching component choice or a stylingStrategy.",
657
+ "3. Every requirement marked (behavior) below needs modelled interactions plus at least one verification target that references it.",
658
+ "4. targets.files must name the concrete deliverable files; never leave the scope broader than the frozen requirements state.",
659
+ "5. Verification targets may only reference UI states and requirements you actually recorded (the record_* tools reject unknown references).",
660
+ `Requirements requiring behavioural coverage: ${behaviorRequiredIds.length > 0 ? behaviorRequiredIds.join(", ") : "(none)"}`,
661
+ "6. A decision=new component must declare sourceRequirementIds and pass sourceFragmentId for the frozen PRD fragment whose section matches the component's purpose — the runtime validates that binding and derives specReference (path/section/line); the reviewer checks purpose↔citation consistency.",
662
+ ...[...input.componentSourceCitations ?? []]
663
+ .filter(([id]) => behaviorRequiredIds.includes(id))
664
+ .flatMap(([id, citations]) => citations.map((citation) => ` ${id} + ${citation.fragmentId} → ${citation.section}${citation.line ? ` (line ${citation.line})` : ""}`)),
665
+ ];
666
+ return [
667
+ "<frontend_plan_input>",
668
+ "Committed Contract/Scout facts, compiled by the runner. Treat them as the complete planning evidence.",
669
+ serialized,
670
+ "Do not read upstream artifacts, task sources, or repository files. If this input cannot support a decision, record a genuine evidence gap.",
671
+ "</frontend_plan_input>",
672
+ "<plan_review_checklist>",
673
+ "The design reviewer re-runs these exact checks on the committed facts; satisfy every line before finalize_plan:",
674
+ ...checklistLines,
675
+ "</plan_review_checklist>",
676
+ ].join("\n");
677
+ }
678
+ export async function buildFrontendPlanInputContext(runDir, componentSourceCitations) {
679
+ const contractFactsPath = path.join(runDir, "frontend-contract-pi", "contract-typed-facts.jsonl");
680
+ const scoutFactsPath = path.join(runDir, "frontend-scout-pi", "scout-typed-facts.jsonl");
681
+ let contractRecords;
682
+ let scoutRecords;
683
+ try {
684
+ [contractRecords, scoutRecords] = await Promise.all([
685
+ readTypedEventStoreFromJsonl(contractFactsPath),
686
+ readTypedEventStoreFromJsonl(scoutFactsPath),
687
+ ]);
688
+ }
689
+ catch (error) {
690
+ // A missing/unreadable upstream store is a broken pipeline, not an
691
+ // evidence gap; fail before spending a model turn on a prompt that
692
+ // forbids reading anything.
693
+ throw new Error(`frontend-plan-input-unavailable: cannot read committed typed facts (${error instanceof Error ? error.message : String(error)})`);
694
+ }
695
+ const committedCount = [...contractRecords, ...scoutRecords].filter((record) => record.phase === "committed").length;
696
+ if (committedCount === 0) {
697
+ throw new Error(`frontend-plan-input-unavailable: no committed Contract/Scout facts in ${contractFactsPath} / ${scoutFactsPath}`);
698
+ }
699
+ return renderFrontendPlanInputContext({
700
+ contractRecords,
701
+ scoutRecords,
702
+ componentSourceCitations,
703
+ });
704
+ }
705
+ export function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths, recoveryDiagnostics, frontendPlanRetryStep) {
166
706
  if (attemptNumber <= 1)
167
707
  return basePrompt;
708
+ // Context overflow is a transport/session failure, not a plan-protocol
709
+ // failure. It must take precedence over the plan retry ladder so every
710
+ // retry, including a repeated plan overflow, starts from the compact
711
+ // envelope rather than re-sending the original prompt.
712
+ if (previousFailureCategory === "context-overflow") {
713
+ return [
714
+ buildContextOverflowRetryPrompt(task, basePrompt),
715
+ "",
716
+ "<retry_instruction>",
717
+ "The previous attempt exceeded the provider context window. This retry starts a fresh session: keep the evidence surface narrow.",
718
+ "Do not re-read task sources, upstream stdout, skills, or full diff artifacts that are already represented by the canonical contract/review context. Read the smallest relevant artifact first, then only the specific source file or diff fragment needed to decide a finding.",
719
+ "Do not read artifacts/diff_patch.patch in full. For frontend review, read the bounded diff summary and then at most the specific per-file diff fragments it identifies, in part order.",
720
+ "Use concise tool calls and finish the required terminal/output protocol as soon as the evidence is sufficient.",
721
+ "</retry_instruction>",
722
+ ].join("\n");
723
+ }
724
+ if (frontendPlanRetryStep === "compact-terminal-first") {
725
+ return [
726
+ basePrompt,
727
+ "",
728
+ "<retry_instruction>",
729
+ "Frontend plan retry ladder step: compact-terminal-first.",
730
+ "Do NOT rewrite the narrative. Only adopt already staged/quarantined fresh facts, fill the missing required facts, then call finalize_plan exactly once.",
731
+ "Keep the existing committed ledger intact; do not re-derive already committed facts.",
732
+ "</retry_instruction>",
733
+ ].join("\n");
734
+ }
735
+ if (frontendPlanRetryStep === "bounded-tool-only") {
736
+ return [
737
+ basePrompt,
738
+ "",
739
+ "<retry_instruction>",
740
+ "Frontend plan retry ladder step: bounded-tool-only.",
741
+ "Reduce reasoning and wall-clock time. Use only read / fact / terminal tools and only for the necessary missing facts, then call finalize_plan exactly once.",
742
+ "Do not expand scope or re-derive already committed facts.",
743
+ "</retry_instruction>",
744
+ ].join("\n");
745
+ }
746
+ if (frontendPlanRetryStep === "backup-model") {
747
+ return [
748
+ basePrompt,
749
+ "",
750
+ "<retry_instruction>",
751
+ "Frontend plan retry ladder step: backup-model.",
752
+ "A backup provider/model route was selected after repeated non-converging failures. Re-derive only the missing committed facts, then call finalize_plan exactly once; do not repeat the failed strategy.",
753
+ "</retry_instruction>",
754
+ ].join("\n");
755
+ }
168
756
  if (previousFailureCategory === "protocol-invalid" &&
169
757
  task.outputProtocol &&
170
758
  previousProtocolReason) {
@@ -174,9 +762,65 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
174
762
  buildProtocolRetryInstruction(task.outputProtocol, previousProtocolReason),
175
763
  ].join("\n");
176
764
  }
765
+ if (previousFailureCategory === "review-terminal-missing") {
766
+ return [
767
+ basePrompt,
768
+ "",
769
+ "<retry_instruction>",
770
+ "The review emitted a verdict in response text but never committed the authoritative typed terminal tool call (approve_review / request_review_changes). The response text is NOT the authority: no branch or gate reads it.",
771
+ "Call exactly one typed terminal tool to finish: approve_review (implementation passes, no Critical/Important findings) or request_review_changes (with typed issueCategory, at least one evidenceRef, and non-empty findings). Do not repeat the review analysis; commit the terminal tool once and stop.",
772
+ "</retry_instruction>",
773
+ ].join("\n");
774
+ }
775
+ if (previousFailureCategory === "read-burst") {
776
+ if (task.id !== FRONTEND_PLAN_NODE_ID) {
777
+ return [
778
+ basePrompt,
779
+ "",
780
+ "<retry_instruction>",
781
+ "The previous attempt exceeded its read budget. Start a fresh, evidence-minimal pass: do not re-read task sources, upstream stdout, skills, or full diff artifacts already represented by a canonical context artifact.",
782
+ "Read the smallest relevant summary first, then only the specific source file or diff fragment needed for the required decision. Finish the required terminal/output protocol as soon as evidence is sufficient.",
783
+ "</retry_instruction>",
784
+ ].join("\n");
785
+ }
786
+ return [
787
+ basePrompt,
788
+ "",
789
+ "<retry_instruction>",
790
+ "Previous plan attempt issued too many read-only tool calls (read/grep/ls/find) and blew up the context window. Trust the upstream frontend-contract-pi typed requirement facts and frontend-scout-pi target surface already provided — do NOT re-read contract/scout stdout, PRD/source files, or component sources you already inspected.",
791
+ "Minimize discovery reads: only read what you genuinely need, once. Commit record_plan_requirement / record_plan_verification_target / record_* facts directly from the facts already in context (one tool call per message), then call finalize_plan exactly once.",
792
+ "</retry_instruction>",
793
+ ].join("\n");
794
+ }
177
795
  if (previousFailureCategory === "invalid-output" &&
178
796
  task.structuredContractOutput &&
179
797
  previousProtocolReason) {
798
+ // The frontend plan node's compile authority is the committed typed
799
+ // ledger, not a fenced JSON text artifact: its retry guidance must
800
+ // direct the model to re-commit corrected record_* facts and
801
+ // finalize_plan. The legacy full-contract JSON guidance below applies
802
+ // only to nodes whose authority is still a text contract artifact.
803
+ if (task.structuredContractOutput.schemaId ===
804
+ "frontend-implementation-contract-plan-patch-v1") {
805
+ const splitGuidance = /write-set-too-large|split the task/.test(previousProtocolReason ?? "")
806
+ ? [
807
+ "",
808
+ "The writeSet is too large for one implement node. Do NOT delete implementation files to squeeze under the limit — that drops required work. Split the task via record_split_proposal (or narrow targets.files to a genuine subset) so each implement node stays bounded; the full file set must remain covered across the split.",
809
+ ]
810
+ : [];
811
+ return [
812
+ basePrompt,
813
+ "",
814
+ "<retry_instruction>",
815
+ "Previous plan ledger facts failed canonical contract validation:",
816
+ previousProtocolReason,
817
+ "Fix the reported violations by re-committing corrected record_* facts and calling finalize_plan exactly once. The committed typed ledger is the only compile authority.",
818
+ "Do not emit a fenced JSON contract, protected skeleton fields, or an openspec-citations block; narrative JSON is not validated and wastes the output budget.",
819
+ ...frontendPlanValidationRetryGuidance(previousProtocolReason),
820
+ ...splitGuidance,
821
+ "</retry_instruction>",
822
+ ].join("\n");
823
+ }
180
824
  return [
181
825
  basePrompt,
182
826
  "",
@@ -188,8 +832,31 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
188
832
  "</retry_instruction>",
189
833
  ].join("\n");
190
834
  }
835
+ if (previousFailureCategory === "invalid-output" &&
836
+ task.id === "frontend-scout-pi") {
837
+ return [
838
+ basePrompt,
839
+ "",
840
+ "<retry_instruction>",
841
+ "The previous Scout attempt did not commit a complete, runtime-evidenced target surface.",
842
+ "Search the repository only as needed to establish the real entrypoint, implementation ownership, and applicable test path. Commit record_target_surface with completeness=complete and unresolvedPaths=[] only after at least one named target has fresh runtime evidence, unless the task source explicitly declares a greenfield target: in that case every future path must be source-declared by the runtime-enriched fact. If ownership truly cannot be established, record completeness=blocked with each unresolved path; do not make Plan discover it.",
843
+ "</retry_instruction>",
844
+ ].join("\n");
845
+ }
191
846
  if (previousFailureCategory === "structured-output-truncated" &&
192
847
  task.structuredContractOutput) {
848
+ if (task.structuredContractOutput.schemaId ===
849
+ "frontend-implementation-contract-plan-patch-v1") {
850
+ return [
851
+ basePrompt,
852
+ "",
853
+ "<retry_instruction>",
854
+ "Previous attempt was truncated by the provider (stopReason=length) before the plan facts were fully committed.",
855
+ "Re-commit the missing record_* facts and call finalize_plan exactly once; the committed typed ledger is the only compile authority. Do NOT re-read contract/scout outputs or source files — use the facts already in context. Commit record_* facts one tool call per message, then finalize_plan immediately.",
856
+ "Do not emit a fenced JSON contract, protected skeleton fields, or an openspec-citations block; narrative JSON is not validated and wastes the output budget.",
857
+ "</retry_instruction>",
858
+ ].join("\n");
859
+ }
193
860
  return [
194
861
  basePrompt,
195
862
  "",
@@ -230,6 +897,26 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
230
897
  }
231
898
  if (previousFailureCategory === "incomplete-write-set") {
232
899
  const maxAttempts = task.retryPolicy?.maxAttempts ?? 3;
900
+ const bindingOnly = task.id?.startsWith("generate-backend-md-case-") === true &&
901
+ (recoveryTargetPaths?.length ?? 0) === 1 &&
902
+ Object.values(recoveryDiagnostics ?? {}).flat().some((detail) => /(?:unclassified Test Points|duplicate Test Point bindings)/i.test(detail));
903
+ if (bindingOnly) {
904
+ const target = recoveryTargetPaths[0];
905
+ const diagnostics = (recoveryDiagnostics?.[target] ?? []).slice(0, 4);
906
+ return [
907
+ "<retry_instruction>",
908
+ "BACKEND_TEST_LIGHTWEIGHT_BINDING_REPAIR attempt=1/1",
909
+ `target_path=${target}`,
910
+ "Read and edit only that single Markdown file. Do not read the PRD, references, plan, other modules, pytest, source code, or reports.",
911
+ "Modify only the affected Case `### 自动化映射` binding lines. Preserve every Case ID, Rule, Test Point, scenario intent, payload contract, script path, primary symbol, step and expected result.",
912
+ "Materialize exactly these canonical lines in every affected Case: `- 变体测试点:...`, `- 场景断言测试点:...`, `- 横切证据测试点:...`; use `无` for an empty set.",
913
+ "Do not guess a classification from negation, alternatives or vague prose. If exact classification cannot be proven from the same Case, return IMPLEMENTATION_OUTCOME: blocked without changing the file.",
914
+ "Diagnostics:",
915
+ ...diagnostics.map((detail) => `- ${detail}`),
916
+ "After editing, verify the declared Test Point set exactly equals the disjoint union of the three binding lists. Return a short IMPLEMENTATION_OUTCOME only.",
917
+ "</retry_instruction>",
918
+ ].join("\n");
919
+ }
233
920
  // Embed concrete missing/broken paths from the run-owned progress facts
234
921
  // so the continuation attempt is fully self-contained and never reads
235
922
  // a forbidden `.harness/**` evidence file. When the loader finds no
@@ -276,6 +963,18 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
276
963
  }
277
964
  if (task.outputMode === "structured-required") {
278
965
  if (task.structuredContractOutput) {
966
+ if (task.structuredContractOutput.schemaId ===
967
+ "frontend-implementation-contract-plan-patch-v1") {
968
+ return [
969
+ basePrompt,
970
+ "",
971
+ "<retry_instruction>",
972
+ "Previous attempt exceeded the structured output size limit.",
973
+ "Re-commit corrected record_* facts and call finalize_plan exactly once; the committed typed ledger is the only compile authority.",
974
+ "Do not emit a fenced JSON contract, protected skeleton fields, or an openspec-citations block; narrative JSON is not validated and wastes the output budget.",
975
+ "</retry_instruction>",
976
+ ].join("\n");
977
+ }
279
978
  return [
280
979
  basePrompt,
281
980
  "",
@@ -314,10 +1013,7 @@ function promptRestartCandidateForAttempt(input) {
314
1013
  }
315
1014
  const appendedPrefix = `${input.baseRuntimePrompt}\n\n`;
316
1015
  if (input.attemptPrompt.startsWith(appendedPrefix)) {
317
- return [
318
- input.taskPrompt,
319
- input.attemptPrompt.slice(appendedPrefix.length),
320
- ]
1016
+ return [input.taskPrompt, input.attemptPrompt.slice(appendedPrefix.length)]
321
1017
  .filter(Boolean)
322
1018
  .join("\n\n");
323
1019
  }
@@ -353,6 +1049,7 @@ export async function buildNodePromptWithResolvedSkillInstructions(spec, task, u
353
1049
  spec,
354
1050
  task,
355
1051
  upstream,
1052
+ runDir: options?.runDir,
356
1053
  resolvedSkills: skillNames,
357
1054
  resolvedSkillInstructions,
358
1055
  maxUpstreamChars: policy.resolveMaxUpstreamChars(task),
@@ -533,7 +1230,11 @@ export async function executeDagNode(input) {
533
1230
  await notifyNodeObserver(input.observer, "onNodeFinish", nodeId, state);
534
1231
  };
535
1232
  if (task.finalWriteSetApproval) {
536
- const authorization = parseAndValidateFinalWriteSetApproval({ task, spec, state });
1233
+ const authorization = parseAndValidateFinalWriteSetApproval({
1234
+ task,
1235
+ spec,
1236
+ state,
1237
+ });
537
1238
  if (!authorization.ok) {
538
1239
  node.runtimeWriteAuthorization = {
539
1240
  schemaVersion: 1,
@@ -582,6 +1283,52 @@ export async function executeDagNode(input) {
582
1283
  await skipFrontendWriter(record);
583
1284
  return;
584
1285
  }
1286
+ // The admission artifact is the effective authorization boundary. Never
1287
+ // leave the writer using the broad task glob after the shell has frozen a
1288
+ // concrete set: doing so makes the receipt auditable but unenforceable.
1289
+ // Files referenced by frozen verification commands are unioned in: the
1290
+ // plan's verification targets do not always name verification
1291
+ // infrastructure, yet verify-shell cannot run without it and the writer
1292
+ // must be authorized to create it.
1293
+ const admissionWriteSetEntries = admission.result.writeSet.map((entry) => entry.trim().replace(/\\/g, "/").replace(/^\.\//, ""));
1294
+ const frozenVerificationBundle = spec.tasks.find((specTask) => specTask.shell?.frontendVerificationBundle)?.shell?.frontendVerificationBundle;
1295
+ const verificationCommandFiles = frozenVerificationBundle
1296
+ ? collectVerificationCommandFiles([
1297
+ ...(frozenVerificationBundle.staticCommands ?? []),
1298
+ ...(frozenVerificationBundle.behaviorCommands ?? []),
1299
+ ...(frozenVerificationBundle.mockCommands ?? []),
1300
+ ...(frozenVerificationBundle.lintCommands ?? []),
1301
+ ])
1302
+ : [];
1303
+ const extraVerificationFiles = verificationCommandFiles.filter((file) => !admissionWriteSetEntries.includes(file) &&
1304
+ file &&
1305
+ !file.includes("*") &&
1306
+ !file.includes("?") &&
1307
+ !file.split("/").some((segment) => segment === "..") &&
1308
+ task.allowedPaths.some((allowed) => pathMatchesPattern(file, allowed)) &&
1309
+ !task.forbiddenPaths.some((forbidden) => pathMatchesPattern(file, forbidden)));
1310
+ const admittedWriteSet = [
1311
+ ...new Set([...admissionWriteSetEntries, ...extraVerificationFiles]),
1312
+ ];
1313
+ if (admittedWriteSet.length === 0 ||
1314
+ new Set(admittedWriteSet).size !== admittedWriteSet.length ||
1315
+ admittedWriteSet.some((entry) => !entry ||
1316
+ entry.includes("*") ||
1317
+ entry.includes("?") ||
1318
+ entry.split("/").some((segment) => segment === "..") ||
1319
+ !task.allowedPaths.some((allowed) => pathMatchesPattern(entry, allowed)) ||
1320
+ task.forbiddenPaths.some((forbidden) => pathMatchesPattern(entry, forbidden)))) {
1321
+ await failBeforePrompt(new Error("frontend writer admission contains an invalid effective writeSet"), "final-write-set-approval-invalid");
1322
+ return;
1323
+ }
1324
+ task = { ...task, writeSet: admittedWriteSet };
1325
+ node.runtimeWriteAuthorization = {
1326
+ schemaVersion: 1,
1327
+ status: "validated",
1328
+ approvalSourceNodeId: FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT,
1329
+ approvalDigest: admission.result.admissionDigest,
1330
+ effectiveWriteSet: [...admittedWriteSet],
1331
+ };
585
1332
  node.frontendWriterAdmission = record;
586
1333
  }
587
1334
  let projectGovernanceContext;
@@ -622,6 +1369,7 @@ export async function executeDagNode(input) {
622
1369
  spec,
623
1370
  task,
624
1371
  upstream: state.nodes,
1372
+ runDir,
625
1373
  snapshot: skillSnapshot,
626
1374
  projectGovernanceContext,
627
1375
  });
@@ -712,6 +1460,7 @@ export async function executeDagNode(input) {
712
1460
  else {
713
1461
  ({ prompt, resolvedSkills } =
714
1462
  await buildNodePromptWithResolvedSkillInstructions(spec, task, state.nodes, cwd, {
1463
+ runDir,
715
1464
  projectGovernanceContext,
716
1465
  convergenceFeedback: deriveConvergenceFeedback(state),
717
1466
  }));
@@ -723,9 +1472,61 @@ export async function executeDagNode(input) {
723
1472
  await failSkillSnapshot(error);
724
1473
  return;
725
1474
  }
1475
+ if (isFrontendPlanLadderTask(task)) {
1476
+ let planInputContext;
1477
+ try {
1478
+ const componentSourceCitations = await resolveComponentSourceCitations(spec, cwd);
1479
+ planInputContext = await buildFrontendPlanInputContext(runDir, componentSourceCitations);
1480
+ }
1481
+ catch (error) {
1482
+ await failBeforePrompt(error, "frontend-plan-input-unavailable");
1483
+ return;
1484
+ }
1485
+ prompt = `${prompt}\n\n${planInputContext}`;
1486
+ }
1487
+ if (isFrontendContractTypedNode(task)) {
1488
+ // Extreme-environment input handoff: compile the ledger's canonical
1489
+ // requirements into a bounded block so the contract node never re-reads
1490
+ // the raw source (mirrors the plan node's compiled input; its tool set
1491
+ // is record_* + finalize_contract, no read tools).
1492
+ let contractInputContext;
1493
+ try {
1494
+ const ledgerPath = resolveFrontendLedgerPath(spec.sourceBinding, cwd);
1495
+ contractInputContext = await buildFrontendContractInputContext({
1496
+ ledgerPath,
1497
+ });
1498
+ prompt = `${prompt}\n\n${contractInputContext}`;
1499
+ }
1500
+ catch (error) {
1501
+ // Ledger unreadable is a broken pipeline: fail before spending a
1502
+ // model turn on a prompt that forbids reading anything.
1503
+ await failBeforePrompt(error, "frontend-contract-input-unavailable");
1504
+ return;
1505
+ }
1506
+ }
1507
+ if (FRONTEND_WRITER_NODE_IDS.includes(nodeId)) {
1508
+ // Design-review findings are not reliable in the provider's prose output
1509
+ // (typed terminal nodes commonly return an empty assistant message). Inject
1510
+ // the bounded admission capsule explicitly so an authorized retry has the
1511
+ // reviewer's concrete issue/evidence context.
1512
+ try {
1513
+ const admission = await readFrontendPrewriteResult(runDir);
1514
+ const advisory = admission.ok ? admission.result.designReviewAdvisory : undefined;
1515
+ if (advisory?.findings?.length || advisory?.evidenceRefs?.length) {
1516
+ prompt = `${prompt}\n\n<design_review_findings>\n${JSON.stringify({
1517
+ verdict: advisory.verdict,
1518
+ findings: advisory.findings.slice(0, 16),
1519
+ evidenceRefs: advisory.evidenceRefs.slice(0, 16),
1520
+ })}\n</design_review_findings>\nAddress every Critical/Important finding before writing.`;
1521
+ }
1522
+ }
1523
+ catch {
1524
+ // Admission is already enforced above; prompt enrichment is best effort.
1525
+ }
1526
+ }
726
1527
  node.resolvedSkills = resolvedSkills;
727
1528
  await writeNodeSkillArtifacts(runDir, nodeId, resolvedSkills);
728
- const model = resolveModelForTask(task, spec.executorModels);
1529
+ let model = resolveModelForTask(task, spec.executorModels);
729
1530
  let thinking;
730
1531
  if (task.executor === "pi") {
731
1532
  try {
@@ -752,6 +1553,9 @@ export async function executeDagNode(input) {
752
1553
  const attempts = [];
753
1554
  let totalAttemptWallDurationMs = 0;
754
1555
  let totalBackoffMs = 0;
1556
+ const frontendPlanLadderEnabled = isFrontendPlanLadderTask(task);
1557
+ let frontendPlanRetryStep = "normal";
1558
+ const frontendPriorFingerprints = [];
755
1559
  const livenessPolicy = resolveLivenessPolicy(spec.defaults?.livenessPolicy, task.livenessPolicy);
756
1560
  /**
757
1561
  * Attempt-fenced, throttled activity sink. Late events from a previous
@@ -795,6 +1599,13 @@ export async function executeDagNode(input) {
795
1599
  const attemptStartedAt = new Date().toISOString();
796
1600
  node.currentAttempt = attemptNumber;
797
1601
  node.livenessStatus = "active";
1602
+ const beforeCommittedDigest = frontendPlanLadderEnabled
1603
+ ? (await readFrontendPlanCommittedSnapshot(runDir, nodeId)).digest
1604
+ : undefined;
1605
+ if (frontendPlanLadderEnabled &&
1606
+ frontendPlanRetryStep === "backup-model") {
1607
+ model = resolveModelForTask(task, spec.executorModels);
1608
+ }
798
1609
  // Capture the session-events.jsonl length BEFORE the executor appends this
799
1610
  // attempt's events, so the post-attempt repair tool audit can be scoped to
800
1611
  // exactly this attempt's segment (B1: earlier attempts legitimately use
@@ -823,8 +1634,18 @@ export async function executeDagNode(input) {
823
1634
  (acc[i.path] ??= []).push(i.detail);
824
1635
  return acc;
825
1636
  }, {});
826
- let attemptPrompt = buildAttemptPrompt(task, prompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths, recoveryDiagnostics);
1637
+ let attemptPrompt = buildAttemptPrompt(task, prompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths, recoveryDiagnostics, frontendPlanRetryStep);
1638
+ if (task.id === "frontend-scout-pi" && attemptNumber > 1) {
1639
+ attemptPrompt = `${attemptPrompt}\n\nSCOUT RETRY (reuse existing evidence): preserve all committed target-surface/design-evidence facts and do not re-read files already covered by the prior attempt. Inspect only unresolvedPaths or missing target-surface fields, then commit the minimal correction. If the existing facts are complete, commit the same canonical facts without broad rediscovery.`;
1640
+ }
827
1641
  if (attemptNumber > 1 &&
1642
+ // The plan node (frontend-plan-pi) is a typed-facts ladder task:
1643
+ // its retry is driven by the §5.1 ladder (compact-terminal-first
1644
+ // / backup-model) and its compile authority is the committed
1645
+ // ledger — never the legacy frozen fenced-JSON repair. The
1646
+ // frozen repair path below applies only to non-ladder
1647
+ // structured nodes whose output authority is a text artifact.
1648
+ !frontendPlanLadderEnabled &&
828
1649
  isFrontendStructuredRepairSchemaId(task.structuredContractOutput?.schemaId) &&
829
1650
  isStructuredRepairableFailureCategory(previousFailureCategory) &&
830
1651
  (await hasNonEmptyStructuredCandidate({
@@ -875,6 +1696,7 @@ export async function executeDagNode(input) {
875
1696
  model,
876
1697
  ...(thinking ? { thinking } : {}),
877
1698
  prompt: attemptPrompt,
1699
+ resolvedSkills: resolvedSkills.map((skill) => skill.name),
878
1700
  attempt: attemptNumber,
879
1701
  reportActivity,
880
1702
  timeoutMs: livenessPolicy.absoluteMaxWallClockMs,
@@ -912,9 +1734,7 @@ export async function executeDagNode(input) {
912
1734
  ...result,
913
1735
  ok: false,
914
1736
  failureCategory: GOVERNANCE_BLOCKED_CATEGORY,
915
- stderr: [result.stderr, audit.reason]
916
- .filter(Boolean)
917
- .join("\n"),
1737
+ stderr: [result.stderr, audit.reason].filter(Boolean).join("\n"),
918
1738
  };
919
1739
  }
920
1740
  }
@@ -1005,6 +1825,28 @@ export async function executeDagNode(input) {
1005
1825
  prompt: promptRestartCandidate,
1006
1826
  });
1007
1827
  }
1828
+ // C: contract incremental-progress guard. A failed contract attempt that
1829
+ // committed zero record_* submissions is a "no-progress" failure, not an
1830
+ // opaque timeout/error: normalize it to empty-output so the retryPolicy
1831
+ // retries with the incremental-commit discipline instead of burning the
1832
+ // remaining attempts on the same stalled behavior.
1833
+ if (isFrontendContractTypedNode(task) &&
1834
+ !result.ok &&
1835
+ retryPolicy !== undefined) {
1836
+ const submissions = await countContractRecordSubmissions(runDir, nodeId);
1837
+ if (submissions === 0) {
1838
+ result = {
1839
+ ...result,
1840
+ failureCategory: "empty-output",
1841
+ stderr: [
1842
+ result.stderr,
1843
+ "contract attempt failed with zero record_* submissions; retrying with incremental-commit discipline (one record_* call per message, starting from the first tool call)",
1844
+ ]
1845
+ .filter(Boolean)
1846
+ .join("\n"),
1847
+ };
1848
+ }
1849
+ }
1008
1850
  const attemptRecord = {
1009
1851
  attempt: attemptNumber,
1010
1852
  startedAt: attemptStartedAt,
@@ -1021,6 +1863,9 @@ export async function executeDagNode(input) {
1021
1863
  sdkAttempted: result.sdkAttempted,
1022
1864
  tokensUsed: result.tokensUsed,
1023
1865
  parsedEvents: result.parsedEvents,
1866
+ stopReason: result.stopReason,
1867
+ thinkingObserved: result.thinkingObserved,
1868
+ writeToolCallCount: result.writeToolCallCount,
1024
1869
  artifactPath: `${nodeId}/attempt-${attemptNumber}.json`,
1025
1870
  };
1026
1871
  if (retryPolicy !== undefined) {
@@ -1031,6 +1876,60 @@ export async function executeDagNode(input) {
1031
1876
  await writeDagNodeJsonArtifact(runDir, nodeId, `attempt-${attemptNumber}.json`, attemptRecord);
1032
1877
  node.attempts = attempts;
1033
1878
  }
1879
+ // Frontend plan retry ladder: project the 7-value protocol failure
1880
+ // reason, detect new committed facts, and escalate instead of re-running.
1881
+ if (frontendPlanLadderEnabled && !result.ok) {
1882
+ const afterSnapshot = await readFrontendPlanCommittedSnapshot(runDir, nodeId);
1883
+ const hasNewCommittedFact = beforeCommittedDigest !== undefined &&
1884
+ afterSnapshot.digest !== beforeCommittedDigest;
1885
+ const protocolReason = projectFrontendNodeProtocolFailureReason({
1886
+ failureCategory: result.failureCategory,
1887
+ stopReason: result.stopReason,
1888
+ assistantText: result.assistantText ?? result.stdout,
1889
+ hasTerminalFact: afterSnapshot.hasTerminalFact,
1890
+ });
1891
+ const fingerprint = computeNormalizedFailureFingerprint({
1892
+ failureOwner: "plan",
1893
+ protocolFailureReason: protocolReason ?? "",
1894
+ });
1895
+ const backupRoute = resolveFrontendPlanBackupRoute(protocolReason);
1896
+ const nextStep = resolveFrontendPlanRetryStep({
1897
+ currentStep: frontendPlanRetryStep,
1898
+ protocolFailureReason: protocolReason,
1899
+ hasNewCommittedFact,
1900
+ fingerprint,
1901
+ priorFingerprints: frontendPriorFingerprints,
1902
+ backupRouteAvailable: backupRoute.ok,
1903
+ });
1904
+ frontendPriorFingerprints.push(fingerprint);
1905
+ frontendPlanRetryStep = nextStep;
1906
+ if (nextStep === "unsupported-provider-capability") {
1907
+ result = {
1908
+ ...result,
1909
+ ok: false,
1910
+ failureCategory: "unsupported-provider-capability",
1911
+ stderr: [
1912
+ result.stderr,
1913
+ "frontend plan retry ladder: no compatible provider capability route for the protocol failure reason",
1914
+ ]
1915
+ .filter(Boolean)
1916
+ .join("\n"),
1917
+ };
1918
+ }
1919
+ else if (nextStep === "non-converging") {
1920
+ result = {
1921
+ ...result,
1922
+ ok: false,
1923
+ failureCategory: "non-converging",
1924
+ stderr: [
1925
+ result.stderr,
1926
+ "frontend plan retry ladder: repeated failure fingerprint with no new committed fact",
1927
+ ]
1928
+ .filter(Boolean)
1929
+ .join("\n"),
1930
+ };
1931
+ }
1932
+ }
1034
1933
  // Reflect the latest attempt on the node so progress is observable,
1035
1934
  // but keep node.status RUNNING while retry is still possible.
1036
1935
  node.durationMs = durationBetween(node.startedAt, attemptFinishedAt);
@@ -1048,6 +1947,9 @@ export async function executeDagNode(input) {
1048
1947
  retryPolicy === undefined
1049
1948
  ? result.parsedEvents
1050
1949
  : sumAttemptMetric(attempts, (attempt) => attempt.parsedEvents);
1950
+ node.stopReason = result.stopReason;
1951
+ node.thinkingObserved = result.thinkingObserved;
1952
+ node.writeToolCallCount = result.writeToolCallCount;
1051
1953
  node.lastActivityAt = attemptFinishedAt;
1052
1954
  if (result.failureCategory === "termination-unconfirmed") {
1053
1955
  node.needsAttentionReason = "attempt-termination-unconfirmed";
@@ -1237,6 +2139,13 @@ export async function executeDagNode(input) {
1237
2139
  node.structuredArtifactSha256 = pendingStructuredArtifact.sha256;
1238
2140
  node.structuredArtifactSchemaId = pendingStructuredArtifact.schemaId;
1239
2141
  }
2142
+ if ((task.producesArtifacts?.length ?? 0) > 0) {
2143
+ node.declaredArtifacts = await materializeDeclaredArtifactFacts({
2144
+ runDir,
2145
+ task,
2146
+ node,
2147
+ });
2148
+ }
1240
2149
  }
1241
2150
  const finishedAt = new Date().toISOString();
1242
2151
  node.finishedAt = finishedAt;