slidemind 0.3.20 → 0.4.1

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 (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -83,6 +83,28 @@ export function writeConversionRecord(
83
83
  return full;
84
84
  }
85
85
 
86
+ /**
87
+ * Drop one slide's record AND its preserve package. Used when a slide's local
88
+ * hydration is withdrawn — a rebase took it back to its package form, or an
89
+ * update replaced it — because a record left behind names a slide that is not
90
+ * there any more, which `conversionIntegrityErrors` correctly refuses to build.
91
+ * Returns the names it removed.
92
+ */
93
+ export function removeConversionRecord(deckDir, slideFileName) {
94
+ // Callers name the slide FILE, which may already have been replaced by its
95
+ // native form — so both extensions are stripped, not just `.jsx`.
96
+ const stem = path.basename(String(slideFileName)).replace(/\.(jsx|pptx)$/i, "");
97
+ const dir = conversionsDir(deckDir);
98
+ const removed = [];
99
+ for (const name of [`${stem}.json`, `${stem}.slide.pptx`]) {
100
+ const file = path.join(dir, name);
101
+ if (!fs.existsSync(file)) continue;
102
+ fs.rmSync(file, { force: true });
103
+ removed.push(name);
104
+ }
105
+ return removed;
106
+ }
107
+
86
108
  /**
87
109
  * Every conversion record of a deck, keyed by slide file name, in the shape the
88
110
  * render request carries: identity + evidence + the base64 preserve source.
@@ -90,8 +112,17 @@ export function writeConversionRecord(
90
112
  * A record whose `slides/<stem>.jsx` no longer exists is skipped — the slide was
91
113
  * deleted or renamed, and shipping its baseline would bind conversion identity
92
114
  * to a slide that is no longer in the deck. Unreadable/corrupt records are
93
- * skipped too: a converted slide then renders as ordinary jsx (its preserved
94
- * natives are lost, which the caller warns about) rather than failing the push.
115
+ * skipped too — and a skipped record is INDISTINGUISHABLE here from one that
116
+ * was never written. Nobody warns about it either: every warning path
117
+ * (`conversionIntegrityErrors`, `conversionsMissingPreserveSource`) iterates
118
+ * this same map, so the slide they would name is already gone from it.
119
+ *
120
+ * That is fine for a renderer — the slide previews as ordinary jsx — and wrong
121
+ * for anything that REWRITES source or DELIVERS a deck, where absence reads as
122
+ * "not converted" and the answer is really "nobody could tell". Those callers
123
+ * ask {@link conversionProvenance} per slide instead, which distinguishes the
124
+ * two; `build-and-push` does, and refuses rather than shipping a slide whose
125
+ * preserved natives (SmartArt, OLE, an unmodeled chart) would silently be gone.
95
126
  *
96
127
  * `includeSource: false` skips reading the preserve packages — for callers that
97
128
  * only need to know WHICH slides are converted (they can be megabytes).
@@ -108,6 +139,11 @@ export function loadConversions(deckDir, { includeSource = true } = {}) {
108
139
  try {
109
140
  record = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
110
141
  } catch {
142
+ // Deliberate, and ONLY safe for the render path: a slide missing from
143
+ // this map renders as ordinary jsx. It is not safe for anything that
144
+ // decides whether a slide may be REWRITTEN, because absence here reads
145
+ // as "not converted" when the truth is "nobody could tell". Those
146
+ // callers ask `conversionProvenance` below, which does not skip.
111
147
  continue;
112
148
  }
113
149
  const entry = {
@@ -135,6 +171,34 @@ export function loadConversions(deckDir, { includeSource = true } = {}) {
135
171
  return out;
136
172
  }
137
173
 
174
+ /**
175
+ * Is ONE slide converted? Three answers, and the third is the whole point:
176
+ *
177
+ * the record a `conversions/<stem>.json` that parsed: converted, and this
178
+ * is what it says
179
+ * null looked, there is no record file: not converted
180
+ * undefined a record file IS there and could not be read, so the question
181
+ * is unanswered
182
+ *
183
+ * The third is what {@link loadConversions} cannot express — a map has no way
184
+ * to say "something was here and I could not read it", so a corrupt record
185
+ * arrives at its callers as absence. For a renderer that is a degraded slide;
186
+ * for anything that rewrites source it is the difference between refusing and
187
+ * quietly rewriting a converted slide whose pragma someone removed (E15). The
188
+ * three states line up with `validateSettleTarget`'s own contract
189
+ * (src/builders/settle.mjs): undefined refuses as `provenance_unknown`, a
190
+ * record refuses as `converted_provenance`, null proceeds.
191
+ */
192
+ export function conversionProvenance(deckDir, slideFileName) {
193
+ const file = path.join(conversionsDir(deckDir), `${stemOf(slideFileName)}.json`);
194
+ if (!fs.existsSync(file)) return null;
195
+ try {
196
+ return JSON.parse(fs.readFileSync(file, "utf8"));
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
201
+
138
202
  /**
139
203
  * Follow the deck's reconciled GUIDs: move every conversion record whose slide
140
204
  * file was renamed (a reorder renames every slide — the NN- prefix IS the play
@@ -237,9 +301,9 @@ export function applyConversionGates(deckDir, conversions, gates) {
237
301
  reasons,
238
302
  note:
239
303
  `${file}: this converted slide was NOT confirmed for replacement ` +
240
- `(${gate.route}: ${reasons.join(", ")}) — it is in the rendered deck, but pushing ` +
241
- `it over the original is unreviewed. Compare it with the source slide, then set ` +
242
- `"state": "verified" in ${CONVERSIONS_DIR}/${stemOf(file)}.json once it is right.`,
304
+ `(${gate.route}: ${reasons.join(", ")}) — it is in the rendered deck, but nobody ` +
305
+ `has reviewed it over the original: replacing the original (\`push --replace\`) ` +
306
+ `waits for the user's go-ahead (\`--accept-conversions\`).`,
243
307
  });
244
308
  }
245
309
  stampFirstReplace(deckDir, file, gate);
@@ -377,7 +441,7 @@ export function conversionIntegrityErrors(deckDir, conversions) {
377
441
  errors.push(
378
442
  `${file}: its conversion record still claims "slide": "${record.slide}" — the file was renamed ` +
379
443
  `by hand. Rename it back, or re-run 'slidemind adopt' (a plan-driven reorder belongs to ` +
380
- `'proposal-plan renumber', which moves the jsx, the brief, the record and the GUID together).`,
444
+ `the pack's own renumber command, which moves the jsx, the brief, the record and the GUID together).`,
381
445
  );
382
446
  }
383
447
  }
@@ -0,0 +1,107 @@
1
+ // Convert ONE folded slide of a deck — `slides/NN-*.pptx`, written by
2
+ // `slidemind add-slide` (or kept native by an adoption) — into editable jsx IN
3
+ // PLACE, when the user asks to change it (workflow/search.md: a reused slide
4
+ // stays native until then, and is then edited like any other deck slide).
5
+ //
6
+ // Same machinery as adoption: the add-in's convert-pptx route for a slide with
7
+ // no current embedded source, a verbatim restore for one whose source is proved
8
+ // current, and `adoptDeckFromPptx` to materialize either. The jsx keeps the
9
+ // .pptx's file stem, so play order does not move, and replaces it — the deck
10
+ // never carries the slide twice. A slide that cannot be converted leaves the
11
+ // deck untouched and says why.
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ import { adoptDeckFromPptx, convertUntaggedSlides, hydrationRoutes, readSourceDeck } from "./adopt.mjs";
17
+ import { stageWorkspace } from "./workspace-transaction.mjs";
18
+
19
+ function refuse(code, message) {
20
+ return Object.assign(new Error(message), { code });
21
+ }
22
+
23
+ // Only a deck's own folded slide: any folder on disk can be called `slides`.
24
+ // The CLI asks this before it takes the deck's lock, so a refusal writes nothing.
25
+ export function notFoldedReason(pptxPath) {
26
+ const file = path.basename(pptxPath);
27
+ return path.basename(path.dirname(pptxPath)) === "slides" && /^\d{1,3}-.+\.pptx$/i.test(file)
28
+ ? null
29
+ : `${file} is not a deck's folded slide (<deck>/slides/<NN-name>.pptx); nothing was changed.`;
30
+ }
31
+
32
+ export async function convertFoldedSlide({ pptxPath, deckDir, brandId = null, note = () => {}, owner = null }) {
33
+ const file = path.basename(pptxPath);
34
+ const notFolded = notFoldedReason(pptxPath);
35
+ if (notFolded) throw refuse("not_folded", notFolded);
36
+ const stem = file.replace(/\.pptx$/i, "");
37
+ const source = readSourceDeck(pptxPath);
38
+ if (source.order.length !== 1) {
39
+ throw refuse("not_one_slide", `${file} holds ${source.order.length} slides; a folded slide file holds exactly one.`);
40
+ }
41
+
42
+ const revisionLib = await import("./revision.mjs");
43
+ const revision = revisionLib.readRevision(deckDir);
44
+ const dirty = revision ? revisionLib.computeDirty(deckDir, revision) : null;
45
+ // In the accepted baseline: the conversion is hydration, not an edit, so the
46
+ // jsx joins the baseline in the .pptx's place. Added since: it stays an added
47
+ // slide, just editable. Edited since: converting would drop that edit.
48
+ const inBaseline = revision?.baseline?.inputs?.[`slides/${file}`] !== undefined;
49
+ if (inBaseline && dirty.dirtySlides.includes(file)) {
50
+ throw refuse("local_dirty", `${file} has unpublished changes; publish or reconcile them before converting it.`);
51
+ }
52
+
53
+ const heard = [];
54
+ const [route] = hydrationRoutes(source, { slides: [1] });
55
+ const converted =
56
+ route === "convert"
57
+ ? await convertUntaggedSlides({
58
+ pptxPath,
59
+ deckDir,
60
+ brandId,
61
+ source,
62
+ slides: [1],
63
+ note: (line) => {
64
+ heard.push(line);
65
+ note(line);
66
+ },
67
+ })
68
+ : null;
69
+ if (route === "convert" && !converted?.slides?.some((s) => Number(s?.index) === 1)) {
70
+ const why = converted?.skipped?.find((s) => Number(s?.index) === 1)?.reason ?? heard.at(-1) ?? "the add-in returned no jsx for it";
71
+ throw refuse("not_converted", `${file} was not converted (${why}); it stays native and nothing was changed.`);
72
+ }
73
+
74
+ const tx = stageWorkspace(deckDir);
75
+ try {
76
+ const ws = tx.workspace;
77
+ fs.rmSync(path.join(ws, "slides", file), { force: true });
78
+ const result = adoptDeckFromPptx({
79
+ pptxPath,
80
+ deckDir: ws,
81
+ converted,
82
+ source,
83
+ slides: [1],
84
+ only: [1],
85
+ stems: new Map([[1, stem]]),
86
+ });
87
+ const jsx = `${stem}.jsx`;
88
+ if (inBaseline) {
89
+ revisionLib.writeRevision(ws, {
90
+ ...revision,
91
+ base: {
92
+ ...revision.base,
93
+ slides: (revision.base?.slides ?? []).map((s) => (s.file === file ? { ...s, file: jsx, hydrated: true } : s)),
94
+ },
95
+ });
96
+ revisionLib.noteHydration(ws, [jsx], [file]);
97
+ }
98
+ tx.activate({
99
+ checkpoint: () =>
100
+ revision &&
101
+ revisionLib.assertCheckpoint(deckDir, { owner, generation: revision.generation, digest: dirty.inputDigest }),
102
+ });
103
+ return { file: jsx, replaced: file, route, joinedBaseline: inBaseline, warnings: result.warnings, skipped: result.skipped };
104
+ } finally {
105
+ tx.dispose();
106
+ }
107
+ }
@@ -0,0 +1,418 @@
1
+ // Where a deck keeps its two halves.
2
+ //
3
+ // decks/<slug>/ the DELIVERABLE root — only what the user asked for
4
+ // <slug>.pptx the built presentation
5
+ // plan.html the persisted plan page
6
+ // .slidemind/ the WORKSPACE — everything the CLI works with
7
+ // slides/ assets/ conversions/ the render contract (unchanged, and named
8
+ // by the add-in's bundle-shipped workflow docs)
9
+ // concepts/ plan.md authoring inputs
10
+ // state/*.json the eight sidecars
11
+ // preview/ presentation.html, fonts/, draft/, build/
12
+ //
13
+ // `.slidemind/` holds the deck contract that used to sit at the root, so the
14
+ // CLI needs ONE resolver rather than a path hunt: every command runs on the
15
+ // workspace, and a deck without `.slidemind/` (decks predating this layout, the
16
+ // designer's scratch deck) is its own workspace and keeps working unchanged.
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+
21
+ export const WORKSPACE_DIRNAME = ".slidemind";
22
+
23
+ // `plan.json` / `<something>-plan.json` — a plan record the core does not own.
24
+ // Both the deck test and the page-ownership test key off it.
25
+ function planRecordIn(dir) {
26
+ try {
27
+ return fs.readdirSync(dir).find((n) => /^(.+-)?plan\.json$/i.test(n)) ?? null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function isDir(p) {
34
+ try {
35
+ return fs.statSync(p).isDirectory();
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ // Does this directory stand on its own as a deck? An extension pack applies the
42
+ // same test, so both sides answer "is `.slidemind/scratch` a deck or part of its
43
+ // host?" identically. A plan record alone counts: a planned-but-never-authored
44
+ // deck has nothing else yet.
45
+ export function isDeckDir(dir) {
46
+ if (isDir(path.join(dir, WORKSPACE_DIRNAME))) return true;
47
+ if (isDir(path.join(dir, "slides")) || isDir(path.join(dir, "conversions"))) return true;
48
+ if (fs.existsSync(path.join(dir, "plan.md"))) return true;
49
+ return Boolean(planRecordIn(dir));
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // layout generation
54
+ // ---------------------------------------------------------------------------
55
+
56
+ // The layout before `.slidemind/` existed kept everything at the deck root:
57
+ // slides/, assets/, concepts/, plan.md, the sidecars as dotfiles or bare JSON,
58
+ // `.draft-preview/` and `presentation.html`. This CLI no longer reads those —
59
+ // `resolveWorkspace` treats such a root as its own workspace and looks for the
60
+ // sidecars under `state/`, so an old deck silently loses its binding, its plan
61
+ // and review sign-offs and its reconcile ack. `migrateLegacyDeck` moves such a
62
+ // deck into the current layout; the workspace runs it on every registration.
63
+ export const LEGACY_MARKERS = [
64
+ ".bundle-ref.json",
65
+ "plan-review.json",
66
+ "review.json",
67
+ "draft-check.json",
68
+ "reconcile-ack.json",
69
+ "slide-guids.json",
70
+ ".draft-preview",
71
+ "presentation.html",
72
+ "asset-plan.md",
73
+ "plan.md",
74
+ "slides",
75
+ "assets",
76
+ "concepts",
77
+ "conversions",
78
+ ];
79
+
80
+ /**
81
+ * `current` (a `.slidemind/` workspace), `legacy` (the pre-workspace flat
82
+ * layout: any LEGACY_MARKERS entry at the root) or `unknown` (no layout
83
+ * evidence — e.g. a plan-only record). One stat for a current deck.
84
+ */
85
+ export function deckFormat(root) {
86
+ if (isDir(path.join(root, WORKSPACE_DIRNAME))) return "current";
87
+ if (LEGACY_MARKERS.some((n) => fs.existsSync(path.join(root, n)))) return "legacy";
88
+ return "unknown";
89
+ }
90
+
91
+ // Where each root entry of the flat layout lives in a `.slidemind/` workspace.
92
+ // Every sign-off survives the move: the review hash is workspace-relative path +
93
+ // bytes (`slides/01-x.jsx` is the same string in both layouts), the plan hash is
94
+ // plan.md's bytes, the draft check hashes per slide file — nothing stores an
95
+ // absolute path. The two stylesheet copies are the bundle's, nothing reads them.
96
+ const LEGACY_MOVES = [
97
+ ["slides", "slides"],
98
+ ["assets", "assets"],
99
+ ["concepts", "concepts"],
100
+ ["conversions", "conversions"],
101
+ ["plan.md", "plan.md"],
102
+ ["asset-plan.md", "asset-plan.md"],
103
+ [".bundle-ref.json", "state/bundle-ref.json"],
104
+ ["plan-review.json", "state/plan-review.json"],
105
+ ["review.json", "state/review.json"],
106
+ ["draft-check.json", "state/draft-check.json"],
107
+ ["reconcile-ack.json", "state/reconcile-ack.json"],
108
+ ["slide-guids.json", "state/slide-guids.json"],
109
+ ["presentation.html", "preview/presentation.html"],
110
+ [".draft-preview", "preview/draft"],
111
+ [".draft-preview/draft-check.json", "state/draft-check.json"],
112
+ ];
113
+
114
+ function mtime(p) {
115
+ try {
116
+ return fs.statSync(p).mtimeMs;
117
+ } catch {
118
+ return -1;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Move a flat pre-workspace deck into `<root>/.slidemind/`. Idempotent: a deck
124
+ * that already has a workspace, or carries no legacy marker, is left alone
125
+ * (returns null). A root `state/` — written by this CLI when it ran on the flat
126
+ * deck as its own workspace — merges into the new `state/`, newer file wins.
127
+ * Returns the workspace-relative paths that were written.
128
+ */
129
+ export function migrateLegacyDeck(root, { bundleDirFor = null } = {}) {
130
+ // A workspace dir itself looks flat from the inside; never nest one in it.
131
+ if (path.basename(path.resolve(root)) === WORKSPACE_DIRNAME) return null;
132
+ if (deckFormat(root) !== "legacy") return null;
133
+ const ws = path.join(root, WORKSPACE_DIRNAME);
134
+ const moved = [];
135
+ const move = (from, to) => {
136
+ if (!fs.existsSync(from)) return;
137
+ if (to === null) return fs.rmSync(from, { force: true });
138
+ if (fs.existsSync(to)) {
139
+ // Both layouts wrote this sidecar: keep the newer, drop the other.
140
+ if (mtime(from) <= mtime(to)) return fs.rmSync(from, { force: true, recursive: true });
141
+ fs.rmSync(to, { force: true, recursive: true });
142
+ }
143
+ fs.mkdirSync(path.dirname(to), { recursive: true });
144
+ fs.renameSync(from, to);
145
+ moved.push(path.relative(ws, to).split(path.sep).join("/"));
146
+ };
147
+ fs.mkdirSync(path.join(ws, "state"), { recursive: true });
148
+ for (const [from, to] of LEGACY_MOVES) {
149
+ // `.draft-preview/draft-check.json` moves after the dir itself did.
150
+ const src = from.startsWith(".draft-preview/") ? path.join(ws, "preview/draft", path.basename(from)) : path.join(root, from);
151
+ move(src, to === null ? null : path.join(ws, to));
152
+ }
153
+ const rootState = path.join(root, "state");
154
+ if (isDir(rootState)) {
155
+ for (const n of fs.readdirSync(rootState)) move(path.join(rootState, n), path.join(ws, "state", n));
156
+ fs.rmSync(rootState, { recursive: true, force: true });
157
+ }
158
+ // The two stylesheets the flat layout kept at the root are the bundle's, and a
159
+ // deck no longer carries them — but ownership here is the same rule as
160
+ // everywhere else: MATCHING BYTES, never the filename. A copy the user edited
161
+ // is theirs and stays. Runs last, so the bundle ref has already moved into
162
+ // `state/` and `bundleDirFor` can resolve it; without a resolver nothing is
163
+ // deleted, because an unprovable delete is the one outcome worth avoiding.
164
+ const bundleDir = bundleDirFor ? bundleDirFor(ws) : null;
165
+ if (bundleDir) {
166
+ for (const [deckRel, bundleRel] of BUNDLE_SOURCES) {
167
+ const at = path.join(root, deckRel);
168
+ if (sameBytes(at, path.join(bundleDir, ...bundleRel))) fs.rmSync(at, { force: true });
169
+ }
170
+ }
171
+ return moved;
172
+ }
173
+
174
+ // The workspace a path lies inside, up to and including its last `.slidemind`
175
+ // segment — null when the path is not inside one.
176
+ function hostWorkspace(p) {
177
+ const parts = p.split(path.sep);
178
+ const i = parts.lastIndexOf(WORKSPACE_DIRNAME);
179
+ return i === -1 ? null : parts.slice(0, i + 1).join(path.sep);
180
+ }
181
+
182
+ /**
183
+ * `<deck>/.slidemind` when it exists, else `<deck>` (a flat deck is its own
184
+ * workspace). A path INSIDE someone else's workspace is either a deck of its own
185
+ * — the designer's scratch deck at `.slidemind/scratch` — or part of its host, so
186
+ * `.slidemind/slides` and `.slidemind/plan/` resolve to the workspace that owns them.
187
+ */
188
+ export function resolveWorkspace(deckDir) {
189
+ if (!deckDir) return deckDir;
190
+ const nested = path.join(deckDir, WORKSPACE_DIRNAME);
191
+ if (isDir(nested)) return nested;
192
+ const host = hostWorkspace(deckDir);
193
+ if (host && host !== deckDir && !isDeckDir(deckDir)) return host;
194
+ return deckDir;
195
+ }
196
+
197
+ /** The deliverable root of a workspace — one level up from `.slidemind`, else itself. */
198
+ export function deckRoot(workspaceDir) {
199
+ if (!workspaceDir) return workspaceDir;
200
+ if (path.basename(workspaceDir) === WORKSPACE_DIRNAME) return path.dirname(workspaceDir);
201
+ const host = hostWorkspace(workspaceDir);
202
+ // Inside a workspace and not a deck itself: the deck is the one that owns it.
203
+ if (host && !isDeckDir(workspaceDir)) return path.dirname(host);
204
+ return workspaceDir;
205
+ }
206
+
207
+ /** The deck's name, from the ROOT — `path.basename(workspaceDir)` would say ".slidemind". */
208
+ export function deckSlug(workspaceDir) {
209
+ return path.basename(path.resolve(deckRoot(workspaceDir)));
210
+ }
211
+
212
+ /** The built presentation, at the deck root: `<deck>/<slug>.pptx`. */
213
+ export function deckPptxPath(workspaceDir) {
214
+ return path.join(deckRoot(workspaceDir), `${deckSlug(workspaceDir)}.pptx`);
215
+ }
216
+
217
+ /** The persisted plan page, at the deck root. */
218
+ export function planPagePath(workspaceDir) {
219
+ return path.join(deckRoot(workspaceDir), "plan.html");
220
+ }
221
+
222
+ // The eight sidecars, grouped under `state/`.
223
+ export const STATE_FILES = [
224
+ "slide-guids.json",
225
+ "bundle-ref.json",
226
+ "plan-review.json",
227
+ "review.json",
228
+ "draft-check.json",
229
+ "reconcile-ack.json",
230
+ "layout-context.json",
231
+ // The deck's image registry (src/lib/image-assets.mjs): immutable {assetId,
232
+ // sha256, …} entries for the bytes under `assets/local/`.
233
+ "image-assets.json",
234
+ // Image uses a TARGETED EDIT recorded outside a plan stage
235
+ // (src/lib/image-use-overrides.mjs), keyed by durable slide GUID. Folded back
236
+ // into the plan by the next approved revision.
237
+ "image-use-overrides.json",
238
+ // The intent fingerprint each concept mockup was generated under
239
+ // (src/builders/concept-intent.mjs) — what makes a stale mockup visible
240
+ // instead of silently reused.
241
+ "concept-intent.json",
242
+ ];
243
+
244
+ /** `<workspace>/state/<name>`. */
245
+ export function statePath(workspaceDir, name) {
246
+ return path.join(workspaceDir, "state", name);
247
+ }
248
+
249
+ /** Write a sidecar, creating `state/` on the way. */
250
+ export function writeStateFile(workspaceDir, name, contents) {
251
+ const file = statePath(workspaceDir, name);
252
+ fs.mkdirSync(path.dirname(file), { recursive: true });
253
+ fs.writeFileSync(file, contents);
254
+ return file;
255
+ }
256
+
257
+ /** `<workspace>/preview[/draft|/build]` — the one class of output nothing reads back. */
258
+ export function previewPath(workspaceDir, ...sub) {
259
+ return path.join(workspaceDir, "preview", ...sub);
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // bundle ownership
264
+ // ---------------------------------------------------------------------------
265
+
266
+ // Which files in a deck are COPIES of the design bundle's own. Some decks carry
267
+ // the two stylesheets and the chrome/master images; nothing reads them any more,
268
+ // so they are never uploaded and never hashed into a review approval.
269
+ //
270
+ // Ownership is the materialized bundle (the manifest of what the design owns)
271
+ // PLUS byte identity — never the filename. A user's own `assets/master-data.png`,
272
+ // and a same-named file whose bytes differ from the bundle's, stay deck-owned and
273
+ // keep their place in the preview, the upload and the review hash. With no bundle
274
+ // on hand nothing is bundle-owned, which is the safe direction: a deck file is
275
+ // only ever set aside on positive evidence.
276
+ //
277
+ // Returns workspace-relative POSIX paths.
278
+ //
279
+ // ponytail: byte compare after a size check, no content index — an assets dir is
280
+ // tens of files. Hash them if a deck ever carries hundreds.
281
+ const BUNDLE_SOURCES = [
282
+ ["corporate-design.css", ["corporate-design.css"]],
283
+ ["layouts.css", ["layouts", "layouts.css"]],
284
+ ];
285
+
286
+ function sameBytes(a, b) {
287
+ try {
288
+ if (fs.statSync(a).size !== fs.statSync(b).size) return false;
289
+ return fs.readFileSync(a).equals(fs.readFileSync(b));
290
+ } catch {
291
+ return false;
292
+ }
293
+ }
294
+
295
+ export function bundleOwnedFiles(workspaceDir, bundleDir) {
296
+ const owned = new Set();
297
+ if (!workspaceDir || !bundleDir) return owned;
298
+ for (const [deckRel, bundleRel] of BUNDLE_SOURCES) {
299
+ if (sameBytes(path.join(workspaceDir, deckRel), path.join(bundleDir, ...bundleRel))) {
300
+ owned.add(deckRel);
301
+ }
302
+ }
303
+ let entries = [];
304
+ try {
305
+ entries = fs.readdirSync(path.join(workspaceDir, "assets"), { withFileTypes: true });
306
+ } catch {
307
+ return owned;
308
+ }
309
+ for (const e of entries) {
310
+ if (!e.isFile()) continue;
311
+ const deckFile = path.join(workspaceDir, "assets", e.name);
312
+ if (sameBytes(deckFile, path.join(bundleDir, "assets", e.name))) {
313
+ owned.add(`assets/${e.name}`);
314
+ }
315
+ }
316
+ return owned;
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // who owns plan.html
321
+ // ---------------------------------------------------------------------------
322
+
323
+ // One deck root, one plan page, one writer. The core CLI renders it from
324
+ // `plan.md`; an extension (a pack) renders its own from its own
325
+ // record. Whoever owns it says so in `state/plan-owner.json`:
326
+ //
327
+ // { version: 1, owner: "core" | "<extension id>", reviewCommand: "<what a human runs>",
328
+ // recordPath: "<workspace-relative record>", updatedAt: "<ISO>" }
329
+ //
330
+ // Generic gates never overwrite a page they do not own — they name the owner's
331
+ // review command instead. This is a protocol, not a list: the CLI knows nothing
332
+ // about any particular extension.
333
+ export const PLAN_OWNER_FILE = "plan-owner.json";
334
+
335
+ /** In the <head> of every page the CORE renderer wrote — an unmarked page is someone else's. */
336
+ export const PLAN_PAGE_MARKER = '<meta name="slidemind-plan-owner" content="core">';
337
+
338
+ export function readPlanOwner(workspaceDir) {
339
+ try {
340
+ const data = JSON.parse(fs.readFileSync(statePath(workspaceDir, PLAN_OWNER_FILE), "utf8"));
341
+ return data && typeof data.owner === "string" ? data : null;
342
+ } catch {
343
+ return null;
344
+ }
345
+ }
346
+
347
+ export function writePlanOwner(workspaceDir, { owner, reviewCommand, recordPath }) {
348
+ return writeStateFile(
349
+ workspaceDir,
350
+ PLAN_OWNER_FILE,
351
+ JSON.stringify(
352
+ { version: 1, owner, reviewCommand, recordPath, updatedAt: new Date().toISOString() },
353
+ null,
354
+ 2,
355
+ ) + "\n",
356
+ );
357
+ }
358
+
359
+ // First `bytes` of a file as text, or null — the marker sits in the <head>, and
360
+ // a rendered plan page is a few hundred KB of inlined modules.
361
+ function headOf(file, bytes = 4096) {
362
+ let fd;
363
+ try {
364
+ fd = fs.openSync(file, "r");
365
+ } catch {
366
+ return null;
367
+ }
368
+ try {
369
+ const buf = Buffer.alloc(bytes);
370
+ const n = fs.readSync(fd, buf, 0, bytes, 0);
371
+ return buf.subarray(0, n).toString("utf8");
372
+ } finally {
373
+ fs.closeSync(fd);
374
+ }
375
+ }
376
+
377
+ // A plan record the core does not own: `plan.json` or `<something>-plan.json`,
378
+ // in the workspace or at the deck root. Decks written before the ownership file
379
+ // existed carry one of these and no declaration, so the evidence stands in for it.
380
+ function foreignPlanRecord(workspaceDir) {
381
+ for (const dir of new Set([workspaceDir, deckRoot(workspaceDir)])) {
382
+ const hit = planRecordIn(dir);
383
+ if (hit) return path.relative(workspaceDir, path.join(dir, hit)).split(path.sep).join("/");
384
+ }
385
+ return null;
386
+ }
387
+
388
+ /**
389
+ * Who may write `<root>/plan.html`: the declaration when there is one, else the
390
+ * evidence on disk (an unmarked page, or a plan record the core does not own),
391
+ * else core. `declared` is false when the owner was inferred.
392
+ */
393
+ export function planPageOwner(workspaceDir) {
394
+ const declared = readPlanOwner(workspaceDir);
395
+ if (declared) return { ...declared, declared: true };
396
+
397
+ // Transition: decks written before the declaration existed. A record the core
398
+ // does not own, or a page the core renderer did not write, is an extension's.
399
+ const extension = (recordPath) => ({
400
+ version: 1,
401
+ owner: "extension",
402
+ reviewCommand: null,
403
+ recordPath,
404
+ declared: false,
405
+ });
406
+ const record = foreignPlanRecord(workspaceDir);
407
+ if (record) return extension(record);
408
+ const head = headOf(planPagePath(workspaceDir));
409
+ if (head !== null && !head.includes(PLAN_PAGE_MARKER)) return extension(null);
410
+
411
+ return {
412
+ version: 1,
413
+ owner: "core",
414
+ reviewCommand: `slidemind plan ${deckSlug(workspaceDir)}`,
415
+ recordPath: "plan.md",
416
+ declared: false,
417
+ };
418
+ }