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
@@ -12,8 +12,9 @@
12
12
  import { createHash } from "node:crypto";
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
+ import { statePath, writeStateFile } from "./deck-paths.mjs";
15
16
 
16
- const SIDECAR = ".slide-guids.json";
17
+ const SIDECAR = "slide-guids.json";
17
18
 
18
19
  const sha256Hex = (data) => createHash("sha256").update(data).digest("hex");
19
20
 
@@ -28,7 +29,7 @@ export function slideGuidEntry(guid, jsx) {
28
29
  function readSidecar(deckDir) {
29
30
  let parsed;
30
31
  try {
31
- parsed = JSON.parse(fs.readFileSync(path.join(deckDir, SIDECAR), "utf8"));
32
+ parsed = JSON.parse(fs.readFileSync(statePath(deckDir, SIDECAR), "utf8"));
32
33
  } catch {
33
34
  return {};
34
35
  }
@@ -44,10 +45,7 @@ function readSidecar(deckDir) {
44
45
  }
45
46
 
46
47
  function writeSidecar(deckDir, entries) {
47
- fs.writeFileSync(
48
- path.join(deckDir, SIDECAR),
49
- `${JSON.stringify(entries, null, 2)}\n`,
50
- );
48
+ writeStateFile(deckDir, SIDECAR, `${JSON.stringify(entries, null, 2)}\n`);
51
49
  }
52
50
 
53
51
  /** { [path]: guid } — the wire shape the render routes take. */
@@ -0,0 +1,423 @@
1
+ // What a slide must still contain after an edit — the preservation record.
2
+ //
3
+ // Contract from `openspec/changes/safeguard-slide-editing/implementation-guide.md`
4
+ // section 1. Persisted at `<deck>/.slidemind/state/slide-preservation.json`,
5
+ // indexed by slideKey, and mirrored byte-for-byte into the add-in
6
+ // (`scripts/sync-transcription-runtime.mjs`) so both clients enforce one set of
7
+ // obligations.
8
+ //
9
+ // The rule this file exists to make unrepresentable: **a changed source hash
10
+ // never clears these records.** An agent that rewrites a slide has changed the
11
+ // bytes, not the promises. Dropping a requirement is only legal as an explicit,
12
+ // operation-scoped supersession — which is why the transition below compares
13
+ // two versions of the file rather than just validating one. "Clear all
14
+ // requirements" has no encoding.
15
+ //
16
+ // Pure: no disk, no mutation, no throw.
17
+
18
+ import { EDIT_OPERATION_VERSION, PROPERTY_SCOPES, operationSlideKeys } from "./edit-operations.mjs";
19
+ import { isDurableId } from "./durable-ids.mjs";
20
+
21
+ /** Sidecar name under the workspace's `state/`. */
22
+ export const SLIDE_PRESERVATION_FILE = "slide-preservation.json";
23
+
24
+ /** Same version line as the operation record; the two are written together. */
25
+ export const SLIDE_PRESERVATION_VERSION = EDIT_OPERATION_VERSION;
26
+
27
+ /**
28
+ * Where the obligations came from:
29
+ * concept — a transcribed slide; requirement ids are concept-spec element ids
30
+ * converted — an imported slide; ids are the converter's source-shape ids
31
+ * legacy-current — a deck that predates this record; seeded from CURRENT
32
+ * evidence, never from an old plan's obligations
33
+ */
34
+ export const PRESERVATION_ORIGINS = ["concept", "converted", "legacy-current"];
35
+
36
+ /**
37
+ * Requirement flavours. `element` covers an ordinary shape/text/picture;
38
+ * `connection` and `native-object` are defined by what they point AT, so both
39
+ * must carry relationships; `image-use` delegates byte/framing verification to
40
+ * the image-intent validator and only stores the canonical use id.
41
+ */
42
+ export const REQUIREMENT_KINDS = ["element", "connection", "native-object", "image-use"];
43
+
44
+ /**
45
+ * Evidence is tri-state on purpose: missing evidence is `unknown`, which is NOT
46
+ * `passed`. A record that cannot say what it verified must say so, so a final
47
+ * replacement can refuse rather than assume.
48
+ */
49
+ export const EVIDENCE_STATUSES = ["unknown", "passed", "failed", "unsupported"];
50
+
51
+ const SHA256_RE = /^[0-9a-f]{64}$/;
52
+
53
+ const err = (out, path, code, message) => out.push({ path, code, message });
54
+ const isPlainObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
55
+ const isNonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
56
+
57
+ function checkVersion(record, path, out) {
58
+ if (record.version === SLIDE_PRESERVATION_VERSION) return true;
59
+ err(
60
+ out,
61
+ `${path}.version`,
62
+ "unsupported_version",
63
+ `schema version ${JSON.stringify(record.version ?? null)} is not supported; ` +
64
+ `this build reads version ${SLIDE_PRESERVATION_VERSION}. Preserve the record as it is ` +
65
+ `and upgrade slidemind (\`slidemind update\`) before editing this deck.`,
66
+ );
67
+ return false;
68
+ }
69
+
70
+ function checkEnum(value, allowed, path, code, out) {
71
+ if (allowed.includes(value)) return true;
72
+ err(out, path, code, `${JSON.stringify(value ?? null)} is not one of ${allowed.join(" | ")}`);
73
+ return false;
74
+ }
75
+
76
+ function validateRequirement(req, path, seenIds, seenIdentities, out) {
77
+ if (!isPlainObject(req)) {
78
+ err(out, path, "invalid_record", "a requirement must be an object");
79
+ return;
80
+ }
81
+ if (!isDurableId(req.requirementId)) {
82
+ err(out, `${path}.requirementId`, "missing_field", "requirementId must be a durable identity");
83
+ } else if (seenIds.has(req.requirementId)) {
84
+ err(out, `${path}.requirementId`, "duplicate_requirement_id", `requirementId ${req.requirementId} appears twice`);
85
+ } else {
86
+ seenIds.add(req.requirementId);
87
+ }
88
+
89
+ if (!isDurableId(req.elementId)) {
90
+ err(out, `${path}.elementId`, "missing_field", "elementId must be a durable element identity");
91
+ }
92
+ const kindOk = checkEnum(req.kind, REQUIREMENT_KINDS, `${path}.kind`, "invalid_value", out);
93
+
94
+ // One element gets one requirement per kind. Two of the same is an ambiguous
95
+ // obligation: a verdict could satisfy one and violate the other.
96
+ if (isDurableId(req.elementId) && kindOk) {
97
+ const identity = `${req.kind}:${req.elementId}`;
98
+ if (seenIdentities.has(identity)) {
99
+ err(
100
+ out,
101
+ `${path}.elementId`,
102
+ "duplicate_element_id",
103
+ `element ${req.elementId} already has a ${req.kind} requirement on this slide`,
104
+ );
105
+ } else {
106
+ seenIdentities.add(identity);
107
+ }
108
+ }
109
+
110
+ if (!Array.isArray(req.properties)) {
111
+ err(out, `${path}.properties`, "missing_field", "properties must be an array of protected property scopes");
112
+ } else {
113
+ req.properties.forEach((scope, i) => {
114
+ if (!PROPERTY_SCOPES.includes(scope)) {
115
+ err(
116
+ out,
117
+ `${path}.properties[${i}]`,
118
+ "invalid_property_scope",
119
+ `${JSON.stringify(scope ?? null)} is not a known property scope (${PROPERTY_SCOPES.join(", ")})`,
120
+ );
121
+ }
122
+ });
123
+ }
124
+
125
+ const relationships = req.relationships;
126
+ if (relationships != null && !Array.isArray(relationships)) {
127
+ err(out, `${path}.relationships`, "invalid_value", "relationships must be an array");
128
+ } else if (Array.isArray(relationships)) {
129
+ relationships.forEach((rel, i) => {
130
+ const at = `${path}.relationships[${i}]`;
131
+ if (!isPlainObject(rel)) {
132
+ err(out, at, "invalid_record", "a relationship must be an object");
133
+ return;
134
+ }
135
+ if (!isNonEmptyString(rel.role)) err(out, `${at}.role`, "missing_field", "role is required (e.g. start, end, package-part)");
136
+ if (!isDurableId(rel.target)) {
137
+ err(out, `${at}.target`, "missing_field", "target must name the element id or package part it depends on");
138
+ }
139
+ });
140
+ }
141
+
142
+ // A connector without its endpoints and a native object without its package
143
+ // closure are not obligations at all — they are the exact holes E11 and E17
144
+ // walk through (same element count, different endpoint; native object whose
145
+ // dependency was repointed).
146
+ if ((req.kind === "connection" || req.kind === "native-object") && !(relationships?.length > 0)) {
147
+ err(
148
+ out,
149
+ `${path}.relationships`,
150
+ "missing_field",
151
+ `a ${req.kind} requirement must record what it is bound to; without it the obligation cannot be checked`,
152
+ );
153
+ }
154
+ if (req.kind === "image-use" && !isDurableId(req.useId)) {
155
+ err(
156
+ out,
157
+ `${path}.useId`,
158
+ "missing_field",
159
+ "an image-use requirement must name the canonical image-intent use id it delegates verification to",
160
+ );
161
+ }
162
+ }
163
+
164
+ /** Validate one slide's entry. `slideKey` is the map key it was stored under. */
165
+ export function validateSlidePreservation(entry, { slideKey = null, path = "preservation" } = {}) {
166
+ const errors = [];
167
+ if (!isPlainObject(entry)) {
168
+ err(errors, path, "invalid_record", "a preservation entry must be an object");
169
+ return { ok: false, errors };
170
+ }
171
+ if (!checkVersion(entry, path, errors)) return { ok: false, errors };
172
+
173
+ if (!isDurableId(entry.slideKey)) {
174
+ err(errors, `${path}.slideKey`, "missing_field", "slideKey must be a durable slide identity, never a position");
175
+ } else if (slideKey != null && entry.slideKey !== slideKey) {
176
+ err(
177
+ errors,
178
+ `${path}.slideKey`,
179
+ "slide_key_mismatch",
180
+ `entry is stored under ${slideKey} but claims ${entry.slideKey}; the index and the record must agree`,
181
+ );
182
+ }
183
+
184
+ checkEnum(entry.origin, PRESERVATION_ORIGINS, `${path}.origin`, "invalid_value", errors);
185
+ checkEnum(entry.evidence, EVIDENCE_STATUSES, `${path}.evidence`, "missing_field", errors);
186
+
187
+ if (!isPlainObject(entry.baseline)) {
188
+ err(errors, `${path}.baseline`, "missing_base_revision", "baseline { revision, sourceHash } is required");
189
+ } else {
190
+ if (!SHA256_RE.test(String(entry.baseline.sourceHash ?? ""))) {
191
+ err(
192
+ errors,
193
+ `${path}.baseline.sourceHash`,
194
+ "missing_base_revision",
195
+ "baseline.sourceHash (sha256 of the source these requirements were taken from) is required",
196
+ );
197
+ }
198
+ if (entry.baseline.revision != null && !isNonEmptyString(entry.baseline.revision)) {
199
+ err(errors, `${path}.baseline.revision`, "invalid_value", "baseline.revision, when present, must be a string");
200
+ }
201
+ }
202
+
203
+ if (!Array.isArray(entry.active)) {
204
+ err(errors, `${path}.active`, "missing_field", "active must be an array of requirements");
205
+ } else {
206
+ const seenIds = new Set();
207
+ const seenIdentities = new Set();
208
+ entry.active.forEach((req, i) =>
209
+ validateRequirement(req, `${path}.active[${i}]`, seenIds, seenIdentities, errors),
210
+ );
211
+
212
+ if (!Array.isArray(entry.superseded)) {
213
+ err(errors, `${path}.superseded`, "missing_field", "superseded must be an array (empty is allowed)");
214
+ } else {
215
+ const seenSuperseded = new Set();
216
+ entry.superseded.forEach((s, i) => {
217
+ const at = `${path}.superseded[${i}]`;
218
+ if (!isPlainObject(s)) {
219
+ err(errors, at, "invalid_record", "a supersession must be an object");
220
+ return;
221
+ }
222
+ if (!isDurableId(s.requirementId)) {
223
+ err(errors, `${at}.requirementId`, "missing_field", "requirementId is required");
224
+ } else if (seenSuperseded.has(s.requirementId)) {
225
+ err(errors, `${at}.requirementId`, "duplicate_requirement_id", `${s.requirementId} is superseded twice`);
226
+ } else {
227
+ seenSuperseded.add(s.requirementId);
228
+ }
229
+ if (!isNonEmptyString(s.operationId)) {
230
+ err(
231
+ errors,
232
+ `${at}.operationId`,
233
+ "missing_field",
234
+ "a supersession names the operation the user authorized it in",
235
+ );
236
+ }
237
+ if (!isNonEmptyString(s.reason)) {
238
+ err(errors, `${at}.reason`, "missing_field", "a supersession states why the obligation ended");
239
+ }
240
+ if (seenIds.has(s.requirementId)) {
241
+ err(
242
+ errors,
243
+ `${at}.requirementId`,
244
+ "superseded_still_active",
245
+ `${s.requirementId} is listed as both active and superseded`,
246
+ );
247
+ }
248
+ });
249
+ }
250
+ }
251
+
252
+ return { ok: errors.length === 0, errors };
253
+ }
254
+
255
+ /** Validate the whole sidecar: `{ version, slides: { [slideKey]: entry } }`. */
256
+ export function validateSlidePreservationFile(doc, { path = "slide-preservation" } = {}) {
257
+ const errors = [];
258
+ if (!isPlainObject(doc)) {
259
+ err(errors, path, "invalid_record", "the preservation file must be an object");
260
+ return { ok: false, errors };
261
+ }
262
+ if (!checkVersion(doc, path, errors)) return { ok: false, errors };
263
+ if (!isPlainObject(doc.slides)) {
264
+ err(errors, `${path}.slides`, "missing_field", "slides must be an object indexed by slideKey");
265
+ return { ok: false, errors };
266
+ }
267
+ for (const [slideKey, entry] of Object.entries(doc.slides)) {
268
+ errors.push(...validateSlidePreservation(entry, { slideKey, path: `${path}.slides[${slideKey}]` }).errors);
269
+ }
270
+ return { ok: errors.length === 0, errors };
271
+ }
272
+
273
+ /**
274
+ * The transition rule: what `next` is allowed to have dropped relative to
275
+ * `previous`.
276
+ *
277
+ * `next` must validate on its own, AND every obligation `previous` held must
278
+ * still be there — unless `next` explicitly supersedes it, naming the operation
279
+ * that authorized it, and unless that operation is actually scoped to the
280
+ * slide. A changed `baseline.sourceHash` is expressly NOT a reason: source
281
+ * changes on every edit, which is why "the file was rewritten" can never be the
282
+ * argument for dropping a promise.
283
+ *
284
+ * @param {unknown} previous the file as it is on disk (null/absent is allowed)
285
+ * @param {unknown} next the file the caller wants to write
286
+ * @param {{ operation?: object }} [opts] the operation authorizing the write
287
+ */
288
+ export function validatePreservationUpdate(previous, next, { operation = null, path = "slide-preservation" } = {}) {
289
+ const errors = [...validateSlidePreservationFile(next, { path }).errors];
290
+ const before = isPlainObject(previous) && isPlainObject(previous.slides) ? previous.slides : {};
291
+ const after = isPlainObject(next) && isPlainObject(next.slides) ? next.slides : {};
292
+ const scope = operation ? new Set(operationSlideKeys(operation)) : null;
293
+ const operationId = isPlainObject(operation) ? operation.operationId : null;
294
+
295
+ for (const [slideKey, prevEntry] of Object.entries(before)) {
296
+ const nextEntry = after[slideKey];
297
+ if (!isPlainObject(nextEntry)) {
298
+ err(
299
+ errors,
300
+ `${path}.slides[${slideKey}]`,
301
+ "entry_dropped",
302
+ `the preservation record for ${slideKey} disappeared; requirements outlive the source they were taken from`,
303
+ );
304
+ continue;
305
+ }
306
+ const activeNow = new Set(
307
+ (Array.isArray(nextEntry.active) ? nextEntry.active : []).map((r) => (isPlainObject(r) ? r.requirementId : null)),
308
+ );
309
+ const supersededNow = new Map(
310
+ (Array.isArray(nextEntry.superseded) ? nextEntry.superseded : [])
311
+ .filter(isPlainObject)
312
+ .map((s) => [s.requirementId, s]),
313
+ );
314
+ const supersededBefore = new Set(
315
+ (Array.isArray(prevEntry.superseded) ? prevEntry.superseded : [])
316
+ .filter(isPlainObject)
317
+ .map((s) => s.requirementId),
318
+ );
319
+
320
+ for (const req of Array.isArray(prevEntry.active) ? prevEntry.active : []) {
321
+ if (!isPlainObject(req) || !isDurableId(req.requirementId)) continue;
322
+ if (activeNow.has(req.requirementId)) continue;
323
+ const supersession = supersededNow.get(req.requirementId);
324
+ if (!supersession) {
325
+ err(
326
+ errors,
327
+ `${path}.slides[${slideKey}].active`,
328
+ "requirement_dropped",
329
+ `requirement ${req.requirementId} was removed without being superseded; ` +
330
+ `a rewritten slide does not clear its obligations — supersede it in an operation the user asked for`,
331
+ );
332
+ continue;
333
+ }
334
+ if (operationId && supersession.operationId !== operationId) {
335
+ err(
336
+ errors,
337
+ `${path}.slides[${slideKey}].superseded`,
338
+ "supersede_unscoped",
339
+ `requirement ${req.requirementId} claims operation ${supersession.operationId}, ` +
340
+ `but this write is operation ${operationId}`,
341
+ );
342
+ }
343
+ if (scope && !scope.has(slideKey)) {
344
+ err(
345
+ errors,
346
+ `${path}.slides[${slideKey}].superseded`,
347
+ "supersede_unscoped",
348
+ `slide ${slideKey} is not a target of this operation, so its requirements cannot be superseded by it`,
349
+ );
350
+ }
351
+ }
352
+
353
+ // A supersession must point at something that was actually owed.
354
+ for (const [requirementId] of supersededNow) {
355
+ const wasActive = (Array.isArray(prevEntry.active) ? prevEntry.active : []).some(
356
+ (r) => isPlainObject(r) && r.requirementId === requirementId,
357
+ );
358
+ if (!wasActive && !supersededBefore.has(requirementId)) {
359
+ err(
360
+ errors,
361
+ `${path}.slides[${slideKey}].superseded`,
362
+ "supersede_unknown",
363
+ `requirement ${requirementId} was never active on ${slideKey}`,
364
+ );
365
+ }
366
+ }
367
+ }
368
+
369
+ return { ok: errors.length === 0, errors };
370
+ }
371
+
372
+ /**
373
+ * One target's requirement amendment — the only legal way an obligation leaves
374
+ * or a requested addition arrives (guide 5.2), shared so both clients retire a
375
+ * declared removal the same way. Pure: returns the next entry plus what moved.
376
+ *
377
+ * remove supersede every requirement on that element, naming the operation
378
+ * and the base it was declared against
379
+ * add the requested element becomes a requirement in its own right; a
380
+ * re-add gets a NEW id (superseded ids are append-only)
381
+ * update nothing here — a permission, read by the verdict, never a narrowing
382
+ *
383
+ * Idempotent: a removal already retired and an addition already owed are
384
+ * skipped, so applying it again (every render, in the add-in) changes nothing.
385
+ */
386
+ export function amendForOperation(entry, target, operation, { at = new Date().toISOString() } = {}) {
387
+ const changes = (Array.isArray(target?.changes) ? target.changes : []).filter((c) => isDurableId(c?.elementId));
388
+ let active = [...(entry?.active ?? [])];
389
+ const superseded = [...(entry?.superseded ?? [])];
390
+ const moved = { superseded: [], added: [] };
391
+ for (const change of changes) {
392
+ if (change.action === "remove") {
393
+ for (const req of active.filter((r) => r.elementId === change.elementId)) {
394
+ superseded.push({
395
+ requirementId: req.requirementId,
396
+ operationId: operation.operationId,
397
+ reason: `removed on request: ${operation.requestSummary}`,
398
+ baseSourceHash: target.baseSourceHash ?? null,
399
+ amendedAt: at,
400
+ });
401
+ moved.superseded.push(req.requirementId);
402
+ }
403
+ active = active.filter((r) => r.elementId !== change.elementId);
404
+ } else if (change.action === "add") {
405
+ if (active.some((r) => r.elementId === change.elementId)) continue;
406
+ const retired = new Set(superseded.map((s) => s.requirementId));
407
+ let requirementId = `req-element-${change.elementId}`;
408
+ if (retired.has(requirementId)) requirementId = `${requirementId}@${operation.operationId}`;
409
+ // Added once per operation: a second pass finds it active and skips it.
410
+ if (retired.has(requirementId)) continue;
411
+ active.push({
412
+ requirementId,
413
+ elementId: change.elementId,
414
+ kind: "element",
415
+ properties: [...(change.properties ?? [])],
416
+ requestedBy: operation.operationId,
417
+ });
418
+ moved.added.push(requirementId);
419
+ }
420
+ }
421
+ if (moved.superseded.length === 0 && moved.added.length === 0) return { entry, ...moved };
422
+ return { entry: { ...entry, active, superseded }, ...moved };
423
+ }
@@ -0,0 +1,9 @@
1
+ // A resolved --only selection is an allowlist, never permission to fall back
2
+ // to the entire deck. Preserve deck order and reject typos before any writes.
3
+ export function selectSlideFiles(files, only = null) {
4
+ if (only == null) return files;
5
+ if (!Array.isArray(only) || !only.length) throw new Error("--only requires at least one slide file");
6
+ const missing = only.filter((name) => !files.includes(name));
7
+ if (missing.length) throw new Error(`--only: unknown slide file(s): ${missing.join(", ")}`);
8
+ return files.filter((name) => only.includes(name));
9
+ }
@@ -0,0 +1,117 @@
1
+ // Stage a complete generation, then activate with a durable undo log. Readers
2
+ // refuse an unfinished activation; the next owner rolls it back before reading.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ const hash = b => createHash('sha256').update(b).digest('hex');
8
+ const durableWrite = (file, bytes) => { const fd = fs.openSync(file, 'w'); try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } };
9
+ const marker = ws => path.join(ws, 'state', 'activation.json');
10
+ const excluded = rel => /^(?:state\/(?:busy\.json|activation\.json|activations(?:\/|$)|staged(?:\/|$)|candidates(?:\/|$)))/.test(rel);
11
+ function files(root, prefix = '', out = new Map()) {
12
+ if (!fs.existsSync(root)) return out;
13
+ for (const e of fs.readdirSync(root, { withFileTypes: true })) {
14
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
15
+ if (excluded(rel)) continue;
16
+ if (e.isSymbolicLink()) throw new Error(`Cannot stage symbolic link ${rel}`);
17
+ if (e.isDirectory()) files(path.join(root, e.name), rel, out);
18
+ else out.set(rel, fs.readFileSync(path.join(root, e.name)));
19
+ }
20
+ return out;
21
+ }
22
+ export function assertNoActivation(ws) {
23
+ if (fs.existsSync(marker(ws))) throw Object.assign(new Error('An interrupted workspace activation needs recovery by the next deck owner.'), { code: 'activation_incomplete' });
24
+ }
25
+ export function recoverActivation(ws) {
26
+ if (!fs.existsSync(marker(ws))) return false;
27
+ const record = JSON.parse(fs.readFileSync(marker(ws), 'utf8'));
28
+ if (!/^[0-9a-f-]{36}$/.test(record.id) || !Array.isArray(record.entries)) throw new Error('Invalid activation journal');
29
+ for (const entry of record.entries) {
30
+ if (!entry || typeof entry.rel !== 'string' || (!/^\.\.\/(?:[^/\\]+\.xlsx|plan\.html)$/i.test(entry.rel) && (path.isAbsolute(entry.rel) || entry.rel.split(/[\\/]/).includes('..') || excluded(entry.rel))) || !/^\d+$/.test(entry.backup)) throw new Error('Unsafe activation journal path');
31
+ const backup = path.join(ws, 'state', 'activations', record.id, entry.backup);
32
+ if (entry.existed && hash(fs.readFileSync(backup)) !== entry.beforeHash) throw new Error('Activation backup is corrupt; recovery refused');
33
+ const target = path.resolve(ws, entry.rel);
34
+ const currentHash = fs.existsSync(target) ? hash(fs.readFileSync(target)) : null;
35
+ if (currentHash !== entry.beforeHash && currentHash !== entry.afterHash) throw new Error('A file changed after the interrupted activation; recovery needs reconciliation');
36
+ for (let parent = path.dirname(target); parent !== path.dirname(ws); parent = path.dirname(parent)) {
37
+ if (fs.existsSync(parent) && fs.lstatSync(parent).isSymbolicLink()) throw new Error('Activation recovery refuses a symbolic link');
38
+ if (parent === path.dirname(parent)) throw new Error('Activation target escapes the deck');
39
+ }
40
+ }
41
+ for (const entry of record.entries) {
42
+ const target = path.resolve(ws, entry.rel);
43
+ if (entry.existed) {
44
+ fs.mkdirSync(path.dirname(target), { recursive: true });
45
+ fs.copyFileSync(path.join(ws, 'state', 'activations', record.id, entry.backup), target);
46
+ } else fs.rmSync(target, { force: true });
47
+ }
48
+ fs.rmSync(marker(ws));
49
+ return true;
50
+ }
51
+ export function stageWorkspace(ws) {
52
+ assertNoActivation(ws);
53
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'slidemind-generation-'));
54
+ const staged = path.join(root, '.slidemind');
55
+ fs.mkdirSync(staged, { recursive: true });
56
+ const original = files(ws);
57
+ // Workbook artifacts may live exactly one directory above the workspace.
58
+ if (path.basename(ws) === '.slidemind') {
59
+ for (const e of fs.readdirSync(path.dirname(ws), { withFileTypes: true })) {
60
+ if (e.isFile() && (/\.xlsx$/i.test(e.name) || e.name === 'plan.html')) original.set(`../${e.name}`, fs.readFileSync(path.join(ws, '..', e.name)));
61
+ }
62
+ }
63
+ for (const [rel, bytes] of original) {
64
+ const target = path.resolve(staged, rel);
65
+ fs.mkdirSync(path.dirname(target), { recursive: true });
66
+ fs.writeFileSync(target, bytes);
67
+ }
68
+ return {
69
+ workspace: staged,
70
+ copySnapshot(revisionId) {
71
+ if (!/^in-[a-zA-Z0-9-]+$/.test(revisionId)) throw new Error('Invalid staged snapshot id');
72
+ fs.cpSync(path.join(ws, 'state', 'staged', revisionId), path.join(staged, 'state', 'staged', revisionId), { recursive: true });
73
+ const busy = path.join(ws, 'state', 'busy.json');
74
+ if (fs.existsSync(busy)) fs.copyFileSync(busy, path.join(staged, 'state', 'busy.json'));
75
+ },
76
+ dispose: () => fs.rmSync(root, { recursive: true, force: true }),
77
+ activate({ checkpoint = () => {}, fault = () => {} } = {}) {
78
+ checkpoint();
79
+ const current = files(ws);
80
+ for (const [rel] of original) if (rel.startsWith('../') && fs.existsSync(path.resolve(ws, rel))) current.set(rel, fs.readFileSync(path.resolve(ws, rel)));
81
+ if (current.size !== original.size || [...original].some(([rel, bytes]) => !current.has(rel) || hash(current.get(rel)) !== hash(bytes))) {
82
+ throw Object.assign(new Error('Workspace changed while staging; nothing activated.'), { code: 'generation_moved' });
83
+ }
84
+ const ownerPath = path.join(staged, 'state', 'plan-owner.json');
85
+ if (fs.existsSync(ownerPath)) {
86
+ const owner = JSON.parse(fs.readFileSync(ownerPath, 'utf8'));
87
+ if (typeof owner.reviewCommand === 'string') owner.reviewCommand = owner.reviewCommand.replace(root, path.dirname(ws));
88
+ fs.writeFileSync(ownerPath, JSON.stringify(owner, null, 2) + '\n');
89
+ }
90
+ const next = files(staged);
91
+ for (const e of fs.readdirSync(root, { withFileTypes: true })) if (e.isFile() && (/\.xlsx$/i.test(e.name) || e.name === 'plan.html')) next.set(`../${e.name}`, fs.readFileSync(path.join(root, e.name)));
92
+ const changes = [...new Set([...original.keys(), ...next.keys()])].filter(rel => !original.has(rel) || !next.has(rel) || hash(original.get(rel)) !== hash(next.get(rel)));
93
+ const id = randomUUID();
94
+ const backupDir = path.join(ws, 'state', 'activations', id);
95
+ fs.mkdirSync(backupDir, { recursive: true });
96
+ const entries = changes.map((rel, i) => {
97
+ const existed = original.has(rel);
98
+ if (existed) durableWrite(path.join(backupDir, String(i)), original.get(rel));
99
+ return { rel, existed, backup: String(i), beforeHash: existed ? hash(original.get(rel)) : null, afterHash: next.has(rel) ? hash(next.get(rel)) : null };
100
+ });
101
+ durableWrite(`${marker(ws)}.tmp`, JSON.stringify({ id, entries }));
102
+ fs.renameSync(`${marker(ws)}.tmp`, marker(ws));
103
+ try {
104
+ for (const entry of entries) {
105
+ const target = path.resolve(ws, entry.rel);
106
+ if (next.has(entry.rel)) {
107
+ fs.mkdirSync(path.dirname(target), { recursive: true });
108
+ durableWrite(`${target}.activation-tmp`, next.get(entry.rel));
109
+ fs.renameSync(`${target}.activation-tmp`, target);
110
+ } else fs.rmSync(target, { force: true });
111
+ fault(entry.rel);
112
+ }
113
+ fs.rmSync(marker(ws));
114
+ } catch (err) { recoverActivation(ws); throw err; }
115
+ },
116
+ };
117
+ }