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,1119 @@
1
+ // Automatic remote synchronization of a deck's shared plan record — the
2
+ // algorithm in synchronization.md, as one per-record queue.
3
+ //
4
+ // state/workspace-sync.json the queue: idle | pending | sending |
5
+ // applying | blocked | outcome-unknown, the
6
+ // latest unsent local hash, the operation in
7
+ // flight (base, sent snapshot, response), the
8
+ // network claim, and a conflict when blocked.
9
+ // state/record.json the CONFIRMED remote baseline (record-state);
10
+ // advanced only from what the service answered.
11
+ //
12
+ // A local save never waits on the network: it commits, then `scheduleSync`
13
+ // marks the queue pending. `drainOnce` is the coordinator — one drain per
14
+ // record at a time, in the daemon (startSyncLoop) or in a headless command
15
+ // (`record sync`): claim and snapshot under the deck lock, do the network
16
+ // call without it, reacquire to apply. A crash anywhere leaves enough on disk
17
+ // to finish or to ask: a persisted `sending`/`outcome-unknown` operation is
18
+ // read back before anything is sent again, never resent blindly.
19
+ //
20
+ // `blocked` is always explicit: it ends with a sign-in, a policy change, a
21
+ // resolve (unblockSync) — never with the next tick. Transient failures stay
22
+ // `pending` with a `retryAt`.
23
+ //
24
+ // Generic plan.md decks are not synchronized here (contracts §7): without an
25
+ // installed adapter the queue stays idle.
26
+
27
+ import { randomUUID } from "node:crypto";
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { isDeepStrictEqual } from "node:util";
31
+
32
+ import { Merge3Error, SERVER_OWNED_TOP_LEVEL, merge3 } from "../lib/merge-json.mjs";
33
+ import { pidAlive } from "../lib/revision.mjs";
34
+ import { createRecord, getRecord, putRecord, recordSelector, resolveRecordBackend } from "../lib/record-client.mjs";
35
+ import { planToSend, readRecordState, recordStateFrom, resolveStoredFile, writeRecordState } from "../lib/record-state.mjs";
36
+ import { dispatchAdapter } from "./adapters.mjs";
37
+ import { EDIT_FILE, SCHEMA_VERSION as EDIT_SCHEMA_VERSION } from "./edit.mjs";
38
+ import { runDeckOperation } from "./operations.mjs";
39
+ import { hasEditablePlan, readDestination, writeDestination } from "./remote-status.mjs";
40
+ import { getTask, listTasks, requestTask, respondTask, supersedeTask } from "./tasks.mjs";
41
+
42
+ export const SYNC_FILE = "workspace-sync.json";
43
+ export const SCHEMA_VERSION = 1;
44
+ export const STATES = ["idle", "pending", "sending", "applying", "blocked", "outcome-unknown"];
45
+ export const OPERATION_KINDS = ["create", "save", "approve"];
46
+ export const BACKOFF_MIN_MS = 2_000;
47
+ export const BACKOFF_MAX_MS = 60_000;
48
+ export const CLAIM_TTL_MS = 10 * 60_000;
49
+
50
+ const NONCE = randomUUID();
51
+ const inFlight = new Set(); // workspaceDirs with a request on the wire in THIS process
52
+ let wakeHook = null; // the daemon loop, when one runs in this process
53
+
54
+ const nowIso = (now) => new Date(now()).toISOString();
55
+ const wsDir = (dir) => (path.basename(dir) === ".slidemind" ? dir : path.join(dir, ".slidemind"));
56
+ const typed = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
57
+ const SHA = /^sha256:[a-f0-9]{64}$/;
58
+ const isBound = (binding) => Boolean(binding?.url || (binding?.driveId && binding?.itemId));
59
+ const approvedHash = (plan) => plan?.approval?.planHash ?? null;
60
+
61
+ /** True while a shared approval is in flight: ordinary saves are buffered by the caller, sends wait (synchronization.md "Approval in flight"). */
62
+ export function approvalInProgress(workspaceDir) {
63
+ return readSync(workspaceDir).operation?.kind === "approve";
64
+ }
65
+
66
+ export function syncPath(workspaceDir) {
67
+ return path.join(wsDir(workspaceDir), "state", SYNC_FILE);
68
+ }
69
+
70
+ function writeAtomic(file, data) {
71
+ fs.mkdirSync(path.dirname(file), { recursive: true });
72
+ const tmp = `${file}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
73
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
74
+ fs.renameSync(tmp, file);
75
+ }
76
+
77
+ const EMPTY = () => ({ schemaVersion: SCHEMA_VERSION, state: "idle", pendingHash: null, attempt: 0, retryAt: null, operation: null, lastError: null, lastConfirmedAt: null });
78
+
79
+ /** `null` for a well-formed queue, else the `recovery_required` reason (fixtures: sync/cases.json). */
80
+ export function validateSync(data) {
81
+ if (!data || typeof data !== "object") return "not an object";
82
+ if (data.schemaVersion !== SCHEMA_VERSION) return `schemaVersion ${data.schemaVersion} is not ${SCHEMA_VERSION}`;
83
+ if (!STATES.includes(data.state)) return `state '${data.state}' is unknown`;
84
+ if (data.pendingHash !== null && typeof data.pendingHash !== "string") return "pendingHash must be a hash or null";
85
+ if (!Number.isInteger(data.attempt) || data.attempt < 0) return "attempt must be a non-negative integer";
86
+ const op = data.operation;
87
+ if (op !== null && op !== undefined) {
88
+ if (typeof op !== "object" || typeof op.id !== "string") return "operation.id must be a string";
89
+ if (!OPERATION_KINDS.includes(op.kind)) return `operation.kind '${op.kind}' is unknown`;
90
+ if (!op.sent || typeof op.sent.hash !== "string") return "operation.sent.hash must be a string";
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /** The queue as stored. Missing → idle; malformed → throws `recovery_required`, the file kept on disk. */
96
+ export function readSync(workspaceDir) {
97
+ let raw;
98
+ try {
99
+ raw = fs.readFileSync(syncPath(workspaceDir), "utf8");
100
+ } catch (err) {
101
+ if (err?.code === "ENOENT") return EMPTY();
102
+ throw err;
103
+ }
104
+ let data;
105
+ try {
106
+ data = JSON.parse(raw);
107
+ } catch (err) {
108
+ throw typed("recovery_required", `${SYNC_FILE} is not JSON: ${err.message}`, { file: syncPath(workspaceDir) });
109
+ }
110
+ const problem = validateSync(data);
111
+ if (problem) throw typed("recovery_required", `${SYNC_FILE}: ${problem}; inspect it before syncing this deck`, { file: syncPath(workspaceDir) });
112
+ return { ...EMPTY(), ...data };
113
+ }
114
+
115
+ function writeSync(workspaceDir, data) {
116
+ const problem = validateSync(data);
117
+ if (problem) throw new Error(`refusing to write an invalid ${SYNC_FILE}: ${problem}`);
118
+ writeAtomic(syncPath(workspaceDir), data);
119
+ return data;
120
+ }
121
+
122
+ /** The queue, summarized for a status line or a card. `synced` = nothing pending, in flight or blocked. */
123
+ export function syncStatus(workspaceDir) {
124
+ const q = readSync(workspaceDir);
125
+ return {
126
+ exists: fs.existsSync(syncPath(workspaceDir)),
127
+ state: q.state,
128
+ synced: q.state === "idle" && q.pendingHash === null && !q.operation,
129
+ pendingHash: q.pendingHash,
130
+ attempt: q.attempt,
131
+ retryAt: q.retryAt,
132
+ operation: q.operation ? { id: q.operation.id, kind: q.operation.kind, startedAt: q.operation.startedAt, sent: q.operation.sent.hash } : null,
133
+ lastError: q.lastError,
134
+ lastConfirmedAt: q.lastConfirmedAt,
135
+ conflict: q.conflict ?? null,
136
+ claim: q.claim ?? null,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * After an ACCEPTED local save: remember the latest local hash and mark the
142
+ * queue pending. Coalesces — ten saves during one held network call become
143
+ * one send of the newest value. A blocked queue keeps its block (a new edit
144
+ * does not erase a conflict); an operation in flight is left alone and the
145
+ * hash is picked up when it settles. Call only for validated values.
146
+ */
147
+ export function scheduleSync(workspaceDir, { hash } = {}) {
148
+ if (typeof hash !== "string") throw typed("validation_failed", "scheduleSync needs the accepted local hash");
149
+ const ws = wsDir(workspaceDir);
150
+ const q = readSync(ws);
151
+ q.pendingHash = hash;
152
+ if (q.state === "idle") {
153
+ q.state = "pending";
154
+ q.attempt = 0;
155
+ q.retryAt = null;
156
+ }
157
+ // 7.7: a deck pointed at a folder before it had a plan creates its record on the first accepted save.
158
+ if (!q.createTarget && !isBound(readRecordState(ws))) {
159
+ const dest = readDestination(ws);
160
+ if (dest && !dest.malformed && dest.webUrl && dest.name) q.createTarget = { folderUrl: dest.webUrl, name: dest.name };
161
+ }
162
+ writeSync(ws, q);
163
+ wakeHook?.(ws);
164
+ return syncStatus(ws);
165
+ }
166
+
167
+ /**
168
+ * A first share: the next drain POSTs the current plan to `{ folderUrl, name }`.
169
+ * A queue blocked on `waiting_for_plan` (destination selected before a plan
170
+ * existed) resumes here.
171
+ */
172
+ export function scheduleCreate(workspaceDir, { hash, folderUrl, name }) {
173
+ const q = readSync(workspaceDir);
174
+ q.createTarget = { folderUrl, name };
175
+ if (q.state === "blocked" && q.lastError?.code === "waiting_for_plan") {
176
+ q.state = "idle";
177
+ q.lastError = null;
178
+ }
179
+ writeSync(workspaceDir, q);
180
+ return scheduleSync(workspaceDir, { hash });
181
+ }
182
+
183
+ /**
184
+ * Explicit resume after a block: a sign-in, a policy change, a resolve. Clears
185
+ * the block (and the conflict when `conflictId` names the current one) and
186
+ * marks pending.
187
+ */
188
+ export function unblockSync(workspaceDir, { conflictId = null } = {}) {
189
+ const q = readSync(workspaceDir);
190
+ if (q.conflict) {
191
+ if (conflictId !== q.conflict.conflictId) throw typed("stale_subject", "that conflict is no longer the current one");
192
+ q.conflict = null;
193
+ }
194
+ if (q.state === "blocked") {
195
+ q.state = q.operation ? (q.operation.response ? "applying" : "outcome-unknown") : "pending";
196
+ q.lastError = null;
197
+ q.retryAt = null;
198
+ q.staleRetries = 0;
199
+ }
200
+ writeSync(workspaceDir, q);
201
+ wakeHook?.(wsDir(workspaceDir));
202
+ return syncStatus(workspaceDir);
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // io — the adapter, the record service, the clock: all injectable
207
+ // ---------------------------------------------------------------------------
208
+
209
+ function adapterIo(adapter, ws) {
210
+ if (!adapter) return null;
211
+ if (typeof adapter.read === "function") return adapter; // a fake: { id, read, applyRemote }
212
+ if (!adapter.entry) return null;
213
+ const deckRoot = path.dirname(ws);
214
+ const call = async (method, payload, token) => {
215
+ const answer = await dispatchAdapter(adapter.entry, { method, deckRoot, payload, ownerToken: token });
216
+ if (answer.status === "ok") return { ok: true, ...answer.data };
217
+ return { ok: false, code: answer.error?.code ?? "validation_failed", message: answer.error?.message, ...(answer.data ?? {}) };
218
+ };
219
+ return {
220
+ id: adapter.id,
221
+ read: (token) => call("read", {}, token),
222
+ applyRemote: (payload, token) => call("apply-remote", payload, token),
223
+ saveLocal: (payload, token) => call("save-local", payload, token),
224
+ approve: (payload, token) => call("approve", payload, token),
225
+ };
226
+ }
227
+
228
+ async function defaultClient(binding) {
229
+ const backendURL = await resolveRecordBackend(binding);
230
+ const { sharedRecordsSupport } = await import("../bridge/client/transport.mjs");
231
+ const kind = binding?.recordKind ?? null; // the service the binding was made against
232
+ return {
233
+ backendURL,
234
+ capabilities: () => sharedRecordsSupport(backendURL, { kind, refresh: true }),
235
+ get: (selector) => getRecord(backendURL, selector, { kind }),
236
+ put: (args) => putRecord(backendURL, { kind, ...args }),
237
+ create: (args) => createRecord(backendURL, { kind, ...args }),
238
+ };
239
+ }
240
+
241
+ /** Same content, ignoring what the service stamps itself — decides whether a write of unknown outcome landed. */
242
+ export function sameContent(a, b) {
243
+ const strip = (v) => {
244
+ if (!v || typeof v !== "object") return v;
245
+ const copy = { ...v };
246
+ for (const k of SERVER_OWNED_TOP_LEVEL) delete copy[k];
247
+ return copy;
248
+ };
249
+ return isDeepStrictEqual(strip(a), strip(b));
250
+ }
251
+
252
+ export function backoffMs(attempt, random = Math.random) {
253
+ const base = Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** Math.max(0, attempt - 1));
254
+ return Math.round(base * (1 + random() * 0.25));
255
+ }
256
+
257
+ // One verdict per network failure: what to persist, and whether to retry.
258
+ // `null` means it is not a network verdict at all — rethrown.
259
+ function classify(err) {
260
+ const code = err?.code;
261
+ if (code === "bridge_timeout" || code === "timeout" || /timed out/i.test(String(err?.message ?? ""))) return { code: "outcome_unknown", unknown: true };
262
+ if (code === "version_unknown") return { code: "outcome_unknown", unknown: true, written: err?.body?.written === true };
263
+ if (code === "offline") return { code: "offline", retryable: true };
264
+ if (err?.status === 429 || code === "rate_limited") {
265
+ const after = Number(err?.headers?.["retry-after"] ?? err?.retryAfter);
266
+ return { code: "rate_limited", retryable: true, retryAfterMs: Number.isFinite(after) && after > 0 ? after * 1000 : null };
267
+ }
268
+ if (err?.status >= 500) return { code: "offline", retryable: true };
269
+ if (code === "sharepoint_write_disabled" || code === "write_disabled") return { code: "write_disabled" };
270
+ if (code === "not_found" || code === "remote_missing") return { code: "remote_missing" };
271
+ if (code === "login_required" || code === "access_denied" || code === "name_exists") return { code };
272
+ if (code === "backend_ambiguous") return { code: "capability_unknown" };
273
+ return null;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // the coordinator
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Drain this record's queue once: read back an unfinished operation, send the
282
+ * newest local value if any, apply what came back. Resolves to `syncStatus`
283
+ * plus `action`: `noop`, `busy`, `deferred`, `blocked`, `recovered`, `applied`.
284
+ *
285
+ * `adapter` is `{ id, entry }` (installed pack) or a fake `{ id, read,
286
+ * applyRemote }`; `client` replaces the record service (`{ capabilities, get,
287
+ * put, create, backendURL }`); `owner` is runDeckOperation's (`false` in the
288
+ * daemon); `now`/`random` are the clock. The deck lock is never held across a
289
+ * network call.
290
+ */
291
+ export async function drainOnce(workspaceDir, { adapter = null, client = null, owner, now = Date.now, random = Math.random } = {}) {
292
+ const ws = wsDir(workspaceDir);
293
+ const io = adapterIo(adapter, ws);
294
+ // Same signature as `refreshOnce`'s and `activateDestination`'s: the settled
295
+ // approval below rides out on `approval`, and a one-parameter `result` dropped
296
+ // it silently — the daemon's `if (r.approval?.ok)` observer could never fire,
297
+ // so a crashed shared approval was finalized without the open page hearing it.
298
+ const result = (action, extra = {}) => ({ action, ...syncStatus(ws), ...extra });
299
+ if (!io) return result("noop");
300
+
301
+ const binding = readRecordState(ws);
302
+ let net = client;
303
+ if (!net) {
304
+ try {
305
+ net = await defaultClient(binding);
306
+ } catch (err) {
307
+ const verdict = classify(err) ?? { code: "capability_unknown" };
308
+ persistBlock(ws, { ...verdict, message: err.message }, now);
309
+ return result("blocked");
310
+ }
311
+ }
312
+
313
+ // 1. claim + snapshot, under the lock.
314
+ const claimed = await runDeckOperation(ws, { kind: "sync-claim", owner }, async (op) => {
315
+ const q = readSync(ws);
316
+ if (q.claim) {
317
+ const mine = q.claim.nonce === NONCE;
318
+ if (mine || (pidAlive(q.claim.pid) && now() - Date.parse(q.claim.at) < CLAIM_TTL_MS)) return { busy: true };
319
+ // a dead claimant: whatever it had on the wire is of unknown outcome, not "nothing sent"
320
+ if (q.state === "sending") q.state = "outcome-unknown";
321
+ delete q.claim;
322
+ }
323
+ if (q.state === "blocked") return { blocked: true };
324
+ if (q.state === "pending" && q.retryAt && Date.parse(q.retryAt) > now()) return { deferred: true };
325
+ // the approval barrier: a shared approval on the wire is finished (read back, finalized) before any ordinary send
326
+ if (q.operation?.kind === "approve" && q.state === "sending") return { busy: true };
327
+ // an unsent operation is re-snapshotted: newer local edits ride along
328
+ if (q.operation && q.state === "pending" && !q.operation.response) q.operation = null;
329
+ if (q.state === "idle" && !q.pendingHash) return { idle: true };
330
+
331
+ if (!q.operation) {
332
+ const isCreate = !binding?.url && !(binding?.driveId && binding?.itemId);
333
+ if (isCreate && !q.createTarget) return { blocked: true, error: { code: "waiting_for_plan", message: "no shared record and no destination yet", retryable: false } };
334
+ const local = await io.read(op.token);
335
+ if (!local.ok) return { blocked: true, error: { code: local.code, message: local.message } };
336
+ q.operation = {
337
+ id: randomUUID(),
338
+ kind: isCreate ? "create" : "save",
339
+ base: { eTag: binding?.eTag ?? null, value: binding?.base ?? null },
340
+ sent: { hash: local.hash, value: planToSend(local.value) },
341
+ ...(isCreate ? { target: q.createTarget } : {}),
342
+ startedAt: nowIso(now),
343
+ };
344
+ q.pendingHash = null;
345
+ q.state = "pending";
346
+ }
347
+ q.claim = { pid: process.pid, nonce: NONCE, operationId: q.operation.id, at: nowIso(now) };
348
+ writeSync(ws, q);
349
+ return { q };
350
+ });
351
+ if (claimed.busy) return result("busy");
352
+ if (claimed.idle) return result("noop");
353
+ if (claimed.deferred) return result("deferred");
354
+ if (claimed.blocked) {
355
+ if (claimed.error) persistBlock(ws, claimed.error, now);
356
+ return result("blocked");
357
+ }
358
+
359
+ let q = claimed.q;
360
+ try {
361
+ // 2. an operation of unknown outcome is read back before anything is sent.
362
+ if (q.state === "sending" || q.state === "outcome-unknown") {
363
+ const back = await readBack(ws, q, net, binding);
364
+ if (back.blocked) return result("blocked");
365
+ q = back.q;
366
+ if (!q.operation) return result("recovered"); // an approval that never landed is void: the human decides again
367
+ }
368
+
369
+ // 3. send, when there is still something to send.
370
+ if (q.state === "pending") {
371
+ const caps = await net.capabilities();
372
+ if (caps.status !== "supported") {
373
+ release(ws, (queue) => {
374
+ queue.operation = null;
375
+ queue.state = "blocked";
376
+ queue.lastError = {
377
+ code: "capability_unknown",
378
+ message: caps.status === "unknown" ? "the backend's capabilities could not be read; nothing was sent" : "the backend does not advertise shared records",
379
+ retryable: caps.status === "unknown",
380
+ };
381
+ });
382
+ return result("blocked");
383
+ }
384
+ q.state = "sending";
385
+ q.attempt += 1;
386
+ q.retryAt = null;
387
+ q.lastError = null;
388
+ // The deck lock is long gone and `q` is the snapshot taken under it (pendingHash
389
+ // nulled there). A browser save landing during these network windows writes a
390
+ // fresh pendingHash, which a whole-snapshot write would silently erase — the page
391
+ // would then report "Synced" over an edit nothing will ever send. Patch fields.
392
+ patch(ws, q, ["state", "attempt", "retryAt", "lastError"]);
393
+ inFlight.add(ws);
394
+ let response;
395
+ try {
396
+ response = await send(net, q.operation, binding);
397
+ } catch (err) {
398
+ inFlight.delete(ws);
399
+ const verdict = classify(err);
400
+ if (verdict?.unknown) {
401
+ q.state = "outcome-unknown";
402
+ q.lastError = { code: "outcome_unknown", message: `the request may have reached the service: ${err.message}`, retryable: false, ...(verdict.written ? { written: true } : {}) };
403
+ patch(ws, q, ["state", "lastError"]);
404
+ const back = await readBack(ws, q, net, binding);
405
+ if (back.blocked) return result("blocked");
406
+ q = back.q;
407
+ if (q.state === "pending") return result("recovered"); // resend on the next drain, with a fresh snapshot
408
+ } else if (err?.code === "conflict") {
409
+ conflictBlock(ws, q, err.body, now);
410
+ await conflictTask(ws, io.id, owner);
411
+ return result("blocked");
412
+ } else if (err?.code === "stale") {
413
+ if ((q.staleRetries ?? 0) >= 1) {
414
+ // one automatic retry only: repeated contention is shown, not looped on
415
+ release(ws, (queue) => {
416
+ queue.state = "blocked";
417
+ queue.lastError = { code: "stale", message: "the shared record keeps moving; sync again once it settles", retryable: true };
418
+ });
419
+ return result("blocked");
420
+ }
421
+ // the answer carries the current record: merge it in, then send once more
422
+ q.operation.response = { ...err.body, staleAnswer: true };
423
+ q.state = "applying";
424
+ patch(ws, q, ["operation", "state"]);
425
+ } else if (verdict?.retryable) {
426
+ release(ws, (queue) => {
427
+ queue.state = "pending";
428
+ queue.retryAt = new Date(now() + (verdict.retryAfterMs ?? backoffMs(queue.attempt, random))).toISOString();
429
+ queue.lastError = { code: verdict.code, message: err.message, retryable: true };
430
+ });
431
+ return result("deferred");
432
+ } else if (verdict) {
433
+ release(ws, (queue) => {
434
+ queue.state = "blocked";
435
+ queue.lastError = { code: verdict.code, message: err.message, retryable: false };
436
+ });
437
+ return result("blocked");
438
+ } else {
439
+ throw err;
440
+ }
441
+ }
442
+ if (response) {
443
+ inFlight.delete(ws);
444
+ q.operation.response = response;
445
+ q.state = "applying";
446
+ patch(ws, q, ["operation", "state"]);
447
+ }
448
+ }
449
+
450
+ // 4. apply, under the lock again.
451
+ if (q.state === "applying") {
452
+ if (q.operation.kind === "approve") {
453
+ // a crashed shared approval: finalize what landed (or void what did not), and answer its task
454
+ const fin = await finalizeApprove(ws, q, io, binding, net.backendURL, owner, now);
455
+ if (fin.ok) await completeApproveTask(ws, q.operation, fin.evidence, owner);
456
+ return result(fin.ok ? "applied" : "recovered", { approval: fin });
457
+ }
458
+ const stale = q.operation.response?.staleAnswer === true;
459
+ await apply(ws, q, io, binding, net.backendURL, owner, now);
460
+ const after = readSync(ws);
461
+ if (after.state === "blocked" && after.conflict) await conflictTask(ws, io.id, owner);
462
+ if (stale && after.state === "pending") {
463
+ // the stale answer is merged in; the one automatic retry sends the result now
464
+ release(ws);
465
+ return drainOnce(ws, { adapter, client: net, owner, now, random });
466
+ }
467
+ return result(after.state === "blocked" ? "blocked" : "applied");
468
+ }
469
+ return result("noop");
470
+ } finally {
471
+ inFlight.delete(ws);
472
+ release(ws);
473
+ }
474
+ }
475
+
476
+ // A conflict is a task for the human (contracts §4 `sync-conflict`): one per
477
+ // local version, so an older conflict task is voided when the inputs move.
478
+ async function conflictTask(ws, adapterId, owner) {
479
+ const c = readSync(ws).conflict;
480
+ if (!c || !SHA.test(c.expectedHash ?? "")) return;
481
+ const deckRoot = path.dirname(ws);
482
+ for (const t of listTasks(deckRoot, { status: "pending" })) {
483
+ if (t.kind === "sync-conflict" && t.subject.hash !== c.expectedHash) await supersedeTask(deckRoot, t.id, { owner });
484
+ }
485
+ await requestTask(deckRoot, { kind: "sync-conflict", subject: { hash: c.expectedHash, remoteETag: c.currentETag ?? null }, adapterId }, { owner });
486
+ }
487
+
488
+ // The confirmed baseline is what was observed remotely — conflict or not.
489
+ function advanceBaseline(ws, R, binding, backendURL) {
490
+ const previous = readRecordState(ws) ?? binding;
491
+ const file = resolveStoredFile(ws, previous?.file) ?? path.join(ws, "plan.json");
492
+ writeRecordState(ws, recordStateFrom(R, { file, deckDir: ws, link: previous?.link ?? null, backendURL: backendURL ?? previous?.backendURL ?? null, previous }));
493
+ }
494
+
495
+ // A remote draft newer than the FROZEN approved plan (plan/context.json) is
496
+ // staged beside it, never over it: the pack's cold resume reads
497
+ // state/plan-incoming.json and warns (resume.mjs). `hash` is the pack's local
498
+ // version, which only the pack computes — null here.
499
+ function stageIncoming(ws, R, now) {
500
+ let ctx;
501
+ try {
502
+ ctx = JSON.parse(fs.readFileSync(path.join(ws, "plan", "context.json"), "utf8"));
503
+ } catch {
504
+ return;
505
+ }
506
+ if (!ctx?.planHash || approvedHash(R.plan) === ctx.planHash) return;
507
+ writeAtomic(path.join(ws, "state", "plan-incoming.json"), { schemaVersion: 1, eTag: R.eTag ?? null, hash: null, planHash: approvedHash(R.plan), value: R.plan, stagedAt: nowIso(now) });
508
+ }
509
+
510
+ /**
511
+ * 7.4 — read the shared record and bring it in: applied as-is when local is at
512
+ * the confirmed base, merged three ways otherwise, a same-field clash kept as a
513
+ * conflict. Never PUTs, never touches the PPTX binding. `busy` while a send,
514
+ * an apply or another process's claim is in progress; `blocked` when the queue
515
+ * is (a conflict, a policy block) or the read itself failed (`error`).
516
+ */
517
+ export async function refreshOnce(workspaceDir, { adapter = null, client = null, owner, now = Date.now } = {}) {
518
+ const ws = wsDir(workspaceDir);
519
+ const io = adapterIo(adapter, ws);
520
+ const result = (action, extra = {}) => ({ action, ...syncStatus(ws), ...extra });
521
+ const binding = readRecordState(ws);
522
+ if (!io || !isBound(binding)) return result("noop");
523
+ const q = readSync(ws);
524
+ if (q.claim && (q.claim.nonce === NONCE || (pidAlive(q.claim.pid) && now() - Date.parse(q.claim.at) < CLAIM_TTL_MS))) return result("busy");
525
+ if (["sending", "applying", "outcome-unknown"].includes(q.state) || q.operation?.response) return result("busy");
526
+ if (q.state === "blocked") return result("blocked");
527
+
528
+ let net = client;
529
+ let remote;
530
+ try {
531
+ net ??= await defaultClient(binding);
532
+ remote = await net.get(recordSelector(binding));
533
+ } catch (err) {
534
+ const verdict = classify(err) ?? (client ? null : { code: "capability_unknown" });
535
+ if (!verdict) throw err;
536
+ return result("blocked", { error: { code: verdict.code, message: err.message } });
537
+ }
538
+ if (remote.eTag && remote.eTag === binding.eTag) return result("noop");
539
+
540
+ try {
541
+ await runDeckOperation(ws, { kind: "sync-refresh", owner }, async (op) => {
542
+ const cur = readSync(ws);
543
+ if (cur.state !== q.state || cur.operation?.response || cur.claim) return; // moved under us: the next refresh sees it
544
+ stageIncoming(ws, remote, now);
545
+ await settle(ws, { io, R: remote, base: { eTag: binding.eTag ?? null, value: binding.base ?? null }, sent: { hash: "sha256:base", value: binding.base ?? {} }, binding, backendURL: net.backendURL, token: op.token, now });
546
+ });
547
+ } catch (err) {
548
+ if (err?.code === "deck_busy") return result("busy");
549
+ throw err;
550
+ }
551
+ const after = readSync(ws);
552
+ if (after.state === "blocked" && after.conflict) {
553
+ await conflictTask(ws, io.id, owner);
554
+ return result("conflict");
555
+ }
556
+ return result(after.state === "pending" ? "merged" : after.state === "blocked" ? "blocked" : "applied");
557
+ }
558
+
559
+ /**
560
+ * 7.5 — the human's answer to a conflict. `conflictId` must be the current
561
+ * conflict and `expectedHash` the current local version, else `stale_subject`
562
+ * with the recomputed conflict (its id kept, inputs refreshed, a new task
563
+ * asked). The resolved `value` is committed as a local save, the baseline set
564
+ * to the latest remote, the block lifted and one drain run.
565
+ */
566
+ export async function resolveConflict(workspaceDir, { adapter = null, client = null, owner, conflictId, expectedHash, value, now = Date.now } = {}) {
567
+ const ws = wsDir(workspaceDir);
568
+ const io = adapterIo(adapter, ws);
569
+ const fail = (code, message, extra = {}) => ({ ok: false, code, message, ...extra });
570
+ const c = readSync(ws).conflict;
571
+ if (!io) return fail("validation_failed", "no installed adapter for this deck");
572
+ if (!c || c.conflictId !== conflictId) return fail("stale_subject", "that conflict is no longer the current one", { conflictId: c?.conflictId ?? null, expectedHash: c?.expectedHash ?? null });
573
+ if (!value || typeof value !== "object") return fail("validation_failed", "a resolution is the whole plan value");
574
+ const binding = readRecordState(ws);
575
+ let net = client;
576
+ let remote;
577
+ try {
578
+ net ??= await defaultClient(binding);
579
+ remote = await net.get(recordSelector(binding));
580
+ } catch (err) {
581
+ const verdict = classify(err) ?? (client ? null : { code: "capability_unknown" });
582
+ if (!verdict) throw err;
583
+ return fail(verdict.code, err.message);
584
+ }
585
+
586
+ const out = await runDeckOperation(ws, { kind: "conflict-resolve", owner }, async (op) => {
587
+ const cur = readSync(ws);
588
+ if (cur.conflict?.conflictId !== conflictId) return { refused: fail("stale_subject", "that conflict is no longer the current one", { conflictId: cur.conflict?.conflictId ?? null }) };
589
+ const local = await io.read(op.token);
590
+ if (!local.ok) return { refused: fail(local.code, local.message) };
591
+ if (local.hash !== expectedHash || (remote.eTag ?? null) !== (c.currentETag ?? null)) {
592
+ // the inputs moved since the conflict was shown: recompute, keep the id, refuse this answer
593
+ let merged;
594
+ try {
595
+ merged = merge3(c.base ?? c.sent?.value ?? {}, remote.plan, local.value);
596
+ } catch (err) {
597
+ if (!(err instanceof Merge3Error)) throw err;
598
+ merged = { merged: null, conflicts: [{ path: err.path, code: "duplicate_id" }] };
599
+ }
600
+ cur.conflict = { ...c, expectedHash: local.hash, currentETag: remote.eTag ?? null, current: remote.plan, local: local.value, merged: merged.merged, conflicts: merged.conflicts, at: nowIso(now) };
601
+ writeSync(ws, cur);
602
+ return { stale: cur.conflict };
603
+ }
604
+ op.note({ before: local.hash });
605
+ const committed = await io.saveLocal({ base: { hash: local.hash, value: local.value }, value }, op.token);
606
+ if (!committed.ok) return { refused: fail(committed.code ?? "validation_failed", committed.message ?? "the resolution was refused", { conflicts: committed.conflicts, merged: committed.merged }) };
607
+ op.note({ after: committed.hash });
608
+ writeAtomic(path.join(ws, "state", EDIT_FILE), { schemaVersion: EDIT_SCHEMA_VERSION, adapterId: io.id, hash: committed.hash, value: committed.value, updatedAt: nowIso(now) });
609
+ advanceBaseline(ws, remote, binding, net.backendURL);
610
+ return { hash: committed.hash };
611
+ });
612
+ if (out.stale) {
613
+ await conflictTask(ws, io.id, owner);
614
+ return fail("stale_subject", "the plan or the shared record changed since this conflict was shown; decide again", { conflictId, expectedHash: out.stale.expectedHash, merged: out.stale.merged, conflicts: out.stale.conflicts, supersede: out.stale.expectedHash !== expectedHash });
615
+ }
616
+ if (out.refused) return out.refused;
617
+ scheduleSync(ws, { hash: out.hash });
618
+ unblockSync(ws, { conflictId });
619
+ const drained = await drainOnce(ws, { adapter, client: net, owner, now });
620
+ return { ok: true, hash: out.hash, ...drained };
621
+ }
622
+
623
+ /**
624
+ * 7.6 — approve a SHARED plan: local edits are flushed, the reviewed content
625
+ * must be what is local AND what the service holds (semantically — a revision
626
+ * the flush restamped is not a change), the pack prepares the approval on that
627
+ * exact version, the conditional write is read back for the exact approved
628
+ * hash, and only then does the pack finalize context and evidence. The
629
+ * operation (`kind: "approve"`) is the barrier: while it exists ordinary sends
630
+ * wait and `approvalInProgress` is true; an unknown outcome keeps it until a
631
+ * readback settles it. `response.actor` is the signed-in account (the caller
632
+ * resolved it); `task.subject.hash` is the version the human saw.
633
+ */
634
+ export async function approveShared(workspaceDir, { adapter = null, client = null, owner, task, response, now = Date.now } = {}) {
635
+ const ws = wsDir(workspaceDir);
636
+ const io = adapterIo(adapter, ws);
637
+ const fail = (code, message, extra = {}) => ({ ok: false, code, message, ...extra });
638
+ const stale = (message, extra = {}) => fail("stale_subject", message, { supersede: true, ...extra });
639
+ const binding = readRecordState(ws);
640
+ if (!io || !isBound(binding)) return fail("validation_failed", "this plan is not shared");
641
+ if (!task?.subject?.hash || !response?.actor) return fail("validation_failed", "approveShared needs the task and the attributed response");
642
+ let net = client;
643
+ try {
644
+ net ??= await defaultClient(binding);
645
+ } catch (err) {
646
+ return fail(classify(err)?.code ?? "capability_unknown", err.message);
647
+ }
648
+
649
+ // 0. the human saw THIS local version
650
+ const reviewed = await io.read();
651
+ if (!reviewed.ok) return fail(reviewed.code, reviewed.message);
652
+ if (reviewed.hash !== task.subject.hash) return stale("the plan changed since it was reviewed", { current: reviewed.hash });
653
+
654
+ // 1. local edits go first; a queue that cannot drain cannot carry an approval
655
+ const flushed = await drainOnce(ws, { adapter, client: net, owner, now });
656
+ if (flushed.action === "busy") return fail("deck_busy", "another sync is in progress; try again in a moment");
657
+ if (flushed.state === "blocked") return fail(flushed.lastError?.code ?? "blocked", `the shared plan is not synchronized (${flushed.lastError?.code ?? "blocked"}); resolve that first`);
658
+ if (flushed.operation?.kind === "approve") return fail("approval_in_progress", "an approval of this plan is already in flight");
659
+ if (flushed.state !== "idle" || flushed.operation) return fail("sync_pending", "local edits are still being synchronized; try again in a moment");
660
+
661
+ // 2. the barrier, under the lock: the reviewed content stands, the pack prepares on exactly this version
662
+ const claimed = await runDeckOperation(ws, { kind: "approve-claim", owner }, async (op) => {
663
+ const q = readSync(ws);
664
+ if (q.operation?.kind === "approve") return { code: "approval_in_progress", message: "an approval of this plan is already in flight" };
665
+ if (q.claim || q.state !== "idle" || q.pendingHash) return { code: "sync_pending", message: "a save landed meanwhile; try again in a moment" };
666
+ const local = await io.read(op.token);
667
+ if (!local.ok) return { code: local.code, message: local.message };
668
+ if (!sameContent(local.value, reviewed.value)) return { code: "stale_subject", message: "synchronizing brought in changes nobody reviewed; the view is refreshed", supersede: true, current: local.hash };
669
+ const prep = await io.approve({ phase: "prepare", approvedBy: response.actor, expectedHash: local.hash, approvedAt: response.at }, op.token);
670
+ if (!prep.ok) return { code: prep.code, message: prep.message, supersede: prep.code === "stale_subject" };
671
+ if (!prep.prepared?.approved || typeof prep.prepared.hash !== "string") return { code: "extension_incompatible", message: "the pack's prepare answer carries no approved record" };
672
+ q.operation = {
673
+ id: randomUUID(),
674
+ kind: "approve",
675
+ base: { eTag: binding.eTag ?? null, value: binding.base ?? null },
676
+ sent: { hash: local.hash, value: planToSend(prep.prepared.approved, { approval: true }) },
677
+ prepared: prep.prepared,
678
+ task: { id: task.id, subjectHash: task.subject.hash, response },
679
+ startedAt: nowIso(now),
680
+ };
681
+ q.state = "sending";
682
+ q.claim = { pid: process.pid, nonce: NONCE, operationId: q.operation.id, at: nowIso(now) };
683
+ writeSync(ws, q);
684
+ return { q, local };
685
+ });
686
+ if (claimed.code) return fail(claimed.code, claimed.message, { supersede: claimed.supersede === true, current: claimed.current });
687
+
688
+ const q = claimed.q;
689
+ const prepared = q.operation.prepared;
690
+ const voided = (R) =>
691
+ release(ws, (queue) => {
692
+ queue.operation = null;
693
+ queue.state = queue.pendingHash ? "pending" : "idle";
694
+ if (R?.plan) advanceBaseline(ws, R, binding, net.backendURL);
695
+ });
696
+ try {
697
+ // 3. what the service holds must be the reviewed content
698
+ let remote;
699
+ try {
700
+ remote = await net.get(recordSelector(binding));
701
+ } catch (err) {
702
+ const verdict = classify(err);
703
+ if (!verdict) throw err;
704
+ voided();
705
+ return fail(verdict.code === "outcome_unknown" ? "offline" : verdict.code, err.message);
706
+ }
707
+ if (approvedHash(remote.plan) === prepared.hash) {
708
+ // an earlier attempt landed: nothing to send, finalize what is there
709
+ q.operation.response = { ...remote, submittedApplied: true, recovered: true };
710
+ } else {
711
+ if (!sameContent(remote.plan, claimed.local.value)) {
712
+ voided(remote);
713
+ return stale("the shared plan changed since it was reviewed; the view is refreshed");
714
+ }
715
+ q.attempt += 1;
716
+ patch(ws, q, ["attempt"]);
717
+ inFlight.add(ws);
718
+ let R;
719
+ try {
720
+ R = await net.put({ selector: recordSelector(binding), base: { eTag: remote.eTag ?? null, plan: remote.plan }, plan: q.operation.sent.value });
721
+ } catch (err) {
722
+ inFlight.delete(ws);
723
+ const verdict = classify(err);
724
+ if (verdict?.unknown) {
725
+ q.state = "outcome-unknown";
726
+ q.lastError = { code: "outcome_unknown", message: `the approval may have reached the service: ${err.message}`, retryable: false };
727
+ patch(ws, q, ["state", "lastError"]);
728
+ return fail("outcome_unknown", "the approval's outcome is unknown; it is read back before anything else happens", { pending: true });
729
+ }
730
+ if (err?.code === "conflict" || err?.code === "stale") {
731
+ voided(err.body?.plan ? { plan: err.body.plan, eTag: err.body.eTag } : null);
732
+ return stale("the shared plan moved while approving; the view is refreshed");
733
+ }
734
+ voided();
735
+ if (verdict) return fail(verdict.code, err.message);
736
+ throw err;
737
+ }
738
+ inFlight.delete(ws);
739
+ q.operation.response = R;
740
+ }
741
+ q.state = "applying";
742
+ patch(ws, q, ["state", "operation"]);
743
+
744
+ // 4/5. exact readback, then the pack finalizes under the lock; the task is answered with the evidence
745
+ const fin = await finalizeApprove(ws, q, io, binding, net.backendURL, owner, now);
746
+ if (fin.ok) fin.task = await completeApproveTask(ws, q.operation, fin.evidence, owner);
747
+ return fin;
748
+ } finally {
749
+ inFlight.delete(ws);
750
+ release(ws);
751
+ }
752
+ }
753
+
754
+ // The persisted approve operation's response is on disk: verify it carries the
755
+ // exact approved hash, then let the pack write context and evidence. Anything
756
+ // else voids the operation (the task stays pending for a fresh decision).
757
+ async function finalizeApprove(ws, q, io, binding, backendURL, owner, now) {
758
+ const op = q.operation;
759
+ const R = op.response;
760
+ const fail = (code, message, extra = {}) => ({ ok: false, code, message, ...extra });
761
+ const clear = (edit) => release(ws, (queue) => { if (queue.operation?.id === op.id) queue.operation = null; edit?.(queue); });
762
+ if (R.staleAnswer || approvedHash(R.plan) !== op.prepared?.hash) {
763
+ advanceBaseline(ws, R, binding, backendURL);
764
+ clear((queue) => { queue.state = queue.pendingHash ? "pending" : "idle"; });
765
+ return fail("stale_subject", "the approval was overtaken by another change; the view is refreshed", { supersede: true });
766
+ }
767
+ return runDeckOperation(ws, { operationId: `${op.id}:finalize`, kind: "approve-finalize", owner }, async (opctx) => {
768
+ if (readSync(ws).operation?.id !== op.id) return fail("outcome_unknown", "the approval was settled by another process");
769
+ const local = await io.read(opctx.token);
770
+ if (!local.ok) return fail(local.code, local.message);
771
+ if (local.hash !== op.sent.hash) {
772
+ // a raw edit during approval: the remote is approved, the local draft is not — it is sent as an amendment, the task is void
773
+ advanceBaseline(ws, R, binding, backendURL);
774
+ clear((queue) => { queue.state = "pending"; queue.pendingHash = local.hash; });
775
+ return fail("stale_subject", "the plan was edited while it was being approved; reconcile and decide again", { supersede: true });
776
+ }
777
+ const fin = await io.approve({ phase: "finalize", prepared: op.prepared, sharedVersion: R.eTag ?? null }, opctx.token);
778
+ if (!fin.ok) {
779
+ if (fin.code === "stale_subject") {
780
+ advanceBaseline(ws, R, binding, backendURL);
781
+ clear((queue) => { queue.state = queue.pendingHash ? "pending" : "idle"; });
782
+ return fail("stale_subject", fin.message ?? "the pack refused to finalize", { supersede: true });
783
+ }
784
+ // the pack died or errored: the outcome of its context write is unknown — the next drain retries the finalize
785
+ return fail("outcome_unknown", fin.message ?? "the pack did not finalize the approval", { pending: true });
786
+ }
787
+ advanceBaseline(ws, R, binding, backendURL);
788
+ writeAtomic(path.join(ws, "state", EDIT_FILE), { schemaVersion: EDIT_SCHEMA_VERSION, adapterId: io.id, hash: fin.hash, value: fin.value, updatedAt: nowIso(now) });
789
+ clear((queue) => {
790
+ queue.lastError = null;
791
+ queue.attempt = 0;
792
+ queue.retryAt = null;
793
+ if (queue.pendingHash && queue.pendingHash !== fin.hash) queue.state = "pending";
794
+ else {
795
+ queue.state = "idle";
796
+ queue.pendingHash = null;
797
+ queue.lastConfirmedAt = nowIso(now);
798
+ }
799
+ });
800
+ return { ok: true, evidence: { record: `context.json#${fin.planHash ?? op.prepared.hash}`, hash: op.task?.subjectHash ?? op.sent.hash, remoteETag: R.eTag ?? null } };
801
+ });
802
+ }
803
+
804
+ // Answer the task the approval was for, with the recorded response — from the
805
+ // coordinator or from a later drain that finished a crashed approval.
806
+ async function completeApproveTask(ws, op, evidence, owner) {
807
+ const deckRoot = path.dirname(ws);
808
+ const t = op.task;
809
+ if (!t?.id || !t.response || !getTask(deckRoot, t.id)) return null;
810
+ const r = t.response;
811
+ try {
812
+ const out = await respondTask(deckRoot, t.id, { operationId: r.operationId, expectedHash: t.subjectHash ?? op.sent.hash, action: r.action ?? "approve", actor: r.actor, payload: r.comment ? { comment: r.comment } : null, selfDeclared: r.selfDeclared === true }, { invoke: () => ({ evidence }), owner });
813
+ return out.task;
814
+ } catch (err) {
815
+ if (err?.code === "stale_subject" || err?.code === "validation_failed") return null; // answered or voided elsewhere; the approval itself stands
816
+ throw err;
817
+ }
818
+ }
819
+
820
+ /**
821
+ * 7.7 — create the shared record in the saved destination when a plan exists
822
+ * and policy allows. `waiting_for_plan` until a structurally editable plan is
823
+ * there; `blocked` (destination kept) when support or writes are missing or
824
+ * the name is taken; `created` once the binding is confirmed; `noop` with a
825
+ * `reason` when there is nothing to do. The uncertain-create readback is the
826
+ * queue's (`drainOnce`).
827
+ */
828
+ export async function activateDestination(workspaceDir, { adapter = null, client = null, owner, now = Date.now } = {}) {
829
+ const ws = wsDir(workspaceDir);
830
+ const io = adapterIo(adapter, ws);
831
+ const result = (action, extra = {}) => ({ action, ...syncStatus(ws), ...extra });
832
+ const blocked = (code, message) => result("blocked", { error: { code, message, retryable: false } });
833
+ const binding = readRecordState(ws);
834
+ const dest = readDestination(ws);
835
+ if (dest?.malformed) return blocked("recovery_required", "destination.json is unreadable; inspect it before creating a record");
836
+ if (!dest) return result("noop", { reason: "no_destination" });
837
+ if (isBound(binding)) {
838
+ if (dest.state !== "created") writeDestination(ws, { ...dest, state: "created" });
839
+ return result("noop", { reason: "already_shared" });
840
+ }
841
+ if (!io) return result("noop", { reason: "no_adapter" });
842
+ if (!hasEditablePlan(ws)) {
843
+ if (dest.state !== "waiting_for_plan") writeDestination(ws, { ...dest, state: "waiting_for_plan" });
844
+ return result("waiting_for_plan");
845
+ }
846
+ let net = client;
847
+ try {
848
+ net ??= await defaultClient({ backendURL: dest.backendURL });
849
+ } catch (err) {
850
+ return blocked(classify(err)?.code ?? "capability_unknown", err.message);
851
+ }
852
+ const caps = await net.capabilities();
853
+ if (caps.status !== "supported") {
854
+ writeDestination(ws, { ...dest, state: "ready_to_create", writes: "unknown" });
855
+ return blocked("capability_unknown", caps.status === "unknown" ? "the backend's capabilities could not be read; nothing was created" : "the backend does not advertise shared records");
856
+ }
857
+ if (caps.sharepointPublish === false) {
858
+ writeDestination(ws, { ...dest, state: "ready_to_create", writes: "disabled" });
859
+ return blocked("write_disabled", "this deployment has record writing switched off; the destination is kept");
860
+ }
861
+ const target = { folderUrl: dest.webUrl, name: dest.name };
862
+ try {
863
+ await net.get({ url: candidateUrl(target) });
864
+ return blocked("name_exists", `${dest.name} already exists in ${dest.folderName ?? "that folder"}; open it or choose another name`);
865
+ } catch (err) {
866
+ const verdict = classify(err);
867
+ if (!verdict) throw err;
868
+ if (verdict.code !== "remote_missing") return blocked(verdict.code, err.message);
869
+ }
870
+ const local = await io.read();
871
+ if (!local.ok) return blocked(local.code, local.message);
872
+ scheduleCreate(ws, { hash: local.hash, ...target });
873
+ const drained = await drainOnce(ws, { adapter, client: net, owner, now });
874
+ if (isBound(readRecordState(ws))) {
875
+ writeDestination(ws, { ...readDestination(ws), state: "created", writes: "enabled" });
876
+ return result("created");
877
+ }
878
+ return drained.action === "blocked" ? result("blocked", { error: drained.lastError }) : result(drained.action);
879
+ }
880
+
881
+ function persistBlock(ws, error, now) {
882
+ release(ws, (q) => {
883
+ q.state = "blocked";
884
+ q.lastError = { code: error.code, message: error.message ?? error.code, retryable: error.retryable === true };
885
+ q.updatedAt = nowIso(now);
886
+ });
887
+ }
888
+
889
+ // Write only `fields` of an in-memory snapshot over the queue on disk: a save
890
+ // that landed while the request was on the wire (pendingHash) is kept.
891
+ function patch(ws, snapshot, fields) {
892
+ const q = readSync(ws);
893
+ for (const f of fields) q[f] = snapshot[f];
894
+ writeSync(ws, q);
895
+ }
896
+
897
+ // Drop our claim, editing the queue in the same write.
898
+ function release(ws, edit = null) {
899
+ let q;
900
+ try {
901
+ q = readSync(ws);
902
+ } catch {
903
+ return;
904
+ }
905
+ if (q.claim?.nonce === NONCE) delete q.claim;
906
+ if (edit) edit(q);
907
+ writeSync(ws, q);
908
+ }
909
+
910
+ function send(net, operation, binding) {
911
+ if (operation.kind === "create") return net.create({ folderUrl: operation.target.folderUrl, name: operation.target.name, plan: operation.sent.value });
912
+ return net.put({ selector: recordSelector(binding), base: { eTag: operation.base.eTag, plan: operation.base.value }, plan: operation.sent.value });
913
+ }
914
+
915
+ // The file a create lands at: the destination's folder link plus the record
916
+ // name (destination.json, 7.7). The record service resolves that link like any
917
+ // pasted one, so the candidate is read through the same client as the record.
918
+ const candidateUrl = (target) => `${String(target.folderUrl).replace(/\/+$/, "")}/${encodeURIComponent(target.name)}`;
919
+
920
+ // Did a write of unknown outcome land? Content for a save; content AND the
921
+ // exact approval for an approve — `sameContent` ignores what the service owns.
922
+ const landed = (remote, op) => sameContent(remote.plan, op.sent.value) && (op.kind !== "approve" || approvedHash(remote.plan) === op.prepared?.hash);
923
+
924
+ // A GET before another PUT: did the write of unknown outcome land? Leaves the
925
+ // queue `applying` (it did — the readback is the response), `pending` (it did
926
+ // not, and the remote is where we left it — resend), `applying` with a stale
927
+ // answer (the remote moved without us — merge, then send), or blocked.
928
+ async function readBack(ws, q, net, binding) {
929
+ const op = q.operation;
930
+ let remote;
931
+ try {
932
+ remote = op.kind === "create" ? await net.get({ url: candidateUrl(op.target) }) : await net.get(recordSelector(binding));
933
+ } catch (err) {
934
+ const verdict = classify(err);
935
+ if (!verdict) throw err;
936
+ if (verdict.code === "remote_missing" && op.kind === "create") {
937
+ q.state = "pending"; // nothing at that name: the create never landed
938
+ writeSync(ws, q);
939
+ return { q };
940
+ }
941
+ release(ws, (queue) => {
942
+ queue.state = verdict.retryable || verdict.unknown ? "outcome-unknown" : "blocked";
943
+ queue.lastError = { code: verdict.unknown ? "outcome_unknown" : verdict.code, message: err.message, retryable: Boolean(verdict.retryable) };
944
+ });
945
+ return { blocked: true };
946
+ }
947
+ if (landed(remote, op)) {
948
+ q.operation.response = { ...remote, submittedApplied: true, recovered: true };
949
+ q.state = "applying";
950
+ } else if (op.kind === "approve") {
951
+ // never resend an approval blindly: nothing of it landed, so it is void — the human decides again
952
+ q.operation = null;
953
+ q.state = q.pendingHash ? "pending" : "idle";
954
+ q.lastError = null;
955
+ } else if (op.kind === "create") {
956
+ // a different file sits at that name: never overwrite it, never invent a second name
957
+ release(ws, (queue) => {
958
+ queue.state = "blocked";
959
+ queue.lastError = { code: "name_exists", message: `${op.target.name} exists in that folder with different content`, retryable: false };
960
+ });
961
+ return { blocked: true };
962
+ } else if (remote.eTag === op.base.eTag) {
963
+ q.state = "pending"; // remote unchanged since our base: the write never arrived
964
+ q.lastError = null;
965
+ } else {
966
+ q.operation.response = { ...remote, staleAnswer: true };
967
+ q.state = "applying";
968
+ }
969
+ writeSync(ws, q);
970
+ return { q };
971
+ }
972
+
973
+ // Apply a persisted response under the lock: merge what came back over what
974
+ // was typed since, advance the confirmed baseline, settle the queue. Keyed by
975
+ // the operation id — a second application of the same response is a no-op.
976
+ async function apply(ws, q, io, binding, backendURL, owner, now) {
977
+ const op = q.operation;
978
+ const R = op.response;
979
+ return runDeckOperation(ws, { operationId: `${op.id}:apply`, kind: "sync-apply", owner }, async (opctx) => {
980
+ const current = readSync(ws);
981
+ if (current.operation?.id !== op.id || !current.operation.response) return;
982
+ const staleAnswer = R.staleAnswer === true;
983
+ // A stale answer means nothing of ours landed: merge R over our BASE so
984
+ // every local change since the base is carried forward and sent next.
985
+ const sent = staleAnswer ? { hash: "sha256:base", value: op.base.value ?? {} } : op.sent;
986
+ await settle(ws, { io, R, base: op.base, sent, binding, backendURL, token: opctx.token, now, staleAnswer, operation: op });
987
+ });
988
+ }
989
+
990
+ // Merge what the service holds over what is local (the pack's rule), advance
991
+ // the confirmed baseline, settle the queue. Shared by a response (`apply`) and
992
+ // a read (`refreshOnce`) — `operation` is the former's, cleared here.
993
+ async function settle(ws, { io, R, base, sent, binding, backendURL, token, now, staleAnswer = false, operation = null }) {
994
+ const merged = await io.applyRemote({ base, sent, returned: { eTag: R.eTag ?? null, value: R.plan } }, token);
995
+ advanceBaseline(ws, R, binding, backendURL);
996
+
997
+ const next = readSync(ws);
998
+ if (!merged.ok) {
999
+ next.state = "blocked";
1000
+ if (merged.code === "remote_conflict") {
1001
+ next.conflict = {
1002
+ conflictId: randomUUID(),
1003
+ expectedHash: merged.hash ?? null,
1004
+ currentETag: R.eTag ?? null,
1005
+ current: R.plan,
1006
+ local: merged.local ?? null,
1007
+ base: base?.value ?? null,
1008
+ sent: operation?.sent ?? sent,
1009
+ merged: merged.merged ?? null,
1010
+ conflicts: merged.conflicts ?? [],
1011
+ at: nowIso(now),
1012
+ };
1013
+ next.operation = null;
1014
+ next.lastError = { code: "remote_conflict", message: `${next.conflict.conflicts.length} field(s) changed on both sides`, retryable: false };
1015
+ } else {
1016
+ // the pack refused the apply: keep the response for a later attempt
1017
+ next.lastError = { code: merged.code, message: merged.message ?? merged.code, retryable: false };
1018
+ }
1019
+ writeSync(ws, next);
1020
+ return merged;
1021
+ }
1022
+ writeAtomic(path.join(ws, "state", EDIT_FILE), { schemaVersion: EDIT_SCHEMA_VERSION, adapterId: io.id, hash: merged.hash, value: merged.value, updatedAt: nowIso(now) });
1023
+ next.operation = null;
1024
+ next.conflict = null;
1025
+ next.lastError = null;
1026
+ next.attempt = 0;
1027
+ next.retryAt = null;
1028
+ if (merged.pending || staleAnswer) {
1029
+ next.state = "pending";
1030
+ next.pendingHash = merged.hash;
1031
+ next.staleRetries = (next.staleRetries ?? 0) + (staleAnswer ? 1 : 0);
1032
+ } else {
1033
+ next.state = "idle";
1034
+ next.pendingHash = null;
1035
+ next.staleRetries = 0;
1036
+ next.lastConfirmedAt = nowIso(now);
1037
+ }
1038
+ writeSync(ws, next);
1039
+ return merged;
1040
+ }
1041
+
1042
+ function conflictBlock(ws, q, body, now) {
1043
+ release(ws, (queue) => {
1044
+ queue.conflict = {
1045
+ conflictId: randomUUID(),
1046
+ expectedHash: q.operation.sent.hash,
1047
+ currentETag: body?.eTag ?? null,
1048
+ current: body?.current ?? null,
1049
+ local: null,
1050
+ base: q.operation.base?.value ?? null,
1051
+ sent: q.operation.sent,
1052
+ merged: body?.merged ?? null,
1053
+ conflicts: Array.isArray(body?.conflicts) ? body.conflicts : [],
1054
+ at: nowIso(now),
1055
+ };
1056
+ queue.operation = null;
1057
+ queue.state = "blocked";
1058
+ queue.lastError = { code: "remote_conflict", message: `${queue.conflict.conflicts.length} field(s) changed on both sides`, retryable: false };
1059
+ });
1060
+ }
1061
+
1062
+ /** Stop hook: a request on the wire when the process goes down is of unknown outcome. */
1063
+ export function markInFlightUnknown() {
1064
+ for (const ws of inFlight) {
1065
+ try {
1066
+ const q = readSync(ws);
1067
+ if (q.state === "sending") {
1068
+ q.state = "outcome-unknown";
1069
+ q.lastError = { code: "outcome_unknown", message: "the process stopped while the request was on the wire", retryable: false };
1070
+ writeSync(ws, q);
1071
+ }
1072
+ } catch {
1073
+ /* the next drain reports it */
1074
+ }
1075
+ }
1076
+ inFlight.clear();
1077
+ }
1078
+
1079
+ /**
1080
+ * The daemon's sender: every `intervalMs` (and on `wake`) drain each deck whose
1081
+ * queue has work. One drain per deck at a time. `decks()` lists workspace
1082
+ * dirs, `adapterFor(ws)` the installed adapter or null. Returns `{ stop,
1083
+ * wake }`; `stop` marks in-flight sends unknown — register it as a stop hook.
1084
+ */
1085
+ export function startSyncLoop({ decks, adapterFor, intervalMs = 2_000, track = (p) => p, log = () => {}, owner = false } = {}) {
1086
+ const running = new Set();
1087
+ const drain = (ws) => {
1088
+ if (running.has(ws)) return;
1089
+ running.add(ws);
1090
+ track(
1091
+ drainOnce(ws, { adapter: adapterFor(ws), owner })
1092
+ .catch((err) => log(`sync ${ws}: ${err?.code ?? ""} ${err?.message ?? err}`))
1093
+ .finally(() => running.delete(ws)),
1094
+ );
1095
+ };
1096
+ const tick = () => {
1097
+ for (const ws of decks()) {
1098
+ try {
1099
+ const { state, retryAt } = syncStatus(ws);
1100
+ const due = state === "pending" && (!retryAt || Date.parse(retryAt) <= Date.now());
1101
+ if (due || state === "sending" || state === "applying" || state === "outcome-unknown") drain(ws);
1102
+ } catch (err) {
1103
+ log(`sync ${ws}: ${err?.message ?? err}`);
1104
+ }
1105
+ }
1106
+ };
1107
+ const timer = setInterval(tick, intervalMs);
1108
+ timer.unref?.();
1109
+ wakeHook = drain;
1110
+ tick();
1111
+ return {
1112
+ wake: (ws) => drain(wsDir(ws)),
1113
+ stop() {
1114
+ clearInterval(timer);
1115
+ if (wakeHook === drain) wakeHook = null;
1116
+ markInFlightUnknown();
1117
+ },
1118
+ };
1119
+ }