@1agh/maude 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +689 -36
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-edit.ts +391 -7
  10. package/apps/studio/canvas-lib.tsx +82 -18
  11. package/apps/studio/canvas-notice-message.ts +16 -0
  12. package/apps/studio/canvas-notifications.tsx +19 -0
  13. package/apps/studio/canvas-shell.tsx +57 -6
  14. package/apps/studio/client/app.jsx +686 -241
  15. package/apps/studio/client/apply-edit-request.ts +55 -0
  16. package/apps/studio/client/export-center.jsx +111 -61
  17. package/apps/studio/client/github.js +38 -1
  18. package/apps/studio/client/index-loader.ts +76 -0
  19. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  20. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  21. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  23. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  24. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  25. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  26. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  27. package/apps/studio/client/photo-knobs.jsx +3 -0
  28. package/apps/studio/client/share-dialog.jsx +91 -0
  29. package/apps/studio/client/share-link.js +94 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +35 -18
  31. package/apps/studio/client/styles/4-components.css +18 -1
  32. package/apps/studio/client/tree-row-menu.jsx +48 -3
  33. package/apps/studio/client/whats-new.jsx +26 -32
  34. package/apps/studio/cloud/endpoints.ts +132 -0
  35. package/apps/studio/collab/index.ts +4 -0
  36. package/apps/studio/collab/persistence.ts +5 -1
  37. package/apps/studio/collab/registry.ts +9 -3
  38. package/apps/studio/collab/room.ts +13 -2
  39. package/apps/studio/context.ts +59 -0
  40. package/apps/studio/dist/client.bundle.js +1586 -1554
  41. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  42. package/apps/studio/dist/runtime/sonner.js +1 -0
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/http.ts +289 -18
  45. package/apps/studio/inspect.ts +19 -0
  46. package/apps/studio/managed-projects.ts +150 -0
  47. package/apps/studio/notifications.tsx +292 -0
  48. package/apps/studio/runtime-bundle.ts +2 -0
  49. package/apps/studio/server.ts +59 -3
  50. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  51. package/apps/studio/sync/accepted-link.ts +320 -0
  52. package/apps/studio/sync/action-stage.ts +343 -0
  53. package/apps/studio/sync/agent.ts +12 -60
  54. package/apps/studio/sync/cell-file-events.ts +3 -0
  55. package/apps/studio/sync/codec.ts +73 -6
  56. package/apps/studio/sync/ctl-provider.ts +15 -2
  57. package/apps/studio/sync/document-discovery.ts +81 -0
  58. package/apps/studio/sync/file-membership.ts +10 -2
  59. package/apps/studio/sync/file-plane.ts +309 -26
  60. package/apps/studio/sync/index.ts +1078 -19
  61. package/apps/studio/sync/migrate-seed.ts +110 -19
  62. package/apps/studio/sync/poke.ts +4 -2
  63. package/apps/studio/sync/presentation.ts +285 -2
  64. package/apps/studio/sync/projection.ts +820 -23
  65. package/apps/studio/sync/repeated-module.ts +73 -0
  66. package/apps/studio/sync/revision-barrier.ts +129 -0
  67. package/apps/studio/sync/seed-repair.ts +46 -0
  68. package/apps/studio/sync/source-merge.ts +100 -0
  69. package/apps/studio/sync/source-ops.ts +289 -0
  70. package/apps/studio/sync/source-recovery.ts +70 -0
  71. package/apps/studio/sync/source-validation.ts +56 -0
  72. package/apps/studio/sync/status.ts +79 -1
  73. package/apps/studio/sync/transaction-client.ts +460 -0
  74. package/apps/studio/sync/writer-registry.ts +236 -0
  75. package/apps/studio/text-caret.ts +35 -0
  76. package/apps/studio/undo-hud.tsx +9 -87
  77. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  78. package/apps/studio/whats-new.json +101 -0
  79. package/cli/lib/harness/codex-runtime.mjs +4 -1
  80. package/package.json +9 -8
  81. package/plugins/design/dependencies.json +3 -3
  82. package/plugins/design/templates/_shell.html +47 -0
  83. package/plugins/flow/dependencies.json +3 -3
@@ -52,12 +52,10 @@ import {
52
52
  cssFromDoc,
53
53
  htmlFromDoc,
54
54
  isEmptyAnnotationsSvg,
55
- markSeeded,
56
55
  mergeSharedMetaIntoLocal,
57
56
  metaFromDoc,
58
57
  movedToFromDoc,
59
58
  repairSharedMeta,
60
- seededByFromDoc,
61
59
  stampAnnotationsEdit,
62
60
  stampBodyEdit,
63
61
  Y_SYNC_TYPES,
@@ -66,13 +64,13 @@ import {
66
64
  decideAnnotationsColdStart,
67
65
  decideColdStart,
68
66
  decideCssColdStart,
69
- isExactRepeat,
70
67
  unionCommentsById,
71
68
  } from './cold-start.ts';
72
69
  import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
73
70
  import { dedupeCommentsById, hasDuplicateComments } from './comment-identity.ts';
74
71
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
75
72
  import type { SyncJournal } from './journal.ts';
73
+ import { rememberSeed, repairSeedDuplication } from './seed-repair.ts';
76
74
 
77
75
  export const DOC_FLUSH_MS = 800;
78
76
 
@@ -82,7 +80,7 @@ export const DOC_FLUSH_MS = 800;
82
80
  * the cold-start race — a genuine peer edit that arrives minutes later is NEVER
83
81
  * collapsed back to our original seed. Generous relative to the hub sync RTT.
84
82
  */
85
- export const SEED_REPAIR_WINDOW_MS = 10_000;
83
+ export { SEED_REPAIR_WINDOW_MS } from './seed-repair.ts';
86
84
 
87
85
  export interface CanvasSyncPaths {
88
86
  /** Absolute path to <designRoot>/<canvas>.html. */
@@ -187,18 +185,6 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
187
185
  let lastMeta: string | null = null;
188
186
  let lastCss: string | null = null;
189
187
 
190
- // F1 — the content this agent pushed on `seed-local-up`, plus the deadline
191
- // past which the de-dup repair stops firing. Set on seed; nulled only when the
192
- // window lapses (after convergence the repair is a cheap no-op — the
193
- // `=== seedInfo.x` guards short-circuit, so we don't bother clearing).
194
- // Null = this agent never seeded (so it never repairs).
195
- //
196
- // `css` rides along with the body (issue #114): a seed writes BOTH lanes in
197
- // the same breath, so both can collide with a concurrent peer's identical
198
- // seed, and repairing only the body left the canvas un-buildable anyway —
199
- // duplicated css is as fatal to the build as a duplicated `export default`.
200
- let seedInfo: { body: string; css: string | null; until: number } | null = null;
201
-
202
188
  function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
203
189
  if (stopped) return;
204
190
  // Self-applied (we just synced from disk) — disk is already current.
@@ -246,45 +232,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
246
232
  * variant is an accepted follow-up, not handled in-flight.
247
233
  */
248
234
  function maybeRepairSeedDuplication(): void {
249
- if (!seedInfo) return;
250
- if (Date.now() > seedInfo.until) {
251
- seedInfo = null;
252
- return;
253
- }
254
- const owner = seededByFromDoc(doc);
255
- if (owner === null || owner !== doc.clientID) return; // not the elected writer
256
- const seeded = seedInfo;
257
- const repaired: string[] = [];
258
-
259
- const body = htmlFromDoc(doc);
260
- // `=== seeded.body` → already a single copy; not an exact repeat → a
261
- // divergent edit, which carries genuine bytes and belongs to the conflict
262
- // path, not to a silent collapse.
263
- if (body !== seeded.body && isExactRepeat(body, seeded.body)) {
264
- const canonical = seeded.body;
265
- doc.transact(() => {
266
- if (applyHtmlToDoc(doc, canonical, origin)) stampBodyEdit(doc, origin);
267
- }, origin);
268
- lastHtml = canonical;
269
- opts.journal?.record(slug, { bodyHash: hashBytes(canonical) });
270
- repaired.push('body');
235
+ const repaired = repairSeedDuplication(doc, origin);
236
+ if (repaired.includes('body')) {
237
+ lastHtml = htmlFromDoc(doc);
238
+ opts.journal?.record(slug, { bodyHash: hashBytes(lastHtml) });
271
239
  }
272
-
273
- // The css half of the same collision (issue #114). Same proof obligation as
274
- // the body — an exact integer repeat of what WE seeded — and the same
275
- // idempotence: the collapse deletes the same CRDT items on every peer that
276
- // runs it, so concurrent recoveries converge instead of fighting.
277
- if (paths.css && seeded.css !== null) {
278
- const css = cssFromDoc(doc);
279
- if (css !== null && css !== seeded.css && isExactRepeat(css, seeded.css)) {
280
- const canonicalCss = seeded.css;
281
- doc.transact(() => {
282
- applyCssToDoc(doc, canonicalCss, origin);
283
- }, origin);
284
- lastCss = canonicalCss;
285
- opts.journal?.record(slug, { cssHash: hashBytes(canonicalCss) });
286
- repaired.push('css');
287
- }
240
+ if (repaired.includes('css')) {
241
+ lastCss = cssFromDoc(doc);
242
+ if (lastCss !== null) opts.journal?.record(slug, { cssHash: hashBytes(lastCss) });
288
243
  }
289
244
 
290
245
  if (repaired.length > 0) {
@@ -518,9 +473,8 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
518
473
  // F1 — adopt is also a "seed local up" (first link / fresh hub); claim
519
474
  // it + arm the repair window so two peers adopting the same draft into
520
475
  // one hub at once self-heal the same way the decision-table seed does.
521
- markSeeded(doc, origin);
476
+ rememberSeed(doc, localHtml, localCss, origin);
522
477
  }, origin);
523
- seedInfo = { body: localHtml, css: localCss, until: Date.now() + SEED_REPAIR_WINDOW_MS };
524
478
  }
525
479
  if (localComments !== null) {
526
480
  const parsed = tryParseJsonArray(localComments);
@@ -575,15 +529,13 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
575
529
  if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
576
530
  // F1 — claim the seed (clientID marker) in the SAME update so a
577
531
  // concurrent second seeder's merge resolves a single elected writer.
578
- markSeeded(doc, origin);
532
+ rememberSeed(doc, body, localCss, origin);
579
533
  }, origin);
580
534
  lastHtml = body;
581
535
  // Arm the de-dup repair window: if another peer seeded the same content at
582
536
  // the same instant, the merged Y.Text will double — maybeRepairSeedDuplication
583
537
  // (on the elected owner) collapses it back during this window. `localCss`
584
- // is armed too: the css lane is seeded by the same cold start a few lines
585
- // below and collides identically (issue #114).
586
- seedInfo = { body, css: localCss, until: Date.now() + SEED_REPAIR_WINDOW_MS };
538
+ // is remembered too, since it collides identically (issue #114).
587
539
  opts.journal?.record(slug, { bodyHash: hashBytes(body) });
588
540
  };
589
541
 
@@ -89,6 +89,9 @@ export function startCellFileEvents(
89
89
  url: target.url,
90
90
  token: target.token,
91
91
  onPoke: (head) => healer.onPoke(head),
92
+ // The paired cell runtime does not open a second control provider. Route
93
+ // metadata invalidation to its bounded discovery queue, not the journal.
94
+ onDocuments: () => ctx.bus.emit('sync:documents-changed'),
92
95
  });
93
96
 
94
97
  // Every write this process makes surfaces here, because both synthetic-event
@@ -1,3 +1,4 @@
1
+ import { ANNOTATION_WRITE_ID } from '../annotations-sync.ts';
1
2
  // Y.Doc ↔ disk codecs for the bidirectional file sync agent (Phase 9 Task 4).
2
3
  //
3
4
  // The agent shuttles three classes of files between disk and the Y.Doc the
@@ -22,11 +23,11 @@
22
23
  // in the unchanged regions.
23
24
 
24
25
  import { hostname } from 'node:os';
25
-
26
+ import { diffChars } from 'diff';
26
27
  import type * as Y from 'yjs';
27
28
 
28
29
  import { Y_TYPES } from '../collab/persistence.ts';
29
- import { commentKey } from './comment-identity.ts';
30
+ import { commentKey, dedupeCommentsById } from './comment-identity.ts';
30
31
  import {
31
32
  MAX_ANNOTATIONS_BYTES,
32
33
  MAX_COMMENTS_BYTES,
@@ -97,8 +98,8 @@ export function htmlFromDoc(doc: Y.Doc): string {
97
98
  }
98
99
 
99
100
  /**
100
- * Apply `next` to one Y.Text lane as a minimal common-prefix / common-suffix
101
- * replace, so peers see a small op rather than a full replace.
101
+ * Apply `next` as bounded text hunks, preserving unchanged interior CRDT
102
+ * anchors as well as the common prefix/suffix (#121).
102
103
  *
103
104
  * THIS SHAPE IS A CONVERGENCE PROPERTY, NOT AN OPTIMIZATION (issue #114).
104
105
  * Collapsing a concurrency-duplicated lane — `X + X` back to `X` — comes out of
@@ -137,12 +138,37 @@ function applyTextLane(doc: Y.Doc, lane: string, next: string, origin?: unknown)
137
138
  suffix++;
138
139
  }
139
140
 
141
+ // Y.Text offsets are UTF-16; never leave half a surrogate outside a hunk.
142
+ if (prefix > 0 && /[\uD800-\uDBFF]/.test(current[prefix - 1] ?? '')) prefix--;
143
+ if (suffix > 0 && /[\uDC00-\uDFFF]/.test(current[current.length - suffix] ?? '')) suffix--;
140
144
  const deleteLen = current.length - prefix - suffix;
141
145
  const insertStr = next.slice(prefix, next.length - suffix);
142
146
 
147
+ // Keep pure inserts/deletes (especially repeat collapse) as one operation.
148
+ // For replacements preserve interior anchors: one prefix/suffix splice can
149
+ // move a concurrent insertion inside an unrelated JSX attribute (#121).
150
+ const changes =
151
+ deleteLen > 0 && insertStr.length > 0
152
+ ? diffChars(current.slice(prefix, current.length - suffix), insertStr, {
153
+ maxEditLength: 4096,
154
+ timeout: 50,
155
+ })
156
+ : null;
157
+ if (changes === undefined) throw new Error('Source diff exceeds the safe merge budget');
143
158
  doc.transact(() => {
144
- if (deleteLen > 0) yText.delete(prefix, deleteLen);
145
- if (insertStr.length > 0) yText.insert(prefix, insertStr);
159
+ if (changes) {
160
+ let offset = prefix;
161
+ for (const change of changes) {
162
+ if (change.removed) yText.delete(offset, change.value.length);
163
+ else {
164
+ if (change.added) yText.insert(offset, change.value);
165
+ offset += change.value.length;
166
+ }
167
+ }
168
+ } else {
169
+ if (deleteLen > 0) yText.delete(prefix, deleteLen);
170
+ if (insertStr.length > 0) yText.insert(prefix, insertStr);
171
+ }
146
172
  }, origin);
147
173
 
148
174
  return true;
@@ -306,6 +332,8 @@ export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?:
306
332
  if (currentStr === next) return false;
307
333
 
308
334
  doc.transact(() => {
335
+ // A filesystem import is a new operation, never the previous UI author's echo.
336
+ map.delete(ANNOTATION_WRITE_ID);
309
337
  if (next === null || next === '') {
310
338
  map.delete('svg');
311
339
  } else {
@@ -389,6 +417,45 @@ function parsesAsObject(s: string): boolean {
389
417
  }
390
418
 
391
419
  /** The synced shared-meta JSON string held in the doc, or null when unset. */
420
+ /**
421
+ * The canonical LANE value for a file's text — what the accepted-revisions
422
+ * kernel stores and what `readLaneFromDoc` returns once it is accepted
423
+ * (DDR-241). `null` when the file text cannot be a value of that lane.
424
+ */
425
+ export function laneValueFromFile(
426
+ lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments',
427
+ text: string
428
+ ): string | null {
429
+ if (lane === 'html' || lane === 'css' || lane === 'annotations') return text;
430
+ let parsed: unknown;
431
+ try {
432
+ parsed = parseJsonSafe(text);
433
+ } catch {
434
+ return null;
435
+ }
436
+ if (lane === 'meta') {
437
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
438
+ return sharedMetaCanonical(parsed as Record<string, unknown>);
439
+ }
440
+ if (!Array.isArray(parsed)) return null;
441
+ const list = dedupeCommentsById(parsed);
442
+ return list.length ? JSON.stringify(list) : '';
443
+ }
444
+
445
+ /** A lane's value as the accepted replica holds it (same canonical form). */
446
+ export function readLaneFromDoc(
447
+ doc: Y.Doc,
448
+ lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments'
449
+ ): string {
450
+ if (lane === 'html' || lane === 'css' || lane === 'meta') return doc.getText(lane).toString();
451
+ if (lane === 'annotations') {
452
+ const svg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
453
+ return typeof svg === 'string' ? svg : '';
454
+ }
455
+ const list = doc.getArray<unknown>(Y_TYPES.comments).toArray();
456
+ return list.length ? JSON.stringify(list) : '';
457
+ }
458
+
392
459
  export function metaFromDoc(doc: Y.Doc): string | null {
393
460
  return normalizeSharedMeta(doc.getText(Y_SYNC_TYPES.meta).toString());
394
461
  }
@@ -95,6 +95,8 @@ export interface CtlProviderOptions {
95
95
  token: string;
96
96
  /** Fired with the hub's head on every well-formed poke. */
97
97
  onPoke: (head: number) => void;
98
+ /** Optional fast document-list invalidation; older consumers use onPoke. */
99
+ onDocuments?: () => void;
98
100
  documentName?: string;
99
101
  log?: Pick<Console, 'log' | 'warn' | 'error'>;
100
102
  /** Injected in tests; production builds one from `@hocuspocus/provider`. */
@@ -155,9 +157,19 @@ export function createCtlProvider(opts: CtlProviderOptions): CtlProvider {
155
157
 
156
158
  const wire = (p: CtlProviderLike): void => {
157
159
  p.on('status', (data: { status?: string }) => {
158
- if (typeof data?.status === 'string') status = data.status;
160
+ if (typeof data?.status !== 'string') return;
161
+ const reconnect = data.status === 'connected' && status !== 'connected';
162
+ status = data.status;
163
+ if (reconnect && !stopped) {
164
+ try {
165
+ opts.onDocuments?.();
166
+ } catch (error) {
167
+ log.error?.(`[sync/ctl] discovery handler threw: ${(error as Error).message}`);
168
+ }
169
+ }
159
170
  });
160
171
  p.on('stateless', (data: { payload: string }) => {
172
+ if (stopped) return;
161
173
  const poke = parsePoke(data?.payload);
162
174
  if (poke === null) {
163
175
  malformed += 1;
@@ -172,7 +184,8 @@ export function createCtlProvider(opts: CtlProviderOptions): CtlProvider {
172
184
  }
173
185
  received += 1;
174
186
  try {
175
- opts.onPoke(poke.head);
187
+ if (poke.documents && opts.onDocuments) opts.onDocuments();
188
+ else opts.onPoke(poke.head);
176
189
  } catch (err) {
177
190
  log.error?.(`[sync/ctl] poke handler threw: ${(err as Error).message}`);
178
191
  }
@@ -0,0 +1,81 @@
1
+ // A bounded metadata read queue. File walks/uploads have their own scheduler.
2
+ // A notification during a read requires another read: the first response may
3
+ // already describe the old head. Keep one pending pass, never an unbounded queue.
4
+ export function createDocumentDiscovery(opts: {
5
+ run: () => Promise<void>;
6
+ onError: (error: unknown) => void;
7
+ delayMs?: number;
8
+ minIntervalMs?: number;
9
+ now?: () => number;
10
+ setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
11
+ clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
12
+ }) {
13
+ const now = opts.now ?? Date.now;
14
+ const setTimer = opts.setTimer ?? setTimeout;
15
+ const clearTimer = opts.clearTimer ?? clearTimeout;
16
+ let timer: ReturnType<typeof setTimeout> | null = null;
17
+ let running = false;
18
+ let pending = false;
19
+ let stopped = false;
20
+ let lastStarted = -Infinity;
21
+ let waiters: Array<() => void> = [];
22
+
23
+ function arm(immediate = false) {
24
+ if (stopped || running || timer !== null || !pending) return;
25
+ const delay = Math.max(
26
+ immediate ? 0 : (opts.delayMs ?? 50),
27
+ (opts.minIntervalMs ?? 250) - (now() - lastStarted)
28
+ );
29
+ timer = setTimer(() => {
30
+ timer = null;
31
+ void drain();
32
+ }, delay);
33
+ timer.unref?.();
34
+ }
35
+
36
+ async function drain() {
37
+ if (stopped || running || !pending) return;
38
+ running = true;
39
+ pending = false;
40
+ lastStarted = now();
41
+ const currentWaiters = waiters;
42
+ waiters = [];
43
+ try {
44
+ await opts.run();
45
+ } catch (error) {
46
+ opts.onError(error);
47
+ } finally {
48
+ running = false;
49
+ for (const resolve of currentWaiters) resolve();
50
+ arm();
51
+ }
52
+ }
53
+
54
+ return {
55
+ schedule() {
56
+ if (!stopped) {
57
+ pending = true;
58
+ arm();
59
+ }
60
+ },
61
+ flush(): Promise<void> {
62
+ if (stopped) return Promise.resolve();
63
+ pending = true;
64
+ const done = new Promise<void>((resolve) => waiters.push(resolve));
65
+ if (timer !== null) {
66
+ clearTimer(timer);
67
+ timer = null;
68
+ }
69
+ arm(true);
70
+ return done;
71
+ },
72
+ stop() {
73
+ stopped = true;
74
+ pending = false;
75
+ if (timer !== null) clearTimer(timer);
76
+ timer = null;
77
+ for (const resolve of waiters) resolve();
78
+ waiters = [];
79
+ },
80
+ };
81
+ }
@@ -105,7 +105,7 @@ const INERT_MEDIA_EXTS = new Set([
105
105
  'ttf',
106
106
  'otf',
107
107
  ]);
108
- const COMPANION_TEXT_EXTS = new Set(['css', 'md', 'srt']);
108
+ const COMPANION_TEXT_EXTS = new Set(['css', 'md', 'srt', 'vtt']);
109
109
  const CODE_MODULE_EXTS = new Set(['ts', 'tsx', 'js', 'mjs']);
110
110
 
111
111
  /**
@@ -118,7 +118,15 @@ const CODE_MODULE_EXTS = new Set(['ts', 'tsx', 'js', 'mjs']);
118
118
  * machine silently loses every photo edit — the exact bug class this module
119
119
  * exists to end.
120
120
  */
121
- const COMPANION_SIDECAR_SUFFIXES = ['.photo.json', '.audio.json'];
121
+ // + the footage analysis, component registry, edit decision list: versioned
122
+ // sidecars (DDR-115) the T32 scale run found never reaching a peer.
123
+ const COMPANION_SIDECAR_SUFFIXES = [
124
+ '.photo.json',
125
+ '.audio.json',
126
+ '.footage.json',
127
+ '.registry.json',
128
+ '.edl.json',
129
+ ];
122
130
 
123
131
  /**
124
132
  * A DIRECTORY segment must start alphanumeric — the same rule as the hub's