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
@@ -0,0 +1,686 @@
1
+ // `slidemind revision <sub>` and `slidemind rebase <deck>` — the user-facing
2
+ // half of the local revision protocol (src/lib/revision.mjs).
3
+ //
4
+ // Two things live here that nothing else in the CLI does:
5
+ //
6
+ // • OWNERSHIP that outlives one command. A skill editing a deck writes raw
7
+ // .jsx files, runs several commands and fans work out to parallel lanes;
8
+ // a per-command lock protects none of that. `revision begin` hands back an
9
+ // owner token, the session exports it as SLIDEMIND_OWNER, and every
10
+ // command underneath re-enters under it instead of blocking.
11
+ // • ACCEPTANCE as a deliberate act. A build produces a candidate; it does not
12
+ // decide that the candidate is the deck. `revision accept --candidate <id>`
13
+ // is where a local-only deck moves its accepted base, and it refuses a
14
+ // stale one, a conflicted deck, and a deck bound to a remote copy (there,
15
+ // only a confirmed publish receipt may advance the base).
16
+
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { randomUUID } from "node:crypto";
20
+
21
+ import { fail, resolveDeckArg, deckSlug, splitDeckArg, requireReviewApproved } from "./common.mjs";
22
+ import {
23
+ acquireOwner,
24
+ candidateStale,
25
+ journalIncomplete,
26
+ listCandidates,
27
+ readBusy,
28
+ readCandidate,
29
+ readConflicts,
30
+ readRevision,
31
+ releaseStaleOwner,
32
+ revisionStatus,
33
+ stageIncoming,
34
+ acceptCandidate,
35
+ } from "../lib/revision.mjs";
36
+ import {
37
+ SESSION_OPERATION_KINDS,
38
+ applyRequirementAmendment,
39
+ checkRedesignTransition,
40
+ closeOpenOperations,
41
+ migrateEditState,
42
+ openOperations,
43
+ openRedesigns,
44
+ parseSlideSelection,
45
+ recordOperation,
46
+ resolveSlideTargets,
47
+ } from "../lib/edit-state.mjs";
48
+ import { EDIT_OPERATION_VERSION } from "../lib/edit-operations.mjs";
49
+ import { editTransitionRefusals } from "../lib/edit-transitions.mjs";
50
+
51
+ const json = (data) => process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
52
+
53
+ /**
54
+ * Refuse a deck whose slides changed in a way the open scopes do not authorize —
55
+ * another slide or a sibling beside a target, an import's pragma dropped outside
56
+ * a redesign. The add-in refuses the same transitions at the write; here the
57
+ * first command that reads the deck does (view-draft, build-pptx, push).
58
+ */
59
+ export function requireAuthorizedEdits(deckDir) {
60
+ const refusals = editTransitionRefusals(deckDir);
61
+ if (refusals.length === 0) return;
62
+ fail(`nothing was rendered — this change is outside what the edit was authorized to do:\n${refusals.map((r) => ` ${r}`).join("\n")}`, 2);
63
+ }
64
+
65
+ function requireAdopted(deckDir) {
66
+ const revision = readRevision(deckDir);
67
+ if (!revision) {
68
+ fail(
69
+ `${deckSlug(deckDir)} has no accepted base — it was authored here rather than adopted from a .pptx. ` +
70
+ `\`slidemind adopt <file.pptx> --deck ${deckSlug(deckDir)}\` establishes one.`,
71
+ );
72
+ }
73
+ return revision;
74
+ }
75
+
76
+ // Every open redesign on this deck must have replaced its target, not sat next
77
+ // to it. Reports every violation at once — an agent that has to be told one
78
+ // problem per run will fix them one per run.
79
+ function assertRedesignsReplaced(deckDir) {
80
+ for (const operation of openRedesigns(deckDir)) {
81
+ const { ok, violations } = checkRedesignTransition(deckDir, operation);
82
+ if (ok) continue;
83
+ fail(
84
+ `redesign ${operation.operationId} did not replace what it was scoped to:\n` +
85
+ violations.map((v) => ` [${v.code}] ${v.detail}`).join("\n"),
86
+ 2,
87
+ );
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Seed a preservation record for every rendered slide that has none, from its
93
+ * current evidence — the add-in seeds at every render the same way
94
+ * (`preservation-seed.ts`), so what a slide owes is checked from its first
95
+ * draft on, not only once a scope or an adopt happened to capture it.
96
+ */
97
+ export function seedRenderedSlides(deckDir, files) {
98
+ const selection = (files ?? []).filter((f) => /\.jsx$/i.test(f));
99
+ if (selection.length === 0) return;
100
+ try {
101
+ migrateEditState(deckDir, { selection });
102
+ } catch {
103
+ // An unreadable record is never rewritten; the diagnostics name it and
104
+ // delivery refuses on it.
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // revision
110
+ // ---------------------------------------------------------------------------
111
+
112
+ export async function cmdRevision(rest) {
113
+ const sub = rest[0];
114
+ const args = rest.slice(1);
115
+ switch (sub) {
116
+ case "begin":
117
+ return revisionBegin(args);
118
+ case "finish":
119
+ return revisionFinish(args);
120
+ case "status":
121
+ return revisionStatusCmd(args);
122
+ case "accept":
123
+ return revisionAccept(args);
124
+ default:
125
+ fail(
126
+ `usage: slidemind revision <begin|finish|status|accept> <deck> [options]\n` +
127
+ ` begin take local ownership of the deck for a whole edit session\n` +
128
+ ` --operation ${SESSION_OPERATION_KINDS.join("|")} scope this session to one kind of edit\n` +
129
+ ` --slides <sel> which slides it may touch — '2,7', '2-4', 'all' or a file name\n` +
130
+ ` (required with --operation; resolved to durable slide ids at once)\n` +
131
+ ` --scope <file.json> per-slide declared changes / live identity / repair budget\n` +
132
+ ` --summary <text> what the user asked for, in one line\n` +
133
+ ` --command <name> label for the ownership record (default: session)\n` +
134
+ ` finish release it (work and candidates are kept; open edit scopes are closed)\n` +
135
+ ` status accepted base, generation, dirty slides, candidates, staged snapshots, open scopes\n` +
136
+ ` accept --candidate <id>: make a validated local candidate the accepted base\n` +
137
+ ` --json machine-readable result (begin | status | accept)`,
138
+ );
139
+ }
140
+ }
141
+
142
+ // A structured scope file is how an operation says more than "these slides":
143
+ // the declared changes, the live slide it was read from, a repair budget. Keyed
144
+ // by slide key, file name, stem or 1-based number — whatever the caller already
145
+ // has — and merged onto the resolved target.
146
+ const SCOPE_TARGET_KEYS = [
147
+ "liveSlideId",
148
+ "provenance",
149
+ "baseLiveFingerprint",
150
+ "sourceRevision",
151
+ "repairPolicy",
152
+ "changes",
153
+ ];
154
+
155
+ function readScopeFile(file) {
156
+ if (!fs.existsSync(file)) fail(`no such file: ${file}`);
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
160
+ } catch (err) {
161
+ fail(`${file} is not readable JSON: ${err.message}`);
162
+ }
163
+ if (!parsed || typeof parsed !== "object") fail(`${file} must be a JSON object`);
164
+ const targets = parsed.targets ?? {};
165
+ if (typeof targets !== "object" || Array.isArray(targets)) {
166
+ fail(`${file}: "targets" must be an object keyed by slide id, file name or slide number`);
167
+ }
168
+ for (const [key, entry] of Object.entries(targets)) {
169
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
170
+ fail(`${file}: targets["${key}"] must be an object`);
171
+ }
172
+ for (const k of Object.keys(entry)) {
173
+ if (!SCOPE_TARGET_KEYS.includes(k)) {
174
+ fail(`${file}: targets["${key}"].${k} is not part of an operation target (${SCOPE_TARGET_KEYS.join(", ")})`);
175
+ }
176
+ }
177
+ }
178
+ return { requestSummary: parsed.requestSummary ?? null, targets };
179
+ }
180
+
181
+ function revisionBegin(args) {
182
+ // Parsed here rather than through splitDeckArg: every flag below takes a
183
+ // value, and a positional-scanning split would read `--operation repair`'s
184
+ // value as the deck.
185
+ let deck = null;
186
+ let command = "session";
187
+ let asJson = false;
188
+ let operation = null;
189
+ let slides = null;
190
+ let scopeFile = null;
191
+ let summary = null;
192
+ for (let i = 0; i < args.length; i++) {
193
+ const a = args[i];
194
+ if (a === "--command") command = args[++i] ?? command;
195
+ else if (a === "--operation") operation = args[++i] ?? null;
196
+ else if (a === "--slides") slides = args[++i] ?? null;
197
+ else if (a === "--scope") scopeFile = args[++i] ?? null;
198
+ else if (a === "--summary") summary = args[++i] ?? null;
199
+ else if (a === "--json") asJson = true;
200
+ else if (a.startsWith("-")) fail(`unknown flag: ${a}`);
201
+ else if (deck === null) deck = a;
202
+ else fail(`unexpected argument: ${a}`);
203
+ }
204
+ if (!deck) {
205
+ fail(
206
+ `usage: slidemind revision begin <deck> [--operation ${SESSION_OPERATION_KINDS.join("|")} --slides <sel>]\n` +
207
+ ` [--scope <file.json>] [--summary <text>] [--command <name>] [--json]`,
208
+ );
209
+ }
210
+ if (operation && !SESSION_OPERATION_KINDS.includes(operation)) {
211
+ fail(
212
+ `--operation must be one of ${SESSION_OPERATION_KINDS.join(", ")} (got '${operation}'). ` +
213
+ `\`create\` is what a build infers for a genuinely new slide, not something a session claims.`,
214
+ );
215
+ }
216
+ // A scope with no slides is not a scope: it would silently claim the deck.
217
+ if (operation && !slides) fail(`--operation ${operation} needs --slides <sel> — the slides it may touch`);
218
+ if (!operation && (slides || scopeFile)) fail(`--slides/--scope describe an operation; add --operation <kind>`);
219
+ const scope = scopeFile ? readScopeFile(scopeFile) : null;
220
+
221
+ const deckDir = resolveDeckArg(deck, { allowUnfinished: true });
222
+ let held;
223
+ try {
224
+ held = acquireOwner(deckDir, { command: operation ? `${command} (${operation})` : command, session: true });
225
+ } catch (err) {
226
+ if (err?.code === "deck_busy") fail(err.message, 2);
227
+ throw err;
228
+ }
229
+
230
+ let recorded = null;
231
+ let migration = null;
232
+ let amended = null;
233
+ if (operation) {
234
+ try {
235
+ // Capture what the slides owe BEFORE the first safeguarded edit: a deck
236
+ // that predates these records stays editable, its obligations read from
237
+ // the source actually on disk.
238
+ migration = migrateEditState(deckDir);
239
+ recorded = recordOperation(deckDir, buildOperation({ deckDir, kind: operation, slides, scope, summary }));
240
+ // A declared removal or addition amends the record HERE, against the base
241
+ // the scope was just written for — not at commit, when the source has
242
+ // moved and nothing can be tied to it any more.
243
+ amended = applyRequirementAmendment(deckDir, recorded);
244
+ } catch (err) {
245
+ // Nothing was opened, so nothing stays held: a session token for a scope
246
+ // that was refused would lock the deck behind an edit that never began.
247
+ held.release();
248
+ if (
249
+ [
250
+ "operation_conflict",
251
+ "operation_invalid",
252
+ "state_invalid",
253
+ "unsupported_version",
254
+ "state_unreadable",
255
+ "bad_selection",
256
+ "no_preservation_record",
257
+ "source_moved",
258
+ "source_missing",
259
+ ].includes(err?.code)
260
+ ) {
261
+ fail(err.message, 2);
262
+ }
263
+ throw err;
264
+ }
265
+ }
266
+
267
+ // The scope IS the unit of repair, and this command is the only thing that
268
+ // records one — so the next step is named here rather than left to be
269
+ // guessed. `--repair` runs the whole scope: it refuses a targeted-edit scope
270
+ // by name, and refuses `--only NN` inside a wider one (a partial run would
271
+ // mark the operation done for slides it never touched).
272
+ if (recorded?.kind === "repair") {
273
+ process.stderr.write(
274
+ `slidemind: run the repair inside this session — \`slidemind transcribe ${deck} --repair\` ` +
275
+ `(the whole scope; re-open with --slides NN to repair one slide alone).\n`,
276
+ );
277
+ }
278
+ const status = revisionStatus(deckDir);
279
+ if (asJson) {
280
+ // The token LAST: revisionStatus also reports an `owner` (who holds the
281
+ // deck), and spreading it over the token would hand the caller an object
282
+ // where it asked for a string.
283
+ json({
284
+ ...status,
285
+ heldBy: status.owner,
286
+ owner: held.token,
287
+ nested: held.nested,
288
+ operation: recorded,
289
+ migration,
290
+ amended,
291
+ });
292
+ return;
293
+ }
294
+ process.stdout.write(
295
+ `Owner token for ${deckSlug(deckDir)}: ${held.token}\n` +
296
+ `Export it so every command in this session runs under it:\n` +
297
+ ` export SLIDEMIND_OWNER=${held.token}\n` +
298
+ `Release it when the session ends: slidemind revision finish ${deck}\n` +
299
+ `Generation ${status.generation}${status.localDirty ? `, ${status.dirtySlides.length} slide(s) with unpublished edits` : ", clean"}\n` +
300
+ (recorded
301
+ ? `Scope ${recorded.operationId}: ${recorded.kind} on ${recorded.targets.length} slide(s)\n` +
302
+ (recorded.kind === "redesign"
303
+ ? `Redesign replaces these slides in place: rewrite each file listed below, keep its ` +
304
+ `native placeholders, and do not add a slide beside it.\n`
305
+ : ``) +
306
+ recorded.targets
307
+ .map((t) => ` ${t.jsxPath} ${t.slideKey} base ${String(t.baseSourceHash ?? "").slice(0, 12)}\n`)
308
+ .join("") +
309
+ (amended?.superseded.length || amended?.added.length
310
+ ? `Amended on request: ${amended.superseded.length} obligation(s) retired, ` +
311
+ `${amended.added.length} required addition(s).\n`
312
+ : ``) +
313
+ (migration?.seeded.length
314
+ ? `Captured preservation records for ${migration.seeded.length} slide(s) from their current source ` +
315
+ `(${[...new Set(migration.seeded.map((e) => e.origin))].join(", ")}); evidence: unknown until a render verdict.\n` +
316
+ migration.seeded
317
+ .flatMap((e) => e.limitations.map((l) => ` limitation ${path.basename(e.jsxPath)}: ${l}\n`))
318
+ .join("")
319
+ : ``)
320
+ : ``),
321
+ );
322
+ }
323
+
324
+ // One operation record from the resolved targets. `changes` stays whatever the
325
+ // scope file declared — empty when it declared none, which authorizes no
326
+ // deletion and no property change.
327
+ function buildOperation({ deckDir, kind, slides, scope, summary }) {
328
+ // The whole deck first: `preparedSlideKeys` is what later lets a redesign
329
+ // tell a slide that appeared since from one that was always there
330
+ // (checkRedesignTransition). Resolved here because this is the moment the
331
+ // scope is taken — afterwards nobody can reconstruct it.
332
+ const deckSlides = resolveSlideTargets(deckDir);
333
+ const selection = parseSlideSelection(slides);
334
+ const targets = selection
335
+ ? resolveSlideTargets(deckDir, { selection })
336
+ : deckSlides;
337
+ if (targets.length === 0) fail(`--slides matched no slide in ${deckSlug(deckDir)}`);
338
+ const scoped = (target) => {
339
+ const entry =
340
+ scope?.targets?.[target.slideKey] ??
341
+ scope?.targets?.[target.file] ??
342
+ scope?.targets?.[target.file.replace(/\.(jsx|pptx)$/i, "")] ??
343
+ scope?.targets?.[String(target.number)] ??
344
+ null;
345
+ return entry ?? {};
346
+ };
347
+ return {
348
+ version: EDIT_OPERATION_VERSION,
349
+ operationId: `op-${randomUUID()}`,
350
+ kind,
351
+ preparedSlideKeys: deckSlides.map((t) => t.slideKey),
352
+ // Every slide's bytes as the scope opened: what tells a slide changed outside
353
+ // the scope (or a sibling written beside a target) from one left alone
354
+ // (src/lib/edit-transitions.mjs).
355
+ preparedSourceHashes: Object.fromEntries(deckSlides.map((t) => [t.slideKey, t.sourceHash])),
356
+ requestSummary:
357
+ summary ?? scope?.requestSummary ?? `${kind} on ${targets.map((t) => t.file).join(", ")}`,
358
+ status: "prepared",
359
+ targets: targets.map((t) => ({
360
+ slideKey: t.slideKey,
361
+ jsxPath: t.jsxPath,
362
+ baseSourceHash: t.sourceHash,
363
+ provenance: "local",
364
+ changes: [],
365
+ ...scoped(t),
366
+ })),
367
+ };
368
+ }
369
+
370
+ function revisionFinish(args) {
371
+ const { deck, passthrough } = splitDeckArg(args, "revision finish");
372
+ let owner = process.env.SLIDEMIND_OWNER ?? null;
373
+ let force = false;
374
+ for (let i = 0; i < passthrough.length; i++) {
375
+ const a = passthrough[i];
376
+ if (a === "--owner") owner = passthrough[++i] ?? owner;
377
+ else if (a === "--force") force = true;
378
+ else fail(`unknown flag: ${a}`);
379
+ }
380
+ const deckDir = resolveDeckArg(deck, { allowUnfinished: true });
381
+ const busy = readBusy(deckDir);
382
+ if (!busy) {
383
+ process.stdout.write(`${deckSlug(deckDir)} has no owner — nothing to release.\n`);
384
+ return;
385
+ }
386
+ const released = releaseStaleOwner(deckDir, { owner, force });
387
+ if (!released) {
388
+ fail(
389
+ (busy.kind === "session"
390
+ ? `${deckSlug(deckDir)} is held by an edit session (\`${busy.command}\`, started ${busy.startedAt}).`
391
+ : `${deckSlug(deckDir)} is owned by a running \`${busy.command}\` (pid ${busy.pid}, since ${busy.startedAt}).`) +
392
+ ` Pass --owner <token> to release your own session (export SLIDEMIND_OWNER and it is read from there), ` +
393
+ `or --force to release it anyway.`,
394
+ 2,
395
+ );
396
+ }
397
+ // Releasing ownership is NOT discarding work: the dirty files, the candidates
398
+ // and any journaled operation are all exactly where they were. What DOES end
399
+ // is the edit scope — a prepared operation whose session is over authorizes
400
+ // nothing, and leaving it open would lease those slides forever.
401
+ let closed = [];
402
+ try {
403
+ closed = closeOpenOperations(deckDir);
404
+ } catch (err) {
405
+ // An unreadable scope file is not a reason to refuse releasing the deck.
406
+ process.stderr.write(`slidemind: open edit scopes were left as they are — ${err.message}\n`);
407
+ }
408
+ const status = revisionStatus(deckDir);
409
+ process.stdout.write(
410
+ `Released ${deckSlug(deckDir)} (was ${released.command}).\n` +
411
+ (closed.length ? `Closed ${closed.length} open edit scope(s): ${closed.join(", ")}\n` : ``) +
412
+ (status.localDirty
413
+ ? `${status.dirtySlides.length} slide(s) still carry unpublished edits — they are untouched.\n`
414
+ : `Nothing unpublished.\n`),
415
+ );
416
+ }
417
+
418
+ // Reporting must survive a record it cannot read: `status` is the command
419
+ // somebody runs BECAUSE the deck is in a strange state.
420
+ function openScopes(deckDir) {
421
+ try {
422
+ return openOperations(deckDir);
423
+ } catch {
424
+ return [];
425
+ }
426
+ }
427
+
428
+ function revisionStatusCmd(args) {
429
+ const { deck, passthrough } = splitDeckArg(args, "revision status");
430
+ const asJson = passthrough.includes("--json");
431
+ const deckDir = resolveDeckArg(deck, { allowUnfinished: true });
432
+ const status = revisionStatus(deckDir);
433
+ if (asJson) return json(status);
434
+ if (!status.adopted) {
435
+ // A deck authored here still has edit scopes, and they are the only thing
436
+ // this command can report about it.
437
+ process.stdout.write(
438
+ `${deckSlug(deckDir)} has no accepted base (authored here, not adopted from a .pptx).\n` +
439
+ openScopes(deckDir)
440
+ .map((op) => ` scope ${op.operationId} ${op.kind} (${op.status}) on ${op.targets.length} slide(s)\n`)
441
+ .join(""),
442
+ );
443
+ return;
444
+ }
445
+ const lines = [
446
+ `${deckSlug(deckDir)} — generation ${status.generation}`,
447
+ status.base
448
+ ? ` base ${status.base.sha256.slice(0, 12)} · ${status.base.slideCount} slide(s) · accepted ${status.base.acceptedAt}`
449
+ : ` base none — authored here; the first confirmed publish (or \`revision accept\`) sets it`,
450
+ ` origin ${status.origin?.kind ?? "unknown"}${status.origin?.file ? ` (${status.origin.file})` : ""}`,
451
+ ` local ${status.localDirty ? `${status.dirtySlides.length} slide(s) changed: ${status.dirtySlides.join(", ")}` : "clean"}`,
452
+ ];
453
+ if (status.added.length) lines.push(` added ${status.added.join(", ")}`);
454
+ if (status.removed.length) lines.push(` removed ${status.removed.join(", ")}`);
455
+ if (status.orderChanged) lines.push(` order changed locally`);
456
+ if (status.designMoved) {
457
+ lines.push(` design moved since the accepted base — untouched slides keep the old design until \`build-pptx --refresh-design\``);
458
+ }
459
+ if (status.unverified.length) {
460
+ lines.push(
461
+ ` unverified ${status.unverified.length} slide(s) carry a chart, whose data this CLI cannot hash — ` +
462
+ `"unchanged" is not proved for them`,
463
+ );
464
+ }
465
+ lines.push(
466
+ ` candidate ${
467
+ status.candidate
468
+ ? `${status.candidate.candidateId}${status.candidate.stale ? ` (stale: ${status.candidate.stale})` : " (ready to accept)"}`
469
+ : "none"
470
+ }`,
471
+ );
472
+ if (status.staged.length) {
473
+ lines.push(` staged ${status.staged.map((s) => s.revisionId).join(", ")}`);
474
+ }
475
+ if (status.conflicts) lines.push(` conflicts ${status.conflicts} unresolved`);
476
+ if (status.owner) {
477
+ lines.push(` owner ${status.owner.command} (pid ${status.owner.pid}, since ${status.owner.startedAt})`);
478
+ }
479
+ if (status.journal) lines.push(` journal an unfinished ${status.journal} — re-run it to recover`);
480
+ for (const op of openScopes(deckDir)) {
481
+ lines.push(` scope ${op.operationId} ${op.kind} (${op.status}) on ${op.targets.length} slide(s)`);
482
+ }
483
+ process.stdout.write(`${lines.join("\n")}\n`);
484
+ }
485
+
486
+ async function revisionAccept(args) {
487
+ const { deck, passthrough } = splitDeckArg(args, "revision accept");
488
+ let candidateId = null;
489
+ let asJson = false;
490
+ for (let i = 0; i < passthrough.length; i++) {
491
+ const a = passthrough[i];
492
+ if (a === "--candidate") candidateId = passthrough[++i] ?? null;
493
+ else if (a === "--json") asJson = true;
494
+ else fail(`unknown flag: ${a}`);
495
+ }
496
+ const deckDir = resolveDeckArg(deck, { allowUnfinished: true });
497
+ requireAdopted(deckDir);
498
+ if (!candidateId) {
499
+ const available = listCandidates(deckDir);
500
+ fail(
501
+ `usage: slidemind revision accept <deck> --candidate <id>\n` +
502
+ (available.length
503
+ ? `candidates:\n${available
504
+ .map((c) => {
505
+ const why = candidateStale(deckDir, c);
506
+ return ` ${c.candidateId} ${c.createdAt} ${why ? `stale — ${why}` : "ready"}`;
507
+ })
508
+ .join("\n")}`
509
+ : `this deck has no candidates yet — run \`slidemind build-pptx ${deck}\` first`),
510
+ );
511
+ }
512
+ const held = acquireOwner(deckDir, { command: "revision accept" });
513
+ try {
514
+ await requireReviewApproved(deckDir);
515
+ // An explicit redesign is authorized by the request — it is never asked
516
+ // about again here. What IS checked is that it replaced what it was pointed
517
+ // at: same identity, required content carried forward, native packages
518
+ // still backed, and no second slide left beside the original (E18). The
519
+ // base may not move onto a candidate that failed that.
520
+ assertRedesignsReplaced(deckDir);
521
+ const next = acceptCandidate(deckDir, candidateId, { owner: held.token });
522
+ const status = revisionStatus(deckDir);
523
+ if (asJson) return json({ accepted: candidateId, ...status });
524
+ process.stdout.write(
525
+ `Accepted ${candidateId} as the base of ${deckSlug(deckDir)} (generation ${next.generation}).\n` +
526
+ (status.localDirty
527
+ ? `${status.dirtySlides.length} slide(s) still carry edits made since that build.\n`
528
+ : `The workspace matches the accepted base.\n`),
529
+ );
530
+ } catch (err) {
531
+ if (["remote_bound", "conflicted", "candidate_stale", "candidate_invalid", "generation_moved", "ownership_lost"].includes(err?.code)) {
532
+ fail(err.message, 2);
533
+ }
534
+ throw err;
535
+ } finally {
536
+ held.release();
537
+ }
538
+ }
539
+
540
+ // ---------------------------------------------------------------------------
541
+ // rebase
542
+ // ---------------------------------------------------------------------------
543
+
544
+ export async function cmdRebase(rest) {
545
+ const { deck, passthrough } = splitDeckArg(rest, "rebase");
546
+ let incoming = null;
547
+ let resolve = null;
548
+ let dryRun = false;
549
+ let asJson = false;
550
+ for (let i = 0; i < passthrough.length; i++) {
551
+ const a = passthrough[i];
552
+ if (a === "--incoming") {
553
+ incoming = passthrough[++i] ?? null;
554
+ if (!incoming) fail("--incoming expects a .pptx file or a staged revision id");
555
+ } else if (a === "--resolve") {
556
+ resolve = passthrough[++i] ?? null;
557
+ if (!resolve) fail("--resolve expects a resolution .json file");
558
+ } else if (a === "--dry-run") {
559
+ dryRun = true;
560
+ } else if (a === "--json") {
561
+ asJson = true;
562
+ } else {
563
+ fail(`unknown flag: ${a}`);
564
+ }
565
+ }
566
+ const deckDir = resolveDeckArg(deck, { allowUnfinished: true });
567
+ requireAdopted(deckDir);
568
+ const { applyRebase, classifyRebase, resolutionTemplate, resumeRebase } = await import(
569
+ "../lib/rebase.mjs"
570
+ );
571
+
572
+ // No flags, but an unfinished rebase on disk: that is the recovery call. Every
573
+ // other command refuses this deck until the half-applied generation resolves,
574
+ // and re-running from the staged bytes is how it resolves.
575
+ const open = journalIncomplete(deckDir);
576
+ if (!incoming && !resolve && open?.op === "rebase") {
577
+ const held = acquireOwner(deckDir, { command: "rebase" });
578
+ try {
579
+ const resumed = resumeRebase(deckDir, open, { owner: held.token });
580
+ if (asJson) return json(resumed);
581
+ process.stdout.write(
582
+ resumed.resumed
583
+ ? `Finished the interrupted rebase of ${deckSlug(deckDir)} (generation ${resumed.generation}).\n` +
584
+ (resumed.stillDirty.length
585
+ ? `Still unpublished: ${resumed.stillDirty.join(", ")}\n`
586
+ : `The workspace matches the accepted base.\n`)
587
+ : `Nothing to finish: ${resumed.reason}\n`,
588
+ );
589
+ return;
590
+ } finally {
591
+ held.release();
592
+ }
593
+ }
594
+ if (!incoming && !resolve) {
595
+ fail(
596
+ `usage: slidemind rebase <deck> --incoming <file.pptx | revisionId> [--dry-run]\n` +
597
+ ` slidemind rebase <deck> --resolve <resolution.json>\n` +
598
+ ` slidemind rebase <deck> (finish an interrupted rebase)`,
599
+ );
600
+ }
601
+
602
+ // Resolving continues the rebase the conflict manifest belongs to.
603
+ let revisionId = null;
604
+ let resolutions = null;
605
+ if (resolve) {
606
+ if (!fs.existsSync(resolve)) fail(`no such file: ${resolve}`);
607
+ try {
608
+ resolutions = JSON.parse(fs.readFileSync(resolve, "utf8"));
609
+ } catch (err) {
610
+ fail(`${resolve} is not readable JSON: ${err.message}`);
611
+ }
612
+ revisionId = resolutions.revisionId ?? readConflicts(deckDir)?.revisionId ?? null;
613
+ if (!revisionId) fail(`${resolve} names no revisionId, and this deck has no open conflict manifest`);
614
+ } else if (/\.(pptx|pptm|potx|potm|ppsx|ppsm)$/i.test(incoming)) {
615
+ const file = path.resolve(incoming);
616
+ if (!fs.existsSync(file)) fail(`no such file: ${incoming}`);
617
+ revisionId = stageIncoming(deckDir, {
618
+ bytes: fs.readFileSync(file),
619
+ source: { kind: "file", path: file, name: path.basename(file) },
620
+ }).revisionId;
621
+ } else {
622
+ revisionId = incoming;
623
+ }
624
+
625
+ if (dryRun) {
626
+ const classified = classifyRebase(deckDir, revisionId);
627
+ if (asJson) return json(classified);
628
+ process.stdout.write(
629
+ `${deckSlug(deckDir)} vs ${revisionId}:\n` +
630
+ ` incoming-only ${classified.remoteOnly.length}\n` +
631
+ ` local-only ${classified.localOnly.length}\n` +
632
+ ` unchanged ${classified.unchanged.length}\n` +
633
+ ` conflicts ${classified.conflicts.length}\n` +
634
+ classified.notes.map((n) => ` note: ${n}\n`).join("") +
635
+ classified.conflicts.map((c) => ` [${c.id}] ${c.kind}${c.file ? ` ${c.file}` : ""} — ${c.detail}\n`).join(""),
636
+ );
637
+ if (classified.conflicts.length > 0) {
638
+ const out = path.join(deckDir, "state", "staged", revisionId, "resolution.json");
639
+ fs.mkdirSync(path.dirname(out), { recursive: true });
640
+ fs.writeFileSync(out, `${JSON.stringify(resolutionTemplate(classified), null, 2)}\n`);
641
+ process.stdout.write(
642
+ ` Fill in one choice per conflict and apply it:\n slidemind rebase ${deck} --resolve ${out}\n`,
643
+ );
644
+ }
645
+ return;
646
+ }
647
+
648
+ const held = acquireOwner(deckDir, { command: "rebase" });
649
+ try {
650
+ const result = applyRebase(deckDir, revisionId, { owner: held.token, resolutions });
651
+ if (asJson) return json(result);
652
+ process.stdout.write(
653
+ `Rebased ${deckSlug(deckDir)} onto ${revisionId} (generation ${result.generation}).\n` +
654
+ ` refreshed from the incoming file: ${result.refreshed.length}\n` +
655
+ ` removed (deleted there): ${result.removed.length}\n` +
656
+ ` kept as unpublished local work: ${result.localOnly.length}\n` +
657
+ result.notes.map((n) => ` note: ${n}\n`).join("") +
658
+ (result.stillDirty.length
659
+ ? `Still unpublished: ${result.stillDirty.join(", ")}\n`
660
+ : `The workspace matches the new accepted base.\n`),
661
+ );
662
+ } catch (err) {
663
+ if (err?.code === "rebase_conflicts") {
664
+ const classified = classifyRebase(deckDir, revisionId);
665
+ const out = path.join(deckDir, "state", "staged", revisionId, "resolution.json");
666
+ fs.mkdirSync(path.dirname(out), { recursive: true });
667
+ fs.writeFileSync(out, `${JSON.stringify(resolutionTemplate(classified), null, 2)}\n`);
668
+ process.stderr.write(
669
+ `slidemind: ${err.message}\n` +
670
+ err.conflicts.map((c) => ` [${c.id}] ${c.kind}${c.file ? ` ${c.file}` : ""} — ${c.detail}\n`).join("") +
671
+ `A resolution skeleton with every hash already filled in is at:\n ${out}\n` +
672
+ `Set each "choice" to local, incoming or merged, then:\n` +
673
+ ` slidemind rebase ${deck} --resolve ${out}\n`,
674
+ );
675
+ process.exit(2);
676
+ }
677
+ if (["resolution_stale", "no_such_revision", "no_base", "generation_moved", "ownership_lost"].includes(err?.code)) {
678
+ fail(err.message, 2);
679
+ }
680
+ throw err;
681
+ } finally {
682
+ held.release();
683
+ }
684
+ }
685
+
686
+ export { readCandidate, revisionStatus };