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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +319 -1
  3. package/dist/application/dag/generate-task-dag.js +75 -19
  4. package/dist/application/dag/run-dag.js +41 -0
  5. package/dist/application/task-lifecycle/advance.js +24 -5
  6. package/dist/application/task-lifecycle/observe.js +171 -17
  7. package/dist/application/task-lifecycle/plan-transitions.js +42 -7
  8. package/dist/application/task-lifecycle/recommendations.js +13 -2
  9. package/dist/build-stamp.json +3 -3
  10. package/dist/cli/command-definitions.js +7 -0
  11. package/dist/cli/program.js +6 -1
  12. package/dist/commands/client-recovery.js +3 -0
  13. package/dist/commands/dag-follow-up.js +138 -0
  14. package/dist/commands/dag-rerun-task.js +2 -0
  15. package/dist/commands/init.js +27 -1
  16. package/dist/commands/task-advance.js +19 -0
  17. package/dist/executors/dag-pi-executor.js +3976 -76
  18. package/dist/executors/pi-executor.js +15 -4
  19. package/dist/executors/pi-read-budget-policy.js +239 -0
  20. package/dist/executors/shell-executor.js +978 -188
  21. package/dist/executors/shell-write-guard.js +7 -0
  22. package/dist/infrastructure/console/operation-store.js +226 -6
  23. package/dist/shared/dag-failure-category.js +12 -0
  24. package/dist/shared/openspec-spec.js +70 -4
  25. package/dist/shared/operator/capabilities.js +7 -0
  26. package/dist/task/config-types.js +105 -5
  27. package/dist/task/contract/adopt.js +4 -0
  28. package/dist/task/contract/import-revision.js +4 -0
  29. package/dist/task/contract/project.js +3 -0
  30. package/dist/task/contract/schema.js +2 -1
  31. package/dist/task/frontend-project-capability.js +203 -20
  32. package/dist/task/runtime.js +5 -2
  33. package/dist/task/source-prepare/build-draft.js +3 -3
  34. package/dist/task/source-prepare/fragment-inventory.js +64 -25
  35. package/dist/task/source-prepare/prepare.js +126 -21
  36. package/dist/task/source-prepare/semantic-intake.js +6 -2
  37. package/dist/task/source-references.js +22 -1
  38. package/dist/worker/console/chat/chat-ui-policy.js +4 -2
  39. package/dist/worker/console/chat/model-resolver.js +7 -4
  40. package/dist/worker/console/chat/pi-runtime.js +258 -11
  41. package/dist/worker/console/chat/repo-browser.js +10 -3
  42. package/dist/worker/console/chat/routes.js +165 -71
  43. package/dist/worker/console/chat/sdd-data-alignment.js +222 -0
  44. package/dist/worker/console/chat/session-catalog.js +2 -0
  45. package/dist/worker/console/chat/session-store.js +159 -70
  46. package/dist/worker/console/chat/shortcuts.js +19 -3
  47. package/dist/worker/console/chat/turn-process.js +111 -56
  48. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  49. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  50. package/dist/worker/console/index.js +3 -0
  51. package/dist/worker/console/operator-actions.js +169 -7
  52. package/dist/worker/console/pi-readiness.js +3 -2
  53. package/dist/worker/console/server.js +23 -1
  54. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DZZ8m3PO.js → abnfDiagram-N423BO3Z-CcS17TBr.js} +1 -1
  55. package/dist/worker/console/static/assets/{arc-D6PvaVd-.js → arc-COptKq2S.js} +1 -1
  56. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-B_OTOiI8.js → architectureDiagram-T3A2C74G-h4LKMHjP.js} +1 -1
  57. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-Bv6rqHBg.js → blockDiagram-VBNYF7ZC-COA1MOH0.js} +1 -1
  58. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B8eHr0oz.js → c4Diagram-5PPSVZJV-DJUf0QPm.js} +1 -1
  59. package/dist/worker/console/static/assets/channel-DdBCaOJ6.js +1 -0
  60. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DYwR0im2.js → chunk-2GRJ4B5K-mtWfKrUX.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D2WPGqXt.js → chunk-2Q5K7J3B-B8pXsxDQ.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CQISJ_I7.js → chunk-5RXB4S5H-ipKzByl1.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-C0o2Du1e.js → chunk-5VM5RSS4-DYi3Ald_.js} +1 -1
  64. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-4f8kr-U7.js → chunk-6Q2QTUOP-DQtGYoty.js} +1 -1
  65. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D_OWvXzX.js → chunk-GF5L2VYU-BU0qS2YV.js} +1 -1
  66. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CasdPz5X.js → chunk-JWPE2WC7-DVH9dIGE.js} +1 -1
  67. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Cj8lRrla.js → chunk-KBJHAD2P-Dt60SmgM.js} +1 -1
  68. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CUvD1FSd.js → chunk-RYQCIY6F-BoaUGuEY.js} +1 -1
  69. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CqLw_eqB.js → chunk-XXDRQBXY-_WHFDTp2.js} +1 -1
  70. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DZFra1GO.js +1 -0
  71. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DZFra1GO.js +1 -0
  72. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-ku-WqJPx.js → cose-bilkent-JH36ORCC-BxkbTIRd.js} +1 -1
  73. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-Bq_PYDhl.js → cynefin-VYW2F7L2-Bf2UVnoG.js} +1 -1
  74. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BiGZV641.js → cynefinDiagram-MW4NZA55-Bo23q1J_.js} +1 -1
  75. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C4JRlglF.js → dagre-VZM6K2ZE-Dd_UU49i.js} +1 -1
  76. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B0dNeWYG.js → diagram-7IWD3JNH-ebUa1a9y.js} +1 -1
  77. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cj35YvYM.js → diagram-B4RE2ZJO-BucthU8r.js} +1 -1
  78. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-uzQoJ2-8.js → diagram-LBJQPF4R-BgqNpAkm.js} +1 -1
  79. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D1a-Buoz.js → diagram-Q27KOJAE-DC4q5NGa.js} +1 -1
  80. package/dist/worker/console/static/assets/{diagram-UB23O5K3-XjRrLRSs.js → diagram-UB23O5K3-CBqCaMeb.js} +1 -1
  81. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Da_O24cW.js → ebnfDiagram-BXEA7PRR-CGtnbQ3-.js} +1 -1
  82. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BLJ8jrYU.js → erDiagram-JOGREHBK-CG3LUao5.js} +1 -1
  83. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-B9GrLjM0.js → flowDiagram-UKHOOZJN-D2ZVgoFS.js} +1 -1
  84. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CeJ0TqiK.js → ganttDiagram-PKOTCBZU-DoYFZiKt.js} +1 -1
  85. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js → gitGraphDiagram-DS77QQ5N-CKoP1s6j.js} +1 -1
  86. package/dist/worker/console/static/assets/index-CAZ2fC_X.css +1 -0
  87. package/dist/worker/console/static/assets/index-vbTcFnFs.js +449 -0
  88. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DB26i3d8.js → infoDiagram-6WML65LV-Duofv8p2.js} +1 -1
  89. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BOGRyeZT.js → ishikawaDiagram-WSZJBQD7-D2nlCkA1.js} +1 -1
  90. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CZRoBO_6.js → journeyDiagram-NVQOT4AX-Dd4IHdus.js} +1 -1
  91. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-qlWhJyoB.js → kanban-definition-27J2QSJJ-Bi20AOdt.js} +1 -1
  92. package/dist/worker/console/static/assets/{linear-CfUiDDB3.js → linear-D59YJ9kB.js} +1 -1
  93. package/dist/worker/console/static/assets/{mermaid.core-BQe6fpqj.js → mermaid.core-Bl13LpNa.js} +5 -5
  94. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-zFzWHw64.js → mindmap-definition-FAOFIHXS-C6DLP5eY.js} +1 -1
  95. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DFGUqjZO.js → pegDiagram-VL7TDLO6-BjO4cy64.js} +1 -1
  96. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BB5i0l8Z.js → pieDiagram-7S7Q4E2Y-D7i2qZTG.js} +1 -1
  97. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-obbVZ_X5.js → quadrantDiagram-CIZ2JOQS-Dx8o_h0y.js} +1 -1
  98. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwdaNzc1.js → railroadDiagram-AXF67PYL-B55orI_t.js} +1 -1
  99. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CkTJaugh.js → requirementDiagram-LRYGKXZP-zWaehp6f.js} +1 -1
  100. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BwJ-hIgq.js → sankeyDiagram-W5VNT64P-CcA-pjvD.js} +1 -1
  101. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BzSgmm7w.js → sequenceDiagram-SI44F4Z6-BOFbzNHI.js} +1 -1
  102. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-Bn3oj5Q1.js → sizeCapture-X5ZJPWSS-BjAejah1.js} +1 -1
  103. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-dxhGtL8K.js → stateDiagram-OKZ733FA-DXYwxJwZ.js} +1 -1
  104. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Clg3V9t1.js +1 -0
  105. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BLpEInya.js → swimlanes-SLNWSIFB-OYiOch8n.js} +2 -2
  106. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DX1dxAAW.js +8 -0
  107. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ChJGYcXy.js → timeline-definition-Z64GVDOM-cZfH3nmU.js} +1 -1
  108. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DK-qjRer.js → vennDiagram-T6HMQDX7-BDE7b3E1.js} +1 -1
  109. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-3qeaWAg-.js → wardleyDiagram-T6FBY63Y-kyyGy9WJ.js} +1 -1
  110. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CootlyP9.js → xychartDiagram-ELKLHX3M-CJj6VTog.js} +1 -1
  111. package/dist/worker/console/static/index.html +2 -2
  112. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  113. package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -5
  114. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +19 -8
  115. package/dist/worker/console/static-src/operator-chat/input-history.js +8 -6
  116. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +15 -7
  117. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +10 -0
  118. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +258 -23
  119. package/dist/worker/console/static-src/operator-chat/useChatThread.js +112 -4
  120. package/dist/worker/console/static-src/operator-chat/useComposer.js +13 -4
  121. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +29 -6
  122. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +8 -2
  123. package/dist/worker/console/static-src/shell/console-update-reload.js +36 -9
  124. package/dist/worker/console/static-src/shell/workspace-route.js +11 -0
  125. package/dist/worker/console/workspace-context.js +115 -1
  126. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  127. package/dist/worker/observe/routes.js +4 -0
  128. package/dist/worker/observe/static/dag-helpers.js +4 -2
  129. package/dist/worker/observe/static/dag-node-purpose.js +5 -0
  130. package/dist/worker/observe/static/inspect-workspace.js +23 -0
  131. package/dist/worker/observe/static/kpi.js +2 -2
  132. package/dist/worker/observe/static/operator-chrome.d.ts +10 -2
  133. package/dist/worker/observe/static/operator-chrome.js +37 -23
  134. package/dist/worker/observe/static/relations.js +2 -2
  135. package/dist/worker/observe/static/router.d.ts +12 -1
  136. package/dist/worker/observe/static/router.js +48 -6
  137. package/dist/worker/observe/static/run-processing.js +4 -2
  138. package/dist/worker/observe/static/shell-chrome.js +2 -2
  139. package/dist/worker/observe/static/state.js +5 -0
  140. package/dist/worker/observe/static/styles.css +95 -0
  141. package/dist/worker/observe/static/views/dag-inspector.js +14 -7
  142. package/dist/worker/observe/static/views/dag.js +10 -2
  143. package/dist/worker/observe/static/views/dags.js +3 -1
  144. package/dist/worker/observe/static/views/dashboard.js +14 -6
  145. package/dist/worker/observe/static/views/pool.js +7 -2
  146. package/dist/worker/observe/static/views/run.js +12 -3
  147. package/dist/worker/observe/static/views/session-timeline.js +69 -10
  148. package/dist/worker/observe/static/views/task.js +7 -2
  149. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +215 -28
  150. package/dist/workflows/dag/backend-test-markdown-workflow.js +13 -1
  151. package/dist/workflows/dag/backend-test-plan-protocol.js +106 -0
  152. package/dist/workflows/dag/backend-test-scenario-param.js +172 -25
  153. package/dist/workflows/dag/backend-test-scenario-partitions.js +62 -1
  154. package/dist/workflows/dag/backend-test-writer-completeness.js +104 -12
  155. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  156. package/dist/workflows/dag/dag-retry-schema.js +138 -0
  157. package/dist/workflows/dag/frontend-closeout.js +221 -0
  158. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  159. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  160. package/dist/workflows/dag/frontend-implementation-contract.js +1237 -192
  161. package/dist/workflows/dag/frontend-plan-render.js +2 -1
  162. package/dist/workflows/dag/frontend-prewrite-gate.js +256 -350
  163. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  164. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  165. package/dist/workflows/dag/frontend-recovery-controller.js +226 -0
  166. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  167. package/dist/workflows/dag/frontend-recovery-plan.js +21 -10
  168. package/dist/workflows/dag/frontend-recovery-run.js +166 -34
  169. package/dist/workflows/dag/frontend-repair.js +1 -432
  170. package/dist/workflows/dag/frontend-review-context.js +261 -15
  171. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  172. package/dist/workflows/dag/frontend-shadow-dual-write.js +941 -0
  173. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  174. package/dist/workflows/dag/frontend-shape-facts.js +419 -0
  175. package/dist/workflows/dag/frontend-shape.js +435 -0
  176. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  177. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  178. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  179. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  180. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  181. package/dist/workflows/dag/frontend-verification-trace.js +252 -25
  182. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  183. package/dist/workflows/dag/frontend-writer-admission.js +319 -0
  184. package/dist/workflows/dag/frontend-writer-status.js +256 -0
  185. package/dist/workflows/dag/init-hybrid.js +1028 -562
  186. package/dist/workflows/dag/interrupt-request.js +7 -0
  187. package/dist/workflows/dag/node-execution.js +828 -4
  188. package/dist/workflows/dag/prompt.js +70 -3
  189. package/dist/workflows/dag/recovery-lease.js +170 -0
  190. package/dist/workflows/dag/report.js +37 -1
  191. package/dist/workflows/dag/rerun-feedback.js +315 -1
  192. package/dist/workflows/dag/rerun-plan.js +17 -6
  193. package/dist/workflows/dag/rerun-run.js +44 -6
  194. package/dist/workflows/dag/rerun-task.js +251 -13
  195. package/dist/workflows/dag/retry-policy.js +219 -104
  196. package/dist/workflows/dag/runner.js +580 -124
  197. package/dist/workflows/dag/scheduler.js +133 -20
  198. package/dist/workflows/dag/types.js +244 -16
  199. package/dist/workflows/dag/validate.js +28 -12
  200. package/docs/examples/README.md +5 -0
  201. package/docs/init-surface.manifest.json +30 -12
  202. package/docs/skills/vetted-skill-registry.md +4 -2
  203. package/docs/templates/README.md +2 -0
  204. package/docs/templates/agent-dag-report.schema.json +8 -2
  205. package/docs/templates/agent-dag.schema.json +1 -1
  206. package/docs/templates/backend-test-dag.json +23 -20
  207. package/docs/templates/frontend-implementation-contract.schema.json +4 -1
  208. package/docs/templates/frontend-implementation-dag.json +89 -0
  209. package/docs/templates/spec-registry.schema.json +45 -0
  210. package/harness.json +1 -1
  211. package/package.json +4 -3
  212. package/skills/frontend-bounded-implement/SKILL.md +15 -14
  213. package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
  214. package/skills/frontend-contract/SKILL.md +23 -0
  215. package/skills/frontend-contract/references/contract-protocol.md +34 -0
  216. package/skills/frontend-design-review/SKILL.md +22 -41
  217. package/skills/frontend-plan/SKILL.md +26 -0
  218. package/skills/frontend-plan/references/decision-contract.md +37 -0
  219. package/skills/frontend-plan/references/design-decisions.md +17 -0
  220. package/skills/frontend-review/SKILL.md +20 -15
  221. package/skills/frontend-review/references/review-findings.md +6 -7
  222. package/skills/frontend-scout/SKILL.md +25 -0
  223. package/skills/frontend-scout/references/design-evidence.md +16 -0
  224. package/skills/frontend-scout/references/scout-evidence.md +23 -0
  225. package/skills/frontend-verification/SKILL.md +1 -1
  226. package/skills/loop-agent/references/command-reference.md +1 -0
  227. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  228. package/dist/worker/console/static/assets/channel-BU5gOilw.js +0 -1
  229. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +0 -1
  230. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +0 -1
  231. package/dist/worker/console/static/assets/index-H9rFJiGL.css +0 -1
  232. package/dist/worker/console/static/assets/index-xwu9GxEc.js +0 -451
  233. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +0 -1
  234. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +0 -8
  235. package/skills/frontend-implementation/SKILL.md +0 -52
  236. package/skills/frontend-implementation/references/code-standards.md +0 -33
  237. package/skills/frontend-implementation/references/design-spec.md +0 -56
  238. package/skills/frontend-implementation/references/node-contracts.md +0 -31
@@ -8,8 +8,15 @@ import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-sto
8
8
  import { findPackageRoot } from "../../shared/package-metadata.js";
9
9
  import { pathMatchesPattern } from "../../shared/git-progress.js";
10
10
  import { resolveDagTaskSourcePath } from "../../task/dag-source-paths.js";
11
+ import { readTypedEventStoreFromJsonl } from "./frontend-typed-event-store.js";
12
+ // Cycle-safe value imports: the check functions are hoisted declarations used
13
+ // only at call time, so the contract module and the policy module can depend
14
+ // on each other without module-init ordering issues.
15
+ import { checkDependencies, checkTargetPaths, checkUiDesignCoverage, checkUiStateAttribution, } from "./frontend-design-policy.js";
11
16
  import { isDagSourceBindingV2 } from "./types.js";
12
- import { canonicalContractRel, classifyStructuredContractFailure, formatStructuredArtifactPointer, openspecCitationsRel, parseOpenspecCitationsBlock, persistRuntimeSkeleton, } from "./structured-output-repair.js";
17
+ import { canonicalContractRel, classifyStructuredContractFailure, formatStructuredArtifactPointer, persistRuntimeSkeleton, } from "./structured-output-repair.js";
18
+ import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
19
+ import { isConfigurationVerificationFile, verificationSymbolMatchesContent, } from "./frontend-verification-trace.js";
13
20
  export const frontendNormalizationActionSchema = z.enum([
14
21
  "remove-trailing-commas",
15
22
  "strip-comments",
@@ -17,6 +24,8 @@ export const frontendNormalizationActionSchema = z.enum([
17
24
  "canonicalize-verification-alias",
18
25
  "drop-unresolved-verification-symbols",
19
26
  "inject-source-binding",
27
+ "compact-canonical-contract",
28
+ "resolve-verification-command-ids",
20
29
  ]);
21
30
  export function sha256Hex(input) {
22
31
  return createHash("sha256").update(input, "utf8").digest("hex");
@@ -40,6 +49,15 @@ function sortKeysDeep(value) {
40
49
  export function serializeDeterministicJson(value) {
41
50
  return JSON.stringify(sortKeysDeep(value), null, 2);
42
51
  }
52
+ /**
53
+ * Deterministic compact JSON for the canonical contract artifact. The pretty
54
+ * serializer remains the default for human-facing audit artifacts, while the
55
+ * writer contract uses this representation to reduce artifact size and
56
+ * downstream read pressure without changing contract semantics.
57
+ */
58
+ export function serializeCompactDeterministicJson(value) {
59
+ return JSON.stringify(sortKeysDeep(value));
60
+ }
43
61
  export function deterministicSha256(value) {
44
62
  return sha256Hex(serializeDeterministicJson(value));
45
63
  }
@@ -70,21 +88,208 @@ export async function writeDeterministicJsonArtifact(runDir, relativePath, value
70
88
  await writeTextArtifactFile(targetPath, json);
71
89
  return { path: targetPath, sha256: sha256Hex(json) };
72
90
  }
91
+ /** Write a canonical contract artifact with compact deterministic JSON. */
92
+ export async function writeCompactDeterministicJsonArtifact(runDir, relativePath, value) {
93
+ const json = `${serializeCompactDeterministicJson(value)}\n`;
94
+ const targetPath = path.join(runDir, relativePath);
95
+ await writeTextArtifactFile(targetPath, json);
96
+ return { path: targetPath, sha256: sha256Hex(json) };
97
+ }
98
+ /**
99
+ * Verification mode v2 (AC-V2-001..004).
100
+ *
101
+ * The mode is owned by the runtime, never by the model: plan tools submit a
102
+ * `commandId` referencing the generation-time frozen command directory, and
103
+ * materialization resolves `mode` + `commandLabel` from that directory. This
104
+ * makes contradictory combinations such as `type: component` bound to
105
+ * `npm run build` unrepresentable instead of failing late at the writer
106
+ * focused-check.
107
+ */
108
+ export const frontendVerificationModeSchema = z.enum(["static", "behavior"]);
109
+ /**
110
+ * Optional display-only test scope. It never participates in control flow:
111
+ * trace checks, writeSet derivation and behavior-required evaluation read
112
+ * only the runtime-derived `mode`.
113
+ */
114
+ export const frontendVerificationScopeSchema = z.enum([
115
+ "unit",
116
+ "component",
117
+ "integration",
118
+ ]);
119
+ function laneToVerificationMode(lane) {
120
+ return lane === "static" || lane === "lint" ? "static" : "behavior";
121
+ }
122
+ const VERIFY_RUNNER_RE = /\b(vitest|jest|mocha|ava|tap|playwright|cypress|testcafe|nightwatch|webdriverio|pytest|go test)\b/i;
123
+ const VERIFY_E2E_RE = /(^|[\s:"'=/-])e2e([\s"'/-]|$)/i;
124
+ const VERIFY_STATIC_SCRIPT_WORDS = new Set([
125
+ "typecheck",
126
+ "check",
127
+ "lint",
128
+ "eslint",
129
+ "tsc",
130
+ "build",
131
+ ]);
132
+ // A script segment carrying test semantics wins over static words:
133
+ // `test:build` builds nothing observable by itself — it runs tests, and
134
+ // `check:unit` is a unit-test entrypoint, not a project-wide check.
135
+ const VERIFY_BEHAVIOR_SCRIPT_WORDS = new Set([
136
+ "test",
137
+ "tests",
138
+ "unit",
139
+ "spec",
140
+ "specs",
141
+ "e2e",
142
+ "integration",
143
+ "coverage",
144
+ ]);
145
+ function extractRunScriptName(command) {
146
+ // Skip package-manager flags between the binary and the script, e.g.
147
+ // `npm --prefix website run build` executes the `build` script.
148
+ const tokens = command.match(/(?:npm|pnpm|yarn|bun)(?:\.cmd|\.ps1)?\s+(.+)/i)?.[1];
149
+ if (!tokens)
150
+ return undefined;
151
+ const parts = tokens.split(/\s+/).filter(Boolean);
152
+ let index = 0;
153
+ while (index < parts.length) {
154
+ const part = parts[index].replace(/["']/g, "");
155
+ if (part === "--prefix" ||
156
+ part === "--cwd" ||
157
+ part === "-C" ||
158
+ part === "--workspace") {
159
+ index += 2;
160
+ continue;
161
+ }
162
+ if (part.startsWith("-")) {
163
+ index += 1;
164
+ continue;
165
+ }
166
+ if (/^(run|exec)$/i.test(part)) {
167
+ index += 1;
168
+ continue;
169
+ }
170
+ return part;
171
+ }
172
+ return undefined;
173
+ }
174
+ /**
175
+ * Whether a command carries known verification semantics. Markdown intake
176
+ * uses this before lane classification so prose cannot authorize an arbitrary
177
+ * package script such as `npm run deploy`. Explicit task verifyCommands remain
178
+ * operator-authorized and intentionally do not use this admission check.
179
+ */
180
+ export function isRecognizedFrontendVerifyCommandText(command) {
181
+ if (VERIFY_RUNNER_RE.test(command) || VERIFY_E2E_RE.test(command))
182
+ return true;
183
+ const scriptName = extractRunScriptName(command);
184
+ if (scriptName) {
185
+ const segments = scriptName.toLowerCase().split(/[:/_-]+/);
186
+ return segments.some((segment) => VERIFY_BEHAVIOR_SCRIPT_WORDS.has(segment) ||
187
+ VERIFY_STATIC_SCRIPT_WORDS.has(segment));
188
+ }
189
+ const head = command.trim().split(/\s+/, 1)[0] ?? "";
190
+ const binary = head.split(/[\\/]/).pop() ?? "";
191
+ return (/^(tsc|eslint|stylelint|lint|typecheck|check|build)$/i.test(binary) ||
192
+ /\b(tsc|eslint|typecheck|check-types)\b/i.test(command) ||
193
+ /\bscripts[\\/]+ci(?:-tests)?\.sh\b/i.test(command));
194
+ }
195
+ /**
196
+ * Classify a raw verification command text into the runtime-owned mode.
197
+ * Shared single implementation used by DAG generation (lane split) and
198
+ * documented here so generation and validation can never disagree.
199
+ *
200
+ * Order matters: test runners and e2e harnesses always prove behavior, even
201
+ * when their arguments mention build/check/lint paths (e.g.
202
+ * `npx vitest run test/build.test.ts`). For npm scripts, a segment with
203
+ * test semantics (`test:build`, `check:unit`) wins over static words; pure
204
+ * static scripts (`npm run check`, `check:types`) and leading binaries
205
+ * (`tsc`, `eslint`) stay static. Package-manager flags (`npm --prefix
206
+ * website run build`) are skipped when locating the script.
207
+ */
208
+ export function classifyFrontendVerifyCommandText(command) {
209
+ if (VERIFY_RUNNER_RE.test(command) || VERIFY_E2E_RE.test(command)) {
210
+ return "behavior";
211
+ }
212
+ const scriptName = extractRunScriptName(command);
213
+ if (scriptName) {
214
+ const segments = scriptName.toLowerCase().split(/[:/_-]+/);
215
+ if (segments.some((segment) => VERIFY_BEHAVIOR_SCRIPT_WORDS.has(segment))) {
216
+ return "behavior";
217
+ }
218
+ return segments.some((segment) => VERIFY_STATIC_SCRIPT_WORDS.has(segment))
219
+ ? "static"
220
+ : "behavior";
221
+ }
222
+ const head = command.trim().split(/\s+/, 1)[0] ?? "";
223
+ const binary = head.split(/[\\/]/).pop() ?? "";
224
+ if (/^(tsc|eslint|stylelint|lint|typecheck|check|build)$/i.test(binary) ||
225
+ /\b(tsc|eslint|typecheck|check-types)\b/i.test(command) ||
226
+ /\bscripts[\\/]+ci(?:-tests)?\.sh\b/i.test(command)) {
227
+ return "static";
228
+ }
229
+ return "behavior";
230
+ }
231
+ export function slugFrontendVerifyCommandId(label, taken) {
232
+ const slug = label
233
+ .toLowerCase()
234
+ .replace(/[^a-z0-9]+/g, "-")
235
+ .replace(/^-+|-+$/g, "")
236
+ .slice(0, 48) || "verify";
237
+ let candidate = `verify-${slug}`;
238
+ let counter = 2;
239
+ while (taken.has(candidate)) {
240
+ candidate = `verify-${slug}-${counter++}`;
241
+ }
242
+ taken.add(candidate);
243
+ return candidate;
244
+ }
245
+ /**
246
+ * Build the generation-time frozen command directory. Lanes come from the
247
+ * DAG generation split (init-hybrid classifies each verify command once);
248
+ * labels stay human-readable while `commandId` is the stable machine key
249
+ * the plan references. Mock/lint lanes keep their provenance in `lane` but
250
+ * map to the behavior/static mode the trace gate enforces. Command texts
251
+ * (parallel to labels; usually from the bundle evidence) bind each id to
252
+ * the exact frozen command so same-label entries across lanes cannot drift
253
+ * into the same identity silently.
254
+ */
255
+ export function buildFrontendVerifyCommandDirectory(lanes) {
256
+ const taken = new Set();
257
+ const entries = [];
258
+ const push = (labels, lane, texts) => {
259
+ labels.forEach((label, index) => {
260
+ const trimmed = label.trim();
261
+ if (!trimmed)
262
+ return;
263
+ entries.push({
264
+ commandId: slugFrontendVerifyCommandId(trimmed, taken),
265
+ label: trimmed,
266
+ mode: laneToVerificationMode(lane),
267
+ lane,
268
+ command: texts?.[index]?.trim() ?? "",
269
+ });
270
+ });
271
+ };
272
+ push(lanes.staticLabels, "static", lanes.staticCommandTexts);
273
+ push(lanes.lintLabels ?? [], "lint", lanes.lintCommandTexts);
274
+ push(lanes.behaviorLabels, "behavior", lanes.behaviorCommandTexts);
275
+ push(lanes.mockLabels ?? [], "mock", lanes.mockCommandTexts);
276
+ return entries;
277
+ }
73
278
  /**
74
- * Extract frozen command labels from the DAG run spec (run.json).
75
- * The run spec is written before any node executes, so it is always available
76
- * when the prewrite gate materializes the contract.
279
+ * Derive the frozen command directory from the DAG run spec (run.json),
280
+ * reading the per-lane evidence of the `frontend-verify-shell` bundle. The
281
+ * run spec is written before any node executes, so it is always available
282
+ * when the prewrite gate materializes the contract. When absent (e.g. test
283
+ * fixtures), there is no directory to validate against and callers skip
284
+ * directory validation downstream — mirroring the legacy label-set behavior.
77
285
  */
78
- async function deriveFrozenCommandLabelsFromRun(runDir) {
286
+ export async function deriveFrontendVerifyCommandDirectoryFromRun(runDir) {
79
287
  const specPath = path.join(runDir, "run.json");
80
288
  let raw;
81
289
  try {
82
290
  raw = await readFile(specPath, "utf8");
83
291
  }
84
292
  catch {
85
- // run.json is guaranteed to exist in production (DAG runner writes it
86
- // before any node executes). When absent (e.g. test fixtures), there is
87
- // no frozen set to validate against and the check is skipped downstream.
88
293
  return [];
89
294
  }
90
295
  let spec;
@@ -92,20 +297,174 @@ async function deriveFrozenCommandLabelsFromRun(runDir) {
92
297
  spec = JSON.parse(raw);
93
298
  }
94
299
  catch (error) {
95
- throw new Error(`cannot derive frozen command labels: run.json is not valid JSON at ${specPath}: ${error.message}`);
300
+ throw new Error(`cannot derive frontend verify command directory: run.json is not valid JSON at ${specPath}: ${error.message}`);
301
+ }
302
+ const lanes = {
303
+ staticLabels: [],
304
+ behaviorLabels: [],
305
+ mockLabels: [],
306
+ lintLabels: [],
307
+ staticCommandTexts: [],
308
+ behaviorCommandTexts: [],
309
+ mockCommandTexts: [],
310
+ lintCommandTexts: [],
311
+ };
312
+ for (const task of spec.tasks ?? []) {
313
+ const bundle = task.shell?.frontendVerificationBundle;
314
+ if (!bundle)
315
+ continue;
316
+ const lanePairs = [
317
+ ["static", bundle.staticEvidence],
318
+ ["behavior", bundle.behaviorEvidence],
319
+ ["mock", bundle.mockEvidence],
320
+ ["lint", bundle.lintEvidence],
321
+ ];
322
+ for (const [lane, evidence] of lanePairs) {
323
+ const labels = evidence?.commandLabels ?? [];
324
+ const texts = evidence?.commandTexts ?? [];
325
+ // run.json is untrusted input: labels and texts must zip 1:1.
326
+ // A lane that violates the alignment fails closed instead of
327
+ // binding ids to the wrong commands.
328
+ if (texts.length > 0 && texts.length !== labels.length) {
329
+ throw new Error(`cannot derive frontend verify command directory: ${lane} evidence commandTexts length (${texts.length}) does not match commandLabels length (${labels.length}) in ${specPath}`);
330
+ }
331
+ lanes[`${lane}Labels`].push(...labels);
332
+ lanes[`${lane}CommandTexts`].push(...texts);
333
+ }
334
+ }
335
+ if (lanes.staticLabels.length === 0 &&
336
+ lanes.behaviorLabels.length === 0 &&
337
+ lanes.mockLabels.length === 0 &&
338
+ lanes.lintLabels.length === 0) {
339
+ return [];
340
+ }
341
+ return buildFrontendVerifyCommandDirectory(lanes);
342
+ }
343
+ /**
344
+ * A behavior verification target must bind a test file: the trace gate
345
+ * proves behavior targets by finding the target id in a real
346
+ * describe/it/test title inside `file`. Enforced at the plan boundary and
347
+ * at materialization (path shape only — the writer may create the file).
348
+ *
349
+ * Only code files count (the gate reads titles from source): fixtures and
350
+ * data files such as `test/fixtures/data.json` are rejected even under a
351
+ * test directory, as are helper/support/setup files (`cypress/fixtures/*`,
352
+ * `tests/support/*`, `setup.*`) that never carry describe/it/test titles.
353
+ * Cypress (`*.cy.*`, `cypress/`) and e2e (`e2e/`) entries are accepted —
354
+ * the repo supports those harnesses.
355
+ */
356
+ export function isFrontendTestFilePath(filePath) {
357
+ const normalized = filePath.replace(/\\/g, "/");
358
+ const base = normalized.split("/").pop() ?? "";
359
+ if (!/\.(c|m)?[jt]sx?$/.test(base))
360
+ return false;
361
+ const segments = normalized.toLowerCase().split("/");
362
+ // `helpers` is deliberately NOT excluded: source helper directories
363
+ // (e.g. `src/helpers/format.test.ts`) legitimately contain unit tests.
364
+ if (segments.some((segment) => segment === "fixtures" || segment === "support")) {
365
+ return false;
96
366
  }
97
- const labels = new Set();
367
+ if (/^setup\.[^/]+$/.test(base))
368
+ return false;
369
+ if (/\.cy\.[^/]+$/.test(base))
370
+ return true;
371
+ if (/(^|\/)__tests__\//.test(normalized))
372
+ return true;
373
+ if (/(^|\/)(tests?|e2e|cypress)\//.test(normalized))
374
+ return true;
375
+ if (/\.test\.[^/]+$/.test(normalized))
376
+ return true;
377
+ if (/\.spec\.[^/]+$/.test(normalized))
378
+ return true;
379
+ return false;
380
+ }
381
+ /**
382
+ * Extract the design-policy allowedDependencies set from the DAG run spec
383
+ * (run.json), mirroring `deriveFrontendVerifyCommandDirectoryFromRun`. Used by the plan
384
+ * node's front-loaded policy pre-check so dependency declarations can be
385
+ * corrected through the retry ladder before the policy shell (the authority)
386
+ * re-runs the same check on committed facts.
387
+ */
388
+ async function deriveAllowedDependenciesFromRun(runDir) {
389
+ const specPath = path.join(runDir, "run.json");
390
+ let raw;
391
+ try {
392
+ raw = await readFile(specPath, "utf8");
393
+ }
394
+ catch {
395
+ return [];
396
+ }
397
+ let spec;
398
+ try {
399
+ spec = JSON.parse(raw);
400
+ }
401
+ catch {
402
+ return [];
403
+ }
404
+ const deps = new Set();
98
405
  for (const task of spec.tasks ?? []) {
99
- const cmdLabels = task.shell?.verifyEvidence?.commandLabels;
100
- if (cmdLabels) {
101
- for (const label of cmdLabels)
102
- labels.add(label);
406
+ const allowed = task.shell?.frontendDesignPolicy?.allowedDependencies;
407
+ if (allowed) {
408
+ for (const dep of allowed)
409
+ deps.add(dep);
103
410
  }
104
411
  }
105
- return [...labels];
412
+ return [...deps];
413
+ }
414
+ /** Read the frozen implementation writeSet patterns from run.json so the plan
415
+ * validator can front-load checkTargetPaths (write-set-too-large) before the
416
+ * design-policy shell terminates the run. */
417
+ async function deriveWriteSetFromRun(runDir) {
418
+ const specPath = path.join(runDir, "run.json");
419
+ let raw;
420
+ try {
421
+ raw = await readFile(specPath, "utf8");
422
+ }
423
+ catch {
424
+ return [];
425
+ }
426
+ let spec;
427
+ try {
428
+ spec = JSON.parse(raw);
429
+ }
430
+ catch {
431
+ return [];
432
+ }
433
+ const writeSet = new Set();
434
+ for (const task of spec.tasks ?? []) {
435
+ const patterns = task.shell?.frontendDesignPolicy?.implementationWriteSet;
436
+ if (patterns) {
437
+ for (const pattern of patterns)
438
+ writeSet.add(pattern);
439
+ }
440
+ }
441
+ return [...writeSet];
106
442
  }
107
443
  export const FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID = "frontend-implementation-contract-v1";
108
444
  export const FRONTEND_IMPLEMENTATION_CONTRACT_PLAN_PATCH_SCHEMA_ID = "frontend-implementation-contract-plan-patch-v1";
445
+ export const FRONTEND_CONTRACT_REVIEW_INDEX_SCHEMA_ID = "frontend-contract-review-index-v1";
446
+ export const FRONTEND_CONTRACT_SECTION_SCHEMA_ID = "frontend-contract-section-v1";
447
+ export const FRONTEND_CONTRACT_CAPACITY_SCHEMA_ID = "frontend-contract-capacity-v1";
448
+ const FRONTEND_CONTRACT_REVIEW_GROUPS = [
449
+ {
450
+ id: "core",
451
+ fields: ["schemaVersion", "sourceBinding", "riskLevel", "targets", "requirements"],
452
+ },
453
+ {
454
+ id: "ux",
455
+ fields: ["uiStates", "interactions", "uiComponentChoices", "stylingStrategy"],
456
+ },
457
+ {
458
+ id: "data-design",
459
+ fields: ["mockApi", "designEvidence", "dependencyPolicy", "realIntegrationGap"],
460
+ },
461
+ {
462
+ id: "verification",
463
+ fields: ["verificationTargets", "evidenceGaps", "implementationSteps", "residualRisks"],
464
+ },
465
+ ];
466
+ const FRONTEND_CONTRACT_CAPACITY_WARNING_RATIO = 0.6;
467
+ const FRONTEND_CONTRACT_SECTION_DATA_TARGET_BYTES = 40 * 1024;
109
468
  /**
110
469
  * Build the deterministic, run-owned portion of the frontend contract. The
111
470
  * planner is allowed to fill semantic fields with an RFC 7386 merge patch but
@@ -171,7 +530,6 @@ export function loadFrontendImplementationContractJsonSchema(startDir = path.dir
171
530
  "mockApi",
172
531
  "designEvidence",
173
532
  "verificationTargets",
174
- "evidenceGaps",
175
533
  ];
176
534
  const actualRequired = Array.isArray(schema.required) ? schema.required : [];
177
535
  const missing = expectedRequired.filter((key) => !actualRequired.includes(key));
@@ -220,13 +578,13 @@ const gap = z
220
578
  * `specReference` is required (path/section/line hit).
221
579
  * - `reuse-existing`: reuse an existing repo component/convention not named by
222
580
  * the spec → `specReference` may be null, rationale explains the basis.
223
- * - `new`: neither spec nor existing code fits → `specReference` must be null,
224
- * rationale must declare the deviation (design-review approves it).
581
+ * - `new`: the task source mandates a component absent from the repository →
582
+ * `specReference` is required and points at that task-source declaration;
583
+ * rationale explains the bounded addition.
225
584
  *
226
- * `specReference` is nullable + optional because `stripNullValuesDeep` strips
227
- * null object values before validation, so a `new` choice's `specReference:
228
- * null` becomes an absent field; the superRefine below treats absent and null
229
- * identically.
585
+ * `specReference` is nullable + optional at the wire boundary because
586
+ * `stripNullValuesDeep` removes model-emitted nulls before validation. The
587
+ * decision-specific requirements are enforced by the superRefine below.
230
588
  */
231
589
  const uiComponentSpecReferenceSchema = z
232
590
  .object({
@@ -241,7 +599,11 @@ const uiComponentChoiceSchema = z
241
599
  component: z.string().min(1),
242
600
  decision: z.enum(["specified", "reuse-existing", "new"]),
243
601
  specReference: uiComponentSpecReferenceSchema.nullable().optional(),
244
- rationale: z.string().min(1),
602
+ // Optional: the decision enum already carries the semantic (specified →
603
+ // specReference, new → task-source traceability, reuse-existing → no
604
+ // rationale needed).
605
+ // Omit for reuse-existing to keep plan output small.
606
+ rationale: z.string().min(1).optional(),
245
607
  })
246
608
  .strict();
247
609
  const uiComponentChoicesSchema = z.array(uiComponentChoiceSchema);
@@ -270,7 +632,12 @@ function stripNullValuesDeep(value) {
270
632
  return value;
271
633
  }
272
634
  export const frontendImplementationContractSchema = z
273
- .preprocess(stripNullValuesDeep, z
635
+ .preprocess((value) => {
636
+ const record = asRecord(value);
637
+ if (!record)
638
+ return stripNullValuesDeep(value);
639
+ return stripNullValuesDeep(record);
640
+ }, z
274
641
  .object({
275
642
  schemaVersion: z.literal(1),
276
643
  sourceBinding: z
@@ -390,15 +757,21 @@ export const frontendImplementationContractSchema = z
390
757
  verificationTargets: z.array(z
391
758
  .object({
392
759
  id: z.string().min(1),
393
- type: z.enum(["static", "unit", "component", "integration", "mock"]),
760
+ // Runtime-owned verification mode, resolved from commandId
761
+ // against the frozen command directory at materialization.
762
+ mode: frontendVerificationModeSchema,
763
+ // Stable machine key into the frozen command directory.
764
+ commandId: z.string().min(1),
394
765
  commandLabel: z.string().min(1),
395
766
  file: safePath,
396
767
  symbol: z.preprocess((value) => (value === "" || value === null ? undefined : value), z.string().min(1).optional()),
397
768
  requirementIds: z.array(id),
398
769
  uiStates: z.array(z.string().min(1)),
770
+ // Display-only test scope; never read by control flow.
771
+ scope: frontendVerificationScopeSchema.optional(),
399
772
  })
400
773
  .strict()).min(1),
401
- evidenceGaps: z.array(gap),
774
+ evidenceGaps: z.array(gap).optional(),
402
775
  implementationSteps: z.array(z.string().min(1)).optional(),
403
776
  stylingStrategy: z.string().min(1).optional(),
404
777
  uiComponentChoices: uiComponentChoicesSchema.optional(),
@@ -408,6 +781,37 @@ export const frontendImplementationContractSchema = z
408
781
  })
409
782
  .strict()
410
783
  .superRefine((value, ctx) => {
784
+ const stableIdentities = [
785
+ {
786
+ path: "requirements",
787
+ label: "requirement id",
788
+ values: value.requirements.map((item) => item.id),
789
+ },
790
+ {
791
+ path: "uiStates",
792
+ label: "UI state name",
793
+ values: value.uiStates.map((item) => item.name),
794
+ },
795
+ {
796
+ path: "interactions",
797
+ label: "interaction name",
798
+ values: value.interactions.map((item) => item.name),
799
+ },
800
+ {
801
+ path: "uiComponentChoices",
802
+ label: "component choice purpose",
803
+ values: (value.uiComponentChoices ?? []).map((item) => item.purpose),
804
+ },
805
+ ];
806
+ for (const identity of stableIdentities) {
807
+ if (new Set(identity.values).size !== identity.values.length) {
808
+ ctx.addIssue({
809
+ code: "custom",
810
+ message: `duplicate ${identity.label}`,
811
+ path: [identity.path],
812
+ });
813
+ }
814
+ }
411
815
  const verificationIds = value.verificationTargets.map((target) => target.id);
412
816
  if (new Set(verificationIds).size !== verificationIds.length)
413
817
  ctx.addIssue({
@@ -462,18 +866,32 @@ export const frontendImplementationContractSchema = z
462
866
  message: `unknown verification target ${targetId}`,
463
867
  path: ["requirements"],
464
868
  });
869
+ // Interactions bind to VTs too: an interaction whose behavior
870
+ // verification points at an unmaterialized VT is untraceable —
871
+ // r20 review finding (dangling *-BEHAVIOR references sailed
872
+ // through plan/design/implement to the final review).
873
+ for (const interaction of value.interactions)
874
+ for (const targetId of interaction.verificationTargetIds)
875
+ if (!verificationIds.includes(targetId))
876
+ ctx.addIssue({
877
+ code: "custom",
878
+ message: `interaction "${interaction.name}" references unknown verification target ${targetId}`,
879
+ path: ["interactions"],
880
+ });
465
881
  // Source fidelity ledger binding (AC-005/AC-006): 绑定携带
466
882
  // requirementToFragments(ledger v2)时,每个 requirement 必须携带
467
- // 有效 sourceFragmentIds/sourceRefs,否则 fail closed(防引用伪造/缺失)。
883
+ // 非空 sourceFragmentIds,否则 fail closed(防引用伪造/缺失)。
884
+ // sourceRefs(fragment→path 的派生展示)不设硬门槛:模型从 binding
885
+ // 只能拿到 fragment id 拿不到权威 path,强制只会诱导编造;防伪造的
886
+ // 真实性校验由 fidelity gate 按 sourceFragmentIds 执行(存在性 +
887
+ // 与 ledger boundFragments 一致)。
468
888
  const boundFragments = value.sourceBinding.requirementToFragments?.[requirement.id];
469
889
  if (value.sourceBinding.ledgerPath &&
470
890
  (!requirement.sourceFragmentIds ||
471
- requirement.sourceFragmentIds.length === 0 ||
472
- !requirement.sourceRefs ||
473
- requirement.sourceRefs.length === 0)) {
891
+ requirement.sourceFragmentIds.length === 0)) {
474
892
  ctx.addIssue({
475
893
  code: "custom",
476
- message: `requirement ${requirement.id} must carry sourceFragmentIds/sourceRefs when a source fidelity ledger is bound`,
894
+ message: `requirement ${requirement.id} must carry sourceFragmentIds when a source fidelity ledger is bound`,
477
895
  path: ["requirements", requirement.id, "sourceFragmentIds"],
478
896
  });
479
897
  }
@@ -491,12 +909,23 @@ export const frontendImplementationContractSchema = z
491
909
  if (state.applicable &&
492
910
  (!state.expectedBehavior ||
493
911
  !state.implementationTargets?.length ||
494
- !state.verificationTargetIds?.length))
912
+ !state.verificationTargetIds?.length)) {
913
+ // Name the state and the exact missing fields: the fixer is a
914
+ // model iterating on finalize receipts — it cannot fix a
915
+ // defect it cannot locate (r18: 39 blind finalize retries).
916
+ const missing = [];
917
+ if (!state.expectedBehavior)
918
+ missing.push("expectedBehavior");
919
+ if (!state.implementationTargets?.length)
920
+ missing.push("implementationTargets");
921
+ if (!state.verificationTargetIds?.length)
922
+ missing.push("verificationTargetIds");
495
923
  ctx.addIssue({
496
924
  code: "custom",
497
- message: "applicable UI state requires behavior, implementation, and verification",
925
+ message: `applicable UI state "${state.name}" is missing: ${missing.join(", ")} — record_state_flow it again with those fields filled`,
498
926
  path: ["uiStates"],
499
927
  });
928
+ }
500
929
  if (!state.applicable && !state.notApplicableReason)
501
930
  ctx.addIssue({
502
931
  code: "custom",
@@ -515,10 +944,10 @@ export const frontendImplementationContractSchema = z
515
944
  }
516
945
  }
517
946
  else if (choice.decision === "new") {
518
- if (choice.specReference) {
947
+ if (!choice.specReference || !choice.specReference.path) {
519
948
  ctx.addIssue({
520
949
  code: "custom",
521
- message: "new component choice must not carry a specReference",
950
+ message: "new component choice requires a task-source specReference.path",
522
951
  path: ["uiComponentChoices", index, "specReference"],
523
952
  });
524
953
  }
@@ -1045,11 +1474,40 @@ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
1045
1474
  ])];
1046
1475
  const evidenceGap = asRecord(requirement.evidenceGap);
1047
1476
  const hasProof = provenRequirementIds.has(requirementId);
1477
+ // A committed evidenceGap with a blank description means "no gap":
1478
+ // small-output models emit the slot defensively with description
1479
+ // "" and the strict gap schema (description min 1 char) would
1480
+ // fail the whole compile. Strip the incoming slot first and only
1481
+ // re-add it when usable — the requirement either has proof (no
1482
+ // gap needed) or receives the derived blocking gap below.
1483
+ const evidenceGapDescription = asString(evidenceGap?.description).trim();
1484
+ const hasUsableEvidenceGap = evidenceGap !== undefined && evidenceGapDescription !== "";
1485
+ // Source fidelity bindings are deterministic ledger data, not
1486
+ // model-authored content: when the plan requirement carries no
1487
+ // binding (r6 — the contract input block degraded, the model
1488
+ // correctly refused to invent ids), inject the ledger binding
1489
+ // for the requirement id. The strict schema fails a ledger-bound
1490
+ // contract on any requirement without sourceFragmentIds.
1491
+ const declaredSourceFragmentIds = Array.isArray(requirement.sourceFragmentIds)
1492
+ ? requirement.sourceFragmentIds
1493
+ : undefined;
1494
+ const ledgerBoundFragmentIds = Array.isArray(canonicalBinding.requirementToFragments?.[requirementId])
1495
+ ? canonicalBinding.requirementToFragments[requirementId]
1496
+ : undefined;
1497
+ const sourceFragmentIds = declaredSourceFragmentIds && declaredSourceFragmentIds.length > 0
1498
+ ? declaredSourceFragmentIds
1499
+ : ledgerBoundFragmentIds;
1500
+ const { evidenceGap: _incomingGap, sourceFragmentIds: _incomingFragmentIds, ...requirementWithoutGap } = requirement;
1501
+ void _incomingGap;
1502
+ void _incomingFragmentIds;
1048
1503
  return {
1049
- ...requirement,
1504
+ ...requirementWithoutGap,
1505
+ ...(sourceFragmentIds ? { sourceFragmentIds } : {}),
1050
1506
  ...(verificationTargetIds.length > 0 ? { verificationTargetIds } : {}),
1051
1507
  ...(hasProof
1052
- ? (evidenceGap ? { evidenceGap: { ...evidenceGap, blocking: false } } : {})
1508
+ ? (hasUsableEvidenceGap
1509
+ ? { evidenceGap: { ...evidenceGap, blocking: false } }
1510
+ : {})
1053
1511
  : {
1054
1512
  evidenceGap: {
1055
1513
  requirementId,
@@ -1061,10 +1519,16 @@ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
1061
1519
  })
1062
1520
  : record.requirements;
1063
1521
  const modelEvidenceGaps = Array.isArray(record.evidenceGaps)
1064
- ? record.evidenceGaps.map((item) => {
1522
+ ? record.evidenceGaps
1523
+ .map((item) => {
1065
1524
  const gap = asRecord(item);
1066
1525
  if (!gap)
1067
1526
  return item;
1527
+ // Blank-description gaps are meaningless statements that would
1528
+ // fail the strict gap schema; drop them like the embedded
1529
+ // per-requirement empty slots.
1530
+ if (asString(gap.description).trim() === "")
1531
+ return undefined;
1068
1532
  const requirementId = canonicalizeRequirementId(asString(gap.requirementId));
1069
1533
  // Model gaps are advisory. Blocking status is reconstructed below
1070
1534
  // from the source binding and executable verification targets.
@@ -1077,6 +1541,7 @@ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
1077
1541
  blocking: false,
1078
1542
  };
1079
1543
  })
1544
+ .filter((item) => item !== undefined)
1080
1545
  : [];
1081
1546
  const derivedBlockingGaps = canonicalBinding.requirementIds
1082
1547
  .filter((requirementId) => !provenRequirementIds.has(requirementId))
@@ -1091,6 +1556,114 @@ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
1091
1556
  evidenceGaps: [...modelEvidenceGaps, ...derivedBlockingGaps],
1092
1557
  };
1093
1558
  }
1559
+ function dedupeJsonArray(value) {
1560
+ if (!Array.isArray(value))
1561
+ return value;
1562
+ const seen = new Set();
1563
+ return value.filter((item) => {
1564
+ const key = serializeCompactDeterministicJson(item);
1565
+ if (seen.has(key))
1566
+ return false;
1567
+ seen.add(key);
1568
+ return true;
1569
+ });
1570
+ }
1571
+ function compactRecordArrayField(value, fields) {
1572
+ if (!Array.isArray(value))
1573
+ return value;
1574
+ return dedupeJsonArray(value.map((item) => {
1575
+ const record = asRecord(item);
1576
+ if (!record)
1577
+ return item;
1578
+ const compact = { ...record };
1579
+ for (const field of fields) {
1580
+ if (Object.hasOwn(compact, field)) {
1581
+ compact[field] = dedupeJsonArray(compact[field]);
1582
+ }
1583
+ }
1584
+ return compact;
1585
+ }));
1586
+ }
1587
+ /**
1588
+ * Compact the canonical contract without weakening its schema or coverage
1589
+ * semantics. This is deliberately a closed list of transformations:
1590
+ * byte-identical records and repeated set members are deduplicated, and
1591
+ * mutually exclusive UI-state fields are normalized. Conflicting stable
1592
+ * identities remain visible so strict schema validation can reject them.
1593
+ */
1594
+ export function compactFrontendImplementationContract(value) {
1595
+ const record = asRecord(value);
1596
+ if (!record)
1597
+ return value;
1598
+ const compact = { ...record };
1599
+ const sourceBinding = asRecord(record.sourceBinding);
1600
+ if (sourceBinding) {
1601
+ const compactBinding = { ...sourceBinding };
1602
+ for (const field of ["referencePaths", "requirementIds"]) {
1603
+ if (Object.hasOwn(compactBinding, field)) {
1604
+ compactBinding[field] = dedupeJsonArray(compactBinding[field]);
1605
+ }
1606
+ }
1607
+ const fragments = asRecord(sourceBinding.requirementToFragments);
1608
+ if (fragments) {
1609
+ compactBinding.requirementToFragments = Object.fromEntries(Object.entries(fragments).map(([id, ids]) => [id, dedupeJsonArray(ids)]));
1610
+ }
1611
+ compact.sourceBinding = compactBinding;
1612
+ }
1613
+ const targets = asRecord(record.targets);
1614
+ if (targets) {
1615
+ const compactTargets = { ...targets };
1616
+ for (const field of ["files", "routes", "publicApiChanges"]) {
1617
+ if (Object.hasOwn(compactTargets, field)) {
1618
+ compactTargets[field] = dedupeJsonArray(compactTargets[field]);
1619
+ }
1620
+ }
1621
+ compact.targets = compactTargets;
1622
+ }
1623
+ compact.requirements = compactRecordArrayField(record.requirements, [
1624
+ "implementationTargets",
1625
+ "verificationTargetIds",
1626
+ "sourceFragmentIds",
1627
+ "sourceRefs",
1628
+ ]);
1629
+ compact.uiStates = compactRecordArrayField(record.uiStates, ["implementationTargets", "verificationTargetIds"]);
1630
+ if (Array.isArray(compact.uiStates)) {
1631
+ compact.uiStates = dedupeJsonArray(compact.uiStates.map((item) => {
1632
+ const state = asRecord(item);
1633
+ if (!state)
1634
+ return item;
1635
+ const normalized = { ...state };
1636
+ if (normalized.applicable === true)
1637
+ delete normalized.notApplicableReason;
1638
+ if (normalized.applicable === false)
1639
+ delete normalized.expectedBehavior;
1640
+ return normalized;
1641
+ }));
1642
+ }
1643
+ compact.interactions = compactRecordArrayField(record.interactions, ["implementationTargets", "verificationTargetIds"]);
1644
+ compact.verificationTargets = compactRecordArrayField(record.verificationTargets, ["requirementIds", "uiStates"]);
1645
+ const mockApi = asRecord(record.mockApi);
1646
+ if (mockApi) {
1647
+ compact.mockApi = {
1648
+ ...mockApi,
1649
+ endpoints: dedupeJsonArray(mockApi.endpoints),
1650
+ };
1651
+ }
1652
+ const designEvidence = asRecord(record.designEvidence);
1653
+ if (designEvidence) {
1654
+ compact.designEvidence = {
1655
+ ...designEvidence,
1656
+ paths: dedupeJsonArray(designEvidence.paths),
1657
+ conflicts: dedupeJsonArray(designEvidence.conflicts),
1658
+ };
1659
+ }
1660
+ for (const field of ["evidenceGaps", "implementationSteps", "residualRisks"]) {
1661
+ if (Object.hasOwn(compact, field))
1662
+ compact[field] = dedupeJsonArray(compact[field]);
1663
+ }
1664
+ compact.uiComponentChoices = dedupeJsonArray(record.uiComponentChoices);
1665
+ return compact;
1666
+ }
1094
1667
  function assertFrontendContractPathsSafe(value) {
1095
1668
  const record = asRecord(value);
1096
1669
  if (!record)
@@ -1166,6 +1739,40 @@ function looksLikeStrictFrontendContract(value) {
1166
1739
  asRecord(record.mockApi) !== null &&
1167
1740
  asRecord(record.designEvidence) !== null);
1168
1741
  }
1742
+ /**
1743
+ * Single shared completion layer for required-with-fallback contract fields.
1744
+ *
1745
+ * Structural invariant: the coerce normalizer has strict-shape and free-form
1746
+ * branches that reshape differently, but every required key that carries a
1747
+ * deterministic fallback (currently `designEvidence`) must be completed in
1748
+ * exactly ONE place — here — so the branches can never drift again (the
1749
+ * designEvidence Required and invented-uiStates failures both came from
1750
+ * branch-local rules diverging). This layer never invents values that later
1751
+ * refinements reject: it only fills documented fallbacks for keys already
1752
+ * present-but-partial, and is idempotent for already-complete inputs.
1753
+ */
1754
+ export function completeCanonicalRequiredFields(value) {
1755
+ const record = asRecord(value);
1756
+ if (!record)
1757
+ return value;
1758
+ const designEvidence = asRecord(record.designEvidence);
1759
+ if (!designEvidence)
1760
+ return value;
1761
+ const source = asString(designEvidence.source);
1762
+ const paths = Array.isArray(designEvidence.paths);
1763
+ const conflicts = Array.isArray(designEvidence.conflicts);
1764
+ if (source && paths && conflicts)
1765
+ return value;
1766
+ return {
1767
+ ...record,
1768
+ designEvidence: {
1769
+ ...designEvidence,
1770
+ ...(source ? {} : { source: "repository-fallback+task-source" }),
1771
+ ...(paths ? {} : { paths: [] }),
1772
+ ...(conflicts ? {} : { conflicts: [] }),
1773
+ },
1774
+ };
1775
+ }
1169
1776
  /**
1170
1777
  * Coerce common free-form plan JSON into frontend-implementation-contract-v1.
1171
1778
  * Near-schema payloads are left untouched so unknown-key fail-closed still holds.
@@ -1270,6 +1877,9 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
1270
1877
  ...strictRecord,
1271
1878
  requirements,
1272
1879
  ...(strictMockApi ? { mockApi: { ...strictMockApi, endpoints: mockEndpoints } } : {}),
1880
+ // Required-with-fallback keys (designEvidence etc.) are completed
1881
+ // by the single shared `completeCanonicalRequiredFields` layer in
1882
+ // the analyze pipeline — never per-branch.
1273
1883
  };
1274
1884
  }
1275
1885
  }
@@ -1312,15 +1922,32 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
1312
1922
  for (const [index, item] of rawVerification.entries()) {
1313
1923
  const vt = asRecord(item) ?? {};
1314
1924
  const id = asString(vt.id) || `VT-${String(index + 1).padStart(3, "0")}`;
1315
- const typeRaw = asString(vt.type) || asString(vt.phase) || "unit";
1316
- const type = ["static", "unit", "component", "integration", "mock"].includes(typeRaw)
1317
- ? typeRaw
1318
- : typeRaw.includes("type")
1925
+ // Legacy compatibility input may still carry a model-written `type`
1926
+ // and a bare `commandLabel`. Map them onto the v2 shape: the five
1927
+ // legacy types collapse to the runtime mode (only `static` stays
1928
+ // static), the commandId is slugged from the label, and a legacy
1929
+ // unit/component/integration type is preserved as display-only scope.
1930
+ // Production plan facts already carry commandId and are resolved
1931
+ // against the frozen directory during materialization instead.
1932
+ const legacyType = asString(vt.type) || asString(vt.phase);
1933
+ const mode = asString(vt.mode) === "static" ||
1934
+ asString(vt.mode) === "behavior"
1935
+ ? asString(vt.mode)
1936
+ : legacyType === "static"
1319
1937
  ? "static"
1320
- : "unit";
1321
- const commandLabel = asString(vt.commandLabel) ||
1322
- asString(vt.command) ||
1323
- (type === "static" ? "npm run typecheck" : "npm run test:unit:fe");
1938
+ : "behavior";
1939
+ const commandLabel = asString(vt.commandLabel) || asString(vt.command);
1940
+ const commandId = asString(vt.commandId) ||
1941
+ slugFrontendVerifyCommandId(commandLabel || id, new Set());
1942
+ const scope = asString(vt.scope) === "unit" ||
1943
+ asString(vt.scope) === "component" ||
1944
+ asString(vt.scope) === "integration"
1945
+ ? asString(vt.scope)
1946
+ : legacyType === "unit" ||
1947
+ legacyType === "component" ||
1948
+ legacyType === "integration"
1949
+ ? legacyType
1950
+ : undefined;
1324
1951
  const file = asString(vt.file) ||
1325
1952
  (Array.isArray(vt.symbols) ? targetFiles.find((p) => p.includes("__tests__")) : "") ||
1326
1953
  targetFiles.find((p) => p.includes("__tests__")) ||
@@ -1329,14 +1956,20 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
1329
1956
  const uiStateNames = asStringArray(vt.uiStates);
1330
1957
  verificationTargets.push({
1331
1958
  id,
1332
- type,
1959
+ mode,
1960
+ commandId,
1333
1961
  commandLabel,
1334
1962
  file,
1335
1963
  symbol: asString(vt.symbol) || undefined,
1964
+ ...(scope ? { scope } : {}),
1336
1965
  requirementIds: requirementIds.length > 0
1337
1966
  ? requirementIds
1338
1967
  : [...canonicalBinding.requirementIds],
1339
- uiStates: uiStateNames.length > 0 ? uiStateNames : ["success", "error"],
1968
+ // Never invent UI state references: "success" is not a canonical
1969
+ // state name, so a defaulted ["success","error"] was always rejected
1970
+ // by the schema's unknown-uiState refinement — a verification
1971
+ // target that declares no UI binding stays unbound ([]).
1972
+ uiStates: uiStateNames,
1340
1973
  });
1341
1974
  }
1342
1975
  if (verificationTargets.length === 0) {
@@ -1371,6 +2004,14 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
1371
2004
  verificationTargetIds: verificationTargetIds.length > 0
1372
2005
  ? verificationTargetIds
1373
2006
  : defaultVerificationIds,
2007
+ // Source fidelity provenance 透传:兼容分支重建 requirement 时必须
2008
+ // 保留已声明的 sourceFragmentIds/sourceRefs(strict 分支通过
2009
+ // `...requirement` 保留,两边必须一致),否则 ledger-bound 契约在
2010
+ // designEvidence 缺失等情况下走兼容分支时丢失防伪造证据。
2011
+ ...(Array.isArray(req.sourceFragmentIds)
2012
+ ? { sourceFragmentIds: req.sourceFragmentIds }
2013
+ : {}),
2014
+ ...(Array.isArray(req.sourceRefs) ? { sourceRefs: req.sourceRefs } : {}),
1374
2015
  };
1375
2016
  // Do not mark free-form realIntegrationGap as blocking; defer to FE-TEST/FINAL-VERIFY.
1376
2017
  if (gapText) {
@@ -1644,19 +2285,42 @@ export async function analyzeFrontendImplementationContract(input) {
1644
2285
  catch (error) {
1645
2286
  fail("blocked", error instanceof Error ? error.message : String(error), candidateJsonSha256);
1646
2287
  }
1647
- // Validate verificationTarget commandLabels against frozen command set.
1648
- // The frozen set is derived from DAG verification shell task verifyEvidence.
1649
- const frozenLabels = await deriveFrozenCommandLabelsFromRun(input.runDir);
1650
- if (frozenLabels.length > 0) {
1651
- const frozen = new Set(frozenLabels);
1652
- const parsedVt = asRecord(parsed)?.verificationTargets;
2288
+ // Resolve verificationTarget commandIds against the frozen command
2289
+ // directory (per-lane evidence of the frontend-verify-shell bundle in
2290
+ // run.json). The directory is the only authority for mode and label: a
2291
+ // plan that references an unknown commandId, or binds a behavior command
2292
+ // to a non-test file, is rejected here with plan-attributable codes
2293
+ // instead of failing late at the writer focused-check.
2294
+ const verifyDirectory = await deriveFrontendVerifyCommandDirectoryFromRun(input.runDir);
2295
+ if (verifyDirectory.length > 0) {
2296
+ const byCommandId = new Map(verifyDirectory.map((entry) => [entry.commandId, entry]));
2297
+ const available = verifyDirectory
2298
+ .map((entry) => `${entry.commandId} (${entry.mode}: ${entry.label})`)
2299
+ .join(", ");
2300
+ const parsedRecord = asRecord(parsed);
2301
+ const parsedVt = parsedRecord?.verificationTargets;
1653
2302
  if (Array.isArray(parsedVt)) {
1654
2303
  for (const vt of parsedVt) {
1655
- const label = asString(asRecord(vt)?.commandLabel);
1656
- if (label && !frozen.has(label)) {
1657
- fail("blocked", `invalid-output: verificationTarget commandLabel "${label}" is not in the frozen command set [${[...frozen].join(", ")}]`, candidateJsonSha256);
2304
+ const record = asRecord(vt);
2305
+ if (!record)
2306
+ continue;
2307
+ const vtId = asString(record.id) || "(missing id)";
2308
+ const commandId = asString(record.commandId);
2309
+ if (!commandId) {
2310
+ fail("retryable-invalid", `invalid-output: verification target "${vtId}" must reference a frozen command: set commandId to one of [${available}]`, candidateJsonSha256);
2311
+ }
2312
+ const entry = byCommandId.get(commandId);
2313
+ if (!entry) {
2314
+ fail("retryable-invalid", `invalid-output: verification-target-unknown-command: verification target "${vtId}" references unknown commandId "${commandId}"; available frozen commands: [${available}]`, candidateJsonSha256);
2315
+ }
2316
+ record.mode = entry.mode;
2317
+ record.commandLabel = entry.label;
2318
+ const file = asString(record.file);
2319
+ if (entry.mode === "behavior" && file && !isFrontendTestFilePath(file)) {
2320
+ fail("retryable-invalid", `invalid-output: verification-target-phase-mismatch: verification target "${vtId}" binds behavior command "${entry.label}" (${entry.commandId}) to non-test file "${file}"; behavior targets must bind a test file (__tests__/, tests?/, e2e/, cypress/, *.test.*, *.spec.*, *.cy.*) so the trace gate can match the target id in a real describe/it/test title`, candidateJsonSha256);
1658
2321
  }
1659
2322
  }
2323
+ pushAction("resolve-verification-command-ids");
1660
2324
  }
1661
2325
  }
1662
2326
  const parsedTargets = asRecord(parsed)?.targets;
@@ -1664,12 +2328,25 @@ export async function analyzeFrontendImplementationContract(input) {
1664
2328
  if (parsedTargetFiles.some((file) => file.startsWith("/") || file.includes("\\")))
1665
2329
  fail("blocked", "invalid-output: frontend contract target paths must be relative POSIX paths", candidateJsonSha256);
1666
2330
  const parsedStates = asRecord(parsed)?.uiStates;
1667
- if (Array.isArray(parsedStates) && parsedStates.some((item) => {
1668
- const state = asRecord(item);
1669
- return state?.applicable === true &&
1670
- (!asString(state.expectedBehavior) || asStringArray(state.implementationTargets).length === 0 || asStringArray(state.verificationTargetIds).length === 0);
1671
- }))
1672
- fail("retryable-invalid", "invalid-output: applicable UI state requires behavior, implementation, and verification", candidateJsonSha256);
2331
+ if (Array.isArray(parsedStates)) {
2332
+ const incompleteStates = [];
2333
+ for (const item of parsedStates) {
2334
+ const state = asRecord(item);
2335
+ if (!state || state.applicable !== true)
2336
+ continue;
2337
+ const missing = [];
2338
+ if (!asString(state.expectedBehavior))
2339
+ missing.push("expectedBehavior");
2340
+ if (asStringArray(state.implementationTargets).length === 0)
2341
+ missing.push("implementationTargets");
2342
+ if (asStringArray(state.verificationTargetIds).length === 0)
2343
+ missing.push("verificationTargetIds");
2344
+ if (missing.length > 0)
2345
+ incompleteStates.push(`"${asString(state.name)}": missing ${missing.join(", ")}`);
2346
+ }
2347
+ if (incompleteStates.length > 0)
2348
+ fail("retryable-invalid", `invalid-output: applicable UI states incomplete — re-record each with record_state_flow filling the named fields: ${incompleteStates.join("; ")}`, candidateJsonSha256);
2349
+ }
1673
2350
  const parsedMockApi = asRecord(parsed)?.mockApi;
1674
2351
  if (asRecord(parsedMockApi) &&
1675
2352
  typeof asRecord(parsedMockApi)?.strategy === "string" &&
@@ -1686,15 +2363,49 @@ export async function analyzeFrontendImplementationContract(input) {
1686
2363
  // There is exactly one post-security candidate. A fallback candidate would
1687
2364
  // allow malformed raw fields to bypass the boundary checks above.
1688
2365
  const normalizedContract = coerceFrontendImplementationContractInput(parsed, canonicalBinding);
2366
+ const completedContract = completeCanonicalRequiredFields(normalizedContract);
1689
2367
  const candidate = deriveFrontendVerificationCoverage({
1690
- ...(asRecord(normalizedContract) ?? parsed),
2368
+ ...(asRecord(completedContract) ?? parsed),
1691
2369
  sourceBinding: canonicalBinding,
1692
2370
  }, canonicalBinding);
1693
- const result = frontendImplementationContractSchema.safeParse(candidate);
2371
+ const compactedCandidate = compactFrontendImplementationContract(candidate);
2372
+ if (serializeCompactDeterministicJson(compactedCandidate) !==
2373
+ serializeCompactDeterministicJson(candidate))
2374
+ pushAction("compact-canonical-contract");
2375
+ const result = frontendImplementationContractSchema.safeParse(compactedCandidate);
1694
2376
  if (!result.success)
1695
2377
  fail("retryable-invalid", `invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`, candidateJsonSha256);
2378
+ // targets.files is runtime-owned (the skeleton derives it from the task
2379
+ // writeSet, a glob the model must not edit). Narrow it AFTER the full
2380
+ // schema validation — never before, so model-authored unsafe/absolute
2381
+ // declarations are still rejected — to every CONCRETE path the validated
2382
+ // contract references (requirement implementationTargets,
2383
+ // verification-target files, Mock endpoint fixture/consumer paths). The
2384
+ // prewrite containment semantics keep covering everything the contract
2385
+ // references because every narrowed entry already matched the declared
2386
+ // patterns during validation. Satisfies frozen requirements like "deliver
2387
+ // exactly these files" without asking the model to edit a protected
2388
+ // field (r10/r11 design-review findings).
2389
+ const referencedConcretePaths = [
2390
+ ...new Set([
2391
+ ...result.data.requirements.flatMap((requirement) => requirement.implementationTargets),
2392
+ // Mock endpoint paths stay in the set: the zod refine validated
2393
+ // them against the declared targets.files patterns, so the
2394
+ // narrowing must keep covering them. Verification-target files
2395
+ // deliberately do NOT join: they are test artifacts, not
2396
+ // deliverables, and pulling them in would change which gate
2397
+ // fires for an out-of-writeSet VT (AC-3a semantics).
2398
+ ...(result.data.mockApi?.endpoints ?? []).flatMap((endpoint) => [endpoint.fixture, endpoint.consumer].filter((value) => typeof value === "string" && value.length > 0)),
2399
+ ].filter((value) => value.length > 0)),
2400
+ ].sort();
2401
+ if (referencedConcretePaths.length > 0) {
2402
+ result.data.targets = {
2403
+ ...result.data.targets,
2404
+ files: referencedConcretePaths,
2405
+ };
2406
+ }
1696
2407
  const blockingGaps = [
1697
- ...result.data.evidenceGaps,
2408
+ ...(result.data.evidenceGaps ?? []),
1698
2409
  ...result.data.requirements.flatMap((item) => item.evidenceGap ? [item.evidenceGap] : []),
1699
2410
  ].filter((item) => item.blocking);
1700
2411
  if (blockingGaps.length > 0)
@@ -1703,7 +2414,7 @@ export async function analyzeFrontendImplementationContract(input) {
1703
2414
  .join(", ")}`, candidateJsonSha256);
1704
2415
  for (const requirementId of canonicalBinding.requirementIds)
1705
2416
  if (!result.data.requirements.some((item) => item.id === requirementId) &&
1706
- !result.data.evidenceGaps.some((item) => item.requirementId === requirementId))
2417
+ !(result.data.evidenceGaps ?? []).some((item) => item.requirementId === requirementId))
1707
2418
  fail("blocked", `frontend contract does not cover ${requirementId}`, candidateJsonSha256);
1708
2419
  return {
1709
2420
  canonical: result.data,
@@ -1712,12 +2423,187 @@ export async function analyzeFrontendImplementationContract(input) {
1712
2423
  normalizationActions,
1713
2424
  };
1714
2425
  }
2426
+ function splitFrontendContractReviewGroup(canonical, fields) {
2427
+ const parts = [];
2428
+ let data = {};
2429
+ let slices = [];
2430
+ const dataBytes = (candidate) => Buffer.byteLength(serializeCompactDeterministicJson(candidate), "utf8");
2431
+ const flush = () => {
2432
+ if (Object.keys(data).length === 0)
2433
+ return;
2434
+ parts.push({ data, slices });
2435
+ data = {};
2436
+ slices = [];
2437
+ };
2438
+ for (const field of fields) {
2439
+ if (!Object.hasOwn(canonical, field))
2440
+ continue;
2441
+ const fieldValue = canonical[field];
2442
+ if (!Array.isArray(fieldValue)) {
2443
+ const candidate = { ...data, [field]: fieldValue };
2444
+ if (Object.keys(data).length > 0 &&
2445
+ dataBytes(candidate) > FRONTEND_CONTRACT_SECTION_DATA_TARGET_BYTES) {
2446
+ flush();
2447
+ }
2448
+ data[field] = fieldValue;
2449
+ slices.push({ field });
2450
+ continue;
2451
+ }
2452
+ if (fieldValue.length === 0) {
2453
+ const candidate = { ...data, [field]: [] };
2454
+ if (Object.keys(data).length > 0 &&
2455
+ dataBytes(candidate) > FRONTEND_CONTRACT_SECTION_DATA_TARGET_BYTES) {
2456
+ flush();
2457
+ }
2458
+ data[field] = [];
2459
+ slices.push({ field, start: 0, end: 0, total: 0 });
2460
+ continue;
2461
+ }
2462
+ let start = 0;
2463
+ while (start < fieldValue.length) {
2464
+ let end = start;
2465
+ let accepted = [];
2466
+ while (end < fieldValue.length) {
2467
+ const next = [...accepted, fieldValue[end]];
2468
+ const candidate = { ...data, [field]: next };
2469
+ if (accepted.length > 0 &&
2470
+ dataBytes(candidate) > FRONTEND_CONTRACT_SECTION_DATA_TARGET_BYTES) {
2471
+ break;
2472
+ }
2473
+ if (accepted.length === 0 &&
2474
+ Object.keys(data).length > 0 &&
2475
+ dataBytes(candidate) > FRONTEND_CONTRACT_SECTION_DATA_TARGET_BYTES) {
2476
+ flush();
2477
+ continue;
2478
+ }
2479
+ accepted = next;
2480
+ end += 1;
2481
+ }
2482
+ data[field] = accepted;
2483
+ slices.push({ field, start, end, total: fieldValue.length });
2484
+ start = end;
2485
+ if (start < fieldValue.length)
2486
+ flush();
2487
+ }
2488
+ }
2489
+ flush();
2490
+ return parts;
2491
+ }
2492
+ /**
2493
+ * Materialize a hash-bound field index and bounded contract sections.
2494
+ * These artifacts are an alternate read surface only: the canonical contract
2495
+ * remains authoritative and capacity diagnostics never affect admission.
2496
+ */
2497
+ export async function writeFrontendContractReviewArtifacts(input) {
2498
+ const contractRelativePath = path.posix.join(input.outputDir, input.artifactName);
2499
+ const baseName = input.artifactName.endsWith(".json")
2500
+ ? input.artifactName.slice(0, -".json".length)
2501
+ : input.artifactName;
2502
+ const sectionDir = path.posix.join(input.outputDir, `${baseName}.sections`);
2503
+ const indexRelativePath = path.posix.join(input.outputDir, `${baseName}.index.json`);
2504
+ const capacityRelativePath = path.posix.join(input.outputDir, `${baseName}.capacity.json`);
2505
+ const compactContract = `${serializeCompactDeterministicJson(input.canonical)}\n`;
2506
+ const canonicalArtifact = input.canonicalArtifact ?? {
2507
+ path: path.join(input.runDir, contractRelativePath),
2508
+ sha256: sha256Hex(compactContract),
2509
+ bytes: Buffer.byteLength(compactContract, "utf8"),
2510
+ };
2511
+ const sections = [];
2512
+ for (const [index, group] of FRONTEND_CONTRACT_REVIEW_GROUPS.entries()) {
2513
+ const parts = splitFrontendContractReviewGroup(input.canonical, group.fields);
2514
+ for (const [partIndex, part] of parts.entries()) {
2515
+ const relativePath = path.posix.join(sectionDir, `${String(index + 1).padStart(2, "0")}-${group.id}-part-${String(partIndex + 1).padStart(2, "0")}.json`);
2516
+ const sectionPayload = {
2517
+ schemaVersion: 1,
2518
+ schemaId: FRONTEND_CONTRACT_SECTION_SCHEMA_ID,
2519
+ contractSchemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
2520
+ contractSha256: canonicalArtifact.sha256,
2521
+ group: group.id,
2522
+ part: partIndex + 1,
2523
+ partCount: parts.length,
2524
+ fields: Object.keys(part.data),
2525
+ slices: part.slices,
2526
+ data: part.data,
2527
+ };
2528
+ const written = await writeCompactDeterministicJsonArtifact(input.runDir, relativePath, sectionPayload);
2529
+ sections.push({
2530
+ id: group.id,
2531
+ fields: Object.keys(part.data),
2532
+ part: partIndex + 1,
2533
+ partCount: parts.length,
2534
+ slices: part.slices,
2535
+ path: relativePath,
2536
+ sha256: written.sha256,
2537
+ bytes: Buffer.byteLength(`${serializeCompactDeterministicJson(sectionPayload)}\n`, "utf8"),
2538
+ });
2539
+ }
2540
+ }
2541
+ const indexPayload = {
2542
+ schemaVersion: 1,
2543
+ schemaId: FRONTEND_CONTRACT_REVIEW_INDEX_SCHEMA_ID,
2544
+ contract: {
2545
+ path: contractRelativePath,
2546
+ schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
2547
+ sha256: canonicalArtifact.sha256,
2548
+ bytes: canonicalArtifact.bytes,
2549
+ },
2550
+ sections,
2551
+ };
2552
+ const writtenIndex = await writeCompactDeterministicJsonArtifact(input.runDir, indexRelativePath, indexPayload);
2553
+ // Read budgets for the model-backed frontend review nodes were removed
2554
+ // (2026-08-31 one-shot): nodes no longer carry maxFiles/maxBytes, so the
2555
+ // capacity diagnostic has no budget basis and emits no consumers.
2556
+ const consumers = [];
2557
+ const capacityPayload = {
2558
+ schemaVersion: 1,
2559
+ schemaId: FRONTEND_CONTRACT_CAPACITY_SCHEMA_ID,
2560
+ nonBlocking: true,
2561
+ warningThresholdRatio: FRONTEND_CONTRACT_CAPACITY_WARNING_RATIO,
2562
+ contract: indexPayload.contract,
2563
+ index: {
2564
+ path: indexRelativePath,
2565
+ sha256: writtenIndex.sha256,
2566
+ sectionCount: sections.length,
2567
+ sectionBytes: sections.reduce((total, section) => total + section.bytes, 0),
2568
+ maxSectionBytes: Math.max(...sections.map((section) => section.bytes)),
2569
+ oversizedSectionCount: sections.filter((section) => section.bytes >= 48 * 1024).length,
2570
+ },
2571
+ consumers,
2572
+ overallLevel: consumers.some((entry) => entry.level === "over-budget")
2573
+ ? "over-budget"
2574
+ : consumers.some((entry) => entry.level === "warning")
2575
+ ? "warning"
2576
+ : "ok",
2577
+ };
2578
+ const writtenCapacity = await writeCompactDeterministicJsonArtifact(input.runDir, capacityRelativePath, capacityPayload);
2579
+ return {
2580
+ index: {
2581
+ path: writtenIndex.path,
2582
+ relativePath: indexRelativePath,
2583
+ artifactSha256: writtenIndex.sha256,
2584
+ ...indexPayload,
2585
+ },
2586
+ capacity: {
2587
+ path: writtenCapacity.path,
2588
+ relativePath: capacityRelativePath,
2589
+ artifactSha256: writtenCapacity.sha256,
2590
+ ...capacityPayload,
2591
+ },
2592
+ };
2593
+ }
1715
2594
  export async function writeFrontendImplementationContractArtifact(input) {
1716
- const written = await writeDeterministicJsonArtifact(input.runDir, path.posix.join(input.outputDir, input.artifactName), input.canonical);
2595
+ const written = await writeCompactDeterministicJsonArtifact(input.runDir, path.posix.join(input.outputDir, input.artifactName), input.canonical);
2596
+ const bytes = Buffer.byteLength(`${serializeCompactDeterministicJson(input.canonical)}\n`, "utf8");
2597
+ const reviewArtifacts = await writeFrontendContractReviewArtifacts({
2598
+ ...input,
2599
+ canonicalArtifact: { ...written, bytes },
2600
+ });
1717
2601
  return {
1718
2602
  path: written.path,
1719
2603
  sha256: written.sha256,
1720
2604
  schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
2605
+ reviewIndexPath: reviewArtifacts.index.path,
2606
+ capacityDiagnosticPath: reviewArtifacts.capacity.path,
1721
2607
  };
1722
2608
  }
1723
2609
  const FRONTEND_PLAN_PATCH_PROTECTED_PATHS = [
@@ -1727,6 +2613,40 @@ const FRONTEND_PLAN_PATCH_PROTECTED_PATHS = [
1727
2613
  "/targets/files",
1728
2614
  "/mockApi/productionDefaultOff",
1729
2615
  ];
2616
+ const PLAN_LEDGER_FACT_KINDS_FOR_COMPILE = [
2617
+ "target-surface",
2618
+ "component-choice",
2619
+ "state-flow",
2620
+ "data-flow",
2621
+ "mock-api",
2622
+ "design-deviation",
2623
+ "dependency",
2624
+ "plan-requirement",
2625
+ "plan-verification-target",
2626
+ "plan-evidence-gap",
2627
+ ];
2628
+ /**
2629
+ * Restore the editable RFC 7386 plan patch from a committed plan ledger.
2630
+ * This is the single compile authority for the frontend implementation
2631
+ * contract: the tool payload (`patch` on any committed plan fact) wins over
2632
+ * any narrative JSON in the plan node text.
2633
+ */
2634
+ export function restorePlanPatchFromCommittedFacts(records) {
2635
+ const planFacts = records
2636
+ .filter((record) => record.phase === "committed")
2637
+ .map((record) => record.fact)
2638
+ .filter((fact) => fact.origin === "plan" &&
2639
+ typeof fact.kind === "string" &&
2640
+ PLAN_LEDGER_FACT_KINDS_FOR_COMPILE.includes(fact.kind));
2641
+ if (planFacts.length === 0)
2642
+ return undefined;
2643
+ for (const fact of planFacts) {
2644
+ const patch = fact.patch;
2645
+ if (isPlainObject(patch))
2646
+ return patch;
2647
+ }
2648
+ return undefined;
2649
+ }
1730
2650
  function frontendPlanPatchProtectedPathViolations(patch) {
1731
2651
  const record = asRecord(patch);
1732
2652
  if (!record)
@@ -1756,34 +2676,34 @@ function workspaceRootFromDagRunDir(runDir) {
1756
2676
  const joined = segments.slice(0, harnessIndex).join(path.sep);
1757
2677
  return joined || path.parse(absolute).root;
1758
2678
  }
1759
- function verificationSymbolMatchesFile(symbol, content) {
1760
- if (symbol.trim().toLowerCase() === "all describe blocks")
1761
- return content.includes("describe(");
1762
- const symbols = symbol.includes(" / ")
1763
- ? symbol
1764
- .split(/[((]/, 1)[0]
1765
- .split("/")
1766
- .map((item) => item.trim())
1767
- .filter(Boolean)
1768
- : [symbol];
1769
- return symbols.every((item) => {
1770
- const describeTitle = item.match(/^describe\((?:['"])(.+?)(?:['"])/i)?.[1];
1771
- return [item, describeTitle, describeTitle ? "describe(" : undefined]
1772
- .filter((candidate) => Boolean(candidate))
1773
- .some((candidate) => content.includes(candidate));
1774
- });
1775
- }
1776
2679
  async function dropUnresolvedVerificationSymbols(input) {
1777
2680
  const workspaceRoot = workspaceRootFromDagRunDir(input.runDir);
1778
2681
  const targets = asRecord(input.contract)?.verificationTargets;
1779
2682
  if (!workspaceRoot || !Array.isArray(targets))
1780
- return false;
1781
- let changed = false;
2683
+ return [];
2684
+ const actions = [];
1782
2685
  for (const targetValue of targets) {
1783
2686
  const target = asRecord(targetValue);
1784
2687
  const file = asString(target?.file);
1785
2688
  const symbol = asString(target?.symbol);
1786
- if (!target || !file || !symbol)
2689
+ if (!target || !file)
2690
+ continue;
2691
+ // Static targets (project-wide commands such as typecheck/build) verify
2692
+ // by command exit code, never by a per-file symbol. Strip any symbol
2693
+ // the planner attached — including fabricated placeholders like "null"
2694
+ // — so the plan shape check and the verify trace gate never evaluate
2695
+ // it. This is the root fix for the recurring fabricated-symbol error
2696
+ // class: the field was allowed for static, the prompt did not forbid
2697
+ // it, and models habitually fill it; the compiler removes it instead.
2698
+ if (target.mode === "static") {
2699
+ if (symbol) {
2700
+ delete target.symbol;
2701
+ if (!actions.includes("strip-static-verification-symbols"))
2702
+ actions.push("strip-static-verification-symbols");
2703
+ }
2704
+ continue;
2705
+ }
2706
+ if (!symbol)
1787
2707
  continue;
1788
2708
  const absolute = path.resolve(workspaceRoot, file);
1789
2709
  const relative = path.relative(workspaceRoot, absolute);
@@ -1792,6 +2712,11 @@ async function dropUnresolvedVerificationSymbols(input) {
1792
2712
  relative.includes("..")) {
1793
2713
  continue;
1794
2714
  }
2715
+ // Configuration files (tsconfig.json, vite.config.*, ...) do not expose
2716
+ // source symbols; legacy symbol normalization leaves them alone. Runtime
2717
+ // trace binding handles static targets through file + command only.
2718
+ if (isConfigurationVerificationFile(file))
2719
+ continue;
1795
2720
  let content;
1796
2721
  try {
1797
2722
  content = await readFile(absolute, "utf8");
@@ -1801,23 +2726,13 @@ async function dropUnresolvedVerificationSymbols(input) {
1801
2726
  // changing the optional symbol during candidate normalization.
1802
2727
  continue;
1803
2728
  }
1804
- if (verificationSymbolMatchesFile(symbol, content))
2729
+ if (verificationSymbolMatchesContent(symbol, content))
1805
2730
  continue;
1806
2731
  delete target.symbol;
1807
- changed = true;
1808
- }
1809
- return changed;
1810
- }
1811
- function extractSingleOpenspecCitationsBlock(text) {
1812
- // Line-anchored so a directly closed empty fence (no candidate rows) still
1813
- // counts as the one required block instead of "found 0" retry loops.
1814
- const blocks = [
1815
- ...text.matchAll(/^```openspec-citations[ \t]*\r?\n([\s\S]*?)^```[ \t]*$/gim),
1816
- ].map((match) => match[0]);
1817
- if (blocks.length !== 1) {
1818
- throw new Error(`frontend plan patch output must include exactly one fenced openspec-citations block (found ${blocks.length})`);
2732
+ if (!actions.includes("drop-unresolved-verification-symbols"))
2733
+ actions.push("drop-unresolved-verification-symbols");
1819
2734
  }
1820
- return blocks[0];
2735
+ return actions;
1821
2736
  }
1822
2737
  async function writeFrontendPlanCandidateRaw(input) {
1823
2738
  const relativePath = path.posix.join("contracts", "candidates", input.nodeId, `attempt-${input.attempt}.raw.md`);
@@ -1831,6 +2746,118 @@ async function writeFrontendPlanCandidateRaw(input) {
1831
2746
  * prose. A successful result returns a path-only pointer plus the hash-bound canonical artifact for
1832
2747
  * downstream review and prewrite nodes.
1833
2748
  */
2749
+ /**
2750
+ * Placeholder tokens that are never valid verification symbols ("null" is the
2751
+ * classic model artifact: it serializes a JS null into the symbol string).
2752
+ */
2753
+ const VERIFICATION_SYMBOL_PLACEHOLDERS = new Set([
2754
+ "null",
2755
+ "undefined",
2756
+ "none",
2757
+ "n/a",
2758
+ "na",
2759
+ "tbd",
2760
+ "todo",
2761
+ "...",
2762
+ "?",
2763
+ "-",
2764
+ ]);
2765
+ /** An over-long symbol can never resolve to a real declaration. */
2766
+ const VERIFICATION_SYMBOL_MAX_CHARS = 60;
2767
+ /**
2768
+ * Deterministic shape check for a verification target symbol. Catches obvious
2769
+ * model fabrication (placeholder tokens, prose fragments) when reading a
2770
+ * legacy contract. New plan tool calls do not expose this field; runtime trace
2771
+ * binding uses the stable verification-target id instead.
2772
+ *
2773
+ * Deliberately conservative: bare test titles without punctuation ("renders
2774
+ * loading") and `describe(...)` / `it(...)` forms stay valid — a real symbol
2775
+ * may be a function name, a dotted path, or a describe/it title.
2776
+ */
2777
+ export function isSuspiciousVerificationSymbol(symbol) {
2778
+ const trimmed = symbol.trim();
2779
+ if (!trimmed)
2780
+ return false;
2781
+ if (VERIFICATION_SYMBOL_PLACEHOLDERS.has(trimmed.toLowerCase()))
2782
+ return true;
2783
+ if (trimmed.length > VERIFICATION_SYMBOL_MAX_CHARS)
2784
+ return true;
2785
+ // Deliberately conservative: bare test titles with punctuation (e.g.
2786
+ // "adds +1 button") are valid describe/it-style symbols and must pass;
2787
+ // the length + placeholder rules above already catch obvious fabrication
2788
+ // ("null", over-long prose fragments). Everything else is left to the
2789
+ // deterministic drop/trace resolvability check.
2790
+ return false;
2791
+ }
2792
+ function assertVerificationSymbolShapes(contract) {
2793
+ for (const target of contract.verificationTargets) {
2794
+ if (!target.symbol)
2795
+ continue;
2796
+ if (isSuspiciousVerificationSymbol(target.symbol)) {
2797
+ throw new Error(`frontend plan verification target "${target.id}" declares a fabricated legacy symbol "${target.symbol}" for ${target.file}; remove the symbol (deterministic trace binding uses the stable target id)`);
2798
+ }
2799
+ }
2800
+ }
2801
+ /**
2802
+ * Thrown by analyzeFrontendPlanPatchCandidate when the design-policy
2803
+ * pre-checks report findings. Carries the structured findings so callers
2804
+ * (finalize_plan receipt) can synthesize fix suggestions instead of making
2805
+ * the model re-derive them from prose.
2806
+ */
2807
+ export class PlanPolicyPrecheckFailure extends Error {
2808
+ findings;
2809
+ constructor(message, findings) {
2810
+ super(message);
2811
+ this.name = "PlanPolicyPrecheckFailure";
2812
+ this.findings = findings;
2813
+ }
2814
+ }
2815
+ /**
2816
+ * Shared tail of the plan patch validation: analyze the merged contract and
2817
+ * front-load the plan-attributable design-policy checks. Throws the same
2818
+ * errors the node self-check throws (FrontendContractFailure for schema
2819
+ * failures, Error for policy findings) so every caller — the node validator
2820
+ * and the finalize_plan receipt — surfaces identical diagnostics.
2821
+ */
2822
+ export async function analyzeFrontendPlanPatchCandidate(input) {
2823
+ const analysis = await analyzeFrontendImplementationContract(input);
2824
+ // A committed VT with a fabricated symbol is an immutable ledger fact —
2825
+ // the record boundary rejects duplicate ids, so the model cannot overwrite
2826
+ // it and throwing here deadlocks the receipt loop (r19: VT-AC006-BEHAVIOR).
2827
+ // Drop suspicious symbols deterministically instead: the VT stays valid and
2828
+ // the deterministic trace gate verifies file+command (and resolvability)
2829
+ // after verification. Mirrors the shell materialization's drop semantics.
2830
+ for (const target of analysis.canonical.verificationTargets) {
2831
+ if (target.symbol && isSuspiciousVerificationSymbol(target.symbol)) {
2832
+ target.symbol = undefined;
2833
+ }
2834
+ }
2835
+ // Front-load the verification-symbol shape check so fabricated symbols
2836
+ // are fixed by the plan retry ladder in-node instead of failing the
2837
+ // verify trace gate at the end of the run.
2838
+ assertVerificationSymbolShapes(analysis.canonical);
2839
+ // Front-load the plan-attributable design-policy checks so the §5.1
2840
+ // retry ladder can fix these facts in-node with diagnostics instead of
2841
+ // the run terminating at the policy shell. The policy shell remains the
2842
+ // final authority and re-runs the identical checks on committed facts.
2843
+ const policyPreFindings = [
2844
+ ...checkUiDesignCoverage(analysis.canonical),
2845
+ ...checkUiStateAttribution(analysis.canonical),
2846
+ ...checkDependencies(analysis.canonical, await deriveAllowedDependenciesFromRun(input.runDir)),
2847
+ ...checkTargetPaths(analysis.canonical, await deriveWriteSetFromRun(input.runDir)),
2848
+ ];
2849
+ if (policyPreFindings.length > 0) {
2850
+ const message = `frontend plan policy pre-check failed (fix these plan facts, then re-commit and finalize): ${policyPreFindings
2851
+ .map((finding) => `${finding.code}: ${finding.message}`)
2852
+ .join("; ")}`;
2853
+ throw new PlanPolicyPrecheckFailure(message, policyPreFindings.map((finding) => ({
2854
+ code: finding.code,
2855
+ message: finding.message,
2856
+ ...(finding.path ? { path: finding.path } : {}),
2857
+ })));
2858
+ }
2859
+ return analysis;
2860
+ }
1834
2861
  export async function validateFrontendPlanPatchNodeOutput(input) {
1835
2862
  const nodeId = input.nodeId ?? "frontend-plan-pi";
1836
2863
  const attempt = input.attempt ?? 1;
@@ -1867,22 +2894,22 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1867
2894
  const skeleton = input.structuredContractOutput?.skeleton;
1868
2895
  if (!skeleton)
1869
2896
  throw new Error("frontend plan patch validator requires a deterministic runtime skeleton");
1870
- const patch = extractFrontendImplementationJson(input.text);
2897
+ const committed = await readTypedEventStoreFromJsonl(path.join(input.runDir, nodeId, "plan-typed-facts.jsonl"));
2898
+ const patch = restorePlanPatchFromCommittedFacts(committed);
1871
2899
  if (!isPlainObject(patch))
1872
- throw new Error("frontend plan patch must extract to one JSON object");
2900
+ throw new Error("frontend plan ledger missing: no committed origin=plan facts (record_* + finalize_plan); narrative JSON is not the compile authority");
1873
2901
  extractedPatchPath = path.posix.join(candidateDir, `attempt-${attempt}.extracted.json`);
1874
2902
  const extractedArtifact = await writeDeterministicJsonArtifact(input.runDir, extractedPatchPath, patch);
1875
2903
  extractedPatchSha256 = extractedArtifact.sha256;
1876
- const citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
1877
2904
  const protectedViolations = frontendPlanPatchProtectedPathViolations(patch);
1878
2905
  if (protectedViolations.length > 0)
1879
2906
  throw new Error(`frontend plan patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`);
1880
2907
  const merged = applyFrontendContractMergePatch(skeleton, patch);
1881
- const droppedUnresolvedSymbols = await dropUnresolvedVerificationSymbols({
2908
+ const symbolNormalizationActions = await dropUnresolvedVerificationSymbols({
1882
2909
  runDir: input.runDir,
1883
2910
  contract: merged,
1884
2911
  });
1885
- const analysis = await analyzeFrontendImplementationContract({
2912
+ const analysis = await analyzeFrontendPlanPatchCandidate({
1886
2913
  runDir: input.runDir,
1887
2914
  rawContractText: serializeDeterministicJson(merged),
1888
2915
  sourceBinding: input.sourceBinding,
@@ -1894,20 +2921,13 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1894
2921
  normalizedContractSha256: normalizedArtifact.sha256,
1895
2922
  normalizationActions: [
1896
2923
  "apply-runtime-skeleton",
1897
- ...(droppedUnresolvedSymbols
1898
- ? ["drop-unresolved-verification-symbols"]
1899
- : []),
2924
+ ...symbolNormalizationActions,
1900
2925
  ...analysis.normalizationActions,
1901
2926
  ],
1902
2927
  errors: [],
1903
2928
  });
1904
2929
  const canonicalRel = canonicalContractRel(nodeId);
1905
2930
  const canonicalArtifact = await writeDeterministicJsonArtifact(input.runDir, canonicalRel, analysis.canonical);
1906
- const parsedCitations = parseOpenspecCitationsBlock(citationsBlock);
1907
- await writeDeterministicJsonArtifact(input.runDir, openspecCitationsRel(nodeId), {
1908
- citations: parsedCitations.citations,
1909
- block: citationsBlock,
1910
- });
1911
2931
  await persistRuntimeSkeleton({
1912
2932
  runDir: input.runDir,
1913
2933
  nodeId,
@@ -1918,11 +2938,17 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1918
2938
  sha256: canonicalArtifact.sha256,
1919
2939
  schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
1920
2940
  };
2941
+ // Human-readable plan summary appended after the artifact pointer. The
2942
+ // pointer remains the machine authority (path/schema/sha256); the
2943
+ // rendered summary is a deterministic view of the canonical contract so
2944
+ // Console/reviewers can read the plan without opening the JSON. It never
2945
+ // participates in downstream validation.
2946
+ const planSummary = renderFrontendPlanMarkdown(analysis.canonical);
1921
2947
  return {
1922
2948
  ok: true,
1923
2949
  contract: analysis.canonical,
1924
2950
  artifact,
1925
- normalizedText: formatStructuredArtifactPointer(artifact),
2951
+ normalizedText: `${formatStructuredArtifactPointer(artifact)}\n\n${planSummary}`,
1926
2952
  };
1927
2953
  }
1928
2954
  catch (error) {
@@ -1989,106 +3015,125 @@ export async function validateFrontendContractNodeOutput(input) {
1989
3015
  }
1990
3016
  }
1991
3017
  /**
1992
- * Node-output self-check for the plan revision node. Format-level checks still
1993
- * require one openspec-citations fence and one JSON object. When the original
1994
- * canonical contract and sourceBinding are present, this node is the merge
1995
- * authority: it applies the RFC 7386 patch, validates the full contract, and
1996
- * writes the revision node's own hash-bound canonical artifact.
3018
+ * Deterministically assemble the canonical frontend implementation contract
3019
+ * from committed typed facts, never from plan/contract node text.
3020
+ *
3021
+ * Inputs (all read-only, all fail-closed):
3022
+ * - `<planNodeId>/plan-typed-facts.jsonl` (phase=committed) restores the
3023
+ * editable RFC 7386 patch;
3024
+ * - `<contractNodeId>/contract-typed-facts.jsonl` (phase=committed) must carry
3025
+ * a `contract-finalized` terminal fact;
3026
+ * - `<contractNodeId>/frontend-task-contract-vNext.json` must exist and parse.
3027
+ *
3028
+ * The patch is applied onto the runtime skeleton and re-validated through the
3029
+ * same `analyzeFrontendImplementationContract` pipeline used by the legacy
3030
+ * text path, so the downstream artifact semantics are unchanged. The function
3031
+ * is pure: it never writes candidate/audit artifacts and always produces the
3032
+ * same canonical serialization for the same committed facts. It never falls
3033
+ * back to parsing Plan/Contract node text.
1997
3034
  */
1998
- export async function validateFrontendRevisionPatchNodeOutput(input) {
1999
- const fail = (reason) => {
2000
- const classified = classifyStructuredContractFailure({ reason });
3035
+ export async function buildContractFromCommittedFacts(input) {
3036
+ const contractNodeId = input.contractNodeId ?? "frontend-contract-pi";
3037
+ // Skeleton guard: the runtime skeleton must carry the protected identity
3038
+ // fields so the merged contract can be validated against the full schema.
3039
+ const skeletonRecord = asRecord(input.skeleton);
3040
+ const skeletonTargets = asRecord(skeletonRecord?.targets);
3041
+ const skeletonRiskLevel = asString(skeletonRecord?.riskLevel);
3042
+ if (!skeletonRecord ||
3043
+ !["small", "standard", "high-risk"].includes(skeletonRiskLevel) ||
3044
+ !Array.isArray(skeletonTargets?.files) ||
3045
+ skeletonTargets.files.length === 0) {
2001
3046
  return {
2002
3047
  ok: false,
2003
- reason,
2004
- classification: classified.classification,
2005
- errors: classified.errors,
3048
+ reason: "frontend contract skeleton missing required runtime fields (riskLevel/targets.files)",
3049
+ failureCode: "missing-skeleton",
2006
3050
  };
2007
- };
2008
- const nodeId = input.nodeId ?? "frontend-plan-revision-pi";
2009
- const attempt = input.attempt ?? 1;
2010
- await writeFrontendPlanCandidateRaw({
2011
- runDir: input.runDir,
2012
- nodeId,
2013
- attempt,
2014
- text: input.text,
2015
- });
2016
- let citationsBlock;
2017
- try {
2018
- citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
2019
3051
  }
2020
- catch (error) {
2021
- return fail(error instanceof Error ? error.message : String(error));
3052
+ // a. Committed plan ledger restores the editable patch.
3053
+ const planRecords = await readTypedEventStoreFromJsonl(path.join(input.runDir, input.planNodeId, "plan-typed-facts.jsonl"));
3054
+ const patch = restorePlanPatchFromCommittedFacts(planRecords);
3055
+ if (!isPlainObject(patch)) {
3056
+ return {
3057
+ ok: false,
3058
+ reason: "frontend plan ledger missing: no committed origin=plan facts (record_* + finalize_plan); narrative JSON is not the compile authority",
3059
+ failureCode: "missing-plan-ledger",
3060
+ };
2022
3061
  }
2023
- const textWithoutCitations = input.text.replace(citationsBlock, "");
2024
- let patch;
3062
+ // b. Committed contract terminal fact (contract-finalized).
3063
+ const contractRecords = await readTypedEventStoreFromJsonl(path.join(input.runDir, contractNodeId, "contract-typed-facts.jsonl"));
3064
+ const contractKinds = new Set(contractRecords
3065
+ .filter((record) => record.phase === "committed")
3066
+ .map((record) => record.fact.kind)
3067
+ .filter((kind) => typeof kind === "string"));
3068
+ if (!contractKinds.has("contract-finalized")) {
3069
+ return {
3070
+ ok: false,
3071
+ reason: "frontend contract facts missing: no committed contract-finalized terminal fact",
3072
+ failureCode: "missing-contract-finalize",
3073
+ };
3074
+ }
3075
+ // c. frontend-task-contract-vNext.json must exist and parse (input integrity).
3076
+ let rawVNext;
2025
3077
  try {
2026
- patch = extractFrontendImplementationJson(textWithoutCitations);
3078
+ rawVNext = await readFile(path.join(input.runDir, contractNodeId, "frontend-task-contract-vNext.json"), "utf8");
2027
3079
  }
2028
- catch (error) {
2029
- return fail(`revision patch output must contain exactly one valid JSON object: ${error instanceof Error ? error.message : String(error)}`);
3080
+ catch {
3081
+ return {
3082
+ ok: false,
3083
+ reason: `frontend task contract vNext missing at ${contractNodeId}/frontend-task-contract-vNext.json`,
3084
+ failureCode: "contract-vnext-missing",
3085
+ };
2030
3086
  }
2031
- if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
2032
- return fail("revision patch output must extract to exactly one JSON object (RFC 7386 merge-patch delta)");
3087
+ try {
3088
+ JSON.parse(rawVNext);
2033
3089
  }
2034
- const protectedViolations = frontendPlanPatchProtectedPathViolations(patch);
2035
- if (protectedViolations.length > 0) {
2036
- return fail(`frontend revision patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`);
3090
+ catch (error) {
3091
+ return {
3092
+ ok: false,
3093
+ reason: `frontend task contract vNext is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
3094
+ failureCode: "contract-vnext-invalid",
3095
+ };
2037
3096
  }
2038
- const original = (await readFrontendCanonicalCandidate(input.runDir, "frontend-plan-pi")) ??
2039
- (await readFrontendCanonicalCandidate(input.runDir, nodeId));
2040
- if (!input.sourceBinding) {
2041
- return { ok: true, contract: patch };
3097
+ // d. Merge the editable patch onto the protected skeleton and validate.
3098
+ let merged;
3099
+ try {
3100
+ merged = applyFrontendContractMergePatch(input.skeleton, patch);
2042
3101
  }
2043
- if (!original) {
2044
- return fail("frontend revision patch requires the original frontend-plan-pi canonical contract");
3102
+ catch (error) {
3103
+ return {
3104
+ ok: false,
3105
+ reason: `frontend contract patch could not be applied to the runtime skeleton: ${error instanceof Error ? error.message : String(error)}`,
3106
+ failureCode: "schema-drift",
3107
+ };
2045
3108
  }
3109
+ // The design-policy shell is the authoritative materializer of the
3110
+ // canonical contract; keep legacy symbol normalization identical to the
3111
+ // plan validator even though trace binding now uses stable target ids.
3112
+ await dropUnresolvedVerificationSymbols({
3113
+ runDir: input.runDir,
3114
+ contract: merged,
3115
+ });
2046
3116
  try {
2047
- const merged = applyFrontendContractMergePatch(original, patch);
2048
- const droppedUnresolvedSymbols = await dropUnresolvedVerificationSymbols({
2049
- runDir: input.runDir,
2050
- contract: merged,
2051
- });
2052
3117
  const analysis = await analyzeFrontendImplementationContract({
2053
3118
  runDir: input.runDir,
2054
3119
  rawContractText: serializeDeterministicJson(merged),
2055
3120
  sourceBinding: input.sourceBinding,
2056
3121
  });
2057
- const candidateDir = path.posix.join("contracts", "candidates", nodeId);
2058
- await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.extracted.json`), patch);
2059
- await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.normalized.json`), analysis.canonical);
2060
- await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.validation.json`), {
2061
- classification: "accepted-normalized",
2062
- normalizationActions: [
2063
- "apply-original-canonical",
2064
- ...(droppedUnresolvedSymbols
2065
- ? ["drop-unresolved-verification-symbols"]
2066
- : []),
2067
- ...analysis.normalizationActions,
2068
- ],
2069
- errors: [],
2070
- });
2071
- const canonicalArtifact = await writeDeterministicJsonArtifact(input.runDir, canonicalContractRel(nodeId), analysis.canonical);
2072
- const parsedCitations = parseOpenspecCitationsBlock(citationsBlock);
2073
- await writeDeterministicJsonArtifact(input.runDir, openspecCitationsRel(nodeId), {
2074
- citations: parsedCitations.citations,
2075
- block: citationsBlock,
2076
- });
2077
- const artifact = {
2078
- path: canonicalArtifact.path,
2079
- sha256: canonicalArtifact.sha256,
2080
- schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
2081
- };
2082
3122
  return {
2083
3123
  ok: true,
2084
- contract: analysis.canonical,
2085
- artifact,
2086
- normalizedText: formatStructuredArtifactPointer(artifact),
3124
+ canonical: analysis.canonical,
3125
+ normalizationActions: analysis.normalizationActions,
3126
+ candidateJsonSha256: analysis.candidateJsonSha256,
2087
3127
  };
2088
3128
  }
2089
3129
  catch (error) {
2090
- const reason = error instanceof Error ? error.message : String(error);
2091
- return fail(reason);
3130
+ const failureKind = error instanceof FrontendContractFailure ? error.kind : undefined;
3131
+ return {
3132
+ ok: false,
3133
+ reason: `frontend contract schema drift: ${error instanceof Error ? error.message : String(error)}`,
3134
+ failureCode: "schema-drift",
3135
+ ...(failureKind ? { failureKind } : {}),
3136
+ };
2092
3137
  }
2093
3138
  }
2094
3139
  export async function materializeFrontendImplementationContract(input) {