slidemind 0.3.19 → 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 (186) 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/engine-slides.mjs +1 -1
  16. package/src/bridge/client/render.mjs +27 -1
  17. package/src/bridge/client/transport.mjs +499 -23
  18. package/src/bridge/client.mjs +10 -0
  19. package/src/bridge/config.mjs +67 -6
  20. package/src/bridge/engine-download.mjs +0 -10
  21. package/src/bridge/graph-auth.mjs +55 -3
  22. package/src/bridge/image-intent-envelope.mjs +239 -0
  23. package/src/bridge/package-preview.mjs +67 -0
  24. package/src/bridge/sharepoint.mjs +914 -0
  25. package/src/builders/asset-fetch.mjs +203 -0
  26. package/src/builders/block-dividers.mjs +83 -0
  27. package/src/builders/build.mjs +171 -87
  28. package/src/builders/card-height.mjs +294 -0
  29. package/src/builders/chart-hole.mjs +183 -0
  30. package/src/builders/chart-jsx.mjs +410 -0
  31. package/src/builders/chart-labels.mjs +326 -0
  32. package/src/builders/concept-geometry.mjs +370 -0
  33. package/src/builders/concept-intent.mjs +200 -0
  34. package/src/builders/concept-layout.mjs +79 -0
  35. package/src/builders/concept-lines.mjs +509 -0
  36. package/src/builders/concept-lists.mjs +270 -0
  37. package/src/builders/concept-marks.mjs +84 -0
  38. package/src/builders/concept-relationships.mjs +12 -0
  39. package/src/builders/concept-spec.mjs +645 -0
  40. package/src/builders/concept-text.mjs +140 -0
  41. package/src/builders/concept-waves.mjs +57 -0
  42. package/src/builders/copy-fit.mjs +139 -0
  43. package/src/builders/design-tokens.mjs +347 -0
  44. package/src/builders/draft-check.mjs +552 -340
  45. package/src/builders/draft-lines.mjs +369 -0
  46. package/src/builders/edit-route.mjs +29 -0
  47. package/src/builders/figure-sketch.mjs +375 -0
  48. package/src/builders/fold-sets.mjs +639 -0
  49. package/src/builders/gate-server.mjs +9 -4
  50. package/src/builders/gate-session.js +273 -12
  51. package/src/builders/gate-shell.js +1494 -0
  52. package/src/builders/image-intent-check.mjs +308 -0
  53. package/src/builders/image-use-reconcile.mjs +107 -0
  54. package/src/builders/kit-helpers.mjs +121 -0
  55. package/src/builders/layout-names.mjs +32 -0
  56. package/src/builders/legacy-reference-uses.mjs +179 -0
  57. package/src/builders/line-fit.mjs +224 -0
  58. package/src/builders/line-native.mjs +895 -0
  59. package/src/builders/line-relations.mjs +230 -0
  60. package/src/builders/lint-fix.mjs +117 -9
  61. package/src/builders/lint.mjs +25 -20
  62. package/src/builders/list-jsx.mjs +88 -0
  63. package/src/builders/placement.mjs +133 -0
  64. package/src/builders/plan-checks.mjs +135 -0
  65. package/src/builders/plan-comments.mjs +19 -0
  66. package/src/builders/plan-handlers.mjs +95 -0
  67. package/src/builders/plan-markdown-edit.mjs +3 -3
  68. package/src/builders/plan-overlay.js +1530 -1608
  69. package/src/builders/plan.mjs +689 -186
  70. package/src/builders/preset-definitions.json +1 -0
  71. package/src/builders/preset-eval.mjs +244 -0
  72. package/src/builders/preview.html +51 -120
  73. package/src/builders/red-thread-backbone.mjs +135 -36
  74. package/src/builders/repair.mjs +205 -0
  75. package/src/builders/repeat-groups.mjs +540 -0
  76. package/src/builders/resolve-skill.mjs +5 -4
  77. package/src/builders/review-overlay.js +296 -293
  78. package/src/builders/review.mjs +305 -84
  79. package/src/builders/rule-shift.mjs +96 -0
  80. package/src/builders/seat.mjs +354 -0
  81. package/src/builders/settle-driver.mjs +236 -0
  82. package/src/builders/settle.mjs +1448 -0
  83. package/src/builders/slide-prep.mjs +67 -0
  84. package/src/builders/spec-assets.mjs +236 -0
  85. package/src/builders/spec-match.mjs +56 -0
  86. package/src/builders/stack.mjs +413 -0
  87. package/src/builders/storyboard-markdown.mjs +70 -27
  88. package/src/builders/storyboard-schema.mjs +54 -114
  89. package/src/builders/text-blocks.mjs +212 -0
  90. package/src/builders/text-card-fit.mjs +552 -0
  91. package/src/builders/text-stack.mjs +276 -0
  92. package/src/builders/transcribe.mjs +1069 -0
  93. package/src/cli/assets.mjs +164 -0
  94. package/src/cli/common.mjs +173 -12
  95. package/src/cli/concept-spec-support.mjs +76 -0
  96. package/src/cli/concepts.mjs +1002 -103
  97. package/src/cli/deck.mjs +401 -65
  98. package/src/cli/diagnose.mjs +4 -2
  99. package/src/cli/engine-helpers.mjs +17 -10
  100. package/src/cli/engine-results.mjs +76 -3
  101. package/src/cli/help.mjs +232 -28
  102. package/src/cli/install.mjs +9 -0
  103. package/src/cli/maintenance.mjs +104 -0
  104. package/src/cli/read.mjs +2 -1
  105. package/src/cli/ready.mjs +6 -3
  106. package/src/cli/record.mjs +522 -0
  107. package/src/cli/remote.mjs +818 -0
  108. package/src/cli/revision.mjs +686 -0
  109. package/src/cli/search.mjs +30 -11
  110. package/src/cli/session.mjs +56 -13
  111. package/src/cli/setup.mjs +46 -2
  112. package/src/cli/status.mjs +142 -33
  113. package/src/cli/tasks.mjs +246 -0
  114. package/src/cli/transcribe.mjs +854 -0
  115. package/src/cli/workspace-ui.mjs +260 -0
  116. package/src/cli/workspace.mjs +212 -34
  117. package/src/install-channel.mjs +64 -7
  118. package/src/lib/adopt.mjs +346 -25
  119. package/src/lib/concept-spec-binding.mjs +96 -0
  120. package/src/lib/conversions.mjs +70 -6
  121. package/src/lib/convert-folded.mjs +107 -0
  122. package/src/lib/deck-paths.mjs +418 -0
  123. package/src/lib/durable-ids.mjs +47 -0
  124. package/src/lib/edit-operations.mjs +316 -0
  125. package/src/lib/edit-state.mjs +1244 -0
  126. package/src/lib/edit-transitions.mjs +120 -0
  127. package/src/lib/extensions.mjs +501 -0
  128. package/src/lib/image-assets.mjs +465 -0
  129. package/src/lib/image-derivatives.mjs +234 -0
  130. package/src/lib/image-intent-fingerprint.mjs +70 -0
  131. package/src/lib/image-intent-tags.mjs +176 -0
  132. package/src/lib/image-intent.mjs +802 -0
  133. package/src/lib/image-use-overrides.mjs +279 -0
  134. package/src/lib/inline-components.mjs +613 -0
  135. package/src/lib/lint-converted.mjs +68 -4
  136. package/src/lib/merge-json.mjs +163 -0
  137. package/src/lib/overwrite-guard.mjs +3 -3
  138. package/src/lib/presentation-context.mjs +531 -0
  139. package/src/lib/preservation-tags.mjs +157 -0
  140. package/src/lib/preservation-verdict.mjs +1342 -0
  141. package/src/lib/publish.mjs +1557 -0
  142. package/src/lib/rebase.mjs +945 -0
  143. package/src/lib/reconcile-ack.mjs +4 -6
  144. package/src/lib/reconcile.mjs +62 -24
  145. package/src/lib/record-client.mjs +175 -0
  146. package/src/lib/record-state.mjs +151 -0
  147. package/src/lib/remote.mjs +661 -0
  148. package/src/lib/revision.mjs +1210 -0
  149. package/src/lib/slide-guids.mjs +4 -6
  150. package/src/lib/slide-preservation.mjs +423 -0
  151. package/src/lib/slide-selection.mjs +9 -0
  152. package/src/lib/workspace-transaction.mjs +117 -0
  153. package/src/pptx/content-digest.mjs +334 -0
  154. package/src/pptx/deck-assemble.mjs +1278 -0
  155. package/src/pptx/fill/engine.mjs +2 -3
  156. package/src/pptx/fill/freeform.mjs +6 -3
  157. package/src/pptx/ooxml.mjs +20 -0
  158. package/src/pptx/package-evidence.mjs +383 -0
  159. package/src/pptx/tags.mjs +163 -11
  160. package/src/update-check.mjs +22 -4
  161. package/src/workspace/adapters.mjs +222 -0
  162. package/src/workspace/auth.mjs +256 -0
  163. package/src/workspace/autostart.mjs +204 -0
  164. package/src/workspace/daemon.mjs +360 -0
  165. package/src/workspace/discovery.mjs +180 -0
  166. package/src/workspace/edit.mjs +167 -0
  167. package/src/workspace/events.mjs +52 -0
  168. package/src/workspace/gate-client.mjs +290 -0
  169. package/src/workspace/handback.mjs +73 -0
  170. package/src/workspace/lifecycle.mjs +635 -0
  171. package/src/workspace/operations.mjs +200 -0
  172. package/src/workspace/pick-folder.mjs +152 -0
  173. package/src/workspace/registry.mjs +359 -0
  174. package/src/workspace/remote-links.mjs +397 -0
  175. package/src/workspace/remote-status.mjs +359 -0
  176. package/src/workspace/routes.mjs +1331 -0
  177. package/src/workspace/server.mjs +159 -0
  178. package/src/workspace/sync.mjs +1119 -0
  179. package/src/workspace/task-actions.mjs +215 -0
  180. package/src/workspace/tasks.mjs +507 -0
  181. package/src/workspace/ui/app.js +1886 -0
  182. package/src/workspace/ui/index.html +141 -0
  183. package/src/workspace/ui/plan-bridge.js +120 -0
  184. package/src/workspace/ui/sharepoint.svg +59 -0
  185. package/src/workspace/ui/styles.css +423 -0
  186. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,1244 @@
1
+ // The two edit-safeguard sidecars on disk: `state/edit-operations.json` (what
2
+ // this run is allowed to change) and `state/slide-preservation.json` (what the
3
+ // slide must still contain afterwards). The SHAPES live in
4
+ // `edit-operations.mjs` / `slide-preservation.mjs` — pure, mirrored into the
5
+ // add-in, and the only judges of validity. This module is the CLI's I/O half:
6
+ // read, validate, refuse, write atomically, and migrate a deck that predates
7
+ // both records.
8
+ //
9
+ // Three rules the implementation exists to keep:
10
+ //
11
+ // • A RECORD WE CANNOT READ IS NEVER REWRITTEN. Corrupt JSON, a schema from a
12
+ // future build, or a record that fails its own validator all raise a coded
13
+ // error and leave the bytes exactly as they were found. Downgrading state
14
+ // is how obligations silently disappear.
15
+ // • IDENTITY IS RESOLVED, NEVER INFERRED. `--slides 2,7` is an INPUT
16
+ // grammar; what gets persisted is the slide's durable GUID
17
+ // (src/lib/slide-guids.mjs), minted and saved when a slide has none yet.
18
+ // Nothing here stores a slide number.
19
+ // • MIGRATION READS THE SOURCE IN FRONT OF IT. A slide that has been edited
20
+ // since `transcribe` stamped it is seeded from its CURRENT source, never
21
+ // from the concept spec it no longer resembles, and its evidence is
22
+ // `unknown` — which is not `passed`.
23
+
24
+ import { createHash, randomUUID } from "node:crypto";
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+
28
+ import { statePath } from "./deck-paths.mjs";
29
+ import { readRevision, slideFileOrder, writeStateJson } from "./revision.mjs";
30
+ import { loadSlideGuids, saveSlideGuids, slideGuidEntry, reconcileSlideGuids } from "./slide-guids.mjs";
31
+ import { conversionProvenance, loadConversions } from "./conversions.mjs";
32
+ import { mintSlideGuid } from "../pptx/tags.mjs";
33
+ import {
34
+ EDIT_OPERATIONS_FILE,
35
+ EDIT_OPERATION_VERSION,
36
+ OPERATION_KINDS,
37
+ operationSlideKeys,
38
+ validateEditOperation,
39
+ validateEditOperationsFile,
40
+ } from "./edit-operations.mjs";
41
+ import {
42
+ SLIDE_PRESERVATION_FILE,
43
+ SLIDE_PRESERVATION_VERSION,
44
+ amendForOperation,
45
+ validatePreservationUpdate,
46
+ validateSlidePreservationFile,
47
+ } from "./slide-preservation.mjs";
48
+ import { PROPERTY_SCOPES } from "./edit-operations.mjs";
49
+ import { isDurableId } from "./durable-ids.mjs";
50
+ import { bindSplitIds, readSlideElements } from "./preservation-verdict.mjs";
51
+ import { inlineComponents } from "./inline-components.mjs";
52
+ import { cleanText, readSpec } from "../builders/concept-spec.mjs";
53
+
54
+ const sha256 = (data) => createHash("sha256").update(data).digest("hex");
55
+
56
+ /**
57
+ * What a SEEDED requirement protects, by where the obligation came from.
58
+ *
59
+ * Existence is protected unconditionally — an empty `properties` list still
60
+ * means "this element must still be there". Beyond that, only what the evidence
61
+ * at hand actually establishes, because a scope nobody can justify is a block
62
+ * on an edit the user is entitled to make:
63
+ *
64
+ * concept the spec IS the approved content: its text is the business
65
+ * fact, its image/icon the asset that was chosen, its fill the
66
+ * design decision. GEOMETRY IS NOT PROTECTED — settling moves
67
+ * elements by design, and the repair budget (phase 6) is what
68
+ * governs that, not a permanent obligation.
69
+ * converted an import is a fidelity claim about somebody else's slide:
70
+ * its raw borders, spacing and typography are precisely the
71
+ * thing being preserved (E02), so the full set applies. The
72
+ * CLI never snaps or settles converted source, so nothing
73
+ * legitimate trips over it.
74
+ * legacy-current hand-owned or pre-record source. The bytes prove an element
75
+ * is there and which asset it points at; they prove nothing
76
+ * about which of its properties were deliberate, so nothing
77
+ * deeper is claimed. Stated as a limitation rather than
78
+ * guessed at in either direction.
79
+ */
80
+ export const PROTECTED_BY_ORIGIN = {
81
+ concept: ["text", "fill", "media", "chart", "native"],
82
+ converted: ["text", "geom", "fill", "line", "media", "chart", "native"],
83
+ "legacy-current": ["media", "native"],
84
+ };
85
+
86
+ // Candidate scopes ∩ what this origin may claim, in the vocabulary's own order
87
+ // so two seeds of the same slide are byte-comparable.
88
+ const protect = (candidates, origin) => {
89
+ const allowed = new Set(PROTECTED_BY_ORIGIN[origin] ?? PROTECTED_BY_ORIGIN["legacy-current"]);
90
+ const wanted = new Set(candidates);
91
+ return PROPERTY_SCOPES.filter((scope) => allowed.has(scope) && wanted.has(scope));
92
+ };
93
+
94
+ /** Operations that still hold their slides. Anything else has let go. */
95
+ export const OPEN_OPERATION_STATUSES = ["prepared", "validated"];
96
+
97
+ /** The kinds `revision begin --operation` accepts: `create` is a build's inference, never a session's claim. */
98
+ export const SESSION_OPERATION_KINDS = OPERATION_KINDS.filter((k) => k !== "create");
99
+
100
+ const EMPTY_OPERATIONS = { version: EDIT_OPERATION_VERSION, operations: [] };
101
+ const EMPTY_PRESERVATION = { version: SLIDE_PRESERVATION_VERSION, slides: {} };
102
+
103
+ const stateError = (code, message, extra = {}) =>
104
+ Object.assign(new Error(message), { code, ...extra });
105
+
106
+ const errorLines = (errors) =>
107
+ errors.slice(0, 8).map((e) => ` ${e.path}: ${e.message}`).join("\n") +
108
+ (errors.length > 8 ? `\n … and ${errors.length - 8} more` : "");
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // read / write
112
+ // ---------------------------------------------------------------------------
113
+
114
+ function readDoc(workspaceDir, name, validate, empty) {
115
+ const file = statePath(workspaceDir, name);
116
+ let raw;
117
+ try {
118
+ raw = fs.readFileSync(file, "utf8");
119
+ } catch (err) {
120
+ if (err?.code === "ENOENT") return structuredClone(empty);
121
+ throw stateError("state_unreadable", `state/${name} cannot be read (${err.message}); nothing was changed.`);
122
+ }
123
+ let doc;
124
+ try {
125
+ doc = JSON.parse(raw);
126
+ } catch (err) {
127
+ throw stateError(
128
+ "state_invalid",
129
+ `state/${name} is not readable JSON (${err.message}). The file was left untouched — ` +
130
+ `repair or remove it before editing this deck.`,
131
+ );
132
+ }
133
+ const { ok, errors } = validate(doc);
134
+ if (ok) return doc;
135
+ // A version this build does not know is its own outcome: the record may be
136
+ // perfectly valid for a newer slidemind, so it is preserved and named.
137
+ const future = errors.find((e) => e.code === "unsupported_version");
138
+ if (future) {
139
+ throw stateError("unsupported_version", `state/${name}: ${future.message} The file was left untouched.`, {
140
+ file,
141
+ });
142
+ }
143
+ throw stateError(
144
+ "state_invalid",
145
+ `state/${name} does not match the record contract, so it was left untouched:\n${errorLines(errors)}`,
146
+ { errors },
147
+ );
148
+ }
149
+
150
+ /** `{ version, operations[] }` — an absent file reads as empty, never as an error. */
151
+ export function readEditOperations(workspaceDir) {
152
+ return readDoc(workspaceDir, EDIT_OPERATIONS_FILE, (d) => validateEditOperationsFile(d), EMPTY_OPERATIONS);
153
+ }
154
+
155
+ /** `{ version, slides{} }` — same. */
156
+ export function readSlidePreservation(workspaceDir) {
157
+ return readDoc(workspaceDir, SLIDE_PRESERVATION_FILE, (d) => validateSlidePreservationFile(d), EMPTY_PRESERVATION);
158
+ }
159
+
160
+ /** Validate, then write-then-rename. An invalid document never reaches disk. */
161
+ export function writeEditOperations(workspaceDir, doc) {
162
+ const { ok, errors } = validateEditOperationsFile(doc);
163
+ if (!ok) {
164
+ throw stateError("state_invalid", `refusing to write state/${EDIT_OPERATIONS_FILE}:\n${errorLines(errors)}`, {
165
+ errors,
166
+ });
167
+ }
168
+ writeStateJson(workspaceDir, EDIT_OPERATIONS_FILE, doc);
169
+ return doc;
170
+ }
171
+
172
+ /**
173
+ * Write the preservation file. The transition rule runs FIRST: `next` may not
174
+ * have dropped an obligation `previous` held unless it supersedes it inside
175
+ * `operation`'s scope. A rejected write leaves the previous bytes in place.
176
+ */
177
+ export function writeSlidePreservation(workspaceDir, next, { operation = null } = {}) {
178
+ const previous = readSlidePreservation(workspaceDir);
179
+ const { ok, errors } = validatePreservationUpdate(previous, next, { operation });
180
+ if (!ok) {
181
+ throw stateError("state_invalid", `refusing to write state/${SLIDE_PRESERVATION_FILE}:\n${errorLines(errors)}`, {
182
+ errors,
183
+ });
184
+ }
185
+ writeStateJson(workspaceDir, SLIDE_PRESERVATION_FILE, next);
186
+ return next;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // operations
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export function readOperation(workspaceDir, operationId) {
194
+ return readEditOperations(workspaceDir).operations.find((o) => o.operationId === operationId) ?? null;
195
+ }
196
+
197
+ /** The operations that still hold slides — what a lease check compares against. */
198
+ export function openOperations(workspaceDir) {
199
+ return readEditOperations(workspaceDir).operations.filter((o) => OPEN_OPERATION_STATUSES.includes(o.status));
200
+ }
201
+
202
+ /**
203
+ * Persist one operation, upserting by `operationId`.
204
+ *
205
+ * The LEASE: a slide is scoped by at most one open operation. A second, different
206
+ * operation naming the same slide is refused rather than layered on top — two
207
+ * live scopes on one slide means neither one describes what may change.
208
+ */
209
+ export function recordOperation(workspaceDir, operation) {
210
+ const { ok, errors } = validateEditOperation(operation);
211
+ if (!ok) {
212
+ throw stateError("operation_invalid", `this operation record is not valid:\n${errorLines(errors)}`, { errors });
213
+ }
214
+ const doc = readEditOperations(workspaceDir);
215
+ const mine = new Set(operationSlideKeys(operation));
216
+ for (const other of doc.operations) {
217
+ if (other.operationId === operation.operationId) continue;
218
+ if (!OPEN_OPERATION_STATUSES.includes(other.status)) continue;
219
+ const clash = operationSlideKeys(other).filter((k) => mine.has(k));
220
+ if (clash.length > 0) {
221
+ throw stateError(
222
+ "operation_conflict",
223
+ `slide ${clash[0]} is already scoped by operation ${other.operationId} (${other.kind}, ${other.status}). ` +
224
+ `Finish that edit session (\`slidemind revision finish <deck>\`) before opening a second scope on it.`,
225
+ { operationId: other.operationId, slideKeys: clash },
226
+ );
227
+ }
228
+ }
229
+ writeEditOperations(workspaceDir, {
230
+ version: EDIT_OPERATION_VERSION,
231
+ operations: [...doc.operations.filter((o) => o.operationId !== operation.operationId), operation],
232
+ });
233
+ return operation;
234
+ }
235
+
236
+ /** Move every open operation to `status` — what the end of an edit session means. */
237
+ export function closeOpenOperations(workspaceDir, { status = "aborted" } = {}) {
238
+ const doc = readEditOperations(workspaceDir);
239
+ const closed = [];
240
+ const operations = doc.operations.map((o) => {
241
+ if (!OPEN_OPERATION_STATUSES.includes(o.status)) return o;
242
+ closed.push(o.operationId);
243
+ return { ...o, status };
244
+ });
245
+ if (closed.length > 0) writeEditOperations(workspaceDir, { version: EDIT_OPERATION_VERSION, operations });
246
+ return closed;
247
+ }
248
+
249
+ /** sha256 of a slide's bytes as they are on disk right now, or null when it is gone. */
250
+ export function sourceHash(workspaceDir, jsxPath) {
251
+ try {
252
+ return sha256(fs.readFileSync(path.join(workspaceDir, jsxPath)));
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Compare-and-swap: every target's source must still be the bytes the operation
260
+ * was prepared against. A candidate built from source that has since moved is
261
+ * not this operation's candidate, whatever its ledger says.
262
+ */
263
+ export function assertOperationFresh(workspaceDir, operation) {
264
+ for (const target of operation?.targets ?? []) {
265
+ if (!target?.baseSourceHash) continue;
266
+ const current = sourceHash(workspaceDir, target.jsxPath);
267
+ if (current === null) {
268
+ throw stateError(
269
+ "source_missing",
270
+ `${target.jsxPath} is gone, but operation ${operation.operationId} was prepared against it.`,
271
+ { slideKey: target.slideKey, jsxPath: target.jsxPath },
272
+ );
273
+ }
274
+ if (current !== target.baseSourceHash) {
275
+ throw stateError(
276
+ "source_moved",
277
+ `${target.jsxPath} changed since operation ${operation.operationId} was prepared ` +
278
+ `(${target.baseSourceHash.slice(0, 12)} → ${current.slice(0, 12)}). ` +
279
+ `Re-open the scope (\`slidemind revision begin\`) against the current source.`,
280
+ { slideKey: target.slideKey, jsxPath: target.jsxPath, expected: target.baseSourceHash, actual: current },
281
+ );
282
+ }
283
+ }
284
+ return true;
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // resolving a selection to durable identity
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /**
292
+ * "2,7" / "2-4" / "all" / "03-market.jsx" / "03-market" → the raw selectors.
293
+ * Position is an input grammar only: {@link resolveSlideTargets} turns each one
294
+ * into a slide GUID before anything is written down.
295
+ */
296
+ export function parseSlideSelection(raw) {
297
+ if (raw == null || raw === "" || raw === "all") return null;
298
+ const out = [];
299
+ for (const chunk of String(raw).split(",")) {
300
+ const token = chunk.trim();
301
+ if (!token) continue;
302
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(token);
303
+ if (range) {
304
+ const [from, to] = [Number(range[1]), Number(range[2])];
305
+ if (from < 1 || to < from) throw stateError("bad_selection", `'${token}' is not a slide range`);
306
+ for (let n = from; n <= to; n++) out.push(n);
307
+ continue;
308
+ }
309
+ if (/^\d+$/.test(token)) {
310
+ const n = Number(token);
311
+ if (n < 1) throw stateError("bad_selection", `'${token}' is not a 1-based slide number`);
312
+ out.push(n);
313
+ continue;
314
+ }
315
+ out.push(token);
316
+ }
317
+ return out.length > 0 ? out : null;
318
+ }
319
+
320
+ /**
321
+ * Every slide file in play order with its DURABLE key. A slide with no GUID yet
322
+ * (a deck that has never rendered) gets one minted and persisted here, because
323
+ * an operation that named a position would be worthless the moment the deck is
324
+ * reordered.
325
+ *
326
+ * `selection` is the output of {@link parseSlideSelection}; null means all.
327
+ */
328
+ export function resolveSlideTargets(workspaceDir, { selection = null } = {}) {
329
+ const known = loadSlideGuids(workspaceDir);
330
+ const guids = reconcileSlideGuids(workspaceDir);
331
+ const order = slideFileOrder(workspaceDir);
332
+ const fresh = {};
333
+ const all = order.map((file, i) => {
334
+ const jsxPath = `slides/${file}`;
335
+ let slideKey = guids[jsxPath] ?? null;
336
+ if (!slideKey) {
337
+ // `reconcileSlideGuids` tracks .jsx only, so it drops a reused native
338
+ // slide's entry every time it runs. `known` is the sidecar as it was
339
+ // before that, which is where a native's identity survives — and it is
340
+ // re-persisted below so the next command resolves the same key.
341
+ slideKey = known[jsxPath] ?? null;
342
+ if (!slideKey) slideKey = mintSlideGuid();
343
+ try {
344
+ fresh[jsxPath] = slideGuidEntry(slideKey, fs.readFileSync(path.join(workspaceDir, jsxPath)));
345
+ } catch {
346
+ fresh[jsxPath] = slideKey;
347
+ }
348
+ }
349
+ return { number: i + 1, file, jsxPath, slideKey, sourceHash: sourceHash(workspaceDir, jsxPath) };
350
+ });
351
+ if (Object.keys(fresh).length > 0) saveSlideGuids(workspaceDir, fresh);
352
+ if (!selection) return all;
353
+
354
+ const byNumber = new Map(all.map((t) => [t.number, t]));
355
+ const byName = new Map();
356
+ for (const t of all) {
357
+ byName.set(t.file, t);
358
+ byName.set(t.file.replace(/\.(jsx|pptx)$/i, ""), t);
359
+ byName.set(t.slideKey, t);
360
+ }
361
+ const picked = new Map();
362
+ for (const selector of selection) {
363
+ const hit = typeof selector === "number" ? byNumber.get(selector) : byName.get(selector);
364
+ if (!hit) {
365
+ throw stateError(
366
+ "bad_selection",
367
+ `--slides: this deck has no slide '${selector}' (it has ${all.length}: ${all.map((t) => t.file).join(", ") || "none"})`,
368
+ );
369
+ }
370
+ picked.set(hit.slideKey, hit);
371
+ }
372
+ return [...picked.values()].sort((a, b) => a.number - b.number);
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // legacy migration
377
+ // ---------------------------------------------------------------------------
378
+
379
+ // `state/transcribed.json`, written by `slidemind transcribe` (src/cli/transcribe.mjs):
380
+ // slide file name → sha256 of the utf8 source it wrote. Read directly rather
381
+ // than through that module, which is a command (its helpers exit the process).
382
+ // `state/transcribed-delivery.json`, written beside it: per file, the ids that
383
+ // write left out (`dropped`) and the line id each connector went under.
384
+ function readTranscribeStamps(workspaceDir, name = "transcribed.json") {
385
+ try {
386
+ const parsed = JSON.parse(fs.readFileSync(statePath(workspaceDir, name), "utf8"));
387
+ return parsed && typeof parsed === "object" ? parsed : {};
388
+ } catch {
389
+ return {};
390
+ }
391
+ }
392
+
393
+ // ponytail: tag scan by regex, not a JSX parse. Slide source has no `>` inside
394
+ // an attribute expression (`style={{…}}`, `data={{…}}`, numeric props), so the
395
+ // bound holds for everything the transcriber and the converter emit. A real
396
+ // parser belongs with the repair validator (phase 6), which needs one anyway.
397
+ const TAG_RE = /<[A-Za-z][^>]*>/g;
398
+ // `(?<![-\w])` and not `\b`: `\bid=` also matches the tail of
399
+ // `data-sm-source-id=`, which would hand a converted element its source id
400
+ // under the wrong attribute name. A braced value counts only as a string or
401
+ // number literal: `id={id}` names a helper's parameter, not a value.
402
+ const attr = (tag, name) => {
403
+ const m = new RegExp(`(?<![-\\w])${name}="([^"]*)"`).exec(tag);
404
+ if (m) return m[1];
405
+ const braced = new RegExp(`(?<![-\\w])${name}=\\{\\s*(?:"([^"]*)"|'([^']*)'|\\x60([^\\x60$]*)\\x60|(\\d+))\\s*\\}`).exec(tag);
406
+ return braced ? (braced[1] ?? braced[2] ?? braced[3] ?? braced[4]) : null;
407
+ };
408
+ /** `<div id={id} …>`: a helper's template, given a real id at each call site. */
409
+ const isTemplateTag = (tag) => attr(tag, "id") == null && /(?<![-\w])id=\{/.test(tag);
410
+
411
+ /** How much of an element's text a requirement keeps, to quote in a refusal. */
412
+ const EXCERPT_CHARS = 32;
413
+ const excerptOf = (text) => (text.length > EXCERPT_CHARS ? `${text.slice(0, EXCERPT_CHARS - 1)}…` : text);
414
+
415
+ /**
416
+ * The obligations readable from the source in front of us: one requirement per
417
+ * element that carries a durable id, connectors bound to their endpoints, and
418
+ * the native-object placeholders an import left behind.
419
+ *
420
+ * For a CONVERTED slide the ids are the converter's own shape ids
421
+ * (`data-sm-source-id`), never a concept's — an import has no concept. What
422
+ * each requirement protects comes from {@link PROTECTED_BY_ORIGIN}; an image
423
+ * also records the asset it points at (`mediaRef`), which is what makes a
424
+ * substitution detectable at the same element count (E11).
425
+ */
426
+ export function scanSourceRequirements(jsx, { packagePart = null, origin = "legacy-current" } = {}) {
427
+ const requirements = [];
428
+ const limitations = [];
429
+ const ids = new Set();
430
+ const sourceIds = new Set();
431
+ const connectors = [];
432
+ let unidentified = 0;
433
+ const read = readSlideElements(jsx);
434
+
435
+ // A set component's members are scanned where they are drawn, by their
436
+ // literal part ids (inline-components.mjs).
437
+ const inlined = inlineComponents(String(jsx ?? ""));
438
+ for (const u of inlined.unreadable) {
439
+ limitations.push(`<${u.component}> on line ${u.line} is spread from an object, so its members cannot be required back`);
440
+ }
441
+ for (const tag of inlined.src.match(TAG_RE) ?? []) {
442
+ if (/^<\//.test(tag) || isTemplateTag(tag)) continue;
443
+ const srcId = attr(tag, "data-sm-source-id");
444
+ const elementId = attr(tag, "id") ?? (srcId ? String(srcId) : null);
445
+ const isConnector = /\bdata-connector\b/.test(tag);
446
+ const isNative = /\bdata-sm-native-object\b/.test(tag);
447
+ const isImage = /^<img\b/i.test(tag);
448
+
449
+ if (isConnector) {
450
+ // A connector is identified by what it joins, so an unlabelled one is not
451
+ // ambiguous the way an unlabelled box is: its id is derived below, after
452
+ // both endpoints have been resolved against the slide.
453
+ connectors.push({ elementId, from: attr(tag, "data-from"), to: attr(tag, "data-to") });
454
+ if (elementId) ids.add(elementId);
455
+ if (srcId) sourceIds.add(String(srcId));
456
+ continue;
457
+ }
458
+ if (!elementId) {
459
+ // A layout wrapper carries no identity and owes nothing; a content element
460
+ // without one cannot be required back. Counted, never guessed at.
461
+ if (isNative || isImage || /\bx=|\bleft:/.test(tag)) unidentified += 1;
462
+ continue;
463
+ }
464
+ if (srcId) sourceIds.add(String(srcId));
465
+ ids.add(elementId);
466
+
467
+ if (isNative) {
468
+ const rel = packagePart ? [{ role: "package-part", target: packagePart }] : [];
469
+ if (rel.length === 0) {
470
+ limitations.push(
471
+ `${elementId} is a preserved native object whose source package is not in this deck; ` +
472
+ `its dependency closure cannot be required back`,
473
+ );
474
+ requirements.push({
475
+ requirementId: `req-element-${elementId}`,
476
+ elementId,
477
+ kind: "element",
478
+ // Still a native object: content, never decoration a redesign may drop.
479
+ properties: protect(["geom", "native"], origin),
480
+ });
481
+ continue;
482
+ }
483
+ const slot = attr(tag, "data-sm-native-object");
484
+ requirements.push({
485
+ requirementId: `req-native-object-${elementId}`,
486
+ elementId,
487
+ kind: "native-object",
488
+ properties: protect(["native"], origin),
489
+ relationships: slot != null ? [...rel, { role: "native-slot", target: String(slot) }] : rel,
490
+ });
491
+ continue;
492
+ }
493
+ // What each element class can even have: an image has bytes, a chart has
494
+ // data, an ordinary box has styling — and text or a picture fill only
495
+ // where it actually carries one. A shape with no content owes none, which
496
+ // is what lets a redesign restyle it away (`declaredFor`,
497
+ // preservation-verdict.mjs). The origin then decides which of those it is
498
+ // entitled to claim.
499
+ const isChart = /^<Chart\b/.test(tag);
500
+ const text = read.byId.get(elementId)?.text ?? "";
501
+ const candidates = isImage
502
+ ? ["media", "geom"]
503
+ : isChart
504
+ ? ["chart", "geom"]
505
+ : ["geom", "fill", "line", ...(text ? ["text"] : []), ...(/\burl\(/.test(tag) ? ["media"] : [])];
506
+ const properties = protect(candidates, origin);
507
+ const mediaRef = isImage ? attr(tag, "src") : null;
508
+ requirements.push({
509
+ requirementId: `req-element-${elementId}`,
510
+ elementId,
511
+ kind: "element",
512
+ properties,
513
+ ...(mediaRef ? { mediaRef } : {}),
514
+ ...(properties.includes("text") ? { excerpt: excerptOf(text) } : {}),
515
+ });
516
+ }
517
+
518
+ // A connector's endpoints are the whole obligation (E11: same element count,
519
+ // different endpoint). The emitted anchor may be prefixed — resolve it against
520
+ // the ids actually on this slide, and say so when it resolves to nothing.
521
+ const resolveEnd = (ref) => {
522
+ const bare = String(ref ?? "").replace(/^#/, "");
523
+ if (!bare) return null;
524
+ if (ids.has(bare)) return bare;
525
+ const suffix = /^sm-(.+)$/.exec(bare)?.[1];
526
+ if (suffix && (ids.has(suffix) || sourceIds.has(suffix))) return suffix;
527
+ return bare;
528
+ };
529
+ const usedConnectorIds = new Set(ids);
530
+ for (const c of connectors) {
531
+ const start = resolveEnd(c.from);
532
+ const end = resolveEnd(c.to);
533
+ if (!start || !end) {
534
+ // Without both ends there is no obligation to state and no identity to
535
+ // derive — the only honest outcome is to say so.
536
+ unidentified += 1;
537
+ limitations.push(`a connector names no endpoints in the source; it cannot be required back`);
538
+ continue;
539
+ }
540
+ // Derived, not invented: a connector the source did not name is identified
541
+ // by the pair it joins, and two connectors on the same pair are numbered in
542
+ // source order — the order both clients read the slide in.
543
+ let elementId = c.elementId;
544
+ if (!elementId) {
545
+ elementId = `conn-${start}-${end}`;
546
+ for (let n = 2; usedConnectorIds.has(elementId); n++) elementId = `conn-${start}-${end}-${n}`;
547
+ }
548
+ usedConnectorIds.add(elementId);
549
+ if (!ids.has(start) && !sourceIds.has(start)) {
550
+ limitations.push(`connector ${elementId} starts at '${start}', which is not an element of this slide`);
551
+ }
552
+ if (!ids.has(end) && !sourceIds.has(end)) {
553
+ limitations.push(`connector ${elementId} ends at '${end}', which is not an element of this slide`);
554
+ }
555
+ requirements.push({
556
+ requirementId: `req-connection-${elementId}`,
557
+ elementId,
558
+ kind: "connection",
559
+ // A connector's obligation IS its endpoints; the route is style, and only
560
+ // an import claims that much.
561
+ properties: protect(["geom", "line"], origin),
562
+ relationships: [
563
+ { role: "start", target: start },
564
+ { role: "end", target: end },
565
+ ],
566
+ });
567
+ }
568
+
569
+ if (unidentified > 0) {
570
+ limitations.push(
571
+ `${unidentified} element(s) in the current source carry no durable id, so their preservation cannot be required`,
572
+ );
573
+ }
574
+ return { requirements, limitations };
575
+ }
576
+
577
+ /**
578
+ * The obligations a TRANSCRIBED slide carries, taken from its concept spec's
579
+ * element ids (`state/concept-specs/concept-slide-NN.json`) rather than from
580
+ * the JSX those ids were written into. The spec is the approved content: it
581
+ * knows which text is a business fact, which asset was chosen and which element
582
+ * is a chart — none of which the emitted source states about itself.
583
+ *
584
+ * Connector ids are derived exactly as {@link scanSourceRequirements} derives
585
+ * them (`conn-<from>-<to>`), so a slide seeded from its spec and one re-read
586
+ * from its source name the same obligations.
587
+ */
588
+ export function specRequirements(spec, { origin = "concept" } = {}) {
589
+ const requirements = [];
590
+ const limitations = [];
591
+ const ids = new Set();
592
+ let unidentified = 0;
593
+
594
+ for (const e of spec?.elements ?? []) {
595
+ if (!isDurableId(e?.id)) {
596
+ unidentified += 1;
597
+ continue;
598
+ }
599
+ ids.add(e.id);
600
+ // A bound original delegates byte and framing verification to the
601
+ // image-intent validator; all this record keeps is the canonical use id.
602
+ if (isDurableId(e.image?.assetUseId)) {
603
+ requirements.push({
604
+ requirementId: `req-image-use-${e.id}`,
605
+ elementId: e.id,
606
+ kind: "image-use",
607
+ useId: e.image.assetUseId,
608
+ properties: protect(["media"], origin),
609
+ });
610
+ continue;
611
+ }
612
+ const candidates = [];
613
+ if (cleanText(e.text) != null) candidates.push("text");
614
+ if (e.fillToken || e.fillHex || (e.fill && e.fill !== "none")) candidates.push("fill");
615
+ if (e.image || e.icon) candidates.push("media");
616
+ if (e.chart) candidates.push("chart");
617
+ requirements.push({
618
+ requirementId: `req-element-${e.id}`,
619
+ elementId: e.id,
620
+ kind: "element",
621
+ properties: protect(candidates, origin),
622
+ });
623
+ }
624
+
625
+ const used = new Set(ids);
626
+ for (const c of spec?.connectors ?? []) {
627
+ // A free line (an end at a point) is drawn geometry, not a relationship.
628
+ if (c?.fromPoint || c?.toPoint) continue;
629
+ const start = String(c?.from ?? "");
630
+ const end = String(c?.to ?? "");
631
+ if (!isDurableId(start) || !isDurableId(end)) {
632
+ limitations.push("a connector in the concept spec names no endpoints; it cannot be required back");
633
+ continue;
634
+ }
635
+ let elementId = `conn-${start}-${end}`;
636
+ for (let n = 2; used.has(elementId); n++) elementId = `conn-${start}-${end}-${n}`;
637
+ used.add(elementId);
638
+ if (!ids.has(start)) limitations.push(`connector ${elementId} starts at '${start}', which the spec does not list`);
639
+ if (!ids.has(end)) limitations.push(`connector ${elementId} ends at '${end}', which the spec does not list`);
640
+ requirements.push({
641
+ requirementId: `req-connection-${elementId}`,
642
+ elementId,
643
+ kind: "connection",
644
+ properties: protect(["geom", "line"], origin),
645
+ relationships: [
646
+ { role: "start", target: start },
647
+ { role: "end", target: end },
648
+ ],
649
+ });
650
+ }
651
+ if (unidentified > 0) {
652
+ limitations.push(`${unidentified} concept element(s) carry no id, so their preservation cannot be required`);
653
+ }
654
+ return { requirements, limitations };
655
+ }
656
+
657
+ // Concept obligations bound to what `transcribe` DELIVERED — LOCKSTEP with the
658
+ // add-in's `bindDelivered` (transcription-state.ts). A spec is read only while
659
+ // its source is byte for byte transcribe's output, so an obligation that output
660
+ // cannot answer for fails our own gate on a slide nobody touched. `delivery` is
661
+ // transcribe's own record of that write: a split line or a mark's dots is
662
+ // `<id>-N` (`bindSplitIds`); a connector not glued as the concept says (a
663
+ // loop's arc, an end whose ink stops short) is owed as the line it was written
664
+ // under (`connectorLines`); an id it deliberately left out (`dropped`) is
665
+ // released. Anything else the output lacks stays owed: a silent loss is a
666
+ // transcriber defect and must read as one.
667
+ function bindDelivered(seeded, jsx, { dropped = [], connectorLines = [] } = {}) {
668
+ const present = readSlideElements(jsx).byId;
669
+ const pairOf = (r) => ["start", "end"].map((role) => r.relationships?.find((x) => x.role === role)?.target).join(" -> ");
670
+ const bound = bindSplitIds(seeded.requirements, jsx);
671
+ // A delivered connector answers for one obligation, never for an element id.
672
+ const claimed = new Set(bound.filter((r) => r.kind !== "connection").map((r) => r.elementId));
673
+ const limitations = [...seeded.limitations];
674
+ const requirements = [];
675
+ for (const req of bound) {
676
+ const ends = req.kind === "connection" ? pairOf(req) : null;
677
+ const written = ends ? connectorLines.filter((l) => `${l.from} -> ${l.to}` === ends).map((l) => l.id) : [];
678
+ const glued = ends && [...present].find(([id, e]) => !claimed.has(id) && pairOf(e) === ends)?.[0];
679
+ const line = ends && !glued && written.find((id) => present.has(id) && !claimed.has(id));
680
+ if (glued || line) {
681
+ claimed.add(glued || line);
682
+ if (glued) requirements.push({ ...req, requirementId: `req-connection-${glued}`, elementId: glued });
683
+ else {
684
+ requirements.push({ requirementId: `req-element-${line}`, elementId: line, kind: "element", properties: req.properties });
685
+ limitations.push(`${ends} is drawn as ${line} without both ends glued: the line is owed, the relation cannot be required back`);
686
+ }
687
+ } else if ((ends || !present.has(req.elementId)) && [req.elementId, ...written].some((id) => dropped.includes(id))) {
688
+ limitations.push(`${req.elementId} was left out by transcribe (see its notes), so it cannot be required back`);
689
+ } else {
690
+ requirements.push(req);
691
+ }
692
+ }
693
+ return { requirements, limitations };
694
+ }
695
+
696
+ // A slide file's number is its NN- prefix — the same rule `transcribe` uses to
697
+ // pair `concept-slide-NN.json` with `NN-name.jsx`. Falls back to play order for
698
+ // a deck that never used the prefix.
699
+ function slideNumberFor(target) {
700
+ const prefixed = /^(\d+)[-_.]/.exec(target.file);
701
+ return prefixed ? Number(prefixed[1]) : target.number;
702
+ }
703
+
704
+ function seedEntry(workspaceDir, target, { stamps, delivery, conversions, baseRevision }) {
705
+ let bytes;
706
+ try {
707
+ bytes = fs.readFileSync(path.join(workspaceDir, target.jsxPath));
708
+ } catch {
709
+ return null;
710
+ }
711
+ const baseline = { sourceHash: sha256(bytes), revision: baseRevision ?? null };
712
+ const limitations = [];
713
+ // `loadConversions` cannot say "a record was here and I could not read it" —
714
+ // a map has no encoding for that, so a corrupt record reaches it as absence.
715
+ // Read the third state directly: seeding `legacy-current` for a slide that
716
+ // IS an import would hand it the WEAKER protected set (media/native instead
717
+ // of the full raw-fidelity set), and an import is precisely the case where
718
+ // the raw values are the content. The record file existing is evidence about
719
+ // what the slide IS; failing to parse it says nothing about that, only about
720
+ // the details — so origin stays `converted` and the details become a stated
721
+ // limitation.
722
+ const provenance = conversionProvenance(workspaceDir, target.file);
723
+ const conversionUnreadable = provenance === undefined;
724
+ const conversion = conversions[target.file] ?? (conversionUnreadable ? {} : null);
725
+ const isJsx = target.file.toLowerCase().endsWith(".jsx");
726
+ const stem = target.file.replace(/\.(jsx|pptx)$/i, "");
727
+ const packagePart = fs.existsSync(path.join(workspaceDir, "conversions", `${stem}.slide.pptx`))
728
+ ? `conversions/${stem}.slide.pptx`
729
+ : null;
730
+
731
+ // WHERE THE OBLIGATIONS COME FROM decides both the origin and the reader.
732
+ // An import is read through the converter's shape ids, a machine-owned
733
+ // transcription through its concept spec, and everything else through the
734
+ // source as it stands — which is the one rule with teeth here: source that a
735
+ // person has edited since it was stamped is NOT a concept's to promise.
736
+ let origin;
737
+ let seeded = null;
738
+ const machineOwned = Boolean(stamps[target.file]) && stamps[target.file] === sha256(bytes.toString("utf8"));
739
+ if (conversion) {
740
+ origin = "converted";
741
+ if (conversionUnreadable) {
742
+ // The natives its `preserved[]` would have named cannot be enumerated, so
743
+ // any native without a placeholder in the source is unrecoverable here.
744
+ limitations.push(
745
+ `conversions/${stem}.json is on disk and could not be read: this slide is treated as an import ` +
746
+ `(its raw values are protected), but its preserved natives could not be enumerated — repair or ` +
747
+ `re-adopt the record before relying on native preservation`,
748
+ );
749
+ } else if (!conversion.conversionEvidence) {
750
+ limitations.push("imported without render evidence; what the conversion produced was never verified here");
751
+ }
752
+ } else if (!isJsx) {
753
+ origin = "legacy-current";
754
+ limitations.push("native .pptx slide: its contents are not modelled locally, only its bytes are");
755
+ } else if (machineOwned) {
756
+ const spec = readSpec(workspaceDir, slideNumberFor(target));
757
+ if (spec) {
758
+ origin = "concept";
759
+ seeded = bindDelivered(specRequirements(spec, { origin }), bytes.toString("utf8"), delivery[target.file]);
760
+ } else {
761
+ origin = "legacy-current";
762
+ limitations.push(
763
+ "transcribed, but its concept spec is no longer on disk; these obligations come from the current source",
764
+ );
765
+ }
766
+ } else {
767
+ origin = "legacy-current";
768
+ if (stamps[target.file]) {
769
+ limitations.push(
770
+ "edited since `transcribe` stamped it: these obligations come from the CURRENT source, " +
771
+ "not from the concept spec it was generated from",
772
+ );
773
+ }
774
+ }
775
+
776
+ const scan =
777
+ seeded ??
778
+ (isJsx
779
+ ? scanSourceRequirements(bytes.toString("utf8"), { packagePart, origin })
780
+ : { requirements: [], limitations: [] });
781
+ const active = [...scan.requirements];
782
+ limitations.push(...scan.limitations);
783
+
784
+ // A converted slide whose source carries fewer identities than the original
785
+ // had shapes lost something in conversion; that is a stated gap, not a pass.
786
+ const sourceShapes = conversion?.sourceBaseline?.projection?.shapes?.length ?? null;
787
+ if (sourceShapes != null && active.length > 0 && sourceShapes > active.length) {
788
+ limitations.push(
789
+ `the source slide had ${sourceShapes} shape(s) but only ${active.length} carry an identity here; ` +
790
+ `the rest cannot be required back`,
791
+ );
792
+ }
793
+
794
+ // Natives the import preserved but the source carries no placeholder for:
795
+ // the package is the obligation, whatever the jsx shows.
796
+ const placeheld = new Set(active.filter((r) => r.kind === "native-object").map((r) => r.elementId));
797
+ (conversion?.preserved ?? []).forEach((p, i) => {
798
+ const elementId = String(p?.sourceId ?? `native-${i}`).replace(/\s+/g, "-");
799
+ if (placeheld.has(elementId) || active.some((r) => r.kind === "native-object" && r.relationships?.some((rel) => rel.role === "native-slot" && rel.target === String(i)))) {
800
+ return;
801
+ }
802
+ if (!packagePart) {
803
+ limitations.push(`preserved native '${elementId}' has no source package in this deck; it cannot be required back`);
804
+ return;
805
+ }
806
+ active.push({
807
+ requirementId: `req-native-object-${elementId}`,
808
+ elementId,
809
+ kind: "native-object",
810
+ properties: protect(["native"], origin),
811
+ relationships: [
812
+ { role: "package-part", target: packagePart },
813
+ { role: "native-slot", target: String(i) },
814
+ ],
815
+ });
816
+ });
817
+
818
+ return {
819
+ version: SLIDE_PRESERVATION_VERSION,
820
+ slideKey: target.slideKey,
821
+ origin,
822
+ // Migration captures obligations, never verdicts. `unknown` is what makes a
823
+ // final replacement refusable instead of quietly assumed good.
824
+ evidence: "unknown",
825
+ baseline,
826
+ active,
827
+ superseded: [],
828
+ limitations,
829
+ migratedAt: new Date().toISOString(),
830
+ };
831
+ }
832
+
833
+ /**
834
+ * Seed preservation records for every slide that has none, from the evidence
835
+ * actually on disk. Additive and resumable: an existing entry is never touched,
836
+ * so a run interrupted halfway continues where it stopped, and a deck that has
837
+ * already migrated is a no-op.
838
+ *
839
+ * Refuses (and writes nothing) when the record on disk is corrupt or carries a
840
+ * schema this build does not read.
841
+ */
842
+ export function migrateEditState(workspaceDir, { selection = null } = {}) {
843
+ const previous = readSlidePreservation(workspaceDir);
844
+ const targets = resolveSlideTargets(workspaceDir, { selection });
845
+ const stamps = readTranscribeStamps(workspaceDir);
846
+ const delivery = readTranscribeStamps(workspaceDir, "transcribed-delivery.json");
847
+ const conversions = loadConversions(workspaceDir, { includeSource: false });
848
+ const baseRevision = readRevision(workspaceDir)?.base?.sha256 ?? null;
849
+
850
+ const slides = { ...previous.slides };
851
+ const seeded = [];
852
+ const skipped = [];
853
+ for (const target of targets) {
854
+ if (slides[target.slideKey]) continue;
855
+ const entry = seedEntry(workspaceDir, target, { stamps, delivery, conversions, baseRevision });
856
+ if (!entry) {
857
+ skipped.push({ slideKey: target.slideKey, jsxPath: target.jsxPath, reason: "unreadable" });
858
+ continue;
859
+ }
860
+ slides[target.slideKey] = entry;
861
+ seeded.push({
862
+ slideKey: target.slideKey,
863
+ jsxPath: target.jsxPath,
864
+ origin: entry.origin,
865
+ requirements: entry.active.length,
866
+ limitations: entry.limitations,
867
+ });
868
+ }
869
+ if (seeded.length > 0) {
870
+ writeSlidePreservation(workspaceDir, { version: SLIDE_PRESERVATION_VERSION, slides });
871
+ }
872
+ return { seeded, skipped, migrated: seeded.length > 0, slideCount: Object.keys(slides).length };
873
+ }
874
+
875
+ // ---------------------------------------------------------------------------
876
+ // scoped amendments — the only legal way a requirement set changes
877
+ // ---------------------------------------------------------------------------
878
+
879
+ /**
880
+ * What this operation PERMITS on a slide: `elementId → Set(property scope)`,
881
+ * plus `removed` for the elements it was asked to delete.
882
+ *
883
+ * Permissions live on the operation, obligations on the preservation record.
884
+ * Keeping them apart is deliberate: an `update` never narrows a requirement (it
885
+ * would never widen back), so the verdict reads both and the record stays the
886
+ * statement of what is owed.
887
+ */
888
+ export function permittedChanges(operation, slideKey) {
889
+ const out = new Map();
890
+ const target = (operation?.targets ?? []).find((t) => t?.slideKey === slideKey);
891
+ for (const change of target?.changes ?? []) {
892
+ if (!isDurableId(change?.elementId)) continue;
893
+ const entry = out.get(change.elementId) ?? { action: change.action, properties: new Set() };
894
+ entry.action = change.action;
895
+ for (const scope of change.properties ?? []) entry.properties.add(scope);
896
+ out.set(change.elementId, entry);
897
+ }
898
+ return out;
899
+ }
900
+
901
+ /**
902
+ * Apply an operation's declared changes to the preservation record.
903
+ *
904
+ * This is the ONLY door through which an obligation ends. It is request-scoped
905
+ * in three ways at once: the supersession names the operation that authorized
906
+ * it and the base source it was authorized against; the write goes through
907
+ * `validatePreservationUpdate(previous, next, { operation })`, which refuses any
908
+ * drop this operation did not name and any slide it does not target; and the
909
+ * operation must still be fresh, so a scope declared against source that has
910
+ * since moved cannot retire anything.
911
+ *
912
+ * Three actions, three outcomes:
913
+ * remove supersede every requirement on that element — the intentional
914
+ * deletion, distinguishable from the accidental one precisely because
915
+ * it was asked for in advance (E09 vs E10).
916
+ * add a REQUESTED ADDITION becomes a required output in its own right.
917
+ * Absence of old content is not the only failure mode: an addition
918
+ * the user asked for and did not get fails too.
919
+ * update nothing here. See {@link permittedChanges}.
920
+ *
921
+ * Unaffected requirements are carried through untouched, and there is no
922
+ * encoding for "reset this slide" — a blanket clear is unrepresentable because
923
+ * every drop has to be named element by element.
924
+ */
925
+ export function applyRequirementAmendment(workspaceDir, operation) {
926
+ assertOperationFresh(workspaceDir, operation);
927
+ const doc = readSlidePreservation(workspaceDir);
928
+ const slides = { ...doc.slides };
929
+ const amended = { added: [], superseded: [], slides: [] };
930
+ const at = new Date().toISOString();
931
+
932
+ for (const target of operation?.targets ?? []) {
933
+ const changes = (target.changes ?? []).filter((c) => isDurableId(c?.elementId));
934
+ if (changes.length === 0) continue;
935
+ const entry = slides[target.slideKey];
936
+ if (!entry) {
937
+ throw stateError(
938
+ "no_preservation_record",
939
+ `${target.jsxPath} has no preservation record, so there is nothing to amend. ` +
940
+ `Open the scope with \`slidemind revision begin\`, which captures one first.`,
941
+ { slideKey: target.slideKey },
942
+ );
943
+ }
944
+ const next = amendForOperation(entry, target, operation, { at });
945
+ for (const requirementId of next.superseded) amended.superseded.push({ slideKey: target.slideKey, requirementId });
946
+ for (const requirementId of next.added) amended.added.push({ slideKey: target.slideKey, requirementId });
947
+ slides[target.slideKey] = next.entry;
948
+ amended.slides.push(target.slideKey);
949
+ }
950
+
951
+ if (amended.slides.length > 0) {
952
+ writeSlidePreservation(workspaceDir, { version: SLIDE_PRESERVATION_VERSION, slides }, { operation });
953
+ }
954
+ return amended;
955
+ }
956
+
957
+ // ---------------------------------------------------------------------------
958
+ // checking a requirement set against evidence
959
+ // ---------------------------------------------------------------------------
960
+
961
+ /**
962
+ * The slide's obligations-facing evidence, read from its SOURCE: which element
963
+ * identities are present, what each connector is bound to, and which asset each
964
+ * image points at.
965
+ *
966
+ * Source evidence proves identity and relationships exactly; it cannot prove
967
+ * that a protected text, fill or geometry is unchanged — that needs the
968
+ * normalized render IR. {@link checkRequirements} therefore reports those
969
+ * scopes as UNVERIFIED rather than passing them, which is the whole reason the
970
+ * two are separate values.
971
+ */
972
+ export function requirementEvidenceFromSource(jsx, options = {}) {
973
+ const { requirements } = scanSourceRequirements(jsx, options);
974
+ const ids = new Set();
975
+ const relationships = new Map();
976
+ const media = new Map();
977
+ for (const req of requirements) {
978
+ ids.add(req.elementId);
979
+ if (req.relationships?.length) relationships.set(req.elementId, req.relationships);
980
+ if (req.mediaRef) media.set(req.elementId, req.mediaRef);
981
+ }
982
+ return { ids, relationships, media };
983
+ }
984
+
985
+ /**
986
+ * Which of a slide's active requirements the evidence says are met, unmet, or
987
+ * simply not answerable.
988
+ *
989
+ * `operation` is the permission side: an element it was asked to remove is not
990
+ * reported missing, and a property scope it was allowed to change is not
991
+ * reported unverified.
992
+ */
993
+ export function checkRequirements(entry, evidence, { operation = null } = {}) {
994
+ const violations = [];
995
+ const unverified = [];
996
+ const permitted = operation ? permittedChanges(operation, entry?.slideKey) : new Map();
997
+ // Only these two are decidable from source; everything else needs the render.
998
+ const PROVABLE = new Set(["media"]);
999
+
1000
+ for (const req of entry?.active ?? []) {
1001
+ const allowed = permitted.get(req.elementId) ?? null;
1002
+ if (!evidence.ids.has(req.elementId)) {
1003
+ // An element the operation was asked to remove should have been
1004
+ // superseded by the amendment; if it still stands, it is still owed.
1005
+ violations.push({
1006
+ requirementId: req.requirementId,
1007
+ elementId: req.elementId,
1008
+ code: allowed?.action === "remove" ? "removed_without_amendment" : "missing",
1009
+ detail:
1010
+ allowed?.action === "remove"
1011
+ ? `${req.elementId} was removed, but its requirement was never superseded by this operation`
1012
+ : `${req.elementId} is required on this slide and is not in the evidence`,
1013
+ });
1014
+ continue;
1015
+ }
1016
+ for (const rel of req.relationships ?? []) {
1017
+ // A package part is evidence about the .pptx, not about the source.
1018
+ if (rel.role !== "start" && rel.role !== "end") continue;
1019
+ const actual = (evidence.relationships.get(req.elementId) ?? []).find((r) => r.role === rel.role);
1020
+ if (!actual) {
1021
+ violations.push({
1022
+ requirementId: req.requirementId,
1023
+ elementId: req.elementId,
1024
+ code: "relationship_missing",
1025
+ detail: `${req.elementId} no longer records its ${rel.role} endpoint`,
1026
+ });
1027
+ } else if (actual.target !== rel.target) {
1028
+ violations.push({
1029
+ requirementId: req.requirementId,
1030
+ elementId: req.elementId,
1031
+ code: "relationship_changed",
1032
+ detail: `${req.elementId}'s ${rel.role} was ${rel.target}, now ${actual.target}`,
1033
+ });
1034
+ }
1035
+ }
1036
+ if (req.mediaRef && !allowed?.properties.has("media")) {
1037
+ const actual = evidence.media.get(req.elementId) ?? null;
1038
+ if (actual !== req.mediaRef) {
1039
+ violations.push({
1040
+ requirementId: req.requirementId,
1041
+ elementId: req.elementId,
1042
+ code: "media_changed",
1043
+ detail: `${req.elementId} pointed at ${req.mediaRef}, now ${actual ?? "nothing"}`,
1044
+ });
1045
+ }
1046
+ }
1047
+ const open = (req.properties ?? []).filter(
1048
+ (scope) => !PROVABLE.has(scope) && !allowed?.properties.has(scope),
1049
+ );
1050
+ const mediaUnprovable = (req.properties ?? []).includes("media") && !req.mediaRef && !allowed?.properties.has("media");
1051
+ if (mediaUnprovable) open.push("media");
1052
+ if (req.kind === "native-object" || req.kind === "image-use") open.push(req.kind);
1053
+ if (open.length > 0) {
1054
+ unverified.push({ requirementId: req.requirementId, elementId: req.elementId, properties: [...new Set(open)] });
1055
+ }
1056
+ }
1057
+ return { ok: violations.length === 0, violations, unverified };
1058
+ }
1059
+
1060
+ /**
1061
+ * The verdict a check earns: a violation is `failed`, an unanswerable
1062
+ * obligation is `unknown`, and only a check that answered everything is
1063
+ * `passed`. Missing evidence never becomes a pass.
1064
+ */
1065
+ export function evidenceStatus(result) {
1066
+ if (!result || result.violations.length > 0) return "failed";
1067
+ return result.unverified.length > 0 ? "unknown" : "passed";
1068
+ }
1069
+
1070
+ // ---------------------------------------------------------------------------
1071
+ // redesign — a replacement, not an addition
1072
+ // ---------------------------------------------------------------------------
1073
+
1074
+ /**
1075
+ * Whether an explicit redesign actually REPLACED what it was pointed at.
1076
+ *
1077
+ * A redesign is authorized by the user's request, so nothing here asks again
1078
+ * (guide section 1: "A user who already asked for redesign has authorized that
1079
+ * transition; no redundant approval prompt"). What it does check is the one
1080
+ * thing a restyle gets wrong in practice — E18 — which is producing a second
1081
+ * slide next to the original instead of replacing it, and then carrying neither
1082
+ * the original's required content nor its native objects across.
1083
+ *
1084
+ * Four questions, each answerable from what is already on disk:
1085
+ *
1086
+ * • does every target still exist under its OWN slideKey? A redesign that
1087
+ * minted a new identity has lost the thing it was asked to change.
1088
+ * • did a slide appear that nobody has a scope for? The operation records the
1089
+ * deck's slide identities as they stood when it was prepared; a slide
1090
+ * outside that set, and outside any `create`, is the duplicate E18 is
1091
+ * about. A slide merely LACKING a preservation record proves nothing — a
1092
+ * legacy or partially migrated deck is full of those — so it is never the
1093
+ * test, and an operation that recorded no such set reports the question as
1094
+ * unanswerable rather than answering it.
1095
+ * • did the required content survive? Delegated to {@link checkRequirements},
1096
+ * so "carried forward" means the same thing here as everywhere else.
1097
+ * • are the native objects still backed? A native's obligation is its source
1098
+ * package dependency closure — which survives a change of placement, and
1099
+ * does not survive the package being dropped.
1100
+ *
1101
+ * Reports; never writes. The caller decides whether a violation blocks —
1102
+ * `unverified` names what could not be decided and is never a violation.
1103
+ */
1104
+ export function checkRedesignTransition(workspaceDir, operation) {
1105
+ const violations = [];
1106
+ const unverified = [];
1107
+ const { slides } = readSlidePreservation(workspaceDir);
1108
+ const targets = resolveSlideTargets(workspaceDir);
1109
+ const byKey = new Map(targets.map((t) => [t.slideKey, t]));
1110
+
1111
+ for (const target of operation?.targets ?? []) {
1112
+ const current = byKey.get(target.slideKey);
1113
+ if (!current) {
1114
+ violations.push({
1115
+ code: "target_missing",
1116
+ slideKey: target.slideKey,
1117
+ detail:
1118
+ `${target.jsxPath} was redesigned away rather than redesigned: no slide in this deck carries ` +
1119
+ `${target.slideKey} any more. A redesign replaces the slide it was pointed at, under its own identity.`,
1120
+ });
1121
+ continue;
1122
+ }
1123
+ const entry = slides[target.slideKey];
1124
+ if (!entry) {
1125
+ violations.push({
1126
+ code: "no_preservation_record",
1127
+ slideKey: target.slideKey,
1128
+ detail: `${current.jsxPath} has no preservation record, so nothing can be said to have carried forward`,
1129
+ });
1130
+ continue;
1131
+ }
1132
+
1133
+ let jsx = "";
1134
+ try {
1135
+ jsx = fs.readFileSync(path.join(workspaceDir, current.jsxPath), "utf8");
1136
+ } catch {
1137
+ /* read failure surfaces below as every requirement missing */
1138
+ }
1139
+ const stem = current.file.replace(/\.(jsx|pptx)$/i, "");
1140
+ const packagePart = fs.existsSync(path.join(workspaceDir, "conversions", `${stem}.slide.pptx`))
1141
+ ? `conversions/${stem}.slide.pptx`
1142
+ : null;
1143
+ const result = checkRequirements(entry, requirementEvidenceFromSource(jsx, { packagePart, origin: entry.origin }), {
1144
+ operation,
1145
+ });
1146
+ for (const v of result.violations) {
1147
+ violations.push({ code: `carry_forward_${v.code}`, slideKey: target.slideKey, requirementId: v.requirementId, detail: v.detail });
1148
+ }
1149
+
1150
+ // A native object's placement may change; its package may not go missing.
1151
+ for (const req of entry.active) {
1152
+ if (req.kind !== "native-object") continue;
1153
+ for (const rel of req.relationships ?? []) {
1154
+ if (rel.role !== "package-part") continue;
1155
+ if (fs.existsSync(path.join(workspaceDir, rel.target))) continue;
1156
+ violations.push({
1157
+ code: "native_package_missing",
1158
+ slideKey: target.slideKey,
1159
+ requirementId: req.requirementId,
1160
+ detail:
1161
+ `${req.elementId} is a preserved native object and ${rel.target} is gone; ` +
1162
+ `its dependency closure has to survive a redesign, wherever the object ends up on the slide`,
1163
+ });
1164
+ }
1165
+ }
1166
+ }
1167
+
1168
+ // THE DUPLICATE. The question is "did this slide appear since the scope
1169
+ // opened?" — never "does this slide have a preservation record?". A deck can
1170
+ // legitimately hold slides with no record at all (a legacy deck, a partial
1171
+ // migration, an adopt from a file an older CLI baked, where
1172
+ // `restorePreservationFromTags` only restores what carried a payload), and
1173
+ // convicting those would report an innocent slide as E18. Absence of a record
1174
+ // is not evidence either way.
1175
+ //
1176
+ // The evidence is `preparedSlideKeys`: the deck's slide identities as they
1177
+ // stood when the operation was recorded. A slide in that set was there
1178
+ // before, whatever its record says; one outside it appeared afterwards and
1179
+ // belongs to a `create` operation or to nothing.
1180
+ const prepared = Array.isArray(operation?.preparedSlideKeys)
1181
+ ? new Set(operation.preparedSlideKeys.filter(isDurableId))
1182
+ : null;
1183
+ if (!prepared) {
1184
+ // An operation recorded without that set — another client's, or one built
1185
+ // by hand — cannot answer the question. Say so; do not guess in either
1186
+ // direction.
1187
+ unverified.push({
1188
+ code: "insertion_unverifiable",
1189
+ detail:
1190
+ `operation ${operation?.operationId ?? "(unnamed)"} did not record the deck's slides as they stood when ` +
1191
+ `it was prepared, so a slide added since cannot be told from one that was always there`,
1192
+ });
1193
+ } else {
1194
+ const created = new Set(
1195
+ openOperations(workspaceDir)
1196
+ .filter((o) => o.kind === "create")
1197
+ .flatMap((o) => operationSlideKeys(o)),
1198
+ );
1199
+ for (const target of targets) {
1200
+ if (prepared.has(target.slideKey) || created.has(target.slideKey)) continue;
1201
+ violations.push({
1202
+ code: "duplicate_insertion",
1203
+ slideKey: target.slideKey,
1204
+ detail:
1205
+ `${target.jsxPath} appeared after this redesign was scoped and belongs to no operation. A redesign ` +
1206
+ `replaces its target in place; a genuinely new slide is its own \`create\`.`,
1207
+ });
1208
+ }
1209
+ }
1210
+
1211
+ return { ok: violations.length === 0, violations, unverified };
1212
+ }
1213
+
1214
+ /** The open redesign operations governing this deck. */
1215
+ export function openRedesigns(workspaceDir) {
1216
+ return openOperations(workspaceDir).filter((o) => o.kind === "redesign");
1217
+ }
1218
+
1219
+ /**
1220
+ * Why a FINAL replacement cannot go ahead for these slides yet: no record at
1221
+ * all, or a record whose evidence is anything but `passed`. Reading and
1222
+ * drafting stay open — the caller reports the limitation; only delivery refuses.
1223
+ */
1224
+ export function finalEvidenceBlockers(workspaceDir, slideKeys) {
1225
+ const { slides } = readSlidePreservation(workspaceDir);
1226
+ const blockers = [];
1227
+ for (const slideKey of slideKeys ?? []) {
1228
+ const entry = slides[slideKey];
1229
+ if (!entry) {
1230
+ blockers.push({ slideKey, code: "no_record", reason: "no preservation record; run an edit scope to capture one" });
1231
+ continue;
1232
+ }
1233
+ if (entry.evidence !== "passed") {
1234
+ blockers.push({
1235
+ slideKey,
1236
+ code: `evidence_${entry.evidence}`,
1237
+ reason:
1238
+ `preservation evidence is '${entry.evidence}'` +
1239
+ (entry.limitations?.length ? ` — ${entry.limitations.join("; ")}` : ""),
1240
+ });
1241
+ }
1242
+ }
1243
+ return blockers;
1244
+ }