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,359 @@
1
+ // The persistent deck library: `<sessionConfigDir>/workspace/registry.json`.
2
+ //
3
+ // { schemaVersion: 1, cliVersion, roots: [abs...], decks: [{ id, path, name, lastSeenAt, missing, hidden, format }] }
4
+ //
5
+ // `cliVersion` is the CLI that last populated the library; a different one at
6
+ // daemon start re-scans every remembered root (discovery.mjs autoDiscover).
7
+ // `format` is deck-paths `deckFormat` — current / legacy / unknown — refreshed
8
+ // on every registration and listing. Registration migrates a flat pre-workspace
9
+ // deck into `.slidemind/` first (`migrateLegacyDeck`), so `legacy` only survives
10
+ // a migration that failed, and such a deck is flagged, never opened.
11
+ //
12
+ // An entry's `id` is a local UUID; its `path` is the canonical realpath of the
13
+ // deck ROOT (the deliverable dir, never `.slidemind`). Two directories that
14
+ // carry the same portable deck ID stay two entries — the library identifies
15
+ // working copies, not decks. Every function reads the file fresh: the daemon
16
+ // and any CLI process share it through the short-lived registry lock, so there
17
+ // is no in-memory copy to invalidate. The lock is never held while acquiring a
18
+ // deck lock (lock ordering: registry inside deck, never the other way round).
19
+
20
+ import { randomUUID } from "node:crypto";
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+
24
+ import { resolveBundleDirForDeck } from "../bridge/bundle.mjs";
25
+ import { sessionConfigDir } from "../bridge/config.mjs";
26
+ import { deckFormat, deckRoot, deckSlug, isDeckDir, migrateLegacyDeck, resolveWorkspace } from "../lib/deck-paths.mjs";
27
+
28
+ export const REGISTRY_SCHEMA_VERSION = 1;
29
+
30
+ export function workspaceConfigDir() {
31
+ return path.join(sessionConfigDir(), "workspace");
32
+ }
33
+
34
+ export function registryPath() {
35
+ return path.join(workspaceConfigDir(), "registry.json");
36
+ }
37
+
38
+ /** Write-then-rename, so a kill mid-write never leaves half a JSON file. */
39
+ export function writeJsonAtomic(file, data, { mode } = {}) {
40
+ fs.mkdirSync(path.dirname(file), { recursive: true });
41
+ const tmp = `${file}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
42
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", mode ? { mode } : undefined);
43
+ fs.renameSync(tmp, file);
44
+ return file;
45
+ }
46
+
47
+ export function coded(code, message, extra = {}) {
48
+ return Object.assign(new Error(message), { code, ...extra });
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // lock
53
+ // ---------------------------------------------------------------------------
54
+
55
+ const LOCK_STALE_MS = 10_000;
56
+ const LOCK_WAIT_MS = 5_000;
57
+
58
+ const LOCK_SLICE_MS = 25;
59
+
60
+ function sleepSync(ms) {
61
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
62
+ }
63
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
64
+
65
+ const lockPath = () => path.join(workspaceConfigDir(), "registry.lock");
66
+ const lockOwner = (lock) => {
67
+ try {
68
+ const pid = Number(fs.readFileSync(path.join(lock, "owner"), "utf8"));
69
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
70
+ } catch {
71
+ return null; // a lock from before this file, or taken a microsecond ago
72
+ }
73
+ };
74
+ // A pid can be recycled and an unreadable owner cannot be judged, so this only
75
+ // ever SHORTENS a wait — the mtime staleness stays the backstop.
76
+ const ownerAlive = (pid) => {
77
+ if (pid === null) return true;
78
+ try {
79
+ process.kill(pid, 0);
80
+ return true;
81
+ } catch (err) {
82
+ return err?.code === "EPERM";
83
+ }
84
+ };
85
+
86
+ /**
87
+ * One acquisition loop, driven two ways: it yields the milliseconds to wait
88
+ * before the next attempt and returns once the lock is held.
89
+ *
90
+ * The critical section is one small read-modify-write, so an honest hold is
91
+ * about a millisecond. The only way the wait below ever reached its 5s deadline
92
+ * was a lock nobody holds any more, which is why the owner's pid is written into
93
+ * it and a dead owner's lock is reclaimed at once instead of after the stale TTL.
94
+ */
95
+ function* acquireLock(lock) {
96
+ fs.mkdirSync(path.dirname(lock), { recursive: true });
97
+ const deadline = Date.now() + LOCK_WAIT_MS;
98
+ for (;;) {
99
+ try {
100
+ fs.mkdirSync(lock);
101
+ try {
102
+ fs.writeFileSync(path.join(lock, "owner"), String(process.pid));
103
+ } catch {
104
+ // an unwritable owner file costs a faster reclaim, never correctness
105
+ }
106
+ return;
107
+ } catch (err) {
108
+ if (err.code !== "EEXIST") throw err;
109
+ let age = 0;
110
+ try {
111
+ age = Date.now() - fs.statSync(lock).mtimeMs;
112
+ } catch {
113
+ continue; // released between our attempt and the stat
114
+ }
115
+ if (age > LOCK_STALE_MS || !ownerAlive(lockOwner(lock))) {
116
+ fs.rmSync(lock, { recursive: true, force: true });
117
+ continue;
118
+ }
119
+ if (Date.now() > deadline) throw coded("registry_busy", "deck registry is locked by another slidemind process");
120
+ yield LOCK_SLICE_MS;
121
+ }
122
+ }
123
+ }
124
+
125
+ /** Run `fn` under the registry lock (a mkdir'd directory; exclusive by the OS). */
126
+ export function withRegistryLock(fn) {
127
+ const lock = lockPath();
128
+ const it = acquireLock(lock);
129
+ // A CLI process has nothing else to do while it waits, so it blocks its thread.
130
+ for (let step = it.next(); !step.done; step = it.next()) sleepSync(step.value);
131
+ try {
132
+ return fn();
133
+ } finally {
134
+ fs.rmSync(lock, { recursive: true, force: true });
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The same lock, awaited. The daemon serves SSE streams and long polls off one
140
+ * event loop, so `Atomics.wait` there stalls every other request in the process
141
+ * — never spin on the daemon's thread, use this from its request handlers.
142
+ */
143
+ export async function withRegistryLockAsync(fn) {
144
+ const lock = lockPath();
145
+ const it = acquireLock(lock);
146
+ for (let step = it.next(); !step.done; step = it.next()) await sleep(step.value);
147
+ try {
148
+ return await fn();
149
+ } finally {
150
+ fs.rmSync(lock, { recursive: true, force: true });
151
+ }
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // read / write
156
+ // ---------------------------------------------------------------------------
157
+
158
+ const EMPTY = () => ({ schemaVersion: REGISTRY_SCHEMA_VERSION, roots: [], decks: [] });
159
+
160
+ /** The registry as on disk; a missing file is an empty one, a malformed one throws `registry_malformed` and is retained. */
161
+ export function readRegistry() {
162
+ const file = registryPath();
163
+ let raw;
164
+ try {
165
+ raw = fs.readFileSync(file, "utf8");
166
+ } catch (err) {
167
+ if (err.code === "ENOENT") return EMPTY();
168
+ throw err;
169
+ }
170
+ let data;
171
+ try {
172
+ data = JSON.parse(raw);
173
+ } catch {
174
+ throw coded("registry_malformed", `deck registry ${file} is not valid JSON; it was left in place for recovery`, { file });
175
+ }
176
+ if (!data || data.schemaVersion !== REGISTRY_SCHEMA_VERSION || !Array.isArray(data.decks) || !Array.isArray(data.roots)) {
177
+ throw coded("registry_malformed", `deck registry ${file} has an unsupported shape; it was left in place for recovery`, { file });
178
+ }
179
+ return data;
180
+ }
181
+
182
+ function writeRegistry(data) {
183
+ return writeJsonAtomic(registryPath(), data);
184
+ }
185
+
186
+ // The critical section itself, shared by both drivers. Every registry mutator is
187
+ // a synchronous function of the loaded registry, so only the WAIT differs.
188
+ const mutate = (fn) => () => {
189
+ const reg = readRegistry();
190
+ const out = fn(reg);
191
+ writeRegistry(reg);
192
+ return out;
193
+ };
194
+ const update = (fn) => withRegistryLock(mutate(fn));
195
+ const updateAsync = (fn) => withRegistryLockAsync(mutate(fn));
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // decks
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /** Canonical deck root of anything a user might point at: the root, its workspace, or a file inside. */
202
+ export function canonicalDeckPath(p) {
203
+ const abs = path.resolve(p);
204
+ const root = deckRoot(resolveWorkspace(abs));
205
+ return fs.realpathSync(root);
206
+ }
207
+
208
+ /**
209
+ * Register a deck directory (idempotent per realpath). Throws `not_a_deck` for a
210
+ * directory without deck markers and `missing_path` when it does not exist.
211
+ */
212
+ // The path checks and the migration happen BEFORE the lock is taken (neither
213
+ // touches the registry), then the mutator runs under it. Split out so the async
214
+ // twin the daemon's routes use shares every rule rather than repeating it.
215
+ function deckEntryMutator(p, { hidden = false, name = null } = {}) {
216
+ let real;
217
+ try {
218
+ real = canonicalDeckPath(p);
219
+ } catch (err) {
220
+ if (err.code === "ENOENT") throw coded("missing_path", `${p} does not exist`);
221
+ throw err;
222
+ }
223
+ if (!fs.statSync(real).isDirectory() || !isDeckDir(real)) {
224
+ throw coded("not_a_deck", `${real} carries no deck markers (.slidemind/, slides/, plan.md or a plan record)`);
225
+ }
226
+ migrateBestEffort(real);
227
+ return (reg) => {
228
+ const now = new Date().toISOString();
229
+ let entry = reg.decks.find((d) => d.path === real);
230
+ const format = deckFormat(real);
231
+ if (entry) {
232
+ entry.lastSeenAt = now;
233
+ entry.missing = false;
234
+ entry.format = format;
235
+ if (name) entry.name = name;
236
+ return { ...entry, created: false };
237
+ }
238
+ entry = { id: randomUUID(), path: real, name: name ?? deckSlug(real), lastSeenAt: now, missing: false, hidden, format };
239
+ reg.decks.push(entry);
240
+ return { ...entry, created: true };
241
+ };
242
+ }
243
+
244
+ export const addDeck = (p, opts = {}) => update(deckEntryMutator(p, opts));
245
+ /** `addDeck` for the daemon: the wait for the lock never blocks its event loop. */
246
+ export const addDeckAsync = (p, opts = {}) => updateAsync(deckEntryMutator(p, opts));
247
+
248
+ /** Move a flat deck into `.slidemind/`; a failure leaves it `legacy` and says why. */
249
+ export function migrateBestEffort(root) {
250
+ try {
251
+ // The resolver is injected rather than imported by deck-paths: bundle.mjs
252
+ // already imports deck-paths, so the arrow would be a cycle.
253
+ const moved = migrateLegacyDeck(root, { bundleDirFor: resolveBundleDirForDeck });
254
+ if (moved) process.stderr.write(`slidemind: migrated ${deckSlug(root)} into ${path.join(root, ".slidemind")} (${moved.length} entries)\n`);
255
+ return moved;
256
+ } catch (err) {
257
+ process.stderr.write(`slidemind: could not migrate ${root} into .slidemind/: ${err.message}\n`);
258
+ return null;
259
+ }
260
+ }
261
+
262
+ /** Registration that never throws: warns on stderr and returns null. For command hooks. */
263
+ export function registerDeckBestEffort(p, opts = {}) {
264
+ try {
265
+ return addDeck(p, opts);
266
+ } catch (err) {
267
+ process.stderr.write(`slidemind: workspace registry skipped ${p}: ${err.message}\n`);
268
+ return null;
269
+ }
270
+ }
271
+
272
+ export function getDeck(id) {
273
+ return readRegistry().decks.find((d) => d.id === id) ?? null;
274
+ }
275
+
276
+ /** Visible entries, newest-seen first, with `missing` refreshed from the filesystem. */
277
+ export function listDecks({ q = "", hidden = false, limit = 100, offset = 0 } = {}) {
278
+ const needle = String(q || "").toLowerCase();
279
+ const all = readRegistry()
280
+ .decks.filter((d) => hidden || !d.hidden)
281
+ .filter((d) => !needle || d.name.toLowerCase().includes(needle) || d.path.toLowerCase().includes(needle))
282
+ .map((d) => {
283
+ const missing = !fs.existsSync(d.path);
284
+ return { ...d, missing, format: missing ? (d.format ?? "unknown") : deckFormat(d.path) };
285
+ })
286
+ .sort((a, b) => (b.lastSeenAt ?? "").localeCompare(a.lastSeenAt ?? ""));
287
+ return { total: all.length, decks: all.slice(offset, offset + limit) };
288
+ }
289
+
290
+ export function markMissing(id, missing = true) {
291
+ return update((reg) => {
292
+ const entry = reg.decks.find((d) => d.id === id);
293
+ if (!entry) throw coded("unknown_deck", `no registered deck ${id}`);
294
+ entry.missing = missing;
295
+ return { ...entry };
296
+ });
297
+ }
298
+
299
+ /** Point a moved entry at its new location; refuses a path another entry already owns. */
300
+ export function locateDeck(id, newPath) {
301
+ const real = canonicalDeckPath(newPath);
302
+ if (!isDeckDir(real)) throw coded("not_a_deck", `${real} carries no deck markers`);
303
+ return update((reg) => {
304
+ const entry = reg.decks.find((d) => d.id === id);
305
+ if (!entry) throw coded("unknown_deck", `no registered deck ${id}`);
306
+ const other = reg.decks.find((d) => d.id !== id && d.path === real);
307
+ if (other) throw coded("path_registered", `${real} is already registered as ${other.name} (${other.id})`, { deckId: other.id });
308
+ entry.path = real;
309
+ entry.name = entry.name || deckSlug(real);
310
+ entry.missing = false;
311
+ entry.format = deckFormat(real);
312
+ entry.lastSeenAt = new Date().toISOString();
313
+ return { ...entry };
314
+ });
315
+ }
316
+
317
+ /** Drop the entry only. Never touches the deck's files. */
318
+ const removeDeckMutator = (id) => (reg) => {
319
+ const i = reg.decks.findIndex((d) => d.id === id);
320
+ if (i === -1) return false;
321
+ reg.decks.splice(i, 1);
322
+ return true;
323
+ };
324
+ export const removeDeck = (id) => update(removeDeckMutator(id));
325
+ /** `removeDeck` for the daemon: see `addDeckAsync`. */
326
+ export const removeDeckAsync = (id) => updateAsync(removeDeckMutator(id));
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // roots
330
+ // ---------------------------------------------------------------------------
331
+
332
+ export function addRoot(p) {
333
+ const real = fs.realpathSync(path.resolve(p));
334
+ if (!fs.statSync(real).isDirectory()) throw coded("not_a_directory", `${real} is not a directory`);
335
+ return update((reg) => {
336
+ if (!reg.roots.includes(real)) reg.roots.push(real);
337
+ return real;
338
+ });
339
+ }
340
+
341
+ export function listRoots() {
342
+ return readRegistry().roots.slice();
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // which CLI populated the library
347
+ // ---------------------------------------------------------------------------
348
+
349
+ /** The CLI version that last populated the library, or null (never / before this field). */
350
+ export function registryVersion() {
351
+ return readRegistry().cliVersion ?? null;
352
+ }
353
+
354
+ export function recordRegistryVersion(version) {
355
+ return update((reg) => {
356
+ reg.cliVersion = version;
357
+ return version;
358
+ });
359
+ }