@tea-agent/loop-agent 0.41.0-next.3 → 0.41.1-beta.0

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 (200) hide show
  1. package/CHANGELOG.md +264 -32
  2. package/dist/application/dag/generate-task-dag.js +75 -19
  3. package/dist/application/task-lifecycle/advance.js +24 -5
  4. package/dist/application/task-lifecycle/observe.js +171 -17
  5. package/dist/application/task-lifecycle/plan-transitions.js +42 -7
  6. package/dist/application/task-lifecycle/recommendations.js +13 -2
  7. package/dist/build-stamp.json +3 -3
  8. package/dist/cli/command-definitions.js +7 -0
  9. package/dist/cli/program.js +5 -0
  10. package/dist/commands/client-recovery.js +3 -0
  11. package/dist/commands/dag-follow-up.js +138 -0
  12. package/dist/commands/init.js +27 -1
  13. package/dist/commands/task-advance.js +19 -0
  14. package/dist/executors/dag-pi-executor.js +3784 -231
  15. package/dist/executors/pi-executor.js +15 -4
  16. package/dist/executors/pi-read-budget-policy.js +239 -0
  17. package/dist/executors/shell-executor.js +945 -217
  18. package/dist/executors/shell-write-guard.js +7 -0
  19. package/dist/shared/dag-failure-category.js +12 -0
  20. package/dist/shared/openspec-spec.js +70 -4
  21. package/dist/shared/operator/capabilities.js +7 -0
  22. package/dist/task/config-types.js +34 -7
  23. package/dist/task/contract/adopt.js +4 -0
  24. package/dist/task/contract/import-revision.js +4 -0
  25. package/dist/task/contract/project.js +3 -0
  26. package/dist/task/contract/schema.js +2 -1
  27. package/dist/task/frontend-project-capability.js +203 -20
  28. package/dist/task/runtime.js +5 -2
  29. package/dist/task/source-prepare/build-draft.js +3 -3
  30. package/dist/task/source-prepare/fragment-inventory.js +15 -9
  31. package/dist/task/source-prepare/prepare.js +89 -1
  32. package/dist/task/source-prepare/semantic-intake.js +6 -2
  33. package/dist/task/source-references.js +22 -1
  34. package/dist/worker/console/chat/pi-runtime.js +2 -3
  35. package/dist/worker/console/chat/routes.js +3 -12
  36. package/dist/worker/console/chat/semantic-activity.js +11 -12
  37. package/dist/worker/console/chat/session-stats.js +0 -96
  38. package/dist/worker/console/chat/turn-process.js +4 -28
  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 +169 -7
  44. package/dist/worker/console/pi-plugins.js +1 -3
  45. package/dist/worker/console/server.js +3 -0
  46. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DwVJxUxz.js → abnfDiagram-N423BO3Z-Bho4we4c.js} +1 -1
  47. package/dist/worker/console/static/assets/{arc-DqfCcXdu.js → arc-TBDkTeK0.js} +1 -1
  48. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-qMVtggRv.js → architectureDiagram-T3A2C74G-BF_fZrn2.js} +1 -1
  49. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-ECrV38dh.js → blockDiagram-VBNYF7ZC-CCnPgUe2.js} +1 -1
  50. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DBaaLqQv.js → c4Diagram-5PPSVZJV-BCYZJLAT.js} +1 -1
  51. package/dist/worker/console/static/assets/channel-bnXW0U3C.js +1 -0
  52. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BvfNHkAz.js → chunk-2GRJ4B5K-D8K6adhU.js} +1 -1
  53. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-C5StiIbc.js → chunk-2Q5K7J3B-DkT1Q78b.js} +1 -1
  54. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-HabGiJkS.js → chunk-5RXB4S5H-DuhvpIr7.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BiTg127i.js → chunk-5VM5RSS4-BnK6dC49.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-CZsXBPuR.js → chunk-6Q2QTUOP-DPz__z7r.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D-r1MxLK.js → chunk-GF5L2VYU-0kkppM2j.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-DQxb9plr.js → chunk-JWPE2WC7-BB41iK5t.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DMndGvCT.js → chunk-KBJHAD2P-CiVrxKHU.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-ByL4BgOJ.js → chunk-RYQCIY6F-C2h9Xje1.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BVe1b1gL.js → chunk-XXDRQBXY-3iOSHdbM.js} +1 -1
  62. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-rZpq_twR.js +1 -0
  63. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-rZpq_twR.js +1 -0
  64. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BAyGzN8j.js → cose-bilkent-JH36ORCC-BN3A7tkk.js} +1 -1
  65. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-C5yVYU7E.js → cynefin-VYW2F7L2-CTuBy0Or.js} +1 -1
  66. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-x-MwsEm9.js → cynefinDiagram-MW4NZA55-wb6tUlMN.js} +1 -1
  67. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CT5Tle5F.js → dagre-VZM6K2ZE-GP1Ivx8B.js} +1 -1
  68. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BARERhlS.js → diagram-7IWD3JNH-ejrcK6L0.js} +1 -1
  69. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BwfUz0VM.js → diagram-B4RE2ZJO-YFkCCvUU.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Mk4r2WvJ.js → diagram-LBJQPF4R-B4rrvIaL.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-KUrxuBs0.js → diagram-Q27KOJAE-C-YIZcUf.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CjZOceZn.js → diagram-UB23O5K3-BPF5wcL1.js} +1 -1
  73. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-rnwUCiEI.js → ebnfDiagram-BXEA7PRR-CU5SzXjO.js} +1 -1
  74. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DM4j23FM.js → erDiagram-JOGREHBK-CpL414DE.js} +1 -1
  75. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-DD5HdDEs.js → flowDiagram-UKHOOZJN-MrhLzNau.js} +1 -1
  76. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CIAGSF47.js → ganttDiagram-PKOTCBZU-Bg4qFuUo.js} +1 -1
  77. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DbwFbVEL.js → gitGraphDiagram-DS77QQ5N-ColxSXLR.js} +1 -1
  78. package/dist/worker/console/static/assets/index-D3CC4eUz.css +1 -0
  79. package/dist/worker/console/static/assets/index-DLt4ZFvD.js +437 -0
  80. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-B-Pb0T66.js → infoDiagram-6WML65LV-Czvtm6gc.js} +1 -1
  81. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-B9Unk-YX.js → ishikawaDiagram-WSZJBQD7-B7Xp8HyJ.js} +1 -1
  82. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-C2F3JkPt.js → journeyDiagram-NVQOT4AX-DWYZcvph.js} +1 -1
  83. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Bl8-Xcoz.js → kanban-definition-27J2QSJJ-KTLZRUjx.js} +1 -1
  84. package/dist/worker/console/static/assets/{linear-yK4uZmC5.js → linear-_Exn7bfl.js} +1 -1
  85. package/dist/worker/console/static/assets/{mermaid.core-Bpa6PmuE.js → mermaid.core-Cxg8kX8V.js} +5 -5
  86. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BfGRoCSe.js → mindmap-definition-FAOFIHXS-oKrlo2Bt.js} +1 -1
  87. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Bz6_HtST.js → pegDiagram-VL7TDLO6-XAeteiNd.js} +1 -1
  88. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-i0Umbsby.js → pieDiagram-7S7Q4E2Y-BTf1K-lp.js} +1 -1
  89. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DaVQBobe.js → quadrantDiagram-CIZ2JOQS-C0IlY0Jf.js} +1 -1
  90. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-DMTGnSq7.js → railroadDiagram-AXF67PYL-BVbKtZhW.js} +1 -1
  91. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-2ao_DaSq.js → requirementDiagram-LRYGKXZP-C5e4JFcr.js} +1 -1
  92. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-2SG1lTB9.js → sankeyDiagram-W5VNT64P-Cz30hc29.js} +1 -1
  93. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BQYOLybR.js → sequenceDiagram-SI44F4Z6-DS33n6d0.js} +1 -1
  94. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-C8g3yjuI.js → sizeCapture-X5ZJPWSS-46w7v-9T.js} +1 -1
  95. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-q_F8nAQG.js → stateDiagram-OKZ733FA-pbcD5SsZ.js} +1 -1
  96. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-zBMYiUyi.js +1 -0
  97. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-8LrWpH2s.js → swimlanes-SLNWSIFB-DuivYBSj.js} +2 -2
  98. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-d_rms-5i.js +8 -0
  99. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-BQoY3qzl.js → timeline-definition-Z64GVDOM-Cb9VZVIl.js} +1 -1
  100. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BDu1UjSp.js → vennDiagram-T6HMQDX7-D2-mfOgR.js} +1 -1
  101. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-C48M7Y0a.js → wardleyDiagram-T6FBY63Y-DeS2aC76.js} +1 -1
  102. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YU6FTl_F.js → xychartDiagram-ELKLHX3M-WGsrFjxH.js} +1 -1
  103. package/dist/worker/console/static/index.html +2 -2
  104. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  105. package/dist/worker/console/static-src/app/useRunProgress.js +2 -9
  106. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +0 -2
  107. package/dist/worker/console/static-src/operator-chat/useChatThread.js +0 -6
  108. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +3 -28
  109. package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +0 -106
  110. package/dist/worker/console/workspace-context.js +111 -1
  111. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  112. package/dist/worker/observability/read-model.js +0 -4
  113. package/dist/worker/observe/static/dag-node-purpose.js +5 -0
  114. package/dist/worker/observe/static/views/dag-inspector.js +7 -2
  115. package/dist/worker/observe/static/views/session-timeline.js +15 -46
  116. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  117. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  118. package/dist/workflows/dag/dag-retry-schema.js +138 -0
  119. package/dist/workflows/dag/frontend-closeout.js +221 -0
  120. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  121. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  122. package/dist/workflows/dag/frontend-implementation-contract.js +932 -162
  123. package/dist/workflows/dag/frontend-plan-render.js +2 -1
  124. package/dist/workflows/dag/frontend-prewrite-gate.js +255 -349
  125. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  126. package/dist/workflows/dag/frontend-read-budgets.js +12 -0
  127. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  128. package/dist/workflows/dag/frontend-recovery-controller.js +202 -0
  129. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  130. package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
  131. package/dist/workflows/dag/frontend-recovery-run.js +33 -20
  132. package/dist/workflows/dag/frontend-repair.js +1 -432
  133. package/dist/workflows/dag/frontend-review-context.js +261 -15
  134. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  135. package/dist/workflows/dag/frontend-shadow-dual-write.js +914 -0
  136. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  137. package/dist/workflows/dag/frontend-shape-facts.js +409 -0
  138. package/dist/workflows/dag/frontend-shape.js +427 -0
  139. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  140. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  141. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  142. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  143. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  144. package/dist/workflows/dag/frontend-verification-trace.js +249 -24
  145. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  146. package/dist/workflows/dag/frontend-writer-admission.js +285 -0
  147. package/dist/workflows/dag/frontend-writer-status.js +256 -0
  148. package/dist/workflows/dag/init-hybrid.js +685 -494
  149. package/dist/workflows/dag/interrupt-request.js +7 -0
  150. package/dist/workflows/dag/node-execution.js +732 -4
  151. package/dist/workflows/dag/prompt.js +34 -1
  152. package/dist/workflows/dag/recovery-lease.js +80 -0
  153. package/dist/workflows/dag/report.js +37 -1
  154. package/dist/workflows/dag/rerun-feedback.js +256 -1
  155. package/dist/workflows/dag/rerun-plan.js +10 -2
  156. package/dist/workflows/dag/rerun-run.js +23 -0
  157. package/dist/workflows/dag/rerun-task.js +29 -0
  158. package/dist/workflows/dag/retry-policy.js +214 -104
  159. package/dist/workflows/dag/runner.js +429 -122
  160. package/dist/workflows/dag/scheduler.js +114 -16
  161. package/dist/workflows/dag/types.js +239 -12
  162. package/dist/workflows/dag/validate.js +28 -12
  163. package/docs/init-surface.manifest.json +30 -12
  164. package/docs/skills/vetted-skill-registry.md +4 -2
  165. package/docs/templates/README.md +2 -0
  166. package/docs/templates/agent-dag-report.schema.json +8 -2
  167. package/docs/templates/agent-dag.schema.json +1 -1
  168. package/docs/templates/frontend-implementation-contract.schema.json +4 -1
  169. package/docs/templates/frontend-implementation-dag.json +89 -0
  170. package/docs/templates/spec-registry.schema.json +45 -0
  171. package/harness.json +3 -3
  172. package/package.json +1 -1
  173. package/skills/frontend-bounded-implement/SKILL.md +15 -14
  174. package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
  175. package/skills/frontend-contract/SKILL.md +23 -0
  176. package/skills/frontend-contract/references/contract-protocol.md +34 -0
  177. package/skills/frontend-design-review/SKILL.md +22 -41
  178. package/skills/frontend-plan/SKILL.md +26 -0
  179. package/skills/frontend-plan/references/decision-contract.md +37 -0
  180. package/skills/frontend-plan/references/design-decisions.md +17 -0
  181. package/skills/frontend-review/SKILL.md +20 -15
  182. package/skills/frontend-review/references/review-findings.md +6 -7
  183. package/skills/frontend-scout/SKILL.md +25 -0
  184. package/skills/frontend-scout/references/design-evidence.md +16 -0
  185. package/skills/frontend-scout/references/scout-evidence.md +23 -0
  186. package/skills/frontend-verification/SKILL.md +1 -1
  187. package/skills/loop-agent/references/command-reference.md +1 -0
  188. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  189. package/dist/worker/console/static/assets/channel-CL1NRAmS.js +0 -1
  190. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-PBGzzjZA.js +0 -1
  191. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-PBGzzjZA.js +0 -1
  192. package/dist/worker/console/static/assets/index-BfleX-qj.css +0 -1
  193. package/dist/worker/console/static/assets/index-Bih9w9h9.js +0 -451
  194. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrxGBDJu.js +0 -1
  195. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BhpDxzAf.js +0 -8
  196. package/dist/worker/console/static-src/operator-chat/dag-progress-link.js +0 -22
  197. package/skills/frontend-implementation/SKILL.md +0 -52
  198. package/skills/frontend-implementation/references/code-standards.md +0 -33
  199. package/skills/frontend-implementation/references/design-spec.md +0 -56
  200. package/skills/frontend-implementation/references/node-contracts.md +0 -31
@@ -2,21 +2,17 @@ import { createHash } from "node:crypto";
2
2
  import { readFile, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
- import { isDagSourceBindingV2 } from "./types.js";
6
- import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
5
+ import { OPENSPEC_SPEC_EXT_RE, isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
7
6
  import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
8
7
  import { countExcludedGovernanceSpecFiles, resolveOpenspecGovernanceRoot, } from "../../task/frontend-project-capability.js";
9
- import { resolveDagTaskSourcePath } from "../../task/dag-source-paths.js";
10
- import { parseLedgerJson, validateRequirementLedger, } from "../../task/source-prepare/ledger.js";
11
- import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
8
+ import { checkFrontendSourceFidelityLedger, } from "./frontend-source-fidelity-ledger.js";
9
+ import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
12
10
  import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
13
11
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
14
12
  import { pathMatchesPattern } from "../../shared/git-progress.js";
15
13
  import { normalizeVerdictCandidateLine } from "./dynamic-runtime/shared.js";
16
14
  export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
17
15
  export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
18
- /** Canonical contract serialization must stay at or below this byte size. */
19
- export const FRONTEND_CONTRACT_MAX_BYTES = 65536;
20
16
  /**
21
17
  * Machine-readable failure classification for blocked/retryable-invalid
22
18
  * prewrite outcomes. Kept alongside the human-readable failureReason so
@@ -37,13 +33,12 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
37
33
  "verification-target-outside-write-set",
38
34
  "openspec-not-read",
39
35
  "candidate-missing-drift",
40
- "openspec-citation-block-unparseable",
41
- "openspec-not-cited",
42
- "openspec-citation-not-read",
36
+ "candidate-content-drift",
37
+ "openspec-selection-invalid",
43
38
  "component-spec-reference-invalid",
44
39
  "component-spec-not-cited",
40
+ "component-spec-choice-unbound",
45
41
  "component-choices-missing",
46
- "contract-too-large",
47
42
  "revision-patch-unmergeable",
48
43
  // Source fidelity ledger 类目(SOURCE_FIDELITY_*/source-* 稳定错误码)。
49
44
  "source-fidelity-ledger-missing",
@@ -140,7 +135,7 @@ export const frontendPrewriteResultV1Schema = z
140
135
  export const frontendWriterAdmissionSchema = z
141
136
  .object({
142
137
  schemaVersion: z.literal(1),
143
- writerNodeId: z.enum(["frontend-implement-pi", "frontend-repair-pi"]),
138
+ writerNodeId: z.enum(["frontend-implement-pi"]),
144
139
  decision: z.enum(["authorized", "denied"]),
145
140
  sourceArtifact: z.string().min(1),
146
141
  artifactHash: z.string().regex(/^[a-f0-9]{64}$/),
@@ -274,6 +269,7 @@ function isFailedToolResult(event) {
274
269
  }
275
270
  async function collectOpenspecReadPaths(input) {
276
271
  const { runDir, planNodeId, reviewNodeId, repoRoot } = input;
272
+ const candidateSet = input.candidatePaths ? new Set(input.candidatePaths) : undefined;
277
273
  const matched = new Set();
278
274
  for (const nodeId of [planNodeId, reviewNodeId]) {
279
275
  const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
@@ -308,7 +304,7 @@ async function collectOpenspecReadPaths(input) {
308
304
  : null;
309
305
  if (relativePath &&
310
306
  !isFailedToolResult(event) &&
311
- isOpenspecSpecFilePath(relativePath)) {
307
+ (candidateSet ? candidateSet.has(relativePath) : OPENSPEC_SPEC_EXT_RE.test(relativePath))) {
312
308
  matched.add(relativePath);
313
309
  }
314
310
  startedReads.delete(event.toolCallId);
@@ -323,21 +319,6 @@ async function collectOpenspecReadPaths(input) {
323
319
  }
324
320
  return [...matched];
325
321
  }
326
- async function checkOpenspecReadEvidence(input) {
327
- const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
328
- if (candidatePaths.length === 0)
329
- return [];
330
- const allReadPaths = await collectOpenspecReadPaths({
331
- runDir,
332
- planNodeId,
333
- reviewNodeId,
334
- repoRoot,
335
- });
336
- const normalizedCandidates = new Set(candidatePaths
337
- .map((candidate) => toRepoRelativePath(candidate, repoRoot))
338
- .filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
339
- return allReadPaths.filter((readPath) => normalizedCandidates.has(readPath));
340
- }
341
322
  function normalizeCitationPath(rawPath, repoRoot) {
342
323
  let candidate = rawPath.trim();
343
324
  const hashIndex = candidate.indexOf("#");
@@ -352,57 +333,132 @@ function normalizeCitationPath(rawPath, repoRoot) {
352
333
  }
353
334
  return candidate;
354
335
  }
355
- /**
356
- * Parses the deterministic openspec-citations fence from the effective plan
357
- * output. Each non-empty line must be one JSON object {"path","section","line"}.
358
- * Rows whose normalized path is not a supported openspec spec file are ignored
359
- * (out of scope); rows that are invalid JSON or lack a string path mark the
360
- * block unparseable (fail-closed when candidates are non-empty).
361
- */
362
- export function parseOpenspecCitations(planText, repoRoot) {
363
- const citations = [];
364
- let blockPresent = false;
365
- let unparseable = false;
366
- const fenceRe = /```openspec-citations[ \t]*\r?\n([\s\S]*?)\r?\n```/g;
367
- let match;
368
- while ((match = fenceRe.exec(planText)) !== null) {
369
- blockPresent = true;
370
- const body = match[1] ?? "";
371
- for (const rawLine of body.split(/\r?\n/)) {
372
- const line = rawLine.trim();
373
- if (!line)
374
- continue;
375
- let parsed;
376
- try {
377
- parsed = JSON.parse(line);
378
- }
379
- catch {
380
- unparseable = true;
381
- continue;
382
- }
383
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
384
- unparseable = true;
385
- continue;
336
+ async function resolveRequiredOpenspecPaths(input) {
337
+ if (input.policy === "scan-strict")
338
+ return { ok: true, paths: input.candidatePaths };
339
+ // Legacy cited DAGs had no selector: preserve their full candidate must-read
340
+ // semantics instead of silently weakening an existing run.
341
+ if (!input.selectionNodeId)
342
+ return { ok: true, paths: input.candidatePaths };
343
+ // OpenSpec classifications arrive as individual typed `openspec-selection`
344
+ // facts (one path per record_openspec_selection call) so a large candidate
345
+ // set never exceeds a single model output budget. Only positive
346
+ // classifications (required | relevant) are legal facts; candidates that
347
+ // were never mentioned default to irrelevant. Explicit task declarations /
348
+ // source citations (`mandatoryPaths`) are never downgraded: they are
349
+ // always required and always must-read, whether or not the model declared
350
+ // them. Fall back to the legacy single fenced JSON for older runs (which
351
+ // may still carry explicit irrelevant rows).
352
+ let selections;
353
+ let allowIrrelevant = false;
354
+ const selectionNodeId = input.selectionNodeId; // guarded above: no selector -> full candidate semantics
355
+ try {
356
+ const facts = await readCommittedContractFacts(input.runDir, selectionNodeId);
357
+ selections = facts
358
+ .filter((fact) => fact.kind === "openspec-selection")
359
+ .map((fact) => ({
360
+ path: String(fact.path ?? ""),
361
+ disposition: String(fact.disposition ?? ""),
362
+ rationale: String(fact.rationale ?? ""),
363
+ }));
364
+ }
365
+ catch {
366
+ selections = [];
367
+ }
368
+ if (selections.length === 0) {
369
+ const legacy = await readLegacyOpenspecSelection({
370
+ runDir: input.runDir,
371
+ selectionNodeId,
372
+ candidatePaths: input.candidatePaths,
373
+ mandatoryPaths: input.mandatoryPaths,
374
+ });
375
+ if (!legacy.ok)
376
+ return legacy;
377
+ selections = legacy.selections;
378
+ allowIrrelevant = true;
379
+ }
380
+ try {
381
+ const byPath = new Map();
382
+ for (const row of selections) {
383
+ const legalDispositions = allowIrrelevant
384
+ ? ["required", "relevant", "irrelevant"]
385
+ : ["required", "relevant"];
386
+ if (typeof row.path !== "string" ||
387
+ !legalDispositions.includes(row.disposition) ||
388
+ typeof row.rationale !== "string" ||
389
+ !row.rationale.trim()) {
390
+ throw new Error(`invalid selection row (path+${legalDispositions.join("|")}+non-empty rationale expected): ${JSON.stringify(row)}`);
386
391
  }
387
- const rawPath = parsed.path;
388
- if (typeof rawPath !== "string" || !rawPath.trim()) {
389
- unparseable = true;
390
- continue;
392
+ if (!input.candidatePaths.includes(row.path)) {
393
+ throw new Error(`invalid selection ${row.path}: not a candidate path`);
391
394
  }
392
- const normalized = normalizeCitationPath(rawPath, repoRoot);
393
- if (!normalized || !isOpenspecSpecFilePath(normalized))
394
- continue;
395
- const section = typeof parsed.section === "string"
396
- ? parsed.section
397
- : "";
398
- const lineValue = parsed.line;
399
- const citationLine = typeof lineValue === "number" && Number.isInteger(lineValue)
400
- ? lineValue
401
- : null;
402
- citations.push({ path: normalized, section, line: citationLine });
395
+ if (byPath.has(row.path))
396
+ throw new Error(`duplicate selection ${row.path}`);
397
+ byPath.set(row.path, row.disposition);
403
398
  }
399
+ // Only explicitly required/relevant candidates must be read; everything
400
+ // unmentioned defaults to irrelevant. mandatoryPaths are intrinsic:
401
+ // they must be read even if the model omitted them, and they can never
402
+ // be downgraded by a declaration.
403
+ const mustRead = [
404
+ ...input.mandatoryPaths,
405
+ ...input.candidatePaths.filter((candidate) => byPath.get(candidate) === "required"),
406
+ ];
407
+ return { ok: true, paths: [...new Set(mustRead)].sort() };
408
+ }
409
+ catch (error) {
410
+ return {
411
+ ok: false,
412
+ reason: `openspec selector invalid: ${error instanceof Error ? error.message : String(error)}`,
413
+ };
414
+ }
415
+ }
416
+ async function readCommittedContractFacts(runDir, nodeId) {
417
+ const { readTypedEventStoreFromJsonl } = await import("./frontend-typed-event-store.js");
418
+ const records = await readTypedEventStoreFromJsonl(path.join(runDir, nodeId, "contract-typed-facts.jsonl"));
419
+ return records
420
+ .filter((record) => record.phase === "committed")
421
+ .map((record) => record.fact)
422
+ .filter((fact) => Boolean(fact));
423
+ }
424
+ async function readLegacyOpenspecSelection(input) {
425
+ let text;
426
+ try {
427
+ text = await readNodeText(input.runDir, input.selectionNodeId);
428
+ }
429
+ catch (error) {
430
+ return {
431
+ ok: false,
432
+ reason: `openspec selector output unreadable: ${error instanceof Error ? error.message : String(error)}`,
433
+ };
434
+ }
435
+ const fenced = /```json\s*\n([\s\S]*?)\n```/.exec(text);
436
+ if (!fenced) {
437
+ return {
438
+ ok: false,
439
+ reason: "openspec selector emitted neither typed openspec-selection facts nor a legacy fenced JSON selection object",
440
+ };
441
+ }
442
+ try {
443
+ const parsed = JSON.parse(fenced[1] ?? "");
444
+ if (parsed.schemaVersion !== 1 ||
445
+ parsed.schemaId !== "frontend-openspec-selection-v1" ||
446
+ !Array.isArray(parsed.selections)) {
447
+ throw new Error("schemaVersion/schemaId/selections invalid");
448
+ }
449
+ const rows = parsed.selections.map((row) => ({
450
+ path: String(row.path ?? ""),
451
+ disposition: String(row.disposition ?? ""),
452
+ rationale: String(row.rationale ?? ""),
453
+ }));
454
+ return { ok: true, selections: rows };
455
+ }
456
+ catch (error) {
457
+ return {
458
+ ok: false,
459
+ reason: `openspec selector invalid: ${error instanceof Error ? error.message : String(error)}`,
460
+ };
404
461
  }
405
- return { blockPresent, unparseable, citations };
406
462
  }
407
463
  async function finalizePrewrite(input, pending) {
408
464
  const sourceBindingSha256 = input.sourceBinding
@@ -453,162 +509,7 @@ async function finalizePrewrite(input, pending) {
453
509
  openspecPolicy: pending.openspecPolicy,
454
510
  };
455
511
  }
456
- /**
457
- * Source fidelity ledger 门禁检查(AC-FIDELITY-002/003/006):绑定携带 ledger 指针时,
458
- * 依次核验 ledger 存在、绑定 hash 一致、inputDigest 新鲜、无 unresolved/conflict/
459
- * 高风险未覆盖、canonical requirement 全覆盖、来源引用有效且非伪造。任一失败 fail closed。
460
- */
461
- export async function checkFrontendSourceFidelityLedger(input) {
462
- const { binding } = input;
463
- // v1 绑定不携带 ledger 指针:非 frontend task / 无可解析引用 → 无 v2 校验路径。
464
- if (!isDagSourceBindingV2(binding)) {
465
- return { ok: true, failureCode: null, failureReason: null, ledger: null };
466
- }
467
- const absolute = resolveDagTaskSourcePath({
468
- workspaceRoot: input.workspaceRoot,
469
- taskId: binding.taskId,
470
- sourcePath: binding.ledgerPath,
471
- });
472
- let raw;
473
- try {
474
- raw = await readFile(absolute, "utf8");
475
- }
476
- catch (error) {
477
- return {
478
- ok: false,
479
- failureCode: "source-fidelity-ledger-missing",
480
- failureReason: `frontend source fidelity ledger missing: ${binding.ledgerPath} (${error instanceof Error ? error.message : String(error)})`,
481
- ledger: null,
482
- };
483
- }
484
- const actualSha = createHash("sha256").update(raw).digest("hex");
485
- if (actualSha !== binding.ledgerSha256) {
486
- return {
487
- ok: false,
488
- failureCode: "source-fidelity-ledger-stale",
489
- failureReason: `frontend source fidelity ledger stale or tampered: ${binding.ledgerPath} (bound ${binding.ledgerSha256}, observed ${actualSha})`,
490
- ledger: null,
491
- };
492
- }
493
- let ledger;
494
- try {
495
- ledger = parseLedgerJson(raw);
496
- }
497
- catch (error) {
498
- return {
499
- ok: false,
500
- failureCode: "source-fidelity-ledger-stale",
501
- failureReason: `frontend source fidelity ledger is not a valid v1 ledger: ${error instanceof Error ? error.message : String(error)}`,
502
- ledger: null,
503
- };
504
- }
505
- if (ledger.inputDigest !== binding.inputDigest) {
506
- return {
507
- ok: false,
508
- failureCode: "source-fidelity-ledger-stale",
509
- failureReason: `frontend source fidelity ledger inputDigest mismatch: bound ${binding.inputDigest}, ledger ${ledger.inputDigest}`,
510
- ledger,
511
- };
512
- }
513
- const validation = validateRequirementLedger(ledger);
514
- const firstError = (code) => validation.find((error) => error.code === code)?.message ?? null;
515
- if (ledger.stats.unresolvedCount > 0 || validation.some((e) => e.code === "SOURCE_FIDELITY_UNRESOLVED")) {
516
- return {
517
- ok: false,
518
- failureCode: "source-fidelity-unresolved",
519
- failureReason: firstError("SOURCE_FIDELITY_UNRESOLVED") ??
520
- `frontend source fidelity ledger has ${ledger.stats.unresolvedCount} unresolved fragment(s)`,
521
- ledger,
522
- };
523
- }
524
- if (ledger.stats.conflictCount > 0 || validation.some((e) => e.code === "SOURCE_FIDELITY_CONFLICT")) {
525
- return {
526
- ok: false,
527
- failureCode: "source-fidelity-conflict",
528
- failureReason: firstError("SOURCE_FIDELITY_CONFLICT") ??
529
- `frontend source fidelity ledger has ${ledger.stats.conflictCount} conflict(s)`,
530
- ledger,
531
- };
532
- }
533
- if (ledger.stats.highRiskUncoveredCount > 0 ||
534
- validation.some((e) => e.code === "SOURCE_FIDELITY_HIGH_RISK_UNCOVERED")) {
535
- return {
536
- ok: false,
537
- failureCode: "source-fidelity-high-risk-uncovered",
538
- failureReason: firstError("SOURCE_FIDELITY_HIGH_RISK_UNCOVERED") ??
539
- `frontend source fidelity ledger has ${ledger.stats.highRiskUncoveredCount} high-risk uncovered fragment(s)`,
540
- ledger,
541
- };
542
- }
543
- // AC-BLOCK-001:canonical requirement 缺少 fragment/sourceRef 证据(ledger 侧
544
- // REQUIREMENT_NOT_COVERED)与来源引用/片段无效(SOURCE_REF_INVALID /
545
- // FRAGMENT_INVALID)均为阻断性失败,结构化失败结果不得产生可继续执行的成功。
546
- const notCoveredError = validation.find((e) => e.code === "SOURCE_FIDELITY_REQUIREMENT_NOT_COVERED");
547
- if (notCoveredError) {
548
- return {
549
- ok: false,
550
- failureCode: "source-fidelity-requirement-not-covered",
551
- failureReason: notCoveredError.message,
552
- ledger,
553
- };
554
- }
555
- const refInvalidError = validation.find((e) => e.code === "SOURCE_FIDELITY_SOURCE_REF_INVALID" ||
556
- e.code === "SOURCE_FIDELITY_FRAGMENT_INVALID");
557
- if (refInvalidError) {
558
- return {
559
- ok: false,
560
- failureCode: "source-fidelity-source-ref-invalid",
561
- failureReason: refInvalidError.message,
562
- ledger,
563
- };
564
- }
565
- const ledgerIds = new Set(ledger.canonicalRequirements.map((requirement) => requirement.id));
566
- const contractIds = new Set(input.contractRequirements.map((r) => r.id));
567
- const uncoveredRequirements = [...ledgerIds].filter((id) => !contractIds.has(id));
568
- if (uncoveredRequirements.length > 0) {
569
- return {
570
- ok: false,
571
- failureCode: "source-fidelity-requirement-not-covered",
572
- failureReason: `frontend contract requirements do not cover ledger canonical requirement ids: ${uncoveredRequirements.join(", ")}`,
573
- ledger,
574
- };
575
- }
576
- const fragmentById = new Map(ledger.fragments.map((fragment) => [fragment.id, fragment]));
577
- for (const requirement of input.contractRequirements) {
578
- if (!ledgerIds.has(requirement.id))
579
- continue;
580
- const declared = requirement.sourceFragmentIds ?? [];
581
- if (declared.length === 0) {
582
- return {
583
- ok: false,
584
- failureCode: "source-fidelity-source-ref-invalid",
585
- failureReason: `frontend contract requirement ${requirement.id} lacks sourceFragmentIds for the bound ledger`,
586
- ledger,
587
- };
588
- }
589
- const boundFragments = new Set(ledger.canonicalRequirements.find((r) => r.id === requirement.id)
590
- ?.sourceFragmentIds ?? []);
591
- for (const fragmentId of declared) {
592
- if (!fragmentById.has(fragmentId)) {
593
- return {
594
- ok: false,
595
- failureCode: "source-fidelity-source-ref-invalid",
596
- failureReason: `frontend contract requirement ${requirement.id} references unknown fragment ${fragmentId}`,
597
- ledger,
598
- };
599
- }
600
- if (!boundFragments.has(fragmentId)) {
601
- return {
602
- ok: false,
603
- failureCode: "source-fidelity-source-ref-invalid",
604
- failureReason: `frontend contract requirement ${requirement.id} declares fabricated fragment reference ${fragmentId} (not bound in ledger)`,
605
- ledger,
606
- };
607
- }
608
- }
609
- }
610
- return { ok: true, failureCode: null, failureReason: null, ledger };
611
- }
512
+ export { checkFrontendSourceFidelityLedger } from "./frontend-source-fidelity-ledger.js";
612
513
  export async function runFrontendPrewriteGate(input) {
613
514
  const workspaceRoot = input.workspaceRoot ?? input.repoRoot;
614
515
  // selectNode throws for a scheduling defect (no FINISHED plan/review). That
@@ -657,9 +558,6 @@ export async function runFrontendPrewriteGate(input) {
657
558
  openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
658
559
  openspecPolicy,
659
560
  };
660
- const planText = [planArtifact.contractText, planArtifact.citationsText]
661
- .filter(Boolean)
662
- .join("\n");
663
561
  let reviewText;
664
562
  try {
665
563
  reviewText = await readNodeText(input.runDir, reviewNodeId);
@@ -783,21 +681,6 @@ export async function runFrontendPrewriteGate(input) {
783
681
  });
784
682
  }
785
683
  }
786
- // Size guard: a canonical contract above 64KB is a retryable-invalid defect
787
- // (compress the contract) rather than a writer authorization failure.
788
- const contractSizeBytes = Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8");
789
- if (contractSizeBytes > FRONTEND_CONTRACT_MAX_BYTES) {
790
- return finalizePrewrite(input, {
791
- ...basePending,
792
- verdict,
793
- candidateJsonSha256: analysis.candidateJsonSha256,
794
- normalizationActions: [],
795
- mockStrategy: analysis.canonical.mockApi.strategy,
796
- classification: "retryable-invalid",
797
- failureReason: `frontend prewrite gate: canonical contract serialization is ${contractSizeBytes} bytes, exceeding the ${FRONTEND_CONTRACT_MAX_BYTES}-byte limit. 压缩契约(移除冗余 prose、重复目标或过长描述)后重试。`,
798
- failureCode: "contract-too-large",
799
- });
800
- }
801
684
  const mockStrategy = analysis.canonical.mockApi.strategy;
802
685
  if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
803
686
  const quotedAllowed = input.config.allowedMockStrategies
@@ -869,14 +752,31 @@ export async function runFrontendPrewriteGate(input) {
869
752
  // Effective plan/review must actually read the candidate openspec paths when
870
753
  // any exist. This fail-closes BEFORE the canonical contract is materialized
871
754
  // so a writer is never authorized on top of unread normative evidence.
872
- const candidatePaths = input.config.openspecCandidatePaths ?? [];
755
+ const frozenCandidatePaths = input.config.openspecCandidatePaths ?? [];
756
+ const selection = await resolveRequiredOpenspecPaths({
757
+ runDir: input.runDir,
758
+ selectionNodeId: input.config.openspecSelectionNodeId,
759
+ candidatePaths: frozenCandidatePaths,
760
+ mandatoryPaths: input.config.openspecMandatoryPaths ?? [],
761
+ policy: openspecPolicy,
762
+ });
763
+ if (!selection.ok) {
764
+ return finalizePrewrite(input, {
765
+ ...basePending, verdict, candidateJsonSha256: analysis.candidateJsonSha256,
766
+ normalizationActions: [], mockStrategy, classification: "retryable-invalid",
767
+ failureReason: `frontend prewrite gate: ${selection.reason}`,
768
+ failureCode: "openspec-selection-invalid",
769
+ openspecCandidatePaths: frozenCandidatePaths,
770
+ });
771
+ }
772
+ const candidatePaths = selection.paths;
873
773
  const candidateRoot = workspaceRoot ?? process.cwd();
874
774
  // Drift precheck: candidates are frozen at generation time. A candidate that
875
775
  // no longer exists on disk was deleted/renamed after generation and is a
876
776
  // generation-contract defect, not an "unread" failure.
877
777
  const missingCandidates = [];
878
- if (candidatePaths.length > 0) {
879
- for (const candidate of candidatePaths) {
778
+ if (frozenCandidatePaths.length > 0) {
779
+ for (const candidate of frozenCandidatePaths) {
880
780
  try {
881
781
  await stat(path.join(candidateRoot, candidate));
882
782
  }
@@ -899,99 +799,78 @@ export async function runFrontendPrewriteGate(input) {
899
799
  openspecCandidatePaths: candidatePaths,
900
800
  });
901
801
  }
902
- let openspecReadPaths;
903
- if (openspecPolicy === "cited") {
904
- openspecReadPaths = await collectOpenspecReadPaths({
905
- runDir: input.runDir,
906
- planNodeId,
907
- reviewNodeId,
908
- repoRoot: candidateRoot,
909
- });
910
- if (candidatePaths.length > 0) {
911
- const parsed = parseOpenspecCitations(planText, candidateRoot);
912
- if (!parsed.blockPresent || parsed.unparseable) {
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: the effective plan output must end with a parseable \`\`\`openspec-citations fenced block (one JSON {"path","section","line"} per line). ${parsed.blockPresent
921
- ? "The block is present but contains unparseable lines."
922
- : "No openspec-citations block was found."}`,
923
- failureCode: "openspec-citation-block-unparseable",
924
- openspecReadPaths,
925
- openspecCandidatePaths: candidatePaths,
926
- });
927
- }
928
- const citedPaths = new Set(parsed.citations.map((citation) => citation.path));
929
- const notCited = candidatePaths.filter((candidate) => !citedPaths.has(candidate));
930
- if (notCited.length > 0) {
931
- return finalizePrewrite(input, {
932
- ...basePending,
933
- verdict,
934
- candidateJsonSha256: analysis.candidateJsonSha256,
935
- normalizationActions: [],
936
- mockStrategy,
937
- classification: "retryable-invalid",
938
- failureReason: `frontend prewrite gate: these openspec candidate paths were not cited in the plan openspec-citations block: ${notCited.join(", ")}`,
939
- failureCode: "openspec-not-cited",
940
- openspecReadPaths,
941
- openspecCandidatePaths: candidatePaths,
942
- });
943
- }
944
- const readSet = new Set(openspecReadPaths);
945
- const uncorroborated = [
946
- ...new Set(parsed.citations
947
- .filter((citation) => !readSet.has(citation.path))
948
- .map((citation) => citation.path)),
949
- ];
950
- if (uncorroborated.length > 0) {
951
- return finalizePrewrite(input, {
952
- ...basePending,
953
- verdict,
954
- candidateJsonSha256: analysis.candidateJsonSha256,
955
- normalizationActions: [],
956
- mockStrategy,
957
- classification: "retryable-invalid",
958
- 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(", ")}`,
959
- failureCode: "openspec-citation-not-read",
960
- openspecReadPaths,
961
- openspecCandidatePaths: candidatePaths,
962
- });
963
- }
802
+ const frozenSnapshots = input.config.openspecCandidateSnapshots;
803
+ if (frozenSnapshots) {
804
+ const snapshotByPath = new Map(frozenSnapshots.map((snapshot) => [snapshot.path, snapshot.sha256]));
805
+ const incompleteSnapshots = frozenCandidatePaths.filter((candidate) => !snapshotByPath.has(candidate));
806
+ const unexpectedSnapshots = frozenSnapshots
807
+ .map((snapshot) => snapshot.path)
808
+ .filter((candidate) => !frozenCandidatePaths.includes(candidate));
809
+ const changedCandidates = [];
810
+ for (const candidate of frozenCandidatePaths) {
811
+ const expected = snapshotByPath.get(candidate);
812
+ if (!expected)
813
+ continue;
814
+ const actual = createHash("sha256")
815
+ .update(await readFile(path.join(candidateRoot, candidate)))
816
+ .digest("hex");
817
+ if (actual !== expected)
818
+ changedCandidates.push(candidate);
819
+ }
820
+ if (incompleteSnapshots.length > 0 ||
821
+ unexpectedSnapshots.length > 0 ||
822
+ changedCandidates.length > 0) {
823
+ const details = [
824
+ ...(incompleteSnapshots.length > 0
825
+ ? [`missing snapshots: ${incompleteSnapshots.join(", ")}`]
826
+ : []),
827
+ ...(unexpectedSnapshots.length > 0
828
+ ? [`unexpected snapshots: ${unexpectedSnapshots.join(", ")}`]
829
+ : []),
830
+ ...(changedCandidates.length > 0
831
+ ? [`content changed: ${changedCandidates.join(", ")}`]
832
+ : []),
833
+ ].join("; ");
834
+ return finalizePrewrite(input, {
835
+ ...basePending,
836
+ verdict,
837
+ candidateJsonSha256: analysis.candidateJsonSha256,
838
+ normalizationActions: [],
839
+ mockStrategy,
840
+ classification: "blocked",
841
+ 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.`,
842
+ failureCode: "candidate-content-drift",
843
+ openspecReadPaths: [],
844
+ openspecCandidatePaths: candidatePaths,
845
+ });
964
846
  }
965
- // cited with empty candidates: no read enforcement. The generation step
966
- // emits an advisory instead of failing the gate.
967
847
  }
968
- else {
969
- openspecReadPaths = await checkOpenspecReadEvidence({
970
- runDir: input.runDir,
971
- candidatePaths,
972
- planNodeId,
973
- reviewNodeId,
974
- repoRoot: candidateRoot,
975
- });
976
- if (candidatePaths.length > 0) {
977
- const readSet = new Set(openspecReadPaths);
978
- const unread = candidatePaths.filter((candidate) => !readSet.has(candidate));
979
- if (unread.length > 0) {
980
- const governanceRoot = await resolveOpenspecGovernanceRoot(candidateRoot);
981
- const excludedGovernanceCount = await countExcludedGovernanceSpecFiles(candidateRoot, governanceRoot);
982
- return finalizePrewrite(input, {
983
- ...basePending,
984
- verdict,
985
- candidateJsonSha256: analysis.candidateJsonSha256,
986
- normalizationActions: [],
987
- mockStrategy,
988
- classification: "retryable-invalid",
989
- 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。`,
990
- failureCode: "openspec-not-read",
991
- openspecReadPaths,
992
- openspecCandidatePaths: candidatePaths,
993
- });
994
- }
848
+ let openspecReadPaths;
849
+ openspecReadPaths = await collectOpenspecReadPaths({
850
+ runDir: input.runDir,
851
+ planNodeId,
852
+ reviewNodeId,
853
+ repoRoot: candidateRoot,
854
+ ...(candidatePaths.length > 0 ? { candidatePaths } : {}),
855
+ });
856
+ if (candidatePaths.length > 0) {
857
+ const readSet = new Set(openspecReadPaths);
858
+ const unread = candidatePaths.filter((candidate) => !readSet.has(candidate));
859
+ if (unread.length > 0) {
860
+ const governanceRoot = await resolveOpenspecGovernanceRoot(candidateRoot);
861
+ const excludedGovernanceCount = await countExcludedGovernanceSpecFiles(candidateRoot, governanceRoot);
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: 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。`,
870
+ failureCode: "openspec-not-read",
871
+ openspecReadPaths,
872
+ openspecCandidatePaths: candidatePaths,
873
+ });
995
874
  }
996
875
  }
997
876
  // Component selection spec conformance (ADR 0016). Deterministic cross-check
@@ -1013,13 +892,17 @@ export async function runFrontendPrewriteGate(input) {
1013
892
  normalizationActions: [],
1014
893
  mockStrategy,
1015
894
  classification: "retryable-invalid",
1016
- failureReason: "frontend prewrite gate: the canonical contract has UI-visible work but uiComponentChoices is missing or empty while the generation-frozen component/theme specification bucket is non-empty. 为每个 UI 用途声明组件选型(decision: specified | reuse-existing | new),specified 必须给 specReference.path/section/line,new 必须给 rationale 声明偏差。",
895
+ failureReason: "frontend prewrite gate: the canonical contract has UI-visible work but uiComponentChoices is missing or empty while the generation-frozen component/theme specification bucket is non-empty. 为每个 UI 用途声明组件选型(decision: specified | reuse-existing | new);specified 必须给 OpenSpec specReference.path/section/line,new 必须给任务源 PRD specReference.path/section/line。",
1017
896
  failureCode: "component-choices-missing",
1018
897
  openspecReadPaths,
1019
898
  openspecCandidatePaths: candidatePaths,
1020
899
  });
1021
900
  }
901
+ // Only `specified` choices cite the frozen OpenSpec candidate bucket. A
902
+ // `new` choice instead cites its task-source/PRD declaration and must not be
903
+ // rejected merely because that source is outside the OpenSpec roots.
1022
904
  const componentSpecReferences = componentChoices
905
+ .filter((choice) => choice.decision === "specified")
1023
906
  .map((choice) => ({
1024
907
  raw: choice.specReference?.path ?? "",
1025
908
  normalized: choice.specReference?.path
@@ -1027,11 +910,37 @@ export async function runFrontendPrewriteGate(input) {
1027
910
  : null,
1028
911
  }))
1029
912
  .filter((ref) => ref.raw.length > 0);
913
+ if (componentSpecCandidates.length > 0 && hasUiVisibleWork) {
914
+ const componentCandidateSet = new Set(componentSpecCandidates);
915
+ const hasBoundSpecifiedChoice = componentChoices.some((choice) => {
916
+ if (choice.decision !== "specified" || !choice.specReference?.path) {
917
+ return false;
918
+ }
919
+ const normalized = normalizeCitationPath(choice.specReference.path, candidateRoot);
920
+ return Boolean(normalized && componentCandidateSet.has(normalized));
921
+ });
922
+ if (!hasBoundSpecifiedChoice) {
923
+ return finalizePrewrite(input, {
924
+ ...basePending,
925
+ verdict,
926
+ candidateJsonSha256: analysis.candidateJsonSha256,
927
+ normalizationActions: [],
928
+ mockStrategy,
929
+ classification: "retryable-invalid",
930
+ 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.",
931
+ failureCode: "component-spec-choice-unbound",
932
+ openspecReadPaths,
933
+ openspecCandidatePaths: candidatePaths,
934
+ });
935
+ }
936
+ }
1030
937
  if (componentSpecReferences.length > 0) {
1031
938
  const invalidRefs = [
1032
939
  ...new Set(componentSpecReferences
1033
940
  .filter((ref) => !ref.normalized ||
1034
- !isOpenspecSpecFilePath(ref.normalized))
941
+ !(candidatePaths.length > 0
942
+ ? candidatePaths.includes(ref.normalized)
943
+ : isOpenspecSpecFilePath(ref.normalized)))
1035
944
  .map((ref) => ref.raw)),
1036
945
  ];
1037
946
  if (invalidRefs.length > 0) {
@@ -1049,13 +958,10 @@ export async function runFrontendPrewriteGate(input) {
1049
958
  });
1050
959
  }
1051
960
  if (openspecPolicy === "cited") {
1052
- const parsedCitations = parseOpenspecCitations(planText, candidateRoot);
1053
- const citedPaths = new Set(parsedCitations.citations.map((citation) => citation.path));
1054
961
  const readSet = new Set(openspecReadPaths);
1055
962
  const uncorroborated = [
1056
963
  ...new Set(componentSpecReferences
1057
964
  .filter((ref) => !ref.normalized ||
1058
- !citedPaths.has(ref.normalized) ||
1059
965
  !readSet.has(ref.normalized))
1060
966
  .map((ref) => ref.raw)),
1061
967
  ];
@@ -1067,7 +973,7 @@ export async function runFrontendPrewriteGate(input) {
1067
973
  normalizationActions: [],
1068
974
  mockStrategy,
1069
975
  classification: "retryable-invalid",
1070
- 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(", ")}`,
976
+ failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths are missing a ledger specReference or lack a successful read event: ${uncorroborated.join(", ")}`,
1071
977
  failureCode: "component-spec-not-cited",
1072
978
  openspecReadPaths,
1073
979
  openspecCandidatePaths: candidatePaths,