@1agh/maude 0.60.7 → 1.0.2

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +190 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -71
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +915 -308
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +71 -108
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +90 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -0,0 +1,581 @@
1
+ // The desktop file ledger — Sync v2 Increment 3 (DDR-226 §3).
2
+ //
3
+ // Three jobs, one file:
4
+ //
5
+ // 1. **The ancestor store.** Per path, the hash this machine last reconciled
6
+ // through. It is what turns "the two sides differ" from a coin flip into
7
+ // a fact (see `decide-file.ts`).
8
+ // 2. **The stat cache.** `(size, mtimeMs) → hash`, so a boot reconcile hashes
9
+ // only files that actually moved. On a converged project the steady-state
10
+ // cost is a stat per file and no reads at all.
11
+ // 3. **The doručenka.** Per-path delivery state, so "where is file X" is a
12
+ // lookup instead of archaeology across four logs (DDR-214's deferred
13
+ // ledger, now due).
14
+ //
15
+ // It lives at `<designRoot>/_state/file-ledger/<hubId>.json`. `_state/` is
16
+ // already IGNORED in all four DDR-115 lists, so this adds no taxonomy churn and
17
+ // no `_*` path anybody has to remember to add anywhere.
18
+ //
19
+ // **Deleting it is always safe.** It holds no content — only hashes of content
20
+ // that exists elsewhere. Losing it forces a re-anchor: every path looks
21
+ // first-seen, differences become conflict-copies rather than overwrites, and
22
+ // the project converges noisily instead of losing anything.
23
+ //
24
+ // ── The write-ordering invariant, and why it lives HERE ─────────────────────
25
+ //
26
+ // The one rule that makes crashes survivable:
27
+ //
28
+ // BYTES LAND FIRST. THE ANCESTOR MOVES SECOND.
29
+ //
30
+ // If we crash between the two, the ancestor LAGS: it names bytes older than
31
+ // what is on disk. The next pass then sees local ≠ ancestor and treats it as a
32
+ // local change — a conflict-copy at worst, noise. If the order were reversed
33
+ // the ancestor would LEAD: it would name bytes that never landed, and the next
34
+ // pass would read a real local file as "already reconciled" and let the hub
35
+ // overwrite it. That is the eraser class, and it is the reason the ordering is
36
+ // not left to callers to remember: `adoptAfter` takes the byte-landing as a
37
+ // callback and records the ancestor only on its success. There is no exported
38
+ // way to move an ancestor without landing something first.
39
+
40
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
41
+ import path from 'node:path';
42
+
43
+ import { atomicWrite } from './atomic-write.ts';
44
+
45
+ /** Debounce for persisting the ledger. Same figure as the doc journal. */
46
+ export const LEDGER_FLUSH_MS = 1_000;
47
+
48
+ /**
49
+ * How recently a file must have been written for its stat cache to be
50
+ * distrusted. Comfortably above any filesystem's timestamp granularity, and
51
+ * well below "a file somebody stopped editing".
52
+ */
53
+ const RECENT_WRITE_MS = 3_000;
54
+
55
+ /**
56
+ * Where a file has got to, most-severe first.
57
+ *
58
+ * The ORDER is the semantics (DDR-214, applied to files): a refusal outranks a
59
+ * cursor outranks any count. `synced`/`everywhere` is a positive assertion and
60
+ * the pessimistic branch is the default — a state nobody set reads as
61
+ * `local-only`, never as delivered.
62
+ */
63
+ export type DeliveryState =
64
+ /** Both sides changed it; a copy is parked and a person has to look. */
65
+ | 'conflict'
66
+ /** We tried and could not — the reason says what to fix. */
67
+ | 'stuck'
68
+ /** Something references this asset and NO peer has ever offered it. */
69
+ | 'referenced-but-unoffered'
70
+ /** On this disk, and the hub has not acknowledged it. */
71
+ | 'local-only'
72
+ /** Upload in flight. */
73
+ | 'pushing'
74
+ /** The hub answered 2xx and gave it a seq. */
75
+ | 'on-hub'
76
+ /** The hub mirrored it to object storage. */
77
+ | 'durable'
78
+ /** At least one other peer's cursor has passed it (and did not refuse it). */
79
+ | 'at-peer'
80
+ /** A heal event was emitted for it (honestly: emitted, not render-acked). */
81
+ | 'ui-healed'
82
+ /** Everywhere we know of. */
83
+ | 'everywhere';
84
+
85
+ export interface LedgerRow {
86
+ /** THE ANCESTOR — the hash this machine last reconciled through. */
87
+ syncedHash: string | null;
88
+ /** The journal seq that ancestor corresponds to, when we learned it. */
89
+ remoteSeq?: number;
90
+ /**
91
+ * THE HUB'S SIDE, as last learned — this peer's replica of the hub manifest.
92
+ *
93
+ * Load-bearing, and subtle. A cursor read returns a DELTA: a path absent
94
+ * from the page means "no news about it", NOT "the hub does not have it".
95
+ * Feeding that absence to `decideFile` as `remote: null` would read as
96
+ * "the hub lost this file" and push every converged file back up on every
97
+ * pass — absence treated as authority, which is the exact trap DDR-076
98
+ * exists to close, reintroduced one layer up.
99
+ *
100
+ * So the delta UPDATES this, and only a full read (`since=0`) is allowed to
101
+ * clear it.
102
+ */
103
+ remoteHash?: string | null;
104
+ /**
105
+ * STAT CACHE — the hash of what was last OBSERVED on disk, valid while
106
+ * `(size, mtimeMs)` still match.
107
+ *
108
+ * Deliberately separate from `syncedHash`. They differ exactly when this
109
+ * machine has an unreconciled local change, and that difference is the
110
+ * signal `decideFile` reads. Folding them into one field would make the
111
+ * cache hand back an ancestor for bytes that have since changed — which is
112
+ * the ancestor-LEADS failure the whole ordering rule exists to prevent.
113
+ */
114
+ localHash?: string;
115
+ /** Stat cache: the size `localHash` was computed for. */
116
+ size?: number;
117
+ /** Stat cache: the mtime `localHash` was computed for. */
118
+ mtimeMs?: number;
119
+ /** Doručenka. Absent reads as `local-only` — never as delivered. */
120
+ state?: DeliveryState;
121
+ /** One sentence a person can act on. Set with `stuck`/`conflict`. */
122
+ reason?: string;
123
+ /** Where the conflict copy was parked, so the panel can point at it. */
124
+ conflictCopy?: string;
125
+ /**
126
+ * The remote hash whose bytes we have ALREADY parked aside.
127
+ *
128
+ * Epoch-degraded parking is otherwise not idempotent: the decision is
129
+ * `noop` + `parkRemote`, so the ancestor deliberately does not move and the
130
+ * next pass finds the identical state — and the copy name carries a
131
+ * millisecond stamp, so it produces a NEW file every time. A hub answering
132
+ * `reanchor` on every request (or rotating its epoch, which is a legitimate
133
+ * event) therefore fills the disk one conflict copy per diverged path per
134
+ * pass, and each copy is then scanned as `create-up` and uploaded back.
135
+ * Remembering which remote we parked makes the second pass a no-op.
136
+ */
137
+ parkedRemote?: string;
138
+ pushedAt?: number;
139
+ pulledAt?: number;
140
+ healedAt?: number;
141
+ }
142
+
143
+ interface LedgerFileShape {
144
+ version: 1;
145
+ hubUrl: string | null;
146
+ /** The journal epoch these ancestors were anchored against. */
147
+ epoch: string | null;
148
+ /** How far through the journal this machine has read. */
149
+ cursor: number;
150
+ updatedAt: number;
151
+ rows: Record<string, LedgerRow>;
152
+ /**
153
+ * Deletions actually applied, per direction, within a rolling window.
154
+ *
155
+ * PERSISTED, and that is the whole point. The first version of the delete
156
+ * breaker recomputed its limit from one pass's worth of decisions, which
157
+ * makes it a rate limit and not a budget: ten per pass, six passes a minute
158
+ * once the hub can poke, and a two-hundred-file project is gone in three
159
+ * minutes without the limit ever tripping. Two per pass was under every arm
160
+ * of it unconditionally, at every project size.
161
+ *
162
+ * A budget has to remember, and it has to remember across a restart, or
163
+ * "restart the app" is the bypass.
164
+ */
165
+ deleteBudget?: { out: DeleteWindow; in: DeleteWindow };
166
+ }
167
+
168
+ /** Deletions applied since `since`, and where they went — for the report. */
169
+ interface DeleteWindow {
170
+ since: number;
171
+ count: number;
172
+ recent: string[];
173
+ }
174
+
175
+ export interface FileLedger {
176
+ /** The epoch our ancestors are anchored against, or null before first read. */
177
+ epoch(): string | null;
178
+ cursor(): number;
179
+ /** True when the hub's epoch no longer matches ours — ancestors stop being
180
+ * overwrite authority until we re-anchor (decide-file's degraded rows). */
181
+ isDegraded(hubEpoch: string | null): boolean;
182
+ /** Adopt a hub epoch + cursor after a successful read. */
183
+ setPosition(epoch: string | null, cursor: number): void;
184
+ /** Forget the cursor (not the ancestors) and re-anchor against a new epoch. */
185
+ reanchor(epoch: string | null): void;
186
+
187
+ row(rel: string): LedgerRow | null;
188
+
189
+ /**
190
+ * How many deletions this direction has applied inside the live window.
191
+ * Rolls the window forward when it has expired, so a caller only ever sees
192
+ * the current budget.
193
+ */
194
+ deletesInWindow(direction: 'out' | 'in', windowMs: number): number;
195
+ /** Charge applied deletions against the window. Persisted. */
196
+ noteDeletes(direction: 'out' | 'in', rels: readonly string[], windowMs: number): void;
197
+ ancestorOf(rel: string): string | null;
198
+ /** The hub's hash for this path, as last learned. `undefined` = never learned. */
199
+ remoteOf(rel: string): string | null | undefined;
200
+ /** Record what the hub holds. A delta read UPDATES; only a full read clears. */
201
+ noteRemote(rel: string, hash: string | null, seq?: number): void;
202
+ /** After a FULL read: forget every remote we did not just see. */
203
+ pruneRemotes(seen: Set<string>): void;
204
+ /** All rows, for the doručenka. Defensive copy. */
205
+ rows(): Record<string, LedgerRow>;
206
+
207
+ /** Cached hash for a file whose (size, mtimeMs) still match. */
208
+ cachedHash(rel: string, size: number, mtimeMs: number): string | null;
209
+ /** This path just changed — drop its stat-cache entry so it is re-read. */
210
+ noteChanged(rel: string): void;
211
+ /** Remember a freshly computed local hash (stat cache only — NOT an ancestor). */
212
+ noteLocal(rel: string, hash: string, size: number, mtimeMs: number): void;
213
+
214
+ /**
215
+ * Land bytes, THEN move the ancestor. The only way to move one.
216
+ *
217
+ * `land` must complete the byte-landing (tmp+rename) before it returns. If it
218
+ * throws, the ancestor is untouched and the row is marked `stuck`.
219
+ */
220
+ adoptAfter(
221
+ rel: string,
222
+ hash: string,
223
+ land: () => void | Promise<void>,
224
+ meta?: { remoteSeq?: number; state?: DeliveryState; size?: number; mtimeMs?: number }
225
+ ): Promise<boolean>;
226
+
227
+ setState(rel: string, state: DeliveryState, extra?: Partial<LedgerRow>): void;
228
+ forget(rel: string): void;
229
+
230
+ /** The outbox: hashes this machine has in flight, for self-echo detection. */
231
+ outboxAdd(hash: string): void;
232
+ outboxDone(hash: string): void;
233
+ outboxHas(hash: string): boolean;
234
+
235
+ flush(): void;
236
+ stop(): void;
237
+ /** Where this ledger persists. Tests and the panel read it. */
238
+ file(): string;
239
+ }
240
+
241
+ /** A stable, filesystem-safe id for a hub URL. */
242
+ export function hubIdFor(url: string): string {
243
+ // Deterministic and short. Not a secret: it names a file inside the user's
244
+ // own runtime state, beside a `_sync.json` that already carries the URL.
245
+ let h = 0x811c9dc5;
246
+ for (let i = 0; i < url.length; i += 1) {
247
+ h ^= url.charCodeAt(i);
248
+ h = Math.imul(h, 0x01000193) >>> 0;
249
+ }
250
+ const host = (() => {
251
+ try {
252
+ return new URL(url).hostname.replace(/[^a-z0-9.-]/gi, '').slice(0, 40);
253
+ } catch {
254
+ return 'hub';
255
+ }
256
+ })();
257
+ return `${host || 'hub'}-${h.toString(16).padStart(8, '0')}`;
258
+ }
259
+
260
+ export interface FileLedgerOptions {
261
+ designRoot: string;
262
+ hubUrl: string;
263
+ now?: () => number;
264
+ flushMs?: number;
265
+ log?: Pick<Console, 'warn'>;
266
+ }
267
+
268
+ export function createFileLedger(opts: FileLedgerOptions): FileLedger {
269
+ const now = opts.now ?? Date.now;
270
+ const flushMs = opts.flushMs ?? LEDGER_FLUSH_MS;
271
+ const log = opts.log ?? console;
272
+ const dir = path.join(opts.designRoot, '_state', 'file-ledger');
273
+ const file = path.join(dir, `${hubIdFor(opts.hubUrl)}.json`);
274
+
275
+ const data: LedgerFileShape = load();
276
+ let timer: ReturnType<typeof setTimeout> | null = null;
277
+ const outbox = new Set<string>();
278
+
279
+ function load(): LedgerFileShape {
280
+ const fresh: LedgerFileShape = {
281
+ version: 1,
282
+ hubUrl: opts.hubUrl,
283
+ epoch: null,
284
+ cursor: 0,
285
+ updatedAt: now(),
286
+ rows: {},
287
+ };
288
+ try {
289
+ if (!existsSync(file)) return fresh;
290
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<LedgerFileShape>;
291
+ // A ledger recorded against a DIFFERENT hub says nothing about this one:
292
+ // the seqs belong to another log and the ancestors to another project.
293
+ if (parsed?.hubUrl !== opts.hubUrl) return fresh;
294
+ if (parsed.version !== 1 || typeof parsed.rows !== 'object' || parsed.rows === null) {
295
+ return fresh;
296
+ }
297
+ return {
298
+ version: 1,
299
+ hubUrl: opts.hubUrl,
300
+ epoch: typeof parsed.epoch === 'string' ? parsed.epoch : null,
301
+ // `typeof` first: Number.isInteger is a runtime guard, not a narrowing
302
+ // one, so a ledger written by an older build with no `cursor` key read
303
+ // as `number` to the checker while being `undefined` at runtime.
304
+ cursor:
305
+ typeof parsed.cursor === 'number' && Number.isInteger(parsed.cursor) && parsed.cursor >= 0
306
+ ? parsed.cursor
307
+ : 0,
308
+ updatedAt: now(),
309
+ rows: sanitizeRows(parsed.rows as Record<string, unknown>),
310
+ };
311
+ } catch {
312
+ // Corrupt is treated as absent — which forces a safe re-anchor rather
313
+ // than acting on half-parsed ancestors.
314
+ return fresh;
315
+ }
316
+ }
317
+
318
+ function sanitizeRows(raw: Record<string, unknown>): Record<string, LedgerRow> {
319
+ const out: Record<string, LedgerRow> = {};
320
+ for (const [rel, value] of Object.entries(raw)) {
321
+ // Proto-pollution reviver discipline, same as every other parse in this
322
+ // tree: this file is on disk and a hostile writer is cheap to imagine.
323
+ if (rel === '__proto__' || rel === 'constructor' || rel === 'prototype') continue;
324
+ if (!value || typeof value !== 'object') continue;
325
+ const r = value as LedgerRow;
326
+ out[rel] = {
327
+ syncedHash: typeof r.syncedHash === 'string' ? r.syncedHash : null,
328
+ ...(typeof r.localHash === 'string' ? { localHash: r.localHash } : {}),
329
+ ...(Number.isInteger(r.remoteSeq) ? { remoteSeq: r.remoteSeq } : {}),
330
+ ...(typeof r.remoteHash === 'string' || r.remoteHash === null
331
+ ? { remoteHash: r.remoteHash }
332
+ : {}),
333
+ ...(Number.isFinite(r.size) ? { size: r.size } : {}),
334
+ ...(Number.isFinite(r.mtimeMs) ? { mtimeMs: r.mtimeMs } : {}),
335
+ ...(typeof r.state === 'string' ? { state: r.state as DeliveryState } : {}),
336
+ ...(typeof r.reason === 'string' ? { reason: r.reason.slice(0, 200) } : {}),
337
+ ...(typeof r.conflictCopy === 'string' ? { conflictCopy: r.conflictCopy } : {}),
338
+ };
339
+ }
340
+ return out;
341
+ }
342
+
343
+ function schedule(): void {
344
+ if (timer !== null) return;
345
+ timer = setTimeout(() => {
346
+ timer = null;
347
+ persist();
348
+ }, flushMs);
349
+ timer.unref?.();
350
+ }
351
+
352
+ function persist(): void {
353
+ try {
354
+ mkdirSync(dir, { recursive: true });
355
+ data.updatedAt = now();
356
+ atomicWrite(file, `${JSON.stringify(data, null, 2)}\n`);
357
+ } catch (err) {
358
+ // Never throws into the sync hot path. A ledger we cannot persist costs
359
+ // a re-anchor next boot, which is noise rather than loss.
360
+ log.warn?.(`[sync/ledger] could not persist: ${(err as Error).message}`);
361
+ }
362
+ }
363
+
364
+ function rowFor(rel: string): LedgerRow {
365
+ const existing = data.rows[rel];
366
+ if (existing) return existing;
367
+ const created: LedgerRow = { syncedHash: null };
368
+ data.rows[rel] = created;
369
+ return created;
370
+ }
371
+
372
+ /** The live window for `direction`, rolled forward if it has expired. */
373
+ function windowFor(direction: 'out' | 'in', windowMs: number): DeleteWindow {
374
+ if (!data.deleteBudget) {
375
+ data.deleteBudget = {
376
+ out: { since: now(), count: 0, recent: [] },
377
+ in: { since: now(), count: 0, recent: [] },
378
+ };
379
+ }
380
+ const w = data.deleteBudget[direction];
381
+ if (now() - w.since >= windowMs) {
382
+ w.since = now();
383
+ w.count = 0;
384
+ w.recent = [];
385
+ }
386
+ return w;
387
+ }
388
+
389
+ return {
390
+ epoch: () => data.epoch,
391
+ cursor: () => data.cursor,
392
+
393
+ deletesInWindow(direction, windowMs) {
394
+ return windowFor(direction, windowMs).count;
395
+ },
396
+
397
+ noteDeletes(direction, rels, windowMs) {
398
+ if (rels.length === 0) return;
399
+ const w = windowFor(direction, windowMs);
400
+ w.count += rels.length;
401
+ // A short tail, so the panel can say WHAT went without the ledger
402
+ // growing without bound on a busy project.
403
+ w.recent = [...w.recent, ...rels].slice(-50);
404
+ schedule();
405
+ },
406
+
407
+ isDegraded(hubEpoch) {
408
+ // Before the first read we have no epoch and nothing to be degraded
409
+ // against; a fresh ledger anchors on whatever the hub says.
410
+ if (data.epoch === null || hubEpoch === null) return false;
411
+ return data.epoch !== hubEpoch;
412
+ },
413
+
414
+ setPosition(epoch, cursor) {
415
+ data.epoch = epoch;
416
+ if (Number.isInteger(cursor) && cursor >= 0) data.cursor = cursor;
417
+ schedule();
418
+ },
419
+
420
+ reanchor(epoch) {
421
+ // The ancestors STAY. They still record what this machine last
422
+ // reconciled, which is true regardless of what the hub's log did; they
423
+ // simply stop being overwrite authority until the next clean read
424
+ // (decide-file's degraded rows). Throwing them away would turn every
425
+ // path into a first-anchor conflict for no gain.
426
+ data.epoch = epoch;
427
+ data.cursor = 0;
428
+ schedule();
429
+ },
430
+
431
+ row: (rel) => data.rows[rel] ?? null,
432
+ ancestorOf: (rel) => data.rows[rel]?.syncedHash ?? null,
433
+ remoteOf: (rel) => data.rows[rel]?.remoteHash,
434
+
435
+ noteRemote(rel, hash, seq) {
436
+ const r = rowFor(rel);
437
+ r.remoteHash = hash;
438
+ if (seq !== undefined) r.remoteSeq = seq;
439
+ schedule();
440
+ },
441
+
442
+ pruneRemotes(seen) {
443
+ // ONLY a full compaction read may say "the hub does not have this".
444
+ // Called with the set of paths that read carried; anything else loses
445
+ // its remembered remote rather than keeping a value the hub has since
446
+ // dropped.
447
+ for (const [rel, r] of Object.entries(data.rows)) {
448
+ if (!seen.has(rel) && r.remoteHash !== undefined) r.remoteHash = null;
449
+ }
450
+ schedule();
451
+ },
452
+ rows: () => JSON.parse(JSON.stringify(data.rows)) as Record<string, LedgerRow>,
453
+
454
+ cachedHash(rel, size, mtimeMs) {
455
+ const r = data.rows[rel];
456
+ if (!r || r.size !== size || r.mtimeMs !== mtimeMs) return null;
457
+ // THE MTIME-GRANULARITY GUARD. `(size, mtime)` is a good enough identity
458
+ // for a file that has been sitting still, and a poor one for a file
459
+ // edited moments ago: two same-length writes inside the filesystem's
460
+ // timestamp resolution are indistinguishable, and trusting the cache
461
+ // there means a real edit is simply never noticed. rsync has carried
462
+ // this same guard for the same reason.
463
+ //
464
+ // So a recently-touched file is always re-read. It costs one hash of one
465
+ // file at exactly the moment somebody is working on it, and it buys back
466
+ // the class of silently-unsynced edit.
467
+ if (now() - mtimeMs < RECENT_WRITE_MS) return null;
468
+ return r.localHash ?? null;
469
+ },
470
+
471
+ noteChanged(rel) {
472
+ // The watcher told us this path moved. Drop the stat cache for it so the
473
+ // next scan reads the bytes rather than trusting a stamp — the cheap,
474
+ // exact version of the guard above.
475
+ const r = data.rows[rel];
476
+ if (!r) return;
477
+ r.localHash = undefined;
478
+ r.size = undefined;
479
+ r.mtimeMs = undefined;
480
+ schedule();
481
+ },
482
+
483
+ noteLocal(rel, hash, size, mtimeMs) {
484
+ // Records an OBSERVATION, never a reconciliation. This is what makes a
485
+ // boot reconcile cheap: next time, a file whose (size, mtime) still
486
+ // match is not read at all.
487
+ const r = rowFor(rel);
488
+ r.localHash = hash;
489
+ r.size = size;
490
+ r.mtimeMs = mtimeMs;
491
+ // A file whose bytes differ from the ancestor has unsent work on it, and
492
+ // saying so is the honest default — `local-only` is the pessimistic
493
+ // branch, and nothing here may claim delivery.
494
+ if (r.syncedHash !== hash && !r.state) r.state = 'local-only';
495
+ schedule();
496
+ },
497
+
498
+ async adoptAfter(rel, hash, land, meta) {
499
+ try {
500
+ await land();
501
+ } catch (err) {
502
+ const r = rowFor(rel);
503
+ r.state = 'stuck';
504
+ r.reason = (err as Error).message.slice(0, 200);
505
+ schedule();
506
+ return false;
507
+ }
508
+ // ONLY here. Bytes are on disk; the ancestor may move.
509
+ const r = rowFor(rel);
510
+ r.syncedHash = hash;
511
+ // The observation moves with it — what landed IS what is on disk now,
512
+ // so leaving a stale `localHash` behind would make the very next pass
513
+ // read a freshly-reconciled file as locally changed.
514
+ r.localHash = hash;
515
+ if (meta?.remoteSeq !== undefined) r.remoteSeq = meta.remoteSeq;
516
+ if (meta?.size !== undefined) r.size = meta.size;
517
+ if (meta?.mtimeMs !== undefined) r.mtimeMs = meta.mtimeMs;
518
+ if (meta?.state) {
519
+ r.state = meta.state;
520
+ } else if (
521
+ r.state === 'stuck' ||
522
+ r.state === 'conflict' ||
523
+ r.state === 'referenced-but-unoffered'
524
+ ) {
525
+ // A resolved problem must stop being reported as a problem. Dropping
526
+ // the state (rather than inventing a cheerful one) leaves the row at
527
+ // the pessimistic default — we no longer know it is stuck, and we are
528
+ // not claiming it is delivered either. Leaving the old value would be
529
+ // the "status lies" failure DDR-214 exists to end, just slower.
530
+ r.state = undefined;
531
+ }
532
+ r.reason = undefined;
533
+ r.conflictCopy = undefined;
534
+ // B13 (post-1.0 burn-down) — the park memo dies with the conflict it
535
+ // memoised. `parkedRemote` used to survive convergence forever, so once
536
+ // hash H was memoised for a path, H was never parked again — after the
537
+ // user deleted the copy, after the row re-diverged, after a `_trash/`
538
+ // prune. A hub re-offering H a week later got a `noop` with no
539
+ // recoverable copy anywhere, while the row still claimed one was made.
540
+ r.parkedRemote = undefined;
541
+ schedule();
542
+ return true;
543
+ },
544
+
545
+ setState(rel, state, extra) {
546
+ const r = rowFor(rel);
547
+ r.state = state;
548
+ if (extra) Object.assign(r, extra);
549
+ schedule();
550
+ },
551
+
552
+ forget(rel) {
553
+ delete data.rows[rel];
554
+ schedule();
555
+ },
556
+
557
+ outboxAdd: (hash) => {
558
+ outbox.add(hash);
559
+ },
560
+ outboxDone: (hash) => {
561
+ outbox.delete(hash);
562
+ },
563
+ outboxHas: (hash) => outbox.has(hash),
564
+
565
+ flush() {
566
+ if (timer !== null) {
567
+ clearTimeout(timer);
568
+ timer = null;
569
+ }
570
+ persist();
571
+ },
572
+ stop() {
573
+ if (timer !== null) {
574
+ clearTimeout(timer);
575
+ timer = null;
576
+ }
577
+ persist();
578
+ },
579
+ file: () => file,
580
+ };
581
+ }
@@ -218,6 +218,20 @@ export function classifyProjectFile(rel: string, opts: ClassifyOptions = {}): Fi
218
218
  }
219
219
  }
220
220
 
221
+ // The annotations sidecar's REAL shape: flat at the design root, keyed by
222
+ // the slug (`ui-2.annotations.svg`) — the naming asymmetry the canvas
223
+ // artifacts vocabulary documents. The in-group rule above never fires for
224
+ // it, so it fell through to `inert-media` and the FILE plane carried a file
225
+ // the DOC lane already owns. Two lanes, two conflict semantics, no shared
226
+ // ancestor: a stale doc-lane materialisation on one peer bumped the file's
227
+ // mtime, the file plane read that as a fresh local edit and pushed it, and
228
+ // a drawing made seconds earlier on the other machine was erased everywhere
229
+ // (observed live: 417 B of strokes at 10:50:28, an empty 72 B wrapper
230
+ // pushed over them at 10:50:33). The annotations lane's own stamped
231
+ // newest-wins protection never saw it coming — it guards the DOC lane, and
232
+ // this was the file plane acting alone. One owner: the canvas.
233
+ if (parts.length === 1 && lowerLast.endsWith('.annotations.svg')) return 'canvas-owned';
234
+
221
235
  if (COMPANION_SIDECAR_SUFFIXES.some((s) => lowerLast.endsWith(s))) return 'companion-text';
222
236
 
223
237
  const dot = lowerLast.lastIndexOf('.');
@@ -243,11 +257,29 @@ export function isProjectFileShape(rel: string): boolean {
243
257
  }
244
258
 
245
259
  /** The segment shape rules alone — split parts, or null on refusal. */
260
+
261
+ /**
262
+ * Another program's conflict artifact. Never ours to carry.
263
+ *
264
+ * `~/git` is a real Syncthing tree, and Syncthing writes
265
+ * `hero.sync-conflict-20260818-101500-ABCDEF.png` beside the original. Nothing
266
+ * excluded those, so `scanLocalFiles` saw one as `create-up`, pushed it to the
267
+ * hub, journalled it, and delivered it to every peer — where Syncthing could
268
+ * in turn make conflict copies OF the conflict copies. A noise-amplification
269
+ * loop in the one environment the maintainer actually runs, and it makes
270
+ * conflict provenance exactly as unattributable as `conflictCopyName`'s own
271
+ * comment says it must not be.
272
+ */
273
+ export function isForeignConflictArtifact(rel: string): boolean {
274
+ return /\.sync-conflict-/i.test(rel);
275
+ }
276
+
246
277
  function relShape(rel: unknown): string[] | null {
247
278
  if (typeof rel !== 'string' || rel.length === 0 || rel.length > MAX_REL_LEN) return null;
248
279
  // biome-ignore lint/suspicious/noControlCharactersInRegex: refusing them is the point.
249
280
  if (/[\u0000-\u001f\u007f]/.test(rel)) return null;
250
281
  if (rel.startsWith('/') || rel.includes('\\') || /^[A-Za-z]:/.test(rel)) return null;
282
+ if (isForeignConflictArtifact(rel)) return null;
251
283
  const parts = rel.split('/');
252
284
  if (parts.length > MAX_SEGMENTS) return null;
253
285
  for (let i = 0; i < parts.length; i++) {