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
@@ -11,12 +11,13 @@
11
11
  *
12
12
  * Pure string logic — no React, no Office (unit-testable).
13
13
  *
14
- * 1:1 port of the add-in's red-thread-backbone.ts. Top-level helpers are
15
- * prefixed `rt…` so the browser bundle (one IIFE across five modules) has no
16
- * name collisions; exported API names match the add-in verbatim.
14
+ * Canonical here; the add-in imports a byte-identical mirror
15
+ * (scripts/sync-transcription-runtime.mjs). Top-level helpers are prefixed
16
+ * `rt…` so the browser bundle (one IIFE across five modules) has no name
17
+ * collisions.
17
18
  */
18
19
 
19
- import { extractRedThreadMessages, findRedThreadSection } from "./storyboard-markdown.mjs";
20
+ import { extractFrameLine, extractRedThreadMessages, findRedThreadSection, replaceStoryboardSegment, splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
20
21
 
21
22
  /**
22
23
  * One slide in the user-edited backbone draft:
@@ -54,19 +55,44 @@ function rtFindSlideSections(markdown) {
54
55
  }
55
56
 
56
57
  /**
57
- * The backbone is editable only when the red-thread moves and the slide
58
+ * A frame slide is a section but not a move: the red thread lists body moves
59
+ * only and names the frame on its `Frame:` line. Returns a frame slide's card
60
+ * label, or null for a body slide (an unreadable record counts as body).
61
+ */
62
+ function rtFrameLabel(sectionText) {
63
+ const board = splitPlanMarkdownIntoSegments(sectionText).find(
64
+ (segment) => segment.type === "storyboard",
65
+ );
66
+ const data = board?.data;
67
+ if (data?.role !== "frame") return null;
68
+ const heading = /^###\s+Slide\s+\d+\s*:?[ \t]*(.*)$/m.exec(sectionText)?.[1];
69
+ return data.keyMessage?.trim() || heading?.trim() || data.frameKind || "Frame";
70
+ }
71
+
72
+ /**
73
+ * The backbone is editable only when the red-thread moves and the BODY slide
58
74
  * sections line up one-to-one — otherwise a positional restructure could
59
- * attach the wrong storyboard to a move.
75
+ * attach the wrong storyboard to a move. Frame slides sit between them in play
76
+ * order; a whole presentation always has some, so counting them as moves left
77
+ * every presentation's storyline read-only.
60
78
  *
61
- * Returns { messages, sectionCount } or null.
79
+ * Returns { messages, sectionCount, frame } or null: `messages` holds one card
80
+ * label per section (a body slide's move, a frame slide's key message), `frame`
81
+ * marks the frame sections.
62
82
  */
63
83
  export function parsePlanBackbone(markdown) {
64
- const messages = extractRedThreadMessages(markdown);
65
- if (!messages.length) return null;
84
+ const moves = extractRedThreadMessages(markdown);
85
+ if (!moves.length) return null;
66
86
  if (!findRedThreadSection(markdown)) return null;
67
87
  const sections = rtFindSlideSections(markdown);
68
- if (sections.length !== messages.length) return null;
69
- return { messages, sectionCount: sections.length };
88
+ const labels = sections.map((section) => rtFrameLabel(section.text));
89
+ if (labels.filter((label) => label === null).length !== moves.length) return null;
90
+ let move = 0;
91
+ return {
92
+ messages: labels.map((label) => label ?? moves[move++] ?? ""),
93
+ sectionCount: sections.length,
94
+ frame: labels.map((label) => label !== null),
95
+ };
70
96
  }
71
97
 
72
98
  function rtSanitizeMessage(message) {
@@ -84,34 +110,87 @@ function rtRenumberSection(text, slideNumber, editedMessage) {
84
110
  return next.replace(/("slide"\s*:\s*)\d+/, `$1${slideNumber}`);
85
111
  }
86
112
 
87
- const RT_NEW_SLIDE_CONTENT_MAX = 28;
113
+ // ---------------------------------------------------------------------------
114
+ // image uses across a restructure
115
+ // ---------------------------------------------------------------------------
116
+ //
117
+ // Reordering and deletion need no help: a slide's `assetUses` live inside its
118
+ // own storyboard fence, so they move with the section text and vanish with it,
119
+ // and `useId`s stay exactly as they were (design decision 8 — never persist a
120
+ // use's ownership as a slide number).
121
+ //
122
+ // DUPLICATION does: the copy would carry the original's `useId`s, and a use ID
123
+ // is the identity the spec binding, the render evidence and the targeted-edit
124
+ // override all key on. Two slides claiming one ID is not a picture used twice,
125
+ // it is one obligation the renderer cannot attribute to a slide. So the first
126
+ // occurrence keeps its IDs and every later copy mints fresh ones (A12).
127
+
128
+ // Every useId the plan already spends, so a minted one can't collide. Scanned
129
+ // off the RAW markdown rather than the parsed records: a fence that fails to
130
+ // parse still spends its IDs the moment someone fixes it, and a taken set that
131
+ // is too large only ever costs a suffix. Matches the add-in's scan verbatim.
132
+ const RT_USE_ID_RE = /"useId"\s*:\s*"([A-Za-z0-9_-]+)"/g;
133
+
134
+ function rtUseIdsIn(markdown) {
135
+ const taken = new Set();
136
+ for (const match of markdown.matchAll(RT_USE_ID_RE)) taken.add(match[1]);
137
+ return taken;
138
+ }
139
+
140
+ // The contract caps an ID at 128 characters, so the stem is trimmed before the
141
+ // `_c<n>` suffix is appended — an over-long mint would fail the contract's own
142
+ // parser and take the whole duplicated record down with it. The trim is PER
143
+ // CANDIDATE, because the suffix grows with n: a fixed 124-character stem is
144
+ // right for `_c2` and four characters too long for `_c1000`. Must stay
145
+ // byte-identical to the add-in's `mintUseId`
146
+ // (src/ui/src/pages/plan-review/red-thread-backbone.ts).
147
+ const RT_USE_ID_MAX = 128;
148
+
149
+ function rtMintUseId(base, n) {
150
+ const suffix = `_c${n}`;
151
+ return `${base.slice(0, RT_USE_ID_MAX - suffix.length)}${suffix}`;
152
+ }
153
+
154
+ function rtFreshUseId(base, taken) {
155
+ for (let n = 2; ; n++) {
156
+ const id = rtMintUseId(base, n);
157
+ if (taken.has(id)) continue;
158
+ taken.add(id);
159
+ return id;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Rewrite one section's image uses with fresh IDs. A section whose record
165
+ * carries none is returned byte-identical — a restructure of a deck with no
166
+ * image intent must not reformat a single fence.
167
+ */
168
+ function rtRemintAssetUses(sectionText, taken) {
169
+ const board = splitPlanMarkdownIntoSegments(sectionText).find(
170
+ (segment) => segment.type === "storyboard" && segment.data?.assetUses?.length,
171
+ );
172
+ if (!board) return sectionText;
173
+ return replaceStoryboardSegment(sectionText, board, {
174
+ ...board.data,
175
+ assetUses: board.data.assetUses.map((use) => ({
176
+ ...use,
177
+ useId: rtFreshUseId(use.useId, taken),
178
+ })),
179
+ });
180
+ }
88
181
 
89
182
  function rtPlaceholderSection(message, slideNumber) {
90
- const content =
91
- message.slice(0, RT_NEW_SLIDE_CONTENT_MAX).trim() || `Slide ${slideNumber}`;
92
- const storyboard = {
93
- version: 1,
94
- slide: slideNumber,
95
- blocks: [
96
- {
97
- id: "block-1",
98
- x: 80,
99
- y: 110,
100
- w: 1440,
101
- h: 300,
102
- designElement: "Open canvas",
103
- content,
104
- },
105
- ],
106
- keyMessage: message,
107
- };
183
+ // A slide the user inserted, with no content yet: the record carries the move
184
+ // as its story beat and nothing else. The agent fills in the content points
185
+ // and the layout on the round the composer sends.
186
+ const record = { version: 1, slide: slideNumber, keyMessage: message };
108
187
  return [
109
188
  `### Slide ${slideNumber}: ${message}`,
110
189
  "",
111
190
  `Core message: ${message}`,
112
191
  "",
113
192
  "```storyboard-canvas",
114
- JSON.stringify(storyboard, null, 2),
193
+ JSON.stringify(record, null, 2),
115
194
  "```",
116
195
  ].join("\n");
117
196
  }
@@ -127,14 +206,19 @@ function rtPlaceholderSection(message, slideNumber) {
127
206
  export function stageStoryIteration(markdown, slides) {
128
207
  const editedPlanMarkdown = applyPlanBackbone(markdown, slides);
129
208
  if (!editedPlanMarkdown) return null;
130
- const committed = extractRedThreadMessages(markdown);
209
+ const backbone = parsePlanBackbone(markdown);
210
+ const committed = backbone?.messages ?? [];
131
211
  const changes = [];
132
212
  slides.forEach((slide, index) => {
133
213
  const position = index + 1;
134
214
  if (slide.originalIndex === null) {
135
215
  changes.push(`Added slide ${position} for the move "${slide.message}".`);
136
216
  } else if (committed[slide.originalIndex] !== slide.message) {
137
- changes.push(`Renamed slide ${position}'s move to "${slide.message}".`);
217
+ changes.push(
218
+ backbone?.frame[slide.originalIndex]
219
+ ? `Renamed slide ${position} to "${slide.message}".`
220
+ : `Renamed slide ${position}'s move to "${slide.message}".`,
221
+ );
138
222
  }
139
223
  });
140
224
  if (!changes.length) return null;
@@ -166,10 +250,21 @@ export function applyPlanBackbone(markdown, slides) {
166
250
  if (!firstSection || !lastSection) return null;
167
251
  if (firstSection.start < redThread.bodyEnd) return null;
168
252
 
169
- const newRedThreadBody = `\n\n${cleaned
253
+ // Frame slides keep their place among the sections but are not moves.
254
+ const moves = cleaned.filter(
255
+ (slide) => slide.originalIndex === null || !backbone.frame[slide.originalIndex],
256
+ );
257
+ if (!moves.length) return null;
258
+ // The `Frame:` declaration is not a move, so it is not in `cleaned` — and
259
+ // rewriting the body without it would silently drop the deck's frame from an
260
+ // approved plan on the first reorder.
261
+ const frameLine = extractFrameLine(markdown);
262
+ const newRedThreadBody = `\n\n${moves
170
263
  .map((slide) => `- ${slide.message}`)
171
- .join("\n")}\n\n`;
264
+ .join("\n")}${frameLine ? `\n\n${frameLine}` : ""}\n\n`;
172
265
 
266
+ const takenUseIds = rtUseIdsIn(markdown);
267
+ const placed = new Set();
173
268
  const newSections = cleaned.map((slide, index) => {
174
269
  const slideNumber = index + 1;
175
270
  if (slide.originalIndex === null) {
@@ -180,7 +275,11 @@ export function applyPlanBackbone(markdown, slides) {
180
275
  const originalMessage = backbone.messages[slide.originalIndex] ?? "";
181
276
  const edited =
182
277
  rtSanitizeMessage(originalMessage) !== slide.message ? slide.message : null;
183
- return rtRenumberSection(section.text.trimEnd(), slideNumber, edited);
278
+ const text = rtRenumberSection(section.text.trimEnd(), slideNumber, edited);
279
+ // Second and later appearances of one original section are copies.
280
+ if (placed.has(slide.originalIndex)) return rtRemintAssetUses(text, takenUseIds);
281
+ placed.add(slide.originalIndex);
282
+ return text;
184
283
  });
185
284
 
186
285
  return [
@@ -0,0 +1,205 @@
1
+ // Bounded, transactional repair of transcribed slides
2
+ // (openspec/changes/safeguard-slide-editing 6.3).
3
+ //
4
+ // The engine itself is pure and lives in the mirrored core (settle.mjs:
5
+ // `validateSettleTarget`, `repairPasses`, `REPAIR_BUDGET`). This module is the
6
+ // CLI half that gives it a place to work and a way to publish: a CANDIDATE
7
+ // workspace, and one atomic commit at the end. The two are separate files
8
+ // because settle.mjs ships byte-identical into the add-in runtime
9
+ // (scripts/sync-transcription-runtime.mjs), where none of the deck state below
10
+ // exists.
11
+ //
12
+ // The order is the whole point:
13
+ //
14
+ // 1. the operation is a `repair`, and its source is still the bytes it was
15
+ // prepared against (`assertOperationFresh`);
16
+ // 2. every target's CURRENT representation is one the core may rewrite
17
+ // (`validateSettleTarget`) — before a candidate is even staged;
18
+ // 3. all passes run in the candidate, rendered there, budget checked against
19
+ // the ORIGINAL after every pass;
20
+ // 4. the last mutation is rendered before anything is published;
21
+ // 5. compare-and-swap, then one atomic activation of source + assets +
22
+ // records together.
23
+ //
24
+ // Anything that fails discards the candidate. Nothing is restored, because
25
+ // nothing was published.
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+
29
+ import { conversionProvenance } from "../lib/conversions.mjs";
30
+ import { assertOperationFresh, readSlidePreservation, recordOperation, sourceHash, writeSlidePreservation } from "../lib/edit-state.mjs";
31
+ import { inlineComponents } from "../lib/inline-components.mjs";
32
+ import { compareSlideSource } from "../lib/preservation-verdict.mjs";
33
+ import { stageWorkspace } from "../lib/workspace-transaction.mjs";
34
+ import { refoldLike } from "./fold-sets.mjs";
35
+ import { repairPasses, repairPolicyFor, repairDelta, validateSettleTarget } from "./settle.mjs";
36
+
37
+ /**
38
+ * One slide's result, in the shape both adapters report and the preservation
39
+ * verdict composes with (safeguard-slide-editing 6.5).
40
+ *
41
+ * `elements` is `compareSlideSource`'s own output — changed elements keyed to
42
+ * the operation record's PROPERTY_SCOPES (geom | text | fill | line | media |
43
+ * chart | native), so a repair's summary and a preservation requirement speak
44
+ * one vocabulary rather than two. `delta` carries the raw numbers the budget
45
+ * was read from; `changes` and `routeChanges` are the engine's own sentences.
46
+ *
47
+ * @typedef {{
48
+ * slideKey: string, jsxPath: string,
49
+ * verdict: "repaired" | "unchanged" | "refused",
50
+ * changes: string[], routeChanges: string[], unresolved: string[],
51
+ * elements: Array<{elementId: string, action: string, properties: string[]}>,
52
+ * unknown: Array<object>, unsupported: Array<object>, ambiguous: Array<object>,
53
+ * delta: object,
54
+ * }} RepairSlideSummary
55
+ */
56
+ function summarise(slides, verdictOf) {
57
+ return slides.map((s) => {
58
+ const comparison = compareSlideSource(s.original, s.src);
59
+ return {
60
+ slideKey: s.key,
61
+ jsxPath: s.path,
62
+ verdict: verdictOf(s),
63
+ changes: s.changes,
64
+ routeChanges: s.routeChanges,
65
+ unresolved: s.unresolved,
66
+ elements: comparison.changes,
67
+ unknown: comparison.unknown,
68
+ unsupported: comparison.unsupported,
69
+ ambiguous: comparison.ambiguous,
70
+ delta: repairDelta(s.original, s.src),
71
+ };
72
+ });
73
+ }
74
+
75
+ const refusal = (code, message, extra = {}) => ({ ok: false, committed: false, passes: 0, verified: false, slides: [], reasons: [{ code, message, ...extra }] });
76
+
77
+ /**
78
+ * Repair the slides one `repair` operation scopes.
79
+ *
80
+ * @param {string} workspaceDir the deck workspace (`<deck>/.slidemind`, or the
81
+ * deck itself for a workspace-less deck)
82
+ * @param {object} options
83
+ * @param {object} options.operation the operation record (kind `repair`)
84
+ * @param {(ctx: {workspaceDir: string, files: string[]}) => Promise<Map<string, Map>>} options.capture
85
+ * render the CANDIDATE and return each file's measured boxes
86
+ * @param {() => boolean} [options.cancelled] checked before every pass
87
+ * @param {() => void} [options.checkpoint] runs inside the commit, before the swap
88
+ * @param {(rel: string) => void} [options.fault] runs after each activated file
89
+ * @returns {Promise<{ok: boolean, committed: boolean, passes: number, verified: boolean,
90
+ * slides: Array<object>, reasons: Array<{code: string, message: string}>}>}
91
+ */
92
+ export async function repairOperation(workspaceDir, { operation, capture, cancelled = () => false, checkpoint = () => {}, fault = () => {} }) {
93
+ if (operation?.kind !== "repair") {
94
+ return refusal("not_a_repair", `bounded repair runs under a repair operation; this one is ${operation?.kind ?? "absent"}`);
95
+ }
96
+ const targets = operation.targets ?? [];
97
+ if (!targets.length) return refusal("no_targets", "this operation scopes no slides");
98
+
99
+ try {
100
+ assertOperationFresh(workspaceDir, operation);
101
+ } catch (err) {
102
+ return refusal(err.code ?? "source_moved", err.message);
103
+ }
104
+
105
+ // 2. The representation gate, on every target, before a candidate exists.
106
+ const slides = [];
107
+ const reasons = [];
108
+ for (const target of targets) {
109
+ const file = path.join(workspaceDir, target.jsxPath);
110
+ let original;
111
+ try {
112
+ original = fs.readFileSync(file, "utf8");
113
+ } catch (err) {
114
+ reasons.push({ code: "source_missing", slide: target.slideKey, message: `${target.jsxPath} cannot be read (${err.message})` });
115
+ continue;
116
+ }
117
+ // Three states, not two: a record that exists and cannot be read is
118
+ // `undefined` — unknown, which the validator refuses — and never `null`,
119
+ // which would say "looked, not converted" about a slide nobody could
120
+ // classify. The byte marker catches a converted slide that still carries
121
+ // its pragma; this is what catches one whose pragma was removed (E15).
122
+ const conversion = conversionProvenance(workspaceDir, target.jsxPath);
123
+ if (conversion === undefined) {
124
+ reasons.push({
125
+ code: "conversion_unreadable",
126
+ slide: target.slideKey,
127
+ jsxPath: target.jsxPath,
128
+ message: `conversions/${path.basename(target.jsxPath).replace(/\.jsx$/i, "")}.json exists but cannot be read, so whether ${target.jsxPath} is converted is unknown; repair or remove that record first`,
129
+ });
130
+ }
131
+ // A set component is repaired in its members' own lines: the core reads
132
+ // and rewrites one literal element per line, so each call site is inlined
133
+ // first and the result folded back afterwards (fold-sets.mjs refoldLike).
134
+ const flat = inlineComponents(original, { preserveLines: false });
135
+ for (const u of flat.unreadable) {
136
+ reasons.push({ code: "unreadable_component", slide: target.slideKey, jsxPath: target.jsxPath, line: u.line, message: `<${u.component}> is spread from an object, so bounded repair cannot read its members — edit the JSX directly` });
137
+ }
138
+ const check = validateSettleTarget(flat.src, { conversion });
139
+ for (const reason of check.reasons) reasons.push({ ...reason, slide: target.slideKey, jsxPath: target.jsxPath });
140
+ const { policy, errors } = repairPolicyFor(target);
141
+ for (const error of errors) reasons.push({ ...error, slide: target.slideKey });
142
+ slides.push({ key: target.slideKey, path: target.jsxPath, original: flat.src, raw: original, policy });
143
+ }
144
+ if (reasons.length) return { ok: false, committed: false, passes: 0, verified: false, slides: [], reasons };
145
+
146
+ // 3–4. The candidate. Every write below lands in the staged copy.
147
+ let candidate;
148
+ try {
149
+ candidate = stageWorkspace(workspaceDir);
150
+ } catch (err) {
151
+ return refusal(err.code ?? "stage_failed", err.message);
152
+ }
153
+ try {
154
+ const measured = new Map();
155
+ const result = await repairPasses({
156
+ slides,
157
+ cancelled,
158
+ render: async (sources) => {
159
+ for (const [rel, src] of sources) fs.writeFileSync(path.join(candidate.workspace, rel), src);
160
+ const captured = await capture({ workspaceDir: candidate.workspace, files: [...sources.keys()] });
161
+ for (const [rel, boxes] of captured ?? []) measured.set(rel, boxes);
162
+ return captured;
163
+ },
164
+ });
165
+ // An untouched slide keeps its own bytes; a repaired one goes back to its
166
+ // components where that is still exact.
167
+ const published = (s) => (s.src === s.original ? s.raw : refoldLike(s.raw, s.src, measured.get(s.path) ?? null).src);
168
+ if (!result.ok) {
169
+ return { ok: false, committed: false, passes: result.passes, verified: result.verified, slides: summarise(result.slides, () => "refused"), reasons: result.reasons };
170
+ }
171
+ const summary = summarise(result.slides, (s) => (s.src === s.original ? "unchanged" : "repaired"));
172
+
173
+ // 5. Publish: the repaired source, then the records that describe it, then
174
+ // one activation. `assertOperationFresh` names the slide that moved;
175
+ // `activate` refuses on ANY file that changed since staging. Every step
176
+ // here writes into the candidate only, so a throw anywhere leaves the deck
177
+ // exactly as it was — and comes back as a reason, not as an exception.
178
+ try {
179
+ for (const s of result.slides) fs.writeFileSync(path.join(candidate.workspace, s.path), published(s));
180
+ const preservation = readSlidePreservation(candidate.workspace);
181
+ let touched = false;
182
+ for (const s of result.slides) {
183
+ const entry = preservation.slides?.[s.key];
184
+ if (!entry?.baseline) continue;
185
+ // Requirements do not move; the source they were read from does.
186
+ entry.baseline = { ...entry.baseline, sourceHash: sourceHash(candidate.workspace, s.path) };
187
+ touched = true;
188
+ }
189
+ if (touched) writeSlidePreservation(candidate.workspace, preservation, { operation });
190
+ recordOperation(candidate.workspace, { ...operation, status: "committed" });
191
+ candidate.activate({
192
+ checkpoint: () => {
193
+ checkpoint();
194
+ assertOperationFresh(workspaceDir, operation);
195
+ },
196
+ fault,
197
+ });
198
+ } catch (err) {
199
+ return { ok: false, committed: false, passes: result.passes, verified: result.verified, slides: summary, reasons: [{ code: err.code ?? "commit_failed", message: err.message }] };
200
+ }
201
+ return { ok: true, committed: true, passes: result.passes, verified: result.verified, slides: summary, reasons: [] };
202
+ } finally {
203
+ candidate.dispose();
204
+ }
205
+ }