@1agh/maude 0.60.7 → 1.0.3

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 +201 -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,334 @@
1
+ // The desktop file ledger — Sync v2 Increment 3 (DDR-226 §3).
2
+ //
3
+ // The properties worth proving are all about SURVIVING A CRASH and about the
4
+ // ledger never claiming more than it knows:
5
+ //
6
+ // • bytes land before the ancestor moves, and the ordering is enforced by the
7
+ // API rather than remembered by callers;
8
+ // • a crash between the two leaves the ancestor LAGGING (recoverable noise)
9
+ // and never LEADING (the eraser class) — proven by killing a real process
10
+ // between the two writes;
11
+ // • a ledger from another hub, a corrupt one, or a missing one all degrade to
12
+ // "re-anchor safely", never to "act on someone else's ancestors";
13
+ // • the stat cache is an observation, not a claim of reconciliation.
14
+
15
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
16
+ import { execFileSync } from 'node:child_process';
17
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ import { createFileLedger, hubIdFor } from '../sync/file-ledger.ts';
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ const HUB = 'https://hub.example.test';
26
+ const A = 'a'.repeat(64);
27
+ const B = 'b'.repeat(64);
28
+
29
+ let root: string;
30
+ beforeEach(() => {
31
+ root = mkdtempSync(join(tmpdir(), 'file-ledger-'));
32
+ });
33
+ afterEach(() => {
34
+ rmSync(root, { recursive: true, force: true });
35
+ });
36
+
37
+ const make = (over: Partial<Parameters<typeof createFileLedger>[0]> = {}) =>
38
+ createFileLedger({ designRoot: root, hubUrl: HUB, flushMs: 0, ...over });
39
+
40
+ describe('where it lives', () => {
41
+ test('under `_state/`, which is already IGNORED in every DDR-115 list', () => {
42
+ const l = make();
43
+ const rel = l.file().slice(root.length + 1);
44
+ // No new `_*` path anybody has to remember to add to four ignore lists.
45
+ expect(rel.startsWith('_state/file-ledger/')).toBe(true);
46
+ });
47
+
48
+ test('one file per hub, stable across runs', () => {
49
+ expect(hubIdFor(HUB)).toBe(hubIdFor(HUB));
50
+ expect(hubIdFor(HUB)).not.toBe(hubIdFor('https://other.example.test'));
51
+ expect(hubIdFor(HUB)).toMatch(/^[a-z0-9.-]+-[0-9a-f]{8}$/i);
52
+ });
53
+ });
54
+
55
+ describe('the write-ordering invariant', () => {
56
+ test('the ancestor moves only after the bytes land', async () => {
57
+ const l = make();
58
+ const order: string[] = [];
59
+ await l.adoptAfter('a/x.png', A, () => {
60
+ order.push('bytes');
61
+ expect(l.ancestorOf('a/x.png')).toBeNull();
62
+ });
63
+ order.push('ancestor');
64
+ expect(order).toEqual(['bytes', 'ancestor']);
65
+ expect(l.ancestorOf('a/x.png')).toBe(A);
66
+ });
67
+
68
+ test('a failed landing leaves the ancestor ALONE and says why', async () => {
69
+ const l = make();
70
+ await l.adoptAfter('a/x.png', A, () => {
71
+ throw new Error('disk full');
72
+ });
73
+ // The ancestor never moved — so the next pass still sees local work.
74
+ expect(l.ancestorOf('a/x.png')).toBeNull();
75
+ expect(l.row('a/x.png')?.state).toBe('stuck');
76
+ expect(l.row('a/x.png')?.reason).toContain('disk full');
77
+ });
78
+
79
+ test('adopting clears a previous failure rather than leaving a stale reason', async () => {
80
+ const l = make();
81
+ await l.adoptAfter('a/x.png', A, () => {
82
+ throw new Error('transient');
83
+ });
84
+ await l.adoptAfter('a/x.png', A, () => {});
85
+ expect(l.row('a/x.png')?.state).not.toBe('stuck');
86
+ expect(l.row('a/x.png')?.reason).toBeUndefined();
87
+ });
88
+
89
+ test('there is NO exported way to move an ancestor without landing something', () => {
90
+ // The ordering is structural, not a convention: the only mutator that
91
+ // touches `syncedHash` takes the byte-landing as its argument.
92
+ const l = make();
93
+ const surface = Object.keys(l);
94
+ expect(surface).toContain('adoptAfter');
95
+ for (const forbidden of ['setAncestor', 'adopt', 'recordAncestor', 'markSynced']) {
96
+ expect(surface).not.toContain(forbidden);
97
+ }
98
+ });
99
+ });
100
+
101
+ describe('the stat cache is an observation, not a claim', () => {
102
+ test('a cached hash is returned only while size AND mtime still match', () => {
103
+ const l = make();
104
+ l.noteLocal('a/x.png', A, 10, 1000);
105
+ expect(l.cachedHash('a/x.png', 10, 1000)).toBe(A);
106
+ expect(l.cachedHash('a/x.png', 11, 1000)).toBeNull();
107
+ expect(l.cachedHash('a/x.png', 10, 1001)).toBeNull();
108
+ expect(l.cachedHash('a/other.png', 10, 1000)).toBeNull();
109
+ });
110
+
111
+ test('observing local bytes NEVER moves the ancestor', () => {
112
+ // This is the separation that keeps the cache from handing back an
113
+ // ancestor for bytes that have since changed.
114
+ const l = make();
115
+ l.noteLocal('a/x.png', B, 10, 1000);
116
+ expect(l.ancestorOf('a/x.png')).toBeNull();
117
+ expect(l.row('a/x.png')?.state).toBe('local-only');
118
+ });
119
+
120
+ test('after a real adopt the observation and the ancestor agree', async () => {
121
+ const l = make();
122
+ l.noteLocal('a/x.png', B, 10, 1000);
123
+ await l.adoptAfter('a/x.png', B, () => {}, { size: 10, mtimeMs: 2000 });
124
+ expect(l.ancestorOf('a/x.png')).toBe(B);
125
+ expect(l.cachedHash('a/x.png', 10, 2000)).toBe(B);
126
+ });
127
+ });
128
+
129
+ describe('the epoch, and what a degraded one means', () => {
130
+ test('a fresh ledger is never degraded — it anchors on whatever the hub says', () => {
131
+ const l = make();
132
+ expect(l.isDegraded('epoch-1')).toBe(false);
133
+ l.setPosition('epoch-1', 12);
134
+ expect(l.isDegraded('epoch-1')).toBe(false);
135
+ expect(l.isDegraded('epoch-2')).toBe(true);
136
+ });
137
+
138
+ test('re-anchoring KEEPS the ancestors and drops only the cursor', async () => {
139
+ // Throwing ancestors away would turn every path into a first-anchor
140
+ // conflict for no gain: they still record what this machine reconciled,
141
+ // which is true whatever the hub's log did.
142
+ const l = make();
143
+ await l.adoptAfter('a/x.png', A, () => {});
144
+ l.setPosition('epoch-1', 40);
145
+ l.reanchor('epoch-2');
146
+ expect(l.ancestorOf('a/x.png')).toBe(A);
147
+ expect(l.cursor()).toBe(0);
148
+ expect(l.epoch()).toBe('epoch-2');
149
+ });
150
+ });
151
+
152
+ describe('the outbox — self-echo detection', () => {
153
+ test('remembers what is in flight and forgets it on completion', () => {
154
+ const l = make();
155
+ expect(l.outboxHas(A)).toBe(false);
156
+ l.outboxAdd(A);
157
+ expect(l.outboxHas(A)).toBe(true);
158
+ l.outboxDone(A);
159
+ expect(l.outboxHas(A)).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe('persistence degrades safely, never dangerously', () => {
164
+ test('a ledger round-trips', async () => {
165
+ const l = make();
166
+ await l.adoptAfter('a/x.png', A, () => {}, { remoteSeq: 7 });
167
+ l.setPosition('epoch-1', 7);
168
+ l.flush();
169
+
170
+ const again = make();
171
+ expect(again.ancestorOf('a/x.png')).toBe(A);
172
+ expect(again.cursor()).toBe(7);
173
+ expect(again.epoch()).toBe('epoch-1');
174
+ expect(again.row('a/x.png')?.remoteSeq).toBe(7);
175
+ });
176
+
177
+ test('a ledger recorded against ANOTHER hub is ignored wholesale', async () => {
178
+ const l = make();
179
+ await l.adoptAfter('a/x.png', A, () => {});
180
+ l.setPosition('epoch-1', 7);
181
+ l.flush();
182
+
183
+ // Same file, different hub: the seqs belong to another log and the
184
+ // ancestors to another project. Acting on them would be worse than
185
+ // starting over.
186
+ const other = createFileLedger({
187
+ designRoot: root,
188
+ hubUrl: HUB,
189
+ flushMs: 0,
190
+ });
191
+ expect(other.ancestorOf('a/x.png')).toBe(A); // same hub → kept
192
+
193
+ const elsewhere = createFileLedger({
194
+ designRoot: root,
195
+ hubUrl: 'https://elsewhere.test',
196
+ flushMs: 0,
197
+ });
198
+ expect(elsewhere.ancestorOf('a/x.png')).toBeNull();
199
+ expect(elsewhere.cursor()).toBe(0);
200
+ });
201
+
202
+ test('a CORRUPT ledger reads as absent — a safe re-anchor, not half-parsed ancestors', () => {
203
+ const l = make();
204
+ l.flush();
205
+ writeFileSync(l.file(), '{ this is not json');
206
+ const again = make();
207
+ expect(again.cursor()).toBe(0);
208
+ expect(Object.keys(again.rows())).toEqual([]);
209
+ });
210
+
211
+ test('a planted `__proto__` row cannot pollute', () => {
212
+ const l = make();
213
+ l.flush();
214
+ writeFileSync(
215
+ l.file(),
216
+ JSON.stringify({
217
+ version: 1,
218
+ hubUrl: HUB,
219
+ epoch: null,
220
+ cursor: 0,
221
+ updatedAt: 0,
222
+ rows: { __proto__: { syncedHash: 'x' }, 'a/ok.png': { syncedHash: A } },
223
+ })
224
+ );
225
+ const again = make();
226
+ expect(again.ancestorOf('a/ok.png')).toBe(A);
227
+ expect(({} as Record<string, unknown>).syncedHash).toBeUndefined();
228
+ });
229
+
230
+ test('an unpersistable ledger warns and keeps working', () => {
231
+ const warns: string[] = [];
232
+ // A path that cannot be a directory: `_state` is a FILE here.
233
+ const blocked = mkdtempSync(join(tmpdir(), 'file-ledger-blocked-'));
234
+ writeFileSync(join(blocked, '_state'), 'not a directory');
235
+ const l = createFileLedger({
236
+ designRoot: blocked,
237
+ hubUrl: HUB,
238
+ flushMs: 0,
239
+ log: { warn: (m: string) => warns.push(m) },
240
+ });
241
+ l.setState('a/x.png', 'local-only');
242
+ l.flush();
243
+ expect(warns.length).toBeGreaterThan(0);
244
+ // …and the in-memory state is intact, so sync keeps running.
245
+ expect(l.row('a/x.png')?.state).toBe('local-only');
246
+ rmSync(blocked, { recursive: true, force: true });
247
+ });
248
+ });
249
+
250
+ /* ------------------------------------------------------------------------- */
251
+
252
+ describe('CRASH: killed between the bytes and the ancestor', () => {
253
+ // The real thing, in a real process. A harness script lands bytes through
254
+ // `adoptAfter` and then hard-exits before the ledger can persist — which is
255
+ // exactly the window a SIGKILL, a power cut or a container migration opens.
256
+ //
257
+ // The outcome that must hold: the file is on disk and the ancestor does NOT
258
+ // name it. That is the ancestor LAGGING, and it degrades to "this looks like
259
+ // a local change" — a conflict copy at worst. The forbidden outcome is the
260
+ // reverse: an ancestor naming bytes that never landed, which would let the
261
+ // next pass overwrite real local work as though it were already reconciled.
262
+ test('the ancestor lags; it never leads', () => {
263
+ const scratch = mkdtempSync(join(tmpdir(), 'ledger-crash-'));
264
+ try {
265
+ const script = join(scratch, 'crash.ts');
266
+ writeFileSync(
267
+ script,
268
+ `
269
+ import { writeFileSync, mkdirSync } from 'node:fs';
270
+ import { join } from 'node:path';
271
+ import { createFileLedger } from ${JSON.stringify(join(HERE, '..', 'sync', 'file-ledger.ts'))};
272
+
273
+ const root = process.argv[2];
274
+ const ledger = createFileLedger({ designRoot: root, hubUrl: ${JSON.stringify(HUB)}, flushMs: 60_000 });
275
+ mkdirSync(join(root, 'assets'), { recursive: true });
276
+ await ledger.adoptAfter('assets/x.png', ${JSON.stringify(A)}, () => {
277
+ writeFileSync(join(root, 'assets/x.png'), 'REAL BYTES');
278
+ });
279
+ // The bytes are down and the ancestor has moved IN MEMORY. The debounce has
280
+ // not fired, so nothing is on disk yet — now die, the way a container does.
281
+ process.kill(process.pid, 'SIGKILL');
282
+ `
283
+ );
284
+ try {
285
+ execFileSync('bun', [script, scratch], { stdio: 'ignore' });
286
+ } catch {
287
+ /* SIGKILL is the point */
288
+ }
289
+
290
+ // The bytes made it…
291
+ expect(existsSync(join(scratch, 'assets/x.png'))).toBe(true);
292
+ expect(readFileSync(join(scratch, 'assets/x.png'), 'utf8')).toBe('REAL BYTES');
293
+
294
+ // …and the ancestor did not, so the next boot re-reads this as local
295
+ // work rather than as something already reconciled.
296
+ const after = createFileLedger({ designRoot: scratch, hubUrl: HUB, flushMs: 0 });
297
+ expect(after.ancestorOf('assets/x.png')).toBeNull();
298
+ } finally {
299
+ rmSync(scratch, { recursive: true, force: true });
300
+ }
301
+ });
302
+
303
+ test('and re-running converges — the lag costs a pass, not a file', async () => {
304
+ // Same shape, survivable: with the ancestor absent the next pass simply
305
+ // adopts what is on disk once it agrees with the hub again.
306
+ const l = make();
307
+ mkdirSync(join(root, 'assets'), { recursive: true });
308
+ writeFileSync(join(root, 'assets/x.png'), 'REAL BYTES');
309
+ expect(l.ancestorOf('assets/x.png')).toBeNull();
310
+ await l.adoptAfter('assets/x.png', A, () => {});
311
+ expect(l.ancestorOf('assets/x.png')).toBe(A);
312
+ });
313
+ });
314
+
315
+ describe('B13 (post-1.0 burn-down) — the park memo dies with the conflict it memoised', () => {
316
+ test('adoptAfter clears parkedRemote alongside conflictCopy', async () => {
317
+ const l = make();
318
+ l.setState('ui/hero.tsx', 'conflict', {
319
+ reason: 'epoch degraded',
320
+ conflictCopy: '_trash/ui/hero.tsx-conflict-1',
321
+ parkedRemote: B,
322
+ });
323
+ expect(l.row('ui/hero.tsx')?.parkedRemote).toBe(B);
324
+
325
+ // The row converges (a later pass lands agreed bytes).
326
+ await l.adoptAfter('ui/hero.tsx', A, () => {});
327
+
328
+ const row = l.row('ui/hero.tsx');
329
+ expect(row?.conflictCopy).toBeUndefined();
330
+ // Without this, hash B was memoised FOREVER: re-diverging to B a week
331
+ // later skipped the park while the ledger still claimed a copy existed.
332
+ expect(row?.parkedRemote).toBeUndefined();
333
+ });
334
+ });
@@ -104,10 +104,26 @@ describe('file-membership — canvas-owned is Plane A, never Plane B', () => {
104
104
  test('.meta.json / .annotations.svg outside a group do not leak into the plane', () => {
105
105
  // `.json` is not positively enumerated; a stray meta sidecar stays home.
106
106
  expect(classifyProjectFile('notes/thing.meta.json')).toBe('never');
107
- // A stray annotations svg outside a group is just an svg.
107
+ // A stray annotations svg in an unrelated SUBFOLDER is just an svg…
108
108
  expect(classifyProjectFile('notes/thing.annotations.svg')).toBe('inert-media');
109
109
  });
110
110
 
111
+ test("the FLAT annotations sidecar is Plane A's — the two-lane erase", () => {
112
+ // Annotations live flat at the design root, keyed by slug
113
+ // (`ui-2.annotations.svg`) — the naming asymmetry the canvas artifacts
114
+ // vocabulary documents. The in-group rule never fires for that shape, so
115
+ // they classified `inert-media` and the FILE plane carried a file the DOC
116
+ // lane already owns. Two lanes, two conflict semantics, no shared
117
+ // ancestor: a stale doc-lane materialisation on one peer read as a fresh
118
+ // local edit to the file plane, was pushed, and erased a drawing made
119
+ // seconds earlier on the other machine (417 B of strokes at 10:50:28, an
120
+ // empty 72 B wrapper over them at 10:50:33 — live journal rows).
121
+ expect(classifyProjectFile('ui-2.annotations.svg')).toBe('canvas-owned');
122
+ expect(classifyProjectFile('ui-ahoj.annotations.svg')).toBe('canvas-owned');
123
+ // But a root-level svg that is NOT an annotations sidecar still flows.
124
+ expect(classifyProjectFile('logo.svg')).toBe('inert-media');
125
+ });
126
+
111
127
  test('the sidecar suffixes flow; bare .json stays default-closed', () => {
112
128
  expect(classifyProjectFile('assets/066a990a.photo.json')).toBe('companion-text');
113
129
  expect(classifyProjectFile('assets/46a07c5e.audio.json')).toBe('companion-text');