@tea-agent/loop-agent 0.39.0-beta.2 → 0.39.0-beta.3

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 (202) hide show
  1. package/AGENTS.md +4 -1
  2. package/CHANGELOG.md +187 -92
  3. package/README.md +9 -5
  4. package/bin/loop-agent.js +7 -3
  5. package/dist/application/task-lifecycle/recommendations.js +13 -2
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/cli/command-definitions.js +16 -5
  8. package/dist/cli/program.js +16 -2
  9. package/dist/cli/update/notifier.js +2 -2
  10. package/dist/cli.js +2 -1
  11. package/dist/commands/dag-rerun.js +55 -1
  12. package/dist/commands/init-model-catalog.js +464 -0
  13. package/dist/commands/init-upgrade.js +265 -97
  14. package/dist/commands/init.js +429 -28
  15. package/dist/commands/inspect-next.js +8 -0
  16. package/dist/executors/dag-pi-executor.js +1645 -29
  17. package/dist/executors/pi-extension-resolver.js +233 -0
  18. package/dist/executors/pi-playwright-cli-tool.js +51 -12
  19. package/dist/executors/pi-sdk-executor.js +180 -33
  20. package/dist/executors/shell-executor.js +1150 -171
  21. package/dist/executors/shell-write-guard.js +14 -0
  22. package/dist/shared/dag-prompt-override.js +28 -0
  23. package/dist/shared/openspec-spec.js +70 -4
  24. package/dist/shared/operator/capabilities.js +32 -0
  25. package/dist/shared/playwright-cli-command-policy.js +15 -0
  26. package/dist/shared/update/console-notifier.js +100 -0
  27. package/dist/{cli → shared}/update/npm-client.js +41 -8
  28. package/dist/{cli → shared}/update/state.js +41 -13
  29. package/dist/task/config-types.js +19 -5
  30. package/dist/task/contract/project.js +3 -0
  31. package/dist/task/contract/schema.js +2 -1
  32. package/dist/task/frontend-project-capability.js +42 -16
  33. package/dist/task/source-prepare/parse-intent.js +24 -4
  34. package/dist/task/source-prepare/prepare.js +36 -0
  35. package/dist/worker/cli.js +31 -1
  36. package/dist/worker/console/chat/pi-console-config.js +81 -10
  37. package/dist/worker/console/chat/routes.js +1 -0
  38. package/dist/worker/console/dag-execution-receipt.js +33 -0
  39. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  40. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  41. package/dist/worker/console/index.js +3 -0
  42. package/dist/worker/console/operation-store.js +226 -6
  43. package/dist/worker/console/operator-actions.js +263 -0
  44. package/dist/worker/console/operator-user-error.js +2 -2
  45. package/dist/worker/console/routes.js +43 -2
  46. package/dist/worker/console/security.js +63 -9
  47. package/dist/worker/console/server.js +151 -6
  48. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4kVYweA.js → abnfDiagram-N423BO3Z-zsNyvGaH.js} +1 -1
  49. package/dist/worker/console/static/assets/{arc-Bj2M8iO5.js → arc-BDjZ5kE1.js} +1 -1
  50. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CLlXe4-6.js → architectureDiagram-T3A2C74G-BkgaujnN.js} +1 -1
  51. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-psEp5xH0.js → blockDiagram-VBNYF7ZC-CqxOJi0j.js} +1 -1
  52. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DeKcOCGJ.js → c4Diagram-5PPSVZJV-C2JHLdRi.js} +1 -1
  53. package/dist/worker/console/static/assets/channel-BuqaGKAT.js +1 -0
  54. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-K0C744p1.js → chunk-2GRJ4B5K-DF_YSvOS.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-xbNw4J9-.js → chunk-2Q5K7J3B-D3Ort5Vo.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLaSDUO.js → chunk-5RXB4S5H-XfAfZeUC.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CtiPlKel.js → chunk-5VM5RSS4-SLBibhf5.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DZtolir7.js → chunk-6Q2QTUOP-Dz29Ye_f.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CPcdNeew.js → chunk-GF5L2VYU-DB2G4H8V.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-NgHc6V37.js → chunk-JWPE2WC7-CIoGkxdu.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DQ_T-hg8.js → chunk-KBJHAD2P-CG78FGkn.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DgLsxcVP.js → chunk-RYQCIY6F-ZDHAvXPd.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-UrVoM9z1.js → chunk-XXDRQBXY-BKkkC4VA.js} +1 -1
  64. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DNdSHjH9.js +1 -0
  65. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DNdSHjH9.js +1 -0
  66. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CLkYvTb3.js → cose-bilkent-JH36ORCC-SposUZsO.js} +1 -1
  67. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BwT_xKrE.js → cynefin-VYW2F7L2-BSCtQpD7.js} +1 -1
  68. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bi9tuzif.js → cynefinDiagram-MW4NZA55-DgFHrw9l.js} +1 -1
  69. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BCIqkWBV.js → dagre-VZM6K2ZE-BVBasYbj.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dip6l9_Z.js → diagram-7IWD3JNH-6r_4rN_c.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B-xkh_wK.js → diagram-B4RE2ZJO-JmuaB41P.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DZO0kTt_.js → diagram-LBJQPF4R-xXvAY3sk.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D6ZplNbZ.js → diagram-Q27KOJAE-D2B3lY2o.js} +1 -1
  74. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPekbhoS.js → diagram-UB23O5K3-tK54tMEX.js} +1 -1
  75. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BtmaJpK5.js → ebnfDiagram-BXEA7PRR-CNtELzV-.js} +1 -1
  76. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-6DS0Xc44.js → erDiagram-JOGREHBK-C1xKMKcW.js} +1 -1
  77. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CihnROSm.js → flowDiagram-UKHOOZJN-CZ7dlPMz.js} +1 -1
  78. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CZC4zOE2.js → ganttDiagram-PKOTCBZU-BqGb42Xe.js} +1 -1
  79. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js → gitGraphDiagram-DS77QQ5N-D_BlXEtt.js} +1 -1
  80. package/dist/worker/console/static/assets/{index-DTZOKgAn.js → index-D5zGX6fG.js} +68 -68
  81. package/dist/worker/console/static/assets/index-DBbhESQ_.css +1 -0
  82. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DlSl4BGx.js → infoDiagram-6WML65LV-Vce2vmIe.js} +1 -1
  83. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-snnzFl_U.js → ishikawaDiagram-WSZJBQD7-DDwVBAvI.js} +1 -1
  84. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BDE7qpMx.js → journeyDiagram-NVQOT4AX-y_Ew-wv4.js} +1 -1
  85. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CD2Ci04G.js → kanban-definition-27J2QSJJ-CjXJFp1K.js} +1 -1
  86. package/dist/worker/console/static/assets/{linear-DdnapKIH.js → linear-1itMst1Q.js} +1 -1
  87. package/dist/worker/console/static/assets/{mermaid.core-BVjAT9b8.js → mermaid.core-C-D3ZRUa.js} +5 -5
  88. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D0yJk-zA.js → mindmap-definition-FAOFIHXS-BiYywmZc.js} +1 -1
  89. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVo9tFP-.js → pegDiagram-VL7TDLO6-l2fydNWA.js} +1 -1
  90. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DnOV-mZ_.js → pieDiagram-7S7Q4E2Y-Dz0hNbYW.js} +1 -1
  91. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CzUJo58i.js → quadrantDiagram-CIZ2JOQS-BujI_mLZ.js} +1 -1
  92. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bvikrolh.js → railroadDiagram-AXF67PYL-pWe84beb.js} +1 -1
  93. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DtaSVCap.js → requirementDiagram-LRYGKXZP-ByYu2frg.js} +1 -1
  94. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-C2c9A0wW.js → sankeyDiagram-W5VNT64P-CoPnilFP.js} +1 -1
  95. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DmXJcU7r.js → sequenceDiagram-SI44F4Z6-1Md2LnS0.js} +1 -1
  96. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4GUFW92.js → sizeCapture-X5ZJPWSS-DqqqAoSH.js} +1 -1
  97. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Dsad2MXf.js → stateDiagram-OKZ733FA-C4sGkf--.js} +1 -1
  98. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8rmop_9.js +1 -0
  99. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DGq48Fbi.js → swimlanes-SLNWSIFB-ByxJrUzW.js} +2 -2
  100. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B0ky-kBM.js +8 -0
  101. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CSSi6OFf.js → timeline-definition-Z64GVDOM-D8MLgM0O.js} +1 -1
  102. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BqyzPrWv.js → vennDiagram-T6HMQDX7-DnBis-wZ.js} +1 -1
  103. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DlznVb6S.js → wardleyDiagram-T6FBY63Y-uuyYT7UO.js} +1 -1
  104. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CfBcgJ_K.js → xychartDiagram-ELKLHX3M-BjgabhwK.js} +1 -1
  105. package/dist/worker/console/static/index.html +2 -2
  106. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  107. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
  108. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  109. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  110. package/dist/worker/observe/node-input.js +74 -3
  111. package/dist/worker/observe/node-process.js +377 -0
  112. package/dist/worker/observe/routes.js +36 -3
  113. package/dist/worker/observe/shell-handler-keys.js +34 -0
  114. package/dist/worker/observe/static/api.js +66 -0
  115. package/dist/worker/observe/static/constants.js +8 -1
  116. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  117. package/dist/worker/observe/static/format.js +23 -0
  118. package/dist/worker/observe/static/markdown-render.js +20 -1
  119. package/dist/worker/observe/static/operator-chrome.js +4 -0
  120. package/dist/worker/observe/static/state.js +13 -2
  121. package/dist/worker/observe/static/styles.css +73 -0
  122. package/dist/worker/observe/static/views/dag-inspector.js +612 -8
  123. package/dist/worker/observe/static/views/dag.js +8 -1
  124. package/dist/worker/observe/static/views/failures.js +5 -2
  125. package/dist/worker/observe/static/views/session-timeline.js +78 -9
  126. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +20 -2
  127. package/dist/workflows/dag/contract-output-registry.js +15 -0
  128. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  129. package/dist/workflows/dag/failure-category.js +12 -0
  130. package/dist/workflows/dag/frontend-closeout.js +221 -0
  131. package/dist/workflows/dag/frontend-design-policy.js +347 -0
  132. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  133. package/dist/workflows/dag/frontend-implementation-contract.js +378 -160
  134. package/dist/workflows/dag/frontend-prewrite-gate.js +258 -393
  135. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  136. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  137. package/dist/workflows/dag/frontend-recovery-controller.js +201 -0
  138. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  139. package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
  140. package/dist/workflows/dag/frontend-recovery-run.js +33 -20
  141. package/dist/workflows/dag/frontend-repair.js +7 -424
  142. package/dist/workflows/dag/frontend-review-context.js +195 -8
  143. package/dist/workflows/dag/frontend-risk.js +15 -2
  144. package/dist/workflows/dag/frontend-shadow-dual-write.js +665 -0
  145. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  146. package/dist/workflows/dag/frontend-shape-facts.js +409 -0
  147. package/dist/workflows/dag/frontend-shape.js +425 -0
  148. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  149. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  150. package/dist/workflows/dag/frontend-test-case-checklist.js +26 -2
  151. package/dist/workflows/dag/frontend-test-case-manifest.js +5 -0
  152. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  153. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  154. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  155. package/dist/workflows/dag/frontend-test-result-contract.js +160 -28
  156. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  157. package/dist/workflows/dag/frontend-typed-event-store.js +438 -0
  158. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  159. package/dist/workflows/dag/frontend-verification-trace.js +52 -17
  160. package/dist/workflows/dag/frontend-worktree-diff.js +97 -18
  161. package/dist/workflows/dag/frontend-writer-admission.js +260 -0
  162. package/dist/workflows/dag/frontend-writer-status.js +244 -0
  163. package/dist/workflows/dag/init-hybrid.js +572 -500
  164. package/dist/workflows/dag/node-execution.js +307 -70
  165. package/dist/workflows/dag/prompt.js +10 -18
  166. package/dist/workflows/dag/rerun-plan.js +144 -5
  167. package/dist/workflows/dag/rerun-run.js +58 -6
  168. package/dist/workflows/dag/retry-policy.js +228 -4
  169. package/dist/workflows/dag/runner.js +314 -119
  170. package/dist/workflows/dag/scheduler.js +114 -16
  171. package/dist/workflows/dag/structured-output-repair.js +712 -0
  172. package/dist/workflows/dag/types.js +235 -23
  173. package/dist/workflows/dag/upstream-artifacts.js +0 -4
  174. package/dist/workflows/dag/validate.js +60 -10
  175. package/docs/init-surface.manifest.json +5 -0
  176. package/docs/operations/README.md +1 -1
  177. package/docs/templates/agent-dag.schema.json +44 -0
  178. package/docs/templates/frontend-test-dag.json +8 -10
  179. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  180. package/docs/templates/init-managed-agents.md +13 -3
  181. package/harness.json +3 -3
  182. package/package.json +5 -1
  183. package/scripts/next-info.mjs +356 -0
  184. package/scripts/next-publish-gate.mjs +238 -0
  185. package/scripts/release-source-binding.mjs +251 -0
  186. package/skills/codebase-scout/SKILL.md +1 -1
  187. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  188. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  189. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  190. package/skills/frontend-implementation/SKILL.md +4 -4
  191. package/skills/frontend-implementation/references/design-spec.md +11 -7
  192. package/skills/frontend-implementation/references/node-contracts.md +20 -11
  193. package/skills/loop-agent/references/command-reference.md +11 -4
  194. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  195. package/skills/playwright-cli/SKILL.md +22 -0
  196. package/skills/playwright-cli-case-generator/SKILL.md +20 -1
  197. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +0 -1
  198. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +0 -1
  199. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +0 -1
  200. package/dist/worker/console/static/assets/index-Ya5FE7cD.css +0 -1
  201. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +0 -1
  202. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +0 -8
@@ -1,16 +1,19 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFile, stat } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { z } from "zod";
4
- import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
5
+ import { OPENSPEC_SPEC_EXT_RE, isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
5
6
  import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
6
7
  import { countExcludedGovernanceSpecFiles, resolveOpenspecGovernanceRoot, } from "../../task/frontend-project-capability.js";
7
- import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, applyFrontendImplementationContractPatch, extractFrontendImplementationJson, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
8
+ import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
8
9
  import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
9
10
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
10
11
  import { pathMatchesPattern } from "../../shared/git-progress.js";
11
12
  import { normalizeVerdictCandidateLine } from "./dynamic-runtime/shared.js";
12
13
  export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
13
14
  export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
15
+ /** Canonical contract serialization must stay at or below this byte size. */
16
+ export const FRONTEND_CONTRACT_MAX_BYTES = 65536;
14
17
  /**
15
18
  * Machine-readable failure classification for blocked/retryable-invalid
16
19
  * prewrite outcomes. Kept alongside the human-readable failureReason so
@@ -31,12 +34,13 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
31
34
  "verification-target-outside-write-set",
32
35
  "openspec-not-read",
33
36
  "candidate-missing-drift",
34
- "openspec-citation-block-unparseable",
35
- "openspec-not-cited",
36
- "openspec-citation-not-read",
37
+ "candidate-content-drift",
38
+ "openspec-selection-invalid",
37
39
  "component-spec-reference-invalid",
38
40
  "component-spec-not-cited",
41
+ "component-spec-choice-unbound",
39
42
  "component-choices-missing",
43
+ "contract-too-large",
40
44
  "revision-patch-unmergeable",
41
45
  ]);
42
46
  export const frontendPrewriteResultV1Schema = z
@@ -125,7 +129,7 @@ export const frontendPrewriteResultV1Schema = z
125
129
  export const frontendWriterAdmissionSchema = z
126
130
  .object({
127
131
  schemaVersion: z.literal(1),
128
- writerNodeId: z.enum(["frontend-implement-pi", "frontend-repair-pi"]),
132
+ writerNodeId: z.enum(["frontend-implement-pi"]),
129
133
  decision: z.enum(["authorized", "denied"]),
130
134
  sourceArtifact: z.string().min(1),
131
135
  artifactHash: z.string().regex(/^[a-f0-9]{64}$/),
@@ -154,136 +158,68 @@ async function selectNode(runDir, primary, fallbacks) {
154
158
  }
155
159
  throw new Error(`frontend prewrite gate no FINISHED candidate: ${rejections.join("; ")}`);
156
160
  }
157
- async function readNodeText(runDir, nodeId) {
158
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
161
+ async function readFinishedNodeRecord(runDir, nodeId) {
162
+ let record;
163
+ try {
164
+ record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
165
+ }
166
+ catch (error) {
167
+ throw new Error(`frontend prewrite gate cannot parse node record ${nodeId}: ${error instanceof Error ? error.message : String(error)}`);
168
+ }
159
169
  if (record.status !== "FINISHED") {
160
170
  throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
161
171
  }
162
- // Pi execution mirrors a successful structured-contract normalization into
163
- // both assistantText and stdout. Treat node output the same way as the DAG
164
- // runtime's canonicalNodeOutput(): assistantText is authoritative and stdout
165
- // is only a fallback. Concatenating the two duplicate fields makes a single
166
- // canonical plan appear as two complete JSON contracts to the prewrite gate.
172
+ return record;
173
+ }
174
+ async function readNodeText(runDir, nodeId) {
175
+ const record = await readFinishedNodeRecord(runDir, nodeId);
167
176
  const text = record.assistantText?.trim() || record.stdout?.trim() || "";
168
177
  if (!text)
169
178
  throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
170
179
  return text;
171
180
  }
172
- async function readNodeRawText(runDir, nodeId) {
173
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
174
- return record.assistantText ?? record.stdout ?? "";
175
- }
176
- function resolveRunOwnedArtifact(runDir, candidate, label) {
177
- const root = path.resolve(runDir);
178
- const resolved = path.resolve(candidate);
179
- const relative = path.relative(root, resolved);
181
+ async function readCanonicalContractFromNode(runDir, nodeId) {
182
+ const record = await readFinishedNodeRecord(runDir, nodeId);
183
+ if (!record.structuredArtifactPath) {
184
+ throw new Error(`frontend prewrite gate missing structured artifact for ${nodeId}`);
185
+ }
186
+ const artifactPath = path.isAbsolute(record.structuredArtifactPath)
187
+ ? record.structuredArtifactPath
188
+ : path.join(runDir, record.structuredArtifactPath);
189
+ const resolvedRun = path.resolve(runDir);
190
+ const resolvedArtifact = path.resolve(artifactPath);
191
+ const relative = path.relative(resolvedRun, resolvedArtifact);
180
192
  if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
181
- throw new Error(`frontend prewrite gate ${label} must be contained by the current run directory`);
182
- }
183
- return resolved;
184
- }
185
- async function readHashBoundArtifact(input) {
186
- const resolved = resolveRunOwnedArtifact(input.runDir, input.artifactPath, input.label);
187
- const text = await readFile(resolved, "utf8");
188
- const actual = sha256Hex(text);
189
- if (actual !== input.expectedSha256) {
190
- throw new Error(`frontend prewrite gate ${input.label} SHA-256 mismatch (expected ${input.expectedSha256}, got ${actual})`);
191
- }
192
- return text;
193
- }
194
- async function readFrontendPlanNodeInput(runDir, nodeId) {
195
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
196
- if (record.status !== "FINISHED") {
197
- throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
193
+ throw new Error(`frontend prewrite gate structured artifact escapes runDir: ${record.structuredArtifactPath}`);
198
194
  }
199
- const frontend = record.frontendPlanOutput;
200
- if (!frontend) {
201
- const payloadText = record.assistantText?.trim() || record.stdout?.trim() || "";
202
- if (!payloadText)
203
- throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
204
- return {
205
- artifactFirst: false,
206
- payloadText,
207
- citationsText: payloadText,
208
- rawCandidateSha256: sha256Hex(record.assistantText ?? record.stdout ?? ""),
209
- payloadKind: "canonical-contract",
210
- };
195
+ const bytes = await readFile(resolvedArtifact);
196
+ const digest = createHash("sha256").update(bytes).digest("hex");
197
+ if (record.structuredArtifactSha256 &&
198
+ record.structuredArtifactSha256 !== digest) {
199
+ throw new Error(`frontend prewrite gate structured artifact hash mismatch for ${nodeId}`);
211
200
  }
212
- if (frontend.schemaVersion !== 1 ||
213
- !record.structuredArtifactPath ||
214
- !record.structuredArtifactSha256 ||
215
- !record.structuredArtifactSchemaId) {
216
- throw new Error(`frontend prewrite gate ${nodeId} has incomplete artifact-first metadata`);
201
+ if (record.structuredArtifactSchemaId &&
202
+ record.structuredArtifactSchemaId !== FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID) {
203
+ throw new Error(`frontend prewrite gate structured artifact schema mismatch for ${nodeId}: ${record.structuredArtifactSchemaId}`);
217
204
  }
218
- const expectedSchema = frontend.payloadKind === "canonical-contract"
219
- ? "frontend-implementation-contract-v1"
220
- : "frontend-implementation-contract-revision-patch-v1";
221
- if (record.structuredArtifactSchemaId !== expectedSchema) {
222
- throw new Error(`frontend prewrite gate ${nodeId} artifact schema ${record.structuredArtifactSchemaId} does not match ${frontend.payloadKind}`);
205
+ const contractText = bytes.toString("utf8");
206
+ let citationsText = "";
207
+ try {
208
+ const citationsRaw = await readFile(path.join(path.dirname(resolvedArtifact), "openspec-citations.json"), "utf8");
209
+ try {
210
+ const parsed = JSON.parse(citationsRaw);
211
+ citationsText =
212
+ typeof parsed.block === "string" ? parsed.block : citationsRaw;
213
+ }
214
+ catch {
215
+ citationsText = citationsRaw;
216
+ }
223
217
  }
224
- const [payloadText, citationsText] = await Promise.all([
225
- readHashBoundArtifact({
226
- runDir,
227
- artifactPath: record.structuredArtifactPath,
228
- expectedSha256: record.structuredArtifactSha256,
229
- label: `${nodeId} primary payload`,
230
- }),
231
- readHashBoundArtifact({
232
- runDir,
233
- artifactPath: frontend.citationsArtifactPath,
234
- expectedSha256: frontend.citationsArtifactSha256,
235
- label: `${nodeId} citations`,
236
- }),
237
- readHashBoundArtifact({
238
- runDir,
239
- artifactPath: frontend.rawCandidatePath,
240
- expectedSha256: frontend.rawCandidateSha256,
241
- label: `${nodeId} raw candidate`,
242
- }).then(() => undefined),
243
- ]);
244
- return {
245
- artifactFirst: true,
246
- payloadText,
247
- citationsText,
248
- rawCandidateSha256: frontend.rawCandidateSha256,
249
- payloadKind: frontend.payloadKind,
250
- };
251
- }
252
- /**
253
- * Same shape predicate as the internal `isContract` inside
254
- * `extractFrontendImplementationJson` (frontend-implementation-contract.ts):
255
- * schemaVersion 1 + a plain `targets` object + `requirements` and
256
- * `verificationTargets` arrays. A merge-patch delta does not carry these
257
- * fields, so this is the deterministic boundary between a full contract and a
258
- * patch. Keep the two predicates in sync.
259
- */
260
- function isFullContractShape(value) {
261
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
262
- return false;
218
+ catch (error) {
219
+ if (error.code !== "ENOENT")
220
+ throw error;
263
221
  }
264
- const record = value;
265
- const targets = record.targets;
266
- return (record.schemaVersion === 1 &&
267
- targets !== null &&
268
- typeof targets === "object" &&
269
- !Array.isArray(targets) &&
270
- Array.isArray(record.requirements) &&
271
- Array.isArray(record.verificationTargets));
272
- }
273
- /**
274
- * Shared merge pipeline for revisionPatch mode and content-driven
275
- * compatibility: read the original contract node, extract the original
276
- * contract and the effective node's patch, apply the RFC 7386 merge-patch, and
277
- * serialize deterministically. Throws on any failure; callers map the error to
278
- * their own classification (retryable-invalid for explicit revisionPatch mode,
279
- * blocked for content-driven fallback).
280
- */
281
- async function mergeRevisionPatch(runDir, planText, originalNodeId) {
282
- const originalText = await readNodeText(runDir, originalNodeId);
283
- const originalValue = extractFrontendImplementationJson(originalText);
284
- const patchValue = extractFrontendImplementationJson(planText);
285
- const merged = applyFrontendImplementationContractPatch(originalValue, patchValue);
286
- return serializeDeterministicJson(merged);
222
+ return { contractText, citationsText, rawSha256: digest };
287
223
  }
288
224
  function firstNonEmptyVerdictLine(text) {
289
225
  const lines = text
@@ -327,6 +263,7 @@ function isFailedToolResult(event) {
327
263
  }
328
264
  async function collectOpenspecReadPaths(input) {
329
265
  const { runDir, planNodeId, reviewNodeId, repoRoot } = input;
266
+ const candidateSet = input.candidatePaths ? new Set(input.candidatePaths) : undefined;
330
267
  const matched = new Set();
331
268
  for (const nodeId of [planNodeId, reviewNodeId]) {
332
269
  const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
@@ -361,7 +298,7 @@ async function collectOpenspecReadPaths(input) {
361
298
  : null;
362
299
  if (relativePath &&
363
300
  !isFailedToolResult(event) &&
364
- isOpenspecSpecFilePath(relativePath)) {
301
+ (candidateSet ? candidateSet.has(relativePath) : OPENSPEC_SPEC_EXT_RE.test(relativePath))) {
365
302
  matched.add(relativePath);
366
303
  }
367
304
  startedReads.delete(event.toolCallId);
@@ -376,21 +313,6 @@ async function collectOpenspecReadPaths(input) {
376
313
  }
377
314
  return [...matched];
378
315
  }
379
- async function checkOpenspecReadEvidence(input) {
380
- const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
381
- if (candidatePaths.length === 0)
382
- return [];
383
- const allReadPaths = await collectOpenspecReadPaths({
384
- runDir,
385
- planNodeId,
386
- reviewNodeId,
387
- repoRoot,
388
- });
389
- const normalizedCandidates = new Set(candidatePaths
390
- .map((candidate) => toRepoRelativePath(candidate, repoRoot))
391
- .filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
392
- return allReadPaths.filter((readPath) => normalizedCandidates.has(readPath));
393
- }
394
316
  function normalizeCitationPath(rawPath, repoRoot) {
395
317
  let candidate = rawPath.trim();
396
318
  const hashIndex = candidate.indexOf("#");
@@ -405,57 +327,45 @@ function normalizeCitationPath(rawPath, repoRoot) {
405
327
  }
406
328
  return candidate;
407
329
  }
408
- /**
409
- * Parses the deterministic openspec-citations fence from the effective plan
410
- * output. Each non-empty line must be one JSON object {"path","section","line"}.
411
- * Rows whose normalized path is not a supported openspec spec file are ignored
412
- * (out of scope); rows that are invalid JSON or lack a string path mark the
413
- * block unparseable (fail-closed when candidates are non-empty).
414
- */
415
- export function parseOpenspecCitations(planText, repoRoot) {
416
- const citations = [];
417
- let blockPresent = false;
418
- let unparseable = false;
419
- const fenceRe = /```openspec-citations[ \t]*\r?\n([\s\S]*?)\r?\n```/g;
420
- let match;
421
- while ((match = fenceRe.exec(planText)) !== null) {
422
- blockPresent = true;
423
- const body = match[1] ?? "";
424
- for (const rawLine of body.split(/\r?\n/)) {
425
- const line = rawLine.trim();
426
- if (!line)
427
- continue;
428
- let parsed;
429
- try {
430
- parsed = JSON.parse(line);
431
- }
432
- catch {
433
- unparseable = true;
434
- continue;
435
- }
436
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
437
- unparseable = true;
438
- continue;
439
- }
440
- const rawPath = parsed.path;
441
- if (typeof rawPath !== "string" || !rawPath.trim()) {
442
- unparseable = true;
443
- continue;
444
- }
445
- const normalized = normalizeCitationPath(rawPath, repoRoot);
446
- if (!normalized || !isOpenspecSpecFilePath(normalized))
447
- continue;
448
- const section = typeof parsed.section === "string"
449
- ? parsed.section
450
- : "";
451
- const lineValue = parsed.line;
452
- const citationLine = typeof lineValue === "number" && Number.isInteger(lineValue)
453
- ? lineValue
454
- : null;
455
- citations.push({ path: normalized, section, line: citationLine });
330
+ async function resolveRequiredOpenspecPaths(input) {
331
+ if (input.policy === "scan-strict")
332
+ return { ok: true, paths: input.candidatePaths };
333
+ // Legacy cited DAGs had no selector: preserve their full candidate must-read
334
+ // semantics instead of silently weakening an existing run.
335
+ if (!input.selectionNodeId)
336
+ return { ok: true, paths: input.candidatePaths };
337
+ let text;
338
+ try {
339
+ text = await readNodeText(input.runDir, input.selectionNodeId);
340
+ }
341
+ catch (error) {
342
+ return { ok: false, reason: `openspec selector output unreadable: ${error instanceof Error ? error.message : String(error)}` };
343
+ }
344
+ const fenced = /```json\s*\n([\s\S]*?)\n```/.exec(text);
345
+ if (!fenced)
346
+ return { ok: false, reason: "openspec selector must emit exactly one fenced JSON selection object" };
347
+ try {
348
+ const parsed = JSON.parse(fenced[1] ?? "");
349
+ if (parsed.schemaVersion !== 1 || parsed.schemaId !== "frontend-openspec-selection-v1" || !Array.isArray(parsed.selections))
350
+ throw new Error("schemaVersion/schemaId/selections invalid");
351
+ const byPath = new Map();
352
+ for (const row of parsed.selections) {
353
+ if (typeof row.path !== "string" || typeof row.disposition !== "string" || typeof row.rationale !== "string")
354
+ throw new Error("selection row invalid");
355
+ if (!input.candidatePaths.includes(row.path) || !["required", "relevant", "irrelevant"].includes(row.disposition))
356
+ throw new Error(`invalid selection ${row.path}`);
357
+ if (byPath.has(row.path))
358
+ throw new Error(`duplicate selection ${row.path}`);
359
+ byPath.set(row.path, row.disposition);
456
360
  }
361
+ const missing = input.candidatePaths.filter((candidate) => !byPath.has(candidate));
362
+ if (missing.length)
363
+ throw new Error(`missing classifications: ${missing.join(", ")}`);
364
+ return { ok: true, paths: [...new Set([...input.mandatoryPaths, ...input.candidatePaths.filter((candidate) => byPath.get(candidate) === "required")])].sort() };
365
+ }
366
+ catch (error) {
367
+ return { ok: false, reason: `openspec selector invalid: ${error instanceof Error ? error.message : String(error)}` };
457
368
  }
458
- return { blockPresent, unparseable, citations };
459
369
  }
460
370
  async function finalizePrewrite(input, pending) {
461
371
  const sourceBindingSha256 = input.sourceBinding
@@ -512,7 +422,30 @@ export async function runFrontendPrewriteGate(input) {
512
422
  // remains an ERROR and is intentionally not classified into prewrite-result.
513
423
  const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
514
424
  const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
515
- const candidateRawSha256 = sha256Hex(await readNodeRawText(input.runDir, planNodeId));
425
+ let planArtifact;
426
+ try {
427
+ planArtifact = await readCanonicalContractFromNode(input.runDir, planNodeId);
428
+ }
429
+ catch (error) {
430
+ return finalizePrewrite(input, {
431
+ planNodeId,
432
+ reviewNodeId,
433
+ verdict: "",
434
+ candidateRawSha256: sha256Hex(""),
435
+ candidateJsonSha256: null,
436
+ canonicalSha256: null,
437
+ normalizationActions: [],
438
+ mockStrategy: undefined,
439
+ artifact: undefined,
440
+ failureReason: error instanceof Error ? error.message : String(error),
441
+ failureCode: "plan-text-unreadable",
442
+ classification: "retryable-invalid",
443
+ openspecReadPaths: [],
444
+ openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
445
+ openspecPolicy: input.config.openspecPolicy ?? "scan-strict",
446
+ });
447
+ }
448
+ const candidateRawSha256 = planArtifact.rawSha256;
516
449
  const openspecPolicy = input.config.openspecPolicy ?? "scan-strict";
517
450
  const basePending = {
518
451
  planNodeId,
@@ -531,21 +464,6 @@ export async function runFrontendPrewriteGate(input) {
531
464
  openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
532
465
  openspecPolicy,
533
466
  };
534
- let planInput;
535
- try {
536
- planInput = await readFrontendPlanNodeInput(input.runDir, planNodeId);
537
- }
538
- catch (error) {
539
- return finalizePrewrite(input, {
540
- ...basePending,
541
- classification: "retryable-invalid",
542
- failureReason: error instanceof Error ? error.message : String(error),
543
- failureCode: "plan-text-unreadable",
544
- });
545
- }
546
- const planText = planInput.payloadText;
547
- const citationsText = planInput.citationsText;
548
- basePending.candidateRawSha256 = planInput.rawCandidateSha256;
549
467
  let reviewText;
550
468
  try {
551
469
  reviewText = await readNodeText(input.runDir, reviewNodeId);
@@ -568,90 +486,7 @@ export async function runFrontendPrewriteGate(input) {
568
486
  failureCode: "verdict-not-pass",
569
487
  });
570
488
  }
571
- // revisionPatch mode: the effective plan node emits an RFC 7386 merge-patch
572
- // delta against the original contract node (planFallbackFromNodeIds[0]).
573
- // Legacy DAGs without revisionPatch (undefined) keep full-contract semantics.
574
- const revisionPatchMode = input.config.revisionPatch === true &&
575
- planNodeId === input.config.planFromNodeId &&
576
- input.config.planFallbackFromNodeIds.length > 0;
577
- let effectiveContractText = null;
578
- if (revisionPatchMode) {
579
- const originalNodeId = input.config.planFallbackFromNodeIds[0];
580
- try {
581
- const originalInput = await readFrontendPlanNodeInput(input.runDir, originalNodeId);
582
- if (planInput.artifactFirst && !originalInput.artifactFirst) {
583
- throw new Error(`artifact-first revision ${planNodeId} requires artifact-first original contract ${originalNodeId}`);
584
- }
585
- if (planInput.artifactFirst) {
586
- if (planInput.payloadKind !== "merge-patch" ||
587
- originalInput.payloadKind !== "canonical-contract") {
588
- throw new Error("artifact-first revision/original payload kinds are invalid");
589
- }
590
- effectiveContractText = serializeDeterministicJson(applyFrontendImplementationContractPatch(JSON.parse(originalInput.payloadText), JSON.parse(planInput.payloadText)));
591
- }
592
- else {
593
- effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
594
- }
595
- }
596
- catch (error) {
597
- return finalizePrewrite(input, {
598
- ...basePending,
599
- verdict,
600
- classification: "retryable-invalid",
601
- failureReason: `frontend prewrite gate revisionPatch mode requires original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}`,
602
- failureCode: "contract-invalid",
603
- });
604
- }
605
- }
606
- else {
607
- // Content-driven compatibility (Bug 1): legacy DAGs without
608
- // revisionPatch can still receive a merge-patch delta from the revision
609
- // node. Detect the output shape instead of trusting the DAG flag alone.
610
- // - Full contract shape → keep legacy full-contract semantics unchanged.
611
- // - Patch shape with a usable original contract node → auto-merge and
612
- // run the existing analysis/materialization pipeline.
613
- // - Patch shape but unmergeable → blocked + revision-patch-unmergeable
614
- // (no retryable loop).
615
- let planJsonValue = null;
616
- try {
617
- planJsonValue = extractFrontendImplementationJson(planText);
618
- }
619
- catch {
620
- // Not machine-readable JSON: fall back to the legacy full-contract
621
- // path where analyzeFrontendImplementationContract surfaces the
622
- // original diagnostic (fail-closed without a new failure mode).
623
- }
624
- if (planJsonValue !== null && !isFullContractShape(planJsonValue)) {
625
- const originalNodeId = input.config.planFallbackFromNodeIds[0];
626
- if (!originalNodeId || originalNodeId === planNodeId) {
627
- return finalizePrewrite(input, {
628
- ...basePending,
629
- verdict,
630
- classification: "blocked",
631
- failureReason: "frontend prewrite gate: the effective plan output is a merge-patch delta but no distinct original contract node is available. Check the DAG revisionPatch/planFallbackFromNodeIds configuration or regenerate the DAG.",
632
- failureCode: "revision-patch-unmergeable",
633
- });
634
- }
635
- try {
636
- const originalText = await readNodeText(input.runDir, originalNodeId);
637
- const originalValue = extractFrontendImplementationJson(originalText);
638
- if (!isFullContractShape(originalValue)) {
639
- throw new Error(`original contract node ${originalNodeId} is not a full frontend implementation contract`);
640
- }
641
- effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
642
- }
643
- catch (error) {
644
- return finalizePrewrite(input, {
645
- ...basePending,
646
- verdict,
647
- classification: "blocked",
648
- failureReason: `frontend prewrite gate: the effective plan output is a merge-patch delta but it cannot be merged onto the original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}. Check the DAG revisionPatch/planFallbackFromNodeIds configuration or regenerate the DAG.`,
649
- failureCode: "revision-patch-unmergeable",
650
- });
651
- }
652
- }
653
- }
654
- const requirementIdSearchText = effectiveContractText ?? planText;
489
+ const requirementIdSearchText = planArtifact.contractText;
655
490
  const missingIds = input.config.requiredRequirementIds.filter((id) => !requirementIdSearchText.includes(id));
656
491
  if (missingIds.length > 0) {
657
492
  return finalizePrewrite(input, {
@@ -699,17 +534,13 @@ export async function runFrontendPrewriteGate(input) {
699
534
  workspaceRoot,
700
535
  });
701
536
  }
702
- // Candidate -> canonical conversion. Artifact-first inputs must keep using
703
- // the already hash-verified payload: re-reading the selected node record
704
- // here would silently fall back to its prose and defeat the integrity gate.
705
- // Historical records intentionally retain their node-text compatibility path.
537
+ // Candidate -> canonical conversion. The gate consumes the producing node's
538
+ // hash-bound canonical artifact and never re-merges plan/revision stdout.
706
539
  let analysis;
707
540
  try {
708
- const rawContractText = effectiveContractText ?? (planInput.artifactFirst ? planText : undefined);
709
541
  analysis = await analyzeFrontendImplementationContract({
710
542
  runDir: input.runDir,
711
- fromNodeId: rawContractText ? undefined : planNodeId,
712
- rawContractText,
543
+ rawContractText: planArtifact.contractText,
713
544
  sourceBinding: input.sourceBinding,
714
545
  });
715
546
  }
@@ -729,6 +560,21 @@ export async function runFrontendPrewriteGate(input) {
729
560
  }
730
561
  throw error;
731
562
  }
563
+ // Size guard: a canonical contract above 64KB is a retryable-invalid defect
564
+ // (compress the contract) rather than a writer authorization failure.
565
+ const contractSizeBytes = Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8");
566
+ if (contractSizeBytes > FRONTEND_CONTRACT_MAX_BYTES) {
567
+ return finalizePrewrite(input, {
568
+ ...basePending,
569
+ verdict,
570
+ candidateJsonSha256: analysis.candidateJsonSha256,
571
+ normalizationActions: [],
572
+ mockStrategy: analysis.canonical.mockApi.strategy,
573
+ classification: "retryable-invalid",
574
+ failureReason: `frontend prewrite gate: canonical contract serialization is ${contractSizeBytes} bytes, exceeding the ${FRONTEND_CONTRACT_MAX_BYTES}-byte limit. 压缩契约(移除冗余 prose、重复目标或过长描述)后重试。`,
575
+ failureCode: "contract-too-large",
576
+ });
577
+ }
732
578
  const mockStrategy = analysis.canonical.mockApi.strategy;
733
579
  if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
734
580
  const quotedAllowed = input.config.allowedMockStrategies
@@ -800,14 +646,31 @@ export async function runFrontendPrewriteGate(input) {
800
646
  // Effective plan/review must actually read the candidate openspec paths when
801
647
  // any exist. This fail-closes BEFORE the canonical contract is materialized
802
648
  // so a writer is never authorized on top of unread normative evidence.
803
- const candidatePaths = input.config.openspecCandidatePaths ?? [];
649
+ const frozenCandidatePaths = input.config.openspecCandidatePaths ?? [];
650
+ const selection = await resolveRequiredOpenspecPaths({
651
+ runDir: input.runDir,
652
+ selectionNodeId: input.config.openspecSelectionNodeId,
653
+ candidatePaths: frozenCandidatePaths,
654
+ mandatoryPaths: input.config.openspecMandatoryPaths ?? [],
655
+ policy: openspecPolicy,
656
+ });
657
+ if (!selection.ok) {
658
+ return finalizePrewrite(input, {
659
+ ...basePending, verdict, candidateJsonSha256: analysis.candidateJsonSha256,
660
+ normalizationActions: [], mockStrategy, classification: "retryable-invalid",
661
+ failureReason: `frontend prewrite gate: ${selection.reason}`,
662
+ failureCode: "openspec-selection-invalid",
663
+ openspecCandidatePaths: frozenCandidatePaths,
664
+ });
665
+ }
666
+ const candidatePaths = selection.paths;
804
667
  const candidateRoot = workspaceRoot ?? process.cwd();
805
668
  // Drift precheck: candidates are frozen at generation time. A candidate that
806
669
  // no longer exists on disk was deleted/renamed after generation and is a
807
670
  // generation-contract defect, not an "unread" failure.
808
671
  const missingCandidates = [];
809
- if (candidatePaths.length > 0) {
810
- for (const candidate of candidatePaths) {
672
+ if (frozenCandidatePaths.length > 0) {
673
+ for (const candidate of frozenCandidatePaths) {
811
674
  try {
812
675
  await stat(path.join(candidateRoot, candidate));
813
676
  }
@@ -830,99 +693,78 @@ export async function runFrontendPrewriteGate(input) {
830
693
  openspecCandidatePaths: candidatePaths,
831
694
  });
832
695
  }
833
- let openspecReadPaths;
834
- if (openspecPolicy === "cited") {
835
- openspecReadPaths = await collectOpenspecReadPaths({
836
- runDir: input.runDir,
837
- planNodeId,
838
- reviewNodeId,
839
- repoRoot: candidateRoot,
840
- });
841
- if (candidatePaths.length > 0) {
842
- const parsed = parseOpenspecCitations(citationsText, candidateRoot);
843
- if (!parsed.blockPresent || parsed.unparseable) {
844
- return finalizePrewrite(input, {
845
- ...basePending,
846
- verdict,
847
- candidateJsonSha256: analysis.candidateJsonSha256,
848
- normalizationActions: [],
849
- mockStrategy,
850
- classification: "retryable-invalid",
851
- failureReason: `frontend prewrite gate: the effective plan output must end with a parseable \`\`\`openspec-citations fenced block (one JSON {"path","section","line"} per line). ${parsed.blockPresent
852
- ? "The block is present but contains unparseable lines."
853
- : "No openspec-citations block was found."}`,
854
- failureCode: "openspec-citation-block-unparseable",
855
- openspecReadPaths,
856
- openspecCandidatePaths: candidatePaths,
857
- });
858
- }
859
- const citedPaths = new Set(parsed.citations.map((citation) => citation.path));
860
- const notCited = candidatePaths.filter((candidate) => !citedPaths.has(candidate));
861
- if (notCited.length > 0) {
862
- return finalizePrewrite(input, {
863
- ...basePending,
864
- verdict,
865
- candidateJsonSha256: analysis.candidateJsonSha256,
866
- normalizationActions: [],
867
- mockStrategy,
868
- classification: "retryable-invalid",
869
- failureReason: `frontend prewrite gate: these openspec candidate paths were not cited in the plan openspec-citations block: ${notCited.join(", ")}`,
870
- failureCode: "openspec-not-cited",
871
- openspecReadPaths,
872
- openspecCandidatePaths: candidatePaths,
873
- });
874
- }
875
- const readSet = new Set(openspecReadPaths);
876
- const uncorroborated = [
877
- ...new Set(parsed.citations
878
- .filter((citation) => !readSet.has(citation.path))
879
- .map((citation) => citation.path)),
880
- ];
881
- if (uncorroborated.length > 0) {
882
- return finalizePrewrite(input, {
883
- ...basePending,
884
- verdict,
885
- candidateJsonSha256: analysis.candidateJsonSha256,
886
- normalizationActions: [],
887
- mockStrategy,
888
- classification: "retryable-invalid",
889
- failureReason: `frontend prewrite gate: these openspec paths were cited in the plan but have no successful read event in the effective plan/review: ${uncorroborated.join(", ")}`,
890
- failureCode: "openspec-citation-not-read",
891
- openspecReadPaths,
892
- openspecCandidatePaths: candidatePaths,
893
- });
894
- }
696
+ const frozenSnapshots = input.config.openspecCandidateSnapshots;
697
+ if (frozenSnapshots) {
698
+ const snapshotByPath = new Map(frozenSnapshots.map((snapshot) => [snapshot.path, snapshot.sha256]));
699
+ const incompleteSnapshots = frozenCandidatePaths.filter((candidate) => !snapshotByPath.has(candidate));
700
+ const unexpectedSnapshots = frozenSnapshots
701
+ .map((snapshot) => snapshot.path)
702
+ .filter((candidate) => !frozenCandidatePaths.includes(candidate));
703
+ const changedCandidates = [];
704
+ for (const candidate of frozenCandidatePaths) {
705
+ const expected = snapshotByPath.get(candidate);
706
+ if (!expected)
707
+ continue;
708
+ const actual = createHash("sha256")
709
+ .update(await readFile(path.join(candidateRoot, candidate)))
710
+ .digest("hex");
711
+ if (actual !== expected)
712
+ changedCandidates.push(candidate);
713
+ }
714
+ if (incompleteSnapshots.length > 0 ||
715
+ unexpectedSnapshots.length > 0 ||
716
+ changedCandidates.length > 0) {
717
+ const details = [
718
+ ...(incompleteSnapshots.length > 0
719
+ ? [`missing snapshots: ${incompleteSnapshots.join(", ")}`]
720
+ : []),
721
+ ...(unexpectedSnapshots.length > 0
722
+ ? [`unexpected snapshots: ${unexpectedSnapshots.join(", ")}`]
723
+ : []),
724
+ ...(changedCandidates.length > 0
725
+ ? [`content changed: ${changedCandidates.join(", ")}`]
726
+ : []),
727
+ ].join("; ");
728
+ return finalizePrewrite(input, {
729
+ ...basePending,
730
+ verdict,
731
+ candidateJsonSha256: analysis.candidateJsonSha256,
732
+ normalizationActions: [],
733
+ mockStrategy,
734
+ classification: "blocked",
735
+ failureReason: `frontend prewrite gate blocked: the generation-frozen OpenSpec content snapshot no longer matches the workspace (${details}). Re-read the changed specification and regenerate the DAG.`,
736
+ failureCode: "candidate-content-drift",
737
+ openspecReadPaths: [],
738
+ openspecCandidatePaths: candidatePaths,
739
+ });
895
740
  }
896
- // cited with empty candidates: no read enforcement. The generation step
897
- // emits an advisory instead of failing the gate.
898
741
  }
899
- else {
900
- openspecReadPaths = await checkOpenspecReadEvidence({
901
- runDir: input.runDir,
902
- candidatePaths,
903
- planNodeId,
904
- reviewNodeId,
905
- repoRoot: candidateRoot,
906
- });
907
- if (candidatePaths.length > 0) {
908
- const readSet = new Set(openspecReadPaths);
909
- const unread = candidatePaths.filter((candidate) => !readSet.has(candidate));
910
- if (unread.length > 0) {
911
- const governanceRoot = await resolveOpenspecGovernanceRoot(candidateRoot);
912
- const excludedGovernanceCount = await countExcludedGovernanceSpecFiles(candidateRoot, governanceRoot);
913
- return finalizePrewrite(input, {
914
- ...basePending,
915
- verdict,
916
- candidateJsonSha256: analysis.candidateJsonSha256,
917
- normalizationActions: [],
918
- mockStrategy,
919
- classification: "retryable-invalid",
920
- failureReason: `frontend prewrite gate: effective plan/review did not read these openspec candidate paths: ${unread.join(", ")}. Read each candidate and report applicable rules plus hit path/section/line number, conflicts, and missing specifications, then rerun. 实际成功读取: ${openspecReadPaths.length > 0 ? openspecReadPaths.join(", ") : "(none)"}; 候选总数: ${candidatePaths.length}; 排除治理目录 ${governanceRoot} 下 ${excludedGovernanceCount} 个文件(运行期诊断口径); 候选集在生成期冻结,若候选与本任务无关属于生成期契约配置问题,应修正后重新生成 DAG。`,
921
- failureCode: "openspec-not-read",
922
- openspecReadPaths,
923
- openspecCandidatePaths: candidatePaths,
924
- });
925
- }
742
+ let openspecReadPaths;
743
+ openspecReadPaths = await collectOpenspecReadPaths({
744
+ runDir: input.runDir,
745
+ planNodeId,
746
+ reviewNodeId,
747
+ repoRoot: candidateRoot,
748
+ ...(candidatePaths.length > 0 ? { candidatePaths } : {}),
749
+ });
750
+ if (candidatePaths.length > 0) {
751
+ const readSet = new Set(openspecReadPaths);
752
+ const unread = candidatePaths.filter((candidate) => !readSet.has(candidate));
753
+ if (unread.length > 0) {
754
+ const governanceRoot = await resolveOpenspecGovernanceRoot(candidateRoot);
755
+ const excludedGovernanceCount = await countExcludedGovernanceSpecFiles(candidateRoot, governanceRoot);
756
+ return finalizePrewrite(input, {
757
+ ...basePending,
758
+ verdict,
759
+ candidateJsonSha256: analysis.candidateJsonSha256,
760
+ normalizationActions: [],
761
+ mockStrategy,
762
+ classification: "retryable-invalid",
763
+ failureReason: `frontend prewrite gate: effective plan/review did not read these openspec candidate paths: ${unread.join(", ")}. Read each candidate and report applicable rules plus hit path/section/line number, conflicts, and missing specifications, then rerun. 实际成功读取: ${openspecReadPaths.length > 0 ? openspecReadPaths.join(", ") : "(none)"}; 候选总数: ${candidatePaths.length}; 排除治理目录 ${governanceRoot} 下 ${excludedGovernanceCount} 个文件(运行期诊断口径); 候选集在生成期冻结,若候选与本任务无关属于生成期契约配置问题,应修正后重新生成 DAG。`,
764
+ failureCode: "openspec-not-read",
765
+ openspecReadPaths,
766
+ openspecCandidatePaths: candidatePaths,
767
+ });
926
768
  }
927
769
  }
928
770
  // Component selection spec conformance (ADR 0016). Deterministic cross-check
@@ -958,11 +800,37 @@ export async function runFrontendPrewriteGate(input) {
958
800
  : null,
959
801
  }))
960
802
  .filter((ref) => ref.raw.length > 0);
803
+ if (componentSpecCandidates.length > 0 && hasUiVisibleWork) {
804
+ const componentCandidateSet = new Set(componentSpecCandidates);
805
+ const hasBoundSpecifiedChoice = componentChoices.some((choice) => {
806
+ if (choice.decision !== "specified" || !choice.specReference?.path) {
807
+ return false;
808
+ }
809
+ const normalized = normalizeCitationPath(choice.specReference.path, candidateRoot);
810
+ return Boolean(normalized && componentCandidateSet.has(normalized));
811
+ });
812
+ if (!hasBoundSpecifiedChoice) {
813
+ return finalizePrewrite(input, {
814
+ ...basePending,
815
+ verdict,
816
+ candidateJsonSha256: analysis.candidateJsonSha256,
817
+ normalizationActions: [],
818
+ mockStrategy,
819
+ classification: "retryable-invalid",
820
+ failureReason: "frontend prewrite gate: UI-visible work has a generation-frozen component/theme specification bucket, but no uiComponentChoices decision=specified is bound to a path in that bucket. At least one declared UI purpose must use and cite the applicable component/theme specification; reuse-existing/new alone cannot bypass the bucket.",
821
+ failureCode: "component-spec-choice-unbound",
822
+ openspecReadPaths,
823
+ openspecCandidatePaths: candidatePaths,
824
+ });
825
+ }
826
+ }
961
827
  if (componentSpecReferences.length > 0) {
962
828
  const invalidRefs = [
963
829
  ...new Set(componentSpecReferences
964
830
  .filter((ref) => !ref.normalized ||
965
- !isOpenspecSpecFilePath(ref.normalized))
831
+ !(candidatePaths.length > 0
832
+ ? candidatePaths.includes(ref.normalized)
833
+ : isOpenspecSpecFilePath(ref.normalized)))
966
834
  .map((ref) => ref.raw)),
967
835
  ];
968
836
  if (invalidRefs.length > 0) {
@@ -980,13 +848,10 @@ export async function runFrontendPrewriteGate(input) {
980
848
  });
981
849
  }
982
850
  if (openspecPolicy === "cited") {
983
- const parsedCitations = parseOpenspecCitations(citationsText, candidateRoot);
984
- const citedPaths = new Set(parsedCitations.citations.map((citation) => citation.path));
985
851
  const readSet = new Set(openspecReadPaths);
986
852
  const uncorroborated = [
987
853
  ...new Set(componentSpecReferences
988
854
  .filter((ref) => !ref.normalized ||
989
- !citedPaths.has(ref.normalized) ||
990
855
  !readSet.has(ref.normalized))
991
856
  .map((ref) => ref.raw)),
992
857
  ];
@@ -998,7 +863,7 @@ export async function runFrontendPrewriteGate(input) {
998
863
  normalizationActions: [],
999
864
  mockStrategy,
1000
865
  classification: "retryable-invalid",
1001
- failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths were not cited in the plan openspec-citations block or lack a successful read event: ${uncorroborated.join(", ")}`,
866
+ failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths are missing a ledger specReference or lack a successful read event: ${uncorroborated.join(", ")}`,
1002
867
  failureCode: "component-spec-not-cited",
1003
868
  openspecReadPaths,
1004
869
  openspecCandidatePaths: candidatePaths,