@tea-agent/loop-agent 0.43.0-next.18 → 0.43.0-next.19

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 (161) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +60 -2308
  3. package/README.md +7 -0
  4. package/dist/application/task-lifecycle/advance.js +38 -7
  5. package/dist/build-stamp.json +3 -3
  6. package/dist/commands/init.js +13 -9
  7. package/dist/commands/task-advance.js +32 -0
  8. package/dist/executors/dag-pi-executor.js +1615 -188
  9. package/dist/executors/pi-executor.js +85 -3
  10. package/dist/executors/pi-sdk-executor.js +18 -0
  11. package/dist/executors/shell-executor.js +92 -7
  12. package/dist/shared/backend-dogfood-preflight.js +47 -0
  13. package/dist/shared/dag-failure-category.js +3 -0
  14. package/dist/shared/operator/capabilities.js +180 -0
  15. package/dist/task/config-types.js +29 -0
  16. package/dist/task/contract/project.js +9 -0
  17. package/dist/task/contract/schema.js +17 -0
  18. package/dist/task/source-prepare/build-draft.js +60 -0
  19. package/dist/worker/console/chat/pi-mode-loop-isolation.js +155 -0
  20. package/dist/worker/console/chat/pi-runtime/custom-tools/operator-tools.js +24 -16
  21. package/dist/worker/console/chat/pi-runtime/custom-tools/scheduled-goal-tools.js +22 -0
  22. package/dist/worker/console/chat/pi-runtime/progressive-tools.js +195 -0
  23. package/dist/worker/console/chat/pi-runtime.js +86 -27
  24. package/dist/worker/console/chat/routes.js +19 -0
  25. package/dist/worker/console/chat/scheduled-goal-booking.js +59 -0
  26. package/dist/worker/console/chat/scheduled-goal-delivery.js +27 -0
  27. package/dist/worker/console/chat/scheduled-goal-request.js +190 -0
  28. package/dist/worker/console/chat/session-mode.js +7 -3
  29. package/dist/worker/console/chat/session-store.js +42 -9
  30. package/dist/worker/console/chat/tool-preview.js +115 -0
  31. package/dist/worker/console/chat/turn-order.js +13 -0
  32. package/dist/worker/console/chat/turn-process.js +32 -30
  33. package/dist/worker/console/operator-actions.js +50 -0
  34. package/dist/worker/console/prd-intake-bridge.js +54 -1
  35. package/dist/worker/console/scheduled-goal-host.js +98 -0
  36. package/dist/worker/console/scheduled-goal-operation-adapter.js +127 -0
  37. package/dist/worker/console/server.js +15 -0
  38. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-B_UDknhR.js → abnfDiagram-N423BO3Z-8-j6y-sd.js} +1 -1
  39. package/dist/worker/console/static/assets/{arc-D6hU0drN.js → arc-eoQiMvuk.js} +1 -1
  40. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DmVikdRK.js → architectureDiagram-T3A2C74G-C4A3uMcI.js} +1 -1
  41. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DydABCRg.js → blockDiagram-VBNYF7ZC-D4zD2F-Q.js} +1 -1
  42. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DrXKC2Jl.js → c4Diagram-5PPSVZJV-j1RkJziL.js} +1 -1
  43. package/dist/worker/console/static/assets/channel-ChE7y-cx.js +1 -0
  44. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D2-qFxr7.js → chunk-2GRJ4B5K-YBHmhik1.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-ChfeF41P.js → chunk-2Q5K7J3B-COfWyo9P.js} +1 -1
  46. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Drwrz55x.js → chunk-5RXB4S5H-I99OUkHY.js} +1 -1
  47. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CAQGErL7.js → chunk-5VM5RSS4-XKxoJNJ4.js} +1 -1
  48. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DHO38w5U.js → chunk-6Q2QTUOP-DLT_cYx1.js} +1 -1
  49. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-EYIN0EOI.js → chunk-GF5L2VYU-CxcKZ9mV.js} +1 -1
  50. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CrFLKqkT.js → chunk-JWPE2WC7-V1EqXdjY.js} +1 -1
  51. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DMchffoo.js → chunk-KBJHAD2P-DebTtdFp.js} +1 -1
  52. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DaSvtKQa.js → chunk-RYQCIY6F-B-ivNRDf.js} +1 -1
  53. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DcW8W9Qj.js → chunk-XXDRQBXY-DC_Ds11b.js} +1 -1
  54. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C01TCf2X.js +1 -0
  55. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C01TCf2X.js +1 -0
  56. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BUirrMEi.js → cose-bilkent-JH36ORCC-sWEqKwIb.js} +1 -1
  57. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BXzO8iXR.js → cynefin-VYW2F7L2-BXa_dcu4.js} +1 -1
  58. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DAtgaxIn.js → cynefinDiagram-MW4NZA55-C-Mle84F.js} +1 -1
  59. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C1re9Noh.js → dagre-VZM6K2ZE-CXPDBITe.js} +1 -1
  60. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-N8Kj08J9.js → diagram-7IWD3JNH-CC-WJQfa.js} +1 -1
  61. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-D6BpSLs3.js → diagram-B4RE2ZJO-CDAV5Vs4.js} +1 -1
  62. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C2Vlgnl4.js → diagram-LBJQPF4R-CmFiAcNz.js} +1 -1
  63. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-CMbqtVLS.js → diagram-Q27KOJAE-DPBZHuyn.js} +1 -1
  64. package/dist/worker/console/static/assets/{diagram-UB23O5K3-A770Eb-0.js → diagram-UB23O5K3-jUlm_Ds3.js} +1 -1
  65. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-LV2w_2pT.js → ebnfDiagram-BXEA7PRR-DWhQ3mfY.js} +1 -1
  66. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-bwqf56ah.js → erDiagram-JOGREHBK-Tr2gMqet.js} +1 -1
  67. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BnVtHhZh.js → flowDiagram-UKHOOZJN-DJjVQHPA.js} +1 -1
  68. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BGVToEqc.js → ganttDiagram-PKOTCBZU-D74dQ4u0.js} +1 -1
  69. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DL2l7Vne.js → gitGraphDiagram-DS77QQ5N-DwW0tZ0X.js} +1 -1
  70. package/dist/worker/console/static/assets/index-BWkIfcrK.css +1 -0
  71. package/dist/worker/console/static/assets/{index-B_V4wvXs.js → index-Cdkvw_H6.js} +97 -97
  72. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BWIpubOx.js → infoDiagram-6WML65LV-ILCbxJyb.js} +1 -1
  73. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DjYBD8vv.js → ishikawaDiagram-WSZJBQD7-DeuWBQ89.js} +1 -1
  74. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-C0_mBaHb.js → journeyDiagram-NVQOT4AX-CF5ih8Fk.js} +1 -1
  75. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Cy71zbX6.js → kanban-definition-27J2QSJJ-C7yOSuRO.js} +1 -1
  76. package/dist/worker/console/static/assets/{linear-Dt3_w3Vn.js → linear-BDZ9riWi.js} +1 -1
  77. package/dist/worker/console/static/assets/{mermaid.core-DnNOlfEP.js → mermaid.core-7pKqYtpZ.js} +5 -5
  78. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DAbTspwq.js → mindmap-definition-FAOFIHXS-LeJDybSU.js} +1 -1
  79. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-NNiM141p.js → pegDiagram-VL7TDLO6-BJvT3pMD.js} +1 -1
  80. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CN80Z1Do.js → pieDiagram-7S7Q4E2Y-_rGqpMan.js} +1 -1
  81. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bxyfh0JU.js → quadrantDiagram-CIZ2JOQS-DPcSv5aA.js} +1 -1
  82. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BlWnC79M.js → railroadDiagram-AXF67PYL-Drxx4hkJ.js} +1 -1
  83. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BrEu4P_e.js → requirementDiagram-LRYGKXZP-BCTUdU4z.js} +1 -1
  84. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Bt70igBr.js → sankeyDiagram-W5VNT64P-B6wzbZmB.js} +1 -1
  85. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-C8DQR-r9.js → sequenceDiagram-SI44F4Z6-BGt8d3QQ.js} +1 -1
  86. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DEMinJAT.js → sizeCapture-X5ZJPWSS-7nlhJKo7.js} +1 -1
  87. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-DDdDBzQc.js → stateDiagram-OKZ733FA-Cv2stqMA.js} +1 -1
  88. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DC7V5vcq.js +1 -0
  89. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-G2wcBs3T.js → swimlanes-SLNWSIFB-qDAo4Yc1.js} +2 -2
  90. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bwy4QUTO.js +8 -0
  91. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Dzn4g2gJ.js → timeline-definition-Z64GVDOM-CKbDNKTr.js} +1 -1
  92. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-5v2rP9oO.js → vennDiagram-T6HMQDX7-DI-9EHic.js} +1 -1
  93. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-B4EOx7ew.js → wardleyDiagram-T6FBY63Y-BrzzRDpX.js} +1 -1
  94. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CQ1v8eYP.js → xychartDiagram-ELKLHX3M-BclH5hGh.js} +1 -1
  95. package/dist/worker/console/static/index.html +2 -2
  96. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +41 -11
  97. package/dist/worker/console/static-src/operator-chat/compaction-message.js +3 -17
  98. package/dist/worker/console/static-src/operator-chat/timeline-merge.js +29 -0
  99. package/dist/worker/console/static-src/operator-chat/useChatThread.js +2 -5
  100. package/dist/worker/console/workspace-context.js +11 -0
  101. package/dist/worker/observe/static/operator-chrome.js +3 -1
  102. package/dist/worker/scheduler/scheduled-goal-dispatch.js +117 -0
  103. package/dist/worker/scheduler/scheduled-goal-evidence.js +167 -0
  104. package/dist/worker/scheduler/scheduled-goal-recovery.js +62 -0
  105. package/dist/worker/scheduler/scheduled-goal-store.js +699 -0
  106. package/dist/worker/scheduler/scheduled-goal-supervisor.js +132 -0
  107. package/dist/worker/scheduler/scheduled-goal-time.js +102 -0
  108. package/dist/worker/scheduler/scheduled-goal-types.js +95 -0
  109. package/dist/workflows/dag/backend-test-markdown-workflow.js +6 -24
  110. package/dist/workflows/dag/backend-test-plan-protocol.js +82 -5
  111. package/dist/workflows/dag/dag-retry-schema.js +11 -0
  112. package/dist/workflows/dag/frontend-closeout.js +4 -2
  113. package/dist/workflows/dag/frontend-committed-facts.js +461 -0
  114. package/dist/workflows/dag/frontend-durable-tools.js +15 -3
  115. package/dist/workflows/dag/frontend-implementation-contract.js +365 -8
  116. package/dist/workflows/dag/frontend-plan-canary.js +53 -0
  117. package/dist/workflows/dag/frontend-plan-decision-contract.js +803 -0
  118. package/dist/workflows/dag/frontend-plan-render.js +0 -2
  119. package/dist/workflows/dag/frontend-prewrite-gate.js +3 -3
  120. package/dist/workflows/dag/frontend-provider-capability-matrix.js +8 -61
  121. package/dist/workflows/dag/frontend-recovery-run.js +57 -0
  122. package/dist/workflows/dag/frontend-review-context.js +43 -70
  123. package/dist/workflows/dag/frontend-risk.js +92 -10
  124. package/dist/workflows/dag/frontend-session-budget.js +117 -3
  125. package/dist/workflows/dag/frontend-shape.js +11 -55
  126. package/dist/workflows/dag/frontend-test-execution-evidence.js +35 -10
  127. package/dist/workflows/dag/frontend-typed-event-store.js +32 -25
  128. package/dist/workflows/dag/frontend-verification-trace.js +11 -2
  129. package/dist/workflows/dag/frontend-writer-admission.js +3 -47
  130. package/dist/workflows/dag/frontend-writer-status.js +0 -23
  131. package/dist/workflows/dag/init-hybrid.js +182 -185
  132. package/dist/workflows/dag/lifecycle.js +7 -2
  133. package/dist/workflows/dag/node-execution.js +48 -13
  134. package/dist/workflows/dag/rerun-plan.js +10 -0
  135. package/dist/workflows/dag/rerun-task.js +77 -1
  136. package/dist/workflows/dag/retry-policy.js +18 -0
  137. package/dist/workflows/dag/scheduler.js +2 -4
  138. package/dist/workflows/dag/types.js +23 -0
  139. package/docs/README.md +1 -0
  140. package/docs/init-surface.manifest.json +1 -0
  141. package/docs/operations/README.md +2 -0
  142. package/docs/templates/README.md +1 -1
  143. package/docs/templates/agent-dag.schema.json +2 -2
  144. package/docs/templates/backend-test-dag.json +14 -10
  145. package/docs/templates/frontend-implementation-contract.schema.json +0 -7
  146. package/docs/templates/frontend-implementation-dag.json +8 -9
  147. package/package.json +6 -3
  148. package/skills/frontend-bounded-implement/SKILL.md +3 -4
  149. package/skills/frontend-design-review/SKILL.md +7 -12
  150. package/skills/frontend-design-review/references/review-checklist.md +7 -7
  151. package/skills/frontend-plan/SKILL.md +8 -18
  152. package/skills/frontend-plan/references/decision-contract.md +19 -26
  153. package/skills/frontend-review/SKILL.md +21 -25
  154. package/skills/frontend-review/references/review-findings.md +48 -24
  155. package/dist/worker/console/static/assets/channel-Drecd94a.js +0 -1
  156. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-8Udu0t8-.js +0 -1
  157. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-8Udu0t8-.js +0 -1
  158. package/dist/worker/console/static/assets/index-DcudonhZ.css +0 -1
  159. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-CyCiS0Lt.js +0 -1
  160. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-U0lhi6N0.js +0 -8
  161. package/dist/workflows/dag/frontend-shadow-dual-write.js +0 -975
@@ -0,0 +1,803 @@
1
+ import { z } from "zod";
2
+ import { pathMatchesPattern } from "../../shared/git-progress.js";
3
+ import { deterministicSha256 } from "./frontend-implementation-contract.js";
4
+ import { isConcreteWriteSetPath } from "./frontend-writer-admission.js";
5
+ /** Experimental model input/output. Not a canonical writer authority. The
6
+ * only coverage judgment is behaviorGroupId -> verification focus. Group
7
+ * membership belongs to the source-bound Contract, not another Plan table. */
8
+ export const frontendPlanDecisionContractSchema = z.object({
9
+ schemaId: z.literal("frontend-plan-decision-contract-v1"),
10
+ modulePlacements: z.array(z.object({ id: z.string().min(1), paths: z.array(z.string().min(1)).min(1) }).strict()),
11
+ reuseDecisions: z.array(z.object({
12
+ symbol: z.string().min(1),
13
+ decision: z.enum(["specified", "reuse-existing", "new"]),
14
+ evidence: z.array(z.string()),
15
+ // Required at tool level for specified/new (the spec section or task-source
16
+ // declaration the choice implements); the bridge carries it into the
17
+ // canonical specReference.section, which the contract schema mandates.
18
+ specSection: z.string().min(1).optional(),
19
+ purpose: z.string().min(1), covers: z.array(z.string().min(1)).optional(), rationale: z.string().min(1).optional(),
20
+ // Optional task-wide style contract carried once on any choice row (the
21
+ // relationship component-choice channel); the bridge promotes the first
22
+ // non-empty value to the canonical top-level stylingStrategy.
23
+ stylingStrategy: z.string().min(1).optional(),
24
+ }).strict()),
25
+ stateOwnership: z.array(z.object({
26
+ state: z.string().min(1), owner: z.string().min(1),
27
+ applicable: z.boolean(), expectedBehavior: z.string().min(1).optional(), notApplicableReason: z.string().min(1).optional(),
28
+ }).strict()),
29
+ dataFlows: z.array(z.object({
30
+ interaction: z.string().min(1), source: z.string().min(1),
31
+ trigger: z.string().min(1), expectedBehavior: z.string().min(1),
32
+ }).strict()),
33
+ apiMockBoundaries: z.array(z.object({
34
+ boundary: z.string().min(1),
35
+ mode: z.enum(["real", "mock", "not-needed"]),
36
+ evidence: z.string().min(1),
37
+ // Design policy hard-requires fixture + consumer on every derived endpoint
38
+ // (non-not-needed strategies); the bridge promotes them verbatim instead of
39
+ // fabricating paths. Required at tool level for real/mock (gate).
40
+ fixture: z.string().min(1).optional(),
41
+ consumer: z.string().min(1).optional(),
42
+ }).strict()),
43
+ dependencies: z.array(z.string()),
44
+ verificationFocus: z.array(z.object({
45
+ id: z.string().min(1), behaviorGroupId: z.string().min(1),
46
+ file: z.string().min(1), commandId: z.string().min(1),
47
+ evidenceLevel: z.enum(["unit", "mounted", "real-integration"]),
48
+ boundary: z.string().min(1).optional(),
49
+ }).strict()),
50
+ }).strict();
51
+ const sorted = (values) => [...new Set(values)].sort();
52
+ const concrete = (file) => isConcreteWriteSetPath(file) && !/[\[\]{}():]/.test(file) && !file.split("/").includes(".");
53
+ export function projectFrontendSafetyFacts(input) {
54
+ const decision = frontendPlanDecisionContractSchema.parse(input.decision);
55
+ const { authority } = input;
56
+ const findings = [];
57
+ if (!/^[a-f0-9]{64}$/.test(authority.sourceBinding.requirementSha256) || authority.sourceBinding.requirementSha256 !== authority.observedRequirementSha256)
58
+ findings.push("source-binding-stale-or-invalid");
59
+ const requirementIds = sorted(authority.requirements.map(item => item.id));
60
+ if (requirementIds.length !== authority.requirements.length || authority.requirements.some(item => !item.id.trim() || !item.expectedOutcome.trim()))
61
+ findings.push("invalid-requirements");
62
+ const groups = new Map(authority.behaviorGroups.map(group => [group.id, group]));
63
+ if (groups.size !== authority.behaviorGroups.length)
64
+ findings.push("duplicate-behavior-group");
65
+ const ownedStates = new Set(decision.stateOwnership.map(item => item.state));
66
+ for (const group of authority.behaviorGroups) {
67
+ if (!group.requirementIds.length)
68
+ findings.push(`empty-behavior-group:${group.id}`);
69
+ for (const id of group.requirementIds)
70
+ if (!requirementIds.includes(id))
71
+ findings.push(`unknown-requirement:${id}`);
72
+ for (const state of group.requiredStates ?? []) {
73
+ if (!ownedStates.has(state))
74
+ findings.push(`missing-state:${group.id}:${state}`);
75
+ }
76
+ }
77
+ const mockBoundaries = new Set(decision.apiMockBoundaries.filter(item => item.mode === "mock").map(item => item.boundary));
78
+ const verificationByRequirement = Object.fromEntries(requirementIds.map(id => [id, []]));
79
+ const seen = new Set();
80
+ const levels = { unit: 0, mounted: 1, "real-integration": 2 };
81
+ const verificationTargets = decision.verificationFocus.map(focus => {
82
+ if (seen.has(focus.id))
83
+ findings.push(`duplicate-verification:${focus.id}`);
84
+ seen.add(focus.id);
85
+ const group = groups.get(focus.behaviorGroupId);
86
+ if (!group)
87
+ findings.push(`unknown-behavior-group:${focus.behaviorGroupId}`);
88
+ if (!authority.commands.some(command => command.id === focus.commandId))
89
+ findings.push(`unknown-command:${focus.commandId}`);
90
+ if (group && levels[focus.evidenceLevel] < levels[group.minimumEvidenceLevel])
91
+ findings.push(`insufficient-evidence-level:${focus.id}`);
92
+ if (focus.boundary && focus.evidenceLevel === "real-integration" && mockBoundaries.has(focus.boundary))
93
+ findings.push(`mock-claimed-as-real:${focus.id}:${focus.boundary}`);
94
+ const ids = sorted(group?.requirementIds ?? []);
95
+ for (const id of ids)
96
+ verificationByRequirement[id]?.push(focus.id);
97
+ return { id: focus.id, file: focus.file, commandId: focus.commandId, requirementIds: ids };
98
+ });
99
+ for (const id of requirementIds)
100
+ verificationByRequirement[id] = sorted(verificationByRequirement[id]);
101
+ const deliveryFiles = sorted([...decision.modulePlacements.flatMap(item => item.paths), ...verificationTargets.map(item => item.file)]);
102
+ for (const file of sorted([...deliveryFiles, ...input.concreteWriteSet])) {
103
+ if (!concrete(file))
104
+ findings.push(`write-set-path-not-concrete:${file}`);
105
+ if (!authority.allowedPaths.some(pattern => pathMatchesPattern(file, pattern)) || authority.forbiddenPaths.some(pattern => pathMatchesPattern(file, pattern)))
106
+ findings.push(`write-set-outside-authority:${file}`);
107
+ }
108
+ // References in reuseDecisions never expand delivery files or authorization.
109
+ const writeSetGaps = deliveryFiles.filter(file => !input.concreteWriteSet.includes(file));
110
+ for (const file of input.concreteWriteSet)
111
+ if (!deliveryFiles.includes(file))
112
+ findings.push(`unplanned-write:${file}`);
113
+ const missingVerificationRequirementIds = requirementIds.filter(id => !verificationByRequirement[id].length);
114
+ return { schemaId: "frontend-safety-projection-v1", authorityDigest: deterministicSha256(authority), requirementIds, deliveryFiles, verificationTargets, verificationByRequirement, missingVerificationRequirementIds, writeSetGaps, findings: sorted(findings) };
115
+ }
116
+ /** Compile the experimental Plan output into a canonical-contract-shaped
117
+ * requirements + verificationTargets view. The model supplies semantic decisions
118
+ * only: module placements, reuse, state ownership, data flows, Mock boundaries
119
+ * and one behavior-group -> verification-focus judgment per group. The runtime
120
+ * expands group membership (source-bound) and derives the per-AC
121
+ * implementationTargets and verificationTargetIds that production Policy /
122
+ * Admission consume. This proves the per-AC file table is derivable and does
123
+ * not have to be re-entered by the planner. */
124
+ export function assembleExperimentPlanContract(input) {
125
+ const projection = projectFrontendSafetyFacts(input);
126
+ const groupByRequirement = new Map();
127
+ for (const group of input.authority.behaviorGroups) {
128
+ for (const id of group.requirementIds)
129
+ groupByRequirement.set(id, group.id);
130
+ }
131
+ const placementPathsById = new Map(input.decision.modulePlacements.map(item => [item.id, item.paths]));
132
+ const unplacedRequirementIds = [];
133
+ const requirements = input.authority.requirements.map(req => {
134
+ const groupId = groupByRequirement.get(req.id);
135
+ const implementationTargets = groupId
136
+ ? sorted(placementPathsById.get(groupId) ?? [])
137
+ : [];
138
+ if (implementationTargets.length === 0)
139
+ unplacedRequirementIds.push(req.id);
140
+ return {
141
+ id: req.id,
142
+ expectedOutcome: req.expectedOutcome,
143
+ implementationTargets,
144
+ deliverableTargets: [],
145
+ verificationTargetIds: projection.verificationByRequirement[req.id] ?? [],
146
+ };
147
+ });
148
+ const uiStates = input.decision.stateOwnership.map(item => ({
149
+ name: item.state,
150
+ applicable: item.applicable,
151
+ ...(item.expectedBehavior ? { expectedBehavior: item.expectedBehavior } : {}),
152
+ ...(item.notApplicableReason ? { notApplicableReason: item.notApplicableReason } : {}),
153
+ }));
154
+ const focusIdsByGroup = new Map();
155
+ for (const focus of input.decision.verificationFocus) {
156
+ const ids = focusIdsByGroup.get(focus.behaviorGroupId) ?? [];
157
+ ids.push(focus.id);
158
+ focusIdsByGroup.set(focus.behaviorGroupId, ids);
159
+ }
160
+ const interactions = input.decision.dataFlows.map(flow => ({
161
+ name: flow.interaction,
162
+ trigger: flow.trigger,
163
+ expectedBehavior: flow.expectedBehavior,
164
+ implementationTargets: sorted(placementPathsById.get(flow.source) ?? []),
165
+ verificationTargetIds: sorted(focusIdsByGroup.get(flow.source) ?? []),
166
+ }));
167
+ const uiComponentChoices = input.decision.reuseDecisions.map(item => {
168
+ const evidencePath = item.evidence[0];
169
+ return {
170
+ purpose: item.purpose,
171
+ component: item.symbol,
172
+ decision: item.decision,
173
+ specReference: evidencePath
174
+ ? { path: evidencePath, ...(item.specSection ? { section: item.specSection } : {}) }
175
+ : null,
176
+ rationale: item.rationale ?? "",
177
+ ...(item.covers ? { covers: item.covers } : {}),
178
+ };
179
+ });
180
+ const boundaries = input.decision.apiMockBoundaries;
181
+ const mockStrategy = boundaries.some(item => item.mode === "mock")
182
+ ? "browser-intercept"
183
+ : boundaries.some(item => item.mode === "real")
184
+ ? "native"
185
+ : "not-needed";
186
+ const mockEndpoints = boundaries.map(item => {
187
+ const [method = "", ...pathParts] = item.boundary.split(/\s+/);
188
+ const rawPath = (pathParts.join(" ") || item.boundary).trim();
189
+ return {
190
+ method,
191
+ path: rawPath.startsWith("/") ? rawPath : `/${rawPath}`,
192
+ ...(item.fixture ? { fixture: item.fixture } : {}),
193
+ ...(item.consumer ? { consumer: item.consumer } : {}),
194
+ };
195
+ });
196
+ const mockApi = {
197
+ strategy: mockStrategy,
198
+ productionDefaultOff: true,
199
+ activation: "explicit activation boundary",
200
+ endpoints: mockEndpoints,
201
+ };
202
+ const targets = { files: projection.deliveryFiles };
203
+ const designEvidence = { source: "experiment-decision", paths: [], conflicts: [] };
204
+ const dependencyPolicy = input.decision.dependencies.length > 0
205
+ ? input.decision.dependencies.join(", ")
206
+ : "零新增";
207
+ const verificationTargets = projection.verificationTargets.map(target => ({
208
+ ...target,
209
+ mode: "behavior",
210
+ commandLabel: target.commandId,
211
+ uiStates: [],
212
+ }));
213
+ return {
214
+ requirements,
215
+ verificationTargets,
216
+ uiStates,
217
+ interactions,
218
+ uiComponentChoices,
219
+ mockApi,
220
+ targets,
221
+ designEvidence,
222
+ dependencyPolicy,
223
+ deliveryFiles: projection.deliveryFiles,
224
+ missingVerificationRequirementIds: projection.missingVerificationRequirementIds,
225
+ unplacedRequirementIds: sorted(unplacedRequirementIds),
226
+ writeSetGaps: projection.writeSetGaps,
227
+ findings: sorted([
228
+ ...projection.findings,
229
+ ...unplacedRequirementIds.map(id => `unplaced-requirement:${id}`),
230
+ ]),
231
+ };
232
+ }
233
+ /** Fact kinds emitted by the experimental decision Plan tools. Each tool's
234
+ * entry mirrors the corresponding decision-schema array element exactly. */
235
+ export const FRONTEND_DECISION_FACT_KINDS = [
236
+ "module-placement",
237
+ "reuse-decision",
238
+ "state-ownership",
239
+ "data-flow",
240
+ "api-mock-boundary",
241
+ "verification-focus",
242
+ "dependency",
243
+ ];
244
+ function isRecord(value) {
245
+ return typeof value === "object" && value !== null && !Array.isArray(value);
246
+ }
247
+ /** Case/separator-insensitive identity used by `checkUxNameCollisions`. The
248
+ * decision ledger is append-only across retry attempts (a correction re-records
249
+ * a state/flow with `replace` only for id-keyed kinds), so the same concept
250
+ * legitimately appears under "increment counter", "increment-counter", … .
251
+ * Grouping by this identity and letting the LAST committed row win collapses
252
+ * retry churn onto the model's final correction instead of failing the derived
253
+ * canonical with ux-name-collision. */
254
+ function planNameKey(value) {
255
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
256
+ }
257
+ const HTTP_METHODS = new Set([
258
+ "GET",
259
+ "POST",
260
+ "PUT",
261
+ "PATCH",
262
+ "DELETE",
263
+ "HEAD",
264
+ "OPTIONS",
265
+ ]);
266
+ const NO_DEPENDENCY_TOKENS = new Set(["none", "无", "无新增", "不新增"]);
267
+ /**
268
+ * Compile the relationship-shaped plan patch from a *finalized* decision
269
+ * contract (the same one `finalizeDecision` derived the safety projection
270
+ * from). This is the decision → relationship bridge: after a successful
271
+ * decision finalize the executor publishes the patch here as a
272
+ * `target-surface` + `finalize_plan` ledger pair so the plan node's R1
273
+ * self-check and every downstream shell see the canonical compile authority
274
+ * they already consume on the relationship path.
275
+ *
276
+ * The pure function is intentionally free of the UI-name → requirement heuristics
277
+ * that plague relationship-model output: every row is bound from the decision's
278
+ * own group structure, so nothing is invented here.
279
+ */
280
+ export function buildFrontendPlanRelationshipPatch(input) {
281
+ const { decision, authority } = input;
282
+ const groupByRequirement = new Map();
283
+ for (const group of authority.behaviorGroups) {
284
+ for (const id of group.requirementIds)
285
+ groupByRequirement.set(id, group.id);
286
+ }
287
+ const groupById = new Map(authority.behaviorGroups.map(group => [group.id, group]));
288
+ const placementPathsById = new Map(decision.modulePlacements.map(item => [item.id, item.paths]));
289
+ const vtIdsByGroup = new Map();
290
+ for (const focus of decision.verificationFocus) {
291
+ const ids = vtIdsByGroup.get(focus.behaviorGroupId) ?? [];
292
+ if (!ids.includes(focus.id))
293
+ ids.push(focus.id);
294
+ vtIdsByGroup.set(focus.behaviorGroupId, ids);
295
+ }
296
+ const requiredByRequirement = new Map();
297
+ for (const [requirementId, paths] of input.requiredDeliverables ?? []) {
298
+ const set = requiredByRequirement.get(requirementId) ?? new Set();
299
+ for (const file of paths)
300
+ if (file.trim())
301
+ set.add(file.trim());
302
+ requiredByRequirement.set(requirementId, set);
303
+ }
304
+ // ---- Raw state rows (dedupe by case/separator-insensitive name, last row
305
+ // wins). Canonical applicability/attribution is decided below after the
306
+ // component choices' group coverage is known.
307
+ const stateByKey = new Map();
308
+ const stateKeyOrder = [];
309
+ for (const entry of decision.stateOwnership) {
310
+ const key = planNameKey(entry.state);
311
+ if (!key)
312
+ continue;
313
+ if (!stateByKey.has(key))
314
+ stateKeyOrder.push(key);
315
+ stateByKey.set(key, entry);
316
+ }
317
+ // ---- interactions: derived from data flows; dedupe by name identity, last
318
+ // committed flow wins (the model's final correction vocabulary), and bind
319
+ // implementationTargets / verificationTargetIds from the flow source group.
320
+ const flowByKey = new Map();
321
+ const flowKeyOrder = [];
322
+ for (const flow of decision.dataFlows) {
323
+ const key = planNameKey(flow.interaction);
324
+ if (!key)
325
+ continue;
326
+ if (!flowByKey.has(key))
327
+ flowKeyOrder.push(key);
328
+ flowByKey.set(key, flow);
329
+ }
330
+ const interactions = [];
331
+ const interactionNamesByGroup = new Map();
332
+ for (const key of flowKeyOrder) {
333
+ const flow = flowByKey.get(key);
334
+ const groupId = flow.source;
335
+ interactions.push({
336
+ name: flow.interaction,
337
+ trigger: flow.trigger,
338
+ expectedBehavior: flow.expectedBehavior,
339
+ implementationTargets: sorted(placementPathsById.get(groupId) ?? []),
340
+ verificationTargetIds: sorted(vtIdsByGroup.get(groupId) ?? []),
341
+ });
342
+ const names = interactionNamesByGroup.get(groupId) ?? [];
343
+ names.push(flow.interaction);
344
+ interactionNamesByGroup.set(groupId, names);
345
+ }
346
+ // ---- Which behavior groups do the component decisions actually cover?
347
+ // A reuse row covers a group when one of its `covers` entries names the
348
+ // group directly or one of its requirement ids, or when the row's `purpose`
349
+ // is exactly an applicable state / interaction name owned by the group
350
+ // (single-purpose rows like "loading"). Union across every reuse row: a
351
+ // state row below may only stay applicable when its owner group is designed
352
+ // by at least one choice — a finalized decision set that declares an
353
+ // applicable state with no design choice is internally inconsistent, and the
354
+ // derived canonical cannot invent a component for it.
355
+ const rawStateByGroup = new Map();
356
+ for (const key of stateKeyOrder) {
357
+ const entry = stateByKey.get(key);
358
+ const names = rawStateByGroup.get(entry.owner) ?? [];
359
+ if (!names.includes(entry.state))
360
+ names.push(entry.state);
361
+ rawStateByGroup.set(entry.owner, names);
362
+ }
363
+ const designedGroupIds = new Set();
364
+ for (const entry of decision.reuseDecisions) {
365
+ for (const cover of entry.covers ?? []) {
366
+ const groupId = groupByRequirement.get(cover) ??
367
+ (groupById.has(cover) ? cover : undefined);
368
+ if (groupId)
369
+ designedGroupIds.add(groupId);
370
+ }
371
+ for (const [groupId, stateNames] of rawStateByGroup) {
372
+ if (stateNames.includes(entry.purpose))
373
+ designedGroupIds.add(groupId);
374
+ }
375
+ for (const [groupId, interactionNames] of interactionNamesByGroup) {
376
+ if (interactionNames.includes(entry.purpose))
377
+ designedGroupIds.add(groupId);
378
+ }
379
+ }
380
+ // ---- uiStates: canonical rows. Dedupe by case/separator-insensitive name
381
+ // (last row wins), bind applicable states to their owner group's placement +
382
+ // verification focus + covering component choice. Non-applicable states keep
383
+ // applicable:false + notApplicableReason. An applicable state the finalized
384
+ // decisions cannot realize is a contradiction inside the decision set, not a
385
+ // compile-time judgment call: silently demoting it to not-applicable (or
386
+ // dropping the row) would rewrite the model's design semantics behind its
387
+ // back, so the compiler fails with a repairable diagnostic instead — the
388
+ // finalize repair loop feeds it straight back to the model.
389
+ const unbindable = (name, groupId, problem) => new Error(`decision-state-unbindable: applicable state "${name}" (owner behavior group "${groupId}") ${problem} — fix the named record_* facts or resubmit the state row (same state, replace:true) with applicable:false plus notApplicableReason`);
390
+ const applicableStateNamesByGroup = new Map();
391
+ const declaredStateNamesByGroup = new Map();
392
+ const uiStates = [];
393
+ for (const key of stateKeyOrder) {
394
+ const entry = stateByKey.get(key);
395
+ const name = entry.state;
396
+ const groupId = entry.owner;
397
+ const declared = declaredStateNamesByGroup.get(groupId) ?? [];
398
+ if (!declared.includes(name))
399
+ declared.push(name);
400
+ declaredStateNamesByGroup.set(groupId, declared);
401
+ const state = {
402
+ name,
403
+ applicable: entry.applicable,
404
+ };
405
+ if (!entry.applicable) {
406
+ state.notApplicableReason =
407
+ entry.notApplicableReason ?? "not applicable by decision";
408
+ uiStates.push(state);
409
+ continue;
410
+ }
411
+ const implementationTargets = sorted(placementPathsById.get(groupId) ?? []);
412
+ const verificationTargetIds = sorted(vtIdsByGroup.get(groupId) ?? []);
413
+ if (implementationTargets.length === 0) {
414
+ throw unbindable(name, groupId, `has no module placement; record record_module_placement {id: "${groupId}", paths: [...]} or place the state under a placed group`);
415
+ }
416
+ if (verificationTargetIds.length === 0) {
417
+ throw unbindable(name, groupId, `has no verification focus; record record_verification_focus {behaviorGroupId: "${groupId}", ...}`);
418
+ }
419
+ if (!designedGroupIds.has(groupId)) {
420
+ throw unbindable(name, groupId, `is not covered by any component decision; record record_reuse_decision with purpose or covers including "${name}"`);
421
+ }
422
+ if (entry.expectedBehavior)
423
+ state.expectedBehavior = entry.expectedBehavior;
424
+ state.implementationTargets = implementationTargets;
425
+ state.verificationTargetIds = verificationTargetIds;
426
+ const names = applicableStateNamesByGroup.get(groupId) ?? [];
427
+ if (!names.includes(name))
428
+ names.push(name);
429
+ applicableStateNamesByGroup.set(groupId, names);
430
+ uiStates.push(state);
431
+ }
432
+ // ---- verificationTargets: one row per committed focus; requirementIds from
433
+ // the focus group's membership; uiStates from that group's applicable
434
+ // state-ownership rows so every behavior target references a declared state
435
+ // (falling back to the group's declared names when a pure-verification group
436
+ // owns no applicable state).
437
+ const verificationTargets = decision.verificationFocus.map(focus => {
438
+ const group = groupById.get(focus.behaviorGroupId);
439
+ const requirementIds = sorted(group?.requirementIds ?? []);
440
+ const uiStateNames = applicableStateNamesByGroup.get(focus.behaviorGroupId) ?? [];
441
+ const referencedStates = uiStateNames.length > 0
442
+ ? uiStateNames
443
+ : declaredStateNamesByGroup.get(focus.behaviorGroupId) ?? [];
444
+ return {
445
+ id: focus.id,
446
+ commandId: focus.commandId,
447
+ file: focus.file,
448
+ requirementIds,
449
+ uiStates: sorted(referencedStates),
450
+ };
451
+ });
452
+ const verificationTargetIds = new Set(verificationTargets.map(target => target.id));
453
+ // ---- requirements: derived group expansion + contract deliverable
454
+ // obligations. Every requirement must keep a placement and at least one
455
+ // verification target (both are guaranteed post-finalize because the
456
+ // decision safety projection blocks unplaced / unverified requirements).
457
+ const requirements = authority.requirements.map(requirement => {
458
+ const groupId = groupByRequirement.get(requirement.id);
459
+ const placed = sorted(placementPathsById.get(groupId ?? "") ?? []);
460
+ const deliverable = sorted(requiredByRequirement.get(requirement.id) ?? []);
461
+ const implementationTargets = sorted([...placed, ...deliverable]);
462
+ const derivedVerificationTargetIds = sorted([...(vtIdsByGroup.get(groupId ?? "") ?? [])].filter(id => verificationTargetIds.has(id)));
463
+ return {
464
+ id: requirement.id,
465
+ expectedOutcome: requirement.expectedOutcome,
466
+ implementationTargets,
467
+ deliverableTargets: deliverable,
468
+ verificationTargetIds: derivedVerificationTargetIds,
469
+ };
470
+ });
471
+ // ---- uiComponentChoices: reuse/new decisions keep their component identity;
472
+ // covers are expanded with the applicable state and interaction names of the
473
+ // behavior groups the choice already covers by requirement id (or group id).
474
+ // We never invent a component for an uncovered name.
475
+ const uiComponentChoices = decision.reuseDecisions.map(entry => {
476
+ const coveredGroupIds = new Set();
477
+ for (const cover of entry.covers ?? []) {
478
+ const groupId = groupByRequirement.get(cover) ??
479
+ (groupById.has(cover) ? cover : undefined);
480
+ if (groupId)
481
+ coveredGroupIds.add(groupId);
482
+ }
483
+ const expanded = sorted([...coveredGroupIds].flatMap(groupId => [
484
+ ...(applicableStateNamesByGroup.get(groupId) ?? []),
485
+ ...(interactionNamesByGroup.get(groupId) ?? []),
486
+ ]));
487
+ // Explicit state/interaction names the model listed stay; requirement /
488
+ // group ids in covers were the expansion source and are replaced by the
489
+ // derived names (covers is the choice→UX-name map the policy reads).
490
+ const explicitNames = (entry.covers ?? []).filter(cover => !groupByRequirement.has(cover) && !groupById.has(cover));
491
+ const choice = {
492
+ purpose: entry.purpose,
493
+ component: entry.symbol,
494
+ decision: entry.decision,
495
+ rationale: entry.rationale ?? "",
496
+ };
497
+ if (entry.decision === "reuse-existing") {
498
+ if (entry.evidence.length > 0)
499
+ choice.evidencePath = entry.evidence[0];
500
+ choice.specReference = null;
501
+ }
502
+ else if (entry.evidence.length > 0 && entry.specSection) {
503
+ // The canonical contract schema mandates specReference.section for
504
+ // specified/new choices; the tool gate requires specSection up front,
505
+ // so a row reaching here without one stays specReference-less and the
506
+ // contract superRefine reports it against the decision channel.
507
+ choice.specReference = { path: entry.evidence[0], section: entry.specSection };
508
+ }
509
+ const covers = sorted([...expanded, ...explicitNames]);
510
+ if (covers.length > 0)
511
+ choice.covers = covers;
512
+ return choice;
513
+ });
514
+ // ---- stylingStrategy: task-wide style contract. The relationship channel
515
+ // sets it once on any component choice; the decision channel mirrors that.
516
+ // Rows naming different strategies contradict each other, and silently
517
+ // keeping one would drop the other behind the model's back, so the compiler
518
+ // reports the conflict for repair instead of picking a winner.
519
+ const strategyRows = decision.reuseDecisions
520
+ .filter(entry => typeof entry.stylingStrategy === "string" && entry.stylingStrategy.trim().length > 0)
521
+ .map(entry => ({ purpose: entry.purpose, strategy: entry.stylingStrategy.trim() }));
522
+ const strategiesByValue = new Map();
523
+ for (const row of strategyRows) {
524
+ const purposes = strategiesByValue.get(row.strategy) ?? [];
525
+ if (!purposes.includes(row.purpose))
526
+ purposes.push(row.purpose);
527
+ strategiesByValue.set(row.strategy, purposes);
528
+ }
529
+ if (strategiesByValue.size > 1) {
530
+ const breakdown = [...strategiesByValue.entries()]
531
+ .map(([strategy, purposes]) => `${JSON.stringify(strategy)} (on ${purposes.join(", ")})`)
532
+ .join(" vs ");
533
+ throw new Error(`decision-styling-strategy-conflict: reuse decisions declare different stylingStrategy values: ${breakdown} — the task-wide style contract must be one statement; resubmit the losing rows (same purpose, replace:true) without stylingStrategy`);
534
+ }
535
+ const stylingStrategy = strategyRows[0]?.strategy;
536
+ // ---- mockApi: never fabricate endpoints from prose boundaries. All
537
+ // not-needed boundaries → not-needed. A real/mock boundary becomes an
538
+ // endpoint only when the boundary parses as "<METHOD> /path" with a frozen
539
+ // method; fixture/consumer are bound to derived implementation paths.
540
+ // ---- mock boundaries: dedupe by trimmed boundary identity (last row wins)
541
+ // so a mock→real correction replaces the superseded decision instead of
542
+ // leaving its stale fixture/mode in play alongside the correction.
543
+ const boundaryByKey = new Map();
544
+ const boundaryOrder = [];
545
+ for (const boundary of decision.apiMockBoundaries) {
546
+ const key = boundary.boundary.trim();
547
+ if (!key)
548
+ continue;
549
+ if (!boundaryByKey.has(key))
550
+ boundaryOrder.push(key);
551
+ boundaryByKey.set(key, boundary);
552
+ }
553
+ const boundaries = boundaryOrder.map(key => boundaryByKey.get(key));
554
+ const allNotNeeded = boundaries.length > 0 && boundaries.every(boundary => boundary.mode === "not-needed");
555
+ const mockStrategy = allNotNeeded
556
+ ? "not-needed"
557
+ : boundaries.some(boundary => boundary.mode === "mock")
558
+ ? "browser-intercept"
559
+ : boundaries.some(boundary => boundary.mode === "real")
560
+ ? "native"
561
+ : "not-needed";
562
+ const endpoints = [];
563
+ if (!allNotNeeded && mockStrategy !== "not-needed") {
564
+ for (const boundary of boundaries) {
565
+ if (boundary.mode === "not-needed")
566
+ continue;
567
+ const match = /^\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\/\S.*?)\s*$/.exec(boundary.boundary);
568
+ if (!match)
569
+ continue;
570
+ const method = match[1];
571
+ if (!HTTP_METHODS.has(method))
572
+ continue;
573
+ const pathValue = match[2].trim();
574
+ if (!pathValue.startsWith("/"))
575
+ continue;
576
+ // Design policy hard-requires fixture + consumer on every endpoint. The
577
+ // decision channel is their only honest source: fabricating a default
578
+ // fixture path or the first placement as consumer would point the mock
579
+ // evidence chain at files the model never planned, so an endpoint whose
580
+ // decision lacks them fails the compile with a named repair instead.
581
+ const missing = [!boundary.fixture?.trim() && "fixture", !boundary.consumer?.trim() && "consumer"].filter(Boolean).join(" and ");
582
+ if (missing) {
583
+ throw new Error(`decision-mock-endpoint-unbindable: ${boundary.mode} boundary "${boundary.boundary}" has no ${missing}; record record_api_mock_boundary with the same boundary plus fixture (the test fixture path) and consumer (the implementation file that calls it), or mark the boundary not-needed`);
584
+ }
585
+ endpoints.push({
586
+ method,
587
+ path: pathValue,
588
+ fixture: boundary.fixture,
589
+ consumer: boundary.consumer,
590
+ });
591
+ }
592
+ }
593
+ const mockApi = {
594
+ strategy: mockStrategy,
595
+ activation: mockStrategy === "not-needed"
596
+ ? "n/a; no backend or remote request path"
597
+ : "explicit activation boundary",
598
+ endpoints,
599
+ };
600
+ // ---- dependencyPolicy: explicit declarative no-new-dependencies default
601
+ // when the model recorded no real dependency names ("none"/"无" tokens are
602
+ // not package names).
603
+ const dependencies = decision.dependencies.filter(name => !NO_DEPENDENCY_TOKENS.has(name.trim().toLowerCase()) && name.trim() !== "");
604
+ const dependencyPolicy = dependencies.length > 0 ? dependencies.join(", ") : "零新增,不新增 npm 依赖";
605
+ return {
606
+ requirements,
607
+ uiStates,
608
+ interactions,
609
+ verificationTargets,
610
+ uiComponentChoices,
611
+ mockApi,
612
+ dependencyPolicy,
613
+ ...(stylingStrategy ? { stylingStrategy } : {}),
614
+ };
615
+ }
616
+ const repairAssertionSchema = z.discriminatedUnion("check", [
617
+ z.object({ check: z.literal("file-absent"), token: z.string().min(1), source: z.string() }).strict(),
618
+ z.object({ check: z.literal("state-present"), token: z.string().min(1), source: z.string() }).strict(),
619
+ z.object({ check: z.literal("vt-changed"), token: z.string().min(1), source: z.string(),
620
+ expectedRequirementIds: z.array(z.string().min(1)).min(1) }).strict(),
621
+ ]);
622
+ /** No heuristic fallback: absent structured expectations mean that semantic
623
+ * closure must be decided by the subsequent design review. Invalid explicit
624
+ * assertions throw rather than silently disabling a requested check. */
625
+ export function extractRepairAssertions(findings, _parentCanonical) {
626
+ return findings.flatMap(finding => z.array(repairAssertionSchema).parse(finding.repairAssertions ?? []));
627
+ }
628
+ /**
629
+ * Fields that annotate a decision but cannot change what the plan decides:
630
+ * justification prose, the human-readable purpose, the non-applicability
631
+ * explanation, and the source citation. They are excluded from the recovery
632
+ * decision identity.
633
+ *
634
+ * Smoke r34: the identity guard (rationale-only at the time) blocked a
635
+ * no-op repair twice, and the child then cleared it by rewriting only
636
+ * `uiComponentChoices[].specReference` — the citation for a choice whose
637
+ * ownership mapping had not changed at all. A repair must move a decision the
638
+ * machine reads (targets, implementation mappings, states, coverage, plan
639
+ * requirements, verification targets), never just the words around it.
640
+ */
641
+ const DECISION_FREE_TEXT_KEYS = new Set([
642
+ "rationale",
643
+ "purpose",
644
+ "notApplicableReason",
645
+ "specReference",
646
+ ]);
647
+ export function stripDecisionFreeText(value) {
648
+ if (Array.isArray(value))
649
+ return value.map(stripDecisionFreeText);
650
+ if (value && typeof value === "object") {
651
+ const source = value;
652
+ const out = {};
653
+ for (const key of Object.keys(source)) {
654
+ if (DECISION_FREE_TEXT_KEYS.has(key))
655
+ continue;
656
+ out[key] = stripDecisionFreeText(source[key]);
657
+ }
658
+ return out;
659
+ }
660
+ return value;
661
+ }
662
+ /**
663
+ * Identity of a compiled plan's decisions, ignoring justification prose.
664
+ *
665
+ * The recovery identical-plan guard used a raw content hash, so smoke r27
666
+ * showed a repair child clearing `DECISION_REPAIR_NO_CHANGE` by rewriting two
667
+ * `rationale` strings while every structured decision row — targets,
668
+ * implementation mappings, states, verification targets — stayed identical.
669
+ * The guard compares this projection instead, so "no decision changed" is a
670
+ * blocked repair regardless of prose edits. Semantics beyond that remain the
671
+ * design review's judgment.
672
+ */
673
+ export function decisionIdentitySha256(canonical) {
674
+ return deterministicSha256(stripDecisionFreeText(canonical));
675
+ }
676
+ /**
677
+ * Evaluate the repair closure: every assertion must hold on the child
678
+ * canonical. `unmet` names exactly what the model still has to change, so a
679
+ * failed closure is a repairable finalize rejection, never a silent pass.
680
+ */
681
+ export function evaluateRepairClosure(assertions, childCanonical, parentCanonical) {
682
+ const childFiles = (childCanonical.targets?.files ?? []).map((f) => String(f));
683
+ const childStates = new Set((childCanonical.uiStates ?? [])
684
+ .filter((state) => state.applicable !== false)
685
+ .map((state) => String(state.name ?? "")));
686
+ const childVtById = new Map((childCanonical.verificationTargets ?? []).map((target) => [
687
+ String(target.id ?? ""),
688
+ target,
689
+ ]));
690
+ const parentVtById = new Map((parentCanonical.verificationTargets ?? []).map((target) => [
691
+ String(target.id ?? ""),
692
+ target,
693
+ ]));
694
+ const unmet = [];
695
+ for (const assertion of assertions) {
696
+ if (assertion.check === "file-absent") {
697
+ if (childFiles.some((file) => file === assertion.token)) {
698
+ unmet.push(`file-absent: "${assertion.token}" is still present in the child canonical (targets.files/implementationTargets)`);
699
+ }
700
+ }
701
+ else if (assertion.check === "state-present") {
702
+ if (!childStates.has(assertion.token)) {
703
+ unmet.push(`state-present: applicable UI state "${assertion.token}" is still missing from the child canonical`);
704
+ }
705
+ }
706
+ else if (assertion.check === "vt-changed") {
707
+ const child = childVtById.get(assertion.token);
708
+ const parent = parentVtById.get(assertion.token);
709
+ if (!child || !parent) {
710
+ unmet.push(`vt-changed: VT "${assertion.token}" is missing from the child canonical`);
711
+ }
712
+ else {
713
+ const expected = JSON.stringify([...new Set(assertion.expectedRequirementIds)].sort());
714
+ const actualIds = Array.isArray(child.requirementIds) ? child.requirementIds.map(String) : [];
715
+ const actual = JSON.stringify([...new Set(actualIds)].sort());
716
+ if (actual !== expected)
717
+ unmet.push(`vt-changed: VT "${assertion.token}" does not map to the required requirementIds`);
718
+ }
719
+ }
720
+ }
721
+ return { closed: unmet.length === 0, unmet };
722
+ }
723
+ /** Last-wins collapse of committed decision entries by their natural
724
+ * identity (id/purpose/state/interaction/boundary). Shared by the ledger
725
+ * compile and the recovery snapshot builder so both see exactly the same
726
+ * surviving rows. Keyless rows are kept: schema validation still rejects
727
+ * malformed entries instead of silently dropping them. */
728
+ export function collapseDecisionEntriesByKind(kind, collected) {
729
+ const keyOf = (entry) => {
730
+ const key = kind === "module-placement" || kind === "verification-focus"
731
+ ? entry.id
732
+ : kind === "reuse-decision"
733
+ ? entry.purpose
734
+ : kind === "state-ownership"
735
+ ? entry.state
736
+ : kind === "data-flow"
737
+ ? entry.interaction
738
+ : kind === "api-mock-boundary"
739
+ ? typeof entry.boundary === "string" ? entry.boundary.trim() : undefined
740
+ : undefined;
741
+ return typeof key === "string" && key ? key : undefined;
742
+ };
743
+ const byKey = new Map();
744
+ const keyless = [];
745
+ // Retracted tombstones remove their own key and every other record keyed to
746
+ // the same interaction — a wrong-named record cannot survive a rename (r19).
747
+ const retractedKeys = new Set();
748
+ for (const entry of collected) {
749
+ if (entry.retracted === true && typeof entry.interaction === "string") {
750
+ retractedKeys.add(entry.interaction);
751
+ }
752
+ }
753
+ for (const entry of collected) {
754
+ if (entry.retracted === true)
755
+ continue;
756
+ const key = keyOf(entry);
757
+ if (kind === "data-flow" && typeof entry.interaction === "string" && retractedKeys.has(entry.interaction))
758
+ continue;
759
+ if (key)
760
+ byKey.set(key, entry);
761
+ else
762
+ keyless.push(entry);
763
+ }
764
+ return byKey.size > 0 ? [...byKey.values(), ...keyless] : [...collected];
765
+ }
766
+ /** Compile committed decision facts into a FrontendPlanDecisionContract. Each
767
+ * fact's `entry` is already schema-shaped (the tool validates it); this collects
768
+ * entries by kind into the decision arrays and re-validates the whole. This is
769
+ * the compile authority for the experimental decision Plan path and is the
770
+ * runtime counterpart to the model emitting decision facts instead of the
771
+ * per-AC relationship table. */
772
+ export function assembleDecisionContractFromFacts(facts) {
773
+ const committed = facts.filter(fact => fact.origin === undefined || fact.origin === "plan");
774
+ // Durable resubmission with replace:true appends a *new* ledger record with the
775
+ // same natural identity instead of physically deleting the superseded one
776
+ // (mirroring the relationship ledger, where the compiler resolves replaces).
777
+ // Without a last-wins collapse here, a correction that re-records the same VT
778
+ // id compiles into duplicate entries and every subsequent finalize fails with
779
+ // duplicate-verification even though the model did exactly what replace told
780
+ // it to. Identity is per kind: module placement and verification focus are
781
+ // keyed by entry.id; the committed array is already in ledger (revision)
782
+ // order, so the last record with a given key wins.
783
+ const committedEntries = (kind) => {
784
+ const collected = committed
785
+ .filter(fact => fact.kind === kind && isRecord(fact.entry))
786
+ .map(fact => fact.entry);
787
+ return collapseDecisionEntriesByKind(kind, collected);
788
+ };
789
+ const entries = committedEntries;
790
+ return frontendPlanDecisionContractSchema.parse({
791
+ schemaId: "frontend-plan-decision-contract-v1",
792
+ modulePlacements: entries("module-placement"),
793
+ reuseDecisions: entries("reuse-decision"),
794
+ stateOwnership: entries("state-ownership"),
795
+ dataFlows: entries("data-flow"),
796
+ apiMockBoundaries: entries("api-mock-boundary"),
797
+ dependencies: entries("dependency").flatMap(entry => {
798
+ const names = Array.isArray(entry.names) ? entry.names : [entry.name];
799
+ return names.filter((name) => typeof name === "string" && name.length > 0);
800
+ }),
801
+ verificationFocus: entries("verification-focus"),
802
+ });
803
+ }