@tea-agent/loop-agent 0.42.0-next.2 → 0.42.0-next.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 (193) hide show
  1. package/CHANGELOG.md +242 -1
  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 +942 -187
  18. package/dist/executors/shell-write-guard.js +7 -0
  19. package/dist/infrastructure/console/operation-store.js +226 -6
  20. package/dist/shared/dag-failure-category.js +12 -0
  21. package/dist/shared/openspec-spec.js +70 -4
  22. package/dist/shared/operator/capabilities.js +7 -0
  23. package/dist/task/config-types.js +34 -5
  24. package/dist/task/contract/adopt.js +4 -0
  25. package/dist/task/contract/import-revision.js +4 -0
  26. package/dist/task/contract/project.js +3 -0
  27. package/dist/task/contract/schema.js +2 -1
  28. package/dist/task/frontend-project-capability.js +203 -20
  29. package/dist/task/runtime.js +5 -2
  30. package/dist/task/source-prepare/build-draft.js +3 -3
  31. package/dist/task/source-prepare/fragment-inventory.js +15 -9
  32. package/dist/task/source-prepare/prepare.js +89 -1
  33. package/dist/task/source-prepare/semantic-intake.js +6 -2
  34. package/dist/task/source-references.js +22 -1
  35. package/dist/worker/console/chat/chat-ui-policy.js +4 -2
  36. package/dist/worker/console/chat/pi-runtime.js +173 -7
  37. package/dist/worker/console/chat/repo-browser.js +10 -3
  38. package/dist/worker/console/chat/routes.js +30 -19
  39. package/dist/worker/console/chat/session-store.js +117 -70
  40. package/dist/worker/console/chat/shortcuts.js +1 -1
  41. package/dist/worker/console/chat/turn-process.js +111 -56
  42. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  43. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  44. package/dist/worker/console/index.js +3 -0
  45. package/dist/worker/console/operator-actions.js +169 -7
  46. package/dist/worker/console/server.js +3 -0
  47. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DZZ8m3PO.js → abnfDiagram-N423BO3Z-CeznJ4Iw.js} +1 -1
  48. package/dist/worker/console/static/assets/{arc-D6PvaVd-.js → arc-B4u9u7SX.js} +1 -1
  49. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-B_OTOiI8.js → architectureDiagram-T3A2C74G-oghpT81p.js} +1 -1
  50. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-Bv6rqHBg.js → blockDiagram-VBNYF7ZC-BPJ5Kvcj.js} +1 -1
  51. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B8eHr0oz.js → c4Diagram-5PPSVZJV-CoIu69pd.js} +1 -1
  52. package/dist/worker/console/static/assets/channel-DmIXqJ6B.js +1 -0
  53. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DYwR0im2.js → chunk-2GRJ4B5K-DtgiAsSc.js} +1 -1
  54. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D2WPGqXt.js → chunk-2Q5K7J3B-YSu_EVSD.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CQISJ_I7.js → chunk-5RXB4S5H-DaRO-NB6.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-C0o2Du1e.js → chunk-5VM5RSS4-Dh0ZKiGA.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-4f8kr-U7.js → chunk-6Q2QTUOP-DOrvbR-H.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D_OWvXzX.js → chunk-GF5L2VYU-C3b22LN2.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CasdPz5X.js → chunk-JWPE2WC7-CXlqO8l6.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Cj8lRrla.js → chunk-KBJHAD2P-BblTBrDu.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CUvD1FSd.js → chunk-RYQCIY6F-DswqPALe.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CqLw_eqB.js → chunk-XXDRQBXY-CfTwVOlc.js} +1 -1
  63. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-MFV5Qxb5.js +1 -0
  64. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-MFV5Qxb5.js +1 -0
  65. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-ku-WqJPx.js → cose-bilkent-JH36ORCC-bLD8jVFo.js} +1 -1
  66. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-Bq_PYDhl.js → cynefin-VYW2F7L2-BvoG1fh5.js} +1 -1
  67. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BiGZV641.js → cynefinDiagram-MW4NZA55-DEPAdJu3.js} +1 -1
  68. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C4JRlglF.js → dagre-VZM6K2ZE-DmrNvBLy.js} +1 -1
  69. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B0dNeWYG.js → diagram-7IWD3JNH-CdwzIwvs.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cj35YvYM.js → diagram-B4RE2ZJO-CJ3hj-md.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-uzQoJ2-8.js → diagram-LBJQPF4R-C6YJT2a4.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D1a-Buoz.js → diagram-Q27KOJAE-C_LPfjhX.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-UB23O5K3-XjRrLRSs.js → diagram-UB23O5K3-xvsSb5Ie.js} +1 -1
  74. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Da_O24cW.js → ebnfDiagram-BXEA7PRR-Rxp2tKcD.js} +1 -1
  75. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BLJ8jrYU.js → erDiagram-JOGREHBK-DRyLYmZh.js} +1 -1
  76. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-B9GrLjM0.js → flowDiagram-UKHOOZJN-TLSU_D2B.js} +1 -1
  77. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CeJ0TqiK.js → ganttDiagram-PKOTCBZU-DGqeTvHC.js} +1 -1
  78. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js → gitGraphDiagram-DS77QQ5N-BZxyH2wh.js} +1 -1
  79. package/dist/worker/console/static/assets/index-C8V8K9b4.js +451 -0
  80. package/dist/worker/console/static/assets/index-Cnf_KxTQ.css +1 -0
  81. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DB26i3d8.js → infoDiagram-6WML65LV-BsYtzx01.js} +1 -1
  82. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BOGRyeZT.js → ishikawaDiagram-WSZJBQD7-Qr-ezGv-.js} +1 -1
  83. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CZRoBO_6.js → journeyDiagram-NVQOT4AX-DYbB3ibo.js} +1 -1
  84. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-qlWhJyoB.js → kanban-definition-27J2QSJJ-BHJHewZO.js} +1 -1
  85. package/dist/worker/console/static/assets/{linear-CfUiDDB3.js → linear-CmPIPmCG.js} +1 -1
  86. package/dist/worker/console/static/assets/{mermaid.core-BQe6fpqj.js → mermaid.core-F--C1uV0.js} +5 -5
  87. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-zFzWHw64.js → mindmap-definition-FAOFIHXS-YTXfL1st.js} +1 -1
  88. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DFGUqjZO.js → pegDiagram-VL7TDLO6-DdDMYXP9.js} +1 -1
  89. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BB5i0l8Z.js → pieDiagram-7S7Q4E2Y-C3nH4KVd.js} +1 -1
  90. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-obbVZ_X5.js → quadrantDiagram-CIZ2JOQS-kXyxjG7Y.js} +1 -1
  91. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwdaNzc1.js → railroadDiagram-AXF67PYL-KNzbl-7w.js} +1 -1
  92. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CkTJaugh.js → requirementDiagram-LRYGKXZP-CSwI6dG0.js} +1 -1
  93. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BwJ-hIgq.js → sankeyDiagram-W5VNT64P-Bl1AyuXy.js} +1 -1
  94. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BzSgmm7w.js → sequenceDiagram-SI44F4Z6-2y4EmXQa.js} +1 -1
  95. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-Bn3oj5Q1.js → sizeCapture-X5ZJPWSS-E8v8YrMg.js} +1 -1
  96. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-dxhGtL8K.js → stateDiagram-OKZ733FA-eGGlR856.js} +1 -1
  97. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8e4J3at.js +1 -0
  98. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BLpEInya.js → swimlanes-SLNWSIFB-B7g7xc07.js} +2 -2
  99. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Do88I1Nb.js +8 -0
  100. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ChJGYcXy.js → timeline-definition-Z64GVDOM-DR3VCHBG.js} +1 -1
  101. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DK-qjRer.js → vennDiagram-T6HMQDX7-LUvapZIy.js} +1 -1
  102. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-3qeaWAg-.js → wardleyDiagram-T6FBY63Y-C7GDcaXq.js} +1 -1
  103. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CootlyP9.js → xychartDiagram-ELKLHX3M-CbNAqoGK.js} +1 -1
  104. package/dist/worker/console/static/index.html +2 -2
  105. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  106. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +19 -8
  107. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +15 -7
  108. package/dist/worker/console/workspace-context.js +111 -1
  109. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  110. package/dist/worker/observe/static/dag-node-purpose.js +5 -0
  111. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  112. package/dist/workflows/dag/dag-retry-schema.js +138 -0
  113. package/dist/workflows/dag/frontend-closeout.js +221 -0
  114. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  115. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  116. package/dist/workflows/dag/frontend-implementation-contract.js +907 -162
  117. package/dist/workflows/dag/frontend-plan-render.js +2 -1
  118. package/dist/workflows/dag/frontend-prewrite-gate.js +255 -349
  119. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  120. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  121. package/dist/workflows/dag/frontend-recovery-controller.js +202 -0
  122. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  123. package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
  124. package/dist/workflows/dag/frontend-recovery-run.js +33 -20
  125. package/dist/workflows/dag/frontend-repair.js +1 -432
  126. package/dist/workflows/dag/frontend-review-context.js +261 -15
  127. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  128. package/dist/workflows/dag/frontend-shadow-dual-write.js +914 -0
  129. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  130. package/dist/workflows/dag/frontend-shape-facts.js +419 -0
  131. package/dist/workflows/dag/frontend-shape.js +435 -0
  132. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  133. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  134. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  135. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  136. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  137. package/dist/workflows/dag/frontend-verification-trace.js +249 -24
  138. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  139. package/dist/workflows/dag/frontend-writer-admission.js +285 -0
  140. package/dist/workflows/dag/frontend-writer-status.js +256 -0
  141. package/dist/workflows/dag/init-hybrid.js +677 -492
  142. package/dist/workflows/dag/interrupt-request.js +7 -0
  143. package/dist/workflows/dag/node-execution.js +732 -4
  144. package/dist/workflows/dag/prompt.js +34 -1
  145. package/dist/workflows/dag/recovery-lease.js +80 -0
  146. package/dist/workflows/dag/report.js +37 -1
  147. package/dist/workflows/dag/rerun-feedback.js +256 -1
  148. package/dist/workflows/dag/rerun-plan.js +17 -6
  149. package/dist/workflows/dag/rerun-run.js +23 -0
  150. package/dist/workflows/dag/rerun-task.js +29 -0
  151. package/dist/workflows/dag/retry-policy.js +214 -104
  152. package/dist/workflows/dag/runner.js +433 -126
  153. package/dist/workflows/dag/scheduler.js +114 -16
  154. package/dist/workflows/dag/types.js +239 -12
  155. package/dist/workflows/dag/validate.js +28 -12
  156. package/docs/examples/README.md +5 -0
  157. package/docs/init-surface.manifest.json +30 -12
  158. package/docs/skills/vetted-skill-registry.md +4 -2
  159. package/docs/templates/README.md +2 -0
  160. package/docs/templates/agent-dag-report.schema.json +8 -2
  161. package/docs/templates/agent-dag.schema.json +1 -1
  162. package/docs/templates/frontend-implementation-contract.schema.json +4 -1
  163. package/docs/templates/frontend-implementation-dag.json +89 -0
  164. package/docs/templates/spec-registry.schema.json +45 -0
  165. package/harness.json +1 -1
  166. package/package.json +1 -1
  167. package/skills/frontend-bounded-implement/SKILL.md +15 -14
  168. package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
  169. package/skills/frontend-contract/SKILL.md +23 -0
  170. package/skills/frontend-contract/references/contract-protocol.md +34 -0
  171. package/skills/frontend-design-review/SKILL.md +22 -41
  172. package/skills/frontend-plan/SKILL.md +26 -0
  173. package/skills/frontend-plan/references/decision-contract.md +37 -0
  174. package/skills/frontend-plan/references/design-decisions.md +17 -0
  175. package/skills/frontend-review/SKILL.md +20 -15
  176. package/skills/frontend-review/references/review-findings.md +6 -7
  177. package/skills/frontend-scout/SKILL.md +25 -0
  178. package/skills/frontend-scout/references/design-evidence.md +16 -0
  179. package/skills/frontend-scout/references/scout-evidence.md +23 -0
  180. package/skills/frontend-verification/SKILL.md +1 -1
  181. package/skills/loop-agent/references/command-reference.md +1 -0
  182. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  183. package/dist/worker/console/static/assets/channel-BU5gOilw.js +0 -1
  184. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +0 -1
  185. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +0 -1
  186. package/dist/worker/console/static/assets/index-H9rFJiGL.css +0 -1
  187. package/dist/worker/console/static/assets/index-xwu9GxEc.js +0 -451
  188. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +0 -1
  189. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +0 -8
  190. package/skills/frontend-implementation/SKILL.md +0 -52
  191. package/skills/frontend-implementation/references/code-standards.md +0 -33
  192. package/skills/frontend-implementation/references/design-spec.md +0 -56
  193. package/skills/frontend-implementation/references/node-contracts.md +0 -31
@@ -8,8 +8,15 @@ import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-sto
8
8
  import { findPackageRoot } from "../../shared/package-metadata.js";
9
9
  import { pathMatchesPattern } from "../../shared/git-progress.js";
10
10
  import { resolveDagTaskSourcePath } from "../../task/dag-source-paths.js";
11
+ import { readTypedEventStoreFromJsonl } from "./frontend-typed-event-store.js";
12
+ // Cycle-safe value imports: the check functions are hoisted declarations used
13
+ // only at call time, so the contract module and the policy module can depend
14
+ // on each other without module-init ordering issues.
15
+ import { checkDependencies, checkTargetPaths, checkUiDesignCoverage, checkUiStateAttribution, } from "./frontend-design-policy.js";
11
16
  import { isDagSourceBindingV2 } from "./types.js";
12
- import { canonicalContractRel, classifyStructuredContractFailure, formatStructuredArtifactPointer, openspecCitationsRel, parseOpenspecCitationsBlock, persistRuntimeSkeleton, } from "./structured-output-repair.js";
17
+ import { canonicalContractRel, classifyStructuredContractFailure, formatStructuredArtifactPointer, persistRuntimeSkeleton, } from "./structured-output-repair.js";
18
+ import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
19
+ import { isConfigurationVerificationFile, verificationSymbolMatchesContent, } from "./frontend-verification-trace.js";
13
20
  export const frontendNormalizationActionSchema = z.enum([
14
21
  "remove-trailing-commas",
15
22
  "strip-comments",
@@ -17,6 +24,7 @@ export const frontendNormalizationActionSchema = z.enum([
17
24
  "canonicalize-verification-alias",
18
25
  "drop-unresolved-verification-symbols",
19
26
  "inject-source-binding",
27
+ "compact-canonical-contract",
20
28
  ]);
21
29
  export function sha256Hex(input) {
22
30
  return createHash("sha256").update(input, "utf8").digest("hex");
@@ -40,6 +48,15 @@ function sortKeysDeep(value) {
40
48
  export function serializeDeterministicJson(value) {
41
49
  return JSON.stringify(sortKeysDeep(value), null, 2);
42
50
  }
51
+ /**
52
+ * Deterministic compact JSON for the canonical contract artifact. The pretty
53
+ * serializer remains the default for human-facing audit artifacts, while the
54
+ * writer contract uses this representation to reduce artifact size and
55
+ * downstream read pressure without changing contract semantics.
56
+ */
57
+ export function serializeCompactDeterministicJson(value) {
58
+ return JSON.stringify(sortKeysDeep(value));
59
+ }
43
60
  export function deterministicSha256(value) {
44
61
  return sha256Hex(serializeDeterministicJson(value));
45
62
  }
@@ -70,6 +87,13 @@ export async function writeDeterministicJsonArtifact(runDir, relativePath, value
70
87
  await writeTextArtifactFile(targetPath, json);
71
88
  return { path: targetPath, sha256: sha256Hex(json) };
72
89
  }
90
+ /** Write a canonical contract artifact with compact deterministic JSON. */
91
+ export async function writeCompactDeterministicJsonArtifact(runDir, relativePath, value) {
92
+ const json = `${serializeCompactDeterministicJson(value)}\n`;
93
+ const targetPath = path.join(runDir, relativePath);
94
+ await writeTextArtifactFile(targetPath, json);
95
+ return { path: targetPath, sha256: sha256Hex(json) };
96
+ }
73
97
  /**
74
98
  * Extract frozen command labels from the DAG run spec (run.json).
75
99
  * The run spec is written before any node executes, so it is always available
@@ -104,8 +128,93 @@ async function deriveFrozenCommandLabelsFromRun(runDir) {
104
128
  }
105
129
  return [...labels];
106
130
  }
131
+ /**
132
+ * Extract the design-policy allowedDependencies set from the DAG run spec
133
+ * (run.json), mirroring `deriveFrozenCommandLabelsFromRun`. Used by the plan
134
+ * node's front-loaded policy pre-check so dependency declarations can be
135
+ * corrected through the retry ladder before the policy shell (the authority)
136
+ * re-runs the same check on committed facts.
137
+ */
138
+ async function deriveAllowedDependenciesFromRun(runDir) {
139
+ const specPath = path.join(runDir, "run.json");
140
+ let raw;
141
+ try {
142
+ raw = await readFile(specPath, "utf8");
143
+ }
144
+ catch {
145
+ return [];
146
+ }
147
+ let spec;
148
+ try {
149
+ spec = JSON.parse(raw);
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ const deps = new Set();
155
+ for (const task of spec.tasks ?? []) {
156
+ const allowed = task.shell?.frontendDesignPolicy?.allowedDependencies;
157
+ if (allowed) {
158
+ for (const dep of allowed)
159
+ deps.add(dep);
160
+ }
161
+ }
162
+ return [...deps];
163
+ }
164
+ /** Read the frozen implementation writeSet patterns from run.json so the plan
165
+ * validator can front-load checkTargetPaths (write-set-too-large) before the
166
+ * design-policy shell terminates the run. */
167
+ async function deriveWriteSetFromRun(runDir) {
168
+ const specPath = path.join(runDir, "run.json");
169
+ let raw;
170
+ try {
171
+ raw = await readFile(specPath, "utf8");
172
+ }
173
+ catch {
174
+ return [];
175
+ }
176
+ let spec;
177
+ try {
178
+ spec = JSON.parse(raw);
179
+ }
180
+ catch {
181
+ return [];
182
+ }
183
+ const writeSet = new Set();
184
+ for (const task of spec.tasks ?? []) {
185
+ const patterns = task.shell?.frontendDesignPolicy?.implementationWriteSet;
186
+ if (patterns) {
187
+ for (const pattern of patterns)
188
+ writeSet.add(pattern);
189
+ }
190
+ }
191
+ return [...writeSet];
192
+ }
107
193
  export const FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID = "frontend-implementation-contract-v1";
108
194
  export const FRONTEND_IMPLEMENTATION_CONTRACT_PLAN_PATCH_SCHEMA_ID = "frontend-implementation-contract-plan-patch-v1";
195
+ export const FRONTEND_CONTRACT_REVIEW_INDEX_SCHEMA_ID = "frontend-contract-review-index-v1";
196
+ export const FRONTEND_CONTRACT_SECTION_SCHEMA_ID = "frontend-contract-section-v1";
197
+ export const FRONTEND_CONTRACT_CAPACITY_SCHEMA_ID = "frontend-contract-capacity-v1";
198
+ const FRONTEND_CONTRACT_REVIEW_GROUPS = [
199
+ {
200
+ id: "core",
201
+ fields: ["schemaVersion", "sourceBinding", "riskLevel", "targets", "requirements"],
202
+ },
203
+ {
204
+ id: "ux",
205
+ fields: ["uiStates", "interactions", "uiComponentChoices", "stylingStrategy"],
206
+ },
207
+ {
208
+ id: "data-design",
209
+ fields: ["mockApi", "designEvidence", "dependencyPolicy", "realIntegrationGap"],
210
+ },
211
+ {
212
+ id: "verification",
213
+ fields: ["verificationTargets", "evidenceGaps", "implementationSteps", "residualRisks"],
214
+ },
215
+ ];
216
+ const FRONTEND_CONTRACT_CAPACITY_WARNING_RATIO = 0.6;
217
+ const FRONTEND_CONTRACT_SECTION_DATA_TARGET_BYTES = 40 * 1024;
109
218
  /**
110
219
  * Build the deterministic, run-owned portion of the frontend contract. The
111
220
  * planner is allowed to fill semantic fields with an RFC 7386 merge patch but
@@ -171,7 +280,6 @@ export function loadFrontendImplementationContractJsonSchema(startDir = path.dir
171
280
  "mockApi",
172
281
  "designEvidence",
173
282
  "verificationTargets",
174
- "evidenceGaps",
175
283
  ];
176
284
  const actualRequired = Array.isArray(schema.required) ? schema.required : [];
177
285
  const missing = expectedRequired.filter((key) => !actualRequired.includes(key));
@@ -220,13 +328,13 @@ const gap = z
220
328
  * `specReference` is required (path/section/line hit).
221
329
  * - `reuse-existing`: reuse an existing repo component/convention not named by
222
330
  * the spec → `specReference` may be null, rationale explains the basis.
223
- * - `new`: neither spec nor existing code fits → `specReference` must be null,
224
- * rationale must declare the deviation (design-review approves it).
331
+ * - `new`: the task source mandates a component absent from the repository →
332
+ * `specReference` is required and points at that task-source declaration;
333
+ * rationale explains the bounded addition.
225
334
  *
226
- * `specReference` is nullable + optional because `stripNullValuesDeep` strips
227
- * null object values before validation, so a `new` choice's `specReference:
228
- * null` becomes an absent field; the superRefine below treats absent and null
229
- * identically.
335
+ * `specReference` is nullable + optional at the wire boundary because
336
+ * `stripNullValuesDeep` removes model-emitted nulls before validation. The
337
+ * decision-specific requirements are enforced by the superRefine below.
230
338
  */
231
339
  const uiComponentSpecReferenceSchema = z
232
340
  .object({
@@ -241,7 +349,11 @@ const uiComponentChoiceSchema = z
241
349
  component: z.string().min(1),
242
350
  decision: z.enum(["specified", "reuse-existing", "new"]),
243
351
  specReference: uiComponentSpecReferenceSchema.nullable().optional(),
244
- rationale: z.string().min(1),
352
+ // Optional: the decision enum already carries the semantic (specified →
353
+ // specReference, new → task-source traceability, reuse-existing → no
354
+ // rationale needed).
355
+ // Omit for reuse-existing to keep plan output small.
356
+ rationale: z.string().min(1).optional(),
245
357
  })
246
358
  .strict();
247
359
  const uiComponentChoicesSchema = z.array(uiComponentChoiceSchema);
@@ -270,7 +382,12 @@ function stripNullValuesDeep(value) {
270
382
  return value;
271
383
  }
272
384
  export const frontendImplementationContractSchema = z
273
- .preprocess(stripNullValuesDeep, z
385
+ .preprocess((value) => {
386
+ const record = asRecord(value);
387
+ if (!record)
388
+ return stripNullValuesDeep(value);
389
+ return stripNullValuesDeep(record);
390
+ }, z
274
391
  .object({
275
392
  schemaVersion: z.literal(1),
276
393
  sourceBinding: z
@@ -398,7 +515,7 @@ export const frontendImplementationContractSchema = z
398
515
  uiStates: z.array(z.string().min(1)),
399
516
  })
400
517
  .strict()).min(1),
401
- evidenceGaps: z.array(gap),
518
+ evidenceGaps: z.array(gap).optional(),
402
519
  implementationSteps: z.array(z.string().min(1)).optional(),
403
520
  stylingStrategy: z.string().min(1).optional(),
404
521
  uiComponentChoices: uiComponentChoicesSchema.optional(),
@@ -408,6 +525,37 @@ export const frontendImplementationContractSchema = z
408
525
  })
409
526
  .strict()
410
527
  .superRefine((value, ctx) => {
528
+ const stableIdentities = [
529
+ {
530
+ path: "requirements",
531
+ label: "requirement id",
532
+ values: value.requirements.map((item) => item.id),
533
+ },
534
+ {
535
+ path: "uiStates",
536
+ label: "UI state name",
537
+ values: value.uiStates.map((item) => item.name),
538
+ },
539
+ {
540
+ path: "interactions",
541
+ label: "interaction name",
542
+ values: value.interactions.map((item) => item.name),
543
+ },
544
+ {
545
+ path: "uiComponentChoices",
546
+ label: "component choice purpose",
547
+ values: (value.uiComponentChoices ?? []).map((item) => item.purpose),
548
+ },
549
+ ];
550
+ for (const identity of stableIdentities) {
551
+ if (new Set(identity.values).size !== identity.values.length) {
552
+ ctx.addIssue({
553
+ code: "custom",
554
+ message: `duplicate ${identity.label}`,
555
+ path: [identity.path],
556
+ });
557
+ }
558
+ }
411
559
  const verificationIds = value.verificationTargets.map((target) => target.id);
412
560
  if (new Set(verificationIds).size !== verificationIds.length)
413
561
  ctx.addIssue({
@@ -462,18 +610,32 @@ export const frontendImplementationContractSchema = z
462
610
  message: `unknown verification target ${targetId}`,
463
611
  path: ["requirements"],
464
612
  });
613
+ // Interactions bind to VTs too: an interaction whose behavior
614
+ // verification points at an unmaterialized VT is untraceable —
615
+ // r20 review finding (dangling *-BEHAVIOR references sailed
616
+ // through plan/design/implement to the final review).
617
+ for (const interaction of value.interactions)
618
+ for (const targetId of interaction.verificationTargetIds)
619
+ if (!verificationIds.includes(targetId))
620
+ ctx.addIssue({
621
+ code: "custom",
622
+ message: `interaction "${interaction.name}" references unknown verification target ${targetId}`,
623
+ path: ["interactions"],
624
+ });
465
625
  // Source fidelity ledger binding (AC-005/AC-006): 绑定携带
466
626
  // requirementToFragments(ledger v2)时,每个 requirement 必须携带
467
- // 有效 sourceFragmentIds/sourceRefs,否则 fail closed(防引用伪造/缺失)。
627
+ // 非空 sourceFragmentIds,否则 fail closed(防引用伪造/缺失)。
628
+ // sourceRefs(fragment→path 的派生展示)不设硬门槛:模型从 binding
629
+ // 只能拿到 fragment id 拿不到权威 path,强制只会诱导编造;防伪造的
630
+ // 真实性校验由 fidelity gate 按 sourceFragmentIds 执行(存在性 +
631
+ // 与 ledger boundFragments 一致)。
468
632
  const boundFragments = value.sourceBinding.requirementToFragments?.[requirement.id];
469
633
  if (value.sourceBinding.ledgerPath &&
470
634
  (!requirement.sourceFragmentIds ||
471
- requirement.sourceFragmentIds.length === 0 ||
472
- !requirement.sourceRefs ||
473
- requirement.sourceRefs.length === 0)) {
635
+ requirement.sourceFragmentIds.length === 0)) {
474
636
  ctx.addIssue({
475
637
  code: "custom",
476
- message: `requirement ${requirement.id} must carry sourceFragmentIds/sourceRefs when a source fidelity ledger is bound`,
638
+ message: `requirement ${requirement.id} must carry sourceFragmentIds when a source fidelity ledger is bound`,
477
639
  path: ["requirements", requirement.id, "sourceFragmentIds"],
478
640
  });
479
641
  }
@@ -491,12 +653,23 @@ export const frontendImplementationContractSchema = z
491
653
  if (state.applicable &&
492
654
  (!state.expectedBehavior ||
493
655
  !state.implementationTargets?.length ||
494
- !state.verificationTargetIds?.length))
656
+ !state.verificationTargetIds?.length)) {
657
+ // Name the state and the exact missing fields: the fixer is a
658
+ // model iterating on finalize receipts — it cannot fix a
659
+ // defect it cannot locate (r18: 39 blind finalize retries).
660
+ const missing = [];
661
+ if (!state.expectedBehavior)
662
+ missing.push("expectedBehavior");
663
+ if (!state.implementationTargets?.length)
664
+ missing.push("implementationTargets");
665
+ if (!state.verificationTargetIds?.length)
666
+ missing.push("verificationTargetIds");
495
667
  ctx.addIssue({
496
668
  code: "custom",
497
- message: "applicable UI state requires behavior, implementation, and verification",
669
+ message: `applicable UI state "${state.name}" is missing: ${missing.join(", ")} — record_state_flow it again with those fields filled`,
498
670
  path: ["uiStates"],
499
671
  });
672
+ }
500
673
  if (!state.applicable && !state.notApplicableReason)
501
674
  ctx.addIssue({
502
675
  code: "custom",
@@ -515,10 +688,10 @@ export const frontendImplementationContractSchema = z
515
688
  }
516
689
  }
517
690
  else if (choice.decision === "new") {
518
- if (choice.specReference) {
691
+ if (!choice.specReference || !choice.specReference.path) {
519
692
  ctx.addIssue({
520
693
  code: "custom",
521
- message: "new component choice must not carry a specReference",
694
+ message: "new component choice requires a task-source specReference.path",
522
695
  path: ["uiComponentChoices", index, "specReference"],
523
696
  });
524
697
  }
@@ -1045,11 +1218,40 @@ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
1045
1218
  ])];
1046
1219
  const evidenceGap = asRecord(requirement.evidenceGap);
1047
1220
  const hasProof = provenRequirementIds.has(requirementId);
1221
+ // A committed evidenceGap with a blank description means "no gap":
1222
+ // small-output models emit the slot defensively with description
1223
+ // "" and the strict gap schema (description min 1 char) would
1224
+ // fail the whole compile. Strip the incoming slot first and only
1225
+ // re-add it when usable — the requirement either has proof (no
1226
+ // gap needed) or receives the derived blocking gap below.
1227
+ const evidenceGapDescription = asString(evidenceGap?.description).trim();
1228
+ const hasUsableEvidenceGap = evidenceGap !== undefined && evidenceGapDescription !== "";
1229
+ // Source fidelity bindings are deterministic ledger data, not
1230
+ // model-authored content: when the plan requirement carries no
1231
+ // binding (r6 — the contract input block degraded, the model
1232
+ // correctly refused to invent ids), inject the ledger binding
1233
+ // for the requirement id. The strict schema fails a ledger-bound
1234
+ // contract on any requirement without sourceFragmentIds.
1235
+ const declaredSourceFragmentIds = Array.isArray(requirement.sourceFragmentIds)
1236
+ ? requirement.sourceFragmentIds
1237
+ : undefined;
1238
+ const ledgerBoundFragmentIds = Array.isArray(canonicalBinding.requirementToFragments?.[requirementId])
1239
+ ? canonicalBinding.requirementToFragments[requirementId]
1240
+ : undefined;
1241
+ const sourceFragmentIds = declaredSourceFragmentIds && declaredSourceFragmentIds.length > 0
1242
+ ? declaredSourceFragmentIds
1243
+ : ledgerBoundFragmentIds;
1244
+ const { evidenceGap: _incomingGap, sourceFragmentIds: _incomingFragmentIds, ...requirementWithoutGap } = requirement;
1245
+ void _incomingGap;
1246
+ void _incomingFragmentIds;
1048
1247
  return {
1049
- ...requirement,
1248
+ ...requirementWithoutGap,
1249
+ ...(sourceFragmentIds ? { sourceFragmentIds } : {}),
1050
1250
  ...(verificationTargetIds.length > 0 ? { verificationTargetIds } : {}),
1051
1251
  ...(hasProof
1052
- ? (evidenceGap ? { evidenceGap: { ...evidenceGap, blocking: false } } : {})
1252
+ ? (hasUsableEvidenceGap
1253
+ ? { evidenceGap: { ...evidenceGap, blocking: false } }
1254
+ : {})
1053
1255
  : {
1054
1256
  evidenceGap: {
1055
1257
  requirementId,
@@ -1061,10 +1263,16 @@ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
1061
1263
  })
1062
1264
  : record.requirements;
1063
1265
  const modelEvidenceGaps = Array.isArray(record.evidenceGaps)
1064
- ? record.evidenceGaps.map((item) => {
1266
+ ? record.evidenceGaps
1267
+ .map((item) => {
1065
1268
  const gap = asRecord(item);
1066
1269
  if (!gap)
1067
1270
  return item;
1271
+ // Blank-description gaps are meaningless statements that would
1272
+ // fail the strict gap schema; drop them like the embedded
1273
+ // per-requirement empty slots.
1274
+ if (asString(gap.description).trim() === "")
1275
+ return undefined;
1068
1276
  const requirementId = canonicalizeRequirementId(asString(gap.requirementId));
1069
1277
  // Model gaps are advisory. Blocking status is reconstructed below
1070
1278
  // from the source binding and executable verification targets.
@@ -1077,6 +1285,7 @@ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
1077
1285
  blocking: false,
1078
1286
  };
1079
1287
  })
1288
+ .filter((item) => item !== undefined)
1080
1289
  : [];
1081
1290
  const derivedBlockingGaps = canonicalBinding.requirementIds
1082
1291
  .filter((requirementId) => !provenRequirementIds.has(requirementId))
@@ -1091,6 +1300,114 @@ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
1091
1300
  evidenceGaps: [...modelEvidenceGaps, ...derivedBlockingGaps],
1092
1301
  };
1093
1302
  }
1303
+ function dedupeJsonArray(value) {
1304
+ if (!Array.isArray(value))
1305
+ return value;
1306
+ const seen = new Set();
1307
+ return value.filter((item) => {
1308
+ const key = serializeCompactDeterministicJson(item);
1309
+ if (seen.has(key))
1310
+ return false;
1311
+ seen.add(key);
1312
+ return true;
1313
+ });
1314
+ }
1315
+ function compactRecordArrayField(value, fields) {
1316
+ if (!Array.isArray(value))
1317
+ return value;
1318
+ return dedupeJsonArray(value.map((item) => {
1319
+ const record = asRecord(item);
1320
+ if (!record)
1321
+ return item;
1322
+ const compact = { ...record };
1323
+ for (const field of fields) {
1324
+ if (Object.hasOwn(compact, field)) {
1325
+ compact[field] = dedupeJsonArray(compact[field]);
1326
+ }
1327
+ }
1328
+ return compact;
1329
+ }));
1330
+ }
1331
+ /**
1332
+ * Compact the canonical contract without weakening its schema or coverage
1333
+ * semantics. This is deliberately a closed list of transformations:
1334
+ * byte-identical records and repeated set members are deduplicated, and
1335
+ * mutually exclusive UI-state fields are normalized. Conflicting stable
1336
+ * identities remain visible so strict schema validation can reject them.
1337
+ */
1338
+ export function compactFrontendImplementationContract(value) {
1339
+ const record = asRecord(value);
1340
+ if (!record)
1341
+ return value;
1342
+ const compact = { ...record };
1343
+ const sourceBinding = asRecord(record.sourceBinding);
1344
+ if (sourceBinding) {
1345
+ const compactBinding = { ...sourceBinding };
1346
+ for (const field of ["referencePaths", "requirementIds"]) {
1347
+ if (Object.hasOwn(compactBinding, field)) {
1348
+ compactBinding[field] = dedupeJsonArray(compactBinding[field]);
1349
+ }
1350
+ }
1351
+ const fragments = asRecord(sourceBinding.requirementToFragments);
1352
+ if (fragments) {
1353
+ compactBinding.requirementToFragments = Object.fromEntries(Object.entries(fragments).map(([id, ids]) => [id, dedupeJsonArray(ids)]));
1354
+ }
1355
+ compact.sourceBinding = compactBinding;
1356
+ }
1357
+ const targets = asRecord(record.targets);
1358
+ if (targets) {
1359
+ const compactTargets = { ...targets };
1360
+ for (const field of ["files", "routes", "publicApiChanges"]) {
1361
+ if (Object.hasOwn(compactTargets, field)) {
1362
+ compactTargets[field] = dedupeJsonArray(compactTargets[field]);
1363
+ }
1364
+ }
1365
+ compact.targets = compactTargets;
1366
+ }
1367
+ compact.requirements = compactRecordArrayField(record.requirements, [
1368
+ "implementationTargets",
1369
+ "verificationTargetIds",
1370
+ "sourceFragmentIds",
1371
+ "sourceRefs",
1372
+ ]);
1373
+ compact.uiStates = compactRecordArrayField(record.uiStates, ["implementationTargets", "verificationTargetIds"]);
1374
+ if (Array.isArray(compact.uiStates)) {
1375
+ compact.uiStates = dedupeJsonArray(compact.uiStates.map((item) => {
1376
+ const state = asRecord(item);
1377
+ if (!state)
1378
+ return item;
1379
+ const normalized = { ...state };
1380
+ if (normalized.applicable === true)
1381
+ delete normalized.notApplicableReason;
1382
+ if (normalized.applicable === false)
1383
+ delete normalized.expectedBehavior;
1384
+ return normalized;
1385
+ }));
1386
+ }
1387
+ compact.interactions = compactRecordArrayField(record.interactions, ["implementationTargets", "verificationTargetIds"]);
1388
+ compact.verificationTargets = compactRecordArrayField(record.verificationTargets, ["requirementIds", "uiStates"]);
1389
+ const mockApi = asRecord(record.mockApi);
1390
+ if (mockApi) {
1391
+ compact.mockApi = {
1392
+ ...mockApi,
1393
+ endpoints: dedupeJsonArray(mockApi.endpoints),
1394
+ };
1395
+ }
1396
+ const designEvidence = asRecord(record.designEvidence);
1397
+ if (designEvidence) {
1398
+ compact.designEvidence = {
1399
+ ...designEvidence,
1400
+ paths: dedupeJsonArray(designEvidence.paths),
1401
+ conflicts: dedupeJsonArray(designEvidence.conflicts),
1402
+ };
1403
+ }
1404
+ for (const field of ["evidenceGaps", "implementationSteps", "residualRisks"]) {
1405
+ if (Object.hasOwn(compact, field))
1406
+ compact[field] = dedupeJsonArray(compact[field]);
1407
+ }
1408
+ compact.uiComponentChoices = dedupeJsonArray(record.uiComponentChoices);
1409
+ return compact;
1410
+ }
1094
1411
  function assertFrontendContractPathsSafe(value) {
1095
1412
  const record = asRecord(value);
1096
1413
  if (!record)
@@ -1166,6 +1483,40 @@ function looksLikeStrictFrontendContract(value) {
1166
1483
  asRecord(record.mockApi) !== null &&
1167
1484
  asRecord(record.designEvidence) !== null);
1168
1485
  }
1486
+ /**
1487
+ * Single shared completion layer for required-with-fallback contract fields.
1488
+ *
1489
+ * Structural invariant: the coerce normalizer has strict-shape and free-form
1490
+ * branches that reshape differently, but every required key that carries a
1491
+ * deterministic fallback (currently `designEvidence`) must be completed in
1492
+ * exactly ONE place — here — so the branches can never drift again (the
1493
+ * designEvidence Required and invented-uiStates failures both came from
1494
+ * branch-local rules diverging). This layer never invents values that later
1495
+ * refinements reject: it only fills documented fallbacks for keys already
1496
+ * present-but-partial, and is idempotent for already-complete inputs.
1497
+ */
1498
+ export function completeCanonicalRequiredFields(value) {
1499
+ const record = asRecord(value);
1500
+ if (!record)
1501
+ return value;
1502
+ const designEvidence = asRecord(record.designEvidence);
1503
+ if (!designEvidence)
1504
+ return value;
1505
+ const source = asString(designEvidence.source);
1506
+ const paths = Array.isArray(designEvidence.paths);
1507
+ const conflicts = Array.isArray(designEvidence.conflicts);
1508
+ if (source && paths && conflicts)
1509
+ return value;
1510
+ return {
1511
+ ...record,
1512
+ designEvidence: {
1513
+ ...designEvidence,
1514
+ ...(source ? {} : { source: "repository-fallback+task-source" }),
1515
+ ...(paths ? {} : { paths: [] }),
1516
+ ...(conflicts ? {} : { conflicts: [] }),
1517
+ },
1518
+ };
1519
+ }
1169
1520
  /**
1170
1521
  * Coerce common free-form plan JSON into frontend-implementation-contract-v1.
1171
1522
  * Near-schema payloads are left untouched so unknown-key fail-closed still holds.
@@ -1270,6 +1621,9 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
1270
1621
  ...strictRecord,
1271
1622
  requirements,
1272
1623
  ...(strictMockApi ? { mockApi: { ...strictMockApi, endpoints: mockEndpoints } } : {}),
1624
+ // Required-with-fallback keys (designEvidence etc.) are completed
1625
+ // by the single shared `completeCanonicalRequiredFields` layer in
1626
+ // the analyze pipeline — never per-branch.
1273
1627
  };
1274
1628
  }
1275
1629
  }
@@ -1318,9 +1672,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
1318
1672
  : typeRaw.includes("type")
1319
1673
  ? "static"
1320
1674
  : "unit";
1321
- const commandLabel = asString(vt.commandLabel) ||
1322
- asString(vt.command) ||
1323
- (type === "static" ? "npm run typecheck" : "npm run test:unit:fe");
1675
+ const commandLabel = asString(vt.commandLabel) || asString(vt.command);
1324
1676
  const file = asString(vt.file) ||
1325
1677
  (Array.isArray(vt.symbols) ? targetFiles.find((p) => p.includes("__tests__")) : "") ||
1326
1678
  targetFiles.find((p) => p.includes("__tests__")) ||
@@ -1336,7 +1688,11 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
1336
1688
  requirementIds: requirementIds.length > 0
1337
1689
  ? requirementIds
1338
1690
  : [...canonicalBinding.requirementIds],
1339
- uiStates: uiStateNames.length > 0 ? uiStateNames : ["success", "error"],
1691
+ // Never invent UI state references: "success" is not a canonical
1692
+ // state name, so a defaulted ["success","error"] was always rejected
1693
+ // by the schema's unknown-uiState refinement — a verification
1694
+ // target that declares no UI binding stays unbound ([]).
1695
+ uiStates: uiStateNames,
1340
1696
  });
1341
1697
  }
1342
1698
  if (verificationTargets.length === 0) {
@@ -1371,6 +1727,14 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
1371
1727
  verificationTargetIds: verificationTargetIds.length > 0
1372
1728
  ? verificationTargetIds
1373
1729
  : defaultVerificationIds,
1730
+ // Source fidelity provenance 透传:兼容分支重建 requirement 时必须
1731
+ // 保留已声明的 sourceFragmentIds/sourceRefs(strict 分支通过
1732
+ // `...requirement` 保留,两边必须一致),否则 ledger-bound 契约在
1733
+ // designEvidence 缺失等情况下走兼容分支时丢失防伪造证据。
1734
+ ...(Array.isArray(req.sourceFragmentIds)
1735
+ ? { sourceFragmentIds: req.sourceFragmentIds }
1736
+ : {}),
1737
+ ...(Array.isArray(req.sourceRefs) ? { sourceRefs: req.sourceRefs } : {}),
1374
1738
  };
1375
1739
  // Do not mark free-form realIntegrationGap as blocking; defer to FE-TEST/FINAL-VERIFY.
1376
1740
  if (gapText) {
@@ -1664,12 +2028,25 @@ export async function analyzeFrontendImplementationContract(input) {
1664
2028
  if (parsedTargetFiles.some((file) => file.startsWith("/") || file.includes("\\")))
1665
2029
  fail("blocked", "invalid-output: frontend contract target paths must be relative POSIX paths", candidateJsonSha256);
1666
2030
  const parsedStates = asRecord(parsed)?.uiStates;
1667
- if (Array.isArray(parsedStates) && parsedStates.some((item) => {
1668
- const state = asRecord(item);
1669
- return state?.applicable === true &&
1670
- (!asString(state.expectedBehavior) || asStringArray(state.implementationTargets).length === 0 || asStringArray(state.verificationTargetIds).length === 0);
1671
- }))
1672
- fail("retryable-invalid", "invalid-output: applicable UI state requires behavior, implementation, and verification", candidateJsonSha256);
2031
+ if (Array.isArray(parsedStates)) {
2032
+ const incompleteStates = [];
2033
+ for (const item of parsedStates) {
2034
+ const state = asRecord(item);
2035
+ if (!state || state.applicable !== true)
2036
+ continue;
2037
+ const missing = [];
2038
+ if (!asString(state.expectedBehavior))
2039
+ missing.push("expectedBehavior");
2040
+ if (asStringArray(state.implementationTargets).length === 0)
2041
+ missing.push("implementationTargets");
2042
+ if (asStringArray(state.verificationTargetIds).length === 0)
2043
+ missing.push("verificationTargetIds");
2044
+ if (missing.length > 0)
2045
+ incompleteStates.push(`"${asString(state.name)}": missing ${missing.join(", ")}`);
2046
+ }
2047
+ if (incompleteStates.length > 0)
2048
+ fail("retryable-invalid", `invalid-output: applicable UI states incomplete — re-record each with record_state_flow filling the named fields: ${incompleteStates.join("; ")}`, candidateJsonSha256);
2049
+ }
1673
2050
  const parsedMockApi = asRecord(parsed)?.mockApi;
1674
2051
  if (asRecord(parsedMockApi) &&
1675
2052
  typeof asRecord(parsedMockApi)?.strategy === "string" &&
@@ -1686,15 +2063,49 @@ export async function analyzeFrontendImplementationContract(input) {
1686
2063
  // There is exactly one post-security candidate. A fallback candidate would
1687
2064
  // allow malformed raw fields to bypass the boundary checks above.
1688
2065
  const normalizedContract = coerceFrontendImplementationContractInput(parsed, canonicalBinding);
2066
+ const completedContract = completeCanonicalRequiredFields(normalizedContract);
1689
2067
  const candidate = deriveFrontendVerificationCoverage({
1690
- ...(asRecord(normalizedContract) ?? parsed),
2068
+ ...(asRecord(completedContract) ?? parsed),
1691
2069
  sourceBinding: canonicalBinding,
1692
2070
  }, canonicalBinding);
1693
- const result = frontendImplementationContractSchema.safeParse(candidate);
2071
+ const compactedCandidate = compactFrontendImplementationContract(candidate);
2072
+ if (serializeCompactDeterministicJson(compactedCandidate) !==
2073
+ serializeCompactDeterministicJson(candidate))
2074
+ pushAction("compact-canonical-contract");
2075
+ const result = frontendImplementationContractSchema.safeParse(compactedCandidate);
1694
2076
  if (!result.success)
1695
2077
  fail("retryable-invalid", `invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`, candidateJsonSha256);
2078
+ // targets.files is runtime-owned (the skeleton derives it from the task
2079
+ // writeSet, a glob the model must not edit). Narrow it AFTER the full
2080
+ // schema validation — never before, so model-authored unsafe/absolute
2081
+ // declarations are still rejected — to every CONCRETE path the validated
2082
+ // contract references (requirement implementationTargets,
2083
+ // verification-target files, Mock endpoint fixture/consumer paths). The
2084
+ // prewrite containment semantics keep covering everything the contract
2085
+ // references because every narrowed entry already matched the declared
2086
+ // patterns during validation. Satisfies frozen requirements like "deliver
2087
+ // exactly these files" without asking the model to edit a protected
2088
+ // field (r10/r11 design-review findings).
2089
+ const referencedConcretePaths = [
2090
+ ...new Set([
2091
+ ...result.data.requirements.flatMap((requirement) => requirement.implementationTargets),
2092
+ // Mock endpoint paths stay in the set: the zod refine validated
2093
+ // them against the declared targets.files patterns, so the
2094
+ // narrowing must keep covering them. Verification-target files
2095
+ // deliberately do NOT join: they are test artifacts, not
2096
+ // deliverables, and pulling them in would change which gate
2097
+ // fires for an out-of-writeSet VT (AC-3a semantics).
2098
+ ...(result.data.mockApi?.endpoints ?? []).flatMap((endpoint) => [endpoint.fixture, endpoint.consumer].filter((value) => typeof value === "string" && value.length > 0)),
2099
+ ].filter((value) => value.length > 0)),
2100
+ ].sort();
2101
+ if (referencedConcretePaths.length > 0) {
2102
+ result.data.targets = {
2103
+ ...result.data.targets,
2104
+ files: referencedConcretePaths,
2105
+ };
2106
+ }
1696
2107
  const blockingGaps = [
1697
- ...result.data.evidenceGaps,
2108
+ ...(result.data.evidenceGaps ?? []),
1698
2109
  ...result.data.requirements.flatMap((item) => item.evidenceGap ? [item.evidenceGap] : []),
1699
2110
  ].filter((item) => item.blocking);
1700
2111
  if (blockingGaps.length > 0)
@@ -1703,7 +2114,7 @@ export async function analyzeFrontendImplementationContract(input) {
1703
2114
  .join(", ")}`, candidateJsonSha256);
1704
2115
  for (const requirementId of canonicalBinding.requirementIds)
1705
2116
  if (!result.data.requirements.some((item) => item.id === requirementId) &&
1706
- !result.data.evidenceGaps.some((item) => item.requirementId === requirementId))
2117
+ !(result.data.evidenceGaps ?? []).some((item) => item.requirementId === requirementId))
1707
2118
  fail("blocked", `frontend contract does not cover ${requirementId}`, candidateJsonSha256);
1708
2119
  return {
1709
2120
  canonical: result.data,
@@ -1712,12 +2123,187 @@ export async function analyzeFrontendImplementationContract(input) {
1712
2123
  normalizationActions,
1713
2124
  };
1714
2125
  }
2126
+ function splitFrontendContractReviewGroup(canonical, fields) {
2127
+ const parts = [];
2128
+ let data = {};
2129
+ let slices = [];
2130
+ const dataBytes = (candidate) => Buffer.byteLength(serializeCompactDeterministicJson(candidate), "utf8");
2131
+ const flush = () => {
2132
+ if (Object.keys(data).length === 0)
2133
+ return;
2134
+ parts.push({ data, slices });
2135
+ data = {};
2136
+ slices = [];
2137
+ };
2138
+ for (const field of fields) {
2139
+ if (!Object.hasOwn(canonical, field))
2140
+ continue;
2141
+ const fieldValue = canonical[field];
2142
+ if (!Array.isArray(fieldValue)) {
2143
+ const candidate = { ...data, [field]: fieldValue };
2144
+ if (Object.keys(data).length > 0 &&
2145
+ dataBytes(candidate) > FRONTEND_CONTRACT_SECTION_DATA_TARGET_BYTES) {
2146
+ flush();
2147
+ }
2148
+ data[field] = fieldValue;
2149
+ slices.push({ field });
2150
+ continue;
2151
+ }
2152
+ if (fieldValue.length === 0) {
2153
+ const candidate = { ...data, [field]: [] };
2154
+ if (Object.keys(data).length > 0 &&
2155
+ dataBytes(candidate) > FRONTEND_CONTRACT_SECTION_DATA_TARGET_BYTES) {
2156
+ flush();
2157
+ }
2158
+ data[field] = [];
2159
+ slices.push({ field, start: 0, end: 0, total: 0 });
2160
+ continue;
2161
+ }
2162
+ let start = 0;
2163
+ while (start < fieldValue.length) {
2164
+ let end = start;
2165
+ let accepted = [];
2166
+ while (end < fieldValue.length) {
2167
+ const next = [...accepted, fieldValue[end]];
2168
+ const candidate = { ...data, [field]: next };
2169
+ if (accepted.length > 0 &&
2170
+ dataBytes(candidate) > FRONTEND_CONTRACT_SECTION_DATA_TARGET_BYTES) {
2171
+ break;
2172
+ }
2173
+ if (accepted.length === 0 &&
2174
+ Object.keys(data).length > 0 &&
2175
+ dataBytes(candidate) > FRONTEND_CONTRACT_SECTION_DATA_TARGET_BYTES) {
2176
+ flush();
2177
+ continue;
2178
+ }
2179
+ accepted = next;
2180
+ end += 1;
2181
+ }
2182
+ data[field] = accepted;
2183
+ slices.push({ field, start, end, total: fieldValue.length });
2184
+ start = end;
2185
+ if (start < fieldValue.length)
2186
+ flush();
2187
+ }
2188
+ }
2189
+ flush();
2190
+ return parts;
2191
+ }
2192
+ /**
2193
+ * Materialize a hash-bound field index and bounded contract sections.
2194
+ * These artifacts are an alternate read surface only: the canonical contract
2195
+ * remains authoritative and capacity diagnostics never affect admission.
2196
+ */
2197
+ export async function writeFrontendContractReviewArtifacts(input) {
2198
+ const contractRelativePath = path.posix.join(input.outputDir, input.artifactName);
2199
+ const baseName = input.artifactName.endsWith(".json")
2200
+ ? input.artifactName.slice(0, -".json".length)
2201
+ : input.artifactName;
2202
+ const sectionDir = path.posix.join(input.outputDir, `${baseName}.sections`);
2203
+ const indexRelativePath = path.posix.join(input.outputDir, `${baseName}.index.json`);
2204
+ const capacityRelativePath = path.posix.join(input.outputDir, `${baseName}.capacity.json`);
2205
+ const compactContract = `${serializeCompactDeterministicJson(input.canonical)}\n`;
2206
+ const canonicalArtifact = input.canonicalArtifact ?? {
2207
+ path: path.join(input.runDir, contractRelativePath),
2208
+ sha256: sha256Hex(compactContract),
2209
+ bytes: Buffer.byteLength(compactContract, "utf8"),
2210
+ };
2211
+ const sections = [];
2212
+ for (const [index, group] of FRONTEND_CONTRACT_REVIEW_GROUPS.entries()) {
2213
+ const parts = splitFrontendContractReviewGroup(input.canonical, group.fields);
2214
+ for (const [partIndex, part] of parts.entries()) {
2215
+ const relativePath = path.posix.join(sectionDir, `${String(index + 1).padStart(2, "0")}-${group.id}-part-${String(partIndex + 1).padStart(2, "0")}.json`);
2216
+ const sectionPayload = {
2217
+ schemaVersion: 1,
2218
+ schemaId: FRONTEND_CONTRACT_SECTION_SCHEMA_ID,
2219
+ contractSchemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
2220
+ contractSha256: canonicalArtifact.sha256,
2221
+ group: group.id,
2222
+ part: partIndex + 1,
2223
+ partCount: parts.length,
2224
+ fields: Object.keys(part.data),
2225
+ slices: part.slices,
2226
+ data: part.data,
2227
+ };
2228
+ const written = await writeCompactDeterministicJsonArtifact(input.runDir, relativePath, sectionPayload);
2229
+ sections.push({
2230
+ id: group.id,
2231
+ fields: Object.keys(part.data),
2232
+ part: partIndex + 1,
2233
+ partCount: parts.length,
2234
+ slices: part.slices,
2235
+ path: relativePath,
2236
+ sha256: written.sha256,
2237
+ bytes: Buffer.byteLength(`${serializeCompactDeterministicJson(sectionPayload)}\n`, "utf8"),
2238
+ });
2239
+ }
2240
+ }
2241
+ const indexPayload = {
2242
+ schemaVersion: 1,
2243
+ schemaId: FRONTEND_CONTRACT_REVIEW_INDEX_SCHEMA_ID,
2244
+ contract: {
2245
+ path: contractRelativePath,
2246
+ schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
2247
+ sha256: canonicalArtifact.sha256,
2248
+ bytes: canonicalArtifact.bytes,
2249
+ },
2250
+ sections,
2251
+ };
2252
+ const writtenIndex = await writeCompactDeterministicJsonArtifact(input.runDir, indexRelativePath, indexPayload);
2253
+ // Read budgets for the model-backed frontend review nodes were removed
2254
+ // (2026-08-31 one-shot): nodes no longer carry maxFiles/maxBytes, so the
2255
+ // capacity diagnostic has no budget basis and emits no consumers.
2256
+ const consumers = [];
2257
+ const capacityPayload = {
2258
+ schemaVersion: 1,
2259
+ schemaId: FRONTEND_CONTRACT_CAPACITY_SCHEMA_ID,
2260
+ nonBlocking: true,
2261
+ warningThresholdRatio: FRONTEND_CONTRACT_CAPACITY_WARNING_RATIO,
2262
+ contract: indexPayload.contract,
2263
+ index: {
2264
+ path: indexRelativePath,
2265
+ sha256: writtenIndex.sha256,
2266
+ sectionCount: sections.length,
2267
+ sectionBytes: sections.reduce((total, section) => total + section.bytes, 0),
2268
+ maxSectionBytes: Math.max(...sections.map((section) => section.bytes)),
2269
+ oversizedSectionCount: sections.filter((section) => section.bytes >= 48 * 1024).length,
2270
+ },
2271
+ consumers,
2272
+ overallLevel: consumers.some((entry) => entry.level === "over-budget")
2273
+ ? "over-budget"
2274
+ : consumers.some((entry) => entry.level === "warning")
2275
+ ? "warning"
2276
+ : "ok",
2277
+ };
2278
+ const writtenCapacity = await writeCompactDeterministicJsonArtifact(input.runDir, capacityRelativePath, capacityPayload);
2279
+ return {
2280
+ index: {
2281
+ path: writtenIndex.path,
2282
+ relativePath: indexRelativePath,
2283
+ artifactSha256: writtenIndex.sha256,
2284
+ ...indexPayload,
2285
+ },
2286
+ capacity: {
2287
+ path: writtenCapacity.path,
2288
+ relativePath: capacityRelativePath,
2289
+ artifactSha256: writtenCapacity.sha256,
2290
+ ...capacityPayload,
2291
+ },
2292
+ };
2293
+ }
1715
2294
  export async function writeFrontendImplementationContractArtifact(input) {
1716
- const written = await writeDeterministicJsonArtifact(input.runDir, path.posix.join(input.outputDir, input.artifactName), input.canonical);
2295
+ const written = await writeCompactDeterministicJsonArtifact(input.runDir, path.posix.join(input.outputDir, input.artifactName), input.canonical);
2296
+ const bytes = Buffer.byteLength(`${serializeCompactDeterministicJson(input.canonical)}\n`, "utf8");
2297
+ const reviewArtifacts = await writeFrontendContractReviewArtifacts({
2298
+ ...input,
2299
+ canonicalArtifact: { ...written, bytes },
2300
+ });
1717
2301
  return {
1718
2302
  path: written.path,
1719
2303
  sha256: written.sha256,
1720
2304
  schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
2305
+ reviewIndexPath: reviewArtifacts.index.path,
2306
+ capacityDiagnosticPath: reviewArtifacts.capacity.path,
1721
2307
  };
1722
2308
  }
1723
2309
  const FRONTEND_PLAN_PATCH_PROTECTED_PATHS = [
@@ -1727,6 +2313,40 @@ const FRONTEND_PLAN_PATCH_PROTECTED_PATHS = [
1727
2313
  "/targets/files",
1728
2314
  "/mockApi/productionDefaultOff",
1729
2315
  ];
2316
+ const PLAN_LEDGER_FACT_KINDS_FOR_COMPILE = [
2317
+ "target-surface",
2318
+ "component-choice",
2319
+ "state-flow",
2320
+ "data-flow",
2321
+ "mock-api",
2322
+ "design-deviation",
2323
+ "dependency",
2324
+ "plan-requirement",
2325
+ "plan-verification-target",
2326
+ "plan-evidence-gap",
2327
+ ];
2328
+ /**
2329
+ * Restore the editable RFC 7386 plan patch from a committed plan ledger.
2330
+ * This is the single compile authority for the frontend implementation
2331
+ * contract: the tool payload (`patch` on any committed plan fact) wins over
2332
+ * any narrative JSON in the plan node text.
2333
+ */
2334
+ export function restorePlanPatchFromCommittedFacts(records) {
2335
+ const planFacts = records
2336
+ .filter((record) => record.phase === "committed")
2337
+ .map((record) => record.fact)
2338
+ .filter((fact) => fact.origin === "plan" &&
2339
+ typeof fact.kind === "string" &&
2340
+ PLAN_LEDGER_FACT_KINDS_FOR_COMPILE.includes(fact.kind));
2341
+ if (planFacts.length === 0)
2342
+ return undefined;
2343
+ for (const fact of planFacts) {
2344
+ const patch = fact.patch;
2345
+ if (isPlainObject(patch))
2346
+ return patch;
2347
+ }
2348
+ return undefined;
2349
+ }
1730
2350
  function frontendPlanPatchProtectedPathViolations(patch) {
1731
2351
  const record = asRecord(patch);
1732
2352
  if (!record)
@@ -1756,34 +2376,34 @@ function workspaceRootFromDagRunDir(runDir) {
1756
2376
  const joined = segments.slice(0, harnessIndex).join(path.sep);
1757
2377
  return joined || path.parse(absolute).root;
1758
2378
  }
1759
- function verificationSymbolMatchesFile(symbol, content) {
1760
- if (symbol.trim().toLowerCase() === "all describe blocks")
1761
- return content.includes("describe(");
1762
- const symbols = symbol.includes(" / ")
1763
- ? symbol
1764
- .split(/[((]/, 1)[0]
1765
- .split("/")
1766
- .map((item) => item.trim())
1767
- .filter(Boolean)
1768
- : [symbol];
1769
- return symbols.every((item) => {
1770
- const describeTitle = item.match(/^describe\((?:['"])(.+?)(?:['"])/i)?.[1];
1771
- return [item, describeTitle, describeTitle ? "describe(" : undefined]
1772
- .filter((candidate) => Boolean(candidate))
1773
- .some((candidate) => content.includes(candidate));
1774
- });
1775
- }
1776
2379
  async function dropUnresolvedVerificationSymbols(input) {
1777
2380
  const workspaceRoot = workspaceRootFromDagRunDir(input.runDir);
1778
2381
  const targets = asRecord(input.contract)?.verificationTargets;
1779
2382
  if (!workspaceRoot || !Array.isArray(targets))
1780
- return false;
1781
- let changed = false;
2383
+ return [];
2384
+ const actions = [];
1782
2385
  for (const targetValue of targets) {
1783
2386
  const target = asRecord(targetValue);
1784
2387
  const file = asString(target?.file);
1785
2388
  const symbol = asString(target?.symbol);
1786
- if (!target || !file || !symbol)
2389
+ if (!target || !file)
2390
+ continue;
2391
+ // Static targets (project-wide commands such as typecheck/build) verify
2392
+ // by command exit code, never by a per-file symbol. Strip any symbol
2393
+ // the planner attached — including fabricated placeholders like "null"
2394
+ // — so the plan shape check and the verify trace gate never evaluate
2395
+ // it. This is the root fix for the recurring fabricated-symbol error
2396
+ // class: the field was allowed for static, the prompt did not forbid
2397
+ // it, and models habitually fill it; the compiler removes it instead.
2398
+ if (target.type === "static") {
2399
+ if (symbol) {
2400
+ delete target.symbol;
2401
+ if (!actions.includes("strip-static-verification-symbols"))
2402
+ actions.push("strip-static-verification-symbols");
2403
+ }
2404
+ continue;
2405
+ }
2406
+ if (!symbol)
1787
2407
  continue;
1788
2408
  const absolute = path.resolve(workspaceRoot, file);
1789
2409
  const relative = path.relative(workspaceRoot, absolute);
@@ -1792,6 +2412,11 @@ async function dropUnresolvedVerificationSymbols(input) {
1792
2412
  relative.includes("..")) {
1793
2413
  continue;
1794
2414
  }
2415
+ // Configuration files (tsconfig.json, vite.config.*, ...) do not expose
2416
+ // source symbols; legacy symbol normalization leaves them alone. Runtime
2417
+ // trace binding handles static targets through file + command only.
2418
+ if (isConfigurationVerificationFile(file))
2419
+ continue;
1795
2420
  let content;
1796
2421
  try {
1797
2422
  content = await readFile(absolute, "utf8");
@@ -1801,23 +2426,13 @@ async function dropUnresolvedVerificationSymbols(input) {
1801
2426
  // changing the optional symbol during candidate normalization.
1802
2427
  continue;
1803
2428
  }
1804
- if (verificationSymbolMatchesFile(symbol, content))
2429
+ if (verificationSymbolMatchesContent(symbol, content))
1805
2430
  continue;
1806
2431
  delete target.symbol;
1807
- changed = true;
2432
+ if (!actions.includes("drop-unresolved-verification-symbols"))
2433
+ actions.push("drop-unresolved-verification-symbols");
1808
2434
  }
1809
- return changed;
1810
- }
1811
- function extractSingleOpenspecCitationsBlock(text) {
1812
- // Line-anchored so a directly closed empty fence (no candidate rows) still
1813
- // counts as the one required block instead of "found 0" retry loops.
1814
- const blocks = [
1815
- ...text.matchAll(/^```openspec-citations[ \t]*\r?\n([\s\S]*?)^```[ \t]*$/gim),
1816
- ].map((match) => match[0]);
1817
- if (blocks.length !== 1) {
1818
- throw new Error(`frontend plan patch output must include exactly one fenced openspec-citations block (found ${blocks.length})`);
1819
- }
1820
- return blocks[0];
2435
+ return actions;
1821
2436
  }
1822
2437
  async function writeFrontendPlanCandidateRaw(input) {
1823
2438
  const relativePath = path.posix.join("contracts", "candidates", input.nodeId, `attempt-${input.attempt}.raw.md`);
@@ -1831,6 +2446,118 @@ async function writeFrontendPlanCandidateRaw(input) {
1831
2446
  * prose. A successful result returns a path-only pointer plus the hash-bound canonical artifact for
1832
2447
  * downstream review and prewrite nodes.
1833
2448
  */
2449
+ /**
2450
+ * Placeholder tokens that are never valid verification symbols ("null" is the
2451
+ * classic model artifact: it serializes a JS null into the symbol string).
2452
+ */
2453
+ const VERIFICATION_SYMBOL_PLACEHOLDERS = new Set([
2454
+ "null",
2455
+ "undefined",
2456
+ "none",
2457
+ "n/a",
2458
+ "na",
2459
+ "tbd",
2460
+ "todo",
2461
+ "...",
2462
+ "?",
2463
+ "-",
2464
+ ]);
2465
+ /** An over-long symbol can never resolve to a real declaration. */
2466
+ const VERIFICATION_SYMBOL_MAX_CHARS = 60;
2467
+ /**
2468
+ * Deterministic shape check for a verification target symbol. Catches obvious
2469
+ * model fabrication (placeholder tokens, prose fragments) when reading a
2470
+ * legacy contract. New plan tool calls do not expose this field; runtime trace
2471
+ * binding uses the stable verification-target id instead.
2472
+ *
2473
+ * Deliberately conservative: bare test titles without punctuation ("renders
2474
+ * loading") and `describe(...)` / `it(...)` forms stay valid — a real symbol
2475
+ * may be a function name, a dotted path, or a describe/it title.
2476
+ */
2477
+ export function isSuspiciousVerificationSymbol(symbol) {
2478
+ const trimmed = symbol.trim();
2479
+ if (!trimmed)
2480
+ return false;
2481
+ if (VERIFICATION_SYMBOL_PLACEHOLDERS.has(trimmed.toLowerCase()))
2482
+ return true;
2483
+ if (trimmed.length > VERIFICATION_SYMBOL_MAX_CHARS)
2484
+ return true;
2485
+ // Deliberately conservative: bare test titles with punctuation (e.g.
2486
+ // "adds +1 button") are valid describe/it-style symbols and must pass;
2487
+ // the length + placeholder rules above already catch obvious fabrication
2488
+ // ("null", over-long prose fragments). Everything else is left to the
2489
+ // deterministic drop/trace resolvability check.
2490
+ return false;
2491
+ }
2492
+ function assertVerificationSymbolShapes(contract) {
2493
+ for (const target of contract.verificationTargets) {
2494
+ if (!target.symbol)
2495
+ continue;
2496
+ if (isSuspiciousVerificationSymbol(target.symbol)) {
2497
+ throw new Error(`frontend plan verification target "${target.id}" declares a fabricated legacy symbol "${target.symbol}" for ${target.file}; remove the symbol (deterministic trace binding uses the stable target id)`);
2498
+ }
2499
+ }
2500
+ }
2501
+ /**
2502
+ * Thrown by analyzeFrontendPlanPatchCandidate when the design-policy
2503
+ * pre-checks report findings. Carries the structured findings so callers
2504
+ * (finalize_plan receipt) can synthesize fix suggestions instead of making
2505
+ * the model re-derive them from prose.
2506
+ */
2507
+ export class PlanPolicyPrecheckFailure extends Error {
2508
+ findings;
2509
+ constructor(message, findings) {
2510
+ super(message);
2511
+ this.name = "PlanPolicyPrecheckFailure";
2512
+ this.findings = findings;
2513
+ }
2514
+ }
2515
+ /**
2516
+ * Shared tail of the plan patch validation: analyze the merged contract and
2517
+ * front-load the plan-attributable design-policy checks. Throws the same
2518
+ * errors the node self-check throws (FrontendContractFailure for schema
2519
+ * failures, Error for policy findings) so every caller — the node validator
2520
+ * and the finalize_plan receipt — surfaces identical diagnostics.
2521
+ */
2522
+ export async function analyzeFrontendPlanPatchCandidate(input) {
2523
+ const analysis = await analyzeFrontendImplementationContract(input);
2524
+ // A committed VT with a fabricated symbol is an immutable ledger fact —
2525
+ // the record boundary rejects duplicate ids, so the model cannot overwrite
2526
+ // it and throwing here deadlocks the receipt loop (r19: VT-AC006-BEHAVIOR).
2527
+ // Drop suspicious symbols deterministically instead: the VT stays valid and
2528
+ // the deterministic trace gate verifies file+command (and resolvability)
2529
+ // after verification. Mirrors the shell materialization's drop semantics.
2530
+ for (const target of analysis.canonical.verificationTargets) {
2531
+ if (target.symbol && isSuspiciousVerificationSymbol(target.symbol)) {
2532
+ target.symbol = undefined;
2533
+ }
2534
+ }
2535
+ // Front-load the verification-symbol shape check so fabricated symbols
2536
+ // are fixed by the plan retry ladder in-node instead of failing the
2537
+ // verify trace gate at the end of the run.
2538
+ assertVerificationSymbolShapes(analysis.canonical);
2539
+ // Front-load the plan-attributable design-policy checks so the §5.1
2540
+ // retry ladder can fix these facts in-node with diagnostics instead of
2541
+ // the run terminating at the policy shell. The policy shell remains the
2542
+ // final authority and re-runs the identical checks on committed facts.
2543
+ const policyPreFindings = [
2544
+ ...checkUiDesignCoverage(analysis.canonical),
2545
+ ...checkUiStateAttribution(analysis.canonical),
2546
+ ...checkDependencies(analysis.canonical, await deriveAllowedDependenciesFromRun(input.runDir)),
2547
+ ...checkTargetPaths(analysis.canonical, await deriveWriteSetFromRun(input.runDir)),
2548
+ ];
2549
+ if (policyPreFindings.length > 0) {
2550
+ const message = `frontend plan policy pre-check failed (fix these plan facts, then re-commit and finalize): ${policyPreFindings
2551
+ .map((finding) => `${finding.code}: ${finding.message}`)
2552
+ .join("; ")}`;
2553
+ throw new PlanPolicyPrecheckFailure(message, policyPreFindings.map((finding) => ({
2554
+ code: finding.code,
2555
+ message: finding.message,
2556
+ ...(finding.path ? { path: finding.path } : {}),
2557
+ })));
2558
+ }
2559
+ return analysis;
2560
+ }
1834
2561
  export async function validateFrontendPlanPatchNodeOutput(input) {
1835
2562
  const nodeId = input.nodeId ?? "frontend-plan-pi";
1836
2563
  const attempt = input.attempt ?? 1;
@@ -1867,22 +2594,22 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1867
2594
  const skeleton = input.structuredContractOutput?.skeleton;
1868
2595
  if (!skeleton)
1869
2596
  throw new Error("frontend plan patch validator requires a deterministic runtime skeleton");
1870
- const patch = extractFrontendImplementationJson(input.text);
2597
+ const committed = await readTypedEventStoreFromJsonl(path.join(input.runDir, nodeId, "plan-typed-facts.jsonl"));
2598
+ const patch = restorePlanPatchFromCommittedFacts(committed);
1871
2599
  if (!isPlainObject(patch))
1872
- throw new Error("frontend plan patch must extract to one JSON object");
2600
+ throw new Error("frontend plan ledger missing: no committed origin=plan facts (record_* + finalize_plan); narrative JSON is not the compile authority");
1873
2601
  extractedPatchPath = path.posix.join(candidateDir, `attempt-${attempt}.extracted.json`);
1874
2602
  const extractedArtifact = await writeDeterministicJsonArtifact(input.runDir, extractedPatchPath, patch);
1875
2603
  extractedPatchSha256 = extractedArtifact.sha256;
1876
- const citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
1877
2604
  const protectedViolations = frontendPlanPatchProtectedPathViolations(patch);
1878
2605
  if (protectedViolations.length > 0)
1879
2606
  throw new Error(`frontend plan patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`);
1880
2607
  const merged = applyFrontendContractMergePatch(skeleton, patch);
1881
- const droppedUnresolvedSymbols = await dropUnresolvedVerificationSymbols({
2608
+ const symbolNormalizationActions = await dropUnresolvedVerificationSymbols({
1882
2609
  runDir: input.runDir,
1883
2610
  contract: merged,
1884
2611
  });
1885
- const analysis = await analyzeFrontendImplementationContract({
2612
+ const analysis = await analyzeFrontendPlanPatchCandidate({
1886
2613
  runDir: input.runDir,
1887
2614
  rawContractText: serializeDeterministicJson(merged),
1888
2615
  sourceBinding: input.sourceBinding,
@@ -1894,20 +2621,13 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1894
2621
  normalizedContractSha256: normalizedArtifact.sha256,
1895
2622
  normalizationActions: [
1896
2623
  "apply-runtime-skeleton",
1897
- ...(droppedUnresolvedSymbols
1898
- ? ["drop-unresolved-verification-symbols"]
1899
- : []),
2624
+ ...symbolNormalizationActions,
1900
2625
  ...analysis.normalizationActions,
1901
2626
  ],
1902
2627
  errors: [],
1903
2628
  });
1904
2629
  const canonicalRel = canonicalContractRel(nodeId);
1905
2630
  const canonicalArtifact = await writeDeterministicJsonArtifact(input.runDir, canonicalRel, analysis.canonical);
1906
- const parsedCitations = parseOpenspecCitationsBlock(citationsBlock);
1907
- await writeDeterministicJsonArtifact(input.runDir, openspecCitationsRel(nodeId), {
1908
- citations: parsedCitations.citations,
1909
- block: citationsBlock,
1910
- });
1911
2631
  await persistRuntimeSkeleton({
1912
2632
  runDir: input.runDir,
1913
2633
  nodeId,
@@ -1918,11 +2638,17 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1918
2638
  sha256: canonicalArtifact.sha256,
1919
2639
  schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
1920
2640
  };
2641
+ // Human-readable plan summary appended after the artifact pointer. The
2642
+ // pointer remains the machine authority (path/schema/sha256); the
2643
+ // rendered summary is a deterministic view of the canonical contract so
2644
+ // Console/reviewers can read the plan without opening the JSON. It never
2645
+ // participates in downstream validation.
2646
+ const planSummary = renderFrontendPlanMarkdown(analysis.canonical);
1921
2647
  return {
1922
2648
  ok: true,
1923
2649
  contract: analysis.canonical,
1924
2650
  artifact,
1925
- normalizedText: formatStructuredArtifactPointer(artifact),
2651
+ normalizedText: `${formatStructuredArtifactPointer(artifact)}\n\n${planSummary}`,
1926
2652
  };
1927
2653
  }
1928
2654
  catch (error) {
@@ -1989,106 +2715,125 @@ export async function validateFrontendContractNodeOutput(input) {
1989
2715
  }
1990
2716
  }
1991
2717
  /**
1992
- * Node-output self-check for the plan revision node. Format-level checks still
1993
- * require one openspec-citations fence and one JSON object. When the original
1994
- * canonical contract and sourceBinding are present, this node is the merge
1995
- * authority: it applies the RFC 7386 patch, validates the full contract, and
1996
- * writes the revision node's own hash-bound canonical artifact.
2718
+ * Deterministically assemble the canonical frontend implementation contract
2719
+ * from committed typed facts, never from plan/contract node text.
2720
+ *
2721
+ * Inputs (all read-only, all fail-closed):
2722
+ * - `<planNodeId>/plan-typed-facts.jsonl` (phase=committed) restores the
2723
+ * editable RFC 7386 patch;
2724
+ * - `<contractNodeId>/contract-typed-facts.jsonl` (phase=committed) must carry
2725
+ * a `contract-finalized` terminal fact;
2726
+ * - `<contractNodeId>/frontend-task-contract-vNext.json` must exist and parse.
2727
+ *
2728
+ * The patch is applied onto the runtime skeleton and re-validated through the
2729
+ * same `analyzeFrontendImplementationContract` pipeline used by the legacy
2730
+ * text path, so the downstream artifact semantics are unchanged. The function
2731
+ * is pure: it never writes candidate/audit artifacts and always produces the
2732
+ * same canonical serialization for the same committed facts. It never falls
2733
+ * back to parsing Plan/Contract node text.
1997
2734
  */
1998
- export async function validateFrontendRevisionPatchNodeOutput(input) {
1999
- const fail = (reason) => {
2000
- const classified = classifyStructuredContractFailure({ reason });
2735
+ export async function buildContractFromCommittedFacts(input) {
2736
+ const contractNodeId = input.contractNodeId ?? "frontend-contract-pi";
2737
+ // Skeleton guard: the runtime skeleton must carry the protected identity
2738
+ // fields so the merged contract can be validated against the full schema.
2739
+ const skeletonRecord = asRecord(input.skeleton);
2740
+ const skeletonTargets = asRecord(skeletonRecord?.targets);
2741
+ const skeletonRiskLevel = asString(skeletonRecord?.riskLevel);
2742
+ if (!skeletonRecord ||
2743
+ !["small", "standard", "high-risk"].includes(skeletonRiskLevel) ||
2744
+ !Array.isArray(skeletonTargets?.files) ||
2745
+ skeletonTargets.files.length === 0) {
2001
2746
  return {
2002
2747
  ok: false,
2003
- reason,
2004
- classification: classified.classification,
2005
- errors: classified.errors,
2748
+ reason: "frontend contract skeleton missing required runtime fields (riskLevel/targets.files)",
2749
+ failureCode: "missing-skeleton",
2006
2750
  };
2007
- };
2008
- const nodeId = input.nodeId ?? "frontend-plan-revision-pi";
2009
- const attempt = input.attempt ?? 1;
2010
- await writeFrontendPlanCandidateRaw({
2011
- runDir: input.runDir,
2012
- nodeId,
2013
- attempt,
2014
- text: input.text,
2015
- });
2016
- let citationsBlock;
2017
- try {
2018
- citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
2019
2751
  }
2020
- catch (error) {
2021
- return fail(error instanceof Error ? error.message : String(error));
2752
+ // a. Committed plan ledger restores the editable patch.
2753
+ const planRecords = await readTypedEventStoreFromJsonl(path.join(input.runDir, input.planNodeId, "plan-typed-facts.jsonl"));
2754
+ const patch = restorePlanPatchFromCommittedFacts(planRecords);
2755
+ if (!isPlainObject(patch)) {
2756
+ return {
2757
+ ok: false,
2758
+ reason: "frontend plan ledger missing: no committed origin=plan facts (record_* + finalize_plan); narrative JSON is not the compile authority",
2759
+ failureCode: "missing-plan-ledger",
2760
+ };
2761
+ }
2762
+ // b. Committed contract terminal fact (contract-finalized).
2763
+ const contractRecords = await readTypedEventStoreFromJsonl(path.join(input.runDir, contractNodeId, "contract-typed-facts.jsonl"));
2764
+ const contractKinds = new Set(contractRecords
2765
+ .filter((record) => record.phase === "committed")
2766
+ .map((record) => record.fact.kind)
2767
+ .filter((kind) => typeof kind === "string"));
2768
+ if (!contractKinds.has("contract-finalized")) {
2769
+ return {
2770
+ ok: false,
2771
+ reason: "frontend contract facts missing: no committed contract-finalized terminal fact",
2772
+ failureCode: "missing-contract-finalize",
2773
+ };
2022
2774
  }
2023
- const textWithoutCitations = input.text.replace(citationsBlock, "");
2024
- let patch;
2775
+ // c. frontend-task-contract-vNext.json must exist and parse (input integrity).
2776
+ let rawVNext;
2025
2777
  try {
2026
- patch = extractFrontendImplementationJson(textWithoutCitations);
2778
+ rawVNext = await readFile(path.join(input.runDir, contractNodeId, "frontend-task-contract-vNext.json"), "utf8");
2027
2779
  }
2028
- catch (error) {
2029
- return fail(`revision patch output must contain exactly one valid JSON object: ${error instanceof Error ? error.message : String(error)}`);
2780
+ catch {
2781
+ return {
2782
+ ok: false,
2783
+ reason: `frontend task contract vNext missing at ${contractNodeId}/frontend-task-contract-vNext.json`,
2784
+ failureCode: "contract-vnext-missing",
2785
+ };
2030
2786
  }
2031
- if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
2032
- return fail("revision patch output must extract to exactly one JSON object (RFC 7386 merge-patch delta)");
2787
+ try {
2788
+ JSON.parse(rawVNext);
2033
2789
  }
2034
- const protectedViolations = frontendPlanPatchProtectedPathViolations(patch);
2035
- if (protectedViolations.length > 0) {
2036
- return fail(`frontend revision patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`);
2790
+ catch (error) {
2791
+ return {
2792
+ ok: false,
2793
+ reason: `frontend task contract vNext is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
2794
+ failureCode: "contract-vnext-invalid",
2795
+ };
2037
2796
  }
2038
- const original = (await readFrontendCanonicalCandidate(input.runDir, "frontend-plan-pi")) ??
2039
- (await readFrontendCanonicalCandidate(input.runDir, nodeId));
2040
- if (!input.sourceBinding) {
2041
- return { ok: true, contract: patch };
2797
+ // d. Merge the editable patch onto the protected skeleton and validate.
2798
+ let merged;
2799
+ try {
2800
+ merged = applyFrontendContractMergePatch(input.skeleton, patch);
2042
2801
  }
2043
- if (!original) {
2044
- return fail("frontend revision patch requires the original frontend-plan-pi canonical contract");
2802
+ catch (error) {
2803
+ return {
2804
+ ok: false,
2805
+ reason: `frontend contract patch could not be applied to the runtime skeleton: ${error instanceof Error ? error.message : String(error)}`,
2806
+ failureCode: "schema-drift",
2807
+ };
2045
2808
  }
2809
+ // The design-policy shell is the authoritative materializer of the
2810
+ // canonical contract; keep legacy symbol normalization identical to the
2811
+ // plan validator even though trace binding now uses stable target ids.
2812
+ await dropUnresolvedVerificationSymbols({
2813
+ runDir: input.runDir,
2814
+ contract: merged,
2815
+ });
2046
2816
  try {
2047
- const merged = applyFrontendContractMergePatch(original, patch);
2048
- const droppedUnresolvedSymbols = await dropUnresolvedVerificationSymbols({
2049
- runDir: input.runDir,
2050
- contract: merged,
2051
- });
2052
2817
  const analysis = await analyzeFrontendImplementationContract({
2053
2818
  runDir: input.runDir,
2054
2819
  rawContractText: serializeDeterministicJson(merged),
2055
2820
  sourceBinding: input.sourceBinding,
2056
2821
  });
2057
- const candidateDir = path.posix.join("contracts", "candidates", nodeId);
2058
- await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.extracted.json`), patch);
2059
- await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.normalized.json`), analysis.canonical);
2060
- await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.validation.json`), {
2061
- classification: "accepted-normalized",
2062
- normalizationActions: [
2063
- "apply-original-canonical",
2064
- ...(droppedUnresolvedSymbols
2065
- ? ["drop-unresolved-verification-symbols"]
2066
- : []),
2067
- ...analysis.normalizationActions,
2068
- ],
2069
- errors: [],
2070
- });
2071
- const canonicalArtifact = await writeDeterministicJsonArtifact(input.runDir, canonicalContractRel(nodeId), analysis.canonical);
2072
- const parsedCitations = parseOpenspecCitationsBlock(citationsBlock);
2073
- await writeDeterministicJsonArtifact(input.runDir, openspecCitationsRel(nodeId), {
2074
- citations: parsedCitations.citations,
2075
- block: citationsBlock,
2076
- });
2077
- const artifact = {
2078
- path: canonicalArtifact.path,
2079
- sha256: canonicalArtifact.sha256,
2080
- schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
2081
- };
2082
2822
  return {
2083
2823
  ok: true,
2084
- contract: analysis.canonical,
2085
- artifact,
2086
- normalizedText: formatStructuredArtifactPointer(artifact),
2824
+ canonical: analysis.canonical,
2825
+ normalizationActions: analysis.normalizationActions,
2826
+ candidateJsonSha256: analysis.candidateJsonSha256,
2087
2827
  };
2088
2828
  }
2089
2829
  catch (error) {
2090
- const reason = error instanceof Error ? error.message : String(error);
2091
- return fail(reason);
2830
+ const failureKind = error instanceof FrontendContractFailure ? error.kind : undefined;
2831
+ return {
2832
+ ok: false,
2833
+ reason: `frontend contract schema drift: ${error instanceof Error ? error.message : String(error)}`,
2834
+ failureCode: "schema-drift",
2835
+ ...(failureKind ? { failureKind } : {}),
2836
+ };
2092
2837
  }
2093
2838
  }
2094
2839
  export async function materializeFrontendImplementationContract(input) {