@1agh/maude 0.23.0 → 0.24.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 (101) 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/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -0,0 +1,62 @@
1
+ // Canonical doc materialization — Phase 9.2 (DDR-064 Task 10, shadow-compare).
2
+ //
3
+ // A deterministic, stable string view of a canvas doc's five synced types. Two
4
+ // docs that have CONVERGED (same comments/annotations/body/meta/css content)
5
+ // produce byte-identical output regardless of CRDT internal structure or apply
6
+ // order. This is the comparison primitive the cutover relies on:
7
+ //
8
+ // - shadow-compare: materialize the shared-doc path's doc and the legacy
9
+ // two-doc path's doc; equal strings ⇒ zero divergence ⇒ safe to cut over a
10
+ // canvas's reads to the shared doc. (The convergence test suite asserts this
11
+ // law deterministically; the live runtime "shadow mode" — running both paths
12
+ // simultaneously and logging divergences — is a cutover-time activity layered
13
+ // on this same primitive.)
14
+ // - round-trip laws: materialize∘import and import∘materialize for the
15
+ // projection (test/shared-doc-* suites).
16
+ //
17
+ // Comments are canonicalized via JSON.stringify of the array (the snapshot IS
18
+ // the source of truth — LWW per the codec); the SVG / body / css / meta are
19
+ // taken as their string values. The object is key-ordered so the output is
20
+ // stable.
21
+
22
+ import type * as Y from 'yjs';
23
+ import { Y_TYPES } from '../collab/persistence.ts';
24
+ import { Y_SYNC_TYPES, annotationsFromDoc, commentsFromDoc } from './codec.ts';
25
+
26
+ export interface CanonicalMaterialization {
27
+ html: string;
28
+ css: string;
29
+ meta: string;
30
+ comments: unknown[];
31
+ annotations: string;
32
+ }
33
+
34
+ /** Structured canonical view (for assertions that want to compare per-type). */
35
+ export function materialize(doc: Y.Doc): CanonicalMaterialization {
36
+ return {
37
+ html: doc.getText(Y_SYNC_TYPES.html).toString(),
38
+ css: doc.getText(Y_SYNC_TYPES.css).toString(),
39
+ meta: doc.getText(Y_SYNC_TYPES.meta).toString(),
40
+ comments: commentsFromDoc(doc),
41
+ annotations: annotationsFromDoc(doc) ?? '',
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Stable canonical STRING of a doc's synced content. Byte-equal ⟺ converged.
47
+ * Use for shadow-compare diffing + the round-trip convergence laws.
48
+ */
49
+ export function materializeCanonical(doc: Y.Doc): string {
50
+ const m = materialize(doc);
51
+ // Fixed key order; comments serialized as-is (the JSON snapshot is canonical).
52
+ return JSON.stringify({
53
+ html: m.html,
54
+ css: m.css,
55
+ meta: m.meta,
56
+ comments: m.comments,
57
+ annotations: m.annotations,
58
+ });
59
+ }
60
+
61
+ // Re-export so a shadow-compare caller has the type names in one import.
62
+ export { Y_TYPES, Y_SYNC_TYPES };
@@ -0,0 +1,163 @@
1
+ // One-time authoritative seed for the shared-doc cutover — Phase 9.2 (DDR-064
2
+ // Task 9). The fix for Risk 1 (the duplication-on-merge trap).
3
+ //
4
+ // The trap (dmonad-confirmed, discuss.yjs.dev/t/.../2538): if a shared doc is
5
+ // populated from TWO independent sources — the local file-seed (room.seed
6
+ // pushing `_comments/<slug>.json` as fresh Y.Array items) AND the hub provider
7
+ // (syncing the hub's canonical items) — the two item-sets have different client
8
+ // IDs, so the CRDT merge CONCATENATES them: comment "c1" appears twice. You
9
+ // cannot fix this with `applyUpdate` of two docs; you must pick ONE authoritative
10
+ // source and build the doc from it.
11
+ //
12
+ // This module is that decision, run ONCE per canvas at cutover, AFTER the
13
+ // provider's first sync (so the doc already holds hub state if the hub had any):
14
+ //
15
+ // - hub HAD state (doc non-empty) → HUB WINS. Leave the doc; the projection
16
+ // materializes hub state to disk. Local divergent files are snapshotted to
17
+ // `_history/` first (rollback) then overwritten by the room persist.
18
+ // - hub was EMPTY (doc empty) → ADOPT. Clear+rebuild the doc from the
19
+ // local files inside `transact(fn, MIGRATION)` (the apply* codecs already
20
+ // delete-then-insert, so this is a true rebuild, not an append). The
21
+ // provider then pushes it up as the canonical first version.
22
+ //
23
+ // Idempotent: re-running with hub state present is a no-op (doc already holds
24
+ // it); re-running an adopt rebuilds from the same files → byte-identical, no
25
+ // duplicate items. The companion guard is the room's seed being disabled for
26
+ // pinned (provider-attached) slugs under sharedDoc (see collab/index.ts
27
+ // `shouldSeed`), so the file-seed can never re-introduce duplicate items after
28
+ // this runs.
29
+
30
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
31
+ import path from 'node:path';
32
+
33
+ import type * as Y from 'yjs';
34
+
35
+ import { Y_TYPES } from '../collab/persistence.ts';
36
+ import {
37
+ Y_SYNC_TYPES,
38
+ applyAnnotationsToDoc,
39
+ applyCommentsToDoc,
40
+ applyCssToDoc,
41
+ applyHtmlToDoc,
42
+ applyMetaToDoc,
43
+ } from './codec.ts';
44
+ import { ORIGINS } from './origins.ts';
45
+
46
+ export interface MigrateSeedPaths {
47
+ html: string;
48
+ comments: string;
49
+ annotations: string;
50
+ meta?: string;
51
+ css?: string;
52
+ }
53
+
54
+ export interface MigrateSeedOptions {
55
+ slug: string;
56
+ doc: Y.Doc;
57
+ paths: MigrateSeedPaths;
58
+ /** `_history/<slug>/` (or any dir) — when set, local files are snapshotted
59
+ * here before a hub-wins overwrite, for rollback. Best-effort. */
60
+ historyDir?: string;
61
+ }
62
+
63
+ export type MigrateSeedResult = 'hub-wins' | 'local-adopt' | 'empty';
64
+
65
+ /** True when the shared doc holds no synced content for any of the five types. */
66
+ export function docIsEmpty(doc: Y.Doc): boolean {
67
+ if (doc.getText(Y_SYNC_TYPES.html).length > 0) return false;
68
+ if (doc.getText(Y_SYNC_TYPES.css).length > 0) return false;
69
+ if (doc.getText(Y_SYNC_TYPES.meta).length > 0) return false;
70
+ if (doc.getArray(Y_TYPES.comments).length > 0) return false;
71
+ const svg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
72
+ if (typeof svg === 'string' && svg.length > 0) return false;
73
+ return true;
74
+ }
75
+
76
+ /**
77
+ * Run the one-time authoritative seed. Returns which source won so the caller
78
+ * can log / surface a conflict. Safe to call on every boot (idempotent).
79
+ */
80
+ export function migrateSeed(opts: MigrateSeedOptions): MigrateSeedResult {
81
+ const { doc, paths } = opts;
82
+
83
+ // Hub had canonical state → it wins. The doc already holds it; the projection
84
+ // will materialize it to disk. Snapshot any divergent local files first so the
85
+ // overwrite is recoverable.
86
+ if (!docIsEmpty(doc)) {
87
+ snapshotLocal(opts);
88
+ return 'hub-wins';
89
+ }
90
+
91
+ // Hub was empty → adopt local. Build the doc from the local files ONCE, inside
92
+ // a single MIGRATION transaction. The apply* codecs delete-then-insert, so
93
+ // this is a clear+rebuild (re-running is a no-op once content matches).
94
+ const localHtml = readLocal(paths.html);
95
+ const localComments = readLocal(paths.comments);
96
+ const localAnnotations = readLocal(paths.annotations);
97
+ const localMeta = paths.meta ? readLocal(paths.meta) : null;
98
+ const localCss = paths.css ? readLocal(paths.css) : null;
99
+
100
+ const hasLocal =
101
+ !!localHtml || !!localComments || !!localAnnotations || !!localMeta || !!localCss;
102
+ if (!hasLocal) return 'empty';
103
+
104
+ doc.transact(() => {
105
+ if (localHtml) applyHtmlToDoc(doc, localHtml, ORIGINS.MIGRATION);
106
+ if (localComments) {
107
+ const parsed = tryParseJsonArray(localComments);
108
+ if (parsed) applyCommentsToDoc(doc, parsed, ORIGINS.MIGRATION);
109
+ }
110
+ if (localAnnotations) applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION);
111
+ if (paths.meta && localMeta) applyMetaToDoc(doc, localMeta, ORIGINS.MIGRATION);
112
+ if (paths.css && localCss) applyCssToDoc(doc, localCss, ORIGINS.MIGRATION);
113
+ }, ORIGINS.MIGRATION);
114
+
115
+ return 'local-adopt';
116
+ }
117
+
118
+ /* ---------------------------------------------------------------- helpers */
119
+
120
+ function snapshotLocal(opts: MigrateSeedOptions): void {
121
+ if (!opts.historyDir) return;
122
+ try {
123
+ const dir = path.join(opts.historyDir, 'pre-shared-doc-migration');
124
+ mkdirSync(dir, { recursive: true });
125
+ for (const p of [
126
+ opts.paths.html,
127
+ opts.paths.comments,
128
+ opts.paths.annotations,
129
+ opts.paths.meta,
130
+ opts.paths.css,
131
+ ]) {
132
+ if (p && existsSync(p)) {
133
+ copyFileSync(p, path.join(dir, path.basename(p)));
134
+ }
135
+ }
136
+ } catch {
137
+ /* best-effort — a snapshot failure must not block the cutover */
138
+ }
139
+ }
140
+
141
+ function readLocal(p: string): string | null {
142
+ if (!existsSync(p)) return null;
143
+ try {
144
+ return readFileSync(p, 'utf8');
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ // Proto-pollution-safe (DDR-054 §2g), mirroring the agent + projection comments
151
+ // parse — a planted local `_comments/<slug>.json` must not seed dangerous keys
152
+ // during adopt.
153
+ function tryParseJsonArray(s: string): unknown[] | null {
154
+ try {
155
+ const parsed = JSON.parse(s, (key, value) => {
156
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined;
157
+ return value;
158
+ });
159
+ return Array.isArray(parsed) ? parsed : null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
@@ -0,0 +1,57 @@
1
+ // Central transaction-origin sentinels for the shared-doc path — Phase 9.2
2
+ // (DDR-064 Task 5).
3
+ //
4
+ // Once a SINGLE Y.Doc per canvas has multiple writers attached (the browser
5
+ // collab WS, the hub HocuspocusProvider, the disk projector, and — at cutover —
6
+ // the one-time migration seed), every writer MUST tag its transactions with a
7
+ // distinct origin and every consumer MUST filter its own, or a write echoes
8
+ // back into the system and loops. This module is the one place those origins
9
+ // are defined so the discipline is auditable in one read.
10
+ //
11
+ // Two of the origins are inherently PER-INSTANCE and are NOT exported here:
12
+ //
13
+ // HUB — the HocuspocusProvider applies hub updates tagged with its own
14
+ // internal origin (the provider instance). Consumers detect "this
15
+ // came from the hub" as "origin is neither a RoomConn nor one of
16
+ // the sentinels below".
17
+ // LOCAL_WS — a browser edit arrives through the collab Room as a RoomConn
18
+ // object (it has an `id` field — see room.ts). That object IS the
19
+ // origin; the Room's own broadcaster already keys off it.
20
+ //
21
+ // The remaining origins are process-wide singletons (frozen so identity
22
+ // comparison `===` is stable and a consumer can't accidentally reconstruct an
23
+ // equal-but-different object):
24
+
25
+ export const ORIGINS = {
26
+ /**
27
+ * The disk projector materializing the doc to files (doc→file). The projector
28
+ * writes FILES, not the doc, so this is not normally applied as a Y
29
+ * transaction origin — it exists for symmetry + so any future doc-touching
30
+ * projection step (e.g. a normalization write-back) is filterable.
31
+ */
32
+ DISK_PROJECTION: Object.freeze({ origin: 'disk-projection' as const }),
33
+ /**
34
+ * An external file edit (`/design:edit`, a human, a git pull) imported INTO
35
+ * the doc as a minimal diff (file→doc). The projector tags its import with
36
+ * this so its OWN doc.on('update') handler skips it — the file is already
37
+ * current, re-projecting it back would be a redundant write.
38
+ */
39
+ FILE_IMPORT: Object.freeze({ origin: 'file-import' as const }),
40
+ /**
41
+ * The one-time authoritative clear-and-rebuild seed at per-canvas cutover
42
+ * (Phase E migrate-seed). Distinct so the projector can choose to debounce or
43
+ * suppress disk writes during the seed transaction (it's already on disk).
44
+ */
45
+ MIGRATION: Object.freeze({ origin: 'migration' as const }),
46
+ } as const;
47
+
48
+ export type OriginSentinel = (typeof ORIGINS)[keyof typeof ORIGINS];
49
+
50
+ /** True when `origin` is one of this module's projector/seed sentinels. */
51
+ export function isProjectorOrigin(origin: unknown): boolean {
52
+ return (
53
+ origin === ORIGINS.DISK_PROJECTION ||
54
+ origin === ORIGINS.FILE_IMPORT ||
55
+ origin === ORIGINS.MIGRATION
56
+ );
57
+ }
@@ -0,0 +1,368 @@
1
+ // Loop-free bidirectional disk projection for the shared-doc path — Phase 9.2
2
+ // (DDR-064 Tasks 6 + 7).
3
+ //
4
+ // In the two-doc world (flag OFF) the sync AGENT mirrored a SECOND doc to disk.
5
+ // Under MAUDE_SHARED_DOC there is ONE doc per canvas (the collab room's), so the
6
+ // file stops being a reconciliation medium and becomes a materialized
7
+ // PROJECTION of the doc (Zed's principle). This module owns that projection:
8
+ //
9
+ // doc → file (browser/hub edits converged): debounced, hash-gated, writes the
10
+ // human-readable files. The projector writes html/css/meta ONLY —
11
+ // the collab room already persists comments + annotations doc→file
12
+ // (persistence.ts), so projecting them here too would double-write.
13
+ // file → doc (external edit by /design:edit, a human, git): diff the file
14
+ // against the doc's current materialization and apply ONLY the
15
+ // delta as Yjs ops tagged ORIGINS.FILE_IMPORT — never a wholesale
16
+ // replace (that was the Phase 9.1 clobber). All 5 types import.
17
+ //
18
+ // Loop freedom rests on two guards, exactly as the agent's:
19
+ // 1. echo hash — every doc→file write records sha256(bytes); the fs event it
20
+ // triggers carries the same hash → consume() drops it.
21
+ // 2. origin — a file→doc import is tagged FILE_IMPORT; the projector's own
22
+ // doc.on('update') skips that origin so it doesn't re-project what it just
23
+ // imported.
24
+ // Plus a per-path circuit breaker: a file that fails to PARSE 3× in a row is
25
+ // quarantined until its checksum changes, so an unparseable file can't spin.
26
+
27
+ import { existsSync, readFileSync } from 'node:fs';
28
+
29
+ import type * as Y from 'yjs';
30
+
31
+ import { atomicWrite } from './atomic-write.ts';
32
+ import {
33
+ MAX_CSS_BYTES,
34
+ MAX_HTML_BYTES,
35
+ MAX_META_BYTES,
36
+ applyAnnotationsToDoc,
37
+ applyCommentsToDoc,
38
+ applyCssToDoc,
39
+ applyHtmlToDoc,
40
+ applyMetaToDoc,
41
+ cssFromDoc,
42
+ htmlFromDoc,
43
+ mergeSharedMetaIntoLocal,
44
+ metaFromDoc,
45
+ } from './codec.ts';
46
+ import { type EchoGuard, hashBytes } from './echo-guard.ts';
47
+ import { ORIGINS } from './origins.ts';
48
+
49
+ export const PROJECT_FLUSH_MS = 800;
50
+ export const CIRCUIT_MAX_STRIKES = 3;
51
+
52
+ export interface ProjectionPaths {
53
+ /** Absolute path to the canvas body (`.html` or opted-in `.tsx`). */
54
+ html: string;
55
+ /** Absolute path to `_comments/<slug>.json`. */
56
+ comments: string;
57
+ /** Absolute path to `<slug>.annotations.svg`. */
58
+ annotations: string;
59
+ /** Absolute path to the canvas `.meta.json` sibling (optional). */
60
+ meta?: string;
61
+ /** Absolute path to the canvas `.css` sibling (optional). */
62
+ css?: string;
63
+ }
64
+
65
+ export interface DocProjectionOptions {
66
+ slug: string;
67
+ doc: Y.Doc;
68
+ paths: ProjectionPaths;
69
+ /**
70
+ * Echo guard shared with the rest of the sync runtime so a doc→file write here
71
+ * is recognized as an echo when its fs event arrives. Optional — a standalone
72
+ * test can omit it (echo handling then relies on the hash-equality drop only).
73
+ */
74
+ echoGuard?: EchoGuard;
75
+ /** Injected for tests — defaults to atomicWrite. */
76
+ writer?: (path: string, bytes: string | Uint8Array) => void;
77
+ /** Override the 800 ms debounce. Tests use 0 to flush on the next microtask. */
78
+ flushMs?: number;
79
+ /** Circuit-breaker threshold (consecutive parse failures per path). */
80
+ maxStrikes?: number;
81
+ }
82
+
83
+ export interface DocProjection {
84
+ readonly slug: string;
85
+ /** Subscribe to doc updates (doc→file). Idempotent. */
86
+ start(): void;
87
+ /**
88
+ * Apply an external file edit to the doc (file→doc). Returns true when the doc
89
+ * changed. Mirrors the agent's `applyFromFs` signature so the runtime's
90
+ * fs-reader can dispatch to either uniformly.
91
+ */
92
+ applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean;
93
+ /** Project the doc's current html/css/meta to disk now (initial materialize).
94
+ * SAFE: never writes an empty doc value over non-empty local content — the
95
+ * authoritative push-local-up seed is Phase E (migrate-seed). */
96
+ reconcile(): void;
97
+ /** Force the pending doc→file flush immediately. */
98
+ flush(): Promise<void>;
99
+ /** Stop the doc listener + timers. */
100
+ stop(): void;
101
+ /** Test/inspection — the origin used on file→doc imports. */
102
+ readonly importOrigin: object;
103
+ }
104
+
105
+ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
106
+ const { slug, doc, paths } = opts;
107
+ const flushMs = opts.flushMs ?? PROJECT_FLUSH_MS;
108
+ const writer = opts.writer ?? atomicWrite;
109
+ const maxStrikes = opts.maxStrikes ?? CIRCUIT_MAX_STRIKES;
110
+ const importOrigin = ORIGINS.FILE_IMPORT;
111
+
112
+ let started = false;
113
+ let stopped = false;
114
+ let dirty = false;
115
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
116
+
117
+ // doc→file last-written hashes (skip redundant writes).
118
+ let lastHtml: string | null = null;
119
+ let lastMeta: string | null = null;
120
+ let lastCss: string | null = null;
121
+
122
+ // Circuit breaker: per-path { hash, strikes }. A file that fails to PARSE
123
+ // maxStrikes times in a row is quarantined until its checksum changes.
124
+ const quarantine = new Map<string, { hash: string; strikes: number }>();
125
+
126
+ function onDocUpdate(_u: Uint8Array, origin: unknown): void {
127
+ if (stopped) return;
128
+ // Skip our own file→doc import — the file is already current, re-projecting
129
+ // would be a redundant write. (Migration seed is on disk already too.)
130
+ if (origin === ORIGINS.FILE_IMPORT || origin === ORIGINS.MIGRATION) return;
131
+ scheduleFlush();
132
+ }
133
+
134
+ function scheduleFlush(): void {
135
+ dirty = true;
136
+ if (flushMs === 0) {
137
+ queueMicrotask(() => void flush());
138
+ return;
139
+ }
140
+ if (flushTimer) clearTimeout(flushTimer);
141
+ flushTimer = setTimeout(() => {
142
+ flushTimer = null;
143
+ void flush();
144
+ }, flushMs);
145
+ }
146
+
147
+ function recordEcho(path: string, value: string): void {
148
+ opts.echoGuard?.record(path, hashBytes(value));
149
+ }
150
+
151
+ // Security re-audit (Phase D, finding A2 / DDR-054 §2d): the codec's
152
+ // MAX_*_BYTES caps guard the file→doc *import* lane, but hub-pushed content
153
+ // arrives as raw Yjs updates through the provider (NOT via applyXToDoc), so it
154
+ // bypasses those caps. This doc→file lane is the consumer's guard on that
155
+ // direction — refuse to materialize an oversized hub-pushed body to disk
156
+ // (disk-fill DoS). Returns true when the write is allowed.
157
+ function withinCap(path: string, value: string, max: number): boolean {
158
+ if (Buffer.byteLength(value, 'utf8') <= max) return true;
159
+ console.warn(
160
+ `[projection/${slug}] refusing doc→file write of ${path} > ${max} bytes (hub-pushed oversize). DDR-054 §2d.`
161
+ );
162
+ return false;
163
+ }
164
+
165
+ // ----- doc → file (html / css / meta only; room owns comments/annotations)
166
+
167
+ function writeHtmlIfChanged(): void {
168
+ const next = htmlFromDoc(doc);
169
+ if (next === lastHtml) return;
170
+ // Don't clobber a non-empty local body with an empty doc (cold-start before
171
+ // the doc is seeded — the safe-reconcile invariant; full adopt is Phase E).
172
+ if (next === '') {
173
+ lastHtml = next;
174
+ return;
175
+ }
176
+ if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return;
177
+ recordEcho(paths.html, next);
178
+ writer(paths.html, next);
179
+ lastHtml = next;
180
+ }
181
+
182
+ function writeCssIfChanged(): void {
183
+ if (!paths.css) return;
184
+ const next = cssFromDoc(doc);
185
+ if (next === lastCss) return;
186
+ lastCss = next;
187
+ if (next === null) return; // doc carries no css yet — nothing to write
188
+ if (!withinCap(paths.css, next, MAX_CSS_BYTES)) return;
189
+ recordEcho(paths.css, next);
190
+ writer(paths.css, next);
191
+ }
192
+
193
+ function writeMetaIfChanged(): void {
194
+ if (!paths.meta) return;
195
+ const shared = metaFromDoc(doc);
196
+ if (shared === lastMeta) return;
197
+ lastMeta = shared;
198
+ if (shared === null) return; // doc carries no shared meta yet
199
+ const local = readLocal(paths.meta);
200
+ const merged = mergeSharedMetaIntoLocal(local, shared);
201
+ if (merged === null || merged === local) return; // unparseable / disk matches
202
+ if (!withinCap(paths.meta, merged, MAX_META_BYTES)) return;
203
+ recordEcho(paths.meta, merged);
204
+ writer(paths.meta, merged);
205
+ }
206
+
207
+ async function flush(): Promise<void> {
208
+ if (!dirty || stopped) return;
209
+ dirty = false;
210
+ if (flushTimer) {
211
+ clearTimeout(flushTimer);
212
+ flushTimer = null;
213
+ }
214
+ try {
215
+ writeHtmlIfChanged();
216
+ writeCssIfChanged();
217
+ writeMetaIfChanged();
218
+ } catch (err) {
219
+ dirty = true;
220
+ console.error(`[projection/${slug}] flush failed:`, err);
221
+ }
222
+ }
223
+
224
+ // ----- file → doc (all five types; diff-import, FILE_IMPORT origin)
225
+
226
+ /** Returns true if the path is currently quarantined for `hash`. */
227
+ function isQuarantined(path: string, hash: string): boolean {
228
+ const q = quarantine.get(path);
229
+ return !!q && q.hash === hash && q.strikes >= maxStrikes;
230
+ }
231
+
232
+ /** Record a parse failure; quarantine after maxStrikes for this exact hash. */
233
+ function strike(path: string, hash: string): void {
234
+ const q = quarantine.get(path);
235
+ if (q && q.hash === hash) q.strikes += 1;
236
+ else quarantine.set(path, { hash, strikes: 1 });
237
+ if ((quarantine.get(path)?.strikes ?? 0) >= maxStrikes) {
238
+ console.warn(
239
+ `[projection/${slug}] quarantined ${path} after ${maxStrikes} parse failures; ignoring until its checksum changes.`
240
+ );
241
+ }
242
+ }
243
+
244
+ /** Clear any circuit-breaker state for a path (it parsed / changed). */
245
+ function clearStrike(path: string): void {
246
+ quarantine.delete(path);
247
+ }
248
+
249
+ function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
250
+ if (stopped) return false;
251
+ // Echo of our own doc→file write — drop.
252
+ if (opts.echoGuard?.consume(evt.path, evt.hash)) return false;
253
+ // Circuit breaker — a file that won't parse can't spin the loop.
254
+ if (isQuarantined(evt.path, evt.hash)) return false;
255
+
256
+ const str = bytesToString(evt.bytes);
257
+
258
+ if (evt.path === paths.html) {
259
+ clearStrike(evt.path);
260
+ const changed = applyHtmlToDoc(doc, str, importOrigin);
261
+ if (changed) lastHtml = htmlFromDoc(doc);
262
+ return changed;
263
+ }
264
+ if (paths.css && evt.path === paths.css) {
265
+ clearStrike(evt.path);
266
+ const changed = applyCssToDoc(doc, str, importOrigin);
267
+ if (changed) lastCss = str;
268
+ return changed;
269
+ }
270
+ if (paths.meta && evt.path === paths.meta) {
271
+ // applyMetaToDoc parses internally; a parse failure returns false. Detect
272
+ // it explicitly so the circuit breaker can quarantine a broken sidecar.
273
+ if (!isParseableJsonObject(str)) {
274
+ strike(evt.path, evt.hash);
275
+ return false;
276
+ }
277
+ clearStrike(evt.path);
278
+ const changed = applyMetaToDoc(doc, str, importOrigin);
279
+ if (changed) lastMeta = metaFromDoc(doc);
280
+ return changed;
281
+ }
282
+ if (evt.path === paths.comments) {
283
+ const parsed = tryParseJsonArray(str);
284
+ if (parsed === null) {
285
+ strike(evt.path, evt.hash);
286
+ return false;
287
+ }
288
+ clearStrike(evt.path);
289
+ return applyCommentsToDoc(doc, parsed, importOrigin);
290
+ }
291
+ if (evt.path === paths.annotations) {
292
+ clearStrike(evt.path);
293
+ return applyAnnotationsToDoc(doc, str, importOrigin);
294
+ }
295
+ return false;
296
+ }
297
+
298
+ function reconcile(): void {
299
+ if (stopped) return;
300
+ // Materialize the converged doc to disk (html/css/meta). The *IfChanged
301
+ // writers already guard against clobbering non-empty local with empty doc
302
+ // values, so this is safe to run at cold start before the authoritative
303
+ // seed (Phase E) — it only writes what the doc actually holds.
304
+ writeHtmlIfChanged();
305
+ writeCssIfChanged();
306
+ writeMetaIfChanged();
307
+ }
308
+
309
+ return {
310
+ slug,
311
+ importOrigin,
312
+ start() {
313
+ if (started) return;
314
+ doc.on('update', onDocUpdate);
315
+ started = true;
316
+ },
317
+ applyFromFs,
318
+ reconcile,
319
+ flush,
320
+ stop() {
321
+ stopped = true;
322
+ doc.off('update', onDocUpdate);
323
+ if (flushTimer) {
324
+ clearTimeout(flushTimer);
325
+ flushTimer = null;
326
+ }
327
+ },
328
+ };
329
+ }
330
+
331
+ /* ---------------------------------------------------------------- helpers */
332
+
333
+ function readLocal(p: string): string | null {
334
+ if (!existsSync(p)) return null;
335
+ try {
336
+ return readFileSync(p, 'utf8');
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+
342
+ function bytesToString(bytes: Uint8Array): string {
343
+ return new TextDecoder().decode(bytes);
344
+ }
345
+
346
+ function isParseableJsonObject(s: string): boolean {
347
+ try {
348
+ const v = JSON.parse(s);
349
+ return !!v && typeof v === 'object' && !Array.isArray(v);
350
+ } catch {
351
+ return false;
352
+ }
353
+ }
354
+
355
+ // Same proto-pollution-safe reviver the agent uses (DDR-054 §2g): strip
356
+ // dangerous keys at parse time so a hostile file can't seed __proto__ into the
357
+ // comment objects yjs serializes to peers.
358
+ function tryParseJsonArray(s: string): unknown[] | null {
359
+ try {
360
+ const parsed = JSON.parse(s, (key, value) => {
361
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined;
362
+ return value;
363
+ });
364
+ return Array.isArray(parsed) ? parsed : null;
365
+ } catch {
366
+ return null;
367
+ }
368
+ }