slidemind 0.3.20 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/render.mjs +27 -1
  16. package/src/bridge/client/transport.mjs +499 -23
  17. package/src/bridge/client.mjs +10 -0
  18. package/src/bridge/config.mjs +67 -6
  19. package/src/bridge/engine-download.mjs +0 -10
  20. package/src/bridge/graph-auth.mjs +55 -3
  21. package/src/bridge/image-intent-envelope.mjs +239 -0
  22. package/src/bridge/package-preview.mjs +67 -0
  23. package/src/bridge/sharepoint.mjs +914 -0
  24. package/src/builders/asset-fetch.mjs +203 -0
  25. package/src/builders/block-dividers.mjs +83 -0
  26. package/src/builders/build.mjs +171 -87
  27. package/src/builders/card-height.mjs +294 -0
  28. package/src/builders/chart-hole.mjs +183 -0
  29. package/src/builders/chart-jsx.mjs +410 -0
  30. package/src/builders/chart-labels.mjs +326 -0
  31. package/src/builders/concept-geometry.mjs +370 -0
  32. package/src/builders/concept-intent.mjs +200 -0
  33. package/src/builders/concept-layout.mjs +79 -0
  34. package/src/builders/concept-lines.mjs +509 -0
  35. package/src/builders/concept-lists.mjs +270 -0
  36. package/src/builders/concept-marks.mjs +84 -0
  37. package/src/builders/concept-relationships.mjs +12 -0
  38. package/src/builders/concept-spec.mjs +645 -0
  39. package/src/builders/concept-text.mjs +140 -0
  40. package/src/builders/concept-waves.mjs +57 -0
  41. package/src/builders/copy-fit.mjs +139 -0
  42. package/src/builders/design-tokens.mjs +347 -0
  43. package/src/builders/draft-check.mjs +552 -340
  44. package/src/builders/draft-lines.mjs +369 -0
  45. package/src/builders/edit-route.mjs +29 -0
  46. package/src/builders/figure-sketch.mjs +375 -0
  47. package/src/builders/fold-sets.mjs +639 -0
  48. package/src/builders/gate-server.mjs +9 -4
  49. package/src/builders/gate-session.js +273 -12
  50. package/src/builders/gate-shell.js +1494 -0
  51. package/src/builders/image-intent-check.mjs +308 -0
  52. package/src/builders/image-use-reconcile.mjs +107 -0
  53. package/src/builders/kit-helpers.mjs +121 -0
  54. package/src/builders/layout-names.mjs +32 -0
  55. package/src/builders/legacy-reference-uses.mjs +179 -0
  56. package/src/builders/line-fit.mjs +224 -0
  57. package/src/builders/line-native.mjs +895 -0
  58. package/src/builders/line-relations.mjs +230 -0
  59. package/src/builders/lint-fix.mjs +117 -9
  60. package/src/builders/lint.mjs +25 -20
  61. package/src/builders/list-jsx.mjs +88 -0
  62. package/src/builders/placement.mjs +133 -0
  63. package/src/builders/plan-checks.mjs +135 -0
  64. package/src/builders/plan-comments.mjs +19 -0
  65. package/src/builders/plan-handlers.mjs +95 -0
  66. package/src/builders/plan-markdown-edit.mjs +3 -3
  67. package/src/builders/plan-overlay.js +1530 -1608
  68. package/src/builders/plan.mjs +689 -186
  69. package/src/builders/preset-definitions.json +1 -0
  70. package/src/builders/preset-eval.mjs +244 -0
  71. package/src/builders/preview.html +51 -120
  72. package/src/builders/red-thread-backbone.mjs +135 -36
  73. package/src/builders/repair.mjs +205 -0
  74. package/src/builders/repeat-groups.mjs +540 -0
  75. package/src/builders/resolve-skill.mjs +5 -4
  76. package/src/builders/review-overlay.js +296 -293
  77. package/src/builders/review.mjs +305 -84
  78. package/src/builders/rule-shift.mjs +96 -0
  79. package/src/builders/seat.mjs +354 -0
  80. package/src/builders/settle-driver.mjs +236 -0
  81. package/src/builders/settle.mjs +1448 -0
  82. package/src/builders/slide-prep.mjs +67 -0
  83. package/src/builders/spec-assets.mjs +236 -0
  84. package/src/builders/spec-match.mjs +56 -0
  85. package/src/builders/stack.mjs +413 -0
  86. package/src/builders/storyboard-markdown.mjs +70 -27
  87. package/src/builders/storyboard-schema.mjs +54 -114
  88. package/src/builders/text-blocks.mjs +212 -0
  89. package/src/builders/text-card-fit.mjs +552 -0
  90. package/src/builders/text-stack.mjs +276 -0
  91. package/src/builders/transcribe.mjs +1069 -0
  92. package/src/cli/assets.mjs +164 -0
  93. package/src/cli/common.mjs +173 -12
  94. package/src/cli/concept-spec-support.mjs +76 -0
  95. package/src/cli/concepts.mjs +1002 -103
  96. package/src/cli/deck.mjs +401 -65
  97. package/src/cli/diagnose.mjs +4 -2
  98. package/src/cli/engine-helpers.mjs +17 -10
  99. package/src/cli/engine-results.mjs +76 -3
  100. package/src/cli/help.mjs +232 -28
  101. package/src/cli/install.mjs +9 -0
  102. package/src/cli/maintenance.mjs +104 -0
  103. package/src/cli/read.mjs +2 -1
  104. package/src/cli/ready.mjs +6 -3
  105. package/src/cli/record.mjs +522 -0
  106. package/src/cli/remote.mjs +818 -0
  107. package/src/cli/revision.mjs +686 -0
  108. package/src/cli/search.mjs +30 -11
  109. package/src/cli/session.mjs +56 -13
  110. package/src/cli/setup.mjs +46 -2
  111. package/src/cli/status.mjs +142 -33
  112. package/src/cli/tasks.mjs +246 -0
  113. package/src/cli/transcribe.mjs +854 -0
  114. package/src/cli/workspace-ui.mjs +260 -0
  115. package/src/cli/workspace.mjs +212 -34
  116. package/src/install-channel.mjs +64 -7
  117. package/src/lib/adopt.mjs +346 -25
  118. package/src/lib/concept-spec-binding.mjs +96 -0
  119. package/src/lib/conversions.mjs +70 -6
  120. package/src/lib/convert-folded.mjs +107 -0
  121. package/src/lib/deck-paths.mjs +418 -0
  122. package/src/lib/durable-ids.mjs +47 -0
  123. package/src/lib/edit-operations.mjs +316 -0
  124. package/src/lib/edit-state.mjs +1244 -0
  125. package/src/lib/edit-transitions.mjs +120 -0
  126. package/src/lib/extensions.mjs +501 -0
  127. package/src/lib/image-assets.mjs +465 -0
  128. package/src/lib/image-derivatives.mjs +234 -0
  129. package/src/lib/image-intent-fingerprint.mjs +70 -0
  130. package/src/lib/image-intent-tags.mjs +176 -0
  131. package/src/lib/image-intent.mjs +802 -0
  132. package/src/lib/image-use-overrides.mjs +279 -0
  133. package/src/lib/inline-components.mjs +613 -0
  134. package/src/lib/lint-converted.mjs +68 -4
  135. package/src/lib/merge-json.mjs +163 -0
  136. package/src/lib/overwrite-guard.mjs +3 -3
  137. package/src/lib/presentation-context.mjs +531 -0
  138. package/src/lib/preservation-tags.mjs +157 -0
  139. package/src/lib/preservation-verdict.mjs +1342 -0
  140. package/src/lib/publish.mjs +1557 -0
  141. package/src/lib/rebase.mjs +945 -0
  142. package/src/lib/reconcile-ack.mjs +4 -6
  143. package/src/lib/reconcile.mjs +62 -24
  144. package/src/lib/record-client.mjs +175 -0
  145. package/src/lib/record-state.mjs +151 -0
  146. package/src/lib/remote.mjs +661 -0
  147. package/src/lib/revision.mjs +1210 -0
  148. package/src/lib/slide-guids.mjs +4 -6
  149. package/src/lib/slide-preservation.mjs +423 -0
  150. package/src/lib/slide-selection.mjs +9 -0
  151. package/src/lib/workspace-transaction.mjs +117 -0
  152. package/src/pptx/content-digest.mjs +334 -0
  153. package/src/pptx/deck-assemble.mjs +1278 -0
  154. package/src/pptx/fill/engine.mjs +2 -3
  155. package/src/pptx/fill/freeform.mjs +6 -3
  156. package/src/pptx/ooxml.mjs +20 -0
  157. package/src/pptx/package-evidence.mjs +383 -0
  158. package/src/pptx/tags.mjs +163 -11
  159. package/src/update-check.mjs +22 -4
  160. package/src/workspace/adapters.mjs +222 -0
  161. package/src/workspace/auth.mjs +256 -0
  162. package/src/workspace/autostart.mjs +204 -0
  163. package/src/workspace/daemon.mjs +360 -0
  164. package/src/workspace/discovery.mjs +180 -0
  165. package/src/workspace/edit.mjs +167 -0
  166. package/src/workspace/events.mjs +52 -0
  167. package/src/workspace/gate-client.mjs +290 -0
  168. package/src/workspace/handback.mjs +73 -0
  169. package/src/workspace/lifecycle.mjs +635 -0
  170. package/src/workspace/operations.mjs +200 -0
  171. package/src/workspace/pick-folder.mjs +152 -0
  172. package/src/workspace/registry.mjs +359 -0
  173. package/src/workspace/remote-links.mjs +397 -0
  174. package/src/workspace/remote-status.mjs +359 -0
  175. package/src/workspace/routes.mjs +1331 -0
  176. package/src/workspace/server.mjs +159 -0
  177. package/src/workspace/sync.mjs +1119 -0
  178. package/src/workspace/task-actions.mjs +215 -0
  179. package/src/workspace/tasks.mjs +507 -0
  180. package/src/workspace/ui/app.js +1886 -0
  181. package/src/workspace/ui/index.html +141 -0
  182. package/src/workspace/ui/plan-bridge.js +120 -0
  183. package/src/workspace/ui/sharepoint.svg +59 -0
  184. package/src/workspace/ui/styles.css +423 -0
  185. package/src/builders/visual-families.mjs +0 -46
@@ -4,285 +4,52 @@
4
4
  // live in the add-in and reach the CLI as the capture engine's `draftChecks`
5
5
  // export (`capture.mjs` → `loadDraftChecks`), so in-app see_draft and
6
6
  // view-draft run one rule set with one wording. What stays CLI-side is what
7
- // only exists in the CLI's world: the asset-plan.md inventory form, the
8
- // .draft-preview record and the gates it arms, and the 422 translate parser.
7
+ // only exists in the CLI's world: the .draft-preview record and the gates it
8
+ // arms, and the 422 translate parser.
9
9
  // Images are never overlaid: a magenta proportion grid on the concept and the
10
10
  // render dominated both and hid whether the slide was built well.
11
11
 
12
12
  import crypto from "node:crypto";
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
+ import { deckPptxPath, deckSlug, statePath } from "../lib/deck-paths.mjs";
15
16
 
16
- export const ASSET_PLAN_FILE = "asset-plan.md";
17
- export const DRAFT_CHECK_FILE = path.join(".draft-preview", "draft-check.json");
18
-
19
- // ---------------------------------------------------------------------------
20
- // Inventory — asset-plan.md as a form the build reads (port of inventory.ts).
21
- // ---------------------------------------------------------------------------
22
-
23
- const SLIDE_HEADING_RE = /^## Slide (\d+)\b/;
24
- const SLIDE_FILE_NUMBER_RE = /^(\d{1,3})[-_.]/;
25
- const SECTION_RE = /^(regions|organisms|connectors|icons):/;
26
- const FETCHED_ASSET_RE = /^(engine|gen)-/;
27
- // "none", "(none)", "n/a", "no connectors …", "none authorable …" — an
28
- // explicit empty row, never a listed organism/connector.
29
- const NONE_RE = /^\(?\s*(none|n\/a|no\b)/i;
30
-
31
- const pad2 = (n) => String(n).padStart(2, "0");
32
-
33
- // Route kind per connector row / directive — part of the mockup contract: a
34
- // loop the concept draws as curves that lands as elbows is a different figure,
35
- // and the count alone (5 = 5) never fires. Port of inventory.ts (gotcha 84).
36
- const ROUTE_KINDS = ["straight", "elbow", "curved"];
37
- const emptyRoutes = () => ({ straight: 0, elbow: 0, curved: 0 });
38
- function routeKindOf(text) {
39
- for (const token of text.split(",")) {
40
- const t = token.trim().toLowerCase();
41
- if (ROUTE_KINDS.includes(t)) return t;
42
- }
43
- return null;
44
- }
45
- const describeRoutes = (r) => ROUTE_KINDS.map((k) => `${r[k]} ${k}`).join(", ");
46
-
47
- /** Seed the inventory form once (never overwrites a filled one). `images`: [{slideNumber,label}]. */
48
- export function seedAssetPlan(deckDir, images) {
49
- const file = path.join(deckDir, ASSET_PLAN_FILE);
50
- if (!images?.length || fs.existsSync(file)) return false;
51
- const slides = [...images]
52
- .sort((a, b) => a.slideNumber - b.slideNumber)
53
- .map((img) =>
54
- img.frame
55
- ? [
56
- `## Slide ${pad2(img.slideNumber)} — ${img.label ?? `Slide ${img.slideNumber}`}`,
57
- `jsx: slides/${pad2(img.slideNumber)}-<name>.jsx`,
58
- `(frame slide — ${img.frame}: chrome slots only, authored from the plan as the layout's props; no inventory, no mockup, no verdict)`,
59
- "",
60
- ].join("\n")
61
- : [
62
- `## Slide ${pad2(img.slideNumber)} — ${img.label ?? `Slide ${img.slideNumber}`}`,
63
- `jsx: slides/${pad2(img.slideNumber)}-<name>.jsx`,
64
- "regions:",
65
- "- ",
66
- "organisms:",
67
- "- ",
68
- "connectors:",
69
- "- ",
70
- "icons:",
71
- "- ",
72
- "",
73
- ].join("\n"),
74
- );
75
- fs.writeFileSync(
76
- file,
77
- [
78
- "# Asset plan — concept inventory",
79
- "",
80
- "Fill this BEFORE writing any slide, from `concepts/concept-slide-NN.png` (the BODY region only —",
81
- "chrome goes through layout props). One bullet per line; the",
82
- "draft check counts `connectors:` against the JSX and every fetched asset against the slides.",
83
- "",
84
- "- regions: split of the content slot in percentages of its width/height, e.g. `hero left x0–60 y0–100`, `rail right x65–100`, `callout bottom y85–100`.",
85
- "- organisms: every card, figure, badge, chip row, callout, loop, satellite — region, share, alignment.",
86
- "- connectors: `from → to, straight|elbow|curved, arrow end|both|none, solid|dashed` — one per arrow/line in the mockup.",
87
- "- icons: subject, prominence, engine query; append the fetched `assets/<file>` when sourced, or `(no result)`.",
88
- "",
89
- "After `slidemind view-draft`, under each slide add `draft-check:` followed by ONE tick line per",
90
- "organism row: `- [x] <organism>` when it is in the render at the concept's position and share,",
91
- "`- [ ] <organism> — <what differs>` when it is not. `review` refuses to open while a slide has",
92
- "fewer ticks than organisms or any `[ ]` left — fix the miss in the JSX, re-render, re-tick.",
93
- "",
94
- ...slides,
95
- ].join("\n"),
96
- );
97
- return true;
98
- }
99
-
100
- function parseAssetPlan(text) {
101
- const out = new Map();
102
- let current = null;
103
- let section = "";
104
- for (const raw of text.split("\n")) {
105
- const line = raw.trim();
106
- const heading = SLIDE_HEADING_RE.exec(line);
107
- if (heading) {
108
- current = { filled: false, connectors: 0, routes: emptyRoutes(), organisms: 0, icons: 0, iconsResolved: 0, verdict: null, ticked: 0, unticked: 0 };
109
- out.set(Number(heading[1]), current);
110
- section = "";
111
- continue;
112
- }
113
- if (!current) continue;
114
- if (/^draft-check:/i.test(line)) {
115
- current.verdict = line.slice("draft-check:".length).trim();
116
- section = "draft-check";
117
- continue;
118
- }
119
- if (section === "draft-check") {
120
- const tick = /^-\s*\[( |x|X)\]/.exec(line);
121
- if (tick) {
122
- if (tick[1] === " ") current.unticked += 1;
123
- else current.ticked += 1;
124
- continue;
125
- }
126
- }
127
- const sec = SECTION_RE.exec(line);
128
- if (sec) {
129
- section = sec[1];
130
- // Inline form (`connectors: a -> b, …; c -> d, …`) counts like bullets —
131
- // agents write both shapes; only the seeded form uses bullets.
132
- const inline = line.slice(sec[0].length).trim();
133
- if (inline && !NONE_RE.test(inline)) {
134
- if (section === "organisms" || section === "regions") current.filled = true;
135
- if (section === "connectors") {
136
- for (const row of inline.split(";")) {
137
- if (!row.trim()) continue;
138
- current.connectors += 1;
139
- const kind = routeKindOf(row);
140
- if (kind) current.routes[kind] += 1;
141
- }
142
- }
143
- }
144
- continue;
145
- }
146
- if (!line.startsWith("-")) continue;
147
- const body = line.slice(1).trim();
148
- if (!body || NONE_RE.test(body)) continue;
149
- if (section === "organisms" || section === "regions") current.filled = true;
150
- if (section === "organisms") current.organisms += 1;
151
- if (section === "connectors") {
152
- current.connectors += 1;
153
- const kind = routeKindOf(body);
154
- if (kind) current.routes[kind] += 1;
155
- }
156
- if (section === "icons") {
157
- current.icons += 1;
158
- // Resolved = a fetched file appended, or an explicit "(no result)".
159
- if (/assets\/|\(no result\)/i.test(body)) current.iconsResolved += 1;
160
- }
161
- }
162
- return out;
163
- }
17
+ // Which draft lines hold the gate is the shared table's (draft-lines.mjs,
18
+ // mirrored into the add-in): `view-draft` records every line it does not
19
+ // class as advisory, and that record is what `review` and delivery refuse on.
20
+ import {
21
+ draftLineTier,
22
+ preservationFailureLines,
23
+ preservationUnreadableLine,
24
+ } from "./draft-lines.mjs";
25
+ export { draftLineTier };
164
26
 
165
27
  /**
166
- * The asset plan as stage facts: per slide whether the inventory is filled,
167
- * how many icon rows exist and how many are resolved. null without a plan.
28
+ * A build's package lines (build-pptx, push), split by the same table: a
29
+ * defect means the file is not ready for the user; a line outside the table is
30
+ * a note; the advisories the draft check already printed are not repeated.
168
31
  */
169
- export function assetPlanSummary(deckDir) {
170
- const planFile = path.join(deckDir, ASSET_PLAN_FILE);
171
- if (!fs.existsSync(planFile)) return null;
172
- let plan;
173
- try {
174
- plan = parseAssetPlan(fs.readFileSync(planFile, "utf8"));
175
- } catch {
176
- return null;
177
- }
178
- const out = { slides: plan.size, unfilled: 0, icons: 0, iconsResolved: 0 };
179
- for (const inv of plan.values()) {
180
- if (!inv.filled) out.unfilled += 1;
181
- out.icons += inv.icons;
182
- out.iconsResolved += inv.iconsResolved;
183
- }
184
- return out;
185
- }
186
-
187
- function countConnectors(jsx) {
188
- const attached = (jsx.match(/data-connector\b/g) ?? []).length;
189
- const markers = (jsx.match(/marker-(?:end|start)\s*=/g) ?? []).length;
190
- return attached + markers;
32
+ export function splitPackageLines(warnings) {
33
+ const lines = (warnings ?? []).map((w) => String(w).trim());
34
+ return {
35
+ defects: lines.filter((l) => draftLineTier(l) === "defect"),
36
+ notes: lines.filter((l) => draftLineTier(l) === null),
37
+ };
191
38
  }
192
39
 
193
- // Route kind per `data-connector` directive; a missing data-route is straight.
194
- // Imported native connectors carry their own preset and are not authored
195
- // against the mockup, so they are left out of the kind tally.
196
- function countRoutes(jsx) {
197
- const routes = emptyRoutes();
198
- for (const tag of jsx.match(/<[^<>]*\bdata-connector\b[^<>]*>/g) ?? []) {
199
- if (/\bdata-native-connector\b/.test(tag)) continue;
200
- const kind = /\bdata-route\s*=\s*["']([a-z]+)["']/.exec(tag)?.[1] ?? "straight";
201
- if (ROUTE_KINDS.includes(kind)) routes[kind] += 1;
202
- }
203
- return routes;
40
+ /** The `PACKAGE DEFECTS:` block both delivering commands print. */
41
+ export function packageDefectsText(defects, deckName) {
42
+ return (
43
+ `PACKAGE DEFECTS: ${defects.length} measured defect${defects.length === 1 ? "" : "s"} in the built file — not ready for the user. ` +
44
+ `Fix each in the JSX, run \`slidemind view-draft ${deckName} --only <slide>\`, then deliver again:\n` +
45
+ defects.map((l) => ` ${l}\n`).join("")
46
+ );
204
47
  }
205
48
 
206
- /**
207
- * `[inventory:warn]` lines for the deck: per authored slide (inventory empty,
208
- * connectors listed > drawn) plus fetched assets no slide references. Empty
209
- * when the deck has no asset-plan.md.
210
- */
211
- export function inventoryWarnings(deckDir, jsxFiles = null) {
212
- const planFile = path.join(deckDir, ASSET_PLAN_FILE);
213
- if (!fs.existsSync(planFile)) return [];
214
- let plan;
215
- try {
216
- plan = parseAssetPlan(fs.readFileSync(planFile, "utf8"));
217
- } catch {
218
- return [];
219
- }
220
- if (plan.size === 0) return [];
221
- const slidesDir = path.join(deckDir, "slides");
222
- const allJsx = fs.existsSync(slidesDir)
223
- ? fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx")).sort()
224
- : [];
225
- const files = jsxFiles ?? allJsx;
226
- const lines = [];
227
- for (const file of files) {
228
- const num = SLIDE_FILE_NUMBER_RE.exec(file);
229
- if (!num) continue;
230
- const inv = plan.get(Number(num[1]));
231
- if (!inv) continue;
232
- const jsxPath = `slides/${file}`;
233
- if (!inv.filled) {
234
- lines.push(
235
- `[inventory:warn] ${jsxPath} — asset-plan.md has no regions/organisms rows for this slide. Inventory its concept mockup (every organism, its region and share) and rebuild from that list, not from memory.`,
236
- );
237
- continue;
238
- }
239
- let jsx = "";
240
- try {
241
- jsx = fs.readFileSync(path.join(slidesDir, file), "utf8");
242
- } catch {
243
- continue;
244
- }
245
- const drawn = countConnectors(jsx);
246
- if (inv.connectors > drawn) {
247
- lines.push(
248
- `[inventory:warn] ${jsxPath} — asset-plan.md lists ${inv.connectors} connector${inv.connectors === 1 ? "" : "s"} for this slide, the JSX draws ${drawn} (data-connector / marker-end). Draw the missing ones per workflow/authoring.md → Arrow contract; a mockup connector is never dropped.`,
249
- );
250
- }
251
- const drawnRoutes = countRoutes(jsx);
252
- for (const kind of ROUTE_KINDS) {
253
- if (inv.routes[kind] > drawnRoutes[kind]) {
254
- lines.push(
255
- `[inventory:warn] ${jsxPath} — asset-plan.md lists ${inv.routes[kind]} ${kind} connector${inv.routes[kind] === 1 ? "" : "s"} for this slide, the JSX draws ${drawnRoutes[kind]} (${describeRoutes(drawnRoutes)}). The mockup's route shape is the contract: keep data-route="${kind}" and fix the endpoint sides or node positions instead of changing the route kind.`,
256
- );
257
- }
258
- }
259
- }
260
- // Fetched assets nobody references: the mockup staged them, the deck lost them.
261
- const referenced = allJsx
262
- .map((f) => {
263
- try {
264
- return fs.readFileSync(path.join(slidesDir, f), "utf8");
265
- } catch {
266
- return "";
267
- }
268
- })
269
- .join("\n");
270
- const assetsDir = path.join(deckDir, "assets");
271
- const assets = fs.existsSync(assetsDir) ? fs.readdirSync(assetsDir).sort() : [];
272
- const planText = fs.readFileSync(planFile, "utf8");
273
- for (const file of assets) {
274
- if (!FETCHED_ASSET_RE.test(file)) continue;
275
- if (referenced.includes(file)) continue;
276
- if (new RegExp(`${file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]*\\(unused\\)`).test(planText)) continue;
277
- lines.push(
278
- `[inventory:warn] assets/${file} was fetched for this deck but no slide references it — place it where the mockup stages that icon (\`<img src="./assets/${file}">\`), or mark it \`(unused)\` in asset-plan.md.`,
279
- );
280
- }
281
- return lines;
282
- }
49
+ export const DRAFT_CHECK_FILE = "draft-check.json";
283
50
 
284
51
  // ---------------------------------------------------------------------------
285
- // The draft-check record — what `review` refuses on.
52
+ // The draft-check record — what `review`, `build-pptx` and `push` refuse on.
286
53
  // ---------------------------------------------------------------------------
287
54
 
288
55
  export function hashFile(file) {
@@ -291,7 +58,7 @@ export function hashFile(file) {
291
58
 
292
59
  /** Persist the measured defects per slide, keyed by the jsx content hash. */
293
60
  export function saveDraftCheck(deckDir, slides) {
294
- const file = path.join(deckDir, DRAFT_CHECK_FILE);
61
+ const file = statePath(deckDir, DRAFT_CHECK_FILE);
295
62
  fs.mkdirSync(path.dirname(file), { recursive: true });
296
63
  const record = { version: 1, at: new Date().toISOString(), slides: {} };
297
64
  for (const s of slides) record.slides[s.fileName] = { hash: s.hash, defects: s.defects };
@@ -301,117 +68,97 @@ export function saveDraftCheck(deckDir, slides) {
301
68
 
302
69
  export function loadDraftCheck(deckDir) {
303
70
  try {
304
- return JSON.parse(fs.readFileSync(path.join(deckDir, DRAFT_CHECK_FILE), "utf8"));
71
+ return JSON.parse(fs.readFileSync(statePath(deckDir, DRAFT_CHECK_FILE), "utf8"));
305
72
  } catch {
306
73
  return null;
307
74
  }
308
75
  }
309
76
 
310
77
  /**
311
- * Standing measured defects: the last view-draft flagged them and the jsx has
312
- * not changed since. null when nothing stands (no record, all fixed, or the
313
- * slide was edited after the check — the next view-draft re-measures it).
78
+ * Why these slides may not reach the user yet, or null. Every slide file
79
+ * delivered (default: every authored `.jsx` under slides/) needs a draft check
80
+ * of its current bytes that left no defect standing. A slide never checked, or
81
+ * edited after its check, holds like a defect: only a new view-draft clears a
82
+ * check, and editing is not evidence that a defect was fixed.
314
83
  */
315
- export function draftCheckBlocker(deckDir) {
316
- const record = loadDraftCheck(deckDir);
317
- if (!record?.slides) return null;
84
+ export function draftCheckBlocker(deckDir, { files = null } = {}) {
85
+ const slidesDir = path.join(deckDir, "slides");
86
+ let shipped = files;
87
+ if (!shipped) {
88
+ try {
89
+ shipped = fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx"));
90
+ } catch {
91
+ shipped = [];
92
+ }
93
+ }
94
+ const slides = loadDraftCheck(deckDir)?.slides ?? {};
318
95
  const standing = [];
319
- for (const [fileName, entry] of Object.entries(record.slides)) {
320
- if (!entry?.defects?.length) continue;
321
- const file = path.join(deckDir, "slides", fileName);
96
+ for (const fileName of [...new Set(shipped)].filter((f) => f.endsWith(".jsx")).sort((a, b) => a.localeCompare(b, "en", { numeric: true }))) {
97
+ const file = path.join(slidesDir, fileName);
322
98
  if (!fs.existsSync(file)) continue;
323
- if (hashFile(file) !== entry.hash) continue;
324
- standing.push(...entry.defects);
99
+ const entry = slides[fileName];
100
+ if (!entry) {
101
+ standing.push(`[draft:unchecked] ${fileName} has no draft check. Run view-draft --only ${fileName} and inspect the new image.`);
102
+ continue;
103
+ }
104
+ if (hashFile(file) !== entry.hash) {
105
+ standing.push(`[draft:stale] ${fileName} changed after its preview. Re-run view-draft --only ${fileName} and inspect the new image.`);
106
+ continue;
107
+ }
108
+ standing.push(...(entry.defects ?? []));
325
109
  }
326
110
  if (standing.length === 0) return null;
327
111
  return (
328
- `the last draft check measured ${standing.length} defect${standing.length === 1 ? "" : "s"} that still stand ` +
329
- `(the slide files are unchanged since). Fix them in the JSX, re-run \`slidemind view-draft\`, then \`review\`:\n` +
112
+ `the draft check has ${standing.length} unresolved defect${standing.length === 1 ? "" : "s"} or unchecked slide${standing.length === 1 ? "" : "s"}. ` +
113
+ `Fix the defects, re-run \`slidemind view-draft\`, then retry:\n` +
330
114
  standing.map((l) => ` ${l}`).join("\n")
331
115
  );
332
116
  }
333
117
 
334
-
335
- // ---------------------------------------------------------------------------
336
- // The self-check verdict — an inventory, not an opinion.
337
- //
338
- // Every `draft-check:` verdict on disk across 181 judged sessions reads
339
- // "matches" (15 of 15), including slides the judge scored 0.71 with eight
340
- // organisms missing. A verdict is free; a tick per organism is not — the
341
- // agent has to find each one in the render or say what differs.
342
- // ---------------------------------------------------------------------------
343
-
344
- /**
345
- * Concept-seeded slides whose asset-plan.md section lists organisms but whose
346
- * `draft-check:` is missing, ticks fewer organisms than listed, or leaves a
347
- * `[ ]` standing. null when every such slide is fully ticked.
348
- */
349
- export function verdictBlocker(deckDir) {
350
- const planFile = path.join(deckDir, ASSET_PLAN_FILE);
351
- if (!fs.existsSync(planFile)) return null;
352
- let plan;
353
- try {
354
- plan = parseAssetPlan(fs.readFileSync(planFile, "utf8"));
355
- } catch {
356
- return null;
357
- }
358
- const slidesDir = path.join(deckDir, "slides");
359
- const conceptsDir = path.join(deckDir, "concepts");
360
- const files = fs.existsSync(slidesDir)
361
- ? fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx")).sort()
362
- : [];
363
- const problems = [];
364
- for (const file of files) {
365
- const num = SLIDE_FILE_NUMBER_RE.exec(file);
366
- if (!num) continue;
367
- const n = Number(num[1]);
368
- const inv = plan.get(n);
369
- if (!inv || inv.organisms === 0) continue;
370
- if (!fs.existsSync(path.join(conceptsDir, `concept-slide-${pad2(n)}.png`))) continue;
371
- const ticks = inv.ticked + inv.unticked;
372
- if (inv.verdict === null) {
373
- problems.push(`slides/${file}: no draft-check verdict — asset-plan.md lists ${inv.organisms} organisms; open the concept and the render together and tick each one`);
374
- } else if (inv.unticked > 0) {
375
- problems.push(`slides/${file}: ${inv.unticked} organism${inv.unticked === 1 ? "" : "s"} ticked [ ] (differs) — fix in the JSX, re-run view-draft, re-tick`);
376
- } else if (ticks < inv.organisms) {
377
- problems.push(`slides/${file}: asset-plan.md lists ${inv.organisms} organisms, the verdict ticks ${ticks} — one \`- [x]\`/\`- [ ]\` line per organism`);
378
- }
379
- }
380
- if (problems.length === 0) return null;
381
- return (
382
- "the self-check is incomplete — a verdict is one tick per organism, not one word:\n" +
383
- problems.map((l) => ` ${l}`).join("\n")
384
- );
385
- }
386
-
387
118
  // ---------------------------------------------------------------------------
388
- // Translate-gate errors — the server's slot-fit / diagram-geometry /
389
- // connector-endpoints verdicts, folded into the draft check so a builder
119
+ // Translate-gate errors — the server's slot-fit / diagram-geometry / escape
120
+ // verdicts (older add-ins also sent connector-endpoints), folded into the draft check so a builder
390
121
  // learns them on ITS slide before the deck-wide export, not five build-pptx
391
122
  // rounds later (x18: 17 of 39 minutes were that loop).
392
123
  // ---------------------------------------------------------------------------
393
124
 
394
125
  /**
395
- * Split a translate 422 message into per-slide lines. The server writes
396
- * `<file>.jsx: <rule>: <summary>` headers followed by ` [<rule>:error] <stem> — …`
397
- * lines; both are kept, keyed by the jsx file name. Unattributed lines land
398
- * under "" so nothing is lost.
126
+ * Split a translate 422 message into per-slide lines. The server prefixes each
127
+ * failing slide's error with `<file>.jsx: `, and that prefix makes it the
128
+ * current slide whatever follows: a `<rule>: <summary>` gate header (kept as
129
+ * `[<rule>:error] <file> — <summary>`), a tagged `[<tag>:error] <stem> — …`
130
+ * line (chrome), or plain text (a degraded render). Gate headers are followed
131
+ * by ` [<rule>:error] <stem> — …` lines; a slide that fails several measured
132
+ * gates reports each further gate as a bare `<rule>: <summary>` header under
133
+ * the same file. A chrome verdict quotes the slide's text, and a line break
134
+ * in that text splits it: the next unmatched line completes the chrome line
135
+ * just added, wherever it was filed. Unattributed lines land under "" so
136
+ * nothing is lost.
399
137
  */
400
138
  export function parseTranslateErrors(message) {
401
139
  const out = new Map();
140
+ let open = null; // the file whose last line is a chrome verdict cut inside its quote
402
141
  const add = (file, line) => {
403
142
  if (!out.has(file)) out.set(file, []);
404
143
  out.get(file).push(line);
144
+ open = /^\[chrome:error\]/.test(line) && (line.match(/"/g) ?? []).length % 2 ? file : null;
405
145
  };
406
146
  const text = String(message ?? "").replace(/^[^\n]*\/translate:\s*/, "");
407
147
  let current = "";
408
148
  for (const raw of text.split("\n")) {
409
149
  const line = raw.trimEnd();
410
150
  if (!line.trim()) continue;
411
- const head = /^\s*(\S+\.jsx):\s+([a-z-]+):\s+(.*)$/.exec(line);
151
+ const head = /^\s*(\S+\.jsx):\s+(.*)$/.exec(line);
412
152
  if (head) {
413
153
  current = head[1];
414
- add(current, `[${head[2]}:error] ${head[1]} — ${head[3]}`);
154
+ const rule = /^([a-z-]+):\s+(.*)$/.exec(head[2]);
155
+ if (rule) add(current, `[${rule[1]}:error] ${current} — ${rule[2]}`);
156
+ else add(current, head[2].startsWith("[") ? head[2] : line.trim());
157
+ continue;
158
+ }
159
+ const bare = current ? /^(slot-fit|svg-overlay|connector-endpoints|diagram-geometry|escape):\s+(.*)$/.exec(line) : null;
160
+ if (bare) {
161
+ add(current, `[${bare[1]}:error] ${current} — ${bare[2]}`);
415
162
  continue;
416
163
  }
417
164
  const tagged = /^\s*\[([a-z-]+):error\]\s+(\S+)\s+—/.exec(line);
@@ -421,7 +168,472 @@ export function parseTranslateErrors(message) {
421
168
  add(file, line.trim());
422
169
  continue;
423
170
  }
424
- add(current, line.trim());
171
+ if (open) {
172
+ const file = open;
173
+ add(file, `${out.get(file).pop()} ${line.trim()}`);
174
+ } else add(current, line.trim());
425
175
  }
426
176
  return out;
427
177
  }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Preservation — what a slide still OWES, checked by element identity.
181
+ // ---------------------------------------------------------------------------
182
+ //
183
+ // This replaces the old `ownership()` gate, which switched the concept-spec
184
+ // count check OFF the moment a slide's bytes stopped matching
185
+ // `state/transcribed.json`. That is exactly backwards: a hand edit is when a
186
+ // slide is most likely to lose something, and "the hash changed" was read as
187
+ // "nothing is owed any more" (E09 — a title edit that drops an arrow).
188
+ //
189
+ // What is owed now lives in `state/slide-preservation.json` (src/lib/edit-state.mjs),
190
+ // one requirement per element identity, and it survives any rewrite. Two
191
+ // evidence channels answer it, because neither can answer alone:
192
+ //
193
+ // source — the only place a connector can be checked. Its id is DERIVED from
194
+ // the pair it joins (`conn-a-b`) and never reaches the DOM, so the
195
+ // render literally cannot see one.
196
+ // render — the only place "present in the JSX but paints nothing" shows up.
197
+ //
198
+ // The modules are imported lazily: `edit-state` pulls in revision + bundle
199
+ // resolution, and `draft-check` is loaded eagerly by `status` and `concepts`.
200
+
201
+ /**
202
+ * The two packages a slide's obligations can be proved against: the import it
203
+ * came FROM, and the deck as last DELIVERED. Both or nothing — comparing a
204
+ * package with itself proves only that it exists, which is the "manufacture a
205
+ * package to satisfy the gate" move. A slide with no import baseline (anything
206
+ * authored rather than converted) therefore has no package evidence and stays
207
+ * `unknown`; see `packageEvidenceReachable`.
208
+ */
209
+ function packagePair(deckDir, fileName) {
210
+ const stem = fileName.replace(/\.[^.]+$/, "");
211
+ const base = path.join(deckDir, "conversions", `${stem}.slide.pptx`);
212
+ const delivered = deckPptxPath(deckDir);
213
+ if (!fs.existsSync(base) || !fs.existsSync(delivered)) return null;
214
+ return { base, delivered };
215
+ }
216
+
217
+ /** Can this slide's obligations be proved from packages at all? */
218
+ export function packageEvidenceReachable(deckDir, fileName) {
219
+ return packagePair(deckDir, fileName) !== null;
220
+ }
221
+
222
+ /**
223
+ * One slide's package evidence: its import compared against the bytes the deck
224
+ * last delivered, keyed by the element identity both packages carry.
225
+ *
226
+ * The delivered slide is found by its GUID in the baked source tags, never by
227
+ * position — a native slide spliced in ahead of it would shift every index.
228
+ * Null whenever anything cannot be established; nothing here guesses.
229
+ */
230
+ function workspaceResolvedTargets(deckDir, fileName, entry) {
231
+ // A requirement's relationship targets are minted in THREE namespaces and
232
+ // only one of them is an OOXML part path:
233
+ //
234
+ // start / end an element id on the same slide → the package resolves it
235
+ // package-part a WORKSPACE path → the filesystem does
236
+ // native-slot an index into the conversion → that record does
237
+ // record's `preserved[]`
238
+ //
239
+ // `composeSlideEvidence().resolvedTargets` only speaks the first. Passing it
240
+ // alone makes every native-object requirement fail `relationship_broken`
241
+ // against a file that is sitting right there — a FALSE DELETION, which is
242
+ // worse than the `unknown` it replaces. So each target is resolved in the
243
+ // namespace it was minted in, and anything that does not resolve stays
244
+ // unresolved and still fails.
245
+ const resolved = [];
246
+ let preserved = 0;
247
+ try {
248
+ const stem = fileName.replace(/\.[^.]+$/, "");
249
+ const record = JSON.parse(fs.readFileSync(path.join(deckDir, "conversions", `${stem}.json`), "utf8"));
250
+ preserved = Array.isArray(record?.preserved) ? record.preserved.length : 0;
251
+ } catch {}
252
+ for (const req of entry?.active ?? []) {
253
+ for (const rel of req.relationships ?? []) {
254
+ if (rel.role === "package-part") {
255
+ if (fs.existsSync(path.join(deckDir, String(rel.target)))) resolved.push(rel.target);
256
+ } else if (rel.role === "native-slot") {
257
+ const slot = Number(rel.target);
258
+ if (Number.isInteger(slot) && slot >= 0 && slot < preserved) resolved.push(rel.target);
259
+ }
260
+ }
261
+ }
262
+ return resolved;
263
+ }
264
+
265
+ async function packageEvidenceFor(deckDir, fileName, slideKey, entry) {
266
+ const pair = packagePair(deckDir, fileName);
267
+ if (!pair) return null;
268
+ try {
269
+ const [{ unzip }, { slideOrderPaths }, tags, { composeSlideEvidence }, verdictMod] = await Promise.all([
270
+ import("../pptx/zip.mjs"),
271
+ import("../pptx/ooxml.mjs"),
272
+ import("../pptx/tags.mjs"),
273
+ import("../pptx/package-evidence.mjs"),
274
+ import("../lib/preservation-verdict.mjs"),
275
+ ]);
276
+ const beforeFiles = unzip(fs.readFileSync(pair.base));
277
+ const afterFiles = unzip(fs.readFileSync(pair.delivered));
278
+ const part = slideOrderPaths(afterFiles).find((candidate) => {
279
+ try {
280
+ return tags.decodeSourceTags(tags.readSlideTags(afterFiles, candidate))?.guid === slideKey;
281
+ } catch {
282
+ return false;
283
+ }
284
+ });
285
+ if (!part) return null; // the delivered deck does not carry this slide
286
+ const before = composeSlideEvidence(beforeFiles);
287
+ const after = composeSlideEvidence(afterFiles, { slidePart: part });
288
+ if (!before.slidePart || !after.slidePart) return null;
289
+ return verdictMod.comparePackageEvidence(before.shapes, after.shapes, {
290
+ resolvedTargets: [
291
+ ...after.resolvedTargets,
292
+ ...workspaceResolvedTargets(deckDir, fileName, entry),
293
+ ],
294
+ });
295
+ } catch {
296
+ // Evidence that cannot be composed is absent evidence, never a pass.
297
+ return null;
298
+ }
299
+ }
300
+
301
+ async function preservationModules() {
302
+ const [state, verdict, guids] = await Promise.all([
303
+ import("../lib/edit-state.mjs"),
304
+ import("../lib/preservation-verdict.mjs"),
305
+ import("../lib/slide-guids.mjs"),
306
+ ]);
307
+ return { state, verdict, guids };
308
+ }
309
+
310
+ // failed > unsupported > unknown > passed. `unsupported` rolls up as `unknown`
311
+ // downstream — the verdict module already does that for its own status, and
312
+ // missing evidence is never a pass.
313
+ const EVIDENCE_ORDER = ["failed", "unsupported", "unknown", "passed"];
314
+ const worstOf = (statuses) => EVIDENCE_ORDER.find((s) => statuses.includes(s)) ?? "passed";
315
+
316
+ // The CLI's way to declare an intended removal, named in every [preserve:warn].
317
+ const supersedeHint = (deckDir) =>
318
+ `supersede the obligation explicitly (\`slidemind revision begin ${deckSlug(deckDir)} --operation targeted-edit\`)`;
319
+
320
+ /**
321
+ * The preservation diagnostics for one captured deck, per slide file.
322
+ *
323
+ * `slides` is `[{ fileName, render }]` straight off the capture. Returns
324
+ * `{ byFile, covered }` — `covered` names every slide that HAS a record,
325
+ * which is what stands the spec-count check down (each of its elements is
326
+ * checked by id instead, which is strictly stronger than counting them).
327
+ *
328
+ * Draft diagnostics never block: they name the element and the repair. What
329
+ * blocks is {@link preservationBlocker} at delivery.
330
+ */
331
+ export async function preservationDiagnostics(deckDir, slides, { persist = true } = {}) {
332
+ const empty = { byFile: {}, covered: new Set() };
333
+ let mods;
334
+ try {
335
+ mods = await preservationModules();
336
+ } catch {
337
+ return empty;
338
+ }
339
+ const { readSlidePreservation, writeSlidePreservation, openOperations, sourceHash } = mods.state;
340
+ const { requirementEvidenceFromSource, checkRequirements, evidenceStatus } = mods.state;
341
+ const { compareRenderEvidence, preservationVerdict, readSlideElements } = mods.verdict;
342
+
343
+ let doc;
344
+ try {
345
+ doc = readSlidePreservation(deckDir);
346
+ } catch (err) {
347
+ // Delivery refuses an unreadable record (preservationBlocker), so the draft
348
+ // names it as a defect, on the first slide, as the add-in's render does.
349
+ const first = (slides ?? []).find((s) => /\.jsx$/i.test(s.fileName ?? ""));
350
+ if (!first) return empty;
351
+ return {
352
+ ...empty,
353
+ byFile: { [first.fileName]: [preservationUnreadableLine({ fileName: first.fileName, detail: err.message })] },
354
+ };
355
+ }
356
+ if (Object.keys(doc.slides ?? {}).length === 0) return empty;
357
+
358
+ const guids = mods.guids.loadSlideGuids(deckDir);
359
+ let operations = [];
360
+ try {
361
+ operations = openOperations(deckDir);
362
+ } catch {}
363
+
364
+ const byFile = {};
365
+ const covered = new Set();
366
+ const next = { ...doc, slides: { ...doc.slides } };
367
+ let changed = false;
368
+
369
+ for (const { fileName, render } of slides ?? []) {
370
+ if (!/\.jsx$/i.test(fileName)) continue;
371
+ const jsxPath = `slides/${fileName}`;
372
+ const slideKey = guids[jsxPath];
373
+ const entry = slideKey ? doc.slides[slideKey] : null;
374
+ if (!entry) continue;
375
+ let jsx;
376
+ try {
377
+ jsx = fs.readFileSync(path.join(deckDir, jsxPath), "utf8");
378
+ } catch {
379
+ continue;
380
+ }
381
+ covered.add(fileName);
382
+ const operation =
383
+ operations.find((o) => (o.targets ?? []).some((t) => t?.slideKey === slideKey)) ?? null;
384
+
385
+ // Ask the render only about ids the source actually declares as a DOM id.
386
+ // Everything else (connectors above all) is the source check's business —
387
+ // asking the render would read "absent" for every arrow ever drawn.
388
+ const declared = readSlideElements(jsx).byId;
389
+ const ids = (entry.active ?? []).map((r) => r.elementId).filter((id) => declared.has(id));
390
+ let renderEvidence = compareRenderEvidence(render ?? null, {
391
+ canvas: render?.canvas ?? null,
392
+ elementIds: ids,
393
+ });
394
+ if (ids.length > 0 && renderEvidence.indexed.length === 0) {
395
+ // Boxes, but not one carries an identity: this capture proved nothing
396
+ // about any element. Silence is `unknown`, never absence.
397
+ renderEvidence = {
398
+ ...renderEvidence,
399
+ evidence: "absent",
400
+ byId: Object.fromEntries(
401
+ ids.map((id) => [id, { status: "unknown", reason: "no_identified_boxes" }]),
402
+ ),
403
+ };
404
+ }
405
+ // The third channel. Present only for a slide whose import AND delivered
406
+ // bytes are both on disk; a draft has neither, and gets none.
407
+ const packageEvidence = await packageEvidenceFor(deckDir, fileName, slideKey, entry);
408
+ const verdict = preservationVerdict({
409
+ entry,
410
+ operation,
411
+ slideKey,
412
+ render: renderEvidence,
413
+ packageEvidence,
414
+ binding: { sourceHash: sourceHash(deckDir, jsxPath) },
415
+ });
416
+ const source = checkRequirements(
417
+ entry,
418
+ requirementEvidenceFromSource(jsx, { origin: entry.origin }),
419
+ { operation },
420
+ );
421
+
422
+ // One verdict, both channels. The source violations become checks of the
423
+ // verdict's own shape so that everything downstream — the lines here, the
424
+ // refusal at delivery — reads ONE list and names the same elements.
425
+ const checks = [
426
+ ...verdict.checks,
427
+ ...source.violations.map((v) => ({
428
+ requirementId: v.requirementId ?? null,
429
+ elementId: v.elementId ?? null,
430
+ kind: null,
431
+ scope: null,
432
+ status: "failed",
433
+ reason: v.code,
434
+ detail: v.detail,
435
+ })),
436
+ ];
437
+ // `checkRequirements` reports every non-`media` scope UNVERIFIED, because
438
+ // source bytes cannot prove a text, fill or geometry is unchanged. That is
439
+ // "source cannot say", not "nobody can" — so once the package HAS spoken,
440
+ // its silence must not veto the answer. Its VIOLATIONS always count: a
441
+ // missing element, a repointed connector or a swapped asset is a fact about
442
+ // the source, which no package evidence makes untrue.
443
+ const sourceStatus = packageEvidence
444
+ ? source.violations.length > 0
445
+ ? "failed"
446
+ : "passed"
447
+ : evidenceStatus(source);
448
+ const status = worstOf([verdict.status, sourceStatus]);
449
+ const counts = Object.fromEntries(EVIDENCE_ORDER.map((s) => [s, 0]));
450
+ for (const c of checks) counts[c.status] = (counts[c.status] ?? 0) + 1;
451
+ const merged = { ...verdict, status, checks, counts };
452
+
453
+ // One wording on both clients (draft-lines.mjs).
454
+ const lines = preservationFailureLines({
455
+ fileName,
456
+ checks,
457
+ active: entry.active,
458
+ supersede: supersedeHint(deckDir),
459
+ });
460
+ if (lines.length > 0) byFile[fileName] = lines;
461
+
462
+ if (entry.evidence !== status || entry.verdict?.binding?.sourceHash !== merged.binding.sourceHash) {
463
+ next.slides[slideKey] = { ...entry, evidence: status, verdict: merged };
464
+ changed = true;
465
+ }
466
+ }
467
+
468
+ if (persist && changed) {
469
+ // Obligations are never dropped here — only the verdict and the evidence
470
+ // status move — so the transition rule has nothing to refuse. A refusal is
471
+ // still swallowed: a draft preview must not die over its own bookkeeping.
472
+ try {
473
+ writeSlidePreservation(deckDir, next);
474
+ } catch {}
475
+ }
476
+ return { byFile, covered };
477
+ }
478
+
479
+ /**
480
+ * Why a FINAL replacement cannot go ahead, or null.
481
+ *
482
+ * Scope is REPLACEMENT, and a preservation record is what marks it — not
483
+ * session bookkeeping. Three things seed one, and all three mean "this slide
484
+ * existed before this build": `revision begin` (migrated from current
485
+ * evidence), `adopt` (restored from the package's embedded tags,
486
+ * src/lib/preservation-tags.mjs), and `transcribe --repair`. A deck authored
487
+ * from scratch acquires none of them, so ordinary authoring is never gated —
488
+ * this change safeguards replacement, it does not add a precondition to
489
+ * creating slides.
490
+ *
491
+ * Deliberately NOT keyed on an open operation. An operation is how a user
492
+ * DECLARES an edit and what permits a removal (`permittedChanges`, honoured in
493
+ * the draft verdict); it is not how a replacement is detected. Keying on it
494
+ * would have left the commonest replacement there is — `build-pptx` on an
495
+ * adopted deck, whose records `adopt` restored — entirely ungated, so E09's
496
+ * protection would have existed only for users who happened to open a scope by
497
+ * hand. Build-time inference (`inferBuildOperations`) would answer the same
498
+ * question one indirection later and has no production caller today.
499
+ *
500
+ * ponytail: draft evidence alone rarely reaches `passed` (a connector's
501
+ * endpoints and a native's package closure are only resolvable against the
502
+ * final package, which is task 5.5/5.6's channel). Until that lands, the way
503
+ * past it is to fix what the draft names, not to remove the record.
504
+ */
505
+ export async function preservationBlocker(deckDir) {
506
+ const clear = { blocker: null, note: null };
507
+ let mods;
508
+ try {
509
+ mods = await preservationModules();
510
+ } catch {
511
+ return clear;
512
+ }
513
+ const { readSlidePreservation, finalEvidenceBlockers, sourceHash } = mods.state;
514
+ const { verdictIsStale, requirementSetHash } = mods.verdict;
515
+
516
+ let doc;
517
+ try {
518
+ doc = readSlidePreservation(deckDir);
519
+ } catch (err) {
520
+ return { blocker: err.message, note: null };
521
+ }
522
+ // The sidecar remembers a slide's identity after the file is gone; only what
523
+ // is still IN the deck can be replaced by this build, so only that is gated.
524
+ const fileOf = new Map(
525
+ Object.entries(mods.guids.loadSlideGuids(deckDir))
526
+ .filter(([jsxPath]) => fs.existsSync(path.join(deckDir, jsxPath)))
527
+ .map(([jsxPath, key]) => [key, jsxPath]),
528
+ );
529
+ const keys = Object.keys(doc.slides ?? {}).filter((slideKey) => fileOf.has(slideKey));
530
+ if (keys.length === 0) return { blocker: null, note: null };
531
+
532
+ // Is the recorded verdict about the bytes we are shipping?
533
+ const current = (slideKey) =>
534
+ !verdictIsStale(doc.slides[slideKey]?.verdict, {
535
+ sourceHash: sourceHash(deckDir, fileOf.get(slideKey)),
536
+ requirementSetHash: requirementSetHash(doc.slides[slideKey]),
537
+ });
538
+ const failedOf = (slideKey) =>
539
+ (doc.slides[slideKey]?.verdict?.checks ?? []).filter((c) => c.status === "failed" && c.elementId != null);
540
+
541
+ // `finalEvidenceBlockers` is the authority on EVIDENCE state, and it yields
542
+ // nothing for a `passed` entry. Freshness is a dimension it does not model,
543
+ // so every slide is walked — otherwise a verdict that passed once would keep
544
+ // certifying bytes it never saw.
545
+ const byKey = new Map(finalEvidenceBlockers(deckDir, keys).map((b) => [b.slideKey, b]));
546
+ const unproven = [];
547
+ const problems = [];
548
+ for (const slideKey of keys) {
549
+ const blocker = byKey.get(slideKey) ?? {
550
+ slideKey,
551
+ code: "evidence_passed",
552
+ reason: "preservation evidence is 'passed'",
553
+ };
554
+ // A record with a VIOLATED obligation refuses, always — that is E09.
555
+ if (blocker.code === "evidence_failed" || failedOf(blocker.slideKey).length > 0) {
556
+ problems.push(blocker);
557
+ continue;
558
+ }
559
+ // Anything else is "not proven", and there are two very different reasons.
560
+ // Nobody has checked THESE bytes: one command fixes that, so refuse and
561
+ // name it. Checking is what catches an edit made after the last draft.
562
+ if (!current(slideKey)) {
563
+ problems.push({
564
+ ...blocker,
565
+ code: "evidence_stale",
566
+ reason: "these exact bytes have not been checked (no verdict, or one bound to earlier source)",
567
+ });
568
+ continue;
569
+ }
570
+ if (!byKey.has(slideKey)) continue; // checked, current, and evidence passed
571
+ // Checked, nothing violated, and something still unproven. Which of two
572
+ // things that is decides whether it refuses.
573
+ //
574
+ // The package channel now exists (`src/pptx/package-evidence.mjs`), so a
575
+ // slide whose import and delivered bytes are both on disk CAN reach
576
+ // `passed` — and one that did not is a genuine failure to prove, which
577
+ // refuses. That is the guide's rung, restored.
578
+ //
579
+ // A slide with no import baseline has no package to be proved against at
580
+ // all; refusing it would block authored decks for evidence that cannot
581
+ // exist, which is the outage this compromise was opened to avoid. It ships
582
+ // with the limitation named, and is still never recorded as a pass.
583
+ if (packageEvidenceReachable(deckDir, path.basename(fileOf.get(slideKey)))) {
584
+ problems.push({
585
+ ...blocker,
586
+ code: "evidence_unproven",
587
+ reason:
588
+ "this slide can be proved against its imported and delivered packages, and this render did not prove it",
589
+ });
590
+ continue;
591
+ }
592
+ unproven.push(blocker);
593
+ }
594
+
595
+ // The note NAMES what went unproven, per slide and per element. A generic
596
+ // caveat would let a later reader mistake a shipped-with-note slide for a
597
+ // verified one, which is the only thing that would make this compromise
598
+ // unacceptable. The record still says `unknown`; nothing here writes a pass.
599
+ const WHY = {
600
+ relationships_not_resolved: "what it is bound to could not be resolved",
601
+ not_checked_in_render: "absent from this render's evidence",
602
+ not_checked_in_package: "no final-package evidence",
603
+ no_evidence_supplied: "no evidence at all",
604
+ scope_not_compared: "its protected properties were not compared",
605
+ no_identified_boxes: "the capture carried no element identities",
606
+ };
607
+ const note = unproven.length
608
+ ? `preservation: ${unproven.length} slide${unproven.length === 1 ? "" : "s"} shipped with obligations this build ` +
609
+ `could not prove — nothing was violated, nothing was certified either:\n` +
610
+ unproven
611
+ .map((b) => {
612
+ const owed = new Map();
613
+ for (const c of doc.slides[b.slideKey]?.verdict?.checks ?? []) {
614
+ if (c.status !== "unknown" && c.status !== "unsupported") continue;
615
+ const id = c.elementId ?? "(slide)";
616
+ if (!owed.has(id)) owed.set(id, new Set());
617
+ owed.get(id).add(WHY[c.reason] ?? c.reason ?? "unproven");
618
+ }
619
+ const items = [...owed].map(([id, why]) => `${id} (${[...why].join("; ")})`);
620
+ return ` ${fileOf.get(b.slideKey) ?? b.slideKey} — ${items.join(", ") || b.reason}`;
621
+ })
622
+ .join("\n")
623
+ : null;
624
+ if (problems.length === 0) return { blocker: null, note };
625
+
626
+ const slug = deckSlug(deckDir);
627
+ const detail = problems.map((p) => {
628
+ const file = fileOf.get(p.slideKey) ?? p.slideKey;
629
+ const failed = failedOf(p.slideKey).map((c) => c.elementId);
630
+ const owed = failed.length > 0 ? ` — unmet: ${[...new Set(failed)].join(", ")}` : "";
631
+ return ` ${file} — ${p.reason}${owed}`;
632
+ });
633
+ return { note, blocker: (
634
+ `${problems.length} slide${problems.length === 1 ? "" : "s"} cannot be replaced: ` +
635
+ `${problems.length === 1 ? "its" : "their"} preservation obligations are not established.\n${detail.join("\n")}\n` +
636
+ `Fix the [preserve:warn] lines and re-run \`slidemind view-draft ${slug}\` to re-establish the verdict. ` +
637
+ `An intentional removal is superseded explicitly: \`slidemind revision begin ${slug} --operation targeted-edit --slides <sel>\`.`
638
+ ) };
639
+ }