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,260 @@
1
+ // `slidemind workspace …` — the persistent local workspace (browser UI, deck
2
+ // library, discovery). Adoption/push/new live in workspace.mjs; this module is
3
+ // the UI/process surface only.
4
+ //
5
+ // Exit codes (contracts §1): 0 done, 1 operational error, 2 blocked / busy /
6
+ // incompatible, 3 conflict. Under --json exactly one JSON value goes to stdout;
7
+ // everything else to stderr. The code is set on process.exitCode so bin's
8
+ // post-command hooks still run.
9
+
10
+ import { addDeck, listDecks } from "../workspace/registry.mjs";
11
+ import { deckRoot } from "../lib/deck-paths.mjs";
12
+ import { resolveDeckArg } from "./common.mjs";
13
+ import { startDiscovery } from "../workspace/discovery.mjs";
14
+ import { daemonRequest, ensureRunning, readDescriptor, registerDeck, stopWorkspace, workspaceStatus } from "../workspace/lifecycle.mjs";
15
+ import { resolveRemoteLink, selectChoice } from "../workspace/remote-links.mjs";
16
+ import { connectDestination, remoteStatus, uploadToDestination } from "../workspace/remote-status.mjs";
17
+
18
+ const EXIT_BY_CODE = {
19
+ deck_busy: 2,
20
+ registry_busy: 2,
21
+ workspace_upgrade_required: 2,
22
+ workspace_start_failed: 1,
23
+ // the workspace cannot run here (an agent sandbox) or is wedged: blocked, not broken
24
+ workspace_sandboxed: 2,
25
+ workspace_unresponsive: 2,
26
+ cli_update_required: 2,
27
+ path_registered: 3,
28
+ // remote association: blocked (2) vs a conflict with existing work (3)
29
+ backend_ambiguous: 2,
30
+ select_deck: 2,
31
+ login_required: 2,
32
+ access_denied: 2,
33
+ write_disabled: 2,
34
+ selection_expired: 2,
35
+ unsupported_link: 2,
36
+ offline: 2,
37
+ dirty_target: 3,
38
+ already_bound: 3,
39
+ name_exists: 3,
40
+ cross_tenant: 3,
41
+ };
42
+ const VALUE_FLAGS = ["--deck", "--backend", "--folder", "--selection", "--choice", "--name"];
43
+
44
+ function parse(rest) {
45
+ const flags = { json: false, open: true, foreground: false };
46
+ const positional = [];
47
+ for (let i = 0; i < rest.length; i++) {
48
+ const a = rest[i];
49
+ if (VALUE_FLAGS.includes(a)) {
50
+ const v = rest[++i];
51
+ if (v === undefined || v.startsWith("--")) throw Object.assign(new Error(`${a} needs a value`), { code: "usage" });
52
+ flags[a.slice(2)] = v;
53
+ } else if (a === "--json") flags.json = true;
54
+ else if (a === "--no-open") flags.open = false;
55
+ else if (a === "--foreground") flags.foreground = true;
56
+ else if (a.startsWith("--")) throw Object.assign(new Error(`unknown flag ${a}`), { code: "usage" });
57
+ else positional.push(a);
58
+ }
59
+ return { flags, positional };
60
+ }
61
+
62
+ function emit(flags, payload, human) {
63
+ const write = flags.write ?? ((s) => process.stdout.write(s));
64
+ write((flags.json ? JSON.stringify(payload) : human) + "\n");
65
+ }
66
+
67
+ // A settled link job on stdout; blocked/failed set the exit code by its error.
68
+ function finishJob(flags, job) {
69
+ const base = { jobId: job.id, state: job.state, result: job.result, error: job.error, selection: job.selection ? { selectionId: job.selection.selectionId, expiresAt: job.selection.expiresAt, choices: job.selection.choices, nextCursor: job.selection.cursor } : null };
70
+ let human;
71
+ if (job.state === "completed" && job.result?.type === "folder") {
72
+ human = `select one (selection ${job.result.selectionId}):\n` + job.result.choices.map((c) => ` ${c.id}\t${c.kind}\t${c.name}`).join("\n") + `\n slidemind workspace select ${job.id} --selection ${job.result.selectionId} --choice <id>`;
73
+ } else if (job.state === "completed") {
74
+ human = `${job.result.type} ${job.result.name ?? ""}: ${job.result.existing ? "already registered as" : "opened as"} deck ${job.result.deckId}`;
75
+ } else {
76
+ human = `${job.state}: ${job.error?.code} — ${job.error?.message}` + (job.error?.choices ? `\n choices: ${JSON.stringify(job.error.choices)}` : "");
77
+ }
78
+ emit(flags, base, human);
79
+ if (job.state !== "completed") process.exitCode = EXIT_BY_CODE[job.error?.code] ?? 1;
80
+ }
81
+
82
+ // A deck argument the way every deck command takes it (a slug under ./decks, a
83
+ // relative or absolute path); its ROOT is what the library registers.
84
+ function deckRootArg(arg) {
85
+ const ws = resolveDeckArg(arg, { allowUnfinished: true });
86
+ return deckRoot(ws);
87
+ }
88
+
89
+ /** Ask the open workspace page to show this deck (SSE `focus`); false when it could not be told. */
90
+ async function focusDeck(deckId) {
91
+ try {
92
+ const { status } = await daemonRequest(`/decks/${deckId}/attention`, { method: "POST", body: { focus: true }, timeoutMs: 3000 });
93
+ return status === 200;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ export function deckUrl(base, deckId) {
100
+ return `${base}/deck/${deckId}`;
101
+ }
102
+
103
+ /**
104
+ * A signed-in browser link into the live workspace, or null when none is live:
105
+ * `<url>[/deck/<id>[/plan|/preview|/tasks/<id>]]#bootstrap=<key>`. The key is
106
+ * the workspace's link key (auth.mjs): the link brings the user there every
107
+ * time it is clicked, in any browser, and never expires. Asking for it is no
108
+ * new trust boundary: whoever runs this already holds the CLI bearer from the
109
+ * user-private config dir. A question's link names its VIEW, never the task, so
110
+ * it stays right when the agent asks again.
111
+ */
112
+ export async function browserUrl({ deckId = null, taskId = null, view = null } = {}) {
113
+ const d = readDescriptor();
114
+ if (!d || d.malformed) return null;
115
+ let answer;
116
+ try {
117
+ answer = await daemonRequest("/bootstrap", { method: "POST", timeoutMs: 3000 });
118
+ } catch {
119
+ return null;
120
+ }
121
+ const body = answer.status === 200 ? answer.body : null;
122
+ if (!body?.token) throw Object.assign(new Error(`workspace refused a sign-in link (HTTP ${answer.status})`), { code: "workspace_start_failed" });
123
+ const p = deckId ? `/deck/${deckId}${view ? `/${view}` : taskId ? `/tasks/${taskId}` : ""}` : "/";
124
+ return `${d.url}${p}#bootstrap=${body.token}`;
125
+ }
126
+
127
+ async function defaultOpen(url) {
128
+ const { openInBrowser } = await import("../builders/build.mjs");
129
+ return openInBrowser(url, { quiet: true });
130
+ }
131
+
132
+ export async function cmdWorkspace(rest, { open = defaultOpen, write = null } = {}) {
133
+ let flags;
134
+ try {
135
+ const parsed = parse(rest);
136
+ flags = parsed.flags;
137
+ if (write) flags.write = write; // test seam: capture stdout without patching the stream
138
+ const [sub, ...args] = parsed.positional;
139
+ switch (sub) {
140
+ case "status": {
141
+ // A headless run (gates auto-approve: no human is there to review)
142
+ // has no workspace to wait for — the agent goes on without one.
143
+ const headless = process.env.SLIDEMIND_GATE_AUTOAPPROVE === "1";
144
+ const s = { ...(await workspaceStatus()), ...(headless ? { headless: true } : {}) };
145
+ emit(flags, s, s.running ? `workspace running at ${s.url} (pid ${s.pid}, ${s.pendingJobs} pending job(s))` : headless ? "workspace is not running — headless run (gates auto-approve): continue without it" : "workspace is not running");
146
+ return;
147
+ }
148
+ case "start": {
149
+ // `slidemind workspace start` — what the login item runs (autostart.mjs): start
150
+ // the workspace unless it runs, silently, and never open a browser
151
+ const run = await ensureRunning({ note: () => {} });
152
+ emit(flags, { url: run.url, started: run.started, pid: run.pid, protocolVersion: run.protocolVersion }, `${run.started ? "started" : "running"} — workspace at ${run.url}`);
153
+ return;
154
+ }
155
+ case "stop": {
156
+ const r = await stopWorkspace();
157
+ emit(flags, r, r.stopped ? `workspace stopped (${r.pendingJobs} pending job(s) kept on disk)` : "workspace was not running");
158
+ return;
159
+ }
160
+ case "add": {
161
+ if (!args[0]) throw Object.assign(new Error("usage: slidemind workspace add <path>"), { code: "usage" });
162
+ const entry = await registerDeck(args[0]);
163
+ const s = await workspaceStatus();
164
+ const url = s.running ? deckUrl(s.url, entry.id) : null;
165
+ emit(flags, { deckId: entry.id, url, path: entry.path, created: entry.created }, `${entry.created ? "registered" : "already registered"} ${entry.path}${url ? `\n${url}` : ""}`);
166
+ return;
167
+ }
168
+ case "discover": {
169
+ if (!args[0]) throw Object.assign(new Error("usage: slidemind workspace discover <root>"), { code: "usage" });
170
+ // ponytail: scans in this process until done; the API lane can hand this to the daemon's job route.
171
+ const { jobId, done } = startDiscovery(args[0]);
172
+ const job = await done;
173
+ emit(flags, { jobId, state: job.state, found: job.found.length, warnings: job.warnings }, `${job.state}: ${job.found.length} deck(s) under ${job.root}${job.warnings.length ? ` (${job.warnings.length} warning(s))` : ""}`);
174
+ if (job.state === "failed") process.exitCode = 1;
175
+ return;
176
+ }
177
+ case "open-remote": {
178
+ if (!args[0]) throw Object.assign(new Error("usage: slidemind workspace open-remote <url> [--deck <path>] [--backend <env>]"), { code: "usage" });
179
+ const deckId = flags.deck ? addDeck(flags.deck).id : null;
180
+ finishJob(flags, await resolveRemoteLink({ url: args[0], backendURL: flags.backend ?? null, deckId }));
181
+ return;
182
+ }
183
+ case "select": {
184
+ if (!args[0] || !flags.selection || !flags.choice) throw Object.assign(new Error("usage: slidemind workspace select <jobId> --selection <id> --choice <id> [--deck <path>]"), { code: "usage" });
185
+ const localTarget = flags.deck ? addDeck(flags.deck).id : null;
186
+ finishJob(flags, await selectChoice(args[0], { selectionId: flags.selection, choiceId: flags.choice, localTarget }));
187
+ return;
188
+ }
189
+ case "connect": {
190
+ if (!args[0] || !flags.folder) throw Object.assign(new Error("usage: slidemind workspace connect <deck> --folder <url> [--name <file.pptx>] [--backend <env>]"), { code: "usage" });
191
+ const entry = addDeck(args[0]);
192
+ const r = await connectDestination({ deckRoot: entry.path, folderRef: flags.folder, pptxName: flags.name ?? null, backendURL: flags.backend ?? null });
193
+ // a deck built before its folder was connected: that build goes up now
194
+ const upload = r.shouldPublish ? await uploadToDestination(entry.path) : null;
195
+ const remote = await remoteStatus(entry.path);
196
+ const uploaded = upload ? (upload.state === "done" ? `\n uploaded ${r.destination.pptxName} there` : `\n ${r.destination.pptxName} was not uploaded (${upload.reason}) — \`slidemind publish ${entry.path}\` retries`) : "";
197
+ emit(flags, { deckId: entry.id, ...r, upload, remote }, `${entry.name}: destination ${r.destination.folderName ?? r.destination.webUrl} (${r.destination.state}, plan writes ${r.writes}, presentation writes ${r.publishWrites})${r.shouldCreatePlan ? "\n the plan will be shared there on the next sync" : ""}${uploaded}${r.publishWrites === "disabled" ? "\n this deployment does not save presentations to SharePoint" : "\n every build-pptx publishes the presentation there"}`);
198
+ return;
199
+ }
200
+ case "list": {
201
+ const { decks, total } = listDecks();
202
+ emit(flags, { total, decks }, decks.map((d) => `${d.missing ? "! " : " "}${d.name}\t${d.path}`).join("\n") || "no registered decks");
203
+ return;
204
+ }
205
+ case undefined:
206
+ default: {
207
+ // `slidemind workspace [<deck>]` — an agent runs this when the user asks
208
+ // to see the workspace; it starts or reuses the daemon and never strands
209
+ // the user in a terminal.
210
+ const deckArg = sub;
211
+ const run = await ensureRunning({ foreground: flags.foreground });
212
+ let deckId = null;
213
+ if (deckArg) deckId = (await registerDeck(deckRootArg(deckArg))).id;
214
+ const plain = deckId ? deckUrl(run.url, deckId) : run.url;
215
+ // A page already connected is the user's workspace: no second tab per call.
216
+ let pages = 0;
217
+ try {
218
+ pages = Number((await workspaceStatus()).pages) || 0;
219
+ } catch {
220
+ pages = 0;
221
+ }
222
+ // One link for both: the tab we open and the line we print for the user to
223
+ // click, in this browser or any other, as often as they like.
224
+ // `url` stays the plain base other commands build on; `link` is the one to click.
225
+ const link = (await browserUrl({ deckId })) ?? plain;
226
+ let opened = false;
227
+ let focused = false;
228
+ if (flags.open && (run.started || pages === 0)) opened = (await open(link)) !== false;
229
+ else if (flags.open && deckId && pages > 0) focused = await focusDeck(deckId);
230
+ const where = opened
231
+ ? "opened in a new browser tab"
232
+ : focused
233
+ ? "already open in the user's browser — it now shows this deck (no new tab)"
234
+ : pages > 0
235
+ ? "already open in the user's browser (no new tab)"
236
+ : flags.open
237
+ ? "could NOT open a browser tab — give the user the link"
238
+ : "not opened (--no-open)";
239
+ emit(
240
+ flags,
241
+ { url: plain, link, deckId, started: run.started, opened, focused, pages, protocolVersion: run.protocolVersion },
242
+ `${run.started ? "started" : "reusing"} workspace — ${where}\nWorkspace: ${link}`,
243
+ );
244
+ if (run.daemon) {
245
+ process.stderr.write("slidemind: workspace running in the foreground; Ctrl-C drains and stops it\n");
246
+ await run.daemon.stopped;
247
+ }
248
+ }
249
+ }
250
+ } catch (err) {
251
+ const code = err?.code ?? "error";
252
+ const exit = code === "usage" ? 1 : EXIT_BY_CODE[code] ?? 1;
253
+ if (flags?.json) {
254
+ process.stdout.write(JSON.stringify({ ok: false, error: { code, message: err.message, retryable: exit === 2 } }) + "\n");
255
+ } else {
256
+ process.stderr.write(`slidemind: ${err.message}\n`);
257
+ }
258
+ process.exitCode = exit;
259
+ }
260
+ }
@@ -1,7 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
- import { fail, requireReviewApproved, LOCAL_PPTX_RE, resolveDecksDir, resolveDeckArg, splitDeckArg, renderProgressPrinter, freshenDeckBundle, loadBridgeModule, SRC_DIR } from "./common.mjs";
4
+ import { requireAuthorizedEdits } from "./revision.mjs";
5
+ import { fail, requireReviewApproved, requireSlidesDraftChecked, requireSlidesPreserved, LOCAL_PPTX_RE, resolveDecksDir, resolveDeckArg, registerDeckHook, splitDeckArg, renderProgressPrinter, freshenDeckBundle, loadBridgeModule, SRC_DIR } from "./common.mjs";
6
+ import { WORKSPACE_DIRNAME } from "../lib/deck-paths.mjs";
5
7
  import { readDesignName } from "./designs.mjs";
6
8
 
7
9
  // `slidemind adopt <file.pptx> [--deck <slug>] [--brand <presFP>] [--no-convert]`
@@ -11,11 +13,34 @@ import { readDesignName } from "./designs.mjs";
11
13
  // conversion route where it is available (`docs/slidemind-cli-connect/
12
14
  // 14-foreign-slide-conversion.md`); whatever it declines folds in as native
13
15
  // reused .pptx. See also intern-slidemind docs/deck-embedded-source-spec.md.
16
+ // "2,7,10" / "2-4,9" / "all" → [2,3,4,9] (1-based play order), or null for all.
17
+ function parseSlideSelection(raw) {
18
+ if (!raw || raw === "all") return null;
19
+ const out = new Set();
20
+ for (const chunk of String(raw).split(",")) {
21
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(chunk.trim());
22
+ if (range) {
23
+ const [from, to] = [Number(range[1]), Number(range[2])];
24
+ if (from < 1 || to < from) fail(`--slides: '${chunk.trim()}' is not a slide range`);
25
+ for (let n = from; n <= to; n++) out.add(n);
26
+ continue;
27
+ }
28
+ const n = Number(chunk.trim());
29
+ if (!Number.isInteger(n) || n < 1) {
30
+ fail(`--slides expects 1-based slide numbers like '2,7' or '2-4', got '${chunk.trim()}'`);
31
+ }
32
+ out.add(n);
33
+ }
34
+ return [...out].sort((a, b) => a - b);
35
+ }
36
+
14
37
  export async function cmdAdopt(rest) {
15
38
  const args = [...rest];
16
39
  let deckSlug = null;
17
40
  let brand = null;
18
41
  let convert = true;
42
+ let slides = null;
43
+ let inPlace = false;
19
44
  const positional = [];
20
45
  for (let i = 0; i < args.length; i++) {
21
46
  const a = args[i];
@@ -25,8 +50,14 @@ export async function cmdAdopt(rest) {
25
50
  } else if (a === "--brand") {
26
51
  brand = args[++i] ?? null;
27
52
  if (!brand) fail("--brand expects a design id (presFP)");
53
+ } else if (a === "--slides") {
54
+ const next = args[++i] ?? null;
55
+ if (!next) fail("--slides expects slide numbers, e.g. --slides 2,7 or --slides 2-4");
56
+ slides = parseSlideSelection(next);
28
57
  } else if (a === "--no-convert") {
29
58
  convert = false;
59
+ } else if (a === "--in-place") {
60
+ inPlace = true;
30
61
  } else if (a.startsWith("-")) {
31
62
  fail(`unknown flag: ${a}`);
32
63
  } else {
@@ -35,20 +66,30 @@ export async function cmdAdopt(rest) {
35
66
  }
36
67
  const pptxArg = positional[0];
37
68
  if (!pptxArg) {
38
- fail("usage: slidemind adopt <file.pptx> [--deck <slug>] [--brand <presFP>] [--no-convert]");
69
+ fail(
70
+ "usage: slidemind adopt <file.pptx> [--deck <slug>] [--brand <presFP>] [--slides <sel>] [--no-convert]\n" +
71
+ " slidemind adopt <deck>/.slidemind/slides/<NN-name>.pptx --in-place [--brand <presFP>]",
72
+ );
39
73
  }
40
74
  // v1 is FILE decks only. A presId (or any non-file argument) would mean
41
75
  // converting a LIVE presentation, which needs slide-export and tag-read
42
76
  // events the relay does not have — say so instead of "no such file".
43
- const {
44
- LIVE_DECK_UNSUPPORTED,
45
- adoptDeckFromPptx,
46
- convertUntaggedSlides,
47
- readSourceDeck,
48
- } = await import(pathToFileURL(path.join(SRC_DIR, "lib", "adopt.mjs")).href);
77
+ const { LIVE_DECK_UNSUPPORTED, adoptWorkspace } = await import(
78
+ pathToFileURL(path.join(SRC_DIR, "lib", "adopt.mjs")).href
79
+ );
49
80
  if (!LOCAL_PPTX_RE.test(pptxArg)) fail(LIVE_DECK_UNSUPPORTED);
50
81
  const pptxPath = path.resolve(pptxArg);
51
82
  if (!fs.existsSync(pptxPath)) fail(`no such file: ${pptxArg}`);
83
+ // A folded slide of an existing deck (`<deck>/.slidemind/slides/NN-*.pptx`, from
84
+ // add-slide) converts in place only when asked by name: inferring it from
85
+ // the path would turn any `slides/` folder on disk into a deck and replace
86
+ // the user's file.
87
+ if (inPlace) {
88
+ if (deckSlug || slides || !convert) {
89
+ fail("--in-place converts one folded slide on its own: `slidemind adopt <deck>/.slidemind/slides/<NN-name>.pptx --in-place [--brand <presFP>]`");
90
+ }
91
+ return adoptFoldedSlide(pptxPath, brand);
92
+ }
52
93
  const slug = deckSlug ?? path.basename(pptxPath).replace(/\.pptx$/i, "");
53
94
  // The decks dir, not a hardcoded ./decks — otherwise `adopt` writes where no
54
95
  // other subcommand looks whenever SLIDEMIND_DECKS is set. A RELATIVE slug
@@ -56,41 +97,97 @@ export async function cmdAdopt(rest) {
56
97
  // every other untrusted name in this feature is escape-guarded, and this one
57
98
  // rides agent-authored command lines.
58
99
  const decksRoot = path.resolve(resolveDecksDir());
59
- const deckDir = path.isAbsolute(slug) ? slug : path.resolve(decksRoot, slug);
100
+ const rootDir = path.isAbsolute(slug) ? slug : path.resolve(decksRoot, slug);
101
+ // The deck ROOT keeps only the deliverables; the adopted workspace is nested.
102
+ // An existing flat deck (no `.slidemind/`) is adopted into where it already is.
103
+ const deckDir = fs.existsSync(path.join(rootDir, "slides"))
104
+ ? rootDir
105
+ : path.join(rootDir, WORKSPACE_DIRNAME);
60
106
  if (
61
107
  !path.isAbsolute(slug) &&
62
- (deckDir === decksRoot || !deckDir.startsWith(decksRoot + path.sep))
108
+ (rootDir === decksRoot || !rootDir.startsWith(decksRoot + path.sep))
63
109
  ) {
64
110
  fail(
65
111
  `--deck '${slug}' escapes the decks directory (${decksRoot}) — ` +
66
112
  "pass an absolute path to adopt somewhere else on purpose",
67
113
  );
68
114
  }
69
- // ONE read/unzip/tag-decode of the source file, threaded into both halves —
70
- // they must see the same snapshot (and a 40 MB deck decodes slowly).
71
- const source = readSourceDeck(pptxPath);
72
- const converted = convert
73
- ? await convertUntaggedSlides({
74
- pptxPath,
75
- deckDir,
76
- brandId: brand,
77
- note: (line) => process.stderr.write(`${line}\n`),
78
- source,
79
- })
80
- : null;
81
- const result = adoptDeckFromPptx({ pptxPath, deckDir, converted, source });
115
+ // Adoption is repeatable, so it runs under the deck's own ownership: a second
116
+ // `adopt` while a session is mid-edit must queue behind it, not race it.
117
+ fs.mkdirSync(deckDir, { recursive: true });
118
+ const { acquireOwner } = await import(
119
+ pathToFileURL(path.join(SRC_DIR, "lib", "revision.mjs")).href
120
+ );
121
+ let held;
122
+ try {
123
+ held = acquireOwner(deckDir, { command: "adopt" });
124
+ } catch (err) {
125
+ if (err?.code === "deck_busy") fail(err.message, 2);
126
+ throw err;
127
+ }
128
+
129
+ let outcome;
130
+ try {
131
+ outcome = await adoptWorkspace({
132
+ pptxPath,
133
+ deckDir,
134
+ brandId: brand,
135
+ slides,
136
+ convert,
137
+ owner: held.token,
138
+ note: (line) => process.stderr.write(`${line}\n`),
139
+ });
140
+ } catch (err) {
141
+ if (err?.code === "needs_reconcile") fail(err.message, 2);
142
+ throw err;
143
+ } finally {
144
+ held.release();
145
+ }
146
+ registerDeckHook(deckDir);
147
+
82
148
  // A decks dir outside the cwd (SLIDEMIND_DECKS) relativizes to ../../.. noise
83
149
  // — print the absolute path there instead.
84
- const shown = path.relative(process.cwd(), deckDir);
150
+ const shown = path.relative(process.cwd(), rootDir);
151
+ const where = shown.startsWith("..") ? rootDir : shown;
152
+
153
+ if (outcome.mode === "unchanged") {
154
+ process.stdout.write(
155
+ `${slug} is already adopted from this exact file — nothing was rewritten.\n` +
156
+ `Hydrate more slides to editable jsx with \`slidemind adopt ${pptxArg} --deck ${slug} --slides <n,m>\`.\n`,
157
+ );
158
+ return;
159
+ }
160
+ if (outcome.mode === "hydrated") {
161
+ process.stdout.write(
162
+ `Hydrated ${outcome.hydrated.length} slide(s) of ${slug} to editable jsx: ${outcome.hydrated.join(", ")}\n` +
163
+ `The rest of the deck, and any edits in progress, were left untouched.\n`,
164
+ );
165
+ return;
166
+ }
167
+ if (outcome.mode === "rebased") {
168
+ const r = outcome.rebase;
169
+ process.stdout.write(
170
+ `${slug} was already adopted, so this file was reconciled onto it (generation ${r.generation}):\n` +
171
+ ` refreshed from the newer file: ${r.refreshed.length}\n` +
172
+ ` removed (deleted there): ${r.removed.length}\n` +
173
+ (r.stillDirty.length ? ` still unpublished locally: ${r.stillDirty.join(", ")}\n` : ""),
174
+ );
175
+ return;
176
+ }
177
+
178
+ const result = outcome.result;
85
179
  process.stdout.write(
86
180
  `Adopted ${result.adopted.length} slide(s) with embedded source` +
87
181
  (result.converted.length
88
- ? `, converted ${result.converted.length} foreign slide(s) to editable jsx`
182
+ ? `, converted ${result.converted.length} slide(s) from their CURRENT content to editable jsx`
89
183
  : "") +
90
184
  (result.native.length
91
- ? ` and folded ${result.native.length} untagged slide(s) as native .pptx`
185
+ ? ` and kept ${result.native.length} slide(s) as lossless native .pptx`
186
+ : "") +
187
+ ` into ${where}/ (workspace ${path.basename(deckDir) === WORKSPACE_DIRNAME ? `${WORKSPACE_DIRNAME}/` : "."}).\n` +
188
+ (slides
189
+ ? `Only slide(s) ${slides.join(", ")} were hydrated; the rest keep their notes, comments and native objects verbatim.\n`
92
190
  : "") +
93
- ` into ${shown.startsWith("..") ? deckDir : shown}/.\n` +
94
191
  (result.skipped.length
95
192
  ? `${result.skipped.length} slide(s) stayed native: ` +
96
193
  `${result.skipped
@@ -100,10 +197,53 @@ export async function cmdAdopt(rest) {
100
197
  (result.bundle?.presFP && result.bundle.presFP !== "unknown"
101
198
  ? `Deck bound to bundle ${result.bundle.presFP}.\n`
102
199
  : `No bundle reference found in the file — run 'slidemind download-bundle ${slug}' before rendering.\n`) +
200
+ `The file you adopted is the accepted base; a build proposes a candidate and never replaces it ` +
201
+ `until \`slidemind revision accept ${slug} --candidate <id>\`.\n` +
103
202
  `Edit slides/*.jsx and run 'slidemind push ${slug}' or 'slidemind build-pptx ${slug}'.\n`,
104
203
  );
105
204
  }
106
205
 
206
+ async function adoptFoldedSlide(pptxPath, brand) {
207
+ const deckDir = path.dirname(path.dirname(pptxPath));
208
+ const { convertFoldedSlide, notFoldedReason } = await import(
209
+ pathToFileURL(path.join(SRC_DIR, "lib", "convert-folded.mjs")).href
210
+ );
211
+ const notFolded = notFoldedReason(pptxPath);
212
+ if (notFolded) fail(notFolded, 2);
213
+ const { acquireOwner, journalIncomplete } = await import(
214
+ pathToFileURL(path.join(SRC_DIR, "lib", "revision.mjs")).href
215
+ );
216
+ const unfinished = journalIncomplete(deckDir);
217
+ if (unfinished) fail(`this deck has an unfinished ${unfinished.op}; finish it before converting a slide.`);
218
+ let held;
219
+ try {
220
+ held = acquireOwner(deckDir, { command: "adopt" });
221
+ } catch (err) {
222
+ if (err?.code === "deck_busy") fail(err.message, 2);
223
+ throw err;
224
+ }
225
+ let out;
226
+ try {
227
+ out = await convertFoldedSlide({
228
+ pptxPath,
229
+ deckDir,
230
+ brandId: brand,
231
+ owner: held.token,
232
+ note: (line) => process.stderr.write(`${line}\n`),
233
+ });
234
+ } catch (err) {
235
+ if (["not_folded", "not_one_slide", "local_dirty", "not_converted"].includes(err?.code)) fail(err.message, 2);
236
+ throw err;
237
+ } finally {
238
+ held.release();
239
+ }
240
+ process.stdout.write(
241
+ `Converted ${out.replaced} to editable jsx in place: slides/${out.file} (same play position; the .pptx is gone).\n` +
242
+ (out.route === "source" ? "Its embedded slidemind source was restored verbatim.\n" : "It is converted-dialect jsx: edit it per workflow/edit.md.\n") +
243
+ (out.warnings.length ? `Notes: ${out.warnings.join("; ")}\n` : ""),
244
+ );
245
+ }
246
+
107
247
  export async function cmdPush(rest) {
108
248
  const { deck, passthrough } = splitDeckArg(rest, "push");
109
249
  let to = null;
@@ -149,6 +289,10 @@ export async function cmdPush(rest) {
149
289
  fail(`expected 'slides/' folder in ${deckDir}`);
150
290
  }
151
291
  await requireReviewApproved(deckDir);
292
+ requireAuthorizedEdits(deckDir);
293
+ await requireSlidesPreserved(deckDir);
294
+ // A push renders the whole deck, so every authored slide needs its check.
295
+ await requireSlidesDraftChecked(deckDir);
152
296
  await freshenDeckBundle(deckDir);
153
297
 
154
298
  const mod = await import(
@@ -164,9 +308,18 @@ export async function cmdPush(rest) {
164
308
  force,
165
309
  onProgress: renderProgressPrinter(),
166
310
  });
167
- for (const w of result.warnings ?? []) {
311
+ // Classed by the shared draft-line table, as build-pptx classes them.
312
+ const { splitPackageLines, packageDefectsText } = await import(
313
+ pathToFileURL(path.join(SRC_DIR, "builders", "draft-check.mjs")).href
314
+ );
315
+ const lines = splitPackageLines(result.warnings);
316
+ for (const w of lines.notes) {
168
317
  process.stderr.write(` warning: ${w}\n`);
169
318
  }
319
+ if (lines.defects.length > 0) {
320
+ process.exitCode = 1;
321
+ process.stdout.write(packageDefectsText(lines.defects, deck));
322
+ }
170
323
  for (const s of result.skippedSlides ?? []) {
171
324
  process.stderr.write(` skipped ${s.stem}: ${s.reason}\n`);
172
325
  }
@@ -188,10 +341,16 @@ export async function cmdNew(rest) {
188
341
  let to = null;
189
342
  let brand = null;
190
343
  let noBind = false;
344
+ // Every deck command takes --json; agents reach for it here too (a failed
345
+ // `new --json` cost a round in three sim sessions). The human lines still
346
+ // print; the payload is appended for the parser.
347
+ let asJson = false;
191
348
  const positional = [];
192
349
  for (let i = 0; i < rest.length; i++) {
193
350
  const a = rest[i];
194
- if (a === "--to") {
351
+ if (a === "--json") {
352
+ asJson = true;
353
+ } else if (a === "--to") {
195
354
  to = rest[++i];
196
355
  if (!to) fail("--to expects a presId");
197
356
  } else if (a === "--brand") {
@@ -209,15 +368,18 @@ export async function cmdNew(rest) {
209
368
  fail("--brand and --to are mutually exclusive (a design id or a window, not both)");
210
369
  }
211
370
  const name = positional[0];
212
- if (!name) fail("usage: slidemind new <name> [--brand <presFP> | --to <presId>] [--no-bind]");
371
+ if (!name) fail("usage: slidemind new <name> [--brand <presFP> | --to <presId>] [--no-bind] [--json]");
213
372
  if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
214
373
  fail(`deck name must be kebab-case alphanumerics, got '${name}'`);
215
374
  }
216
375
  const decksDir = resolveDecksDir();
217
- const deckDir = path.join(decksDir, name);
218
- if (fs.existsSync(deckDir)) {
219
- fail(`deck already exists: ${deckDir}`);
376
+ const rootDir = path.join(decksDir, name);
377
+ if (fs.existsSync(rootDir)) {
378
+ fail(`deck already exists: ${rootDir}`);
220
379
  }
380
+ // Two halves from the first command: the root holds what the user will want
381
+ // (the .pptx, plan.html), `.slidemind/` everything the CLI works with.
382
+ const deckDir = path.join(rootDir, WORKSPACE_DIRNAME);
221
383
  fs.mkdirSync(path.join(deckDir, "slides"), { recursive: true });
222
384
  fs.writeFileSync(
223
385
  path.join(deckDir, "plan.md"),
@@ -226,10 +388,26 @@ export async function cmdNew(rest) {
226
388
  `the slidemind-create-slides skill fills this in, then writes slides under ./slides/\n` +
227
389
  `as NN-<topic>.jsx (zero-padded prefix — slide order is filename order).\n`,
228
390
  );
229
- process.stdout.write(`Created ${path.relative(process.cwd(), deckDir)}\n`);
391
+ const { writePlanPage } = await import(
392
+ pathToFileURL(path.join(SRC_DIR, "builders", "plan.mjs")).href
393
+ );
394
+ writePlanPage(deckDir);
395
+ registerDeckHook(deckDir);
396
+ process.stdout.write(`Created ${path.relative(process.cwd(), rootDir)} (workspace ${WORKSPACE_DIRNAME}/)\n`);
230
397
  process.stdout.write(
231
398
  `Next: ask your agent to author "${name}", then \`slidemind view-draft ${name}\`\n`,
232
399
  );
400
+ if (asJson) {
401
+ process.on("exit", () => {
402
+ let bound = null;
403
+ try {
404
+ bound = JSON.parse(fs.readFileSync(path.join(deckDir, "state", "bundle-ref.json"), "utf8"));
405
+ } catch {}
406
+ process.stdout.write(
407
+ `${JSON.stringify({ deck: name, dir: rootDir, workspace: deckDir, bound: bound?.presFP ?? null })}\n`,
408
+ );
409
+ });
410
+ }
233
411
 
234
412
  // Stateless auto-bind: bind the fresh deck to whatever design is OPEN so the
235
413
  // agent skips a follow-up `download-bundle`. Best-effort — a bind miss (no