slidemind 0.3.20 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/render.mjs +27 -1
  16. package/src/bridge/client/transport.mjs +499 -23
  17. package/src/bridge/client.mjs +10 -0
  18. package/src/bridge/config.mjs +67 -6
  19. package/src/bridge/engine-download.mjs +0 -10
  20. package/src/bridge/graph-auth.mjs +55 -3
  21. package/src/bridge/image-intent-envelope.mjs +239 -0
  22. package/src/bridge/package-preview.mjs +67 -0
  23. package/src/bridge/sharepoint.mjs +914 -0
  24. package/src/builders/asset-fetch.mjs +203 -0
  25. package/src/builders/block-dividers.mjs +83 -0
  26. package/src/builders/build.mjs +171 -87
  27. package/src/builders/card-height.mjs +294 -0
  28. package/src/builders/chart-hole.mjs +183 -0
  29. package/src/builders/chart-jsx.mjs +410 -0
  30. package/src/builders/chart-labels.mjs +326 -0
  31. package/src/builders/concept-geometry.mjs +370 -0
  32. package/src/builders/concept-intent.mjs +200 -0
  33. package/src/builders/concept-layout.mjs +79 -0
  34. package/src/builders/concept-lines.mjs +509 -0
  35. package/src/builders/concept-lists.mjs +270 -0
  36. package/src/builders/concept-marks.mjs +84 -0
  37. package/src/builders/concept-relationships.mjs +12 -0
  38. package/src/builders/concept-spec.mjs +645 -0
  39. package/src/builders/concept-text.mjs +140 -0
  40. package/src/builders/concept-waves.mjs +57 -0
  41. package/src/builders/copy-fit.mjs +139 -0
  42. package/src/builders/design-tokens.mjs +347 -0
  43. package/src/builders/draft-check.mjs +552 -340
  44. package/src/builders/draft-lines.mjs +369 -0
  45. package/src/builders/edit-route.mjs +29 -0
  46. package/src/builders/figure-sketch.mjs +375 -0
  47. package/src/builders/fold-sets.mjs +639 -0
  48. package/src/builders/gate-server.mjs +9 -4
  49. package/src/builders/gate-session.js +273 -12
  50. package/src/builders/gate-shell.js +1494 -0
  51. package/src/builders/image-intent-check.mjs +308 -0
  52. package/src/builders/image-use-reconcile.mjs +107 -0
  53. package/src/builders/kit-helpers.mjs +121 -0
  54. package/src/builders/layout-names.mjs +32 -0
  55. package/src/builders/legacy-reference-uses.mjs +179 -0
  56. package/src/builders/line-fit.mjs +224 -0
  57. package/src/builders/line-native.mjs +895 -0
  58. package/src/builders/line-relations.mjs +230 -0
  59. package/src/builders/lint-fix.mjs +117 -9
  60. package/src/builders/lint.mjs +25 -20
  61. package/src/builders/list-jsx.mjs +88 -0
  62. package/src/builders/placement.mjs +133 -0
  63. package/src/builders/plan-checks.mjs +135 -0
  64. package/src/builders/plan-comments.mjs +19 -0
  65. package/src/builders/plan-handlers.mjs +95 -0
  66. package/src/builders/plan-markdown-edit.mjs +3 -3
  67. package/src/builders/plan-overlay.js +1530 -1608
  68. package/src/builders/plan.mjs +689 -186
  69. package/src/builders/preset-definitions.json +1 -0
  70. package/src/builders/preset-eval.mjs +244 -0
  71. package/src/builders/preview.html +51 -120
  72. package/src/builders/red-thread-backbone.mjs +135 -36
  73. package/src/builders/repair.mjs +205 -0
  74. package/src/builders/repeat-groups.mjs +540 -0
  75. package/src/builders/resolve-skill.mjs +5 -4
  76. package/src/builders/review-overlay.js +296 -293
  77. package/src/builders/review.mjs +305 -84
  78. package/src/builders/rule-shift.mjs +96 -0
  79. package/src/builders/seat.mjs +354 -0
  80. package/src/builders/settle-driver.mjs +236 -0
  81. package/src/builders/settle.mjs +1448 -0
  82. package/src/builders/slide-prep.mjs +67 -0
  83. package/src/builders/spec-assets.mjs +236 -0
  84. package/src/builders/spec-match.mjs +56 -0
  85. package/src/builders/stack.mjs +413 -0
  86. package/src/builders/storyboard-markdown.mjs +70 -27
  87. package/src/builders/storyboard-schema.mjs +54 -114
  88. package/src/builders/text-blocks.mjs +212 -0
  89. package/src/builders/text-card-fit.mjs +552 -0
  90. package/src/builders/text-stack.mjs +276 -0
  91. package/src/builders/transcribe.mjs +1069 -0
  92. package/src/cli/assets.mjs +164 -0
  93. package/src/cli/common.mjs +173 -12
  94. package/src/cli/concept-spec-support.mjs +76 -0
  95. package/src/cli/concepts.mjs +1002 -103
  96. package/src/cli/deck.mjs +401 -65
  97. package/src/cli/diagnose.mjs +4 -2
  98. package/src/cli/engine-helpers.mjs +17 -10
  99. package/src/cli/engine-results.mjs +76 -3
  100. package/src/cli/help.mjs +232 -28
  101. package/src/cli/install.mjs +9 -0
  102. package/src/cli/maintenance.mjs +104 -0
  103. package/src/cli/read.mjs +2 -1
  104. package/src/cli/ready.mjs +6 -3
  105. package/src/cli/record.mjs +522 -0
  106. package/src/cli/remote.mjs +818 -0
  107. package/src/cli/revision.mjs +686 -0
  108. package/src/cli/search.mjs +30 -11
  109. package/src/cli/session.mjs +56 -13
  110. package/src/cli/setup.mjs +46 -2
  111. package/src/cli/status.mjs +142 -33
  112. package/src/cli/tasks.mjs +246 -0
  113. package/src/cli/transcribe.mjs +854 -0
  114. package/src/cli/workspace-ui.mjs +260 -0
  115. package/src/cli/workspace.mjs +212 -34
  116. package/src/install-channel.mjs +64 -7
  117. package/src/lib/adopt.mjs +346 -25
  118. package/src/lib/concept-spec-binding.mjs +96 -0
  119. package/src/lib/conversions.mjs +70 -6
  120. package/src/lib/convert-folded.mjs +107 -0
  121. package/src/lib/deck-paths.mjs +418 -0
  122. package/src/lib/durable-ids.mjs +47 -0
  123. package/src/lib/edit-operations.mjs +316 -0
  124. package/src/lib/edit-state.mjs +1244 -0
  125. package/src/lib/edit-transitions.mjs +120 -0
  126. package/src/lib/extensions.mjs +501 -0
  127. package/src/lib/image-assets.mjs +465 -0
  128. package/src/lib/image-derivatives.mjs +234 -0
  129. package/src/lib/image-intent-fingerprint.mjs +70 -0
  130. package/src/lib/image-intent-tags.mjs +176 -0
  131. package/src/lib/image-intent.mjs +802 -0
  132. package/src/lib/image-use-overrides.mjs +279 -0
  133. package/src/lib/inline-components.mjs +613 -0
  134. package/src/lib/lint-converted.mjs +68 -4
  135. package/src/lib/merge-json.mjs +163 -0
  136. package/src/lib/overwrite-guard.mjs +3 -3
  137. package/src/lib/presentation-context.mjs +531 -0
  138. package/src/lib/preservation-tags.mjs +157 -0
  139. package/src/lib/preservation-verdict.mjs +1342 -0
  140. package/src/lib/publish.mjs +1557 -0
  141. package/src/lib/rebase.mjs +945 -0
  142. package/src/lib/reconcile-ack.mjs +4 -6
  143. package/src/lib/reconcile.mjs +62 -24
  144. package/src/lib/record-client.mjs +175 -0
  145. package/src/lib/record-state.mjs +151 -0
  146. package/src/lib/remote.mjs +661 -0
  147. package/src/lib/revision.mjs +1210 -0
  148. package/src/lib/slide-guids.mjs +4 -6
  149. package/src/lib/slide-preservation.mjs +423 -0
  150. package/src/lib/slide-selection.mjs +9 -0
  151. package/src/lib/workspace-transaction.mjs +117 -0
  152. package/src/pptx/content-digest.mjs +334 -0
  153. package/src/pptx/deck-assemble.mjs +1278 -0
  154. package/src/pptx/fill/engine.mjs +2 -3
  155. package/src/pptx/fill/freeform.mjs +6 -3
  156. package/src/pptx/ooxml.mjs +20 -0
  157. package/src/pptx/package-evidence.mjs +383 -0
  158. package/src/pptx/tags.mjs +163 -11
  159. package/src/update-check.mjs +22 -4
  160. package/src/workspace/adapters.mjs +222 -0
  161. package/src/workspace/auth.mjs +256 -0
  162. package/src/workspace/autostart.mjs +204 -0
  163. package/src/workspace/daemon.mjs +360 -0
  164. package/src/workspace/discovery.mjs +180 -0
  165. package/src/workspace/edit.mjs +167 -0
  166. package/src/workspace/events.mjs +52 -0
  167. package/src/workspace/gate-client.mjs +290 -0
  168. package/src/workspace/handback.mjs +73 -0
  169. package/src/workspace/lifecycle.mjs +635 -0
  170. package/src/workspace/operations.mjs +200 -0
  171. package/src/workspace/pick-folder.mjs +152 -0
  172. package/src/workspace/registry.mjs +359 -0
  173. package/src/workspace/remote-links.mjs +397 -0
  174. package/src/workspace/remote-status.mjs +359 -0
  175. package/src/workspace/routes.mjs +1331 -0
  176. package/src/workspace/server.mjs +159 -0
  177. package/src/workspace/sync.mjs +1119 -0
  178. package/src/workspace/task-actions.mjs +215 -0
  179. package/src/workspace/tasks.mjs +507 -0
  180. package/src/workspace/ui/app.js +1886 -0
  181. package/src/workspace/ui/index.html +141 -0
  182. package/src/workspace/ui/plan-bridge.js +120 -0
  183. package/src/workspace/ui/sharepoint.svg +59 -0
  184. package/src/workspace/ui/styles.css +423 -0
  185. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,661 @@
1
+ import { assertNoActivation } from './workspace-transaction.mjs';
2
+ // The remote half of a deck's revision state: which item it publishes to, what
3
+ // this machine last observed there, what a publish is allowed to do, and the
4
+ // durable record an interrupted write is recovered from.
5
+ //
6
+ // Everything here is SYNC and filesystem-only — the same shape contract as
7
+ // `revision.mjs`, and for the same reason: a pack and the provider
8
+ // client both read these records and cannot import each other.
9
+ //
10
+ // state/remote.json the binding + capabilities + last receipt
11
+ // state/operations/<operationId>.json journaled BEFORE any external write
12
+ // state/receipts/<operationId>.json written only on a CONFIRMED commit
13
+ //
14
+ // Three rules the file exists to keep:
15
+ //
16
+ // 1. **Physical identity is the binding.** provider/tenant/host/driveId/itemId
17
+ // select the target; name/webUrl/path are labels that a rename changes and
18
+ // nothing routes on. A tag inside a .pptx never redirects a publish.
19
+ // 2. **Journal before the write, receipt after the confirmation.** An
20
+ // operation record exists before the first byte leaves; the receipt exists
21
+ // only once the server has named the version it committed. A crash between
22
+ // the two is `unknown_outcome`, which is INSPECTED, never retried blindly.
23
+ // 3. **Policy, grant, route support and item access are four things.** A
24
+ // readable item is not a writable one, a 403 is not a consent verdict, and
25
+ // an unproved conditional route disables automatic publication even when
26
+ // everything else is allowed.
27
+
28
+ import { randomUUID } from "node:crypto";
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+
32
+ export const SCHEMA_VERSION = 1;
33
+
34
+ export const REMOTE_FILE = "remote.json";
35
+ export const OPERATIONS_DIR = "operations";
36
+ export const RECEIPTS_DIR = "receipts";
37
+
38
+ /**
39
+ * Every typed reason a remote operation can be blocked or degraded by, from the
40
+ * design's §1 list. `publish`/`pull`/`ready` return ALL that apply and pick one
41
+ * stable primary — a caller that switches on a single string still gets a
42
+ * deterministic answer, and a caller that renders the list gets the whole truth.
43
+ *
44
+ * Ordered by how much they decide: the earlier reason is the primary one.
45
+ */
46
+ export const REASONS = [
47
+ // the deck is not pointed anywhere / cannot be pointed anywhere
48
+ "identity_unresolved",
49
+ "remote_missing",
50
+ "context_missing",
51
+ // deployment or tenant policy said no before any request was made
52
+ "remote_saving_disabled",
53
+ "write_scope_missing",
54
+ "consent_required",
55
+ // the provider refused THIS item, or an artifact's exact version cannot be pinned
56
+ "target_access_denied",
57
+ "conditional_write_unsupported",
58
+ // a required local gate has not passed
59
+ "extension_missing",
60
+ "extension_incompatible",
61
+ "validation_required",
62
+ "needs_reconcile",
63
+ // a previous operation left the world in a state only inspection resolves
64
+ "unknown_outcome",
65
+ "snapshot_raced",
66
+ // transient
67
+ "throttled",
68
+ "offline",
69
+ ];
70
+
71
+ /** Capability answers are three-valued: never infer the third from the other two. */
72
+ export const SUPPORT = ["supported", "unsupported", "unknown"];
73
+ export const ACCESS = ["allowed", "denied", "unknown"];
74
+
75
+ /** The routes a provider adapter reports on, independently. */
76
+ export const ROUTES = [
77
+ "read",
78
+ "create",
79
+ "rename",
80
+ "conditionalSmall",
81
+ "conditionalLarge",
82
+ "exactVersionRead",
83
+ ];
84
+
85
+ /**
86
+ * What a deck may do with its remote copy right now.
87
+ *
88
+ * - `automatic` — a protected publish may be ATTEMPTED. Not a promise it will
89
+ * succeed: item write access can still be `unknown` until the write happens.
90
+ * - `read-only` — reads work, managed writes are ineligible.
91
+ * - `manual` — reads are unavailable; the deck exports a validated handoff.
92
+ * - `blocked` — a local gate must clear first (reconcile, extension, unknown
93
+ * outcome); nothing remote is attempted.
94
+ */
95
+ export const MODES = ["automatic", "read-only", "manual", "blocked"];
96
+
97
+ const nowIso = () => new Date().toISOString();
98
+
99
+ function stateDir(workspaceDir, ...sub) {
100
+ return path.join(workspaceDir, "state", ...sub);
101
+ }
102
+
103
+ function readJson(file) {
104
+ try {
105
+ return JSON.parse(fs.readFileSync(file, "utf8"));
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ // Write-then-rename: a kill mid-write must not leave half a JSON record where
112
+ // the operation journal is supposed to be.
113
+ function writeJson(file, data) {
114
+ fs.mkdirSync(path.dirname(file), { recursive: true });
115
+ const tmp = `${file}.${process.pid}.tmp`;
116
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
117
+ fs.renameSync(tmp, file);
118
+ return file;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // the binding
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * The physical identity of one stored document, as one comparable string.
127
+ *
128
+ * This is what a portable context's `documentInstance` carries and what a cold
129
+ * open compares against the item the user's own link resolved to. Name, path and
130
+ * webUrl are deliberately absent: a rename must not read as a different document,
131
+ * and a moved file inside one drive is still the same file.
132
+ */
133
+ export function documentInstance({ provider, tenant, driveId, itemId }) {
134
+ if (!provider || !tenant || !driveId || !itemId) return null;
135
+ return `${provider}:${tenant}:${driveId}:${itemId}`;
136
+ }
137
+
138
+ export function remotePath(workspaceDir) {
139
+ return stateDir(workspaceDir, REMOTE_FILE);
140
+ }
141
+
142
+ export function readRemote(workspaceDir) {
143
+ assertNoActivation(workspaceDir);
144
+ const data = readJson(remotePath(workspaceDir));
145
+ return data?.schemaVersion === SCHEMA_VERSION ? data : null;
146
+ }
147
+
148
+ /**
149
+ * `destination.json` as a create target, or null. Folder identity plus the
150
+ * saved presentation name — the folder the user connected in the workspace
151
+ * (src/workspace/remote-status.mjs writes it), where the first publish creates
152
+ * the file. A malformed or nameless file is ignored here — the workspace card
153
+ * reports it, publish just behaves as if no folder was chosen.
154
+ */
155
+ export function savedDestination(workspaceDir) {
156
+ const saved = readJson(stateDir(workspaceDir, "destination.json"));
157
+ if (saved?.schemaVersion !== 1 || !saved.driveId || !saved.itemId || !saved.pptxName) return null;
158
+ if (!/\.pptx$/i.test(saved.pptxName) || /[\\/]/.test(saved.pptxName)) return null;
159
+ return { driveId: saved.driveId, parentId: saved.itemId, name: saved.pptxName, backendURL: saved.backendURL ?? null };
160
+ }
161
+
162
+ export function writeRemote(workspaceDir, remote) {
163
+ const record = { ...remote, schemaVersion: SCHEMA_VERSION, updatedAt: nowIso() };
164
+ writeJson(remotePath(workspaceDir), record);
165
+ return record;
166
+ }
167
+
168
+ /**
169
+ * Point a deck at an item. `identity` is the physical half and is never merged
170
+ * from an existing record — binding somewhere else is a REBIND, which the caller
171
+ * decides explicitly; this only refuses to do it silently.
172
+ *
173
+ * Throws `rebind_required` when a different item is already bound, so no caller
174
+ * can repoint a deck by forgetting to ask.
175
+ */
176
+ export function bindRemote(
177
+ workspaceDir,
178
+ { identity, labels = {}, rebind = false, ...rest },
179
+ ) {
180
+ const instance = documentInstance(identity);
181
+ if (!instance) {
182
+ const e = new Error("a remote binding needs provider, tenant, driveId and itemId");
183
+ e.code = "identity_unresolved";
184
+ throw e;
185
+ }
186
+ const existing = readRemote(workspaceDir);
187
+ if (existing && existing.documentInstance !== instance && !rebind) {
188
+ const e = new Error(
189
+ `this deck is bound to ${existing.name ?? existing.documentInstance}. ` +
190
+ `Publishing it somewhere else is a rebind — pass --rebind to confirm, or open the file ` +
191
+ `into a fresh deck to keep both.`,
192
+ );
193
+ e.code = "rebind_required";
194
+ e.bound = existing;
195
+ throw e;
196
+ }
197
+ // A rebind starts a clean remote record: the old item's accepted eTag, its
198
+ // committed refs and its pending operation describe a DIFFERENT file, and
199
+ // carrying any of them forward would let a publish send an If-Match that
200
+ // belongs to someone else's document.
201
+ const carried = existing && existing.documentInstance === instance ? existing : {};
202
+ return writeRemote(workspaceDir, {
203
+ ...carried,
204
+ ...identity,
205
+ documentInstance: instance,
206
+ ...labels,
207
+ boundAt: carried.boundAt ?? nowIso(),
208
+ ...rest,
209
+ });
210
+ }
211
+
212
+ /** Forget the binding; the workspace and its revision state are untouched. */
213
+ export function detachRemote(workspaceDir) {
214
+ const existing = readRemote(workspaceDir);
215
+ fs.rmSync(remotePath(workspaceDir), { force: true });
216
+ return existing;
217
+ }
218
+
219
+ /**
220
+ * Record what a read actually observed. `lastPulledAt` means "a freshness
221
+ * observation SUCCEEDED at this time" and is never refreshed by a failed
222
+ * attempt — a stale timestamp that says so beats a current one that lies.
223
+ */
224
+ export function noteObservation(workspaceDir, { revision = null, failure = null, labels = {} }) {
225
+ const remote = readRemote(workspaceDir);
226
+ if (!remote) return null;
227
+ return writeRemote(workspaceDir, {
228
+ ...remote,
229
+ ...labels,
230
+ ...(revision ? { observedRevision: revision, lastPulledAt: nowIso() } : {}),
231
+ lastObservationError: failure ? { at: nowIso(), ...failure } : null,
232
+ });
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // capabilities, access and the mode they add up to
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /**
240
+ * Fold policy, grant, route support, item access and the local gates into one
241
+ * answer.
242
+ *
243
+ * `capabilities` maps each route of {@link ROUTES} to a {@link SUPPORT} value.
244
+ * `access` is `{ read, write }` over {@link ACCESS} — `write: "unknown"` is the
245
+ * NORMAL answer and stays that way: the only honest way to learn item write
246
+ * access is the publish the user actually asked for, never a surprise probe.
247
+ *
248
+ * `blockers` is every applicable reason; `reason` is the first of them in
249
+ * {@link REASONS} order, so a switch on one string is stable across runs.
250
+ *
251
+ * `canPublish` means a protected publish is ELIGIBLE to be attempted, not that
252
+ * it will be allowed. That distinction is the whole point of keeping
253
+ * `writeAccess` separate.
254
+ */
255
+ export function computeMode({ capabilities = {}, access = {}, policy = {}, gates = {} } = {}) {
256
+ const blockers = new Set();
257
+ // Every route is live (bridge/sharepoint.mjs); `capabilities` is kept for the
258
+ // on-disk record and for a provider that reports a read as unsupported.
259
+ const support = (route) => capabilities[route] ?? "supported";
260
+ const readAccess = access.read ?? "unknown";
261
+ const writeAccess = access.write ?? "unknown";
262
+
263
+ if (policy.identityUnresolved) blockers.add("identity_unresolved");
264
+ if (policy.itemMissing) blockers.add("remote_missing");
265
+ if (policy.offline) blockers.add("offline");
266
+ if (policy.throttled) blockers.add("throttled");
267
+ if (policy.remoteSavingDisabled) blockers.add("remote_saving_disabled");
268
+ if (policy.writeScopeMissing) blockers.add("write_scope_missing");
269
+ // Only ever set from EVIDENCE the provider returned (an `invalid_grant`, a
270
+ // WWW-Authenticate naming insufficient_claims). A bare 403 never lands here.
271
+ if (policy.consentRequired) blockers.add("consent_required");
272
+
273
+ if (gates.needsReconcile) blockers.add("needs_reconcile");
274
+ if (gates.extensionMissing) blockers.add("extension_missing");
275
+ if (gates.extensionIncompatible) blockers.add("extension_incompatible");
276
+ if (gates.validationRequired) blockers.add("validation_required");
277
+ if (gates.unknownOutcome) blockers.add("unknown_outcome");
278
+ if (gates.contextMissing) blockers.add("context_missing");
279
+ if (gates.snapshotRaced) blockers.add("snapshot_raced");
280
+
281
+ if (writeAccess === "denied") blockers.add("target_access_denied");
282
+
283
+ const writesPossible = !policy.remoteSavingDisabled && !policy.writeScopeMissing;
284
+
285
+ // Reads unavailable is the manual branch — and the reason for it is kept,
286
+ // because "we could not read it" and "you may not write it" need different
287
+ // remedies.
288
+ const readable = readAccess !== "denied" && support("read") === "supported" && !policy.readsDisabled;
289
+ if (!readable && readAccess === "denied") blockers.add("target_access_denied");
290
+
291
+ let mode;
292
+ if (!readable) {
293
+ mode = "manual";
294
+ } else if (
295
+ blockers.has("context_missing") ||
296
+ blockers.has("identity_unresolved") ||
297
+ blockers.has("remote_missing") ||
298
+ blockers.has("needs_reconcile") ||
299
+ blockers.has("extension_missing") ||
300
+ blockers.has("extension_incompatible") ||
301
+ blockers.has("validation_required") ||
302
+ blockers.has("unknown_outcome") ||
303
+ blockers.has("snapshot_raced")
304
+ ) {
305
+ mode = "blocked";
306
+ } else if (writesPossible && writeAccess !== "denied" && !blockers.has("consent_required")) {
307
+ mode = "automatic";
308
+ } else {
309
+ mode = "read-only";
310
+ }
311
+
312
+ const ordered = REASONS.filter((r) => blockers.has(r));
313
+ return {
314
+ mode,
315
+ canPublish: mode === "automatic",
316
+ writeAccess,
317
+ readAccess,
318
+ capabilities: Object.fromEntries(ROUTES.map((r) => [r, support(r)])),
319
+ blockers: ordered,
320
+ reason: ordered[0] ?? null,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Classify a provider response into access evidence WITHOUT inventing a consent
326
+ * verdict. The distinction the design insists on:
327
+ *
328
+ * - a READ 403 → the target is not readable by this user → manual.
329
+ * - a WRITE 403 with readable metadata → read-only, `target_access_denied`.
330
+ * - a generic 403 → never `consent_required`. Consent is claimed only when the
331
+ * provider says so (a `WWW-Authenticate` naming insufficient_claims, or an
332
+ * `invalid_grant` / `interaction_required` from the token endpoint).
333
+ */
334
+ export function classifyAccessResponse({ status, on, headers = {}, body = null }) {
335
+ const challenge = String(
336
+ headers["www-authenticate"] ?? headers["WWW-Authenticate"] ?? "",
337
+ ).toLowerCase();
338
+ const code = String(body?.error?.code ?? body?.error ?? "").toLowerCase();
339
+ const consentEvidence =
340
+ challenge.includes("insufficient_claims") ||
341
+ challenge.includes("consent") ||
342
+ ["invalid_grant", "interaction_required", "consent_required"].includes(code);
343
+
344
+ if (status === 404) return { access: "unknown", reason: "remote_missing" };
345
+ if (status === 429 || status === 503) return { access: "unknown", reason: "throttled" };
346
+ if (status === 401) return { access: "unknown", reason: "consent_required" };
347
+ if (status === 403) {
348
+ if (consentEvidence) return { access: "unknown", reason: "consent_required" };
349
+ return {
350
+ access: "denied",
351
+ reason: on === "read" ? "target_access_denied" : "target_access_denied",
352
+ };
353
+ }
354
+ if (status >= 200 && status < 300) return { access: "allowed", reason: null };
355
+ return { access: "unknown", reason: null };
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // the operation journal — written BEFORE any external effect
360
+ // ---------------------------------------------------------------------------
361
+
362
+ /**
363
+ * The phases an operation passes through. Everything from `artifacts` onward may
364
+ * have left an effect on the server, so a restart that finds one of those must
365
+ * INSPECT before it acts.
366
+ */
367
+ export const OPERATION_PHASES = [
368
+ "planned", // journaled; nothing has left this machine
369
+ "artifacts", // durable context / attachments may have been written
370
+ "deck-write", // the conditional replace or create is in flight
371
+ "committed", // the server confirmed; a receipt exists
372
+ "finalized", // local state advanced and the extension callback acknowledged
373
+ ];
374
+
375
+ /** Phases whose outcome cannot be assumed without asking the server. */
376
+ export const UNCERTAIN_PHASES = ["artifacts", "deck-write"];
377
+
378
+ export function operationsDir(workspaceDir) {
379
+ return stateDir(workspaceDir, OPERATIONS_DIR);
380
+ }
381
+
382
+ export function operationPath(workspaceDir, operationId) {
383
+ return path.join(operationsDir(workspaceDir), `${operationId}.json`);
384
+ }
385
+
386
+ export function readOperation(workspaceDir, operationId) {
387
+ const data = readJson(operationPath(workspaceDir, operationId));
388
+ return data?.schemaVersion === SCHEMA_VERSION ? data : null;
389
+ }
390
+
391
+ export function listOperations(workspaceDir) {
392
+ let names = [];
393
+ try {
394
+ names = fs.readdirSync(operationsDir(workspaceDir));
395
+ } catch {
396
+ return [];
397
+ }
398
+ return names
399
+ .filter((n) => n.endsWith(".json"))
400
+ .map((n) => readOperation(workspaceDir, n.slice(0, -5)))
401
+ .filter(Boolean)
402
+ .sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
403
+ }
404
+
405
+ /** Operations whose outcome the next command must resolve before it writes. */
406
+ export function pendingOperations(workspaceDir) {
407
+ return listOperations(workspaceDir).filter(
408
+ (op) => op.phase !== "finalized" && op.phase !== "abandoned",
409
+ );
410
+ }
411
+
412
+ /**
413
+ * Journal an operation before its first external effect.
414
+ *
415
+ * `recoveryMarker` is what makes an unknown outcome findable: it rides into the
416
+ * server's own record of the write (the content-addressed artifact name, the
417
+ * created item's name, the operation id inside the uploaded context tag) so a
418
+ * restart can ask "is MY write the one that landed?" instead of guessing from a
419
+ * timestamp.
420
+ */
421
+ export function beginOperation(
422
+ workspaceDir,
423
+ { kind, deck = null, destination = null, evidence = {}, plannedArtifacts = [], recoveryMarker = null },
424
+ ) {
425
+ const operationId = `op-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
426
+ const record = {
427
+ schemaVersion: SCHEMA_VERSION,
428
+ operationId,
429
+ kind,
430
+ phase: "planned",
431
+ startedAt: nowIso(),
432
+ deck,
433
+ ...(destination ? { destination } : {}),
434
+ evidence,
435
+ plannedArtifacts,
436
+ pendingRefs: [],
437
+ committedRefs: [],
438
+ recoveryMarker: recoveryMarker ?? operationId,
439
+ steps: [],
440
+ receipt: null,
441
+ };
442
+ writeJson(operationPath(workspaceDir, operationId), record);
443
+ const remote = readRemote(workspaceDir);
444
+ if (remote) writeRemote(workspaceDir, { ...remote, pendingOperationId: operationId });
445
+ return record;
446
+ }
447
+
448
+ /** Advance an operation. The record on disk is the truth; the return value is a copy. */
449
+ export function stepOperation(workspaceDir, operationId, { phase = null, step = null, ...patch }) {
450
+ const current = readOperation(workspaceDir, operationId);
451
+ if (!current) return null;
452
+ const next = {
453
+ ...current,
454
+ ...patch,
455
+ ...(phase ? { phase } : {}),
456
+ steps: step ? [...current.steps, { step, at: nowIso() }] : current.steps,
457
+ };
458
+ writeJson(operationPath(workspaceDir, operationId), next);
459
+ return next;
460
+ }
461
+
462
+ /**
463
+ * Persist a CONFIRMED receipt, idempotently.
464
+ *
465
+ * Called twice with the same operation id, the second call returns the first
466
+ * receipt unchanged: a retried extension callback, a resumed publish and a
467
+ * crash-recovery pass must all converge on one record rather than each writing
468
+ * their own version of what happened.
469
+ */
470
+ export function writeReceipt(workspaceDir, operationId, receipt) {
471
+ const file = path.join(stateDir(workspaceDir, RECEIPTS_DIR), `${operationId}.json`);
472
+ const existing = readJson(file);
473
+ if (existing?.schemaVersion === SCHEMA_VERSION) return existing;
474
+ const record = {
475
+ schemaVersion: SCHEMA_VERSION,
476
+ operationId,
477
+ confirmedAt: nowIso(),
478
+ ...receipt,
479
+ };
480
+ writeJson(file, record);
481
+ stepOperation(workspaceDir, operationId, {
482
+ phase: "committed",
483
+ step: "receipt",
484
+ receipt: record,
485
+ });
486
+ return record;
487
+ }
488
+
489
+ export function readReceipt(workspaceDir, operationId) {
490
+ const data = readJson(path.join(stateDir(workspaceDir, RECEIPTS_DIR), `${operationId}.json`));
491
+ return data?.schemaVersion === SCHEMA_VERSION ? data : null;
492
+ }
493
+
494
+ export function listReceipts(workspaceDir) {
495
+ let names = [];
496
+ try {
497
+ names = fs.readdirSync(stateDir(workspaceDir, RECEIPTS_DIR));
498
+ } catch {
499
+ return [];
500
+ }
501
+ return names
502
+ .filter((n) => n.endsWith(".json"))
503
+ .map((n) => readReceipt(workspaceDir, n.slice(0, -5)))
504
+ .filter(Boolean)
505
+ .sort((a, b) => (a.confirmedAt < b.confirmedAt ? 1 : -1));
506
+ }
507
+
508
+ /**
509
+ * Advance the ACCEPTED remote base from a confirmed receipt. This is the remote
510
+ * half of acceptance — the only door besides `revision accept` (which refuses a
511
+ * remote-bound deck for exactly this reason).
512
+ *
513
+ * Pending refs become committed refs here and nowhere else: a ref an artifact
514
+ * upload resolved is not paired with anything until the deck version that names
515
+ * it is the one the server holds.
516
+ */
517
+ export function acceptReceipt(workspaceDir, receipt) {
518
+ const remote = readRemote(workspaceDir);
519
+ if (!remote) return null;
520
+ if (receipt.route === "rename" || receipt.route === "rename-recovered") {
521
+ return writeRemote(workspaceDir, {
522
+ ...remote,
523
+ name: receipt.item?.name ?? remote.name,
524
+ webUrl: receipt.item?.webUrl ?? remote.webUrl,
525
+ pendingOperationId: null,
526
+ lastReceipt: { operationId: receipt.operationId, confirmedAt: receipt.confirmedAt, route: receipt.route },
527
+ });
528
+ }
529
+ return writeRemote(workspaceDir, {
530
+ ...remote,
531
+ // A rename or a move changes the labels; identity does not move.
532
+ name: receipt.item?.name ?? remote.name,
533
+ webUrl: receipt.item?.webUrl ?? remote.webUrl,
534
+ acceptedETag: receipt.version?.eTag ?? remote.acceptedETag,
535
+ acceptedCTag: receipt.version?.cTag ?? remote.acceptedCTag,
536
+ acceptedVersionId: receipt.version?.versionId ?? remote.acceptedVersionId,
537
+ acceptedSha256: receipt.candidateSha256 ?? remote.acceptedSha256,
538
+ observedRevision: receipt.version ?? remote.observedRevision,
539
+ lastPulledAt: nowIso(),
540
+ committedRefs: receipt.refs ?? remote.committedRefs ?? [],
541
+ pendingRefs: [],
542
+ pendingOperationId: null,
543
+ pendingCandidateId: null,
544
+ lastReceipt: {
545
+ operationId: receipt.operationId,
546
+ confirmedAt: receipt.confirmedAt,
547
+ versionId: receipt.version?.versionId ?? null,
548
+ eTag: receipt.version?.eTag ?? null,
549
+ route: receipt.route ?? null,
550
+ },
551
+ });
552
+ }
553
+
554
+ /**
555
+ * What a restart must do about an operation it found.
556
+ *
557
+ * `resolved` when a receipt already exists (the commit landed; only local
558
+ * finalization is pending), `inspect` while the phase could have left an effect,
559
+ * `discard` when nothing can have happened yet.
560
+ */
561
+ export function operationOutcome(workspaceDir, operation) {
562
+ if (!operation) return null;
563
+ const receipt = readReceipt(workspaceDir, operation.operationId);
564
+ if (receipt) return { action: "resolved", receipt, operation };
565
+ if (UNCERTAIN_PHASES.includes(operation.phase)) {
566
+ return { action: "inspect", operation, reason: "unknown_outcome" };
567
+ }
568
+ return { action: "discard", operation };
569
+ }
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // what `ready` and `status` report about the remote half
573
+ // ---------------------------------------------------------------------------
574
+
575
+ /**
576
+ * The remote block of `ready --json`, computed WITHOUT a network call.
577
+ *
578
+ * Deliberately a disk read: `ready` runs at the top of every session and must
579
+ * not turn into a SharePoint round trip. It reports the last SUCCESSFUL
580
+ * observation and says when that was; it never claims the deck is current,
581
+ * because nothing here has asked. Continuous freshness is a lie a status command
582
+ * must not tell.
583
+ */
584
+ export function remoteReadiness(workspaceDir, revisionStatus) {
585
+ const remote = readRemote(workspaceDir);
586
+ if (!remote) return null;
587
+ const status = revisionStatus?.(workspaceDir) ?? null;
588
+ const pending = pendingOperations(workspaceDir);
589
+ const decision = computeMode({
590
+ capabilities: remote.capabilities ?? {},
591
+ access: remote.access ?? {},
592
+ gates: {
593
+ needsReconcile: Boolean(status && (status.conflicts > 0 || status.staged.length > 0)),
594
+ unknownOutcome: pending.some((op) => UNCERTAIN_PHASES.includes(op.phase)),
595
+ },
596
+ });
597
+ return {
598
+ provider: remote.provider ?? "sharepoint",
599
+ documentInstance: remote.documentInstance,
600
+ name: remote.name ?? null,
601
+ webUrl: remote.webUrl ?? null,
602
+ mode: decision.mode,
603
+ canPublish: decision.canPublish,
604
+ writeAccess: decision.writeAccess,
605
+ capabilities: decision.capabilities,
606
+ blockers: decision.blockers,
607
+ reason: decision.reason,
608
+ baseRevision: remote.acceptedETag ?? null,
609
+ observedRemoteRevision: remote.observedRevision?.eTag ?? null,
610
+ lastPulledAt: remote.lastPulledAt ?? null,
611
+ lastObservationError: remote.lastObservationError ?? null,
612
+ localDirty: status?.localDirty ?? null,
613
+ pendingCandidate: status?.candidate?.candidateId ?? remote.pendingCandidateId ?? null,
614
+ stagedIncomingRevision: status?.staged?.[0]?.revisionId ?? null,
615
+ dependencyChanges: Object.keys(remote.dependencyObservations ?? {}),
616
+ operationOutcome: pending.length
617
+ ? { operationId: pending[0].operationId, phase: pending[0].phase }
618
+ : null,
619
+ pendingManualUpload: Boolean(remote.manualHandoff),
620
+ };
621
+ }
622
+
623
+ /** One human line for `ready <deck>` — observation time included, no freshness claim. */
624
+ export function remoteHumanLine(readiness) {
625
+ if (!readiness) return "";
626
+ const where = readiness.name ?? readiness.documentInstance;
627
+ const when = readiness.lastPulledAt
628
+ ? `last checked ${readiness.lastPulledAt}`
629
+ : "never checked";
630
+ // The mode is the verdict; the blockers are why. "read-only — conditional_write_unsupported"
631
+ // and "blocked — needs_reconcile" need different remedies and must not share a label.
632
+ const state = readiness.canPublish
633
+ ? "publishing is available"
634
+ : `${readiness.mode} — ${readiness.blockers.join(", ") || readiness.reason || "no protected write route"}`;
635
+ return (
636
+ `remote: ${where} · ${state} · ${when}\n` +
637
+ (readiness.stagedIncomingRevision
638
+ ? ` a newer save is staged (${readiness.stagedIncomingRevision}) — reconcile before publishing\n`
639
+ : "") +
640
+ (readiness.pendingManualUpload
641
+ ? ` a manual upload is prepared and still pending — nothing is saved remotely yet\n`
642
+ : "") +
643
+ (readiness.operationOutcome
644
+ ? ` a previous publish (${readiness.operationOutcome.operationId}) has an unresolved outcome\n`
645
+ : "")
646
+ );
647
+ }
648
+
649
+ /** Close an operation that inspection proved never landed. Never deletes its record. */
650
+ export function abandonOperation(workspaceDir, operationId, why) {
651
+ const next = stepOperation(workspaceDir, operationId, {
652
+ phase: "abandoned",
653
+ step: "abandoned",
654
+ abandonedBecause: why,
655
+ });
656
+ const remote = readRemote(workspaceDir);
657
+ if (remote?.pendingOperationId === operationId) {
658
+ writeRemote(workspaceDir, { ...remote, pendingOperationId: null });
659
+ }
660
+ return next;
661
+ }