@tea-agent/loop-agent 0.39.0-beta.1 → 0.39.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. package/AGENTS.md +4 -1
  2. package/CHANGELOG.md +349 -97
  3. package/README.md +9 -5
  4. package/bin/loop-agent.js +7 -3
  5. package/dist/application/task-lifecycle/advance.js +12 -0
  6. package/dist/application/task-lifecycle/recommendations.js +13 -2
  7. package/dist/build-stamp.json +3 -3
  8. package/dist/cli/command-definitions.js +23 -5
  9. package/dist/cli/program.js +21 -2
  10. package/dist/cli/update/notifier.js +2 -2
  11. package/dist/cli/update/runtime-activity.js +1 -29
  12. package/dist/cli.js +2 -1
  13. package/dist/commands/client-recovery.js +3 -0
  14. package/dist/commands/dag-follow-up.js +138 -0
  15. package/dist/commands/dag-rerun.js +55 -1
  16. package/dist/commands/init-model-catalog.js +464 -0
  17. package/dist/commands/init-upgrade.js +265 -97
  18. package/dist/commands/init.js +455 -28
  19. package/dist/commands/inspect-next.js +8 -0
  20. package/dist/commands/task-advance.js +19 -0
  21. package/dist/executors/dag-pi-executor.js +2370 -64
  22. package/dist/executors/pi-executor.js +11 -2
  23. package/dist/executors/pi-extension-resolver.js +233 -0
  24. package/dist/executors/pi-playwright-cli-tool.js +74 -28
  25. package/dist/executors/pi-read-budget-policy.js +239 -0
  26. package/dist/executors/pi-sdk-executor.js +225 -33
  27. package/dist/executors/pi-writer-tool-policy.js +57 -1
  28. package/dist/executors/shell-executor.js +1258 -224
  29. package/dist/executors/shell-write-guard.js +14 -0
  30. package/dist/executors/workspace-write-snapshot.js +68 -0
  31. package/dist/infrastructure/harness/atomic-write.js +23 -0
  32. package/dist/shared/dag-failure-category.js +150 -0
  33. package/dist/shared/dag-prompt-override.js +27 -0
  34. package/dist/shared/openspec-spec.js +70 -4
  35. package/dist/shared/operator/capabilities.js +39 -0
  36. package/dist/shared/playwright-cli-command-policy.js +15 -0
  37. package/dist/shared/update/console-notifier.js +100 -0
  38. package/dist/{cli → shared}/update/npm-client.js +70 -15
  39. package/dist/{cli → shared}/update/state.js +41 -13
  40. package/dist/task/config-types.js +50 -5
  41. package/dist/task/contract/apply.js +11 -1
  42. package/dist/task/contract/constants.js +2 -0
  43. package/dist/task/contract/hash.js +3 -0
  44. package/dist/task/contract/observe.js +25 -4
  45. package/dist/task/contract/paths.js +2 -1
  46. package/dist/task/contract/project.js +18 -1
  47. package/dist/task/contract/schema.js +4 -1
  48. package/dist/task/contract/transaction.js +12 -0
  49. package/dist/task/frontend-project-capability.js +199 -18
  50. package/dist/task/source-prepare/completeness.js +188 -0
  51. package/dist/task/source-prepare/fragment-inventory.js +477 -0
  52. package/dist/task/source-prepare/index.js +3 -0
  53. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  54. package/dist/task/source-prepare/ledger-review.js +214 -0
  55. package/dist/task/source-prepare/ledger.js +545 -0
  56. package/dist/task/source-prepare/parse-intent.js +24 -4
  57. package/dist/task/source-prepare/prepare.js +298 -1
  58. package/dist/task/source-prepare/semantic-intake.js +25 -5
  59. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  60. package/dist/task/source-prepare/types.js +22 -0
  61. package/dist/task/source-references.js +22 -1
  62. package/dist/worker/cli.js +18 -2
  63. package/dist/worker/console/chat/chat-event-store.js +75 -5
  64. package/dist/worker/console/chat/context-panel.js +7 -0
  65. package/dist/worker/console/chat/deferred-turn.js +12 -0
  66. package/dist/worker/console/chat/operation-card.js +8 -1
  67. package/dist/worker/console/chat/pi-console-config.js +81 -10
  68. package/dist/worker/console/chat/pi-runtime.js +331 -18
  69. package/dist/worker/console/chat/provider-error.js +98 -0
  70. package/dist/worker/console/chat/routes.js +395 -59
  71. package/dist/worker/console/chat/session-stats.js +179 -0
  72. package/dist/worker/console/chat/session-store.js +93 -63
  73. package/dist/worker/console/chat/todos.js +115 -0
  74. package/dist/worker/console/chat/tool-preview.js +42 -0
  75. package/dist/worker/console/chat/turn-process.js +20 -55
  76. package/dist/worker/console/chat/user-questions.js +266 -0
  77. package/dist/worker/console/chat/workspace-landing.js +79 -0
  78. package/dist/worker/console/console-handoff.js +82 -0
  79. package/dist/worker/console/console-update-and-init.js +115 -0
  80. package/dist/worker/console/console-update-runtime.js +84 -0
  81. package/dist/worker/console/dag-execution-receipt.js +33 -0
  82. package/dist/worker/console/draft-store.js +49 -0
  83. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  84. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  85. package/dist/worker/console/index.js +3 -0
  86. package/dist/worker/console/inspect-split.js +18 -0
  87. package/dist/worker/console/native-directory-picker.js +13 -0
  88. package/dist/worker/console/operation-run-facts.js +19 -3
  89. package/dist/worker/console/operation-store.js +227 -7
  90. package/dist/worker/console/operator-actions.js +388 -0
  91. package/dist/worker/console/operator-surface-health.js +1 -0
  92. package/dist/worker/console/operator-user-error.js +2 -2
  93. package/dist/worker/console/prd-intake-bridge.js +13 -0
  94. package/dist/worker/console/routes.js +355 -3
  95. package/dist/worker/console/security.js +67 -13
  96. package/dist/worker/console/server.js +609 -164
  97. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4kVYweA.js → abnfDiagram-N423BO3Z-CXj_GnSb.js} +1 -1
  98. package/dist/worker/console/static/assets/{arc-Bj2M8iO5.js → arc-BZp6JAp7.js} +1 -1
  99. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CLlXe4-6.js → architectureDiagram-T3A2C74G-DfpcEuYU.js} +1 -1
  100. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-psEp5xH0.js → blockDiagram-VBNYF7ZC-_Gf0xadb.js} +1 -1
  101. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DeKcOCGJ.js → c4Diagram-5PPSVZJV-Ct2QPCmv.js} +1 -1
  102. package/dist/worker/console/static/assets/channel-3TxJgYaH.js +1 -0
  103. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-K0C744p1.js → chunk-2GRJ4B5K-BfklkzKl.js} +1 -1
  104. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-xbNw4J9-.js → chunk-2Q5K7J3B-Dy25vZJV.js} +1 -1
  105. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLaSDUO.js → chunk-5RXB4S5H-BFlCRZep.js} +1 -1
  106. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CtiPlKel.js → chunk-5VM5RSS4-op3oVxIE.js} +1 -1
  107. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DZtolir7.js → chunk-6Q2QTUOP-C0R7rzF2.js} +1 -1
  108. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CPcdNeew.js → chunk-GF5L2VYU-Bt44TCGy.js} +1 -1
  109. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-NgHc6V37.js → chunk-JWPE2WC7-_uEE_XFx.js} +1 -1
  110. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DQ_T-hg8.js → chunk-KBJHAD2P-C3TOYGZ9.js} +1 -1
  111. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DgLsxcVP.js → chunk-RYQCIY6F-Cz60oBPV.js} +1 -1
  112. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-UrVoM9z1.js → chunk-XXDRQBXY-8Bik0qis.js} +1 -1
  113. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BHIkXpp3.js +1 -0
  114. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BHIkXpp3.js +1 -0
  115. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CLkYvTb3.js → cose-bilkent-JH36ORCC-C2QIOA4a.js} +1 -1
  116. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BwT_xKrE.js → cynefin-VYW2F7L2-CSH_yUUd.js} +1 -1
  117. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bi9tuzif.js → cynefinDiagram-MW4NZA55-BDLw2XFM.js} +1 -1
  118. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BCIqkWBV.js → dagre-VZM6K2ZE-CcD9ZtF4.js} +1 -1
  119. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dip6l9_Z.js → diagram-7IWD3JNH-DqwtkBsI.js} +1 -1
  120. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B-xkh_wK.js → diagram-B4RE2ZJO-BWVEcqsC.js} +1 -1
  121. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DZO0kTt_.js → diagram-LBJQPF4R-M-WAbtQi.js} +1 -1
  122. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D6ZplNbZ.js → diagram-Q27KOJAE-DKW7SixP.js} +1 -1
  123. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPekbhoS.js → diagram-UB23O5K3-PHHPPtrj.js} +1 -1
  124. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BtmaJpK5.js → ebnfDiagram-BXEA7PRR-BQD-B4RX.js} +1 -1
  125. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-6DS0Xc44.js → erDiagram-JOGREHBK-BFvULb51.js} +1 -1
  126. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CihnROSm.js → flowDiagram-UKHOOZJN-Bw-aXTCb.js} +1 -1
  127. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CZC4zOE2.js → ganttDiagram-PKOTCBZU-BGKw04Qx.js} +1 -1
  128. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js → gitGraphDiagram-DS77QQ5N-RqKaPR-I.js} +1 -1
  129. package/dist/worker/console/static/assets/index-B_D8rbWc.js +407 -0
  130. package/dist/worker/console/static/assets/index-BdNx6fj0.css +1 -0
  131. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DlSl4BGx.js → infoDiagram-6WML65LV-YO5dnzrJ.js} +1 -1
  132. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-snnzFl_U.js → ishikawaDiagram-WSZJBQD7-Bi1VZHMq.js} +1 -1
  133. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BDE7qpMx.js → journeyDiagram-NVQOT4AX-Bszls1DD.js} +1 -1
  134. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CD2Ci04G.js → kanban-definition-27J2QSJJ-PhzeaZ09.js} +1 -1
  135. package/dist/worker/console/static/assets/{linear-DdnapKIH.js → linear-BsjbDoXi.js} +1 -1
  136. package/dist/worker/console/static/assets/{mermaid.core-BVjAT9b8.js → mermaid.core-0B7NnWKk.js} +5 -5
  137. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D0yJk-zA.js → mindmap-definition-FAOFIHXS-BJr4Fj-q.js} +1 -1
  138. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVo9tFP-.js → pegDiagram-VL7TDLO6-moC4fpGB.js} +1 -1
  139. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DnOV-mZ_.js → pieDiagram-7S7Q4E2Y-BEw37-2c.js} +1 -1
  140. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CzUJo58i.js → quadrantDiagram-CIZ2JOQS-Cq6LyasU.js} +1 -1
  141. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bvikrolh.js → railroadDiagram-AXF67PYL-DUCMcK0D.js} +1 -1
  142. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DtaSVCap.js → requirementDiagram-LRYGKXZP-C3upTZm7.js} +1 -1
  143. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-C2c9A0wW.js → sankeyDiagram-W5VNT64P-BI_gMsCW.js} +1 -1
  144. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DmXJcU7r.js → sequenceDiagram-SI44F4Z6-YFOIRzfN.js} +1 -1
  145. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4GUFW92.js → sizeCapture-X5ZJPWSS-dOnB7UDD.js} +1 -1
  146. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Dsad2MXf.js → stateDiagram-OKZ733FA-BXUniaIh.js} +1 -1
  147. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cdi6UhLa.js +1 -0
  148. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DGq48Fbi.js → swimlanes-SLNWSIFB-2tA4wTNu.js} +2 -2
  149. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D-RJBbb0.js +8 -0
  150. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CSSi6OFf.js → timeline-definition-Z64GVDOM-DO0HkJXC.js} +1 -1
  151. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BqyzPrWv.js → vennDiagram-T6HMQDX7-DvIOzixv.js} +1 -1
  152. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DlznVb6S.js → wardleyDiagram-T6FBY63Y-DXDZ0cTj.js} +1 -1
  153. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CfBcgJ_K.js → xychartDiagram-ELKLHX3M-B32Ark0D.js} +1 -1
  154. package/dist/worker/console/static/index.html +2 -2
  155. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  156. package/dist/worker/console/static-src/app/console-types.js +68 -4
  157. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  158. package/dist/worker/console/static-src/app/useOperatorActions.js +41 -6
  159. package/dist/worker/console/static-src/app/useRecoveryConsole.js +45 -10
  160. package/dist/worker/console/static-src/app/useRunProgress.js +47 -0
  161. package/dist/worker/console/static-src/app/useTaskWizard.js +74 -2
  162. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  163. package/dist/worker/console/static-src/operator-chat/cards/failure-category-advice.js +25 -0
  164. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +141 -8
  165. package/dist/worker/console/static-src/operator-chat/compaction-message.js +79 -0
  166. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  167. package/dist/worker/console/static-src/operator-chat/input-history.js +76 -0
  168. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
  169. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  170. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  171. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  172. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +25 -0
  173. package/dist/worker/console/static-src/operator-chat/turn-submission.js +16 -3
  174. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +181 -87
  175. package/dist/worker/console/static-src/operator-chat/useChatStream.js +183 -48
  176. package/dist/worker/console/static-src/operator-chat/useChatThread.js +177 -8
  177. package/dist/worker/console/static-src/operator-chat/useComposer.js +32 -0
  178. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  179. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  180. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  181. package/dist/worker/console/static-src/pages/tasks/run-id-resolution.js +79 -0
  182. package/dist/worker/console/static-src/pages/tasks/run-ownership-verify.js +23 -0
  183. package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +110 -0
  184. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  185. package/dist/worker/console/static-src/shell/workspace-route.js +339 -0
  186. package/dist/worker/console/workspace-context.js +344 -0
  187. package/dist/worker/console/workspace-registry.js +214 -0
  188. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  189. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  190. package/dist/worker/materialize/harness-task-lineage.js +5 -2
  191. package/dist/worker/observability/init-runtime-activity.js +26 -0
  192. package/dist/worker/observability/read-model.js +22 -0
  193. package/dist/worker/observe/node-input.js +160 -3
  194. package/dist/worker/observe/node-process.js +377 -0
  195. package/dist/worker/observe/routes.js +224 -3
  196. package/dist/worker/observe/server.js +4 -0
  197. package/dist/worker/observe/shell-handler-keys.js +34 -0
  198. package/dist/worker/observe/static/api.js +90 -4
  199. package/dist/worker/observe/static/app.js +18 -70
  200. package/dist/worker/observe/static/constants.js +8 -1
  201. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  202. package/dist/worker/observe/static/dag-node-purpose.d.ts +6 -0
  203. package/dist/worker/observe/static/dag-node-purpose.js +208 -0
  204. package/dist/worker/observe/static/format.js +60 -0
  205. package/dist/worker/observe/static/index.html +6 -1
  206. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  207. package/dist/worker/observe/static/markdown-render.js +20 -1
  208. package/dist/worker/observe/static/operator-chrome.css +145 -20
  209. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  210. package/dist/worker/observe/static/operator-chrome.js +330 -96
  211. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  212. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  213. package/dist/worker/observe/static/router.d.ts +46 -0
  214. package/dist/worker/observe/static/router.js +25 -1
  215. package/dist/worker/observe/static/state.d.ts +50 -0
  216. package/dist/worker/observe/static/state.js +184 -1
  217. package/dist/worker/observe/static/styles.css +395 -0
  218. package/dist/worker/observe/static/views/dag-graph.js +4 -2
  219. package/dist/worker/observe/static/views/dag-inspector.js +1067 -9
  220. package/dist/worker/observe/static/views/dag.d.ts +13 -0
  221. package/dist/worker/observe/static/views/dag.js +14 -5
  222. package/dist/worker/observe/static/views/dashboard.js +6 -4
  223. package/dist/worker/observe/static/views/failures.js +5 -2
  224. package/dist/worker/observe/static/views/night.js +2 -2
  225. package/dist/worker/observe/static/views/pool.js +1 -0
  226. package/dist/worker/observe/static/views/run.js +3 -1
  227. package/dist/worker/observe/static/views/session-timeline.js +78 -9
  228. package/dist/worker/observe/static/views/task.js +56 -1
  229. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +49 -3
  230. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  231. package/dist/workflows/dag/backend-test-pytest-collection.js +98 -14
  232. package/dist/workflows/dag/backend-test-writer-completeness.js +17 -17
  233. package/dist/workflows/dag/contract-output-registry.js +15 -0
  234. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  235. package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
  236. package/dist/workflows/dag/dynamic-runtime/map.js +2 -1
  237. package/dist/workflows/dag/failure-category.js +7 -116
  238. package/dist/workflows/dag/frontend-closeout.js +221 -0
  239. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  240. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  241. package/dist/workflows/dag/frontend-implementation-contract.js +538 -166
  242. package/dist/workflows/dag/frontend-plan-render.js +24 -0
  243. package/dist/workflows/dag/frontend-prewrite-gate.js +388 -392
  244. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  245. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  246. package/dist/workflows/dag/frontend-recovery-controller.js +202 -0
  247. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  248. package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
  249. package/dist/workflows/dag/frontend-recovery-run.js +33 -20
  250. package/dist/workflows/dag/frontend-repair.js +7 -424
  251. package/dist/workflows/dag/frontend-review-context.js +288 -14
  252. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  253. package/dist/workflows/dag/frontend-risk.js +15 -2
  254. package/dist/workflows/dag/frontend-shadow-dual-write.js +814 -0
  255. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  256. package/dist/workflows/dag/frontend-shape-facts.js +409 -0
  257. package/dist/workflows/dag/frontend-shape.js +427 -0
  258. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  259. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  260. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  261. package/dist/workflows/dag/frontend-test-case-checklist.js +30 -4
  262. package/dist/workflows/dag/frontend-test-case-manifest.js +11 -4
  263. package/dist/workflows/dag/frontend-test-case-quality.js +11 -16
  264. package/dist/workflows/dag/frontend-test-environment-probe.js +230 -0
  265. package/dist/workflows/dag/frontend-test-html-report.js +3 -1
  266. package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
  267. package/dist/workflows/dag/frontend-test-layout.js +159 -0
  268. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  269. package/dist/workflows/dag/frontend-test-result-contract.js +188 -42
  270. package/dist/workflows/dag/frontend-test-standard-scenarios.js +70 -0
  271. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  272. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  273. package/dist/workflows/dag/frontend-verification-trace.js +52 -17
  274. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  275. package/dist/workflows/dag/frontend-writer-admission.js +272 -0
  276. package/dist/workflows/dag/frontend-writer-status.js +244 -0
  277. package/dist/workflows/dag/init-hybrid.js +1072 -660
  278. package/dist/workflows/dag/interrupt-request.js +10 -3
  279. package/dist/workflows/dag/lifecycle.js +3 -3
  280. package/dist/workflows/dag/node-execution.js +552 -58
  281. package/dist/workflows/dag/prompt.js +44 -19
  282. package/dist/workflows/dag/recovery-lease.js +80 -0
  283. package/dist/workflows/dag/report.js +37 -1
  284. package/dist/workflows/dag/rerun-feedback.js +124 -1
  285. package/dist/workflows/dag/rerun-plan.js +144 -5
  286. package/dist/workflows/dag/rerun-run.js +81 -6
  287. package/dist/workflows/dag/rerun-task.js +29 -0
  288. package/dist/workflows/dag/retry-policy.js +318 -8
  289. package/dist/workflows/dag/runner.js +429 -122
  290. package/dist/workflows/dag/scheduler.js +114 -16
  291. package/dist/workflows/dag/structured-output-repair.js +712 -0
  292. package/dist/workflows/dag/types.js +338 -31
  293. package/dist/workflows/dag/upstream-artifacts.js +0 -4
  294. package/dist/workflows/dag/validate.js +92 -32
  295. package/docs/README.md +2 -0
  296. package/docs/architecture/evolution.md +45 -0
  297. package/docs/init-surface.manifest.json +9 -0
  298. package/docs/operations/README.md +1 -1
  299. package/docs/operations/local-development-environment.md +21 -0
  300. package/docs/templates/README.md +2 -1
  301. package/docs/templates/agent-dag-report.schema.json +8 -2
  302. package/docs/templates/agent-dag.schema.json +63 -1
  303. package/docs/templates/backend-test-dag.json +265 -237
  304. package/docs/templates/frontend-implementation-contract.schema.json +684 -24
  305. package/docs/templates/frontend-test-case-checklist.md +2 -2
  306. package/docs/templates/frontend-test-dag.json +8 -10
  307. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  308. package/docs/templates/init-managed-agents.md +13 -3
  309. package/docs/templates/spec-registry.schema.json +45 -0
  310. package/harness.json +1 -1
  311. package/package.json +7 -1
  312. package/scripts/next-info.mjs +356 -0
  313. package/scripts/next-publish-gate.mjs +238 -0
  314. package/scripts/release-source-binding.mjs +251 -0
  315. package/skills/codebase-scout/SKILL.md +1 -1
  316. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  317. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  318. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  319. package/skills/frontend-bounded-implement/SKILL.md +8 -14
  320. package/skills/frontend-design-review/SKILL.md +20 -41
  321. package/skills/frontend-implementation/SKILL.md +3 -4
  322. package/skills/frontend-implementation/references/design-spec.md +15 -10
  323. package/skills/frontend-implementation/references/node-contracts.md +23 -13
  324. package/skills/frontend-review/SKILL.md +22 -14
  325. package/skills/frontend-review/references/review-findings.md +6 -7
  326. package/skills/loop-agent/references/command-reference.md +12 -4
  327. package/skills/loop-agent/references/hybrid-dag.md +8 -1
  328. package/skills/playwright-cli/SKILL.md +23 -1
  329. package/skills/playwright-cli-case-generator/SKILL.md +29 -8
  330. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +0 -1
  331. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +0 -1
  332. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +0 -1
  333. package/dist/worker/console/static/assets/index-DTZOKgAn.js +0 -404
  334. package/dist/worker/console/static/assets/index-Ya5FE7cD.css +0 -1
  335. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +0 -1
  336. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +0 -8
  337. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
  338. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
@@ -1,16 +1,20 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFile, stat } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { z } from "zod";
4
- import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
5
+ import { OPENSPEC_SPEC_EXT_RE, isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
5
6
  import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
6
7
  import { countExcludedGovernanceSpecFiles, resolveOpenspecGovernanceRoot, } from "../../task/frontend-project-capability.js";
7
- import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, applyFrontendImplementationContractPatch, extractFrontendImplementationJson, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
8
+ import { checkFrontendSourceFidelityLedger, } from "./frontend-source-fidelity-ledger.js";
9
+ import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
8
10
  import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
9
11
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
10
12
  import { pathMatchesPattern } from "../../shared/git-progress.js";
11
13
  import { normalizeVerdictCandidateLine } from "./dynamic-runtime/shared.js";
12
14
  export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
13
15
  export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
16
+ /** Canonical contract serialization must stay at or below this byte size. */
17
+ export const FRONTEND_CONTRACT_MAX_BYTES = 65536;
14
18
  /**
15
19
  * Machine-readable failure classification for blocked/retryable-invalid
16
20
  * prewrite outcomes. Kept alongside the human-readable failureReason so
@@ -31,13 +35,22 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
31
35
  "verification-target-outside-write-set",
32
36
  "openspec-not-read",
33
37
  "candidate-missing-drift",
34
- "openspec-citation-block-unparseable",
35
- "openspec-not-cited",
36
- "openspec-citation-not-read",
38
+ "candidate-content-drift",
39
+ "openspec-selection-invalid",
37
40
  "component-spec-reference-invalid",
38
41
  "component-spec-not-cited",
42
+ "component-spec-choice-unbound",
39
43
  "component-choices-missing",
44
+ "contract-too-large",
40
45
  "revision-patch-unmergeable",
46
+ // Source fidelity ledger 类目(SOURCE_FIDELITY_*/source-* 稳定错误码)。
47
+ "source-fidelity-ledger-missing",
48
+ "source-fidelity-ledger-stale",
49
+ "source-fidelity-unresolved",
50
+ "source-fidelity-conflict",
51
+ "source-fidelity-high-risk-uncovered",
52
+ "source-fidelity-requirement-not-covered",
53
+ "source-fidelity-source-ref-invalid",
41
54
  ]);
42
55
  export const frontendPrewriteResultV1Schema = z
43
56
  .object({
@@ -125,7 +138,7 @@ export const frontendPrewriteResultV1Schema = z
125
138
  export const frontendWriterAdmissionSchema = z
126
139
  .object({
127
140
  schemaVersion: z.literal(1),
128
- writerNodeId: z.enum(["frontend-implement-pi", "frontend-repair-pi"]),
141
+ writerNodeId: z.enum(["frontend-implement-pi"]),
129
142
  decision: z.enum(["authorized", "denied"]),
130
143
  sourceArtifact: z.string().min(1),
131
144
  artifactHash: z.string().regex(/^[a-f0-9]{64}$/),
@@ -154,136 +167,68 @@ async function selectNode(runDir, primary, fallbacks) {
154
167
  }
155
168
  throw new Error(`frontend prewrite gate no FINISHED candidate: ${rejections.join("; ")}`);
156
169
  }
157
- async function readNodeText(runDir, nodeId) {
158
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
170
+ async function readFinishedNodeRecord(runDir, nodeId) {
171
+ let record;
172
+ try {
173
+ record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
174
+ }
175
+ catch (error) {
176
+ throw new Error(`frontend prewrite gate cannot parse node record ${nodeId}: ${error instanceof Error ? error.message : String(error)}`);
177
+ }
159
178
  if (record.status !== "FINISHED") {
160
179
  throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
161
180
  }
162
- // Pi execution mirrors a successful structured-contract normalization into
163
- // both assistantText and stdout. Treat node output the same way as the DAG
164
- // runtime's canonicalNodeOutput(): assistantText is authoritative and stdout
165
- // is only a fallback. Concatenating the two duplicate fields makes a single
166
- // canonical plan appear as two complete JSON contracts to the prewrite gate.
181
+ return record;
182
+ }
183
+ async function readNodeText(runDir, nodeId) {
184
+ const record = await readFinishedNodeRecord(runDir, nodeId);
167
185
  const text = record.assistantText?.trim() || record.stdout?.trim() || "";
168
186
  if (!text)
169
187
  throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
170
188
  return text;
171
189
  }
172
- async function readNodeRawText(runDir, nodeId) {
173
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
174
- return record.assistantText ?? record.stdout ?? "";
175
- }
176
- function resolveRunOwnedArtifact(runDir, candidate, label) {
177
- const root = path.resolve(runDir);
178
- const resolved = path.resolve(candidate);
179
- const relative = path.relative(root, resolved);
180
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
181
- throw new Error(`frontend prewrite gate ${label} must be contained by the current run directory`);
190
+ async function readCanonicalContractFromNode(runDir, nodeId) {
191
+ const record = await readFinishedNodeRecord(runDir, nodeId);
192
+ if (!record.structuredArtifactPath) {
193
+ throw new Error(`frontend prewrite gate missing structured artifact for ${nodeId}`);
182
194
  }
183
- return resolved;
184
- }
185
- async function readHashBoundArtifact(input) {
186
- const resolved = resolveRunOwnedArtifact(input.runDir, input.artifactPath, input.label);
187
- const text = await readFile(resolved, "utf8");
188
- const actual = sha256Hex(text);
189
- if (actual !== input.expectedSha256) {
190
- throw new Error(`frontend prewrite gate ${input.label} SHA-256 mismatch (expected ${input.expectedSha256}, got ${actual})`);
195
+ const artifactPath = path.isAbsolute(record.structuredArtifactPath)
196
+ ? record.structuredArtifactPath
197
+ : path.join(runDir, record.structuredArtifactPath);
198
+ const resolvedRun = path.resolve(runDir);
199
+ const resolvedArtifact = path.resolve(artifactPath);
200
+ const relative = path.relative(resolvedRun, resolvedArtifact);
201
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
202
+ throw new Error(`frontend prewrite gate structured artifact escapes runDir: ${record.structuredArtifactPath}`);
191
203
  }
192
- return text;
193
- }
194
- async function readFrontendPlanNodeInput(runDir, nodeId) {
195
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
196
- if (record.status !== "FINISHED") {
197
- throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
204
+ const bytes = await readFile(resolvedArtifact);
205
+ const digest = createHash("sha256").update(bytes).digest("hex");
206
+ if (record.structuredArtifactSha256 &&
207
+ record.structuredArtifactSha256 !== digest) {
208
+ throw new Error(`frontend prewrite gate structured artifact hash mismatch for ${nodeId}`);
198
209
  }
199
- const frontend = record.frontendPlanOutput;
200
- if (!frontend) {
201
- const payloadText = record.assistantText?.trim() || record.stdout?.trim() || "";
202
- if (!payloadText)
203
- throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
204
- return {
205
- artifactFirst: false,
206
- payloadText,
207
- citationsText: payloadText,
208
- rawCandidateSha256: sha256Hex(record.assistantText ?? record.stdout ?? ""),
209
- payloadKind: "canonical-contract",
210
- };
210
+ if (record.structuredArtifactSchemaId &&
211
+ record.structuredArtifactSchemaId !== FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID) {
212
+ throw new Error(`frontend prewrite gate structured artifact schema mismatch for ${nodeId}: ${record.structuredArtifactSchemaId}`);
211
213
  }
212
- if (frontend.schemaVersion !== 1 ||
213
- !record.structuredArtifactPath ||
214
- !record.structuredArtifactSha256 ||
215
- !record.structuredArtifactSchemaId) {
216
- throw new Error(`frontend prewrite gate ${nodeId} has incomplete artifact-first metadata`);
217
- }
218
- const expectedSchema = frontend.payloadKind === "canonical-contract"
219
- ? "frontend-implementation-contract-v1"
220
- : "frontend-implementation-contract-revision-patch-v1";
221
- if (record.structuredArtifactSchemaId !== expectedSchema) {
222
- throw new Error(`frontend prewrite gate ${nodeId} artifact schema ${record.structuredArtifactSchemaId} does not match ${frontend.payloadKind}`);
214
+ const contractText = bytes.toString("utf8");
215
+ let citationsText = "";
216
+ try {
217
+ const citationsRaw = await readFile(path.join(path.dirname(resolvedArtifact), "openspec-citations.json"), "utf8");
218
+ try {
219
+ const parsed = JSON.parse(citationsRaw);
220
+ citationsText =
221
+ typeof parsed.block === "string" ? parsed.block : citationsRaw;
222
+ }
223
+ catch {
224
+ citationsText = citationsRaw;
225
+ }
223
226
  }
224
- const [payloadText, citationsText] = await Promise.all([
225
- readHashBoundArtifact({
226
- runDir,
227
- artifactPath: record.structuredArtifactPath,
228
- expectedSha256: record.structuredArtifactSha256,
229
- label: `${nodeId} primary payload`,
230
- }),
231
- readHashBoundArtifact({
232
- runDir,
233
- artifactPath: frontend.citationsArtifactPath,
234
- expectedSha256: frontend.citationsArtifactSha256,
235
- label: `${nodeId} citations`,
236
- }),
237
- readHashBoundArtifact({
238
- runDir,
239
- artifactPath: frontend.rawCandidatePath,
240
- expectedSha256: frontend.rawCandidateSha256,
241
- label: `${nodeId} raw candidate`,
242
- }).then(() => undefined),
243
- ]);
244
- return {
245
- artifactFirst: true,
246
- payloadText,
247
- citationsText,
248
- rawCandidateSha256: frontend.rawCandidateSha256,
249
- payloadKind: frontend.payloadKind,
250
- };
251
- }
252
- /**
253
- * Same shape predicate as the internal `isContract` inside
254
- * `extractFrontendImplementationJson` (frontend-implementation-contract.ts):
255
- * schemaVersion 1 + a plain `targets` object + `requirements` and
256
- * `verificationTargets` arrays. A merge-patch delta does not carry these
257
- * fields, so this is the deterministic boundary between a full contract and a
258
- * patch. Keep the two predicates in sync.
259
- */
260
- function isFullContractShape(value) {
261
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
262
- return false;
227
+ catch (error) {
228
+ if (error.code !== "ENOENT")
229
+ throw error;
263
230
  }
264
- const record = value;
265
- const targets = record.targets;
266
- return (record.schemaVersion === 1 &&
267
- targets !== null &&
268
- typeof targets === "object" &&
269
- !Array.isArray(targets) &&
270
- Array.isArray(record.requirements) &&
271
- Array.isArray(record.verificationTargets));
272
- }
273
- /**
274
- * Shared merge pipeline for revisionPatch mode and content-driven
275
- * compatibility: read the original contract node, extract the original
276
- * contract and the effective node's patch, apply the RFC 7386 merge-patch, and
277
- * serialize deterministically. Throws on any failure; callers map the error to
278
- * their own classification (retryable-invalid for explicit revisionPatch mode,
279
- * blocked for content-driven fallback).
280
- */
281
- async function mergeRevisionPatch(runDir, planText, originalNodeId) {
282
- const originalText = await readNodeText(runDir, originalNodeId);
283
- const originalValue = extractFrontendImplementationJson(originalText);
284
- const patchValue = extractFrontendImplementationJson(planText);
285
- const merged = applyFrontendImplementationContractPatch(originalValue, patchValue);
286
- return serializeDeterministicJson(merged);
231
+ return { contractText, citationsText, rawSha256: digest };
287
232
  }
288
233
  function firstNonEmptyVerdictLine(text) {
289
234
  const lines = text
@@ -327,6 +272,7 @@ function isFailedToolResult(event) {
327
272
  }
328
273
  async function collectOpenspecReadPaths(input) {
329
274
  const { runDir, planNodeId, reviewNodeId, repoRoot } = input;
275
+ const candidateSet = input.candidatePaths ? new Set(input.candidatePaths) : undefined;
330
276
  const matched = new Set();
331
277
  for (const nodeId of [planNodeId, reviewNodeId]) {
332
278
  const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
@@ -361,7 +307,7 @@ async function collectOpenspecReadPaths(input) {
361
307
  : null;
362
308
  if (relativePath &&
363
309
  !isFailedToolResult(event) &&
364
- isOpenspecSpecFilePath(relativePath)) {
310
+ (candidateSet ? candidateSet.has(relativePath) : OPENSPEC_SPEC_EXT_RE.test(relativePath))) {
365
311
  matched.add(relativePath);
366
312
  }
367
313
  startedReads.delete(event.toolCallId);
@@ -376,21 +322,6 @@ async function collectOpenspecReadPaths(input) {
376
322
  }
377
323
  return [...matched];
378
324
  }
379
- async function checkOpenspecReadEvidence(input) {
380
- const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
381
- if (candidatePaths.length === 0)
382
- return [];
383
- const allReadPaths = await collectOpenspecReadPaths({
384
- runDir,
385
- planNodeId,
386
- reviewNodeId,
387
- repoRoot,
388
- });
389
- const normalizedCandidates = new Set(candidatePaths
390
- .map((candidate) => toRepoRelativePath(candidate, repoRoot))
391
- .filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
392
- return allReadPaths.filter((readPath) => normalizedCandidates.has(readPath));
393
- }
394
325
  function normalizeCitationPath(rawPath, repoRoot) {
395
326
  let candidate = rawPath.trim();
396
327
  const hashIndex = candidate.indexOf("#");
@@ -405,57 +336,132 @@ function normalizeCitationPath(rawPath, repoRoot) {
405
336
  }
406
337
  return candidate;
407
338
  }
408
- /**
409
- * Parses the deterministic openspec-citations fence from the effective plan
410
- * output. Each non-empty line must be one JSON object {"path","section","line"}.
411
- * Rows whose normalized path is not a supported openspec spec file are ignored
412
- * (out of scope); rows that are invalid JSON or lack a string path mark the
413
- * block unparseable (fail-closed when candidates are non-empty).
414
- */
415
- export function parseOpenspecCitations(planText, repoRoot) {
416
- const citations = [];
417
- let blockPresent = false;
418
- let unparseable = false;
419
- const fenceRe = /```openspec-citations[ \t]*\r?\n([\s\S]*?)\r?\n```/g;
420
- let match;
421
- while ((match = fenceRe.exec(planText)) !== null) {
422
- blockPresent = true;
423
- const body = match[1] ?? "";
424
- for (const rawLine of body.split(/\r?\n/)) {
425
- const line = rawLine.trim();
426
- if (!line)
427
- continue;
428
- let parsed;
429
- try {
430
- parsed = JSON.parse(line);
431
- }
432
- catch {
433
- unparseable = true;
434
- continue;
435
- }
436
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
437
- unparseable = true;
438
- continue;
339
+ async function resolveRequiredOpenspecPaths(input) {
340
+ if (input.policy === "scan-strict")
341
+ return { ok: true, paths: input.candidatePaths };
342
+ // Legacy cited DAGs had no selector: preserve their full candidate must-read
343
+ // semantics instead of silently weakening an existing run.
344
+ if (!input.selectionNodeId)
345
+ return { ok: true, paths: input.candidatePaths };
346
+ // OpenSpec classifications arrive as individual typed `openspec-selection`
347
+ // facts (one path per record_openspec_selection call) so a large candidate
348
+ // set never exceeds a single model output budget. Only positive
349
+ // classifications (required | relevant) are legal facts; candidates that
350
+ // were never mentioned default to irrelevant. Explicit task declarations /
351
+ // source citations (`mandatoryPaths`) are never downgraded: they are
352
+ // always required and always must-read, whether or not the model declared
353
+ // them. Fall back to the legacy single fenced JSON for older runs (which
354
+ // may still carry explicit irrelevant rows).
355
+ let selections;
356
+ let allowIrrelevant = false;
357
+ const selectionNodeId = input.selectionNodeId; // guarded above: no selector -> full candidate semantics
358
+ try {
359
+ const facts = await readCommittedContractFacts(input.runDir, selectionNodeId);
360
+ selections = facts
361
+ .filter((fact) => fact.kind === "openspec-selection")
362
+ .map((fact) => ({
363
+ path: String(fact.path ?? ""),
364
+ disposition: String(fact.disposition ?? ""),
365
+ rationale: String(fact.rationale ?? ""),
366
+ }));
367
+ }
368
+ catch {
369
+ selections = [];
370
+ }
371
+ if (selections.length === 0) {
372
+ const legacy = await readLegacyOpenspecSelection({
373
+ runDir: input.runDir,
374
+ selectionNodeId,
375
+ candidatePaths: input.candidatePaths,
376
+ mandatoryPaths: input.mandatoryPaths,
377
+ });
378
+ if (!legacy.ok)
379
+ return legacy;
380
+ selections = legacy.selections;
381
+ allowIrrelevant = true;
382
+ }
383
+ try {
384
+ const byPath = new Map();
385
+ for (const row of selections) {
386
+ const legalDispositions = allowIrrelevant
387
+ ? ["required", "relevant", "irrelevant"]
388
+ : ["required", "relevant"];
389
+ if (typeof row.path !== "string" ||
390
+ !legalDispositions.includes(row.disposition) ||
391
+ typeof row.rationale !== "string" ||
392
+ !row.rationale.trim()) {
393
+ throw new Error(`invalid selection row (path+${legalDispositions.join("|")}+non-empty rationale expected): ${JSON.stringify(row)}`);
439
394
  }
440
- const rawPath = parsed.path;
441
- if (typeof rawPath !== "string" || !rawPath.trim()) {
442
- unparseable = true;
443
- continue;
395
+ if (!input.candidatePaths.includes(row.path)) {
396
+ throw new Error(`invalid selection ${row.path}: not a candidate path`);
444
397
  }
445
- const normalized = normalizeCitationPath(rawPath, repoRoot);
446
- if (!normalized || !isOpenspecSpecFilePath(normalized))
447
- continue;
448
- const section = typeof parsed.section === "string"
449
- ? parsed.section
450
- : "";
451
- const lineValue = parsed.line;
452
- const citationLine = typeof lineValue === "number" && Number.isInteger(lineValue)
453
- ? lineValue
454
- : null;
455
- citations.push({ path: normalized, section, line: citationLine });
398
+ if (byPath.has(row.path))
399
+ throw new Error(`duplicate selection ${row.path}`);
400
+ byPath.set(row.path, row.disposition);
401
+ }
402
+ // Only explicitly required/relevant candidates must be read; everything
403
+ // unmentioned defaults to irrelevant. mandatoryPaths are intrinsic:
404
+ // they must be read even if the model omitted them, and they can never
405
+ // be downgraded by a declaration.
406
+ const mustRead = [
407
+ ...input.mandatoryPaths,
408
+ ...input.candidatePaths.filter((candidate) => byPath.get(candidate) === "required"),
409
+ ];
410
+ return { ok: true, paths: [...new Set(mustRead)].sort() };
411
+ }
412
+ catch (error) {
413
+ return {
414
+ ok: false,
415
+ reason: `openspec selector invalid: ${error instanceof Error ? error.message : String(error)}`,
416
+ };
417
+ }
418
+ }
419
+ async function readCommittedContractFacts(runDir, nodeId) {
420
+ const { readTypedEventStoreFromJsonl } = await import("./frontend-typed-event-store.js");
421
+ const records = await readTypedEventStoreFromJsonl(path.join(runDir, nodeId, "contract-typed-facts.jsonl"));
422
+ return records
423
+ .filter((record) => record.phase === "committed")
424
+ .map((record) => record.fact)
425
+ .filter((fact) => Boolean(fact));
426
+ }
427
+ async function readLegacyOpenspecSelection(input) {
428
+ let text;
429
+ try {
430
+ text = await readNodeText(input.runDir, input.selectionNodeId);
431
+ }
432
+ catch (error) {
433
+ return {
434
+ ok: false,
435
+ reason: `openspec selector output unreadable: ${error instanceof Error ? error.message : String(error)}`,
436
+ };
437
+ }
438
+ const fenced = /```json\s*\n([\s\S]*?)\n```/.exec(text);
439
+ if (!fenced) {
440
+ return {
441
+ ok: false,
442
+ reason: "openspec selector emitted neither typed openspec-selection facts nor a legacy fenced JSON selection object",
443
+ };
444
+ }
445
+ try {
446
+ const parsed = JSON.parse(fenced[1] ?? "");
447
+ if (parsed.schemaVersion !== 1 ||
448
+ parsed.schemaId !== "frontend-openspec-selection-v1" ||
449
+ !Array.isArray(parsed.selections)) {
450
+ throw new Error("schemaVersion/schemaId/selections invalid");
456
451
  }
452
+ const rows = parsed.selections.map((row) => ({
453
+ path: String(row.path ?? ""),
454
+ disposition: String(row.disposition ?? ""),
455
+ rationale: String(row.rationale ?? ""),
456
+ }));
457
+ return { ok: true, selections: rows };
458
+ }
459
+ catch (error) {
460
+ return {
461
+ ok: false,
462
+ reason: `openspec selector invalid: ${error instanceof Error ? error.message : String(error)}`,
463
+ };
457
464
  }
458
- return { blockPresent, unparseable, citations };
459
465
  }
460
466
  async function finalizePrewrite(input, pending) {
461
467
  const sourceBindingSha256 = input.sourceBinding
@@ -506,13 +512,37 @@ async function finalizePrewrite(input, pending) {
506
512
  openspecPolicy: pending.openspecPolicy,
507
513
  };
508
514
  }
515
+ export { checkFrontendSourceFidelityLedger } from "./frontend-source-fidelity-ledger.js";
509
516
  export async function runFrontendPrewriteGate(input) {
510
517
  const workspaceRoot = input.workspaceRoot ?? input.repoRoot;
511
518
  // selectNode throws for a scheduling defect (no FINISHED plan/review). That
512
519
  // remains an ERROR and is intentionally not classified into prewrite-result.
513
520
  const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
514
521
  const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
515
- const candidateRawSha256 = sha256Hex(await readNodeRawText(input.runDir, planNodeId));
522
+ let planArtifact;
523
+ try {
524
+ planArtifact = await readCanonicalContractFromNode(input.runDir, planNodeId);
525
+ }
526
+ catch (error) {
527
+ return finalizePrewrite(input, {
528
+ planNodeId,
529
+ reviewNodeId,
530
+ verdict: "",
531
+ candidateRawSha256: sha256Hex(""),
532
+ candidateJsonSha256: null,
533
+ canonicalSha256: null,
534
+ normalizationActions: [],
535
+ mockStrategy: undefined,
536
+ artifact: undefined,
537
+ failureReason: error instanceof Error ? error.message : String(error),
538
+ failureCode: "plan-text-unreadable",
539
+ classification: "retryable-invalid",
540
+ openspecReadPaths: [],
541
+ openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
542
+ openspecPolicy: input.config.openspecPolicy ?? "scan-strict",
543
+ });
544
+ }
545
+ const candidateRawSha256 = planArtifact.rawSha256;
516
546
  const openspecPolicy = input.config.openspecPolicy ?? "scan-strict";
517
547
  const basePending = {
518
548
  planNodeId,
@@ -531,21 +561,6 @@ export async function runFrontendPrewriteGate(input) {
531
561
  openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
532
562
  openspecPolicy,
533
563
  };
534
- let planInput;
535
- try {
536
- planInput = await readFrontendPlanNodeInput(input.runDir, planNodeId);
537
- }
538
- catch (error) {
539
- return finalizePrewrite(input, {
540
- ...basePending,
541
- classification: "retryable-invalid",
542
- failureReason: error instanceof Error ? error.message : String(error),
543
- failureCode: "plan-text-unreadable",
544
- });
545
- }
546
- const planText = planInput.payloadText;
547
- const citationsText = planInput.citationsText;
548
- basePending.candidateRawSha256 = planInput.rawCandidateSha256;
549
564
  let reviewText;
550
565
  try {
551
566
  reviewText = await readNodeText(input.runDir, reviewNodeId);
@@ -568,90 +583,7 @@ export async function runFrontendPrewriteGate(input) {
568
583
  failureCode: "verdict-not-pass",
569
584
  });
570
585
  }
571
- // revisionPatch mode: the effective plan node emits an RFC 7386 merge-patch
572
- // delta against the original contract node (planFallbackFromNodeIds[0]).
573
- // Legacy DAGs without revisionPatch (undefined) keep full-contract semantics.
574
- const revisionPatchMode = input.config.revisionPatch === true &&
575
- planNodeId === input.config.planFromNodeId &&
576
- input.config.planFallbackFromNodeIds.length > 0;
577
- let effectiveContractText = null;
578
- if (revisionPatchMode) {
579
- const originalNodeId = input.config.planFallbackFromNodeIds[0];
580
- try {
581
- const originalInput = await readFrontendPlanNodeInput(input.runDir, originalNodeId);
582
- if (planInput.artifactFirst && !originalInput.artifactFirst) {
583
- throw new Error(`artifact-first revision ${planNodeId} requires artifact-first original contract ${originalNodeId}`);
584
- }
585
- if (planInput.artifactFirst) {
586
- if (planInput.payloadKind !== "merge-patch" ||
587
- originalInput.payloadKind !== "canonical-contract") {
588
- throw new Error("artifact-first revision/original payload kinds are invalid");
589
- }
590
- effectiveContractText = serializeDeterministicJson(applyFrontendImplementationContractPatch(JSON.parse(originalInput.payloadText), JSON.parse(planInput.payloadText)));
591
- }
592
- else {
593
- effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
594
- }
595
- }
596
- catch (error) {
597
- return finalizePrewrite(input, {
598
- ...basePending,
599
- verdict,
600
- classification: "retryable-invalid",
601
- failureReason: `frontend prewrite gate revisionPatch mode requires original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}`,
602
- failureCode: "contract-invalid",
603
- });
604
- }
605
- }
606
- else {
607
- // Content-driven compatibility (Bug 1): legacy DAGs without
608
- // revisionPatch can still receive a merge-patch delta from the revision
609
- // node. Detect the output shape instead of trusting the DAG flag alone.
610
- // - Full contract shape → keep legacy full-contract semantics unchanged.
611
- // - Patch shape with a usable original contract node → auto-merge and
612
- // run the existing analysis/materialization pipeline.
613
- // - Patch shape but unmergeable → blocked + revision-patch-unmergeable
614
- // (no retryable loop).
615
- let planJsonValue = null;
616
- try {
617
- planJsonValue = extractFrontendImplementationJson(planText);
618
- }
619
- catch {
620
- // Not machine-readable JSON: fall back to the legacy full-contract
621
- // path where analyzeFrontendImplementationContract surfaces the
622
- // original diagnostic (fail-closed without a new failure mode).
623
- }
624
- if (planJsonValue !== null && !isFullContractShape(planJsonValue)) {
625
- const originalNodeId = input.config.planFallbackFromNodeIds[0];
626
- if (!originalNodeId || originalNodeId === planNodeId) {
627
- return finalizePrewrite(input, {
628
- ...basePending,
629
- verdict,
630
- classification: "blocked",
631
- failureReason: "frontend prewrite gate: the effective plan output is a merge-patch delta but no distinct original contract node is available. Check the DAG revisionPatch/planFallbackFromNodeIds configuration or regenerate the DAG.",
632
- failureCode: "revision-patch-unmergeable",
633
- });
634
- }
635
- try {
636
- const originalText = await readNodeText(input.runDir, originalNodeId);
637
- const originalValue = extractFrontendImplementationJson(originalText);
638
- if (!isFullContractShape(originalValue)) {
639
- throw new Error(`original contract node ${originalNodeId} is not a full frontend implementation contract`);
640
- }
641
- effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
642
- }
643
- catch (error) {
644
- return finalizePrewrite(input, {
645
- ...basePending,
646
- verdict,
647
- classification: "blocked",
648
- failureReason: `frontend prewrite gate: the effective plan output is a merge-patch delta but it cannot be merged onto the original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}. Check the DAG revisionPatch/planFallbackFromNodeIds configuration or regenerate the DAG.`,
649
- failureCode: "revision-patch-unmergeable",
650
- });
651
- }
652
- }
653
- }
654
- const requirementIdSearchText = effectiveContractText ?? planText;
586
+ const requirementIdSearchText = planArtifact.contractText;
655
587
  const missingIds = input.config.requiredRequirementIds.filter((id) => !requirementIdSearchText.includes(id));
656
588
  if (missingIds.length > 0) {
657
589
  return finalizePrewrite(input, {
@@ -699,17 +631,13 @@ export async function runFrontendPrewriteGate(input) {
699
631
  workspaceRoot,
700
632
  });
701
633
  }
702
- // Candidate -> canonical conversion. Artifact-first inputs must keep using
703
- // the already hash-verified payload: re-reading the selected node record
704
- // here would silently fall back to its prose and defeat the integrity gate.
705
- // Historical records intentionally retain their node-text compatibility path.
634
+ // Candidate -> canonical conversion. The gate consumes the producing node's
635
+ // hash-bound canonical artifact and never re-merges plan/revision stdout.
706
636
  let analysis;
707
637
  try {
708
- const rawContractText = effectiveContractText ?? (planInput.artifactFirst ? planText : undefined);
709
638
  analysis = await analyzeFrontendImplementationContract({
710
639
  runDir: input.runDir,
711
- fromNodeId: rawContractText ? undefined : planNodeId,
712
- rawContractText,
640
+ rawContractText: planArtifact.contractText,
713
641
  sourceBinding: input.sourceBinding,
714
642
  });
715
643
  }
@@ -729,6 +657,48 @@ export async function runFrontendPrewriteGate(input) {
729
657
  }
730
658
  throw error;
731
659
  }
660
+ // Source fidelity ledger gate (20260824-frontend-source-fidelity-ledger-v1):
661
+ // 在 verdict/missing-requirement-ids/source-binding-not-fresh 之后插入 ledger
662
+ // 检查。缺失即 fail closed 且 writer 不获授权;所有检查通过才继续物化契约。
663
+ if (input.sourceBinding) {
664
+ const ledgerCheck = await checkFrontendSourceFidelityLedger({
665
+ workspaceRoot: workspaceRoot ?? "",
666
+ binding: input.sourceBinding,
667
+ contractRequirements: analysis.canonical.requirements.map((requirement) => ({
668
+ id: requirement.id,
669
+ sourceFragmentIds: requirement.sourceFragmentIds,
670
+ sourceRefs: requirement.sourceRefs,
671
+ })),
672
+ });
673
+ if (!ledgerCheck.ok && ledgerCheck.failureCode) {
674
+ return finalizePrewrite(input, {
675
+ ...basePending,
676
+ verdict,
677
+ candidateJsonSha256: analysis.candidateJsonSha256,
678
+ normalizationActions: [],
679
+ mockStrategy: analysis.canonical.mockApi.strategy,
680
+ classification: "blocked",
681
+ failureReason: ledgerCheck.failureReason ??
682
+ "frontend source fidelity ledger gate failed",
683
+ failureCode: ledgerCheck.failureCode,
684
+ });
685
+ }
686
+ }
687
+ // Size guard: a canonical contract above 64KB is a retryable-invalid defect
688
+ // (compress the contract) rather than a writer authorization failure.
689
+ const contractSizeBytes = Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8");
690
+ if (contractSizeBytes > FRONTEND_CONTRACT_MAX_BYTES) {
691
+ return finalizePrewrite(input, {
692
+ ...basePending,
693
+ verdict,
694
+ candidateJsonSha256: analysis.candidateJsonSha256,
695
+ normalizationActions: [],
696
+ mockStrategy: analysis.canonical.mockApi.strategy,
697
+ classification: "retryable-invalid",
698
+ failureReason: `frontend prewrite gate: canonical contract serialization is ${contractSizeBytes} bytes, exceeding the ${FRONTEND_CONTRACT_MAX_BYTES}-byte limit. 压缩契约(移除冗余 prose、重复目标或过长描述)后重试。`,
699
+ failureCode: "contract-too-large",
700
+ });
701
+ }
732
702
  const mockStrategy = analysis.canonical.mockApi.strategy;
733
703
  if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
734
704
  const quotedAllowed = input.config.allowedMockStrategies
@@ -800,14 +770,31 @@ export async function runFrontendPrewriteGate(input) {
800
770
  // Effective plan/review must actually read the candidate openspec paths when
801
771
  // any exist. This fail-closes BEFORE the canonical contract is materialized
802
772
  // so a writer is never authorized on top of unread normative evidence.
803
- const candidatePaths = input.config.openspecCandidatePaths ?? [];
773
+ const frozenCandidatePaths = input.config.openspecCandidatePaths ?? [];
774
+ const selection = await resolveRequiredOpenspecPaths({
775
+ runDir: input.runDir,
776
+ selectionNodeId: input.config.openspecSelectionNodeId,
777
+ candidatePaths: frozenCandidatePaths,
778
+ mandatoryPaths: input.config.openspecMandatoryPaths ?? [],
779
+ policy: openspecPolicy,
780
+ });
781
+ if (!selection.ok) {
782
+ return finalizePrewrite(input, {
783
+ ...basePending, verdict, candidateJsonSha256: analysis.candidateJsonSha256,
784
+ normalizationActions: [], mockStrategy, classification: "retryable-invalid",
785
+ failureReason: `frontend prewrite gate: ${selection.reason}`,
786
+ failureCode: "openspec-selection-invalid",
787
+ openspecCandidatePaths: frozenCandidatePaths,
788
+ });
789
+ }
790
+ const candidatePaths = selection.paths;
804
791
  const candidateRoot = workspaceRoot ?? process.cwd();
805
792
  // Drift precheck: candidates are frozen at generation time. A candidate that
806
793
  // no longer exists on disk was deleted/renamed after generation and is a
807
794
  // generation-contract defect, not an "unread" failure.
808
795
  const missingCandidates = [];
809
- if (candidatePaths.length > 0) {
810
- for (const candidate of candidatePaths) {
796
+ if (frozenCandidatePaths.length > 0) {
797
+ for (const candidate of frozenCandidatePaths) {
811
798
  try {
812
799
  await stat(path.join(candidateRoot, candidate));
813
800
  }
@@ -830,99 +817,78 @@ export async function runFrontendPrewriteGate(input) {
830
817
  openspecCandidatePaths: candidatePaths,
831
818
  });
832
819
  }
833
- let openspecReadPaths;
834
- if (openspecPolicy === "cited") {
835
- openspecReadPaths = await collectOpenspecReadPaths({
836
- runDir: input.runDir,
837
- planNodeId,
838
- reviewNodeId,
839
- repoRoot: candidateRoot,
840
- });
841
- if (candidatePaths.length > 0) {
842
- const parsed = parseOpenspecCitations(citationsText, candidateRoot);
843
- if (!parsed.blockPresent || parsed.unparseable) {
844
- return finalizePrewrite(input, {
845
- ...basePending,
846
- verdict,
847
- candidateJsonSha256: analysis.candidateJsonSha256,
848
- normalizationActions: [],
849
- mockStrategy,
850
- classification: "retryable-invalid",
851
- failureReason: `frontend prewrite gate: the effective plan output must end with a parseable \`\`\`openspec-citations fenced block (one JSON {"path","section","line"} per line). ${parsed.blockPresent
852
- ? "The block is present but contains unparseable lines."
853
- : "No openspec-citations block was found."}`,
854
- failureCode: "openspec-citation-block-unparseable",
855
- openspecReadPaths,
856
- openspecCandidatePaths: candidatePaths,
857
- });
858
- }
859
- const citedPaths = new Set(parsed.citations.map((citation) => citation.path));
860
- const notCited = candidatePaths.filter((candidate) => !citedPaths.has(candidate));
861
- if (notCited.length > 0) {
862
- return finalizePrewrite(input, {
863
- ...basePending,
864
- verdict,
865
- candidateJsonSha256: analysis.candidateJsonSha256,
866
- normalizationActions: [],
867
- mockStrategy,
868
- classification: "retryable-invalid",
869
- failureReason: `frontend prewrite gate: these openspec candidate paths were not cited in the plan openspec-citations block: ${notCited.join(", ")}`,
870
- failureCode: "openspec-not-cited",
871
- openspecReadPaths,
872
- openspecCandidatePaths: candidatePaths,
873
- });
874
- }
875
- const readSet = new Set(openspecReadPaths);
876
- const uncorroborated = [
877
- ...new Set(parsed.citations
878
- .filter((citation) => !readSet.has(citation.path))
879
- .map((citation) => citation.path)),
880
- ];
881
- if (uncorroborated.length > 0) {
882
- return finalizePrewrite(input, {
883
- ...basePending,
884
- verdict,
885
- candidateJsonSha256: analysis.candidateJsonSha256,
886
- normalizationActions: [],
887
- mockStrategy,
888
- classification: "retryable-invalid",
889
- failureReason: `frontend prewrite gate: these openspec paths were cited in the plan but have no successful read event in the effective plan/review: ${uncorroborated.join(", ")}`,
890
- failureCode: "openspec-citation-not-read",
891
- openspecReadPaths,
892
- openspecCandidatePaths: candidatePaths,
893
- });
894
- }
820
+ const frozenSnapshots = input.config.openspecCandidateSnapshots;
821
+ if (frozenSnapshots) {
822
+ const snapshotByPath = new Map(frozenSnapshots.map((snapshot) => [snapshot.path, snapshot.sha256]));
823
+ const incompleteSnapshots = frozenCandidatePaths.filter((candidate) => !snapshotByPath.has(candidate));
824
+ const unexpectedSnapshots = frozenSnapshots
825
+ .map((snapshot) => snapshot.path)
826
+ .filter((candidate) => !frozenCandidatePaths.includes(candidate));
827
+ const changedCandidates = [];
828
+ for (const candidate of frozenCandidatePaths) {
829
+ const expected = snapshotByPath.get(candidate);
830
+ if (!expected)
831
+ continue;
832
+ const actual = createHash("sha256")
833
+ .update(await readFile(path.join(candidateRoot, candidate)))
834
+ .digest("hex");
835
+ if (actual !== expected)
836
+ changedCandidates.push(candidate);
837
+ }
838
+ if (incompleteSnapshots.length > 0 ||
839
+ unexpectedSnapshots.length > 0 ||
840
+ changedCandidates.length > 0) {
841
+ const details = [
842
+ ...(incompleteSnapshots.length > 0
843
+ ? [`missing snapshots: ${incompleteSnapshots.join(", ")}`]
844
+ : []),
845
+ ...(unexpectedSnapshots.length > 0
846
+ ? [`unexpected snapshots: ${unexpectedSnapshots.join(", ")}`]
847
+ : []),
848
+ ...(changedCandidates.length > 0
849
+ ? [`content changed: ${changedCandidates.join(", ")}`]
850
+ : []),
851
+ ].join("; ");
852
+ return finalizePrewrite(input, {
853
+ ...basePending,
854
+ verdict,
855
+ candidateJsonSha256: analysis.candidateJsonSha256,
856
+ normalizationActions: [],
857
+ mockStrategy,
858
+ classification: "blocked",
859
+ failureReason: `frontend prewrite gate blocked: the generation-frozen OpenSpec content snapshot no longer matches the workspace (${details}). Re-read the changed specification and regenerate the DAG.`,
860
+ failureCode: "candidate-content-drift",
861
+ openspecReadPaths: [],
862
+ openspecCandidatePaths: candidatePaths,
863
+ });
895
864
  }
896
- // cited with empty candidates: no read enforcement. The generation step
897
- // emits an advisory instead of failing the gate.
898
865
  }
899
- else {
900
- openspecReadPaths = await checkOpenspecReadEvidence({
901
- runDir: input.runDir,
902
- candidatePaths,
903
- planNodeId,
904
- reviewNodeId,
905
- repoRoot: candidateRoot,
906
- });
907
- if (candidatePaths.length > 0) {
908
- const readSet = new Set(openspecReadPaths);
909
- const unread = candidatePaths.filter((candidate) => !readSet.has(candidate));
910
- if (unread.length > 0) {
911
- const governanceRoot = await resolveOpenspecGovernanceRoot(candidateRoot);
912
- const excludedGovernanceCount = await countExcludedGovernanceSpecFiles(candidateRoot, governanceRoot);
913
- return finalizePrewrite(input, {
914
- ...basePending,
915
- verdict,
916
- candidateJsonSha256: analysis.candidateJsonSha256,
917
- normalizationActions: [],
918
- mockStrategy,
919
- classification: "retryable-invalid",
920
- failureReason: `frontend prewrite gate: effective plan/review did not read these openspec candidate paths: ${unread.join(", ")}. Read each candidate and report applicable rules plus hit path/section/line number, conflicts, and missing specifications, then rerun. 实际成功读取: ${openspecReadPaths.length > 0 ? openspecReadPaths.join(", ") : "(none)"}; 候选总数: ${candidatePaths.length}; 排除治理目录 ${governanceRoot} 下 ${excludedGovernanceCount} 个文件(运行期诊断口径); 候选集在生成期冻结,若候选与本任务无关属于生成期契约配置问题,应修正后重新生成 DAG。`,
921
- failureCode: "openspec-not-read",
922
- openspecReadPaths,
923
- openspecCandidatePaths: candidatePaths,
924
- });
925
- }
866
+ let openspecReadPaths;
867
+ openspecReadPaths = await collectOpenspecReadPaths({
868
+ runDir: input.runDir,
869
+ planNodeId,
870
+ reviewNodeId,
871
+ repoRoot: candidateRoot,
872
+ ...(candidatePaths.length > 0 ? { candidatePaths } : {}),
873
+ });
874
+ if (candidatePaths.length > 0) {
875
+ const readSet = new Set(openspecReadPaths);
876
+ const unread = candidatePaths.filter((candidate) => !readSet.has(candidate));
877
+ if (unread.length > 0) {
878
+ const governanceRoot = await resolveOpenspecGovernanceRoot(candidateRoot);
879
+ const excludedGovernanceCount = await countExcludedGovernanceSpecFiles(candidateRoot, governanceRoot);
880
+ return finalizePrewrite(input, {
881
+ ...basePending,
882
+ verdict,
883
+ candidateJsonSha256: analysis.candidateJsonSha256,
884
+ normalizationActions: [],
885
+ mockStrategy,
886
+ classification: "retryable-invalid",
887
+ failureReason: `frontend prewrite gate: effective plan/review did not read these openspec candidate paths: ${unread.join(", ")}. Read each candidate and report applicable rules plus hit path/section/line number, conflicts, and missing specifications, then rerun. 实际成功读取: ${openspecReadPaths.length > 0 ? openspecReadPaths.join(", ") : "(none)"}; 候选总数: ${candidatePaths.length}; 排除治理目录 ${governanceRoot} 下 ${excludedGovernanceCount} 个文件(运行期诊断口径); 候选集在生成期冻结,若候选与本任务无关属于生成期契约配置问题,应修正后重新生成 DAG。`,
888
+ failureCode: "openspec-not-read",
889
+ openspecReadPaths,
890
+ openspecCandidatePaths: candidatePaths,
891
+ });
926
892
  }
927
893
  }
928
894
  // Component selection spec conformance (ADR 0016). Deterministic cross-check
@@ -944,13 +910,17 @@ export async function runFrontendPrewriteGate(input) {
944
910
  normalizationActions: [],
945
911
  mockStrategy,
946
912
  classification: "retryable-invalid",
947
- failureReason: "frontend prewrite gate: the canonical contract has UI-visible work but uiComponentChoices is missing or empty while the generation-frozen component/theme specification bucket is non-empty. 为每个 UI 用途声明组件选型(decision: specified | reuse-existing | new),specified 必须给 specReference.path/section/line,new 必须给 rationale 声明偏差。",
913
+ failureReason: "frontend prewrite gate: the canonical contract has UI-visible work but uiComponentChoices is missing or empty while the generation-frozen component/theme specification bucket is non-empty. 为每个 UI 用途声明组件选型(decision: specified | reuse-existing | new);specified 必须给 OpenSpec specReference.path/section/line,new 必须给任务源 PRD specReference.path/section/line。",
948
914
  failureCode: "component-choices-missing",
949
915
  openspecReadPaths,
950
916
  openspecCandidatePaths: candidatePaths,
951
917
  });
952
918
  }
919
+ // Only `specified` choices cite the frozen OpenSpec candidate bucket. A
920
+ // `new` choice instead cites its task-source/PRD declaration and must not be
921
+ // rejected merely because that source is outside the OpenSpec roots.
953
922
  const componentSpecReferences = componentChoices
923
+ .filter((choice) => choice.decision === "specified")
954
924
  .map((choice) => ({
955
925
  raw: choice.specReference?.path ?? "",
956
926
  normalized: choice.specReference?.path
@@ -958,11 +928,37 @@ export async function runFrontendPrewriteGate(input) {
958
928
  : null,
959
929
  }))
960
930
  .filter((ref) => ref.raw.length > 0);
931
+ if (componentSpecCandidates.length > 0 && hasUiVisibleWork) {
932
+ const componentCandidateSet = new Set(componentSpecCandidates);
933
+ const hasBoundSpecifiedChoice = componentChoices.some((choice) => {
934
+ if (choice.decision !== "specified" || !choice.specReference?.path) {
935
+ return false;
936
+ }
937
+ const normalized = normalizeCitationPath(choice.specReference.path, candidateRoot);
938
+ return Boolean(normalized && componentCandidateSet.has(normalized));
939
+ });
940
+ if (!hasBoundSpecifiedChoice) {
941
+ return finalizePrewrite(input, {
942
+ ...basePending,
943
+ verdict,
944
+ candidateJsonSha256: analysis.candidateJsonSha256,
945
+ normalizationActions: [],
946
+ mockStrategy,
947
+ classification: "retryable-invalid",
948
+ failureReason: "frontend prewrite gate: UI-visible work has a generation-frozen component/theme specification bucket, but no uiComponentChoices decision=specified is bound to a path in that bucket. At least one declared UI purpose must use and cite the applicable component/theme specification; reuse-existing/new alone cannot bypass the bucket.",
949
+ failureCode: "component-spec-choice-unbound",
950
+ openspecReadPaths,
951
+ openspecCandidatePaths: candidatePaths,
952
+ });
953
+ }
954
+ }
961
955
  if (componentSpecReferences.length > 0) {
962
956
  const invalidRefs = [
963
957
  ...new Set(componentSpecReferences
964
958
  .filter((ref) => !ref.normalized ||
965
- !isOpenspecSpecFilePath(ref.normalized))
959
+ !(candidatePaths.length > 0
960
+ ? candidatePaths.includes(ref.normalized)
961
+ : isOpenspecSpecFilePath(ref.normalized)))
966
962
  .map((ref) => ref.raw)),
967
963
  ];
968
964
  if (invalidRefs.length > 0) {
@@ -980,13 +976,10 @@ export async function runFrontendPrewriteGate(input) {
980
976
  });
981
977
  }
982
978
  if (openspecPolicy === "cited") {
983
- const parsedCitations = parseOpenspecCitations(citationsText, candidateRoot);
984
- const citedPaths = new Set(parsedCitations.citations.map((citation) => citation.path));
985
979
  const readSet = new Set(openspecReadPaths);
986
980
  const uncorroborated = [
987
981
  ...new Set(componentSpecReferences
988
982
  .filter((ref) => !ref.normalized ||
989
- !citedPaths.has(ref.normalized) ||
990
983
  !readSet.has(ref.normalized))
991
984
  .map((ref) => ref.raw)),
992
985
  ];
@@ -998,7 +991,7 @@ export async function runFrontendPrewriteGate(input) {
998
991
  normalizationActions: [],
999
992
  mockStrategy,
1000
993
  classification: "retryable-invalid",
1001
- failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths were not cited in the plan openspec-citations block or lack a successful read event: ${uncorroborated.join(", ")}`,
994
+ failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths are missing a ledger specReference or lack a successful read event: ${uncorroborated.join(", ")}`,
1002
995
  failureCode: "component-spec-not-cited",
1003
996
  openspecReadPaths,
1004
997
  openspecCandidatePaths: candidatePaths,
@@ -1072,6 +1065,9 @@ export function formatFrontendPrewriteGateStdout(result) {
1072
1065
  }
1073
1066
  lines.push(`Prewrite result: ${result.prewriteResult.path}`);
1074
1067
  lines.push(`Openspec policy: ${result.openspecPolicy}`);
1068
+ if (result.failureCode?.startsWith("source-fidelity-")) {
1069
+ lines.push(`Source fidelity ledger: blocked (${result.failureCode})`);
1070
+ }
1075
1071
  if (result.failureReason)
1076
1072
  lines.push(`Failure reason: ${result.failureReason}`);
1077
1073
  if (result.openspecReadPaths.length > 0) {