slidemind 0.3.20 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,1210 @@
1
+ import { assertNoActivation, recoverActivation, stageWorkspace } from './workspace-transaction.mjs';
2
+ // The local revision protocol: what the deck was ACCEPTED from, what is on disk
3
+ // RIGHT NOW, and the immutable candidates a build produced in between.
4
+ //
5
+ // Three states, deliberately separate (design `add-pptx-first-adoption` §1):
6
+ //
7
+ // state/base.pptx the accepted snapshot, byte-identical to the file
8
+ // adoption took delivery of. Immutable.
9
+ // slides/ assets/ … the WORKING inputs. Edited freely; never cleared
10
+ // by a build, never advanced by one.
11
+ // state/candidates/<id>/ an immutable rendered candidate + the evidence
12
+ // (base sha, generation, input digest, order,
13
+ // dependencies, output sha) it was built from.
14
+ //
15
+ // A build writes a candidate. It does NOT advance the base and does NOT clear
16
+ // localDirty — edit → build → build keeps the first edit, which is the whole
17
+ // point. Only an explicit `revision accept --candidate <id>` (local-only) or a
18
+ // confirmed remote receipt (the next change) moves the accepted base.
19
+ //
20
+ // Everything here is SYNC and filesystem-only. This module is the contract the
21
+ // SharePoint pull/publish change and the pack read, so the schemas are
22
+ // exported as constants and pinned by fixtures under test/fixtures/revision/.
23
+
24
+ import { createHash, randomUUID } from "node:crypto";
25
+ import fs from "node:fs";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+
29
+ import { statePath, writeStateFile } from "./deck-paths.mjs";
30
+ import { EDIT_OPERATIONS_FILE } from "./edit-operations.mjs";
31
+ import { SLIDE_PRESERVATION_FILE } from "./slide-preservation.mjs";
32
+ import { resolveBundleDirForDeck, readDeckBundleRef } from "../bridge/bundle.mjs";
33
+ import { bundleOwnedFiles } from "./deck-paths.mjs";
34
+ import { unzip, readText } from "../pptx/zip.mjs";
35
+ import { relsFor, resolvePartPath, slideOrderPaths } from "../pptx/ooxml.mjs";
36
+ import {
37
+ TAG_GUID,
38
+ computeProjection,
39
+ readSlideTags,
40
+ referencedAssets,
41
+ withoutChartHashes,
42
+ } from "../pptx/tags.mjs";
43
+
44
+ export const SCHEMA_VERSION = 1;
45
+
46
+ // Every file this protocol owns, relative to `<workspace>/state/`. `clean` must
47
+ // keep all of them (they are the only record of what was accepted).
48
+ export const REVISION_FILE = "revision.json";
49
+ export const BUSY_FILE = "busy.json";
50
+ export const JOURNAL_FILE = "journal.json";
51
+ export const CONFLICTS_FILE = "conflicts.json";
52
+ export const BASE_PPTX = "base.pptx";
53
+ export const CANDIDATES_DIR = "candidates";
54
+ export const STAGED_DIR = "staged";
55
+ export const CANDIDATE_FILE = "candidate.json";
56
+ export const CANDIDATE_PPTX = "deck.pptx";
57
+ export const INCOMING_FILE = "incoming.json";
58
+ export const INCOMING_PPTX = "incoming.pptx";
59
+ // The remote half (src/lib/remote.mjs) keeps its records in the same directory
60
+ // and under the same rule: they are the only copy of what was published and of
61
+ // what an interrupted write may have done.
62
+ export const REMOTE_FILE = "remote.json";
63
+ export const OPERATIONS_DIR = "operations";
64
+ export const RECEIPTS_DIR = "receipts";
65
+
66
+ /** Everything under `<workspace>/state/` this protocol owns — `clean` skips these. */
67
+ export const PROTECTED_STATE = [
68
+ REVISION_FILE,
69
+ BUSY_FILE,
70
+ JOURNAL_FILE,
71
+ CONFLICTS_FILE,
72
+ BASE_PPTX,
73
+ CANDIDATES_DIR,
74
+ STAGED_DIR,
75
+ REMOTE_FILE,
76
+ OPERATIONS_DIR,
77
+ RECEIPTS_DIR,
78
+ // What an edit was allowed to change, and what the slide still owes
79
+ // afterwards (src/lib/edit-state.mjs). Requirements outlive the source they
80
+ // were taken from, so neither is ever a build artifact.
81
+ EDIT_OPERATIONS_FILE,
82
+ SLIDE_PRESERVATION_FILE,
83
+ ];
84
+
85
+ const sha256 = (data) =>
86
+ createHash("sha256")
87
+ .update(typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data))
88
+ .digest("hex");
89
+
90
+ const nowIso = () => new Date().toISOString();
91
+
92
+ function stateDir(workspaceDir, ...sub) {
93
+ return path.join(workspaceDir, "state", ...sub);
94
+ }
95
+
96
+ function readJson(file) {
97
+ try {
98
+ return JSON.parse(fs.readFileSync(file, "utf8"));
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function writeJson(file, data) {
105
+ fs.mkdirSync(path.dirname(file), { recursive: true });
106
+ // Write-then-rename: a kill mid-write must not leave half a JSON record where
107
+ // the accepted base is supposed to be.
108
+ const tmp = `${file}.${process.pid}.tmp`;
109
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
110
+ fs.renameSync(tmp, file);
111
+ return file;
112
+ }
113
+
114
+ /**
115
+ * Atomic sidecar write for any `<workspace>/state/<name>.json`: the same
116
+ * write-then-rename `revision.json` gets, so a kill mid-write can never leave
117
+ * half a record where an obligation is supposed to be. The edit-safeguard
118
+ * sidecars (src/lib/edit-state.mjs) ride on this rather than on a second
119
+ * persistence layer.
120
+ */
121
+ export function writeStateJson(workspaceDir, name, data) {
122
+ return writeJson(statePath(workspaceDir, name), data);
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // package inventory — read the package, NOT the source decoder
127
+ // ---------------------------------------------------------------------------
128
+
129
+ // Whether a slide reaches a chart part. `computeProjection` here emits
130
+ // `chart: null` unconditionally (the documented parity divergence in tags.mjs),
131
+ // so a chart-bearing slide can never be proved unchanged from its projection
132
+ // alone — it is reported `unverified` rather than silently "same".
133
+ function slideIsChartBlind(files, part) {
134
+ for (const rel of relsFor(files, part)) {
135
+ if ((rel.type ?? "").endsWith("/chart") || (rel.type ?? "").endsWith("/chartEx")) return true;
136
+ }
137
+ return false;
138
+ }
139
+
140
+ // Every part a slide reaches, hashed together with the slide itself: the
141
+ // evidence a projection cannot carry (native objects, embeddings, animation,
142
+ // notes, comments). Slides are never traversed into — a slide is a root.
143
+ function slideClosureSha(files, part) {
144
+ const seen = new Set();
145
+ const queue = [part];
146
+ const hashes = [];
147
+ while (queue.length) {
148
+ const p = queue.shift();
149
+ if (!p || files[p] === undefined || seen.has(p)) continue;
150
+ seen.add(p);
151
+ hashes.push(`${p}:${sha256(files[p])}`);
152
+ const relsPath = `${p.slice(0, p.lastIndexOf("/"))}/_rels/${p.slice(p.lastIndexOf("/") + 1)}.rels`;
153
+ if (files[relsPath] !== undefined && !seen.has(relsPath)) {
154
+ seen.add(relsPath);
155
+ hashes.push(`${relsPath}:${sha256(files[relsPath])}`);
156
+ }
157
+ const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
158
+ for (const rel of relsFor(files, p)) {
159
+ if (rel.targetMode === "External") continue;
160
+ const target = resolvePartPath(dir, rel.target);
161
+ // Masters and layouts are SHARED: hashing them into every slide would make
162
+ // one theme tweak dirty the whole deck. They ride the package hash instead.
163
+ if (/^ppt\/slide(Masters|Layouts)\//.test(target)) continue;
164
+ if (/^ppt\/slides\/slide\d+\.xml$/.test(target)) continue; // links, not content
165
+ queue.push(target);
166
+ }
167
+ }
168
+ return sha256(hashes.sort().join("\n"));
169
+ }
170
+
171
+ /**
172
+ * What a .pptx actually contains, read from the package and independent of any
173
+ * source tag payload. `decodeSourceTags` proves a payload is INTACT, never that
174
+ * it agrees with the slide — so nothing here consults it.
175
+ *
176
+ * Returns `{ sha256, slideCount, packageSha256, order: [guid…], slides: [{…}], parts }`.
177
+ */
178
+ export function inventoryPackage(bytes) {
179
+ const buf = Buffer.from(bytes);
180
+ const files = unzip(buf);
181
+ const order = slideOrderPaths(files);
182
+ const slides = order.map((part, i) => {
183
+ const tags = readSlideTags(files, part);
184
+ const sldId = sldIdForPart(files, part);
185
+ return {
186
+ // A GUID tag survives source-tag removal, so it is read on its own.
187
+ guid: tags[TAG_GUID] ?? null,
188
+ part,
189
+ sldId,
190
+ index: i + 1,
191
+ projection: sha256(
192
+ JSON.stringify(withoutChartHashes(computeProjection(files, part))),
193
+ ),
194
+ package: slideClosureSha(files, part),
195
+ chartBlind: slideIsChartBlind(files, part),
196
+ tagged: Boolean(tags[TAG_GUID]),
197
+ };
198
+ });
199
+ return {
200
+ sha256: sha256(buf),
201
+ slideCount: order.length,
202
+ // Every part except the slides themselves — masters, layouts, themes,
203
+ // presProps, the section list. A theme edit lands here and nowhere else.
204
+ packageSha256: sha256(
205
+ Object.keys(files)
206
+ .filter((p) => !/^ppt\/slides\//.test(p))
207
+ .sort()
208
+ .map((p) => `${p}:${sha256(files[p])}`)
209
+ .join("\n"),
210
+ ),
211
+ order: slides.map((s) => s.guid),
212
+ slides,
213
+ parts: Object.keys(files).sort(),
214
+ };
215
+ }
216
+
217
+ function sldIdForPart(files, part) {
218
+ const pres = readText(files, "ppt/presentation.xml");
219
+ if (!pres) return null;
220
+ const rels = {};
221
+ for (const rel of relsFor(files, "ppt/presentation.xml")) {
222
+ if (rel.id) rels[rel.id] = resolvePartPath("ppt", rel.target);
223
+ }
224
+ const list = /<(?:[\w.-]+:)?sldIdLst[\s\S]*?<\/(?:[\w.-]+:)?sldIdLst>/.exec(pres);
225
+ if (!list) return null;
226
+ const re = /<(?:[\w.-]+:)?sldId\b([^>]*)>/g;
227
+ let m;
228
+ while ((m = re.exec(list[0])) !== null) {
229
+ const rId = /\b[\w.-]+:id\s*=\s*"([^"]+)"/.exec(m[1])?.[1];
230
+ if (rId && rels[rId] === part) return /\bid\s*=\s*"([^"]+)"/.exec(m[1])?.[1] ?? null;
231
+ }
232
+ return null;
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // working inputs — what is on disk right now
237
+ // ---------------------------------------------------------------------------
238
+
239
+ function walkFiles(dir, prefix, out, skip) {
240
+ let entries = [];
241
+ try {
242
+ entries = fs.readdirSync(dir, { withFileTypes: true });
243
+ } catch {
244
+ return;
245
+ }
246
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
247
+ const abs = path.join(dir, e.name);
248
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
249
+ if (e.isDirectory()) walkFiles(abs, rel, out, skip);
250
+ else if (e.isFile() && !skip.has(rel)) {
251
+ try {
252
+ out[rel] = sha256(fs.readFileSync(abs));
253
+ } catch {
254
+ /* unreadable — it simply carries no baseline this round */
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ /** The slide files in play order (filename order — jsx and native .pptx share it). */
261
+ export function slideFileOrder(workspaceDir) {
262
+ try {
263
+ return fs
264
+ .readdirSync(path.join(workspaceDir, "slides"))
265
+ .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
266
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Everything a render reads, hashed by BYTES: slide sources, deck-owned assets
274
+ * and the conversion records (which carry the preserved native packages). Plus
275
+ * the effective shared dependency — the design the deck is bound to — because a
276
+ * corporate design that moved changes every slide's output without touching a
277
+ * single deck file.
278
+ *
279
+ * Copies of the design bundle a pre-workspace deck still carries are skipped,
280
+ * exactly as `hashDeckContent` skips them, so re-materializing an unchanged
281
+ * design cannot flip a deck dirty.
282
+ */
283
+ export function materializedInputs(workspaceDir) {
284
+ const skip = bundleOwnedFiles(workspaceDir, resolveBundleDirForDeck(workspaceDir));
285
+ const inputs = {};
286
+ for (const sub of ["slides", "assets", "conversions"]) {
287
+ walkFiles(path.join(workspaceDir, sub), sub, inputs, skip);
288
+ }
289
+ const ref = readDeckBundleRef(workspaceDir);
290
+ return {
291
+ inputs,
292
+ order: slideFileOrder(workspaceDir),
293
+ shared: { design: ref?.presFP ? `${ref.presFP}@${ref.etag ?? ""}` : null },
294
+ };
295
+ }
296
+
297
+ /** One hash over a materialized-input set — the value a candidate binds to. */
298
+ export function inputDigest(materialized) {
299
+ const { inputs, order, shared } = materialized;
300
+ return sha256(
301
+ JSON.stringify({
302
+ inputs: Object.keys(inputs)
303
+ .sort()
304
+ .map((k) => [k, inputs[k]]),
305
+ order,
306
+ shared,
307
+ }),
308
+ );
309
+ }
310
+
311
+ // slide file -> the assets its jsx references, so a changed image dirties every
312
+ // slide that draws it (an image-only edit must not read as "nothing changed").
313
+ function assetDependents(workspaceDir, order) {
314
+ const assetsDir = path.join(workspaceDir, "assets");
315
+ const map = new Map();
316
+ for (const file of order) {
317
+ if (!file.endsWith(".jsx")) continue;
318
+ let jsx;
319
+ try {
320
+ jsx = fs.readFileSync(path.join(workspaceDir, "slides", file), "utf8");
321
+ } catch {
322
+ continue;
323
+ }
324
+ for (const asset of referencedAssets(jsx, assetsDir)) {
325
+ const list = map.get(asset.name) ?? [];
326
+ list.push(file);
327
+ map.set(asset.name, list);
328
+ }
329
+ }
330
+ return map;
331
+ }
332
+
333
+ /**
334
+ * Local differences against the accepted working-input baseline. Content,
335
+ * structure and dependencies all count: changed/added/removed slide files, a
336
+ * changed referenced asset, a changed conversion record (the preserved native
337
+ * bytes live there), slide ORDER, and the effective design.
338
+ *
339
+ * Hydration is not an edit: it records a new baseline materialization against
340
+ * the same accepted package, which `noteHydration` folds into the baseline.
341
+ */
342
+ export function computeDirty(
343
+ workspaceDir,
344
+ revision = readRevision(workspaceDir),
345
+ materialized = materializedInputs(workspaceDir),
346
+ ) {
347
+ const empty = {
348
+ localDirty: false,
349
+ dirtySlides: [],
350
+ added: [],
351
+ removed: [],
352
+ orderChanged: false,
353
+ sharedChanged: false,
354
+ unverified: [],
355
+ inputDigest: inputDigest(materialized),
356
+ };
357
+ if (!revision?.baseline) return { ...empty, localDirty: Object.keys(materialized.inputs).length > 0 };
358
+
359
+ const baseline = revision.baseline.inputs ?? {};
360
+ const now = materialized.inputs;
361
+ const dirty = new Set();
362
+ const added = [];
363
+ const removed = [];
364
+
365
+ const dependents = assetDependents(workspaceDir, materialized.order);
366
+ const touch = (rel) => {
367
+ if (rel.startsWith("slides/")) {
368
+ dirty.add(rel.slice("slides/".length));
369
+ return;
370
+ }
371
+ if (rel.startsWith("conversions/")) {
372
+ // conversions/<stem>.json and conversions/<stem>.slide.pptx both belong to
373
+ // slides/<stem>.jsx.
374
+ const stem = path.basename(rel).replace(/\.slide\.pptx$/, "").replace(/\.json$/, "");
375
+ dirty.add(`${stem}.jsx`);
376
+ return;
377
+ }
378
+ for (const file of dependents.get(rel) ?? []) dirty.add(file);
379
+ };
380
+
381
+ for (const [rel, hash] of Object.entries(now)) {
382
+ if (baseline[rel] === undefined) {
383
+ if (rel.startsWith("slides/")) added.push(rel.slice("slides/".length));
384
+ touch(rel);
385
+ } else if (baseline[rel] !== hash) {
386
+ touch(rel);
387
+ }
388
+ }
389
+ for (const rel of Object.keys(baseline)) {
390
+ if (now[rel] !== undefined) continue;
391
+ if (rel.startsWith("slides/")) removed.push(rel.slice("slides/".length));
392
+ touch(rel);
393
+ }
394
+
395
+ const orderChanged =
396
+ JSON.stringify(materialized.order) !== JSON.stringify(revision.baseline.order ?? []);
397
+ // A design that moved changes every slide's render without touching a file —
398
+ // but it is NOT a local edit, so it never joins `dirtySlides` (every consumer
399
+ // of that list means "unpublished edits": rebase conflicts, adopt/pull
400
+ // refusals, the build's render set). It is reported on its own; build-pptx
401
+ // re-renders the untouched slides only with --refresh-design.
402
+ const sharedChanged =
403
+ JSON.stringify(materialized.shared) !== JSON.stringify(revision.baseline.shared ?? {});
404
+
405
+ // Slides whose evidence is known-incomplete here: a chart's data lives in a
406
+ // spec hash this repo cannot produce (see the CHART HASH note in tags.mjs), so
407
+ // "no input changed" is not proof for them. Reported, never silently clean.
408
+ const unverified = (revision.base?.slides ?? [])
409
+ .filter((s) => s.chartBlind)
410
+ .map((s) => s.guid ?? s.part);
411
+
412
+ const dirtySlides = [...dirty].sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
413
+ return {
414
+ localDirty:
415
+ dirtySlides.length > 0 || added.length > 0 || removed.length > 0 || orderChanged,
416
+ dirtySlides,
417
+ added: added.sort(),
418
+ removed: removed.sort(),
419
+ orderChanged,
420
+ sharedChanged,
421
+ unverified,
422
+ inputDigest: inputDigest(materialized),
423
+ };
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // revision.json
428
+ // ---------------------------------------------------------------------------
429
+
430
+ export function revisionPath(workspaceDir) {
431
+ return statePath(workspaceDir, REVISION_FILE);
432
+ }
433
+
434
+ export function readRevision(workspaceDir) {
435
+ assertNoActivation(workspaceDir);
436
+ const data = readJson(revisionPath(workspaceDir));
437
+ return data?.schemaVersion === SCHEMA_VERSION ? data : null;
438
+ }
439
+
440
+ /** Persist and return the RECORD — callers read the generation back off it. */
441
+ export function writeRevision(workspaceDir, revision) {
442
+ const record = { ...revision, schemaVersion: SCHEMA_VERSION };
443
+ writeJson(revisionPath(workspaceDir), record);
444
+ return record;
445
+ }
446
+
447
+ export function acceptedBasePath(workspaceDir) {
448
+ return stateDir(workspaceDir, BASE_PPTX);
449
+ }
450
+
451
+ export function readAcceptedBase(workspaceDir) {
452
+ assertNoActivation(workspaceDir);
453
+ try {
454
+ return fs.readFileSync(acceptedBasePath(workspaceDir));
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Record a fresh accepted base: the exact bytes adoption took delivery of, the
462
+ * package evidence read out of them, and the materialized working inputs that
463
+ * correspond to that package. Generation starts at 1 and only ever advances.
464
+ */
465
+ export function recordAcceptedBase(workspaceDir, { bytes, origin }) {
466
+ const inventory = inventoryPackage(bytes);
467
+ const previous = readRevision(workspaceDir);
468
+ fs.mkdirSync(stateDir(workspaceDir), { recursive: true });
469
+ fs.writeFileSync(acceptedBasePath(workspaceDir), Buffer.from(bytes));
470
+ const materialized = materializedInputs(workspaceDir);
471
+ return writeRevision(workspaceDir, {
472
+ schemaVersion: SCHEMA_VERSION,
473
+ generation: (previous?.generation ?? 0) + 1,
474
+ origin: { ...origin, at: nowIso() },
475
+ base: {
476
+ sha256: inventory.sha256,
477
+ acceptedAt: nowIso(),
478
+ slideCount: inventory.slideCount,
479
+ packageSha256: inventory.packageSha256,
480
+ order: inventory.order,
481
+ slides: inventory.slides,
482
+ },
483
+ baseline: {
484
+ inputs: materialized.inputs,
485
+ order: materialized.order,
486
+ shared: materialized.shared,
487
+ at: nowIso(),
488
+ },
489
+ candidate: null,
490
+ staged: previous?.staged ?? [],
491
+ conflicts: null,
492
+ });
493
+ }
494
+
495
+ // Every baseline key one slide file owns: the slide itself plus its conversion
496
+ // record and preserved package.
497
+ function baselineKeysFor(file) {
498
+ const stem = file.replace(/\.(jsx|pptx)$/, "");
499
+ return [`slides/${file}`, `conversions/${stem}.json`, `conversions/${stem}.slide.pptx`];
500
+ }
501
+
502
+ /**
503
+ * Hydration added working inputs for slides of the SAME accepted package. It is
504
+ * not an edit: the new files join the baseline, so they do not read as dirty —
505
+ * but every other slide's pending edit stays exactly where it was.
506
+ *
507
+ * `replaced` names the files the hydration WITHDREW (the native form a slide had
508
+ * before it became editable jsx). They leave the baseline with it; without that
509
+ * the vanished native file reads as a local deletion, which is the one thing
510
+ * hydration must not look like. Only the named files are dropped — a slide the
511
+ * user actually deleted is still a local change and stays dirty.
512
+ */
513
+ export function noteHydration(workspaceDir, hydratedFiles, replaced = []) {
514
+ const revision = readRevision(workspaceDir);
515
+ if (!revision) return null;
516
+ const materialized = materializedInputs(workspaceDir);
517
+ const baseline = { ...(revision.baseline?.inputs ?? {}) };
518
+ for (const file of replaced) {
519
+ for (const key of baselineKeysFor(file)) delete baseline[key];
520
+ }
521
+ const claim = new Set();
522
+ for (const file of hydratedFiles) {
523
+ for (const key of baselineKeysFor(file)) claim.add(key);
524
+ }
525
+ for (const rel of Object.keys(materialized.inputs)) {
526
+ // Assets a freshly hydrated slide brought with it join the baseline too —
527
+ // they came out of the accepted package, so they are not a local edit.
528
+ if (claim.has(rel) || (rel.startsWith("assets/") && baseline[rel] === undefined)) {
529
+ baseline[rel] = materialized.inputs[rel];
530
+ }
531
+ }
532
+ return writeRevision(workspaceDir, {
533
+ ...revision,
534
+ generation: revision.generation + 1,
535
+ baseline: {
536
+ ...revision.baseline,
537
+ inputs: baseline,
538
+ order: materialized.order,
539
+ shared: materialized.shared,
540
+ at: nowIso(),
541
+ },
542
+ });
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // ownership — an exclusive create, not a read/check/write
547
+ // ---------------------------------------------------------------------------
548
+
549
+ // Seconds since boot at which THIS process started. A recycled pid carries a
550
+ // different value, so the pair (pid, processStart) identifies a process without
551
+ // a platform-specific /proc read.
552
+ // ponytail: whole-second resolution and a reboot resets the clock — both only
553
+ // ever make a stale owner look MORE stale, never a live one look dead.
554
+ // os.uptime() throws EPERM inside some agent sandboxes (codex exec seatbelt,
555
+ // observed 2026-09-18: every build-pptx died with "uv_uptime returned EPERM"
556
+ // and left a zero-byte busy.json behind). Fall back to epoch seconds; the two
557
+ // call sites below share the basis, so a mark compared within one process
558
+ // stays consistent.
559
+ // ponytail: a sandboxed and an unsandboxed process judging the same lock use
560
+ // different bases and read each other as recycled pids — only ever releases a
561
+ // lock, never wedges one; fine until two harnesses share one deck live.
562
+ function uptimeSeconds() {
563
+ try {
564
+ return os.uptime();
565
+ } catch {
566
+ return Date.now() / 1000;
567
+ }
568
+ }
569
+
570
+ function processStartMark() {
571
+ return Math.max(0, Math.round(uptimeSeconds() - process.uptime()));
572
+ }
573
+
574
+ // Is this pid a live process? EPERM means it exists but belongs to someone
575
+ // else — another user, or another harness sandbox (Codex seatbelt answers
576
+ // EPERM across instances), so treating any throw as dead made every lock
577
+ // under a sandbox look free. Only ESRCH (and garbage) is dead.
578
+ export function pidAlive(pid) {
579
+ try {
580
+ process.kill(pid, 0);
581
+ return true;
582
+ } catch (err) {
583
+ return err?.code === "EPERM";
584
+ }
585
+ }
586
+
587
+ // Is this record still someone's?
588
+ //
589
+ // A SESSION marker (`revision begin`) deliberately outlives the process that
590
+ // created it: the whole point is to protect an edit session made of many
591
+ // commands and raw file writes, and the command that minted the token exited a
592
+ // second later. It is released by `revision finish`, by its own token, or by an
593
+ // explicit --force when the human says the session is gone — never by a pid
594
+ // check, which would make every session token stale the instant it was issued.
595
+ //
596
+ // A COMMAND marker is pid-bound, so a crashed build releases its own lock.
597
+ function alive(busy) {
598
+ if (!busy) return false;
599
+ if (busy.kind === "session") return true;
600
+ if (!busy.pid) return false;
601
+ if (busy.host && busy.host !== os.hostname()) return true; // not ours to judge
602
+ try {
603
+ process.kill(busy.pid, 0);
604
+ } catch (err) {
605
+ return err?.code === "EPERM"; // exists but is someone else's
606
+ }
607
+ if (busy.pid === process.pid) return true;
608
+ // A live pid whose recorded start time is far from any plausible current one
609
+ // is a recycled pid, not the original owner.
610
+ return Math.abs(processStartMark() - (busy.processStart ?? 0)) <= uptimeSeconds();
611
+ }
612
+
613
+ export function readBusy(workspaceDir) {
614
+ return readJson(stateDir(workspaceDir, BUSY_FILE));
615
+ }
616
+
617
+ /**
618
+ * Take local ownership of a deck. Exclusive: the record is created with `wx`, so
619
+ * two processes racing can never both win. A nested call under the SAME owner
620
+ * token (env `SLIDEMIND_OWNER`, or an explicit `owner`) re-enters instead of
621
+ * deadlocking.
622
+ *
623
+ * Returns `{ token, generation, nested, release() }`. `release()` on a nested
624
+ * acquisition is a no-op — the outermost holder owns the lifetime.
625
+ */
626
+ export function acquireOwner(
627
+ workspaceDir,
628
+ { command = "unknown", owner = null, session = false } = {},
629
+ ) {
630
+ const file = stateDir(workspaceDir, BUSY_FILE);
631
+ const wanted = owner ?? process.env.SLIDEMIND_OWNER ?? null;
632
+ fs.mkdirSync(path.dirname(file), { recursive: true });
633
+
634
+ for (let attempt = 0; attempt < 2; attempt++) {
635
+ let fd;
636
+ try {
637
+ fd = fs.openSync(file, "wx");
638
+ } catch (err) {
639
+ if (err?.code !== "EEXIST") throw err;
640
+ const busy = readBusy(workspaceDir);
641
+ if (busy && wanted && busy.owner === wanted) {
642
+ // Same session, nested command: re-enter under the existing token.
643
+ writeJson(file, { ...busy, depth: (busy.depth ?? 1) + 1 });
644
+ return {
645
+ token: busy.owner,
646
+ generation: busy.generation,
647
+ nested: true,
648
+ release: () => {
649
+ const cur = readBusy(workspaceDir);
650
+ if (cur?.owner === busy.owner) {
651
+ writeJson(file, { ...cur, depth: Math.max(1, (cur.depth ?? 1) - 1) });
652
+ }
653
+ },
654
+ };
655
+ }
656
+ if (!busy || alive(busy)) {
657
+ const e = new Error(
658
+ !busy ? "deck ownership is being initialized or its lock is unreadable; retry or inspect busy.json before forcing release." : busy.kind === "session"
659
+ ? `deck is held by an edit session (\`${busy.command}\`, started ${busy.startedAt}). ` +
660
+ `Run every command under its token (export SLIDEMIND_OWNER), or end it with ` +
661
+ `\`slidemind revision finish <deck>\`.`
662
+ : `deck is in use by ${busy.command ?? "another command"} (pid ${busy.pid}, since ${busy.startedAt}). ` +
663
+ `Wait for it, or run \`slidemind revision finish <deck> --force\` if that process is gone.`,
664
+ );
665
+ e.code = "deck_busy";
666
+ e.busy = busy;
667
+ throw e;
668
+ }
669
+ // A dead owner: release ONLY the ownership. Dirty files and any journaled
670
+ // operation survive untouched — a crash is not a reason to lose work.
671
+ // Serialize stale removers. A second remover must inspect the current
672
+ // lock again, not unlink a replacement using its earlier observation.
673
+ const guard = `${file}.reap`;
674
+ try { fs.mkdirSync(guard); }
675
+ catch (error) {
676
+ if (error.code !== "EEXIST") throw error;
677
+ throw Object.assign(new Error("deck ownership recovery is in progress"), { code: "deck_busy" });
678
+ }
679
+ try {
680
+ const current = readBusy(workspaceDir);
681
+ if (current?.owner === busy.owner && !alive(current)) fs.rmSync(file, { force: true });
682
+ } finally { fs.rmdirSync(guard); }
683
+ continue;
684
+ }
685
+ let revision;
686
+ try { recoverActivation(workspaceDir); revision = readRevision(workspaceDir); }
687
+ catch (error) { fs.closeSync(fd); fs.rmSync(file, { force: true }); throw error; }
688
+ const record = {
689
+ schemaVersion: SCHEMA_VERSION,
690
+ owner: `own-${randomUUID()}`,
691
+ // "session" outlives its process (see alive); "command" is pid-bound.
692
+ kind: session ? "session" : "command",
693
+ pid: process.pid,
694
+ processStart: processStartMark(),
695
+ host: os.hostname(),
696
+ command,
697
+ generation: revision?.generation ?? 0,
698
+ startedAt: nowIso(),
699
+ depth: 1,
700
+ };
701
+ fs.writeSync(fd, JSON.stringify(record, null, 2) + "\n");
702
+ fs.closeSync(fd);
703
+ // `fail()` ends the process with process.exit(), which skips every
704
+ // `finally` — so a command that dies that way used to leave its marker
705
+ // behind and the next one waited on a lock nobody held. It self-healed
706
+ // (the pid is dead, so `alive()` reaps it), but an operation lease reads a
707
+ // leaked marker as a live claim, so the release happens here instead of at
708
+ // each of the ten call sites. Fully synchronous, as an exit handler must be.
709
+ //
710
+ // A SESSION marker is deliberately excluded: `revision begin` prints a
711
+ // token precisely so the marker outlives the process that minted it.
712
+ const onExit = session
713
+ ? null
714
+ : () => {
715
+ const cur = readBusy(workspaceDir);
716
+ // Only ever this process's own command marker — never a replacement
717
+ // somebody else acquired after we let go.
718
+ if (cur?.owner === record.owner && cur.kind === "command" && cur.pid === process.pid) {
719
+ fs.rmSync(file, { force: true });
720
+ }
721
+ };
722
+ if (onExit) process.on("exit", onExit);
723
+ return {
724
+ token: record.owner,
725
+ generation: record.generation,
726
+ nested: false,
727
+ release: () => {
728
+ // Idempotent: a normal release retires the hook, so exiting afterwards
729
+ // is a no-op even if the marker has since been re-acquired.
730
+ if (onExit) process.off("exit", onExit);
731
+ const cur = readBusy(workspaceDir);
732
+ if (!cur || cur.owner !== record.owner) return;
733
+ if ((cur.depth ?? 1) > 1) {
734
+ writeJson(file, { ...cur, depth: cur.depth - 1 });
735
+ return;
736
+ }
737
+ fs.rmSync(file, { force: true });
738
+ },
739
+ };
740
+ }
741
+ throw new Error("could not acquire deck ownership");
742
+ }
743
+
744
+ /**
745
+ * Drop a stale owner record. Returns what was released, or null when the owner
746
+ * is alive (the caller must not steal it) or there was none. NEVER touches
747
+ * working files or journals.
748
+ */
749
+ export function releaseStaleOwner(workspaceDir, { owner = null, force = false } = {}) {
750
+ const busy = readBusy(workspaceDir);
751
+ if (!busy) return null;
752
+ if (owner && busy.owner !== owner) return null;
753
+ if (!force && !owner && alive(busy)) return null;
754
+ fs.rmSync(stateDir(workspaceDir, BUSY_FILE), { force: true });
755
+ return busy;
756
+ }
757
+
758
+ /**
759
+ * The safe checkpoint every activation passes: ownership still ours, and the
760
+ * generation + working inputs unchanged since they were captured. Throws
761
+ * `generation_moved` otherwise, so a snapshot whose inputs changed mid-capture
762
+ * is never certified.
763
+ */
764
+ export function assertCheckpoint(workspaceDir, { owner, generation, digest }) {
765
+ const busy = readBusy(workspaceDir);
766
+ if (!busy || busy.owner !== owner) {
767
+ const e = new Error("lost deck ownership while the operation was running");
768
+ e.code = "ownership_lost";
769
+ throw e;
770
+ }
771
+ const revision = readRevision(workspaceDir);
772
+ const current = computeDirty(workspaceDir, revision);
773
+ if ((revision?.generation ?? 0) !== generation || current.inputDigest !== digest) {
774
+ const e = new Error(
775
+ "the deck changed while this operation ran — nothing was activated. Re-run it.",
776
+ );
777
+ e.code = "generation_moved";
778
+ throw e;
779
+ }
780
+ return true;
781
+ }
782
+
783
+ // ---------------------------------------------------------------------------
784
+ // journal — a half-applied activation must be recoverable, never valid
785
+ // ---------------------------------------------------------------------------
786
+
787
+ export function readJournal(workspaceDir) {
788
+ const data = readJson(stateDir(workspaceDir, JOURNAL_FILE));
789
+ return data?.schemaVersion === SCHEMA_VERSION ? data : null;
790
+ }
791
+
792
+ export function journalBegin(workspaceDir, { op, stagingDir = null, detail = null }) {
793
+ const journal = {
794
+ schemaVersion: SCHEMA_VERSION,
795
+ id: `jr-${randomUUID()}`,
796
+ op,
797
+ status: "staged",
798
+ startedAt: nowIso(),
799
+ stagingDir,
800
+ detail,
801
+ steps: [],
802
+ };
803
+ writeJson(stateDir(workspaceDir, JOURNAL_FILE), journal);
804
+ return journal;
805
+ }
806
+
807
+ export function journalStep(workspaceDir, journal, step) {
808
+ const next = { ...journal, status: "activating", steps: [...journal.steps, { step, at: nowIso() }] };
809
+ writeJson(stateDir(workspaceDir, JOURNAL_FILE), next);
810
+ return next;
811
+ }
812
+
813
+ export function journalComplete(workspaceDir) {
814
+ fs.rmSync(stateDir(workspaceDir, JOURNAL_FILE), { force: true });
815
+ }
816
+
817
+ /** An unfinished activation, or null. Only the absence of a journal means done. */
818
+ export function journalIncomplete(workspaceDir) {
819
+ const journal = readJournal(workspaceDir);
820
+ return journal && journal.status !== "complete" ? journal : null;
821
+ }
822
+
823
+ // ---------------------------------------------------------------------------
824
+ // candidates — immutable rendered output plus the evidence it came from
825
+ // ---------------------------------------------------------------------------
826
+
827
+ export function candidateDir(workspaceDir, candidateId) {
828
+ return stateDir(workspaceDir, CANDIDATES_DIR, candidateId);
829
+ }
830
+
831
+ /**
832
+ * Persist a rendered deck as an immutable candidate. Does NOT advance the base
833
+ * and does NOT clear dirty — building twice keeps both edits, and neither build
834
+ * counts as acceptance.
835
+ *
836
+ * An identical candidate (same base, generation, inputs and output bytes) is
837
+ * reused rather than duplicated.
838
+ */
839
+ export function saveCandidate(workspaceDir, { bytes, validation = null, review = null, renderedAll = true }) {
840
+ let revision = readRevision(workspaceDir);
841
+ const materialized = materializedInputs(workspaceDir);
842
+ const digest = inputDigest(materialized);
843
+ const inventory = inventoryPackage(bytes);
844
+ if (!revision) revision = writeRevision(workspaceDir, { generation: 0, origin: { kind: 'authored' }, base: null, baseline: null, candidate: null, conflicts: null });
845
+ const evidence = {
846
+ baseSha256: revision?.base?.sha256 ?? null,
847
+ generation: revision?.generation ?? 0,
848
+ inputDigest: digest,
849
+ outputSha256: inventory.sha256,
850
+ };
851
+
852
+ for (const existing of listCandidates(workspaceDir)) {
853
+ if (
854
+ existing.baseSha256 === evidence.baseSha256 &&
855
+ existing.generation === evidence.generation &&
856
+ existing.inputDigest === evidence.inputDigest &&
857
+ existing.outputSha256 === evidence.outputSha256
858
+ ) {
859
+ return existing;
860
+ }
861
+ }
862
+
863
+ const candidateId = `cand-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
864
+ const dir = candidateDir(workspaceDir, candidateId);
865
+ fs.mkdirSync(dir, { recursive: true });
866
+ fs.writeFileSync(path.join(dir, CANDIDATE_PPTX), Buffer.from(bytes));
867
+ const record = {
868
+ schemaVersion: SCHEMA_VERSION,
869
+ candidateId,
870
+ createdAt: nowIso(),
871
+ ...evidence,
872
+ outputBytes: Buffer.byteLength(Buffer.from(bytes)),
873
+ slideCount: inventory.slideCount,
874
+ order: inventory.order,
875
+ inputs: materialized.inputs,
876
+ // A build that left untouched slides on the design they were last rendered
877
+ // with must not claim the current design for them: the accepted base then
878
+ // keeps the old `shared`, so `sharedChanged` stays true and the next
879
+ // --refresh-design still knows those slides are behind.
880
+ dependencies: renderedAll ? materialized.shared : (revision.baseline?.shared ?? materialized.shared),
881
+ validation,
882
+ review,
883
+ };
884
+ writeJson(path.join(dir, CANDIDATE_FILE), record);
885
+ if (revision) writeRevision(workspaceDir, { ...revision, candidate: candidateId });
886
+ return record;
887
+ }
888
+
889
+ export function readCandidate(workspaceDir, candidateId) {
890
+ const data = readJson(path.join(candidateDir(workspaceDir, candidateId), CANDIDATE_FILE));
891
+ return data?.schemaVersion === SCHEMA_VERSION ? data : null;
892
+ }
893
+
894
+ export function candidateBytes(workspaceDir, candidateId) {
895
+ try {
896
+ return fs.readFileSync(path.join(candidateDir(workspaceDir, candidateId), CANDIDATE_PPTX));
897
+ } catch {
898
+ return null;
899
+ }
900
+ }
901
+
902
+ export function listCandidates(workspaceDir) {
903
+ let names = [];
904
+ try {
905
+ names = fs.readdirSync(stateDir(workspaceDir, CANDIDATES_DIR));
906
+ } catch {
907
+ return [];
908
+ }
909
+ return names
910
+ .map((id) => readCandidate(workspaceDir, id))
911
+ .filter(Boolean)
912
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
913
+ }
914
+
915
+ /** Why a candidate is no longer a valid thing to accept, or null. */
916
+ export function candidateStale(workspaceDir, candidate, revision = readRevision(workspaceDir)) {
917
+ if (!candidate) return "no such candidate";
918
+ if (!revision) return "the deck has no accepted base";
919
+ if (candidate.baseSha256 !== (revision.base?.sha256 ?? null)) {
920
+ return "it was built against a different accepted base";
921
+ }
922
+ if (candidate.generation !== revision.generation) {
923
+ return "the deck's accepted state moved since it was built";
924
+ }
925
+ const digest = inputDigest(materializedInputs(workspaceDir));
926
+ if (candidate.inputDigest !== digest) {
927
+ return "the deck's inputs changed since it was built";
928
+ }
929
+ const bytes = candidateBytes(workspaceDir, candidate.candidateId);
930
+ if (!bytes || sha256(bytes) !== candidate.outputSha256) {
931
+ return "its recorded output bytes are missing or no longer match";
932
+ }
933
+ return null;
934
+ }
935
+
936
+ /**
937
+ * Accept a validated LOCAL-ONLY candidate: its bytes become the accepted base
938
+ * and its inputs become the baseline, journaled so a crash resolves to one
939
+ * complete generation. Refuses a remote-bound deck (only a confirmed publish
940
+ * receipt may advance THAT base), a stale candidate, an unresolved conflict and
941
+ * a candidate whose required gates did not pass.
942
+ */
943
+ export function acceptCandidate(workspaceDir, candidateId, { owner }) {
944
+ const revision = readRevision(workspaceDir);
945
+ if (revision?.remote || readJson(path.join(workspaceDir, "state", "remote.json"))) {
946
+ const e = new Error(
947
+ "this deck is bound to a remote copy — acceptance there comes from a confirmed publish receipt, not from `revision accept`.",
948
+ );
949
+ e.code = "remote_bound";
950
+ throw e;
951
+ }
952
+ if (readConflicts(workspaceDir)) {
953
+ const e = new Error(
954
+ "unresolved rebase conflicts — resolve them (`slidemind rebase <deck> --resolve <file>`) before accepting.",
955
+ );
956
+ e.code = "conflicted";
957
+ throw e;
958
+ }
959
+ const candidate = readCandidate(workspaceDir, candidateId);
960
+ const why = candidateStale(workspaceDir, candidate, revision);
961
+ if (why) {
962
+ const e = new Error(`candidate ${candidateId} cannot be accepted: ${why}.`);
963
+ e.code = "candidate_stale";
964
+ throw e;
965
+ }
966
+ if (candidate.validation?.ok !== true) {
967
+ const e = new Error(
968
+ `candidate ${candidateId} did not pass validation: ${(candidate.validation?.errors ?? ["validation evidence is missing"]).join("; ")}`,
969
+ );
970
+ e.code = "candidate_invalid";
971
+ throw e;
972
+ }
973
+
974
+ return advanceBaseToCandidate(workspaceDir, candidate, { owner, origin: "local", revision });
975
+ }
976
+
977
+ /**
978
+ * Move the accepted base onto a candidate's bytes and its inputs onto the
979
+ * baseline, journaled so a crash resolves to one complete generation.
980
+ *
981
+ * Shared by the two acceptance doors — `revision accept` locally and a CONFIRMED
982
+ * publish receipt remotely. The guards differ (that is the whole point of having
983
+ * two doors); what happens once a caller has earned the right is identical, and
984
+ * a second copy of it would be a second chance to get the baseline wrong.
985
+ */
986
+ function advanceBaseToCandidate(workspaceDir, candidate, { owner, origin, revision, receipt = null }) {
987
+ const candidateId = candidate.candidateId;
988
+ const bytes = candidateBytes(workspaceDir, candidateId);
989
+ assertCheckpoint(workspaceDir, {
990
+ owner,
991
+ generation: revision.generation,
992
+ digest: candidate.inputDigest,
993
+ });
994
+ const inventory = inventoryPackage(bytes);
995
+ const tx = stageWorkspace(workspaceDir);
996
+ try {
997
+ fs.writeFileSync(acceptedBasePath(tx.workspace), bytes);
998
+ const next = writeRevision(tx.workspace, {
999
+ ...revision,
1000
+ generation: revision.generation + 1,
1001
+ base: {
1002
+ sha256: inventory.sha256,
1003
+ acceptedAt: nowIso(),
1004
+ slideCount: inventory.slideCount,
1005
+ packageSha256: inventory.packageSha256,
1006
+ order: inventory.order,
1007
+ // The per-slide file map is what a later rebase matches a colleague's
1008
+ // change against; rebuilding it from the candidate would lose it.
1009
+ slides: inventory.slides.map((s, i) => ({
1010
+ ...s,
1011
+ file: slideFileOrder(workspaceDir)[i] ?? null,
1012
+ })),
1013
+ },
1014
+ baseline: {
1015
+ inputs: candidate.inputs,
1016
+ order: slideFileOrder(workspaceDir),
1017
+ shared: candidate.dependencies,
1018
+ at: nowIso(),
1019
+ },
1020
+ candidate: null,
1021
+ acceptedCandidate: {
1022
+ candidateId,
1023
+ at: nowIso(),
1024
+ origin,
1025
+ ...(receipt ? { receipt: { operationId: receipt.operationId, versionId: receipt.version?.versionId ?? null } } : {}),
1026
+ },
1027
+ });
1028
+ tx.activate({ checkpoint: () => assertCheckpoint(workspaceDir, {
1029
+ owner, generation: revision.generation, digest: candidate.inputDigest,
1030
+ }) });
1031
+ return next;
1032
+ } finally { tx.dispose(); }
1033
+ }
1034
+
1035
+ /**
1036
+ * The REMOTE acceptance door: a confirmed publish receipt, and nothing else,
1037
+ * moves a remote-bound deck's accepted base.
1038
+ *
1039
+ * `acceptCandidate` refuses these decks deliberately — a local decision cannot
1040
+ * certify that a server stored anything. This is the same operation with the
1041
+ * opposite precondition: the receipt must name the candidate whose bytes the
1042
+ * server confirmed, so the base can only ever advance to what is actually there.
1043
+ */
1044
+ export function acceptCandidateFromReceipt(workspaceDir, candidateId, { owner, receipt }) {
1045
+ const revision = readRevision(workspaceDir);
1046
+ const candidate = readCandidate(workspaceDir, candidateId);
1047
+ if (!candidate) {
1048
+ const e = new Error(`no such candidate: ${candidateId}`);
1049
+ e.code = "candidate_stale";
1050
+ throw e;
1051
+ }
1052
+ if (!receipt?.candidateSha256 || receipt.candidateSha256 !== candidate.outputSha256) {
1053
+ const e = new Error(
1054
+ "the receipt does not name this candidate's bytes — the base is not advanced on evidence about a different build.",
1055
+ );
1056
+ e.code = "receipt_mismatch";
1057
+ throw e;
1058
+ }
1059
+ return advanceBaseToCandidate(workspaceDir, candidate, {
1060
+ owner,
1061
+ origin: "remote",
1062
+ revision,
1063
+ receipt,
1064
+ });
1065
+ }
1066
+
1067
+ // ---------------------------------------------------------------------------
1068
+ // staged incoming snapshots + the conflict manifest
1069
+ // ---------------------------------------------------------------------------
1070
+
1071
+ export function stagedDir(workspaceDir, revisionId) {
1072
+ return stateDir(workspaceDir, STAGED_DIR, revisionId);
1073
+ }
1074
+
1075
+ /**
1076
+ * Put incoming bytes somewhere IMMUTABLE and revision-specific. Staging never
1077
+ * touches working inputs, so a pull that lands mid-edit cannot disturb the edit.
1078
+ */
1079
+ export function stageIncoming(workspaceDir, { bytes, source }) {
1080
+ const inventory = inventoryPackage(bytes);
1081
+ // The same bytes staged twice (a pull after a publish that already staged
1082
+ // them) are one incoming revision, not two to reconcile.
1083
+ for (const id of readRevision(workspaceDir)?.staged ?? []) {
1084
+ const existing = readStaged(workspaceDir, id);
1085
+ if (existing?.sha256 === inventory.sha256) return existing;
1086
+ }
1087
+ const revisionId = `in-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
1088
+ const dir = stagedDir(workspaceDir, revisionId);
1089
+ fs.mkdirSync(dir, { recursive: true });
1090
+ fs.writeFileSync(path.join(dir, INCOMING_PPTX), Buffer.from(bytes));
1091
+ const record = {
1092
+ schemaVersion: SCHEMA_VERSION,
1093
+ revisionId,
1094
+ stagedAt: nowIso(),
1095
+ source,
1096
+ sha256: inventory.sha256,
1097
+ slideCount: inventory.slideCount,
1098
+ packageSha256: inventory.packageSha256,
1099
+ order: inventory.order,
1100
+ slides: inventory.slides,
1101
+ };
1102
+ writeJson(path.join(dir, INCOMING_FILE), record);
1103
+ const revision = readRevision(workspaceDir);
1104
+ if (revision) {
1105
+ writeRevision(workspaceDir, {
1106
+ ...revision,
1107
+ staged: [...new Set([...(revision.staged ?? []), revisionId])],
1108
+ });
1109
+ }
1110
+ return record;
1111
+ }
1112
+
1113
+ export function readStaged(workspaceDir, revisionId) {
1114
+ const data = readJson(path.join(stagedDir(workspaceDir, revisionId), INCOMING_FILE));
1115
+ return data?.schemaVersion === SCHEMA_VERSION ? data : null;
1116
+ }
1117
+
1118
+ export function stagedBytes(workspaceDir, revisionId) {
1119
+ try {
1120
+ return fs.readFileSync(path.join(stagedDir(workspaceDir, revisionId), INCOMING_PPTX));
1121
+ } catch {
1122
+ return null;
1123
+ }
1124
+ }
1125
+
1126
+ export function listStaged(workspaceDir) {
1127
+ try {
1128
+ return fs
1129
+ .readdirSync(stateDir(workspaceDir, STAGED_DIR))
1130
+ .map((id) => readStaged(workspaceDir, id))
1131
+ .filter(Boolean)
1132
+ .sort((a, b) => (a.stagedAt < b.stagedAt ? 1 : -1));
1133
+ } catch {
1134
+ return [];
1135
+ }
1136
+ }
1137
+
1138
+ export function readConflicts(workspaceDir) {
1139
+ const data = readJson(stateDir(workspaceDir, CONFLICTS_FILE));
1140
+ return data?.schemaVersion === SCHEMA_VERSION ? data : null;
1141
+ }
1142
+
1143
+ export function writeConflicts(workspaceDir, manifest) {
1144
+ const file = writeJson(stateDir(workspaceDir, CONFLICTS_FILE), {
1145
+ ...manifest,
1146
+ schemaVersion: SCHEMA_VERSION,
1147
+ });
1148
+ const revision = readRevision(workspaceDir);
1149
+ if (revision) writeRevision(workspaceDir, { ...revision, conflicts: manifest.revisionId });
1150
+ return file;
1151
+ }
1152
+
1153
+ export function clearConflicts(workspaceDir) {
1154
+ fs.rmSync(stateDir(workspaceDir, CONFLICTS_FILE), { force: true });
1155
+ const revision = readRevision(workspaceDir);
1156
+ if (revision) writeRevision(workspaceDir, { ...revision, conflicts: null });
1157
+ }
1158
+
1159
+ // ---------------------------------------------------------------------------
1160
+ // the one shape ready/status report
1161
+ // ---------------------------------------------------------------------------
1162
+
1163
+ /** Everything `ready --json` and `revision status` expose, read from disk. */
1164
+ export function revisionStatus(workspaceDir) {
1165
+ const revision = readRevision(workspaceDir);
1166
+ const dirty = computeDirty(workspaceDir, revision);
1167
+ const busy = readBusy(workspaceDir);
1168
+ const candidateId = revision?.candidate ?? null;
1169
+ const candidate = candidateId ? readCandidate(workspaceDir, candidateId) : null;
1170
+ return {
1171
+ schemaVersion: SCHEMA_VERSION,
1172
+ adopted: Boolean(revision),
1173
+ origin: revision?.origin ?? null,
1174
+ generation: revision?.generation ?? 0,
1175
+ base: revision?.base
1176
+ ? {
1177
+ sha256: revision.base.sha256,
1178
+ acceptedAt: revision.base.acceptedAt,
1179
+ slideCount: revision.base.slideCount,
1180
+ }
1181
+ : null,
1182
+ localDirty: dirty.localDirty,
1183
+ dirtySlides: dirty.dirtySlides,
1184
+ added: dirty.added,
1185
+ removed: dirty.removed,
1186
+ orderChanged: dirty.orderChanged,
1187
+ designMoved: dirty.sharedChanged,
1188
+ unverified: dirty.unverified,
1189
+ inputDigest: dirty.inputDigest,
1190
+ candidate: candidate
1191
+ ? {
1192
+ candidateId: candidate.candidateId,
1193
+ createdAt: candidate.createdAt,
1194
+ outputSha256: candidate.outputSha256,
1195
+ stale: candidateStale(workspaceDir, candidate, revision),
1196
+ }
1197
+ : null,
1198
+ candidates: listCandidates(workspaceDir).map((c) => c.candidateId),
1199
+ staged: listStaged(workspaceDir).map((s) => ({
1200
+ revisionId: s.revisionId,
1201
+ stagedAt: s.stagedAt,
1202
+ sha256: s.sha256,
1203
+ })),
1204
+ conflicts: readConflicts(workspaceDir)?.conflicts?.length ?? 0,
1205
+ owner: busy
1206
+ ? { kind: busy.kind ?? "command", command: busy.command, pid: busy.pid, startedAt: busy.startedAt }
1207
+ : null,
1208
+ journal: journalIncomplete(workspaceDir)?.op ?? null,
1209
+ };
1210
+ }