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,104 @@
1
+ // `slidemind clean <deck>` — the one command that operates on the deck DIRECTORY
2
+ // rather than on its contents. It removes only what a build reproduces: preview/
3
+ // and abandoned downloads. Concepts cost a model call, gate state is a human
4
+ // decision, and a journal is the only record of an operation in flight, so all
5
+ // three stay.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ import { deckSlug, previewPath } from "../lib/deck-paths.mjs";
12
+ import { fail, resolveDeckRoot, resolveWorkspace, splitDeckArg, SRC_DIR } from "./common.mjs";
13
+
14
+ function rel(p) {
15
+ const r = path.relative(process.cwd(), p);
16
+ return r.startsWith("..") ? p : r;
17
+ }
18
+
19
+ function posix(p) {
20
+ return p.split(path.sep).join("/");
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // clean
25
+ // ---------------------------------------------------------------------------
26
+
27
+ // A `.part` still being written is an engine download in flight, not an
28
+ // abandoned one. ponytail: mtime heuristic — exact would need a lock file, and
29
+ // nothing writes one today.
30
+ const ACTIVE_PART_MS = 2 * 60 * 1000;
31
+
32
+ function partDownloads(dir) {
33
+ const out = [];
34
+ const walk = (d) => {
35
+ let entries;
36
+ try {
37
+ entries = fs.readdirSync(d, { withFileTypes: true });
38
+ } catch {
39
+ return;
40
+ }
41
+ for (const e of entries) {
42
+ const p = path.join(d, e.name);
43
+ if (e.isDirectory()) walk(p);
44
+ else if (e.name.endsWith(".part")) out.push(p);
45
+ }
46
+ };
47
+ walk(dir);
48
+ return out;
49
+ }
50
+
51
+ export async function cmdClean(rest) {
52
+ const { deck } = splitDeckArg(rest, "clean");
53
+ const root = resolveDeckRoot(deck);
54
+ if (!root || !fs.existsSync(root)) fail(`deck '${deck}' not found`);
55
+ const workspace = resolveWorkspace(root);
56
+
57
+ const removed = [];
58
+ const kept = [];
59
+
60
+ for (const dir of [
61
+ previewPath(workspace),
62
+ path.join(workspace, ".draft-preview"),
63
+ path.join(workspace, ".build-preview"),
64
+ ]) {
65
+ if (!fs.existsSync(dir)) continue;
66
+ fs.rmSync(dir, { recursive: true, force: true });
67
+ removed.push(dir);
68
+ }
69
+
70
+ // The revision protocol's state is NEVER cleanable. A candidate is the only
71
+ // copy of a build nobody has accepted yet, `base.pptx` is the only record of
72
+ // what the deck was adopted from, and a journal is the only way an interrupted
73
+ // operation resolves — none of the three is something a build reproduces.
74
+ const { PROTECTED_STATE } = await import(
75
+ pathToFileURL(path.join(SRC_DIR, "lib", "revision.mjs")).href
76
+ );
77
+ for (const name of PROTECTED_STATE) {
78
+ if (fs.existsSync(path.join(workspace, "state", name))) kept.push(`state/${name}`);
79
+ }
80
+
81
+ const now = Date.now();
82
+ for (const part of partDownloads(path.join(workspace, "assets"))) {
83
+ if (now - fs.statSync(part).mtimeMs < ACTIVE_PART_MS) {
84
+ kept.push(`${posix(path.relative(workspace, part))} (still downloading)`);
85
+ continue;
86
+ }
87
+ fs.rmSync(part, { force: true });
88
+ removed.push(part);
89
+ }
90
+
91
+ const keptLines = kept.map((k) => ` kept ${k}\n`).join("");
92
+ if (removed.length === 0) {
93
+ process.stdout.write(`Nothing to clean in ${rel(workspace)}\n${keptLines}`);
94
+ return;
95
+ }
96
+ process.stdout.write(
97
+ `Cleaned ${rel(workspace)}:\n` +
98
+ removed.map((p) => ` removed ${rel(p)}\n`).join("") +
99
+ keptLines +
100
+ `Slides, assets, conversions, state (the accepted base, candidates and journals ` +
101
+ `included) and concepts are untouched; ` +
102
+ `\`slidemind view-draft ${deckSlug(workspace)}\` regenerates the previews.\n`,
103
+ );
104
+ }
package/src/cli/read.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { fail, LOCAL_PPTX_RE, SRC_DIR } from "./common.mjs";
5
+ import { resolveWorkspace, writeStateFile } from "../lib/deck-paths.mjs";
5
6
 
6
7
  // Print a read result — the `{ ok, mode, slideCount, slides[] }` shape, whether
7
8
  // it came from the add-in (open presentation) or a local .pptx. Shared by both
@@ -134,5 +135,5 @@ export function saveLayoutContext(deck, result, target) {
134
135
  if (slide.layout && !slide.hidden) counts.set(slide.layout, (counts.get(slide.layout) || 0) + 1);
135
136
  return counts;
136
137
  }, new Map())) };
137
- fs.writeFileSync(path.join(deckDir, ".layout-context.json"), JSON.stringify(context, null, 2) + "\n");
138
+ writeStateFile(resolveWorkspace(deckDir), "layout-context.json", JSON.stringify(context, null, 2) + "\n");
138
139
  }
package/src/cli/ready.mjs CHANGED
@@ -7,6 +7,7 @@ import { publicConnectionStatus, backendBlocker, connectionBlocker } from "./con
7
7
  import { readDesignName, cachedDesigns, designRows, designsHumanLines, windowsHumanLine } from "./designs.mjs";
8
8
  import { runDiagnose } from "./diagnose.mjs";
9
9
  import { deckStage, stageHumanLines } from "./status.mjs";
10
+ import { deckSlug as slugOf } from "../lib/deck-paths.mjs";
10
11
 
11
12
  // The one-command agent preflight. Collapses the old 4-call session start
12
13
  // (list windows → pick → download-bundle → download-bundle-to-bind) into a
@@ -78,7 +79,7 @@ export async function cmdReady(rest) {
78
79
  `deck '${deckArg}' not found — run \`slidemind ready\` (no deck) to set up, then \`slidemind new ${deckArg}\``,
79
80
  );
80
81
  }
81
- deckSlug = path.basename(deckDir);
82
+ deckSlug = slugOf(deckDir);
82
83
  deckPresFP = bundle.readDeckBundleRef(deckDir)?.presFP ?? null;
83
84
  }
84
85
 
@@ -161,7 +162,7 @@ export async function cmdReady(rest) {
161
162
  backends: statusRaw.backends,
162
163
  });
163
164
  } catch {
164
- engineInfo = { available: false, envs: [] };
165
+ engineInfo = { available: false, envs: [], unknown: [] };
165
166
  }
166
167
  }
167
168
  return engineInfo;
@@ -329,7 +330,9 @@ export async function cmdReady(rest) {
329
330
  const nextAction = noWindow
330
331
  ? eng.available
331
332
  ? "Ready (no PowerPoint window — authoring, engine search and build-pptx all work; only push/insert-slide and reading an open presentation need one)."
332
- : "Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane)."
333
+ : eng.unknown?.length
334
+ ? `Ready (no PowerPoint window — authoring and build-pptx work; whether engine search runs off the connection could not be checked [${eng.unknown.join(", ")}]: the capability probe got no answer, and a search retries it).`
335
+ : "Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane)."
333
336
  : paneReady
334
337
  ? "Ready (design resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide)."
335
338
  : eng.available
@@ -0,0 +1,522 @@
1
+ // `slidemind record create | open | sync | status` — one JSON file shared
2
+ // through the backend's record service.
3
+ //
4
+ // Deliberately content-blind: the CLI sends a JSON file, stores what came
5
+ // back, and reports what the service says changed under it. It never validates
6
+ // the document, never merges it, and never learns what its fields mean.
7
+ //
8
+ // This is the thin entry point: the network lives in src/lib/record-client.mjs,
9
+ // the binding in src/lib/record-state.mjs, and a response is written back
10
+ // ONLY by the explicit apply step here — never inside the request.
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { isDeepStrictEqual } from "node:util";
15
+
16
+ import { deckSlug, fail, resolveDeckArg, resolveDeckRoot } from "./common.mjs";
17
+ import { WORKSPACE_DIRNAME } from "../lib/deck-paths.mjs";
18
+ import {
19
+ REFUSALS,
20
+ createRecord,
21
+ getRecord,
22
+ putRecord,
23
+ recordSelector,
24
+ refusalCode,
25
+ resolveRecordBackend,
26
+ } from "../lib/record-client.mjs";
27
+ import {
28
+ applyRecordResponse,
29
+ conflictsPath,
30
+ pageLink,
31
+ planToSend,
32
+ readConflicts,
33
+ readRecordState,
34
+ recordStateFrom,
35
+ resolveStoredFile,
36
+ writeConflicts,
37
+ writeRecordFile,
38
+ writeRecordState,
39
+ } from "../lib/record-state.mjs";
40
+
41
+ const USAGE =
42
+ "usage: slidemind record create <deck> --folder <link> --name <file.json> --file <path> [--kind <kind>]\n" +
43
+ " slidemind record open <link> --deck <slug> --file <path>\n" +
44
+ " slidemind record sync <deck> --file <path> [--approval] [--resolve <file>]\n" +
45
+ " slidemind record status <deck>\n" +
46
+ " (every subcommand takes --json)";
47
+
48
+ const emit = (data) => process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
49
+
50
+ function readJsonFile(file, what) {
51
+ let raw;
52
+ try {
53
+ raw = fs.readFileSync(file, "utf8");
54
+ } catch {
55
+ fail(`${what} not found: ${file}`);
56
+ }
57
+ try {
58
+ return JSON.parse(raw);
59
+ } catch (err) {
60
+ fail(`${what} is not valid JSON (${file}): ${err.message}`);
61
+ }
62
+ }
63
+
64
+ function readJsonOrNull(file) {
65
+ try {
66
+ return JSON.parse(fs.readFileSync(file, "utf8"));
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ async function backendFor(binding) {
73
+ try {
74
+ return await resolveRecordBackend(binding);
75
+ } catch (err) {
76
+ fail(err.message);
77
+ }
78
+ }
79
+
80
+ function failCall(err, asJson) {
81
+ const code = refusalCode(err);
82
+ if (!code) throw err;
83
+ if (asJson) {
84
+ emit({ ok: false, error: code, message: REFUSALS[code] });
85
+ process.exit(1);
86
+ }
87
+ fail(`${code} — ${REFUSALS[code]}`);
88
+ }
89
+
90
+ function requireState(deckDir, slug) {
91
+ const state = readRecordState(deckDir);
92
+ if (!state?.url) {
93
+ fail(
94
+ `${slug} has no shared record. Create one with \`slidemind record create ${slug} ` +
95
+ "--folder <link> --name <file.json> --file <path>`, or open an existing one with " +
96
+ "`slidemind record open <link> --deck " +
97
+ `${slug} --file <path>\`.`,
98
+ );
99
+ }
100
+ return state;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // arguments
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function parseArgs(rest, sub) {
108
+ const args = {
109
+ target: null,
110
+ folder: null,
111
+ name: null,
112
+ file: null,
113
+ deck: null,
114
+ resolve: null,
115
+ approval: false,
116
+ json: false,
117
+ };
118
+ for (let i = 0; i < rest.length; i++) {
119
+ const a = rest[i];
120
+ if (a === "--folder") args.folder = rest[++i] ?? null;
121
+ else if (a === "--name") args.name = rest[++i] ?? null;
122
+ else if (a === "--file") args.file = rest[++i] ?? null;
123
+ else if (a === "--deck") args.deck = rest[++i] ?? null;
124
+ else if (a === "--resolve") args.resolve = rest[++i] ?? null;
125
+ else if (a === "--kind") args.kind = rest[++i] ?? null;
126
+ else if (a === "--approval") args.approval = true;
127
+ else if (a === "--json") args.json = true;
128
+ else if (a.startsWith("-")) fail(`unknown flag: ${a}\n${USAGE}`);
129
+ else if (!args.target) args.target = a;
130
+ else fail(`unexpected argument: ${a}\n${USAGE}`);
131
+ }
132
+ if (!args.target) fail(`slidemind record ${sub} needs a ${sub === "open" ? "link" : "deck"}.\n${USAGE}`);
133
+ return args;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // create
138
+ // ---------------------------------------------------------------------------
139
+
140
+ async function cmdCreate(rest) {
141
+ const args = parseArgs(rest, "create");
142
+ if (!args.folder || !args.name || !args.file) {
143
+ fail(`record create needs --folder, --name and --file.\n${USAGE}`);
144
+ }
145
+ const deckDir = resolveDeckArg(args.target);
146
+ const file = path.resolve(args.file);
147
+ const plan = readJsonFile(file, "the record");
148
+ const backendURL = await backendFor(readRecordState(deckDir));
149
+
150
+ let data;
151
+ try {
152
+ data = await createRecord(backendURL, { kind: args.kind ?? null, folderUrl: args.folder, name: args.name, plan });
153
+ } catch (err) {
154
+ failCall(err, args.json);
155
+ }
156
+ const link = pageLink(data);
157
+ // The local file is NOT rewritten: create shares what the caller wrote, and
158
+ // rewriting it here would make "share" look like an edit. `plan` as sent is
159
+ // the right base when the service echoes none: it is exactly the bytes it
160
+ // just stored.
161
+ writeRecordState(
162
+ deckDir,
163
+ recordStateFrom(
164
+ { ...data, name: data.name ?? args.name, plan: data.plan ?? plan },
165
+ { file, deckDir, link, backendURL },
166
+ ),
167
+ );
168
+ if (args.json) {
169
+ emit({
170
+ ok: true,
171
+ url: data.url,
172
+ name: data.name ?? args.name,
173
+ eTag: data.eTag ?? null,
174
+ versionLabel: data.versionLabel ?? null,
175
+ link,
176
+ });
177
+ return;
178
+ }
179
+ process.stdout.write(
180
+ `Shared ${data.name ?? args.name}${data.versionLabel ? ` (v${data.versionLabel})` : ""}.\n` +
181
+ `${link}\n`,
182
+ );
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // open
187
+ // ---------------------------------------------------------------------------
188
+
189
+ async function cmdOpenRecord(rest) {
190
+ const args = parseArgs(rest, "open");
191
+ if (!args.deck || !args.file) {
192
+ fail(`record open needs --deck and --file.\n${USAGE}`);
193
+ }
194
+ const backendURL = await backendFor(null);
195
+ let data;
196
+ try {
197
+ data = await getRecord(backendURL, { url: args.target }, { kind: args.kind ?? null });
198
+ } catch (err) {
199
+ failCall(err, args.json);
200
+ }
201
+ // The workspace may not exist yet — this is how a colleague's machine first
202
+ // hears about the deck at all.
203
+ const root = resolveDeckRoot(args.deck);
204
+ fs.mkdirSync(path.join(root, WORKSPACE_DIRNAME), { recursive: true });
205
+ const deckDir = resolveDeckArg(args.deck, { allowUnfinished: true });
206
+ const file = path.resolve(args.file);
207
+ writeRecordFile(file, data.plan);
208
+ const link = pageLink(data, args.target);
209
+ writeRecordState(deckDir, recordStateFrom(data, { file, deckDir, link, backendURL }));
210
+ if (args.json) {
211
+ emit({
212
+ ok: true,
213
+ url: data.url,
214
+ name: data.name ?? null,
215
+ eTag: data.eTag ?? null,
216
+ versionLabel: data.versionLabel ?? null,
217
+ link,
218
+ file,
219
+ deck: root,
220
+ });
221
+ return;
222
+ }
223
+ process.stdout.write(
224
+ `Wrote ${path.relative(process.cwd(), file)}${data.versionLabel ? ` (v${data.versionLabel})` : ""}.\n` +
225
+ `${link}\n`,
226
+ );
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // sync
231
+ // ---------------------------------------------------------------------------
232
+
233
+ const preview = (value) => {
234
+ const text = JSON.stringify(value ?? null);
235
+ return text.length > 120 ? `${text.slice(0, 117)}…` : text;
236
+ };
237
+
238
+ async function cmdSync(rest) {
239
+ const args = parseArgs(rest, "sync");
240
+ const sendFrom = args.resolve ? path.resolve(args.resolve) : args.file ? path.resolve(args.file) : null;
241
+ const writeTo = args.file ? path.resolve(args.file) : sendFrom;
242
+ if (!sendFrom) fail(`record sync needs --file (or --resolve <file>).\n${USAGE}`);
243
+
244
+ const deckDir = resolveDeckArg(args.target);
245
+ const slug = deckSlug(deckDir);
246
+ const state = requireState(deckDir, slug);
247
+ // A deck the workspace queue already manages drains through the shared
248
+ // coordinator: same claim as the daemon, so the two never both send.
249
+ if (!args.resolve && !args.approval && (await queuedSync(deckDir, state, writeTo, args.json))) return;
250
+ const mine = planToSend(readJsonFile(sendFrom, "the record"), { approval: args.approval });
251
+
252
+ let base;
253
+ if (args.resolve) {
254
+ const conflicts = readConflicts(deckDir);
255
+ if (!conflicts?.eTag) {
256
+ fail(
257
+ `${slug} has no conflicts to resolve (${path.relative(process.cwd(), conflictsPath(deckDir))} is missing). ` +
258
+ "Run `slidemind record sync` without --resolve.",
259
+ );
260
+ }
261
+ // Resolving means "against the version the conflicts came from", not
262
+ // against whatever this machine last loaded — and `current` is the ONLY
263
+ // field that is that version. `merged` is not a substitute: it carries
264
+ // MINE at every non-conflicting path, so sending it as the base would tell
265
+ // the next merge that my own edits were already in the shared record, and
266
+ // it would attribute them to everyone else.
267
+ if (!conflicts.current) {
268
+ fail(
269
+ `${slug}'s conflicts file carries no \`current\` — it predates this CLI. ` +
270
+ "Run `slidemind record sync` again to get a fresh conflict list, then resolve that one.",
271
+ );
272
+ }
273
+ base = { eTag: conflicts.eTag, plan: conflicts.current };
274
+ } else {
275
+ base = { eTag: state.eTag, plan: state.base ?? null };
276
+ }
277
+
278
+ const backendURL = await backendFor(state);
279
+ let data;
280
+ try {
281
+ data = await putRecord(backendURL, { kind: state.recordKind ?? null, selector: recordSelector(state), base, plan: mine });
282
+ } catch (err) {
283
+ if (err?.code === "conflict") return reportConflicts(deckDir, slug, err.body, args.json);
284
+ if (err?.code === "stale") return reportStale(err.body, args.json);
285
+ if (err?.code === "version_unknown") {
286
+ data = await recoverVersion(backendURL, state, args.json, err);
287
+ } else {
288
+ failCall(err, args.json);
289
+ }
290
+ }
291
+
292
+ // The explicit apply: the write landed, so the response is this deck's new
293
+ // base and whatever conflicts were pending are settled by it.
294
+ applyRecordResponse(deckDir, data, {
295
+ file: writeTo,
296
+ link: state.link ?? pageLink(data),
297
+ backendURL,
298
+ previous: state,
299
+ });
300
+
301
+ const applied = Array.isArray(data.applied) ? data.applied : [];
302
+ if (args.json) {
303
+ emit({
304
+ ok: true,
305
+ url: data.url ?? state.url,
306
+ eTag: data.eTag ?? null,
307
+ versionLabel: data.versionLabel ?? null,
308
+ applied,
309
+ file: writeTo,
310
+ ...(data.recovered ? { versionReadAfterWrite: true } : {}),
311
+ });
312
+ return;
313
+ }
314
+ process.stdout.write(
315
+ `synced v${data.versionLabel ?? data.eTag ?? "?"}` +
316
+ (data.recovered ? " (version read after write)" : "") +
317
+ "\n" +
318
+ (applied.length
319
+ ? ` ${applied.length} change(s) came back from the shared record:\n` +
320
+ applied.map((p) => ` ${p}\n`).join("")
321
+ : ""),
322
+ );
323
+ }
324
+
325
+ // synchronization.md: the headless drain of a deck whose plan is queued by
326
+ // the workspace (state/workspace-sync.json exists and the plan is pack-owned).
327
+ // Returns false when this deck is not queued, so the classic path runs.
328
+ async function queuedSync(deckDir, state, writeTo, asJson) {
329
+ const { drainOnce, syncStatus } = await import("../workspace/sync.mjs");
330
+ if (!syncStatus(deckDir).exists) return false;
331
+ const { planPageOwner } = await import("../lib/deck-paths.mjs");
332
+ const owner = planPageOwner(deckDir).owner;
333
+ if (!owner || owner === "core") return false;
334
+ const { resolveWorkspaceAdapter } = await import("../workspace/adapters.mjs");
335
+ const adapter = resolveWorkspaceAdapter(owner === "extension" ? null : owner);
336
+ if (!adapter.entry) fail(`${adapter.blocker?.code ?? "extension_missing"} — ${adapter.blocker?.message ?? "the plan's extension is not installed"}`);
337
+ let result;
338
+ try {
339
+ // A CLI save may have changed the file since the queue became idle. Accept
340
+ // and enqueue that current value under the same lock as browser edits before
341
+ // draining; an idle queue is not evidence that the file is still synced.
342
+ const { savePlanLocal, readAcceptedEdit } = await import("../workspace/edit.mjs");
343
+ const { dispatchAdapter } = await import("../workspace/adapters.mjs");
344
+ const accepted = readAcceptedEdit(deckDir);
345
+ const current = await dispatchAdapter(adapter.entry, { method: "read", deckRoot: path.dirname(deckDir) });
346
+ if (current.status !== "ok") throw Object.assign(new Error(current.error?.message ?? "cannot read plan"), { code: current.error?.code });
347
+ const value = current.data.value;
348
+ const saved = await savePlanLocal({ workspaceDir: deckDir, adapter, base: { hash: current.data.hash, value }, value });
349
+ if (!saved.ok) throw Object.assign(new Error(saved.message ?? saved.code), { code: saved.code });
350
+ result = await drainOnce(deckDir, { adapter });
351
+ } catch (err) {
352
+ if (err?.code === "deck_busy" || err?.code === "recovery_required") {
353
+ if (asJson) {
354
+ emit({ ok: false, error: err.code, message: err.message });
355
+ process.exit(2);
356
+ }
357
+ fail(`${err.code} — ${err.message}`);
358
+ }
359
+ throw err;
360
+ }
361
+ const after = readRecordState(deckDir) ?? state;
362
+ if (result.state === "blocked" && result.lastError?.code === "remote_conflict") {
363
+ if (asJson) {
364
+ emit({ ok: false, error: "conflict", conflicts: result.conflict?.conflicts ?? [], eTag: result.conflict?.currentETag ?? null, conflictId: result.conflict?.conflictId ?? null });
365
+ process.exit(3);
366
+ }
367
+ process.stdout.write(`${result.conflict?.conflicts?.length ?? 0} conflict(s) — nothing was written, both values are kept. Resolve them in the workspace.\n`);
368
+ process.exit(3);
369
+ }
370
+ if (result.state === "blocked") {
371
+ const code = result.lastError?.code ?? "blocked";
372
+ if (asJson) {
373
+ emit({ ok: false, error: code, message: result.lastError?.message ?? REFUSALS[code] ?? code });
374
+ process.exit(code === "stale" ? 3 : 1);
375
+ }
376
+ fail(`${code} — ${result.lastError?.message ?? REFUSALS[code] ?? code}`);
377
+ }
378
+ if (asJson) {
379
+ emit({ ok: true, url: after.url, eTag: after.eTag ?? null, versionLabel: after.versionLabel ?? null, applied: [], file: writeTo ?? resolveStoredFile(deckDir, after.file), state: result.state, synced: result.synced, ...(result.retryAt ? { retryAt: result.retryAt } : {}) });
380
+ return true;
381
+ }
382
+ process.stdout.write(result.synced ? `synced v${after.versionLabel ?? after.eTag ?? "?"}\n` : `${result.state}${result.lastError ? ` (${result.lastError.code})` : ""}${result.retryAt ? `, retry at ${result.retryAt}` : ""}\n`);
383
+ return true;
384
+ }
385
+
386
+ // The one answer where a non-2xx means the write SUCCEEDED: the bytes are in
387
+ // SharePoint and only the version read after them failed (twice, server-side).
388
+ //
389
+ // Reporting that as a failed save is the worst answer available — the user's
390
+ // edit is safe, and a plain re-read recovers everything the PUT could not say.
391
+ // Storing the OLD eTag is the second worst: it is stale by exactly one version,
392
+ // so every later sync would be refused against a version that no longer exists.
393
+ // Hence: re-read and carry on, or change nothing at all and say so.
394
+ async function recoverVersion(backendURL, state, asJson, err) {
395
+ try {
396
+ const data = await getRecord(backendURL, recordSelector(state), { kind: state.recordKind ?? null });
397
+ if (!data?.plan || !data?.eTag) throw new Error("the re-read carried no version");
398
+ return { ...data, applied: [], recovered: true };
399
+ } catch {
400
+ // The code leads the line so this is recognisable without parsing JSON —
401
+ // and it must never read as "your work is gone".
402
+ const message =
403
+ "your save landed in SharePoint, but the version it produced could not be read. " +
404
+ "Nothing local was changed, so this record now reads as one version behind until " +
405
+ "something re-reads it — run `slidemind record sync` again to pick it up.";
406
+ if (asJson) {
407
+ emit({
408
+ ok: false,
409
+ error: "version_unknown",
410
+ // Forwarded, never assumed: it is what tells a caller the write landed.
411
+ written: err?.body?.written === true,
412
+ message,
413
+ });
414
+ process.exit(1);
415
+ }
416
+ fail(`version_unknown — ${message}`);
417
+ }
418
+ }
419
+
420
+ function reportConflicts(deckDir, slug, body, asJson) {
421
+ const conflicts = Array.isArray(body.conflicts) ? body.conflicts : [];
422
+ const file = writeConflicts(deckDir, body);
423
+ if (asJson) {
424
+ emit({
425
+ ok: false,
426
+ error: "conflict",
427
+ conflicts,
428
+ eTag: body.eTag ?? null,
429
+ conflictsFile: file,
430
+ });
431
+ process.exit(3);
432
+ }
433
+ process.stdout.write(
434
+ `${conflicts.length} conflict(s) — nothing was written, both values are kept.\n` +
435
+ conflicts
436
+ .map(
437
+ (c) =>
438
+ ` ${c.path}\n theirs: ${preview(c.theirs)}\n mine: ${preview(c.mine)}\n`,
439
+ )
440
+ .join("") +
441
+ `Edit "merged" in ${path.relative(process.cwd(), file)}, then: ` +
442
+ `slidemind record sync ${slug} --resolve <file>\n`,
443
+ );
444
+ process.exit(3);
445
+ }
446
+
447
+ function reportStale(body, asJson) {
448
+ if (asJson) {
449
+ emit({ ok: false, error: "stale", eTag: body.eTag ?? null });
450
+ process.exit(3);
451
+ }
452
+ // The code leads the line: conflict and stale both exit 3, and a caller
453
+ // reading plain output has nothing else to tell them apart by.
454
+ //
455
+ // A stale answer carries the current eTag but no version label (the service
456
+ // sends one only where it has fetched the item), and an eTag is a GUID with a
457
+ // counter — printing it after a "v" would read as a version nobody can match
458
+ // to anything. So it is named only when there is a label to name.
459
+ process.stdout.write(
460
+ `stale — the shared record moved${body.versionLabel ? ` to v${body.versionLabel}` : " on"}; ` +
461
+ "run `slidemind record sync` again to merge it.\n",
462
+ );
463
+ process.exit(3);
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // status
468
+ // ---------------------------------------------------------------------------
469
+
470
+ async function cmdStatus(rest) {
471
+ const args = parseArgs(rest, "status");
472
+ const deckDir = resolveDeckArg(args.target);
473
+ const state = requireState(deckDir, deckSlug(deckDir));
474
+ const backendURL = await backendFor(state);
475
+ let data;
476
+ try {
477
+ data = await getRecord(backendURL, recordSelector(state), { kind: state.recordKind ?? null });
478
+ } catch (err) {
479
+ failCall(err, args.json);
480
+ }
481
+ const behind = Boolean(data.eTag) && data.eTag !== state.eTag;
482
+ // A local edit is the file differing from the version last RECEIVED — key
483
+ // order and whitespace are not edits, so the comparison is structural.
484
+ const local = resolveStoredFile(deckDir, state.file);
485
+ const localPlan = local ? readJsonOrNull(local) : null;
486
+ const localEdits = Boolean(localPlan) && !isDeepStrictEqual(localPlan, state.base);
487
+
488
+ if (args.json) {
489
+ emit({
490
+ ok: true,
491
+ url: state.url,
492
+ state: behind ? "behind" : "current",
493
+ behind,
494
+ localEdits,
495
+ eTag: data.eTag ?? null,
496
+ versionLabel: data.versionLabel ?? null,
497
+ link: state.link ?? null,
498
+ });
499
+ return;
500
+ }
501
+ const parts = [behind ? `behind (v${data.versionLabel ?? data.eTag})` : "current"];
502
+ if (localEdits) parts.push("local edits");
503
+ process.stdout.write(`${parts.join(", ")}\n`);
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+
508
+ const SUBCOMMANDS = {
509
+ create: cmdCreate,
510
+ open: cmdOpenRecord,
511
+ sync: cmdSync,
512
+ status: cmdStatus,
513
+ };
514
+
515
+ export async function cmdRecord(rest) {
516
+ const [sub, ...args] = rest;
517
+ const run = SUBCOMMANDS[sub];
518
+ if (!run) {
519
+ fail(sub ? `unknown record subcommand: ${sub}\n${USAGE}` : USAGE);
520
+ }
521
+ await run(args);
522
+ }