@1agh/maude 0.23.0 → 0.25.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 (127) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dependencies.json +30 -2
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
  27. package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
  28. package/plugins/design/dev-server/api.ts +123 -4
  29. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  30. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  31. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  32. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  33. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  34. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  35. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  36. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  37. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  38. package/plugins/design/dev-server/bin/prep.sh +211 -0
  39. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  40. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  41. package/plugins/design/dev-server/bin/smoke.sh +208 -23
  42. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  43. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  44. package/plugins/design/dev-server/canvas-cursors.ts +173 -0
  45. package/plugins/design/dev-server/canvas-icons.tsx +299 -0
  46. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  47. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  48. package/plugins/design/dev-server/canvas-shell.tsx +404 -66
  49. package/plugins/design/dev-server/client/app.jsx +1231 -416
  50. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  51. package/plugins/design/dev-server/collab/index.ts +87 -9
  52. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  53. package/plugins/design/dev-server/collab/registry.ts +80 -2
  54. package/plugins/design/dev-server/collab/room.ts +21 -8
  55. package/plugins/design/dev-server/context-menu.tsx +167 -23
  56. package/plugins/design/dev-server/context.ts +24 -0
  57. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  58. package/plugins/design/dev-server/dist/client.bundle.js +364 -29
  59. package/plugins/design/dev-server/dist/comment-mount.js +78 -33
  60. package/plugins/design/dev-server/dist/styles.css +16 -0
  61. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  62. package/plugins/design/dev-server/export-dialog.tsx +208 -18
  63. package/plugins/design/dev-server/exporters/html.ts +4 -1
  64. package/plugins/design/dev-server/exporters/index.ts +4 -1
  65. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  66. package/plugins/design/dev-server/exporters/png.ts +21 -2
  67. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  68. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  69. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  70. package/plugins/design/dev-server/fs-watch.ts +1 -0
  71. package/plugins/design/dev-server/http.ts +299 -28
  72. package/plugins/design/dev-server/input-router.tsx +35 -5
  73. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  74. package/plugins/design/dev-server/server.ts +123 -1
  75. package/plugins/design/dev-server/sync/agent.ts +95 -0
  76. package/plugins/design/dev-server/sync/codec.ts +155 -0
  77. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  78. package/plugins/design/dev-server/sync/index.ts +479 -35
  79. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  80. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  81. package/plugins/design/dev-server/sync/origins.ts +57 -0
  82. package/plugins/design/dev-server/sync/projection.ts +368 -0
  83. package/plugins/design/dev-server/sync/status.ts +115 -0
  84. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  85. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  86. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  87. package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
  88. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
  89. package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
  90. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  91. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  92. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  93. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  94. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  95. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  96. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  97. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  98. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  99. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  100. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  101. package/plugins/design/dev-server/test/input-router.test.ts +32 -4
  102. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
  103. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  104. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  105. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  106. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  107. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  108. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  109. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  110. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  111. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  112. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  113. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
  114. package/plugins/design/dev-server/tool-palette.tsx +155 -24
  115. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  116. package/plugins/design/dev-server/undo-stack.ts +20 -4
  117. package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
  118. package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
  119. package/plugins/design/dev-server/ws.ts +40 -1
  120. package/plugins/design/templates/_shell.html +85 -10
  121. package/plugins/design/templates/canvas.tsx.template +13 -0
  122. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  123. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  124. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  125. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  127. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -24,11 +24,21 @@ import path from 'node:path';
24
24
  import type { Awareness } from 'y-protocols/awareness';
25
25
  import * as Y from 'yjs';
26
26
 
27
+ import { Y_TYPES } from '../collab/persistence.ts';
27
28
  import type { Context } from '../context.ts';
28
29
  import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
30
+ import {
31
+ type ConnectionMonitor,
32
+ type ProviderStatus,
33
+ createConnectionMonitor,
34
+ } from './connection-state.ts';
29
35
  import { type EchoGuard, createEchoGuard } from './echo-guard.ts';
30
36
  import { type FsReader, createFsReader } from './fs-mirror.ts';
31
37
  import { getHubToken } from './hubs-config.ts';
38
+ import { migrateSeed } from './migrate-seed.ts';
39
+ import { type DocProjection, createDocProjection } from './projection.ts';
40
+ import { type SyncStatusStore, createSyncStatusStore } from './status.ts';
41
+ import { writeUntrustedMarkers } from './untrusted.ts';
32
42
 
33
43
  /** A minimum-surface stand-in for the HocuspocusProvider's runtime API. */
34
44
  export interface SyncProvider {
@@ -42,6 +52,12 @@ export interface SyncProvider {
42
52
  readonly awareness?: Awareness;
43
53
  /** Resolves when the first hub sync handshake completes. */
44
54
  onceSynced(): Promise<void>;
55
+ /**
56
+ * Subscribe to WS connection-status transitions (Phase 9 Task 8 offline
57
+ * mode). Returns an unsubscribe fn. Optional — a test stub or a provider
58
+ * without status events just isn't monitored (treated as always-online).
59
+ */
60
+ onStatus?(cb: (status: ProviderStatus) => void): () => void;
45
61
  destroy(): void;
46
62
  }
47
63
 
@@ -52,6 +68,23 @@ export interface SyncProvider {
52
68
  */
53
69
  export interface AwarenessRegistry {
54
70
  attachHubAwareness(slug: string, awareness: Awareness): () => void;
71
+ /** Phase 9.1 — relay a hub-pushed comment/annotation snapshot into a live
72
+ * room (wholesale, in-process) so the peer's canvas reflects it immediately
73
+ * and the room's own debounced persist can't clobber the synced state back.
74
+ * Optional so file-sync-only tests can pass a minimal registry. */
75
+ syncRoomFromComments?(slug: string, comments: readonly unknown[]): void;
76
+ syncRoomFromAnnotations?(slug: string, svg: string): void;
77
+ /**
78
+ * Phase 9.2 (DDR-064) — the single cached `Y.Doc` for a slug. When present
79
+ * AND `ctx.sharedDoc` is set, the runtime attaches the HocuspocusProvider to
80
+ * THIS doc instead of a fresh one (the convergence: one doc, both providers).
81
+ * Optional so file-sync-only tests can pass a minimal registry.
82
+ */
83
+ getDoc?(slug: string): Y.Doc;
84
+ /** Keep a shared-doc room alive while its provider is attached (drop guard). */
85
+ pin?(slug: string): void;
86
+ /** Release the shared-doc pin on runtime stop. */
87
+ unpin?(slug: string): void;
55
88
  }
56
89
 
57
90
  /** Factory the runtime calls per discovered canvas. Default uses Hocuspocus. */
@@ -59,6 +92,13 @@ export type ProviderFactory = (args: {
59
92
  url: string;
60
93
  token: string;
61
94
  documentName: string;
95
+ /**
96
+ * Phase 9.2 (DDR-064) — when set, the provider MUST attach to this existing
97
+ * `Y.Doc` (the shared room doc) instead of creating its own. The runtime
98
+ * passes it only when `ctx.sharedDoc` is on and the registry exposes
99
+ * `getDoc`. Default factory: `args.document ?? new Y.Doc()`.
100
+ */
101
+ document?: Y.Doc;
62
102
  }) => SyncProvider | Promise<SyncProvider>;
63
103
 
64
104
  export interface SyncRuntime {
@@ -68,6 +108,8 @@ export interface SyncRuntime {
68
108
  size(): number;
69
109
  /** Test inspection — get the agent for a slug if one was created. */
70
110
  agentFor(slug: string): CanvasSyncAgent | undefined;
111
+ /** Current offline/sync status payload (Task 8), or null when unlinked. */
112
+ status(): import('./status.ts').SyncStatusPayload | null;
71
113
  }
72
114
 
73
115
  export interface CreateSyncRuntimeOptions {
@@ -83,6 +125,14 @@ export interface CreateSyncRuntimeOptions {
83
125
  * unit tests that only exercise the file-sync path.
84
126
  */
85
127
  registry?: AwarenessRegistry;
128
+ /**
129
+ * Override the offline-mode connection monitor (Task 8 test injection —
130
+ * lets tests pass injectable timers/clock). Defaults to a real monitor that
131
+ * writes `_sync.json` + broadcasts 'sync:status' on the bus.
132
+ */
133
+ connectionMonitor?: ConnectionMonitor;
134
+ /** Override the status store (Task 8 test injection). */
135
+ statusStore?: SyncStatusStore;
86
136
  }
87
137
 
88
138
  /**
@@ -95,6 +145,11 @@ export interface CanvasDescriptor {
95
145
  html: string;
96
146
  comments: string;
97
147
  annotations: string;
148
+ /** The canvas `.meta.json` sidecar (sibling of the body). Phase 9.1 Gap 2 —
149
+ * shared keys (layout/artboards) sync; per-user viewport stays local. */
150
+ meta: string;
151
+ /** The canvas's sibling `.css` (Phase 9.1 Gap 3), synced as opaque text. */
152
+ css: string;
98
153
  }
99
154
 
100
155
  /**
@@ -143,36 +198,93 @@ export function createSyncRuntime(
143
198
  const providerFactory = opts.providerFactory ?? defaultProviderFactory;
144
199
  const echoGuard = createEchoGuard();
145
200
  const agents = new Map<string, CanvasSyncAgent>();
201
+ // Phase 9.2 (DDR-064) — under sharedDoc the disk handler is a loop-free
202
+ // projection (sole owner of html/css/meta doc→file + all-types file→doc),
203
+ // created INSTEAD of an agent. Exactly one of agents/projections is populated
204
+ // per run (chosen by useSharedDoc).
205
+ const projections = new Map<string, DocProjection>();
146
206
  const providers = new Map<string, SyncProvider>();
147
207
  const awarenessDetaches: Array<() => void> = [];
208
+ const statusDetaches: Array<() => void> = [];
209
+ // Phase 9.2 (DDR-064) — slugs pinned in the registry because a provider is
210
+ // attached to their shared doc; released on stop(). Empty unless sharedDoc.
211
+ const pinnedSlugs = new Set<string>();
212
+ // The shared-doc convergence path is active only when the flag is ON AND the
213
+ // registry can hand us the canvas's single doc. Flag OFF / no registry / a
214
+ // minimal test registry without getDoc → the proven two-doc path, unchanged.
215
+ const useSharedDoc = !!ctx.sharedDoc && typeof opts.registry?.getDoc === 'function';
148
216
  let fsReader: FsReader | null = null;
149
217
  let busUnsub: (() => void) | null = null;
150
218
  let started = false;
151
219
  let stopped = false;
152
220
 
221
+ // Task 8 — offline-mode status surface, initialized in start() once the
222
+ // canvas count is known. The store writes `_sync.json` + broadcasts
223
+ // 'sync:status' on the bus; the monitor aggregates provider WS status into
224
+ // online/offline/escalated and feeds every change to the store.
225
+ let statusStore: SyncStatusStore | null = null;
226
+ let monitor: ConnectionMonitor | null = null;
227
+
153
228
  async function start(): Promise<void> {
154
229
  if (started || stopped) return;
155
230
  started = true;
156
231
 
157
- const canvases = opts.canvases ?? (await discoverCanvases(ctx));
232
+ const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
233
+ const canvases = scan.canvases;
234
+ // T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
235
+ // content, so the whole set is untrusted Claude-context. Mark it (writes
236
+ // `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
237
+ // when the set is empty). Best-effort — never throws into boot.
238
+ writeUntrustedMarkers(ctx, canvases, linkedHub.url);
158
239
  if (canvases.length === 0) {
159
- console.log(
160
- `[sync] linked to ${linkedHub.url} no canvases discovered under ${ctx.paths.designRoot}.`
161
- );
240
+ // DDR-060 / 9.1-D — the silent early-return made linked mode look healthy
241
+ // while syncing nothing (TSX-only projects: discovery admits .html only,
242
+ // .tsx needs the opt-in + sandbox gate that 9.1-A/B ship). Surface the gap
243
+ // loudly: a warn, a `_sync.json` the CLI + browser banner read, and a bus
244
+ // broadcast so open tabs render it immediately.
245
+ surfaceNoSyncable(ctx, linkedHub.url, scan.tsxCount);
162
246
  return;
163
247
  }
164
248
 
249
+ statusStore =
250
+ opts.statusStore ??
251
+ createSyncStatusStore({
252
+ url: linkedHub.url,
253
+ canvases: canvases.length,
254
+ sharedDoc: useSharedDoc,
255
+ write: (payload) => {
256
+ const file = path.join(ctx.paths.designRoot, '_sync.json');
257
+ writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
258
+ },
259
+ broadcast: (payload) => ctx.bus.emit('sync:status', payload),
260
+ });
261
+ const store = statusStore;
262
+ monitor =
263
+ opts.connectionMonitor ?? createConnectionMonitor({ onChange: (snap) => store.update(snap) });
264
+ const mon = monitor;
265
+
165
266
  const reader = createFsReader({
166
267
  rootDir: ctx.paths.designRoot,
167
268
  accept: (rel) => {
168
269
  const ext = path.extname(rel).toLowerCase();
169
- return ext === '.html' || ext === '.json' || ext === '.svg';
270
+ // `.tsx` added in T3 (9.1-B) so local edits to an opted-in syncable
271
+ // `.tsx` body propagate. Non-syncable `.tsx` changes still notify but
272
+ // match no agent in onRead (descriptor-scoped) → harmless no-op.
273
+ // `.css` added in Gap 3 so the canvas's sibling stylesheet syncs.
274
+ return (
275
+ ext === '.html' || ext === '.tsx' || ext === '.json' || ext === '.svg' || ext === '.css'
276
+ );
170
277
  },
171
278
  onRead: (evt) => {
279
+ const abs = path.join(ctx.paths.designRoot, evt.path);
280
+ // Dispatch to whichever disk handler owns this path. Only one of
281
+ // agents/projections is populated (useSharedDoc decides); the projector
282
+ // exposes the same applyFromFs(evt) shape as the agent.
172
283
  for (const agent of agents.values()) {
173
- const abs = path.join(ctx.paths.designRoot, evt.path);
174
- const changed = agent.applyFromFs({ path: abs, bytes: evt.bytes, hash: evt.hash });
175
- if (changed) break; // a path belongs to at most one canvas
284
+ if (agent.applyFromFs({ path: abs, bytes: evt.bytes, hash: evt.hash })) return;
285
+ }
286
+ for (const proj of projections.values()) {
287
+ if (proj.applyFromFs({ path: abs, bytes: evt.bytes, hash: evt.hash })) return;
176
288
  }
177
289
  },
178
290
  });
@@ -188,25 +300,82 @@ export function createSyncRuntime(
188
300
 
189
301
  for (const canvas of canvases) {
190
302
  try {
303
+ // Phase 9.2 (DDR-064) — when sharedDoc is on, the provider attaches to
304
+ // the collab room's single Y.Doc (registry.getDoc) instead of a fresh
305
+ // one, so browser edits flow straight into the doc that syncs to the
306
+ // hub — no disk hop, no relay, no clobber. Pin the room so the
307
+ // last-browser-leaves drop can't destroy the doc out from under the
308
+ // provider. NB: cold-start seeding of a divergent local+hub doc is the
309
+ // duplication trap (Risk 1) — made safe by Phase E (migrate-seed); the
310
+ // flag stays OFF until then.
311
+ const sharedYDoc = useSharedDoc ? opts.registry?.getDoc?.(canvas.slug) : undefined;
312
+ if (useSharedDoc && sharedYDoc) {
313
+ opts.registry?.pin?.(canvas.slug);
314
+ pinnedSlugs.add(canvas.slug);
315
+ }
191
316
  const provider = await providerFactory({
192
317
  url: linkedHub.url,
193
318
  token,
194
319
  documentName: canvas.slug,
320
+ document: sharedYDoc,
195
321
  });
196
322
  providers.set(canvas.slug, provider);
197
- const agent = createCanvasSyncAgent({
198
- slug: canvas.slug,
199
- doc: provider.document,
200
- paths: {
201
- html: canvas.html,
202
- comments: canvas.comments,
203
- annotations: canvas.annotations,
204
- },
205
- echoGuard,
206
- adopt: adoptOnce,
207
- });
208
- agent.start();
209
- agents.set(canvas.slug, agent);
323
+ const canvasPaths = {
324
+ html: canvas.html,
325
+ comments: canvas.comments,
326
+ annotations: canvas.annotations,
327
+ meta: canvas.meta,
328
+ css: canvas.css,
329
+ };
330
+ // Phase 9.2 (DDR-064) — the disk handler. Under sharedDoc it's a
331
+ // loop-free projection (html/css/meta doc→file + all-types file→doc;
332
+ // the collab room keeps comments/annotations doc→file, so no
333
+ // double-write). Flag-OFF keeps the proven two-doc agent.
334
+ let agent: CanvasSyncAgent | undefined;
335
+ let projection: DocProjection | undefined;
336
+ if (useSharedDoc && sharedYDoc) {
337
+ projection = createDocProjection({
338
+ slug: canvas.slug,
339
+ doc: provider.document,
340
+ paths: canvasPaths,
341
+ echoGuard,
342
+ });
343
+ projection.start();
344
+ projections.set(canvas.slug, projection);
345
+ } else {
346
+ agent = createCanvasSyncAgent({
347
+ slug: canvas.slug,
348
+ doc: provider.document,
349
+ paths: canvasPaths,
350
+ echoGuard,
351
+ adopt: adoptOnce,
352
+ onConflict: (info) => store.addConflict(info),
353
+ });
354
+ agent.start();
355
+ agents.set(canvas.slug, agent);
356
+ }
357
+
358
+ // Task 8 — feed this provider's WS status into the offline monitor.
359
+ if (provider.onStatus) {
360
+ statusDetaches.push(provider.onStatus((s) => mon.noteProviderStatus(canvas.slug, s)));
361
+ } else {
362
+ // No status events (test stub) — treat as connected so the monitor
363
+ // doesn't sit in the boot 'connecting' state forever.
364
+ mon.noteProviderStatus(canvas.slug, 'connected');
365
+ }
366
+ // Count local edits (agent-origin doc updates) toward queuedOps while
367
+ // the hub is unreachable — the banner's "N edits queued" figure. Under
368
+ // sharedDoc there is no agent origin to key off (browser edits carry a
369
+ // RoomConn origin); queued-edit counting in that mode is a known gap
370
+ // (offline-banner accuracy only, not data) deferred past Phase C.
371
+ if (agent) {
372
+ const agentOrigin = agent.origin;
373
+ const onLocalUpdate = (_u: Uint8Array, origin: unknown) => {
374
+ if (origin === agentOrigin) mon.noteLocalEdit();
375
+ };
376
+ provider.document.on('update', onLocalUpdate);
377
+ statusDetaches.push(() => provider.document.off('update', onLocalUpdate));
378
+ }
210
379
 
211
380
  // Task 5 — bridge the provider's hub-synced Awareness to the Room so
212
381
  // browser cursors relay cross-machine. No-op when the provider exposes
@@ -215,8 +384,74 @@ export function createSyncRuntime(
215
384
  awarenessDetaches.push(opts.registry.attachHubAwareness(canvas.slug, provider.awareness));
216
385
  }
217
386
 
387
+ // Relay hub-pushed comment/annotation changes straight into the live
388
+ // room — IN-PROCESS + synchronous, so the room's in-memory doc is
389
+ // updated BEFORE its 800ms persist timer can flush stale pre-sync state
390
+ // back over the file (the disk-mediated re-seed in createCollab loses
391
+ // that race under an actively-edited peer; this is the tight path that
392
+ // actually closes the "comment reverts" clobber). Wholesale-replace via
393
+ // syncRoomFrom* → no duplication. Skip agent-origin updates (our own
394
+ // disk→doc apply — the file is authoritative there; a local design:edit
395
+ // reaches the room via createCollab's fs hook instead).
396
+ //
397
+ // CRITICAL: observe the comment + annotation Y-types SEPARATELY, not the
398
+ // whole-doc update. A whole-doc relay re-applies BOTH types on every
399
+ // change, so a comment sync would re-push the (stale) annotation and
400
+ // clobber an annotation the peer just drew but hasn't synced yet — and
401
+ // vice versa. Per-type observers keep the two lanes independent.
402
+ //
403
+ // Phase 9.2 (DDR-064): under sharedDoc the provider IS attached to the
404
+ // room's doc, so there is no second doc to relay into — the room already
405
+ // has every change. Skipping the relay is what RETIRES the
406
+ // wholesale-replace clobber path (the Phase 9.1 ceiling): with one doc,
407
+ // CRDT merge handles concurrency, no last-writer-wins blob copy.
408
+ const reg = opts.registry;
409
+ if (!useSharedDoc && agent && reg?.syncRoomFromComments) {
410
+ const agentOrigin = agent.origin;
411
+ const slug = canvas.slug;
412
+ const provComments = provider.document.getArray(Y_TYPES.comments);
413
+ const provAnn = provider.document.getMap(Y_TYPES.annotations);
414
+ const onComments = (_e: unknown, tx: { origin: unknown }) => {
415
+ if (tx.origin === agentOrigin) return;
416
+ reg.syncRoomFromComments?.(slug, provComments.toArray());
417
+ };
418
+ const onAnn = (_e: unknown, tx: { origin: unknown }) => {
419
+ if (tx.origin === agentOrigin) return;
420
+ const svg = provAnn.get('svg');
421
+ if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
422
+ };
423
+ provComments.observe(onComments);
424
+ provAnn.observe(onAnn);
425
+ statusDetaches.push(() => {
426
+ provComments.unobserve(onComments);
427
+ provAnn.unobserve(onAnn);
428
+ });
429
+ }
430
+
218
431
  // Cold-start reconcile fires once the provider has hub state.
219
432
  void provider.onceSynced().then(async () => {
433
+ if (projection) {
434
+ // Phase E (DDR-064 Task 9) — one-time authoritative seed BEFORE
435
+ // materializing: escapes the duplication trap by picking ONE source
436
+ // (hub-wins if the synced doc holds state; adopt local files only
437
+ // when the hub was empty), inside a MIGRATION transaction. The room
438
+ // file-seed is disabled for this pinned slug (createCollab
439
+ // shouldSeed), so no duplicate items can be introduced afterward.
440
+ const result = migrateSeed({
441
+ slug: canvas.slug,
442
+ doc: provider.document,
443
+ paths: canvasPaths,
444
+ historyDir: path.join(ctx.paths.historyDir, canvas.slug),
445
+ });
446
+ if (result === 'local-adopt') {
447
+ console.log(`[sync/${canvas.slug}] shared-doc: adopted local state (hub was empty).`);
448
+ }
449
+ // Then materialize the converged doc to disk (safe — never clobbers
450
+ // non-empty local with an empty doc value).
451
+ projection.reconcile();
452
+ return;
453
+ }
454
+ if (!agent) return;
220
455
  await agent.reconcile();
221
456
  if (adoptOnce) {
222
457
  adoptReconciled++;
@@ -232,8 +467,17 @@ export function createSyncRuntime(
232
467
  }
233
468
  }
234
469
 
470
+ // Persist an initial status snapshot so `_sync.json` exists — and `maude
471
+ // design status` + the browser banner report "agent running" — from the
472
+ // moment serve boots. The ConnectionMonitor only emits on *transitions* and
473
+ // starts in 'online', so on a clean fast localhost connect (provider
474
+ // reaches 'connected' at/before subscribe → no transition fires) nothing
475
+ // would otherwise be written, and status would read "idle / sync agent not
476
+ // running" while sync is in fact healthy. This was the observed bug.
477
+ store.update(mon.snapshot());
478
+
235
479
  console.log(
236
- `[sync] linked to ${linkedHub.url} — ${agents.size}/${canvases.length} canvas(es) syncing${adoptOnce ? ' (adopt mode — pushing local up)' : ''}.`
480
+ `[sync] linked to ${linkedHub.url} — ${agents.size + projections.size}/${canvases.length} canvas(es) syncing${useSharedDoc ? ' (shared-doc)' : ''}${adoptOnce ? ' (adopt mode — pushing local up)' : ''}.`
237
481
  );
238
482
  }
239
483
 
@@ -248,6 +492,25 @@ export function createSyncRuntime(
248
492
  }
249
493
  }
250
494
  awarenessDetaches.length = 0;
495
+ // Phase 9.2 (DDR-064) — release shared-doc pins so the rooms can be dropped
496
+ // / destroyed normally on shutdown. Empty unless sharedDoc was active.
497
+ for (const slug of pinnedSlugs) {
498
+ try {
499
+ opts.registry?.unpin?.(slug);
500
+ } catch {
501
+ /* best-effort */
502
+ }
503
+ }
504
+ pinnedSlugs.clear();
505
+ for (const detach of statusDetaches) {
506
+ try {
507
+ detach();
508
+ } catch {
509
+ /* best-effort */
510
+ }
511
+ }
512
+ statusDetaches.length = 0;
513
+ monitor?.stop();
251
514
  busUnsub?.();
252
515
  busUnsub = null;
253
516
  fsReader?.stop();
@@ -261,6 +524,19 @@ export function createSyncRuntime(
261
524
  }
262
525
  }
263
526
  agents.clear();
527
+ // Phase 9.2 (DDR-064) — final doc→file flush + stop the projectors BEFORE
528
+ // tearing down providers (so the converged doc lands on disk). The shared
529
+ // doc itself is owned by the collab room and destroyed by registry teardown,
530
+ // not here.
531
+ for (const proj of projections.values()) {
532
+ try {
533
+ await proj.flush();
534
+ proj.stop();
535
+ } catch {
536
+ /* best-effort */
537
+ }
538
+ }
539
+ projections.clear();
264
540
  for (const provider of providers.values()) {
265
541
  try {
266
542
  provider.destroy();
@@ -274,8 +550,11 @@ export function createSyncRuntime(
274
550
  return {
275
551
  start,
276
552
  stop,
277
- size: () => agents.size,
553
+ // Under sharedDoc the per-canvas handler is a projection, not an agent;
554
+ // count both so size() reflects the synced-canvas count in either mode.
555
+ size: () => agents.size + projections.size,
278
556
  agentFor: (slug) => agents.get(slug),
557
+ status: () => statusStore?.get() ?? null,
279
558
  };
280
559
  }
281
560
 
@@ -287,21 +566,70 @@ export function createSyncRuntime(
287
566
  * specialised for the sync runtime (we only need the three paths per canvas,
288
567
  * not the full metadata).
289
568
  *
290
- * DDR-054 §2b — `.tsx` canvases are deliberately EXCLUDED from sync. The
291
- * dev-server transpiles `.tsx` to JavaScript and serves it as
569
+ * DDR-054 §2b / DDR-060 — `.tsx` canvases are deliberately EXCLUDED from sync.
570
+ * The dev-server transpiles `.tsx` to JavaScript and serves it as
292
571
  * `application/javascript` in iframe same-origin; a hostile hub pushing
293
- * arbitrary TypeScript source would result in RCE. `.tsx` stays editable in
294
- * solo mode; per-canvas opt-in via `.meta.json.syncable: true` is deferred to
295
- * Task 8 (alongside CSP + iframe sandbox).
572
+ * arbitrary TypeScript source would result in RCE (the audit's CRITICAL F1).
573
+ * Since Phase 3.6 made `.tsx` the ONLY canvas format, this means real projects
574
+ * discover zero syncable canvases surfaced loudly by `surfaceNoSyncable`
575
+ * (9.1-D) rather than silently. The per-canvas opt-in (`.meta.json.syncable:
576
+ * true`) + CSP/sandbox gate that make `.tsx` syncable land in 9.1-A/B.
296
577
  */
297
578
  export async function discoverCanvases(ctx: Context): Promise<CanvasDescriptor[]> {
579
+ return (await scanCanvases(ctx)).canvases;
580
+ }
581
+
582
+ /** Result of a canvas-group scan: syncable descriptors + a tally of the .tsx
583
+ * canvases that exist but are NOT yet syncable (DDR-060 — they need the
584
+ * per-canvas opt-in + sandbox gate). The tsx count feeds 9.1-D's loud
585
+ * zero-syncable surface so the message can say *why* nothing syncs. */
586
+ export interface CanvasScan {
587
+ canvases: CanvasDescriptor[];
588
+ tsxCount: number;
589
+ }
590
+
591
+ export async function scanCanvases(ctx: Context): Promise<CanvasScan> {
298
592
  const out: CanvasDescriptor[] = [];
593
+ const counter = { tsx: 0 };
594
+ // T3 (9.1-B) — LOAD-BEARING coupling (the plan's "two locks flip together"
595
+ // invariant): a `.tsx` body is admitted to sync ONLY when the cross-origin
596
+ // CSP/sandbox containment is active (`ctx.canvasOrigin` is set — Lock 2). The
597
+ // sandbox is now ON BY DEFAULT, but a user can opt out with
598
+ // MAUDE_CANVAS_ORIGIN_SPLIT=0; if they do, `canvasOrigin` is undefined and NO
599
+ // `.tsx` syncs — the per-canvas opt-in is inert without the sandbox, and
600
+ // decoupling them would re-open the CRITICAL F1 RCE (DDR-060, DDR-054 §F1).
601
+ const splitActive = !!ctx.canvasOrigin;
299
602
  for (const group of ctx.cfg.canvasGroups) {
300
603
  const groupAbs = path.join(ctx.paths.designRoot, group.path);
301
604
  if (!existsSync(groupAbs)) continue;
302
- await walk(groupAbs, ctx.paths.designRoot, ctx.paths.commentsDir, ctx.paths.designRel, out);
605
+ await walk(
606
+ groupAbs,
607
+ ctx.paths.designRoot,
608
+ ctx.paths.commentsDir,
609
+ ctx.paths.designRel,
610
+ out,
611
+ counter,
612
+ splitActive
613
+ );
614
+ }
615
+ return { canvases: out, tsxCount: counter.tsx };
616
+ }
617
+
618
+ /**
619
+ * T3 (9.1-B) — per-canvas sync opt-in. A `.tsx` canvas is syncable only if its
620
+ * sibling `<name>.meta.json` declares `"syncable": true`. The flag is set by a
621
+ * human editing the sidecar; it is deliberately NOT in the untrusted
622
+ * `/_api/canvas-meta` PATCH whitelist (api.ts), so a hostile canvas/hub cannot
623
+ * flip its own body into the sync set. Missing sidecar / parse error → false.
624
+ */
625
+ function readSyncableFlag(bodyAbs: string): boolean {
626
+ const metaAbs = bodyAbs.replace(/\.(tsx|html)$/i, '.meta.json');
627
+ try {
628
+ const obj = JSON.parse(readFileSync(metaAbs, 'utf8'));
629
+ return obj && typeof obj === 'object' && obj.syncable === true;
630
+ } catch {
631
+ return false;
303
632
  }
304
- return out;
305
633
  }
306
634
 
307
635
  async function walk(
@@ -309,7 +637,9 @@ async function walk(
309
637
  designRoot: string,
310
638
  commentsDir: string,
311
639
  designRel: string,
312
- acc: CanvasDescriptor[]
640
+ acc: CanvasDescriptor[],
641
+ counter: { tsx: number },
642
+ splitActive: boolean
313
643
  ): Promise<void> {
314
644
  let entries: import('node:fs').Dirent[];
315
645
  try {
@@ -322,22 +652,101 @@ async function walk(
322
652
  if (entry.isDirectory()) {
323
653
  // Skip plugin runtime dirs.
324
654
  if (entry.name.startsWith('_')) continue;
325
- await walk(abs, designRoot, commentsDir, designRel, acc);
655
+ await walk(abs, designRoot, commentsDir, designRel, acc, counter, splitActive);
326
656
  continue;
327
657
  }
328
658
  const ext = path.extname(entry.name).toLowerCase();
329
- // DDR-054 §2b refuse .tsx; only .html canvases sync.
330
- if (ext !== '.html') continue;
659
+ // T3 (9.1-B)a `.tsx` syncs ONLY when the sandbox is active AND the
660
+ // sidecar opts in (see readSyncableFlag + the splitActive coupling above).
661
+ // Otherwise tally it for 9.1-D's loud zero-syncable surface so the message
662
+ // can explain *why* it isn't syncing.
663
+ if (ext === '.tsx') {
664
+ if (!(splitActive && readSyncableFlag(abs))) {
665
+ counter.tsx += 1;
666
+ continue;
667
+ }
668
+ // falls through to descriptor push (body = the .tsx file)
669
+ } else if (ext !== '.html') {
670
+ continue;
671
+ }
331
672
  const slug = slugFor(abs, designRoot, designRel);
332
673
  acc.push({
674
+ // `html` is the canvas BODY path — `.html` or an opted-in `.tsx`. The
675
+ // sync codec treats the body as opaque Y.Text, so the field name is
676
+ // historical; both formats round-trip identically (DDR-060).
333
677
  slug,
334
678
  html: abs,
335
679
  comments: path.join(commentsDir, `${slug}.json`),
336
680
  annotations: path.join(designRoot, `${slug}.annotations.svg`),
681
+ // The `.meta.json` sidecar sits next to the body: `Foo.tsx` → `Foo.meta.json`.
682
+ meta: abs.replace(/\.(tsx|html)$/i, '.meta.json'),
683
+ // The `.css` sibling: `Foo.tsx` → `Foo.css` (absent for inline-CSS canvases).
684
+ css: abs.replace(/\.(tsx|html)$/i, '.css'),
337
685
  });
338
686
  }
339
687
  }
340
688
 
689
+ /**
690
+ * The shape written to `<designRoot>/_sync.json` (and broadcast on the
691
+ * 'sync:status' bus) when the project is linked but has zero syncable
692
+ * canvases. DDR-060 / 9.1-D. Distinct from the live SyncStatusPayload — the
693
+ * `notSyncable` discriminator lets the CLI status line and the browser banner
694
+ * render the "linked but nothing syncs" state instead of a healthy one.
695
+ */
696
+ export interface NoSyncablePayload {
697
+ linked: true;
698
+ notSyncable: true;
699
+ url: string;
700
+ reason: string;
701
+ /** Count of .tsx canvases present (syncable once 9.1-A/B land). */
702
+ tsxCount: number;
703
+ canvases: 0;
704
+ updatedAt: number;
705
+ }
706
+
707
+ /** Build the zero-syncable payload (exported for the CLI + tests). */
708
+ export function buildNoSyncablePayload(
709
+ url: string,
710
+ tsxCount: number,
711
+ designRoot: string
712
+ ): NoSyncablePayload {
713
+ const reason =
714
+ tsxCount > 0
715
+ ? `${tsxCount} TSX canvas(es) found but none are syncable — a TSX body syncs only when the canvas opts in (.meta.json "syncable": true). The canvas sandbox is on by default; MAUDE_CANVAS_ORIGIN_SPLIT=0 disables it (and TSX sync with it). See DDR-060.`
716
+ : `no canvases found under ${designRoot}.`;
717
+ return {
718
+ linked: true,
719
+ notSyncable: true,
720
+ url,
721
+ reason,
722
+ tsxCount,
723
+ canvases: 0,
724
+ updatedAt: Date.now(),
725
+ };
726
+ }
727
+
728
+ /**
729
+ * 9.1-D — replace the silent zero-canvas early-return with a loud surface:
730
+ * a warn line, a `_sync.json` the CLI + browser read, and a bus broadcast.
731
+ * Best-effort on the write/broadcast — a failure there must never throw into
732
+ * the boot path (solo mode for unlinked projects is unaffected either way).
733
+ */
734
+ function surfaceNoSyncable(ctx: Context, url: string, tsxCount: number): void {
735
+ const payload = buildNoSyncablePayload(url, tsxCount, ctx.paths.designRoot);
736
+ console.warn(`[sync] linked to ${url} but 0 syncable canvases — ${payload.reason}`);
737
+ try {
738
+ const file = path.join(ctx.paths.designRoot, '_sync.json');
739
+ writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
740
+ } catch {
741
+ /* best-effort — never throw into boot */
742
+ }
743
+ try {
744
+ ctx.bus.emit('sync:status', payload);
745
+ } catch {
746
+ /* best-effort */
747
+ }
748
+ }
749
+
341
750
  function slugFor(absPath: string, designRoot: string, designRel: string): string {
342
751
  let rel = path.relative(designRoot, absPath);
343
752
  rel = rel.replace(/\\/g, '/');
@@ -362,6 +771,7 @@ async function defaultProviderFactory(args: {
362
771
  url: string;
363
772
  token: string;
364
773
  documentName: string;
774
+ document?: Y.Doc;
365
775
  }): Promise<SyncProvider> {
366
776
  // biome-ignore lint/suspicious/noExplicitAny: dynamic import of optional dep.
367
777
  let mod: any;
@@ -376,7 +786,9 @@ async function defaultProviderFactory(args: {
376
786
  // the scheme. The provider also accepts http(s):// and upgrades internally
377
787
  // in newer versions, but ws:// is explicit + portable.
378
788
  const wsUrl = toWsUrl(args.url);
379
- const document = new Y.Doc();
789
+ // Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
790
+ // injected one; otherwise own a fresh doc (the legacy two-doc path).
791
+ const document = args.document ?? new Y.Doc();
380
792
  // biome-ignore lint/suspicious/noExplicitAny: provider runtime is typed at the call site.
381
793
  const provider: any = new mod.HocuspocusProvider({
382
794
  url: wsUrl,
@@ -384,12 +796,44 @@ async function defaultProviderFactory(args: {
384
796
  token: args.token,
385
797
  document,
386
798
  connect: true,
799
+ // Make a rejected handshake LOUD instead of an invisible reconnect loop.
800
+ // The most common cause is a scope-bound token (DDR-053 default scope =
801
+ // label) that can't authorize this canvas's flat-slug documentName — that
802
+ // failure mode previously looked identical to "hub unreachable / offline".
803
+ onAuthenticationFailed: (data: { reason?: string }) => {
804
+ const reason = (data?.reason ?? 'unknown reason').replace(/[\r\n]/g, ' ').slice(0, 200);
805
+ console.warn(
806
+ `[sync] hub REJECTED auth for documentName="${args.documentName}": ${reason}.
807
+ If this is "token not authorized for this documentName", the token's scope
808
+ does not cover this canvas — mint a hub-wide token (\`maude hub token generate
809
+ --scope '*'\` or an admin-UI invite) and re-link. The peer keeps retrying until then.`
810
+ );
811
+ },
387
812
  });
388
813
  return {
389
814
  document,
390
815
  // HocuspocusProvider creates a hub-synced Awareness by default; expose it
391
816
  // so the runtime can bridge it to the collab Room (Task 5).
392
817
  awareness: provider.awareness as Awareness | undefined,
818
+ onStatus(cb: (status: ProviderStatus) => void): () => void {
819
+ // HocuspocusProvider emits 'status' with { status: 'connected' |
820
+ // 'connecting' | 'disconnected' } on every WS transition (Task 8).
821
+ const handler = (evt: { status?: string }) => {
822
+ const s = evt?.status;
823
+ if (s === 'connected' || s === 'connecting' || s === 'disconnected') cb(s);
824
+ };
825
+ provider.on('status', handler);
826
+ // Seed the subscriber with the provider's CURRENT status immediately. On
827
+ // localhost the WS can reach 'connected'/synced inside the constructor —
828
+ // before this listener attaches — so waiting for the *next* transition
829
+ // would leave the connection monitor un-seeded and `_sync.json` never
830
+ // written (status reads "idle / sync agent not running" while sync is in
831
+ // fact healthy). Reading provider.status closes that race; it always
832
+ // reflects the true current state, so a missed event can't strand us.
833
+ const cur = provider.status;
834
+ if (cur === 'connected' || cur === 'connecting' || cur === 'disconnected') cb(cur);
835
+ return () => provider.off('status', handler);
836
+ },
393
837
  onceSynced(): Promise<void> {
394
838
  return new Promise<void>((resolve) => {
395
839
  if (provider.synced) {