@tea-agent/loop-agent 0.39.0-beta.0 → 0.39.0-beta.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 (331) hide show
  1. package/AGENTS.md +4 -1
  2. package/CHANGELOG.md +248 -95
  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 +2306 -61
  22. package/dist/executors/pi-executor.js +7 -0
  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 +180 -33
  27. package/dist/executors/shell-executor.js +1249 -224
  28. package/dist/executors/shell-write-guard.js +14 -0
  29. package/dist/executors/workspace-write-snapshot.js +68 -0
  30. package/dist/infrastructure/harness/atomic-write.js +23 -0
  31. package/dist/shared/dag-failure-category.js +150 -0
  32. package/dist/shared/dag-prompt-override.js +27 -0
  33. package/dist/shared/openspec-spec.js +70 -4
  34. package/dist/shared/operator/capabilities.js +39 -0
  35. package/dist/shared/playwright-cli-command-policy.js +15 -0
  36. package/dist/shared/update/console-notifier.js +100 -0
  37. package/dist/{cli → shared}/update/npm-client.js +70 -15
  38. package/dist/{cli → shared}/update/state.js +41 -13
  39. package/dist/task/config-types.js +50 -5
  40. package/dist/task/contract/apply.js +11 -1
  41. package/dist/task/contract/constants.js +2 -0
  42. package/dist/task/contract/hash.js +3 -0
  43. package/dist/task/contract/observe.js +25 -4
  44. package/dist/task/contract/paths.js +2 -1
  45. package/dist/task/contract/project.js +18 -1
  46. package/dist/task/contract/schema.js +4 -1
  47. package/dist/task/contract/transaction.js +12 -0
  48. package/dist/task/frontend-project-capability.js +199 -18
  49. package/dist/task/source-prepare/completeness.js +188 -0
  50. package/dist/task/source-prepare/fragment-inventory.js +477 -0
  51. package/dist/task/source-prepare/index.js +3 -0
  52. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  53. package/dist/task/source-prepare/ledger-review.js +214 -0
  54. package/dist/task/source-prepare/ledger.js +545 -0
  55. package/dist/task/source-prepare/parse-intent.js +24 -4
  56. package/dist/task/source-prepare/prepare.js +298 -1
  57. package/dist/task/source-prepare/semantic-intake.js +25 -5
  58. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  59. package/dist/task/source-prepare/types.js +22 -0
  60. package/dist/task/source-references.js +22 -1
  61. package/dist/worker/cli.js +18 -2
  62. package/dist/worker/console/chat/chat-event-store.js +62 -4
  63. package/dist/worker/console/chat/context-panel.js +7 -0
  64. package/dist/worker/console/chat/deferred-turn.js +12 -0
  65. package/dist/worker/console/chat/operation-card.js +8 -1
  66. package/dist/worker/console/chat/pi-console-config.js +81 -10
  67. package/dist/worker/console/chat/pi-runtime.js +264 -16
  68. package/dist/worker/console/chat/provider-error.js +98 -0
  69. package/dist/worker/console/chat/routes.js +298 -32
  70. package/dist/worker/console/chat/session-stats.js +179 -0
  71. package/dist/worker/console/chat/session-store.js +93 -63
  72. package/dist/worker/console/chat/todos.js +115 -0
  73. package/dist/worker/console/chat/tool-preview.js +42 -0
  74. package/dist/worker/console/chat/turn-process.js +20 -55
  75. package/dist/worker/console/chat/user-questions.js +266 -0
  76. package/dist/worker/console/chat/workspace-landing.js +14 -0
  77. package/dist/worker/console/console-handoff.js +82 -0
  78. package/dist/worker/console/console-update-and-init.js +115 -0
  79. package/dist/worker/console/console-update-runtime.js +84 -0
  80. package/dist/worker/console/dag-execution-receipt.js +33 -0
  81. package/dist/worker/console/draft-store.js +49 -0
  82. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  83. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  84. package/dist/worker/console/index.js +3 -0
  85. package/dist/worker/console/inspect-split.js +18 -0
  86. package/dist/worker/console/native-directory-picker.js +13 -0
  87. package/dist/worker/console/operation-run-facts.js +19 -3
  88. package/dist/worker/console/operation-store.js +227 -7
  89. package/dist/worker/console/operator-actions.js +388 -0
  90. package/dist/worker/console/operator-surface-health.js +1 -0
  91. package/dist/worker/console/operator-user-error.js +2 -2
  92. package/dist/worker/console/prd-intake-bridge.js +13 -0
  93. package/dist/worker/console/routes.js +355 -3
  94. package/dist/worker/console/security.js +67 -13
  95. package/dist/worker/console/server.js +609 -164
  96. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4kVYweA.js → abnfDiagram-N423BO3Z-BAsjg7ew.js} +1 -1
  97. package/dist/worker/console/static/assets/{arc-Bj2M8iO5.js → arc-CyIlrwZw.js} +1 -1
  98. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CLlXe4-6.js → architectureDiagram-T3A2C74G-Bo4tobhH.js} +1 -1
  99. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-psEp5xH0.js → blockDiagram-VBNYF7ZC-Dfpow9gu.js} +1 -1
  100. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DeKcOCGJ.js → c4Diagram-5PPSVZJV-DPmzG_B4.js} +1 -1
  101. package/dist/worker/console/static/assets/channel-DSrJAn0-.js +1 -0
  102. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-K0C744p1.js → chunk-2GRJ4B5K-pKQpB1UW.js} +1 -1
  103. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-xbNw4J9-.js → chunk-2Q5K7J3B-5n9YYquZ.js} +1 -1
  104. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLaSDUO.js → chunk-5RXB4S5H-DP3CtA2a.js} +1 -1
  105. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CtiPlKel.js → chunk-5VM5RSS4-DYxMJPUy.js} +1 -1
  106. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DZtolir7.js → chunk-6Q2QTUOP-CS3D50Io.js} +1 -1
  107. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CPcdNeew.js → chunk-GF5L2VYU-gSdDfo5_.js} +1 -1
  108. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-NgHc6V37.js → chunk-JWPE2WC7-A48q0ZNt.js} +1 -1
  109. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DQ_T-hg8.js → chunk-KBJHAD2P-ow8Mm3DY.js} +1 -1
  110. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DgLsxcVP.js → chunk-RYQCIY6F-CMKfLWYE.js} +1 -1
  111. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-UrVoM9z1.js → chunk-XXDRQBXY-BiTBdQPX.js} +1 -1
  112. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DeMmPl0_.js +1 -0
  113. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DeMmPl0_.js +1 -0
  114. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CLkYvTb3.js → cose-bilkent-JH36ORCC-Cm7exRz_.js} +1 -1
  115. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BwT_xKrE.js → cynefin-VYW2F7L2-D9O5qGOu.js} +1 -1
  116. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bi9tuzif.js → cynefinDiagram-MW4NZA55-CkvQnako.js} +1 -1
  117. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BCIqkWBV.js → dagre-VZM6K2ZE-B1rzp7CB.js} +1 -1
  118. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dip6l9_Z.js → diagram-7IWD3JNH-BP07Ipcc.js} +1 -1
  119. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B-xkh_wK.js → diagram-B4RE2ZJO-BlPzweQb.js} +1 -1
  120. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DZO0kTt_.js → diagram-LBJQPF4R-CxPa7n0S.js} +1 -1
  121. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D6ZplNbZ.js → diagram-Q27KOJAE-CMTZs53A.js} +1 -1
  122. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPekbhoS.js → diagram-UB23O5K3-CxG_X52r.js} +1 -1
  123. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BtmaJpK5.js → ebnfDiagram-BXEA7PRR-D8axtJM0.js} +1 -1
  124. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-6DS0Xc44.js → erDiagram-JOGREHBK-DIsGzn7b.js} +1 -1
  125. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CihnROSm.js → flowDiagram-UKHOOZJN-CU9zkDG0.js} +1 -1
  126. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CZC4zOE2.js → ganttDiagram-PKOTCBZU-DCjTmz5P.js} +1 -1
  127. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js → gitGraphDiagram-DS77QQ5N-BksdmFzg.js} +1 -1
  128. package/dist/worker/console/static/assets/index-B-O5ChYq.css +1 -0
  129. package/dist/worker/console/static/assets/index-CfnY8AQx.js +407 -0
  130. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DlSl4BGx.js → infoDiagram-6WML65LV-CGzzzJtL.js} +1 -1
  131. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-snnzFl_U.js → ishikawaDiagram-WSZJBQD7-BdseB80N.js} +1 -1
  132. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BDE7qpMx.js → journeyDiagram-NVQOT4AX-DM9gsgha.js} +1 -1
  133. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CD2Ci04G.js → kanban-definition-27J2QSJJ-3q-_9R2b.js} +1 -1
  134. package/dist/worker/console/static/assets/{linear-DdnapKIH.js → linear-B8N-Mxsd.js} +1 -1
  135. package/dist/worker/console/static/assets/{mermaid.core-BVjAT9b8.js → mermaid.core-EPIHUUsu.js} +5 -5
  136. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D0yJk-zA.js → mindmap-definition-FAOFIHXS-CxwO12aa.js} +1 -1
  137. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVo9tFP-.js → pegDiagram-VL7TDLO6-bPqbVX6Y.js} +1 -1
  138. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DnOV-mZ_.js → pieDiagram-7S7Q4E2Y-KzqIvzeX.js} +1 -1
  139. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CzUJo58i.js → quadrantDiagram-CIZ2JOQS-CyofpWiV.js} +1 -1
  140. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bvikrolh.js → railroadDiagram-AXF67PYL-cTWJvLTP.js} +1 -1
  141. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DtaSVCap.js → requirementDiagram-LRYGKXZP-BK--q27B.js} +1 -1
  142. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-C2c9A0wW.js → sankeyDiagram-W5VNT64P-C0SDOHXK.js} +1 -1
  143. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DmXJcU7r.js → sequenceDiagram-SI44F4Z6-Cong6YIo.js} +1 -1
  144. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4GUFW92.js → sizeCapture-X5ZJPWSS-DUVkgZx8.js} +1 -1
  145. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Dsad2MXf.js → stateDiagram-OKZ733FA-C7XgxuZg.js} +1 -1
  146. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Dzbnd5oX.js +1 -0
  147. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DGq48Fbi.js → swimlanes-SLNWSIFB-D9Ayvwcy.js} +2 -2
  148. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BY0hHk6G.js +8 -0
  149. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CSSi6OFf.js → timeline-definition-Z64GVDOM-Byb000cH.js} +1 -1
  150. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BqyzPrWv.js → vennDiagram-T6HMQDX7-DMLegPt4.js} +1 -1
  151. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DlznVb6S.js → wardleyDiagram-T6FBY63Y-ChMMeOsP.js} +1 -1
  152. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CfBcgJ_K.js → xychartDiagram-ELKLHX3M-bEMEPisW.js} +1 -1
  153. package/dist/worker/console/static/index.html +2 -2
  154. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  155. package/dist/worker/console/static-src/app/console-types.js +68 -4
  156. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  157. package/dist/worker/console/static-src/app/useOperatorActions.js +41 -6
  158. package/dist/worker/console/static-src/app/useRecoveryConsole.js +45 -10
  159. package/dist/worker/console/static-src/app/useRunProgress.js +47 -0
  160. package/dist/worker/console/static-src/app/useTaskWizard.js +74 -2
  161. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  162. package/dist/worker/console/static-src/operator-chat/cards/failure-category-advice.js +25 -0
  163. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +140 -8
  164. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  165. package/dist/worker/console/static-src/operator-chat/input-history.js +76 -0
  166. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
  167. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  168. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  169. package/dist/worker/console/static-src/operator-chat/turn-submission.js +16 -3
  170. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +168 -72
  171. package/dist/worker/console/static-src/operator-chat/useChatStream.js +102 -21
  172. package/dist/worker/console/static-src/operator-chat/useChatThread.js +184 -4
  173. package/dist/worker/console/static-src/operator-chat/useComposer.js +32 -0
  174. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  175. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  176. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  177. package/dist/worker/console/static-src/pages/tasks/run-id-resolution.js +79 -0
  178. package/dist/worker/console/static-src/pages/tasks/run-ownership-verify.js +23 -0
  179. package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +110 -0
  180. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  181. package/dist/worker/console/static-src/shell/workspace-route.js +337 -0
  182. package/dist/worker/console/workspace-context.js +344 -0
  183. package/dist/worker/console/workspace-registry.js +214 -0
  184. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  185. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  186. package/dist/worker/materialize/harness-task-lineage.js +5 -2
  187. package/dist/worker/observability/init-runtime-activity.js +26 -0
  188. package/dist/worker/observability/read-model.js +22 -0
  189. package/dist/worker/observe/node-input.js +160 -3
  190. package/dist/worker/observe/node-process.js +377 -0
  191. package/dist/worker/observe/routes.js +224 -3
  192. package/dist/worker/observe/server.js +4 -0
  193. package/dist/worker/observe/shell-handler-keys.js +34 -0
  194. package/dist/worker/observe/static/api.js +90 -4
  195. package/dist/worker/observe/static/app.js +18 -70
  196. package/dist/worker/observe/static/constants.js +8 -1
  197. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  198. package/dist/worker/observe/static/dag-node-purpose.d.ts +6 -0
  199. package/dist/worker/observe/static/dag-node-purpose.js +208 -0
  200. package/dist/worker/observe/static/format.js +60 -0
  201. package/dist/worker/observe/static/index.html +6 -1
  202. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  203. package/dist/worker/observe/static/markdown-render.js +20 -1
  204. package/dist/worker/observe/static/operator-chrome.css +145 -20
  205. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  206. package/dist/worker/observe/static/operator-chrome.js +330 -96
  207. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  208. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  209. package/dist/worker/observe/static/router.d.ts +46 -0
  210. package/dist/worker/observe/static/router.js +25 -1
  211. package/dist/worker/observe/static/state.d.ts +50 -0
  212. package/dist/worker/observe/static/state.js +184 -1
  213. package/dist/worker/observe/static/styles.css +395 -0
  214. package/dist/worker/observe/static/views/dag-graph.js +4 -2
  215. package/dist/worker/observe/static/views/dag-inspector.js +1067 -9
  216. package/dist/worker/observe/static/views/dag.d.ts +13 -0
  217. package/dist/worker/observe/static/views/dag.js +14 -5
  218. package/dist/worker/observe/static/views/dashboard.js +6 -4
  219. package/dist/worker/observe/static/views/failures.js +5 -2
  220. package/dist/worker/observe/static/views/night.js +2 -2
  221. package/dist/worker/observe/static/views/pool.js +1 -0
  222. package/dist/worker/observe/static/views/run.js +3 -1
  223. package/dist/worker/observe/static/views/session-timeline.js +78 -9
  224. package/dist/worker/observe/static/views/task.js +56 -1
  225. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +49 -3
  226. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  227. package/dist/workflows/dag/backend-test-pytest-collection.js +98 -14
  228. package/dist/workflows/dag/backend-test-writer-completeness.js +10 -5
  229. package/dist/workflows/dag/contract-output-registry.js +15 -0
  230. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  231. package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
  232. package/dist/workflows/dag/dynamic-runtime/map.js +1 -1
  233. package/dist/workflows/dag/failure-category.js +7 -116
  234. package/dist/workflows/dag/frontend-closeout.js +221 -0
  235. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  236. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  237. package/dist/workflows/dag/frontend-implementation-contract.js +534 -178
  238. package/dist/workflows/dag/frontend-plan-render.js +24 -0
  239. package/dist/workflows/dag/frontend-prewrite-gate.js +371 -393
  240. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  241. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  242. package/dist/workflows/dag/frontend-recovery-controller.js +202 -0
  243. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  244. package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
  245. package/dist/workflows/dag/frontend-recovery-run.js +33 -20
  246. package/dist/workflows/dag/frontend-repair.js +7 -424
  247. package/dist/workflows/dag/frontend-review-context.js +288 -14
  248. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  249. package/dist/workflows/dag/frontend-risk.js +15 -2
  250. package/dist/workflows/dag/frontend-shadow-dual-write.js +814 -0
  251. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  252. package/dist/workflows/dag/frontend-shape-facts.js +409 -0
  253. package/dist/workflows/dag/frontend-shape.js +427 -0
  254. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  255. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  256. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  257. package/dist/workflows/dag/frontend-test-case-checklist.js +30 -4
  258. package/dist/workflows/dag/frontend-test-case-manifest.js +11 -4
  259. package/dist/workflows/dag/frontend-test-case-quality.js +11 -16
  260. package/dist/workflows/dag/frontend-test-environment-probe.js +230 -0
  261. package/dist/workflows/dag/frontend-test-html-report.js +3 -1
  262. package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
  263. package/dist/workflows/dag/frontend-test-layout.js +159 -0
  264. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  265. package/dist/workflows/dag/frontend-test-result-contract.js +188 -42
  266. package/dist/workflows/dag/frontend-test-standard-scenarios.js +70 -0
  267. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  268. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  269. package/dist/workflows/dag/frontend-verification-trace.js +52 -17
  270. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  271. package/dist/workflows/dag/frontend-writer-admission.js +272 -0
  272. package/dist/workflows/dag/frontend-writer-status.js +244 -0
  273. package/dist/workflows/dag/init-hybrid.js +984 -618
  274. package/dist/workflows/dag/interrupt-request.js +10 -3
  275. package/dist/workflows/dag/lifecycle.js +3 -3
  276. package/dist/workflows/dag/node-execution.js +468 -34
  277. package/dist/workflows/dag/prompt.js +44 -19
  278. package/dist/workflows/dag/recovery-lease.js +80 -0
  279. package/dist/workflows/dag/report.js +37 -1
  280. package/dist/workflows/dag/rerun-feedback.js +124 -1
  281. package/dist/workflows/dag/rerun-plan.js +144 -5
  282. package/dist/workflows/dag/rerun-run.js +81 -6
  283. package/dist/workflows/dag/rerun-task.js +29 -0
  284. package/dist/workflows/dag/retry-policy.js +318 -8
  285. package/dist/workflows/dag/runner.js +429 -122
  286. package/dist/workflows/dag/scheduler.js +114 -16
  287. package/dist/workflows/dag/structured-output-repair.js +712 -0
  288. package/dist/workflows/dag/types.js +321 -31
  289. package/dist/workflows/dag/upstream-artifacts.js +0 -4
  290. package/dist/workflows/dag/validate.js +92 -32
  291. package/docs/README.md +2 -0
  292. package/docs/architecture/evolution.md +45 -0
  293. package/docs/init-surface.manifest.json +9 -0
  294. package/docs/operations/README.md +1 -1
  295. package/docs/operations/local-development-environment.md +21 -0
  296. package/docs/templates/README.md +2 -1
  297. package/docs/templates/agent-dag-report.schema.json +8 -2
  298. package/docs/templates/agent-dag.schema.json +45 -1
  299. package/docs/templates/frontend-implementation-contract.schema.json +684 -24
  300. package/docs/templates/frontend-test-case-checklist.md +2 -2
  301. package/docs/templates/frontend-test-dag.json +8 -10
  302. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  303. package/docs/templates/init-managed-agents.md +13 -3
  304. package/docs/templates/spec-registry.schema.json +45 -0
  305. package/harness.json +3 -3
  306. package/package.json +7 -1
  307. package/scripts/next-info.mjs +356 -0
  308. package/scripts/next-publish-gate.mjs +238 -0
  309. package/scripts/release-source-binding.mjs +251 -0
  310. package/skills/codebase-scout/SKILL.md +1 -1
  311. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  312. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  313. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  314. package/skills/frontend-bounded-implement/SKILL.md +8 -14
  315. package/skills/frontend-design-review/SKILL.md +20 -41
  316. package/skills/frontend-implementation/SKILL.md +3 -4
  317. package/skills/frontend-implementation/references/design-spec.md +15 -10
  318. package/skills/frontend-implementation/references/node-contracts.md +23 -13
  319. package/skills/frontend-review/SKILL.md +22 -14
  320. package/skills/frontend-review/references/review-findings.md +6 -7
  321. package/skills/loop-agent/references/command-reference.md +12 -4
  322. package/skills/loop-agent/references/hybrid-dag.md +8 -1
  323. package/skills/playwright-cli/SKILL.md +23 -1
  324. package/skills/playwright-cli-case-generator/SKILL.md +29 -8
  325. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +0 -1
  326. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +0 -1
  327. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +0 -1
  328. package/dist/worker/console/static/assets/index-DTZOKgAn.js +0 -404
  329. package/dist/worker/console/static/assets/index-Ya5FE7cD.css +0 -1
  330. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +0 -1
  331. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +0 -8
@@ -1,10 +1,12 @@
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, FRONTEND_CANONICAL_CONTRACT_MAX_BYTES, 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";
@@ -12,7 +14,7 @@ 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";
14
16
  /** Canonical contract serialization must stay at or below this byte size. */
15
- export const FRONTEND_CONTRACT_MAX_BYTES = FRONTEND_CANONICAL_CONTRACT_MAX_BYTES;
17
+ export const FRONTEND_CONTRACT_MAX_BYTES = 65536;
16
18
  /**
17
19
  * Machine-readable failure classification for blocked/retryable-invalid
18
20
  * prewrite outcomes. Kept alongside the human-readable failureReason so
@@ -33,14 +35,22 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
33
35
  "verification-target-outside-write-set",
34
36
  "openspec-not-read",
35
37
  "candidate-missing-drift",
36
- "openspec-citation-block-unparseable",
37
- "openspec-not-cited",
38
- "openspec-citation-not-read",
38
+ "candidate-content-drift",
39
+ "openspec-selection-invalid",
39
40
  "component-spec-reference-invalid",
40
41
  "component-spec-not-cited",
42
+ "component-spec-choice-unbound",
41
43
  "component-choices-missing",
42
44
  "contract-too-large",
43
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",
44
54
  ]);
45
55
  export const frontendPrewriteResultV1Schema = z
46
56
  .object({
@@ -128,7 +138,7 @@ export const frontendPrewriteResultV1Schema = z
128
138
  export const frontendWriterAdmissionSchema = z
129
139
  .object({
130
140
  schemaVersion: z.literal(1),
131
- writerNodeId: z.enum(["frontend-implement-pi", "frontend-repair-pi"]),
141
+ writerNodeId: z.enum(["frontend-implement-pi"]),
132
142
  decision: z.enum(["authorized", "denied"]),
133
143
  sourceArtifact: z.string().min(1),
134
144
  artifactHash: z.string().regex(/^[a-f0-9]{64}$/),
@@ -157,136 +167,68 @@ async function selectNode(runDir, primary, fallbacks) {
157
167
  }
158
168
  throw new Error(`frontend prewrite gate no FINISHED candidate: ${rejections.join("; ")}`);
159
169
  }
160
- async function readNodeText(runDir, nodeId) {
161
- 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
+ }
162
178
  if (record.status !== "FINISHED") {
163
179
  throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
164
180
  }
165
- // Pi execution mirrors a successful structured-contract normalization into
166
- // both assistantText and stdout. Treat node output the same way as the DAG
167
- // runtime's canonicalNodeOutput(): assistantText is authoritative and stdout
168
- // is only a fallback. Concatenating the two duplicate fields makes a single
169
- // 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);
170
185
  const text = record.assistantText?.trim() || record.stdout?.trim() || "";
171
186
  if (!text)
172
187
  throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
173
188
  return text;
174
189
  }
175
- async function readNodeRawText(runDir, nodeId) {
176
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
177
- return record.assistantText ?? record.stdout ?? "";
178
- }
179
- function resolveRunOwnedArtifact(runDir, candidate, label) {
180
- const root = path.resolve(runDir);
181
- const resolved = path.resolve(candidate);
182
- const relative = path.relative(root, resolved);
183
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
184
- throw new Error(`frontend prewrite gate ${label} must be contained by the current run directory`);
185
- }
186
- return resolved;
187
- }
188
- async function readHashBoundArtifact(input) {
189
- const resolved = resolveRunOwnedArtifact(input.runDir, input.artifactPath, input.label);
190
- const text = await readFile(resolved, "utf8");
191
- const actual = sha256Hex(text);
192
- if (actual !== input.expectedSha256) {
193
- throw new Error(`frontend prewrite gate ${input.label} SHA-256 mismatch (expected ${input.expectedSha256}, got ${actual})`);
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}`);
194
194
  }
195
- return text;
196
- }
197
- async function readFrontendPlanNodeInput(runDir, nodeId) {
198
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
199
- if (record.status !== "FINISHED") {
200
- throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
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}`);
201
203
  }
202
- const frontend = record.frontendPlanOutput;
203
- if (!frontend) {
204
- const payloadText = record.assistantText?.trim() || record.stdout?.trim() || "";
205
- if (!payloadText)
206
- throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
207
- return {
208
- artifactFirst: false,
209
- payloadText,
210
- citationsText: payloadText,
211
- rawCandidateSha256: sha256Hex(record.assistantText ?? record.stdout ?? ""),
212
- payloadKind: "canonical-contract",
213
- };
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}`);
214
209
  }
215
- if (frontend.schemaVersion !== 1 ||
216
- !record.structuredArtifactPath ||
217
- !record.structuredArtifactSha256 ||
218
- !record.structuredArtifactSchemaId) {
219
- throw new Error(`frontend prewrite gate ${nodeId} has incomplete artifact-first metadata`);
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}`);
220
213
  }
221
- const expectedSchema = frontend.payloadKind === "canonical-contract"
222
- ? "frontend-implementation-contract-v1"
223
- : "frontend-implementation-contract-revision-patch-v1";
224
- if (record.structuredArtifactSchemaId !== expectedSchema) {
225
- 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
+ }
226
226
  }
227
- const [payloadText, citationsText] = await Promise.all([
228
- readHashBoundArtifact({
229
- runDir,
230
- artifactPath: record.structuredArtifactPath,
231
- expectedSha256: record.structuredArtifactSha256,
232
- label: `${nodeId} primary payload`,
233
- }),
234
- readHashBoundArtifact({
235
- runDir,
236
- artifactPath: frontend.citationsArtifactPath,
237
- expectedSha256: frontend.citationsArtifactSha256,
238
- label: `${nodeId} citations`,
239
- }),
240
- readHashBoundArtifact({
241
- runDir,
242
- artifactPath: frontend.rawCandidatePath,
243
- expectedSha256: frontend.rawCandidateSha256,
244
- label: `${nodeId} raw candidate`,
245
- }).then(() => undefined),
246
- ]);
247
- return {
248
- artifactFirst: true,
249
- payloadText,
250
- citationsText,
251
- rawCandidateSha256: frontend.rawCandidateSha256,
252
- payloadKind: frontend.payloadKind,
253
- };
254
- }
255
- /**
256
- * Same shape predicate as the internal `isContract` inside
257
- * `extractFrontendImplementationJson` (frontend-implementation-contract.ts):
258
- * schemaVersion 1 + a plain `targets` object + `requirements` and
259
- * `verificationTargets` arrays. A merge-patch delta does not carry these
260
- * fields, so this is the deterministic boundary between a full contract and a
261
- * patch. Keep the two predicates in sync.
262
- */
263
- function isFullContractShape(value) {
264
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
265
- return false;
227
+ catch (error) {
228
+ if (error.code !== "ENOENT")
229
+ throw error;
266
230
  }
267
- const record = value;
268
- const targets = record.targets;
269
- return (record.schemaVersion === 1 &&
270
- targets !== null &&
271
- typeof targets === "object" &&
272
- !Array.isArray(targets) &&
273
- Array.isArray(record.requirements) &&
274
- Array.isArray(record.verificationTargets));
275
- }
276
- /**
277
- * Shared merge pipeline for revisionPatch mode and content-driven
278
- * compatibility: read the original contract node, extract the original
279
- * contract and the effective node's patch, apply the RFC 7386 merge-patch, and
280
- * serialize deterministically. Throws on any failure; callers map the error to
281
- * their own classification (retryable-invalid for explicit revisionPatch mode,
282
- * blocked for content-driven fallback).
283
- */
284
- async function mergeRevisionPatch(runDir, planText, originalNodeId) {
285
- const originalText = await readNodeText(runDir, originalNodeId);
286
- const originalValue = extractFrontendImplementationJson(originalText);
287
- const patchValue = extractFrontendImplementationJson(planText);
288
- const merged = applyFrontendImplementationContractPatch(originalValue, patchValue);
289
- return serializeDeterministicJson(merged);
231
+ return { contractText, citationsText, rawSha256: digest };
290
232
  }
291
233
  function firstNonEmptyVerdictLine(text) {
292
234
  const lines = text
@@ -330,6 +272,7 @@ function isFailedToolResult(event) {
330
272
  }
331
273
  async function collectOpenspecReadPaths(input) {
332
274
  const { runDir, planNodeId, reviewNodeId, repoRoot } = input;
275
+ const candidateSet = input.candidatePaths ? new Set(input.candidatePaths) : undefined;
333
276
  const matched = new Set();
334
277
  for (const nodeId of [planNodeId, reviewNodeId]) {
335
278
  const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
@@ -364,7 +307,7 @@ async function collectOpenspecReadPaths(input) {
364
307
  : null;
365
308
  if (relativePath &&
366
309
  !isFailedToolResult(event) &&
367
- isOpenspecSpecFilePath(relativePath)) {
310
+ (candidateSet ? candidateSet.has(relativePath) : OPENSPEC_SPEC_EXT_RE.test(relativePath))) {
368
311
  matched.add(relativePath);
369
312
  }
370
313
  startedReads.delete(event.toolCallId);
@@ -379,21 +322,6 @@ async function collectOpenspecReadPaths(input) {
379
322
  }
380
323
  return [...matched];
381
324
  }
382
- async function checkOpenspecReadEvidence(input) {
383
- const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
384
- if (candidatePaths.length === 0)
385
- return [];
386
- const allReadPaths = await collectOpenspecReadPaths({
387
- runDir,
388
- planNodeId,
389
- reviewNodeId,
390
- repoRoot,
391
- });
392
- const normalizedCandidates = new Set(candidatePaths
393
- .map((candidate) => toRepoRelativePath(candidate, repoRoot))
394
- .filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
395
- return allReadPaths.filter((readPath) => normalizedCandidates.has(readPath));
396
- }
397
325
  function normalizeCitationPath(rawPath, repoRoot) {
398
326
  let candidate = rawPath.trim();
399
327
  const hashIndex = candidate.indexOf("#");
@@ -408,57 +336,132 @@ function normalizeCitationPath(rawPath, repoRoot) {
408
336
  }
409
337
  return candidate;
410
338
  }
411
- /**
412
- * Parses the deterministic openspec-citations fence from the effective plan
413
- * output. Each non-empty line must be one JSON object {"path","section","line"}.
414
- * Rows whose normalized path is not a supported openspec spec file are ignored
415
- * (out of scope); rows that are invalid JSON or lack a string path mark the
416
- * block unparseable (fail-closed when candidates are non-empty).
417
- */
418
- export function parseOpenspecCitations(planText, repoRoot) {
419
- const citations = [];
420
- let blockPresent = false;
421
- let unparseable = false;
422
- const fenceRe = /```openspec-citations[ \t]*\r?\n([\s\S]*?)\r?\n```/g;
423
- let match;
424
- while ((match = fenceRe.exec(planText)) !== null) {
425
- blockPresent = true;
426
- const body = match[1] ?? "";
427
- for (const rawLine of body.split(/\r?\n/)) {
428
- const line = rawLine.trim();
429
- if (!line)
430
- continue;
431
- let parsed;
432
- try {
433
- parsed = JSON.parse(line);
434
- }
435
- catch {
436
- unparseable = true;
437
- continue;
438
- }
439
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
440
- unparseable = true;
441
- 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)}`);
442
394
  }
443
- const rawPath = parsed.path;
444
- if (typeof rawPath !== "string" || !rawPath.trim()) {
445
- unparseable = true;
446
- continue;
395
+ if (!input.candidatePaths.includes(row.path)) {
396
+ throw new Error(`invalid selection ${row.path}: not a candidate path`);
447
397
  }
448
- const normalized = normalizeCitationPath(rawPath, repoRoot);
449
- if (!normalized || !isOpenspecSpecFilePath(normalized))
450
- continue;
451
- const section = typeof parsed.section === "string"
452
- ? parsed.section
453
- : "";
454
- const lineValue = parsed.line;
455
- const citationLine = typeof lineValue === "number" && Number.isInteger(lineValue)
456
- ? lineValue
457
- : null;
458
- 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");
459
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
+ };
460
464
  }
461
- return { blockPresent, unparseable, citations };
462
465
  }
463
466
  async function finalizePrewrite(input, pending) {
464
467
  const sourceBindingSha256 = input.sourceBinding
@@ -509,13 +512,37 @@ async function finalizePrewrite(input, pending) {
509
512
  openspecPolicy: pending.openspecPolicy,
510
513
  };
511
514
  }
515
+ export { checkFrontendSourceFidelityLedger } from "./frontend-source-fidelity-ledger.js";
512
516
  export async function runFrontendPrewriteGate(input) {
513
517
  const workspaceRoot = input.workspaceRoot ?? input.repoRoot;
514
518
  // selectNode throws for a scheduling defect (no FINISHED plan/review). That
515
519
  // remains an ERROR and is intentionally not classified into prewrite-result.
516
520
  const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
517
521
  const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
518
- 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;
519
546
  const openspecPolicy = input.config.openspecPolicy ?? "scan-strict";
520
547
  const basePending = {
521
548
  planNodeId,
@@ -534,21 +561,6 @@ export async function runFrontendPrewriteGate(input) {
534
561
  openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
535
562
  openspecPolicy,
536
563
  };
537
- let planInput;
538
- try {
539
- planInput = await readFrontendPlanNodeInput(input.runDir, planNodeId);
540
- }
541
- catch (error) {
542
- return finalizePrewrite(input, {
543
- ...basePending,
544
- classification: "retryable-invalid",
545
- failureReason: error instanceof Error ? error.message : String(error),
546
- failureCode: "plan-text-unreadable",
547
- });
548
- }
549
- const planText = planInput.payloadText;
550
- const citationsText = planInput.citationsText;
551
- basePending.candidateRawSha256 = planInput.rawCandidateSha256;
552
564
  let reviewText;
553
565
  try {
554
566
  reviewText = await readNodeText(input.runDir, reviewNodeId);
@@ -571,90 +583,7 @@ export async function runFrontendPrewriteGate(input) {
571
583
  failureCode: "verdict-not-pass",
572
584
  });
573
585
  }
574
- // revisionPatch mode: the effective plan node emits an RFC 7386 merge-patch
575
- // delta against the original contract node (planFallbackFromNodeIds[0]).
576
- // Legacy DAGs without revisionPatch (undefined) keep full-contract semantics.
577
- const revisionPatchMode = input.config.revisionPatch === true &&
578
- planNodeId === input.config.planFromNodeId &&
579
- input.config.planFallbackFromNodeIds.length > 0;
580
- let effectiveContractText = null;
581
- if (revisionPatchMode) {
582
- const originalNodeId = input.config.planFallbackFromNodeIds[0];
583
- try {
584
- const originalInput = await readFrontendPlanNodeInput(input.runDir, originalNodeId);
585
- if (planInput.artifactFirst && !originalInput.artifactFirst) {
586
- throw new Error(`artifact-first revision ${planNodeId} requires artifact-first original contract ${originalNodeId}`);
587
- }
588
- if (planInput.artifactFirst) {
589
- if (planInput.payloadKind !== "merge-patch" ||
590
- originalInput.payloadKind !== "canonical-contract") {
591
- throw new Error("artifact-first revision/original payload kinds are invalid");
592
- }
593
- effectiveContractText = serializeDeterministicJson(applyFrontendImplementationContractPatch(JSON.parse(originalInput.payloadText), JSON.parse(planInput.payloadText)));
594
- }
595
- else {
596
- effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
597
- }
598
- }
599
- catch (error) {
600
- return finalizePrewrite(input, {
601
- ...basePending,
602
- verdict,
603
- classification: "retryable-invalid",
604
- failureReason: `frontend prewrite gate revisionPatch mode requires original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}`,
605
- failureCode: "contract-invalid",
606
- });
607
- }
608
- }
609
- else {
610
- // Content-driven compatibility (Bug 1): legacy DAGs without
611
- // revisionPatch can still receive a merge-patch delta from the revision
612
- // node. Detect the output shape instead of trusting the DAG flag alone.
613
- // - Full contract shape → keep legacy full-contract semantics unchanged.
614
- // - Patch shape with a usable original contract node → auto-merge and
615
- // run the existing analysis/materialization pipeline.
616
- // - Patch shape but unmergeable → blocked + revision-patch-unmergeable
617
- // (no retryable loop).
618
- let planJsonValue = null;
619
- try {
620
- planJsonValue = extractFrontendImplementationJson(planText);
621
- }
622
- catch {
623
- // Not machine-readable JSON: fall back to the legacy full-contract
624
- // path where analyzeFrontendImplementationContract surfaces the
625
- // original diagnostic (fail-closed without a new failure mode).
626
- }
627
- if (planJsonValue !== null && !isFullContractShape(planJsonValue)) {
628
- const originalNodeId = input.config.planFallbackFromNodeIds[0];
629
- if (!originalNodeId || originalNodeId === planNodeId) {
630
- return finalizePrewrite(input, {
631
- ...basePending,
632
- verdict,
633
- classification: "blocked",
634
- 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.",
635
- failureCode: "revision-patch-unmergeable",
636
- });
637
- }
638
- try {
639
- const originalText = await readNodeText(input.runDir, originalNodeId);
640
- const originalValue = extractFrontendImplementationJson(originalText);
641
- if (!isFullContractShape(originalValue)) {
642
- throw new Error(`original contract node ${originalNodeId} is not a full frontend implementation contract`);
643
- }
644
- effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
645
- }
646
- catch (error) {
647
- return finalizePrewrite(input, {
648
- ...basePending,
649
- verdict,
650
- classification: "blocked",
651
- 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.`,
652
- failureCode: "revision-patch-unmergeable",
653
- });
654
- }
655
- }
656
- }
657
- const requirementIdSearchText = effectiveContractText ?? planText;
586
+ const requirementIdSearchText = planArtifact.contractText;
658
587
  const missingIds = input.config.requiredRequirementIds.filter((id) => !requirementIdSearchText.includes(id));
659
588
  if (missingIds.length > 0) {
660
589
  return finalizePrewrite(input, {
@@ -702,17 +631,13 @@ export async function runFrontendPrewriteGate(input) {
702
631
  workspaceRoot,
703
632
  });
704
633
  }
705
- // Candidate -> canonical conversion. Artifact-first inputs must keep using
706
- // the already hash-verified payload: re-reading the selected node record
707
- // here would silently fall back to its prose and defeat the integrity gate.
708
- // 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.
709
636
  let analysis;
710
637
  try {
711
- const rawContractText = effectiveContractText ?? (planInput.artifactFirst ? planText : undefined);
712
638
  analysis = await analyzeFrontendImplementationContract({
713
639
  runDir: input.runDir,
714
- fromNodeId: rawContractText ? undefined : planNodeId,
715
- rawContractText,
640
+ rawContractText: planArtifact.contractText,
716
641
  sourceBinding: input.sourceBinding,
717
642
  });
718
643
  }
@@ -732,6 +657,33 @@ export async function runFrontendPrewriteGate(input) {
732
657
  }
733
658
  throw error;
734
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
+ }
735
687
  // Size guard: a canonical contract above 64KB is a retryable-invalid defect
736
688
  // (compress the contract) rather than a writer authorization failure.
737
689
  const contractSizeBytes = Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8");
@@ -818,14 +770,31 @@ export async function runFrontendPrewriteGate(input) {
818
770
  // Effective plan/review must actually read the candidate openspec paths when
819
771
  // any exist. This fail-closes BEFORE the canonical contract is materialized
820
772
  // so a writer is never authorized on top of unread normative evidence.
821
- 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;
822
791
  const candidateRoot = workspaceRoot ?? process.cwd();
823
792
  // Drift precheck: candidates are frozen at generation time. A candidate that
824
793
  // no longer exists on disk was deleted/renamed after generation and is a
825
794
  // generation-contract defect, not an "unread" failure.
826
795
  const missingCandidates = [];
827
- if (candidatePaths.length > 0) {
828
- for (const candidate of candidatePaths) {
796
+ if (frozenCandidatePaths.length > 0) {
797
+ for (const candidate of frozenCandidatePaths) {
829
798
  try {
830
799
  await stat(path.join(candidateRoot, candidate));
831
800
  }
@@ -848,99 +817,78 @@ export async function runFrontendPrewriteGate(input) {
848
817
  openspecCandidatePaths: candidatePaths,
849
818
  });
850
819
  }
851
- let openspecReadPaths;
852
- if (openspecPolicy === "cited") {
853
- openspecReadPaths = await collectOpenspecReadPaths({
854
- runDir: input.runDir,
855
- planNodeId,
856
- reviewNodeId,
857
- repoRoot: candidateRoot,
858
- });
859
- if (candidatePaths.length > 0) {
860
- const parsed = parseOpenspecCitations(citationsText, candidateRoot);
861
- if (!parsed.blockPresent || parsed.unparseable) {
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: the effective plan output must end with a parseable \`\`\`openspec-citations fenced block (one JSON {"path","section","line"} per line). ${parsed.blockPresent
870
- ? "The block is present but contains unparseable lines."
871
- : "No openspec-citations block was found."}`,
872
- failureCode: "openspec-citation-block-unparseable",
873
- openspecReadPaths,
874
- openspecCandidatePaths: candidatePaths,
875
- });
876
- }
877
- const citedPaths = new Set(parsed.citations.map((citation) => citation.path));
878
- const notCited = candidatePaths.filter((candidate) => !citedPaths.has(candidate));
879
- if (notCited.length > 0) {
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: these openspec candidate paths were not cited in the plan openspec-citations block: ${notCited.join(", ")}`,
888
- failureCode: "openspec-not-cited",
889
- openspecReadPaths,
890
- openspecCandidatePaths: candidatePaths,
891
- });
892
- }
893
- const readSet = new Set(openspecReadPaths);
894
- const uncorroborated = [
895
- ...new Set(parsed.citations
896
- .filter((citation) => !readSet.has(citation.path))
897
- .map((citation) => citation.path)),
898
- ];
899
- if (uncorroborated.length > 0) {
900
- return finalizePrewrite(input, {
901
- ...basePending,
902
- verdict,
903
- candidateJsonSha256: analysis.candidateJsonSha256,
904
- normalizationActions: [],
905
- mockStrategy,
906
- classification: "retryable-invalid",
907
- 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(", ")}`,
908
- failureCode: "openspec-citation-not-read",
909
- openspecReadPaths,
910
- openspecCandidatePaths: candidatePaths,
911
- });
912
- }
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
+ });
913
864
  }
914
- // cited with empty candidates: no read enforcement. The generation step
915
- // emits an advisory instead of failing the gate.
916
865
  }
917
- else {
918
- openspecReadPaths = await checkOpenspecReadEvidence({
919
- runDir: input.runDir,
920
- candidatePaths,
921
- planNodeId,
922
- reviewNodeId,
923
- repoRoot: candidateRoot,
924
- });
925
- if (candidatePaths.length > 0) {
926
- const readSet = new Set(openspecReadPaths);
927
- const unread = candidatePaths.filter((candidate) => !readSet.has(candidate));
928
- if (unread.length > 0) {
929
- const governanceRoot = await resolveOpenspecGovernanceRoot(candidateRoot);
930
- const excludedGovernanceCount = await countExcludedGovernanceSpecFiles(candidateRoot, governanceRoot);
931
- return finalizePrewrite(input, {
932
- ...basePending,
933
- verdict,
934
- candidateJsonSha256: analysis.candidateJsonSha256,
935
- normalizationActions: [],
936
- mockStrategy,
937
- classification: "retryable-invalid",
938
- 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。`,
939
- failureCode: "openspec-not-read",
940
- openspecReadPaths,
941
- openspecCandidatePaths: candidatePaths,
942
- });
943
- }
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
+ });
944
892
  }
945
893
  }
946
894
  // Component selection spec conformance (ADR 0016). Deterministic cross-check
@@ -962,13 +910,17 @@ export async function runFrontendPrewriteGate(input) {
962
910
  normalizationActions: [],
963
911
  mockStrategy,
964
912
  classification: "retryable-invalid",
965
- 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。",
966
914
  failureCode: "component-choices-missing",
967
915
  openspecReadPaths,
968
916
  openspecCandidatePaths: candidatePaths,
969
917
  });
970
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.
971
922
  const componentSpecReferences = componentChoices
923
+ .filter((choice) => choice.decision === "specified")
972
924
  .map((choice) => ({
973
925
  raw: choice.specReference?.path ?? "",
974
926
  normalized: choice.specReference?.path
@@ -976,11 +928,37 @@ export async function runFrontendPrewriteGate(input) {
976
928
  : null,
977
929
  }))
978
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
+ }
979
955
  if (componentSpecReferences.length > 0) {
980
956
  const invalidRefs = [
981
957
  ...new Set(componentSpecReferences
982
958
  .filter((ref) => !ref.normalized ||
983
- !isOpenspecSpecFilePath(ref.normalized))
959
+ !(candidatePaths.length > 0
960
+ ? candidatePaths.includes(ref.normalized)
961
+ : isOpenspecSpecFilePath(ref.normalized)))
984
962
  .map((ref) => ref.raw)),
985
963
  ];
986
964
  if (invalidRefs.length > 0) {
@@ -998,13 +976,10 @@ export async function runFrontendPrewriteGate(input) {
998
976
  });
999
977
  }
1000
978
  if (openspecPolicy === "cited") {
1001
- const parsedCitations = parseOpenspecCitations(citationsText, candidateRoot);
1002
- const citedPaths = new Set(parsedCitations.citations.map((citation) => citation.path));
1003
979
  const readSet = new Set(openspecReadPaths);
1004
980
  const uncorroborated = [
1005
981
  ...new Set(componentSpecReferences
1006
982
  .filter((ref) => !ref.normalized ||
1007
- !citedPaths.has(ref.normalized) ||
1008
983
  !readSet.has(ref.normalized))
1009
984
  .map((ref) => ref.raw)),
1010
985
  ];
@@ -1016,7 +991,7 @@ export async function runFrontendPrewriteGate(input) {
1016
991
  normalizationActions: [],
1017
992
  mockStrategy,
1018
993
  classification: "retryable-invalid",
1019
- 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(", ")}`,
1020
995
  failureCode: "component-spec-not-cited",
1021
996
  openspecReadPaths,
1022
997
  openspecCandidatePaths: candidatePaths,
@@ -1090,6 +1065,9 @@ export function formatFrontendPrewriteGateStdout(result) {
1090
1065
  }
1091
1066
  lines.push(`Prewrite result: ${result.prewriteResult.path}`);
1092
1067
  lines.push(`Openspec policy: ${result.openspecPolicy}`);
1068
+ if (result.failureCode?.startsWith("source-fidelity-")) {
1069
+ lines.push(`Source fidelity ledger: blocked (${result.failureCode})`);
1070
+ }
1093
1071
  if (result.failureReason)
1094
1072
  lines.push(`Failure reason: ${result.failureReason}`);
1095
1073
  if (result.openspecReadPaths.length > 0) {